This commit is contained in:
Alex
2026-02-08 17:47:49 -06:00
parent 09117f9c62
commit 41dd6d9a64
7 changed files with 465 additions and 569 deletions

View File

@@ -179,7 +179,7 @@ This prevents messages from being overwritten when using Claude Desktop on multi
- **Timed wakes**: CMD+R ensures chat is synced before system check message - **Timed wakes**: CMD+R ensures chat is synced before system check message
- **Matrix wakes**: CMD+R ensures you see the latest conversation context - **Matrix wakes**: CMD+R ensures you see the latest conversation context
The daemon waits **10 seconds** after sending CMD+R to ensure the refresh completes before sending the message. The daemon waits **15 seconds** after sending CMD+R to ensure the refresh completes before sending the message.
**Why this matters:** **Why this matters:**
- Multi-device sync: If you're active on mobile/web, desktop chat stays current - Multi-device sync: If you're active on mobile/web, desktop chat stays current
@@ -277,14 +277,6 @@ def generate_message() -> str:
return f"Your custom message template with {timestamp}" return f"Your custom message template with {timestamp}"
``` ```
### Grace Period
After sending a message, the daemon waits 30 seconds for Claude to call MCP tools. Adjust in `automation_daemon.py` line 246:
```python
grace_period = 30 # seconds
```
## Monitoring ## Monitoring
### Logs ### Logs
@@ -707,7 +699,7 @@ claude-desktop-automation/
⚠️ **macOS only** - Uses AppleScript and launchd (Linux port would require xdotool/ydotool) ⚠️ **macOS only** - Uses AppleScript and launchd (Linux port would require xdotool/ydotool)
⚠️ **Requires Accessibility** - Special macOS permissions for UI automation ⚠️ **Requires Accessibility** - Special macOS permissions for UI automation
⚠️ **Screen saver handled** - Uses `caffeinate` to wake screen automatically ⚠️ **Screen saver handled** - Uses `caffeinate` to wake screen automatically
⚠️ **Fixed refresh delay** - Waits 10 seconds after CMD+R (configurable if needed) ⚠️ **Fixed refresh delay** - Waits 15 seconds after CMD+R (configurable if needed)
## Future Plans ## Future Plans

