Initial Commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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]
|
||||
)
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,2 @@
|
||||
---
|
||||
{}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user