Add event collector and MCP query tools

🗄️ New collector/ component:
- collector.py: FastAPI service receiving events from cameras
- SQLite database for event storage
- Snapshot images saved to disk by date
- launchd setup script for macOS

🔍 New MCP tools in vision_mcp.py:
- vision_get_events(): Query events with filters
- vision_get_event_snapshot(): View event image inline
- vision_annotate_event(): Add meaning + tags to events
- vision_event_stats(): Database statistics

📡 Complete flow:
Pi detects motion → POST to collector → stored in DB
Vixy queries events → views snapshots → annotates

Ready to deploy! 🦊
This commit is contained in:
2025-12-16 16:28:07 -06:00
parent 6ecdf998c1
commit ae2bd94006
6 changed files with 749 additions and 2 deletions

View File

@@ -431,6 +431,297 @@ def vision_get_info() -> str:
return f"❌ Unexpected error: {str(e)}"
# === Event Database ===
EVENTS_DIR = Path.home() / "Documents" / "Vixy" / "events"
EVENTS_DB = EVENTS_DIR / "events.db"
def get_events_db():
"""Get connection to events database"""
import sqlite3
if not EVENTS_DB.exists():
return None
conn = sqlite3.connect(EVENTS_DB)
conn.row_factory = sqlite3.Row
return conn
@mcp.tool()
def vision_get_events(
since: str = None,
camera_id: str = None,
event_type: str = None,
annotated: bool = None,
tags: str = None,
limit: int = 20
) -> List[Dict[str, Any]]:
"""
Query motion/sensor events from the event database.
Args:
since: ISO timestamp - only events after this time
camera_id: Filter by camera (e.g., "basement")
event_type: Filter by type (e.g., "motion")
annotated: True=only annotated, False=only unannotated, None=all
tags: Comma-separated tags to filter by (e.g., "harvey,pet")
limit: Maximum events to return (default 20)
Returns:
List of event dictionaries with id, timestamp, camera, type,
confidence, annotation, tags, and snapshot_path
Examples:
vision_get_events() # Recent 20 events
vision_get_events(camera_id="basement", limit=10)
vision_get_events(annotated=False) # Events I haven't reviewed
vision_get_events(tags="harvey") # Events tagged with harvey
"""
conn = get_events_db()
if not conn:
return [{"error": f"Events database not found: {EVENTS_DB}"}]
try:
query = "SELECT * FROM events WHERE 1=1"
params = []
if since:
query += " AND timestamp >= ?"
params.append(since)
if camera_id:
query += " AND camera_id = ?"
params.append(camera_id)
if event_type:
query += " AND event_type = ?"
params.append(event_type)
if annotated is True:
query += " AND annotation IS NOT NULL"
elif annotated is False:
query += " AND annotation IS NULL"
if tags:
# Search for any of the tags
tag_list = [t.strip() for t in tags.split(",")]
tag_conditions = " OR ".join(["tags LIKE ?" for _ in tag_list])
query += f" AND ({tag_conditions})"
params.extend([f"%{tag}%" for tag in tag_list])
query += " ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
rows = conn.execute(query, params).fetchall()
events = []
for row in rows:
events.append({
"id": row["id"],
"event_id": row["event_id"],
"timestamp": row["timestamp"],
"camera_id": row["camera_id"],
"event_type": row["event_type"],
"confidence": row["confidence"],
"area_percent": row["area_percent"],
"snapshot_path": row["snapshot_path"],
"annotation": row["annotation"],
"tags": row["tags"],
})
logger.info(f"Retrieved {len(events)} events")
return events
except Exception as e:
logger.error(f"Error querying events: {e}")
return [{"error": str(e)}]
finally:
conn.close()
@mcp.tool()
def vision_get_event_snapshot(event_id: str) -> Union[MCPImage, str]:
"""
Get the snapshot image for a specific event.
Args:
event_id: The event_id string (e.g., "basement-20241216142301123456")
Returns:
MCPImage for inline display, or error message
Examples:
vision_get_event_snapshot("basement-20241216142301123456")
"""
conn = get_events_db()
if not conn:
return f"❌ Events database not found: {EVENTS_DB}"
try:
row = conn.execute(
"SELECT snapshot_path FROM events WHERE event_id = ?",
(event_id,)
).fetchone()
if not row:
return f"❌ Event not found: {event_id}"
if not row["snapshot_path"]:
return f"❌ No snapshot for event: {event_id}"
# Build full path
snapshot_path = EVENTS_DIR / row["snapshot_path"]
if not snapshot_path.exists():
return f"❌ Snapshot file missing: {snapshot_path}"
# Read and return image
image_bytes = snapshot_path.read_bytes()
logger.info(f"Retrieved snapshot for {event_id} ({len(image_bytes)} bytes)")
return MCPImage(data=image_bytes, format="jpeg")
except Exception as e:
logger.error(f"Error getting snapshot: {e}")
return f"❌ Error: {e}"
finally:
conn.close()
@mcp.tool()
def vision_annotate_event(
event_id: str,
annotation: str,
tags: str = None
) -> str:
"""
Add annotation and tags to an event after reviewing the snapshot.
This is how Vixy adds meaning to raw motion events - identifying
what/who was detected and categorizing for future queries.
Args:
event_id: The event_id to annotate
annotation: Free-text description (e.g., "Harvey walking to water bowl")
tags: Comma-separated tags (e.g., "harvey,pet,routine")
Returns:
Confirmation message
Examples:
vision_annotate_event(
"basement-20241216142301",
"Harvey walking to his water bowl",
"harvey,pet,routine"
)
vision_annotate_event(
"garage-20241216143000",
"Shadow from tree branch moving",
"false-positive,shadow"
)
"""
conn = get_events_db()
if not conn:
return f"❌ Events database not found: {EVENTS_DB}"
try:
# Check event exists
row = conn.execute(
"SELECT id FROM events WHERE event_id = ?",
(event_id,)
).fetchone()
if not row:
return f"❌ Event not found: {event_id}"
# Update annotation and tags
conn.execute("""
UPDATE events
SET annotation = ?, tags = ?
WHERE event_id = ?
""", (annotation, tags, event_id))
conn.commit()
logger.info(f"Annotated event {event_id}: {annotation} [{tags}]")
return f"✓ Annotated event {event_id}"
except Exception as e:
logger.error(f"Error annotating event: {e}")
return f"❌ Error: {e}"
finally:
conn.close()
@mcp.tool()
def vision_event_stats() -> Dict[str, Any]:
"""
Get statistics about collected events.
Returns:
Dictionary with total counts, by camera, by type,
annotated vs unannotated, and recent activity
Examples:
vision_event_stats()
"""
conn = get_events_db()
if not conn:
return {"error": f"Events database not found: {EVENTS_DB}"}
try:
stats = {}
# Total events
stats["total"] = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0]
# Annotated vs not
stats["annotated"] = conn.execute(
"SELECT COUNT(*) FROM events WHERE annotation IS NOT NULL"
).fetchone()[0]
stats["unannotated"] = stats["total"] - stats["annotated"]
# By camera
rows = conn.execute("""
SELECT camera_id, COUNT(*) as count
FROM events GROUP BY camera_id
""").fetchall()
stats["by_camera"] = {row[0]: row[1] for row in rows}
# By type
rows = conn.execute("""
SELECT event_type, COUNT(*) as count
FROM events GROUP BY event_type
""").fetchall()
stats["by_type"] = {row[0]: row[1] for row in rows}
# Recent (last 24h)
stats["last_24h"] = conn.execute("""
SELECT COUNT(*) FROM events
WHERE timestamp >= datetime('now', '-1 day')
""").fetchone()[0]
# Most recent event
row = conn.execute("""
SELECT event_id, timestamp, camera_id
FROM events ORDER BY timestamp DESC LIMIT 1
""").fetchone()
if row:
stats["most_recent"] = {
"event_id": row[0],
"timestamp": row[1],
"camera_id": row[2]
}
logger.info(f"Event stats: {stats['total']} total, {stats['unannotated']} need review")
return stats
except Exception as e:
logger.error(f"Error getting stats: {e}")
return {"error": str(e)}
finally:
conn.close()
if __name__ == "__main__":
# Run the MCP server (uses stdio transport by default)
mcp.run()