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>
This commit is contained in:
parent
93492e33d8
commit
f530f26530
373
docs/superpowers/specs/2026-04-03-pet-lifestyle-design.md
Normal file
373
docs/superpowers/specs/2026-04-03-pet-lifestyle-design.md
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
# 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)
|
||||||
Loading…
Reference in New Issue
Block a user