Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Read status of growatt inverters."""
2 
3 from __future__ import annotations
4 
5 import datetime
6 import json
7 import logging
8 
9 import growattServer
10 
11 from homeassistant.components.sensor import SensorEntity
12 from homeassistant.config_entries import ConfigEntry
13 from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME
14 from homeassistant.core import HomeAssistant
15 from homeassistant.exceptions import ConfigEntryError
16 from homeassistant.helpers.device_registry import DeviceInfo
17 from homeassistant.helpers.entity_platform import AddEntitiesCallback
18 from homeassistant.util import Throttle, dt as dt_util
19 
20 from ..const import (
21  CONF_PLANT_ID,
22  DEFAULT_PLANT_ID,
23  DEFAULT_URL,
24  DEPRECATED_URLS,
25  DOMAIN,
26  LOGIN_INVALID_AUTH_CODE,
27 )
28 from .inverter import INVERTER_SENSOR_TYPES
29 from .mix import MIX_SENSOR_TYPES
30 from .sensor_entity_description import GrowattSensorEntityDescription
31 from .storage import STORAGE_SENSOR_TYPES
32 from .tlx import TLX_SENSOR_TYPES
33 from .total import TOTAL_SENSOR_TYPES
34 
35 _LOGGER = logging.getLogger(__name__)
36 
37 SCAN_INTERVAL = datetime.timedelta(minutes=5)
38 
39 
40 def get_device_list(api, config):
41  """Retrieve the device list for the selected plant."""
42  plant_id = config[CONF_PLANT_ID]
43 
44  # Log in to api and fetch first plant if no plant id is defined.
45  login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD])
46  if (
47  not login_response["success"]
48  and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
49  ):
50  raise ConfigEntryError("Username, Password or URL may be incorrect!")
51  user_id = login_response["user"]["id"]
52  if plant_id == DEFAULT_PLANT_ID:
53  plant_info = api.plant_list(user_id)
54  plant_id = plant_info["data"][0]["plantId"]
55 
56  # Get a list of devices for specified plant to add sensors for.
57  devices = api.device_list(plant_id)
58  return [devices, plant_id]
59 
60 
62  hass: HomeAssistant,
63  config_entry: ConfigEntry,
64  async_add_entities: AddEntitiesCallback,
65 ) -> None:
66  """Set up the Growatt sensor."""
67  config = {**config_entry.data}
68  username = config[CONF_USERNAME]
69  password = config[CONF_PASSWORD]
70  url = config.get(CONF_URL, DEFAULT_URL)
71  name = config[CONF_NAME]
72 
73  # If the URL has been deprecated then change to the default instead
74  if url in DEPRECATED_URLS:
75  _LOGGER.warning(
76  "URL: %s has been deprecated, migrating to the latest default: %s",
77  url,
78  DEFAULT_URL,
79  )
80  url = DEFAULT_URL
81  config[CONF_URL] = url
82  hass.config_entries.async_update_entry(config_entry, data=config)
83 
84  # Initialise the library with the username & a random id each time it is started
85  api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username)
86  api.server_url = url
87 
88  devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config)
89 
90  probe = GrowattData(api, username, password, plant_id, "total")
91  entities = [
93  probe,
94  name=f"{name} Total",
95  unique_id=f"{plant_id}-{description.key}",
96  description=description,
97  )
98  for description in TOTAL_SENSOR_TYPES
99  ]
100 
101  # Add sensors for each device in the specified plant.
102  for device in devices:
103  probe = GrowattData(
104  api, username, password, device["deviceSn"], device["deviceType"]
105  )
106  sensor_descriptions: tuple[GrowattSensorEntityDescription, ...] = ()
107  if device["deviceType"] == "inverter":
108  sensor_descriptions = INVERTER_SENSOR_TYPES
109  elif device["deviceType"] == "tlx":
110  probe.plant_id = plant_id
111  sensor_descriptions = TLX_SENSOR_TYPES
112  elif device["deviceType"] == "storage":
113  probe.plant_id = plant_id
114  sensor_descriptions = STORAGE_SENSOR_TYPES
115  elif device["deviceType"] == "mix":
116  probe.plant_id = plant_id
117  sensor_descriptions = MIX_SENSOR_TYPES
118  else:
119  _LOGGER.debug(
120  "Device type %s was found but is not supported right now",
121  device["deviceType"],
122  )
123 
124  entities.extend(
125  [
127  probe,
128  name=f"{device['deviceAilas']}",
129  unique_id=f"{device['deviceSn']}-{description.key}",
130  description=description,
131  )
132  for description in sensor_descriptions
133  ]
134  )
135 
136  async_add_entities(entities, True)
137 
138 
140  """Representation of a Growatt Sensor."""
141 
142  _attr_has_entity_name = True
143 
144  entity_description: GrowattSensorEntityDescription
145 
146  def __init__(
147  self, probe, name, unique_id, description: GrowattSensorEntityDescription
148  ) -> None:
149  """Initialize a PVOutput sensor."""
150  self.probeprobe = probe
151  self.entity_descriptionentity_description = description
152 
153  self._attr_unique_id_attr_unique_id = unique_id
154  self._attr_icon_attr_icon = "mdi:solar-power"
155 
156  self._attr_device_info_attr_device_info = DeviceInfo(
157  identifiers={(DOMAIN, probe.device_id)},
158  manufacturer="Growatt",
159  name=name,
160  )
161 
162  @property
163  def native_value(self):
164  """Return the state of the sensor."""
165  result = self.probeprobe.get_data(self.entity_descriptionentity_description)
166  if self.entity_descriptionentity_description.precision is not None:
167  result = round(result, self.entity_descriptionentity_description.precision)
168  return result
169 
170  @property
171  def native_unit_of_measurement(self) -> str | None:
172  """Return the unit of measurement of the sensor, if any."""
173  if self.entity_descriptionentity_description.currency:
174  return self.probeprobe.get_currency()
175  return super().native_unit_of_measurement
176 
177  def update(self) -> None:
178  """Get the latest data from the Growat API and updates the state."""
179  self.probeprobe.update()
180 
181 
183  """The class for handling data retrieval."""
184 
185  def __init__(self, api, username, password, device_id, growatt_type):
186  """Initialize the probe."""
187 
188  self.growatt_typegrowatt_type = growatt_type
189  self.apiapi = api
190  self.device_iddevice_id = device_id
191  self.plant_idplant_id = None
192  self.datadata = {}
193  self.previous_valuesprevious_values = {}
194  self.usernameusername = username
195  self.passwordpassword = password
196 
197  @Throttle(SCAN_INTERVAL)
198  def update(self):
199  """Update probe data."""
200  self.apiapi.login(self.usernameusername, self.passwordpassword)
201  _LOGGER.debug("Updating data for %s (%s)", self.device_iddevice_id, self.growatt_typegrowatt_type)
202  try:
203  if self.growatt_typegrowatt_type == "total":
204  total_info = self.apiapi.plant_info(self.device_iddevice_id)
205  del total_info["deviceList"]
206  # PlantMoneyText comes in as "3.1/€" split between value and currency
207  plant_money_text, currency = total_info["plantMoneyText"].split("/")
208  total_info["plantMoneyText"] = plant_money_text
209  total_info["currency"] = currency
210  self.datadata = total_info
211  elif self.growatt_typegrowatt_type == "inverter":
212  inverter_info = self.apiapi.inverter_detail(self.device_iddevice_id)
213  self.datadata = inverter_info
214  elif self.growatt_typegrowatt_type == "tlx":
215  tlx_info = self.apiapi.tlx_detail(self.device_iddevice_id)
216  self.datadata = tlx_info["data"]
217  elif self.growatt_typegrowatt_type == "storage":
218  storage_info_detail = self.apiapi.storage_params(self.device_iddevice_id)[
219  "storageDetailBean"
220  ]
221  storage_energy_overview = self.apiapi.storage_energy_overview(
222  self.plant_idplant_id, self.device_iddevice_id
223  )
224  self.datadata = {**storage_info_detail, **storage_energy_overview}
225  elif self.growatt_typegrowatt_type == "mix":
226  mix_info = self.apiapi.mix_info(self.device_iddevice_id)
227  mix_totals = self.apiapi.mix_totals(self.device_iddevice_id, self.plant_idplant_id)
228  mix_system_status = self.apiapi.mix_system_status(
229  self.device_iddevice_id, self.plant_idplant_id
230  )
231 
232  mix_detail = self.apiapi.mix_detail(self.device_iddevice_id, self.plant_idplant_id)
233  # Get the chart data and work out the time of the last entry, use this
234  # as the last time data was published to the Growatt Server
235  mix_chart_entries = mix_detail["chartData"]
236  sorted_keys = sorted(mix_chart_entries)
237 
238  # Create datetime from the latest entry
239  date_now = dt_util.now().date()
240  last_updated_time = dt_util.parse_time(str(sorted_keys[-1]))
241  mix_detail["lastdataupdate"] = datetime.datetime.combine(
242  date_now, last_updated_time, dt_util.get_default_time_zone()
243  )
244 
245  # Dashboard data is largely inaccurate for mix system but it is the only
246  # call with the ability to return the combined imported from grid value
247  # that is the combination of charging AND load consumption
248  dashboard_data = self.apiapi.dashboard_data(self.plant_idplant_id)
249  # Dashboard values have units e.g. "kWh" as part of their returned
250  # string, so we remove it
251  dashboard_values_for_mix = {
252  # etouser is already used by the results from 'mix_detail' so we
253  # rebrand it as 'etouser_combined'
254  "etouser_combined": float(
255  dashboard_data["etouser"].replace("kWh", "")
256  )
257  }
258  self.datadata = {
259  **mix_info,
260  **mix_totals,
261  **mix_system_status,
262  **mix_detail,
263  **dashboard_values_for_mix,
264  }
265  _LOGGER.debug(
266  "Finished updating data for %s (%s)",
267  self.device_iddevice_id,
268  self.growatt_typegrowatt_type,
269  )
270  except json.decoder.JSONDecodeError:
271  _LOGGER.error("Unable to fetch data from Growatt server")
272 
273  def get_currency(self):
274  """Get the currency."""
275  return self.datadata.get("currency")
276 
277  def get_data(self, entity_description):
278  """Get the data."""
279  _LOGGER.debug(
280  "Data request for: %s",
281  entity_description.name,
282  )
283  variable = entity_description.api_key
284  api_value = self.datadata.get(variable)
285  previous_value = self.previous_valuesprevious_values.get(variable)
286  return_value = api_value
287 
288  # If we have a 'drop threshold' specified, then check it and correct if needed
289  if (
290  entity_description.previous_value_drop_threshold is not None
291  and previous_value is not None
292  and api_value is not None
293  ):
294  _LOGGER.debug(
295  (
296  "%s - Drop threshold specified (%s), checking for drop... API"
297  " Value: %s, Previous Value: %s"
298  ),
299  entity_description.name,
300  entity_description.previous_value_drop_threshold,
301  api_value,
302  previous_value,
303  )
304  diff = float(api_value) - float(previous_value)
305 
306  # Check if the value has dropped (negative value i.e. < 0) and it has only
307  # dropped by a small amount, if so, use the previous value.
308  # Note - The energy dashboard takes care of drops within 10%
309  # of the current value, however if the value is low e.g. 0.2
310  # and drops by 0.1 it classes as a reset.
311  if -(entity_description.previous_value_drop_threshold) <= diff < 0:
312  _LOGGER.debug(
313  (
314  "Diff is negative, but only by a small amount therefore not a"
315  " nightly reset, using previous value (%s) instead of api value"
316  " (%s)"
317  ),
318  previous_value,
319  api_value,
320  )
321  return_value = previous_value
322  else:
323  _LOGGER.debug(
324  "%s - No drop detected, using API value", entity_description.name
325  )
326 
327  # Lifetime total values should always be increasing, they will never reset,
328  # however the API sometimes returns 0 values when the clock turns to 00:00
329  # local time in that scenario we should just return the previous value
330  # Scenarios:
331  # 1 - System has a genuine 0 value when it it first commissioned:
332  # - will return 0 until a non-zero value is registered
333  # 2 - System has been running fine but temporarily resets to 0 briefly
334  # at midnight:
335  # - will return the previous value
336  # 3 - HA is restarted during the midnight 'outage' - Not handled:
337  # - Previous value will not exist meaning 0 will be returned
338  # - This is an edge case that would be better handled by looking
339  # up the previous value of the entity from the recorder
340  if entity_description.never_resets and api_value == 0 and previous_value:
341  _LOGGER.debug(
342  (
343  "API value is 0, but this value should never reset, returning"
344  " previous value (%s) instead"
345  ),
346  previous_value,
347  )
348  return_value = previous_value
349 
350  self.previous_valuesprevious_values[variable] = return_value
351 
352  return return_value
def __init__(self, api, username, password, device_id, growatt_type)
Definition: __init__.py:185
None __init__(self, probe, name, unique_id, GrowattSensorEntityDescription description)
Definition: __init__.py:148
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: __init__.py:65