Skip to content

Commit

Permalink
Fix bugs with multi account and Ariya support (#79)
Browse files Browse the repository at this point in the history
* Improve multi vehicle support

* Improved request retry logic

* Fix hass.data storage to add account_id
  • Loading branch information
dan-r authored Dec 5, 2024
1 parent 664b4e8 commit 8ad74d6
Show file tree
Hide file tree
Showing 8 changed files with 119 additions and 68 deletions.
19 changes: 12 additions & 7 deletions custom_components/nissan_connect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,29 @@ async def async_setup(hass, config) -> bool:
async def async_update_listener(hass, entry):
"""Handle options flow credentials update."""
config = entry.data
account_id = config['email']

# Loop each vehicle and update its session with the new credentials
for vehicle in hass.data[DOMAIN][DATA_VEHICLES]:
await hass.async_add_executor_job(hass.data[DOMAIN][DATA_VEHICLES][vehicle].session.login,
for vehicle in hass.data[DOMAIN][account_id][DATA_VEHICLES]:
await hass.async_add_executor_job(hass.data[DOMAIN][account_id][DATA_VEHICLES][vehicle].session.login,
config.get("email"),
config.get("password")
)

# Update intervals for coordinators
hass.data[DOMAIN][DATA_COORDINATOR_STATISTICS].update_interval = timedelta(minutes=config.get("interval_statistics", DEFAULT_INTERVAL_STATISTICS))
hass.data[DOMAIN][DATA_COORDINATOR_FETCH].update_interval = timedelta(minutes=config.get("interval_fetch", DEFAULT_INTERVAL_FETCH))
hass.data[DOMAIN][account_id][DATA_COORDINATOR_STATISTICS].update_interval = timedelta(minutes=config.get("interval_statistics", DEFAULT_INTERVAL_STATISTICS))
hass.data[DOMAIN][account_id][DATA_COORDINATOR_FETCH].update_interval = timedelta(minutes=config.get("interval_fetch", DEFAULT_INTERVAL_FETCH))

# Refresh fetch coordinator
await hass.data[DOMAIN][DATA_COORDINATOR_FETCH].async_refresh()
await hass.data[DOMAIN][account_id][DATA_COORDINATOR_FETCH].async_refresh()


async def async_setup_entry(hass, entry):
"""This is called from the config flow."""
account_id = entry.data['email']

hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN].setdefault(account_id, {})

config = dict(entry.data)

Expand All @@ -40,7 +45,7 @@ async def async_setup_entry(hass, entry):
unique_id=entry.unique_id
)

