Initial Commit

This commit is contained in:
2026-06-11 11:50:50 -04:00
commit d4a69c41be
2748 changed files with 80489 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
"""Support for Hilo Climate entities."""
from datetime import datetime, timedelta
from homeassistant.components.climate import ClimateEntity
from homeassistant.components.climate.const import (
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_TENTHS,
Platform,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify
from . import Hilo
from .const import CLIMATE_CLASSES, DOMAIN, LOG
from .entity import HiloEntity
def validate_reduction_phase(events, tag):
"""Validate if current time is within a challenge lock reduction phase."""
if not events:
return
current = events[0]
phases = current["phases"]
start = phases["reduction_start"]
end = phases["reduction_end"]
if (
start + timedelta(minutes=2)
< datetime.now(start.tzinfo)
< end - timedelta(minutes=2)
):
LOG.warning(
f"{tag} Attempt to set temperature was blocked because challenge lock is active"
)
# Raising an exception here will raise it up to the GUI
raise Exception("Challenge lock is active, unable to change temperature target")
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Hilo climate entities from a config entry."""
hilo = hass.data[DOMAIN][entry.entry_id]
entities = []
for d in hilo.devices.all:
if d.type in CLIMATE_CLASSES:
d._entity = HiloClimate(hilo, d)
entities.append(d._entity)
async_add_entities(entities)
return True
class HiloClimate(HiloEntity, ClimateEntity):
"""Representation of a Hilo Climate entity."""
_attr_hvac_modes = [HVACMode.HEAT]
_attr_temperature_unit: str = UnitOfTemperature.CELSIUS
_attr_precision: float = PRECISION_TENTHS
_attr_supported_features: int = ClimateEntityFeature.TARGET_TEMPERATURE
def __init__(self, hilo: Hilo, device):
"""Initialize the climate entity."""
super().__init__(hilo, device=device, name=device.name)
old_unique_id = f"{slugify(device.name)}-climate"
self._attr_unique_id = f"{device.identifier.lower()}-climate"
hilo.async_migrate_unique_id(
old_unique_id, self._attr_unique_id, Platform.CLIMATE
)
hilo.async_migrate_unique_id(
f"{slugify(device.identifier)}-climate",
self._attr_unique_id,
Platform.CLIMATE,
)
self.operations = [HVACMode.HEAT]
self._has_operation = False
self._temperature_entity = None
LOG.debug("Setting up Climate entity: %s", self._attr_name)
@property
def current_temperature(self):
"""Return the current temperature."""
return self._device.current_temperature
@property
def target_temperature(self):
"""Return the target temperature."""
return self._device.target_temperature
@property
def max_temp(self):
"""Return the maximum temperature."""
return self._device.max_temp
@property
def min_temp(self):
"""Return the minimum temperature."""
return self._device.min_temp
def set_hvac_mode(self, hvac_mode):
"""Set hvac mode."""
return
@property
def hvac_mode(self):
"""Return hvac mode."""
return HVACMode.HEAT
@property
def hvac_action(self):
"""Return the current hvac action."""
return self._device.hvac_action
@property
def icon(self):
"""Return the icon to use in the frontend, based on hvac_action."""
if self._device.hvac_action == HVACAction.HEATING:
return "mdi:radiator"
return "mdi:radiator-disabled"
async def async_set_temperature(self, **kwargs):
"""Set new target temperature."""
if ATTR_TEMPERATURE in kwargs:
if self._hilo.challenge_lock:
challenge = self._hilo._hass.states.get("sensor.defi_hilo")
validate_reduction_phase(
challenge.attributes.get("next_events", []), self._device._tag
)
LOG.info(
f"{self._device._tag} Setting temperature to {kwargs[ATTR_TEMPERATURE]}"
)
await self._device.set_attribute(
"target_temperature", kwargs[ATTR_TEMPERATURE]
)
+175
View File
@@ -0,0 +1,175 @@
"""Config flow to configure the Hilo component."""
from __future__ import annotations
import logging
from typing import Any
from awesomeversion import AwesomeVersion
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_SCAN_INTERVAL, __version__ as HAVERSION
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
import jwt
import voluptuous as vol
from .const import (
CONF_APPRECIATION_PHASE,
CONF_CHALLENGE_LOCK,
CONF_GENERATE_ENERGY_METERS,
CONF_HQ_PLAN_NAME,
CONF_LOG_TRACES,
CONF_PRE_COLD_PHASE,
CONF_TARIFF,
CONF_TRACK_UNKNOWN_SOURCES,
CONF_UNTARIFICATED_DEVICES,
DEFAULT_APPRECIATION_PHASE,
DEFAULT_CHALLENGE_LOCK,
DEFAULT_GENERATE_ENERGY_METERS,
DEFAULT_HQ_PLAN_NAME,
DEFAULT_LOG_TRACES,
DEFAULT_PRE_COLD_PHASE,
DEFAULT_TRACK_UNKNOWN_SOURCES,
DEFAULT_UNTARIFICATED_DEVICES,
DOMAIN,
LOG,
MIN_SCAN_INTERVAL,
)
from .oauth2 import AuthCodeWithPKCEImplementation
STEP_OPTION_SCHEMA = vol.Schema(
{
vol.Optional(
CONF_GENERATE_ENERGY_METERS, default=DEFAULT_GENERATE_ENERGY_METERS
): cv.boolean,
vol.Optional(
CONF_UNTARIFICATED_DEVICES,
default=DEFAULT_UNTARIFICATED_DEVICES,
): cv.boolean,
vol.Optional(
CONF_LOG_TRACES,
default=DEFAULT_LOG_TRACES,
): cv.boolean,
vol.Optional(
CONF_CHALLENGE_LOCK,
default=DEFAULT_CHALLENGE_LOCK,
): cv.boolean,
vol.Optional(
CONF_TRACK_UNKNOWN_SOURCES,
default=DEFAULT_TRACK_UNKNOWN_SOURCES,
): cv.boolean,
vol.Optional(
CONF_HQ_PLAN_NAME, default=DEFAULT_HQ_PLAN_NAME
): selector.SelectSelector(
selector.SelectSelectorConfig(options=list(CONF_TARIFF.keys()), mode="list")
),
vol.Optional(
CONF_APPRECIATION_PHASE,
default=DEFAULT_APPRECIATION_PHASE,
): cv.positive_int,
vol.Optional(
CONF_PRE_COLD_PHASE,
default=DEFAULT_PRE_COLD_PHASE,
): cv.positive_int,
vol.Optional(CONF_SCAN_INTERVAL): (
vol.All(cv.positive_int, vol.Range(min=MIN_SCAN_INTERVAL))
),
}
)
class HiloFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Handle a Hilo config flow."""
DOMAIN = DOMAIN
VERSION = 2
_reauth_entry: ConfigEntry | None = None
async def async_step_user(self, user_input=None) -> FlowResult:
"""Handle a flow initialized by the user."""
await self.async_set_unique_id(DOMAIN)
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
self.async_register_implementation(
self.hass,
AuthCodeWithPKCEImplementation(self.hass),
)
return await super().async_step_user(user_input)
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return LOG
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> HiloOptionsFlowHandler:
"""Define the config flow to handle options."""
return HiloOptionsFlowHandler(config_entry)
async def async_step_reauth(self, user_input=None) -> FlowResult:
"""Perform reauth upon an API authentication error."""
LOG.debug("async_step_reauth")
self._reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(self, user_input=None) -> FlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({}),
)
user_input["implementation"] = DOMAIN
return await super().async_step_user(user_input)
async def async_oauth_create_entry(self, data: dict) -> FlowResult:
"""Create an oauth config entry or update existing entry for reauth."""
if self._reauth_entry:
self.hass.config_entries.async_update_entry(self._reauth_entry, data=data)
await self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
return self.async_abort(reason="reauth_successful")
LOG.debug("Creating entry: %s", data)
token = data["token"]["access_token"]
decoded_token = jwt.decode(token, options={"verify_signature": False})
email = decoded_token["email"]
return self.async_create_entry(title=email, data=data)
class HiloOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a Hilo options flow."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize."""
if AwesomeVersion(HAVERSION) < "2024.11.99":
self.config_entry = config_entry
else:
self._config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
STEP_OPTION_SCHEMA, self.config_entry.options
),
)
+107
View File
@@ -0,0 +1,107 @@
"""Hilo integration constants."""
import logging
from homeassistant.components.utility_meter.const import DAILY
LOG = logging.getLogger(__package__)
DOMAIN = "hilo"
HILO_ENERGY_TOTAL = "hilo_energy_total"
# Configurations
CONF_APPRECIATION_PHASE = "appreciation_phase"
DEFAULT_APPRECIATION_PHASE = 0
CONF_CHALLENGE_LOCK = "challenge_lock"
DEFAULT_CHALLENGE_LOCK = False
CONF_ENERGY_METER_PERIOD = "energy_meter_period"
DEFAULT_ENERGY_METER_PERIOD = DAILY
CONF_GENERATE_ENERGY_METERS = "generate_energy_meters"
DEFAULT_GENERATE_ENERGY_METERS = False
CONF_HQ_PLAN_NAME = "hq_plan_name"
DEFAULT_HQ_PLAN_NAME = "rate d"
CONF_LOG_TRACES = "log_traces"
DEFAULT_LOG_TRACES = False
CONF_PRE_COLD_PHASE = "pre_cold_phase"
DEFAULT_PRE_COLD_PHASE = 0
CONF_TRACK_UNKNOWN_SOURCES = "track_unknown_sources"
DEFAULT_TRACK_UNKNOWN_SOURCES = False
CONF_UNTARIFICATED_DEVICES = "untarificated_devices"
DEFAULT_UNTARIFICATED_DEVICES = False
DEFAULT_SCAN_INTERVAL = 300
EVENT_SCAN_INTERVAL = 1800
# During reduction phase, let's refresh the current challenge event
# more often to get the reward numbers
# Note ic-dev21: we'll stay at 300 until proper fix
EVENT_SCAN_INTERVAL_REDUCTION = 300
NOTIFICATION_SCAN_INTERVAL = 1800
MAX_SUB_INTERVAL = 120
MIN_SCAN_INTERVAL = 60
REWARD_SCAN_INTERVAL = 7200
WEATHER_SCAN_INTERVAL = 1800
CONF_TARIFF = {
"rate d": {
"low_threshold": 40,
"low": 0.07065,
"medium": 0.11142,
"high": 0,
"access": 0.46154,
"reward_rate": 0.58490,
},
"flex d": {
"low_threshold": 40,
"low": 0.04886,
"medium": 0.09103,
"high": 0.46463,
"access": 0.46154,
"reward_rate": 0.55,
},
}
TARIFF_LIST = ["high", "medium", "low"]
WEATHER_CONDITIONS = {
"Unknown": "mdi:weather-sunny-alert",
"Blowing Snow": "mdi:weather-snowy-heavy",
"Clear": "mdi:weather-sunny",
"Cloudy": "mdi:weather-cloudy",
"Fair": "mdi:weather-partly-cloudy",
"Foggy": "mdi:weather-fog",
"Hail Sleet": "mdi:weather-hail",
"Mostly Cloudy": "mdi:weather-partly-cloudy",
"Rain": "mdi:weather-rainy",
"Rain Snow": "mdi:weather-snowy-rainy",
"Snow": "mdi:weather-snowy",
"Thunder": "mdi:weather-lightning",
"Windy": "mdi:weather-windy",
}
# Class lists
LIGHT_CLASSES = ["LightDimmer", "WhiteBulb", "ColorBulb", "LightSwitch"]
HILO_SENSOR_CLASSES = [
"SmokeDetector",
"IndoorWeatherStation",
"OutdoorWeatherStation",
"Gateway",
]
CLIMATE_CLASSES = [
"Thermostat",
"FloorThermostat",
"Thermostat24V",
"MysaThermostat",
"TCC24V",
"SinopeThermostat",
"SinopeFloorThermostat",
"Sinope24V",
]
SWITCH_CLASSES = ["Outlet", "Ccr", "Cee", "SinopeWaterHeater"]
+111
View File
@@ -0,0 +1,111 @@
"""Base entity for Hilo."""
from __future__ import annotations
from typing import Union
from homeassistant.const import ATTR_CONNECTIONS
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from pyhilo.device import HiloDevice
from pyhilo.signalr import SignalREvent
from . import SIGNAL_UPDATE_ENTITY, Hilo
from .const import DOMAIN
class HiloEntity(CoordinatorEntity):
"""Define a base Hilo base entity."""
def __init__(
self,
hilo: Hilo,
name: Union[str, None] = None,
*,
device: HiloDevice,
) -> None:
"""Initialize."""
assert hilo.coordinator
super().__init__(hilo.coordinator)
device_info_args = {
"identifiers": {(DOMAIN, device.identifier)},
"manufacturer": device.manufacturer,
"model": device.model,
"name": device.name,
}
try:
device_info_args["via_device"] = (DOMAIN, device.gateway_external_id)
except AttributeError:
# If a device doesn't have a gateway_external_id, it's most likely the gateway itself.
pass # Do nothing.
self._attr_device_info = DeviceInfo(**device_info_args)
try:
mac_address = dr.format_mac(device.sdi)
self._attr_device_info[ATTR_CONNECTIONS] = {
(dr.CONNECTION_NETWORK_MAC, mac_address)
}
except AttributeError:
pass
try:
self._attr_device_info["sw_version"] = device.sw_version
except AttributeError:
pass
if not name:
name = device.name
self._attr_name = name
self._device = device
self._hilo = hilo
self._device._entity = self
@property
def should_poll(self) -> bool:
"""Return whether the entity should be polled."""
return False
@property
def available(self) -> bool:
"""Return whether the entity is available."""
return self._device.available
@callback
def _handle_coordinator_update(self) -> None:
self.async_write_ha_state()
@callback
def async_update_from_signalr_event(self, event: SignalREvent) -> None:
"""Update the entity when new data comes from SignalR."""
raise NotImplementedError()
async def async_added_to_hass(self):
"""Call when entity is added to hass."""
await super().async_added_to_hass()
self._remove_signal_update = async_dispatcher_connect(
self._hilo._hass,
SIGNAL_UPDATE_ENTITY.format(self._device.hilo_id),
self._update_callback,
)
async def async_will_remove_from_hass(self) -> None:
"""Call when entity will be removed from hass."""
await super().async_will_remove_from_hass()
self._remove_signal_update()
@callback
def _update_callback(self):
"""Call update method."""
self.async_schedule_update_ha_state(True)
async def async_update(self) -> None:
"""Update the entity.
Only used by the generic entity update service.
"""
# Ignore manual update requests if the entity is disabled
if not self.enabled:
return
if self._device.type != "Gateway":
await self.coordinator.async_request_refresh()
+124
View File
@@ -0,0 +1,124 @@
"""Hilo Light platform integration."""
from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_HS_COLOR, LightEntity
from homeassistant.components.light.const import ColorMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify
from . import Hilo
from .const import DOMAIN, LIGHT_CLASSES, LOG
from .entity import HiloEntity
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Hilo light entities from a config entry."""
hilo = hass.data[DOMAIN][entry.entry_id]
entities = []
for d in hilo.devices.all:
if d.type in LIGHT_CLASSES:
d._entity = HiloLight(hass, hilo, d)
entities.append(d._entity)
async_add_entities(entities)
class HiloLight(HiloEntity, LightEntity):
"""Define a Hilo Light entity."""
def __init__(self, hass: HomeAssistant, hilo: Hilo, device):
"""Initialize the Hilo light entity."""
super().__init__(hilo, device=device, name=device.name)
old_unique_id = f"{slugify(device.name)}-light"
self._attr_unique_id = f"{device.identifier.lower()}-light"
hilo.async_migrate_unique_id(
old_unique_id, self._attr_unique_id, Platform.LIGHT
)
hilo.async_migrate_unique_id(
f"{slugify(device.identifier)}-light", self._attr_unique_id, Platform.LIGHT
)
self._debounced_turn_on = Debouncer(
hass,
LOG,
cooldown=1,
immediate=True,
function=self._async_debounced_turn_on,
)
LOG.debug("Setting up Light entity: %s", self._attr_name)
@property
def brightness(self):
"""Return the brightness of the light."""
return self._device.brightness
@property
def state(self):
"""Return the state of the light."""
return self._device.state
@property
def is_on(self):
"""Return whether the light is on."""
return self._device.get_value("is_on")
@property
def hs_color(self):
"""Return the HS color."""
return (self._device.hue, self._device.saturation)
@property
def color_mode(self):
"""Return the color mode."""
if ColorMode.HS in self.supported_color_modes:
return ColorMode.HS
elif ColorMode.BRIGHTNESS in self.supported_color_modes:
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF
@property
def supported_color_modes(self) -> set:
"""Flag supported modes."""
color_modes = set()
if self._device.has_attribute("hue"):
color_modes.add(ColorMode.HS)
if not color_modes and self._device.has_attribute("intensity"):
color_modes.add(ColorMode.BRIGHTNESS)
if not color_modes:
color_modes.add(ColorMode.ONOFF)
return color_modes
async def async_turn_off(self, **kwargs):
"""Turn off the light."""
LOG.info("%s Turning off", self._device._tag)
await self._device.set_attribute("is_on", False)
self.async_schedule_update_ha_state(True)
async def async_turn_on(self, **kwargs):
"""Turn on the light."""
self._last_kwargs = kwargs
await self._debounced_turn_on.async_call()
async def _async_debounced_turn_on(self):
LOG.info("%s Turning on", self._device._tag)
await self._device.set_attribute("is_on", True)
if ATTR_BRIGHTNESS in self._last_kwargs:
LOG.info(
f"{self._device._tag} Setting brightness to {self._last_kwargs[ATTR_BRIGHTNESS]}"
)
await self._device.set_attribute(
"intensity", self._last_kwargs[ATTR_BRIGHTNESS] / 255
)
if ATTR_HS_COLOR in self._last_kwargs:
LOG.info(
f"{self._device._tag} Setting HS Color to {self._last_kwargs[ATTR_HS_COLOR]}"
)
await self._device.set_attribute("hue", self._last_kwargs[ATTR_HS_COLOR][0])
await self._device.set_attribute(
"saturation", self._last_kwargs[ATTR_HS_COLOR][1]
)
self.async_schedule_update_ha_state(True)
+210
View File
@@ -0,0 +1,210 @@
"""Utility and Energy Manager classes for Hilo integration."""
from collections import OrderedDict
from datetime import timedelta
from homeassistant.components.energy.data import async_get_manager
from homeassistant.components.utility_meter import async_setup as utility_setup
from homeassistant.components.utility_meter.const import (
CONF_TARIFFS,
DOMAIN as UTILITY_DOMAIN,
)
from homeassistant.helpers import entity_registry as er
from .const import HILO_ENERGY_TOTAL, LOG
class UtilityManager:
"""Class that maps to the utility_meters."""
def __init__(self, hass, period, tariffs):
"""Initialize the utility manager."""
self.tariffs = tariffs
self.hass = hass
self.period = period
self.meter_configs = OrderedDict()
self.meter_entities = {}
self.new_entities = 0
def add_meter(self, entity, tariff_list, net_consumption=False):
"""Add meter."""
self.add_meter_entity(entity, tariff_list)
self.add_meter_config(entity, tariff_list, net_consumption)
def add_meter_entity(self, entity, tariff_list):
"""Add meter entity."""
if entity in self.hass.data.get("utility_meter_data", {}):
LOG.debug("Entity %s is already in the utility meters", entity)
return
self.new_entities += 1
for tarif in tariff_list:
name = f"{entity}_{self.period}"
meter_name = f"{name} {tarif}"
LOG.debug("Creating UtilityMeter entity for %s : %s", entity, meter_name)
self.meter_entities[meter_name] = {
"meter": entity,
"name": meter_name,
"tariff": tarif,
}
def add_meter_config(self, entity, tariff_list, net_consumption):
"""Add meter configuration."""
name = f"{entity}_{self.period}"
LOG.debug(
"Creating UtilityMeter config: %s %s (Net Consumption: %s)",
name,
tariff_list,
net_consumption,
)
self.meter_configs[entity] = OrderedDict(
{
"source": f"sensor.{entity}",
"name": name,
"cycle": self.period,
CONF_TARIFFS: tariff_list,
"net_consumption": net_consumption,
"utility_meter_sensors": [],
"offset": timedelta(0),
"delta_values": False,
"periodically_resetting": True,
"always_available": True,
}
)
async def update(self, async_add_entities):
"""Update the entities."""
LOG.debug("=== UtilityManager.update() called ===")
LOG.debug("Setting up UtilityMeter entities %s", UTILITY_DOMAIN)
LOG.debug("new_entities count: %d", self.new_entities)
if self.new_entities == 0:
LOG.debug("No new entities, not setting up again")
return
# Get the entity registry
registry = er.async_get(self.hass)
# Filter out entities that already exist
filtered_configs = OrderedDict()
for source_entity, config in self.meter_configs.items():
# Check if any of the tariff entities for this source already exist
name = config["name"]
should_include = False
for tariff in config[CONF_TARIFFS]:
# Remove period from name to match actual entity ID, this is why the check was failing
name_without_period = name.replace(f"_{self.period}", "")
entity_id = f"sensor.{name_without_period.lower().replace(' ', '_')}_{tariff.lower()}"
if registry.async_get(entity_id) is None:
should_include = True
LOG.debug(
"Entity %s does not exist, will create config for %s",
entity_id,
source_entity,
)
break
else:
LOG.debug("Entity %s already exists", entity_id)
if should_include:
filtered_configs[source_entity] = config
if not filtered_configs:
LOG.debug("All entities already exist, skipping setup")
self.new_entities = 0
return
LOG.debug("Creating utility meter config for %d sources", len(filtered_configs))
config = {
UTILITY_DOMAIN: OrderedDict(
{**self.hass.data.get("utility_meter_data", {}), **filtered_configs}
),
CONF_TARIFFS: self.tariffs,
}
# Replaced utility_setup_platform call
await utility_setup(self.hass, config)
self.new_entities = 0
LOG.debug("=== UtilityManager.update() completed ===")
class EnergyManager:
"""Class that manages the energy dashboard configuration."""
def __init__(self):
"""Initialize the energy manager."""
self.updated = False
@property
def msg(self):
"""Return the message payload for the energy manager."""
return {
"energy_sources": self.src,
"device_consumption": self.dev,
}
@property
def default_flows(self):
"""Return the default grid flow configuration."""
return {
"type": "grid",
"flow_from": [],
"flow_to": [],
"cost_adjustment_day": 0.0,
}
async def init(self, hass, period):
"""Initialize the energy manager."""
self.period = period
self._manager = await async_get_manager(hass)
data = self._manager.data or self._manager.default_preferences()
self.prefs = data.copy()
self.src = self.prefs.get("energy_sources", [])
self.dev = self.prefs.get("device_consumption", [])
if not self.src:
self.src.append(self.default_flows)
return self
def add_flow_from(self, sensor, rate):
"""Add grid source flow_from sensor."""
sensor = f"sensor.{sensor}"
if any(d["stat_energy_from"] == sensor for d in self.src[0]["flow_from"]):
return
self.updated = True
flow = {
"stat_energy_from": sensor,
"stat_cost": None,
"entity_energy_from": sensor,
"entity_energy_price": f"sensor.{rate}",
"number_energy_price": None,
}
LOG.debug("Adding %s / %s to grid source", sensor, rate)
self.src[0]["flow_from"].append(flow)
def add_device(self, sensor):
"""Add device consumption sensor."""
sensor = f"sensor.{sensor}"
if any(d["stat_consumption"] == sensor for d in self.dev):
return
LOG.debug("Adding %s to individual device consumption", sensor)
self.updated = True
self.dev.append({"stat_consumption": sensor})
def add_to_dashboard(self, entity, tariff_list):
"""Add entity to the energy dashboard."""
for tarif in tariff_list:
name = f"{entity}_{self.period}"
if entity == HILO_ENERGY_TOTAL:
self.add_flow_from(f"{name}_{tarif}", f"hilo_rate_{tarif}")
else:
self.add_device(f"{name}_{tarif}")
async def update(self):
"""Push updates to the energy dashboard."""
if not self.updated:
return
LOG.debug("Pushing config to the energy dashboard")
await self._manager.async_update(self.msg)
self.updated = False
+16
View File
@@ -0,0 +1,16 @@
{
"domain": "hilo",
"name": "Hilo",
"after_dependencies": [
"energy",
"integration",
"utility_meter"
],
"codeowners": ["@dvd-dev"],
"config_flow": true,
"documentation": "https://github.com/dvd-dev/hilo",
"iot_class": "cloud_push",
"issue_tracker": "https://github.com/dvd-dev/hilo/issues",
"requirements": ["python-hilo>=2026.3.5"],
"version": "2026.5.2"
}
+71
View File
@@ -0,0 +1,71 @@
"""Custom OAuth2 implementation."""
from typing import Any, cast
from aiohttp import ClientSession, CookieJar
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import LocalOAuth2Implementation
from pyhilo.const import AUTH_AUTHORIZE, AUTH_CLIENT_ID, AUTH_TOKEN, DOMAIN
from pyhilo.oauth2helper import OAuth2Helper
class AuthCodeWithPKCEImplementation(LocalOAuth2Implementation): # type: ignore[misc]
"""Custom OAuth2 implementation."""
def __init__(
self,
hass: HomeAssistant,
) -> None:
"""Initialize AuthCodeWithPKCEImplementation."""
super().__init__(
hass,
DOMAIN,
AUTH_CLIENT_ID,
"",
AUTH_AUTHORIZE,
AUTH_TOKEN,
)
self.session = None
self.oauth_helper = OAuth2Helper()
def _session(self) -> ClientSession:
if self.session is None or self.session.closed:
self.session = async_create_clientsession(
self.hass, cookie_jar=CookieJar(quote_cookie=False)
)
return self.session
# ... Override AbstractOAuth2Implementation details
@property
def name(self) -> str:
"""Name of the implementation."""
return "Hilo"
@property
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
return self.oauth_helper.get_authorize_parameters()
async def async_resolve_external_data(self, external_data: Any) -> dict:
"""Resolve the authorization code to tokens."""
return cast(
dict,
await self._token_request(
self.oauth_helper.get_token_request_parameters(
external_data["code"], external_data["state"]["redirect_uri"]
)
),
)
async def _token_request(self, data: dict) -> dict:
"""Make a token request."""
data["client_id"] = self.client_id
if self.client_secret:
data["client_secret"] = self.client_secret
resp = await self._session().post(self.token_url, data=data)
resp.raise_for_status()
return cast(dict, await resp.json())
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
---
{}
+77
View File
@@ -0,0 +1,77 @@
"""Support for Hilo switches."""
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify
from pyhilo.device.switch import Switch
from . import Hilo
from .const import DOMAIN, LOG, SWITCH_CLASSES
from .entity import HiloEntity
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Hilo switches based on a config entry."""
hilo = hass.data[DOMAIN][entry.entry_id]
entities = []
for d in hilo.devices.all:
if d.type in SWITCH_CLASSES:
d._entity = HiloSwitch(hilo, d)
entities.append(d._entity)
async_add_entities(entities)
class HiloSwitch(HiloEntity, SwitchEntity):
"""Representation of a Hilo Switch."""
def __init__(self, hilo: Hilo, device: Switch):
"""Initialize the switch."""
super().__init__(hilo, device=device, name=device.name)
old_unique_id = f"{slugify(device.name)}-switch"
self._attr_unique_id = f"{device.identifier.lower()}-switch"
hilo.async_migrate_unique_id(
old_unique_id, self._attr_unique_id, Platform.SWITCH
)
hilo.async_migrate_unique_id(
f"{slugify(device.identifier)}-switch",
self._attr_unique_id,
Platform.SWITCH,
)
LOG.debug("Setting up Switch entity: %s", self._attr_name)
@property
def state(self):
"""Return the state of the switch."""
return self._device.state
@property
def icon(self):
"""Set the icon based on the switch state."""
if not self._device.available:
return "mdi:lan-disconnect"
if self.state == "on":
return "mdi:power-plug"
return "mdi:power-plug-off"
@property
def is_on(self):
"""Return true if the switch is on."""
return self._device.get_value("is_on")
async def async_turn_off(self, **kwargs):
"""Turn the switch off."""
LOG.info("%s Turning off", self._device._tag)
await self._device.set_attribute("is_on", False)
self.async_schedule_update_ha_state(True)
async def async_turn_on(self, **kwargs):
"""Turn the switch on."""
LOG.info("%s Turning on", self._device._tag)
await self._device.set_attribute("is_on", True)
self.async_schedule_update_ha_state(True)
@@ -0,0 +1,51 @@
{
"config": {
"step": {
"user": {
"description": "The Hilo integration interacts with the Hilo application. Hilo is a smart home product made by a subsidary of Hydro Quebec."
},
"reauth_confirm": {
"title": "Reauthenticate integration",
"description": "The integration needs to re-authenticate your account"
}
},
"error": {
"identifier_exists": "Account already registered",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "This Hilo account is already in use.",
"reauth_successful": "Re-authentication successful",
"user_rejected_authorize": "Account linking rejected",
"single_instance_allowed": "Already configured. Only a single configuration is possible."
}
},
"options": {
"step": {
"init": {
"title": "Configure Hilo",
"data": {
"generate_energy_meters": "Generate energy meters",
"untarificated_devices": "Generate only total meters for each devices",
"hq_plan_name": "Hydro Quebec rate plan name",
"scan_interval": "Scan interval (seconds)",
"log_traces": "Log request data and websocket messages",
"challenge_lock": "Lock climate entities during challenges",
"track_unknown_sources": "Track unknown power sources",
"appreciation_phase": "Appreciation phase (hours)",
"pre_cold_phase": "Cooldown phase (hours)"
},
"data_description": {
"hq_plan_name": "Select 'rate d' or 'flex d'",
"scan_interval": "Minimum: 60 seconds",
"log_traces": "Requires debug log level on both the integration and pyhilo",
"challenge_lock": "Prevents any changes when a challenge is in progress",
"track_unknown_sources": "This is a round approximation calculated when we get a reading from the Smart Energy Meter",
"appreciation_phase": "Add an appreciation phase of X hours before the preheat phase. Hilo uses 3 hours for AM events, 2 for PM events, chose a value you would like to automatically add and adjust your automations accordingly.",
"pre_cold_phase": "Add a cooldown phase of X hours to reduce temperatures before the appreciation phase"
}
}
}
}
}
@@ -0,0 +1,52 @@
{
"config": {
"step": {
"user": {
"description": "L'intégration Hilo intéragit avec l'application Hilo. Hilo est un produit de domotique fait par une filliale de Hydro Québec."
},
"reauth_confirm": {
"title": "Réauthentifier l'intégration",
"description": "L'intégration doit réauthentifier votre compte"
}
},
"error": {
"identifier_exists": "Compte déjà enregistré",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "Ce compte Hilo est déjà utilisé.",
"reauth_successful": "Ré-authentification réussie",
"user_rejected_authorize": "Association du compte refusée",
"single_instance_allowed": "Déjà configurée. Une seule configuration possible."
}
},
"options": {
"step": {
"init": {
"title": "Configurer Hilo",
"data": {
"generate_energy_meters": "Générer compteurs de consommation électrique",
"untarificated_devices": "Générer seulement les compteurs totaux",
"hq_plan_name": "Nom du tarif Hydro Québec",
"scan_interval": "Intervalle de mise à jour (secondes)",
"log_traces": "Enregistrer requêtes et messages websocket",
"challenge_lock": "Vérouiller les entités climate lors de défis",
"track_unknown_sources": "Suivre les sources de consommation inconnues",
"appreciation_phase": "Période d'ancrage (heures)",
"pre_cold_phase": "Période de refroidissement (heures)"
},
"data_description": {
"untarificated_devices": "Générer seulement les compteurs totaux pour chaque appareil",
"hq_plan_name": "Sélectionnez 'rate d' ou 'flex d'",
"scan_interval": "Minimum: 60 secondes",
"log_traces": "Requiert le niveau de journalisation debug sur l'intégration et pyhilo",
"challenge_lock": "Empêche tout changement lorsqu'un défi est en cours",
"track_unknown_sources": "Ceci est une approximation calculée à partir de la lecture du compteur intelligent",
"appreciation_phase": "Ajouter une période d'ancrage de X heures avant la phase de préchauffage. Hilo utilise 3 heures pour les événements AM, 2 heures pour les événements PM, choisissez une valeur qui vous convient et ajustez vos automatisations en conséquence.",
"pre_cold_phase": "Ajouter une période de refroidissement de X heures avant la phase d'ancrage"
}
}
}
}
}