79
matrix_helpers.py Normal file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Matrix Helper Utilities
Shared utilities for Matrix client operations, reducing code duplication
across matrix_mcp.py and matrix_integration.py.
"""
import json
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncIterator
try:
from nio import AsyncClient
except ImportError:
raise ImportError(
"matrix-nio not installed. Install with: pip3 install matrix-nio\n"
"Note: Without [e2e] extra, only unencrypted rooms are supported"
)
# Configuration
CREDENTIALS_FILE = Path.home() / ".matrix-credentials.json"
MATRIX_DATA_DIR = Path.home() / ".matrix-data"
logger = logging.getLogger(__name__)
def load_credentials() -> dict:
"""Load Matrix credentials from JSON file"""
if not CREDENTIALS_FILE.exists():
raise FileNotFoundError(
f"Credentials file not found: {CREDENTIALS_FILE}\n"
f"Run setup_matrix.sh to create credentials"
)
try:
with open(CREDENTIALS_FILE, 'r') as f:
return json.load(f)
except Exception as e:
logger.error(f"Failed to load credentials: {e}")
raise
@asynccontextmanager
async def matrix_client() -> AsyncIterator[AsyncClient]:
"""
Async context manager for Matrix client operations.
Handles client setup, session restoration, and cleanup automatically.
Ensures client.close() is always called, preventing resource leaks.
Usage:
async with matrix_client() as client:
response = await client.room_send(...)
Yields:
AsyncClient: Configured and authenticated Matrix client
"""
creds = load_credentials()
# Create data directory for E2EE store
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session from credentials
client.access_token = creds['access_token']
client.device_id = creds['device_id']
try:
yield client
finally:
await client.close()

View File

@@ -449,7 +449,7 @@ class MatrixMonitor:
# Trigger wake if rate limit allows # Trigger wake if rate limit allows
await self._maybe_trigger_wake() await self._maybe_trigger_wake()
else: else:
logger.warning(f"Failed to download/compress image: {event.body}") logger.warning(f"Image rejected (too large or download failed): {event.body}")
except Exception as e: except Exception as e:
logger.error(f"Error processing image: {e}") logger.error(f"Error processing image: {e}")
@@ -596,7 +596,19 @@ class MatrixMonitor:
buffer = BytesIO() buffer = BytesIO()
final_img = img.resize((800, 600), Image.Resampling.LANCZOS) final_img = img.resize((800, 600), Image.Resampling.LANCZOS)
final_img.save(buffer, format='JPEG', quality=50, optimize=True) final_img.save(buffer, format='JPEG', quality=50, optimize=True)
return buffer.getvalue() final_data = buffer.getvalue()
# Final size validation - reject if still too large
final_base64 = base64.b64encode(final_data)
final_size_mb = len(final_base64) / (1024 * 1024)
if final_size_mb > target_size_mb:
logger.error(
f"Image still too large after all compression attempts: "
f"{final_size_mb:.2f}MB base64 (limit: {target_size_mb}MB)"
)
return None
return final_data
except Exception as e: except Exception as e:
logger.error(f"Compression failed: {e}") logger.error(f"Compression failed: {e}")

View File

@@ -33,6 +33,8 @@ from typing import List, Optional, Dict, Any
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.utilities.types import Image as MCPImage from mcp.server.fastmcp.utilities.types import Image as MCPImage
from matrix_helpers import matrix_client, load_credentials as load_credentials_helper
try: try:
from nio import ( from nio import (
AsyncClient, AsyncClient,
@@ -429,6 +431,7 @@ async def matrix_send_image(room_id: str, file_path: str) -> str:
from pathlib import Path from pathlib import Path
from io import BytesIO from io import BytesIO
from PIL import Image from PIL import Image
import aiohttp
# Resolve path # Resolve path
path = Path(file_path).expanduser().resolve() path = Path(file_path).expanduser().resolve()
@@ -455,27 +458,13 @@ async def matrix_send_image(room_id: str, file_path: str) -> str:
width, height = None, None width, height = None, None
mime_type = 'image/jpeg' mime_type = 'image/jpeg'
# Load credentials # Load credentials for upload URL
creds = load_credentials() creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Upload image to Matrix homeserver using aiohttp directly
# (nio's upload has timeout issues)
filename = path.name filename = path.name
logger.info(f"Uploading image from file: {filename} ({len(image_data)} bytes)") logger.info(f"Uploading image from file: {filename} ({len(image_data)} bytes)")
import aiohttp # Upload image to Matrix homeserver using aiohttp directly
# (nio's upload has timeout issues)
upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}" upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}"
headers = { headers = {
"Authorization": f"Bearer {creds['access_token']}", "Authorization": f"Bearer {creds['access_token']}",
@@ -490,11 +479,10 @@ async def matrix_send_image(room_id: str, file_path: str) -> str:
logger.info(f"Upload successful: {content_uri}") logger.info(f"Upload successful: {content_uri}")
else: else:
error_text = await resp.text() error_text = await resp.text()
await client.close()
logger.error(f"Upload failed: {resp.status} - {error_text}") logger.error(f"Upload failed: {resp.status} - {error_text}")
return f"Error uploading image: {resp.status} - {error_text}" return f"Error uploading image: {resp.status} - {error_text}"
# Send image message # Send image message using context manager
content = { content = {
"msgtype": "m.image", "msgtype": "m.image",
"body": filename, "body": filename,
@@ -509,14 +497,13 @@ async def matrix_send_image(room_id: str, file_path: str) -> str:
content["info"]["w"] = width content["info"]["w"] = width
content["info"]["h"] = height content["info"]["h"] = height
async with matrix_client() as client:
response = await client.room_send( response = await client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content=content content=content
) )
await client.close()
if isinstance(response, RoomSendResponse): if isinstance(response, RoomSendResponse):
logger.info(f"Sent image to {room_id}: {filename}") logger.info(f"Sent image to {room_id}: {filename}")
return f"✓ Image sent to room: {filename} (event ID: {response.event_id})" return f"✓ Image sent to room: {filename} (event ID: {response.event_id})"
@@ -554,6 +541,7 @@ async def matrix_send_voice(room_id: str, file_path: str) -> str:
try: try:
from pathlib import Path from pathlib import Path
import wave import wave
import aiohttp
# Resolve path # Resolve path
path = Path(file_path).expanduser().resolve() path = Path(file_path).expanduser().resolve()
@@ -595,27 +583,13 @@ async def matrix_send_voice(room_id: str, file_path: str) -> str:
except Exception as e: except Exception as e:
logger.warning(f"Could not extract WAV duration: {e}") logger.warning(f"Could not extract WAV duration: {e}")
# Load credentials # Load credentials for upload URL
creds = load_credentials() creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Upload audio to Matrix homeserver using aiohttp directly
# (nio's upload has timeout issues)
filename = path.name filename = path.name
logger.info(f"Uploading voice message from file: {filename} ({len(audio_data)} bytes)") logger.info(f"Uploading voice message from file: {filename} ({len(audio_data)} bytes)")
import aiohttp # Upload audio to Matrix homeserver using aiohttp directly
# (nio's upload has timeout issues)
upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}" upload_url = f"{creds['homeserver']}/_matrix/media/v3/upload?filename={filename}"
headers = { headers = {
"Authorization": f"Bearer {creds['access_token']}", "Authorization": f"Bearer {creds['access_token']}",
@@ -630,11 +604,10 @@ async def matrix_send_voice(room_id: str, file_path: str) -> str:
logger.info(f"Upload successful: {content_uri}") logger.info(f"Upload successful: {content_uri}")
else: else:
error_text = await resp.text() error_text = await resp.text()
await client.close()
logger.error(f"Upload failed: {resp.status} - {error_text}") logger.error(f"Upload failed: {resp.status} - {error_text}")
return f"Error uploading audio: {resp.status} - {error_text}" return f"Error uploading audio: {resp.status} - {error_text}"
# Send voice message # Send voice message using context manager
content = { content = {
"msgtype": "m.audio", "msgtype": "m.audio",
"body": filename, "body": filename,
@@ -649,14 +622,13 @@ async def matrix_send_voice(room_id: str, file_path: str) -> str:
if duration_ms: if duration_ms:
content["info"]["duration"] = duration_ms content["info"]["duration"] = duration_ms
async with matrix_client() as client:
response = await client.room_send( response = await client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content=content content=content
) )
await client.close()
if isinstance(response, RoomSendResponse): if isinstance(response, RoomSendResponse):
logger.info(f"Sent voice message to {room_id}: {filename}") logger.info(f"Sent voice message to {room_id}: {filename}")
return f"✓ Voice message sent to room: {filename} (event ID: {response.event_id})" return f"✓ Voice message sent to room: {filename} (event ID: {response.event_id})"
@@ -713,21 +685,6 @@ async def matrix_send_message(room_id: str, message: str) -> str:
matrix_send_message("!abc123:matrix.org", "This is **bold** and *italic*") matrix_send_message("!abc123:matrix.org", "This is **bold** and *italic*")
""" """
try: try:
# Load credentials
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Convert markdown to HTML # Convert markdown to HTML
html_body = _convert_markdown_to_html(message) html_body = _convert_markdown_to_html(message)
@@ -742,15 +699,13 @@ async def matrix_send_message(room_id: str, message: str) -> str:
content["format"] = "org.matrix.custom.html" content["format"] = "org.matrix.custom.html"
content["formatted_body"] = html_body content["formatted_body"] = html_body
# Send message async with matrix_client() as client:
response = await client.room_send( response = await client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content=content content=content
) )
await client.close()
if isinstance(response, RoomSendResponse): if isinstance(response, RoomSendResponse):
logger.info(f"Sent message to {room_id}: {message[:50]}...") logger.info(f"Sent message to {room_id}: {message[:50]}...")
return f"✓ Message sent to room (event ID: {response.event_id})" return f"✓ Message sent to room (event ID: {response.event_id})"
@@ -785,21 +740,6 @@ async def matrix_send_emote(room_id: str, action: str) -> str:
matrix_send_emote("!abc123:matrix.org", "is **thinking** carefully") matrix_send_emote("!abc123:matrix.org", "is **thinking** carefully")
""" """
try: try:
# Load credentials
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Convert markdown to HTML # Convert markdown to HTML
html_body = _convert_markdown_to_html(action) html_body = _convert_markdown_to_html(action)
@@ -814,15 +754,13 @@ async def matrix_send_emote(room_id: str, action: str) -> str:
content["format"] = "org.matrix.custom.html" content["format"] = "org.matrix.custom.html"
content["formatted_body"] = html_body content["formatted_body"] = html_body
# Send message async with matrix_client() as client:
response = await client.room_send( response = await client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content=content content=content
) )
await client.close()
if isinstance(response, RoomSendResponse): if isinstance(response, RoomSendResponse):
logger.info(f"Sent emote to {room_id}: {action[:50]}...") logger.info(f"Sent emote to {room_id}: {action[:50]}...")
return f"✓ Emote sent to room (event ID: {response.event_id})" return f"✓ Emote sent to room (event ID: {response.event_id})"
@@ -893,7 +831,7 @@ def get_matrix_status() -> str:
last_wake = datetime.fromisoformat(last_wake_str) last_wake = datetime.fromisoformat(last_wake_str)
time_ago = datetime.now() - last_wake time_ago = datetime.now() - last_wake
status_lines.append(f"Last Matrix wake: {last_wake.strftime('%Y-%m-%d %H:%M:%S')} ({int(time_ago.total_seconds()/60)} min ago)") status_lines.append(f"Last Matrix wake: {last_wake.strftime('%Y-%m-%d %H:%M:%S')} ({int(time_ago.total_seconds()/60)} min ago)")
except: except Exception:
status_lines.append(f"Last Matrix wake: {last_wake_str}") status_lines.append(f"Last Matrix wake: {last_wake_str}")
else: else:
status_lines.append("Last Matrix wake: Never") status_lines.append("Last Matrix wake: Never")
@@ -908,7 +846,7 @@ def get_matrix_status() -> str:
else: else:
remaining = 120 - elapsed remaining = 120 - elapsed
status_lines.append(f"Rate limit: Active ({int(remaining)}s remaining)") status_lines.append(f"Rate limit: Active ({int(remaining)}s remaining)")
except: except Exception:
status_lines.append("Rate limit: Unknown") status_lines.append("Rate limit: Unknown")
else: else:
status_lines.append("Rate limit: OK (can wake)") status_lines.append("Rate limit: OK (can wake)")
@@ -1043,26 +981,10 @@ async def join_matrix_room(room_id: str) -> str:
join_matrix_room("!abc123:matrix.org") join_matrix_room("!abc123:matrix.org")
""" """
try: try:
# Load credentials
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Join room
logger.info(f"Attempting to join room: {room_id}") logger.info(f"Attempting to join room: {room_id}")
response = await client.join(room_id)
await client.close() async with matrix_client() as client:
response = await client.join(room_id)
if isinstance(response, JoinResponse): if isinstance(response, JoinResponse):
logger.info(f"Successfully joined room: {room_id}") logger.info(f"Successfully joined room: {room_id}")
@@ -1099,26 +1021,10 @@ async def leave_matrix_room(room_id: str) -> str:
leave_matrix_room("!abc123:matrix.org") leave_matrix_room("!abc123:matrix.org")
""" """
try: try:
# Load credentials
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Leave room
logger.info(f"Attempting to leave room: {room_id}") logger.info(f"Attempting to leave room: {room_id}")
response = await client.room_leave(room_id)
await client.close() async with matrix_client() as client:
response = await client.room_leave(room_id)
if isinstance(response, RoomLeaveResponse): if isinstance(response, RoomLeaveResponse):
logger.info(f"Successfully left room: {room_id}") logger.info(f"Successfully left room: {room_id}")
@@ -1145,31 +1051,19 @@ async def list_matrix_rooms() -> str:
Formatted list of rooms Formatted list of rooms
""" """
try: try:
# Load credentials # Get room whitelist from credentials
creds = load_credentials() creds = load_credentials()
whitelist = set(creds.get('room_whitelist', []))
# Create Matrix client async with matrix_client() as client:
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Sync to get rooms # Sync to get rooms
await client.sync(timeout=30000) await client.sync(timeout=30000)
# Get room whitelist
whitelist = set(creds.get('room_whitelist', []))
# Format room list # Format room list
rooms_lines = ["Matrix Rooms:"] rooms_lines = ["Matrix Rooms:"]
rooms_lines.append("=" * 60) rooms_lines.append("=" * 60)
room_count = len(client.rooms)
for room_id, room in client.rooms.items(): for room_id, room in client.rooms.items():
# Check if room is whitelisted # Check if room is whitelisted
monitored = "" if (not whitelist or room_id in whitelist) else "" monitored = "" if (not whitelist or room_id in whitelist) else ""
@@ -1179,9 +1073,7 @@ async def list_matrix_rooms() -> str:
rooms_lines.append(f" Members: {len(room.users)}") rooms_lines.append(f" Members: {len(room.users)}")
rooms_lines.append("") rooms_lines.append("")
await client.close() logger.info(f"Listed {room_count} Matrix rooms")
logger.info(f"Listed {len(client.rooms)} Matrix rooms")
return "\n".join(rooms_lines) return "\n".join(rooms_lines)
except Exception as e: except Exception as e:
@@ -1208,21 +1100,6 @@ async def matrix_reply_to_message(room_id: str, event_id: str, message: str) ->
reply_to_message("!abc:matrix.org", "$event123", "Thanks for the info!") reply_to_message("!abc:matrix.org", "$event123", "Thanks for the info!")
""" """
try: try:
# Load credentials
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Send threaded reply # Send threaded reply
content = { content = {
"msgtype": "m.text", "msgtype": "m.text",
@@ -1234,14 +1111,13 @@ async def matrix_reply_to_message(room_id: str, event_id: str, message: str) ->
} }
} }
async with matrix_client() as client:
response = await client.room_send( response = await client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content=content content=content
) )
await client.close()
if isinstance(response, RoomSendResponse): if isinstance(response, RoomSendResponse):
logger.info(f"Sent reply to {event_id} in {room_id}") logger.info(f"Sent reply to {event_id} in {room_id}")
return f"✓ Reply sent (event ID: {response.event_id})" return f"✓ Reply sent (event ID: {response.event_id})"
@@ -1274,21 +1150,6 @@ async def matrix_react_to_message(room_id: str, event_id: str, emoji: str) -> st
react_to_message("!abc:matrix.org", "$event123", "❤️") react_to_message("!abc:matrix.org", "$event123", "❤️")
""" """
try: try:
# Load credentials
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Send reaction # Send reaction
content = { content = {
"m.relates_to": { "m.relates_to": {
@@ -1298,14 +1159,13 @@ async def matrix_react_to_message(room_id: str, event_id: str, emoji: str) -> st
} }
} }
async with matrix_client() as client:
response = await client.room_send( response = await client.room_send(
room_id=room_id, room_id=room_id,
message_type="m.reaction", message_type="m.reaction",
content=content content=content
) )
await client.close()
if isinstance(response, RoomSendResponse): if isinstance(response, RoomSendResponse):
logger.info(f"Sent reaction {emoji} to {event_id}") logger.info(f"Sent reaction {emoji} to {event_id}")
return f"✓ Reacted with {emoji}" return f"✓ Reacted with {emoji}"
@@ -1337,21 +1197,7 @@ async def matrix_get_user_profile(user_id: str) -> Dict[str, Any]:
get_user_profile("@friend:matrix.org") get_user_profile("@friend:matrix.org")
""" """
try: try:
# Load credentials async with matrix_client() as client:
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Get display name # Get display name
display_name_response = await client.get_displayname(user_id) display_name_response = await client.get_displayname(user_id)
display_name = None display_name = None
@@ -1364,8 +1210,6 @@ async def matrix_get_user_profile(user_id: str) -> Dict[str, Any]:
if isinstance(avatar_response, ProfileGetAvatarResponse): if isinstance(avatar_response, ProfileGetAvatarResponse):
avatar_url = avatar_response.avatar_url avatar_url = avatar_response.avatar_url
await client.close()
profile = { profile = {
"user_id": user_id, "user_id": user_id,
"display_name": display_name or user_id, "display_name": display_name or user_id,
@@ -1403,26 +1247,9 @@ async def matrix_set_presence(status: str, message: str = "") -> str:
if status not in valid_statuses: if status not in valid_statuses:
return f"Error: status must be one of {valid_statuses}" return f"Error: status must be one of {valid_statuses}"
# Load credentials async with matrix_client() as client:
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Set presence
await client.set_presence(status, message if message else None) await client.set_presence(status, message if message else None)
await client.close()
logger.info(f"Set presence to {status}") logger.info(f"Set presence to {status}")
return f"✓ Presence set to {status}" + (f": {message}" if message else "") return f"✓ Presence set to {status}" + (f": {message}" if message else "")
@@ -1482,23 +1309,10 @@ async def send_matrix_file(room_id: str, file: Any, filename: str, mime_type: st
if not mime_type: if not mime_type:
mime_type = 'application/octet-stream' mime_type = 'application/octet-stream'
# Load credentials
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Upload file
logger.info(f"Uploading file: {filename} ({len(file_data)} bytes)") logger.info(f"Uploading file: {filename} ({len(file_data)} bytes)")
async with matrix_client() as client:
# Upload file
upload_response = await client.upload( upload_response = await client.upload(
data_provider=lambda *args: file_data, data_provider=lambda *args: file_data,
content_type=mime_type, content_type=mime_type,
@@ -1507,7 +1321,6 @@ async def send_matrix_file(room_id: str, file: Any, filename: str, mime_type: st
) )
if isinstance(upload_response, UploadError): if isinstance(upload_response, UploadError):
await client.close()
return f"Error uploading file: {upload_response.message}" return f"Error uploading file: {upload_response.message}"
# Send file message # Send file message
@@ -1527,8 +1340,6 @@ async def send_matrix_file(room_id: str, file: Any, filename: str, mime_type: st
content=content content=content
) )
await client.close()
if isinstance(response, RoomSendResponse): if isinstance(response, RoomSendResponse):
logger.info(f"Sent file to {room_id}: {filename}") logger.info(f"Sent file to {room_id}: {filename}")
return f"✓ File sent: {filename} (event ID: {response.event_id})" return f"✓ File sent: {filename} (event ID: {response.event_id})"
@@ -1619,27 +1430,12 @@ async def matrix_get_room_members(room_id: str) -> List[Dict[str, Any]]:
get_room_members("!abc:matrix.org") get_room_members("!abc:matrix.org")
""" """
try: try:
# Load credentials async with matrix_client() as client:
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Sync to get rooms # Sync to get rooms
await client.sync(timeout=30000) await client.sync(timeout=30000)
# Find the room # Find the room
if room_id not in client.rooms: if room_id not in client.rooms:
await client.close()
return [{"error": f"Room not found: {room_id}"}] return [{"error": f"Room not found: {room_id}"}]
room = client.rooms[room_id] room = client.rooms[room_id]
@@ -1653,8 +1449,6 @@ async def matrix_get_room_members(room_id: str) -> List[Dict[str, Any]]:
"power_level": member.power_level, "power_level": member.power_level,
}) })
await client.close()
logger.info(f"Retrieved {len(members)} members from {room_id}") logger.info(f"Retrieved {len(members)} members from {room_id}")
return members return members
@@ -1681,30 +1475,13 @@ async def matrix_set_room_topic(room_id: str, topic: str) -> str:
set_room_topic("!abc:matrix.org", "Discussion about AI projects") set_room_topic("!abc:matrix.org", "Discussion about AI projects")
""" """
try: try:
# Load credentials async with matrix_client() as client:
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Set room topic
await client.room_put_state( await client.room_put_state(
room_id=room_id, room_id=room_id,
event_type="m.room.topic", event_type="m.room.topic",
content={"topic": topic} content={"topic": topic}
) )
await client.close()
logger.info(f"Set topic for {room_id}: {topic[:50]}...") logger.info(f"Set topic for {room_id}: {topic[:50]}...")
return f"✓ Room topic updated" return f"✓ Room topic updated"
@@ -1730,27 +1507,12 @@ async def matrix_get_room_state(room_id: str) -> Dict[str, Any]:
get_room_state("!abc:matrix.org") get_room_state("!abc:matrix.org")
""" """
try: try:
# Load credentials async with matrix_client() as client:
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Sync to get rooms # Sync to get rooms
await client.sync(timeout=30000) await client.sync(timeout=30000)
# Find the room # Find the room
if room_id not in client.rooms: if room_id not in client.rooms:
await client.close()
return {"error": f"Room not found: {room_id}"} return {"error": f"Room not found: {room_id}"}
room = client.rooms[room_id] room = client.rooms[room_id]
@@ -1765,8 +1527,6 @@ async def matrix_get_room_state(room_id: str) -> Dict[str, Any]:
"room_version": getattr(room, 'room_version', 'unknown'), "room_version": getattr(room, 'room_version', 'unknown'),
} }
await client.close()
logger.info(f"Retrieved state for {room_id}") logger.info(f"Retrieved state for {room_id}")
return state return state
@@ -1794,21 +1554,7 @@ async def matrix_search_messages(room_id: str, query: str, limit: int = 10) -> L
search_messages("!abc:matrix.org", "meeting", limit=5) search_messages("!abc:matrix.org", "meeting", limit=5)
""" """
try: try:
# Load credentials async with matrix_client() as client:
creds = load_credentials()
# Create Matrix client
MATRIX_DATA_DIR.mkdir(exist_ok=True)
client = AsyncClient(
homeserver=creds['homeserver'],
user=creds['user_id'],
store_path=str(MATRIX_DATA_DIR),
)
# Restore session
client.access_token = creds['access_token']
client.device_id = creds['device_id']
# Get room messages # Get room messages
response = await client.room_messages( response = await client.room_messages(
room_id=room_id, room_id=room_id,
@@ -1832,8 +1578,6 @@ async def matrix_search_messages(room_id: str, query: str, limit: int = 10) -> L
if len(results) >= limit: if len(results) >= limit:
break break
await client.close()
logger.info(f"Found {len(results)} messages matching '{query}'") logger.info(f"Found {len(results)} messages matching '{query}'")
return results return results

View File

@@ -81,7 +81,7 @@ def send_refresh(delay_seconds=REFRESH_DELAY_SECONDS):
Send CMD+R to refresh and wait for completion Send CMD+R to refresh and wait for completion
Args: Args:
delay_seconds: Time to wait after CMD+R (default: 10) delay_seconds: Time to wait after CMD+R (default: 15)
Returns: Returns:
bool: True if successful bool: True if successful

View File

@@ -9,11 +9,15 @@ Day 83: Added movement tracking to filter static false positives (posters!)
""" """
import json import json
import logging
import sqlite3 import sqlite3
import requests import requests
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
# Setup logging
logger = logging.getLogger("vixy_status")
# Service endpoints # Service endpoints
ENVIRO_URL = "http://eye1.local:8767" ENVIRO_URL = "http://eye1.local:8767"
OAK_URL = "http://head-vixy.local:8100" OAK_URL = "http://head-vixy.local:8100"
@@ -90,8 +94,8 @@ def get_enviro_status() -> str:
humidity = data.get('humidity', 0) humidity = data.get('humidity', 0)
light = data.get('light', 0) light = data.get('light', 0)
return f"Basement: {temp_f:.1f}F, {humidity:.1f}% humidity, {light:.1f} lux" return f"Basement: {temp_f:.1f}F, {humidity:.1f}% humidity, {light:.1f} lux"
except Exception: except Exception as e:
pass logger.warning(f"Enviro service unavailable: {e}")
return "Basement: sensors unavailable" return "Basement: sensors unavailable"
@@ -139,8 +143,8 @@ def get_presence_status() -> str:
return f"Foxy: away (last seen {last_seen:.0f}s ago)" return f"Foxy: away (last seen {last_seen:.0f}s ago)"
else: else:
return "Foxy: away" return "Foxy: away"
except Exception: except Exception as e:
pass logger.warning(f"OAK-D presence service unavailable: {e}")
return None # Return None to omit line if camera unavailable return None # Return None to omit line if camera unavailable
@@ -167,8 +171,8 @@ def get_sound_status() -> str:
return f"{category} ({top_score}% {classes_str})" return f"{category} ({top_score}% {classes_str})"
else: else:
return f"{category}" return f"{category}"
except Exception: except Exception as e:
pass logger.warning(f"Headmic sound service unavailable: {e}")
return None # Return None to omit line if service unavailable return None # Return None to omit line if service unavailable
@@ -186,52 +190,117 @@ def get_matrix_status() -> str:
return f"Matrix: {len(unprocessed)} new message(s)" return f"Matrix: {len(unprocessed)} new message(s)"
else: else:
return "Matrix: no new messages" return "Matrix: no new messages"
except Exception: except Exception as e:
pass logger.warning(f"Matrix status unavailable: {e}")
return "Matrix: status unavailable" return "Matrix: status unavailable"
def get_vision_status() -> str: def get_vision_status() -> str:
"""Get vision/motion event status from SQLite database""" """Get vision/motion event status from SQLite database.
Uses object detection data when available to show what was seen
(person, cat, etc.) rather than just raw motion counts.
"""
try: try:
if not EVENTS_DB.exists(): if not EVENTS_DB.exists():
return "Vision: no events database" return "no events database"
conn = sqlite3.connect(str(EVENTS_DB))
conn.row_factory = sqlite3.Row
# Get events from last 2 hours # Get events from last 2 hours
cutoff = (datetime.now() - timedelta(hours=2)).isoformat() cutoff = (datetime.now() - timedelta(hours=2)).isoformat()
with sqlite3.connect(str(EVENTS_DB)) as conn:
conn.row_factory = sqlite3.Row
# Check if detections column exists
columns = [row[1] for row in conn.execute("PRAGMA table_info(events)").fetchall()]
has_detections = "detections" in columns
if has_detections:
cursor = conn.execute( cursor = conn.execute(
"""SELECT camera_id, annotation FROM events """SELECT camera_id, event_type, detections, timestamp FROM events
WHERE timestamp > ?
ORDER BY timestamp DESC""",
(cutoff,)
)
else:
cursor = conn.execute(
"""SELECT camera_id, event_type, NULL as detections, timestamp FROM events
WHERE timestamp > ? WHERE timestamp > ?
ORDER BY timestamp DESC""", ORDER BY timestamp DESC""",
(cutoff,) (cutoff,)
) )
events = cursor.fetchall() events = cursor.fetchall()
conn.close()
if not events: if not events:
return "Vision: no recent motion" return "no recent activity"
# Count labels per camera, track most recent detection
# Structure: {camera: {label: count}}
camera_labels = {}
camera_motion_only = {}
latest_detection = None # (label, camera, timestamp)
# Count by camera
by_camera = {}
unannotated = 0
for event in events: for event in events:
cam = event['camera_id'] or 'unknown' cam = event['camera_id'] or 'unknown'
by_camera[cam] = by_camera.get(cam, 0) + 1
if not event['annotation']:
unannotated += 1
total = len(events) # Parse detections JSON
camera_breakdown = ", ".join(f"{cam}: {count}" for cam, count in by_camera.items()) dets = None
if event['detections']:
try:
dets = json.loads(event['detections'])
except (json.JSONDecodeError, TypeError):
pass
return f"{camera_breakdown}" if dets:
if cam not in camera_labels:
camera_labels[cam] = {}
for d in dets:
label = d.get('label', 'unknown')
camera_labels[cam][label] = camera_labels[cam].get(label, 0) + 1
# Track most recent detection (first one since ordered DESC)
if latest_detection is None:
latest_detection = (dets[0].get('label', 'unknown'), cam, event['timestamp'])
else:
camera_motion_only[cam] = camera_motion_only.get(cam, 0) + 1
# Format per-camera summaries
cam_parts = []
all_cams = sorted(set(list(camera_labels.keys()) + list(camera_motion_only.keys())))
for cam in all_cams:
labels = camera_labels.get(cam, {})
motion_count = camera_motion_only.get(cam, 0)
parts = []
if labels:
# Sort by count descending
for label, count in sorted(labels.items(), key=lambda x: -x[1]):
parts.append(f"{count} {label}")
if motion_count:
parts.append(f"{motion_count} motion")
cam_parts.append(f"{cam}: {', '.join(parts)}")
result = " | ".join(cam_parts)
# Add "last seen" for most recent detection
if latest_detection:
label, cam, ts = latest_detection
try:
event_time = datetime.fromisoformat(ts.replace('Z', '+00:00'))
now = datetime.now(event_time.tzinfo)
mins_ago = int((now - event_time).total_seconds() / 60)
if mins_ago < 1:
result += f" (last: {label} in {cam}, just now)"
else:
result += f" (last: {label} in {cam}, {mins_ago}m ago)"
except Exception:
result += f" (last: {label} in {cam})"
return result
except Exception as e: except Exception as e:
return f"Vision: error ({e})" return f"error ({e})"
def format_status_for_wakeup() -> str: def format_status_for_wakeup() -> str:

View File

@@ -221,7 +221,7 @@ def get_status() -> str:
last_wake = datetime.fromisoformat(state['last_wake']) last_wake = datetime.fromisoformat(state['last_wake'])
time_ago = datetime.now() - last_wake time_ago = datetime.now() - last_wake
status_lines.append(f"Last wake: {last_wake.strftime('%Y-%m-%d %H:%M:%S')} ({int(time_ago.total_seconds()/60)} min ago)") status_lines.append(f"Last wake: {last_wake.strftime('%Y-%m-%d %H:%M:%S')} ({int(time_ago.total_seconds()/60)} min ago)")
except: except Exception:
status_lines.append(f"Last wake: {state.get('last_wake')}") status_lines.append(f"Last wake: {state.get('last_wake')}")
else: else:
status_lines.append("Last wake: Never (daemon just started)") status_lines.append("Last wake: Never (daemon just started)")
@@ -235,7 +235,7 @@ def get_status() -> str:
status_lines.append(f"Next wake: {next_wake.strftime('%Y-%m-%d %H:%M:%S')} (in {int(time_until.total_seconds()/60)} min)") status_lines.append(f"Next wake: {next_wake.strftime('%Y-%m-%d %H:%M:%S')} (in {int(time_until.total_seconds()/60)} min)")
else: else:
status_lines.append(f"Next wake: OVERDUE (was {next_wake.strftime('%Y-%m-%d %H:%M:%S')})") status_lines.append(f"Next wake: OVERDUE (was {next_wake.strftime('%Y-%m-%d %H:%M:%S')})")
except: except Exception:
status_lines.append(f"Next wake: {state.get('next_wake_timestamp')}") status_lines.append(f"Next wake: {state.get('next_wake_timestamp')}")
else: else:
interval = state.get('interval_minutes', 60) interval = state.get('interval_minutes', 60)