Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for the Nissan Leaf Carwings/Nissan Connect API."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from datetime import datetime, timedelta
7 from http import HTTPStatus
8 import logging
9 import sys
10 from typing import Any, cast
11 
12 from pycarwings2 import CarwingsError, Leaf, Session
13 from pycarwings2.responses import (
14  CarwingsLatestBatteryStatusResponse,
15  CarwingsLatestClimateControlStatusResponse,
16 )
17 import voluptuous as vol
18 
19 from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform
20 from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall
22 from homeassistant.helpers.discovery import load_platform
23 from homeassistant.helpers.dispatcher import async_dispatcher_send
24 from homeassistant.helpers.event import async_track_point_in_utc_time
25 from homeassistant.helpers.typing import ConfigType
26 from homeassistant.util.dt import utcnow
27 
28 from .const import (
29  CONF_CHARGING_INTERVAL,
30  CONF_CLIMATE_INTERVAL,
31  CONF_FORCE_MILES,
32  CONF_INTERVAL,
33  CONF_VALID_REGIONS,
34  DATA_BATTERY,
35  DATA_CHARGING,
36  DATA_CLIMATE,
37  DATA_LEAF,
38  DATA_PLUGGED_IN,
39  DATA_RANGE_AC,
40  DATA_RANGE_AC_OFF,
41  DEFAULT_CHARGING_INTERVAL,
42  DEFAULT_CLIMATE_INTERVAL,
43  DEFAULT_INTERVAL,
44  DOMAIN,
45  INITIAL_UPDATE,
46  MAX_RESPONSE_ATTEMPTS,
47  MIN_UPDATE_INTERVAL,
48  PYCARWINGS2_SLEEP,
49  RESTRICTED_BATTERY,
50  RESTRICTED_INTERVAL,
51  SIGNAL_UPDATE_LEAF,
52 )
53 
54 _LOGGER = logging.getLogger(__name__)
55 
56 CONFIG_SCHEMA = vol.Schema(
57  {
58  DOMAIN: vol.All(
59  cv.ensure_list,
60  [
61  vol.Schema(
62  {
63  vol.Required(CONF_USERNAME): cv.string,
64  vol.Required(CONF_PASSWORD): cv.string,
65  vol.Required(CONF_REGION): vol.In(CONF_VALID_REGIONS),
66  vol.Optional(CONF_INTERVAL, default=DEFAULT_INTERVAL): (
67  vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL))
68  ),
69  vol.Optional(
70  CONF_CHARGING_INTERVAL, default=DEFAULT_CHARGING_INTERVAL
71  ): (
72  vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL))
73  ),
74  vol.Optional(
75  CONF_CLIMATE_INTERVAL, default=DEFAULT_CLIMATE_INTERVAL
76  ): (
77  vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL))
78  ),
79  vol.Optional(CONF_FORCE_MILES, default=False): cv.boolean,
80  }
81  )
82  ],
83  )
84  },
85  extra=vol.ALLOW_EXTRA,
86 )
87 
88 PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
89 
90 
91 SERVICE_UPDATE_LEAF = "update"
92 SERVICE_START_CHARGE_LEAF = "start_charge"
93 ATTR_VIN = "vin"
94 
95 UPDATE_LEAF_SCHEMA = vol.Schema({vol.Required(ATTR_VIN): cv.string})
96 START_CHARGE_LEAF_SCHEMA = vol.Schema({vol.Required(ATTR_VIN): cv.string})
97 
98 
99 def setup(hass: HomeAssistant, config: ConfigType) -> bool:
100  """Set up the Nissan Leaf integration."""
101 
102  async def async_handle_update(service: ServiceCall) -> None:
103  """Handle service to update leaf data from Nissan servers."""
104  vin = service.data[ATTR_VIN]
105 
106  if vin in hass.data[DATA_LEAF]:
107  data_store = hass.data[DATA_LEAF][vin]
108  await data_store.async_update_data(utcnow())
109  else:
110  _LOGGER.debug("Vin %s not recognised for update", vin)
111 
112  async def async_handle_start_charge(service: ServiceCall) -> None:
113  """Handle service to start charging."""
114  _LOGGER.warning(
115  "The 'nissan_leaf.start_charge' service is deprecated and has been"
116  "replaced by a dedicated button entity: Please use that to start"
117  "the charge instead"
118  )
119  vin = service.data[ATTR_VIN]
120 
121  if vin in hass.data[DATA_LEAF]:
122  data_store = hass.data[DATA_LEAF][vin]
123 
124  # Send the command to request charging is started to Nissan
125  # servers. If that completes OK then trigger a fresh update to
126  # pull the charging status from the car after waiting a minute
127  # for the charging request to reach the car.
128  result = await hass.async_add_executor_job(data_store.leaf.start_charging)
129  if result:
130  _LOGGER.debug("Start charging sent, request updated data in 1 minute")
131  check_charge_at = utcnow() + timedelta(minutes=1)
132  data_store.next_update = check_charge_at
134  hass, data_store.async_update_data, check_charge_at
135  )
136 
137  else:
138  _LOGGER.debug("Vin %s not recognised for update", vin)
139 
140  def setup_leaf(car_config: dict[str, Any]) -> None:
141  """Set up a car."""
142  _LOGGER.debug("Logging into You+Nissan")
143 
144  username: str = car_config[CONF_USERNAME]
145  password: str = car_config[CONF_PASSWORD]
146  region: str = car_config[CONF_REGION]
147 
148  try:
149  # This might need to be made async (somehow) causes
150  # homeassistant to be slow to start
151  sess = Session(username, password, region)
152  leaf = sess.get_leaf()
153  except KeyError:
154  _LOGGER.error(
155  "Unable to fetch car details..."
156  " do you actually have a Leaf connected to your account?"
157  )
158  return
159  except CarwingsError:
160  _LOGGER.error(
161  "An unknown error occurred while connecting to Nissan: %s",
162  sys.exc_info()[0],
163  )
164  return
165 
166  _LOGGER.warning(
167  "WARNING: This may poll your Leaf too often, and drain the 12V"
168  " battery. If you drain your cars 12V battery it WILL NOT START"
169  " as the drive train battery won't connect."
170  " Don't set the intervals too low"
171  )
172 
173  data_store = LeafDataStore(hass, leaf, car_config)
174  hass.data[DATA_LEAF][leaf.vin] = data_store
175 
176  for platform in PLATFORMS:
177  load_platform(hass, platform, DOMAIN, {}, car_config)
178 
180  hass, data_store.async_update_data, utcnow() + INITIAL_UPDATE
181  )
182 
183  hass.data[DATA_LEAF] = {}
184  for car in config[DOMAIN]:
185  setup_leaf(car)
186 
187  hass.services.register(
188  DOMAIN, SERVICE_UPDATE_LEAF, async_handle_update, schema=UPDATE_LEAF_SCHEMA
189  )
190  hass.services.register(
191  DOMAIN,
192  SERVICE_START_CHARGE_LEAF,
193  async_handle_start_charge,
194  schema=START_CHARGE_LEAF_SCHEMA,
195  )
196 
197  return True
198 
199 
201  battery_info: CarwingsLatestBatteryStatusResponse,
202 ) -> datetime | None:
203  """Extract the server date from the battery response."""
204  try:
205  return cast(
206  datetime,
207  battery_info.answer["BatteryStatusRecords"]["OperationDateAndTime"],
208  )
209  except KeyError:
210  return None
211 
212 
214  """Nissan Leaf Data Store."""
215 
216  def __init__(
217  self, hass: HomeAssistant, leaf: Leaf, car_config: dict[str, Any]
218  ) -> None:
219  """Initialise the data store."""
220  self.hasshass = hass
221  self.leafleaf = leaf
222  self.car_configcar_config = car_config
223  self.force_milesforce_miles = car_config[CONF_FORCE_MILES]
224  self.data: dict[str, Any] = {}
225  self.data[DATA_CLIMATE] = None
226  self.data[DATA_BATTERY] = None
227  self.data[DATA_CHARGING] = None
228  self.data[DATA_RANGE_AC] = None
229  self.data[DATA_RANGE_AC_OFF] = None
230  self.data[DATA_PLUGGED_IN] = None
231  self.next_updatenext_update: datetime | None = None
232  self.last_checklast_check: datetime | None = None
233  self.request_in_progressrequest_in_progress: bool = False
234  # Timestamp of last successful response from battery or climate.
235  self.last_battery_responselast_battery_response: datetime | None = None
236  self.last_climate_responselast_climate_response: datetime | None = None
237  self._remove_listener_remove_listener: CALLBACK_TYPE | None = None
238 
239  async def async_update_data(self, now: datetime) -> None:
240  """Update data from nissan leaf."""
241  # Prevent against a previously scheduled update and an ad-hoc update
242  # started from an update from both being triggered.
243  if self._remove_listener_remove_listener:
244  self._remove_listener_remove_listener()
245  self._remove_listener_remove_listener = None
246 
247  # Clear next update whilst this update is underway
248  self.next_updatenext_update = None
249 
250  await self.async_refresh_dataasync_refresh_data(now)
251  self.next_updatenext_update = self.get_next_intervalget_next_interval()
252  _LOGGER.debug("Next update=%s", self.next_updatenext_update)
253 
254  if self.next_updatenext_update is not None:
255  self._remove_listener_remove_listener = async_track_point_in_utc_time(
256  self.hasshass, self.async_update_dataasync_update_data, self.next_updatenext_update
257  )
258 
259  def get_next_interval(self) -> datetime:
260  """Calculate when the next update should occur."""
261  base_interval = self.car_configcar_config[CONF_INTERVAL]
262  climate_interval = self.car_configcar_config[CONF_CLIMATE_INTERVAL]
263  charging_interval = self.car_configcar_config[CONF_CHARGING_INTERVAL]
264 
265  # The 12V battery is used when communicating with Nissan servers.
266  # The 12V battery is charged from the traction battery when not
267  # connected and when the traction battery has enough charge. To
268  # avoid draining the 12V battery we shall restrict the update
269  # frequency if low battery detected.
270  if (
271  self.last_battery_responselast_battery_response is not None
272  and self.data[DATA_CHARGING] is False
273  and self.data[DATA_BATTERY] <= RESTRICTED_BATTERY
274  ):
275  _LOGGER.debug(
276  "Low battery so restricting refresh frequency (%s)", self.leafleaf.nickname
277  )
278  interval = RESTRICTED_INTERVAL
279  else:
280  intervals = [base_interval]
281 
282  if self.data[DATA_CHARGING]:
283  intervals.append(charging_interval)
284 
285  if self.data[DATA_CLIMATE]:
286  intervals.append(climate_interval)
287 
288  interval = min(intervals)
289 
290  return utcnow() + interval
291 
292  async def async_refresh_data(self, now: datetime) -> None:
293  """Refresh the leaf data and update the datastore."""
294  if self.request_in_progressrequest_in_progress:
295  _LOGGER.debug("Refresh currently in progress for %s", self.leafleaf.nickname)
296  return
297 
298  _LOGGER.debug("Updating Nissan Leaf Data")
299 
300  self.last_checklast_check = datetime.today()
301  self.request_in_progressrequest_in_progress = True
302 
303  server_response = await self.async_get_batteryasync_get_battery()
304 
305  if server_response is not None:
306  _LOGGER.debug("Server Response: %s", server_response.__dict__)
307 
308  if server_response.answer["status"] == HTTPStatus.OK:
309  self.data[DATA_BATTERY] = server_response.battery_percent
310 
311  # pycarwings2 library doesn't always provide cruising rnages
312  # so we have to check if they exist before we can use them.
313  # Root cause: the nissan servers don't always send the data.
314  if hasattr(server_response, "cruising_range_ac_on_km"):
315  self.data[DATA_RANGE_AC] = server_response.cruising_range_ac_on_km
316  else:
317  self.data[DATA_RANGE_AC] = None
318 
319  if hasattr(server_response, "cruising_range_ac_off_km"):
320  self.data[DATA_RANGE_AC_OFF] = (
321  server_response.cruising_range_ac_off_km
322  )
323  else:
324  self.data[DATA_RANGE_AC_OFF] = None
325 
326  self.data[DATA_PLUGGED_IN] = server_response.is_connected
327  self.data[DATA_CHARGING] = server_response.is_charging
328  async_dispatcher_send(self.hasshass, SIGNAL_UPDATE_LEAF)
329  self.last_battery_responselast_battery_response = utcnow()
330 
331  # Climate response only updated if battery data updated first.
332  if server_response is not None:
333  try:
334  climate_response = await self.async_get_climateasync_get_climate()
335  if climate_response is not None:
336  _LOGGER.debug(
337  "Got climate data for Leaf: %s", climate_response.__dict__
338  )
339  self.data[DATA_CLIMATE] = climate_response.is_hvac_running
340  self.last_climate_responselast_climate_response = utcnow()
341  except CarwingsError:
342  _LOGGER.error("Error fetching climate info")
343 
344  self.request_in_progressrequest_in_progress = False
345  async_dispatcher_send(self.hasshass, SIGNAL_UPDATE_LEAF)
346 
347  async def async_get_battery(
348  self,
349  ) -> CarwingsLatestBatteryStatusResponse:
350  """Request battery update from Nissan servers."""
351  try:
352  # Request battery update from the car
353  _LOGGER.debug("Requesting battery update, %s", self.leafleaf.vin)
354  start_date: datetime | None = None
355  try:
356  start_server_info = await self.hasshass.async_add_executor_job(
357  self.leafleaf.get_latest_battery_status
358  )
359  except TypeError: # pycarwings2 can fail if Nissan returns nothing
360  _LOGGER.debug("Battery status check returned nothing")
361  else:
362  if not start_server_info:
363  _LOGGER.debug("Battery status check failed")
364  else:
365  start_date = _extract_start_date(start_server_info)
366  await asyncio.sleep(1) # Critical sleep
367  request = await self.hasshass.async_add_executor_job(self.leafleaf.request_update)
368  if not request:
369  _LOGGER.error("Battery update request failed")
370  return None
371 
372  for attempt in range(MAX_RESPONSE_ATTEMPTS):
373  _LOGGER.debug(
374  "Waiting %s seconds for battery update (%s) (%s)",
375  PYCARWINGS2_SLEEP,
376  self.leafleaf.vin,
377  attempt,
378  )
379  await asyncio.sleep(PYCARWINGS2_SLEEP)
380 
381  # We don't use the response from get_status_from_update
382  # apart from knowing that the car has responded saying it
383  # has given the latest battery status to Nissan.
384  check_result_info = await self.hasshass.async_add_executor_job(
385  self.leafleaf.get_status_from_update, request
386  )
387 
388  if check_result_info is not None:
389  # Get the latest battery status from Nissan servers.
390  # This has the SOC in it.
391  server_info = await self.hasshass.async_add_executor_job(
392  self.leafleaf.get_latest_battery_status
393  )
394  if not start_date or (
395  server_info and start_date != _extract_start_date(server_info)
396  ):
397  return server_info
398  # get_status_from_update returned {"resultFlag": "1"}
399  # but the data didn't change, make a fresh request.
400  await asyncio.sleep(1) # Critical sleep
401  request = await self.hasshass.async_add_executor_job(
402  self.leafleaf.request_update
403  )
404  if not request:
405  _LOGGER.error("Battery update request failed")
406  return None
407 
408  _LOGGER.debug(
409  "%s attempts exceeded return latest data from server",
410  MAX_RESPONSE_ATTEMPTS,
411  )
412  # Get the latest data from the nissan servers, even though
413  # it may be out of date, it's better than nothing.
414  server_info = await self.hasshass.async_add_executor_job(
415  self.leafleaf.get_latest_battery_status
416  )
417  except CarwingsError:
418  _LOGGER.error("An error occurred getting battery status")
419  return None
420  except (KeyError, TypeError):
421  _LOGGER.error("An error occurred parsing response from server")
422  return None
423  return server_info
424 
425  async def async_get_climate(
426  self,
427  ) -> CarwingsLatestClimateControlStatusResponse:
428  """Request climate data from Nissan servers."""
429  try:
430  return await self.hasshass.async_add_executor_job(
431  self.leafleaf.get_latest_hvac_status
432  )
433  except CarwingsError:
434  _LOGGER.error(
435  "An error occurred communicating with the car %s", self.leafleaf.vin
436  )
437  return None
438 
439  async def async_set_climate(self, toggle: bool) -> bool:
440  """Set climate control mode via Nissan servers."""
441  climate_result = None
442  if toggle:
443  _LOGGER.debug("Requesting climate turn on for %s", self.leafleaf.vin)
444  set_function = self.leafleaf.start_climate_control
445  result_function = self.leafleaf.get_start_climate_control_result
446  else:
447  _LOGGER.debug("Requesting climate turn off for %s", self.leafleaf.vin)
448  set_function = self.leafleaf.stop_climate_control
449  result_function = self.leafleaf.get_stop_climate_control_result
450 
451  request = await self.hasshass.async_add_executor_job(set_function)
452  for attempt in range(MAX_RESPONSE_ATTEMPTS):
453  if attempt > 0:
454  _LOGGER.debug(
455  "Climate data not in yet (%s) (%s). Waiting (%s) seconds",
456  self.leafleaf.vin,
457  attempt,
458  PYCARWINGS2_SLEEP,
459  )
460  await asyncio.sleep(PYCARWINGS2_SLEEP)
461 
462  climate_result = await self.hasshass.async_add_executor_job(
463  result_function, request
464  )
465 
466  if climate_result is not None:
467  break
468 
469  if climate_result is not None:
470  _LOGGER.debug("Climate result: %s", climate_result.__dict__)
471  async_dispatcher_send(self.hasshass, SIGNAL_UPDATE_LEAF)
472  return bool(climate_result.is_hvac_running) == toggle
473 
474  _LOGGER.debug("Climate result not returned by Nissan servers")
475  return False
476 
477  async def async_start_charging(self) -> None:
478  """Request to start charging the car. Used by the button platform."""
479  await self.hasshass.async_add_executor_job(self.leafleaf.start_charging)
480  self.schedule_updateschedule_update()
481 
482  def schedule_update(self) -> None:
483  """Set the next update to be triggered in a minute."""
484 
485  # Remove any future updates that may be scheduled
486  if self._remove_listener_remove_listener:
487  self._remove_listener_remove_listener()
488  # Schedule update for one minute in the future - so that previously sent
489  # requests can be processed by Nissan servers or the car.
490  update_at = utcnow() + timedelta(minutes=1)
491  self.next_updatenext_update = update_at
492  self._remove_listener_remove_listener = async_track_point_in_utc_time(
493  self.hasshass, self.async_update_dataasync_update_data, update_at
494  )
CarwingsLatestBatteryStatusResponse async_get_battery(self)
Definition: __init__.py:349
CarwingsLatestClimateControlStatusResponse async_get_climate(self)
Definition: __init__.py:427
None __init__(self, HomeAssistant hass, Leaf leaf, dict[str, Any] car_config)
Definition: __init__.py:218
datetime|None _extract_start_date(CarwingsLatestBatteryStatusResponse battery_info)
Definition: __init__.py:202
bool setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:99
None load_platform(core.HomeAssistant hass, Platform|str component, str platform, DiscoveryInfoType|None discovered, ConfigType hass_config)
Definition: discovery.py:137
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
CALLBACK_TYPE async_track_point_in_utc_time(HomeAssistant hass, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action, datetime point_in_time)
Definition: event.py:1542