data = hass.data[DOMAIN] = {
data = hass.data[DOMAIN][account_id] = {
DATA_VEHICLES: {}
}

Expand All @@ -52,7 +57,7 @@ async def async_setup_entry(hass, entry):

_LOGGER.debug("Finding vehicles")
for vehicle in await hass.async_add_executor_job(kamereon_session.fetch_vehicles):
await hass.async_add_executor_job(vehicle.refresh)
await hass.async_add_executor_job(vehicle.fetch_all)
if vehicle.vin not in data[DATA_VEHICLES]:
data[DATA_VEHICLES][vehicle.vin] = vehicle

Expand Down
6 changes: 4 additions & 2 deletions custom_components/nissan_connect/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@

async def async_setup_entry(hass, config, async_add_entities):
"""Set up the Kamereon sensors."""
data = hass.data[DOMAIN][DATA_VEHICLES]
coordinator = hass.data[DOMAIN][DATA_COORDINATOR_FETCH]
account_id = config.data['email']

data = hass.data[DOMAIN][account_id][DATA_VEHICLES]
coordinator = hass.data[DOMAIN][account_id][DATA_COORDINATOR_FETCH]

entities = []

Expand Down
20 changes: 13 additions & 7 deletions custom_components/nissan_connect/button.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
"""Support for Kamereon cars."""
import logging
import asyncio

from homeassistant.components.button import ButtonEntity

from .base import KamereonEntity
from .kamereon import ChargingStatus, PluggedStatus, Feature
from .const import DOMAIN, DATA_VEHICLES, DATA_COORDINATOR_POLL, DATA_COORDINATOR_STATISTICS
from .const import DOMAIN, DATA_VEHICLES, DATA_COORDINATOR_POLL, DATA_COORDINATOR_FETCH, DATA_COORDINATOR_STATISTICS

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(hass, config, async_add_entities):
data = hass.data[DOMAIN][DATA_VEHICLES]
coordinator = hass.data[DOMAIN][DATA_COORDINATOR_POLL]
stats_coordinator = hass.data[DOMAIN][DATA_COORDINATOR_STATISTICS]
account_id = config.data['email']

data = hass.data[DOMAIN][account_id][DATA_VEHICLES]
coordinator = hass.data[DOMAIN][account_id][DATA_COORDINATOR_POLL]
coordinator_fetch = hass.data[DOMAIN][account_id][DATA_COORDINATOR_FETCH]
stats_coordinator = hass.data[DOMAIN][account_id][DATA_COORDINATOR_STATISTICS]

entities = []

for vehicle in data:
entities.append(ForceUpdateButton(coordinator, data[vehicle], hass, stats_coordinator))
entities.append(ForceUpdateButton(coordinator_fetch, data[vehicle], hass, stats_coordinator))
if Feature.HORN_AND_LIGHTS in data[vehicle].features:
entities += [
HornLightsButtons(coordinator, data[vehicle], "flash_lights", "mdi:car-light-high", "lights"),
Expand All @@ -44,9 +48,11 @@ def icon(self):
return 'mdi:update'

async def async_press(self):
await self.coordinator.async_refresh()
await self.coordinator_statistics.async_refresh()
loop = asyncio.get_running_loop()

await loop.run_in_executor(None, self.vehicle.refresh)
await self.coordinator.async_refresh()

class HornLightsButtons(KamereonEntity, ButtonEntity):
def __init__(self, coordinator, vehicle, translation_key, icon, action):
self._attr_translation_key = translation_key
Expand Down
12 changes: 9 additions & 3 deletions custom_components/nissan_connect/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@


async def async_setup_entry(hass, config, async_add_entities):
data = hass.data[DOMAIN][DATA_VEHICLES]
coordinator = hass.data[DOMAIN][DATA_COORDINATOR_FETCH]
account_id = config.data['email']

data = hass.data[DOMAIN][account_id][DATA_VEHICLES]
coordinator = hass.data[DOMAIN][account_id][DATA_COORDINATOR_FETCH]

for vehicle in data:
if Feature.CLIMATE_ON_OFF in data[vehicle].features:
Expand Down Expand Up @@ -112,8 +114,12 @@ async def _async_fetch_loop(self, target_state):

_LOGGER.debug("Beginning HVAC fetch loop")
self._loop_mutex = True

loop = asyncio.get_running_loop()

for _ in range(10):
await self._hass.data[DOMAIN][DATA_COORDINATOR_POLL].async_refresh()
await loop.run_in_executor(None, self.vehicle.refresh)
await self.coordinator.async_refresh()

# We have our update, break out
if target_state == self.vehicle.hvac_status:
Expand Down
53 changes: 39 additions & 14 deletions custom_components/nissan_connect/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging

from datetime import timedelta
from time import time
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, DATA_VEHICLES, DEFAULT_INTERVAL_POLL, DEFAULT_INTERVAL_CHARGING, DEFAULT_INTERVAL_STATISTICS, DEFAULT_INTERVAL_FETCH, DATA_COORDINATOR_FETCH, DATA_COORDINATOR_POLL
from .kamereon import Feature, PluggedStatus, ChargingStatus, Period
Expand All @@ -18,20 +19,20 @@ def __init__(self, hass, config):
update_interval=timedelta(minutes=config.get("interval_fetch", DEFAULT_INTERVAL_FETCH)),
)
self._hass = hass
self._vehicles = hass.data[DOMAIN][DATA_VEHICLES]
self._account_id = config['email']
self._vehicles = hass.data[DOMAIN][self._account_id][DATA_VEHICLES]

async def _async_update_data(self):
"""Fetch data from API."""
try:
for vehicle in self._vehicles:
await self._hass.async_add_executor_job(self._vehicles[vehicle].fetch_all)

except BaseException:
_LOGGER.warning("Error communicating with API")
return False

# Set interval for polling (the other coordinator)
self._hass.data[DOMAIN][DATA_COORDINATOR_POLL].set_next_interval()
self._hass.data[DOMAIN][self._account_id][DATA_COORDINATOR_POLL].set_next_interval()

return True

Expand All @@ -47,9 +48,14 @@ def __init__(self, hass, config):
update_interval=timedelta(minutes=15),
)
self._hass = hass
self._vehicles = hass.data[DOMAIN][DATA_VEHICLES]
self._account_id = config['email']
self._vehicles = hass.data[DOMAIN][self._account_id][DATA_VEHICLES]
self._config = config

