1531 lines
58 KiB
Python
1531 lines
58 KiB
Python
"""Support for various Hilo sensors."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from datetime import datetime, timedelta, timezone
|
||
from os.path import isfile
|
||
|
||
import aiofiles
|
||
from homeassistant.components.integration.sensor import METHOD_LEFT, IntegrationSensor
|
||
from homeassistant.components.sensor import (
|
||
SensorDeviceClass,
|
||
SensorEntity,
|
||
SensorStateClass,
|
||
)
|
||
from homeassistant.config_entries import ConfigEntry
|
||
from homeassistant.const import (
|
||
CONCENTRATION_PARTS_PER_MILLION,
|
||
CONF_SCAN_INTERVAL,
|
||
CURRENCY_DOLLAR,
|
||
PERCENTAGE,
|
||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||
STATE_UNKNOWN,
|
||
Platform,
|
||
UnitOfEnergy,
|
||
UnitOfPower,
|
||
UnitOfSoundPressure,
|
||
UnitOfTemperature,
|
||
__short_version__ as current_version,
|
||
)
|
||
from homeassistant.core import HomeAssistant
|
||
from homeassistant.helpers import entity_registry as er
|
||
from homeassistant.helpers.debounce import Debouncer
|
||
from homeassistant.helpers.device_registry import DeviceInfo
|
||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||
from homeassistant.helpers.event import async_track_state_change_event
|
||
from homeassistant.helpers.restore_state import RestoreEntity
|
||
from homeassistant.util import Throttle, slugify
|
||
import homeassistant.util.dt as dt_util
|
||
from packaging.version import Version
|
||
from pyhilo.const import UNMONITORED_DEVICES
|
||
from pyhilo.device import HiloDevice
|
||
from pyhilo.event import Event
|
||
from pyhilo.util import from_utc_timestamp
|
||
import yaml
|
||
from yaml.scanner import ScannerError
|
||
|
||
from . import Hilo
|
||
from .const import (
|
||
CONF_ENERGY_METER_PERIOD,
|
||
CONF_GENERATE_ENERGY_METERS,
|
||
CONF_HQ_PLAN_NAME,
|
||
CONF_TARIFF,
|
||
CONF_UNTARIFICATED_DEVICES,
|
||
DEFAULT_ENERGY_METER_PERIOD,
|
||
DEFAULT_GENERATE_ENERGY_METERS,
|
||
DEFAULT_HQ_PLAN_NAME,
|
||
DEFAULT_SCAN_INTERVAL,
|
||
DEFAULT_UNTARIFICATED_DEVICES,
|
||
DOMAIN,
|
||
EVENT_SCAN_INTERVAL_REDUCTION,
|
||
HILO_ENERGY_TOTAL,
|
||
HILO_SENSOR_CLASSES,
|
||
LOG,
|
||
MAX_SUB_INTERVAL,
|
||
MIN_SCAN_INTERVAL,
|
||
NOTIFICATION_SCAN_INTERVAL,
|
||
REWARD_SCAN_INTERVAL,
|
||
TARIFF_LIST,
|
||
WEATHER_CONDITIONS,
|
||
WEATHER_SCAN_INTERVAL,
|
||
)
|
||
from .entity import HiloEntity
|
||
from .managers import EnergyManager, UtilityManager
|
||
|
||
WIFI_STRENGTH = {
|
||
"Low": 1,
|
||
"Medium": 2,
|
||
"High": 3,
|
||
"Full": 4,
|
||
}
|
||
|
||
|
||
# From netatmo integration
|
||
def process_wifi(strength: int) -> str:
|
||
"""Process Wi-Fi signal strength and return string for display."""
|
||
if strength >= 86:
|
||
return "Low"
|
||
if strength >= 71:
|
||
return "Medium"
|
||
if strength >= 56:
|
||
return "High"
|
||
return "Full"
|
||
|
||
|
||
def validate_tariff_list(tariff_config):
|
||
"""Validate the tariff list from the configuration."""
|
||
return TARIFF_LIST
|
||
|
||
|
||
def generate_entities_from_device(device, hilo, scan_interval):
|
||
"""Generate the entities from the device description."""
|
||
entities = []
|
||
if device.type == "Gateway":
|
||
entities.append(
|
||
HiloChallengeSensor(hilo, device, scan_interval),
|
||
)
|
||
entities.append(
|
||
HiloRewardSensor(hilo, device, scan_interval),
|
||
)
|
||
entities.append(
|
||
HiloNotificationSensor(hilo, device, scan_interval),
|
||
)
|
||
entities.append(
|
||
HiloOutdoorTempSensor(hilo, device, scan_interval),
|
||
)
|
||
if device.has_attribute("battery"):
|
||
entities.append(BatterySensor(hilo, device))
|
||
if device.has_attribute("co2"):
|
||
entities.append(Co2Sensor(hilo, device))
|
||
if device.has_attribute("current_temperature"):
|
||
entities.append(TemperatureSensor(hilo, device))
|
||
if device.type in HILO_SENSOR_CLASSES:
|
||
entities.append(DeviceSensor(hilo, device))
|
||
if device.has_attribute("noise"):
|
||
entities.append(NoiseSensor(hilo, device))
|
||
if device.has_attribute("power") and device.model not in UNMONITORED_DEVICES:
|
||
entities.append(PowerSensor(hilo, device))
|
||
if device.has_attribute("target_temperature"):
|
||
entities.append(TargetTemperatureSensor(hilo, device))
|
||
if device.has_attribute("wifi_status"):
|
||
entities.append(WifiStrengthSensor(hilo, device))
|
||
return entities
|
||
|
||
|
||
# noinspection GrazieInspection
|
||
async def async_setup_entry(
|
||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||
) -> None:
|
||
"""Set up Hilo sensors based on a config entry."""
|
||
hilo = hass.data[DOMAIN][entry.entry_id]
|
||
new_entities = []
|
||
cost_entities = []
|
||
hq_plan_name = entry.options.get(CONF_HQ_PLAN_NAME, DEFAULT_HQ_PLAN_NAME)
|
||
untarificated_devices = entry.options.get(
|
||
CONF_UNTARIFICATED_DEVICES, DEFAULT_UNTARIFICATED_DEVICES
|
||
)
|
||
energy_meter_period = entry.options.get(
|
||
CONF_ENERGY_METER_PERIOD, DEFAULT_ENERGY_METER_PERIOD
|
||
)
|
||
scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||
generate_energy_meters = entry.options.get(
|
||
CONF_GENERATE_ENERGY_METERS, DEFAULT_GENERATE_ENERGY_METERS
|
||
)
|
||
tariff_config = CONF_TARIFF.get(hq_plan_name)
|
||
if untarificated_devices:
|
||
default_tariff_list = ["total"]
|
||
else:
|
||
default_tariff_list = validate_tariff_list(tariff_config)
|
||
if generate_energy_meters:
|
||
energy_manager = await EnergyManager().init(hass, energy_meter_period)
|
||
utility_manager = UtilityManager(hass, energy_meter_period, default_tariff_list)
|
||
|
||
def create_energy_entity(hilo, device):
|
||
device._energy_entity = EnergySensor(hilo, device, hass)
|
||
new_entities.append(device._energy_entity)
|
||
energy_entity = f"{slugify(device.name)}_hilo_energy"
|
||
if energy_entity == HILO_ENERGY_TOTAL:
|
||
LOG.error(
|
||
"An hilo entity can't be named 'total' because it conflicts "
|
||
"with the generated name for the smart energy meter"
|
||
)
|
||
return
|
||
tariff_list = default_tariff_list
|
||
if device.type == "Meter":
|
||
energy_entity = HILO_ENERGY_TOTAL
|
||
tariff_list = validate_tariff_list(tariff_config)
|
||
net_consumption = device.net_consumption
|
||
utility_manager.add_meter(energy_entity, tariff_list, net_consumption)
|
||
|
||
for d in hilo.devices.all:
|
||
LOG.debug("Adding device %s", d)
|
||
new_entities.extend(generate_entities_from_device(d, hilo, scan_interval))
|
||
if d.has_attribute("power") and d.model not in UNMONITORED_DEVICES:
|
||
# If we opt out the generation of meters we just create the power sensors
|
||
if generate_energy_meters:
|
||
create_energy_entity(hilo, d)
|
||
|
||
async_add_entities(new_entities)
|
||
if not generate_energy_meters:
|
||
return
|
||
# Creating cost sensors based on plan
|
||
# This will generate hilo_cost_(low|medium|high) sensors which can be
|
||
# referred later in the energy dashboard based on the tarif selected
|
||
hilo.cost_sensors = {}
|
||
for tarif, amount in tariff_config.items():
|
||
if amount > 0:
|
||
sensor_name = f"Hilo rate {tarif}"
|
||
sensor = HiloCostSensor(hilo, sensor_name, hq_plan_name, amount)
|
||
cost_entities.append(sensor)
|
||
hilo.cost_sensors[tarif] = sensor # Stores reference for check_tarif
|
||
|
||
hilo_rate_current = HiloCostSensor(hilo, "Hilo rate current", hq_plan_name)
|
||
cost_entities.append(hilo_rate_current)
|
||
|
||
# Create hilo_rate_current_total sensor that includes access rate per hour
|
||
access_rate = tariff_config.get("access", 0)
|
||
if access_rate > 0:
|
||
hilo_cost_total = HiloCostTotalSensor(
|
||
hilo,
|
||
"Hilo cost total",
|
||
hq_plan_name,
|
||
tariff_config,
|
||
energy_meter_period,
|
||
)
|
||
cost_entities.append(hilo_cost_total)
|
||
|
||
hilo.cost_sensors["current"] = hilo_rate_current
|
||
async_add_entities(cost_entities)
|
||
async_track_state_change_event(
|
||
hilo._hass, ["sensor.hilo_rate_current"], hilo_rate_current._handle_state_change
|
||
)
|
||
|
||
# This setups the utility_meter platform
|
||
await utility_manager.update(async_add_entities)
|
||
# This sends the entities to the energy dashboard
|
||
await energy_manager.update()
|
||
hilo.check_tarif()
|
||
|
||
|
||
class BatterySensor(HiloEntity, SensorEntity):
|
||
"""Define a Battery sensor entity."""
|
||
|
||
_attr_device_class = SensorDeviceClass.BATTERY
|
||
_attr_native_unit_of_measurement = PERCENTAGE
|
||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||
|
||
def __init__(self, hilo, device):
|
||
"""Hilo battery sensor initialization."""
|
||
self._attr_name = f"{device.name} Battery"
|
||
super().__init__(hilo, name=self._attr_name, device=device)
|
||
old_unique_id = f"{slugify(device.name)}-battery"
|
||
self._attr_unique_id = f"{device.identifier.lower()}-battery"
|
||
hilo.async_migrate_unique_id(
|
||
old_unique_id, self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
hilo.async_migrate_unique_id(
|
||
f"{slugify(device.identifier)}-battery",
|
||
self._attr_unique_id,
|
||
Platform.SENSOR,
|
||
)
|
||
LOG.debug("Setting up BatterySensor entity: %s", self._attr_name)
|
||
|
||
@property
|
||
def state(self):
|
||
"""Return the battery level."""
|
||
return str(int(self._device.get_value("battery", 0)))
|
||
|
||
@property
|
||
def icon(self):
|
||
"""Return the icon representing the battery level."""
|
||
if not self._device.available:
|
||
return "mdi:lan-disconnect"
|
||
level = round(int(self._device.get_value("battery", 0)) / 10) * 10
|
||
if level < 10:
|
||
return "mdi:battery-alert"
|
||
return f"mdi:battery-{level}"
|
||
|
||
|
||
class Co2Sensor(HiloEntity, SensorEntity):
|
||
"""Define a Co2 sensor entity."""
|
||
|
||
_attr_device_class = SensorDeviceClass.CO2
|
||
_attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION
|
||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||
|
||
def __init__(self, hilo, device):
|
||
"""Hilo CO2 sensor initialization."""
|
||
self._attr_name = f"{device.name} CO2"
|
||
super().__init__(hilo, name=self._attr_name, device=device)
|
||
old_unique_id = f"{slugify(device.name)}-co2"
|
||
self._attr_unique_id = f"{device.identifier.lower()}-co2"
|
||
hilo.async_migrate_unique_id(
|
||
old_unique_id, self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
hilo.async_migrate_unique_id(
|
||
f"{slugify(device.identifier)}-co2", self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
LOG.debug("Setting up CO2Sensor entity: %s", self._attr_name)
|
||
|
||
@property
|
||
def state(self):
|
||
"""Return the CO2 level."""
|
||
return str(int(self._device.get_value("co2", 0)))
|
||
|
||
@property
|
||
def icon(self):
|
||
"""Return the icon representing the CO2 level."""
|
||
if not self._device.available:
|
||
return "mdi:lan-disconnect"
|
||
return "mdi:molecule-co2"
|
||
|
||
|
||
class EnergySensor(IntegrationSensor):
|
||
"""Define a Hilo energy sensor entity."""
|
||
|
||
_attr_device_class = SensorDeviceClass.ENERGY
|
||
_attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
|
||
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||
_attr_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
|
||
_attr_suggested_display_precision = 2
|
||
_attr_icon = "mdi:lightning-bolt"
|
||
|
||
def __init__(self, hilo, device, hass):
|
||
"""Hilo Energy sensor initialization."""
|
||
self._device = device
|
||
self._attr_name = f"{device.name} Hilo Energy"
|
||
old_unique_id = f"hilo_energy_{slugify(device.name)}"
|
||
self._attr_unique_id = f"{device.identifier.lower()}-energy"
|
||
hilo.async_migrate_unique_id(
|
||
old_unique_id, self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
hilo.async_migrate_unique_id(
|
||
f"{slugify(device.identifier)}-energy",
|
||
self._attr_unique_id,
|
||
Platform.SENSOR,
|
||
)
|
||
self._unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
|
||
self._suggested_display_precision = 2
|
||
|
||
if device.type == "Meter":
|
||
self._attr_name = HILO_ENERGY_TOTAL
|
||
|
||
power_unique_id = f"{device.identifier.lower()}-power"
|
||
entity_registry = er.async_get(hass)
|
||
self._source = next(
|
||
(
|
||
entry.entity_id
|
||
for entry in entity_registry.entities.values()
|
||
if entry.unique_id == power_unique_id and entry.platform == DOMAIN
|
||
),
|
||
f"sensor.{slugify(device.name)}_power", # fallback
|
||
)
|
||
# ic-dev21: Set initial state and last_valid_state, removes log errors and unavailable states
|
||
initial_state = 0
|
||
self._attr_native_value = initial_state
|
||
self._attr_last_valid_state = initial_state
|
||
self._device_info = DeviceInfo(
|
||
identifiers={(DOMAIN, self._device.identifier)},
|
||
)
|
||
|
||
if Version(current_version) >= Version("2025.8"):
|
||
super().__init__(
|
||
hass,
|
||
integration_method=METHOD_LEFT,
|
||
max_sub_interval=timedelta(seconds=MAX_SUB_INTERVAL),
|
||
name=self._attr_name,
|
||
round_digits=2,
|
||
source_entity=self._source,
|
||
unique_id=self._attr_unique_id,
|
||
unit_prefix="k",
|
||
unit_time="h",
|
||
)
|
||
elif Version(current_version) >= Version("2024.7"):
|
||
super().__init__(
|
||
integration_method=METHOD_LEFT,
|
||
max_sub_interval=timedelta(seconds=MAX_SUB_INTERVAL),
|
||
name=self._attr_name,
|
||
round_digits=2,
|
||
source_entity=self._source,
|
||
unique_id=self._attr_unique_id,
|
||
unit_prefix="k",
|
||
unit_time="h",
|
||
device_info=self._device_info,
|
||
)
|
||
else:
|
||
super().__init__(
|
||
integration_method=METHOD_LEFT,
|
||
name=self._attr_name,
|
||
round_digits=2,
|
||
source_entity=self._source,
|
||
unique_id=self._attr_unique_id,
|
||
unit_prefix="k",
|
||
unit_time="h",
|
||
device_info=self._device_info,
|
||
)
|
||
self._unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
|
||
self._suggested_display_precision = 2
|
||
|
||
self._attr_icon = "mdi:lightning-bolt"
|
||
LOG.debug(
|
||
"Setting up EnergySensor entity: %s with source %s",
|
||
self._attr_name,
|
||
self._source,
|
||
)
|
||
|
||
@property
|
||
def unit_of_measurement(self):
|
||
"""Return the unit of measurement."""
|
||
return self._attr_unit_of_measurement
|
||
|
||
@property
|
||
def suggested_display_precision(self):
|
||
"""Return the suggested display precision."""
|
||
return self._attr_suggested_display_precision
|
||
|
||
async def async_added_to_hass(self) -> None:
|
||
"""Handle entity which will be added."""
|
||
LOG.debug("Added to hass: %s", self._attr_name)
|
||
await super().async_added_to_hass()
|
||
|
||
|
||
class NoiseSensor(HiloEntity, SensorEntity):
|
||
"""Define a Netatmo noise sensor entity."""
|
||
|
||
_attr_native_unit_of_measurement = UnitOfSoundPressure.DECIBEL
|
||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||
|
||
def __init__(self, hilo, device):
|
||
"""Hilo Noise sensor initialization."""
|
||
self._attr_name = f"{device.name} Noise"
|
||
super().__init__(hilo, name=self._attr_name, device=device)
|
||
old_unique_id = f"{slugify(device.name)}-noise"
|
||
self._attr_unique_id = f"{device.identifier.lower()}-noise"
|
||
hilo.async_migrate_unique_id(
|
||
old_unique_id, self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
hilo.async_migrate_unique_id(
|
||
f"{slugify(device.identifier)}-noise", self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
LOG.debug("Setting up NoiseSensor entity: %s", self._attr_name)
|
||
|
||
@property
|
||
def state(self):
|
||
"""Return the current noise level."""
|
||
return str(int(self._device.get_value("noise", 0)))
|
||
|
||
@property
|
||
def icon(self):
|
||
"""Return the icon representing the noise level."""
|
||
if not self._device.available:
|
||
return "mdi:lan-disconnect"
|
||
if int(self._device.get_value("noise", 0)) > 0:
|
||
return "mdi:volume-vibrate"
|
||
return "mdi:volume-mute"
|
||
|
||
|
||
class PowerSensor(HiloEntity, SensorEntity):
|
||
"""Define a Hilo power sensor entity."""
|
||
|
||
_attr_device_class = SensorDeviceClass.POWER
|
||
_attr_native_unit_of_measurement = UnitOfPower.WATT
|
||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||
|
||
def __init__(self, hilo: Hilo, device: HiloDevice) -> None:
|
||
"""Initialize."""
|
||
self._attr_name = f"{device.name} Power"
|
||
super().__init__(hilo, name=self._attr_name, device=device)
|
||
old_unique_id = f"{slugify(device.name)}-power"
|
||
self._attr_unique_id = f"{device.identifier.lower()}-power"
|
||
hilo.async_migrate_unique_id(
|
||
old_unique_id, self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
hilo.async_migrate_unique_id(
|
||
f"{slugify(device.identifier)}-power", self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
LOG.debug("Setting up PowerSensor entity: %s", self._attr_name)
|
||
|
||
@property
|
||
def state(self):
|
||
"""Return the current power state."""
|
||
return str(int(self._device.get_value("power", 0)))
|
||
|
||
@property
|
||
def icon(self):
|
||
"""Return the icon representing the power state."""
|
||
if not self._device.available:
|
||
return "mdi:lan-disconnect"
|
||
power = int(self._device.get_value("power", 0))
|
||
if power > 0:
|
||
return "mdi:power-plug"
|
||
return "mdi:power-plug-off"
|
||
|
||
|
||
class TemperatureSensor(HiloEntity, SensorEntity):
|
||
"""Define a Hilo temperature sensor entity."""
|
||
|
||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||
|
||
def __init__(self, hilo, device):
|
||
"""Hilo Temperature sensor initialization."""
|
||
self._attr_name = f"{device.name} Temperature"
|
||
super().__init__(hilo, name=self._attr_name, device=device)
|
||
old_unique_id = f"{slugify(device.name)}-temperature"
|
||
self._attr_unique_id = f"{device.identifier.lower()}-temperature"
|
||
hilo.async_migrate_unique_id(
|
||
old_unique_id, self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
hilo.async_migrate_unique_id(
|
||
f"{slugify(device.identifier)}-temperature",
|
||
self._attr_unique_id,
|
||
Platform.SENSOR,
|
||
)
|
||
LOG.debug("Setting up TemperatureSensor entity: %s", self._attr_name)
|
||
|
||
@property
|
||
def state(self):
|
||
"""Return the current temperature."""
|
||
return str(float(self._device.get_value("current_temperature", 0)))
|
||
|
||
@property
|
||
def icon(self):
|
||
"""Return the icon representing the current temperature."""
|
||
current_temperature = int(self._device.get_value("current_temperature", 0))
|
||
if not self._device.available:
|
||
thermometer = "off"
|
||
elif current_temperature >= 22:
|
||
thermometer = "high"
|
||
elif current_temperature >= 18:
|
||
thermometer = "low"
|
||
else:
|
||
thermometer = "alert"
|
||
return f"mdi:thermometer-{thermometer}"
|
||
|
||
|
||
class TargetTemperatureSensor(HiloEntity, SensorEntity):
|
||
"""Define a Hilo target temperature sensor entity."""
|
||
|
||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||
|
||
def __init__(self, hilo, device):
|
||
"""Hilo Target Temperature sensor initialization."""
|
||
self._attr_name = f"{device.name} Target Temperature"
|
||
super().__init__(hilo, name=self._attr_name, device=device)
|
||
old_unique_id = f"{slugify(device.name)}-target-temperature"
|
||
self._attr_unique_id = f"{device.identifier.lower()}-target-temperature"
|
||
hilo.async_migrate_unique_id(
|
||
old_unique_id, self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
hilo.async_migrate_unique_id(
|
||
f"{slugify(device.identifier)}-target-temperature",
|
||
self._attr_unique_id,
|
||
Platform.SENSOR,
|
||
)
|
||
LOG.debug("Setting up TargetTemperatureSensor entity: %s", self._attr_name)
|
||
|
||
@property
|
||
def state(self):
|
||
"""Return the target temperature."""
|
||
return str(float(self._device.get_value("target_temperature", 0)))
|
||
|
||
@property
|
||
def icon(self):
|
||
"""Return the icon representing the target temperature."""
|
||
target_temperature = int(self._device.get_value("target_temperature", 0))
|
||
if not self._device.available:
|
||
thermometer = "off"
|
||
elif target_temperature >= 22:
|
||
thermometer = "high"
|
||
elif target_temperature >= 18:
|
||
thermometer = "low"
|
||
else:
|
||
thermometer = "alert"
|
||
return f"mdi:thermometer-{thermometer}"
|
||
|
||
|
||
class WifiStrengthSensor(HiloEntity, SensorEntity):
|
||
"""Define a Wi-Fi strength sensor entity."""
|
||
|
||
_attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH
|
||
_attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT
|
||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||
|
||
def __init__(self, hilo, device):
|
||
"""Hilo Wi-Fi strength sensor initialization."""
|
||
self._attr_name = f"{device.name} WifiStrength"
|
||
super().__init__(hilo, name=self._attr_name, device=device)
|
||
old_unique_id = f"{slugify(device.name)}-wifistrength"
|
||
self._attr_unique_id = f"{device.identifier.lower()}-wifistrength"
|
||
hilo.async_migrate_unique_id(
|
||
old_unique_id, self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
LOG.debug("Setting up WifiStrengthSensor entity: %s", self._attr_name)
|
||
|
||
@property
|
||
def state(self):
|
||
"""Return the Wi-Fi signal strength."""
|
||
return process_wifi(self._device.get_value("wifi_status", 0))
|
||
|
||
@property
|
||
def icon(self):
|
||
"""Return the icon representing the Wi-Fi strength."""
|
||
if not self._device.available or self._device.get_value("wifi_status", 0) == 0:
|
||
return "mdi:wifi-strength-off"
|
||
return f"mdi:wifi-strength-{WIFI_STRENGTH[self.state]}"
|
||
|
||
@property
|
||
def extra_state_attributes(self):
|
||
"""Return the Wi-Fi signal strength."""
|
||
return {"wifi_signal": self._device.get_value("wifi_status", 0)}
|
||
|
||
|
||
class HiloNotificationSensor(HiloEntity, RestoreEntity, SensorEntity):
|
||
"""Hilo Notification sensor.
|
||
|
||
Its state will be the number of notification waiting in the Hilo app.
|
||
Notifications only used for OneLink's alerts & Low-battery warnings.
|
||
We should consider having this sensor enabled only if a smoke detector is in use.
|
||
"""
|
||
|
||
def __init__(self, hilo, device, scan_interval):
|
||
"""Hilo Notification sensor initialization."""
|
||
self._attr_name = "Notifications Hilo"
|
||
super().__init__(hilo, name=self._attr_name, device=device)
|
||
old_unique_id = slugify(self._attr_name)
|
||
self._attr_unique_id = f"{device.identifier.lower()}-{slugify(self._attr_name)}"
|
||
hilo.async_migrate_unique_id(
|
||
old_unique_id, self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
hilo.async_migrate_unique_id(
|
||
f"{slugify(device.identifier)}-{slugify(self._attr_name)}",
|
||
self._attr_unique_id,
|
||
Platform.SENSOR,
|
||
)
|
||
LOG.debug("Setting up NotificationSensor entity: %s", self._attr_name)
|
||
self.scan_interval = timedelta(seconds=NOTIFICATION_SCAN_INTERVAL)
|
||
self._state = 0
|
||
self._notifications = []
|
||
self.async_update = Throttle(self.scan_interval)(self._async_update)
|
||
|
||
@property
|
||
def state(self):
|
||
"""Return the number of notifications."""
|
||
try:
|
||
return int(self._state)
|
||
except ValueError:
|
||
return 0
|
||
|
||
@property
|
||
def icon(self):
|
||
"""Return the icon based on the notification state."""
|
||
if not self._device.available:
|
||
return "mdi:lan-disconnect"
|
||
if self.state > 0:
|
||
return "mdi:bell-alert"
|
||
return "mdi:bell-outline"
|
||
|
||
@property
|
||
def should_poll(self):
|
||
"""Enable polling."""
|
||
return True
|
||
|
||
@property
|
||
def extra_state_attributes(self):
|
||
"""Return the notifications."""
|
||
return {"notifications": self._notifications}
|
||
|
||
async def async_added_to_hass(self):
|
||
"""Handle entity about to be added to hass event."""
|
||
await super().async_added_to_hass()
|
||
last_state = await self.async_get_last_state()
|
||
if last_state:
|
||
self._last_update = dt_util.utcnow()
|
||
self._state = last_state.state
|
||
|
||
async def _async_update(self):
|
||
self._notifications = []
|
||
for notification in await self._hilo._api.get_event_notifications(
|
||
self._hilo.devices.location_id
|
||
):
|
||
if notification.get("viewed"):
|
||
continue
|
||
self._notifications.append(
|
||
{
|
||
"type_id": notification.get("eventTypeId"),
|
||
"event_id": notification.get("eventId"),
|
||
"device_id": notification.get("deviceId"),
|
||
"date": from_utc_timestamp(notification.get("notificationDateUTC")),
|
||
"title": notification.get("notificationTitle"),
|
||
"body": notification.get("notificationBody"),
|
||
}
|
||
)
|
||
self._state = len(self._notifications)
|
||
|
||
|
||
class HiloRewardSensor(HiloEntity, RestoreEntity, SensorEntity):
|
||
"""Hilo Reward sensor.
|
||
|
||
Its state will be either 0 or the total amount rewarded this season.
|
||
"""
|
||
|
||
_attr_device_class = SensorDeviceClass.MONETARY
|
||
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||
_entity_component_unrecorded_attributes = frozenset({"history"})
|
||
|
||
def __init__(self, hilo, device, scan_interval):
|
||
"""Hilo Reward sensor initialization."""
|
||
self._attr_name = "Recompenses Hilo"
|
||
|
||
# Check if currency is configured, set a default if not
|
||
currency = hilo._hass.config.currency
|
||
if currency:
|
||
self._attr_native_unit_of_measurement = currency
|
||
else:
|
||
self._attr_native_unit_of_measurement = "CAD"
|
||
|
||
super().__init__(hilo, name=self._attr_name, device=device)
|
||
old_unique_id = slugify(self._attr_name)
|
||
self._attr_unique_id = f"{device.identifier.lower()}-{slugify(self._attr_name)}"
|
||
hilo.async_migrate_unique_id(
|
||
old_unique_id, self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
hilo.async_migrate_unique_id(
|
||
f"{slugify(device.identifier)}-{slugify(self._attr_name)}",
|
||
self._attr_unique_id,
|
||
Platform.SENSOR,
|
||
)
|
||
LOG.debug("Setting up RewardSensor entity: %s", self._attr_name)
|
||
self._history_state_yaml: str = "hilo_eventhistory_state.yaml"
|
||
self.scan_interval = timedelta(seconds=REWARD_SCAN_INTERVAL)
|
||
self._state = 0
|
||
self._history = []
|
||
self._events_to_poll = dict()
|
||
self.async_update = Throttle(self.scan_interval)(self._async_update)
|
||
hilo.register_signalr_listener(self)
|
||
|
||
# When we update the list of reward history, we can end up making
|
||
# hundreds of calls to _save_history in a very short amount of time.
|
||
# With a debouncer, we can reduce this to a single save, which is more
|
||
# efficient (save can become pretty slow when the history is long) and
|
||
# makes sure Home Assistant is not slowed down. Some websocket could
|
||
# even lose connection due to the delay introduced by saving many times.
|
||
LOG.debug("Setting up debouncer for history saver")
|
||
self._save_history_debouncer = Debouncer(
|
||
hilo._hass,
|
||
LOG,
|
||
cooldown=5, # Wait for 5 seconds before writing to file
|
||
immediate=False,
|
||
function=self._save_history,
|
||
)
|
||
|
||
@property
|
||
def state(self):
|
||
"""Return the total reward amount for the current season."""
|
||
return self._state
|
||
|
||
@property
|
||
def icon(self):
|
||
"""Set the icon based on the current state."""
|
||
if not self._device.available:
|
||
return "mdi:lan-disconnect"
|
||
return "mdi:cash-plus"
|
||
|
||
@property
|
||
def should_poll(self):
|
||
"""Enable polling."""
|
||
return True
|
||
|
||
@property
|
||
def extra_state_attributes(self):
|
||
"""Return the history attributes."""
|
||
return {"history": self._history}
|
||
|
||
async def async_added_to_hass(self):
|
||
"""Handle entity about to be added to hass event."""
|
||
await super().async_added_to_hass()
|
||
last_state = await self.async_get_last_state()
|
||
if last_state:
|
||
self._last_update = dt_util.utcnow()
|
||
self._state = last_state.state
|
||
cached = await self._load_history()
|
||
if cached:
|
||
self._history = cached
|
||
else:
|
||
await self._async_update()
|
||
|
||
async def handle_challenge_details_update(self, challenge):
|
||
"""Handle challenge details update from websocket."""
|
||
challenge = challenge[0] if isinstance(challenge, list) else challenge
|
||
LOG.debug("UPDATING challenge in reward: %s", challenge)
|
||
|
||
# We're getting events but didn't request any, do not process them
|
||
if len(self._events_to_poll.items()) == 0:
|
||
return
|
||
|
||
# Only process events that contain an id and phases
|
||
if challenge.get("id") is None or challenge.get("phases") is None:
|
||
return
|
||
|
||
# Skip messages about upcoming events since they don't contain useful info about rewards
|
||
report = challenge.get("report")
|
||
if report and report.get("status") == "Upcoming":
|
||
LOG.debug(
|
||
"Skipping upcoming challenge event in reward: %s", challenge.get("id")
|
||
)
|
||
return
|
||
|
||
event = Event(**challenge).as_dict()
|
||
corresponding_season = self._events_to_poll[event["event_id"]]
|
||
del self._events_to_poll[event["event_id"]]
|
||
|
||
for season in self._history:
|
||
if season.get("season") == corresponding_season:
|
||
for i, season_event in enumerate(season["events"]):
|
||
if season_event["event_id"] == event["event_id"]:
|
||
LOG.debug(
|
||
"ChallengeId matched, replacing: %s", event["event_id"]
|
||
)
|
||
|
||
# Some events from the websocket don't contain reward info. Copying it from history (API) if it's there
|
||
event["reward"] = season_event.get("reward", 0.0)
|
||
season["events"][i] = event # On update
|
||
season["events"] = [
|
||
item
|
||
for item in sorted(
|
||
season["events"], key=lambda x: int(x["event_id"])
|
||
)
|
||
]
|
||
await self._save_history_debouncer.async_call()
|
||
return
|
||
LOG.debug("ChallengeId did not match, appending: %s", event["event_id"])
|
||
season["events"].append(event)
|
||
season["events"] = [
|
||
item
|
||
for item in sorted(
|
||
season["events"], key=lambda x: int(x["event_id"])
|
||
)
|
||
]
|
||
|
||
await self._save_history_debouncer.async_call()
|
||
|
||
async def _async_update(self):
|
||
seasons = await self._hilo._api.get_seasons(self._hilo.devices.location_id)
|
||
self._events_to_poll = dict()
|
||
seasons = sorted(seasons, key=lambda x: x["season"], reverse=True)
|
||
|
||
# Re-add the totalReward that was present in the legacy API
|
||
for season_data in seasons:
|
||
total = sum(
|
||
event["reward"]
|
||
for event in season_data["events"]
|
||
if "reward" in event and not event.get("isPreseasonEvent")
|
||
)
|
||
season_data["totalReward"] = total
|
||
|
||
if seasons:
|
||
current_history = await self._load_history()
|
||
new_history = []
|
||
|
||
for idx, season in enumerate(seasons):
|
||
current_history_season = next(
|
||
(
|
||
item
|
||
for item in current_history
|
||
if item.get("season") == season.get("season")
|
||
),
|
||
None,
|
||
)
|
||
|
||
if idx == 0:
|
||
self._state = season.get("totalReward", 0)
|
||
events = []
|
||
for raw_event in season.get("events", []):
|
||
current_history_event = None
|
||
event = None
|
||
|
||
if current_history_season:
|
||
current_history_event = next(
|
||
(
|
||
ev
|
||
for ev in current_history_season["events"]
|
||
if ev["event_id"] == raw_event["id"]
|
||
),
|
||
None,
|
||
)
|
||
|
||
start_date_utc = datetime.fromisoformat(raw_event["startDateUtc"])
|
||
event_age = datetime.now(timezone.utc) - start_date_utc
|
||
if (
|
||
current_history_event
|
||
and current_history_event.get("state") == "off"
|
||
and event_age > timedelta(days=1)
|
||
):
|
||
# No point updating events for previously completed events, they won't change.
|
||
event = current_history_event
|
||
else:
|
||
# Save the event to poll in a dict so that we can easily lookup the season when the websocket event comes in
|
||
self._events_to_poll[raw_event["id"]] = season.get("season")
|
||
event = Event(**raw_event).as_dict()
|
||
|
||
# details = await self._hilo.get_event_details(raw_event["id"])
|
||
# event = Event(**details).as_dict()
|
||
|
||
if event:
|
||
events.append(event)
|
||
|
||
season["events"] = events
|
||
new_history.append(season)
|
||
|
||
self._history = new_history
|
||
await self._save_history_debouncer.async_call()
|
||
for eventId in self._events_to_poll:
|
||
await self._hilo.subscribe_to_challenge(eventId)
|
||
|
||
async def _load_history(self) -> list:
|
||
history: list = []
|
||
if isfile(self._history_state_yaml):
|
||
async with aiofiles.open(self._history_state_yaml, mode="r") as yaml_file:
|
||
LOG.debug("Loading history state from yaml")
|
||
content = await yaml_file.read()
|
||
try:
|
||
history = await asyncio.get_running_loop().run_in_executor(
|
||
None, yaml.safe_load, content
|
||
)
|
||
except ScannerError:
|
||
LOG.error("History state YAML is corrupted, resetting to default.")
|
||
if not history or not isinstance(history, list):
|
||
LOG.error("History state YAML is invalid, resetting to default.")
|
||
history = []
|
||
|
||
return history
|
||
|
||
async def _save_history(self):
|
||
async with aiofiles.open(self._history_state_yaml, mode="w") as yaml_file:
|
||
LOG.debug("Saving history state to yaml file")
|
||
content = await asyncio.get_running_loop().run_in_executor(
|
||
None, yaml.dump, self._history
|
||
)
|
||
await yaml_file.write(content)
|
||
|
||
|
||
class HiloChallengeSensor(HiloEntity, SensorEntity):
|
||
"""Hilo challenge sensor.
|
||
|
||
Its state will be either:
|
||
- off: no ongoing or scheduled challenge
|
||
- scheduled: A challenge is scheduled, details in the next_events
|
||
extra attribute
|
||
- pre_cold: optional phase to cool further before appreciation
|
||
- appreciation: optional phase to pre-heat more before challenge
|
||
- pre_heat: Currently in the pre-heat phase
|
||
- reduction or on: Challenge is currently active, heat is lowered
|
||
- recovery: Challenge is completed, we're reheating.
|
||
"""
|
||
|
||
def __init__(self, hilo, device, scan_interval):
|
||
"""Hilo Challenge sensor initialization."""
|
||
self._attr_name = "Defi Hilo"
|
||
self._attr_device_class = SensorDeviceClass.ENUM
|
||
self._attr_options = [
|
||
"off",
|
||
"scheduled",
|
||
"pre_cold",
|
||
"appreciation",
|
||
"pre_heat",
|
||
"reduction",
|
||
"recovery",
|
||
"completed",
|
||
"unknown",
|
||
]
|
||
super().__init__(hilo, name=self._attr_name, device=device)
|
||
old_unique_id = slugify(self._attr_name)
|
||
self._attr_unique_id = f"{device.identifier.lower()}-{slugify(self._attr_name)}"
|
||
hilo.async_migrate_unique_id(
|
||
old_unique_id, self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
hilo.async_migrate_unique_id(
|
||
f"{slugify(device.identifier)}-{slugify(self._attr_name)}",
|
||
self._attr_unique_id,
|
||
Platform.SENSOR,
|
||
)
|
||
LOG.debug("Setting up ChallengeSensor entity: %s", self._attr_name)
|
||
self.scan_interval = timedelta(seconds=EVENT_SCAN_INTERVAL_REDUCTION)
|
||
self._state = "off"
|
||
self._next_events = []
|
||
self._events = {} # Store active events
|
||
self.async_update = Throttle(timedelta(seconds=MIN_SCAN_INTERVAL))(
|
||
self._async_update
|
||
)
|
||
hilo.register_signalr_listener(self)
|
||
|
||
async def handle_challenge_added(self, event_data):
|
||
"""Handle new challenge event."""
|
||
LOG.debug("handle_challenge_added running")
|
||
if event_data.get("progress") == "scheduled":
|
||
event_id = event_data.get("id")
|
||
if event_id:
|
||
event = Event(**event_data)
|
||
if self._hilo.appreciation > 0:
|
||
event.appreciation(self._hilo.appreciation)
|
||
if self._hilo.pre_cold > 0:
|
||
event.pre_cold(self._hilo.pre_cold)
|
||
self._events[event_id] = event
|
||
self._update_next_events()
|
||
|
||
async def handle_challenge_list_initial(self, challenges):
|
||
"""Handle initial challenge list."""
|
||
LOG.debug("handle_challenge_list_initial challenges: %s", challenges)
|
||
self._events.clear()
|
||
LOG.debug("handle_challenge_list_initial events: %s", self._events)
|
||
for challenge in challenges:
|
||
event_id = challenge.get("id")
|
||
progress = challenge.get("progress")
|
||
LOG.debug("handle_challenge_list_initial progress is %s", progress)
|
||
if challenge.get("progress") in ["scheduled", "inProgress"]:
|
||
event_id = challenge.get("id")
|
||
if event_id:
|
||
event = Event(**challenge)
|
||
if self._hilo.appreciation > 0:
|
||
event.appreciation(self._hilo.appreciation)
|
||
if self._hilo.pre_cold > 0:
|
||
event.pre_cold(self._hilo.pre_cold)
|
||
self._events[event_id] = event
|
||
self._update_next_events()
|
||
|
||
async def handle_challenge_list_update(self, challenges):
|
||
"""Handle challenge list updates."""
|
||
LOG.debug("handle_challenge_list_update is running")
|
||
for challenge in challenges:
|
||
event_id = challenge.get("id")
|
||
progress = challenge.get("progress")
|
||
baselinewH = challenge.get("baselineWh")
|
||
LOG.debug("handle_challenge_list_update progress is %s", progress)
|
||
LOG.debug("handle_challenge_list_update baselineWh is %s", baselinewH)
|
||
if event_id in self._events:
|
||
if challenge.get("progress") == "completed":
|
||
# Find the oldest event based on recovery_end datetime
|
||
oldest_event_id = min(
|
||
self._events.keys(),
|
||
key=lambda key: (
|
||
self._events[key]
|
||
.as_dict()
|
||
.get("phases", {})
|
||
.get("recovery_end", "")
|
||
),
|
||
)
|
||
await asyncio.sleep(300)
|
||
del self._events[oldest_event_id]
|
||
break
|
||
else:
|
||
current_event = self._events[event_id]
|
||
LOG.debug(
|
||
"handle_challenge_list_update current event is: %s",
|
||
current_event,
|
||
)
|
||
updated_event = Event(**{**current_event.as_dict(), **challenge})
|
||
if self._hilo.appreciation > 0:
|
||
updated_event.appreciation(self._hilo.appreciation)
|
||
if self._hilo.pre_cold > 0:
|
||
updated_event.pre_cold(self._hilo.pre_cold)
|
||
self._events[event_id] = updated_event
|
||
self._update_next_events()
|
||
|
||
async def handle_challenge_details_update(self, challenge):
|
||
"""Handle challenge detail updates."""
|
||
LOG.debug("handle_challenge_details_update challenge is %s", challenge)
|
||
challenge = challenge[0] if isinstance(challenge, list) else challenge
|
||
event_id = challenge.get("id")
|
||
event_has_id = event_id is not None
|
||
|
||
# In case we get a consumption update (there is no event id),
|
||
# get the event id of the next event so that we can update it
|
||
if event_id is None and len(self._next_events) > 0:
|
||
event_id = self._next_events[0]["event_id"]
|
||
|
||
progress = challenge.get("progress", "unknown")
|
||
|
||
baseline_points = challenge.get("cumulativeBaselinePoints", [])
|
||
|
||
if not baseline_points:
|
||
consumption = challenge.get("consumption", {})
|
||
baseline_points = consumption.get("cumulativeBaselinePoints", [])
|
||
if baseline_points:
|
||
baselinewH = baseline_points[-1]["wh"]
|
||
else:
|
||
baselinewH = 0
|
||
allowed_kwh = baselinewH / 1000 if baselinewH > 0 else 0
|
||
|
||
used_wH = challenge.get("currentWh", 0)
|
||
if used_wH is not None and used_wH > 0:
|
||
used_kWh = used_wH / 1000
|
||
else:
|
||
used_kWh = 0
|
||
|
||
LOG.debug("handle_challenge_details_update progress is %s", progress)
|
||
LOG.debug("handle_challenge_details_update baselineWh is %s", baselinewH)
|
||
LOG.debug("handle_challenge_details_update used_kwh is %s", used_kWh)
|
||
LOG.debug("handle_challenge_details_update allowed_kwh is %s", allowed_kwh)
|
||
|
||
if event_id in self._events:
|
||
if challenge.get("progress") == "completed":
|
||
# ajout d'un asyncio sleep ici pour avoir l'état completed avant le retrait du challenge
|
||
await asyncio.sleep(300)
|
||
self._events.pop(event_id, None)
|
||
|
||
# Consumption update
|
||
elif used_wH is not None and used_wH > 0:
|
||
current_event = self._events[event_id]
|
||
current_event.update_wh(used_wH)
|
||
if baselinewH > 0:
|
||
current_event.update_allowed_wh(baselinewH)
|
||
# For non consumption updates, we need an event id
|
||
elif event_has_id:
|
||
current_event = self._events[event_id]
|
||
updated_event = Event(**{**current_event.as_dict(), **challenge})
|
||
if self._hilo.appreciation > 0:
|
||
updated_event.appreciation(self._hilo.appreciation)
|
||
if self._hilo.pre_cold > 0:
|
||
updated_event.pre_cold(self._hilo.pre_cold)
|
||
if baselinewH > 0:
|
||
updated_event.update_allowed_wh(baselinewH)
|
||
elif current_event.allowed_kWh > 0:
|
||
updated_event.allowed_kWh = current_event.allowed_kWh
|
||
self._events[event_id] = updated_event
|
||
self._update_next_events()
|
||
|
||
def _update_next_events(self):
|
||
"""Update the next_events list based on current events."""
|
||
LOG.debug("_update_next_events sorting events")
|
||
# Sort events by start time
|
||
sorted_events = sorted(self._events.values(), key=lambda x: x.preheat_start)
|
||
|
||
self._next_events = [event.as_dict() for event in sorted_events]
|
||
|
||
# Force an update of the entity
|
||
self.async_write_ha_state()
|
||
|
||
@property
|
||
def state(self):
|
||
"""Return the current state based on next events."""
|
||
if len(self._next_events) > 0:
|
||
event = Event(**{**{"id": 0}, **self._next_events[0]})
|
||
return event.state
|
||
return "off"
|
||
|
||
@property
|
||
def icon(self):
|
||
"""Set the icon based on the current state."""
|
||
if not self._device.available:
|
||
return "mdi:lan-disconnect"
|
||
if self.state == "appreciation":
|
||
return "mdi:glass-cocktail"
|
||
if self.state == "off":
|
||
return "mdi:lightning-bolt"
|
||
if self.state == "scheduled":
|
||
return "mdi:progress-clock"
|
||
if self.state == "pre_heat":
|
||
return "mdi:radiator"
|
||
if self.state in ["reduction", "on"]:
|
||
return "mdi:power-plug-off"
|
||
if self.state == "recovery":
|
||
return "mdi:calendar-check"
|
||
if self.state == "pre_cold":
|
||
return "mdi:radiator-off"
|
||
return "mdi:battery-alert"
|
||
|
||
@property
|
||
def should_poll(self):
|
||
"""Don't poll with websockets. Poll to update allowed_wh in pre_heat phrase and consumption in reduction phase."""
|
||
return self.state in ["recovery", "reduction", "pre_heat"]
|
||
|
||
@property
|
||
def extra_state_attributes(self):
|
||
"""Return the next events attribute."""
|
||
return {"next_events": self._next_events}
|
||
|
||
async def async_added_to_hass(self):
|
||
"""Handle entity about to be added to hass event."""
|
||
await super().async_added_to_hass()
|
||
|
||
await self._hilo.subscribe_to_challengelist()
|
||
|
||
async def _async_update(self):
|
||
"""Update fallback, but not needed with websockets."""
|
||
for event_id in self._events:
|
||
event = self._events.get(event_id)
|
||
if event.should_check_for_allowed_wh():
|
||
LOG.debug("ASYNC UPDATE SUB: EVENT: %s", event_id)
|
||
await self._hilo.subscribe_to_challenge(event_id)
|
||
await self._hilo.request_challenge_consumption_update(event_id)
|
||
elif self.state == "reduction":
|
||
LOG.debug("ASYNC UPDATE: EVENT: %s", event_id)
|
||
await self._hilo.request_challenge_consumption_update(event_id)
|
||
|
||
|
||
class DeviceSensor(HiloEntity, SensorEntity):
|
||
"""Simple device entity.
|
||
|
||
Devices like the gateway or Smoke Detectors don't have many attributes,
|
||
except for the "disconnected" attribute. These entities are monitoring
|
||
this state.
|
||
"""
|
||
|
||
def __init__(self, hilo, device):
|
||
"""Initialize."""
|
||
self._attr_name = device.name
|
||
super().__init__(hilo, name=self._attr_name, device=device)
|
||
old_unique_id = slugify(device.name)
|
||
self._attr_unique_id = f"{device.identifier.lower()}-{slugify(device.name)}"
|
||
hilo.async_migrate_unique_id(
|
||
old_unique_id, self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
hilo.async_migrate_unique_id(
|
||
f"{slugify(device.identifier)}-{slugify(device.name)}",
|
||
self._attr_unique_id,
|
||
Platform.SENSOR,
|
||
)
|
||
LOG.debug("Setting up DeviceSensor entity: %s", self._attr_name)
|
||
|
||
@property
|
||
def state(self):
|
||
"""Return the connection state."""
|
||
return "on" if self._device.available else "off"
|
||
|
||
@property
|
||
def extra_state_attributes(self):
|
||
"""Return the extra state attributes."""
|
||
return {k: self._device.get_value(k) for k in self._device.attributes}
|
||
|
||
@property
|
||
def icon(self):
|
||
"""Set the icon based on the connection state."""
|
||
if not self._device.available:
|
||
return "mdi:lan-disconnect"
|
||
if self.state == "off":
|
||
return "mdi:access-point-network-off"
|
||
return "mdi:access-point-network"
|
||
|
||
|
||
class HiloCostSensor(HiloEntity, SensorEntity):
|
||
"""This sensor generates cost entities."""
|
||
|
||
_attr_device_class = SensorDeviceClass.MONETARY
|
||
_attr_native_unit_of_measurement = (
|
||
f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}"
|
||
)
|
||
_attr_state_class = SensorStateClass.TOTAL
|
||
_attr_icon = "mdi:cash"
|
||
|
||
def __init__(self, hilo, name, plan_name, amount=0):
|
||
"""Initialize."""
|
||
for d in hilo.devices.all:
|
||
if d.type == "Gateway":
|
||
device = d
|
||
if "low_threshold" in name:
|
||
self._attr_device_class = SensorDeviceClass.ENERGY
|
||
self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
|
||
elif "access" in name.lower():
|
||
# Access fee is a fixed daily cost, not per kWh
|
||
self._attr_native_unit_of_measurement = f"{CURRENCY_DOLLAR}/day"
|
||
self.data = None
|
||
self._attr_name = name
|
||
self.plan_name = plan_name
|
||
self._last_update = dt_util.utcnow()
|
||
self._cost = amount
|
||
old_unique_id = slugify(self._attr_name)
|
||
self._attr_unique_id = f"{device.identifier.lower()}-{slugify(self._attr_name)}"
|
||
hilo.async_migrate_unique_id(
|
||
old_unique_id, self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
hilo.async_migrate_unique_id(
|
||
f"{slugify(device.identifier)}-{slugify(self._attr_name)}",
|
||
self._attr_unique_id,
|
||
Platform.SENSOR,
|
||
)
|
||
self._last_update = dt_util.utcnow()
|
||
super().__init__(hilo, name=self._attr_name, device=device)
|
||
LOG.info(
|
||
"Initializing energy cost sensor %s %s Amount: %s", name, plan_name, amount
|
||
)
|
||
|
||
def _handle_state_change(self, event):
|
||
LOG.debug("_handle_state_change() %s | %s ", self, self._last_update)
|
||
if (state := event.data.get("new_state")) is None:
|
||
return
|
||
|
||
now = dt_util.utcnow()
|
||
try:
|
||
if (
|
||
state.attributes.get("hilo_update")
|
||
and self._last_update + timedelta(seconds=30) < now
|
||
):
|
||
LOG.debug(
|
||
"Setting new state %s state=%s state.attributes=%s",
|
||
state.state,
|
||
state,
|
||
state.attributes,
|
||
)
|
||
self._cost = state.state
|
||
self._last_update = now
|
||
except ValueError:
|
||
LOG.error(
|
||
"Invalidate state received for %s: %s", self._attr_unique_id, state
|
||
)
|
||
|
||
@property
|
||
def state(self):
|
||
"""Return the cost."""
|
||
return self._cost
|
||
|
||
@property
|
||
def should_poll(self) -> bool:
|
||
"""Disable polling."""
|
||
return False
|
||
|
||
@property
|
||
def extra_state_attributes(self):
|
||
"""Return the cost sensor attributes."""
|
||
return {
|
||
"Cost": self._cost,
|
||
"Plan": self.plan_name,
|
||
"last_update": self._last_update,
|
||
}
|
||
|
||
async def async_added_to_hass(self):
|
||
"""Handle entity about to be added to hass event."""
|
||
await super().async_added_to_hass()
|
||
|
||
async def async_update(self):
|
||
"""Update the state."""
|
||
self._last_update = dt_util.utcnow()
|
||
|
||
|
||
class HiloCostTotalSensor(HiloEntity, SensorEntity):
|
||
"""Sensor that totals all electricity costs including access fee.
|
||
|
||
Calculates: (low_kwh × low_rate) + (medium_kwh × medium_rate)
|
||
+ (high_kwh × high_rate) + access_fee_prorated_today
|
||
|
||
All values in dollars. Updates on poll via should_poll + scan_interval.
|
||
"""
|
||
|
||
_attr_device_class = SensorDeviceClass.MONETARY
|
||
_attr_state_class = SensorStateClass.TOTAL
|
||
_attr_icon = "mdi:cash-register"
|
||
|
||
def __init__(self, hilo, name, plan_name, tariff_config, energy_meter_period):
|
||
"""Initialize."""
|
||
for d in hilo.devices.all:
|
||
if d.type == "Gateway":
|
||
device = d
|
||
# Check if currency is configured, set a default if not
|
||
currency = hilo._hass.config.currency
|
||
if currency:
|
||
self._attr_native_unit_of_measurement = currency
|
||
else:
|
||
self._attr_native_unit_of_measurement = "CAD"
|
||
self._attr_name = name
|
||
self.plan_name = plan_name
|
||
self._tariff_config = tariff_config
|
||
self._access_rate = tariff_config.get("access", 0)
|
||
self._last_update = dt_util.utcnow()
|
||
old_unique_id = slugify(self._attr_name)
|
||
self._attr_unique_id = (
|
||
f"{slugify(device.identifier)}-{slugify(self._attr_name)}"
|
||
)
|
||
hilo.async_migrate_unique_id(
|
||
old_unique_id, self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
super().__init__(hilo, name=self._attr_name, device=device)
|
||
LOG.info(
|
||
"Initializing total cost sensor %s %s access_rate: %s",
|
||
name,
|
||
plan_name,
|
||
self._access_rate,
|
||
)
|
||
|
||
def _update_callback(self):
|
||
"""Gateway value updates don't need to trigger a cost refresh."""
|
||
return
|
||
|
||
@property
|
||
def state(self):
|
||
"""Return the total cost in dollars."""
|
||
total = 0.0
|
||
for tarif in ["low", "medium", "high"]:
|
||
rate = self._tariff_config.get(tarif, 0)
|
||
if rate <= 0:
|
||
continue
|
||
energy_entity = f"sensor.{HILO_ENERGY_TOTAL}_{tarif}"
|
||
energy_state = self._hilo._hass.states.get(energy_entity)
|
||
if energy_state is None or energy_state.state in ("unknown", "unavailable"):
|
||
continue
|
||
try:
|
||
total += float(energy_state.state) * rate
|
||
except (ValueError, TypeError):
|
||
LOG.debug(
|
||
"Could not parse energy state for %s: %s",
|
||
energy_entity,
|
||
energy_state.state,
|
||
)
|
||
# Access fee: prorated based on time elapsed today
|
||
now = dt_util.now()
|
||
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
seconds_today = (now - midnight).total_seconds()
|
||
total += self._access_rate * (seconds_today / 86400)
|
||
return round(total, 2)
|
||
|
||
@property
|
||
def suggested_display_precision(self) -> int:
|
||
"""Return the suggested display precision."""
|
||
return 2
|
||
|
||
@property
|
||
def should_poll(self) -> bool:
|
||
"""Enable polling to update the cost periodically."""
|
||
return True
|
||
|
||
@property
|
||
def extra_state_attributes(self):
|
||
"""Return the cost breakdown attributes."""
|
||
attrs = {
|
||
"Plan": self.plan_name,
|
||
"Access Rate ($/day)": self._access_rate,
|
||
"last_update": self._last_update,
|
||
}
|
||
for tarif in ["low", "medium", "high"]:
|
||
rate = self._tariff_config.get(tarif, 0)
|
||
if rate > 0:
|
||
energy_entity = f"sensor.{HILO_ENERGY_TOTAL}_{tarif}"
|
||
energy_state = self._hilo._hass.states.get(energy_entity)
|
||
kwh = 0.0
|
||
if energy_state is not None and energy_state.state not in (
|
||
"unknown",
|
||
"unavailable",
|
||
):
|
||
try:
|
||
kwh = float(energy_state.state)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
attrs[f"{tarif}_kwh"] = round(kwh, 3)
|
||
attrs[f"{tarif}_rate"] = rate
|
||
attrs[f"{tarif}_cost"] = round(kwh * rate, 2)
|
||
now = dt_util.now()
|
||
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
seconds_today = (now - midnight).total_seconds()
|
||
attrs["access_cost_today"] = round(
|
||
self._access_rate * (seconds_today / 86400), 4
|
||
)
|
||
return attrs
|
||
|
||
async def async_added_to_hass(self):
|
||
"""Handle entity about to be added to hass event."""
|
||
await super().async_added_to_hass()
|
||
|
||
async def async_update(self):
|
||
"""Update the state."""
|
||
self._last_update = dt_util.utcnow()
|
||
|
||
|
||
class HiloOutdoorTempSensor(HiloEntity, SensorEntity):
|
||
"""Hilo outdoor temperature sensor.
|
||
|
||
Its state will be the current outdoor weather as reported by the Hilo App
|
||
"""
|
||
|
||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||
|
||
def __init__(self, hilo, device, scan_interval):
|
||
"""Initialize."""
|
||
self._attr_name = "Outdoor Weather Hilo"
|
||
super().__init__(hilo, name=self._attr_name, device=device)
|
||
old_unique_id = slugify(self._attr_name)
|
||
self._attr_unique_id = f"{device.identifier.lower()}-{slugify(self._attr_name)}"
|
||
hilo.async_migrate_unique_id(
|
||
old_unique_id, self._attr_unique_id, Platform.SENSOR
|
||
)
|
||
hilo.async_migrate_unique_id(
|
||
f"{slugify(device.identifier)}-{slugify(self._attr_name)}",
|
||
self._attr_unique_id,
|
||
Platform.SENSOR,
|
||
)
|
||
LOG.debug("Setting up OutdoorWeatherSensor entity: %s", self._attr_name)
|
||
self.scan_interval = timedelta(seconds=WEATHER_SCAN_INTERVAL)
|
||
self._state = STATE_UNKNOWN
|
||
self._weather = {}
|
||
self.async_update = Throttle(self.scan_interval)(self._async_update)
|
||
|
||
@property
|
||
def state(self):
|
||
"""Return the current outdoor temperature."""
|
||
try:
|
||
return int(self._state)
|
||
except ValueError:
|
||
return STATE_UNKNOWN
|
||
|
||
@property
|
||
def icon(self):
|
||
"""Set the icon based on weather condition."""
|
||
condition = self._weather.get("condition", "").lower()
|
||
LOG.debug("Current condition: %s", condition)
|
||
if not condition:
|
||
return "mdi:lan-disconnect"
|
||
return WEATHER_CONDITIONS.get(self._weather.get("condition", "Unknown"))
|
||
|
||
@property
|
||
def should_poll(self):
|
||
"""Poll needed to update weather data."""
|
||
return True
|
||
|
||
@property
|
||
def extra_state_attributes(self):
|
||
"""Add weather attributes."""
|
||
LOG.debug("Adding weather %s", self._weather)
|
||
return {
|
||
key: self._weather[key]
|
||
for key in self._weather
|
||
if key not in ["temperature", "icon"]
|
||
}
|
||
|
||
def _update_callback(self):
|
||
"""Gateway value updates don't need to trigger a weather refresh."""
|
||
return
|
||
|
||
async def async_added_to_hass(self):
|
||
"""Handle entity about to be added to hass event."""
|
||
await super().async_added_to_hass()
|
||
|
||
async def _async_update(self):
|
||
self._weather = {}
|
||
self._weather = await self._hilo._api.get_weather(
|
||
self._hilo.devices.location_id
|
||
)
|
||
self._state = self._weather.get("temperature")
|