Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """The Tesla Powerwall integration."""
2 
3 from __future__ import annotations
4 
5 from contextlib import AsyncExitStack
6 from datetime import timedelta
7 import logging
8 
9 from aiohttp import CookieJar
10 from tesla_powerwall import (
11  AccessDeniedError,
12  ApiError,
13  MissingAttributeError,
14  Powerwall,
15  PowerwallUnreachableError,
16 )
17 
18 from homeassistant.components import persistent_notification
19 from homeassistant.config_entries import ConfigEntry
20 from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, Platform
21 from homeassistant.core import HomeAssistant, callback
22 from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
23 from homeassistant.helpers import device_registry as dr, entity_registry as er
24 from homeassistant.helpers.aiohttp_client import async_create_clientsession
25 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
26 from homeassistant.util.network import is_ip_address
27 
28 from .const import DOMAIN, POWERWALL_API_CHANGED, POWERWALL_COORDINATOR, UPDATE_INTERVAL
29 from .models import (
30  PowerwallBaseInfo,
31  PowerwallConfigEntry,
32  PowerwallData,
33  PowerwallRuntimeData,
34 )
35 
36 PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
37 
38 _LOGGER = logging.getLogger(__name__)
39 
40 API_CHANGED_ERROR_BODY = (
41  "It seems like your powerwall uses an unsupported version. "
42  "Please update the software of your powerwall or if it is "
43  "already the newest consider reporting this issue.\nSee logs for more information"
44 )
45 API_CHANGED_TITLE = "Unknown powerwall software version"
46 
47 
49  """Class to manager powerwall data and relogin on failure."""
50 
51  def __init__(
52  self,
53  hass: HomeAssistant,
54  power_wall: Powerwall,
55  ip_address: str,
56  password: str | None,
57  runtime_data: PowerwallRuntimeData,
58  ) -> None:
59  """Init the data manager."""
60  self.hasshass = hass
61  self.ip_addressip_address = ip_address
62  self.passwordpassword = password
63  self.runtime_dataruntime_data = runtime_data
64  self.power_wallpower_wall = power_wall
65 
66  @property
67  def api_changed(self) -> int:
68  """Return true if the api has changed out from under us."""
69  return self.runtime_dataruntime_data[POWERWALL_API_CHANGED]
70 
71  async def _recreate_powerwall_login(self) -> None:
72  """Recreate the login on auth failure."""
73  if self.power_wallpower_wall.is_authenticated():
74  await self.power_wallpower_wall.logout()
75  await self.power_wallpower_wall.login(self.passwordpassword or "")
76 
77  async def async_update_data(self) -> PowerwallData:
78  """Fetch data from API endpoint."""
79  # Check if we had an error before
80  _LOGGER.debug("Checking if update failed")
81  if self.api_changedapi_changed:
82  raise UpdateFailed("The powerwall api has changed")
83  return await self._update_data_update_data()
84 
85  async def _update_data(self) -> PowerwallData:
86  """Fetch data from API endpoint."""
87  _LOGGER.debug("Updating data")
88  for attempt in range(2):
89  try:
90  if attempt == 1:
91  await self._recreate_powerwall_login_recreate_powerwall_login()
92  data = await _fetch_powerwall_data(self.power_wallpower_wall)
93  except (TimeoutError, PowerwallUnreachableError) as err:
94  raise UpdateFailed("Unable to fetch data from powerwall") from err
95  except MissingAttributeError as err:
96  _LOGGER.error("The powerwall api has changed: %s", str(err))
97  # The error might include some important information
98  # about what exactly changed.
99  persistent_notification.create(
100  self.hasshass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE
101  )
102  self.runtime_dataruntime_data[POWERWALL_API_CHANGED] = True
103  raise UpdateFailed("The powerwall api has changed") from err
104  except AccessDeniedError as err:
105  if attempt == 1:
106  # failed to authenticate => the credentials must be wrong
107  raise ConfigEntryAuthFailed from err
108  if self.passwordpassword is None:
109  raise ConfigEntryAuthFailed from err
110  _LOGGER.debug("Access denied, trying to reauthenticate")
111  # there is still an attempt left to authenticate,
112  # so we continue in the loop
113  except ApiError as err:
114  raise UpdateFailed(f"Updated failed due to {err}, will retry") from err
115  else:
116  return data
117  raise RuntimeError("unreachable")
118 
119 
120 async def async_setup_entry(hass: HomeAssistant, entry: PowerwallConfigEntry) -> bool:
121  """Set up Tesla Powerwall from a config entry."""
122  ip_address: str = entry.data[CONF_IP_ADDRESS]
123 
124  password: str | None = entry.data.get(CONF_PASSWORD)
125  http_session = async_create_clientsession(
126  hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True)
127  )
128 
129  async with AsyncExitStack() as stack:
130  power_wall = Powerwall(ip_address, http_session=http_session, verify_ssl=False)
131  stack.push_async_callback(power_wall.close)
132 
133  try:
134  base_info = await _login_and_fetch_base_info(
135  power_wall, ip_address, password
136  )
137 
138  # Cancel closing power_wall on success
139  stack.pop_all()
140  except (TimeoutError, PowerwallUnreachableError) as err:
141  raise ConfigEntryNotReady from err
142  except MissingAttributeError as err:
143  # The error might include some important information about what exactly changed.
144  _LOGGER.error("The powerwall api has changed: %s", str(err))
145  persistent_notification.async_create(
146  hass, API_CHANGED_ERROR_BODY, API_CHANGED_TITLE
147  )
148  return False
149  except AccessDeniedError as err:
150  _LOGGER.debug("Authentication failed", exc_info=err)
151  raise ConfigEntryAuthFailed from err
152  except ApiError as err:
153  raise ConfigEntryNotReady from err
154 
155  gateway_din = base_info.gateway_din
156  if entry.unique_id is not None and is_ip_address(entry.unique_id):
157  hass.config_entries.async_update_entry(entry, unique_id=gateway_din)
158 
159  runtime_data = PowerwallRuntimeData(
160  api_changed=False,
161  base_info=base_info,
162  coordinator=None,
163  api_instance=power_wall,
164  )
165 
166  manager = PowerwallDataManager(hass, power_wall, ip_address, password, runtime_data)
167 
168  coordinator = DataUpdateCoordinator(
169  hass,
170  _LOGGER,
171  config_entry=entry,
172  name="Powerwall site",
173  update_method=manager.async_update_data,
174  update_interval=timedelta(seconds=UPDATE_INTERVAL),
175  always_update=False,
176  )
177 
178  await coordinator.async_config_entry_first_refresh()
179 
180  runtime_data[POWERWALL_COORDINATOR] = coordinator
181 
182  entry.runtime_data = runtime_data
183 
184  await async_migrate_entity_unique_ids(hass, entry, base_info)
185 
186  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
187 
188  return True
189 
190 
192  hass: HomeAssistant, entry: PowerwallConfigEntry, base_info: PowerwallBaseInfo
193 ) -> None:
194  """Migrate old entity unique ids to use gateway_din."""
195  old_base_unique_id = "_".join(base_info.serial_numbers)
196  new_base_unique_id = base_info.gateway_din
197 
198  dev_reg = dr.async_get(hass)
199  if device := dev_reg.async_get_device(identifiers={(DOMAIN, old_base_unique_id)}):
200  dev_reg.async_update_device(
201  device.id, new_identifiers={(DOMAIN, new_base_unique_id)}
202  )
203 
204  ent_reg = er.async_get(hass)
205  for ent_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
206  current_unique_id = ent_entry.unique_id
207  if current_unique_id.startswith(old_base_unique_id):
208  unique_id_postfix = current_unique_id.removeprefix(old_base_unique_id)
209  new_unique_id = f"{new_base_unique_id}{unique_id_postfix}"
210  ent_reg.async_update_entity(
211  ent_entry.entity_id, new_unique_id=new_unique_id
212  )
213 
214 
216  power_wall: Powerwall, host: str, password: str | None
217 ) -> PowerwallBaseInfo:
218  """Login to the powerwall and fetch the base info."""
219  if password is not None:
220  await power_wall.login(password)
221  return await _call_base_info(power_wall, host)
222 
223 
224 async def _call_base_info(power_wall: Powerwall, host: str) -> PowerwallBaseInfo:
225  """Return PowerwallBaseInfo for the device."""
226  # We await each call individually since the powerwall
227  # supports http keep-alive and we want to reuse the connection
228  # as its faster than establishing a new connection when
229  # run concurrently.
230  gateway_din = await power_wall.get_gateway_din()
231  site_info = await power_wall.get_site_info()
232  status = await power_wall.get_status()
233  device_type = await power_wall.get_device_type()
234  serial_numbers = await power_wall.get_serial_numbers()
235  batteries = await power_wall.get_batteries()
236  # Serial numbers MUST be sorted to ensure the unique_id is always the same
237  # for backwards compatibility.
238  return PowerwallBaseInfo(
239  gateway_din=gateway_din,
240  site_info=site_info,
241  status=status,
242  device_type=device_type,
243  serial_numbers=sorted(serial_numbers),
244  url=f"https://{host}",
245  batteries={battery.serial_number: battery for battery in batteries},
246  )
247 
248 
249 async def get_backup_reserve_percentage(power_wall: Powerwall) -> float | None:
250  """Return the backup reserve percentage."""
251  try:
252  return await power_wall.get_backup_reserve_percentage()
253  except MissingAttributeError:
254  return None
255 
256 
257 async def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData:
258  """Process and update powerwall data."""
259  # We await each call individually since the powerwall
260  # supports http keep-alive and we want to reuse the connection
261  # as its faster than establishing a new connection when
262  # run concurrently.
263  backup_reserve = await get_backup_reserve_percentage(power_wall)
264  charge = await power_wall.get_charge()
265  site_master = await power_wall.get_sitemaster()
266  meters = await power_wall.get_meters()
267  grid_services_active = await power_wall.is_grid_services_active()
268  grid_status = await power_wall.get_grid_status()
269  batteries = await power_wall.get_batteries()
270  return PowerwallData(
271  charge=charge,
272  site_master=site_master,
273  meters=meters,
274  grid_services_active=grid_services_active,
275  grid_status=grid_status,
276  backup_reserve=backup_reserve,
277  batteries={battery.serial_number: battery for battery in batteries},
278  )
279 
280 
281 @callback
283  hass: HomeAssistant, entry: PowerwallConfigEntry
284 ) -> bool:
285  """Return True if the last update was successful."""
286  return bool(
287  hasattr(entry, "runtime_data")
288  and (runtime_data := entry.runtime_data)
289  and (coordinator := runtime_data.get(POWERWALL_COORDINATOR))
290  and coordinator.last_update_success
291  )
292 
293 
294 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
295  """Unload a config entry."""
296  return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
None __init__(self, HomeAssistant hass, Powerwall power_wall, str ip_address, str|None password, PowerwallRuntimeData runtime_data)
Definition: __init__.py:58
PowerwallData _fetch_powerwall_data(Powerwall power_wall)
Definition: __init__.py:257
float|None get_backup_reserve_percentage(Powerwall power_wall)
Definition: __init__.py:249
None async_migrate_entity_unique_ids(HomeAssistant hass, PowerwallConfigEntry entry, PowerwallBaseInfo base_info)
Definition: __init__.py:193
bool async_setup_entry(HomeAssistant hass, PowerwallConfigEntry entry)
Definition: __init__.py:120
PowerwallBaseInfo _login_and_fetch_base_info(Powerwall power_wall, str host, str|None password)
Definition: __init__.py:217
bool async_last_update_was_successful(HomeAssistant hass, PowerwallConfigEntry entry)
Definition: __init__.py:284
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:294
PowerwallBaseInfo _call_base_info(Powerwall power_wall, str host)
Definition: __init__.py:224
aiohttp.ClientSession async_create_clientsession()
Definition: coordinator.py:51
bool is_ip_address(str address)
Definition: network.py:63