diff --git a/docs/superpowers/specs/2026-04-03-pet-lifestyle-design.md b/docs/superpowers/specs/2026-04-03-pet-lifestyle-design.md new file mode 100644 index 0000000..e7fca78 --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-pet-lifestyle-design.md @@ -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//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)