feat: add activity history, insights, and cat usage events

- Add activity history collection for cat usage events
- Add insight metrics (total cycles, average cycles, cycles today)
- Add cat usage events with weight for LR4
- Add intermediate state events (cycle started, cycle complete, etc.)
- Add pet visits_24h metric from weight history
- Add new LR4 metrics: lifetime_cycles, dfi_trigger_count, cat_detected
- Add configurable activity lookback window to prevent duplicate events
- Add API call delays to avoid rate limiting
- Update default poll interval to 5 minutes
- Update documentation with new metrics and events
This commit is contained in:
Tanishq Dubey
2025-12-16 15:37:34 -05:00
parent bbce85fa31
commit f126202410
6 changed files with 421 additions and 57 deletions

View File

@@ -11,10 +11,16 @@ DATADOG_APP_KEY=your-datadog-app-key
DATADOG_SITE=datadoghq.com DATADOG_SITE=datadoghq.com
# Collector settings # Collector settings
DATACAT_POLL_INTERVAL=120 DATACAT_POLL_INTERVAL=300
DATACAT_INCLUDE_PETS=true DATACAT_INCLUDE_PETS=true
DATACAT_EMIT_EVENTS=true DATACAT_EMIT_EVENTS=true
DATACAT_METRIC_PREFIX=litterrobot DATACAT_METRIC_PREFIX=litterrobot
# Activity and insights collection
DATACAT_COLLECT_ACTIVITY=true
DATACAT_COLLECT_INSIGHTS=true
DATACAT_API_DELAY=1.0
DATACAT_ACTIVITY_LOOKBACK=10
# Optional: Path to config file (if using YAML config instead of env vars) # Optional: Path to config file (if using YAML config instead of env vars)
# DATACAT_CONFIG_FILE=/path/to/config.yaml # DATACAT_CONFIG_FILE=/path/to/config.yaml

View File