self._pluggednotcharging = {key: 0 for key in self._vehicles}
self._intervals = {key: 0 for key in self._vehicles}
self._last_updated = {key: 0 for key in self._vehicles}
self._force_update = {key: False for key in self._vehicles}

def set_next_interval(self):
"""Calculate the next update interval."""
Expand All @@ -58,6 +64,9 @@ def set_next_interval(self):

# Get the shortest interval from all vehicles
for vehicle in self._vehicles:
# Initially set interval to default
new_interval = interval

# EV, decide which time to use based on whether we are plugged in or not
if Feature.BATTERY_STATUS in self._vehicles[vehicle].features and self._vehicles[vehicle].plugged_in == PluggedStatus.PLUGGED:
# If we are plugged in but not charging, increment a counter
Expand All @@ -68,15 +77,25 @@ def set_next_interval(self):

# If we haven't hit the counter limit, use the shorter interval
if self._pluggednotcharging[vehicle] < 5:
interval = interval_charging if interval_charging < interval else interval
new_interval = interval_charging

# Update every minute if HVAC on
if self._vehicles[vehicle].hvac_status:
interval = 1
new_interval = 1

# If the interval has changed, force next update
if new_interval != self._intervals[vehicle]:
_LOGGER.debug(f"Changing #{vehicle[-3:]} update interval to {new_interval} minutes")
self._force_update[vehicle] = True

self._intervals[vehicle] = new_interval

if interval != (self.update_interval.seconds / 60):
_LOGGER.debug(f"Changing next update interval to {interval} minutes")
self.update_interval = timedelta(minutes=interval)
# Set the coordinator to update at the shortest interval
shortest_interval = min(self._intervals.values())

if shortest_interval != (self.update_interval.seconds / 60):
_LOGGER.debug(f"Changing coordinator update interval to {shortest_interval} minutes")
self.update_interval = timedelta(minutes=shortest_interval)
self._async_unsub_refresh()
if self._listeners:
self._schedule_refresh()
Expand All @@ -85,14 +104,19 @@ async def _async_update_data(self):
"""Fetch data from API."""
try:
for vehicle in self._vehicles:
await self._hass.async_add_executor_job(self._vehicles[vehicle].refresh_location)
await self._hass.async_add_executor_job(self._vehicles[vehicle].refresh_battery_status)

time_since_updated = round((time() - self._last_updated[vehicle]) / 60)
if self._force_update[vehicle] or time_since_updated >= self._intervals[vehicle]:
_LOGGER.debug("Polling #%s as %d mins have elapsed (interval %d)", vehicle[-3:], time_since_updated, self._intervals[vehicle])
self._last_updated[vehicle] = int(time())
self._force_update[vehicle] = False
await self._hass.async_add_executor_job(self._vehicles[vehicle].refresh)
else:
_LOGGER.debug("NOT polling #%s as %d mins have elapsed (interval %d)", vehicle[-3:], time_since_updated, self._intervals[vehicle])
except BaseException:
_LOGGER.warning("Error communicating with API")
return False

self._hass.async_create_task(self._hass.data[DOMAIN][DATA_COORDINATOR_FETCH].async_refresh())
self._hass.async_create_task(self._hass.data[DOMAIN][self._account_id][DATA_COORDINATOR_FETCH].async_refresh())
return True


Expand All @@ -106,7 +130,8 @@ def __init__(self, hass, config):
update_interval=timedelta(minutes=config.get("interval_statistics", DEFAULT_INTERVAL_STATISTICS)),
)
self._hass = hass
self._vehicles = hass.data[DOMAIN][DATA_VEHICLES]
self._account_id = config['email']
self._vehicles = hass.data[DOMAIN][self._account_id][DATA_VEHICLES]

async def _async_update_data(self):
"""Fetch data from API."""
Expand Down
6 changes: 4 additions & 2 deletions custom_components/nissan_connect/device_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
_LOGGER = logging.getLogger(__name__)

async def async_setup_entry(hass, entry, async_add_entities):
data = hass.data[DOMAIN][DATA_VEHICLES]
coordinator = hass.data[DOMAIN][DATA_COORDINATOR_FETCH]
account_id = entry.data['email']

