Files
home-automation/apps/rules/impl/window_setback.py
2025-11-11 16:38:41 +01:00

171 lines
6.4 KiB
Python

"""
Example Rule Implementation: Window Setback
Demonstrates how to implement a Rule using the rule_interface.
This rule lowers thermostat temperature when a window is opened.
"""
from typing import Any
from apps.rules.rule_interface import Rule, RuleDescriptor, RuleContext
class WindowSetbackRule(Rule):
"""
Window setback automation rule.
When a window/door contact opens, set thermostats to eco temperature.
When closed for a minimum duration, restore previous target temperature.
Configuration:
targets:
contacts: List of contact sensor device IDs
thermostats: List of thermostat device IDs
params:
eco_target: Temperature to set when window opens (default: 16.0)
open_min_secs: Minimum seconds window must be open before triggering (default: 20)
close_min_secs: Minimum seconds window must be closed before restoring (default: 20)
previous_target_ttl_secs: How long to remember previous temperature (default: 86400)
State storage:
Redis keys:
rule:{rule_id}:contact:{device_id}:state -> "open" | "closed"
rule:{rule_id}:contact:{device_id}:ts -> ISO timestamp of last change
rule:{rule_id}:thermo:{device_id}:previous -> Previous target temperature
"""
async def on_event(
self,
evt: dict[str, Any],
desc: RuleDescriptor,
ctx: RuleContext
) -> None:
"""
Process contact sensor or thermostat state changes.
Logic:
1. If contact opened → remember current thermostat targets, set to eco
2. If contact closed for min_secs → restore previous targets
3. If thermostat target changed → update stored previous value
"""
device_id = evt['device_id']
cap = evt['cap']
payload = evt['payload']
# Only process events for devices in our targets
target_contacts = desc.targets.contacts or []
target_thermostats = desc.targets.thermostats or []
if cap == 'contact' and device_id in target_contacts:
await self._handle_contact_event(evt, desc, ctx)
elif cap == 'thermostat' and device_id in target_thermostats:
await self._handle_thermostat_event(evt, desc, ctx)
async def _handle_contact_event(
self,
evt: dict[str, Any],
desc: RuleDescriptor,
ctx: RuleContext
) -> None:
"""Handle contact sensor state change."""
device_id = evt['device_id']
contact_state = evt['payload'].get('contact') # "open" or "closed"
event_ts = evt.get('ts', ctx.now().isoformat())
if not contact_state:
ctx.logger.warning(f"Contact event missing 'contact' field: {evt}")
return
# Store current state and timestamp
state_key = f"rule:{desc.id}:contact:{device_id}:state"
ts_key = f"rule:{desc.id}:contact:{device_id}:ts"
await ctx.redis.set(state_key, contact_state)
await ctx.redis.set(ts_key, event_ts)
if contact_state == 'open':
await self._on_window_opened(desc, ctx)
elif contact_state == 'closed':
await self._on_window_closed(desc, ctx)
async def _on_window_opened(self, desc: RuleDescriptor, ctx: RuleContext) -> None:
"""Window opened - set thermostats to eco temperature."""
eco_target = desc.params.get('eco_target', 16.0)
target_thermostats = desc.targets.thermostats or []
ctx.logger.info(
f"Rule {desc.id}: Window opened, setting {len(target_thermostats)} "
f"thermostats to eco temperature {eco_target}°C"
)
# Set all thermostats to eco temperature
for thermo_id in target_thermostats:
try:
await ctx.mqtt.publish_set_thermostat(thermo_id, eco_target)
ctx.logger.debug(f"Set {thermo_id} to {eco_target}°C")
except Exception as e:
ctx.logger.error(f"Failed to set {thermo_id}: {e}")
async def _on_window_closed(self, desc: RuleDescriptor, ctx: RuleContext) -> None:
"""
Window closed - restore previous temperatures if closed long enough.
Note: This is simplified. A production implementation would check
close_min_secs and use a timer/scheduler.
"""
target_thermostats = desc.targets.thermostats or []
ttl_secs = desc.params.get('previous_target_ttl_secs', 86400)
ctx.logger.info(
f"Rule {desc.id}: Window closed, restoring {len(target_thermostats)} "
f"thermostats to previous temperatures"
)
# Restore previous temperatures
for thermo_id in target_thermostats:
prev_key = f"rule:{desc.id}:thermo:{thermo_id}:previous"
prev_temp_str = await ctx.redis.get(prev_key)
if prev_temp_str:
try:
prev_temp = float(prev_temp_str)
await ctx.mqtt.publish_set_thermostat(thermo_id, prev_temp)
ctx.logger.debug(f"Restored {thermo_id} to {prev_temp}°C")
except Exception as e:
ctx.logger.error(f"Failed to restore {thermo_id}: {e}")
async def _handle_thermostat_event(
self,
evt: dict[str, Any],
desc: RuleDescriptor,
ctx: RuleContext
) -> None:
"""
Handle thermostat state change - remember current target.
This allows us to restore the temperature when window closes.
"""
device_id = evt['device_id']
payload = evt['payload']
current_target = payload.get('target')
if current_target is None:
return # No target in this state update
# Store as previous target with TTL
prev_key = f"rule:{desc.id}:thermo:{device_id}:previous"
ttl_secs = desc.params.get('previous_target_ttl_secs', 86400)
await ctx.redis.set(prev_key, str(current_target), ttl_secs=ttl_secs)
ctx.logger.debug(
f"Rule {desc.id}: Stored previous target for {device_id}: {current_target}°C"
)
# Rule registry - maps rule type to implementation class
RULE_IMPLEMENTATIONS = {
'window_setback@1.0': WindowSetbackRule,
}