@@ -12,10 +12,12 @@ Datacat connects to the Whisker API (used by Litter-Robot and Feeder-Robot devic
## Features ## Features
- Collects metrics from all connected robots every 2 minutes (configurable) - Collects metrics from all connected robots every 5 minutes (configurable)
- Fetches activity history to detect cat usage events
- Collects insight data (cycle counts, daily averages)
- Submits metrics to Datadog via DogStatsD - Submits metrics to Datadog via DogStatsD
- Emits Datadog events for state changes (drawer full, offline, errors) - Emits Datadog events for state changes (cat usage, drawer full, offline, errors)
- Supports pet profile metrics (weight, health status) - Supports pet profile metrics (weight, health status, visit count)
- Dry-run mode for testing without submitting metrics - Dry-run mode for testing without submitting metrics
- Configuration via environment variables or YAML file - Configuration via environment variables or YAML file
@@ -69,9 +71,13 @@ Datacat can be configured via environment variables, a YAML config file, or both
| `DATADOG_API_KEY` | Datadog API key | Yes | | `DATADOG_API_KEY` | Datadog API key | Yes |
| `DATADOG_APP_KEY` | Datadog application key | No | | `DATADOG_APP_KEY` | Datadog application key | No |
| `DATADOG_SITE` | Datadog site (default: `datadoghq.com`) | No | | `DATADOG_SITE` | Datadog site (default: `datadoghq.com`) | No |
| `DATACAT_POLL_INTERVAL` | Polling interval in seconds (default: `120`) | No | | `DATACAT_POLL_INTERVAL` | Polling interval in seconds (default: `300`) | No |
| `DATACAT_INCLUDE_PETS` | Include pet metrics (default: `true`) | No | | `DATACAT_INCLUDE_PETS` | Include pet metrics (default: `true`) | No |
| `DATACAT_EMIT_EVENTS` | Emit Datadog events (default: `true`) | No | | `DATACAT_EMIT_EVENTS` | Emit Datadog events (default: `true`) | No |
| `DATACAT_COLLECT_ACTIVITY` | Fetch activity history (default: `true`) | No |
| `DATACAT_COLLECT_INSIGHTS` | Fetch insight data (default: `true`) | No |
| `DATACAT_API_DELAY` | Delay between API calls in seconds (default: `1.0`) | No |
| `DATACAT_ACTIVITY_LOOKBACK` | Only emit events for activities within N minutes (default: `10`) | No |
| `DATACAT_METRIC_PREFIX` | Metric prefix (default: `litterrobot`) | No | | `DATACAT_METRIC_PREFIX` | Metric prefix (default: `litterrobot`) | No |
| `DATACAT_CONFIG_FILE` | Path to YAML config file | No | | `DATACAT_CONFIG_FILE` | Path to YAML config file | No |
@@ -89,9 +95,13 @@ datadog:
metric_prefix: "litterrobot" metric_prefix: "litterrobot"
collector: collector:
poll_interval_seconds: 120 poll_interval_seconds: 300
include_pets: true include_pets: true
emit_events: true emit_events: true
collect_activity_history: true
collect_insights: true
api_call_delay_seconds: 1.0
activity_lookback_minutes: 10
``` ```
## Usage ## Usage
@@ -130,16 +140,30 @@ datacat -v
| `litterrobot.panel_lock_enabled` | Gauge | Panel lock status | | `litterrobot.panel_lock_enabled` | Gauge | Panel lock status |
| `litterrobot.power_status` | Gauge | 2=AC, 1=DC, 0=NC | | `litterrobot.power_status` | Gauge | 2=AC, 1=DC, 0=NC |
### Insight Metrics (from Activity History)
| Metric | Type | Description |
|--------|------|-------------|
| `litterrobot.insight.total_cycles` | Gauge | Total cycles in last 30 days |
| `litterrobot.insight.average_cycles` | Gauge | Average daily cycles |
| `litterrobot.insight.cycles_today` | Gauge | Cycles completed today |
### Litter Robot 4 Additional Metrics ### Litter Robot 4 Additional Metrics
| Metric | Type | Description | | Metric | Type | Description |
|--------|------|-------------| |--------|------|-------------|
| `litterrobot.litter_level` | Gauge | Litter level (0-100%) | | `litterrobot.litter_level` | Gauge | Litter level (0-100%) |
| `litterrobot.pet_weight` | Gauge | Last recorded weight (lbs) | | `litterrobot.litter_level_calculated` | Gauge | Calculated litter level from ToF sensor |
| `litterrobot.scoops_saved` | Gauge | Environmental counter | | `litterrobot.pet_weight` | Gauge | Last recorded cat weight (lbs) from robot scale |
| `litterrobot.wifi_rssi` | Gauge | WiFi signal strength | | `litterrobot.scoops_saved` | Gauge | Environmental savings counter |
| `litterrobot.wifi_rssi` | Gauge | WiFi signal strength (dBm) |
| `litterrobot.night_light_brightness` | Gauge | Brightness level | | `litterrobot.night_light_brightness` | Gauge | Brightness level |
| `litterrobot.hopper_enabled` | Gauge | Hopper status | | `litterrobot.hopper_enabled` | Gauge | Hopper status |
| `litterrobot.lifetime_cycles` | Count | Total lifetime clean cycles |
| `litterrobot.odometer_power_cycles` | Count | Total power cycles |
| `litterrobot.odometer_empty_cycles` | Count | Total empty cycles |
| `litterrobot.dfi_trigger_count` | Count | Drawer full indicator triggers |
| `litterrobot.cat_detected` | Gauge | 1 if cat currently detected |
### Feeder Robot Metrics ### Feeder Robot Metrics
@@ -153,10 +177,13 @@ datacat -v
| Metric | Type | Description | | Metric | Type | Description |
|--------|------|-------------| |--------|------|-------------|
| `litterrobot.pet.weight` | Gauge | Pet weight (lbs) | | `litterrobot.pet.weight` | Gauge | Pet weight from profile (lbs) |
| `litterrobot.pet.estimated_weight` | Gauge | User-entered estimated weight |
| `litterrobot.pet.last_weight_reading` | Gauge | Last sensor weight reading |
| `litterrobot.pet.is_healthy` | Gauge | Health status | | `litterrobot.pet.is_healthy` | Gauge | Health status |
| `litterrobot.pet.is_active` | Gauge | Active status | | `litterrobot.pet.is_active` | Gauge | Active status |
| `litterrobot.pet.age` | Gauge | Pet age | | `litterrobot.pet.age` | Gauge | Pet age |
| `litterrobot.pet.visits_24h` | Gauge | Litter box visits in last 24 hours |
### Tags ### Tags
@@ -168,14 +195,56 @@ All metrics include the following tags:
- `robot_model` - Robot model (Litter-Robot 3, Litter-Robot 4, Feeder-Robot) - `robot_model` - Robot model (Litter-Robot 3, Litter-Robot 4, Feeder-Robot)
- `status` - Current robot status (for litter robots) - `status` - Current robot status (for litter robots)
Pet metrics include:
- `pet_id` - Unique pet identifier
- `pet_name` - Pet name
- `pet_type` - Pet type (cat/dog)
## Events ## Events
Datacat emits Datadog events for important state changes: Datacat emits Datadog events for important state changes. Events are sourced from both real-time state changes and activity history polling.
- **Robot goes offline/online** - Warning/Success event ### Cat Usage Events
- **Waste drawer full** - Warning event
- **Robot errors** (sensor faults, pinch detect, etc.) - Error event | Event | Alert Type | Description |
- **Food level low** (Feeder Robot, <20%) - Warning event |-------|------------|-------------|
| Cat used litter box | Info | Cat detected and weighed (LR4 includes weight) |
| Cat detected | Info | Cat is using the litter box |
### Cycle Events
| Event | Alert Type | Description |
|-------|------------|-------------|
| Clean cycle started | Info | Cleaning cycle has begun |
| Clean cycle complete | Info | Cleaning cycle finished |
| Litter dispensed | Info | Litter hopper dispensed (LR4 with hopper) |
### Warning Events
| Event | Alert Type | Description |
|-------|------------|-------------|
| Drawer full | Warning | Waste drawer needs emptying |
| Drawer almost full | Warning | 1-2 cycles remaining |
| Robot went offline | Warning | Lost connection to robot |
| Bonnet removed | Warning | Bonnet has been removed |
| On battery backup | Warning | Running on DC power |
| Pinch detected | Warning | Pinch was detected |
| Food level low | Warning | Feeder below 20% |
### Error Events
| Event | Alert Type | Description |
|-------|------------|-------------|
| Cat sensor fault | Error | Cat sensor has a fault |
| Over torque fault | Error | Motor over torque detected |
| Dump position fault | Error | Dump position fault |
| Home position fault | Error | Home position fault |
### Success Events
| Event | Alert Type | Description |
|-------|------------|-------------|
| Robot back online | Success | Robot reconnected |
## Development ## Development

View File

@@ -15,6 +15,10 @@ datadog:
# Collector settings # Collector settings
collector: collector:
poll_interval_seconds: 120 # How often to collect metrics (default: 2 minutes) poll_interval_seconds: 300 # How often to collect metrics (default: 5 minutes)
include_pets: true # Include pet profile metrics include_pets: true # Include pet profile metrics
emit_events: true # Emit Datadog events for state changes emit_events: true # Emit Datadog events for state changes
collect_activity_history: true # Fetch activity history for cat usage events
collect_insights: true # Fetch insight data (cycle counts, averages)
api_call_delay_seconds: 1.0 # Delay between API calls to avoid rate limits
activity_lookback_minutes: 10 # Only emit events for activities within this window

View File

@@ -1,10 +1,12 @@
"""Collector module for fetching data from Whisker/Litter Robot API.""" """Collector module for fetching data from Whisker/Litter Robot API."""
import asyncio
import logging import logging
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from pylitterbot import Account from pylitterbot import Account
from pylitterbot.enums import LitterBoxStatus
from pylitterbot.robot import Robot from pylitterbot.robot import Robot
from pylitterbot.robot.feederrobot import FeederRobot from pylitterbot.robot.feederrobot import FeederRobot
from pylitterbot.robot.litterrobot import LitterRobot from pylitterbot.robot.litterrobot import LitterRobot
@@ -22,6 +24,37 @@ from datacat.models import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Activity type mappings for LR4
LR4_ACTIVITY_MAP: dict[str, tuple[str, str, str]] = {
# value: (title_suffix, description, alert_type)
"catWeight": ("Cat used litter box", "Cat detected and weighed", "info"),
"robotCycleStatusDump": ("Clean cycle started", "Cleaning cycle has begun", "info"),
"robotCycleStatusIdle": ("Clean cycle complete", "Cleaning cycle finished", "info"),
"DFIFullFlagOn": ("Drawer full", "Waste drawer is full", "warning"),
"bonnetRemovedYes": ("Bonnet removed", "Bonnet has been removed", "warning"),
"catDetectStuckLaser": ("Cat sensor fault", "Cat sensor laser is stuck", "error"),
"litterHopperDispensed": ("Litter dispensed", "Litter hopper dispensed litter", "info"),
"powerTypeDC": ("On battery backup", "Robot switched to battery power", "warning"),
}
# LR3 status codes that indicate activity events
LR3_STATUS_EVENTS: dict[str, tuple[str, str, str]] = {
"CCP": ("Clean cycle started", "Cleaning cycle in progress", "info"),
"CCC": ("Clean cycle complete", "Cleaning cycle completed", "info"),
"CD": ("Cat detected", "Cat is using the litter box", "info"),
"CSI": ("Cat sensor interrupted", "Cat sensor was interrupted during cycle", "info"),
"DFS": ("Drawer full", "Waste drawer is full", "warning"),
"DF1": ("Drawer almost full", "Waste drawer has 2 cycles left", "warning"),
"DF2": ("Drawer almost full", "Waste drawer has 1 cycle left", "warning"),
"BR": ("Bonnet removed", "Bonnet has been removed", "warning"),
"PD": ("Pinch detected", "Pinch was detected", "warning"),
"CSF": ("Cat sensor fault", "Cat sensor has a fault", "error"),
"OTF": ("Over torque fault", "Motor over torque detected", "error"),
"DPF": ("Dump position fault", "Dump position fault detected", "error"),
"HPF": ("Home position fault", "Home position fault detected", "error"),
"DHF": ("Dump + home position fault", "Combined position fault", "error"),
}
class Collector: class Collector:
"""Collects metrics from Whisker Litter Robot devices.""" """Collects metrics from Whisker Litter Robot devices."""
@@ -31,6 +64,7 @@ class Collector:
self.config = config self.config = config
self._account: Account | None = None self._account: Account | None = None
self._previous_states: dict[str, dict[str, Any]] = {} self._previous_states: dict[str, dict[str, Any]] = {}
self._last_activity_timestamps: dict[str, datetime] = {}
async def connect(self) -> None: async def connect(self) -> None:
"""Connect to the Whisker API.""" """Connect to the Whisker API."""
@@ -73,7 +107,7 @@ class Collector:
# Collect robot metrics # Collect robot metrics
for robot in self._account.robots: for robot in self._account.robots:
try: try:
robot_metrics = self._collect_robot_metrics(robot, timestamp) robot_metrics = await self._collect_robot_metrics(robot, timestamp)
result.robots.append(robot_metrics) result.robots.append(robot_metrics)
except Exception as e: except Exception as e:
logger.error("Failed to collect metrics for robot %s: %s", robot.name, e) logger.error("Failed to collect metrics for robot %s: %s", robot.name, e)
@@ -83,7 +117,7 @@ class Collector:
if self.config.collector.include_pets: if self.config.collector.include_pets:
for pet in self._account.pets: for pet in self._account.pets:
try: try:
pet_metrics = self._collect_pet_metrics(pet, timestamp) pet_metrics = await self._collect_pet_metrics(pet, timestamp)
result.pets.append(pet_metrics) result.pets.append(pet_metrics)
except Exception as e: except Exception as e:
logger.error("Failed to collect metrics for pet %s: %s", pet.name, e) logger.error("Failed to collect metrics for pet %s: %s", pet.name, e)
@@ -100,7 +134,7 @@ class Collector:
f"robot_model:{robot.model}", f"robot_model:{robot.model}",
] ]
def _collect_robot_metrics(self, robot: Robot, timestamp: datetime) -> RobotMetrics: async def _collect_robot_metrics(self, robot: Robot, timestamp: datetime) -> RobotMetrics:
"""Collect metrics from a single robot.""" """Collect metrics from a single robot."""
prefix = self.config.datadog.metric_prefix prefix = self.config.datadog.metric_prefix
base_tags = self._get_base_tags(robot) base_tags = self._get_base_tags(robot)
@@ -149,6 +183,28 @@ class Collector:
metrics.extend(self._collect_litter_robot_metrics(robot, prefix, base_tags, timestamp)) metrics.extend(self._collect_litter_robot_metrics(robot, prefix, base_tags, timestamp))
events.extend(self._check_litter_robot_events(robot, base_tags, timestamp)) events.extend(self._check_litter_robot_events(robot, base_tags, timestamp))
# Collect activity history (with delay)
if self.config.collector.collect_activity_history:
await asyncio.sleep(self.config.collector.api_call_delay_seconds)
try:
activity_events = await self._collect_activity_history(
robot, base_tags, timestamp
)
events.extend(activity_events)
except Exception as e:
logger.warning("Failed to collect activity history for %s: %s", robot.name, e)
# Collect insight metrics (with delay)
if self.config.collector.collect_insights:
await asyncio.sleep(self.config.collector.api_call_delay_seconds)
try:
insight_metrics = await self._collect_insight_metrics(
robot, prefix, base_tags, timestamp
)
metrics.extend(insight_metrics)
except Exception as e:
logger.warning("Failed to collect insights for %s: %s", robot.name, e)
# Litter Robot 4 specific metrics # Litter Robot 4 specific metrics
if isinstance(robot, LitterRobot4): if isinstance(robot, LitterRobot4):
metrics.extend(self._collect_lr4_metrics(robot, prefix, base_tags, timestamp)) metrics.extend(self._collect_lr4_metrics(robot, prefix, base_tags, timestamp))
@@ -174,6 +230,160 @@ class Collector:
events=events, events=events,
) )
async def _collect_activity_history(
self, robot: LitterRobot, base_tags: list[str], timestamp: datetime
) -> list[Event]:
"""Fetch activity history and emit events for new activities."""
if not self.config.collector.emit_events:
return []
events: list[Event] = []
# Calculate the lookback cutoff time
lookback_minutes = self.config.collector.activity_lookback_minutes
cutoff_time = timestamp - timedelta(minutes=lookback_minutes)
# Get last seen timestamp for this robot, default to cutoff
last_seen = self._last_activity_timestamps.get(robot.id, cutoff_time)
# Use the more recent of last_seen or cutoff_time
effective_cutoff = max(last_seen, cutoff_time)
logger.debug(
"Fetching activity history for %s (cutoff: %s)", robot.name, effective_cutoff
)
try:
activities = await robot.get_activity_history(limit=50)
except Exception as e:
logger.warning("Failed to get activity history for %s: %s", robot.name, e)
return []
# Filter to activities since effective cutoff (only datetime, not date)
new_activities = [
a for a in activities
if isinstance(a.timestamp, datetime) and a.timestamp > effective_cutoff
]
logger.debug("Found %d new activities for %s", len(new_activities), robot.name)
for activity in new_activities:
event = self._activity_to_event(robot, activity, base_tags)
if event:
events.append(event)
# Update last seen timestamp
if activities:
# Filter to only datetime timestamps (not date)
datetime_timestamps = [
a.timestamp for a in activities if isinstance(a.timestamp, datetime)
]
if datetime_timestamps:
newest_timestamp = max(datetime_timestamps)
self._last_activity_timestamps[robot.id] = newest_timestamp
return events
def _activity_to_event(
self, robot: LitterRobot, activity: Any, base_tags: list[str]
) -> Event | None:
"""Convert an activity record to a Datadog event."""
action = activity.action
activity_timestamp = activity.timestamp
# Handle LitterBoxStatus enum
if isinstance(action, LitterBoxStatus):
status_code = action.value
if status_code in LR3_STATUS_EVENTS:
title_suffix, description, alert_type = LR3_STATUS_EVENTS[status_code]
return Event(
title=f"{robot.name}: {title_suffix}",
text=f"{description} on {robot.name} ({robot.serial})",
tags=base_tags + [f"activity:{status_code}", f"status:{status_code}"],
alert_type=alert_type,
timestamp=activity_timestamp,
)
return None
# Handle string actions (LR4 activity format)
if isinstance(action, str):
# Check for known activity types
for key, (title_suffix, description, alert_type) in LR4_ACTIVITY_MAP.items():
if key in action:
text = f"{description} on {robot.name} ({robot.serial})"
# Extract weight if this is a cat weight event
if key == "catWeight" and ":" in action:
# Format is typically "Pet Weight Recorded: X.X lbs"
try:
weight_str = action.split(":")[-1].strip().replace(" lbs", "")
weight = float(weight_str)
text = f"Cat used litter box - weight: {weight} lbs"
except (ValueError, IndexError):
pass
return Event(
title=f"{robot.name}: {title_suffix}",
text=text,
tags=base_tags + [f"activity:{key}"],
alert_type=alert_type,
timestamp=activity_timestamp,
)
# Log unknown activity types for debugging
logger.debug("Unknown activity type for %s: %s", robot.name, action)
return None
async def _collect_insight_metrics(
self, robot: LitterRobot, prefix: str, base_tags: list[str], timestamp: datetime
) -> list[Metric]:
"""Fetch insight data and return metrics."""
metrics: list[Metric] = []
try:
insight = await robot.get_insight(days=30)
except Exception as e:
logger.warning("Failed to get insight data for %s: %s", robot.name, e)
return []
metrics.append(
Metric(
name=f"{prefix}.insight.total_cycles",
value=float(insight.total_cycles),
tags=base_tags,
timestamp=timestamp,
)
)
metrics.append(
Metric(
name=f"{prefix}.insight.average_cycles",
value=float(insight.average_cycles),
tags=base_tags,
timestamp=timestamp,
)
)
# Get today's cycle count from history
today = timestamp.date()
cycles_today = 0
for cycle_date, count in insight.cycle_history:
if cycle_date == today:
cycles_today = count
break
metrics.append(
Metric(
name=f"{prefix}.insight.cycles_today",
value=float(cycles_today),
tags=base_tags,
timestamp=timestamp,
)
)
return metrics
def _collect_litter_robot_metrics( def _collect_litter_robot_metrics(
self, robot: LitterRobot, prefix: str, base_tags: list[str], timestamp: datetime self, robot: LitterRobot, prefix: str, base_tags: list[str], timestamp: datetime
) -> list[Metric]: ) -> list[Metric]:
@@ -326,8 +536,10 @@ class Collector:
) )
) )
# Raw data metrics if available # Raw data metrics
raw_data = robot.to_dict() raw_data = robot.to_dict()
# WiFi signal strength
if "wifiRssi" in raw_data: if "wifiRssi" in raw_data:
metrics.append( metrics.append(
Metric( Metric(
@@ -337,6 +549,19 @@ class Collector:
timestamp=timestamp, timestamp=timestamp,
) )
) )
# Lifetime odometer metrics
if "odometerCleanCycles" in raw_data:
metrics.append(
Metric(
name=f"{prefix}.lifetime_cycles",
value=float(raw_data.get("odometerCleanCycles", 0)),
tags=base_tags,
timestamp=timestamp,
metric_type=MetricType.COUNT,
)
)
if "odometerPowerCycles" in raw_data: if "odometerPowerCycles" in raw_data:
metrics.append( metrics.append(
Metric( Metric(
@@ -347,6 +572,7 @@ class Collector:
metric_type=MetricType.COUNT, metric_type=MetricType.COUNT,
) )
) )
if "odometerEmptyCycles" in raw_data: if "odometerEmptyCycles" in raw_data:
metrics.append( metrics.append(
Metric( Metric(
@@ -358,6 +584,29 @@ class Collector:
) )
) )
# DFI trigger count
if "DFITriggerCount" in raw_data:
metrics.append(
Metric(
name=f"{prefix}.dfi_trigger_count",
value=float(raw_data.get("DFITriggerCount", 0)),
tags=base_tags,
timestamp=timestamp,
metric_type=MetricType.COUNT,
)
)
# Cat detection state
if "catDetect" in raw_data:
metrics.append(
Metric(
name=f"{prefix}.cat_detected",
value=1.0 if raw_data.get("catDetect") else 0.0,
tags=base_tags,
timestamp=timestamp,
)
)
return metrics return metrics
def _collect_feeder_metrics( def _collect_feeder_metrics(
@@ -407,7 +656,7 @@ class Collector:
if robot.is_online: if robot.is_online:
events.append( events.append(
Event( Event(
title=f"{robot.name} is back online", title=f"{robot.name}: Back online",
text=f"Robot {robot.name} ({robot.serial}) has come back online.", text=f"Robot {robot.name} ({robot.serial}) has come back online.",
tags=base_tags, tags=base_tags,
alert_type="success", alert_type="success",
@@ -417,7 +666,7 @@ class Collector:
else: else:
events.append( events.append(
Event( Event(
title=f"{robot.name} went offline", title=f"{robot.name}: Went offline",
text=f"Robot {robot.name} ({robot.serial}) has gone offline.", text=f"Robot {robot.name} ({robot.serial}) has gone offline.",
tags=base_tags, tags=base_tags,
alert_type="warning", alert_type="warning",
@@ -442,7 +691,7 @@ class Collector:
if prev_drawer_full is not None and not prev_drawer_full and robot.is_waste_drawer_full: if prev_drawer_full is not None and not prev_drawer_full and robot.is_waste_drawer_full:
events.append( events.append(
Event( Event(
title=f"{robot.name} waste drawer is full", title=f"{robot.name}: Drawer full",
text=f"The waste drawer on {robot.name} ({robot.serial}) needs to be emptied. " text=f"The waste drawer on {robot.name} ({robot.serial}) needs to be emptied. "
f"Cycle count: {robot.cycle_count}", f"Cycle count: {robot.cycle_count}",
tags=base_tags + [f"status:{robot.status.value}"], tags=base_tags + [f"status:{robot.status.value}"],
@@ -451,33 +700,6 @@ class Collector:
) )
) )
# Status change events for certain error states
prev_status = prev_state.get("status")
current_status = robot.status.value if hasattr(robot.status, "value") else str(robot.status)
error_statuses = {
"CSF": "Cat Sensor Fault",
"DHF": "Dump + Home Position Fault",
"DPF": "Dump Position Fault",
"HPF": "Home Position Fault",
"OTF": "Over Torque Fault",
"PD": "Pinch Detect",
"SCF": "Cat Sensor Fault At Startup",
"SPF": "Pinch Detect At Startup",
}
if prev_status != current_status and current_status in error_statuses:
events.append(
Event(
title=f"{robot.name} error: {error_statuses[current_status]}",
text=f"Robot {robot.name} ({robot.serial}) has encountered an error: "
f"{error_statuses[current_status]} (status code: {current_status})",
tags=base_tags + [f"status:{current_status}"],
alert_type="error",
timestamp=timestamp,
)
)
return events return events
def _check_feeder_events( def _check_feeder_events(
@@ -497,7 +719,7 @@ class Collector:
if prev_food_level >= 20 and robot.food_level < 20: if prev_food_level >= 20 and robot.food_level < 20:
events.append( events.append(
Event( Event(
title=f"{robot.name} food level low", title=f"{robot.name}: Food level low",
text=f"Food level on {robot.name} ({robot.serial}) is " text=f"Food level on {robot.name} ({robot.serial}) is "
f"low: {robot.food_level}%", f"low: {robot.food_level}%",
tags=base_tags, tags=base_tags,
@@ -524,7 +746,7 @@ class Collector:
self._previous_states[robot.id] = state self._previous_states[robot.id] = state
def _collect_pet_metrics(self, pet, timestamp: datetime) -> PetMetrics: async def _collect_pet_metrics(self, pet: Any, timestamp: datetime) -> PetMetrics:
"""Collect metrics from a pet profile.""" """Collect metrics from a pet profile."""
prefix = self.config.datadog.metric_prefix prefix = self.config.datadog.metric_prefix
pet_type_str = str(pet.pet_type) if pet.pet_type else "unknown" pet_type_str = str(pet.pet_type) if pet.pet_type else "unknown"
@@ -596,6 +818,20 @@ class Collector:
) )
) )
# Visit count in last 24 hours
try:
visits_24h = pet.get_visits_since(timestamp - timedelta(hours=24))
metrics.append(
Metric(
name=f"{prefix}.pet.visits_24h",
value=float(visits_24h),
tags=base_tags,
timestamp=timestamp,
)
)
except Exception as e:
logger.debug("Could not get visit count for pet %s: %s", pet.name, e)
return PetMetrics( return PetMetrics(
pet_id=pet.id, pet_id=pet.id,
pet_name=pet.name, pet_name=pet.name,

View File

@@ -33,9 +33,13 @@ class DatadogConfig:
class CollectorConfig: class CollectorConfig:
"""Collector behavior configuration.""" """Collector behavior configuration."""
poll_interval_seconds: int = 120 poll_interval_seconds: int = 300 # 5 minutes default
include_pets: bool = True include_pets: bool = True
emit_events: bool = True emit_events: bool = True
collect_activity_history: bool = True
collect_insights: bool = True
api_call_delay_seconds: float = 1.0 # Delay between API calls to avoid rate limits
activity_lookback_minutes: int = 10 # Only emit events for activities within this window
@dataclass @dataclass
@@ -58,9 +62,13 @@ def load_config(config_path: str | Path | None = None) -> Config:
DATADOG_API_KEY: Datadog API key DATADOG_API_KEY: Datadog API key
DATADOG_APP_KEY: Datadog application key (optional) DATADOG_APP_KEY: Datadog application key (optional)
DATADOG_SITE: Datadog site (default: datadoghq.com) DATADOG_SITE: Datadog site (default: datadoghq.com)
DATACAT_POLL_INTERVAL: Polling interval in seconds (default: 120) DATACAT_POLL_INTERVAL: Polling interval in seconds (default: 300)
DATACAT_INCLUDE_PETS: Include pet metrics (default: true) DATACAT_INCLUDE_PETS: Include pet metrics (default: true)
DATACAT_EMIT_EVENTS: Emit Datadog events (default: true) DATACAT_EMIT_EVENTS: Emit Datadog events (default: true)
DATACAT_COLLECT_ACTIVITY: Collect activity history (default: true)
DATACAT_COLLECT_INSIGHTS: Collect insight metrics (default: true)
DATACAT_API_DELAY: Delay between API calls in seconds (default: 1.0)
DATACAT_ACTIVITY_LOOKBACK: Only emit events for activities within N minutes (default: 10)
DATACAT_CONFIG_FILE: Path to config file (optional) DATACAT_CONFIG_FILE: Path to config file (optional)
""" """
# Load .env file if present # Load .env file if present
@@ -117,6 +125,15 @@ def load_config(config_path: str | Path | None = None) -> Config:
return int(value) return int(value)
return default return default
def get_float(env_var: str, file_path: list[str], default: float) -> float:
"""Get float config value."""
value = get_value(env_var, file_path, default)
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
return float(value)
return default
# Build configuration # Build configuration
whisker_username = get_value("WHISKER_USERNAME", ["whisker", "username"]) whisker_username = get_value("WHISKER_USERNAME", ["whisker", "username"])
whisker_password = get_value("WHISKER_PASSWORD", ["whisker", "password"]) whisker_password = get_value("WHISKER_PASSWORD", ["whisker", "password"])
@@ -149,9 +166,21 @@ def load_config(config_path: str | Path | None = None) -> Config:
), ),
collector=CollectorConfig( collector=CollectorConfig(
poll_interval_seconds=get_int( poll_interval_seconds=get_int(
"DATACAT_POLL_INTERVAL", ["collector", "poll_interval_seconds"], 120 "DATACAT_POLL_INTERVAL", ["collector", "poll_interval_seconds"], 300
), ),
include_pets=get_bool("DATACAT_INCLUDE_PETS", ["collector", "include_pets"], True), include_pets=get_bool("DATACAT_INCLUDE_PETS", ["collector", "include_pets"], True),
emit_events=get_bool("DATACAT_EMIT_EVENTS", ["collector", "emit_events"], True), emit_events=get_bool("DATACAT_EMIT_EVENTS", ["collector", "emit_events"], True),
collect_activity_history=get_bool(
"DATACAT_COLLECT_ACTIVITY", ["collector", "collect_activity_history"], True
),
collect_insights=get_bool(
"DATACAT_COLLECT_INSIGHTS", ["collector", "collect_insights"], True
),
api_call_delay_seconds=get_float(
"DATACAT_API_DELAY", ["collector", "api_call_delay_seconds"], 1.0
),
activity_lookback_minutes=get_int(
"DATACAT_ACTIVITY_LOOKBACK", ["collector", "activity_lookback_minutes"], 10
),
), ),
) )

View File

@@ -1,7 +1,7 @@
"""Data models for metrics collection.""" """Data models for metrics collection."""
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import date, datetime
from enum import Enum from enum import Enum
@@ -13,6 +13,26 @@ class MetricType(Enum):
RATE = "rate" RATE = "rate"
@dataclass
class ActivityRecord:
"""A single activity from the robot's history."""
timestamp: datetime
action: str
robot_id: str
robot_name: str
weight: float | None = None # For cat weight events (LR4)
@dataclass
class InsightData:
"""Daily insight data for a robot."""
total_cycles: int
average_cycles: float
cycle_history: list[tuple[date, int]] = field(default_factory=list)
@dataclass @dataclass
class Metric: class Metric:
"""A single metric to be submitted to Datadog.""" """A single metric to be submitted to Datadog."""