data = hass.data[DOMAIN][account_id][DATA_VEHICLES]
coordinator = hass.data[DOMAIN][account_id][DATA_COORDINATOR_FETCH]

entities = []

Expand Down
59 changes: 32 additions & 27 deletions custom_components/nissan_connect/kamereon/kamereon.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import logging
from typing import List
import requests
import time
from oauthlib.common import generate_nonce
from oauthlib.oauth2 import TokenExpiredError
from requests_oauthlib import OAuth2Session
Expand Down Expand Up @@ -308,40 +309,43 @@ def __init__(self, data, user_id):
self.mileage = None
self.total_mileage = None

def _request(self, method, url, headers=None, params=None, data=None, max_retries=3):
for attempt in range(max_retries):
try:
if method == 'GET':
resp = self.session.oauth.get(url, headers=headers, params=params)
elif method == 'POST':
resp = self.session.oauth.post(url, data=data, headers=headers)
else:
raise ValueError(f"Unsupported HTTP method: {method}")

# Check for token expiration
if resp.status_code == 401:
raise TokenExpiredError()

# Successful request
return resp

except TokenExpiredError:
_LOGGER.debug("Token expired. Refreshing session and retrying.")
self.session.login()
except Exception as e:
_LOGGER.debug(f"Request failed on attempt {attempt + 1} of {max_retries}: {e}")
if attempt == max_retries - 1: # Exhausted retries
raise
time.sleep(2 ** attempt) # Exponential backoff on retry

raise RuntimeError("Max retries reached, but the request could not be completed.")

def _get(self, url, headers=None, params=None):
"""Try logging in again before returning a failure."""
expired = False
try:
resp = self.session.oauth.get(url, headers=headers, params=params)
except TokenExpiredError:
expired = True

if expired or resp.status_code == 401:
_LOGGER.debug("Refreshing session and retrying request as token expired")
self.session.login()
return self.session.oauth.get(url, headers=headers, params=params)

return resp
return self._request('GET', url, headers=headers, params=params)

def _post(self, url, data=None, headers=None):
"""Try logging in again before returning a failure."""
expired = False
try:
resp = self.session.oauth.post(url, data=data, headers=headers)
except TokenExpiredError:
expired = True

if expired or resp.status_code == 401:
_LOGGER.debug("Refreshing session and retrying request as token expired")
self.session.login()
return self.session.oauth.post(url, data=data, headers=headers)

return resp
return self._request('POST', url, headers=headers, data=data)

def refresh(self):
self.refresh_location()
self.refresh_battery_status()
self.fetch_all()

def fetch_all(self):
self.fetch_cockpit()
Expand Down Expand Up @@ -698,6 +702,7 @@ def fetch_battery_status_ariya(self):
self.battery_supported = False

battery_data = body['data']['attributes']

self.battery_capacity = battery_data.get('batteryCapacity') # kWh
self.battery_level = battery_data.get('batteryLevel') # %
self.battery_temperature = battery_data.get('batteryTemperature') # Fahrenheit?
Expand Down
12 changes: 6 additions & 6 deletions custom_components/nissan_connect/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@

async def async_setup_entry(hass, config, async_add_entities):
"""Set up the Kamereon sensors."""
data = hass.data[DOMAIN][DATA_VEHICLES]
coordinator = hass.data[DOMAIN][DATA_COORDINATOR_FETCH]
coordinator_stats = hass.data[DOMAIN][DATA_COORDINATOR_STATISTICS]
account_id = config.data['email']

data = hass.data[DOMAIN][account_id][DATA_VEHICLES]
coordinator = hass.data[DOMAIN][account_id][DATA_COORDINATOR_FETCH]
coordinator_stats = hass.data[DOMAIN][account_id][DATA_COORDINATOR_STATISTICS]

entities = []

Expand Down Expand Up @@ -177,9 +179,7 @@ def _handle_coordinator_update(self) -> None:
new_state = getattr(self.vehicle, "total_mileage")

# This sometimes goes backwards? So only accept a positive odometer delta
if new_state is not None and new_state > (self._state or 0):
_LOGGER.debug(f"Updating odometer state")

if new_state is not None and new_state > (self._state or 0):
self._state = new_state
self.async_write_ha_state()

Expand Down

0 comments on commit 8ad74d6

Please sign in to comment.