Home Assistant Unofficial Reference 2024.12.1
sensor.py
Go to the documentation of this file.
1 """SAJ solar inverter interface."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable, Coroutine
6 from datetime import date, datetime
7 import logging
8 from typing import Any
9 
10 import pysaj
11 import voluptuous as vol
12 
14  PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
15  SensorDeviceClass,
16  SensorEntity,
17  SensorStateClass,
18 )
19 from homeassistant.const import (
20  CONF_HOST,
21  CONF_NAME,
22  CONF_PASSWORD,
23  CONF_TYPE,
24  CONF_USERNAME,
25  EVENT_HOMEASSISTANT_STOP,
26  UnitOfEnergy,
27  UnitOfMass,
28  UnitOfPower,
29  UnitOfTemperature,
30  UnitOfTime,
31 )
32 from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
33 from homeassistant.exceptions import PlatformNotReady
35 from homeassistant.helpers.entity_platform import AddEntitiesCallback
36 from homeassistant.helpers.event import async_call_later
37 from homeassistant.helpers.start import async_at_start
38 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
39 
40 _LOGGER = logging.getLogger(__name__)
41 
42 MIN_INTERVAL = 5
43 MAX_INTERVAL = 300
44 
45 INVERTER_TYPES = ["ethernet", "wifi"]
46 
47 SAJ_UNIT_MAPPINGS = {
48  "": None,
49  "h": UnitOfTime.HOURS,
50  "kg": UnitOfMass.KILOGRAMS,
51  "kWh": UnitOfEnergy.KILO_WATT_HOUR,
52  "W": UnitOfPower.WATT,
53  "°C": UnitOfTemperature.CELSIUS,
54 }
55 
56 PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
57  {
58  vol.Required(CONF_HOST): cv.string,
59  vol.Optional(CONF_NAME): cv.string,
60  vol.Optional(CONF_TYPE, default=INVERTER_TYPES[0]): vol.In(INVERTER_TYPES),
61  vol.Inclusive(CONF_USERNAME, "credentials"): cv.string,
62  vol.Inclusive(CONF_PASSWORD, "credentials"): cv.string,
63  }
64 )
65 
66 
68  hass: HomeAssistant,
69  config: ConfigType,
70  async_add_entities: AddEntitiesCallback,
71  discovery_info: DiscoveryInfoType | None = None,
72 ) -> None:
73  """Set up the SAJ sensors."""
74 
75  remove_interval_update = None
76  wifi = config[CONF_TYPE] == INVERTER_TYPES[1]
77 
78  # Init all sensors
79  sensor_def = pysaj.Sensors(wifi)
80 
81  # Use all sensors by default
82  hass_sensors: list[SAJsensor] = []
83 
84  kwargs = {}
85  if wifi:
86  kwargs["wifi"] = True
87  if config.get(CONF_USERNAME) and config.get(CONF_PASSWORD):
88  kwargs["username"] = config[CONF_USERNAME]
89  kwargs["password"] = config[CONF_PASSWORD]
90 
91  try:
92  saj = pysaj.SAJ(config[CONF_HOST], **kwargs)
93  done = await saj.read(sensor_def)
94  except pysaj.UnauthorizedException:
95  _LOGGER.error("Username and/or password is wrong")
96  return
97  except pysaj.UnexpectedResponseException as err:
98  _LOGGER.error(
99  "Error in SAJ, please check host/ip address. Original error: %s", err
100  )
101  return
102 
103  if not done:
104  raise PlatformNotReady
105 
106  hass_sensors.extend(
107  SAJsensor(saj.serialnumber, sensor, inverter_name=config.get(CONF_NAME))
108  for sensor in sensor_def
109  if sensor.enabled
110  )
111 
112  async_add_entities(hass_sensors)
113 
114  async def async_saj() -> bool:
115  """Update all the SAJ sensors."""
116  success = await saj.read(sensor_def)
117 
118  for sensor in hass_sensors:
119  state_unknown = False
120  # SAJ inverters are powered by DC via solar panels and thus are
121  # offline after the sun has set. If a sensor resets on a daily
122  # basis like "today_yield", this reset won't happen automatically.
123  # Code below checks if today > day when sensor was last updated
124  # and if so: set state to None.
125  # Sensors with live values like "temperature" or "current_power"
126  # will also be reset to None.
127  if not success and (
128  (sensor.per_day_basis and date.today() > sensor.date_updated)
129  or (not sensor.per_day_basis and not sensor.per_total_basis)
130  ):
131  state_unknown = True
132  sensor.async_update_values(unknown_state=state_unknown)
133 
134  return success
135 
136  @callback
137  def start_update_interval(hass: HomeAssistant) -> None:
138  """Start the update interval scheduling."""
139  nonlocal remove_interval_update
140  remove_interval_update = async_track_time_interval_backoff(hass, async_saj)
141 
142  @callback
143  def stop_update_interval(event):
144  """Properly cancel the scheduled update."""
145  remove_interval_update()
146 
147  hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, stop_update_interval)
148  async_at_start(hass, start_update_interval)
149 
150 
151 @callback
153  hass: HomeAssistant, action: Callable[[], Coroutine[Any, Any, bool]]
154 ) -> CALLBACK_TYPE:
155  """Add a listener that fires repetitively and increases the interval when failed."""
156  remove = None
157  interval = MIN_INTERVAL
158 
159  async def interval_listener(now: datetime | None = None) -> None:
160  """Handle elapsed interval with backoff."""
161  nonlocal interval, remove
162  try:
163  if await action():
164  interval = MIN_INTERVAL
165  else:
166  interval = min(interval * 2, MAX_INTERVAL)
167  finally:
168  remove = async_call_later(hass, interval, interval_listener)
169 
170  hass.async_create_task(interval_listener())
171 
172  def remove_listener() -> None:
173  """Remove interval listener."""
174  if remove:
175  remove()
176 
177  return remove_listener
178 
179 
181  """Representation of a SAJ sensor."""
182 
183  _attr_should_poll = False
184 
185  def __init__(
186  self,
187  serialnumber: str | None,
188  pysaj_sensor: pysaj.Sensor,
189  inverter_name: str | None = None,
190  ) -> None:
191  """Initialize the SAJ sensor."""
192  self._sensor_sensor = pysaj_sensor
193  self._inverter_name_inverter_name = inverter_name
194  self._serialnumber_serialnumber = serialnumber
195  self._state_state = self._sensor_sensor.value
196 
197  if pysaj_sensor.name in ("current_power", "temperature"):
198  self._attr_state_class_attr_state_class = SensorStateClass.MEASUREMENT
199  if pysaj_sensor.name == "total_yield":
200  self._attr_state_class_attr_state_class = SensorStateClass.TOTAL_INCREASING
201 
202  self._attr_unique_id_attr_unique_id = f"{serialnumber}_{pysaj_sensor.name}"
203  native_uom = SAJ_UNIT_MAPPINGS[pysaj_sensor.unit]
204  self._attr_native_unit_of_measurement_attr_native_unit_of_measurement = native_uom
205  if self._inverter_name_inverter_name:
206  self._attr_name_attr_name = f"saj_{self._inverter_name}_{pysaj_sensor.name}"
207  else:
208  self._attr_name_attr_name = f"saj_{pysaj_sensor.name}"
209  if native_uom == UnitOfPower.WATT:
210  self._attr_device_class_attr_device_class = SensorDeviceClass.POWER
211  if native_uom == UnitOfEnergy.KILO_WATT_HOUR:
212  self._attr_device_class_attr_device_class = SensorDeviceClass.ENERGY
213  if native_uom in (
214  UnitOfTemperature.CELSIUS,
215  UnitOfTemperature.FAHRENHEIT,
216  ):
217  self._attr_device_class_attr_device_class = SensorDeviceClass.TEMPERATURE
218 
219  @property
220  def native_value(self):
221  """Return the state of the sensor."""
222  return self._state_state
223 
224  @property
225  def per_day_basis(self) -> bool:
226  """Return if the sensors value is on daily basis or not."""
227  return self._sensor_sensor.per_day_basis
228 
229  @property
230  def per_total_basis(self) -> bool:
231  """Return if the sensors value is cumulative or not."""
232  return self._sensor_sensor.per_total_basis
233 
234  @property
235  def date_updated(self) -> date:
236  """Return the date when the sensor was last updated."""
237  return self._sensor_sensor.date
238 
239  @callback
240  def async_update_values(self, unknown_state=False):
241  """Update this sensor."""
242  update = False
243 
244  if self._sensor_sensor.value != self._state_state:
245  update = True
246  self._state_state = self._sensor_sensor.value
247 
248  if unknown_state and self._state_state is not None:
249  update = True
250  self._state_state = None
251 
252  if update:
253  self.async_write_ha_stateasync_write_ha_state()
None __init__(self, str|None serialnumber, pysaj.Sensor pysaj_sensor, str|None inverter_name=None)
Definition: sensor.py:190
def async_update_values(self, unknown_state=False)
Definition: sensor.py:240
bool remove(self, _T matcher)
Definition: match.py:214
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
Definition: sensor.py:72
CALLBACK_TYPE async_track_time_interval_backoff(HomeAssistant hass, Callable[[], Coroutine[Any, Any, bool]] action)
Definition: sensor.py:154
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)
Definition: event.py:1597
CALLBACK_TYPE async_at_start(HomeAssistant hass, Callable[[HomeAssistant], Coroutine[Any, Any, None]|None] at_start_cb)
Definition: start.py:61