1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-30 04:05:01 +01:00
Files
2026-05-20 14:57:34 +02:00

253 lines
7.8 KiB
Python

"""SAJ solar inverter interface."""
from collections.abc import Callable, Coroutine
from datetime import date, datetime
import logging
from typing import Any
import pysaj
import voluptuous as vol
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_TYPE,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
UnitOfEnergy,
UnitOfMass,
UnitOfPower,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.start import async_at_start
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
MIN_INTERVAL = 5
MAX_INTERVAL = 300
INVERTER_TYPES = ["ethernet", "wifi"]
SAJ_UNIT_MAPPINGS = {
"": None,
"h": UnitOfTime.HOURS,
"kg": UnitOfMass.KILOGRAMS,
"kWh": UnitOfEnergy.KILO_WATT_HOUR,
"W": UnitOfPower.WATT,
"°C": UnitOfTemperature.CELSIUS,
}
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_TYPE, default=INVERTER_TYPES[0]): vol.In(INVERTER_TYPES),
vol.Inclusive(CONF_USERNAME, "credentials"): cv.string,
vol.Inclusive(CONF_PASSWORD, "credentials"): cv.string,
}
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the SAJ sensors."""
remove_interval_update = None
wifi = config[CONF_TYPE] == INVERTER_TYPES[1]
# Init all sensors
sensor_def = pysaj.Sensors(wifi)
# Use all sensors by default
hass_sensors: list[SAJsensor] = []
kwargs = {}
if wifi:
kwargs["wifi"] = True
if config.get(CONF_USERNAME) and config.get(CONF_PASSWORD):
kwargs["username"] = config[CONF_USERNAME]
kwargs["password"] = config[CONF_PASSWORD]
try:
saj = pysaj.SAJ(config[CONF_HOST], **kwargs)
done = await saj.read(sensor_def)
except pysaj.UnauthorizedException:
_LOGGER.error("Username and/or password is wrong")
return
except pysaj.UnexpectedResponseException as err:
_LOGGER.error(
"Error in SAJ, please check host/ip address. Original error: %s", err
)
return
if not done:
raise PlatformNotReady
hass_sensors.extend(
SAJsensor(saj.serialnumber, sensor, inverter_name=config.get(CONF_NAME))
for sensor in sensor_def
if sensor.enabled
)
async_add_entities(hass_sensors)
async def async_saj() -> bool:
"""Update all the SAJ sensors."""
success = await saj.read(sensor_def)
for sensor in hass_sensors:
state_unknown = False
# SAJ inverters are powered by DC via solar panels and thus are
# offline after the sun has set. If a sensor resets on a daily
# basis like "today_yield", this reset won't happen automatically.
# Code below checks if today > day when sensor was last updated
# and if so: set state to None.
# Sensors with live values like "temperature" or "current_power"
# will also be reset to None.
if not success and (
(sensor.per_day_basis and dt_util.now().date() > sensor.date_updated)
or (not sensor.per_day_basis and not sensor.per_total_basis)
):
state_unknown = True
sensor.async_update_values(unknown_state=state_unknown)
return success
@callback
def start_update_interval(hass: HomeAssistant) -> None:
"""Start the update interval scheduling."""
nonlocal remove_interval_update
remove_interval_update = async_track_time_interval_backoff(hass, async_saj)
@callback
def stop_update_interval(event):
"""Properly cancel the scheduled update."""
remove_interval_update()
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, stop_update_interval)
async_at_start(hass, start_update_interval)
@callback
def async_track_time_interval_backoff(
hass: HomeAssistant, action: Callable[[], Coroutine[Any, Any, bool]]
) -> CALLBACK_TYPE:
"""Add a listener that fires repetitively and increases the interval when failed."""
remove = None
interval = MIN_INTERVAL
async def interval_listener(now: datetime | None = None) -> None:
"""Handle elapsed interval with backoff."""
nonlocal interval, remove
try:
if await action():
interval = MIN_INTERVAL
else:
interval = min(interval * 2, MAX_INTERVAL)
finally:
remove = async_call_later(hass, interval, interval_listener)
hass.async_create_task(interval_listener())
def remove_listener() -> None:
"""Remove interval listener."""
if remove:
remove()
return remove_listener
class SAJsensor(SensorEntity):
"""Representation of a SAJ sensor."""
_attr_should_poll = False
def __init__(
self,
serialnumber: str | None,
pysaj_sensor: pysaj.Sensor,
inverter_name: str | None = None,
) -> None:
"""Initialize the SAJ sensor."""
self._sensor = pysaj_sensor
self._inverter_name = inverter_name
self._serialnumber = serialnumber
self._state = self._sensor.value
if pysaj_sensor.name in ("current_power", "temperature"):
self._attr_state_class = SensorStateClass.MEASUREMENT
if pysaj_sensor.name == "total_yield":
self._attr_state_class = SensorStateClass.TOTAL_INCREASING
self._attr_unique_id = f"{serialnumber}_{pysaj_sensor.name}"
native_uom = SAJ_UNIT_MAPPINGS[pysaj_sensor.unit]
self._attr_native_unit_of_measurement = native_uom
if self._inverter_name:
self._attr_name = f"saj_{self._inverter_name}_{pysaj_sensor.name}"
else:
self._attr_name = f"saj_{pysaj_sensor.name}"
if native_uom == UnitOfPower.WATT:
self._attr_device_class = SensorDeviceClass.POWER
if native_uom == UnitOfEnergy.KILO_WATT_HOUR:
self._attr_device_class = SensorDeviceClass.ENERGY
if native_uom in (
UnitOfTemperature.CELSIUS,
UnitOfTemperature.FAHRENHEIT,
):
self._attr_device_class = SensorDeviceClass.TEMPERATURE
@property
def native_value(self):
"""Return the state of the sensor."""
return self._state
@property
def per_day_basis(self) -> bool:
"""Return if the sensors value is on daily basis or not."""
return self._sensor.per_day_basis
@property
def per_total_basis(self) -> bool:
"""Return if the sensors value is cumulative or not."""
return self._sensor.per_total_basis
@property
def date_updated(self) -> date:
"""Return the date when the sensor was last updated."""
return self._sensor.date
@callback
def async_update_values(self, unknown_state=False):
"""Update this sensor."""
update = False
if self._sensor.value != self._state:
update = True
self._state = self._sensor.value
if unknown_state and self._state is not None:
update = True
self._state = None
if update:
self.async_write_ha_state()