added sensors for HPE switch

This commit is contained in:
Gorden Mende
2026-07-15 10:28:33 +02:00
parent 87275eb7c3
commit 33b85dd32e
75 changed files with 10272 additions and 4001 deletions
+1 -1
View File
@@ -1 +1 @@
2025.11.2
2026.7.1
+71 -157
View File
@@ -1,35 +1,3 @@
- id: '1652892911485'
alias: Dunstabzug an
description: ''
trigger:
- type: opened
platform: device
device_id: 925011b73a5e493591e69fb225b7cfd7
entity_id: binary_sensor.openclose_17
domain: binary_sensor
condition: []
action:
- type: turn_on
device_id: 501d9510266fae1bef92308c34718b38
entity_id: switch.on_off_plug_in_unit_27
domain: switch
mode: single
- id: '1652892963309'
alias: Dunstabzug aus
description: ''
trigger:
- type: not_opened
platform: device
device_id: 925011b73a5e493591e69fb225b7cfd7
entity_id: binary_sensor.openclose_17
domain: binary_sensor
condition: []
action:
- type: turn_off
device_id: 501d9510266fae1bef92308c34718b38
entity_id: switch.on_off_plug_in_unit_27
domain: switch
mode: single
- id: '1679990307886'
alias: ring_30min_Wohnen_new_snapshot
description: ''
@@ -89,19 +57,19 @@
- id: '1702998213164'
alias: Bodensensor_Batterie_schwach
description: ''
trigger:
triggers:
- type: battery_level
platform: device
device_id: c98f516ed093e6f4027ef95659173f06
entity_id: fcad955888a5219fe0bed96490dd2f74
domain: sensor
below: 10
condition: []
action:
- service: notify.mobile_app_phone_1
trigger: device
conditions: []
actions:
- action: notify.mobile_app_nothing3apro
metadata: {}
data:
message: Battery Blink Carport is low
title: Attention
message: Bodensensor-Batterie schwach!!!
mode: single
- id: '1703424483609'
alias: OpenWindow_HWR
@@ -138,33 +106,32 @@
below: 10
conditions: []
actions:
- action: notify.mobile_app_m2102j20sg
data: {}
- data:
message: Battery Ring Haustür is low
title: Attention
action: notify.hass_synchat
- action: notify.mobile_app_nothing3apro
metadata: {}
data:
message: Batterie Haustür schwach! Bitte aufladen.
- action: notify.mobile_app_a142
metadata: {}
data:
message: Batterie Haustür schwach! Bitte aufladen.
mode: single
- id: '1704809998635'
alias: info_battery_garten
description: ''
trigger:
triggers:
- type: battery_level
platform: device
device_id: 9e8fab1237714c677c6d747fe81e5639
entity_id: eb89c5a51befafbb6ab287111ff3f0d9
domain: sensor
below: 5
condition: []
action:
- service: notify.mobile_app_phone_1
data:
message: Battery Ring Garten is low
title: Attention
- service: notify.hass_synchat
data:
message: Battery Ring Garten is low
title: Attention
trigger: device
conditions: []
actions:
- action: notify.mobile_app_a142
data: {}
- action: notify.mobile_app_nothing3apro
metadata: {}
data: {}
mode: single
- id: '1707337272134'
alias: window_reminder
@@ -248,13 +215,13 @@
data:
message: Fenster {{ trigger.to_state.attributes.friendly_name }} geöffnet! Bitte
schließen.
action: notify.mobile_app_m2102j20sg
- metadata: {}
action: notify.mobile_app_lenovo_tb_j606f
- action: notify.mobile_app_a142
metadata: {}
data:
message: Fenster {{ trigger.to_state.attributes.friendly_name }} geöffnet! Bitte
schließen.
action: notify.mobile_app_lenovo_tb_j606f
- action: notify.mobile_app_a142
- action: notify.mobile_app_nothing3apro
metadata: {}
data:
message: Fenster {{ trigger.to_state.attributes.friendly_name }} geöffnet! Bitte
@@ -315,9 +282,9 @@
path: panhans/advanced_heating_control.yaml
input:
input_trvs:
- climate.t_kuche
- climate.t_esszimmer
- climate.t_wohnzimmer
- climate.t_kuche
input_temperature_sensor: sensor.temperature_38
input_temperature_eco_static: 16
input_min_instead_of_off: true
@@ -333,7 +300,6 @@
- binary_sensor.fk_wohnzimmer
- binary_sensor.openclose_17
input_liming_protection: true
input_mode_outside_temperature: sensor.sensor1_soil_temperature
input_mode_outside_temperature_threshold: 18
input_generic_calibration_offset: 7
input_frost_protection_temp: 7
@@ -348,6 +314,7 @@
seconds: 30
input_temperature_comfort: input_number.comforttemp
input_temperature_eco: input_number.ecotemp
input_window_open_temperature: 5
- id: '1736353137059'
alias: "\U0001F525 Advanced Heating Control Oben"
description: ''
@@ -356,7 +323,6 @@
input:
input_trvs:
- climate.t_buro
- climate.t_gaste1
input_temperature_sensor: sensor.office_room_temperature
input_temperature_comfort_static: 23
input_min_instead_of_off: true
@@ -369,82 +335,6 @@
- binary_sensor.fk_buro
input_liming_protection: true
input_frost_protection_temp: 7
- id: '1749638886388'
alias: gäste-wc_licht
description: Schaltet bei Bewegung das Licht an
triggers:
- type: motion
device_id: 6e9d01f467927d61f45768160a892e45
entity_id: fb26eefedea1e06a200b9f9ec17a7f3d
domain: binary_sensor
trigger: device
conditions: []
actions:
- type: turn_on
device_id: 8d6bbf2015aa51bbf1e064cac4aef609
entity_id: b7f728e28b4c342d1921a47409822ed9
domain: light
brightness_pct: 50
mode: single
- id: '1749638989528'
alias: gäste-wc_licht_aus
description: ''
triggers:
- type: no_motion
device_id: 6e9d01f467927d61f45768160a892e45
entity_id: fb26eefedea1e06a200b9f9ec17a7f3d
domain: binary_sensor
trigger: device
for:
hours: 0
minutes: 2
seconds: 10
conditions: []
actions:
- type: turn_off
device_id: 8d6bbf2015aa51bbf1e064cac4aef609
entity_id: b7f728e28b4c342d1921a47409822ed9
domain: light
mode: single
- id: '1758515986313'
alias: Licht Eingang an
description: Schaltet bei Bewegung das Licht an
triggers:
- type: motion
device_id: ab5a4270111ddad1ee8bb693143416bd
entity_id: d67ad6df4a99dd98ac122f72dbd9cfa0
domain: binary_sensor
trigger: device
conditions:
- condition: sun
before: sunrise
after: sunset
actions:
- type: turn_on
device_id: 294f5f31177faceb618c0ffd1e24ba7d
entity_id: f0e686c16165f9db196292b778fee428
domain: switch
mode: single
- id: '1758516096352'
alias: eingang_licht_aus
description: ''
triggers:
- type: no_motion
device_id: ab5a4270111ddad1ee8bb693143416bd
entity_id: d67ad6df4a99dd98ac122f72dbd9cfa0
domain: binary_sensor
trigger: device
for:
hours: 0
minutes: 1
seconds: 0
conditions: []
actions:
- type: turn_off
device_id: 294f5f31177faceb618c0ffd1e24ba7d
entity_id: f0e686c16165f9db196292b778fee428
domain: switch
mode: single
- id: '1762262741326'
alias: remove_orphan_deconz
description: ''
@@ -507,8 +397,40 @@
alias: Eingang_Licht
description: ''
triggers:
- trigger: state
entity_id:
- event.haustur_motion
attribute: event_type
to:
- motion
conditions:
- condition: sun
before: sunrise
after: sunset
actions:
- if:
- condition: state
entity_id: event.haustur_motion
state:
- motion
attribute: event_type
then:
- action: switch.turn_on
target:
entity_id: switch.on_off_plug_in_unit_33
data: {}
else:
- action: switch.turn_off
target:
entity_id: switch.on_off_plug_in_unit_33
data: {}
mode: single
- id: '1764618229369'
alias: Dunstabzug_OnOff
description: ''
triggers:
- entity_id:
- binary_sensor.presence_74
- binary_sensor.openclose_17
from:
- 'off'
to:
@@ -516,34 +438,26 @@
id: 'on'
trigger: state
- entity_id:
- binary_sensor.presence_74
- binary_sensor.openclose_17
from:
- 'on'
to:
- 'off'
id: 'off'
for:
hours: 0
minutes: 3
seconds: 0
trigger: state
conditions:
- condition: sun
before: sunrise
after: sunset
conditions: []
actions:
- if:
- condition: trigger
id: 'on'
then:
- data:
brightness_pct: 50
action: light.turn_on
- action: switch.turn_on
target:
entity_id: light.p_eingang
else:
- action: light.turn_off
entity_id: switch.on_off_plug_in_unit_27
data: {}
else:
- action: switch.turn_off
target:
entity_id: light.p_eingang
entity_id: switch.on_off_plug_in_unit_27
data: {}
mode: single
+37 -9
View File
@@ -2,7 +2,7 @@
default_config:
homeassistant:
internal_url: "http://192.168.0.40:8123"
internal_url: "http://192.168.0.87:8123"
external_url: "https://ha.solusar.de"
country: DE
@@ -22,14 +22,22 @@ homeassistant:
- type: homeassistant
customize:
sensor.lepus3_060_zahlerstand_total:
sensor.lepus3_060_lepus_zaehlerstand_total:
device_class: energy
unit_of_measurement: "Wh"
state_class: total_increasing
sensor.lepus3_060_wirkenergie_total:
sensor.lepus3_060_lepus_wirkenergie_total:
device_class: energy
unit_of_measurement: "Wh"
state_class: total_increasing
sensor.lepus3_060_lepus_e_in:
device_class: energy
unit_of_measurement: "kWh"
state_class: total_increasing
sensor.lepus3_060_lepus_e_out:
device_class: energy
unit_of_measurement: "kWh"
state_class: total_increasing
allowlist_external_dirs:
- /tmp
@@ -45,14 +53,33 @@ tts:
http:
use_x_forwarded_for: true
trusted_proxies:
- 192.168.0.28 # Add the IP address of the proxy server
- 192.168.0.27 # Add the IP address of the proxy server
- 192.168.0.0/24 # trusted network
- 172.0.0.0/8
ip_ban_enabled: true
login_attempts_threshold: 3
notify:
- platform: synology_chat
name: hass_synchat
verify_ssl: false
resource: https://realm.solusar.de:5001/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=%22okeKTH4iySRNb8g34muKKnh6MwVZ79TkYu6W57oQst1KOcVbD1cMOY9DMv57vZyp%22
logger:
default: warning
# logs:
# pydeconz: debug
# homeassistant.components.deconz: debug
#notify:
# - platform: synology_chat
# name: hass_synchat
# verify_ssl: false
# resource: https://realm.solusar.de:5001/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=%22okeKTH4iySRNb8g34muKKnh6MwVZ79TkYu6W57oQst1KOcVbD1cMOY9DMv57vZyp%22
#emulated_hue:
# listen_port: 80
# host_ip: 192.168.0.87
# expose_by_default: true
# exposed_domains:
# - light
# - smartplug
# entities:
# includes
automation: !include automations.yaml
@@ -61,3 +88,4 @@ scene: !include scenes.yaml
sensor: !include sensors.yaml
rest: !include rest.yaml
template: !include template.yaml
+93 -21
View File
@@ -1,25 +1,32 @@
"""The better_thermostat component."""
import logging
from asyncio import Lock
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.typing import ConfigType
import voluptuous as vol
from .utils.const import (
CONF_CALIBRATION_MODE,
CONF_HEATER,
CONF_HUMIDITY,
CONF_NO_SYSTEM_MODE_OFF,
CONF_OUTDOOR_SENSOR,
CONF_SENSOR,
CONF_SENSOR_WINDOW,
CONF_WINDOW_TIMEOUT,
CONF_WINDOW_TIMEOUT_AFTER,
CalibrationMode,
)
from .utils.helpers import get_device_model
_LOGGER = logging.getLogger(__name__)
DOMAIN = "better_thermostat"
PLATFORMS = [Platform.CLIMATE]
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.NUMBER, Platform.SWITCH]
CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA)
config_entry_update_listener_lock = Lock()
@@ -36,7 +43,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up entry."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
try:
# Setup climate platform first to ensure entity is available for other platforms
await hass.config_entries.async_forward_entry_setups(entry, [Platform.CLIMATE])
# Setup other platforms that depend on climate entity
await hass.config_entries.async_forward_entry_setups(
entry, [Platform.SENSOR, Platform.NUMBER, Platform.SWITCH]
)
except Exception:
_LOGGER.exception(
"better_thermostat: Fehler beim Laden der Plattformen für Entry %s",
entry.entry_id,
)
return False
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
@@ -55,7 +74,43 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove repair-registry issues created by this Better Thermostat instance.
Issues are scoped by ``device_name`` or by individual ``entity_id`` and
persist in HA's issue registry until explicitly deleted, so they have to
be cleaned up here to avoid stale warnings after a config entry is gone.
"""
device_name = entry.data.get(CONF_NAME, entry.title)
for issue_id in (
f"invalid_external_temperature_{device_name}",
f"invalid_window_state_{device_name}",
f"degraded_mode_{device_name}",
):
ir.async_delete_issue(hass, DOMAIN, issue_id)
entity_ids: list[str] = []
for trv in entry.data.get(CONF_HEATER) or []:
trv_id = trv.get("trv")
if trv_id:
entity_ids.append(trv_id)
for conf_key in (
CONF_SENSOR,
CONF_HUMIDITY,
CONF_SENSOR_WINDOW,
CONF_OUTDOOR_SENSOR,
):
eid = entry.data.get(conf_key)
if eid:
entity_ids.append(eid)
for eid in entity_ids:
ir.async_delete_issue(hass, DOMAIN, f"missing_entity_{eid}")
async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Reload the config entry."""
await async_unload_entry(hass, config_entry)
await async_setup_entry(hass, config_entry)
@@ -63,21 +118,17 @@ async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
async def async_migrate_entry(hass, config_entry: ConfigEntry):
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
new = {**config_entry.data}
if config_entry.version == 1:
new = {**config_entry.data}
for trv in new[CONF_HEATER]:
trv["advanced"].update({CalibrationMode.AGGRESIVE_CALIBRATION: False})
config_entry.version = 2
hass.config_entries.async_update_entry(config_entry, data=new)
if config_entry.version == 2:
new = {**config_entry.data}
new[CONF_WINDOW_TIMEOUT] = 0
config_entry.version = 3
hass.config_entries.async_update_entry(config_entry, data=new)
if config_entry.version == 3:
new = {**config_entry.data}
for trv in new[CONF_HEATER]:
if (
CalibrationMode.AGGRESIVE_CALIBRATION in trv["advanced"]
@@ -87,22 +138,43 @@ async def async_migrate_entry(hass, config_entry: ConfigEntry):
{CONF_CALIBRATION_MODE: CalibrationMode.AGGRESIVE_CALIBRATION}
)
else:
trv["advanced"].update({CONF_CALIBRATION_MODE: CalibrationMode.DEFAULT})
config_entry.version = 4
hass.config_entries.async_update_entry(config_entry, data=new)
trv["advanced"].update(
{CONF_CALIBRATION_MODE: CalibrationMode.MPC_CALIBRATION}
)
if config_entry.version == 4:
new = {**config_entry.data}
for trv in new[CONF_HEATER]:
trv["advanced"].update({CONF_NO_SYSTEM_MODE_OFF: False})
config_entry.version = 5
hass.config_entries.async_update_entry(config_entry, data=new)
if config_entry.version == 5:
new = {**config_entry.data}
new[CONF_WINDOW_TIMEOUT_AFTER] = new[CONF_WINDOW_TIMEOUT]
config_entry.version = 6
hass.config_entries.async_update_entry(config_entry, data=new)
if config_entry.version < 18:
# Make sure all TRVs fetch the get_device_model method to update their model info, which is used for device-specific quirks again.
migration_context = type(
"MigrationContext",
(),
{"hass": hass, "device_name": config_entry.title, "model": None},
)()
heaters = new.get(CONF_HEATER, [])
for trv in heaters:
entity_id = trv.get("entity_id")
if entity_id:
trv["model"] = await get_device_model(migration_context, entity_id)
_LOGGER.debug(
"Migration to version 1.8: TRV %s model updated to %s",
entity_id,
trv["model"],
)
new[CONF_HEATER] = heaters
_LOGGER.debug(
"Migration to version 1.8: Updated TRV model information for all TRVs in config entry %s",
config_entry.entry_id,
)
# update the new config entry with the updated TRV model information
hass.config_entries.async_update_entry(config_entry, data=new, version=18)
_LOGGER.info("Migration to version %s successful", config_entry.version)
@@ -1,7 +1,16 @@
"""Adapter for deCONZ devices.
This module implements the minimal adapter interface required by the
Better Thermostat integration for deCONZ-controlled TRV devices.
"""
import logging
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from .generic import (
set_temperature as generic_set_temperature,
set_hvac_mode as generic_set_hvac_mode,
set_temperature as generic_set_temperature,
)
_LOGGER = logging.getLogger(__name__)
@@ -9,13 +18,21 @@ _LOGGER = logging.getLogger(__name__)
async def get_info(self, entity_id):
"""Get info from TRV."""
_offset = self.hass.states.get(entity_id).attributes.get("offset", None)
state = self.hass.states.get(entity_id)
if state is None:
return {"support_offset": False, "support_valve": False}
_offset = state.attributes.get("offset", None)
if _offset is None:
return {"support_offset": False, "support_valve": False}
return {"support_offset": True, "support_valve": False}
async def init(self, entity_id):
"""Initialize adapter for an entity.
This adapter does not require any special initialization, so the
function returns None.
"""
return None
@@ -31,12 +48,23 @@ async def set_hvac_mode(self, entity_id, hvac_mode):
async def get_current_offset(self, entity_id):
"""Get current offset."""
return float(str(self.hass.states.get(entity_id).attributes.get("offset", 0)))
state = self.hass.states.get(entity_id)
if state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return 0.0
try:
return float(str(state.attributes.get("offset", 0)))
except (ValueError, TypeError):
_LOGGER.warning(
"better_thermostat %s: Could not convert calibration offset '%s' to float, using 0",
self.device_name,
state.attributes.get("offset"),
)
return 0.0
async def get_offset_step(self, entity_id):
"""Get offset step."""
return float(1.0)
return 1.0
async def get_min_offset(self, entity_id):
@@ -1,9 +1,18 @@
"""Generic adapter helpers used by multiple TRV integrations.
This module implements the generic, default behaviour for TRV adapters
used by Better Thermostat when a device-specific adapter does not exist.
"""
import asyncio
import logging
from homeassistant.components.number.const import SERVICE_SET_VALUE
from ..utils.helpers import find_local_calibration_entity
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTemperature
from homeassistant.util.unit_conversion import TemperatureConverter
from ..utils.helpers import find_local_calibration_entity, normalize_hvac_mode
from .base import wait_for_calibration_entity_or_timeout
_LOGGER = logging.getLogger(__name__)
@@ -19,61 +28,72 @@ async def get_info(self, entity_id):
async def init(self, entity_id):
"""Initialize generic adapter for an entity.
Finds and registers a local calibration entity (if configured) and waits
for it to appear before returning. Returns None after initialization.
"""
if (
self.real_trvs[entity_id]["local_temperature_calibration_entity"] is None
and self.real_trvs[entity_id]["calibration"] != 1
):
self.real_trvs[entity_id]["local_temperature_calibration_entity"] = (
await find_local_calibration_entity(self, entity_id)
)
self.real_trvs[entity_id][
"local_temperature_calibration_entity"
] = await find_local_calibration_entity(self, entity_id)
_LOGGER.debug(
"better_thermostat %s: uses local calibration entity %s",
self.device_name,
self.real_trvs[entity_id]["local_temperature_calibration_entity"],
)
# Wait for the entity to be available
_ready = True
while _ready:
if self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
).state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None):
_LOGGER.info(
"better_thermostat %s: waiting for TRV/climate entity with id '%s' to become fully available...",
self.device_name,
self.real_trvs[entity_id]["local_temperature_calibration_entity"],
)
await asyncio.sleep(5)
continue
_ready = False
return
return None
if (
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
is not None
):
await wait_for_calibration_entity_or_timeout(
self,
entity_id,
self.real_trvs[entity_id]["local_temperature_calibration_entity"],
)
else:
_LOGGER.warning(
"better_thermostat %s: no local calibration entity found for '%s', skipping calibration init",
self.device_name,
entity_id,
)
async def get_current_offset(self, entity_id):
"""Get current offset."""
if self.real_trvs[entity_id]["local_temperature_calibration_entity"] is not None:
return float(
str(
self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
).state
)
state = self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
)
if state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return 0.0
try:
# For SELECT entities, remove the 'k' suffix if present (e.g., "1.5k" -> "1.5")
state_str = str(state.state).replace("k", "")
return float(state_str)
except (ValueError, TypeError):
_LOGGER.warning(
"better_thermostat %s: Could not convert calibration offset '%s' to float, using 0",
self.device_name,
state.state,
)
return 0.0
else:
return None
return 0.0
async def get_offset_step(self, entity_id):
"""Get offset step."""
if self.real_trvs[entity_id]["local_temperature_calibration_entity"] is not None:
return float(
str(
self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
).attributes.get("step", 1)
)
state = self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
)
if state is None:
return None
return float(str(state.attributes.get("step", 1)))
else:
return None
@@ -81,13 +101,26 @@ async def get_offset_step(self, entity_id):
async def get_min_offset(self, entity_id):
"""Get min offset."""
if self.real_trvs[entity_id]["local_temperature_calibration_entity"] is not None:
return float(
str(
self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
).attributes.get("min", -10)
)
state = self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
)
if state is None:
return -6.0
# For SELECT entities, infer from options
if state.domain == "select":
options = state.attributes.get("options", [])
if options:
try:
# Extract numeric values from options (remove 'k' suffix)
values = [float(opt.replace("k", "")) for opt in options]
return min(values)
except (ValueError, TypeError):
return -6.0
return -6.0
# For NUMBER entities, use the min attribute
return float(str(state.attributes.get("min", -10)))
else:
return -6
@@ -95,19 +128,37 @@ async def get_min_offset(self, entity_id):
async def get_max_offset(self, entity_id):
"""Get max offset."""
if self.real_trvs[entity_id]["local_temperature_calibration_entity"] is not None:
return float(
str(
self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
).attributes.get("max", 10)
)
state = self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
)
if state is None:
return 6.0
# For SELECT entities, infer from options
if state.domain == "select":
options = state.attributes.get("options", [])
if options:
try:
# Extract numeric values from options (remove 'k' suffix)
values = [float(opt.replace("k", "")) for opt in options]
return max(values)
except (ValueError, TypeError):
return 6.0
return 6.0
# For NUMBER entities, use the max attribute
return float(str(state.attributes.get("max", 10)))
else:
return 6
async def set_temperature(self, entity_id, temperature):
"""Set new target temperature."""
if self.hass.config.units.temperature_unit == UnitOfTemperature.FAHRENHEIT:
temperature = TemperatureConverter.convert(
temperature, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT
)
temperature = round(temperature, 1)
await self.hass.services.async_call(
"climate",
"set_temperature",
@@ -119,17 +170,36 @@ async def set_temperature(self, entity_id, temperature):
async def set_hvac_mode(self, entity_id, hvac_mode):
"""Set new target hvac mode."""
_LOGGER.debug("better_thermostat %s: set_hvac_mode %s", self.device_name, hvac_mode)
hvac_mode_norm = normalize_hvac_mode(hvac_mode)
_LOGGER.debug(
"better_thermostat %s: set_hvac_mode %s -> %s",
self.device_name,
hvac_mode,
hvac_mode_norm,
)
try:
await self.hass.services.async_call(
"climate",
"set_hvac_mode",
{"entity_id": entity_id, "hvac_mode": hvac_mode},
{"entity_id": entity_id, "hvac_mode": hvac_mode_norm},
blocking=True,
context=self.context,
)
except TypeError:
_LOGGER.debug("TypeError in set_hvac_mode")
_LOGGER.debug(
"TypeError in set_hvac_mode (entity=%s, hvac_mode=%s)",
entity_id,
hvac_mode_norm,
)
except Exception as exc:
_LOGGER.exception(
"better_thermostat %s: Exception in set_hvac_mode for %s with %s: %s",
self.device_name,
entity_id,
hvac_mode_norm,
exc,
)
async def set_offset(self, entity_id, offset):
@@ -138,23 +208,69 @@ async def set_offset(self, entity_id, offset):
max_calibration = await get_max_offset(self, entity_id)
min_calibration = await get_min_offset(self, entity_id)
if offset >= max_calibration:
offset = max_calibration
if offset <= min_calibration:
offset = min_calibration
offset = min(max_calibration, offset)
offset = max(min_calibration, offset)
await self.hass.services.async_call(
"number",
SERVICE_SET_VALUE,
{
"entity_id": self.real_trvs[entity_id][
"local_temperature_calibration_entity"
],
"value": offset,
},
blocking=True,
context=self.context,
calibration_entity = self.real_trvs[entity_id][
"local_temperature_calibration_entity"
]
entity_state = self.hass.states.get(calibration_entity)
# Derive domain safely - from entity_state if available, otherwise from entity_id
domain = (
entity_state.domain if entity_state else calibration_entity.split(".", 1)[0]
)
# Check if it's a SELECT entity or NUMBER entity
if domain == "select":
# For SELECT entities, format with 'k' suffix (e.g., "1.5k")
option_value = f"{offset:.1f}k"
# Get available options (handle None entity_state gracefully)
options = []
if entity_state:
options = entity_state.attributes.get("options", [])
# Validate and snap to closest matching option if needed
if options:
if option_value not in options:
try:
# Parse all options and find the closest match
parsed_options = {}
for opt in options:
try:
parsed_options[opt] = float(str(opt).replace("k", ""))
except (ValueError, TypeError):
continue
if parsed_options:
# Find option with minimum distance to target offset
closest_option = min(
parsed_options,
key=lambda opt: abs(parsed_options[opt] - offset),
)
option_value = closest_option
except (ValueError, TypeError):
# If parsing fails, keep original option_value and hope for the best
pass
await self.hass.services.async_call(
"select",
"select_option",
{"entity_id": calibration_entity, "option": option_value},
blocking=True,
context=self.context,
)
else:
# For NUMBER entities, use the original set_value service
await self.hass.services.async_call(
"number",
SERVICE_SET_VALUE,
{"entity_id": calibration_entity, "value": offset},
blocking=True,
context=self.context,
)
self.real_trvs[entity_id]["last_calibration"] = offset
if (
self.real_trvs[entity_id]["last_hvac_mode"] is not None
@@ -1,14 +1,21 @@
"""MQTT adapter for TRV devices.
This module implements MQTT-specific behaviour for TRV devices used by
the Better Thermostat integration.
"""
import asyncio
import logging
from homeassistant.components.number.const import SERVICE_SET_VALUE
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from ..utils.helpers import find_local_calibration_entity, find_valve_entity
from .base import wait_for_calibration_entity_or_timeout
from .generic import (
set_hvac_mode as generic_set_hvac_mode,
set_temperature as generic_set_temperature,
)
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
_LOGGER = logging.getLogger(__name__)
@@ -21,48 +28,55 @@ async def get_info(self, entity_id):
if offset is not None:
support_offset = True
valve = await find_valve_entity(self, entity_id)
if valve is not None:
support_valve = True
if valve is not None and valve.get("entity_id"):
support_valve = bool(valve.get("writable", False))
return {"support_offset": support_offset, "support_valve": support_valve}
async def init(self, entity_id):
"""Initialize the MQTT adapter for a TRV entity.
Performs early discovery of the valve position and the local
calibration entity when available.
"""
# Try to discover valve position entity early
try:
from ..utils.helpers import find_valve_entity as _find_valve
valve = await _find_valve(self, entity_id)
if valve is not None:
self.real_trvs[entity_id]["valve_position_entity"] = valve.get("entity_id")
self.real_trvs[entity_id]["valve_position_writable"] = bool(
valve.get("writable", False)
)
except Exception:
pass
if (
self.real_trvs[entity_id]["local_temperature_calibration_entity"] is None
and self.real_trvs[entity_id]["calibration"] != 1
):
self.real_trvs[entity_id]["local_temperature_calibration_entity"] = (
await find_local_calibration_entity(self, entity_id)
)
self.real_trvs[entity_id][
"local_temperature_calibration_entity"
] = await find_local_calibration_entity(self, entity_id)
_LOGGER.debug(
"better_thermostat %s: uses local calibration entity %s",
self.device_name,
self.real_trvs[entity_id]["local_temperature_calibration_entity"],
)
# Wait for the entity to be available
_ready = True
while _ready:
if self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
).state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None):
_LOGGER.info(
"better_thermostat %s: waiting for TRV/climate entity with id '%s' to become fully available...",
self.device_name,
self.real_trvs[entity_id]["local_temperature_calibration_entity"],
)
await asyncio.sleep(5)
continue
_ready = False
return
_has_preset = self.hass.states.get(entity_id).attributes.get(
"preset_modes", None
await wait_for_calibration_entity_or_timeout(
self,
entity_id,
self.real_trvs[entity_id]["local_temperature_calibration_entity"],
)
state = self.hass.states.get(entity_id)
_has_preset = state.attributes.get("preset_modes", None) if state else None
if _has_preset is not None:
await self.hass.services.async_call(
"climate",
"set_preset_mode",
{"entity_id": entity_id, "preset_mode": "manual"},
{"entity_id": entity_id, "preset_mode": "none"},
blocking=True,
context=self.context,
)
@@ -81,46 +95,50 @@ async def set_hvac_mode(self, entity_id, hvac_mode):
async def get_current_offset(self, entity_id):
"""Get current offset."""
return float(
str(
self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
).state
)
state = self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
)
if state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return 0.0
try:
return float(str(state.state))
except (ValueError, TypeError):
_LOGGER.warning(
"better_thermostat %s: Could not convert calibration offset '%s' to float, using 0",
self.device_name,
state.state,
)
return 0.0
async def get_offset_step(self, entity_id):
"""Get offset step."""
return float(
str(
self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
).attributes.get("step", 1)
)
state = self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
)
if state is None:
return 1.0
return float(str(state.attributes.get("step", 1)))
async def get_min_offset(self, entity_id):
"""Get min offset."""
return float(
str(
self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
).attributes.get("min", -10)
)
state = self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
)
if state is None:
return -10.0
return float(str(state.attributes.get("min", -10)))
async def get_max_offset(self, entity_id):
"""Get max offset."""
return float(
str(
self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
).attributes.get("max", 10)
)
state = self.hass.states.get(
self.real_trvs[entity_id]["local_temperature_calibration_entity"]
)
if state is None:
return 10.0
return float(str(state.attributes.get("max", 10)))
async def set_offset(self, entity_id, offset):
@@ -128,10 +146,8 @@ async def set_offset(self, entity_id, offset):
max_calibration = await get_max_offset(self, entity_id)
min_calibration = await get_min_offset(self, entity_id)
if offset >= max_calibration:
offset = max_calibration
if offset <= min_calibration:
offset = min_calibration
offset = min(max_calibration, offset)
offset = max(min_calibration, offset)
await self.hass.services.async_call(
"number",
@@ -159,8 +175,30 @@ async def set_offset(self, entity_id, offset):
async def set_valve(self, entity_id, valve):
"""Set new target valve."""
_LOGGER.debug(
f"better_thermostat {self.device_name}: TO TRV {entity_id} set_valve: {valve}"
"better_thermostat %s: TO TRV %s set_valve: %s",
self.device_name,
entity_id,
valve,
)
if self.real_trvs.get(entity_id, {}).get("valve_position_writable") is False:
_LOGGER.debug(
"better_thermostat %s: valve entity for %s is read-only, skip adapter write",
self.device_name,
entity_id,
)
return
# get min max from entity attributes
valve_entity = self.hass.states.get(
self.real_trvs[entity_id]["valve_position_entity"]
)
if valve_entity is not None:
min_valve = float(str(valve_entity.attributes.get("min", 0)))
max_valve = float(str(valve_entity.attributes.get("max", 100)))
valve = min_valve + (valve / 100.0) * (max_valve - min_valve)
step = float(str(valve_entity.attributes.get("step", 1)))
valve = round(valve / step) * step
await self.hass.services.async_call(
"number",
SERVICE_SET_VALUE,
@@ -1,7 +1,16 @@
"""Tado adapter helpers for Better Thermostat.
This module implements the thin adapter that maps Better Thermostat actions
onto the Tado climate services (offsets and modes).
"""
import logging
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from .generic import (
set_temperature as generic_set_temperature,
set_hvac_mode as generic_set_hvac_mode,
set_temperature as generic_set_temperature,
)
_LOGGER = logging.getLogger(__name__)
@@ -13,6 +22,10 @@ async def get_info(self, entity_id):
async def init(self, entity_id):
"""Perform per-entity initialization for the Tado adapter.
Currently, no initialization is required and the function returns None.
"""
return None
@@ -28,14 +41,23 @@ async def set_hvac_mode(self, entity_id, hvac_mode):
async def get_current_offset(self, entity_id):
"""Get current offset."""
return float(
str(self.hass.states.get(entity_id).attributes.get("offset_celsius", 0))
)
state = self.hass.states.get(entity_id)
if state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return 0.0
try:
return float(str(state.attributes.get("offset_celsius", 0)))
except (ValueError, TypeError):
_LOGGER.warning(
"better_thermostat %s: Could not convert calibration offset '%s' to float, using 0",
self.device_name,
state.attributes.get("offset_celsius"),
)
return 0.0
async def get_offset_step(self, entity_id):
"""Get offset step."""
return float(0.01)
return 0.01
async def get_min_offset(self, entity_id):
@@ -50,10 +72,8 @@ async def get_max_offset(self, entity_id):
async def set_offset(self, entity_id, offset):
"""Set new target offset."""
if offset >= 10:
offset = 10
if offset <= -10:
offset = -10
offset = min(10, offset)
offset = max(-10, offset)
await self.hass.services.async_call(
"tado",
"set_climate_temperature_offset",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,19 +1,37 @@
"""Device trigger helpers for Better Thermostat.
This module implements the device trigger helpers and validators used by
Home Assistant's device trigger integration for Better Thermostat climate
devices.
Purpose-specific triggers (HA 2025.12+):
- heating_active hvac_action becomes "heating"
- heating_stopped hvac_action leaves "heating"
- humidity_high humidity exceeds a configurable threshold (default 60 %)
- window_opened window_open attribute becomes True
- window_closed window_open attribute becomes False
- battery_low minimum TRV battery level drops below threshold (default 20 %)
- device_error at least one device error is present
- target_temp_reached current temperature reaches the target temperature
Classic triggers (kept for backwards compatibility):
- hvac_mode_changed
- current_temperature_changed
- current_humidity_changed
"""
from __future__ import annotations
import voluptuous as vol
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_registry
from homeassistant.components.climate.const import HVAC_MODES
from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
from homeassistant.components.homeassistant.triggers import (
numeric_state as numeric_state_trigger,
state as state_trigger,
)
from . import DOMAIN
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from homeassistant.components.climate.const import HVAC_MODES
from homeassistant.components.homeassistant.triggers.state import CONF_FROM, CONF_TO
from homeassistant.const import (
CONF_ABOVE,
CONF_ATTRIBUTE,
CONF_BELOW,
CONF_DEVICE_ID,
CONF_DOMAIN,
@@ -23,46 +41,128 @@ from homeassistant.const import (
CONF_TYPE,
PERCENTAGE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_registry
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
import voluptuous as vol
from . import DOMAIN
# ---------------------------------------------------------------------------
# All supported trigger types
# ---------------------------------------------------------------------------
# Purpose-specific (new in HA 2025.12)
_PURPOSE_TRIGGER_TYPES = {
"heating_active",
"heating_stopped",
"humidity_high",
"window_opened",
"window_closed",
"battery_low",
"device_error",
"target_temp_reached",
}
# Legacy triggers (kept for backwards compatibility)
_CLASSIC_TRIGGER_TYPES = {
"hvac_mode_changed",
"current_temperature_changed",
"current_humidity_changed",
}
TRIGGER_TYPES = _PURPOSE_TRIGGER_TYPES | _CLASSIC_TRIGGER_TYPES
# ---------------------------------------------------------------------------
# Static TRIGGER_SCHEMA required by HA 2025.12 device automation framework.
# Extra fields are validated dynamically via async_get_trigger_capabilities.
# ---------------------------------------------------------------------------
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES),
# Fields used by classic triggers
vol.Optional(CONF_TO): vol.Any(str, [str]),
# Fields used by numeric triggers
vol.Optional(CONF_ABOVE): vol.Coerce(float),
vol.Optional(CONF_BELOW): vol.Coerce(float),
# Shared optional field
vol.Optional(CONF_FOR): cv.positive_time_period_dict,
}
)
# ---------------------------------------------------------------------------
# Default threshold values
# ---------------------------------------------------------------------------
DEFAULT_HUMIDITY_THRESHOLD = 60.0 # %
DEFAULT_BATTERY_THRESHOLD = 20.0 # %
# Temperature delta at which "target reached" fires (current - target >= value)
TARGET_REACHED_DELTA = 0.0 # °C / °F
async def async_get_triggers(
hass: HomeAssistant, device_id: str
) -> list[dict[str, str]]:
) -> list[dict[str, str | dict[str, bool]]]:
"""List device triggers for Better Thermostat devices."""
registry = entity_registry.async_get(hass)
triggers = []
triggers: list[dict[str, str | dict[str, bool]]] = []
# Get all the integrations entities for this device
for entry in entity_registry.async_entries_for_device(registry, device_id):
if entry.domain != DOMAIN:
continue
state = hass.states.get(entry.entity_id)
if not state:
if not hass.states.get(entry.entity_id):
continue
base_trigger = {
base = {
CONF_PLATFORM: "device",
CONF_DEVICE_ID: device_id,
CONF_DOMAIN: DOMAIN,
CONF_ENTITY_ID: entry.entity_id,
}
# Add standard climate triggers
# ------------------------------------------------------------------
# Purpose-specific triggers (primary shown first in the UI)
# ------------------------------------------------------------------
primary_types = [
"heating_active",
"heating_stopped",
"window_opened",
"window_closed",
"target_temp_reached",
"device_error",
]
for trigger_type in primary_types:
triggers.append(
{**base, CONF_TYPE: trigger_type, "metadata": {"secondary": False}}
)
# ------------------------------------------------------------------
# Purpose-specific triggers (secondary sensor / diagnostic info)
# ------------------------------------------------------------------
secondary_types = ["humidity_high", "battery_low"]
for trigger_type in secondary_types:
triggers.append(
{**base, CONF_TYPE: trigger_type, "metadata": {"secondary": True}}
)
# ------------------------------------------------------------------
# Classic / legacy triggers
# ------------------------------------------------------------------
triggers.extend(
[
{
**base_trigger,
**base,
CONF_TYPE: "hvac_mode_changed",
"metadata": {"secondary": False},
"metadata": {"secondary": True},
},
{
**base_trigger,
**base,
CONF_TYPE: "current_temperature_changed",
"metadata": {"secondary": False},
"metadata": {"secondary": True},
},
{
**base_trigger,
**base,
CONF_TYPE: "current_humidity_changed",
"metadata": {"secondary": True},
},
@@ -78,15 +178,211 @@ async def async_attach_trigger(
action: TriggerActionType,
trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
if (trigger_type := config[CONF_TYPE]) == "hvac_mode_changed":
"""Attach a trigger and return an unsubscribe callback."""
trigger_type: str = config[CONF_TYPE]
entity_id: str = config[CONF_ENTITY_ID]
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _build_state(
attribute: str, to: str | None = None, from_: str | None = None
) -> dict:
cfg: dict = {
state_trigger.CONF_PLATFORM: "state",
state_trigger.CONF_ENTITY_ID: entity_id,
CONF_ATTRIBUTE: attribute,
}
if to is not None:
cfg[CONF_TO] = to
if from_ is not None:
cfg[CONF_FROM] = from_
if CONF_FOR in config:
cfg[CONF_FOR] = config[CONF_FOR]
return cfg
def _build_numeric(template: str) -> dict:
cfg: dict = {
numeric_state_trigger.CONF_PLATFORM: "numeric_state",
numeric_state_trigger.CONF_ENTITY_ID: entity_id,
numeric_state_trigger.CONF_VALUE_TEMPLATE: template,
}
if CONF_ABOVE in config:
cfg[CONF_ABOVE] = config[CONF_ABOVE]
if CONF_BELOW in config:
cfg[CONF_BELOW] = config[CONF_BELOW]
if CONF_FOR in config:
cfg[CONF_FOR] = config[CONF_FOR]
return cfg
# ------------------------------------------------------------------
# Purpose-specific trigger: heating_active
# Fires when hvac_action changes TO "heating".
# ------------------------------------------------------------------
if trigger_type == "heating_active":
state_config = _build_state("hvac_action", to="heating")
state_config = await state_trigger.async_validate_trigger_config(
hass, state_config
)
return await state_trigger.async_attach_trigger(
hass, state_config, action, trigger_info, platform_type="device"
)
# ------------------------------------------------------------------
# Purpose-specific trigger: heating_stopped
# Fires when hvac_action changes FROM "heating" to anything else.
# ------------------------------------------------------------------
if trigger_type == "heating_stopped":
state_config = _build_state("hvac_action", from_="heating")
state_config = await state_trigger.async_validate_trigger_config(
hass, state_config
)
return await state_trigger.async_attach_trigger(
hass, state_config, action, trigger_info, platform_type="device"
)
# ------------------------------------------------------------------
# Purpose-specific trigger: window_opened
# Fires when window_open attribute becomes truthy (True).
# Uses a numeric template to avoid bool→string comparison issues.
# ------------------------------------------------------------------
if trigger_type == "window_opened":
numeric_config = {
numeric_state_trigger.CONF_PLATFORM: "numeric_state",
numeric_state_trigger.CONF_ENTITY_ID: entity_id,
numeric_state_trigger.CONF_VALUE_TEMPLATE: (
"{{ 1 if state.attributes.get('window_open') else 0 }}"
),
CONF_ABOVE: 0.5,
}
if CONF_FOR in config:
numeric_config[CONF_FOR] = config[CONF_FOR]
numeric_config = await numeric_state_trigger.async_validate_trigger_config(
hass, numeric_config
)
return await numeric_state_trigger.async_attach_trigger(
hass, numeric_config, action, trigger_info, platform_type="device"
)
# ------------------------------------------------------------------
# Purpose-specific trigger: window_closed
# Fires when window_open attribute becomes falsy (False / None).
# ------------------------------------------------------------------
if trigger_type == "window_closed":
numeric_config = {
numeric_state_trigger.CONF_PLATFORM: "numeric_state",
numeric_state_trigger.CONF_ENTITY_ID: entity_id,
numeric_state_trigger.CONF_VALUE_TEMPLATE: (
"{{ 1 if state.attributes.get('window_open') else 0 }}"
),
CONF_BELOW: 0.5,
}
if CONF_FOR in config:
numeric_config[CONF_FOR] = config[CONF_FOR]
numeric_config = await numeric_state_trigger.async_validate_trigger_config(
hass, numeric_config
)
return await numeric_state_trigger.async_attach_trigger(
hass, numeric_config, action, trigger_info, platform_type="device"
)
# ------------------------------------------------------------------
# Purpose-specific trigger: humidity_high
# Fires when the BT humidity attribute exceeds the threshold.
# Threshold is configurable (CONF_ABOVE); default is DEFAULT_HUMIDITY_THRESHOLD.
# ------------------------------------------------------------------
if trigger_type == "humidity_high":
numeric_config = _build_numeric(
"{{ state.attributes.get('humidity', 0) | float(0) }}"
)
if CONF_ABOVE not in numeric_config:
numeric_config[CONF_ABOVE] = DEFAULT_HUMIDITY_THRESHOLD
numeric_config = await numeric_state_trigger.async_validate_trigger_config(
hass, numeric_config
)
return await numeric_state_trigger.async_attach_trigger(
hass, numeric_config, action, trigger_info, platform_type="device"
)
# ------------------------------------------------------------------
# Purpose-specific trigger: battery_low
# Fires when the minimum TRV battery level drops below the threshold.
# Threshold is configurable (CONF_BELOW); default is DEFAULT_BATTERY_THRESHOLD.
# Template extracts the minimum 'battery' value from the batteries JSON dict.
# ------------------------------------------------------------------
if trigger_type == "battery_low":
battery_template = (
"{%- set bat = state.attributes.get('batteries', '{}') | from_json -%}"
"{%- set levels = bat.values() | map(attribute='battery') | reject('none') | list -%}"
"{{ (levels | min) if levels else 101 }}"
)
numeric_config = _build_numeric(battery_template)
if CONF_BELOW not in numeric_config:
numeric_config[CONF_BELOW] = DEFAULT_BATTERY_THRESHOLD
numeric_config = await numeric_state_trigger.async_validate_trigger_config(
hass, numeric_config
)
return await numeric_state_trigger.async_attach_trigger(
hass, numeric_config, action, trigger_info, platform_type="device"
)
# ------------------------------------------------------------------
# Purpose-specific trigger: device_error
# Fires when the errors attribute contains at least one entry.
# ------------------------------------------------------------------
if trigger_type == "device_error":
error_template = (
"{{ (state.attributes.get('errors', '[]') | from_json | length) }}"
)
numeric_config = {
numeric_state_trigger.CONF_PLATFORM: "numeric_state",
numeric_state_trigger.CONF_ENTITY_ID: entity_id,
numeric_state_trigger.CONF_VALUE_TEMPLATE: error_template,
CONF_ABOVE: 0,
}
if CONF_FOR in config:
numeric_config[CONF_FOR] = config[CONF_FOR]
numeric_config = await numeric_state_trigger.async_validate_trigger_config(
hass, numeric_config
)
return await numeric_state_trigger.async_attach_trigger(
hass, numeric_config, action, trigger_info, platform_type="device"
)
# ------------------------------------------------------------------
# Purpose-specific trigger: target_temp_reached
# Fires when current_temperature >= target_temperature.
# The template computes (current - target); triggers when value >= TARGET_REACHED_DELTA.
# ------------------------------------------------------------------
if trigger_type == "target_temp_reached":
reached_template = (
"{{ (state.attributes.get('current_temperature', 0) | float(0))"
" - (state.attributes.get('temperature', 0) | float(0)) }}"
)
numeric_config = {
numeric_state_trigger.CONF_PLATFORM: "numeric_state",
numeric_state_trigger.CONF_ENTITY_ID: entity_id,
numeric_state_trigger.CONF_VALUE_TEMPLATE: reached_template,
CONF_ABOVE: TARGET_REACHED_DELTA,
}
if CONF_FOR in config:
numeric_config[CONF_FOR] = config[CONF_FOR]
numeric_config = await numeric_state_trigger.async_validate_trigger_config(
hass, numeric_config
)
return await numeric_state_trigger.async_attach_trigger(
hass, numeric_config, action, trigger_info, platform_type="device"
)
# ------------------------------------------------------------------
# Classic trigger: hvac_mode_changed
# ------------------------------------------------------------------
if trigger_type == "hvac_mode_changed":
state_config = {
state_trigger.CONF_PLATFORM: "state",
state_trigger.CONF_ENTITY_ID: config[CONF_ENTITY_ID],
state_trigger.CONF_TO: config[state_trigger.CONF_TO],
state_trigger.CONF_FROM: [
mode for mode in HVAC_MODES if mode != config[state_trigger.CONF_TO]
],
state_trigger.CONF_ENTITY_ID: entity_id,
CONF_TO: config[CONF_TO],
CONF_FROM: [mode for mode in HVAC_MODES if mode != config[CONF_TO]],
}
if CONF_FOR in config:
state_config[CONF_FOR] = config[CONF_FOR]
@@ -97,53 +393,97 @@ async def async_attach_trigger(
hass, state_config, action, trigger_info, platform_type="device"
)
numeric_state_config = {
numeric_state_trigger.CONF_PLATFORM: "numeric_state",
numeric_state_trigger.CONF_ENTITY_ID: config[CONF_ENTITY_ID],
}
# ------------------------------------------------------------------
# Classic triggers: current_temperature_changed / current_humidity_changed
# ------------------------------------------------------------------
if trigger_type == "current_temperature_changed":
numeric_state_config[numeric_state_trigger.CONF_VALUE_TEMPLATE] = (
"{{ state.attributes.current_temperature }}"
)
template = "{{ state.attributes.current_temperature }}"
else:
numeric_state_config[numeric_state_trigger.CONF_VALUE_TEMPLATE] = (
"{{ state.attributes.current_humidity }}"
)
template = "{{ state.attributes.current_humidity }}"
if CONF_ABOVE in config:
numeric_state_config[CONF_ABOVE] = config[CONF_ABOVE]
if CONF_BELOW in config:
numeric_state_config[CONF_BELOW] = config[CONF_BELOW]
if CONF_FOR in config:
numeric_state_config[CONF_FOR] = config[CONF_FOR]
numeric_state_config = await numeric_state_trigger.async_validate_trigger_config(
hass, numeric_state_config
numeric_config = _build_numeric(template)
numeric_config = await numeric_state_trigger.async_validate_trigger_config(
hass, numeric_config
)
return await numeric_state_trigger.async_attach_trigger(
hass, numeric_state_config, action, trigger_info, platform_type="device"
hass, numeric_config, action, trigger_info, platform_type="device"
)
async def async_get_trigger_capabilities(
hass: HomeAssistant, config: ConfigType
) -> dict[str, vol.Schema]:
"""List trigger capabilities."""
"""List trigger capabilities (extra fields shown in the automation editor)."""
trigger_type = config[CONF_TYPE]
if trigger_type == "hvac_mode_changed":
# ------------------------------------------------------------------
# Triggers with a "for" duration option only
# ------------------------------------------------------------------
if trigger_type in {
"heating_active",
"heating_stopped",
"window_opened",
"window_closed",
"device_error",
"target_temp_reached",
}:
return {
"extra_fields": vol.Schema(
{vol.Optional(CONF_FOR): cv.positive_time_period_dict}
)
}
# ------------------------------------------------------------------
# humidity_high: configurable threshold + duration
# ------------------------------------------------------------------
if trigger_type == "humidity_high":
return {
"extra_fields": vol.Schema(
{
vol.Required(state_trigger.CONF_TO): vol.In(HVAC_MODES),
vol.Optional(
CONF_ABOVE,
description={"suffix": PERCENTAGE},
default=DEFAULT_HUMIDITY_THRESHOLD,
): vol.Coerce(float),
vol.Optional(CONF_FOR): cv.positive_time_period_dict,
}
)
}
# Temperature and humidity triggers use the same schema
if trigger_type in ["current_temperature_changed", "current_humidity_changed"]:
# ------------------------------------------------------------------
# battery_low: configurable threshold + duration
# ------------------------------------------------------------------
if trigger_type == "battery_low":
return {
"extra_fields": vol.Schema(
{
vol.Optional(
CONF_BELOW,
description={"suffix": PERCENTAGE},
default=DEFAULT_BATTERY_THRESHOLD,
): vol.Coerce(float),
vol.Optional(CONF_FOR): cv.positive_time_period_dict,
}
)
}
# ------------------------------------------------------------------
# Classic trigger: hvac_mode_changed
# ------------------------------------------------------------------
if trigger_type == "hvac_mode_changed":
return {
"extra_fields": vol.Schema(
{
vol.Required(CONF_TO): vol.In(HVAC_MODES),
vol.Optional(CONF_FOR): cv.positive_time_period_dict,
}
)
}
# ------------------------------------------------------------------
# Classic triggers: temperature / humidity value thresholds
# ------------------------------------------------------------------
if trigger_type in {"current_temperature_changed", "current_humidity_changed"}:
unit = (
hass.config.units.temperature_unit
if trigger_type == "current_temperature_changed"
@@ -3,7 +3,7 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, State
from .utils.const import CONF_HEATER, CONF_SENSOR, CONF_SENSOR_WINDOW
@@ -31,7 +31,7 @@ async def async_get_config_entry_diagnostics(
}
external_temperature = hass.states.get(config_entry.data[CONF_SENSOR])
window = "-"
window: str | State | None = "-"
window_entity_id = config_entry.data.get(CONF_SENSOR_WINDOW, False)
if window_entity_id:
try:
@@ -1,16 +1,16 @@
import asyncio
"""Cooler event handlers for the Better Thermostat integration.
Contains the event handler that reacts to changes in the configured cooler
entity and updates the integration state accordingly.
"""
import logging
from homeassistant.components.climate.const import (
ATTR_HVAC_ACTION,
HVACAction,
HVACMode,
)
from homeassistant.components.climate.const import HVACMode
from homeassistant.core import State, callback
from homeassistant.components.group.util import find_state_attributes
from custom_components.better_thermostat.utils.helpers import convert_to_float
_LOGGER = logging.getLogger(__name__)
@@ -21,21 +21,33 @@ async def trigger_cooler_change(self, event):
return
if self.control_queue_task is None:
return
asyncio.create_task(update_hvac_action(self))
_main_change = False
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
entity_id = event.data.get("entity_id")
if None in (new_state, old_state, new_state.attributes):
if new_state is None or old_state is None:
_LOGGER.debug(
f"better_thermostat {self.device_name}: Cooler {entity_id} update contained not all necessary data for processing, skipping"
"better_thermostat %s: Cooler %s update contained not all "
"necessary data for processing, skipping",
self.device_name,
entity_id,
)
return
if not isinstance(new_state, State) or not isinstance(old_state, State):
_LOGGER.debug(
f"better_thermostat {self.device_name}: Cooler {entity_id} update contained not a State, skipping"
"better_thermostat %s: Cooler %s update contained not a State, skipping",
self.device_name,
entity_id,
)
return
if new_state.attributes is None:
_LOGGER.debug(
"better_thermostat %s: Cooler %s update had no attributes, skipping",
self.device_name,
entity_id,
)
return
# set context HACK TO FIND OUT IF AN EVENT WAS SEND BY BT
@@ -45,37 +57,43 @@ async def trigger_cooler_change(self, event):
return
_LOGGER.debug(
f"better_thermostat {self.device_name}: Cooler {entity_id} update received"
"better_thermostat %s: Cooler %s update received", self.device_name, entity_id
)
_main_key = "temperature"
if "temperature" not in old_state.attributes:
_main_key = "target_temp_high"
def _get_cooling_setpoint(state):
"""Extract cooling setpoint from state, checking both attribute keys."""
for key in ("temperature", "target_temp_high"):
if key in state.attributes:
return convert_to_float(
str(state.attributes[key]),
self.device_name,
"trigger_cooler_change()",
)
return None
_old_cooling_setpoint = convert_to_float(
str(old_state.attributes.get(_main_key, None)),
self.device_name,
"trigger_cooler_change()",
)
_new_cooling_setpoint = convert_to_float(
str(new_state.attributes.get(_main_key, None)),
self.device_name,
"trigger_cooler_change()",
)
_old_cooling_setpoint = _get_cooling_setpoint(old_state)
_new_cooling_setpoint = _get_cooling_setpoint(new_state)
if (
_new_cooling_setpoint is not None
and _old_cooling_setpoint is not None
and self.bt_hvac_mode is not HVACMode.OFF
and self.bt_hvac_mode != HVACMode.OFF
):
_LOGGER.debug(
f"better_thermostat {self.device_name}: trigger_cooler_change / _old_cooling_setpoint: {_old_cooling_setpoint} - _new_cooling_setpoint: {_new_cooling_setpoint}"
"better_thermostat %s: trigger_cooler_change / "
"_old_cooling_setpoint: %s - _new_cooling_setpoint: %s",
self.device_name,
_old_cooling_setpoint,
_new_cooling_setpoint,
)
if (
_new_cooling_setpoint < self.bt_min_temp
or self.bt_max_temp < _new_cooling_setpoint
):
_LOGGER.warning(
f"better_thermostat {self.device_name}: New Cooler {entity_id} setpoint outside of range, overwriting it"
"better_thermostat %s: New Cooler %s setpoint outside of range, "
"overwriting it",
self.device_name,
entity_id,
)
if _new_cooling_setpoint < self.bt_min_temp:
@@ -85,7 +103,10 @@ async def trigger_cooler_change(self, event):
self.bt_target_cooltemp = _new_cooling_setpoint
if self.bt_target_temp >= self.bt_target_cooltemp:
self.bt_target_temp = self.bt_target_cooltemp - self.bt_target_temp_step
self.bt_target_temp = max(
self.bt_target_cooltemp - max(self.bt_target_temp_step or 0.5, 0.5),
self.bt_min_temp,
)
_main_change = True
if _main_change is True:
@@ -93,37 +114,3 @@ async def trigger_cooler_change(self, event):
return await self.control_queue_task.put(self)
self.async_write_ha_state()
return
async def update_hvac_action(self):
"""Update the hvac action."""
if self.startup_running or self.control_queue_task is None:
return
hvac_action = None
states = [
state
for entity_id in [self.cooler_entity_id]
if (state := self.hass.states.get(entity_id)) is not None
]
hvac_actions = list(find_state_attributes(states, ATTR_HVAC_ACTION))
if not hvac_actions:
self.attr_hvac_action = None
elif all(a == HVACAction.OFF for a in hvac_actions):
hvac_action = HVACAction.OFF
elif self.bt_target_cooltemp < self.cur_temp and self.window_open is False:
hvac_action = HVACAction.COOLING
elif (
self.bt_target_cooltemp < self.cur_temp
and self.attr_hvac_action == HVACAction.COOLING
and self.window_open is False
):
hvac_action = HVACAction.COOLING
else:
hvac_action = HVACAction.IDLE
if self.hvac_action != hvac_action:
self.attr_hvac_action = hvac_action
await self.async_update_ha_state(force_refresh=True)
@@ -1,16 +1,165 @@
import logging
"""External temperature event handlers for Better Thermostat.
from custom_components.better_thermostat.utils.const import CONF_HOMEMATICIP
from ..utils.helpers import convert_to_float
from datetime import datetime
from homeassistant.helpers import issue_registry as ir
This module includes logic to handle external temperature updates and apply
debounce, anti-flicker, accumulation, and plateau acceptance heuristics used
to make robust decisions about whether the external temperature should be
propagated to the target devices.
"""
import logging
import math
from time import monotonic
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import callback
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.event import async_call_later
from homeassistant.util import dt as dt_util
from custom_components.better_thermostat.utils.const import CONF_HOMEMATICIP
from custom_components.better_thermostat.utils.helpers import (
convert_to_float_celsius,
is_reasonable_temperature,
)
_LOGGER = logging.getLogger(__name__)
# is ignored for this time window (seconds).
FLICKER_REVERT_WINDOW = 45 # can optionally be made configurable later
# Accept sub-threshold changes if the new value stays stable for this window (seconds)
PLATEAU_ACCEPT_WINDOW = 120
def _update_external_temp_ema(self, temp_q: float) -> float:
"""Update and return EMA-filtered external temperature.
Uses a time-based EMA so varying sensor update intervals behave sensibly.
Tunables (optional attributes on `self`):
- `external_temp_ema_tau_s` (float): time constant in seconds (e.g. 900=15min, 1800=30min)
"""
tau_s = float(self.external_temp_ema_tau_s or 300.0)
if tau_s <= 0:
tau_s = 300.0
now_m = monotonic()
prev_ts = self._external_temp_ema_ts
prev_ema = self.external_temp_ema
if prev_ts is None or prev_ema is None:
ema = float(temp_q)
else:
dt_s = max(0.0, float(now_m) - float(prev_ts))
# alpha = 1 - exp(-dt/tau)
alpha = 1.0 - math.exp(-dt_s / tau_s) if dt_s > 0 else 0.0
ema = float(prev_ema) + alpha * (float(temp_q) - float(prev_ema))
_LOGGER.debug(
"better_thermostat %s: EMA calc: prev=%.3f input=%.3f dt=%.1fs alpha=%.4f -> new=%.3f",
self.device_name,
float(prev_ema),
float(temp_q),
dt_s,
alpha,
ema,
)
self._external_temp_ema_ts = now_m
self.external_temp_ema = ema
# Expose a generic name so consumers don't need to know EMA vs SMA
self.cur_temp_filtered = round(float(ema), 2)
return float(ema)
async def _apply_temperature_update(self, new_temp):
"""Apply the new external temperature and trigger updates."""
_LOGGER.debug(
"better_thermostat %s: _apply_temperature_update called with %.2f",
self.device_name,
new_temp,
)
_cur_q = None if self.cur_temp is None else round(self.cur_temp, 2)
new_temp_q = round(new_temp, 2)
# Remember previous value as stable pre-measure before updating
if _cur_q is not None and _cur_q != new_temp_q:
self.prev_stable_temp = _cur_q
# Richtung merken (nur bei echter Änderung)
if _cur_q is not None:
if new_temp_q > _cur_q:
self.last_change_direction = 1
elif new_temp_q < _cur_q:
self.last_change_direction = -1
self.cur_temp = new_temp_q
self.last_known_external_temp = new_temp_q
# Update EMA (useful if called from timer after delay)
try:
_update_external_temp_ema(self, float(new_temp_q))
except Exception as exc:
_LOGGER.debug(
"better_thermostat %s: EMA update failed (non-critical): %s",
self.device_name,
exc,
)
_ema = self.external_temp_ema
self.last_external_sensor_change = dt_util.now()
# Reset accumulation & pending after accept
self.accum_delta = 0.0
self.accum_dir = 0
self.pending_temp = None
self.pending_since = None
# Cancel any pending plateau timer
if getattr(self, "plateau_timer_cancel", None) is not None:
self.plateau_timer_cancel()
self.plateau_timer_cancel = None
self.async_write_ha_state()
if _ema is not None:
_LOGGER.debug(
"better_thermostat %s: external_temperature filtered (ema_tau_s=%s) raw=%.2f ema=%.2f",
self.device_name,
self.external_temp_ema_tau_s,
float(new_temp_q),
float(_ema),
)
# Schreibe den von BT verwendeten Wert (self.cur_temp) ins TRV
try:
trv_ids = list(self.real_trvs.keys())
if not trv_ids and hasattr(self, "entity_ids"):
trv_ids = list(self.entity_ids or [])
for trv_id in trv_ids:
quirks = (
self.real_trvs.get(trv_id, {}).get("model_quirks")
if hasattr(self, "real_trvs")
else None
)
if quirks and hasattr(quirks, "maybe_set_external_temperature"):
await quirks.maybe_set_external_temperature(self, trv_id, self.cur_temp)
else:
_LOGGER.debug(
"better_thermostat %s: no quirks with maybe_set_external_temperature for %s",
self.device_name,
trv_id,
)
except (AttributeError, KeyError, TypeError, ValueError, RuntimeError):
_LOGGER.debug(
"better_thermostat %s: external_temperature write to TRV failed (non critical)",
self.device_name,
)
# Enqueue control action (skip during valve maintenance to avoid overwriting exercise).
# Still mark that a control cycle is needed after maintenance so we immediately
# resume with the latest temperature.
if self.control_queue_task is not None:
if getattr(self, "in_maintenance", False):
self._control_needed_after_maintenance = True
else:
await self.control_queue_task.put(self)
_LOGGER.debug(
"better_thermostat %s: _apply_temperature_update finished", self.device_name
)
@callback
async def trigger_temperature_change(self, event):
"""Handle temperature changes.
@@ -33,49 +182,379 @@ async def trigger_temperature_change(self, event):
if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None):
return
_incoming_temperature = convert_to_float(
str(new_state.state), self.device_name, "external_temperature"
_incoming_temperature = convert_to_float_celsius(
str(new_state.state),
self.device_name,
"external_temperature",
unit_of_measurement=new_state.attributes.get("unit_of_measurement"),
)
# Quantisiere auf 2 Dezimalstellen, um FP-Artefakte zu vermeiden
_incoming_temperature_q = (
None if _incoming_temperature is None else round(_incoming_temperature, 2)
)
_time_diff = 60
# Ensure timestamp exists (first run guard)
if self.last_external_sensor_change is None:
# Setze einen alten Zeitpunkt, damit erste Änderung akzeptiert wird
self.last_external_sensor_change = dt_util.now()
# Basis-Debounce (Sekunden) für normale Geräte; durch Anti-Flicker können wir hier auf 5s runter
# gesetzt werden. HomematicIP erhält unten weiterhin ein höheres Intervall (600s).
_time_diff = 5
# Signifikanz-Schwelle: 0.11°C (um 0.1°C Rauschen zu filtern).
# Wir ignorieren die Toleranz-Einstellung hier, um auch bei größerer Regel-Toleranz
# präzise Sensor-Updates zu erhalten.
_sig_threshold = 0.11
try:
for trv in self.all_trvs:
if trv["advanced"][CONF_HOMEMATICIP]:
_time_diff = 600
except KeyError:
except (KeyError, TypeError):
pass
if _incoming_temperature is None or _incoming_temperature < -50:
# raise a ha repair notication
if not is_reasonable_temperature(_incoming_temperature_q):
# raise a ha repair notification
_LOGGER.error(
"better_thermostat %s: external_temperature is not a valid number: %s",
"better_thermostat %s: external_temperature %s is outside the "
"plausible range; ignoring (raw state: %s)",
self.device_name,
_incoming_temperature_q,
new_state.state,
)
# Minimal kompatibler Aufruf (Parameter-Namen angepasst an aktuelle HA API)
ir.async_create_issue(
hass=self.hass,
issue_id=f"missing_entity_{self.device_name}",
issue_title=f"better_thermostat {self.device_name} has invalid external_temperature value",
issue_severity="error",
issue_description=f"better_thermostat {self.device_name} has invalid external_temperature: {new_state.state}",
issue_category="config",
issue_suggested_action="Please check the external_temperature sensor",
domain="better_thermostat",
issue_id=f"invalid_external_temperature_{self.device_name}",
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="invalid_external_temperature",
translation_placeholders={
"name": self.device_name,
"value": str(new_state.state),
},
)
return
_now = dt_util.now()
try:
_age = (_now - self.last_external_sensor_change).total_seconds()
except (TypeError, AttributeError): # defensiv, sollte nicht auftreten
_age = 999999
# Gerundete Vergleichswerte
_cur_q = None if self.cur_temp is None else round(self.cur_temp, 2)
_diff = None if _cur_q is None else abs(_incoming_temperature_q - _cur_q)
# Quantisierte Differenz zur robusten Schwellenprüfung (vermeidet 0.099999-Fehler)
_diff_q = None if _diff is None else round(_diff, 2)
_sig_threshold_q = round(_sig_threshold, 2)
_is_significant = _cur_q is None or (
_diff_q is not None and _diff_q >= _sig_threshold_q
)
_interval_ok = _age > _time_diff
# Anti-Flicker: Wenn der neue Wert exakt dem vorherigen stabilen Wert entspricht
# (also ein schneller Rücksprung) UND wir kürzlich erst umgestellt haben,
# dann ignorieren wir diesen Rücksprung bis das Fenster abläuft.
if (
_incoming_temperature != self.cur_temp
and (datetime.now() - self.last_external_sensor_change).total_seconds()
> _time_diff
False # Anti-flicker disabled
and _cur_q is not None
and self.prev_stable_temp is not None
and _incoming_temperature_q == round(self.prev_stable_temp, 2)
and _incoming_temperature_q != _cur_q
and _age < FLICKER_REVERT_WINDOW
):
_LOGGER.debug(
"better_thermostat %s: external_temperature changed from %s to %s",
self.device_name,
self.cur_temp,
_incoming_temperature,
# Plane eine Übernahme nach Ablauf des Revert-Fensters, falls der Sensorwert stabil bleibt
try:
remaining = max(0.0, float(FLICKER_REVERT_WINDOW) - float(_age))
except (ValueError, TypeError):
remaining = float(FLICKER_REVERT_WINDOW)
# Merke Kandidatenwert und cancel ggf. vorherige Planung
cancel_cb = self.flicker_unignore_cancel
if callable(cancel_cb):
cancel_cb()
self.flicker_unignore_cancel = None
self.flicker_candidate = _incoming_temperature_q
def _deadline_cb(_now): # executed by HA loop, schedule async body
async def _apply_if_stable():
try:
# Prüfe aktuellen Sensor-Status
sensor_id = self.sensor_entity_id
state = self.hass.states.get(sensor_id) if sensor_id else None
if state is None or state.state in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
None,
):
return
_val = convert_to_float_celsius(
str(state.state),
self.device_name,
"external_temperature",
unit_of_measurement=state.attributes.get("unit_of_measurement"),
)
_val_q = None if _val is None else round(_val, 2)
cand = self.flicker_candidate
# Übernehme nur, wenn Kandidatwert unverändert und ungleich cur_temp ist
if _val_q is not None and cand is not None and _val_q == cand:
if _val_q != self.cur_temp:
_LOGGER.debug(
"better_thermostat %s: external_temperature flicker revert auto-accepted after %ss (value=%.2f)",
self.device_name,
FLICKER_REVERT_WINDOW,
_val_q,
)
# Akzeptiere Wert wie im normalen Pfad
_prev = self.cur_temp
if _prev is not None and _prev != _val_q:
self.prev_stable_temp = _prev
if _val_q > _prev:
self.last_change_direction = 1
elif _val_q < _prev:
self.last_change_direction = -1
self.cur_temp = _val_q
try:
_update_external_temp_ema(self, float(_val_q))
except Exception:
_LOGGER.debug(
"better_thermostat %s: external_temperature EMA update failed (non critical)",
self.device_name,
)
self.last_external_sensor_change = dt_util.now()
# Reset Anti-Flicker-Akkumulatoren
self.accum_delta = 0.0
self.accum_dir = 0
self.accum_since = dt_util.now()
self.pending_temp = None
self.pending_since = None
self.async_write_ha_state()
# Schreibe TRV-External-Temp über Quirks, falls vorhanden
try:
trv_ids = list(self.real_trvs.keys())
if not trv_ids and hasattr(self, "entity_ids"):
trv_ids = list(self.entity_ids or [])
for trv_id in trv_ids:
quirks = (
self.real_trvs.get(trv_id, {}).get(
"model_quirks"
)
if hasattr(self, "real_trvs")
else None
)
if quirks and hasattr(
quirks, "maybe_set_external_temperature"
):
await quirks.maybe_set_external_temperature(
self, trv_id, self.cur_temp
)
except (
AttributeError,
KeyError,
TypeError,
ValueError,
RuntimeError,
):
_LOGGER.debug(
"better_thermostat %s: external_temperature write to TRV failed (non critical)",
self.device_name,
)
if self.control_queue_task is not None:
await self.control_queue_task.put(self)
finally:
# Aufräumen
self.flicker_unignore_cancel = None
self.flicker_candidate = None
# schedule the async part
self.hass.loop.create_task(_apply_if_stable())
self.flicker_unignore_cancel = async_call_later(
self.hass, remaining + 0.1, _deadline_cb
)
_LOGGER.debug(
"better_thermostat %s: external_temperature flicker revert ignored (current=%.2f revert=%.2f age=%.1fs < %ss)",
self.device_name,
_cur_q,
_incoming_temperature_q,
_age,
FLICKER_REVERT_WINDOW,
)
return
# Richtungswechsel-Schutz: kleine Gegenbewegungen (<= Schwellwert) innerhalb des Flicker-Fensters ignorieren,
# um Ping-Pong um genau 0.10°C zu vermeiden.
_dir_now = 0
if _cur_q is not None:
if _incoming_temperature_q > _cur_q:
_dir_now = 1
elif _incoming_temperature_q < _cur_q:
_dir_now = -1
_last_dir = self.last_change_direction
_block_flip_small = (
False # Anti-flicker disabled # noqa: PLR1714
and _dir_now != 0
and _last_dir != 0
and _dir_now != _last_dir
and _diff_q is not None
and _diff_q <= _sig_threshold_q
and _age < FLICKER_REVERT_WINDOW
)
if _block_flip_small:
_LOGGER.debug(
"better_thermostat %s: external_temperature opposite-direction change ignored "
"(current=%.2f new=%.2f diff=%.2f age=%.1fs <= %ss threshold=%.2f)",
self.device_name,
(_cur_q if _cur_q is not None else float("nan")),
_incoming_temperature_q,
(_diff if _diff is not None else float("nan")),
_age,
FLICKER_REVERT_WINDOW,
_sig_threshold_q,
)
return
# Slope calculation (simple delta per minute)
# Disabled in favor of periodic EMA-based slope calculation in climate.py
# try:
# now_m = monotonic()
# _last_ts = getattr(self, "_slope_last_ts", None)
# if _last_ts is not None and _cur_q is not None:
# dt_min = max(1e-6, (now_m - _last_ts) / 60.0)
# dT = _incoming_temperature_q - _cur_q # K
# inst_slope = dT / dt_min # K/min
# # light smoothing
# if getattr(self, "temp_slope", None) is None:
# self.temp_slope = inst_slope
# else:
# self.temp_slope = 0.7 * self.temp_slope + 0.3 * inst_slope
# setattr(self, "_slope_last_ts", now_m)
# except (AttributeError, TypeError, ZeroDivisionError):
# pass
# Accumulation of small changes in the same direction
_accept_reason = None
if _cur_q is not None:
_signed_delta = round(_incoming_temperature_q - _cur_q, 2)
if _signed_delta != 0:
# set direction from sign
_acc_dir_now = 1 if _signed_delta > 0 else -1
if self.accum_dir in (0, _acc_dir_now):
self.accum_delta = round(self.accum_delta + _signed_delta, 2)
self.accum_dir = _acc_dir_now if self.accum_dir == 0 else self.accum_dir
else:
# direction flipped: reset accumulation to current delta
self.accum_delta = _signed_delta
self.accum_dir = _acc_dir_now
# Plateau tracking
if self.pending_temp != _incoming_temperature_q:
self.pending_temp = _incoming_temperature_q
self.pending_since = dt_util.now()
# Cancel existing timer if pending value changes
if getattr(self, "plateau_timer_cancel", None) is not None:
self.plateau_timer_cancel()
self.plateau_timer_cancel = None
# no change (value back to current): reset pending/timer
elif self.pending_temp is not None:
self.pending_temp = None
self.pending_since = None
if getattr(self, "plateau_timer_cancel", None) is not None:
self.plateau_timer_cancel()
self.plateau_timer_cancel = None
_accum_ok = (
_cur_q is not None
and abs(self.accum_delta) >= _sig_threshold_q
and _interval_ok
)
# Plateau acceptance: sub-threshold change persisted long enough
_plateau_ok = False
if (
not _is_significant
and _cur_q is not None
and self.pending_temp is not None
and self.pending_temp != _cur_q
and self.pending_since is not None
):
_plateau_age = (dt_util.now() - self.pending_since).total_seconds()
_plateau_ok = _plateau_age >= PLATEAU_ACCEPT_WINDOW and _interval_ok
# Schedule timer if not already scheduled
if not _plateau_ok and getattr(self, "plateau_timer_cancel", None) is None:
remaining = max(0.1, PLATEAU_ACCEPT_WINDOW - _plateau_age)
async def _plateau_cb(_now):
self.plateau_timer_cancel = None
# Re-check debounce interval so HomematicIP 600s is respected
_cb_age = (
(dt_util.now() - self.last_external_sensor_change).total_seconds()
if self.last_external_sensor_change is not None
else 999999
)
_cb_interval_ok = _cb_age > _time_diff
if self.pending_temp is not None and _cb_interval_ok:
_LOGGER.debug(
"better_thermostat %s: external_temperature plateau auto-accepted (value=%.2f)",
self.device_name,
self.pending_temp,
)
await _apply_temperature_update(self, self.pending_temp)
self.plateau_timer_cancel = async_call_later(
self.hass, remaining, _plateau_cb
)
if _cur_q is None:
# First reading ever — always accept regardless of interval
_accept_reason = "first_reading"
elif _is_significant and _interval_ok:
_accept_reason = "significant"
elif _accum_ok:
_accept_reason = "accumulated"
elif _plateau_ok:
_accept_reason = "plateau"
if _accept_reason is not None:
# Verarbeite sofort, wenn Intervall abgelaufen ODER Änderung sehr groß
_LOGGER.debug(
"better_thermostat %s: external_temperature update accepted (old=%.2f new=%.2f diff=%.2f "
"age=%.1fs threshold=%.2f interval=%ss reason=%s accum=%.2f dir=%s)",
self.device_name,
(_cur_q if _cur_q is not None else float("nan")),
_incoming_temperature_q,
(_diff_q if _diff_q is not None else float("nan")),
_age,
_sig_threshold_q,
_time_diff,
_accept_reason,
(self.accum_delta if _cur_q is not None else 0.0),
("+" if self.accum_dir > 0 else ("-" if self.accum_dir < 0 else "0")),
)
await _apply_temperature_update(self, _incoming_temperature_q)
else:
_LOGGER.debug(
"better_thermostat %s: external_temperature ignored (old=%.2f new=%.2f diff=%s "
"age=%.1fs sig=%s interval_ok=%s threshold=%.2f accum=%.2f dir=%s pending=%s pending_age=%ss)",
self.device_name,
(_cur_q if _cur_q is not None else float("nan")),
_incoming_temperature_q,
(f"{_diff_q:.2f}" if _diff_q is not None else "None"),
_age,
_is_significant,
_interval_ok,
_sig_threshold_q,
(self.accum_delta if _cur_q is not None else 0.0),
("+" if self.accum_dir > 0 else ("-" if self.accum_dir < 0 else "0")),
(
f"{self.pending_temp:.2f}"
if isinstance(self.pending_temp, (int, float))
else None
),
(
f"{(dt_util.now() - self.pending_since).total_seconds():.1f}"
if self.pending_since is not None
else None
),
)
self.cur_temp = _incoming_temperature
self.last_external_sensor_change = datetime.now()
self.async_write_ha_state()
await self.control_queue_task.put(self)
+336 -181
View File
@@ -1,30 +1,37 @@
import asyncio
from datetime import datetime
"""TRV event handlers and helpers for better_thermostat.
This module contains the various Home Assistant TRV event handlers and
helper functions used by the Better Thermostat integration to read and
convert thermostat states and prepare outbound payloads.
"""
import logging
from custom_components.better_thermostat.utils.const import CONF_HOMEMATICIP
from homeassistant.components.climate.const import (
HVACMode,
ATTR_HVAC_ACTION,
HVACAction,
)
from homeassistant.components.climate.const import HVACMode
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import State, callback
from homeassistant.components.group.util import find_state_attributes
from custom_components.better_thermostat.utils.helpers import (
convert_to_float,
mode_remap,
)
from homeassistant.util import dt as dt_util
from custom_components.better_thermostat.adapters.delegate import get_current_offset
from custom_components.better_thermostat.utils.const import (
CalibrationType,
CalibrationMode,
)
from custom_components.better_thermostat.calibration import (
calculate_calibration_local,
calculate_calibration_setpoint,
)
from custom_components.better_thermostat.model_fixes.model_quirks import (
load_model_quirks,
)
from custom_components.better_thermostat.utils.const import (
CONF_HOMEMATICIP,
CalibrationMode,
CalibrationType,
)
from custom_components.better_thermostat.utils.helpers import (
attr_to_celsius,
convert_to_float,
get_device_model,
is_reasonable_temperature,
mode_remap,
)
_LOGGER = logging.getLogger(__name__)
@@ -38,21 +45,26 @@ async def trigger_trv_change(self, event):
return
if self.bt_target_temp is None or self.cur_temp is None or self.tolerance is None:
return
asyncio.create_task(update_hvac_action(self))
if self.bt_update_lock:
return
_main_change = False
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
entity_id = event.data.get("entity_id")
if None in (new_state, old_state, new_state.attributes):
if new_state is None or old_state is None or new_state.attributes is None:
_LOGGER.debug(
f"better_thermostat {self.device_name}: TRV {entity_id} update contained not all necessary data for processing, skipping"
"better_thermostat %s: TRV %s update contained not all necessary data for processing, skipping",
self.device_name,
entity_id,
)
return
if not isinstance(new_state, State) or not isinstance(old_state, State):
_LOGGER.debug(
f"better_thermostat {self.device_name}: TRV {entity_id} update contained not a State, skipping"
"better_thermostat %s: TRV %s update contained not a State, skipping",
self.device_name,
entity_id,
)
return
# set context HACK TO FIND OUT IF AN EVENT WAS SEND BY BT
@@ -64,13 +76,79 @@ async def trigger_trv_change(self, event):
# _LOGGER.debug(f"better_thermostat {self.device_name}: TRV {entity_id} update received")
_org_trv_state = self.hass.states.get(entity_id)
if _org_trv_state is None:
_LOGGER.debug(
"better_thermostat %s: TRV %s state not found in registry, skipping",
self.device_name,
entity_id,
)
return
if _org_trv_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
# The device is gone; its last internal temperature must not
# keep feeding the calibration as if it were live.
if self.real_trvs[entity_id].get("current_temperature") is not None:
_LOGGER.debug(
"better_thermostat %s: TRV %s became %s; invalidating its "
"internal temperature",
self.device_name,
entity_id,
_org_trv_state.state,
)
self.real_trvs[entity_id]["current_temperature"] = None
# The next valid reading is the first live data after the
# outage and must not be dropped by the debounce below.
self.real_trvs[entity_id]["accept_next_internal_temp"] = True
return
child_lock = self.real_trvs[entity_id]["advanced"].get("child_lock")
_new_current_temp = convert_to_float(
str(_org_trv_state.attributes.get("current_temperature", None)),
self.device_name,
"TRV_current_temp",
# Dynamische Modell-Erkennung: nur einmalig (z. B. beim Start) nicht bei jedem Event
try:
prev_model = self.real_trvs.get(entity_id, {}).get("model")
if not prev_model:
if _org_trv_state is not None and isinstance(
_org_trv_state.attributes, dict
):
# Nur prüfen, wenn Hinweise vorhanden sind
if (
"model_id" in _org_trv_state.attributes
or "device" in _org_trv_state.attributes
):
detected = await get_device_model(self, entity_id)
if isinstance(detected, str) and detected:
_LOGGER.info(
"better_thermostat %s: TRV %s model detected: %s; "
"loading quirks",
self.device_name,
entity_id,
detected,
)
quirks = await load_model_quirks(self, detected, entity_id)
self.real_trvs[entity_id]["model"] = detected
self.real_trvs[entity_id]["model_quirks"] = quirks
except Exception as e:
_LOGGER.debug(
"better_thermostat %s: dynamic model detection failed for %s: %s",
self.device_name,
entity_id,
e,
)
_new_current_temp = attr_to_celsius(
self, _org_trv_state, "current_temperature", None, "TRV_current_temp"
)
if _new_current_temp is not None and not is_reasonable_temperature(
_new_current_temp
):
_LOGGER.warning(
"better_thermostat %s: TRV %s reports implausible current_temperature "
"%s; ignoring",
self.device_name,
entity_id,
_new_current_temp,
)
_new_current_temp = None
_time_diff = 5
try:
@@ -83,7 +161,8 @@ async def trigger_trv_change(self, event):
_new_current_temp is not None
and self.real_trvs[entity_id]["current_temperature"] != _new_current_temp
and (
(datetime.now() - self.last_internal_sensor_change).total_seconds()
self.real_trvs[entity_id].pop("accept_next_internal_temp", False)
or (dt_util.now() - self.last_internal_sensor_change).total_seconds()
> _time_diff
or (
self.real_trvs[entity_id]["calibration_received"] is False
@@ -94,22 +173,28 @@ async def trigger_trv_change(self, event):
_old_temp = self.real_trvs[entity_id]["current_temperature"]
self.real_trvs[entity_id]["current_temperature"] = _new_current_temp
_LOGGER.debug(
f"better_thermostat {self.device_name}: TRV {entity_id} sends new internal temperature from {_old_temp} to {_new_current_temp}"
"better_thermostat %s: TRV %s sends new internal temperature from %s to %s",
self.device_name,
entity_id,
_old_temp,
_new_current_temp,
)
self.last_internal_sensor_change = datetime.now()
self.last_internal_sensor_change = dt_util.now()
_main_change = True
# TODO: async def in controlling?
# async def in controlling? (left as note)
if self.real_trvs[entity_id]["calibration_received"] is False:
self.real_trvs[entity_id]["calibration_received"] = True
_LOGGER.debug(
f"better_thermostat {self.device_name}: calibration accepted by TRV {entity_id}"
"better_thermostat %s: calibration accepted by TRV %s",
self.device_name,
entity_id,
)
_main_change = False
if self.real_trvs[entity_id]["calibration"] == 0:
self.real_trvs[entity_id]["last_calibration"] = (
await get_current_offset(self, entity_id)
)
self.real_trvs[entity_id][
"last_calibration"
] = await get_current_offset(self, entity_id)
if self.ignore_states:
return
@@ -118,10 +203,41 @@ async def trigger_trv_change(self, event):
mapped_state = convert_inbound_states(self, entity_id, _org_trv_state)
except TypeError:
_LOGGER.debug(
f"better_thermostat {self.device_name}: remapping TRV {entity_id} state failed, skipping"
"better_thermostat %s: remapping TRV %s state failed, skipping",
self.device_name,
entity_id,
)
return
# hvac_action bedingungslos in den Cache schreiben (immer aktuell halten)
try:
hvac_action_attr = _org_trv_state.attributes.get("hvac_action")
if hvac_action_attr is None:
hvac_action_attr = _org_trv_state.attributes.get("action")
if hvac_action_attr is not None:
val = str(hvac_action_attr).strip().lower()
prev = self.real_trvs[entity_id].get("hvac_action")
self.real_trvs[entity_id]["hvac_action"] = val
if prev != val:
_main_change = True
_LOGGER.debug(
"better_thermostat %s: TRV %s hvac_action changed: %s -> %s",
self.device_name,
entity_id,
prev,
val,
)
# valve_position aktualisieren
val_pos = _org_trv_state.attributes.get("valve_position")
if val_pos is not None:
self.real_trvs[entity_id]["valve_position"] = convert_to_float(
str(val_pos), self.device_name, "trv_event"
)
except Exception:
pass
if mapped_state in (HVACMode.OFF, HVACMode.HEAT, HVACMode.HEAT_COOL):
if (
self.real_trvs[entity_id]["hvac_mode"] != _org_trv_state.state
@@ -129,7 +245,12 @@ async def trigger_trv_change(self, event):
):
_old = self.real_trvs[entity_id]["hvac_mode"]
_LOGGER.debug(
f"better_thermostat {self.device_name}: TRV {entity_id} decoded TRV mode changed from {_old} to {_org_trv_state.state} - converted {new_state.state}"
"better_thermostat %s: TRV %s decoded TRV mode changed from %s to %s - converted %s",
self.device_name,
entity_id,
_old,
_org_trv_state.state,
new_state.state,
)
self.real_trvs[entity_id]["hvac_mode"] = _org_trv_state.state
_main_change = True
@@ -140,34 +261,41 @@ async def trigger_trv_change(self, event):
):
self.bt_hvac_mode = mapped_state
# Hinweis: Kein Caching von hvac_action mehr BT liest direkt vom TRV-State in climate.py
_main_key = "temperature"
if "temperature" not in old_state.attributes:
_main_key = "target_temp_low"
_old_heating_setpoint = convert_to_float(
str(old_state.attributes.get(_main_key, None)),
self.device_name,
"trigger_trv_change()",
_old_heating_setpoint = attr_to_celsius(
self, old_state, _main_key, None, "trigger_trv_change()"
)
_new_heating_setpoint = convert_to_float(
str(new_state.attributes.get(_main_key, None)),
self.device_name,
"trigger_trv_change()",
_new_heating_setpoint = attr_to_celsius(
self, new_state, _main_key, None, "trigger_trv_change()"
)
_is_no_off_device = self.real_trvs[entity_id]["advanced"].get(
"no_off_system_mode", False
)
if (
_new_heating_setpoint is not None
and _old_heating_setpoint is not None
and self.bt_hvac_mode is not HVACMode.OFF
and (self.bt_hvac_mode != HVACMode.OFF or _is_no_off_device)
):
_LOGGER.debug(
f"better_thermostat {self.device_name}: trigger_trv_change test / _old_heating_setpoint: {_old_heating_setpoint} - _new_heating_setpoint: {_new_heating_setpoint} - _last_temperature: {self.real_trvs[entity_id]['last_temperature']}"
"better_thermostat %s: trigger_trv_change / _old_heating_setpoint: %s - _new_heating_setpoint: %s - _last_temperature: %s",
self.device_name,
_old_heating_setpoint,
_new_heating_setpoint,
self.real_trvs[entity_id]["last_temperature"],
)
if (
_new_heating_setpoint < self.bt_min_temp
or self.bt_max_temp < _new_heating_setpoint
):
_LOGGER.warning(
f"better_thermostat {self.device_name}: New TRV {entity_id} setpoint outside of range, overwriting it"
"better_thermostat %s: New TRV %s setpoint outside of range, overwriting it",
self.device_name,
entity_id,
)
if _new_heating_setpoint < self.bt_min_temp:
@@ -175,35 +303,97 @@ async def trigger_trv_change(self, event):
else:
_new_heating_setpoint = self.bt_max_temp
if (
self.bt_target_temp != _new_heating_setpoint
and _old_heating_setpoint != _new_heating_setpoint
and self.real_trvs[entity_id]["last_temperature"] != _new_heating_setpoint
# Step-aware echo detection: changes strictly smaller than the device
# step are treated as device-side rounding echoes of a BT-written
# value, not as user input. User input on a TRV display moves the
# setpoint by at least one step.
_step_raw = (
self.real_trvs[entity_id].get("target_temp_step")
or self.bt_target_temp_step
or 0.5
)
try:
_step = float(_step_raw)
except (TypeError, ValueError):
_step = 0.5
if _step <= 0:
_step = 0.5
# Compare only against values BT itself wrote. ``_old_heating_setpoint``
# is the TRV's previously published state and is not necessarily a
# BT-written value, so it does not belong in the echo-suppression set.
_bt_known_values = (
self.bt_target_temp,
self.real_trvs[entity_id]["last_temperature"],
)
_is_echo = any(
v is not None and abs(_new_heating_setpoint - v) < _step
for v in _bt_known_values
)
_accept_user_setpoint = (
not _is_echo
and not child_lock
and self.real_trvs[entity_id]["target_temp_received"] is True
and self.real_trvs[entity_id]["system_mode_received"] is True
and self.real_trvs[entity_id]["hvac_mode"] is not HVACMode.OFF
and self.real_trvs[entity_id]["hvac_mode"] != HVACMode.OFF
and self.window_open is False
):
and not self.real_trvs[entity_id].get("ignore_trv_states", False)
)
if _accept_user_setpoint:
_LOGGER.debug(
f"better_thermostat {self.device_name}: TRV {entity_id} decoded TRV target temp changed from {self.bt_target_temp} to {_new_heating_setpoint}"
"better_thermostat %s: TRV %s decoded TRV target temp changed from %s to %s",
self.device_name,
entity_id,
self.bt_target_temp,
_new_heating_setpoint,
)
self.bt_target_temp = _new_heating_setpoint
if self.cooler_entity_id is not None:
if self.bt_target_temp <= self.bt_target_cooltemp:
self.bt_target_cooltemp = (
self.bt_target_temp - self.bt_target_temp_step
)
if self.bt_target_temp >= self.bt_target_cooltemp:
self.bt_target_cooltemp = (
self.bt_target_temp - self.bt_target_temp_step
self.bt_target_cooltemp = self.bt_target_temp + (
self.bt_target_temp_step or 0.5
)
_main_change = True
elif _new_heating_setpoint != _old_heating_setpoint:
# A setpoint change arrived from the TRV but was not adopted as user
# intent. Record which guard suppressed it so intermittent "change
# ignored" / "device not syncing" reports can be diagnosed from a
# debug log instead of guesswork.
_LOGGER.debug(
"better_thermostat %s: TRV %s setpoint change %s -> %s NOT adopted "
"(echo=%s child_lock=%s target_temp_received=%s system_mode_received=%s "
"hvac_mode=%s window_open=%s ignore_trv_states=%s bt_target_temp=%s "
"last_temperature=%s step=%s)",
self.device_name,
entity_id,
_old_heating_setpoint,
_new_heating_setpoint,
_is_echo,
child_lock,
self.real_trvs[entity_id]["target_temp_received"],
self.real_trvs[entity_id]["system_mode_received"],
self.real_trvs[entity_id]["hvac_mode"],
self.window_open,
self.real_trvs[entity_id].get("ignore_trv_states", False),
self.bt_target_temp,
self.real_trvs[entity_id]["last_temperature"],
_step,
)
if self.real_trvs[entity_id]["advanced"].get("no_off_system_mode", False):
if _new_heating_setpoint == self.real_trvs[entity_id]["min_temp"]:
self.bt_hvac_mode = HVACMode.OFF
# Only set OFF if window is NOT open - min_temp during window
# open was set by BT, not by user turning off heating
if not self.window_open:
if self.bt_hvac_mode != HVACMode.OFF:
_LOGGER.debug(
"better_thermostat %s: TRV %s reported min_temp %s on a "
"no_off_system_mode device -> interpreting as heating OFF",
self.device_name,
entity_id,
_new_heating_setpoint,
)
self.bt_hvac_mode = HVACMode.OFF
else:
self.bt_hvac_mode = HVACMode.HEAT
_main_change = True
@@ -211,76 +401,21 @@ async def trigger_trv_change(self, event):
if _main_change is True:
self.async_write_ha_state()
return await self.control_queue_task.put(self)
self.async_write_ha_state()
return
async def update_hvac_action(self):
self.bt_target_temp = 5.0 if self.bt_target_temp is None else self.bt_target_temp
self.cur_temp = 5.0 if self.cur_temp is None else self.cur_temp
self.tolerance = 0.0 if self.tolerance is None else self.tolerance
"""Update the hvac action."""
if self.startup_running or self.control_queue_task is None:
return
def convert_inbound_states(self, entity_id, state: State) -> str | None:
"""Convert HVAC mode in a thermostat state from Home Assistant.
hvac_action = None
# i don't know why this is here just for hometicip / wtom - 2023-08-23
# for trv in self.all_trvs:
# if trv["advanced"][CONF_HOMEMATICIP]:
# entity_id = trv["trv"]
# state = self.hass.states.get(entity_id)
# if state is None:
# continue
# if state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.HEATING:
# hvac_action = HVACAction.HEATING
# break
# elif state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.IDLE:
# hvac_action = HVACAction.IDLE
# return the most common action if it is not off
states = [
state
for entity_id in self.real_trvs
if (state := self.hass.states.get(entity_id)) is not None
]
hvac_actions = list(find_state_attributes(states, ATTR_HVAC_ACTION))
if not hvac_actions:
self.attr_hvac_action = None
# return action off if all are off
elif all(a == HVACAction.OFF for a in hvac_actions):
hvac_action = HVACAction.OFF
# else check if is heating
elif (
self.bt_target_temp > self.cur_temp + self.tolerance
and self.window_open is False
):
hvac_action = HVACAction.HEATING
elif (
self.bt_target_temp > self.cur_temp
and self.attr_hvac_action == HVACAction.HEATING
and self.window_open is False
):
hvac_action = HVACAction.HEATING
else:
hvac_action = HVACAction.IDLE
if self.hvac_action != hvac_action:
self.attr_hvac_action = hvac_action
await self.async_update_ha_state(force_refresh=True)
def convert_inbound_states(self, entity_id, state: State) -> str:
"""Convert hvac mode in a thermostat state from HA
Parameters
----------
self :
self instance of better_thermostat
self instance of better_thermostat
state : State
Inbound thermostat state, which will be modified
Inbound thermostat state, which will be modified
Returns
-------
Modified state
@@ -300,27 +435,13 @@ def convert_inbound_states(self, entity_id, state: State) -> str:
def convert_outbound_states(self, entity_id, hvac_mode) -> dict | None:
"""Creates the new outbound thermostat state.
Parameters
----------
self :
self instance of better_thermostat
hvac_mode :
the HA mode to convert to
Returns
-------
dict
A dictionary containing the new outbound thermostat state containing the following keys:
temperature: float
local_temperature: float
local_temperature_calibration: float
system_mode: string
None
In case of an error.
"""
"""Convert outbound states for TRV control.
Returns the payload for setting the TRV state.
"""
_new_local_calibration = None
_new_heating_setpoint = None
_new_valve_position = None
try:
_calibration_type = self.real_trvs[entity_id]["advanced"].get("calibration")
@@ -333,57 +454,91 @@ def convert_outbound_states(self, entity_id, hvac_mode) -> dict | None:
"better_thermostat %s: no calibration type found in device config, talking to the TRV using fallback mode",
self.device_name,
)
# Fallback: keine lokale Kalibrierung durchführen, nur Solltemperatur setzen
_new_heating_setpoint = self.bt_target_temp
_new_local_calibration = calculate_calibration_local(self, entity_id)
_new_local_calibration = None
if _new_local_calibration is None:
return None
elif _calibration_type == CalibrationType.LOCAL_BASED:
_new_local_calibration = calculate_calibration_local(self, entity_id)
_new_heating_setpoint = self.bt_target_temp
elif _calibration_type in (
CalibrationType.TARGET_TEMP_BASED,
CalibrationType.DIRECT_VALVE_BASED,
):
if _calibration_mode == CalibrationMode.NO_CALIBRATION:
_new_heating_setpoint = self.bt_target_temp
else:
_new_heating_setpoint = calculate_calibration_setpoint(self, entity_id)
_new_local_calibration = None
else:
if _calibration_type == CalibrationType.LOCAL_BASED:
_new_local_calibration = calculate_calibration_local(self, entity_id)
# Unknown calibration type - use fallback
_LOGGER.warning(
"better_thermostat %s: unknown calibration type %s, using fallback mode",
self.device_name,
_calibration_type,
)
_new_heating_setpoint = self.bt_target_temp
_new_local_calibration = None
_new_heating_setpoint = self.bt_target_temp
# System mode handling - applies to ALL calibration modes including fallback
_system_modes = self.real_trvs[entity_id]["hvac_modes"]
_has_system_mode = _system_modes is not None
elif _calibration_type == CalibrationType.TARGET_TEMP_BASED:
if _calibration_mode == CalibrationMode.NO_CALIBRATION:
_new_heating_setpoint = self.bt_target_temp
else:
_new_heating_setpoint = calculate_calibration_setpoint(
self, entity_id
)
# Normalize without forcing to str to avoid values like "HVACMode.HEAT"
_orig_mode = hvac_mode
hvac_mode = mode_remap(self, entity_id, hvac_mode, False)
_LOGGER.debug(
"better_thermostat %s: convert_outbound_states(%s) system_mode in=%s out=%s",
self.device_name,
entity_id,
_orig_mode,
hvac_mode,
)
_system_modes = self.real_trvs[entity_id]["hvac_modes"]
_has_system_mode = _system_modes is not None
if not _has_system_mode:
_LOGGER.debug(
"better_thermostat %s: device config expects no system mode, while the device has one. Device system mode will be ignored",
self.device_name,
)
if hvac_mode == HVACMode.OFF:
_new_heating_setpoint = self.real_trvs[entity_id]["min_temp"]
hvac_mode = None
_LOGGER.debug(
"better_thermostat %s: convert_outbound_states(%s) suppressing system_mode for no-off device",
self.device_name,
entity_id,
)
if hvac_mode == HVACMode.OFF and (
(_system_modes is not None and HVACMode.OFF not in _system_modes)
or self.real_trvs[entity_id]["advanced"].get("no_off_system_mode")
):
_min_temp = self.real_trvs[entity_id]["min_temp"]
_LOGGER.debug(
"better_thermostat %s: sending %s°C to the TRV because this device has no system mode off and heater should be off",
self.device_name,
_min_temp,
)
_new_heating_setpoint = _min_temp
hvac_mode = None
# Handling different devices with or without system mode reported or contained in the device config
hvac_mode = mode_remap(self, entity_id, str(hvac_mode), False)
if not _has_system_mode:
_LOGGER.debug(
f"better_thermostat {self.device_name}: device config expects no system mode, while the device has one. Device system mode will be ignored"
)
if hvac_mode == HVACMode.OFF:
_new_heating_setpoint = self.real_trvs[entity_id]["min_temp"]
hvac_mode = None
if hvac_mode == HVACMode.OFF and (
HVACMode.OFF not in _system_modes
or self.real_trvs[entity_id]["advanced"].get("no_off_system_mode")
):
_min_temp = self.real_trvs[entity_id]["min_temp"]
_LOGGER.debug(
f"better_thermostat {self.device_name}: sending {_min_temp}°C to the TRV because this device has no system mode off and heater should be off"
)
_new_heating_setpoint = _min_temp
hvac_mode = None
return {
# Build payload; include calibration only if present
_payload = {
"temperature": _new_heating_setpoint,
"local_temperature": self.real_trvs[entity_id]["current_temperature"],
"system_mode": hvac_mode,
"local_temperature_calibration": _new_local_calibration,
}
if _new_local_calibration is not None:
_payload["local_temperature_calibration"] = _new_local_calibration
if _new_valve_position is not None:
_payload["valve_position"] = _new_valve_position
return _payload
except Exception as e:
_LOGGER.error(e)
_LOGGER.exception(
"better_thermostat %s: exception in convert_outbound_states for %s: %s",
self.device_name,
entity_id,
e,
)
return None
@@ -1,11 +1,17 @@
"""Window event handling and debounce queue helper.
These helpers respond to window sensor events and implement debouncing and
delayed handling so that HVAC behavior uses window-open information reliably.
"""
import asyncio
import logging
from custom_components.better_thermostat import DOMAIN
from homeassistant.core import callback
from homeassistant.const import STATE_OFF
from homeassistant.helpers import issue_registry as ir
from custom_components.better_thermostat import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -34,45 +40,63 @@ async def trigger_window_change(self, event) -> None:
old_window_open = self.window_open
if new_state in ("on", "unknown", "unavailable"):
if new_state in ("on", "true", "open", "unknown", "unavailable"):
new_window_open = True
if new_state == "unknown":
_LOGGER.warning(
"better_thermostat %s: Window sensor state is unknown, assuming window is open",
self.device_name,
)
elif new_state == "unavailable":
_LOGGER.info(
"better_thermostat %s: Window sensor is unavailable, assuming window is open",
self.device_name,
)
# window was opened, disable heating power calculation for this period
self.heating_start_temp = None
self._heating_tracker.start_temp = None
self.async_write_ha_state()
elif new_state == "off":
elif new_state in ("off", "false", "closed"):
new_window_open = False
else:
_LOGGER.error(
f"better_thermostat {self.device_name}: New window sensor state '{new_state}' not recognized"
"better_thermostat %s: New window sensor state '%s' not recognized",
self.device_name,
new_state,
)
ir.async_create_issue(
hass=self.hass,
domain=DOMAIN,
issue_id=f"missing_entity_{self.device_name}",
issue_title=f"better_thermostat {self.device_name} has invalid window sensor state",
issue_severity="error",
issue_description=f"better_thermostat {self.device_name} has invalid window sensor state: {new_state}",
issue_category="config",
issue_suggested_action="Please check the window sensor",
issue_id=f"invalid_window_state_{self.device_name}",
is_fixable=False,
is_persistent=False,
learn_more_url="https://better-thermostat.org/qanda/window_sensor",
severity=ir.IssueSeverity.ERROR,
translation_key="invalid_window_state",
translation_placeholders={
"name": str(self.device_name),
"state": str(new_state),
},
)
return
# make sure to skip events which do not change the saved window state:
if new_window_open == old_window_open:
_LOGGER.debug(
f"better_thermostat {self.device_name}: Window state did not change, skipping event"
"better_thermostat %s: Window state did not change, skipping event",
self.device_name,
)
return
await self.window_queue_task.put(new_window_open)
async def window_queue(self):
"""Process queued window-open events.
This coroutine dequeues window state changes, applies configured wait
delays and triggers the control queue when the window remains in the
expected state after the delay.
"""
try:
while True:
window_event_to_process = await self.window_queue_task.get()
@@ -80,37 +104,65 @@ async def window_queue(self):
if window_event_to_process is not None:
if window_event_to_process:
_LOGGER.debug(
f"better_thermostat {self.device_name}: Window opened, waiting {self.window_delay} seconds before continuing"
"better_thermostat %s: Window opened, "
"waiting %s seconds before continuing",
self.device_name,
self.window_delay,
)
await asyncio.sleep(self.window_delay)
else:
_LOGGER.debug(
f"better_thermostat {self.device_name}: Window closed, waiting {self.window_delay_after} seconds before continuing"
"better_thermostat %s: Window closed, "
"waiting %s seconds before continuing",
self.device_name,
self.window_delay_after,
)
await asyncio.sleep(self.window_delay_after)
window_state = self.hass.states.get(self.window_id)
if window_state is None:
_LOGGER.debug(
"better_thermostat %s: Window sensor %s vanished "
"during the debounce delay; skipping event",
self.device_name,
self.window_id,
)
continue
# remap off on to true false
current_window_state = True
if self.hass.states.get(self.window_id).state == STATE_OFF:
if window_state.state in ("off", "false", "closed"):
current_window_state = False
# make sure the current state is the suggested change state to prevent a false positive:
if current_window_state == window_event_to_process:
self.window_open = window_event_to_process
self.async_write_ha_state()
if not self.control_queue_task.empty():
empty_queue(self.control_queue_task)
await self.control_queue_task.put(self)
if getattr(self, "in_maintenance", False):
# Keep state up to date during maintenance, but defer control
# until maintenance ends.
self._control_needed_after_maintenance = True
else:
if not self.control_queue_task.empty():
empty_queue(self.control_queue_task)
await self.control_queue_task.put(self)
except asyncio.CancelledError:
_LOGGER.debug(
"better_thermostat %s: Window queue processing cancelled",
self.device_name,
)
raise
finally:
self.window_queue_task.task_done()
except asyncio.CancelledError:
_LOGGER.debug(
f"better_thermostat {self.device_name}: Window queue task cancelled"
"better_thermostat %s: Window queue task cancelled", self.device_name
)
raise
def empty_queue(q: asyncio.Queue):
"""Empty out a Queue of pending items.
Consumes all pending items from the queue and marks them as done.
"""
for _ in range(q.qsize()):
q.get_nowait()
q.task_done()
@@ -17,5 +17,5 @@
"iot_class": "local_push",
"issue_tracker": "https://github.com/KartoffelToby/better_thermostat/issues",
"requirements": [],
"version": "1.7.0"
"version": "1.8.2"
}
@@ -1,15 +1,19 @@
"""
This model fix is due to any floating point number being set to +/- 1 million by Zigbee2MQTT for the local_calibration
"""Zigbee2MQTT local calibration value fix.
This model fix addresses an issue where Zigbee2MQTT reports incorrect
floating-point values (e.g., +/- 1e6) for the local calibration. The
helper functions below normalize values to sensible integer offsets.
"""
import math
def fix_local_calibration(self, entity_id, offset):
"""
If still heating, round UP the offset
"""Sanitize and normalize a reported calibration offset.
This creates a lower "fake" thermostat temperature, making it heat the room
Rounds to the nearest integer (towards ceiling if the room is heating)
to recover from the erroneous float values produced by some Zigbee
integrations.
"""
if self.cur_temp < self.bt_target_temp:
offset = math.ceil(offset)
@@ -20,12 +24,15 @@ def fix_local_calibration(self, entity_id, offset):
def fix_target_temperature_calibration(self, entity_id, temperature):
"""No-op target-temperature calibration fix for this model."""
return temperature
async def override_set_hvac_mode(self, entity_id, hvac_mode):
"""No override on system mode for this model."""
return False
async def override_set_temperature(self, entity_id, temperature):
"""No temperature override for this model."""
return False
@@ -1,29 +1,79 @@
"""Model quirks for SEA801/SEA802 Zigbee thermostats.
Includes device-specific offsets and behavior adaptations required for certain
SEA801/SEA802 based devices.
"""
import logging
from custom_components.better_thermostat.utils.helpers import (
entity_uses_mpc_calibration,
)
_LOGGER = logging.getLogger(__name__)
def fix_local_calibration(self, entity_id, offset):
# device SEA802 fix
if (self.cur_temp - self.bt_target_temp) < -0.2:
"""Adjust the local calibration offset for SEA801/SEA802 devices.
The function applies small adjustments based on the external and target
temperatures to avoid incorrect temperature behavior.
"""
if entity_uses_mpc_calibration(self, entity_id):
return offset
_cur_external_temp = self.cur_temp
_target_temp = self.bt_target_temp
if (_cur_external_temp + 0.1) >= _target_temp:
offset = round(offset + 0.5, 1)
elif (_cur_external_temp + 0.5) >= _target_temp:
offset -= 2.5
return offset
def fix_target_temperature_calibration(self, entity_id, temperature):
# device SEA802 fix
_cur_trv_temp = float(
self.hass.states.get(entity_id).attributes["current_temperature"]
)
"""Adjust the setpoint temperature for SEA801/SEA802 devices.
Ensures a minimum distance between the current TRV temperature and the
target temperature to avoid short-cycling and oscillation.
"""
_state = self.hass.states.get(entity_id)
_cur_trv_temp = None
if _state is not None:
_cur_trv_temp = _state.attributes.get("current_temperature")
if _cur_trv_temp is None:
return temperature
if entity_uses_mpc_calibration(self, entity_id):
return temperature
_cur_trv_temp = float(_cur_trv_temp)
if (
round(temperature, 1) > round(_cur_trv_temp, 1)
and temperature - _cur_trv_temp < 2.5
and temperature - _cur_trv_temp < 1.5
):
temperature += 2.5
# Statt die gewünschte Temperatur pauschal um 1.5°C zu erhöhen,
# setze sie auf mindestens (aktuelle TRV-Temp + 1.5°C).
# So ist der Mindestabstand garantiert, ohne unnötig zu überschießen.
temperature = round(_cur_trv_temp + 1.5, 1)
return temperature
async def override_set_hvac_mode(self, entity_id, hvac_mode):
"""No HVAC mode override for SEA801/SEA802 devices.
Return False to indicate no custom handling and let the adapter handle
normal behavior.
"""
return False
async def override_set_temperature(self, entity_id, temperature):
"""No set_temperature override for SEA801/SEA802 devices.
Return False to indicate the adapter should use the default set_temperature
implementation.
"""
return False
@@ -1,4 +1,20 @@
"""Model fixes for SPZB0001 devices.
Device-specific quirks for SPZB0001 thermostats handled by the
Better Thermostat integration.
"""
import logging
from homeassistant.helpers import entity_registry as er
from ..utils.const import CalibrationType
_LOGGER = logging.getLogger(__name__)
def fix_local_calibration(self, entity_id, offset):
"""Clamp local calibration to safe bounds for SPZB0001 devices."""
if offset > 5:
offset = 5
elif offset < -5:
@@ -6,13 +22,80 @@ def fix_local_calibration(self, entity_id, offset):
return offset
async def check_operation_mode(self, entity_id, goal: str = "1"):
"""Return a possibly adjusted valve calibration for SPZB0001.
Currently a no-op.
"""
entity_registry = er.async_get(self.hass)
reg_entity = entity_registry.async_get(entity_id)
if reg_entity is None:
_LOGGER.debug(
"better_thermostat %s: SPZB0001 check_operation_mode: no registry entity for %s",
self.device_name,
entity_id,
)
return False
device_id = reg_entity.device_id
target_entity = None
for ent in entity_registry.entities.values():
if ent.device_id != device_id or ent.domain != "select":
continue
en = (ent.entity_id or "").lower()
uid = (ent.unique_id or "").lower()
name = (getattr(ent, "original_name", None) or "").lower()
if "_trv_mode" in en or "_trv_mode" in uid or "Trv mode" in name:
target_entity = ent.entity_id
if target_entity is None:
_LOGGER.debug(
"better_thermostat %s: SPZB0001 check_operation_mode: no target entity for %s",
self.device_name,
entity_id,
)
return False
val = self.hass.states.get(target_entity)
if val is None:
return False
if val.state != goal:
_LOGGER.debug(
"better_thermostat %s: SPZB0001 check_operation_mode: setting target entity %s to %s from %s",
self.device_name,
target_entity,
goal,
val.state,
)
await self.hass.services.async_call(
"select", "select_option", {"entity_id": target_entity, "option": goal}
)
return True
async def inital_tweak(self, entity_id):
"""Run initial tweaks for the device."""
_calibration_type = self.real_trvs[entity_id]["advanced"].get(
"calibration", CalibrationType.TARGET_TEMP_BASED
)
if _calibration_type == CalibrationType.DIRECT_VALVE_BASED:
await check_operation_mode(self, entity_id, goal="1")
else:
await check_operation_mode(self, entity_id, goal="2")
def fix_target_temperature_calibration(self, entity_id, temperature):
"""Return a possibly adjusted target temperature for SPZB0001.
Currently a no-op.
"""
return temperature
async def override_set_hvac_mode(self, entity_id, hvac_mode):
"""Do not override HVAC mode for SPZB0001 devices."""
return False
async def override_set_temperature(self, entity_id, temperature):
"""Do not override temperature sets for SPZB0001 devices."""
return False
@@ -1,4 +1,15 @@
"""Model quirks for generic TS0601 Zigbee thermostats.
Contains model-specific handling for known quirks in TS0601-based devices.
"""
def fix_local_calibration(self, entity_id, offset):
"""Normalize local calibration offset for TS0601 devices.
This function performs model-specific rounding/adjustment to avoid
spurious values that would lead to incorrect behavior.
"""
_cur_external_temp = self.cur_temp
_target_temp = self.bt_target_temp
@@ -11,6 +22,11 @@ def fix_local_calibration(self, entity_id, offset):
def fix_target_temperature_calibration(self, entity_id, temperature):
"""Adjust target temperature calibration for TS0601 devices.
Ensures a minimum distance between the current TRV internal temperature
and the requested setpoint to avoid oscillation.
"""
_cur_trv_temp = float(
self.hass.states.get(entity_id).attributes["current_temperature"]
)
@@ -26,8 +42,10 @@ def fix_target_temperature_calibration(self, entity_id, temperature):
async def override_set_hvac_mode(self, entity_id, hvac_mode):
"""No special HVAC mode override for TS0601 devices."""
return False
async def override_set_temperature(self, entity_id, temperature):
"""No special set_temperature override for TS0601 devices."""
return False
@@ -1,4 +1,12 @@
"""Model quirks for TS0601 thermostat devices.
These helpers fix or adapt device-reported values for TS0601 thermostat
devices used by the Better Thermostat integration.
"""
def fix_local_calibration(self, entity_id, offset):
"""Normalize a local calibration offset for TS0601 thermostat devices."""
_cur_external_temp = self.cur_temp
_target_temp = self.bt_target_temp
@@ -11,6 +19,7 @@ def fix_local_calibration(self, entity_id, offset):
def fix_target_temperature_calibration(self, entity_id, temperature):
"""Adjust the target temperature for TS0601 thermostat devices."""
_cur_trv_temp = float(
self.hass.states.get(entity_id).attributes["current_temperature"]
)
@@ -26,8 +35,10 @@ def fix_target_temperature_calibration(self, entity_id, temperature):
async def override_set_hvac_mode(self, entity_id, hvac_mode):
"""No special override for HVAC mode on TS0601 thermostats."""
return False
async def override_set_temperature(self, entity_id, temperature):
"""No special override for target temperature on TS0601 thermostats."""
return False
@@ -1,28 +1,34 @@
# Quirks for TV02-Zigbee
"""Quirks for TV02-Zigbee thermostats."""
import logging
from homeassistant.components.climate.const import HVACMode
_LOGGER = logging.getLogger(__name__)
def fix_local_calibration(self, entity_id, offset):
"""Return local calibration offset unchanged for TRV02 devices."""
return offset
def fix_target_temperature_calibration(self, entity_id, temperature):
"""Return target temperature unchanged for TRV02 devices."""
return temperature
async def override_set_hvac_mode(self, entity_id, hvac_mode):
"""Enable specific device quirks while setting hvac mode
"""Enable device quirks while setting HVAC mode.
Parameters
----------
self :
self instance of better_thermostat
self instance of better_thermostat
entity_id :
Entity id of the TRV.
Entity id of the TRV.
hvac_mode:
HVAC mode to be set.
HVAC mode to be set.
Returns
-------
None
@@ -37,7 +43,9 @@ async def override_set_hvac_mode(self, entity_id, hvac_mode):
model = self.real_trvs[entity_id]["model"]
if model == "TV02-Zigbee" and hvac_mode != HVACMode.OFF:
_LOGGER.debug(
f"better_thermostat {self.device_name}: TRV {entity_id} device quirk hvac trv02-zigbee active"
"better_thermostat %s: TRV %s device quirk hvac trv02-zigbee active",
self.device_name,
entity_id,
)
await self.hass.services.async_call(
"climate",
@@ -50,15 +58,17 @@ async def override_set_hvac_mode(self, entity_id, hvac_mode):
async def override_set_temperature(self, entity_id, temperature):
"""Enable specific device quirks while setting temperature
"""Enable device quirks while setting temperature.
Parameters
----------
self :
self instance of better_thermostat
self instance of better_thermostat
entity_id :
Entity id of the TRV.
Entity id of the TRV.
temperature:
Temperature to be set.
Temperature to be set.
Returns
-------
None
@@ -66,7 +76,9 @@ async def override_set_temperature(self, entity_id, temperature):
model = self.real_trvs[entity_id]["model"]
if model == "TV02-Zigbee":
_LOGGER.debug(
f"better_thermostat {self.device_name}: TRV {entity_id} device quirk trv02-zigbee active"
"better_thermostat %s: TRV %s device quirk trv02-zigbee active",
self.device_name,
entity_id,
)
await self.hass.services.async_call(
"climate",
@@ -1,14 +1,195 @@
"""Default model quirks passthrough for unknown devices.
These helpers implement safe no-op defaults for devices that do not
require specific quirks.
"""
import logging
from homeassistant.components.lock import LockState
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.helpers import entity_registry as er
_LOGGER = logging.getLogger(__name__)
VALVE_MAINTENANCE_INTERVAL_HOURS = 168 # Default: 7 days
def fix_local_calibration(self, entity_id, offset):
"""Return the given local calibration offset unchanged."""
return offset
def fix_valve_calibration(self, entity_id, valve):
"""Return the given valve calibration unchanged."""
return valve
def fix_target_temperature_calibration(self, entity_id, temperature):
"""Return the given target temperature unchanged."""
return temperature
async def override_set_hvac_mode(self, entity_id, hvac_mode):
"""Do not override HVAC mode by default."""
return False
async def override_set_temperature(self, entity_id, temperature):
"""Do not override set temperature by default."""
return False
async def override_set_valve(self, entity_id, percent: int):
"""Do not override valve by default."""
return False
async def inital_tweak(self, entity_id):
"""Run initial tweaks for the device."""
entity_registry = er.async_get(self.hass)
reg_entity = entity_registry.async_get(entity_id)
if reg_entity is not None and reg_entity.device_id is not None:
device_id = reg_entity.device_id
def find_entity(domains, keywords):
for ent in entity_registry.entities.values():
if ent.device_id != device_id or ent.domain not in domains:
continue
name = (getattr(ent, "original_name", "") or "").lower()
uid = (ent.unique_id or "").lower()
eid = (ent.entity_id or "").lower()
if (
any(k in name for k in keywords)
or any(k in uid for k in keywords)
or any(k in eid for k in keywords)
):
return ent.entity_id
return None
# 1. Local calibration -> 0
cal_entity = find_entity(
["number"],
["local_temperature_calibration", "local_calibration", "calibration"],
)
if cal_entity:
try:
_LOGGER.debug(
"better_thermostat %s: Resetting local calibration for %s to 0",
self.device_name,
cal_entity,
)
await self.hass.services.async_call(
"number", "set_value", {"entity_id": cal_entity, "value": 0}
)
except Exception as e:
_LOGGER.warning(
"better_thermostat %s: Failed to reset calibration for %s: %s",
self.device_name,
cal_entity,
e,
)
# 2. Child lock sync setting
child_lock_setting = self.real_trvs[entity_id]["advanced"].get("child_lock")
if child_lock_setting is not None:
# Look for switch (Z2M) or lock
cl_entity = find_entity(
["switch", "lock"], ["child_lock", "child lock", "lock"]
)
if cl_entity:
target_state = STATE_ON if child_lock_setting else STATE_OFF
domain = cl_entity.split(".")[0]
try:
if domain == "switch":
cur = self.hass.states.get(cl_entity)
if cur and cur.state != target_state:
_LOGGER.debug(
"better_thermostat %s: Setting child lock (switch) for %s to %s",
self.device_name,
cl_entity,
target_state,
)
service = "turn_on" if child_lock_setting else "turn_off"
await self.hass.services.async_call(
"switch", service, {"entity_id": cl_entity}
)
elif domain == "lock":
target_lock = (
LockState.LOCKED
if child_lock_setting
else LockState.UNLOCKED
)
cur = self.hass.states.get(cl_entity)
if cur and cur.state != target_lock.value:
_LOGGER.debug(
"better_thermostat %s: Setting child lock (lock) for %s to %s",
self.device_name,
cl_entity,
target_lock,
)
service = "lock" if child_lock_setting else "unlock"
await self.hass.services.async_call(
"lock", service, {"entity_id": cl_entity}
)
except Exception as e:
_LOGGER.warning(
"better_thermostat %s: Failed to set child lock for %s: %s",
self.device_name,
cl_entity,
e,
)
# 3. Away / Window detection -> Off
# Window detection disable the interal trv window detection, its handled by better_thermostat
win_entity = find_entity(
["switch"],
["window_detection", "window_open", "window open", "open_window"],
)
if win_entity:
try:
cur = self.hass.states.get(win_entity)
if cur and cur.state != STATE_OFF:
_LOGGER.debug(
"better_thermostat %s: Disabling window detection for %s",
self.device_name,
win_entity,
)
await self.hass.services.async_call(
"switch", "turn_off", {"entity_id": win_entity}
)
except Exception as e:
_LOGGER.warning(
"better_thermostat %s: Failed to disable window detection for %s: %s",
self.device_name,
win_entity,
e,
)
# Away mode -> Off
# Disable the away mode on the device if available
away_entity = find_entity(
["switch"], ["away_mode", "away mode", "holiday_mode", "holiday"]
)
if away_entity:
try:
cur = self.hass.states.get(away_entity)
if cur and cur.state != STATE_OFF:
_LOGGER.debug(
"better_thermostat %s: Disabling away mode for %s",
self.device_name,
away_entity,
)
await self.hass.services.async_call(
"switch", "turn_off", {"entity_id": away_entity}
)
except Exception as e:
_LOGGER.warning(
"better_thermostat %s: Failed to disable away mode for %s: %s",
self.device_name,
away_entity,
e,
)
@@ -1,14 +1,6 @@
save_current_target_temperature:
name: Save target temperature
description: Save the current target temperature for later use.
target:
entity:
domain: climate
integration: better_thermostat
restore_saved_target_temperature:
name: Restore target temperature
description: Restore the previously saved target temperature.
run_valve_maintenance:
name: Run valve maintenance now
description: Run the valve maintenance cycle immediately for this Better Thermostat entity.
target:
entity:
domain: climate
@@ -22,21 +14,51 @@ reset_heating_power:
domain: climate
integration: better_thermostat
set_temp_target_temperature:
name: Set temporary (ECO) target temperature
description: Set a temporary target temperature and save the current temperature.
reset_pid_learnings:
name: Reset PID learnings
description: Clear all learned PID state (and optionally learned open caps) for this entity.
fields:
temperature:
name: Temperature
description: New target temperature to set
required: true
include_open_caps:
name: Also clear learned open caps
description: If enabled, also clears learned min/max open percentages per TRV/target bucket.
required: false
default: false
selector:
boolean: {}
apply_pid_defaults:
name: Apply PID defaults after reset
description: If enabled, seeds the current target bucket(s) with PID default gains (kp/ki/kd). You can override defaults below.
required: false
default: false
selector:
boolean: {}
defaults_kp:
name: Default kp
description: Proportional gain to seed after reset (falls back to BalanceParams.kp if not provided).
required: false
selector:
number:
min: 0
max: 35
step: 0.5
mode: box
unit_of_measurement: °C
max: 1000
step: 0.1
defaults_ki:
name: Default ki
description: Integral gain to seed after reset (falls back to BalanceParams.ki if not provided).
required: false
selector:
number:
min: 0
max: 1
step: 0.001
defaults_kd:
name: Default kd
description: Derivative gain to seed after reset (falls back to BalanceParams.kd if not provided).
required: false
selector:
number:
min: 0
max: 10000
step: 10
target:
entity:
domain: climate
+234 -124
View File
@@ -1,128 +1,238 @@
{
"config": {
"step": {
"user": {
"description": "Setup your Better Thermostat to integrate with Home Assistant\n**If you need more info: https://better-thermostat.org/configuration#first-step**",
"data": {
"name": "Name",
"thermostat": "The real thermostat",
"cooler": "The cooling device (optional)",
"temperature_sensor": "Temperature sensor",
"humidity_sensor": "Humidity sensor",
"window_sensors": "Window sensor",
"off_temperature": "The outdoor temperature when the thermostat should turn off",
"tolerance": "Tolerance, to prevent the thermostat from turning on and off too often",
"window_off_delay": "Delay before the thermostat should turn off when the window is opened",
"window_off_delay_after": "Delay before the thermostat should turn on when the window is closed",
"outdoor_sensor": "Outdoor temperature sensor",
"weather": "Weather entity to get the outdoor temperature"
}
},
"advanced": {
"description": "Advanced configuration {trv}\n***Info about calibration types: https://better-thermostat.org/configuration#second-step***",
"data": {
"protect_overheating": "Overheating protection?",
"heat_auto_swapped": "If 'auto' means 'heat' for your TRV and you want to swap it",
"child_lock": "Ignore all inputs on the TRV like a child lock",
"homematicip": "If you use HomematicIP, you should enable this to slow down the requests to prevent the duty cycle",
"valve_maintenance": "If your thermostat has no own maintenance mode, you can use this one",
"calibration": "Calibration type",
"calibration_mode": "Calibration mode",
"no_off_system_mode": "If your TRV doesn't support the 'off' mode, enable this to use target temperature 5°C instead"
},
"data_description": {
"protect_overheating": "Some TRVs don't close the valve completely when the temperature is reached. Or the radiator has a lot of rest heat. This can cause overheating. This option can prevent this.",
"calibration_mode": "How the calibration is calculated\n***Normal***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor.\n***Aggresive***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor but set much lower/higher to get a quicker boost.\n***AI Time Based***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor, but the value is calculated by a custom algorithm to improve the TRV internal algorithm.",
"calibration": "How the calibration is applied on the TRV\n***Target Temperature Based***: Apply the calibration to the target temperature.\n***Offset Based***: Apply the calibration to the temperature offset."
}
},
"confirm": {
"title": "Confirm adding a Better Thermostat",
"description": "You are about to add `{name}` to Home Assistant.\nWith {trv} as the real Thermostat"
}
},
"error": {
"failed": "Something went wrong.",
"no_name": "Please enter a name.",
"no_off_mode": "Your device is special and has no off mode :(\nBetter Thermostat will use the minimal target temp instead.",
"no_outside_temp": "You have no outside temperature sensor. Better Thermostat will use the weather entity instead."
},
"abort": {
"single_instance_allowed": "Only a single Better Thermostat is allowed for each real thermostat.",
"no_devices_found": "No thermostat entity found, make sure you have a climate entity in your Home Assistant"
}
"title": "Better Thermostat",
"config": {
"step": {
"user": {
"description": "Setup your Better Thermostat to integrate with Home Assistant\n\n**Detailed information:** {docs_url}",
"data": {
"name": "Name",
"thermostat": "The real thermostat",
"cooler": "The cooling device (optional)",
"temperature_sensor": "Temperature sensor",
"humidity_sensor": "Humidity sensor",
"window_sensors": "Window sensor",
"off_temperature": "The outdoor temperature when the thermostat should turn off",
"tolerance": "Tolerance, to prevent the thermostat from turning on and off too often",
"window_off_delay": "Delay before the thermostat should turn off when the window is opened",
"window_off_delay_after": "Delay before the thermostat should turn on when the window is closed",
"outdoor_sensor": "Outdoor temperature sensor",
"weather": "Weather entity to get the outdoor temperature",
"target_temp_step": "Target temperature step",
"presets": "Enabled Presets"
},
"options": {
"step": {
"user": {
"description": "Update your Better Thermostat settings",
"data": {
"name": "Name",
"thermostat": "The real thermostat",
"temperature_sensor": "Temperature Sensor",
"humidity_sensor": "Humidity sensor",
"window_sensors": "Window Sensor",
"off_temperature": "The outdoor temperature when the thermostat should turn off",
"tolerance": "Tolerance, to prevent the thermostat from turning on and off too often",
"window_off_delay": "Delay before the thermostat should turn off when the window is opened",
"window_off_delay_after": "Delay before the thermostat should turn on when the window is closed",
"outdoor_sensor": "Outdoor temperature sensor",
"weather": "Weather entity to get the outdoor temperature",
"valve_maintenance": "If your thermostat has no own maintenance mode, you can use this one",
"calibration": "The sort of calibration https://better-thermostat.org/configuration#second-step",
"heat_auto_swapped": "If the auto means heat for your TRV and you want to swap it",
"child_lock": "Ignore all inputs on the TRV like a child lock",
"homematicip": "If you use HomematicIP, you should enable this to slow down the requests to prevent the duty cycle"
}
},
"advanced": {
"description": "Advanced configuration {trv}\n***Info about calibration types: https://better-thermostat.org/configuration#second-step***",
"data": {
"protect_overheating": "Overheating protection?",
"heat_auto_swapped": "If the auto means heat for your TRV and you want to swap it",
"child_lock": "Ignore all inputs on the TRV like a child lock",
"homematicip": "If you use HomematicIP, you should enable this to slow down the requests to prevent the duty cycle",
"valve_maintenance": "If your thermostat has no own maintenance mode, you can use this one",
"calibration": "Calibration type",
"calibration_mode": "Calibration mode",
"no_off_system_mode": "If your TRV doesn't support the 'off' mode, enable this to use target temperature 5°C instead"
},
"data_description": {
"protect_overheating": "Some TRVs don't close the valve completely when the temperature is reached. Or the radiator has a lot of rest heat. This can cause overheating. This option can prevent this.",
"calibration_mode": "How the calibration is calculated\n***Normal***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor.\n***Aggresive***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor but set much lower/higher to get a quicker boost.\n***AI Time Based***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor, but the value is calculated by a custom algorithm to improve the TRV internal algorithm.",
"calibration": "How the calibration is applied on the TRV\n***Target Temperature Based***: Apply the calibration to the target temperature.\n***Offset Based***: Apply the calibration to the temperature offset." }
}
}
},
"issues": {
"missing_entity": {
"title": "BT: {name} - related entity is missing",
"fix_flow": {
"step": {
"confirm": {
"title": "The related entity {entity} is missing",
"description": "The reason for this is that the entity ({entity}) is not available in your Home Assistant.\n\nYou can fix this by checking if the battery of the device is full or reconnecting it to HA. Make sure that the entity is back in HA before you continue."
}
}
}
}
},
"services": {
"save_current_target_temperature": {
"name": "Save current Temperature",
"description": "Save the current target temperature for later restore."
},
"restore_saved_target_temperature": {
"name": "Restore temperature",
"description": "Restore the saved target temperature."
},
"reset_heating_power": {
"name": "Reset heating power",
"description": "Reset heating power to default value."
},
"set_temp_target_temperature": {
"name": "Set eco temperature",
"description": "Set the target temperature to a temporary like night mode, and save the old one."
}
"data_description": {
"presets": "Select the presets you want to be available for this thermostat."
}
},
"advanced": {
"description": "Advanced configuration {trv}\n\n**Detailed information:** {docs_url}",
"data": {
"protect_overheating": "Overheating protection?",
"heat_auto_swapped": "If 'auto' means 'heat' for your TRV and you want to swap it",
"child_lock": "Ignore all inputs on the TRV like a child lock",
"homematicip": "If you use HomematicIP, you should enable this to slow down the requests to prevent the duty cycle",
"valve_maintenance": "If your thermostat has no own maintenance mode, you can use this one",
"calibration": "Calibration type",
"calibration_mode": "Calibration mode",
"no_off_system_mode": "If your TRV doesn't support the 'off' mode, enable this to use target temperature 5°C instead"
},
"data_description": {
"protect_overheating": "Some TRVs don't close the valve completely when the temperature is reached. Or the radiator has a lot of rest heat. This can cause overheating. This option can prevent this.",
"calibration_mode": "How the calibration is calculated\n***Normal***: Uses your external temperature sensor to correct the TRV's internal sensor. Simple and reliable for most setups.\n***Aggressive***: Similar to Normal, but pushes the TRV harder to reach temperature faster. Good if your room heats slowly.\n***AI Time Based***: Learns your room's heating patterns over time and adjusts automatically. Balances comfort and efficiency.\n***MPC Predictive***: Advanced algorithm that predicts temperature changes and optimizes valve position. Best for stable, efficient heating with minimal overshooting. Learns your room's thermal behavior.\n***PID Controller***: Classic control method that responds to temperature errors. Good for systems with varying heating power or external disturbances.\n***TPI Controller***: Calculates heating duty cycles based on temperature difference. Simple and effective for consistent heating patterns.",
"calibration": "How the calibration is applied on the TRV\n***Target Temperature Based***: Apply the calibration to the target temperature.\n***Offset Based***: Apply the calibration to the temperature offset."
}
},
"confirm": {
"title": "Confirm adding a Better Thermostat",
"description": "You are about to add `{name}` to Home Assistant.\nWith {trv} as the real Thermostat"
}
},
"error": {
"failed": "Something went wrong.",
"no_name": "Please enter a name.",
"no_off_mode": "Your device is special and has no off mode :(\nBetter Thermostat will use the minimal target temp instead.",
"no_outside_temp": "You have no outside temperature sensor. Better Thermostat will use the weather entity instead."
},
"abort": {
"single_instance_allowed": "Only a single Better Thermostat is allowed for each real thermostat.",
"no_devices_found": "No thermostat entity found, make sure you have a climate entity in your Home Assistant"
}
},
"options": {
"step": {
"user": {
"description": "Update your Better Thermostat settings\n\n**Detailed information:** {docs_url}",
"data": {
"name": "Name",
"thermostat": "The real thermostat",
"temperature_sensor": "Temperature Sensor",
"humidity_sensor": "Humidity sensor",
"window_sensors": "Window Sensor",
"off_temperature": "The outdoor temperature when the thermostat should turn off",
"tolerance": "Tolerance, to prevent the thermostat from turning on and off too often",
"window_off_delay": "Delay before the thermostat should turn off when the window is opened",
"window_off_delay_after": "Delay before the thermostat should turn on when the window is closed",
"outdoor_sensor": "Outdoor temperature sensor",
"weather": "Weather entity to get the outdoor temperature",
"target_temp_step": "Target temperature step",
"presets": "Enabled Presets",
"valve_maintenance": "If your thermostat has no own maintenance mode, you can use this one",
"calibration": "The sort of calibration",
"heat_auto_swapped": "If 'auto' means 'heat' for your TRV and you want to swap it",
"child_lock": "Ignore all inputs on the TRV like a child lock",
"homematicip": "If you use HomematicIP, you should enable this to slow down the requests to prevent the duty cycle"
},
"data_description": {
"presets": "Select the presets you want to be available for this thermostat."
}
},
"advanced": {
"description": "Advanced configuration {trv}\n\n**Detailed information:** {docs_url}",
"data": {
"protect_overheating": "Overheating protection?",
"heat_auto_swapped": "If 'auto' means 'heat' for your TRV and you want to swap it",
"child_lock": "Ignore all inputs on the TRV like a child lock",
"homematicip": "If you use HomematicIP, you should enable this to slow down the requests to prevent the duty cycle",
"valve_maintenance": "If your thermostat has no own maintenance mode, you can use this one",
"calibration": "Calibration type",
"calibration_mode": "Calibration mode",
"no_off_system_mode": "If your TRV doesn't support the 'off' mode, enable this to use target temperature 5°C instead"
},
"data_description": {
"protect_overheating": "Some TRVs don't close the valve completely when the temperature is reached. Or the radiator has a lot of rest heat. This can cause overheating. This option can prevent this.",
"calibration_mode": "How the calibration is calculated\n***Normal***: Uses your external temperature sensor to correct the TRV's internal sensor. Simple and reliable for most setups.\n***Aggressive***: Similar to Normal, but pushes the TRV harder to reach temperature faster. Good if your room heats slowly.\n***AI Time Based***: Learns your room's heating patterns over time and adjusts automatically. Balances comfort and efficiency.\n***MPC Predictive***: Advanced algorithm that predicts temperature changes and optimizes valve position. Best for stable, efficient heating with minimal overshooting. Learns your room's thermal behavior.\n***PID Controller***: Classic control method that responds to temperature errors. Good for systems with varying heating power or external disturbances.\n***TPI Controller***: Calculates heating duty cycles based on temperature difference. Simple and effective for consistent heating patterns.",
"calibration": "How the calibration is applied on the TRV\n***Target Temperature Based***: Apply the calibration to the target temperature.\n***Offset Based***: Apply the calibration to the temperature offset."
}
}
}
},
"issues": {
"missing_entity": {
"title": "BT: {name} - related entity is missing",
"fix_flow": {
"step": {
"confirm": {
"title": "The related entity {entity} is missing",
"description": "The reason for this is that the entity ({entity}) is not available in your Home Assistant.\n\nYou can fix this by checking if the battery of the device is full or reconnecting it to HA. Make sure that the entity is back in HA before you continue."
}
}
}
},
"degraded_mode": {
"title": "BT: {name} - sensor error: {sensors} running in degraded mode",
"description": "Better Thermostat is running in degraded mode because some sensors are unavailable: {sensors}\n\nThe thermostat will continue to operate with reduced functionality. For example:\n- If the room temperature sensor is unavailable, the TRV's internal sensor will be used instead.\n- If window sensors are unavailable, windows are assumed to be closed.\n- If outdoor/weather sensors are unavailable, heating will always be enabled.\n\nCheck if the affected devices have battery issues or connectivity problems."
},
"invalid_window_state": {
"title": "BT: {name} - invalid window sensor state",
"description": "The window sensor for Better Thermostat {name} reported an unrecognized state: {state}.\n\nExpected states are: 'on', 'off', 'true', 'false', 'open', 'closed'.\n\nPlease check your window sensor configuration."
}
},
"services": {
"run_valve_maintenance": {
"name": "Run valve maintenance now",
"description": "Run the valve maintenance cycle immediately for this Better Thermostat entity."
},
"reset_heating_power": {
"name": "Reset heating power",
"description": "Reset heating power to default value."
},
"reset_pid_learnings": {
"name": "Reset PID learnings",
"description": "Clear all learned PID state (and optionally learned open caps) for this entity.",
"fields": {
"include_open_caps": {
"name": "Also clear learned open caps",
"description": "If enabled, also clears learned min/max open percentages per TRV/target bucket."
},
"apply_pid_defaults": {
"name": "Apply PID defaults after reset",
"description": "If enabled, seeds the current target bucket(s) with PID default gains (kp/ki/kd). You can override defaults below."
},
"defaults_kp": {
"name": "Default kp",
"description": "Proportional gain to seed after reset (falls back to BalanceParams.kp if not provided)."
},
"defaults_ki": {
"name": "Default ki",
"description": "Integral gain to seed after reset (falls back to BalanceParams.ki if not provided)."
},
"defaults_kd": {
"name": "Default kd",
"description": "Derivative gain to seed after reset (falls back to BalanceParams.kd if not provided)."
}
}
}
},
"selector": {
"presets": {
"options": {
"eco": "Eco",
"away": "Away",
"boost": "Boost",
"comfort": "Comfort",
"home": "Home",
"sleep": "Sleep",
"activity": "Activity"
}
}
},
"entity": {
"number": {
"pid_kp": {
"name": "{trv_name} PID Kp (Proportional)"
},
"pid_ki": {
"name": "{trv_name} PID Ki (Integral)"
},
"pid_kd": {
"name": "{trv_name} PID Kd (Derivative)"
},
"pid_kp_no_trv": {
"name": "PID Kp (Proportional)"
},
"pid_ki_no_trv": {
"name": "PID Ki (Integral)"
},
"pid_kd_no_trv": {
"name": "PID Kd (Derivative)"
}
},
"switch": {
"pid_auto_tune": {
"name": "{trv_name} PID Auto Tune"
},
"pid_auto_tune_no_trv": {
"name": "PID Auto Tune"
},
"child_lock": {
"name": "{trv_name} Child Lock"
},
"child_lock_no_trv": {
"name": "Child Lock"
}
}
},
"device_automation": {
"trigger_type": {
"heating_active": "Thermostat switches to heating",
"heating_stopped": "Thermostat stops heating",
"humidity_high": "Humidity exceeds threshold",
"window_opened": "Window opened",
"window_closed": "Window closed",
"battery_low": "Battery low",
"device_error": "Device error detected",
"target_temp_reached": "Target temperature reached",
"hvac_mode_changed": "HVAC mode changed",
"current_temperature_changed": "Current temperature changed",
"current_humidity_changed": "Current humidity changed"
},
"condition_type": {
"is_hvac_mode": "Thermostat is set to a specific HVAC mode",
"is_hvac_action": "Thermostat is performing a specific HVAC action"
},
"action_type": {
"set_hvac_mode": "Set HVAC mode",
"set_temperature": "Set target temperature"
}
}
}
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
"description": "Konfigurer din Better Thermostat til at integrere med Home Assistant\n**Hvis du har brug for mere info: https://better-thermostat.org/configuration#first-step**",
"description": "Konfigurer din Better Thermostat til at integrere med Home Assistant\n\n**Detaljerede oplysninger:** {docs_url}",
"data": {
"name": "Navn",
"thermostat": "Den rigtige termostat",
@@ -16,11 +16,16 @@
"window_off_delay": "Forsinkelse, før termostaten slukker, når vinduet er åbent, og tændt, når vinduet er lukket",
"window_off_delay_after": "Forsinkelse, før termostaten tænder, når vinduet er lukket",
"outdoor_sensor": "Hvis du har en udeføler, kan du bruge den til at få udetemperaturen",
"weather": "Din vejrentitet for at få udendørstemperaturen"
"weather": "Din vejrentitet for at få udendørstemperaturen",
"target_temp_step": "Trin for måltemperatur",
"presets": "Aktiverede forudindstillinger"
},
"data_description": {
"presets": "Vælg de forudindstillinger, der skal være tilgængelige for denne termostat."
}
},
"advanced": {
"description": "Avanceret konfiguration {trv}\n***Info om kalibreringstyper: https://better-thermostat.org/configuration#second-step*** ",
"description": "Avanceret konfiguration {trv}\n\n**Detaljerede oplysninger:** {docs_url}",
"data": {
"protect_overheating": "Overophedningsbeskyttelse?",
"heat_auto_swapped": "Hvis auto betyder varme for din TRV og du vil bytte det",
@@ -56,7 +61,7 @@
"options": {
"step": {
"user": {
"description": "Opdater dine Better Thermostat indstillinger",
"description": "Opdater dine Better Thermostat indstillinger\n\n**Detaljerede oplysninger:** {docs_url}",
"data": {
"name": "Navn",
"thermostat": "Den rigtige termostat",
@@ -68,16 +73,21 @@
"window_off_delay": "Forsinkelse, før termostaten slukker, når vinduet er åbent, og tændt, når vinduet er lukket",
"window_off_delay_after": "Forsinkelse, før termostaten tænder, når vinduet er lukket",
"outdoor_sensor": "Har du en udendørsføler, kan du bruge den til at få udetemperaturen",
"valve_maintenance": "Hvis din termostat ikke har nogen egen vedligeholdelsestilstand, kan du bruge denne",
"calibration": "Den slags kalibrering https://better-thermostat.org/configuration#second-step",
"weather": "Din vejrentitet for at få udendørstemperaturen",
"target_temp_step": "Trin for måltemperatur",
"presets": "Aktiverede forudindstillinger",
"valve_maintenance": "Hvis din termostat ikke har nogen egen vedligeholdelsestilstand, kan du bruge denne",
"calibration": "Den slags kalibrering",
"heat_auto_swapped": "Hvis auto betyder varme til din TRV, og du ønsker at bytte den",
"child_lock": "Ignorer alle input på TRV som en børnesikring",
"homaticip": "Hvis du bruger HomematicIP, bør du aktivere dette for at bremse anmodningerne for at forhindre arbejdscyklus"
"homematicip": "Hvis du bruger HomematicIP, bør du aktivere dette for at bremse anmodningerne for at forhindre arbejdscyklus"
},
"data_description": {
"presets": "Vælg de forudindstillinger, der skal være tilgængelige for denne termostat."
}
},
"advanced": {
"description": "Avanceret konfiguration {trv}\n***Info om kalibreringstyper: https://better-thermostat.org/configuration#second-step*** ",
"description": "Avanceret konfiguration {trv}\n\n**Detaljerede oplysninger:** {docs_url}",
"data": {
"protect_overheating": "Overophedningsbeskyttelse?",
"heat_auto_swapped": "Hvis auto betyder varme for din TRV og du vil bytte det",
@@ -107,24 +117,122 @@
}
}
}
},
"degraded_mode": {
"title": "BT: {name} - sensorfejl: {sensors} kører i nedsat tilstand",
"description": "Better Thermostat kører i nedsat tilstand, fordi nogle sensorer ikke er tilgængelige: {sensors}\n\nTermostaten vil fortsætte med at fungere med reduceret funktionalitet. For eksempel:\n- Hvis rumtemperatursensoren ikke er tilgængelig, bruges TRV'ens interne sensor i stedet.\n- Hvis vinduesensorer ikke er tilgængelige, antages vinduer at være lukkede.\n- Hvis udendørs-/vejrsensorer ikke er tilgængelige, vil opvarmning altid være aktiveret.\n\nKontroller, om de berørte enheder har batteriproblemer eller forbindelsesproblemer."
},
"invalid_window_state": {
"title": "BT: {name} - ugyldig vinduesensortilstand",
"description": "Vinduesensoren for Better Thermostat {name} rapporterede en ukendt tilstand: {state}.\n\nForventede tilstande er: 'on', 'off', 'true', 'false', 'open', 'closed'.\n\nKontroller din vinduesensorkonfiguration."
}
},
"services": {
"save_current_target_temperature": {
"name": "Gem nuværende temperatur",
"description": "Gem den nuværende måltemperatur til senere gendannelse."
},
"restore_saved_target_temperature": {
"name": "Gendan temperatur",
"description": "Gendan den gemte måltemperatur."
"run_valve_maintenance": {
"name": "Kør ventilvedligeholdelse nu",
"description": "Kører ventilvedligeholdelsescyklussen for denne Better Thermostat-enhed med det samme."
},
"reset_heating_power": {
"name": "Nulstil opvarmningskraft",
"description": "Nulstil opvarmningskraft til standardværdi."
},
"set_temp_target_temperature": {
"name": "Indstil eco-temperatur",
"description": "Indstil måltemperaturen til en midlertidig som nattilstand, og gem den gamle."
"reset_pid_learnings": {
"name": "Nulstil PID-læring",
"description": "Rydder al lært PID-tilstand (og eventuelt lærte åbningsgrænser) for denne enhed.",
"fields": {
"include_open_caps": {
"name": "Ryd også lærte åbningsgrænser",
"description": "Hvis aktiveret, ryddes også de lærte minimums-/maksimumsåbningsprocenter pr. TRV/målinterval."
},
"apply_pid_defaults": {
"name": "Anvend PID-standardværdier efter nulstilling",
"description": "Hvis aktiveret, forudfyldes de aktuelle målintervaller med PID-standardforstærkninger (kp/ki/kd). Du kan tilsidesætte standardværdierne nedenfor."
},
"defaults_kp": {
"name": "Standard-kp",
"description": "Proportionalforstærkning, der sættes efter nulstilling (falder tilbage til BalanceParams.kp, hvis ikke angivet)."
},
"defaults_ki": {
"name": "Standard-ki",
"description": "Integralforstærkning, der sættes efter nulstilling (falder tilbage til BalanceParams.ki, hvis ikke angivet)."
},
"defaults_kd": {
"name": "Standard-kd",
"description": "Differentialforstærkning, der sættes efter nulstilling (falder tilbage til BalanceParams.kd, hvis ikke angivet)."
}
}
}
},
"selector": {
"presets": {
"options": {
"eco": "Eco",
"away": "Ude",
"boost": "Boost",
"comfort": "Komfort",
"home": "Hjemme",
"sleep": "Søvn",
"activity": "Aktivitet"
}
}
},
"entity": {
"number": {
"pid_kp": {
"name": "{trv_name} PID Kp (proportional)"
},
"pid_ki": {
"name": "{trv_name} PID Ki (integral)"
},
"pid_kd": {
"name": "{trv_name} PID Kd (differentiel)"
},
"pid_kp_no_trv": {
"name": "PID Kp (proportional)"
},
"pid_ki_no_trv": {
"name": "PID Ki (integral)"
},
"pid_kd_no_trv": {
"name": "PID Kd (differentiel)"
}
},
"switch": {
"pid_auto_tune": {
"name": "{trv_name} PID-autotuning"
},
"pid_auto_tune_no_trv": {
"name": "PID-autotuning"
},
"child_lock": {
"name": "{trv_name} Børnesikring"
},
"child_lock_no_trv": {
"name": "Børnesikring"
}
}
},
"device_automation": {
"trigger_type": {
"heating_active": "Termostat skifter til opvarmning",
"heating_stopped": "Termostat stopper opvarmning",
"humidity_high": "Luftfugtighed overstiger grænseværdi",
"window_opened": "Vindue åbnet",
"window_closed": "Vindue lukket",
"battery_low": "Batteri lavt",
"device_error": "Enhedsfejl registreret",
"target_temp_reached": "Måltemperatur nået",
"hvac_mode_changed": "HVAC-tilstand ændret",
"current_temperature_changed": "Aktuel temperatur ændret",
"current_humidity_changed": "Aktuel luftfugtighed ændret"
},
"condition_type": {
"is_hvac_mode": "Termostaten er indstillet til en bestemt HVAC-tilstand",
"is_hvac_action": "Termostaten udfører en bestemt HVAC-handling"
},
"action_type": {
"set_hvac_mode": "Indstil HVAC-tilstand",
"set_temperature": "Indstil måltemperatur"
}
}
}
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
"description": "Einrichtung von Better Thermostat mit Home Assistant\n**Für mehr Informationen: https://better-thermostat.org/configuration#first-step**",
"description": "Einrichtung von Better Thermostat mit Home Assistant\n\n**Detaillierte Infos:** {docs_url}",
"data": {
"name": "Name",
"thermostat": "Das reale Thermostat",
@@ -16,11 +16,16 @@
"window_off_delay": "Wartezeit, bevor das Thermostat bei geöffnetem Fenster abgeschaltet wird.",
"window_off_delay_after": "Wartezeit, bevor das Thermostat bei geschlossenem Fenster angeschaltet wird.",
"outdoor_sensor": "Wenn ein Außentemperatursensor vorhanden ist, kann dieser anstelle der Wetter-Entität genutzt werden.",
"weather": "Die Wetter-Entität für die Außentemperatur."
"weather": "Die Wetter-Entität für die Außentemperatur.",
"target_temp_step": "Solltemperatur-Schritt",
"presets": "Aktivierte Presets"
},
"data_description": {
"presets": "Wähle die Presets aus, die für dieses Thermostat verfügbar sein sollen."
}
},
"advanced": {
"description": "Einstellungen für {trv}\n\n***Infos über die Kalibrierungstypen: https://better-thermostat.org/configuration#second-step*** ",
"description": "Einstellungen für {trv}\n\n**Detaillierte Infos:** {docs_url}",
"data": {
"protect_overheating": "Überhitzung verhindern?",
"heat_auto_swapped": "Tauscht die Modi auto und heat, falls diese bei dem realen Thermostat vertauscht sind.",
@@ -33,7 +38,7 @@
},
"data_description": {
"protect_overheating": "Manche TRVs schließen auch nach Erreichen der Temperatur das Ventil nicht vollständig. Dies kann zu Überhitzungen führen. Ebenso, falls der Radiator viel Restwärme abstrahlt. Diese Option kann dies verhindern.",
"calibration_mode": "Wie die Kalibrierung berechnet wird\n***Normal***: In diesem Modus wird die interne TRV-Temperatur an die des externen Sensors angeglichen.\n\n***Aggresive***: In diesem Modus wird die interne TRV-Temperatur an die des externen Sensors angeglichen - allerdings mit größeren Werten. Dies ist hilfreich, wenn ein Raum schnell aufgeheizt werden soll oder das TRV träge ist.\n\n***AI Time Based***: In diesem Modus wird ein eigener Algorithmus genutzt, der anhand des externen Temperatursensors die Kalibrierung berechnet. Dieser Modus versucht den TRV internen Algorithmus zu optimieren.",
"calibration_mode": "Wie die Kalibrierung berechnet wird\n***Normal***: In diesem Modus wird die interne TRV-Temperatur an die des externen Sensors angeglichen.\n\n***Aggressive***: In diesem Modus wird die interne TRV-Temperatur an die des externen Sensors angeglichen - allerdings mit größeren Werten. Dies ist hilfreich, wenn ein Raum schnell aufgeheizt werden soll oder das TRV träge ist.\n\n***AI Time Based***: In diesem Modus wird ein eigener Algorithmus genutzt, der anhand des externen Temperatursensors die Kalibrierung berechnet. Dieser Modus versucht den TRV internen Algorithmus zu optimieren.\n\n***MPC Predictive (Beta)***: Nutzt den MPC-Prädiktivregler, um eine Ventilöffnung vorherzusagen und wandelt sie bei fehlender Ventilsteuerung in Temperatur- oder Offset-Anpassungen um.\n\n***PID Controller***: Verwendet einen PID-Regler, um die Ventilposition dynamisch basierend auf Temperaturfehler, proportionalem Anteil, integraler Akkumulation und derivativem Vorhersage anzupassen.\n\n***TPI Controller***: Verwendet einen Zeit-Proportional-Integral-Regler für stabile und präzise Temperatursteuerung über die Zeit.",
"calibration": "Wie die Kalibrierung auf das TRV angewendet werden soll.\n\n***Target Temperature Based***: Kalibriert das TRV über die Zieltemperatur.\n\n***Offset Based***: Kalibriert das TRV über eine Offset Funktion im TRV selbst. (Empfohlen)"
}
},
@@ -43,10 +48,10 @@
}
},
"error": {
"no_outside_temp": "Es kann keine Außentemperatur geladen werden.",
"failed": "Ups, hier stimmt was nicht.",
"no_name": "Du musst einen Namen vergeben.",
"no_off_mode": "Dein Gerät ist ein Sonderfall, es hat keinen OFF Modus :(\nBetter Thermostat wird stattdessen das TRV auf den Minimalwert setzen."
"no_off_mode": "Dein Gerät ist ein Sonderfall, es hat keinen OFF Modus :(\nBetter Thermostat wird stattdessen das TRV auf den Minimalwert setzen.",
"no_outside_temp": "Es kann keine Außentemperatur geladen werden."
},
"abort": {
"single_instance_allowed": "Es ist nur ein einzelnes BT je realem Thermostat erlaubt.",
@@ -56,7 +61,7 @@
"options": {
"step": {
"user": {
"description": "Aktualisiere die Better Thermostat Einstellungen",
"description": "Aktualisiere die Better Thermostat Einstellungen\n\n**Detaillierte Infos:** {docs_url}",
"data": {
"name": "Name",
"thermostat": "Das reale Thermostat",
@@ -69,15 +74,20 @@
"window_off_delay_after": "Wartezeit, bevor das Thermostat bei geschlossenem Fenster angeschaltet wird.",
"outdoor_sensor": "Wenn ein Außentemperatursensor vorhanden ist, kann dieser anstelle der Wetter-Entität genutzt werden.",
"weather": "Die Wetter-Entität für die Außentemperatur.",
"target_temp_step": "Solltemperatur-Schritt",
"presets": "Aktivierte Presets",
"valve_maintenance": "Wenn Ihr Thermostat keinen eigenen Wartungsmodus hat, können Sie diesen verwenden.",
"child_lock": "Ignorieren Sie alle Eingaben am TRV wie eine Kindersicherung.",
"homematicip": "Wenn Sie HomematicIP verwenden, sollten Sie dies aktivieren, um die Anfragen zu verlangsamen und den Duty Cycle zu verhindern.",
"calibration": "Die Art der Kalibrierung",
"heat_auto_swapped": "Wenn das Auto Wärme für Ihr TRV bedeutet und Sie es austauschen möchten.",
"calibration": "Die Art der Kalibrierung https://better-thermostat.org/configuration#second-step"
"child_lock": "Ignorieren Sie alle Eingaben am TRV wie eine Kindersicherung.",
"homematicip": "Wenn Sie HomematicIP verwenden, sollten Sie dies aktivieren, um die Anfragen zu verlangsamen und den Duty Cycle zu verhindern."
},
"data_description": {
"presets": "Wähle die Presets aus, die für dieses Thermostat verfügbar sein sollen."
}
},
"advanced": {
"description": "Aktualisiere die Einstellungen für {trv}\n\n***Infos über die Kalibrierungstypen: https://better-thermostat.org/configuration#second-step*** ",
"description": "Aktualisiere die Einstellungen für {trv}\n\n**Detaillierte Infos:** {docs_url}",
"data": {
"protect_overheating": "Überhitzung verhindern?",
"heat_auto_swapped": "Tauscht die Modi auto und heat, falls diese bei dem realen Thermostat vertauscht sind.",
@@ -90,7 +100,7 @@
},
"data_description": {
"protect_overheating": "Manche TRVs schließen auch nach Erreichen der Temperatur das Ventil nicht vollständig. Dies kann zu Überhitzungen führen. Ebenso falls der Radiator viel Restwärme abstrahlt. Diese Option kann dies verhindern.",
"calibration_mode": "Wie die Kalibrierung berechnet wird\n***Normal***: In diesem Modus wird die interne TRV-Temperatur an die des externen Sensors angeglichen.\n\n***Aggresive***: In diesem Modus wird die interne TRV-Temperatur an die des externen Sensors angeglichen - allerdings mit größeren Werten. Dies ist hilfreich, wenn ein Raum schnell aufgeheizt werden soll oder das TRV träge ist.\n\n***AI Time Based***: In diesem Modus wird ein eigener Algorithmus genutzt, der anhand des externen Temperatursensors die Kalibrierung berechnet. Dieser Modus versucht den TRV internen Algorithmus zu optimieren.",
"calibration_mode": "Wie die Kalibrierung berechnet wird\n***Normal***: In diesem Modus wird die interne TRV-Temperatur an die des externen Sensors angeglichen.\n\n***Aggressive***: In diesem Modus wird die interne TRV-Temperatur an die des externen Sensors angeglichen - allerdings mit größeren Werten. Dies ist hilfreich, wenn ein Raum schnell aufgeheizt werden soll oder das TRV träge ist.\n\n***AI Time Based***: In diesem Modus wird ein eigener Algorithmus genutzt, der anhand des externen Temperatursensors die Kalibrierung berechnet. Dieser Modus versucht den TRV internen Algorithmus zu optimieren.\n\n***MPC Predictive (Beta)***: Nutzt den MPC-Prädiktivregler, um eine Ventilöffnung vorherzusagen und wandelt sie bei fehlender Ventilsteuerung in Temperatur- oder Offset-Anpassungen um.\n\n***PID Controller***: Verwendet einen PID-Regler, um die Ventilposition dynamisch basierend auf Temperaturfehler, proportionalem Anteil, integraler Akkumulation und derivativem Vorhersage anzupassen.\n\n***TPI Controller***: Verwendet einen Zeit-Proportional-Integral-Regler für stabile und präzise Temperatursteuerung über die Zeit.",
"calibration": "Wie die Kalibrierung auf das TRV angewendet werden soll.\n\n***Target Temperature Based***: Kalibriert das TRV über die Zieltemperatur.\n\n***Offset Based***: Kalibriert das TRV über eine Offset Funktion im TRV selbst. (Empfohlen)"
}
}
@@ -107,24 +117,122 @@
}
}
}
},
"degraded_mode": {
"title": "BT: {name} - Sensorfehler: {sensors} läuft im eingeschränkten Modus",
"description": "Better Thermostat läuft im eingeschränkten Modus, da einige Sensoren nicht verfügbar sind: {sensors}\n\nDer Thermostat arbeitet mit reduzierter Funktionalität weiter. Zum Beispiel:\n- Wenn der Raumtemperatursensor nicht verfügbar ist, wird stattdessen der interne Sensor des TRV verwendet.\n- Wenn Fenstersensoren nicht verfügbar sind, wird angenommen, dass die Fenster geschlossen sind.\n- Wenn Außen-/Wettersensoren nicht verfügbar sind, wird die Heizung immer aktiviert sein.\n\nÜberprüfen Sie, ob die betroffenen Geräte Batterieprobleme oder Verbindungsprobleme haben."
},
"invalid_window_state": {
"title": "BT: {name} - ungültiger Fenstersensor-Status",
"description": "Der Fenstersensor für Better Thermostat {name} hat einen unbekannten Status gemeldet: {state}.\n\nErwartete Status sind: 'on', 'off', 'true', 'false', 'open', 'closed'.\n\nBitte überprüfen Sie Ihre Fenstersensor-Konfiguration."
}
},
"services": {
"save_current_target_temperature": {
"name": "Speichere die aktuelle Zieltemperatur",
"description": "Speichert die aktuelle Zieltemperatur um sie zu einem späteren Zeitpunkt wiederherzustellen."
},
"restore_saved_target_temperature": {
"name": "Stelle die Zieltemperatur wieder her",
"description": "Stellt die Zieltemperatur aus dem letzten gespeicherten Wert wieder her."
"run_valve_maintenance": {
"name": "Ventilwartung jetzt ausführen",
"description": "Führt den Ventilwartungszyklus für diese Better-Thermostat-Entität sofort aus."
},
"reset_heating_power": {
"name": "Setze 'Heating Power' zurück",
"description": "Stellt die Heating Power auf den Startwert zurück."
},
"set_temp_target_temperature": {
"name": "Setze die Temperatur für ECO",
"description": "Speichert eine ECO Temperatur, die für den ECO Modus genutzt wird."
"reset_pid_learnings": {
"name": "PID-Lernwerte zurücksetzen",
"description": "Löscht alle gelernten PID-Werte (und optional gelernte Öffnungsgrenzen) für diese Entität.",
"fields": {
"include_open_caps": {
"name": "Auch gelernte Öffnungsgrenzen löschen",
"description": "Wenn aktiviert, werden auch die gelernten minimalen/maximalen Öffnungsprozente je TRV/Ziel-Bereich gelöscht."
},
"apply_pid_defaults": {
"name": "PID-Standardwerte nach dem Zurücksetzen anwenden",
"description": "Wenn aktiviert, wird der aktuelle Ziel-Bereich mit den PID-Standardverstärkungen (kp/ki/kd) vorbelegt. Die Standardwerte können unten überschrieben werden."
},
"defaults_kp": {
"name": "Standard-kp",
"description": "Proportionalverstärkung, die nach dem Zurücksetzen gesetzt wird (fällt auf BalanceParams.kp zurück, wenn nicht angegeben)."
},
"defaults_ki": {
"name": "Standard-ki",
"description": "Integralverstärkung, die nach dem Zurücksetzen gesetzt wird (fällt auf BalanceParams.ki zurück, wenn nicht angegeben)."
},
"defaults_kd": {
"name": "Standard-kd",
"description": "Differenzialverstärkung, die nach dem Zurücksetzen gesetzt wird (fällt auf BalanceParams.kd zurück, wenn nicht angegeben)."
}
}
}
},
"selector": {
"presets": {
"options": {
"eco": "Eco",
"away": "Abwesend",
"boost": "Boost",
"comfort": "Komfort",
"home": "Zuhause",
"sleep": "Schlafen",
"activity": "Aktivität"
}
}
},
"entity": {
"number": {
"pid_kp": {
"name": "{trv_name} PID Kp (Proportional)"
},
"pid_ki": {
"name": "{trv_name} PID Ki (Integral)"
},
"pid_kd": {
"name": "{trv_name} PID Kd (Differential)"
},
"pid_kp_no_trv": {
"name": "PID Kp (Proportional)"
},
"pid_ki_no_trv": {
"name": "PID Ki (Integral)"
},
"pid_kd_no_trv": {
"name": "PID Kd (Differential)"
}
},
"switch": {
"pid_auto_tune": {
"name": "{trv_name} PID Auto-Tuning"
},
"pid_auto_tune_no_trv": {
"name": "PID Auto-Tuning"
},
"child_lock": {
"name": "{trv_name} Kindersicherung"
},
"child_lock_no_trv": {
"name": "Kindersicherung"
}
}
},
"device_automation": {
"trigger_type": {
"heating_active": "Thermostat wechselt in Heizmodus",
"heating_stopped": "Thermostat stoppt Heizung",
"humidity_high": "Luftfeuchtigkeit überschreitet Schwellenwert",
"window_opened": "Fenster geöffnet",
"window_closed": "Fenster geschlossen",
"battery_low": "Batterie schwach",
"device_error": "Gerätefehler erkannt",
"target_temp_reached": "Zieltemperatur erreicht",
"hvac_mode_changed": "HVAC-Modus geändert",
"current_temperature_changed": "Aktuelle Temperatur geändert",
"current_humidity_changed": "Aktuelle Luftfeuchtigkeit geändert"
},
"condition_type": {
"is_hvac_mode": "Thermostat ist auf einen bestimmten HVAC-Modus eingestellt",
"is_hvac_action": "Thermostat führt eine bestimmte HVAC-Aktion aus"
},
"action_type": {
"set_hvac_mode": "HVAC-Modus setzen",
"set_temperature": "Zieltemperatur setzen"
}
}
}
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
"description": "Setup your Better Thermostat to integrate with Home Assistant\n**If you need more info: https://better-thermostat.org/configuration#first-step**",
"description": "Setup your Better Thermostat to integrate with Home Assistant\n\n**Detailed information:** {docs_url}",
"data": {
"name": "Name",
"thermostat": "The real thermostat",
@@ -11,30 +11,35 @@
"temperature_sensor": "Temperature sensor",
"humidity_sensor": "Humidity sensor",
"window_sensors": "Window sensor",
"off_temperature": "The outdoor temperature when the thermostat turns off",
"tolerance": "Tolerance, to prevent the thermostat from turning on and off too often.",
"window_off_delay": "Delay before the thermostat turns off when the window is opened",
"window_off_delay_after": "Delay before the thermostat turns on when the window is closed",
"outdoor_sensor": "If you have an outdoor sensor, you can use it to get the outdoor temperature",
"weather": "Your weather entity to get the outdoor temperature"
"off_temperature": "The outdoor temperature when the thermostat should turn off",
"tolerance": "Tolerance, to prevent the thermostat from turning on and off too often",
"window_off_delay": "Delay before the thermostat should turn off when the window is opened",
"window_off_delay_after": "Delay before the thermostat should turn on when the window is closed",
"outdoor_sensor": "Outdoor temperature sensor",
"weather": "Weather entity to get the outdoor temperature",
"target_temp_step": "Target temperature step",
"presets": "Enabled Presets"
},
"data_description": {
"presets": "Select the presets you want to be available for this thermostat."
}
},
"advanced": {
"description": "Advanced configuration {trv}\n***Info about calibration types: https://better-thermostat.org/configuration#second-step***",
"description": "Advanced configuration {trv}\n\n**Detailed information:** {docs_url}",
"data": {
"protect_overheating": "Overheating protection?",
"heat_auto_swapped": "If the auto means heat for your TRV and you want to swap it",
"heat_auto_swapped": "If 'auto' means 'heat' for your TRV and you want to swap it",
"child_lock": "Ignore all inputs on the TRV like a child lock",
"homematicip": "If you use HomematicIP, you should enable this to slow down the requests to prevent the duty cycle",
"valve_maintenance": "If your thermostat has no own maintenance mode, you can use this one",
"calibration": "Calibration Type",
"calibration": "Calibration type",
"calibration_mode": "Calibration mode",
"no_off_system_mode": "If your TRV can't handle the off mode, you can enable this to use target temperature 5°C instead"
"no_off_system_mode": "If your TRV doesn't support the 'off' mode, enable this to use target temperature 5°C instead"
},
"data_description": {
"protect_overheating": "Some TRVs don't close the valve completely when the temperature is reached. Or the radiator has a lot of rest heat. This can cause overheating. This option can prevent this.",
"calibration_mode": "The kind how the calibration should be calculated\n***Normal***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor.\n***Aggresive***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor but set much lower/higher to get a quicker boost.\n***AI Time Based***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor, but a custom algorithm calculates the value to improve the TRV internal algorithm.",
"calibration": "How the calibration should be applied on the TRV (Target temp or offset)\n***Target Temperature Based***: Apply the calibration to the target temperature.\n***Offset Based***: Apply the calibration to the offset."
"calibration_mode": "How the calibration is calculated\n***Normal***: Uses your external temperature sensor to correct the TRV's internal sensor. Simple and reliable for most setups.\n***Aggressive***: Similar to Normal, but pushes the TRV harder to reach temperature faster. Good if your room heats slowly.\n***AI Time Based***: Learns your room's heating patterns over time and adjusts automatically. Balances comfort and efficiency.\n***MPC Predictive***: Advanced algorithm that predicts temperature changes and optimizes valve position. Best for stable, efficient heating with minimal overshooting. Learns your room's thermal behavior.\n***PID Controller***: Classic control method that responds to temperature errors. Good for systems with varying heating power or external disturbances.\n***TPI Controller***: Calculates heating duty cycles based on temperature difference. Simple and effective for consistent heating patterns.",
"calibration": "How the calibration is applied on the TRV\n***Target Temperature Based***: Apply the calibration to the target temperature.\n***Offset Based***: Apply the calibration to the temperature offset."
}
},
"confirm": {
@@ -45,53 +50,58 @@
"error": {
"failed": "Something went wrong.",
"no_name": "Please enter a name.",
"no_off_mode": "Your device is very special and has no off mode :(\nBetter Thermostat will use the minimal target temp instead.",
"no_off_mode": "Your device is special and has no off mode :(\nBetter Thermostat will use the minimal target temp instead.",
"no_outside_temp": "You have no outside temperature sensor. Better Thermostat will use the weather entity instead."
},
"abort": {
"single_instance_allowed": "Only a single Thermostat for each real is allowed.",
"no_devices_found": "No thermostat entity found, make sure you have a climate entity in your home assistant"
"single_instance_allowed": "Only a single Better Thermostat is allowed for each real thermostat.",
"no_devices_found": "No thermostat entity found, make sure you have a climate entity in your Home Assistant"
}
},
"options": {
"step": {
"user": {
"description": "Update your Better Thermostat settings",
"description": "Update your Better Thermostat settings\n\n**Detailed information:** {docs_url}",
"data": {
"name": "Name",
"thermostat": "The real thermostat",
"temperature_sensor": "Temperature Sensor",
"humidity_sensor": "Humidity sensor",
"window_sensors": "Window Sensor",
"off_temperature": "The outdoor temperature when the thermostat turns off",
"tolerance": "Tolerance, to prevent the thermostat from turning on and off too often.",
"window_off_delay": "Delay before the thermostat turns off when the window is opened",
"window_off_delay_after": "Delay before the thermostat turns on when the window is closed",
"outdoor_sensor": "If you have an outdoor sensor, you can use it to get the outdoor temperature",
"off_temperature": "The outdoor temperature when the thermostat should turn off",
"tolerance": "Tolerance, to prevent the thermostat from turning on and off too often",
"window_off_delay": "Delay before the thermostat should turn off when the window is opened",
"window_off_delay_after": "Delay before the thermostat should turn on when the window is closed",
"outdoor_sensor": "Outdoor temperature sensor",
"weather": "Weather entity to get the outdoor temperature",
"target_temp_step": "Target temperature step",
"presets": "Enabled Presets",
"valve_maintenance": "If your thermostat has no own maintenance mode, you can use this one",
"calibration": "The sort of calibration https://better-thermostat.org/configuration#second-step",
"weather": "Your weather entity to get the outdoor temperature",
"heat_auto_swapped": "If the auto means heat for your TRV and you want to swap it",
"calibration": "The sort of calibration",
"heat_auto_swapped": "If 'auto' means 'heat' for your TRV and you want to swap it",
"child_lock": "Ignore all inputs on the TRV like a child lock",
"homematicip": "If you use HomematicIP, you should enable this to slow down the requests to prevent the duty cycle"
},
"data_description": {
"presets": "Select the presets you want to be available for this thermostat."
}
},
"advanced": {
"description": "Advanced configuration {trv}\n***Info about calibration types: https://better-thermostat.org/configuration#second-step***",
"description": "Advanced configuration {trv}\n\n**Detailed information:** {docs_url}",
"data": {
"protect_overheating": "Overheating protection?",
"heat_auto_swapped": "If the auto means heat for your TRV and you want to swap it",
"heat_auto_swapped": "If 'auto' means 'heat' for your TRV and you want to swap it",
"child_lock": "Ignore all inputs on the TRV like a child lock",
"homematicip": "If you use HomematicIP, you should enable this to slow down the requests to prevent the duty cycle",
"valve_maintenance": "If your thermostat has no own maintenance mode, you can use this one",
"calibration": "The sort of calibration you want to use",
"calibration": "Calibration type",
"calibration_mode": "Calibration mode",
"no_off_system_mode": "If your TRV can't handle the off mode, you can enable this to use target temperature 5°C instead"
"no_off_system_mode": "If your TRV doesn't support the 'off' mode, enable this to use target temperature 5°C instead"
},
"data_description": {
"protect_overheating": "Some TRVs don't close the valve completely when the temperature is reached. Or the radiator has a lot of rest heat. This can cause overheating. This option can prevent this.",
"calibration_mode": "The kind how the calibration should be calculated\n***Normal***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor.\n***Aggresive***: In this mode, the TRV internal temperature sensor is fixed by the external temperature sensor but set much lower/higher to get a quicker boost.\n***AI Time Based***: In this mode the TRV internal temperature sensor is fixed by the external temperature sensor, but a custom algorithm calculates the value to improve the TRV internal algorithm.",
"calibration": "How the calibration should be applied on the TRV (Target temp or offset)\n***Target Temperature Based***: Apply the calibration to the target temperature.\n***Offset Based***: Apply the calibration to the offset."
"calibration_mode": "How the calibration is calculated\n***Normal***: Uses your external temperature sensor to correct the TRV's internal sensor. Simple and reliable for most setups.\n***Aggressive***: Similar to Normal, but pushes the TRV harder to reach temperature faster. Good if your room heats slowly.\n***AI Time Based***: Learns your room's heating patterns over time and adjusts automatically. Balances comfort and efficiency.\n***MPC Predictive***: Advanced algorithm that predicts temperature changes and optimizes valve position. Best for stable, efficient heating with minimal overshooting. Learns your room's thermal behavior.\n***PID Controller***: Classic control method that responds to temperature errors. Good for systems with varying heating power or external disturbances.\n***TPI Controller***: Calculates heating duty cycles based on temperature difference. Simple and effective for consistent heating patterns.",
"calibration": "How the calibration is applied on the TRV\n***Target Temperature Based***: Apply the calibration to the target temperature.\n***Offset Based***: Apply the calibration to the temperature offset."
}
}
}
@@ -107,24 +117,122 @@
}
}
}
},
"degraded_mode": {
"title": "BT: {name} - sensor error: {sensors} running in degraded mode",
"description": "Better Thermostat is running in degraded mode because some sensors are unavailable: {sensors}\n\nThe thermostat will continue to operate with reduced functionality. For example:\n- If the room temperature sensor is unavailable, the TRV's internal sensor will be used instead.\n- If window sensors are unavailable, windows are assumed to be closed.\n- If outdoor/weather sensors are unavailable, heating will always be enabled.\n\nCheck if the affected devices have battery issues or connectivity problems."
},
"invalid_window_state": {
"title": "BT: {name} - invalid window sensor state",
"description": "The window sensor for Better Thermostat {name} reported an unrecognized state: {state}.\n\nExpected states are: 'on', 'off', 'true', 'false', 'open', 'closed'.\n\nPlease check your window sensor configuration."
}
},
"services": {
"save_current_target_temperature": {
"name": "Save current Temperature",
"description": "Save the current target temperature for later restore."
},
"restore_saved_target_temperature": {
"name": "Restore temperature",
"description": "Restore the saved target temperature."
"run_valve_maintenance": {
"name": "Run valve maintenance now",
"description": "Run the valve maintenance cycle immediately for this Better Thermostat entity."
},
"reset_heating_power": {
"name": "Reset heating power",
"description": "Reset heating power to default value."
},
"set_temp_target_temperature": {
"name": "Set eco temperature",
"description": "Set the target temperature to a temporary like night mode, and save the old one."
"reset_pid_learnings": {
"name": "Reset PID learnings",
"description": "Clear all learned PID state (and optionally learned open caps) for this entity.",
"fields": {
"include_open_caps": {
"name": "Also clear learned open caps",
"description": "If enabled, also clears learned min/max open percentages per TRV/target bucket."
},
"apply_pid_defaults": {
"name": "Apply PID defaults after reset",
"description": "If enabled, seeds the current target bucket(s) with PID default gains (kp/ki/kd). You can override defaults below."
},
"defaults_kp": {
"name": "Default kp",
"description": "Proportional gain to seed after reset (falls back to BalanceParams.kp if not provided)."
},
"defaults_ki": {
"name": "Default ki",
"description": "Integral gain to seed after reset (falls back to BalanceParams.ki if not provided)."
},
"defaults_kd": {
"name": "Default kd",
"description": "Derivative gain to seed after reset (falls back to BalanceParams.kd if not provided)."
}
}
}
},
"selector": {
"presets": {
"options": {
"eco": "Eco",
"away": "Away",
"boost": "Boost",
"comfort": "Comfort",
"home": "Home",
"sleep": "Sleep",
"activity": "Activity"
}
}
},
"entity": {
"number": {
"pid_kp": {
"name": "{trv_name} PID Kp (Proportional)"
},
"pid_ki": {
"name": "{trv_name} PID Ki (Integral)"
},
"pid_kd": {
"name": "{trv_name} PID Kd (Derivative)"
},
"pid_kp_no_trv": {
"name": "PID Kp (Proportional)"
},
"pid_ki_no_trv": {
"name": "PID Ki (Integral)"
},
"pid_kd_no_trv": {
"name": "PID Kd (Derivative)"
}
},
"switch": {
"pid_auto_tune": {
"name": "{trv_name} PID Auto Tune"
},
"pid_auto_tune_no_trv": {
"name": "PID Auto Tune"
},
"child_lock": {
"name": "{trv_name} Child Lock"
},
"child_lock_no_trv": {
"name": "Child Lock"
}
}
},
"device_automation": {
"trigger_type": {
"heating_active": "Thermostat switches to heating",
"heating_stopped": "Thermostat stops heating",
"humidity_high": "Humidity exceeds threshold",
"window_opened": "Window opened",
"window_closed": "Window closed",
"battery_low": "Battery low",
"device_error": "Device error detected",
"target_temp_reached": "Target temperature reached",
"hvac_mode_changed": "HVAC mode changed",
"current_temperature_changed": "Current temperature changed",
"current_humidity_changed": "Current humidity changed"
},
"condition_type": {
"is_hvac_mode": "Thermostat is set to a specific HVAC mode",
"is_hvac_action": "Thermostat is performing a specific HVAC action"
},
"action_type": {
"set_hvac_mode": "Set HVAC mode",
"set_temperature": "Set target temperature"
}
}
}
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
"description": "Configurez Better Thermostat pour l'intégrer à Home Assistant\n**Si vous avez besoin de plus d'informations : https://better-thermostat.org/configuration#first-step**",
"description": "Configurez Better Thermostat pour l'intégrer à Home Assistant\n\n**Infos détaillées :** {docs_url}",
"data": {
"name": "Nom",
"thermostat": "Le vrai thermostat",
@@ -16,11 +16,16 @@
"window_off_delay": "Délai avant que le thermostat ne s'éteigne lorsque la fenêtre est ouverte",
"window_off_delay_after": "Délai avant que le thermostat ne s'allume lorsque la fenêtre est fermée",
"outdoor_sensor": "Si vous avez un capteur extérieur, vous pouvez l'utiliser pour obtenir la température extérieure",
"weather": "Votre entité météo pour obtenir la température extérieure"
"weather": "Votre entité météo pour obtenir la température extérieure",
"target_temp_step": "Pas de la température cible",
"presets": "Préréglages activés"
},
"data_description": {
"presets": "Sélectionnez les préréglages qui doivent être disponibles pour ce thermostat."
}
},
"advanced": {
"description": "Configuration avancée {trv}\n***Informations sur les types de calibrage : https://better-thermostat.org/configuration#second-step***",
"description": "Configuration avancée {trv}\n\n**Infos détaillées :** {docs_url}",
"data": {
"protect_overheating": "Protection contre la surchauffe ?",
"heat_auto_swapped": "Si le mode automatique signifie chauffage pour votre TRV et que vous voulez le permuter",
@@ -33,7 +38,7 @@
},
"data_description": {
"protect_overheating": "Certains TRVs ne ferment pas complètement la vanne lorsque la température est atteinte. Ou le radiateur a beaucoup de chaleur résiduelle. Cela peut provoquer une surchauffe. Cette option peut prévenir cela.",
"calibration_mode": "Le type de calcul du calibrage\n***Normal*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe.\n***Aggressif*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe mais réglé beaucoup plus bas/haut pour obtenir un boost plus rapide.\n***AI Time Based*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe, mais la valeur est calculée par un algorithme personnalisé pour améliorer l'algorithme interne du TRV.",
"calibration_mode": "Le type de calcul du calibrage\n***Normal*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe.\n***Aggressif*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe mais réglé beaucoup plus bas/haut pour obtenir un boost plus rapide.\n***AI Time Based*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe, mais la valeur est calculée par un algorithme personnalisé pour améliorer l'algorithme interne du TRV.\n***MPC Predictive (Beta)*** : Utilise le contrôleur prédictif MPC pour dériver une ouverture de vanne et la convertir en décalages ou points de consigne lorsque le contrôle direct de la vanne n'est pas disponible.\n***PID Controller*** : Utilise un contrôleur PID pour ajuster dynamiquement la position de la vanne en fonction de l'erreur de température, du terme proportionnel, de l'accumulation intégrale et de la prédiction dérivée.\n***TPI Controller*** : Utilise un contrôleur Proportionnel Intégral Temporel pour un contrôle de température stable et précis dans le temps.",
"calibration": "Comment le calibrage doit être appliqué sur le TRV (Température cible ou décalage)\n***Basé sur la température cible*** : Appliquer le calibrage à la température cible.\n***Basé sur le décalage*** : Appliquer le calibrage au décalage."
}
},
@@ -56,7 +61,7 @@
"options": {
"step": {
"user": {
"description": "Mettez à jour les paramètres de Better Thermostat",
"description": "Mettez à jour les paramètres de Better Thermostat\n\n**Infos détaillées :** {docs_url}",
"data": {
"name": "Nom",
"thermostat": "Le vrai thermostat",
@@ -68,16 +73,21 @@
"window_off_delay": "Délai avant que le thermostat ne s'éteigne lorsque la fenêtre est ouverte",
"window_off_delay_after": "Délai avant que le thermostat ne s'allume lorsque la fenêtre est fermée",
"outdoor_sensor": "Si vous avez un capteur extérieur, vous pouvez l'utiliser pour obtenir la température extérieure",
"valve_maintenance": "Si votre thermostat n'a pas de mode maintenance propre, vous pouvez utiliser celui-ci",
"calibration": "Le type de calibrage https://better-thermostat.org/configuration#second-step",
"weather": "Votre entité météo pour obtenir la température extérieure",
"target_temp_step": "Pas de la température cible",
"presets": "Préréglages activés",
"valve_maintenance": "Si votre thermostat n'a pas de mode maintenance propre, vous pouvez utiliser celui-ci",
"calibration": "Le type de calibrage",
"heat_auto_swapped": "Si le mode automatique signifie chauffage pour votre TRV et que vous voulez le permuter",
"child_lock": "Ignorer toutes les entrées sur le TRV comme une sécurité enfant",
"homematicip": "Si vous utilisez HomematicIP, vous devriez l'activer pour ralentir les demandes et éviter le cycle de fonctionnement excessif"
},
"data_description": {
"presets": "Sélectionnez les préréglages qui doivent être disponibles pour ce thermostat."
}
},
"advanced": {
"description": "Configuration avancée {trv}\n***Informations sur les types de calibrage : https://better-thermostat.org/configuration#second-step***",
"description": "Configuration avancée {trv}\n\n**Infos détaillées :** {docs_url}",
"data": {
"protect_overheating": "Protection contre la surchauffe ?",
"heat_auto_swapped": "Si le mode automatique signifie chauffage pour votre TRV et que vous voulez le permuter",
@@ -90,7 +100,7 @@
},
"data_description": {
"protect_overheating": "Certains TRVs ne ferment pas complètement la vanne lorsque la température est atteinte. Ou le radiateur a beaucoup de chaleur résiduelle. Cela peut provoquer une surchauffe. Cette option peut prévenir cela.",
"calibration_mode": "Le type de calcul du calibrage\n***Normal*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe.\n***Aggressif*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe mais réglé beaucoup plus bas/haut pour obtenir un boost plus rapide.\n***AI Time Based*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe, mais la valeur est calculée par un algorithme personnalisé pour améliorer l'algorithme interne du TRV.",
"calibration_mode": "Le type de calcul du calibrage\n***Normal*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe.\n***Aggressif*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe mais réglé beaucoup plus bas/haut pour obtenir un boost plus rapide.\n***AI Time Based*** : Dans ce mode, le capteur de température interne du TRV est fixé par le capteur de température externe, mais la valeur est calculée par un algorithme personnalisé pour améliorer l'algorithme interne du TRV.\n***MPC Predictive (Beta)*** : Utilise le contrôleur prédictif MPC pour dériver une ouverture de vanne et la convertir en décalages ou points de consigne lorsque le contrôle direct de la vanne n'est pas disponible.\n***PID Controller*** : Utilise un contrôleur PID pour ajuster dynamiquement la position de la vanne en fonction de l'erreur de température, du terme proportionnel, de l'accumulation intégrale et de la prédiction dérivée.\n***TPI Controller*** : Utilise un contrôleur Proportionnel Intégral Temporel pour un contrôle de température stable et précis dans le temps.",
"calibration": "Comment le calibrage doit être appliqué sur le TRV (Température cible ou décalage)\n***Basé sur la température cible*** : Appliquer le calibrage à la température cible.\n***Basé sur le décalage*** : Appliquer le calibrage au décalage."
}
}
@@ -107,24 +117,122 @@
}
}
}
},
"degraded_mode": {
"title": "BT: {name} - erreur de capteur : {sensors} fonctionne en mode dégradé",
"description": "Better Thermostat fonctionne en mode dégradé car certains capteurs ne sont pas disponibles : {sensors}\n\nLe thermostat continuera à fonctionner avec des fonctionnalités réduites. Par exemple :\n- Si le capteur de température ambiante n'est pas disponible, le capteur interne du TRV sera utilisé à la place.\n- Si les capteurs de fenêtre ne sont pas disponibles, les fenêtres sont considérées comme fermées.\n- Si les capteurs extérieurs/météo ne sont pas disponibles, le chauffage sera toujours activé.\n\nVérifiez si les appareils concernés ont des problèmes de batterie ou de connectivité."
},
"invalid_window_state": {
"title": "BT: {name} - état du capteur de fenêtre invalide",
"description": "Le capteur de fenêtre pour Better Thermostat {name} a signalé un état non reconnu : {state}.\n\nLes états attendus sont : 'on', 'off', 'true', 'false', 'open', 'closed'.\n\nVeuillez vérifier la configuration de votre capteur de fenêtre."
}
},
"services": {
"save_current_target_temperature": {
"name": "Enregistrer la température actuelle",
"description": "Enregistrez la température cible actuelle pour une restauration ultérieure."
},
"restore_saved_target_temperature": {
"name": "Restaurer la température",
"description": "Restaurez la température cible enregistrée."
"run_valve_maintenance": {
"name": "Lancer la maintenance de la vanne maintenant",
"description": "Exécute immédiatement le cycle de maintenance de la vanne pour cette entité Better Thermostat."
},
"reset_heating_power": {
"name": "Réinitialiser la puissance de chauffage",
"description": "Réinitialisez la puissance de chauffage à la valeur par défaut."
},
"set_temp_target_temperature": {
"name": "Définir la température éco",
"description": "Définissez la température cible sur une valeur temporaire comme le mode nuit, et enregistrez l'ancienne."
"reset_pid_learnings": {
"name": "Réinitialiser l'apprentissage PID",
"description": "Efface tout l'état PID appris (et éventuellement les limites d'ouverture apprises) pour cette entité.",
"fields": {
"include_open_caps": {
"name": "Effacer aussi les limites d'ouverture apprises",
"description": "Si activé, efface également les pourcentages d'ouverture min/max appris par TRV/plage de consigne."
},
"apply_pid_defaults": {
"name": "Appliquer les valeurs PID par défaut après la réinitialisation",
"description": "Si activé, prérenseigne les plages de consigne actuelles avec les gains PID par défaut (kp/ki/kd). Vous pouvez remplacer les valeurs par défaut ci-dessous."
},
"defaults_kp": {
"name": "kp par défaut",
"description": "Gain proportionnel appliqué après la réinitialisation (utilise BalanceParams.kp si non renseigné)."
},
"defaults_ki": {
"name": "ki par défaut",
"description": "Gain intégral appliqué après la réinitialisation (utilise BalanceParams.ki si non renseigné)."
},
"defaults_kd": {
"name": "kd par défaut",
"description": "Gain dérivé appliqué après la réinitialisation (utilise BalanceParams.kd si non renseigné)."
}
}
}
},
"selector": {
"presets": {
"options": {
"eco": "Éco",
"away": "Absent",
"boost": "Boost",
"comfort": "Confort",
"home": "Maison",
"sleep": "Sommeil",
"activity": "Activité"
}
}
},
"entity": {
"number": {
"pid_kp": {
"name": "{trv_name} PID Kp (proportionnel)"
},
"pid_ki": {
"name": "{trv_name} PID Ki (intégral)"
},
"pid_kd": {
"name": "{trv_name} PID Kd (dérivé)"
},
"pid_kp_no_trv": {
"name": "PID Kp (proportionnel)"
},
"pid_ki_no_trv": {
"name": "PID Ki (intégral)"
},
"pid_kd_no_trv": {
"name": "PID Kd (dérivé)"
}
},
"switch": {
"pid_auto_tune": {
"name": "{trv_name} Réglage automatique PID"
},
"pid_auto_tune_no_trv": {
"name": "Réglage automatique PID"
},
"child_lock": {
"name": "{trv_name} Sécurité enfant"
},
"child_lock_no_trv": {
"name": "Sécurité enfant"
}
}
},
"device_automation": {
"trigger_type": {
"heating_active": "Le thermostat passe en mode chauffage",
"heating_stopped": "Le thermostat arrête le chauffage",
"humidity_high": "L'humidité dépasse le seuil",
"window_opened": "Fenêtre ouverte",
"window_closed": "Fenêtre fermée",
"battery_low": "Batterie faible",
"device_error": "Erreur de périphérique détectée",
"target_temp_reached": "Température cible atteinte",
"hvac_mode_changed": "Mode CVC modifié",
"current_temperature_changed": "Température actuelle modifiée",
"current_humidity_changed": "Humidité actuelle modifiée"
},
"condition_type": {
"is_hvac_mode": "Le thermostat est réglé sur un mode HVAC spécifique",
"is_hvac_action": "Le thermostat exécute une action HVAC spécifique"
},
"action_type": {
"set_hvac_mode": "Définir le mode HVAC",
"set_temperature": "Définir la température cible"
}
}
}
}
@@ -3,30 +3,38 @@
"config": {
"step": {
"user": {
"description": "Skonfiguruj swój Better Thermostat do integracji z Home Assistant\n**Więcej informacji znajdziesz na: https://better-thermostat.org/configuration#first-step**",
"description": "Skonfiguruj swój Better Thermostat do integracji z Home Assistant\n\n**Szczegółowe informacje:** {docs_url}",
"data": {
"name": "Nazwa",
"thermostat": "Twój termostat",
"cooler": "Urządzenie chłodzące (opcjonalnie)",
"temperature_sensor": "Sensor temperatury",
"humidity_sensor": "Sensor wilgotności",
"window_sensors": "Sensor okna",
"off_temperature": "Temperatura zewnętrzna, przy której termostat ma się wyłączyć",
"tolerance": "Tolerancja, aby zapobiec zbyt częstemu włączaniu i wyłączaniu termostatu",
"window_off_delay": "Opóźnienie wyłączenia termostatu, kiedy otworzysz okno lub je zamkniesz (w sekundach)",
"window_off_delay_after": "Opóźnienie włączenia termostatu po zamknięciu okna",
"outdoor_sensor": "Jeśli masz czujnik zewnętrzny, możesz go użyć, aby uzyskać temperaturę zewnętrzną",
"weather": "Twoja jednostka pogodowa, aby uzyskać temperaturę zewnętrzną"
"weather": "Twoja jednostka pogodowa, aby uzyskać temperaturę zewnętrzną",
"target_temp_step": "Krok temperatury docelowej",
"presets": "Włączone ustawienia predefiniowane"
},
"data_description": {
"presets": "Wybierz ustawienia predefiniowane, które mają być dostępne dla tego termostatu."
}
},
"advanced": {
"description": "Zaawansowana konfiguracja {trv}\n***Informacja o typach kalibracji: https://better-thermostat.org/configuration#second-step***",
"description": "Zaawansowana konfiguracja {trv}\n\n**Szczegółowe informacje:** {docs_url}",
"data": {
"protect_overheating": "Zabezpieczenie przed przegrzaniem?",
"heat_auto_swapped": "Jeżeli tryb auto oznacza grzanie dla Twojego TRV i chcesz to zmienić",
"child_lock": "Ignoruj wszystkie wejścia w TRV jak np. Blokada dziecięca",
"homematicip": "Jeżeli używasz HomematicIP, powinieneś włączyć tę opcję, żeby spowolnić żądania cyklu pracy",
"valve_maintenance": "Jeżeli Twój termostat nie ma trybu konserwacji, możesz użyć tej opcji.",
"calibration": "Rodzaj kalibracji, której chcesz użyć",
"no_off_system_mode": "Jeśli Twój TRV nie obsługuje trybu wyłączenia, możesz go włączyć, aby zamiast tego używać temperatury docelowej 5°C",
"calibration_mode": "Tryb kalibracji",
"protect_overheating": "Zabezpieczenie przed przegrzaniem?"
"no_off_system_mode": "Jeśli Twój TRV nie obsługuje trybu wyłączenia, możesz go włączyć, aby zamiast tego używać temperatury docelowej 5°C"
},
"data_description": {
"protect_overheating": "Niektóre TRV całkowicie zamykają zawór po osiągnięciu temperatury. Lub grzejnik ma dużo ciepła resztkowego. Może to spowodować przegrzanie. Ta opcja może temu zapobiec.",
@@ -40,10 +48,10 @@
}
},
"error": {
"no_outside_temp": "Nie masz czujnika temperatury zewnętrznej. Better Thermostat zamiast tego użyje jednostki pogodowej.",
"failed": "coś poszło nie tak.",
"no_name": "Proszę podać nazwę.",
"no_off_mode": "Twoje urządzenie jest inne i nie ma trybu wyłączenia :(\nTo będzie działać, ale musisz stworzyć automatyzację, aby ustawić funkcje pod swoje urządzenie"
"no_off_mode": "Twoje urządzenie jest inne i nie ma trybu wyłączenia :(\nTo będzie działać, ale musisz stworzyć automatyzację, aby ustawić funkcje pod swoje urządzenie",
"no_outside_temp": "Nie masz czujnika temperatury zewnętrznej. Better Thermostat zamiast tego użyje jednostki pogodowej."
},
"abort": {
"single_instance_allowed": "Dozwolony jest tylko jeden termostat dla jednego urządzenia.",
@@ -53,33 +61,40 @@
"options": {
"step": {
"user": {
"description": "Zaktualizuj ustawienia Better Thermostat",
"description": "Zaktualizuj ustawienia Better Thermostat\n\n**Szczegółowe informacje:** {docs_url}",
"data": {
"name": "Nazwa",
"thermostat": "Prawdziwy termostat",
"temperature_sensor": "Sensor temperatury",
"humidity_sensor": "Sensor wilgotności",
"window_sensors": "Sensor okna",
"off_temperature": "Temperatura zewnętrzna, przy której termostat ma się wyłączyć",
"tolerance": "Tolerancja, aby zapobiec zbyt częstemu włączaniu i wyłączaniu termostatu",
"window_off_delay": "Opóźnienie wyłączenia termostatu, kiedy otworzysz okno lub je zamkniesz (w sekundach)",
"window_off_delay_after": "Opóźnienie włączenia termostatu po zamknięciu okna",
"outdoor_sensor": "Jeśli masz czujnik zewnętrzny, możesz go użyć, aby uzyskać temperaturę zewnętrzną",
"valve_maintenance": "Jeżeli Twój termostat nie ma własnego trybu konserwacji, możesz użyć tej opcji.",
"calibration": "Rodzaje kalibracji https://better-thermostat.org/configuration#second-step",
"weather": "Twoja jednostka pogodowa, aby uzyskać temperaturę zewnętrzną",
"target_temp_step": "Krok temperatury docelowej",
"presets": "Włączone ustawienia predefiniowane",
"valve_maintenance": "Jeżeli Twój termostat nie ma własnego trybu konserwacji, możesz użyć tej opcji.",
"calibration": "Rodzaje kalibracji",
"heat_auto_swapped": "Jeżeli tryb auto oznacza grzanie dla Twojego TRV i chcesz to zmienić",
"child_lock": "Ignoruj wszystkie wejścia w TRV jak np. Blokada dziecięca",
"homematicip": "Jeżeli używasz HomematicIP, powinienieś włączyć tę opcję żeby spowolnić żądania",
"name": "Nazwa",
"thermostat": "Prawdziwy termostat"
"homematicip": "Jeżeli używasz HomematicIP, powinienieś włączyć tę opcję żeby spowolnić żądania"
},
"data_description": {
"presets": "Wybierz ustawienia predefiniowane, które mają być dostępne dla tego termostatu."
}
},
"advanced": {
"description": "Zaawansowana konfiguracja {trv}\n***Informacja o typach kalibracji: https://better-thermostat.org/configuration#second-step***",
"description": "Zaawansowana konfiguracja {trv}\n\n**Szczegółowe informacje:** {docs_url}",
"data": {
"protect_overheating": "Zabezpieczenie przed przegrzaniem?",
"heat_auto_swapped": "Jeżeli tryb auto oznacza grzanie dla Twojego TRV i chcesz to zmienić",
"child_lock": "Ignoruj wszystkie wejścia w TRV jak np. Blokada dziecięca",
"homematicip": "Jeżeli używasz HomematicIP, powinieneś włączyć tę opcję, żeby spowolnić żądania spowolnienia cyklu pracy",
"valve_maintenance": "Jeżeli Twój termostat nie ma trybu konserwacji, możesz użyć tej opcji.",
"calibration": "Rodzaj kalibracji, której chcesz użyć",
"protect_overheating": "Zabezpieczenie przed przegrzaniem?",
"calibration_mode": "Tryb kalibracji",
"no_off_system_mode": "Jeśli Twój TRV nie obsługuje trybu wyłączenia, możesz go włączyć, aby zamiast tego używać temperatury docelowej 5°C"
},
@@ -102,6 +117,122 @@
}
}
}
},
"degraded_mode": {
"title": "BT: {name} - błąd czujnika: {sensors} działa w trybie ograniczonym",
"description": "Better Thermostat działa w trybie ograniczonym, ponieważ niektóre czujniki są niedostępne: {sensors}\n\nTermostat będzie nadal działać z ograniczoną funkcjonalnością. Na przykład:\n- Jeśli czujnik temperatury pokojowej jest niedostępny, zostanie użyty wewnętrzny czujnik TRV.\n- Jeśli czujniki okien są niedostępne, okna są uznawane za zamknięte.\n- Jeśli czujniki zewnętrzne/pogodowe są niedostępne, ogrzewanie będzie zawsze włączone.\n\nSprawdź, czy dotknięte urządzenia mają problemy z baterią lub łącznością."
},
"invalid_window_state": {
"title": "BT: {name} - nieprawidłowy stan czujnika okna",
"description": "Czujnik okna dla Better Thermostat {name} zgłosił nierozpoznany stan: {state}.\n\nOczekiwane stany to: 'on', 'off', 'true', 'false', 'open', 'closed'.\n\nSprawdź konfigurację czujnika okna."
}
},
"services": {
"run_valve_maintenance": {
"name": "Uruchom teraz konserwację zaworu",
"description": "Natychmiast uruchamia cykl konserwacji zaworu dla tej encji Better Thermostat."
},
"reset_heating_power": {
"name": "Zresetuj moc grzewczą",
"description": "Resetuje moc grzewczą do wartości domyślnej."
},
"reset_pid_learnings": {
"name": "Zresetuj nauczone wartości PID",
"description": "Czyści cały nauczony stan PID (i opcjonalnie nauczone limity otwarcia) dla tej encji.",
"fields": {
"include_open_caps": {
"name": "Wyczyść także nauczone limity otwarcia",
"description": "Jeśli włączone, czyści również nauczone minimalne/maksymalne procenty otwarcia dla każdego TRV/przedziału docelowego."
},
"apply_pid_defaults": {
"name": "Zastosuj domyślne wartości PID po resecie",
"description": "Jeśli włączone, bieżące przedziały docelowe zostaną wstępnie wypełnione domyślnymi wzmocnieniami PID (kp/ki/kd). Wartości domyślne można nadpisać poniżej."
},
"defaults_kp": {
"name": "Domyślne kp",
"description": "Wzmocnienie proporcjonalne ustawiane po resecie (jeśli nie podano, używane jest BalanceParams.kp)."
},
"defaults_ki": {
"name": "Domyślne ki",
"description": "Wzmocnienie całkujące ustawiane po resecie (jeśli nie podano, używane jest BalanceParams.ki)."
},
"defaults_kd": {
"name": "Domyślne kd",
"description": "Wzmocnienie różniczkujące ustawiane po resecie (jeśli nie podano, używane jest BalanceParams.kd)."
}
}
}
},
"selector": {
"presets": {
"options": {
"eco": "Eko",
"away": "Poza domem",
"boost": "Boost",
"comfort": "Komfort",
"home": "W domu",
"sleep": "Sen",
"activity": "Aktywność"
}
}
},
"entity": {
"number": {
"pid_kp": {
"name": "{trv_name} PID Kp (proporcjonalny)"
},
"pid_ki": {
"name": "{trv_name} PID Ki (całkujący)"
},
"pid_kd": {
"name": "{trv_name} PID Kd (różniczkujący)"
},
"pid_kp_no_trv": {
"name": "PID Kp (proporcjonalny)"
},
"pid_ki_no_trv": {
"name": "PID Ki (całkujący)"
},
"pid_kd_no_trv": {
"name": "PID Kd (różniczkujący)"
}
},
"switch": {
"pid_auto_tune": {
"name": "{trv_name} Automatyczne strojenie PID"
},
"pid_auto_tune_no_trv": {
"name": "Automatyczne strojenie PID"
},
"child_lock": {
"name": "{trv_name} Blokada rodzicielska"
},
"child_lock_no_trv": {
"name": "Blokada rodzicielska"
}
}
},
"device_automation": {
"trigger_type": {
"heating_active": "Termostat przełącza się na ogrzewanie",
"heating_stopped": "Termostat zatrzymuje ogrzewanie",
"humidity_high": "Wilgotność przekracza próg",
"window_opened": "Okno otwarte",
"window_closed": "Okno zamknięte",
"battery_low": "Niski poziom baterii",
"device_error": "Wykryto błąd urządzenia",
"target_temp_reached": "Docelowa temperatura osiągnięta",
"hvac_mode_changed": "Tryb HVAC zmieniony",
"current_temperature_changed": "Aktualna temperatura zmieniona",
"current_humidity_changed": "Aktualna wilgotność zmieniona"
},
"condition_type": {
"is_hvac_mode": "Termostat jest ustawiony na określony tryb HVAC",
"is_hvac_action": "Termostat wykonuje określoną akcję HVAC"
},
"action_type": {
"set_hvac_mode": "Ustaw tryb HVAC",
"set_temperature": "Ustaw temperaturę docelową"
}
}
}
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
"description": "Nastavenie lepšieho termostatu na integráciu s aplikáciou Home Assistant\n**Ak potrebujete viac informácií: https://better-thermostat.org/configuration#first-step**",
"description": "Nastavenie lepšieho termostatu na integráciu s aplikáciou Home Assistant\n\n**Podrobné informácie:** {docs_url}",
"data": {
"name": "Názov",
"thermostat": "Skutočný termostat",
@@ -16,11 +16,16 @@
"window_off_delay": "Oneskorenie pred vypnutím termostatu pri otvorení okna",
"window_off_delay_after": "Oneskorenie pred zapnutím termostatu pri zatvorenom okne",
"outdoor_sensor": "Ak máte vonkajší snímač, môžete pomocou neho zistiť vonkajšiu teplotu",
"weather": "Váš meteorologický subjekt na zistenie vonkajšej teploty"
"weather": "Váš meteorologický subjekt na zistenie vonkajšej teploty",
"target_temp_step": "Krok cieľovej teploty",
"presets": "Povolené predvoľby"
},
"data_description": {
"presets": "Vyberte predvoľby, ktoré chcete mať k dispozícii pre tento termostat."
}
},
"advanced": {
"description": "Rozšírená konfigurácia {trv}\n***Informácie o druhoch kalibrácie: https://better-thermostat.org/configuration#second-step***",
"description": "Rozšírená konfigurácia {trv}\n\n**Podrobné informácie:** {docs_url}",
"data": {
"protect_overheating": "Ochrana proti prehriatiu?",
"heat_auto_swapped": "Ak auto znamená teplo pre váš TRV a chcete ho vymeniť",
@@ -32,8 +37,8 @@
"no_off_system_mode": "Ak váš TRV nedokáže pracovať v režime vypnutia, môžete namiesto toho povoliť použitie cieľovej teploty 5 °C."
},
"data_description": {
"protect_overheating": "Niektoré TRV nezatvárajú ventil úplne, keď sa dosiahne požadovaná teplota. Alebo je v radiátore veľa zvyškového tepla. To môže spôsobiť prehriatie. Táto možnosť tomu môže zabrániť.",
"calibration_mode": "Spôsob, ako sa má kalibrácia vypočítať\n***Normal***: V tomto režime je vnútorný snímač teploty TRV fixovaný vonkajším snímačom teploty.\n***Agresívny***: V tomto režime je vnútorný teplotný snímač TRV fixovaný vonkajším teplotným snímačom, ale je nastavený oveľa nižšie/vyššie, aby sa dosiahol rýchlejší nárast.\n***AI na základe času***: V tomto režime je vnútorný teplotný snímač TRV fixovaný vonkajším teplotným snímačom, ale hodnota sa vypočíta pomocou vlastného algoritmu na zlepšenie vnútorného algoritmu TRV.",
"protect_overheating": "Niektoré termostatické ventily úplne neuzatvoria ventil po dosiahnutí požadovanej teploty. Alebo radiátor veľa zvyškového tepla. To môže spôsobiť prehriatie. Táto možnosť tomu môže zabrániť.",
"calibration_mode": "Druh, akým sa má kalibrácia vypočítať\n***Normálny***: V tomto režime je vnútorný snímač teploty TRV fixovaný externým snímačom teploty.\n***Agresívny***: V tomto režime je vnútorný snímač teploty TRV fixovaný externým snímačom teploty, ale nastavený oveľa nižšie/vyššie, aby sa dosiahlo rýchlejšie zvýšenie teploty.\n***Časovo založený na AI***: V tomto režime je vnútorný snímač teploty TRV fixovaný externým snímačom teploty, ale vlastný algoritmus vypočíta hodnotu na zlepšenie vnútorného algoritmu TRV.\n***Prediktívny MPC (Beta)***: Používa prediktívny regulátor MPC na odvodenie otvorenia ventilu a prevedie ho na posuny alebo požadova hodnoty, keď priame riadenie ventilu nie je k dispozícii.\n***PID regulátor***: Používa PID regulátor na dynamické nastavenie polohy ventilu na základe teplotnej chyby, proporcionálneho člena, integrálnej akumulácie a derivačnej predikcie.\n***TPI regulátor***: Používa časovo proporcionálny integračný regulátor pre stabilnú a presnú reguláciu teploty v priebehu času.",
"calibration": "Ako sa má kalibrácia použiť na TRV (cieľová teplota alebo posun)\n***Na základe cieľovej teploty***: Použite kalibráciu na cieľovú teplotu.\n***Na základe ofsetu***: Použite kalibráciu na offset."
}
},
@@ -56,7 +61,7 @@
"options": {
"step": {
"user": {
"description": "Aktualizujte Better Thermostat nastavenia",
"description": "Aktualizujte Better Thermostat nastavenia\n\n**Podrobné informácie:** {docs_url}",
"data": {
"name": "Názov",
"thermostat": "Reálny thermostat",
@@ -68,16 +73,21 @@
"window_off_delay": "Oneskorenie pred vypnutím termostatu pri otvorení okna",
"window_off_delay_after": "Oneskorenie pred zapnutím termostatu pri zatvorenom okne",
"outdoor_sensor": "Ak máte vonkajší snímač, môžete pomocou neho zistiť vonkajšiu teplotu",
"valve_maintenance": "Ak váš termostat nemá vlastný režim údržby, môžete použiť tento režim",
"calibration": "Druh kalibrácie https://better-thermostat.org/configuration#second-step",
"weather": "Váš meteorologický subjekt na zistenie vonkajšej teploty",
"target_temp_step": "Krok cieľovej teploty",
"presets": "Enabled Predvoľby",
"valve_maintenance": "Ak váš termostat nemá vlastný režim údržby, môžete použiť tento režim",
"calibration": "Druh kalibrácie",
"heat_auto_swapped": "Ak auto znamená teplo pre váš TRV a chcete ho vymeniť",
"child_lock": "Ignorovať všetky vstupy na TRV ako detský zámok",
"homematicip": "Ak používate HomematicIP, mali by ste túto funkciu povoliť, aby ste spomalili požiadavky a zabránili tak pracovnému cyklu"
},
"data_description": {
"presets": "Vyberte predvoľby, ktoré chcete mať k dispozícii pre tento termostat."
}
},
"advanced": {
"description": "Rozšírená konfigurácia {trv}\n***Informácie o druhoch kalibrácie: https://better-thermostat.org/configuration#second-step***",
"description": "Rozšírená konfigurácia {trv}\n\n**Podrobné informácie:** {docs_url}",
"data": {
"protect_overheating": "Ochrana proti prehriatiu?",
"heat_auto_swapped": "Ak auto znamená teplo pre váš TRV a chcete ho vymeniť",
@@ -90,7 +100,7 @@
},
"data_description": {
"protect_overheating": "Niektoré TRV nezatvárajú ventil úplne, keď sa dosiahne požadovaná teplota. Alebo je v radiátore veľa zvyškového tepla. To môže spôsobiť prehriatie. Táto možnosť tomu môže zabrániť.",
"calibration_mode": "Druh, ako sa má kalibrácia vypočítať\n***Normálna***: V tomto režime je vnútorný snímač teploty TRV fixovaný vonkajším snímačom teploty.\n***Aggresívna***: V tomto režime je vnútorný teplotný snímač TRV fixovaný vonkajším teplotným snímačom, ale je nastavený oveľa nižšie/vyššie, aby sa dosiahol rýchlejší nárast.\n***AI na základe času***: V tomto režime je vnútorný teplotný snímač TRV fixovaný vonkajším teplotným snímačom, ale hodnota sa vypočíta pomocou vlastného algoritmu na zlepšenie vnútorného algoritmu TRV.",
"calibration_mode": "Druh výpočtu kalibrácie\n***Normálny***: V tomto režime je vnútorný snímač teploty TRV fixovaný externým snímačom teploty.\n***Agresívny***: V tomto režime je vnútorný snímač teploty TRV fixovaný externým snímačom teploty, ale nastavený oveľa nižšie/vyššie, aby sa dosiahlo rýchlejšie zvýšenie teploty.\n***Časovo riadený AI***: V tomto režime je vnútorný snímač teploty TRV fixovaný externým snímačom teploty, ale vlastný algoritmus vypočíta hodnotu na zlepšenie vnútorného algoritmu TRV.\n***Prediktívny MPC (Beta)***: Používa algoritmus hydraulického vyváženia MPC na odvodenie otvorenia ventilu a prevádza ho na posuny alebo požadova hodnoty, keď priame riadenie ventilu nie je k dispozícii.\n***PID regulátor***: Používa PID regulátor na dynamické nastavenie polohy ventilu na základe teplotnej chyby, proporcionálneho člena, integrálnej akumulácie a derivačnej predikcie.\n***TPI regulátor***: Používa časovo proporcionálny integračný regulátor pre stabilnú a presnú reguláciu teploty v priebehu času.",
"calibration": "Ako sa má kalibrácia použiť na TRV (cieľová teplota alebo ofset)\n***Na základe cieľovej teploty***: Použite kalibráciu na cieľovú teplotu.\n***Na základe ofsetu***: Použite kalibráciu na offset."
}
}
@@ -107,24 +117,122 @@
}
}
}
},
"degraded_mode": {
"title": "BT: {name} - chyba snímača: {sensors} beží v obmedzenom režime",
"description": "Better Thermostat beží v obmedzenom režime, pretože niektoré snímače nie sú dostupné: {sensors}\n\nTermostat bude naďalej fungovať s obmedzenou funkcionalitou. Napríklad:\n- Ak snímač izbovej teploty nie je dostupný, použije sa interný snímač TRV.\n- Ak snímače okien nie sú dostupné, okná sa považujú za zatvorené.\n- Ak vonkajšie/meteorologické snímače nie sú dostupné, kúrenie bude vždy zapnuté.\n\nSkontrolujte, či postihnuté zariadenia nemajú problémy s batériou alebo pripojením."
},
"invalid_window_state": {
"title": "BT: {name} - neplatný stav snímača okna",
"description": "Snímač okna pre Better Thermostat {name} nahlásil nerozpoznaný stav: {state}.\n\nOčakávané stavy sú: 'on', 'off', 'true', 'false', 'open', 'closed'.\n\nSkontrolujte konfiguráciu snímača okna."
}
},
"services": {
"save_current_target_temperature": {
"name": "Uložiť aktuálnu teplotu",
"description": "Uloženie aktuálnej cieľovej teploty na neskoršie obnovenie."
},
"restore_saved_target_temperature": {
"name": "Obnoviť teploty",
"description": "Obnovenie uloženej cieľovej teploty."
"run_valve_maintenance": {
"name": "Spustiť údržbu ventilu teraz",
"description": "Okamžite spustí cyklus údržby ventilu pre túto entitu Better Thermostat."
},
"reset_heating_power": {
"name": "Reset vykurovacieho výkonu",
"description": "Obnovenie výkonu vykurovania na predvolenú hodnotu."
},
"set_temp_target_temperature": {
"name": "Nastavenie eko teploty",
"description": "Nastavte cieľovú teplotu na dočasný, napríklad nočný režim, a uložte starú teplotu."
"reset_pid_learnings": {
"name": "Vynulovať naučené hodnoty PID",
"description": "Vymaže všetok naučený stav PID (a voliteľne naučené limity otvorenia) pre túto entitu.",
"fields": {
"include_open_caps": {
"name": "Vymazať aj naučené limity otvorenia",
"description": "Ak je povolené, vymažú sa aj naučené minimálne/maximálne percentá otvorenia pre každý TRV/cieľové pásmo."
},
"apply_pid_defaults": {
"name": "Po vynulovaní použiť predvolené hodnoty PID",
"description": "Ak je povolené, aktuálne cieľové pásma sa predvyplnia predvolenými zosilneniami PID (kp/ki/kd). Predvolené hodnoty môžete prepísať nižšie."
},
"defaults_kp": {
"name": "Predvolené kp",
"description": "Proporcionálne zosilnenie nastavené po vynulovaní (ak nie je zadané, použije sa BalanceParams.kp)."
},
"defaults_ki": {
"name": "Predvolené ki",
"description": "Integračné zosilnenie nastavené po vynulovaní (ak nie je zadané, použije sa BalanceParams.ki)."
},
"defaults_kd": {
"name": "Predvolené kd",
"description": "Derivačné zosilnenie nastavené po vynulovaní (ak nie je zadané, použije sa BalanceParams.kd)."
}
}
}
},
"selector": {
"presets": {
"options": {
"eco": "Eko",
"away": "Preč",
"boost": "Boost",
"comfort": "Komfort",
"home": "Doma",
"sleep": "Spánok",
"activity": "Aktivita"
}
}
},
"entity": {
"number": {
"pid_kp": {
"name": "{trv_name} PID Kp (Proporcionálne)"
},
"pid_ki": {
"name": "{trv_name} PID Ki (Integrálne)"
},
"pid_kd": {
"name": "{trv_name} PID Kd (Derivačné)"
},
"pid_kp_no_trv": {
"name": "PID Kp (Proporcionálne)"
},
"pid_ki_no_trv": {
"name": "PID Ki (Integračné)"
},
"pid_kd_no_trv": {
"name": "PID Kd (Derivačné)"
}
},
"switch": {
"pid_auto_tune": {
"name": "{trv_name} PID Auto Tune"
},
"pid_auto_tune_no_trv": {
"name": "PID Auto Tune"
},
"child_lock": {
"name": "{trv_name} Detská poistka"
},
"child_lock_no_trv": {
"name": "Detská poistka"
}
}
},
"device_automation": {
"trigger_type": {
"heating_active": "Termostat prechádza do režimu kúrenia",
"heating_stopped": "Termostat zastavuje kúrenie",
"humidity_high": "Vlhkosť prekračuje prah",
"window_opened": "Okno otvorené",
"window_closed": "Okno zatvorené",
"battery_low": "Nízka batéria",
"device_error": "Zistená chyba zariadenia",
"target_temp_reached": "Cieľová teplota dosiahnutá",
"hvac_mode_changed": "Režim HVAC zmenený",
"current_temperature_changed": "Aktuálna teplota zmenená",
"current_humidity_changed": "Aktuálna vlhkosť zmenená"
},
"condition_type": {
"is_hvac_mode": "Termostat je nastavený na konkrétny režim HVAC",
"is_hvac_action": "Termostat vykonáva konkrétnu akciu HVAC"
},
"action_type": {
"set_hvac_mode": "Nastaviť režim HVAC",
"set_temperature": "Nastaviť cieľovú teplotu"
}
}
}
}
@@ -1,208 +1,513 @@
"""Controlling module for Better Thermostat."""
import asyncio
import logging
from homeassistant.components.climate.const import HVACMode
from custom_components.better_thermostat.model_fixes.model_quirks import (
override_set_hvac_mode,
override_set_temperature,
)
from homeassistant.components.climate.const import PRESET_BOOST, HVACMode
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTemperature
from homeassistant.util.unit_conversion import TemperatureConverter
from custom_components.better_thermostat.adapters.delegate import (
set_offset,
get_current_offset,
set_temperature,
set_hvac_mode,
set_offset,
set_temperature,
set_valve,
)
from custom_components.better_thermostat.events.trv import (
convert_outbound_states,
update_hvac_action,
from custom_components.better_thermostat.events.trv import convert_outbound_states
from custom_components.better_thermostat.model_fixes.model_quirks import (
override_set_hvac_mode,
)
from custom_components.better_thermostat.utils.const import (
CalibrationMode,
CalibrationType,
)
from custom_components.better_thermostat.utils.helpers import (
attr_to_celsius,
convert_to_float,
)
from custom_components.better_thermostat.utils.helpers import convert_to_float
from custom_components.better_thermostat.utils.const import CalibrationMode
_LOGGER = logging.getLogger(__name__)
class TaskManager:
def __init__(self):
self.tasks = set()
def _is_boost_heating_active(self) -> bool:
"""Check if boost mode is active and heating is needed.
def create_task(self, coro):
task = asyncio.create_task(coro)
Returns True when boost preset is active and current temperature
is below target temperature.
"""
return (
self.preset_mode == PRESET_BOOST
and self.cur_temp is not None
and self.bt_target_temp is not None
and self.cur_temp < self.bt_target_temp
)
def _get_valve_control(
self, heater_entity_id: str, calibration_mode, calibration_type
) -> tuple[dict | None, str | None]:
"""Determine valve control settings based on boost mode or calibration.
Returns a tuple of (valve_settings_dict, source_name).
valve_settings_dict contains 'valve_percent' and 'apply_valve' keys.
Returns (None, None) if no valve control should be applied.
"""
# Forcing the valve on a non-direct-valve TRV bypasses the calibration chain
# and leaves the valve stuck open after boost ends.
if (
_is_boost_heating_active(self)
and calibration_type == CalibrationType.DIRECT_VALVE_BASED
):
max_opening = (self.real_trvs.get(heater_entity_id) or {}).get(
"valve_max_opening", 100
)
if isinstance(max_opening, (int, float)):
target_pct = max(0, min(100, int(round(float(max_opening)))))
else:
target_pct = 100
return {"valve_percent": target_pct, "apply_valve": True}, "boost_mode"
# Check calibration-based valve control
if calibration_type != CalibrationType.DIRECT_VALVE_BASED:
return None, None
# Try calibration balance from various calibration modes
cal_bal = self.real_trvs[heater_entity_id].get("calibration_balance")
if (
isinstance(cal_bal, dict)
and cal_bal.get("apply_valve")
and cal_bal.get("valve_percent") is not None
):
source_map = {
CalibrationMode.MPC_CALIBRATION: "mpc_calibration",
CalibrationMode.TPI_CALIBRATION: "tpi_calibration",
CalibrationMode.PID_CALIBRATION: "pid_calibration",
CalibrationMode.HEATING_POWER_CALIBRATION: "heating_power_calibration",
}
source = source_map.get(calibration_mode)
if source:
return cal_bal, source
# Fallback to raw balance
raw_balance = self.real_trvs[heater_entity_id].get("balance")
if (
isinstance(raw_balance, dict)
and raw_balance.get("apply_valve")
and raw_balance.get("valve_percent") is not None
):
return raw_balance, "balance"
return None, None
class TaskManager:
"""Manages background asyncio tasks with automatic cleanup.
Tracks created tasks and automatically removes them from the set when they complete.
"""
def __init__(self, hass=None):
"""Initialize the task manager with an empty task set."""
self.tasks = set()
self.hass = hass
def create_task(self, coro, name=None):
"""Create and track an asyncio task with automatic cleanup on completion.
Parameters
----------
coro : Coroutine
The coroutine to execute as a task
name : str, optional
A descriptive name for the background task
Returns
-------
asyncio.Task
The created task
"""
if self.hass is not None:
task = self.hass.async_create_background_task(
coro, name=name or "bt_task_manager_task"
)
else:
task = asyncio.create_task(coro)
self.tasks.add(task)
task.add_done_callback(self.tasks.discard)
return task
async def control_queue(self):
"""The accutal control loop.
Parameters
----------
self :
instance of better_thermostat
"""Process control commands from the queue and coordinate TRV control.
Returns
-------
None
"""
if not hasattr(self, "task_manager"):
self.task_manager = TaskManager()
This async task runs continuously, processing control requests from the
control_queue_task queue. It calculates heating power once per cycle,
then controls all TRVs in parallel using asyncio.gather(). Cooler control
is executed separately if a cooler entity is configured.
while True:
if self.ignore_states or self.startup_running:
await asyncio.sleep(1)
continue
else:
controls_to_process = await self.control_queue_task.get()
if controls_to_process is not None:
self.ignore_states = True
result = True
for trv in self.real_trvs.keys():
try:
_temp = await control_trv(self, trv)
if _temp is False:
result = False
except Exception:
_LOGGER.exception(
"better_thermostat %s: ERROR controlling: %s",
self.device_name,
trv,
)
result = False
# Retry task if some TRVs failed. Discard the task if the queue is full
# to avoid blocking and therefore deadlocking this function.
if result is False:
try:
self.control_queue_task.put_nowait(self)
except asyncio.QueueFull:
_LOGGER.debug(
"better_thermostat %s: control queue is full, discarding task"
)
self.control_queue_task.task_done()
self.ignore_states = False
async def control_trv(self, heater_entity_id=None):
"""This is the main controller for the real TRV
The queue pauses during maintenance mode or when ignore_states is True.
Parameters
----------
self :
instance of better_thermostat
self : BetterThermostat
The Better Thermostat climate entity instance
Returns
-------
None
This function runs indefinitely in an asyncio task
"""
if not hasattr(self, "task_manager"):
self.task_manager = TaskManager()
self.task_manager = TaskManager(hass=self.hass)
try:
while True:
if getattr(self, "in_maintenance", False):
await asyncio.sleep(1)
continue
if self.ignore_states or self.startup_running:
await asyncio.sleep(1)
continue
else:
controls_to_process = await self.control_queue_task.get()
if controls_to_process is not None:
self.ignore_states = True
# Calculate heating power once per cycle
try:
await self.calculate_heating_power()
except Exception:
_LOGGER.exception(
"better_thermostat %s: ERROR calculating heating power",
self.device_name,
)
# Calculate heat loss once per cycle (idle cooling)
try:
await self.calculate_heat_loss()
except Exception:
_LOGGER.exception(
"better_thermostat %s: ERROR calculating heat loss",
self.device_name,
)
# Handle cooler logic once per cycle
if self.cooler_entity_id is not None:
try:
await control_cooler(self)
except Exception:
_LOGGER.exception(
"better_thermostat %s: ERROR controlling cooler",
self.device_name,
)
# Create tasks for all TRVs to run in parallel
tasks = []
for trv in self.real_trvs.keys():
tasks.append(control_trv(self, trv))
# Run all TRV controls in parallel
results = await asyncio.gather(*tasks, return_exceptions=True)
result = True
for i, res in enumerate(results):
if isinstance(res, Exception):
trv_id = list(self.real_trvs.keys())[i]
_LOGGER.error(
"better_thermostat %s: ERROR controlling TRV %s: %s",
self.device_name,
trv_id,
res,
)
result = False
elif res is False:
result = False
# Retry task if some TRVs failed. Discard the task if the queue is full
# to avoid blocking and therefore deadlocking this function.
if result is False:
try:
self.control_queue_task.put_nowait(self)
except asyncio.QueueFull:
_LOGGER.debug(
"better_thermostat %s: control queue is full, discarding task",
self.device_name,
)
self.control_queue_task.task_done()
if not getattr(self, "in_maintenance", False):
self.ignore_states = False
except asyncio.CancelledError:
_LOGGER.debug(
"better_thermostat %s: control_queue task cancelled, cleaning up",
self.device_name,
)
raise
finally:
# Ensure ignore_states is reset on any exit unless maintenance wants it suppressed.
if not getattr(self, "in_maintenance", False):
self.ignore_states = False
async def control_cooler(self):
"""Control the cooler entity based on current temperature and cooling setpoint.
Activates cooling when current temperature exceeds target cooling temperature
minus tolerance and is above heating target. Deactivates cooling when
temperature drops below cooling target minus tolerance or when BT HVAC mode is OFF.
"""
# Get current cooler state to avoid sending redundant commands
cooler_state = self.hass.states.get(self.cooler_entity_id)
if cooler_state is None or cooler_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
_LOGGER.debug(
"better_thermostat %s: cooler %s unavailable, skipping",
self.device_name,
self.cooler_entity_id,
)
return
current_hvac_mode = cooler_state.state
current_temp = cooler_state.attributes.get("temperature")
# Determine desired state based on current conditions
desired_temp = self.bt_target_cooltemp
if any(
v is None
for v in (
self.cur_temp,
self.bt_target_cooltemp,
self.tolerance,
self.bt_target_temp,
)
):
_LOGGER.debug(
"better_thermostat %s: cooler %s one or more required values are None "
"(cur_temp=%s, bt_target_cooltemp=%s, tolerance=%s, bt_target_temp=%s), "
"defaulting to OFF",
self.device_name,
self.cooler_entity_id,
self.cur_temp,
self.bt_target_cooltemp,
self.tolerance,
self.bt_target_temp,
)
desired_mode = HVACMode.OFF
elif self.bt_hvac_mode == HVACMode.OFF:
desired_mode = HVACMode.OFF
elif (
self.cur_temp >= self.bt_target_cooltemp - self.tolerance
and self.cur_temp > self.bt_target_temp
):
desired_mode = HVACMode.COOL
else:
desired_mode = HVACMode.OFF
# Only send temperature command if it differs from current
if desired_temp is None:
_LOGGER.debug(
"better_thermostat %s: cooler %s desired temperature is None, "
"skipping set_temperature",
self.device_name,
self.cooler_entity_id,
)
elif current_temp is None or current_temp != desired_temp:
if current_temp is None:
_LOGGER.debug(
"better_thermostat %s: cooler %s current temperature is unknown, "
"sending set_temperature command anyway",
self.device_name,
self.cooler_entity_id,
)
else:
_LOGGER.debug(
"better_thermostat %s: TO COOLER set_temperature: %s from: %s to: %s",
self.device_name,
self.cooler_entity_id,
current_temp,
desired_temp,
)
_temp_to_set = desired_temp
if self.hass.config.units.temperature_unit == UnitOfTemperature.FAHRENHEIT:
_temp_to_set = TemperatureConverter.convert(
desired_temp, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT
)
_temp_to_set = round(_temp_to_set, 1)
await self.hass.services.async_call(
"climate",
"set_temperature",
{"entity_id": self.cooler_entity_id, "temperature": _temp_to_set},
blocking=True,
context=self.context,
)
# Only send hvac_mode command if it differs from current
if current_hvac_mode != desired_mode:
_LOGGER.debug(
"better_thermostat %s: TO COOLER set_hvac_mode: %s from: %s to: %s",
self.device_name,
self.cooler_entity_id,
current_hvac_mode,
desired_mode,
)
await self.hass.services.async_call(
"climate",
"set_hvac_mode",
{"entity_id": self.cooler_entity_id, "hvac_mode": desired_mode},
blocking=True,
context=self.context,
)
async def control_trv(self, heater_entity_id=None):
"""Control a single TRV by setting temperature, HVAC mode, calibration, and valve position.
All operations are executed within self._temp_lock to ensure atomic execution when
multiple TRVs are controlled in parallel. Unavailable TRVs are skipped without
executing any control operations.
Parameters
----------
self : BetterThermostat
The Better Thermostat climate entity instance
heater_entity_id : str, optional
Entity ID of the TRV to control. If None or not found, returns False.
Returns
-------
bool
True if control succeeded or TRV was skipped (unavailable)
False if TRV not found in real_trvs or state conversion failed
"""
# Guard against missing or invalid heater_entity_id
if not heater_entity_id or heater_entity_id not in self.real_trvs:
return False
if not hasattr(self, "task_manager"):
self.task_manager = TaskManager(hass=self.hass)
async with self._temp_lock:
self.real_trvs[heater_entity_id]["ignore_trv_states"] = True
await update_hvac_action(self)
await self.calculate_heating_power()
try:
# Preserve old action for change detection if attributes exist
if hasattr(self, "attr_hvac_action"):
self.old_attr_hvac_action = getattr(self, "attr_hvac_action", None)
# Recompute current hvac action (uses internal climate logic)
if hasattr(self, "_compute_hvac_action_pure"):
result = self._compute_hvac_action_pure()
self._commit_hvac_action(result)
self.attr_hvac_action = result.action
except Exception:
_LOGGER.debug(
"better_thermostat %s: hvac action recompute failed (non critical)",
getattr(self, "device_name", "unknown"),
)
_trv = self.hass.states.get(heater_entity_id)
_current_set_temperature = convert_to_float(
str(_trv.attributes.get("temperature", None)),
self.device_name,
"controlling()",
# Check if TRV is available before attempting to control it
if _trv is None or (
_trv.state in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and not _is_boost_heating_active(self)
):
_LOGGER.debug(
"better_thermostat %s: TRV %s is unavailable, skipping control. "
"Control will resume when TRV becomes available.",
self.device_name,
heater_entity_id,
)
self.real_trvs[heater_entity_id]["ignore_trv_states"] = False
return True
_current_set_temperature = attr_to_celsius(
self, _trv, "temperature", None, "controlling()"
)
_remapped_states = convert_outbound_states(
self, heater_entity_id, self.bt_hvac_mode
)
if not isinstance(_remapped_states, dict):
_LOGGER.debug(
f"better_thermostat {self.device_name}: ERROR {heater_entity_id} {_remapped_states}"
_LOGGER.warning(
"better_thermostat %s: convert_outbound_states returned %r for %s "
"(expected dict) — skipping control cycle",
self.device_name,
_remapped_states,
heater_entity_id,
)
await asyncio.sleep(10)
self.ignore_states = False
await asyncio.sleep(2)
self.real_trvs[heater_entity_id]["ignore_trv_states"] = False
return False
_temperature = _remapped_states.get("temperature", None)
_calibration = _remapped_states.get("local_temperature_calibration", None)
_calibration_mode = self.real_trvs[heater_entity_id]["advanced"].get(
"calibration_mode", CalibrationMode.DEFAULT
"calibration_mode", CalibrationMode.MPC_CALIBRATION
)
_calibration_type = self.real_trvs[heater_entity_id]["advanced"].get(
"calibration", CalibrationType.TARGET_TEMP_BASED
)
# Pair the forced 100 % valve with a max-temp setpoint so the TRV
# firmware does not fight the valve command.
if (
_is_boost_heating_active(self)
and _calibration_type == CalibrationType.DIRECT_VALVE_BASED
):
_temperature = self.real_trvs[heater_entity_id]["max_temp"]
# Optional: set valve position if supported (e.g., MQTT/Z2M)
try:
valve_settings, _source = _get_valve_control(
self, heater_entity_id, _calibration_mode, _calibration_type
)
if valve_settings is not None:
target_pct = int(round(valve_settings.get("valve_percent", 0)))
_LOGGER.debug(
"better_thermostat %s: TO TRV set_valve: %s to: %s%% (source=%s)",
self.device_name,
heater_entity_id,
target_pct,
_source,
)
ok = await set_valve(self, heater_entity_id, target_pct)
if not ok:
_LOGGER.debug(
"better_thermostat %s: delegate.set_valve returned False (target=%s%%, entity=%s, source=%s)",
self.device_name,
target_pct,
heater_entity_id,
_source,
)
elif _calibration_type != CalibrationType.DIRECT_VALVE_BASED:
pass # non-valve TRV: no valve control expected
except Exception:
_LOGGER.debug(
"better_thermostat %s: set_valve not applied for %s (unsupported or failed)",
self.device_name,
heater_entity_id,
)
_new_hvac_mode = handle_window_open(self, _remapped_states)
# New cooler section
if self.cooler_entity_id is not None:
if (
self.cur_temp >= self.bt_target_cooltemp - self.tolerance
and _new_hvac_mode is not HVACMode.OFF
and self.cur_temp > self.bt_target_temp
):
await self.hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": self.cooler_entity_id,
"temperature": self.bt_target_cooltemp,
},
blocking=True,
context=self.context,
)
await self.hass.services.async_call(
"climate",
"set_hvac_mode",
{"entity_id": self.cooler_entity_id, "hvac_mode": HVACMode.COOL},
blocking=True,
context=self.context,
)
elif (
self.cur_temp <= (self.bt_target_cooltemp - self.tolerance)
and _new_hvac_mode is not HVACMode.OFF
):
await self.hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": self.cooler_entity_id,
"temperature": self.bt_target_cooltemp,
},
blocking=True,
context=self.context,
)
await self.hass.services.async_call(
"climate",
"set_hvac_mode",
{"entity_id": self.cooler_entity_id, "hvac_mode": HVACMode.OFF},
blocking=True,
context=self.context,
)
else:
await self.hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": self.cooler_entity_id,
"temperature": self.bt_target_cooltemp,
},
blocking=True,
context=self.context,
)
await self.hass.services.async_call(
"climate",
"set_hvac_mode",
{"entity_id": self.cooler_entity_id, "hvac_mode": HVACMode.OFF},
blocking=True,
context=self.context,
)
# if we don't need ot heat, we force HVACMode to be off
# if we don't need to heat, we force HVACMode to be off
if self.call_for_heat is False:
_new_hvac_mode = HVACMode.OFF
# Safety override: if boost mode was active but we forced OFF (window/no-heat),
# ensure valve is reset to 0% to prevent overheating. Only direct-valve
# calibration types accept valve commands; LOCAL_BASED and
# TARGET_TEMP_BASED control via offset / setpoint instead.
if (
_is_boost_heating_active(self)
and _new_hvac_mode == HVACMode.OFF
and _calibration_type == CalibrationType.DIRECT_VALVE_BASED
):
_LOGGER.debug(
"better_thermostat %s: Boost safety override - resetting valve to 0%% because HVAC mode is OFF",
self.device_name,
)
await set_valve(self, heater_entity_id, 0)
# Manage TRVs with no HVACMode.OFF
_no_off_system_mode = (
HVACMode.OFF not in self.real_trvs[heater_entity_id]["hvac_modes"]
@@ -215,7 +520,9 @@ async def control_trv(self, heater_entity_id=None):
if _no_off_system_mode is True and _new_hvac_mode == HVACMode.OFF:
_min_temp = self.real_trvs[heater_entity_id]["min_temp"]
_LOGGER.debug(
f"better_thermostat {self.device_name}: sending {_min_temp}°C to the TRV because this device has no system mode off and heater should be off"
"better_thermostat %s: sending %s°C to the TRV because this device has no system mode off and heater should be off",
self.device_name,
_min_temp,
)
_temperature = _min_temp
@@ -229,7 +536,11 @@ async def control_trv(self, heater_entity_id=None):
)
):
_LOGGER.debug(
f"better_thermostat {self.device_name}: TO TRV set_hvac_mode: {heater_entity_id} from: {_trv.state} to: {_new_hvac_mode}"
"better_thermostat %s: TO TRV set_hvac_mode: %s from: %s to: %s",
self.device_name,
heater_entity_id,
_trv.state,
_new_hvac_mode,
)
self.real_trvs[heater_entity_id]["last_hvac_mode"] = _new_hvac_mode
_tvr_has_quirk = await override_set_hvac_mode(
@@ -239,7 +550,10 @@ async def control_trv(self, heater_entity_id=None):
await set_hvac_mode(self, heater_entity_id, _new_hvac_mode)
if self.real_trvs[heater_entity_id]["system_mode_received"] is True:
self.real_trvs[heater_entity_id]["system_mode_received"] = False
self.task_manager.create_task(check_system_mode(self, heater_entity_id))
self.task_manager.create_task(
check_system_mode(self, heater_entity_id),
name=f"bt_check_system_mode_{heater_entity_id}",
)
# set new calibration offset
if (
@@ -255,8 +569,6 @@ async def control_trv(self, heater_entity_id=None):
self.device_name,
heater_entity_id,
)
# this should not be before, set_hvac_mode (because if it fails, the new hvac mode will never be sent)
self.ignore_states = False
self.real_trvs[heater_entity_id]["ignore_trv_states"] = False
return True
@@ -270,11 +582,31 @@ async def control_trv(self, heater_entity_id=None):
"last_calibration", _current_calibration
)
# If current calibration already matches target, reset calibration_received
# to avoid it getting stuck at False when the state event was suppressed.
if (
self.real_trvs[heater_entity_id]["calibration_received"] is False
and _current_calibration is not None
and abs(float(_current_calibration) - float(_calibration)) < 0.5
):
_LOGGER.debug(
"better_thermostat %s: TRV %s calibration already at target (%s), "
"resetting calibration_received flag",
self.device_name,
heater_entity_id,
_calibration,
)
self.real_trvs[heater_entity_id]["calibration_received"] = True
if self.real_trvs[heater_entity_id][
"calibration_received"
] is True and float(_old_calibration) != float(_calibration):
_LOGGER.debug(
f"better_thermostat {self.device_name}: TO TRV set_local_temperature_calibration: {heater_entity_id} from: {_old_calibration} to: {_calibration}"
"better_thermostat %s: TO TRV set_local_temperature_calibration: %s from: %s to: %s",
self.device_name,
heater_entity_id,
_old_calibration,
_calibration,
)
await set_offset(self, heater_entity_id, _calibration)
self.real_trvs[heater_entity_id]["calibration_received"] = False
@@ -286,60 +618,76 @@ async def control_trv(self, heater_entity_id=None):
if _temperature != _current_set_temperature:
old = self.real_trvs[heater_entity_id].get("last_temperature", "?")
_LOGGER.debug(
f"better_thermostat {self.device_name}: TO TRV set_temperature: {heater_entity_id} from: {old} to: {_temperature}"
"better_thermostat %s: TO TRV set_temperature: %s from: %s to: %s",
self.device_name,
heater_entity_id,
old,
_temperature,
)
self.real_trvs[heater_entity_id]["last_temperature"] = _temperature
_tvr_has_quirk = await override_set_temperature(
self, heater_entity_id, _temperature
)
if _tvr_has_quirk is False:
await set_temperature(self, heater_entity_id, _temperature)
await set_temperature(self, heater_entity_id, _temperature)
if self.real_trvs[heater_entity_id]["target_temp_received"] is True:
self.real_trvs[heater_entity_id]["target_temp_received"] = False
self.task_manager.create_task(
check_target_temperature(self, heater_entity_id)
check_target_temperature(self, heater_entity_id),
name=f"bt_check_target_temp_{heater_entity_id}",
)
await asyncio.sleep(3)
self.real_trvs[heater_entity_id]["ignore_trv_states"] = False
return True
# Let TRV state updates propagate before accepting new state events
await asyncio.sleep(3)
self.real_trvs[heater_entity_id]["ignore_trv_states"] = False
return True
def handle_window_open(self, _remapped_states):
"""handle window open"""
_converted_hvac_mode = _remapped_states.get("system_mode", None)
_hvac_mode_send = _converted_hvac_mode
"""Override HVAC mode to OFF when window is open.
if self.window_open is True and self.last_window_state is False:
# if the window is open or the sensor is not available, we're done
self.last_main_hvac_mode = _hvac_mode_send
_hvac_mode_send = HVACMode.OFF
self.last_window_state = True
_LOGGER.debug(
f"better_thermostat {self.device_name}: control_trv: window is open or status of window is unknown, setting window open"
)
elif self.window_open is False and self.last_window_state is True:
_hvac_mode_send = self.last_main_hvac_mode
self.last_window_state = False
_LOGGER.debug(
f"better_thermostat {self.device_name}: control_trv: window is closed, setting window closed restoring mode: {_hvac_mode_send}"
)
Parameters
----------
self : BetterThermostat
The Better Thermostat climate entity instance
_remapped_states : dict
Dictionary containing remapped TRV states including system_mode
# Force off on window open
if self.window_open is True:
_hvac_mode_send = HVACMode.OFF
return _hvac_mode_send
Returns
-------
HVACMode
HVACMode.OFF if window is open, otherwise the remapped system_mode
"""
if self.window_open:
return HVACMode.OFF
return _remapped_states.get("system_mode", None)
async def check_system_mode(self, heater_entity_id=None):
"""check system mode"""
"""Wait for TRV to confirm HVAC mode change, timeout after 6 minutes.
Polls the TRV state every second until hvac_mode matches last_hvac_mode
or timeout is reached. Sets system_mode_received flag when complete.
Parameters
----------
self : BetterThermostat
The Better Thermostat climate entity instance
heater_entity_id : str, optional
Entity ID of the TRV to check
Returns
-------
bool
Always returns True
"""
_timeout = 0
_real_trv = self.real_trvs[heater_entity_id]
while _real_trv["hvac_mode"] != _real_trv["last_hvac_mode"]:
if _timeout > 360:
_LOGGER.debug(
f"better_thermostat {self.device_name}: {heater_entity_id} the real TRV did not respond to the system mode change"
_LOGGER.warning(
"better_thermostat %s: TRV %s did not confirm the system mode change "
"after 360s (wrote=%s, last reported=%s); giving up and assuming applied",
self.device_name,
heater_entity_id,
_real_trv["last_hvac_mode"],
_real_trv["hvac_mode"],
)
_timeout = 0
break
@@ -351,22 +699,44 @@ async def check_system_mode(self, heater_entity_id=None):
async def check_target_temperature(self, heater_entity_id=None):
"""Check if target temperature is reached."""
"""Wait for TRV to confirm target temperature change, timeout after 6 minutes.
Polls the TRV temperature attribute every second until it matches
last_temperature or timeout is reached. Sets target_temp_received flag when complete.
Parameters
----------
self : BetterThermostat
The Better Thermostat climate entity instance
heater_entity_id : str, optional
Entity ID of the TRV to check
Returns
-------
bool
Always returns True
"""
_timeout = 0
_real_trv = self.real_trvs[heater_entity_id]
while True:
_current_set_temperature = convert_to_float(
str(
self.hass.states.get(heater_entity_id).attributes.get(
"temperature", None
)
),
self.device_name,
"check_target_temperature()",
_trv_state = self.hass.states.get(heater_entity_id)
if _trv_state is None or _trv_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
_LOGGER.debug(
"better_thermostat %s: %s became unavailable during check_target_temperature",
self.device_name,
heater_entity_id,
)
break
_current_set_temperature = attr_to_celsius(
self, _trv_state, "temperature", None, "check_target_temperature()"
)
if _timeout == 0:
_LOGGER.debug(
f"better_thermostat {self.device_name}: {heater_entity_id} / check_target_temp / _last: {_real_trv['last_temperature']} - _current: {_current_set_temperature}"
"better_thermostat %s: %s / check_target_temp / _last: %s - _current: %s",
self.device_name,
heater_entity_id,
_real_trv["last_temperature"],
_current_set_temperature,
)
if (
_current_set_temperature is None
@@ -375,8 +745,13 @@ async def check_target_temperature(self, heater_entity_id=None):
_timeout = 0
break
if _timeout > 360:
_LOGGER.debug(
f"better_thermostat {self.device_name}: {heater_entity_id} the real TRV did not respond to the target temperature change"
_LOGGER.warning(
"better_thermostat %s: TRV %s did not confirm the target temperature "
"after 360s (wrote=%s, last reported=%s); giving up and assuming applied",
self.device_name,
heater_entity_id,
_real_trv["last_temperature"],
_current_set_temperature,
)
_timeout = 0
break
File diff suppressed because it is too large Load Diff
@@ -1,41 +1,100 @@
"""Watcher helpers to verify the presence and state of configured entities.
This module contains utility functions to verify entities, check batteries,
and raise Home Assistant issues if an entity is missing or unavailable.
Supports degraded mode operation where optional sensors (window, humidity,
outdoor, weather) can be unavailable without blocking thermostat operation.
"""
from __future__ import annotations
from homeassistant.helpers import issue_registry as ir
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from datetime import timedelta
import logging
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.helpers import issue_registry as ir
from homeassistant.util import dt as dt_util
DOMAIN = "better_thermostat"
_LOGGER = logging.getLogger(__name__)
# Window after startup during which a transition into degraded mode is logged
# at DEBUG and does not raise a Home Assistant repair. Slow integrations
# (cloud weather, Ecowitt, etc.) often need several minutes to publish their
# first state.
STARTUP_DEGRADED_GRACE_PERIOD = timedelta(minutes=5)
# States considered unavailable
UNAVAILABLE_STATES = (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
None,
"missing",
"unknown",
"unavail",
"unavailable",
)
def is_entity_available(hass, entity) -> bool:
"""Check if an entity is available without side effects.
Parameters
----------
hass :
Home Assistant instance
entity : str
Entity ID to check
Returns
-------
bool
True if entity exists and is in a valid state
"""
if entity is None:
return False
entity_states = hass.states.get(entity)
if entity_states is None:
return False
return entity_states.state not in UNAVAILABLE_STATES
async def check_entity(self, entity) -> bool:
"""Check if a specific entity is present and available.
Returns True if the entity is available and known to Home Assistant,
otherwise raises an issue and returns False.
"""
if entity is None:
return False
entity_states = self.hass.states.get(entity)
if entity_states is None:
return False
state = entity_states.state
if state in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
None,
"missing",
"unknown",
"unavail",
"unavailable",
):
if state in UNAVAILABLE_STATES:
_LOGGER.debug(
f"better_thermostat {self.device_name}: {entity} is unavailable. with state {state}"
"better_thermostat %s: %s is unavailable. with state %s",
self.device_name,
entity,
state,
)
return False
if entity in self.devices_errors:
self.devices_errors.remove(entity)
self.async_write_ha_state()
ir.async_delete_issue(self.hass, DOMAIN, f"missing_entity_{entity}")
self.hass.async_create_task(get_battery_status(self, entity))
self.hass.async_create_background_task(
get_battery_status(self, entity), name=f"bt_battery_status_{entity}"
)
return True
async def get_battery_status(self, entity):
"""Read a battery entity for a device and update internal state.
Uses the provided mapping stored in `self.devices_states`.
"""
if entity in self.devices_states:
battery_id = self.devices_states[entity].get("battery_id")
if battery_id is not None:
@@ -51,6 +110,10 @@ async def get_battery_status(self, entity):
async def check_all_entities(self) -> bool:
"""Verify all configured entities and report missing ones as issues.
Returns True if all entities are available.
"""
entities = self.all_entities
for entity in entities:
if not await check_entity(self, entity):
@@ -73,3 +136,269 @@ async def check_all_entities(self) -> bool:
)
return False
return True
def get_optional_sensors(self) -> list:
"""Return list of optional sensor entity IDs.
Optional sensors are those that can be unavailable without
blocking thermostat operation (degraded mode).
Returns
-------
list
List of optional sensor entity IDs
"""
optional = []
if getattr(self, "window_id", None):
optional.append(self.window_id)
if getattr(self, "humidity_sensor_entity_id", None):
optional.append(self.humidity_sensor_entity_id)
if getattr(self, "outdoor_sensor", None):
optional.append(self.outdoor_sensor)
if getattr(self, "weather_entity", None):
optional.append(self.weather_entity)
return optional
def get_critical_entities(self) -> list:
"""Return list of critical entity IDs.
Critical entities are TRVs - without them the thermostat cannot function.
The room temperature sensor is semi-critical (can fall back to TRV temp).
Returns
-------
list
List of critical entity IDs (TRVs)
"""
critical = []
if hasattr(self, "real_trvs") and self.real_trvs:
critical.extend(list(self.real_trvs.keys()))
return critical
async def check_critical_entities(self) -> bool:
"""Check only critical entities (TRVs).
Returns True if all TRVs are available. Does not block on optional sensors.
Returns
-------
bool
True if all critical entities are available
"""
critical = get_critical_entities(self)
for entity in critical:
if not is_entity_available(self.hass, entity):
_LOGGER.warning(
"better_thermostat %s: Critical entity %s is unavailable",
self.device_name,
entity,
)
if entity not in self.devices_errors:
self.devices_errors.append(entity)
self.async_write_ha_state()
ir.async_create_issue(
hass=self.hass,
domain=DOMAIN,
issue_id=f"missing_entity_{entity}",
is_fixable=True,
is_persistent=False,
learn_more_url="https://better-thermostat.org/qanda/missing_entity",
severity=ir.IssueSeverity.ERROR,
translation_key="missing_entity",
translation_placeholders={
"entity": str(entity),
"name": str(self.device_name),
},
)
return False
else:
# Clear error if entity is now available
if entity in self.devices_errors:
self.devices_errors.remove(entity)
ir.async_delete_issue(self.hass, DOMAIN, f"missing_entity_{entity}")
# Update battery status for available entities
self.hass.async_create_background_task(
get_battery_status(self, entity), name=f"bt_battery_status_{entity}"
)
return True
# Default delays for the optional-sensor startup retry loop.
# Short initial interval catches fast local sensors (Zigbee), longer
# intervals give cloud / weather integrations time. Total ≈ 60 s.
DEFAULT_OPTIONAL_SENSOR_DELAYS: tuple[int, ...] = (3, 5, 10, 15, 25)
async def await_optional_sensors(
self,
delays: tuple[int, ...] | list[int] = DEFAULT_OPTIONAL_SENSOR_DELAYS,
_sleep=None,
) -> list[str]:
"""Wait for optional sensors to become available with increasing delays.
After a reboot, optional sensors (outdoor, weather, window, humidity)
frequently need a few seconds to initialise. This helper retries with
increasing intervals so that ``check_and_update_degraded_mode`` is not
called while sensors are still starting up.
Parameters
----------
self :
BetterThermostat instance (must expose ``.hass`` and
``.device_name``).
delays :
Sequence of sleep durations in seconds between retries.
Defaults to ``DEFAULT_OPTIONAL_SENSOR_DELAYS`` (3/5/10/15/25 s).
_sleep :
Injectable sleep coroutine for testing. Defaults to
``asyncio.sleep``.
Returns
-------
list[str]
Entity IDs of optional sensors that are still unavailable after
all retries have been exhausted (empty if all came online).
"""
import asyncio
if _sleep is None:
_sleep = asyncio.sleep
elapsed = 0
pending: list[str] = []
for idx, delay in enumerate(delays):
pending = [
eid
for eid in get_optional_sensors(self)
if not is_entity_available(self.hass, eid)
]
if not pending:
_LOGGER.debug(
"better_thermostat %s: all optional sensors available (after %d s)",
self.device_name,
elapsed,
)
return []
_LOGGER.debug(
"better_thermostat %s: waiting for optional sensors "
"(attempt %d/%d, next check in %d s, pending: %s)",
self.device_name,
idx + 1,
len(delays),
delay,
", ".join(pending),
)
await _sleep(delay)
elapsed += delay
# Final check after the last sleep
pending = [
eid
for eid in get_optional_sensors(self)
if not is_entity_available(self.hass, eid)
]
if not pending:
_LOGGER.debug(
"better_thermostat %s: all optional sensors available (after %d s)",
self.device_name,
elapsed,
)
return pending
async def check_and_update_degraded_mode(self) -> bool:
"""Check optional sensors and update degraded mode status.
Sets self.degraded_mode to True if any optional sensor is unavailable.
Updates self.unavailable_sensors with list of unavailable optional sensors.
Returns
-------
bool
True if operating in degraded mode (some optional sensors unavailable)
"""
optional = get_optional_sensors(self)
unavailable = []
for entity in optional:
if not is_entity_available(self.hass, entity):
unavailable.append(entity)
_LOGGER.debug(
"better_thermostat %s: Optional sensor %s is unavailable (degraded mode)",
self.device_name,
entity,
)
else:
# Update battery status for available optional sensors
self.hass.async_create_background_task(
get_battery_status(self, entity), name=f"bt_battery_status_{entity}"
)
# Check room temperature sensor - special case with TRV fallback
sensor_available = is_entity_available(self.hass, self.sensor_entity_id)
if not sensor_available:
unavailable.append(self.sensor_entity_id)
_LOGGER.warning(
"better_thermostat %s: Room temperature sensor %s unavailable, "
"falling back to TRV internal temperature",
self.device_name,
self.sensor_entity_id,
)
else:
# Update battery status for room temperature sensor
self.hass.async_create_background_task(
get_battery_status(self, self.sensor_entity_id),
name=f"bt_battery_status_{self.sensor_entity_id}",
)
# Update instance state
old_degraded = getattr(self, "degraded_mode", False)
self.degraded_mode = len(unavailable) > 0
self.unavailable_sensors = unavailable
grace_until = getattr(self, "_degraded_grace_until", None)
in_grace = grace_until is not None and dt_util.now() < grace_until
has_warned = getattr(self, "_degraded_warning_emitted", False)
if self.degraded_mode and not has_warned and not in_grace:
_LOGGER.warning(
"better_thermostat %s: Entering degraded mode. Unavailable sensors: %s",
self.device_name,
", ".join(unavailable),
)
ir.async_create_issue(
hass=self.hass,
domain=DOMAIN,
issue_id=f"degraded_mode_{self.device_name}",
is_fixable=False,
is_persistent=False,
learn_more_url="https://better-thermostat.org/qanda/degraded_mode",
severity=ir.IssueSeverity.WARNING,
translation_key="degraded_mode",
translation_placeholders={
"name": str(self.device_name),
"sensors": ", ".join(unavailable),
},
)
self._degraded_warning_emitted = True
elif self.degraded_mode and in_grace and not old_degraded:
_LOGGER.debug(
"better_thermostat %s: degraded mode during startup grace period "
"(unavailable: %s); waiting for sensors before warning",
self.device_name,
", ".join(unavailable),
)
elif not self.degraded_mode and has_warned:
_LOGGER.info(
"better_thermostat %s: Exiting degraded mode. All sensors available.",
self.device_name,
)
ir.async_delete_issue(self.hass, DOMAIN, f"degraded_mode_{self.device_name}")
self._degraded_warning_emitted = False
self.async_write_ha_state()
return self.degraded_mode
@@ -1,24 +1,31 @@
"""Weather utils."""
from collections import deque
import logging
from datetime import timedelta, datetime
import homeassistant.util.dt as dt_util
from homeassistant.components.recorder import get_instance, history
from contextlib import suppress
from datetime import datetime, timedelta
import logging
from homeassistant.components.recorder import history
from homeassistant.components.weather import (
DOMAIN as WEATHER_DOMAIN,
WeatherEntityFeature,
)
from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported
# get_instance location can differ between HA versions; prefer helpers API.
from homeassistant.helpers.recorder import get_instance
import homeassistant.util.dt as dt_util
# from datetime import datetime, timedelta
# import homeassistant.util.dt as dt_util
# from homeassistant.components.recorder.history import state_changes_during_period
from .helpers import convert_to_float
from statistics import median
from .helpers import convert_to_float_celsius
_LOGGER = logging.getLogger(__name__)
async def check_weather(self) -> bool:
"""check weather predictions or ambient air temperature if available
"""Check weather predictions or ambient air temperature if available.
Parameters
----------
@@ -31,22 +38,41 @@ async def check_weather(self) -> bool:
true if call_for_heat was changed
"""
old_call_for_heat = self.call_for_heat
_call_for_heat_weather = False
_call_for_heat_weather: bool | None = None
_call_for_heat_outdoor = False
self.call_for_heat = True
if self.weather_entity is not None:
_call_for_heat_weather = await check_weather_prediction(self)
self.call_for_heat = _call_for_heat_weather
if isinstance(
_call_for_heat_weather, bool
): # Only apply if we got a valid response
self.call_for_heat = _call_for_heat_weather
if self.outdoor_sensor is not None:
if None in (self.last_avg_outdoor_temp, self.off_temperature):
# TODO: add condition if heating period (oct-mar) then set it to true?
_LOGGER.warning(
"better_thermostat %s: no outdoor sensor data found. fallback to heat",
self.device_name,
# Check if sensor is currently unavailable (expected during startup)
_outdoor_state = self.hass.states.get(self.outdoor_sensor)
_sensor_unavailable = _outdoor_state is None or _outdoor_state.state in (
"unavailable",
"unknown",
None,
)
if _sensor_unavailable:
# Sensor not ready yet - expected during startup, just debug
_LOGGER.debug(
"better_thermostat %s: outdoor sensor not yet available, fallback to heat",
self.device_name,
)
else:
# Sensor is available but we have no cached data - unexpected, warn
_LOGGER.warning(
"better_thermostat %s: outdoor sensor available but no data cached, fallback to heat",
self.device_name,
)
_call_for_heat_outdoor = True
else:
_call_for_heat_outdoor = self.last_avg_outdoor_temp < self.off_temperature
@@ -63,79 +89,138 @@ async def check_weather(self) -> bool:
return False
async def check_weather_prediction(self) -> bool:
"""Checks configured weather entity for next two days of temperature predictions.
async def check_weather_prediction(self) -> bool | None:
"""Check configured weather entity over roughly the next two days.
The forecast horizon is normalised to about two days regardless of the
entity's forecast granularity (daily, twice-daily or hourly), and the
sampled temperatures are averaged.
Returns
-------
bool
True if the maximum forcast temperature is lower than the off temperature
True if the average forecast temperature (or the current
temperature) is below the off temperature, i.e. heat is required
None
if not successful
"""
if self.weather_entity is None:
_LOGGER.warning(
f"better_thermostat {self.device_name}: weather entity not available."
"better_thermostat %s: weather entity not available.", self.device_name
)
return None
return False
if self.off_temperature is None or not isinstance(self.off_temperature, float):
_LOGGER.warning(
f"better_thermostat {self.device_name}: off_temperature not set or not a float."
"better_thermostat %s: off_temperature not set or not a float.",
self.device_name,
)
return None
return False
try:
state = self.hass.states.get(self.weather_entity)
features = state.attributes.get("supported_features", 0) if state else 0
if features & WeatherEntityFeature.FORECAST_DAILY:
ftype = "daily"
elif features & WeatherEntityFeature.FORECAST_TWICE_DAILY:
ftype = "twice_daily"
elif features & WeatherEntityFeature.FORECAST_HOURLY:
ftype = "hourly"
else:
_LOGGER.warning(
"better_thermostat %s: weather entity '%s' does not advertise any forecast support.",
self.device_name,
self.weather_entity,
)
return None
# Sample roughly the next two days regardless of forecast granularity.
_forecast_samples = {"daily": 2, "twice_daily": 4, "hourly": 48}[ftype]
forecasts = await self.hass.services.async_call(
"weather",
WEATHER_DOMAIN,
"get_forecasts",
{"type": "daily", "entity_id": self.weather_entity},
{"type": ftype, "entity_id": [self.weather_entity]},
blocking=True,
return_response=True,
)
forecast = forecasts.get(self.weather_entity).get("forecast")
if len(forecast) > 0:
cur_outside_temp = convert_to_float(
str(
self.hass.states.get(self.weather_entity).attributes.get(
"temperature"
)
forecast_container = (
forecasts.get(self.weather_entity) if isinstance(forecasts, dict) else None
)
forecast = (
forecast_container.get("forecast")
if isinstance(forecast_container, dict)
else None
)
if isinstance(forecast, list) and len(forecast) > 0:
# current outside temp from entity state (may be None)
cur_state = self.hass.states.get(self.weather_entity)
cur_outside_temp = convert_to_float_celsius(
(
str(cur_state.attributes.get("temperature"))
if cur_state and cur_state.attributes
else ""
),
self.device_name,
"check_weather_prediction()",
unit_of_measurement=(
cur_state.attributes.get("temperature_unit")
if cur_state and cur_state.attributes
else None
),
)
max_forecast_temp = int(
round(
(
convert_to_float(
str(forecast[0]["temperature"]),
self.device_name,
"check_weather_prediction()",
)
+ convert_to_float(
str(forecast[1]["temperature"]),
self.device_name,
"check_weather_prediction()",
)
# average the sampled forecast temps over the two-day horizon
_entity_temp_unit = (
cur_state.attributes.get("temperature_unit")
if cur_state and cur_state.attributes
else None
)
temps = []
for i in range(min(_forecast_samples, len(forecast))):
temps.append(
convert_to_float_celsius(
(
str(forecast[i].get("temperature"))
if isinstance(forecast[i], dict)
else ""
),
self.device_name,
"check_weather_prediction()",
unit_of_measurement=(
forecast[i].get("temperature_unit", _entity_temp_unit)
if isinstance(forecast[i], dict)
else _entity_temp_unit
),
)
/ 2
)
valid_temps: list[float] = [t for t in temps if isinstance(t, (int, float))]
avg_forecast_temp = None
if valid_temps:
avg_forecast_temp = sum(valid_temps) / float(len(valid_temps))
cond_cur = (
isinstance(cur_outside_temp, (int, float))
and cur_outside_temp < self.off_temperature
)
return (
cur_outside_temp < self.off_temperature
or max_forecast_temp < self.off_temperature
cond_fc = (
isinstance(avg_forecast_temp, (int, float))
and avg_forecast_temp < self.off_temperature
)
return bool(cond_cur or cond_fc)
else:
raise TypeError
except TypeError:
except (TypeError, ServiceNotSupported, HomeAssistantError):
_LOGGER.warning(
f"better_thermostat {self.device_name}: no weather entity data found."
"better_thermostat %s: no weather entity data found.", self.device_name
)
# Return None (no opinion) on a transient failure so check_weather keeps
# the current call_for_heat decision while weather data is unavailable.
return None
async def check_ambient_air_temperature(self):
"""Gets the history for two days and evaluates the necessary for heating.
"""Get the history for two days and evaluates the necessary for heating.
Returns
-------
@@ -149,14 +234,29 @@ async def check_ambient_air_temperature(self):
if self.off_temperature is None or not isinstance(self.off_temperature, float):
_LOGGER.warning(
f"better_thermostat {self.device_name}: off_temperature not set or not a float."
"better_thermostat %s: off_temperature not set or not a float.",
self.device_name,
)
return None
self.last_avg_outdoor_temp = convert_to_float(
self.hass.states.get(self.outdoor_sensor).state,
# Check if outdoor sensor is available
outdoor_state = self.hass.states.get(self.outdoor_sensor)
if outdoor_state is None or outdoor_state.state in ("unavailable", "unknown", None):
_LOGGER.debug(
"better_thermostat %s: outdoor sensor %s unavailable, skipping ambient check",
self.device_name,
self.outdoor_sensor,
)
# Keep last known value or default to heating enabled
if self.last_avg_outdoor_temp is None:
self.call_for_heat = True
return None
self.last_avg_outdoor_temp = convert_to_float_celsius(
outdoor_state.state,
self.device_name,
"check_ambient_air_temperature()",
unit_of_measurement=outdoor_state.attributes.get("unit_of_measurement"),
)
if "recorder" in self.hass.config.components:
_temp_history = DailyHistory(2)
@@ -179,29 +279,48 @@ async def check_ambient_air_temperature(self):
dt_util.utcnow(),
lower_entity_id,
)
for item in history_list.get(lower_entity_id):
items = []
try:
items = history_list.get(lower_entity_id) or []
except (AttributeError, KeyError, TypeError):
items = []
for item in items:
# filter out all None, NaN, "unknown" and "unavailable" states.
# only keep real values
with suppress(ValueError):
if item.state not in ("unknown", "unavailable"):
_temp_history.add_measurement(
convert_to_float(
convert_to_float_celsius(
item.state,
self.device_name,
"check_ambient_air_temperature()",
unit_of_measurement=(
getattr(item, "attributes", {}).get(
"unit_of_measurement"
)
or outdoor_state.attributes.get("unit_of_measurement")
),
),
datetime.fromtimestamp(item.last_updated.timestamp()),
)
avg_temp = _temp_history.min
if avg_temp is None:
# No usable recorder history (e.g. a freshly created helper or a
# sensor the recorder does not retain). Fall back to the current
# reading so the outdoor threshold still applies instead of
# defaulting to "heat".
avg_temp = self.last_avg_outdoor_temp
_LOGGER.debug("Initializing from database completed")
else:
avg_temp = self.last_avg_outdoor_temp
_LOGGER.debug(
f"better_thermostat {self.device_name}: avg outdoor temp: {avg_temp}, threshold is {self.off_temperature}"
"better_thermostat %s: avg outdoor temp: %s, threshold is %s",
self.device_name,
avg_temp,
self.off_temperature,
)
if avg_temp is not None:
@@ -213,20 +332,29 @@ async def check_ambient_air_temperature(self):
class DailyHistory:
"""Stores one measurement per day for a maximum number of days.
At the moment only the maximum value per day is kept.
"""Store one measurement per day for a maximum number of days.
Compute an average outside temperature that better reflects the last days:
- Track all readings per day and compute the per-day mean
- Then compute the overall mean across the kept days
Note: Attribute name `min` is kept for backward compatibility with callers,
but it now contains the multi-day mean (float) instead of a median of minima.
"""
def __init__(self, max_length):
"""Create new DailyHistory with a maximum length of the history."""
self.max_length = max_length
self._days = None
self._max_dict = {}
self._days = None # deque[date]
# Track per-day aggregate to compute means
self._sum_dict = {}
self._count_dict = {}
# Back-compat field: will store the resulting multi-day mean
self.min = None
def add_measurement(self, value, timestamp=None):
"""Add a new measurement for a certain day."""
day = (timestamp or datetime.now()).date()
"""Add a new measurement for a certain day (value: float)."""
day = (timestamp or dt_util.now()).date()
if not isinstance(value, (int, float)):
return
if self._days is None:
@@ -235,22 +363,41 @@ class DailyHistory:
else:
current_day = self._days[-1]
if day == current_day:
self._max_dict[day] = min(value, self._max_dict[day])
# Accumulate for the same day
self._sum_dict[day] = self._sum_dict.get(day, 0.0) + float(value)
self._count_dict[day] = self._count_dict.get(day, 0) + 1
elif day > current_day:
self._add_day(day, value)
else:
_LOGGER.warning("Received old measurement, not storing it")
_LOGGER.debug(
"DailyHistory: received out-of-order measurement, skipping"
)
self.min = median(self._max_dict.values())
# Compute per-day means and then the overall mean across days
day_means = []
if self._days:
for d in self._days:
cnt = self._count_dict.get(d, 0)
if cnt > 0:
day_means.append(self._sum_dict.get(d, 0.0) / float(cnt))
if day_means:
self.min = sum(day_means) / float(len(day_means))
def _add_day(self, day, value):
"""Add a new day to the history.
Deletes the oldest day, if the queue becomes too long.
"""
if self._days is None:
self._days = deque()
if len(self._days) == self.max_length:
oldest = self._days.popleft()
del self._max_dict[oldest]
# Clean up aggregates of the removed day
self._sum_dict.pop(oldest, None)
self._count_dict.pop(oldest, None)
self._days.append(day)
if not isinstance(value, (int, float)):
return
self._max_dict[day] = value
# Initialize aggregates for the new day with the first value
self._sum_dict[day] = float(value)
self._count_dict[day] = 1
@@ -1,109 +1,111 @@
"""Support for Gardena Smart System devices."""
import asyncio
import logging
from gardena.exceptions.authentication_exception import AuthenticationException
from gardena.smart_system import SmartSystem, get_ssl_context
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant
from oauthlib.oauth2.rfc6749.errors import (
AccessDeniedError,
InvalidClientError,
MissingTokenError,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.device_registry import DeviceEntry
from .const import (
DOMAIN,
GARDENA_LOCATION,
GARDENA_SYSTEM,
)
from .const import DOMAIN
from .coordinator import GardenaSmartSystemCoordinator
from .services import GardenaServiceManager
_LOGGER = logging.getLogger(__name__)
PLATFORMS = ["lawn_mower", "sensor", "switch", "binary_sensor", "button"]
PLATFORMS = [
Platform.LAWN_MOWER,
Platform.NUMBER,
Platform.SENSOR,
Platform.BINARY_SENSOR,
Platform.SWITCH,
Platform.VALVE,
Platform.BUTTON,
]
# Create SSL context outside of event loop
_SSL_CONTEXT = get_ssl_context()
async def async_setup(hass: HomeAssistant, config: dict):
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the Gardena Smart System integration."""
_LOGGER.info("Setting up Gardena Smart System integration")
# Initialize service manager once per Home Assistant instance
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
service_manager = GardenaServiceManager(hass)
hass.data[DOMAIN]["service_manager"] = service_manager
_LOGGER.info("Gardena Smart System services initialized")
async def _async_reload_service(call: ServiceCall) -> None:
"""Reload all config entries for this integration."""
for entry in hass.config_entries.async_entries(DOMAIN):
await hass.config_entries.async_reload(entry.entry_id)
hass.services.async_register(DOMAIN, "reload", _async_reload_service)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug("Setting up Gardena Smart System component")
gardena_system = GardenaSmartSystem(
hass,
client_id=entry.data[CONF_CLIENT_ID],
client_secret=entry.data[CONF_CLIENT_SECRET],
"""Set up Gardena Smart System from a config entry."""
_LOGGER.info("Setting up Gardena Smart System component")
# Create client
from .gardena_client import GardenaSmartSystemClient
client = GardenaSmartSystemClient(
client_id=entry.data["client_id"],
client_secret=entry.data["client_secret"],
dev_mode=True, # Enable dev mode to bypass SSL issues on macOS
)
while True:
try:
await gardena_system.start()
break # If connection is successful, return True
except ConnectionError:
await asyncio.sleep(60) # Wait for 60 seconds before trying to reconnect
except AccessDeniedError as ex:
_LOGGER.error('Got Access Denied Error when setting up Gardena Smart System: %s', ex)
return False
except InvalidClientError as ex:
_LOGGER.error('Got Invalid Client Error when setting up Gardena Smart System: %s', ex)
return False
except MissingTokenError as ex:
_LOGGER.error('Got Missing Token Error when setting up Gardena Smart System: %s', ex)
return False
hass.data[DOMAIN][GARDENA_SYSTEM] = gardena_system
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, lambda event: hass.async_create_task(gardena_system.stop()))
# Create coordinator
coordinator = GardenaSmartSystemCoordinator(
hass,
client=client,
)
# Store coordinator in hass data
hass.data[DOMAIN][entry.entry_id] = coordinator
# Start the coordinator
await coordinator.async_config_entry_first_refresh()
# Forward the setup to the platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_LOGGER.debug("Gardena Smart System component setup finished")
_LOGGER.info("Gardena Smart System component setup finished")
return True
class GardenaSmartSystem:
"""A Gardena Smart System wrapper class."""
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
_LOGGER.info("Unloading Gardena Smart System component")
def __init__(self, hass, client_id, client_secret):
"""Initialize the Gardena Smart System."""
self._hass = hass
self.smart_system = SmartSystem(
client_id=client_id,
client_secret=client_secret,
ssl_context=_SSL_CONTEXT # Use the pre-created SSL context
)
# Unload platforms
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def start(self):
try:
_LOGGER.debug("Starting GardenaSmartSystem")
await self.smart_system.authenticate()
await self.smart_system.update_locations()
if unload_ok:
# Remove coordinator from hass data
coordinator = hass.data[DOMAIN].pop(entry.entry_id)
await coordinator.async_shutdown()
if len(self.smart_system.locations) < 1:
_LOGGER.error("No locations found")
raise Exception("No locations found")
return unload_ok
# currently gardena supports only one location and gateway, so we can take the first
location = list(self.smart_system.locations.values())[0]
_LOGGER.debug(f"Using location: {location.name} ({location.id})")
await self.smart_system.update_devices(location)
self._hass.data[DOMAIN][GARDENA_LOCATION] = location
_LOGGER.debug("Starting GardenaSmartSystem websocket")
asyncio.create_task(self.smart_system.start_ws(self._hass.data[DOMAIN][GARDENA_LOCATION]))
_LOGGER.debug("Websocket thread launched !")
except AuthenticationException as ex:
_LOGGER.error(
f"Authentication failed : {ex.message}. You may need to check your token or create a new app in the gardena api and use the new token.")
async def stop(self):
_LOGGER.debug("Stopping GardenaSmartSystem")
await self.smart_system.quit()
async def async_remove_config_entry_device(
hass: HomeAssistant,
config_entry: ConfigEntry,
device_entry: DeviceEntry,
) -> bool:
"""Allow removal of devices no longer reported by the Gardena API."""
coordinator = hass.data[DOMAIN].get(config_entry.entry_id)
if not coordinator:
return True
known_device_ids: set[str] = set()
for location in coordinator.locations.values():
for device_id in location.devices:
known_device_ids.add(device_id)
return not device_entry.identifiers.intersection(
(DOMAIN, device_id) for device_id in known_device_ids
)
@@ -1,61 +1,91 @@
"""Support for Gardena Smart System websocket connection status."""
"""Support for Gardena Smart System binary sensors."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from custom_components.gardena_smart_system import GARDENA_SYSTEM
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import GardenaSmartSystemCoordinator
from .entities import GardenaOnlineEntity, GardenaEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Perform the setup for Gardena websocket connection status."""
async_add_entities(
[SmartSystemWebsocketStatus(hass.data[DOMAIN][GARDENA_SYSTEM].smart_system)],
True,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Gardena Smart System binary sensors."""
coordinator: GardenaSmartSystemCoordinator = hass.data[DOMAIN][entry.entry_id]
entities = []
for location in coordinator.locations.values():
for device in location.devices.values():
entities.append(GardenaOnlineBinarySensor(coordinator, device))
entities.append(GardenaWebSocketConnectedSensor(coordinator, entry.entry_id))
async_add_entities(entities)
class SmartSystemWebsocketStatus(BinarySensorEntity):
"""Representation of Gardena Smart System websocket connection status."""
class GardenaOnlineBinarySensor(GardenaOnlineEntity, BinarySensorEntity):
"""Representation of a Gardena device online status sensor."""
def __init__(self, smart_system) -> None:
"""Initialize the binary sensor."""
super().__init__()
self._unique_id = "smart_gardena_websocket_status"
self._name = "Gardena Smart System connection"
self._smart_system = smart_system
def __init__(self, coordinator: GardenaSmartSystemCoordinator, device) -> None:
"""Initialize the online status sensor."""
super().__init__(coordinator, device)
self._attr_name = f"{device.name} Online"
async def async_added_to_hass(self):
"""Subscribe to events."""
self._smart_system.add_ws_status_callback(self.update_callback)
class GardenaWebSocketConnectedSensor(GardenaEntity, BinarySensorEntity):
"""Binary sensor indicating whether the Gardena WebSocket is connected."""
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
def __init__(self, coordinator: GardenaSmartSystemCoordinator, entry_id: str) -> None:
"""Initialize the WebSocket connectivity sensor."""
from .models import GardenaDevice
dummy_device = GardenaDevice(
id=f"websocket_status_{entry_id}",
name="WebSocket Status",
model_type="WebSocket Client",
serial="websocket",
services={},
location_id=""
)
super().__init__(coordinator, dummy_device, "WEBSOCKET")
self._attr_name = "Gardena WebSocket Connected"
self._attr_unique_id = f"gardena_websocket_connected_{entry_id}"
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def unique_id(self) -> str:
"""Return a unique ID."""
return self._unique_id
def available(self) -> bool:
"""Return True if entity is available."""
return True
@property
def is_on(self) -> bool:
"""Return the status of the sensor."""
return self._smart_system.is_ws_connected
@property
def should_poll(self) -> bool:
"""No polling needed for a sensor."""
"""Return True if WebSocket is connected."""
if self.coordinator.websocket_client:
return self.coordinator.websocket_client.is_connected
return False
def update_callback(self, status):
"""Call update for Home Assistant when the device is updated."""
self.schedule_update_ha_state(True)
@property
def device_class(self):
"""Return the class of this device, from component DEVICE_CLASSES."""
return BinarySensorDeviceClass.CONNECTIVITY
def extra_state_attributes(self) -> dict[str, Any]:
"""Return entity specific state attributes."""
attrs = super().extra_state_attributes
if self.coordinator.websocket_client:
attrs.update({
"reconnect_attempts": self.coordinator.websocket_client.reconnect_attempts,
})
return attrs
@@ -1,122 +1,102 @@
"""Config flow for Gardena integration."""
"""Config flow for Gardena Smart System integration."""
from __future__ import annotations
import logging
from collections import OrderedDict
from typing import Any
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from gardena.smart_system import SmartSystem
from homeassistant import config_entries
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_ID,
)
from homeassistant.core import callback
from .const import (
DOMAIN,
CONF_MOWER_DURATION,
CONF_SMART_IRRIGATION_DURATION,
CONF_SMART_WATERING_DURATION,
DEFAULT_MOWER_DURATION,
DEFAULT_SMART_IRRIGATION_DURATION,
DEFAULT_SMART_WATERING_DURATION,
)
from homeassistant import config_entries
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN
from .credential_validation import async_validate_gardena_credentials
_LOGGER = logging.getLogger(__name__)
DEFAULT_OPTIONS = {
CONF_MOWER_DURATION: DEFAULT_MOWER_DURATION,
CONF_SMART_IRRIGATION_DURATION: DEFAULT_SMART_IRRIGATION_DURATION,
CONF_SMART_WATERING_DURATION: DEFAULT_SMART_WATERING_DURATION,
}
class GardenaSmartSystemConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Gardena."""
class GardenaSmartSystemConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Gardena Smart System."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
async def _show_setup_form(self, errors=None):
"""Show the setup form to the user."""
errors = {}
fields = OrderedDict()
fields[vol.Required(CONF_CLIENT_ID)] = str
fields[vol.Required(CONF_CLIENT_SECRET)] = str
return self.async_show_form(
step_id="user", data_schema=vol.Schema(fields), errors=errors
def _credentials_schema(self) -> vol.Schema:
"""Schema for API credential input."""
return vol.Schema(
{
vol.Required(CONF_CLIENT_ID): str,
vol.Required(CONF_CLIENT_SECRET): str,
}
)
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
if user_input is None:
return await self._show_setup_form()
errors = {}
# try:
# await try_connection(
# user_input[CONF_CLIENT_ID],
# user_input[CONF_CLIENT_SECRET])
# except Exception: # pylint: disable=broad-except
# _LOGGER.exception("Unexpected exception")
# errors["base"] = "unknown"
# return await self._show_setup_form(errors)
unique_id = user_input[CONF_CLIENT_ID]
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="",
async def _apply_credential_updates(
self,
entry: config_entries.ConfigEntry,
user_input: dict[str, Any],
) -> FlowResult:
"""Persist credentials; reload only when the config entry is enabled."""
self.hass.config_entries.async_update_entry(
entry,
data={
CONF_ID: unique_id,
CONF_CLIENT_ID: user_input[CONF_CLIENT_ID],
CONF_CLIENT_SECRET: user_input[CONF_CLIENT_SECRET]
})
CONF_CLIENT_SECRET: user_input[CONF_CLIENT_SECRET],
},
)
if entry.disabled_by is None:
await self.hass.config_entries.async_reload(entry.entry_id)
else:
_LOGGER.info(
"Credentials updated for disabled config entry %s; "
"enable the integration to connect",
entry.entry_id,
)
return self.async_abort(reason="reconfigure_successful")
@staticmethod
@callback
def async_get_options_flow(config_entry):
return GardenaSmartSystemOptionsFlowHandler(config_entry)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
class GardenaSmartSystemOptionsFlowHandler(config_entries.OptionsFlow):
async def async_step_init(self, user_input=None):
"""Manage the options."""
return await self.async_step_user()
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
# TODO: Validate options (min, max values)
return self.async_create_entry(title="", data=user_input)
errors = await async_validate_gardena_credentials(user_input)
if not errors:
return self.async_create_entry(
title="Gardena Smart System",
data=user_input,
)
fields = OrderedDict()
fields[vol.Optional(
CONF_MOWER_DURATION,
default=self.config_entry.options.get(
CONF_MOWER_DURATION, DEFAULT_MOWER_DURATION))] = cv.positive_int
fields[vol.Optional(
CONF_SMART_IRRIGATION_DURATION,
default=self.config_entry.options.get(
CONF_SMART_IRRIGATION_DURATION, DEFAULT_SMART_IRRIGATION_DURATION))] = cv.positive_int
fields[vol.Optional(
CONF_SMART_WATERING_DURATION,
default=self.config_entry.options.get(
CONF_SMART_WATERING_DURATION, DEFAULT_SMART_WATERING_DURATION))] = cv.positive_int
return self.async_show_form(
step_id="user",
data_schema=self._credentials_schema(),
errors=errors,
)
return self.async_show_form(step_id="user", data_schema=vol.Schema(fields), errors=errors)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Allow updating API credentials without removing the config entry."""
reconfigure_entry = self._get_reconfigure_entry()
errors: dict[str, str] = {}
if user_input is not None:
errors = await async_validate_gardena_credentials(user_input)
if not errors:
return await self._apply_credential_updates(
reconfigure_entry, user_input
)
async def try_connection(client_id, client_secret):
_LOGGER.debug("Trying to connect to Gardena during setup")
smart_system = SmartSystem(client_id=client_id, client_secret=client_secret)
await smart_system.authenticate()
await smart_system.update_locations()
await smart_system.quit()
_LOGGER.debug("Successfully connected to Gardena during setup")
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
self._credentials_schema(),
{
CONF_CLIENT_ID: reconfigure_entry.data.get(CONF_CLIENT_ID, ""),
CONF_CLIENT_SECRET: reconfigure_entry.data.get(
CONF_CLIENT_SECRET, ""
),
},
),
errors=errors,
)
+128 -21
View File
@@ -1,24 +1,131 @@
DOMAIN = "gardena_smart_system"
GARDENA_SYSTEM = "gardena_system"
GARDENA_LOCATION = "gardena_location"
"""Constants for the Gardena Smart System integration."""
from __future__ import annotations
CONF_MOWER_DURATION = "mower_duration"
CONF_SMART_IRRIGATION_DURATION = "smart_irrigation_control_duration"
CONF_SMART_WATERING_DURATION = "smart_watering_duration"
from typing import Final
DEFAULT_MOWER_DURATION = 60
DEFAULT_SMART_IRRIGATION_DURATION = 30
DEFAULT_SMART_WATERING_DURATION = 30
from homeassistant.components.lawn_mower import LawnMowerActivity
ATTR_NAME = "name"
ATTR_ACTIVITY = "activity"
ATTR_BATTERY_STATE = "battery_state"
ATTR_RF_LINK_LEVEL = "rf_link_level"
ATTR_RF_LINK_STATE = "rf_link_state"
ATTR_SERIAL = "serial"
ATTR_OPERATING_HOURS = "operating_hours"
ATTR_LAST_ERROR = "last_error"
ATTR_ERROR = "error"
ATTR_STATE = "state"
ATTR_STINT_START = "stint_start"
ATTR_STINT_END = "stint_end"
# Domain
DOMAIN: Final = "gardena_smart_system"
# Configuration keys
CONF_CLIENT_ID: Final = "client_id"
CONF_CLIENT_SECRET: Final = "client_secret"
# API constants
API_BASE_URL: Final = "https://api.smart.gardena.dev/v2"
API_TIMEOUT: Final = 30
# Device types
DEVICE_TYPE_MOWER: Final = "MOWER"
DEVICE_TYPE_VALVE: Final = "VALVE"
DEVICE_TYPE_POWER_SOCKET: Final = "POWER_SOCKET"
DEVICE_TYPE_SENSOR: Final = "SENSOR"
# Service types
SERVICE_TYPE_COMMON: Final = "COMMON"
SERVICE_TYPE_MOWER: Final = "MOWER"
SERVICE_TYPE_VALVE: Final = "VALVE"
SERVICE_TYPE_POWER_SOCKET: Final = "POWER_SOCKET"
SERVICE_TYPE_SENSOR: Final = "SENSOR"
# Mower states
MOWER_STATE_OK: Final = "OK"
MOWER_STATE_WARNING: Final = "WARNING"
MOWER_STATE_ERROR: Final = "ERROR"
MOWER_STATE_UNAVAILABLE: Final = "UNAVAILABLE"
# Gardena service states that represent an actual error condition. The lawn
# mower entity only reports LawnMowerActivity.ERROR when the service state is
# one of these — otherwise an unmapped/NONE activity (e.g. the mower stopped in
# the garden out of battery) falls back to PAUSED instead of contradicting the
# mower_error sensor, which reports "no error". See #375.
MOWER_ERROR_STATES: Final = frozenset({MOWER_STATE_ERROR, MOWER_STATE_WARNING})
# Mower activities
MOWER_ACTIVITY_PAUSED: Final = "PAUSED"
MOWER_ACTIVITY_PAUSED_IN_CS: Final = "PAUSED_IN_CS"
MOWER_ACTIVITY_CUTTING: Final = "OK_CUTTING"
MOWER_ACTIVITY_CUTTING_TIMER_OVERRIDDEN: Final = "OK_CUTTING_TIMER_OVERRIDDEN"
MOWER_ACTIVITY_SEARCHING: Final = "OK_SEARCHING"
MOWER_ACTIVITY_LEAVING: Final = "OK_LEAVING"
MOWER_ACTIVITY_CHARGING: Final = "OK_CHARGING"
MOWER_ACTIVITY_PARKED: Final = "PARKED_TIMER"
MOWER_ACTIVITY_PARKED_TIMER: Final = "PARKED_TIMER"
MOWER_ACTIVITY_PARKED_PARK_SELECTED: Final = "PARKED_PARK_SELECTED"
MOWER_ACTIVITY_PARKED_AUTOTIMER: Final = "PARKED_AUTOTIMER"
MOWER_ACTIVITY_PARKED_FROST: Final = "PARKED_FROST"
MOWER_ACTIVITY_PARKED_NO_LIGHT: Final = "PARKED_NO_LIGHT"
MOWER_ACTIVITY_PARKED_MOWING_COMPLETED: Final = "PARKED_MOWING_COMPLETED"
MOWER_ACTIVITY_PARKED_RAIN: Final = "PARKED_RAIN"
MOWER_ACTIVITY_PARKED_DAILY_LIMIT_REACHED: Final = "PARKED_DAILY_LIMIT_REACHED"
MOWER_ACTIVITY_STOPPED_IN_GARDEN: Final = "STOPPED_IN_GARDEN"
MOWER_ACTIVITY_INITIATE_NEXT_ACTION: Final = "INITIATE_NEXT_ACTION"
MOWER_ACTIVITY_SEARCHING_FOR_SATELLITES: Final = "SEARCHING_FOR_SATELLITES"
MOWER_ACTIVITY_NONE: Final = "NONE"
# Mower activity mapping to Home Assistant
MOWER_ACTIVITY_MAP: Final = {
MOWER_ACTIVITY_PAUSED: LawnMowerActivity.PAUSED,
MOWER_ACTIVITY_PAUSED_IN_CS: LawnMowerActivity.PAUSED,
MOWER_ACTIVITY_CUTTING: LawnMowerActivity.MOWING,
MOWER_ACTIVITY_CUTTING_TIMER_OVERRIDDEN: LawnMowerActivity.MOWING,
MOWER_ACTIVITY_SEARCHING: LawnMowerActivity.RETURNING,
MOWER_ACTIVITY_LEAVING: LawnMowerActivity.MOWING,
MOWER_ACTIVITY_CHARGING: LawnMowerActivity.DOCKED,
MOWER_ACTIVITY_PARKED: LawnMowerActivity.DOCKED,
MOWER_ACTIVITY_PARKED_TIMER: LawnMowerActivity.DOCKED,
MOWER_ACTIVITY_PARKED_PARK_SELECTED: LawnMowerActivity.DOCKED,
MOWER_ACTIVITY_PARKED_AUTOTIMER: LawnMowerActivity.DOCKED,
MOWER_ACTIVITY_PARKED_FROST: LawnMowerActivity.DOCKED,
MOWER_ACTIVITY_PARKED_NO_LIGHT: LawnMowerActivity.DOCKED,
MOWER_ACTIVITY_PARKED_MOWING_COMPLETED: LawnMowerActivity.DOCKED,
MOWER_ACTIVITY_PARKED_RAIN: LawnMowerActivity.DOCKED,
MOWER_ACTIVITY_PARKED_DAILY_LIMIT_REACHED: LawnMowerActivity.DOCKED,
MOWER_ACTIVITY_STOPPED_IN_GARDEN: LawnMowerActivity.DOCKED,
MOWER_ACTIVITY_INITIATE_NEXT_ACTION: LawnMowerActivity.MOWING,
MOWER_ACTIVITY_SEARCHING_FOR_SATELLITES: LawnMowerActivity.DOCKED,
# NONE is intentionally not mapped: the entity decides between ERROR and
# PAUSED based on the Gardena service state instead of assuming an error.
# See GardenaLawnMower.activity and MOWER_ERROR_STATES (#375).
}
# Mower informational codes — operational states that are NOT errors.
# The mower_error sensor returns "no_message" when last_error_code is in this set
# so that user automations trigger only on real actionable errors.
MOWER_INFORMATIONAL_CODES: Final = frozenset({
"no_message", # Already "no error"
"uninitialised", # Normal boot state
"parked_daily_limit_reached", # Daily schedule limit reached — normal operation
"outside_working_area", # Mower returned to base outside its zone — normal
"off_disabled", # Disabled manually by user
"off_hatch_open", # Hatch open for maintenance
"off_hatch_closed", # Hatch closed — normal state
"wait_updating", # Firmware update in progress
"wait_power_up", # Booting up
"wait_stop_pressed", # Stop button held — user-initiated maintenance
"wait_for_safety_pin", # Waiting for safety pin — user-initiated maintenance
"guide_calibration_accomplished", # Calibration completed successfully
"connection_changed", # Network state change — informational
"connection_not_changed", # Network state change — informational
})
# WebSocket configuration
WEBSOCKET_RECONNECT_DELAY: Final = 5 # seconds
# Number of fast (exponential-backoff) reconnection attempts before falling back
# to a slow, quota-friendly retry cadence. The client never permanently gives up:
# after this many attempts it keeps retrying at WEBSOCKET_SLOW_RECONNECT_INTERVAL
# so stale data eventually recovers without a Home Assistant restart (#378).
WEBSOCKET_MAX_RECONNECT_ATTEMPTS: Final = 10
# Slow reconnection cadence (seconds) used once the fast attempts are exhausted.
# One attempt/hour is ~168 POST /v2/websocket requests/week, well within the
# 700 requests/week Gardena API quota.
WEBSOCKET_SLOW_RECONNECT_INTERVAL: Final = 3600
# Valve duration configuration
CONF_VALVE_DURATIONS: Final = "valve_durations"
DEFAULT_VALVE_DURATION_SECONDS: Final = 3600
# Attribute names
ATTR_BATTERY_STATE: Final = "battery_state"
ATTR_RF_LINK_LEVEL: Final = "rf_link_level"
ATTR_RF_LINK_STATE: Final = "rf_link_state"
@@ -1,11 +1,13 @@
{
"domain": "gardena_smart_system",
"name": "Gardena Smart System integration",
"version": "1.2.1",
"version": "3.1.3",
"config_flow": true,
"documentation": "https://github.com/py-smart-gardena/hass-gardena-smart-system",
"issue_tracker": "https://github.com/py-smart-gardena/hass-gardena-smart-system/issues",
"dependencies": [],
"codeowners": ["@py-smart-gardena"],
"requirements": ["py-smart-gardena==1.3.12", "oauthlib==3.2.2"]
"requirements": ["aiohttp>=3.7.0", "websockets>=10.0"],
"iot_class": "cloud_polling",
"integration_type": "device"
}
+508 -115
View File
@@ -1,147 +1,540 @@
"""Support for Gardena Smart System sensors."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
from homeassistant.components.sensor import SensorDeviceClass, UnitOfTemperature
from homeassistant.helpers.entity import Entity
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
PERCENTAGE,
)
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
DOMAIN,
ATTR_BATTERY_STATE,
ATTR_RF_LINK_LEVEL,
ATTR_RF_LINK_STATE,
GARDENA_LOCATION,
MOWER_INFORMATIONAL_CODES,
)
from .coordinator import GardenaSmartSystemCoordinator
from .entities import GardenaEntity
_LOGGER = logging.getLogger(__name__)
SOIL_SENSOR_TYPES = {
"soil_temperature": [
UnitOfTemperature.CELSIUS,
"mdi:thermometer",
SensorDeviceClass.TEMPERATURE,
],
"soil_humidity": ["%", "mdi:water-percent", SensorDeviceClass.HUMIDITY],
ATTR_BATTERY_LEVEL: [PERCENTAGE, "mdi:battery", SensorDeviceClass.BATTERY],
}
SENSOR_TYPES = {
**{
"ambient_temperature": [
UnitOfTemperature.CELSIUS,
"mdi:thermometer",
SensorDeviceClass.TEMPERATURE,
],
"light_intensity": ["lx", None, SensorDeviceClass.ILLUMINANCE],
},
**SOIL_SENSOR_TYPES,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Gardena Smart System sensors."""
coordinator: GardenaSmartSystemCoordinator = hass.data[DOMAIN][entry.entry_id]
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Perform the setup for Gardena sensor devices."""
# Create sensor entities for each device
entities = []
for sensor in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type("SENSOR"):
for sensor_type in SENSOR_TYPES:
entities.append(GardenaSensor(sensor, sensor_type))
for location in coordinator.locations.values():
for device in location.devices.values():
_LOGGER.debug(f"Checking device {device.name} ({device.id}) - Services: {list(device.services.keys())}")
# Add battery and RF link sensors for devices that have COMMON service
if "COMMON" in device.services:
common_services = device.services["COMMON"]
_LOGGER.debug(f"Found {len(common_services)} common services for device: {device.name} ({device.id})")
for common_service in common_services:
# Only create battery sensor if device has a battery.
# Include when battery_level is present even if battery_state is not yet
# populated (soil sensors can return null battery_state at initial load).
has_battery = (
common_service.battery_state not in [None, "NO_BATTERY"]
or common_service.battery_level is not None
)
if has_battery:
_LOGGER.debug(f"Creating battery sensor for device with battery: {device.name} (battery_state: {common_service.battery_state})")
entities.append(GardenaBatterySensor(coordinator, device, common_service))
else:
_LOGGER.debug(f"Skipping battery sensor for device without battery: {device.name} (battery_state: {common_service.battery_state})")
for sensor in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type(
"SOIL_SENSOR"
):
for sensor_type in SOIL_SENSOR_TYPES:
entities.append(GardenaSensor(sensor, sensor_type))
# RF Link Quality sensor
if common_service.rf_link_level is not None:
entities.append(GardenaRFLinkLevelSensor(coordinator, device, common_service))
# Add mower sensors
if "MOWER" in device.services:
mower_services = device.services["MOWER"]
for mower_service in mower_services:
entities.append(GardenaMowerErrorSensor(coordinator, device, mower_service))
if mower_service.operating_hours is not None:
entities.append(GardenaMowerOperatingHoursSensor(coordinator, device, mower_service))
for mower in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type("MOWER"):
# Add battery sensor for mower
entities.append(GardenaSensor(mower, ATTR_BATTERY_LEVEL))
# Add watering end time sensors for valves
if "VALVE" in device.services:
valve_services = device.services["VALVE"]
for valve_service in valve_services:
entities.append(GardenaValveRemainingTimeSensor(coordinator, device, valve_service))
for water_control in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type(
"WATER_CONTROL"
):
# Add battery sensor for water control
entities.append(GardenaSensor(water_control, ATTR_BATTERY_LEVEL))
_LOGGER.debug("Adding sensor as sensor %s", entities)
async_add_entities(entities, True)
# Add sensor entities if available
if "SENSOR" in device.services:
sensor_services = device.services["SENSOR"]
_LOGGER.debug(f"Found {len(sensor_services)} sensor services for device: {device.name} ({device.id})")
for sensor_service in sensor_services:
_LOGGER.debug(f"Creating sensor entities for service: {sensor_service.id}")
# Check if this is a soil sensor (has soil_humidity or soil_temperature)
is_soil_sensor = (sensor_service.soil_humidity is not None or
sensor_service.soil_temperature is not None)
# Create temperature sensors
if sensor_service.soil_temperature is not None:
entities.append(GardenaTemperatureSensor(coordinator, device, sensor_service, "soil_temperature", is_soil_sensor))
if sensor_service.ambient_temperature is not None:
entities.append(GardenaTemperatureSensor(coordinator, device, sensor_service, "ambient_temperature", is_soil_sensor))
# Create humidity sensor (only for soil sensors)
if sensor_service.soil_humidity is not None:
entities.append(GardenaHumiditySensor(coordinator, device, sensor_service))
# Create light sensor
if sensor_service.light_intensity is not None:
entities.append(GardenaLightSensor(coordinator, device, sensor_service))
# Add API usage diagnostic sensor (one per config entry)
entities.append(GardenaAPIUsageSensor(coordinator, entry.entry_id))
_LOGGER.debug(f"Created {len(entities)} sensor entities")
async_add_entities(entities)
class GardenaSensor(Entity):
"""Representation of a Gardena Sensor."""
class GardenaBatterySensor(GardenaEntity, SensorEntity):
"""Representation of a Gardena battery sensor."""
def __init__(self, device, sensor_type):
"""Initialize the Gardena Sensor."""
self._sensor_type = sensor_type
self._name = f"{device.name} {sensor_type.replace('_', ' ')}"
self._unique_id = f"{device.serial}-{sensor_type}"
self._device = device
_attr_state_class = SensorStateClass.MEASUREMENT
async def async_added_to_hass(self):
"""Subscribe to sensor events."""
self._device.add_callback(self.update_callback)
def __init__(self, coordinator: GardenaSmartSystemCoordinator, device, common_service) -> None:
"""Initialize the battery sensor."""
super().__init__(coordinator, device, "COMMON")
self._common_service = common_service
self._device_id = device.id
self._attr_name = f"{device.name} Battery Level"
self._attr_unique_id = f"{device.id}_{common_service.id}_battery_level"
self._attr_native_unit_of_measurement = PERCENTAGE
self._attr_device_class = SensorDeviceClass.BATTERY
self._attr_icon = "mdi:battery"
@property
def should_poll(self) -> bool:
"""No polling needed for a sensor."""
return False
def update_callback(self, device):
"""Call update for Home Assistant when the device is updated."""
self.schedule_update_ha_state(True)
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def unique_id(self) -> str:
"""Return a unique ID."""
return self._unique_id
@property
def icon(self):
"""Return the icon to use in the frontend."""
return SENSOR_TYPES[self._sensor_type][1]
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any."""
return SENSOR_TYPES[self._sensor_type][0]
@property
def device_class(self):
"""Return the device class of this entity."""
if self._sensor_type in SENSOR_TYPES:
return SENSOR_TYPES[self._sensor_type][2]
def _get_current_common_service(self):
"""Get current common service from coordinator (fresh data)."""
device = self.coordinator.get_device_by_id(self._device_id)
if device and "COMMON" in device.services:
for service in device.services["COMMON"]:
if service.id == self._common_service.id:
return service
return None
@property
def state(self):
"""Return the state of the sensor."""
return getattr(self._device, self._sensor_type)
def native_value(self) -> int | None:
"""Return the battery level."""
current_service = self._get_current_common_service()
return current_service.battery_level if current_service else None
@property
def extra_state_attributes(self):
"""Return the state attributes of the sensor."""
return {
ATTR_BATTERY_LEVEL: self._device.battery_level,
ATTR_BATTERY_STATE: self._device.battery_state,
ATTR_RF_LINK_LEVEL: self._device.rf_link_level,
ATTR_RF_LINK_STATE: self._device.rf_link_state,
}
def extra_state_attributes(self) -> dict[str, Any]:
"""Return entity specific state attributes."""
attrs = super().extra_state_attributes
current_service = self._get_current_common_service()
if current_service:
attrs.update({
ATTR_BATTERY_STATE: current_service.battery_state,
ATTR_RF_LINK_LEVEL: current_service.rf_link_level,
ATTR_RF_LINK_STATE: current_service.rf_link_state,
})
return attrs
class GardenaMowerErrorSensor(GardenaEntity, SensorEntity):
"""Representation of a Gardena mower error code sensor."""
_attr_translation_key = "mower_error"
_attr_has_entity_name = True
_attr_device_class = SensorDeviceClass.ENUM
def __init__(self, coordinator: GardenaSmartSystemCoordinator, device, mower_service) -> None:
"""Initialize the mower error sensor."""
super().__init__(coordinator, device, "MOWER")
self._mower_service = mower_service
self._device_id = device.id
self._attr_name = None
self._attr_unique_id = f"{device.id}_{mower_service.id}_last_error_code"
self._attr_icon = "mdi:alert-circle-outline"
self._attr_options = [
"uninitialised", "no_message", "outside_working_area", "no_loop_signal",
"no_charging_station_signal", "wrong_loop_signal",
"loop_sensor_problem_front", "loop_sensor_problem_rear",
"trapped", "upside_down", "low_battery", "empty_battery", "no_drive",
"lifted", "stuck_in_charging_station", "charging_station_blocked",
"collision_sensor_problem_rear", "collision_sensor_problem_front",
"wheel_motor_blocked_right", "wheel_motor_blocked_left",
"wheel_drive_problem_right", "wheel_drive_problem_left",
"cutting_system_blocked", "invalid_sub_device_combination",
"settings_restored", "charging_system_problem", "tilt_sensor_problem",
"mower_tilted", "wheel_motor_overloaded_right",
"wheel_motor_overloaded_left", "charging_current_too_high",
"electronic_problem", "cutting_height_blocked", "cutting_height_problem",
"temporary_problem", "guide_1_not_found", "guide_2_not_found",
"guide_3_not_found", "gps_tracker_module_error", "weak_gps_signal",
"guide_calibration_failed", "temporary_battery_problem",
"battery_problem", "alarm_mower_switched_off", "alarm_mower_stopped",
"alarm_mower_lifted", "alarm_mower_tilted", "com_board_not_available",
"slipped", "invalid_battery_combination", "safety_function_faulty",
"invalid_system_conf", "lift_sensor_defect", "mobile_loop_defect",
"left_loop_sensor", "right_loop_sensor", "wrong_pin", "temporary_lift",
"cutting_drive", "steep_slope", "stop_button_fail",
"angle_cutting_means_off", "slave_mcu_lost", "cutting_overload",
"cutting_height_range", "cutting_height_drift", "cutting_height_limited",
"cutting_height_drive", "cutting_height_current",
"cutting_height_direction", "mower_to_cs_com", "ultrasonic_error",
"high_low_bat_temp_a", "high_low_bat_temp_b",
"too_low_voltage_bat_a", "too_low_voltage_bat_b",
"alarm_motion", "alarm_geofence",
"rr_wheel_blocked", "rl_wheel_blocked",
"rr_wheel_drive", "rl_wheel_drive",
"rear_right_wheel_overloaded", "rear_left_wheel_overloaded",
"angular_sensor_defect", "no_power_in_cs", "switch_cord_sensor_defect",
"map_not_valid", "no_position", "no_rs_communication",
"folding_sensor_activated",
"ultrasonic_sensor_1_defect", "ultrasonic_sensor_2_defect",
"ultrasonic_sensor_3_defect", "ultrasonic_sensor_4_defect",
"cutting_drive_motor_1_defect", "cutting_drive_motor_2_defect",
"cutting_drive_motor_3_defect",
"collision_sensor_defect", "docking_sensor_defect",
"folding_cutting_deck_sensor_defect", "loop_sensor_defect",
"collision_sensor_error", "no_confirmed_position",
"major_cutting_disk_imbalance", "complex_working_area",
"invalid_sw_configuration", "radar_error", "work_area_tampered",
"destination_not_reachable", "wait_stop_pressed", "wait_for_safety_pin",
"destination_not_reachable_warning", "battery_near_end_of_life",
"edgemotor_blocked", "no_correction_data", "invalid_correction_data",
"wait_updating", "wait_power_up", "off_disabled", "off_hatch_open",
"off_hatch_closed", "parked_daily_limit_reached",
"vision_system_malfunction", "poor_vision_system_performance",
"vision_processing_failed",
"loop_sensor_problem_left", "loop_sensor_problem_right",
"wrong_pin_code", "lost_wheel_brush", "accessory_power_anomaly",
"loop_wire_broken", "battery_fet_error", "imbalanced_cutting_disc",
"cutting_motor_problem", "limited_cutting_height_range",
"cutting_motor_drive_defect", "memory_circuit_problem",
"stop_button_problem", "difficult_finding_home",
"guide_calibration_accomplished", "too_many_batteries",
"alarm_mower_in_motion", "alarm_outside_geofence",
"connection_changed", "connection_not_changed",
"unknown",
]
def _get_current_mower_service(self):
"""Get current mower service from coordinator (fresh data)."""
device = self.coordinator.get_device_by_id(self._device_id)
if device and "MOWER" in device.services:
for service in device.services["MOWER"]:
if service.id == self._mower_service.id:
return service
return None
@property
def device_info(self):
return {
"identifiers": {
# Serial numbers are unique identifiers within a specific domain
(DOMAIN, self._device.serial)
},
"name": self._device.name,
"manufacturer": "Gardena",
"model": self._device.model_type,
def native_value(self) -> str | None:
"""Return the last error code, or 'no_message' for informational states."""
current_service = self._get_current_mower_service()
if not current_service:
return None
error_code = current_service.last_error_code
if error_code:
code = error_code.lower()
if code in MOWER_INFORMATIONAL_CODES:
return "no_message"
return code
return "no_message"
@property
def icon(self) -> str:
"""Return icon based on error state."""
value = self.native_value
if value and value != "no_message":
return "mdi:alert-circle"
return "mdi:check-circle-outline"
class GardenaMowerOperatingHoursSensor(GardenaEntity, SensorEntity):
"""Representation of a Gardena mower operating hours sensor."""
_attr_state_class = SensorStateClass.TOTAL_INCREASING
_attr_device_class = SensorDeviceClass.DURATION
def __init__(self, coordinator: GardenaSmartSystemCoordinator, device, mower_service) -> None:
"""Initialize the operating hours sensor."""
super().__init__(coordinator, device, "MOWER")
self._mower_service = mower_service
self._device_id = device.id
self._attr_name = f"{device.name} Operating Hours"
self._attr_unique_id = f"{device.id}_{mower_service.id}_operating_hours"
self._attr_native_unit_of_measurement = UnitOfTime.HOURS
self._attr_icon = "mdi:clock-outline"
def _get_current_mower_service(self):
"""Get current mower service from coordinator (fresh data)."""
device = self.coordinator.get_device_by_id(self._device_id)
if device and "MOWER" in device.services:
for service in device.services["MOWER"]:
if service.id == self._mower_service.id:
return service
return None
@property
def native_value(self) -> int | None:
"""Return the operating hours."""
current_service = self._get_current_mower_service()
return current_service.operating_hours if current_service else None
class GardenaRFLinkLevelSensor(GardenaEntity, SensorEntity):
"""Representation of a Gardena RF link quality sensor."""
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, coordinator: GardenaSmartSystemCoordinator, device, common_service) -> None:
"""Initialize the RF link level sensor."""
super().__init__(coordinator, device, "COMMON")
self._common_service = common_service
self._device_id = device.id
self._attr_name = f"{device.name} RF Link Quality"
self._attr_unique_id = f"{device.id}_{common_service.id}_rf_link_level"
self._attr_native_unit_of_measurement = PERCENTAGE
self._attr_icon = "mdi:signal"
def _get_current_common_service(self):
"""Get current common service from coordinator (fresh data)."""
device = self.coordinator.get_device_by_id(self._device_id)
if device and "COMMON" in device.services:
for service in device.services["COMMON"]:
if service.id == self._common_service.id:
return service
return None
@property
def native_value(self) -> int | None:
"""Return the RF link level."""
current_service = self._get_current_common_service()
return current_service.rf_link_level if current_service else None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return entity specific state attributes."""
attrs = super().extra_state_attributes
current_service = self._get_current_common_service()
if current_service:
attrs[ATTR_RF_LINK_STATE] = current_service.rf_link_state
return attrs
class GardenaTemperatureSensor(GardenaEntity, SensorEntity):
"""Representation of a Gardena temperature sensor."""
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, coordinator: GardenaSmartSystemCoordinator, device, sensor_service, temp_attr: str, is_soil_sensor: bool = False) -> None:
"""Initialize the temperature sensor."""
super().__init__(coordinator, device, "SENSOR")
self._sensor_service = sensor_service
self._device_id = device.id
self._temp_attr = temp_attr
if temp_attr == "soil_temperature":
self._attr_name = f"{device.name} Soil Temperature"
self._attr_unique_id = f"{device.id}_{sensor_service.id}_soil_temperature"
else:
self._attr_name = f"{device.name} Ambient Temperature"
self._attr_unique_id = f"{device.id}_{sensor_service.id}_ambient_temperature"
# Add soil sensor indicator to name if it's a soil sensor
if is_soil_sensor and temp_attr == "soil_temperature":
self._attr_name = f"{device.name} Soil Temperature (Soil Sensor)"
self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
self._attr_device_class = SensorDeviceClass.TEMPERATURE
self._attr_icon = "mdi:thermometer"
def _get_current_sensor_service(self):
"""Get current sensor service from coordinator (fresh data)."""
device = self.coordinator.get_device_by_id(self._device_id)
if device and "SENSOR" in device.services:
for service in device.services["SENSOR"]:
if service.id == self._sensor_service.id:
return service
return None
@property
def native_value(self) -> int | None:
"""Return the temperature value."""
current_service = self._get_current_sensor_service()
if not current_service:
return None
if self._temp_attr == "soil_temperature":
return current_service.soil_temperature
else:
return current_service.ambient_temperature
class GardenaHumiditySensor(GardenaEntity, SensorEntity):
"""Representation of a Gardena humidity sensor."""
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, coordinator: GardenaSmartSystemCoordinator, device, sensor_service) -> None:
"""Initialize the humidity sensor."""
super().__init__(coordinator, device, "SENSOR")
self._sensor_service = sensor_service
self._device_id = device.id
self._attr_name = f"{device.name} Soil Humidity"
self._attr_unique_id = f"{device.id}_{sensor_service.id}_soil_humidity"
self._attr_native_unit_of_measurement = PERCENTAGE
self._attr_device_class = SensorDeviceClass.MOISTURE
self._attr_icon = "mdi:water-percent"
def _get_current_sensor_service(self):
"""Get current sensor service from coordinator (fresh data)."""
device = self.coordinator.get_device_by_id(self._device_id)
if device and "SENSOR" in device.services:
for service in device.services["SENSOR"]:
if service.id == self._sensor_service.id:
return service
return None
@property
def native_value(self) -> int | None:
"""Return the humidity value."""
current_service = self._get_current_sensor_service()
return current_service.soil_humidity if current_service else None
class GardenaLightSensor(GardenaEntity, SensorEntity):
"""Representation of a Gardena light sensor."""
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, coordinator: GardenaSmartSystemCoordinator, device, sensor_service) -> None:
"""Initialize the light sensor."""
super().__init__(coordinator, device, "SENSOR")
self._sensor_service = sensor_service
self._device_id = device.id
self._attr_name = f"{device.name} Light Intensity"
self._attr_unique_id = f"{device.id}_{sensor_service.id}_light_intensity"
self._attr_native_unit_of_measurement = "lx"
self._attr_device_class = SensorDeviceClass.ILLUMINANCE
self._attr_icon = "mdi:white-balance-sunny"
def _get_current_sensor_service(self):
"""Get current sensor service from coordinator (fresh data)."""
device = self.coordinator.get_device_by_id(self._device_id)
if device and "SENSOR" in device.services:
for service in device.services["SENSOR"]:
if service.id == self._sensor_service.id:
return service
return None
@property
def native_value(self) -> int | None:
"""Return the light intensity value."""
current_service = self._get_current_sensor_service()
return current_service.light_intensity if current_service else None
class GardenaValveRemainingTimeSensor(GardenaEntity, SensorEntity):
"""Sensor that shows the watering end time for a valve."""
_attr_device_class = SensorDeviceClass.TIMESTAMP
def __init__(self, coordinator: GardenaSmartSystemCoordinator, device, valve_service) -> None:
"""Initialize the valve remaining time sensor."""
super().__init__(coordinator, device, "VALVE")
self._valve_service = valve_service
self._device_id = device.id
valve_name = valve_service.name or device.name
self._attr_name = f"{valve_name} Watering End"
self._attr_unique_id = f"{device.id}_{valve_service.id}_watering_end"
self._attr_icon = "mdi:timer-sand"
def _get_current_valve_service(self):
"""Get current valve service from coordinator (fresh data)."""
device = self.coordinator.get_device_by_id(self._device_id)
if device and "VALVE" in device.services:
for service in device.services["VALVE"]:
if service.id == self._valve_service.id:
return service
return None
@property
def native_value(self) -> datetime | None:
"""Return the watering end timestamp."""
current_service = self._get_current_valve_service()
if not current_service:
return None
if current_service.activity in ("MANUAL_WATERING", "SCHEDULED_WATERING"):
if current_service.duration and current_service.duration_timestamp:
try:
start = datetime.fromisoformat(
current_service.duration_timestamp.replace("Z", "+00:00")
)
return start + timedelta(seconds=current_service.duration)
except (ValueError, TypeError):
return None
return None
class GardenaAPIUsageSensor(SensorEntity):
"""Sensor tracking Gardena API usage against the 700 requests/week quota."""
_attr_has_entity_name = True
_attr_translation_key = "api_requests_week"
_attr_icon = "mdi:api"
_attr_state_class = SensorStateClass.TOTAL
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, coordinator: GardenaSmartSystemCoordinator, entry_id: str) -> None:
"""Initialize the API usage sensor."""
self.coordinator = coordinator
self._entry_id = entry_id
self._attr_unique_id = f"gardena_api_usage_{entry_id}"
self._attr_name = "API Requests (Week)"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"websocket_status_{entry_id}")},
)
@property
def available(self) -> bool:
"""Always available."""
return True
@property
def native_value(self) -> int:
"""Return the number of API requests this week."""
return self.coordinator.client.api_tracker.requests_this_week
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return diagnostic attributes with request breakdown."""
tracker = self.coordinator.client.api_tracker
attrs: dict[str, Any] = {
"requests_today": tracker.requests_today,
"requests_this_week": tracker.requests_this_week,
"quota_weekly": 700,
"requests_by_endpoint": tracker.requests_by_endpoint(),
"recent_requests": tracker.recent_requests,
}
return attrs
async def async_update(self) -> None:
"""No-op: value is computed on read from the shared tracker."""
@@ -1,22 +1,71 @@
{
"entity": {
"sensor": {
"api_requests_week": {
"name": "API Requests (Week)"
},
"mower_error": {
"name": "Error code",
"state": {
"no_message": "No error",
"outside_working_area": "Outside working area",
"no_loop_signal": "No loop signal",
"wrong_loop_signal": "Wrong loop signal",
"loop_sensor_problem_front": "Front loop sensor defect",
"loop_sensor_problem_rear": "Rear loop sensor defect",
"trapped": "Trapped",
"upside_down": "Upside down",
"low_battery": "Low battery",
"empty_battery": "Empty battery",
"no_drive": "No drive",
"lifted": "Lifted",
"stuck_in_charging_station": "Stuck in charging station",
"charging_station_blocked": "Charging station blocked",
"collision_sensor_problem_rear": "Rear collision sensor defect",
"collision_sensor_problem_front": "Front collision sensor defect",
"wheel_motor_blocked_right": "Right wheel motor blocked",
"wheel_motor_blocked_left": "Left wheel motor blocked",
"cutting_system_blocked": "Cutting system blocked",
"steep_slope": "Too steep slope",
"parked_daily_limit_reached": "Daily limit reached",
"unknown": "Unknown error"
}
}
}
},
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reconfigure_successful": "API credentials updated. The integration was reloaded."
},
"error": {
"cannot_connect": "Failed to connect, please try again.",
"invalid_auth": "Invalid authentication.",
"invalid_auth": "Invalid authentication credentials. Please check your Client ID and Client Secret.",
"insufficient_permissions": "Insufficient permissions. Please check your API key and permissions.",
"no_locations": "No locations found. Please set up your Gardena smart Gateway in the official Gardena app first, then retry.",
"server_error": "Server error occurred. Please try again later.",
"auth_error": "Authentication error occurred. Please check your credentials.",
"unknown_error": "An unexpected error occurred. Please try again.",
"too_many_requests": "Too many requests, retry later.",
"api_error": "Failed to connect to the Gardena API. Please verify your credentials and try again.",
"unknown": "Unexpected error."
},
"step": {
"user": {
"description": "Please enter your credentials.",
"description": "Please enter your Gardena Smart System API credentials. You can obtain these from the Gardena Developer Portal.",
"data": {
"client_id": "Application Key / Client ID",
"client_secret": "Application secret / Client secret"
},
"title": "Gardena Smart System"
},
"reconfigure": {
"description": "Update your Husqvarna developer application credentials. Home Assistant obtains short-lived access tokens automatically — you do not enter a Bearer token manually. Existing entities are kept.",
"data": {
"client_id": "Application Key / Client ID",
"client_secret": "Application secret / Client secret"
},
"title": "Update Gardena API credentials"
}
}
},
@@ -42,6 +91,32 @@
"description": "The duration in seconds."
}
}
},
"start_automatic": {
"name": "Start automatic",
"description": "Start the mower with automatic schedule."
},
"park_until_next_task": {
"name": "Park until next task",
"description": "Park the mower until the next scheduled task."
},
"park_until_further_notice": {
"name": "Park until further notice",
"description": "Park the mower until further notice."
},
"reconnect_websocket": {
"name": "Reconnect WebSocket",
"description": "Force WebSocket reconnection for the Gardena Smart System integration."
},
"websocket_diagnostics": {
"name": "WebSocket Diagnostics",
"description": "Get WebSocket connection diagnostics and status information.",
"fields": {
"detailed": {
"name": "Detailed",
"description": "Include detailed connection information."
}
}
}
}
}
+75 -362
View File
@@ -1,381 +1,94 @@
"""Support for Gardena switch (Power control, water control, smart irrigation control)."""
import asyncio
"""Support for Gardena Smart System switches."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.core import callback
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import ATTR_BATTERY_LEVEL
from .const import (
ATTR_ACTIVITY,
ATTR_BATTERY_STATE,
ATTR_LAST_ERROR,
ATTR_RF_LINK_LEVEL,
ATTR_RF_LINK_STATE,
ATTR_SERIAL,
CONF_SMART_IRRIGATION_DURATION,
CONF_SMART_WATERING_DURATION,
DEFAULT_SMART_IRRIGATION_DURATION,
DEFAULT_SMART_WATERING_DURATION,
DOMAIN,
GARDENA_LOCATION,
)
from .sensor import GardenaSensor
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import GardenaSmartSystemCoordinator
from .entities import GardenaDeviceEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the switches platform."""
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Gardena Smart System switches."""
coordinator: GardenaSmartSystemCoordinator = hass.data[DOMAIN][entry.entry_id]
# Create switch entities for each device
entities = []
for water_control in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type("WATER_CONTROL"):
entities.append(GardenaSmartWaterControl(water_control, config_entry.options))
for location in coordinator.locations.values():
for device in location.devices.values():
_LOGGER.debug(f"Checking device {device.name} ({device.id}) - Services: {list(device.services.keys())}")
# Add power socket switches if available
if "POWER_SOCKET" in device.services:
power_services = device.services["POWER_SOCKET"]
_LOGGER.debug(f"Found {len(power_services)} power socket services for device: {device.name} ({device.id})")
for power_service in power_services:
_LOGGER.debug(f"Creating power socket switch for service: {power_service.id}")
entities.append(GardenaPowerSocketSwitch(coordinator, device, power_service))
else:
_LOGGER.debug(f"Device {device.name} ({device.id}) has no POWER_SOCKET service")
for power_switch in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type("POWER_SOCKET"):
entities.append(GardenaPowerSocket(power_switch))
for smart_irrigation in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type("SMART_IRRIGATION_CONTROL"):
for valve in smart_irrigation.valves.values():
entities.append(GardenaSmartIrrigationControl(
smart_irrigation, valve['id'], config_entry.options))
_LOGGER.debug(
"Adding water control, power socket and smart irrigation control as switch: %s",
entities)
async_add_entities(entities, True)
_LOGGER.debug(f"Created {len(entities)} power socket switch entities")
async_add_entities(entities)
class GardenaSmartWaterControl(SwitchEntity):
"""Representation of a Gardena Smart Water Control."""
class GardenaPowerSocketSwitch(GardenaDeviceEntity, SwitchEntity):
"""Representation of a Gardena power socket switch."""
def __init__(self, wc, options):
"""Initialize the Gardena Smart Water Control."""
self._device = wc
self._options = options
self._name = f"{self._device.name}"
self._unique_id = f"{self._device.serial}-valve"
self._state = None
self._error_message = ""
async def async_added_to_hass(self):
"""Subscribe to events."""
self._device.add_callback(self.update_callback)
def __init__(self, coordinator: GardenaSmartSystemCoordinator, device, power_service) -> None:
"""Initialize the power socket switch."""
super().__init__(coordinator, device, "POWER_SOCKET")
self._attr_name = f"{device.name} Power Socket"
self._power_service = power_service
self._device_id = device.id
@property
def should_poll(self) -> bool:
"""No polling needed for a water valve."""
def is_on(self) -> bool:
"""Return true if the switch is on."""
device = self.coordinator.get_device_by_id(self._device_id)
service = device.services.get("POWER_SOCKET")
if service and service[0].activity:
return service[0].activity in ["FOREVER_ON", "TIME_LIMITED_ON", "SCHEDULED_ON"]
return False
def update_callback(self, device):
"""Call update for Home Assistant when the device is updated."""
self.schedule_update_ha_state(True)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
if self._power_service:
command_data = {
"data": {
"id": "turn_on",
"type": "POWER_SOCKET_CONTROL",
"attributes": {
"command": "START_OVERRIDE",
},
}
}
await self.coordinator.client.send_command(self._power_service.id, command_data)
await self.coordinator.async_request_refresh()
async def async_update(self):
"""Update the states of Gardena devices."""
_LOGGER.debug("Running Gardena update")
# Managing state
state = self._device.valve_state
_LOGGER.debug("Water control has state %s", state)
if state in ["WARNING", "ERROR", "UNAVAILABLE"]:
_LOGGER.debug("Water control has an error")
self._state = False
self._error_message = self._device.last_error_code
else:
_LOGGER.debug("Getting water control state")
activity = self._device.valve_activity
self._error_message = ""
_LOGGER.debug("Water control has activity %s", activity)
if activity == "CLOSED":
self._state = False
elif activity in ["MANUAL_WATERING", "SCHEDULED_WATERING"]:
self._state = True
else:
_LOGGER.debug("Water control has none activity")
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def unique_id(self) -> str:
"""Return a unique ID."""
return self._unique_id
@property
def is_on(self):
"""Return true if it is on."""
return self._state
@property
def available(self):
"""Return True if the device is available."""
return self._device.valve_state != "UNAVAILABLE"
def error(self):
"""Return the error message."""
return self._error_message
@property
def extra_state_attributes(self):
"""Return the state attributes of the water valve."""
return {
ATTR_ACTIVITY: self._device.valve_activity,
ATTR_BATTERY_LEVEL: self._device.battery_level,
ATTR_BATTERY_STATE: self._device.battery_state,
ATTR_RF_LINK_LEVEL: self._device.rf_link_level,
ATTR_RF_LINK_STATE: self._device.rf_link_state,
ATTR_LAST_ERROR: self._error_message,
}
@property
def option_smart_watering_duration(self) -> int:
return self._options.get(
CONF_SMART_WATERING_DURATION, DEFAULT_SMART_WATERING_DURATION
)
def turn_on(self, **kwargs):
"""Start watering."""
duration = self.option_smart_watering_duration * 60
return asyncio.run_coroutine_threadsafe(
self._device.start_seconds_to_override(duration), self.hass.loop
).result()
def turn_off(self, **kwargs):
"""Stop watering."""
return asyncio.run_coroutine_threadsafe(
self._device.stop_until_next_task(), self.hass.loop
).result()
@property
def device_info(self):
return {
"identifiers": {
# Serial numbers are unique identifiers within a specific domain
(DOMAIN, self._device.serial)
},
"name": self._device.name,
"manufacturer": "Gardena",
"model": self._device.model_type,
}
class GardenaPowerSocket(SwitchEntity):
"""Representation of a Gardena Power Socket."""
def __init__(self, ps):
"""Initialize the Gardena Power Socket."""
self._device = ps
self._name = f"{self._device.name}"
self._unique_id = f"{self._device.serial}"
self._state = None
self._error_message = ""
async def async_added_to_hass(self):
"""Subscribe to events."""
self._device.add_callback(self.update_callback)
@property
def should_poll(self) -> bool:
"""No polling needed for a power socket."""
return False
def update_callback(self, device):
"""Call update for Home Assistant when the device is updated."""
self.schedule_update_ha_state(True)
async def async_update(self):
"""Update the states of Gardena devices."""
_LOGGER.debug("Running Gardena update")
# Managing state
state = self._device.state
_LOGGER.debug("Power socket has state %s", state)
if state in ["WARNING", "ERROR", "UNAVAILABLE"]:
_LOGGER.debug("Power socket has an error")
self._state = False
self._error_message = self._device.last_error_code
else:
_LOGGER.debug("Getting Power socket state")
activity = self._device.activity
self._error_message = ""
_LOGGER.debug("Power socket has activity %s", activity)
if activity == "OFF":
self._state = False
elif activity in ["FOREVER_ON", "TIME_LIMITED_ON", "SCHEDULED_ON"]:
self._state = True
else:
_LOGGER.debug("Power socket has none activity")
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def unique_id(self) -> str:
"""Return a unique ID."""
return self._unique_id
@property
def is_on(self):
"""Return true if it is on."""
return self._state
@property
def available(self):
"""Return True if the device is available."""
return self._device.state != "UNAVAILABLE"
def error(self):
"""Return the error message."""
return self._error_message
@property
def extra_state_attributes(self):
"""Return the state attributes of the power switch."""
return {
ATTR_ACTIVITY: self._device.activity,
ATTR_RF_LINK_LEVEL: self._device.rf_link_level,
ATTR_RF_LINK_STATE: self._device.rf_link_state,
ATTR_LAST_ERROR: self._error_message,
}
def turn_on(self, **kwargs):
"""Start watering."""
return asyncio.run_coroutine_threadsafe(
self._device.start_override(), self.hass.loop
).result()
def turn_off(self, **kwargs):
"""Stop watering."""
return asyncio.run_coroutine_threadsafe(
self._device.stop_until_next_task(), self.hass.loop
).result()
@property
def device_info(self):
return {
"identifiers": {
# Serial numbers are unique identifiers within a specific domain
(DOMAIN, self._device.serial)
},
"name": self._device.name,
"manufacturer": "Gardena",
"model": self._device.model_type,
}
class GardenaSmartIrrigationControl(SwitchEntity):
"""Representation of a Gardena Smart Irrigation Control."""
def __init__(self, sic, valve_id, options):
"""Initialize the Gardena Smart Irrigation Control."""
self._device = sic
self._valve_id = valve_id
self._options = options
self._name = f"{self._device.name} - {self._device.valves[self._valve_id]['name']}"
self._unique_id = f"{self._device.serial}-{self._valve_id}"
self._state = None
self._error_message = ""
async def async_added_to_hass(self):
"""Subscribe to events."""
self._device.add_callback(self.update_callback)
@property
def should_poll(self) -> bool:
"""No polling needed for a smart irrigation control."""
return False
def update_callback(self, device):
"""Call update for Home Assistant when the device is updated."""
self.schedule_update_ha_state(True)
async def async_update(self):
"""Update the states of Gardena devices."""
_LOGGER.debug("Running Gardena update")
# Managing state
valve = self._device.valves[self._valve_id]
_LOGGER.debug("Valve has state: %s", valve["state"])
if valve["state"] in ["WARNING", "ERROR", "UNAVAILABLE"]:
_LOGGER.debug("Valve has an error")
self._state = False
self._error_message = valve["last_error_code"]
else:
_LOGGER.debug("Getting Valve state")
activity = valve["activity"]
self._error_message = ""
_LOGGER.debug("Valve has activity: %s", activity)
if activity == "CLOSED":
self._state = False
elif activity in ["MANUAL_WATERING", "SCHEDULED_WATERING"]:
self._state = True
else:
_LOGGER.debug("Valve has unknown activity")
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def unique_id(self) -> str:
"""Return a unique ID."""
return self._unique_id
@property
def is_on(self):
"""Return true if it is on."""
return self._state
@property
def available(self):
"""Return True if the device is available."""
return self._device.valves[self._valve_id]["state"] != "UNAVAILABLE"
def error(self):
"""Return the error message."""
return self._error_message
@property
def extra_state_attributes(self):
"""Return the state attributes of the smart irrigation control."""
return {
ATTR_ACTIVITY: self._device.valves[self._valve_id]["activity"],
ATTR_RF_LINK_LEVEL: self._device.rf_link_level,
ATTR_RF_LINK_STATE: self._device.rf_link_state,
ATTR_LAST_ERROR: self._error_message,
}
@property
def option_smart_irrigation_duration(self) -> int:
return self._options.get(
CONF_SMART_IRRIGATION_DURATION, DEFAULT_SMART_IRRIGATION_DURATION
)
def turn_on(self, **kwargs):
"""Start watering."""
duration = self.option_smart_irrigation_duration * 60
return asyncio.run_coroutine_threadsafe(
self._device.start_seconds_to_override(duration, self._valve_id), self.hass.loop
).result()
def turn_off(self, **kwargs):
"""Stop watering."""
return asyncio.run_coroutine_threadsafe(
self._device.stop_until_next_task(self._valve_id), self.hass.loop
).result()
@property
def device_info(self):
return {
"identifiers": {
# Serial numbers are unique identifiers within a specific domain
(DOMAIN, self._device.serial)
},
"name": self._device.name,
"manufacturer": "Gardena",
"model": self._device.model_type,
}
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
if self._power_service:
command_data = {
"data": {
"id": "turn_off",
"type": "POWER_SOCKET_CONTROL",
"attributes": {
"command": "STOP_UNTIL_NEXT_TASK",
},
}
}
await self.coordinator.client.send_command(self._power_service.id, command_data)
await self.coordinator.async_request_refresh()
@@ -1,23 +1,196 @@
{
"entity": {
"sensor": {
"api_requests_week": {
"name": "API-Anfragen (Woche)"
},
"mower_error": {
"name": "Fehlercode",
"state": {
"uninitialised": "Nicht initialisiert",
"no_message": "Kein Fehler",
"outside_working_area": "Außerhalb des Mähbereichs",
"no_loop_signal": "Kein Schleifensignal",
"no_charging_station_signal": "Kein Ladestationssignal",
"wrong_loop_signal": "Falsches Schleifensignal",
"loop_sensor_problem_front": "Vorderer Schleifensensor",
"loop_sensor_problem_rear": "Hinterer Schleifensensor",
"trapped": "Mäher hängt fest",
"upside_down": "Mäher kopfüber",
"low_battery": "Niedriger Batteriestand",
"empty_battery": "Akku leer",
"no_drive": "Kein Antrieb",
"lifted": "Angehoben",
"stuck_in_charging_station": "Eingeklemmt in Ladestation",
"charging_station_blocked": "Ladestation blockiert",
"collision_sensor_problem_rear": "Störung hinterer Kollisionssensor",
"collision_sensor_problem_front": "Störung vorderer Kollisionssensor",
"wheel_motor_blocked_right": "Radmotor rechts blockiert",
"wheel_motor_blocked_left": "Radmotor links blockiert",
"wheel_drive_problem_right": "Rechtes Antriebsrad blockiert",
"wheel_drive_problem_left": "Linkes Antriebsrad blockiert",
"cutting_system_blocked": "Schneidsystem blockiert",
"invalid_sub_device_combination": "Fehlerhafte Verbindung",
"settings_restored": "Standardeinstellungen",
"charging_system_problem": "Fehler des Ladesystems",
"tilt_sensor_problem": "Kippsensorproblem",
"mower_tilted": "Mäher geneigt",
"wheel_motor_overloaded_right": "Rechter Radmotor überlastet",
"wheel_motor_overloaded_left": "Linker Radmotor überlastet",
"charging_current_too_high": "Ladestrom zu hoch",
"electronic_problem": "Elektronisches Problem",
"cutting_height_blocked": "Schnitthöhenregler blockiert",
"cutting_height_problem": "Problem mit Schnitthöhe",
"temporary_problem": "Vorübergehendes Problem",
"guide_1_not_found": "Leitkabel nicht gefunden",
"guide_2_not_found": "Leitkabel 2 nicht gefunden",
"guide_3_not_found": "Leitkabel 3 nicht gefunden",
"gps_tracker_module_error": "GPS Modul Fehler",
"weak_gps_signal": "Ungenaue Positionsdaten",
"guide_calibration_failed": "Kalibration des Leitkabels fehlgeschlagen",
"temporary_battery_problem": "Kurzzeitiges Batterieproblem",
"battery_problem": "Akku unzulässig",
"alarm_mower_switched_off": "Alarm! Mäher ausgeschalten",
"alarm_mower_stopped": "Alarm! Mäher gestoppt",
"alarm_mower_lifted": "Alarm! Mäher angehoben",
"alarm_mower_tilted": "Alarm! Mäher gekippt",
"com_board_not_available": "Kommunikationsboard nicht erreichbar",
"slipped": "Räder rutschen ab",
"invalid_battery_combination": "Ungültige Batteriekombination",
"safety_function_faulty": "Sicherheitsfunktion",
"invalid_system_conf": "Fehlerhafte Systemkonfiguration",
"lift_sensor_defect": "Hebesensor Fehler",
"mobile_loop_defect": "ZoneProtect Akku leer",
"left_loop_sensor": "Linker Schleifensensor defekt",
"right_loop_sensor": "Rechter Schleifensensor defekt",
"wrong_pin": "Falscher PIN-Code",
"temporary_lift": "Mäher angehoben",
"cutting_drive": "Schneideantrieb defekt",
"steep_slope": "Steigung zu steil",
"stop_button_fail": "Problem mit der Stop-Taste",
"angle_cutting_means_off": "Schneidwerk am Hang abgeschaltet",
"slave_mcu_lost": "Verbindungsproblem mit MCU",
"cutting_overload": "Schneidwerk überlastet",
"cutting_height_range": "Schnitthöhenbereich Fehler",
"cutting_height_drift": "Schnitthöhe Drift",
"cutting_height_limited": "Schnitthöhe begrenzt",
"cutting_height_drive": "Schnitthöhenantrieb Problem",
"cutting_height_current": "Schnitthöhe Strom zu hoch",
"cutting_height_direction": "Schnitthöhe falsche Richtung",
"mower_to_cs_com": "Kommunikation mit Ladestation fehlgeschlagen",
"ultrasonic_error": "Ultraschallsensor Fehler",
"high_low_bat_temp_a": "Akkutemperatur außerhalb der Grenzwerte",
"high_low_bat_temp_b": "Batterie B Temperatur außerhalb des Bereichs",
"too_low_voltage_bat_a": "Batterie A Spannung zu niedrig",
"too_low_voltage_bat_b": "Batterie B Spannung zu niedrig",
"alarm_motion": "Alarm: Bewegung erkannt",
"alarm_geofence": "Alarm: Außerhalb des Geofence",
"rr_wheel_blocked": "Hinteres rechtes Rad blockiert",
"rl_wheel_blocked": "Hinteres linkes Rad blockiert",
"rr_wheel_drive": "Hinterer rechter Radantrieb Problem",
"rl_wheel_drive": "Hinterer linker Radantrieb Problem",
"rear_right_wheel_overloaded": "Hinteres rechtes Rad überlastet",
"rear_left_wheel_overloaded": "Hinteres linkes Rad überlastet",
"angular_sensor_defect": "Winkelsensor defekt",
"no_power_in_cs": "Ladestation nicht mit Strom versorgt",
"switch_cord_sensor_defect": "Schaltkabelsensor defekt",
"map_not_valid": "Karte ungültig",
"no_position": "Position zu ungenau",
"no_rs_communication": "Referenzstation Kommunikation verloren",
"folding_sensor_activated": "Klappsensor aktiviert",
"ultrasonic_sensor_1_defect": "Ultraschallsensor 1 defekt",
"ultrasonic_sensor_2_defect": "Ultraschallsensor 2 defekt",
"ultrasonic_sensor_3_defect": "Ultraschallsensor 3 defekt",
"ultrasonic_sensor_4_defect": "Ultraschallsensor 4 defekt",
"cutting_drive_motor_1_defect": "Schneidemotor 1 defekt",
"cutting_drive_motor_2_defect": "Schneidemotor 2 defekt",
"cutting_drive_motor_3_defect": "Schneidemotor 3 defekt",
"collision_sensor_defect": "Kollisionssensor Fehler",
"docking_sensor_defect": "Der Andocksensor ist defekt",
"folding_cutting_deck_sensor_defect": "Klapp-Mähdeck-Sensor defekt",
"loop_sensor_defect": "Schleifensensor Fehler",
"collision_sensor_error": "Kollision",
"no_confirmed_position": "Keine bestätigte Position",
"major_cutting_disk_imbalance": "Starke Unwucht der Schneidscheibe",
"complex_working_area": "Arbeitsbereich zu komplex",
"invalid_sw_configuration": "Ungültige Software-Konfiguration",
"radar_error": "Radar Fehler",
"work_area_tampered": "Arbeitsbereich manipuliert",
"destination_not_reachable": "Zielort nicht erreichbar",
"wait_stop_pressed": "Sicherheitsstopp",
"wait_for_safety_pin": "Mäher gesperrt",
"destination_not_reachable_warning": "Ziel blockiert",
"battery_near_end_of_life": "Akku muss ausgetauscht werden",
"edgemotor_blocked": "Kantenschneidscheibe blockiert",
"no_correction_data": "Positionsdaten unzureichend",
"invalid_correction_data": "Fehlerhafte Korrekturdaten",
"wait_updating": "Firmware wird aktualisiert",
"wait_power_up": "Wird hochgefahren",
"off_disabled": "Über Hauptschalter deaktiviert",
"off_hatch_open": "Klappe offen",
"off_hatch_closed": "Klappe geschlossen",
"parked_daily_limit_reached": "Tageslimit erreicht",
"vision_system_malfunction": "Objekterkennung fehlgeschlagen",
"poor_vision_system_performance": "Kameragehäuse verschmutzt",
"vision_processing_failed": "Bildverarbeitung fehlgeschlagen",
"loop_sensor_problem_left": "Problem Schleifensensor, links",
"loop_sensor_problem_right": "Problem Schleifensensor, rechts",
"wrong_pin_code": "Falscher PIN-Code",
"lost_wheel_brush": "Radbürste verloren",
"accessory_power_anomaly": "Zubehör Stromversorgung Anomalie",
"loop_wire_broken": "Begrenzungsdraht gebrochen",
"battery_fet_error": "Akku beschädigt",
"imbalanced_cutting_disc": "Unwucht der Schneidscheibe",
"cutting_motor_problem": "Schnittmotor gestört",
"limited_cutting_height_range": "Schnitthöhe begrenzt",
"cutting_motor_drive_defect": "Schneidsystem blockiert",
"memory_circuit_problem": "Elektronisches Problem",
"stop_button_problem": "Stop-Taste gestört",
"difficult_finding_home": "Problem LS finden",
"guide_calibration_accomplished": "Kalibration des Leitkabels beendet",
"too_many_batteries": "Zu viele Akkus",
"alarm_mower_in_motion": "Alarm! Mäher wurde bewegt",
"alarm_outside_geofence": "Alarm! Mäher außerhalb Begrenzungszauns",
"connection_changed": "Verbindung geändert",
"connection_not_changed": "Verbindung nicht geändert",
"unknown": "Unbekannter Fehler"
}
}
}
},
"config": {
"abort": {
"already_configured": "Der Standort ist bereits konfiguriert"
"already_configured": "Der Standort ist bereits konfiguriert",
"reconfigure_successful": "API-Zugangsdaten aktualisiert. Die Integration wurde neu geladen."
},
"error": {
"cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut.",
"invalid_auth": "Ungültige Authentifizierung.",
"too_many_requests": "Zu viele Anfragen, versuchen Sie es später erneut.",
"cannot_connect": "Verbindung fehlgeschlagen, bitte erneut versuchen.",
"invalid_auth": "Ungültige Anmeldedaten. Bitte überprüfen Sie Ihre Client-ID und Ihr Client-Secret.",
"insufficient_permissions": "Unzureichende Berechtigungen. Bitte überprüfen Sie Ihren API-Schlüssel und die Berechtigungen.",
"no_locations": "Keine Standorte gefunden. Bitte richten Sie zuerst Ihr Gardena Smart Gateway in der offiziellen Gardena-App ein.",
"server_error": "Serverfehler aufgetreten. Bitte später erneut versuchen.",
"auth_error": "Authentifizierungsfehler aufgetreten. Bitte überprüfen Sie Ihre Anmeldedaten.",
"unknown_error": "Ein unerwarteter Fehler ist aufgetreten. Bitte erneut versuchen.",
"too_many_requests": "Zu viele Anfragen, bitte später erneut versuchen.",
"api_error": "Verbindung zur Gardena-API fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten.",
"unknown": "Unerwarteter Fehler."
},
"step": {
"user": {
"description": "Bitte geben Sie Ihre Anmeldeinformationen ein.",
"description": "Bitte geben Sie Ihre Gardena Smart System API-Anmeldedaten ein. Sie erhalten diese über das Gardena Developer Portal.",
"data": {
"email": "E-Mail",
"password": "Passwort",
"client_id": "Application Key / Client ID"
"client_id": "Application Key / Client ID",
"client_secret": "Application Secret / Client Secret"
},
"title": "Gardena Smart System"
},
"reconfigure": {
"description": "Neue Zugangsdaten der Husqvarna-Developer-App eintragen. Den kurzlebigen Access Token (Bearer) holt Home Assistant automatisch — hier kein manuelles Token nötig. Bestehende Entities bleiben erhalten.",
"data": {
"client_id": "Application Key / Client ID",
"client_secret": "Application Secret / Client Secret"
},
"title": "Gardena API-Zugangsdaten ändern"
}
}
},
@@ -27,10 +200,48 @@
"data": {
"mower_duration": "Mäherdauer (Minuten)",
"smart_irrigation_control_duration": "Smart Irrigation Control Dauer (Minuten)",
"smart_watering_duration": "Smarte Bewässerungs Dauer (Minuten)"
"smart_watering_duration": "Smarte Bewässerungsdauer (Minuten)"
},
"title": "Gardena Smart System - Optionen"
}
}
},
"services": {
"start_override": {
"name": "Überschreibung starten",
"description": "Starten Sie den Mäher sofort für eine bestimmte Dauer und überschreiben Sie den Zeitplan.",
"fields": {
"duration": {
"name": "Dauer",
"description": "Die Dauer in Sekunden."
}
}
},
"start_automatic": {
"name": "Automatisch starten",
"description": "Starten Sie den Mäher mit dem automatischen Zeitplan."
},
"park_until_next_task": {
"name": "Bis zur nächsten Aufgabe parken",
"description": "Parken Sie den Mäher bis zur nächsten geplanten Aufgabe."
},
"park_until_further_notice": {
"name": "Bis auf Weiteres parken",
"description": "Parken Sie den Mäher bis auf Weiteres."
},
"reconnect_websocket": {
"name": "WebSocket neu verbinden",
"description": "WebSocket-Verbindung für die Gardena Smart System-Integration erzwingen."
},
"websocket_diagnostics": {
"name": "WebSocket-Diagnose",
"description": "WebSocket-Verbindungsdiagnose und Statusinformationen abrufen.",
"fields": {
"detailed": {
"name": "Detailliert",
"description": "Detaillierte Verbindungsinformationen einbeziehen."
}
}
}
}
}
@@ -1,23 +1,196 @@
{
"entity": {
"sensor": {
"api_requests_week": {
"name": "API Requests (Week)"
},
"mower_error": {
"name": "Error code",
"state": {
"uninitialised": "Uninitialised",
"no_message": "No error",
"outside_working_area": "Outside working area",
"no_loop_signal": "No loop signal",
"no_charging_station_signal": "No charging station signal",
"wrong_loop_signal": "Wrong loop signal",
"loop_sensor_problem_front": "Front loop sensor defect",
"loop_sensor_problem_rear": "Rear loop sensor defect",
"trapped": "Trapped",
"upside_down": "Upside down",
"low_battery": "Low battery",
"empty_battery": "Empty battery",
"no_drive": "No drive",
"lifted": "Lifted",
"stuck_in_charging_station": "Stuck in charging station",
"charging_station_blocked": "Charging station blocked",
"collision_sensor_problem_rear": "Rear collision sensor defect",
"collision_sensor_problem_front": "Front collision sensor defect",
"wheel_motor_blocked_right": "Right wheel motor blocked",
"wheel_motor_blocked_left": "Left wheel motor blocked",
"wheel_drive_problem_right": "Right wheel drive problem",
"wheel_drive_problem_left": "Left wheel drive problem",
"cutting_system_blocked": "Cutting system blocked",
"invalid_sub_device_combination": "Invalid sub-device combination",
"settings_restored": "Settings restored to default",
"charging_system_problem": "Charging system problem",
"tilt_sensor_problem": "Tilt sensor problem",
"mower_tilted": "Mower tilted",
"wheel_motor_overloaded_right": "Right wheel motor overloaded",
"wheel_motor_overloaded_left": "Left wheel motor overloaded",
"charging_current_too_high": "Charging current too high",
"electronic_problem": "Electronic problem",
"cutting_height_blocked": "Cutting height blocked",
"cutting_height_problem": "Cutting height problem",
"temporary_problem": "Temporary problem",
"guide_1_not_found": "Guide 1 not found",
"guide_2_not_found": "Guide 2 not found",
"guide_3_not_found": "Guide 3 not found",
"gps_tracker_module_error": "GPS tracker module error",
"weak_gps_signal": "Weak GPS signal",
"guide_calibration_failed": "Guide calibration failed",
"temporary_battery_problem": "Temporary battery problem",
"battery_problem": "Battery problem",
"alarm_mower_switched_off": "Alarm: mower switched off",
"alarm_mower_stopped": "Alarm: mower stopped",
"alarm_mower_lifted": "Alarm: mower lifted",
"alarm_mower_tilted": "Alarm: mower tilted",
"com_board_not_available": "Communication board not available",
"slipped": "Slipped",
"invalid_battery_combination": "Invalid battery combination",
"safety_function_faulty": "Safety function faulty",
"invalid_system_conf": "Invalid system configuration",
"lift_sensor_defect": "Lift sensor defect",
"mobile_loop_defect": "Mobile loop defect",
"left_loop_sensor": "Left loop sensor defect",
"right_loop_sensor": "Right loop sensor defect",
"wrong_pin": "Wrong PIN",
"temporary_lift": "Temporarily lifted",
"cutting_drive": "Cutting drive defect",
"steep_slope": "Too steep slope",
"stop_button_fail": "Stop button failure",
"angle_cutting_means_off": "Cutting means turned off on slope",
"slave_mcu_lost": "Connection lost with secondary MCU",
"cutting_overload": "Cutting overload",
"cutting_height_range": "Cutting height range error",
"cutting_height_drift": "Cutting height drift",
"cutting_height_limited": "Cutting height limited",
"cutting_height_drive": "Cutting height drive problem",
"cutting_height_current": "Cutting height current too high",
"cutting_height_direction": "Cutting height wrong direction",
"mower_to_cs_com": "Communication with charging station failed",
"ultrasonic_error": "Ultrasonic sensor error",
"high_low_bat_temp_a": "Battery A temperature out of range",
"high_low_bat_temp_b": "Battery B temperature out of range",
"too_low_voltage_bat_a": "Battery A voltage too low",
"too_low_voltage_bat_b": "Battery B voltage too low",
"alarm_motion": "Alarm: motion detected",
"alarm_geofence": "Alarm: outside geofence",
"rr_wheel_blocked": "Rear right wheel blocked",
"rl_wheel_blocked": "Rear left wheel blocked",
"rr_wheel_drive": "Rear right wheel drive problem",
"rl_wheel_drive": "Rear left wheel drive problem",
"rear_right_wheel_overloaded": "Rear right wheel overloaded",
"rear_left_wheel_overloaded": "Rear left wheel overloaded",
"angular_sensor_defect": "Angular sensor defect",
"no_power_in_cs": "No power in charging station",
"switch_cord_sensor_defect": "Switch cord sensor defect",
"map_not_valid": "Map not valid",
"no_position": "No position available",
"no_rs_communication": "Reference station communication lost",
"folding_sensor_activated": "Folding sensor activated",
"ultrasonic_sensor_1_defect": "Ultrasonic sensor 1 defect",
"ultrasonic_sensor_2_defect": "Ultrasonic sensor 2 defect",
"ultrasonic_sensor_3_defect": "Ultrasonic sensor 3 defect",
"ultrasonic_sensor_4_defect": "Ultrasonic sensor 4 defect",
"cutting_drive_motor_1_defect": "Cutting drive motor 1 defect",
"cutting_drive_motor_2_defect": "Cutting drive motor 2 defect",
"cutting_drive_motor_3_defect": "Cutting drive motor 3 defect",
"collision_sensor_defect": "Collision sensor defect",
"docking_sensor_defect": "Docking sensor defect",
"folding_cutting_deck_sensor_defect": "Folding cutting deck sensor defect",
"loop_sensor_defect": "Loop sensor defect",
"collision_sensor_error": "Collision sensor error",
"no_confirmed_position": "No confirmed position",
"major_cutting_disk_imbalance": "Major cutting disk imbalance",
"complex_working_area": "Working area too complex",
"invalid_sw_configuration": "Invalid software configuration",
"radar_error": "Radar error",
"work_area_tampered": "Work area tampered",
"destination_not_reachable": "Destination not reachable",
"wait_stop_pressed": "Stop button pressed",
"wait_for_safety_pin": "Waiting for safety PIN",
"destination_not_reachable_warning": "Destination not reachable (warning)",
"battery_near_end_of_life": "Battery near end of life",
"edgemotor_blocked": "Edge motor blocked",
"no_correction_data": "No correction data available",
"invalid_correction_data": "Invalid correction data",
"wait_updating": "Updating firmware",
"wait_power_up": "Powering up",
"off_disabled": "Disabled on main switch",
"off_hatch_open": "Hatch open",
"off_hatch_closed": "Hatch closed",
"parked_daily_limit_reached": "Daily limit reached",
"vision_system_malfunction": "Vision system malfunction",
"poor_vision_system_performance": "Poor vision system performance",
"vision_processing_failed": "Vision processing failed",
"loop_sensor_problem_left": "Left loop sensor problem",
"loop_sensor_problem_right": "Right loop sensor problem",
"wrong_pin_code": "Wrong PIN code",
"lost_wheel_brush": "Wheel brush lost",
"accessory_power_anomaly": "Accessory power anomaly",
"loop_wire_broken": "Loop wire broken",
"battery_fet_error": "Battery FET error",
"imbalanced_cutting_disc": "Imbalanced cutting disc",
"cutting_motor_problem": "Cutting motor problem",
"limited_cutting_height_range": "Limited cutting height range",
"cutting_motor_drive_defect": "Cutting motor drive defect",
"memory_circuit_problem": "Memory circuit problem",
"stop_button_problem": "Stop button problem",
"difficult_finding_home": "Difficult finding home",
"guide_calibration_accomplished": "Guide calibration accomplished",
"too_many_batteries": "Too many batteries",
"alarm_mower_in_motion": "Alarm: mower in motion",
"alarm_outside_geofence": "Alarm: outside geofence",
"connection_changed": "Connection changed",
"connection_not_changed": "Connection not changed",
"unknown": "Unknown error"
}
}
}
},
"config": {
"abort": {
"already_configured": "Location is already configured"
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reconfigure_successful": "API credentials updated. The integration was reloaded."
},
"error": {
"cannot_connect": "Failed to connect, please try again.",
"invalid_auth": "Invalid authentication.",
"invalid_auth": "Invalid authentication credentials. Please check your Client ID and Client Secret.",
"insufficient_permissions": "Insufficient permissions. Please check your API key and permissions.",
"no_locations": "No locations found. Please set up your Gardena smart Gateway in the official Gardena app first, then retry.",
"server_error": "Server error occurred. Please try again later.",
"auth_error": "Authentication error occurred. Please check your credentials.",
"unknown_error": "An unexpected error occurred. Please try again.",
"too_many_requests": "Too many requests, retry later.",
"api_error": "Failed to connect to the Gardena API. Please verify your credentials and try again.",
"unknown": "Unexpected error."
},
"step": {
"user": {
"description": "Please enter your credentials.",
"description": "Please enter your Gardena Smart System API credentials. You can obtain these from the Gardena Developer Portal.",
"data": {
"email": "E-mail",
"password": "Password",
"client_id": "Application Key / Client ID"
"client_id": "Application Key / Client ID",
"client_secret": "Application secret / Client secret"
},
"title": "Gardena Smart System"
},
"reconfigure": {
"description": "Update your Husqvarna developer application credentials. Home Assistant obtains short-lived access tokens automatically — you do not enter a Bearer token manually. Existing entities are kept.",
"data": {
"client_id": "Application Key / Client ID",
"client_secret": "Application secret / Client secret"
},
"title": "Update Gardena API credentials"
}
}
},
@@ -43,6 +216,32 @@
"description": "The duration in seconds."
}
}
},
"start_automatic": {
"name": "Start automatic",
"description": "Start the mower with automatic schedule."
},
"park_until_next_task": {
"name": "Park until next task",
"description": "Park the mower until the next scheduled task."
},
"park_until_further_notice": {
"name": "Park until further notice",
"description": "Park the mower until further notice."
},
"reconnect_websocket": {
"name": "Reconnect WebSocket",
"description": "Force WebSocket reconnection for the Gardena Smart System integration."
},
"websocket_diagnostics": {
"name": "WebSocket Diagnostics",
"description": "Get WebSocket connection diagnostics and status information.",
"fields": {
"detailed": {
"name": "Detailed",
"description": "Include detailed connection information."
}
}
}
}
}
@@ -1,21 +1,60 @@
{
"entity": {
"sensor": {
"api_requests_week": {
"name": "Requêtes API (Semaine)"
},
"mower_error": {
"name": "Code d'erreur",
"state": {
"no_message": "Aucune erreur",
"outside_working_area": "Hors de la zone de travail",
"no_loop_signal": "Pas de signal de boucle",
"wrong_loop_signal": "Mauvais signal de boucle",
"loop_sensor_problem_front": "Capteur de boucle avant défectueux",
"loop_sensor_problem_rear": "Capteur de boucle arrière défectueux",
"trapped": "Bloquée",
"upside_down": "Retournée",
"low_battery": "Batterie faible",
"empty_battery": "Batterie vide",
"no_drive": "Pas de traction",
"lifted": "Soulevée",
"stuck_in_charging_station": "Bloquée dans la station de charge",
"charging_station_blocked": "Station de charge bloquée",
"collision_sensor_problem_rear": "Capteur de collision arrière défectueux",
"collision_sensor_problem_front": "Capteur de collision avant défectueux",
"wheel_motor_blocked_right": "Moteur de roue droite bloqué",
"wheel_motor_blocked_left": "Moteur de roue gauche bloqué",
"cutting_system_blocked": "Système de coupe bloqué",
"steep_slope": "Pente trop raide",
"parked_daily_limit_reached": "Limite quotidienne atteinte",
"unknown": "Erreur inconnue"
}
}
}
},
"config": {
"abort": {
"already_configured": "La localisation est déjà configurée"
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"cannot_connect": "Impossible de se connecter, veuillez ré-essayer.",
"invalid_auth": "Authentification invalide.",
"too_many_requests": "Trop de requêtes, veuillez ré-essayer plus tard.",
"unknown": "Erreur innatendue."
"invalid_auth": "Identifiants d'authentification invalides. Veuillez vérifier votre Client ID et Client Secret.",
"insufficient_permissions": "Permissions insuffisantes. Veuillez vérifier votre clé API et vos permissions.",
"no_locations": "Aucun emplacement trouvé. Veuillez d'abord configurer votre passerelle Gardena smart dans l'application officielle Gardena, puis réessayez.",
"server_error": "Erreur serveur survenue. Veuillez ré-essayer plus tard.",
"auth_error": "Erreur d'authentification survenue. Veuillez vérifier vos identifiants.",
"unknown_error": "Une erreur inattendue s'est produite. Veuillez ré-essayer.",
"too_many_requests": "Trop de requêtes, ré-essayez plus tard.",
"api_error": "Impossible de se connecter à l'API Gardena. Veuillez vérifier vos identifiants et réessayer.",
"unknown": "Erreur inattendue."
},
"step": {
"user": {
"description": "Veuillez renseigner vos identifiants.",
"description": "Veuillez saisir vos identifiants API Gardena Smart System. Vous pouvez les obtenir depuis le Portail Développeur Gardena.",
"data": {
"email": "E-mail",
"password": "Mot de passe",
"client_id": "Application Key / Client ID"
"client_id": "Clé d'application / Client ID",
"client_secret": "Secret d'application / Client secret"
},
"title": "Gardena Smart System"
}
@@ -32,5 +71,43 @@
"title": "Gardena Smart System - Options"
}
}
},
"services": {
"start_override": {
"name": "Démarrer la substitution",
"description": "Démarrer la tondeuse immédiatement pour une durée spécifique et remplacer le planning.",
"fields": {
"duration": {
"name": "Durée",
"description": "La durée en secondes."
}
}
},
"start_automatic": {
"name": "Démarrer automatique",
"description": "Démarrer la tondeuse avec le planning automatique."
},
"park_until_next_task": {
"name": "Ranger jusqu'à la prochaine tâche",
"description": "Ranger la tondeuse jusqu'à la prochaine tâche programmée."
},
"park_until_further_notice": {
"name": "Ranger jusqu'à nouvel ordre",
"description": "Ranger la tondeuse jusqu'à nouvel ordre."
},
"reconnect_websocket": {
"name": "Reconnecter WebSocket",
"description": "Forcer la reconnexion WebSocket pour l'intégration Gardena Smart System."
},
"websocket_diagnostics": {
"name": "Diagnostics WebSocket",
"description": "Obtenir les diagnostics et le statut de la connexion WebSocket.",
"fields": {
"detailed": {
"name": "Détaillé",
"description": "Inclure les informations détaillées de connexion."
}
}
}
}
}
@@ -32,5 +32,34 @@
"title": "Gardena Smart System - Alternativer"
}
}
},
"services": {
"start_override": {
"name": "Start overstyring",
"description": "Start klipperen umiddelbart for en bestemt varighet og overstyr timeplanen.",
"icon": "mdi:mower-on",
"fields": {
"duration": {
"name": "Varighet",
"description": "Varigheten i sekunder."
}
}
},
"start_automatic": {
"name": "Start automatisk",
"description": "Start klipperen med den automatiske timeplanen."
},
"park_until_next_task": {
"name": "Parker til neste oppgave",
"description": "Parker klipperen til neste planlagte oppgave."
},
"park_until_further_notice": {
"name": "Parker til videre",
"description": "Parker klipperen til videre."
},
"reconnect_websocket": {
"name": "Koble til WebSocket på nytt",
"description": "Tving WebSocket-tilkobling for Gardena Smart System-integrasjonen."
}
}
}
@@ -32,5 +32,34 @@
"title": "Gardena Smart System - Opties"
}
}
},
"services": {
"start_override": {
"name": "Start overschrijving",
"description": "Start de maaier onmiddellijk voor een specifieke duur en overschrijf het schema.",
"icon": "mdi:mower-on",
"fields": {
"duration": {
"name": "Duur",
"description": "De duur in seconden."
}
}
},
"start_automatic": {
"name": "Start automatisch",
"description": "Start de maaier met het automatische schema."
},
"park_until_next_task": {
"name": "Parkeren tot volgende taak",
"description": "Parkeer de maaier tot de volgende geplande taak."
},
"park_until_further_notice": {
"name": "Parkeren tot nader order",
"description": "Parkeer de maaier tot nader order."
},
"reconnect_websocket": {
"name": "WebSocket opnieuw verbinden",
"description": "WebSocket-verbinding forceren voor de Gardena Smart System-integratie."
}
}
}
@@ -1,29 +0,0 @@
{
"device_automation": {
"action_type": {
"clean": "Lad {entity_name} klippe",
"dock": "Lad {entity_name} vende tilbage til oplader"
},
"condition_type": {
"is_cleaning": "{entity_name} klipper",
"is_docked": "{entity_name} er i oplader"
},
"trigger_type": {
"cleaning": "{entity_name} begyndte at klippe",
"docked": "{entity_name} er i dock"
}
},
"state": {
"_": {
"cleaning": "Klipper",
"docked": "I oplader",
"error": "Fejl",
"idle": "[%key:common::state::idle%]",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
"paused": "Sat p\u00e5 pause",
"returning": "Vender tilbage til oplader"
}
},
"title": "Pl\u00e6neklipper"
}
@@ -1,29 +0,0 @@
{
"title": "Mäher",
"device_automation": {
"condition_type": {
"is_docked": "{entity_name} ist angedockt",
"is_cleaning": "{entity_name} mäht"
},
"trigger_type": {
"cleaning": "{entity_name} hat begonnen zu mähen",
"docked": "{entity_name} hat angedockt"
},
"action_type": {
"clean": "Lasse {entity_name} mähen",
"dock": "Lasse {entity_name} zum Dock zurückkehren"
}
},
"state": {
"_": {
"cleaning": "Mäht",
"docked": "Angedockt",
"error": "Fehler",
"idle": "Inaktiv",
"off": "Aus",
"on": "An",
"paused": "Pausiert",
"returning": "Zurück zum Dock"
}
}
}
@@ -1,29 +0,0 @@
{
"title": "Mower",
"device_automation": {
"condition_type": {
"is_docked": "{entity_name} is docked",
"is_cleaning": "{entity_name} is cutting"
},
"trigger_type": {
"cleaning": "{entity_name} started cutting",
"docked": "{entity_name} docked"
},
"action_type": {
"clean": "Let {entity_name} cut",
"dock": "Let {entity_name} return to the dock"
}
},
"state": {
"_": {
"cleaning": "Cutting",
"docked": "Docked",
"error": "Error",
"idle": "[%key:common::state::idle%]",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
"paused": "[%key:common::state::paused%]",
"returning": "Returning to dock"
}
}
}
@@ -1,29 +0,0 @@
{
"title": "Maaier",
"device_automation": {
"condition_type": {
"is_docked": "{entity_name} is geparkeerd",
"is_cleaning": "{entity_name} is aan het maaien"
},
"trigger_type": {
"cleaning": "{entity_name} is gestart met maaien",
"docked": "{entity_name} parkeert"
},
"action_type": {
"clean": "Laat {entity_name} maaien",
"dock": "Laat {entity_name} parkeren"
}
},
"state": {
"_": {
"cleaning": "Maaien",
"docked": "Geparkeerd",
"error": "Fout",
"idle": "[%key:common::state::idle%]",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
"paused": "[%key:common::state::paused%]",
"returning": "Terug naar laadstation aan het gaan"
}
}
}
+1 -1
View File
@@ -393,7 +393,7 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow):
def __init__(self, config_entry):
"""Initialize localtuya options flow."""
self.config_entry = config_entry
self._config_entry = config_entry
# self.dps_strings = config_entry.data.get(CONF_DPS_STRINGS, gen_dps_strings())
# self.entities = config_entry.data[CONF_ENTITIES]
self.selected_device = None
+25 -19
View File
@@ -1,17 +1,21 @@
"""Omnik Inverter platform configuration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import CONFIGFLOW_VERSION, DOMAIN, LOGGER
from .const import CONFIGFLOW_VERSION, LOGGER
from .coordinator import OmnikInverterDataUpdateCoordinator
type OmnikInverterConfigEntry = ConfigEntry[OmnikInverterDataUpdateCoordinator]
PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""
Set up OmnikInverter as config entry.
async def async_setup_entry(
hass: HomeAssistant, entry: OmnikInverterConfigEntry
) -> bool:
"""Set up OmnikInverter as config entry.
Args:
hass: The HomeAssistant instance.
@@ -19,21 +23,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Returns:
Return true after setting up.
"""
hass.data.setdefault(DOMAIN, {})
"""
coordinator = OmnikInverterDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""
Unload a config entry.
async def async_unload_entry(
hass: HomeAssistant, entry: OmnikInverterConfigEntry
) -> bool:
"""Unload a config entry.
Args:
hass: The HomeAssistant instance.
@@ -41,29 +45,31 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Returns:
Return true if unload was successful, false otherwise.
"""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: ConfigEntry # pylint: disable=unused-argument
):
"""
Migrate an old entry.
_hass: HomeAssistant,
config_entry: ConfigEntry,
) -> bool:
"""Migrate an old entry.
Args:
hass: The HomeAssistant instance.
_hass: The HomeAssistant instance.
config_entry: The ConfigEntry containing the user input.
Returns:
Return false, not possible.
"""
if config_entry.version <= 2:
LOGGER.warning(
"Impossible to migrate config version from version %s to version %s.\r\nPlease consider to delete and re-add the integration.", # noqa: E501
"Impossible to migrate config version from version %s to version %s."
"\r\nPlease consider to delete and re-add the integration.",
config_entry.version,
CONFIGFLOW_VERSION,
)
return False
return False
@@ -1,23 +1,27 @@
"""Support for Omnik Inverter binary sensors."""
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify
from .const import DOMAIN, SERVICE_DEVICE
from .coordinator import OmnikInverterDataUpdateCoordinator
from .const import SERVICE_DEVICE
from .models import OmnikInverterEntity
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OmnikInverterConfigEntry
from .coordinator import OmnikInverterDataUpdateCoordinator
BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = (
BinarySensorEntityDescription(
key="online",
@@ -29,19 +33,19 @@ BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
_hass: HomeAssistant,
entry: OmnikInverterConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""
Load all Omnik Inverter binary sensors.
"""Load all Omnik Inverter binary sensors.
Args:
hass: The HomeAssistant instance.
_hass: The HomeAssistant instance.
entry: The ConfigEntry containing the user input.
async_add_entities: The callback to provide the created entities to.
"""
coordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
async_add_entities(
OmnikInverterBinarySensor(
@@ -58,7 +62,7 @@ class OmnikInverterBinarySensor(OmnikInverterEntity, BinarySensorEntity):
"""Defines an Omnik Inverter Binary Sensor."""
entity_description: BinarySensorEntityDescription
_options: dict[Any]
_options: dict[str, Any]
def __init__( # pylint: disable=too-many-arguments
self,
@@ -66,28 +70,28 @@ class OmnikInverterBinarySensor(OmnikInverterEntity, BinarySensorEntity):
name: str,
description: BinarySensorEntityDescription,
service: str,
):
"""
Initialise the entity.
) -> None:
"""Initialise the entity.
Args:
coordinator: The data coordinator updating the models.
name: The identifier for this entity.
description: The entity description for the binary sensor.
service: The service to create the binary sensor for.
"""
super().__init__(coordinator=coordinator, name=name, service=service)
self.entity_description = description
self.entity_id = f"{BINARY_SENSOR_DOMAIN}.{self._name}_{self.entity_description.key}" # noqa: E501
self._attr_unique_id = f"{self._name}_{service}_{self.entity_description.key}"
self._attr_unique_id = slugify(
f"{self._name}_{service}_{self.entity_description.key}"
)
self._attr_name = self.entity_description.name
@property
def is_on(self) -> bool:
"""
Return True if the service is on.
"""Return True if the service is on.
Returns:
True if the device is on.
+56 -53
View File
@@ -1,11 +1,17 @@
"""Config flow for Omnik Inverter integration."""
from __future__ import annotations
import socket
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
@@ -14,12 +20,12 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from omnikinverter import OmnikInverter, OmnikInverterError
from .const import (
@@ -34,9 +40,12 @@ from .const import (
)
class InvalidHostError(Exception):
"""Exception raised when the host is invalid."""
async def validate_input(user_input: dict[str, Any]) -> str | None:
"""
Validate the given user input.
"""Validate the given user input.
Args:
user_input: The user input.
@@ -45,47 +54,48 @@ async def validate_input(user_input: dict[str, Any]) -> str | None:
The host name of the inverter
Raises:
Exception: If the host could not be validated.
InvalidHostError: If the host could not be validated.
"""
host = user_input[CONF_HOST]
try:
return socket.gethostbyname(host)
except socket.gaierror as exc:
raise Exception( # pylint: disable=broad-exception-raised
"invalid_host"
) from exc
msg = "invalid_host"
raise InvalidHostError(msg) from exc
class OmnikInverterFlowHandler(ConfigFlow, domain=DOMAIN):
class OmnikInverterFlowHandler(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
"""Config flow for Omnik Inverter."""
VERSION = CONFIGFLOW_VERSION
def __init__(self):
def __init__(self) -> None:
"""Initialize with empty source type."""
self.source_type = None
self.source_type: str | None = None
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
_config_entry: ConfigEntry,
) -> OmnikInverterOptionsFlowHandler:
"""
Get the options flow for this handler.
"""Get the options flow for this handler.
Args:
config_entry: The ConfigEntry instance.
_config_entry: The ConfigEntry instance.
Returns:
The created config flow.
"""
return OmnikInverterOptionsFlowHandler(config_entry)
return OmnikInverterOptionsFlowHandler()
async def async_step_user(
self, user_input=None, errors: dict[str, str] | None = None
) -> FlowResult:
"""
Handle a flow initialized by the user.
self,
user_input: dict[str, Any] | None = None,
errors: dict[str, str] | None = None,
) -> ConfigFlowResult:
"""Handle a flow initialized by the user.
Args:
user_input: The input received from the user or none.
@@ -93,6 +103,7 @@ class OmnikInverterFlowHandler(ConfigFlow, domain=DOMAIN):
Returns:
The created config entry or a form to re-enter the user input with errors.
"""
errors = {}
if user_input is not None:
@@ -113,15 +124,15 @@ class OmnikInverterFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_setup(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""
Handle setup flow for the JS and JSON route.
) -> ConfigFlowResult:
"""Handle setup flow for the JS and JSON route.
Args:
user_input: The input received from the user or none.
Returns:
The created config entry or a form to re-enter the user input with errors.
"""
errors = {}
@@ -130,13 +141,13 @@ class OmnikInverterFlowHandler(ConfigFlow, domain=DOMAIN):
await validate_input(user_input)
async with OmnikInverter(
host=user_input[CONF_HOST],
source_type=self.source_type,
source_type=self.source_type, # type: ignore[arg-type]
) as client:
await client.inverter()
except OmnikInverterError:
LOGGER.exception("Failed to connect to the Omnik")
errors["base"] = "cannot_connect"
except Exception as error: # pylint: disable=broad-exception-caught
except InvalidHostError as error:
errors["base"] = str(error)
else:
return self.async_create_entry(
@@ -162,15 +173,15 @@ class OmnikInverterFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_setup_html(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""
Handle setup flow for the HTML`route.
) -> ConfigFlowResult:
"""Handle setup flow for the HTML route.
Args:
user_input: The input received from the user or none.
Returns:
The created config entry or a form to re-enter the user input with errors.
"""
errors = {}
@@ -179,7 +190,7 @@ class OmnikInverterFlowHandler(ConfigFlow, domain=DOMAIN):
await validate_input(user_input)
async with OmnikInverter(
host=user_input[CONF_HOST],
source_type=self.source_type,
source_type=self.source_type, # type: ignore[arg-type]
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
) as client:
@@ -187,7 +198,7 @@ class OmnikInverterFlowHandler(ConfigFlow, domain=DOMAIN):
except OmnikInverterError:
LOGGER.exception("Failed to connect to the Omnik")
errors["base"] = "cannot_connect"
except Exception as error: # pylint: disable=broad-exception-caught
except InvalidHostError as error:
errors["base"] = str(error)
else:
return self.async_create_entry(
@@ -217,15 +228,15 @@ class OmnikInverterFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_setup_tcp(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""
Handle setup flow for the TCP route.
) -> ConfigFlowResult:
"""Handle setup flow for the TCP route.
Args:
user_input: The input received from the user or none.
Returns:
The created config entry or a form to re-enter the user input with errors.
"""
errors = {}
@@ -234,14 +245,14 @@ class OmnikInverterFlowHandler(ConfigFlow, domain=DOMAIN):
await validate_input(user_input)
async with OmnikInverter(
host=user_input[CONF_HOST],
source_type=self.source_type,
source_type=self.source_type, # type: ignore[arg-type]
serial_number=user_input[CONF_SERIAL],
) as client:
await client.inverter()
except OmnikInverterError:
LOGGER.exception("Failed to connect to the Omnik")
errors["base"] = "cannot_connect"
except Exception as error: # pylint: disable=broad-except
except InvalidHostError as error:
errors["base"] = str(error)
else:
return self.async_create_entry(
@@ -271,27 +282,17 @@ class OmnikInverterFlowHandler(ConfigFlow, domain=DOMAIN):
class OmnikInverterOptionsFlowHandler(OptionsFlow):
"""Handle options."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""
Initialize options flow.
Args:
config_entry: The ConfigEntry instance.
"""
self.config_entry = config_entry
self.source_type = config_entry.data[CONF_SOURCE_TYPE]
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""
Handle a flow initialized by the user.
) -> ConfigFlowResult:
"""Handle a flow initialized by the user.
Args:
user_input: The input received from the user or none.
Returns:
The created config entry.
"""
errors = {}
@@ -301,10 +302,12 @@ class OmnikInverterOptionsFlowHandler(OptionsFlow):
except OmnikInverterError:
LOGGER.exception("Failed to connect to the Omnik")
errors["base"] = "cannot_connect"
except Exception as error: # pylint: disable=broad-except
except InvalidHostError as error:
errors["base"] = str(error)
else:
updated_config = {CONF_SOURCE_TYPE: self.source_type}
updated_config = {
CONF_SOURCE_TYPE: self.config_entry.data[CONF_SOURCE_TYPE]
}
for key in (CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_SERIAL):
if key in user_input:
updated_config[key] = user_input[key]
@@ -320,7 +323,7 @@ class OmnikInverterOptionsFlowHandler(OptionsFlow):
options[key] = user_input[key]
return self.async_create_entry(title="", data=options)
fields = {
fields: dict[Any, Any] = {
vol.Optional(
CONF_NAME,
default=self.config_entry.title,
@@ -331,7 +334,7 @@ class OmnikInverterOptionsFlowHandler(OptionsFlow):
): str,
}
if self.source_type == "html":
if self.config_entry.data[CONF_SOURCE_TYPE] == "html":
fields[
vol.Required(
CONF_USERNAME, default=self.config_entry.data.get(CONF_USERNAME)
@@ -342,7 +345,7 @@ class OmnikInverterOptionsFlowHandler(OptionsFlow):
CONF_PASSWORD, default=self.config_entry.data.get(CONF_PASSWORD)
)
] = TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD))
elif self.source_type == "tcp":
elif self.config_entry.data[CONF_SOURCE_TYPE] == "tcp":
fields[
vol.Required(
CONF_SERIAL, default=self.config_entry.data.get(CONF_SERIAL)
@@ -1,4 +1,5 @@
"""Constants for the Omnik Inverter integration."""
from __future__ import annotations
import logging
@@ -1,4 +1,5 @@
"""Omnik Inverter platform configuration."""
import logging
from datetime import timedelta
from typing import TypedDict
@@ -8,6 +9,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from omnikinverter import Device, Inverter, OmnikInverter
from omnikinverter.exceptions import OmnikInverterAuthError, OmnikInverterError
@@ -37,12 +39,12 @@ class OmnikInverterDataUpdateCoordinator(DataUpdateCoordinator):
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""
Class to manage fetching Omnik Inverter data.
"""Class to manage fetching Omnik Inverter data.
Args:
hass: The HomeAssistant instance.
entry: The ConfigEntry containing the user input.
"""
super().__init__(
hass,
@@ -73,8 +75,7 @@ class OmnikInverterDataUpdateCoordinator(DataUpdateCoordinator):
)
async def _async_update_data(self) -> OmnikInverterData:
"""
Fetch data from the omnik inverter.
"""Fetch data from the omnik inverter.
This is the place to pre-process the data to lookup tables
so entities can quickly look up their data.
@@ -86,17 +87,17 @@ class OmnikInverterDataUpdateCoordinator(DataUpdateCoordinator):
Raises:
ConfigEntryAuthFailed: An invalid config was ued.
UpdateFailed: An error occurred when updating the data.
"""
try:
data: OmnikInverterData = {
SERVICE_INVERTER: await self.omnikinverter.inverter(),
SERVICE_DEVICE: await self.omnikinverter.device(),
}
return data
except OmnikInverterAuthError as error:
_LOGGER.exception("Failed to authenticate with the Omnik")
raise ConfigEntryAuthFailed from error
except OmnikInverterError as error:
_LOGGER.exception("Failed to connect to the Omnik")
raise UpdateFailed(error) from error
return data
@@ -1,25 +1,27 @@
"""Diagnostics support for Omnik Inverter integration."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from typing import TYPE_CHECKING, Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS
from homeassistant.core import HomeAssistant
from . import OmnikInverterDataUpdateCoordinator
from .const import CONF_SERIAL, DOMAIN, SERVICE_DEVICE, SERVICE_INVERTER
from .const import CONF_SERIAL, SERVICE_DEVICE, SERVICE_INVERTER
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from . import OmnikInverterConfigEntry
TO_REDACT = {CONF_HOST, CONF_IP_ADDRESS, CONF_SERIAL}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
_hass: HomeAssistant, entry: OmnikInverterConfigEntry
) -> dict[str, Any]:
"""
Return diagnostics for a config entry.
"""Return diagnostics for a config entry.
Args:
hass: The HomeAssistant instance.
@@ -27,8 +29,9 @@ async def async_get_config_entry_diagnostics(
Returns:
The created diagnostics object.
"""
coordinator: OmnikInverterDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
return {
"entry": {
@@ -12,5 +12,5 @@
"requirements": [
"omnikinverter==1.0.0"
],
"version": "2.6.4"
"version": "3.0.0"
}
+9 -10
View File
@@ -1,20 +1,19 @@
"""Support for Omnik Inverter entities."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.sensor import SensorEntityDescription
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo, Entity
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, SERVICE_DEVICE, SERVICE_INVERTER
from .coordinator import OmnikInverterDataUpdateCoordinator
class OmnikInverterEntity(
CoordinatorEntity[OmnikInverterDataUpdateCoordinator], Entity
):
class OmnikInverterEntity(CoordinatorEntity[OmnikInverterDataUpdateCoordinator]):
"""Defines an Omnik Inverter Entity."""
_name: str
@@ -28,13 +27,13 @@ class OmnikInverterEntity(
name: str,
service: str,
) -> None:
"""
Initialise the entity.
"""Initialise the entity.
Args:
coordinator: The data coordinator updating the models.
name: The identifier for this entity.
service: The service (Device or Inverter)/
"""
super().__init__(coordinator)
self._name = name
@@ -44,21 +43,21 @@ class OmnikInverterEntity(
@property
def device_info(self) -> DeviceInfo:
"""
Return information to link this entity with the correct device.
"""Return information to link this entity with the correct device.
Returns:
The device identifiers to make sure the entity is attached
to the correct device.
"""
return DeviceInfo(
identifiers={(DOMAIN, f"{self.entry_id}_{self.service}")},
name=self.service.title(),
name=f"{self._name} {self.service.title()}",
manufacturer=MANUFACTURER,
entry_type=DeviceEntryType.SERVICE,
model=self.coordinator.data[SERVICE_INVERTER].model,
sw_version=self.coordinator.data[self.service].firmware,
configuration_url=f"http://{ self.coordinator.data[SERVICE_DEVICE].ip_address}", # noqa: E501
configuration_url=f"http://{self.coordinator.data[SERVICE_DEVICE].ip_address}",
)
+46 -35
View File
@@ -1,17 +1,16 @@
"""Support for Omnik Inverter sensors."""
from __future__ import annotations
import dataclasses
from typing import Any, Literal
from typing import TYPE_CHECKING, Any, Literal
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
UnitOfElectricCurrent,
@@ -22,14 +21,21 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify
from .const import DOMAIN, SERVICE_DEVICE, SERVICE_INVERTER
from .coordinator import OmnikInverterDataUpdateCoordinator
from .const import SERVICE_DEVICE, SERVICE_INVERTER
from .models import OmnikInverterEntity, RangedSensorEntityDescription
if TYPE_CHECKING:
from collections.abc import Iterator
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OmnikInverterConfigEntry
from .coordinator import OmnikInverterDataUpdateCoordinator
SENSORS: dict[Literal["inverter", "device"], tuple[SensorEntityDescription, ...]] = {
SERVICE_DEVICE: (
SensorEntityDescription(
@@ -162,23 +168,28 @@ SENSORS: dict[Literal["inverter", "device"], tuple[SensorEntityDescription, ...]
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
_hass: HomeAssistant,
entry: OmnikInverterConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""
Load all Omnik Inverter sensors.
"""Load all Omnik Inverter sensors.
Args:
hass: The HomeAssistant instance.
_hass: The HomeAssistant instance.
entry: The ConfigEntry containing the user input.
async_add_entities: The callback to provide the created entities to.
"""
coordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
options = entry.options
def create_sensor_entities(description, service):
if isinstance(description, RangedSensorEntityDescription):
def create_sensor_entities(
description: SensorEntityDescription, service: str
) -> Iterator[OmnikInverterSensor]:
if (
isinstance(description, RangedSensorEntityDescription)
and description.size is not None
):
for i in description.size:
yield OmnikInverterRangedSensor(
coordinator=coordinator,
@@ -211,7 +222,7 @@ class OmnikInverterSensor(OmnikInverterEntity, SensorEntity):
"""Defines an Omnik Inverter Sensor."""
entity_description: SensorEntityDescription
_options: dict[Any]
_options: dict[str, Any]
def __init__( # pylint: disable=too-many-arguments
self,
@@ -219,10 +230,9 @@ class OmnikInverterSensor(OmnikInverterEntity, SensorEntity):
name: str,
description: SensorEntityDescription,
service: str,
options: dict[Any],
):
"""
Initialise the entity.
options: dict[str, Any],
) -> None:
"""Initialise the entity.
Args:
coordinator: The data coordinator updating the models.
@@ -230,27 +240,25 @@ class OmnikInverterSensor(OmnikInverterEntity, SensorEntity):
description: The entity description for the sensor.
service: The service to create the sensor for.
options: The options provided by the user.
"""
super().__init__(coordinator=coordinator, name=name, service=service)
self.entity_description = description
self._options = options
self.entity_id = (
f"{SENSOR_DOMAIN}.{self._name}_{self.entity_description.key}" # noqa: E501
)
self._attr_unique_id = (
self._attr_unique_id = slugify(
f"{self.entry_id}_{service}_{self.entity_description.key}"
)
self._attr_name = self.entity_description.name
self._attr_name = f"{name} {self.entity_description.name}"
@property
def native_value(self) -> Any | None:
"""
Return the state of the sensor.
"""Return the state of the sensor.
Returns:
The current state value of the sensor.
"""
value = getattr(
self.coordinator.data[self.service], self.entity_description.key
@@ -268,17 +276,16 @@ class OmnikInverterRangedSensor(OmnikInverterSensor):
_index: int
_data_key: str
def __init__( # pylint: disable=too-many-arguments
def __init__( # noqa: PLR0913 # pylint: disable=too-many-arguments
self,
coordinator: OmnikInverterDataUpdateCoordinator,
index: int,
name: str,
description: RangedSensorEntityDescription,
service: str,
options: dict[Any],
):
"""
Initialise the entity.
options: dict[str, Any],
) -> None:
"""Initialise the entity.
Args:
coordinator: The data coordinator updating the models.
@@ -287,10 +294,14 @@ class OmnikInverterRangedSensor(OmnikInverterSensor):
description: The entity description for the sensor.
service: The service to create the sensor for.
options: The options provided by the user.
"""
self._index = index
if description.data_key is None:
msg = "data_key is required for RangedSensorEntityDescription"
raise TypeError(msg)
self._data_key = description.data_key
description = dataclasses.replace(
description = dataclasses.replace( # type: ignore[call-arg]
description,
key=description.key.format(index + 1),
name=description.name.format(index + 1),
@@ -306,11 +317,11 @@ class OmnikInverterRangedSensor(OmnikInverterSensor):
@property
def native_value(self) -> Any | None:
"""
Return the state of the sensor.
"""Return the state of the sensor.
Returns:
The current state value of the sensor.
"""
value = getattr(self.coordinator.data[self.service], self._data_key)
-52
View File
@@ -1,52 +0,0 @@
resource: https://api.awekas.at/current.php?key=rAOAW%2BhN93NdB9innyKqE%2Bd%2FIofSmhyZvoeVdiIUPcdzWOh7WSBdgdrBBBboyIKd51lUOh%2BCEQHMlgPoLlPpgA%3D%3D&lng=de
scan_interval: 120
sensor:
- name: "Wetterstation"
json_attributes_path: "$.current"
value_template: "OK"
json_attributes:
- temperature
- windchill
- dewpoint
- humidity
- airpress_rel
- tendency
- precipitation
- rainrate
- itsraining
- windspeed
- gustspeed
- winddirection
- indoortemperature
- indoorhumidity
- wetbulbtemperature
- name: "Wetterstation Vortag"
json_attributes_path: "$.day"
value_template: "OK"
json_attributes:
- temp_min
- temp_max
- dewpoint_min
- dewpoint_max
- hum_min
- hum_max
- airp_rel_min
- airp_rel_max
- windspeed_min
- windspeed_max
- winddir_max
- gustspeed_min
- gustspeed_max
- rainrate_max
- precipitation_24h
- solar_max
- gustspeed_max
- rainrate_max
- precipitation_24h
- name: "Niederschlag letzte Stunde"
json_attributes_path: "$.1h"
value_template: "OK"
json_attributes:
- precipitation_1h
+26
View File
@@ -13,3 +13,29 @@
# attribute_templates:
# state_class: total_increasing
- platform: snmp
name: "HPE Switch Uptime"
host: 192.168.0.4
community: realm_local
baseoid: 1.3.6.1.2.1.1.3.0
value_template: "{{ (value | float / 6000) | round(1) }}"
unit_of_measurement: "Minuten"
- platform: snmp
name: "HPE Switch Port 1 Status"
host: 192.168.0.4
community: realm_local
baseoid: 1.3.6.1.2.1.2.2.1.7.1
value_template: >
{% if value == '1' %}
Connected
{% else %}
Disconnected
{% endif %}
- platform: snmp
name: "HPE Switch Port 1 Traffic In"
host: 192.168.0.4
community: realm_local
baseoid: 1.3.6.1.2.1.2.2.1.10.1
unit_of_measurement: "Bytes"
File diff suppressed because one or more lines are too long
Binary file not shown.