Skip to content

Commit 17a7096

Browse files
Cloudoreclaude
andcommitted
Expose zone runtime context on the override sensor
The per-zone override sensor now carries compact runtime context so a dashboard or automation can explain a zone without opening the panel: runtime_state and control_mode, the Manual adjustment source, policy, start and expiry, the effective Profile and schedule source, and the last target Velair delivered with its source, time, action, temperature or range and HVAC mode. The last-applied values are runtime only. A new `get_zone_context` scheduler method backs the attributes; existing attributes are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WJhD4sYeWQvoikjYP2CxUr
1 parent 054eff7 commit 17a7096

4 files changed

Lines changed: 256 additions & 7 deletions

File tree

‎custom_components/velair/scheduler.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,8 @@ def __init__(
336336
_AppliedPreconditioningTarget,
337337
] = {}
338338
self._preconditioning_sessions: dict[str, _PreconditioningSession] = {}
339+
# Last target Velair delivered per zone (runtime only, for entity attributes).
340+
self._last_applied: dict[str, dict[str, object]] = {}
339341
self._preconditioning_replan_entities: tuple[str, ...] = ()
340342
self._preconditioning_replan_temperatures: dict[str, float] = {}
341343
self._preconditioning_plan_snapshots: dict[str, tuple] = {}
@@ -887,6 +889,62 @@ def get_active_overrides(self) -> dict[str, dict]:
887889
and _is_boost_override(override)
888890
}
889891

