This commit is contained in:
2025-11-11 19:58:06 +01:00
parent d3d96ed3e9
commit b6b441c0ca
5 changed files with 245 additions and 89 deletions

View File

@@ -21,14 +21,18 @@ class RuleDescriptor(BaseModel):
This is the validated representation of a rule from rules.yaml.
The engine loads these and passes them to rule implementations.
The 'objects' field is intentionally flexible (dict) to allow different
rule types to define their own object structures.
"""
id: str = Field(..., description="Unique identifier for this rule instance")
name: Optional[str] = Field(None, description="Optional human-readable name")
type: str = Field(..., description="Rule type with version (e.g., 'window_setback@1.0')")
targets: dict[str, Any] = Field(
enabled: bool = Field(default=True, description="Whether this rule is enabled")
objects: dict[str, Any] = Field(
default_factory=dict,
description="Rule-specific target specification (rooms, devices, etc.)"
description="Objects this rule monitors or controls (structure varies by rule type)"
)
params: dict[str, Any] = Field(
default_factory=dict,
@@ -309,16 +313,22 @@ class MQTTClient:
else:
print(f"[{level.upper()}] {msg}")
async def connect(self):
async def connect(self, topics: list[str] = None):
"""
Connect to MQTT broker with automatic reconnection.
This method manages the connection and automatically reconnects
with exponential backoff if the connection is lost.
Args:
topics: List of MQTT topics to subscribe to. If None, subscribes to nothing.
"""
import aiomqtt
from aiomqtt import Client
if topics is None:
topics = []
reconnect_delay = self._reconnect_interval
while True:
@@ -333,10 +343,11 @@ class MQTTClient:
self._client = client
self._log("info", f"Connected to MQTT broker {self._broker}:{self._port}")
# Subscribe to device state topics
await client.subscribe("home/contact/+/state")
await client.subscribe("home/thermostat/+/state")
self._log("info", "Subscribed to home/contact/+/state, home/thermostat/+/state")
# Subscribe to provided topics
if topics:
for topic in topics:
await client.subscribe(topic)
self._log("info", f"Subscribed to {len(topics)} topic(s): {', '.join(topics[:5])}{'...' if len(topics) > 5 else ''}")
# Reset reconnect delay on successful connection
reconnect_delay = self._reconnect_interval
@@ -542,6 +553,13 @@ class Rule(ABC):
Example implementation:
class WindowSetbackRule(Rule):
def get_subscriptions(self, desc: RuleDescriptor) -> list[str]:
# Subscribe to contact sensor state topics
topics = []
for contact_id in desc.objects.contacts or []:
topics.append(f"home/contact/{contact_id}/state")
return topics
async def on_event(self, evt: dict, desc: RuleDescriptor, ctx: RuleContext) -> None:
device_id = evt['device_id']
cap = evt['cap']
@@ -550,11 +568,31 @@ class Rule(ABC):
contact_state = evt['payload'].get('contact')
if contact_state == 'open':
# Window opened - set thermostats to eco
for thermo_id in desc.targets.get('thermostats', []):
for thermo_id in desc.objects.thermostats or []:
eco_temp = desc.params.get('eco_target', 16.0)
await ctx.mqtt.publish_set_thermostat(thermo_id, eco_temp)
"""
@abstractmethod
def get_subscriptions(self, desc: RuleDescriptor) -> list[str]:
"""
Return list of MQTT topics this rule needs to subscribe to.
Called once during rule engine setup. The rule examines its configuration
(desc.objects) and returns the specific state topics it needs to monitor.
Args:
desc: Rule configuration from rules.yaml
Returns:
List of MQTT topic patterns/strings to subscribe to
Example:
For a window setback rule monitoring 2 contacts:
['home/contact/sensor_bedroom/state', 'home/contact/sensor_kitchen/state']
"""
pass
@abstractmethod
async def on_event(
self,