# Pet Lifestyle Rules Engine — Design Spec ## Overview A configurable per-pet rules engine that evaluates conditions against the detection event stream and triggers actions. Replaces hardcoded pet alert logic with a general-purpose system. Supports up to 8 pets with up to 32 rules each. Covers walk tracking, outdoor timers, missing pet alerts, "on the loose" detection, and any other condition-action pattern a user can compose. ### Example Rules - "Alert if Milo hasn't been seen outside in 4 hours" (needs to go out) - "Alert if Milo has been outside for 45 minutes" (overdue from walk) - "Alert if Snake is detected without a person present" (on the loose) - "Alert if Taquito hasn't been seen anywhere in 8 hours" (missing) - "Alert if Angel is outside at night" (wrong zone + time) ## Rule Structure Every rule has three parts: ### Trigger + Logic (Conditions) Conditions are evaluated together with AND logic (all must be true for the rule to fire). | Condition Type | Parameters | Description | |---------------|------------|-------------| | `detected_in_zone` | `zone`: EXTERIOR, INTERIOR, TRANSITION | Pet detected in a specific zone type | | `not_seen_in_zone` | `zone`, `minutes` | Pet NOT detected in a zone for N minutes | | `not_seen_anywhere` | `minutes` | Pet not detected on any camera for N minutes | | `detected_without_person` | — | Pet detected but no person on same camera within 30 seconds | | `in_zone_longer_than` | `zone`, `minutes` | Pet continuously in a zone for N minutes | | `time_of_day` | `start`, `end` (HH:MM) | Current time within a window (handles midnight wrap) | Conditions combine naturally: - `detected_in_zone(EXTERIOR) AND time_of_day(22:00-06:00)` = pet outside at night - `detected_without_person AND detected_in_zone(INTERIOR)` = snake loose indoors - `not_seen_in_zone(EXTERIOR) AND time_of_day(08:00-20:00)` = dog hasn't gone out during daytime ### Actions | Action | Description | |--------|-------------| | `push_notify` | Send a push notification with configurable message template | | `log_event` | Write to event log without notification | | `start_recording` | Trigger recording on the relevant camera | Action messages support placeholders: `{pet_name}`, `{camera}`, `{zone}`, `{duration}`, `{last_seen}`. Example: `"{pet_name} has been outside for {duration} — {camera}"` → `"Milo has been outside for 47 minutes — Back Deck"` ### Cooldown Each rule has a cooldown period (configurable, default 30 minutes). Once a rule fires, it won't fire again until the cooldown expires. Prevents notification spam. ## Rule Evaluation Engine ### New File: `vigilar/pets/rules.py` `PetRuleEngine` class: **Per-pet state tracking:** ```python @dataclass class PetState: pet_id: str last_seen_camera: str | None = None last_seen_time: float = 0 current_zone: str | None = None # EXTERIOR, INTERIOR, TRANSITION zone_entry_time: float = 0 # when they entered current zone person_present: bool = False # person detected on same camera recently person_last_seen: float = 0 # timestamp of last person on same camera ``` **Event-triggered evaluation:** - Subscribes to `vigilar/camera/+/pet/detected` and `vigilar/camera/+/motion/start` (for person detection) via MQTT - On each pet detection event: update PetState, evaluate all enabled rules for that pet - On each person detection: update `person_present` state for the camera - `person_present` is True if a person was detected on the same camera within the last 30 seconds **Timer-triggered evaluation:** - A background timer runs every 60 seconds - Evaluates time-based conditions (`not_seen_in_zone`, `not_seen_anywhere`, `in_zone_longer_than`) for all pets with active rules - Only evaluates rules with at least one time-based condition **Deduplication:** - Tracks `last_fired` timestamp per rule ID - Rule won't fire again until `now - last_fired >= cooldown_minutes * 60` ### Condition Evaluation ```python def evaluate_rule(rule, pet_state, now) -> bool: for condition in rule.conditions: if not evaluate_condition(condition, pet_state, now): return False return True def evaluate_condition(condition, pet_state, now) -> bool: match condition["type"]: case "detected_in_zone": return pet_state.current_zone == condition["zone"] case "not_seen_in_zone": # True if pet hasn't been seen in the specified zone for N minutes if pet_state.current_zone == condition["zone"]: return False # they're there right now minutes = condition["minutes"] return (now - pet_state.last_seen_time) >= minutes * 60 case "not_seen_anywhere": minutes = condition["minutes"] return (now - pet_state.last_seen_time) >= minutes * 60 case "detected_without_person": return not pet_state.person_present case "in_zone_longer_than": if pet_state.current_zone != condition["zone"]: return False minutes = condition["minutes"] return (now - pet_state.zone_entry_time) >= minutes * 60 case "time_of_day": current_time = time.strftime("%H:%M") return is_in_time_window(condition["start"] + "-" + condition["end"], current_time) ``` Uses the existing `is_in_time_window` from `vigilar/alerts/profiles.py` for time window evaluation (handles midnight wraparound). ### Action Execution When a rule fires: 1. Format the action message with placeholders resolved from PetState 2. Execute the action: - `push_notify`: publish to `Topics.SYSTEM_ALERT` with the formatted message (picked up by the notification sender from Group A) - `log_event`: insert an event with type `PET_RULE_TRIGGERED` and the rule details in the payload - `start_recording`: publish a record command to the relevant camera 3. Update `last_fired` for the rule 4. Log to `alert_log` table ### New Event Types Add to `EventType` enum: - `PET_RULE_TRIGGERED` — a pet rule fired ## Database ### New Table: `pet_rules` | Column | Type | Notes | |--------|------|-------| | id | Integer PK | Autoincrement | | pet_id | String NOT NULL | FK to pets table | | name | String NOT NULL | User-given label (e.g., "Outdoor timer") | | enabled | Integer NOT NULL | 1 = active, 0 = disabled | | conditions | Text NOT NULL | JSON array of condition objects | | action | String NOT NULL | push_notify, log_event, start_recording | | action_message | String | Template with {placeholders} | | cooldown_minutes | Integer NOT NULL | Default 30 | | priority | Integer NOT NULL | Evaluation order (lower = first) | | created_at | Float NOT NULL | Timestamp | Index: `idx_pet_rules_pet` on (pet_id, enabled, priority) ### Condition JSON Format ```json [ {"type": "detected_in_zone", "zone": "EXTERIOR"}, {"type": "time_of_day", "start": "22:00", "end": "06:00"} ] ``` ```json [ {"type": "not_seen_in_zone", "zone": "EXTERIOR", "minutes": 240} ] ``` ```json [ {"type": "detected_without_person"}, {"type": "detected_in_zone", "zone": "INTERIOR"} ] ``` ### Query Functions Add to `vigilar/storage/queries.py`: - `get_pet_rules(engine, pet_id) -> list[dict]` - `get_all_enabled_rules(engine) -> list[dict]` - `insert_pet_rule(engine, pet_id, name, conditions, action, action_message, cooldown_minutes, priority) -> int` - `update_pet_rule(engine, rule_id, **updates) -> None` - `delete_pet_rule(engine, rule_id) -> None` - `count_pet_rules(engine, pet_id) -> int` (for enforcing 32-rule cap) ## Configuration ```toml [pets] max_pets = 8 max_rules_per_pet = 32 rule_eval_interval_s = 60 # how often to check time-based conditions ``` ## Web Routes | Route | Method | Purpose | |-------|--------|---------| | `/pets//rules` | GET | List rules for a pet (JSON) | | `/pets//rules` | POST | Create a new rule | | `/pets//rules/` | PUT | Update a rule | | `/pets//rules/` | DELETE | Delete a rule | | `/pets/api/rule-templates` | GET | List available quick-add templates | ### Rule creation validation - Verify pet_id exists - Verify rule count < `max_rules_per_pet` - Validate condition JSON: each condition has a valid `type`, required parameters present - Validate action is one of the allowed values - Validate cooldown_minutes >= 1 ## Rule Builder UI ### Layout New "Rules" tab on each pet's section of the pet dashboard. Rules displayed as a vertical list of cards, each showing the rule in natural language form. ### Rule Card Display Each rule card renders its conditions and action in readable form: ``` 🟢 Outdoor Timer [Edit] [Delete] [Toggle] WHEN Milo is in EXTERIOR longer than 45 minutes THEN Push notification: "Milo has been outside for {duration}" Cooldown: 30 minutes ``` ``` 🟢 Needs to go out [Edit] [Delete] [Toggle] WHEN Milo not seen in EXTERIOR for 240 minutes AND time is between 08:00 and 20:00 THEN Push notification: "Milo hasn't been outside in {duration}" Cooldown: 60 minutes ``` ``` 🟢 Snake on the loose [Edit] [Delete] [Toggle] WHEN Snek is detected without a person present THEN Push notification: "Snake spotted without supervision — {camera}" Cooldown: 5 minutes ``` ### Rule Editor When creating or editing a rule, the form renders as a series of logic steps: **Step 1 — Conditions (WHEN/AND):** Each condition is a form row with dropdowns: ``` WHEN [condition type ▼] [parameters...] ┌─────────────────────────────────┐ │ detected in zone │ │ not seen in zone for │ │ not seen anywhere for │ │ detected without person │ │ in zone longer than │ │ time of day │ └─────────────────────────────────┘ ``` Selecting a condition type reveals its parameter fields: - `detected_in_zone` → zone dropdown (EXTERIOR / INTERIOR / TRANSITION) - `not_seen_in_zone` → zone dropdown + minutes input - `not_seen_anywhere` → minutes input - `detected_without_person` → no parameters - `in_zone_longer_than` → zone dropdown + minutes input - `time_of_day` → start time + end time inputs "+ Add condition" button adds an AND row (up to 4 conditions per rule). **Step 2 — Action (THEN):** ``` THEN [action type ▼] [message template] ┌─────────────────────────────────┐ │ Push notification │ │ Log event only │ │ Start recording │ └─────────────────────────────────┘ ``` Message template text input with placeholder hints shown below: `Available: {pet_name}, {camera}, {zone}, {duration}, {last_seen}` **Step 3 — Cooldown:** ``` Cooldown: [30] minutes ``` ### Quick-Add Templates "Quick Add" button opens a template picker modal: | Template | Pre-filled Conditions | Pre-filled Action | |----------|----------------------|-------------------| | Outdoor timer | `in_zone_longer_than(EXTERIOR, 45)` | "🐕 {pet_name} has been outside for {duration}" | | Needs to go out | `not_seen_in_zone(EXTERIOR, 240)` | "🐕 {pet_name} hasn't been outside in {duration}" | | Missing pet | `not_seen_anywhere(480)` | "❓ {pet_name} hasn't been seen in {duration}" | | Wrong zone alert | `detected_in_zone(EXTERIOR)` | "⚠️ {pet_name} detected in {zone} — {camera}" | | On the loose | `detected_without_person` | "🐍 {pet_name} spotted without supervision — {camera}" | | Night escape | `detected_in_zone(EXTERIOR), time_of_day(22:00-06:00)` | "🌙 {pet_name} is outside at night — {camera}" | User picks a template, it pre-fills the form, they adjust values and save. Templates are a convenience — every rule is editable after creation. ## Process Integration ### PetRuleEngine as a Subprocess The rule engine runs in the event processor's process (not a separate subprocess). It: 1. Loads all enabled rules from DB at startup 2. Subscribes to pet detection and person detection MQTT topics 3. Maintains in-memory PetState per pet 4. Evaluates event-triggered rules on each detection 5. Runs a 60-second timer for time-based rule evaluation 6. Reloads rules from DB when it receives a `vigilar/system/rules_updated` MQTT message (published by the web blueprint when rules are created/updated/deleted) ### MQTT Topics New topic for rule reload signal: ``` vigilar/system/rules_updated — published when rules change, engine reloads from DB ``` ## Migration from PET_ESCAPE The existing PET_ESCAPE event generation in `events/processor.py` stays as-is for backward compatibility with alert profiles. Pet rules operate as an additional layer — they can coexist. Users who want the old behavior don't need to create rules. Users who want more control create rules that supersede the built-in behavior. If a user creates a `detected_in_zone(EXTERIOR)` rule for a pet, they'll get both the PET_ESCAPE event (from the processor) and the rule notification. This is acceptable — the cooldown prevents spam, and the user can disable the alert profile rule for `known_pet` if they prefer the rules engine. ## Dependencies ### New Packages None. ### New Files | File | Purpose | |------|---------| | `vigilar/pets/__init__.py` | Package init | | `vigilar/pets/rules.py` | PetRuleEngine, PetState, condition evaluation | | `vigilar/web/templates/pets/rules.html` | Rule builder UI (partial template included in dashboard) | ### Modified Files | File | Changes | |------|---------| | `vigilar/constants.py` | Add PET_RULE_TRIGGERED to EventType | | `vigilar/config.py` | Add max_pets, max_rules_per_pet, rule_eval_interval_s to PetsConfig | | `vigilar/storage/schema.py` | Add pet_rules table | | `vigilar/storage/queries.py` | Add rule CRUD functions | | `vigilar/events/processor.py` | Initialize and run PetRuleEngine | | `vigilar/web/blueprints/pets.py` | Add rule CRUD routes + templates route | | `vigilar/web/templates/pets/dashboard.html` | Add Rules tab per pet | ## Out of Scope - Drag-and-drop flowchart builder (v2 — start with the vertical list form builder which is simpler and mobile-friendly) - OR logic between conditions (AND only for v1 — covers all identified use cases) - Rule chaining (one rule triggering another) - Per-camera rules (rules are per-pet, not per-camera — the camera is determined by where the detection happens) - Rule import/export - Rule sharing between pets (create separately per pet)