892+
def get_zone_context(self, entity_id: str) -> dict[str, object]:
893+
"""Return compact runtime context for one zone's entity attributes.
894+
895+
This complements the override state with what dashboards and
896+
automations most often need to explain a zone: the runtime state,
897+
Automatic or Manual control with the Manual adjustment source and
898+
policy, which Profile (if any) supplies the schedule, and the last
899+
target Velair delivered together with its source.
900+
"""
901+
zone = self._data["zones"].get(entity_id)
902+
if zone is None:
903+
return {}
904+
now = dt_util.now()
905+
context: dict[str, object] = {}
906+
try:
907+
runtime = self._zone_runtime_status(entity_id)
908+
except Exception: # pragma: no cover - defensive; attributes must not break
909+
runtime = {}
910+
if runtime.get("state") is not None:
911+
context["runtime_state"] = runtime.get("state")
912+
if runtime.get("control_mode") is not None:
913+
context["control_mode"] = runtime.get("control_mode")
914+
manual = self._manual_control_status(entity_id, now)
915+
if manual is not None:
916+
context["manual_source"] = manual.get("source")
917+
context["manual_policy"] = manual.get("policy")
918+
context["manual_since"] = manual.get("started_at")
919+
if manual.get("until") is not None:
920+
context["manual_until"] = manual.get("until")
921+
changed = manual.get("changed_fields")
922+
if changed:
923+
context["manual_changed_fields"] = list(changed)
924+
profile = self._active_profile_for_zone(entity_id)
925+
behavior = self._profile_zone_behavior(entity_id)
926+
if profile is not None:
927+
context["effective_profile_id"] = profile.get("key")
928+
context["effective_profile_name"] = profile.get("name")
929+
context["schedule_source"] = (
930+
"profile_pause" if behavior.get("behavior") == "pause" else "profile"
931+
)
932+
else:
933+
context["schedule_source"] = "default"
934+
applied = self._last_applied.get(entity_id)
935+
if applied is not None:
936+
context["last_applied_source"] = applied.get("source")
937+
context["last_applied_at"] = applied.get("at")
938+
context["last_applied_action"] = applied.get("action")
939+
if applied.get("temperature") is not None:
940+
context["last_applied_temperature"] = applied.get("temperature")
941+
if applied.get("target_temp_low") is not None:
942+
context["last_applied_target_temp_low"] = applied.get("target_temp_low")
943+
context["last_applied_target_temp_high"] = applied.get("target_temp_high")
944+
if applied.get("hvac_mode") is not None:
945+
context["last_applied_hvac_mode"] = applied.get("hvac_mode")
946+
return context
947+
890948
def get_zone_override_status(self, entity_id: str) -> dict[str, object]:
891949
"""Return the current override state for one managed zone."""
892950
zone = self._data["zones"].get(entity_id)
@@ -8734,6 +8792,15 @@ def _async_fire_climate_target_applied(
87348792
if event.target_when is not None:
87358793
data["target_when"] = event.target_when.isoformat()
87368794

8795+
self._last_applied[event.entity_id] = {
8796+
"source": source,
8797+
"at": dt_util.now().isoformat(),
8798+
"action": event.action,
8799+
"temperature": data.get("temperature"),
8800+
"target_temp_low": data.get(ATTR_TARGET_TEMP_LOW),
8801+
"target_temp_high": data.get(ATTR_TARGET_TEMP_HIGH),
8802+
"hvac_mode": hvac_mode,
8803+
}
87378804
self._async_fire_climate_target_applied_data(data)
87388805

87398806
def _async_fire_climate_target_applied_data(self, data: dict) -> None:

‎custom_components/velair/sensor.py‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -402,15 +402,32 @@ def native_value(self) -> str:
402402

403403
@property
404404
def extra_state_attributes(self) -> dict[str, object] | None:
405-
"""Return compact active override context."""
405+
"""Return compact active override context plus zone runtime context."""
406406
status = self.scheduler.get_zone_override_status(self._climate_entity_id)
407+
context_getter = getattr(self.scheduler, "get_zone_context", None)
408+
context = context_getter(self._climate_entity_id) if callable(context_getter) else {}
407409
return _compact_attributes(
408410
{
409-
key: status.get(key)
410-
for key in (
411-
"started_at", "until", "action", "pause_id",
412-
"pause_count", "pause_ids", "manual",
413-
)
411+
**{
412+
key: status.get(key)
413+
for key in (
414+
"started_at", "until", "action", "pause_id",
415+
"pause_count", "pause_ids", "manual",
416+
)
417+
},
418+
**{
419+
key: context.get(key)
420+
for key in (
421+
"runtime_state", "control_mode", "manual_source",
422+
"manual_policy", "manual_since", "manual_until",
423+
"manual_changed_fields", "effective_profile_id",
424+
"effective_profile_name", "schedule_source",
425+
"last_applied_source", "last_applied_at",
426+
"last_applied_action", "last_applied_temperature",
427+
"last_applied_target_temp_low",
428+
"last_applied_target_temp_high", "last_applied_hvac_mode",
429+
)
430+
},
414431
}
415432
)
416433

‎docs/user/usage.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,15 @@ automation events:
9393
- **Air quality** is created once per managed climate and keeps the independent
9494
CO2 assessment: good, elevated, poor, unavailable, or not monitored.
9595
- **Zone override** shows whether that climate currently has no override, an
96-
active boost, or an active zone pause.
96+
active boost, or an active zone pause. Its attributes also carry compact
97+
runtime context so a dashboard can explain the zone without the panel:
98+
`runtime_state`, `control_mode`, the Manual adjustment `manual_source`,
99+
`manual_policy`, `manual_since` and `manual_until`, the
100+
`effective_profile_id` / `effective_profile_name` and `schedule_source`
101+
(`default`, `profile` or `profile_pause`), and the last target Velair
102+
delivered (`last_applied_source`, `last_applied_at`, `last_applied_action`,
103+
`last_applied_temperature` or range, `last_applied_hvac_mode`). The last
104+
applied values are runtime only and start empty after a restart.
97105
- **Preconditioning start** is a timestamp sensor containing the calculated
98106
start for the next or currently active early-start block. Its attributes
99107
include the scheduled target time, lead, direction, model source, target
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Zone runtime context exposed through the override sensor attributes."""
2+
3+
from __future__ import annotations
4+
5+
from types import SimpleNamespace
6+
import unittest
7+
8+
from .helpers import (
9+
NOW,
10+
FakeClimateManager,
11+
FakeHass,
12+
VelairScheduler,
13+
empty_week_schedule,
14+
normalize_schedule_data,
15+
)
16+
from .test_sensor_entities import sensor_module
17+
18+
19+
class ZoneContextTest(unittest.IsolatedAsyncioTestCase):
20+
def setUp(self) -> None:
21+
self.entity_id = "climate.salon"
22+
self.hass = FakeHass()
23+
self.hass.states[self.entity_id] = SimpleNamespace(
24+
state="cool",
25+
attributes={"temperature": 24, "current_temperature": 26},
26+
)
27+
self.data = normalize_schedule_data(
28+
{
29+
"zones": {
30+
self.entity_id: {
31+
"enabled": True,
32+
"schedule": empty_week_schedule(),
33+
}
34+
}
35+
},
36+
[self.entity_id],
37+
)
38+
self.data["zones"][self.entity_id]["schedule"]["tuesday"] = [
39+
{
40+
"start": "00:00",
41+
"action": "set_temperature",
42+
"temperature": 24.0,
43+
"hvac_mode": "cool",
44+
}
45+
]
46+
self.scheduler = VelairScheduler(
47+
self.hass, self.data, FakeClimateManager(), self._save
48+
)
49+
self.scheduler._climate_manager.current_hvac_modes[self.entity_id] = "cool"
50+
51+
async def _save(self) -> None:
52+
return None
53+
54+
def test_context_before_any_delivery_is_compact(self) -> None:
55+
context = self.scheduler.get_zone_context(self.entity_id)
56+
self.assertEqual("default", context["schedule_source"])
57+
self.assertEqual("automatic", context["control_mode"])
58+
self.assertIn(context["runtime_state"], ("scheduled", "idle"))
59+
self.assertNotIn("last_applied_source", context)
60+
self.assertNotIn("manual_source", context)
61+
self.assertEqual({}, self.scheduler.get_zone_context("climate.unknown"))
62+
63+
async def test_last_applied_target_is_recorded_with_its_source(self) -> None:
64+
await self.scheduler.async_apply_current_schedule(self.entity_id)
65+
context = self.scheduler.get_zone_context(self.entity_id)
66+
self.assertEqual("current_schedule", context["last_applied_source"])
67+
self.assertEqual(24.0, context["last_applied_temperature"])
68+
self.assertEqual("set_temperature", context["last_applied_action"])
69+
self.assertEqual("cool", context["last_applied_hvac_mode"])
70+
self.assertTrue(str(context["last_applied_at"]).startswith(str(NOW.year)))
71+
72+
async def test_manual_adjustment_context_is_exposed(self) -> None:
73+
await self.scheduler.async_update_external_change_policy(
74+
self.entity_id, {"action": "until_resumed"}
75+
)
76+
await self.scheduler.async_handle_external_climate_change(
77+
self.entity_id,
78+
changed_fields=["temperature"],
79+
previous={"temperature": 24.0},
80+
current={"temperature": 20.0},
81+
)
82+
context = self.scheduler.get_zone_context(self.entity_id)
83+
self.assertEqual("manual", context["control_mode"])
84+
self.assertEqual("external_change", context["manual_source"])
85+
self.assertEqual("until_resumed", context["manual_policy"])
86+
self.assertEqual(["temperature"], context["manual_changed_fields"])
87+
self.assertIn("manual_since", context)
88+
self.assertNotIn("manual_until", context)
89+
90+
await self.scheduler.async_resume_automatic_control(self.entity_id)
91+
context = self.scheduler.get_zone_context(self.entity_id)
92+
self.assertEqual("automatic", context["control_mode"])
93+
self.assertNotIn("manual_source", context)
94+
95+
def test_profile_context_is_exposed(self) -> None:
96+
data = normalize_schedule_data(
97+
{
98+
"zones": {
99+
self.entity_id: {
100+
"enabled": True,
101+
"schedule": empty_week_schedule(),
102+
}
103+
},
104+
"profiles": [
105+
{
106+
"key": "away",
107+
"name": "Away",
108+
"zones": {
109+
self.entity_id: {
110+
"behavior": "schedule",
111+
"schedule": empty_week_schedule(),
112+
}
113+
},
114+
}
115+
],
116+
"global_": {"mode": "auto", "active_profile_ids": ["away"]},
117+
},
118+
[self.entity_id],
119+
)
120+
scheduler = VelairScheduler(self.hass, data, FakeClimateManager(), self._save)
121+
context = scheduler.get_zone_context(self.entity_id)
122+
self.assertEqual("away", context["effective_profile_id"])
123+
self.assertEqual("Away", context["effective_profile_name"])
124+
self.assertEqual("profile", context["schedule_source"])
125+
126+
def test_override_sensor_merges_context_attributes(self) -> None:
127+
scheduler = SimpleNamespace(
128+
get_zone_override_status=lambda entity_id: {"state": "none"},
129+
get_zone_context=lambda entity_id: {
130+
"runtime_state": "scheduled",
131+
"control_mode": "automatic",
132+
"schedule_source": "default",
133+
"last_applied_source": "scheduled_event",
134+
"last_applied_temperature": 24.0,
135+
"ignored_key": "dropped",
136+
},
137+
)
138+
entry = SimpleNamespace(
139+
runtime_data=SimpleNamespace(
140+
scheduler=scheduler,
141+
temperature_unit="°C",
142+
configured_entities=["climate.living_room"],
143+
),
144+
entry_id="entry",
145+
options={},
146+
data={},
147+
)
148+
sensor = sensor_module.ZoneOverrideStateSensor(entry, "climate.living_room")
149+
attributes = sensor.extra_state_attributes
150+
self.assertEqual("scheduled", attributes["runtime_state"])
151+
self.assertEqual("automatic", attributes["control_mode"])
152+
self.assertEqual(24.0, attributes["last_applied_temperature"])
153+
self.assertNotIn("ignored_key", attributes)
154+
155+
156+
if __name__ == "__main__":
157+
unittest.main()

0 commit comments

Comments
 (0)