""" 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, }