added sensors for HPE switch
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
2025.11.2
|
||||
2026.7.1
|
||||
+71
-157
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 má 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žadované 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žadované 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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user