vigilar/docs/superpowers/specs/2026-04-03-pet-lifestyle-design.md
Aaron D. Lee f530f26530 Add pet lifestyle rules engine design spec (S5)
Per-pet configurable rules with conditions (zone, time, presence)
and actions (push, log, record). Walk tracker, missing pet, on-the-loose
detection via composable rule builder UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:27:48 -04:00

374 lines
15 KiB
Markdown

# 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/<pet_id>/rules` | GET | List rules for a pet (JSON) |
| `/pets/<pet_id>/rules` | POST | Create a new rule |
| `/pets/<pet_id>/rules/<rule_id>` | PUT | Update a rule |
| `/pets/<pet_id>/rules/<rule_id>` | 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)