113 files
This commit is contained in:
1 parent
0533eaca8d
commit
15c0e35c18
113 files changed
+12448
-1673
No files matched your search
@@ -1743,14 +1743,36 @@ class AutoApplyManager:
|
||||
desired = self._desired_for(eid, schedules, settings, now_min)
|
||||
if desired is None:
|
||||
continue
|
||||
# Manual override guard: if the thermostat target differs from the
|
||||
# scheduled value, do not force it back until the next block boundary.
|
||||
# Guard is only active from 5 min after block start until 5 min
|
||||
# before block end, to keep a buffer around boundaries.
|
||||
# (Boundary ticks and resume reconciles are allowed.)
|
||||
if not boundary_only and not reconcile:
|
||||
# Only apply when desired setpoint changes for this entity.
|
||||
# This prevents overriding manual thermostat changes just because
|
||||
# another room hits a new block (or any global refresh runs).
|
||||
#
|
||||
# Exceptions:
|
||||
# - On resume from pause we reconcile to the desired setpoint even
|
||||
# if it hasn't changed (reconcile=True).
|
||||
last = self._last_applied.get(eid) or {}
|
||||
try:
|
||||
lt = last.get("temp")
|
||||
last_temp = float(lt) if isinstance(lt, (int, float, str)) else None
|
||||
except Exception:
|
||||
last_temp = None
|
||||
schedule_unchanged = (last_temp is not None) and abs(last_temp - float(desired)) < 0.05
|
||||
if (not reconcile) and schedule_unchanged:
|
||||
continue
|
||||
# Manual override guard: only relevant when OUR desired value for this
|
||||
# entity hasn't changed (schedule_unchanged) - i.e. someone turned the
|
||||
# physical thermostat away from what we last set, and we shouldn't
|
||||
# immediately fight that until the next block boundary. When the
|
||||
# desired value HAS changed (a block/holiday/profile edit, presence
|
||||
# change, etc. - which is exactly what schedule_unchanged=False and
|
||||
# the check above already let through), the guard must never apply:
|
||||
# otherwise every edit made while sitting in the middle of a block's
|
||||
# active window (more than 5 min past its start) looks identical to a
|
||||
# manual override and gets silently dropped.
|
||||
# Guard window is 5 min after block start until 5 min before block end.
|
||||
# (Boundary ticks and resume reconciles are allowed to bypass it.)
|
||||
if not boundary_only and not reconcile and schedule_unchanged:
|
||||
try:
|
||||
# Check if guard window is active for this entity right now
|
||||
guard_active = False
|
||||
try:
|
||||
primary = None
|
||||
@@ -1785,8 +1807,6 @@ class AutoApplyManager:
|
||||
except Exception:
|
||||
guard_active = False
|
||||
|
||||
if not guard_active:
|
||||
pass
|
||||
st = self.hass.states.get(eid)
|
||||
cur = None
|
||||
if st is not None:
|
||||
@@ -1823,21 +1843,6 @@ class AutoApplyManager:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
# Only apply when desired setpoint changes for this entity.
|
||||
# This prevents overriding manual thermostat changes just because
|
||||
# another room hits a new block (or any global refresh runs).
|
||||
#
|
||||
# Exceptions:
|
||||
# - On resume from pause we reconcile to the desired setpoint even
|
||||
# if it hasn't changed (reconcile=True).
|
||||
last = self._last_applied.get(eid) or {}
|
||||
try:
|
||||
lt = last.get("temp")
|
||||
last_temp = float(lt) if isinstance(lt, (int, float, str)) else None
|
||||
except Exception:
|
||||
last_temp = None
|
||||
if (not reconcile) and (last_temp is not None) and abs(last_temp - float(desired)) < 0.05:
|
||||
continue
|
||||
# If current equals desired, just update cache
|
||||
st = self.hass.states.get(eid)
|
||||
cur = None
|
||||
@@ -2586,7 +2591,10 @@ class AutoApplyManager:
|
||||
if not (do_turn_on and isinstance(eid, str) and eid.startswith("climate.")):
|
||||
return
|
||||
try:
|
||||
await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=False)
|
||||
# blocking=True so ServiceNotSupported (e.g. room thermostats
|
||||
# that only support set_temperature) is actually caught here
|
||||
# instead of surfacing as an unhandled error in the HA log.
|
||||
await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=True)
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
@@ -2743,7 +2751,7 @@ class AutoApplyManager:
|
||||
pass
|
||||
try:
|
||||
if do_turn_on:
|
||||
await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=False)
|
||||
await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=True)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
@@ -3099,14 +3107,33 @@ class OpenWindowManager:
|
||||
except Exception:
|
||||
return
|
||||
|
||||
async def _turn_climate(self, eid: str, service: str) -> None:
|
||||
"""Best-effort climate.turn_on/turn_off for one entity.
|
||||
|
||||
Many room thermostats (e.g. Danfoss/Devolo paired with a separate
|
||||
radiator valve) only support set_temperature/set_hvac_mode and raise
|
||||
ServiceNotSupported for turn_on/turn_off. With blocking=False (the
|
||||
previous behaviour) that exception is raised inside HA's own background
|
||||
service-call task, *after* this function's try/except has already
|
||||
returned - so it always ended up as an unhandled error in the HA log
|
||||
regardless of any wrapping try/except here. Awaiting with
|
||||
blocking=True makes the exception observable (and therefore catchable)
|
||||
at the call site, so an unsupported entity is skipped quietly instead.
|
||||
"""
|
||||
if not (isinstance(eid, str) and eid.startswith("climate.")):
|
||||
return
|
||||
try:
|
||||
await self.hass.services.async_call("climate", service, {"entity_id": eid}, blocking=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _suspend_room(self, room: str, settings: dict) -> None:
|
||||
try:
|
||||
entities = self._entities_for_room(room, settings)
|
||||
for eid in entities:
|
||||
self._suspended_entities.add(eid)
|
||||
for eid in entities:
|
||||
if isinstance(eid, str) and eid.startswith("climate."):
|
||||
await self.hass.services.async_call("climate", "turn_off", {"entity_id": eid}, blocking=False)
|
||||
await self._turn_climate(eid, "turn_off")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
@@ -3119,8 +3146,7 @@ class OpenWindowManager:
|
||||
except Exception:
|
||||
pass
|
||||
for eid in entities:
|
||||
if isinstance(eid, str) and eid.startswith("climate."):
|
||||
await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=False)
|
||||
await self._turn_climate(eid, "turn_on")
|
||||
try:
|
||||
await self._mgr.async_apply_entity_now(eid, reconcile=True)
|
||||
except Exception:
|
||||
@@ -3135,8 +3161,7 @@ class OpenWindowManager:
|
||||
to_resume = list(self._suspended_entities)
|
||||
self._suspended_entities.clear()
|
||||
for eid in to_resume:
|
||||
if isinstance(eid, str) and eid.startswith("climate."):
|
||||
await self.hass.services.async_call("climate", "turn_on", {"entity_id": eid}, blocking=False)
|
||||
await self._turn_climate(eid, "turn_on")
|
||||
try:
|
||||
await self._mgr.async_apply_entity_now(eid, reconcile=True)
|
||||
except Exception:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -16,5 +16,5 @@
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/qlerup/thermostat-pro-timeline/issues",
|
||||
"requirements": [],
|
||||
"version": "3.2.3"
|
||||
"version": "3.2.4"
|
||||
}
|
||||
@@ -4577,6 +4577,14 @@ class ThermostatTimelineCard extends HTMLElement {
|
||||
if (!this._initialized) {
|
||||
this._initialized = true;
|
||||
this._init();
|
||||
// Must happen before the first _loadStore(): the in-card settings popup
|
||||
// is the only way most users set "Configuration ID" (it has no path to
|
||||
// rewrite the dashboard's YAML, only the separate HA card editor does),
|
||||
// so the persisted value lives in localStorage. Previously this only
|
||||
// ran when the settings popup was opened, by which point the initial
|
||||
// load had already fetched the wrong (default/shared) instance's data -
|
||||
// making independent per-card instances collapse into one on reload (#63).
|
||||
try { this._hydrateInstanceCfgFromLocal(); } catch {}
|
||||
this._loadStore(true).then(async () => {
|
||||
this._ensureSchedules();
|
||||
// Ensure labels/merges/temp_sensors from backend are reflected in config before first render
|
||||
@@ -4654,13 +4662,22 @@ class ThermostatTimelineCard extends HTMLElement {
|
||||
this._yamlProvided = {
|
||||
labels: Object.prototype.hasOwnProperty.call(config, 'labels'),
|
||||
merges: Object.prototype.hasOwnProperty.call(config, 'merges'),
|
||||
color_ranges: Object.prototype.hasOwnProperty.call(config, 'color_ranges'),
|
||||
default_temp: Object.prototype.hasOwnProperty.call(config, 'default_temp'),
|
||||
min_temp: Object.prototype.hasOwnProperty.call(config, 'min_temp'),
|
||||
max_temp: Object.prototype.hasOwnProperty.call(config, 'max_temp'),
|
||||
temp_sensors: Object.prototype.hasOwnProperty.call(config, 'temp_sensors'),
|
||||
turn_on: Object.prototype.hasOwnProperty.call(config, 'turn_on'),
|
||||
// Boiler settings are storage-driven; do not lock them from Lovelace/YAML defaults.
|
||||
away: Object.prototype.hasOwnProperty.call(config, 'away'),
|
||||
// Everything below is edited via the in-card popup/editor and stored in
|
||||
// shared/local storage (see _emit()'s exclusion list, which strips these
|
||||
// same keys before writing config back to YAML). They must never be
|
||||
// treated as "explicitly provided by YAML", or the visual editor's own
|
||||
// round-trip (which re-sends the *current* value as part of the config
|
||||
// object) permanently locks out any future update coming from storage -
|
||||
// this is what broke the Pause-sensor setting (#54) and 24h time format
|
||||
// (#45): once saved once via the editor, hasOwnProperty(config, key) was
|
||||
// always true from then on, even though the user never touched YAML.
|
||||
color_ranges: false,
|
||||
default_temp: false,
|
||||
min_temp: false,
|
||||
max_temp: false,
|
||||
boiler_enabled: false,
|
||||
boiler_switch: false,
|
||||
boiler_switch_domain: false,
|
||||
@@ -4670,25 +4687,23 @@ class ThermostatTimelineCard extends HTMLElement {
|
||||
boiler_temp_sensor: false,
|
||||
boiler_min_temp: false,
|
||||
boiler_max_temp: false,
|
||||
show_pause_button: Object.prototype.hasOwnProperty.call(config, 'show_pause_button'),
|
||||
show_room_temp: Object.prototype.hasOwnProperty.call(config, 'show_room_temp'),
|
||||
pause_sensor_enabled: Object.prototype.hasOwnProperty.call(config, 'pause_sensor_enabled'),
|
||||
pause_sensor_entity: Object.prototype.hasOwnProperty.call(config, 'pause_sensor_entity'),
|
||||
away: Object.prototype.hasOwnProperty.call(config, 'away'),
|
||||
time_12h: Object.prototype.hasOwnProperty.call(config, 'time_12h'),
|
||||
time_source: Object.prototype.hasOwnProperty.call(config, 'time_source'),
|
||||
temp_unit: Object.prototype.hasOwnProperty.call(config, 'temp_unit'),
|
||||
// Holiday settings are storage-driven; do not lock them from Lovelace/YAML defaults.
|
||||
show_pause_button: false,
|
||||
show_room_temp: false,
|
||||
pause_sensor_enabled: false,
|
||||
pause_sensor_entity: false,
|
||||
time_12h: false,
|
||||
time_source: false,
|
||||
temp_unit: false,
|
||||
holidays_enabled: false,
|
||||
holidays_source: false,
|
||||
holidays_entity: false,
|
||||
holidays_dates: false,
|
||||
presence_live_header: false,
|
||||
presence_sensor_enabled: Object.prototype.hasOwnProperty.call(config, 'presence_sensor_enabled'),
|
||||
presence_sensors: Object.prototype.hasOwnProperty.call(config, 'presence_sensors'),
|
||||
presence_sensor_temps: Object.prototype.hasOwnProperty.call(config, 'presence_sensor_temps'),
|
||||
presence_sensor_delays: Object.prototype.hasOwnProperty.call(config, 'presence_sensor_delays'),
|
||||
presence_sensor_delay_units: Object.prototype.hasOwnProperty.call(config, 'presence_sensor_delay_units'),
|
||||
presence_sensor_enabled: false,
|
||||
presence_sensors: false,
|
||||
presence_sensor_temps: false,
|
||||
presence_sensor_delays: false,
|
||||
presence_sensor_delay_units: false,
|
||||
};
|
||||
|
||||
this._config = {
|
||||
@@ -4713,6 +4728,13 @@ class ThermostatTimelineCard extends HTMLElement {
|
||||
storage_enabled: !!(config.storage_enabled ?? true),
|
||||
instance_enabled: !!(config.instance_enabled ?? false),
|
||||
instance_id: String(config.instance_id ?? ''),
|
||||
// The editor generates + persists this stable per-card id into YAML (see
|
||||
// ThermostatTimelineCardEditor.setConfig/_emit), but the rendered card
|
||||
// itself never read it back - so _instanceCfgLocalKey() always fell
|
||||
// through to its unstable page/DOM-order-based fallback key, which
|
||||
// doesn't survive a reload. That's what made "Configuration ID" reset
|
||||
// to off after saving (#63).
|
||||
instance_uid: String(config.instance_uid ?? (this._config?.instance_uid ?? '')),
|
||||
storage_sync_mode: (config.storage_sync_mode === 'delay' ? 'delay' : 'instant'),
|
||||
storage_sync_min: Number.isFinite(config.storage_sync_min) ? Math.max(0, Math.min(1440, Number(config.storage_sync_min))) : (this._config?.storage_sync_min ?? 5),
|
||||
storage_sync_sec: Number.isFinite(config.storage_sync_sec)
|
||||
|
||||
Reference in new issue
Block a user