From 2e5f2bb92cf97272d6116e20b0cf8f35e2fe161b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 00:20:42 +0000 Subject: [PATCH 1/3] Add home security AI agent with iOS-compatible mobile API - Create home_security_agent with Google ADK integration - Smart lock management (view status, lock/unlock doors) - Camera monitoring (status, snapshots, motion events) - Sensor monitoring (motion, door/window, smoke, water leak) - Security system control (arm/disarm with modes) - AI chat interface for natural language interaction - Build FastAPI mobile backend for iOS/Android apps - RESTful API endpoints for all device operations - Quick action endpoints (goodnight, leaving, arriving routines) - Push notification registration and settings - Pydantic models for request/response validation - Full Swagger/OpenAPI documentation at /docs - Add comprehensive test suite (52 tests) - Tests for all device tools - Tests for all API endpoints - Tests for chat functionality - Update documentation with usage examples - API endpoint reference - Swift integration examples - New Makefile targets Co-authored-by: Nathan Faggian --- Makefile | 9 + README.md | 169 +++- pyproject.toml | 2 +- src/__init__.py | 0 src/agents/__init__.py | 0 src/agents/home_security_agent/__init__.py | 5 + src/agents/home_security_agent/agent.py | 80 ++ .../home_security_agent/tools/__init__.py | 35 + .../home_security_agent/tools/device_tools.py | 739 ++++++++++++++++++ src/mobile_api/__init__.py | 5 + src/mobile_api/app.py | 601 ++++++++++++++ src/mobile_api/models.py | 244 ++++++ src/mobile_api/server.py | 17 + tests/test_home_security_agent.py | 267 +++++++ tests/test_mobile_api.py | 375 +++++++++ 15 files changed, 2524 insertions(+), 24 deletions(-) create mode 100644 src/__init__.py create mode 100644 src/agents/__init__.py create mode 100644 src/agents/home_security_agent/__init__.py create mode 100644 src/agents/home_security_agent/agent.py create mode 100644 src/agents/home_security_agent/tools/__init__.py create mode 100644 src/agents/home_security_agent/tools/device_tools.py create mode 100644 src/mobile_api/__init__.py create mode 100644 src/mobile_api/app.py create mode 100644 src/mobile_api/models.py create mode 100644 src/mobile_api/server.py create mode 100644 tests/test_home_security_agent.py create mode 100644 tests/test_mobile_api.py diff --git a/Makefile b/Makefile index 455dbd0..98f74f6 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,15 @@ web: ## Run the ADK web demo server api_server: ## Run the ADK FastAPI server @uv run adk api_server src/agents/ +.PHONY: mobile-api +mobile-api: ## Run the Home Security Mobile API server + @echo "๐Ÿš€ Starting Home Security Mobile API server..." + @PYTHONPATH=$(ROOT_DIR) uv run uvicorn src.mobile_api.app:app --host 0.0.0.0 --port 8000 --reload + +.PHONY: home-security-web +home-security-web: ## Run the Home Security Agent via ADK web interface + @uv run adk web --reload src/agents/home_security_agent/ + .PHONY: prefect-server prefect-server: ## Start Prefect server and serve flows @./scripts/start_prefect.sh diff --git a/README.md b/README.md index 6f250e5..7304707 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ echo 'OLLAMA_API_BASE="http://localhost:11434"' > .env # Run agents via web interface make web +# Run Home Security Mobile API +make mobile-api + # Run Prefect workflows make prefect-server ``` @@ -21,35 +24,96 @@ make prefect-server ## Components ### Agents -- **`doc_agent`**: Technical writing assistant with Google Docs integration - - Uses local Ollama models (`gpt-oss:20b`) via LiteLLM - - Includes Google Docs tools for creating and formatting documents + +#### Home Security Agent (NEW) +An AI-powered home security monitoring agent that manages smart locks, cameras, and sensors. + +**Features:** +- **Smart Lock Management**: View status, lock/unlock doors remotely +- **Camera Monitoring**: View camera status, capture snapshots, review motion events +- **Sensor Monitoring**: Track motion, door/window, smoke, and water leak sensors +- **Security System Control**: Arm/disarm with multiple modes (away, home, night) +- **AI Chat Interface**: Natural language interaction for security queries +- **Quick Actions**: Pre-built routines for common scenarios (goodnight, leaving, arriving) +- **Push Notifications**: Register devices for real-time security alerts + +**Run the Mobile API:** +```bash +make mobile-api +# API available at http://localhost:8000 +# Swagger docs at http://localhost:8000/docs +``` + +**Example API Calls:** +```bash +# Get home security summary +curl http://localhost:8000/api/summary + +# Get all locks status +curl http://localhost:8000/api/locks + +# Lock a specific door +curl -X POST http://localhost:8000/api/locks/front_door/lock + +# Chat with the AI assistant +curl -X POST http://localhost:8000/api/chat \ + -H "Content-Type: application/json" \ + -d '{"message": "What is going on at home?"}' + +# Execute goodnight routine (lock all doors + arm in night mode) +curl -X POST http://localhost:8000/api/quick-actions/goodnight +``` + +#### Doc Agent +Technical writing assistant with Google Docs integration. +- Uses local Ollama models (`gpt-oss:20b`) via LiteLLM +- Includes Google Docs tools for creating and formatting documents ### Workflows - **`pipeline.py`**: Prefect workflow for orchestrating document creation and content generation - **`serve.py`**: Entry point for serving Prefect flows -### Tools -- **Google Docs Tool** (`doc_agent/tools/google_docs_tool.py`): - - Create new Google Docs - - Write plain text or markdown content - - Convert markdown to formatted Google Docs (headings, lists, bold/italic, links, code blocks, blockquotes, etc.) +### Mobile API +A FastAPI-based REST API designed for iOS/Android mobile app consumption. + +**Endpoints:** +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/summary` | GET | Home security dashboard summary | +| `/api/locks` | GET | All locks status | +| `/api/locks/{id}/lock` | POST | Lock a specific door | +| `/api/locks/{id}/unlock` | POST | Unlock a door (requires confirmation) | +| `/api/locks/lock-all` | POST | Lock all doors | +| `/api/cameras` | GET | All cameras status | +| `/api/cameras/{id}/snapshot` | POST | Capture camera snapshot | +| `/api/cameras/motion-events` | GET | Recent motion detection events | +| `/api/sensors` | GET | All sensors status | +| `/api/security-system` | GET | Security system status | +| `/api/security-system/arm` | POST | Arm security system | +| `/api/security-system/disarm` | POST | Disarm security system | +| `/api/activity` | GET | Recent activity log | +| `/api/chat` | POST | AI assistant chat | +| `/api/quick-actions/goodnight` | POST | Execute goodnight routine | +| `/api/quick-actions/leaving` | POST | Execute leaving home routine | +| `/api/quick-actions/arriving` | POST | Execute arriving home routine | ## Prerequisites -- Python 3.13+ -- Ollama installed and running -- Google OAuth credentials (`credentials.json`) for Google Docs API +- Python 3.10+ +- Ollama installed and running (for AI agents) +- Google OAuth credentials (`credentials.json`) for Google Docs API (optional) ## Commands ```bash -make web # Run ADK web interface -make api_server # Run ADK FastAPI server -make prefect-server # Start Prefect server and serve flows -make prefect-flows # Serve flows (server must be running) -make test # Run tests -make check # Lint and type check +make web # Run ADK web interface (all agents) +make mobile-api # Run Home Security Mobile API server +make home-security-web # Run Home Security Agent via ADK web +make api_server # Run ADK FastAPI server +make prefect-server # Start Prefect server and serve flows +make test # Run tests +make check # Lint and type check +make clean # Remove build artifacts and cache ``` ## Project Structure @@ -57,14 +121,65 @@ make check # Lint and type check ``` src/ โ”œโ”€โ”€ agents/ -โ”‚ โ””โ”€โ”€ doc_agent/ # Technical writing assistant -โ”‚ โ”œโ”€โ”€ agent.py # Agent definition +โ”‚ โ”œโ”€โ”€ doc_agent/ # Technical writing assistant +โ”‚ โ”‚ โ”œโ”€โ”€ agent.py # Agent definition +โ”‚ โ”‚ โ””โ”€โ”€ tools/ +โ”‚ โ”‚ โ””โ”€โ”€ google_docs_tool.py +โ”‚ โ””โ”€โ”€ home_security_agent/ # Home security monitoring agent +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ agent.py # Agent definition with tools โ”‚ โ””โ”€โ”€ tools/ -โ”‚ โ””โ”€โ”€ google_docs_tool.py # Google Docs integration +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ””โ”€โ”€ device_tools.py # Smart device integration +โ”œโ”€โ”€ mobile_api/ # REST API for mobile apps +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ app.py # FastAPI application +โ”‚ โ”œโ”€โ”€ models.py # Pydantic models +โ”‚ โ””โ”€โ”€ server.py # Server entry point โ””โ”€โ”€ workflows/ - โ”œโ”€โ”€ pipeline.py # Main Prefect workflow - โ”œโ”€โ”€ serve.py # Flow serving entry point - โ””โ”€โ”€ discover.py # Flow discovery utility + โ”œโ”€โ”€ pipeline.py # Main Prefect workflow + โ”œโ”€โ”€ serve.py # Flow serving entry point + โ””โ”€โ”€ discover.py # Flow discovery utility +tests/ +โ”œโ”€โ”€ test_home_security_agent.py # Agent tool tests +โ””โ”€โ”€ test_mobile_api.py # API endpoint tests +``` + +## iOS App Integration + +The Mobile API is designed for easy iOS app integration: + +1. **SwiftUI/UIKit**: Use URLSession or Alamofire to call REST endpoints +2. **Widgets**: Use quick action endpoints for iOS widgets +3. **Siri Shortcuts**: Integrate quick actions with iOS Shortcuts app +4. **Push Notifications**: Register device tokens via `/api/notifications/register` + +**Example Swift code:** +```swift +struct HomeSecurityAPI { + let baseURL = "http://your-server:8000" + + func getSummary() async throws -> HomeSummary { + let url = URL(string: "\(baseURL)/api/summary")! + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder().decode(HomeSummary.self, from: data) + } + + func lockAllDoors() async throws { + var request = URLRequest(url: URL(string: "\(baseURL)/api/locks/lock-all")!) + request.httpMethod = "POST" + let (_, _) = try await URLSession.shared.data(for: request) + } + + func chat(message: String) async throws -> ChatResponse { + var request = URLRequest(url: URL(string: "\(baseURL)/api/chat")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(["message": message]) + let (data, _) = try await URLSession.shared.data(for: request) + return try JSONDecoder().decode(ChatResponse.self, from: data) + } +} ``` ## Configuration @@ -72,3 +187,11 @@ src/ - **Ollama**: Set `OLLAMA_API_BASE` in `.env` or environment - **Google Docs**: Place `credentials.json` in project root (token.json auto-generated) - **Prefect UI**: Available at `http://127.0.0.1:4200` (or port specified by `PREFECT_PORT`) +- **Mobile API**: Default port 8000, configurable via uvicorn options + +## Security Notes + +- In production, add authentication (JWT/OAuth2) to the Mobile API +- Use HTTPS for all API communications +- Store sensitive credentials in environment variables +- The current implementation uses simulated device data for demonstration diff --git a/pyproject.toml b/pyproject.toml index 4a955ba..0b704b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ classifiers = [ ] keywords = ['agent-catalog', 'adk', 'agent'] readme = "README.md" -requires-python = ">=3.13" +requires-python = ">=3.10" dependencies = [ "google-adk>=1.10.0", "litellm>=1.0.0", diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agents/__init__.py b/src/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agents/home_security_agent/__init__.py b/src/agents/home_security_agent/__init__.py new file mode 100644 index 0000000..ee8e74f --- /dev/null +++ b/src/agents/home_security_agent/__init__.py @@ -0,0 +1,5 @@ +"""Home Security Monitoring Agent - AI-powered smart home security.""" + +from .agent import root_agent + +__all__ = ["root_agent"] diff --git a/src/agents/home_security_agent/agent.py b/src/agents/home_security_agent/agent.py new file mode 100644 index 0000000..efd83c8 --- /dev/null +++ b/src/agents/home_security_agent/agent.py @@ -0,0 +1,80 @@ +"""Home Security Monitoring Agent - AI-powered smart home security assistant.""" + +from google.adk.agents import Agent +from google.adk.models.lite_llm import LiteLlm + +from .tools import ( + get_all_locks_status, + get_lock_status, + lock_door, + unlock_door, + get_all_cameras_status, + get_camera_status, + get_camera_snapshot, + get_motion_events, + get_all_sensors_status, + arm_security_system, + disarm_security_system, + get_security_system_status, + get_recent_activity, + get_home_summary, +) + + +AGENT_INSTRUCTION = """You are a helpful and vigilant home security AI assistant. Your role is to help +homeowners monitor and manage their home security systems including smart locks, cameras, and sensors. + +## Your Capabilities: +1. **Lock Management**: Check lock status, lock/unlock doors remotely +2. **Camera Monitoring**: View camera status, capture snapshots, review motion events +3. **Sensor Monitoring**: Check all sensors (motion, door/window, smoke, water leak) +4. **Security System Control**: Arm/disarm the security system +5. **Activity Tracking**: Review recent security events and activity logs + +## Guidelines: +- Always prioritize security when making recommendations +- Warn users before performing sensitive actions (like unlocking doors) +- Provide clear, concise status updates +- Alert users to any security concerns immediately +- Be proactive about suggesting security improvements +- Use friendly but professional language + +## Response Style: +- Start with the most important information first +- Use clear status indicators (โœ“ for good, โš  for warning, โœ— for issues) +- Provide actionable recommendations when issues are found +- Keep responses mobile-friendly (concise but complete) + +## Security Principles: +- Never unlock doors without explicit confirmation +- Always verify security status before arming the system +- Recommend locking all doors before leaving home +- Suggest reviewing camera footage when motion is detected +- Remind users about low battery sensors + +When asked "what's going on" or for a status update, provide a comprehensive but concise +overview of the home security status including any issues or alerts. +""" + +root_agent = Agent( + model=LiteLlm(model="ollama_chat/gpt-oss:20b"), + name="home_security_agent", + description="An AI assistant that monitors home security including locks, cameras, and sensors.", + instruction=AGENT_INSTRUCTION, + tools=[ + get_all_locks_status, + get_lock_status, + lock_door, + unlock_door, + get_all_cameras_status, + get_camera_status, + get_camera_snapshot, + get_motion_events, + get_all_sensors_status, + arm_security_system, + disarm_security_system, + get_security_system_status, + get_recent_activity, + get_home_summary, + ], +) diff --git a/src/agents/home_security_agent/tools/__init__.py b/src/agents/home_security_agent/tools/__init__.py new file mode 100644 index 0000000..6b872b4 --- /dev/null +++ b/src/agents/home_security_agent/tools/__init__.py @@ -0,0 +1,35 @@ +"""Home security device integration tools.""" + +from .device_tools import ( + get_all_locks_status, + get_lock_status, + lock_door, + unlock_door, + get_all_cameras_status, + get_camera_status, + get_camera_snapshot, + get_motion_events, + get_all_sensors_status, + arm_security_system, + disarm_security_system, + get_security_system_status, + get_recent_activity, + get_home_summary, +) + +__all__ = [ + "get_all_locks_status", + "get_lock_status", + "lock_door", + "unlock_door", + "get_all_cameras_status", + "get_camera_status", + "get_camera_snapshot", + "get_motion_events", + "get_all_sensors_status", + "arm_security_system", + "disarm_security_system", + "get_security_system_status", + "get_recent_activity", + "get_home_summary", +] diff --git a/src/agents/home_security_agent/tools/device_tools.py b/src/agents/home_security_agent/tools/device_tools.py new file mode 100644 index 0000000..fda2abd --- /dev/null +++ b/src/agents/home_security_agent/tools/device_tools.py @@ -0,0 +1,739 @@ +"""Smart home device integration tools for locks, cameras, and sensors.""" + +from datetime import datetime, timedelta +from typing import Any +import random +import uuid + +# Simulated device state storage (in production, this would connect to real APIs) +_device_state: dict[str, Any] = { + "locks": { + "front_door": { + "id": "lock_001", + "name": "Front Door", + "location": "Main Entrance", + "is_locked": True, + "battery_level": 85, + "last_activity": None, + }, + "back_door": { + "id": "lock_002", + "name": "Back Door", + "location": "Kitchen Exit", + "is_locked": True, + "battery_level": 72, + "last_activity": None, + }, + "garage_door": { + "id": "lock_003", + "name": "Garage Door", + "location": "Garage", + "is_locked": False, + "battery_level": 45, + "last_activity": None, + }, + "side_gate": { + "id": "lock_004", + "name": "Side Gate", + "location": "Side Yard", + "is_locked": True, + "battery_level": 90, + "last_activity": None, + }, + }, + "cameras": { + "front_porch": { + "id": "cam_001", + "name": "Front Porch Camera", + "location": "Front Entrance", + "is_online": True, + "is_recording": True, + "motion_detected": False, + "night_vision": True, + "resolution": "1080p", + "last_motion": None, + }, + "backyard": { + "id": "cam_002", + "name": "Backyard Camera", + "location": "Backyard", + "is_online": True, + "is_recording": True, + "motion_detected": True, + "night_vision": True, + "resolution": "4K", + "last_motion": None, + }, + "driveway": { + "id": "cam_003", + "name": "Driveway Camera", + "location": "Driveway", + "is_online": True, + "is_recording": True, + "motion_detected": False, + "night_vision": True, + "resolution": "1080p", + "last_motion": None, + }, + "garage_interior": { + "id": "cam_004", + "name": "Garage Interior", + "location": "Inside Garage", + "is_online": False, + "is_recording": False, + "motion_detected": False, + "night_vision": False, + "resolution": "720p", + "last_motion": None, + }, + }, + "sensors": { + "living_room_motion": { + "id": "sensor_001", + "name": "Living Room Motion", + "type": "motion", + "location": "Living Room", + "is_triggered": False, + "battery_level": 95, + "last_triggered": None, + }, + "front_door_contact": { + "id": "sensor_002", + "name": "Front Door Contact", + "type": "door_window", + "location": "Front Door", + "is_triggered": False, + "battery_level": 88, + "last_triggered": None, + }, + "kitchen_smoke": { + "id": "sensor_003", + "name": "Kitchen Smoke Detector", + "type": "smoke", + "location": "Kitchen", + "is_triggered": False, + "battery_level": 100, + "last_triggered": None, + }, + "basement_water": { + "id": "sensor_004", + "name": "Basement Water Sensor", + "type": "water_leak", + "location": "Basement", + "is_triggered": False, + "battery_level": 78, + "last_triggered": None, + }, + "bedroom_window": { + "id": "sensor_005", + "name": "Master Bedroom Window", + "type": "door_window", + "location": "Master Bedroom", + "is_triggered": False, + "battery_level": 65, + "last_triggered": None, + }, + }, + "security_system": { + "is_armed": False, + "arm_mode": "disarmed", + "last_armed": None, + "last_disarmed": None, + "alarm_triggered": False, + }, + "activity_log": [], +} + + +def _log_activity(event_type: str, device_name: str, details: str) -> None: + """Log an activity event.""" + _device_state["activity_log"].insert(0, { + "id": str(uuid.uuid4()), + "timestamp": datetime.now().isoformat(), + "event_type": event_type, + "device_name": device_name, + "details": details, + }) + # Keep only last 100 events + _device_state["activity_log"] = _device_state["activity_log"][:100] + + +def _simulate_activity() -> None: + """Simulate random activity for demo purposes.""" + now = datetime.now() + + # Set some realistic last activity times + for lock_id, lock in _device_state["locks"].items(): + if lock["last_activity"] is None: + minutes_ago = random.randint(5, 180) + lock["last_activity"] = (now - timedelta(minutes=minutes_ago)).isoformat() + + for cam_id, cam in _device_state["cameras"].items(): + if cam["last_motion"] is None and cam["is_online"]: + minutes_ago = random.randint(1, 60) + cam["last_motion"] = (now - timedelta(minutes=minutes_ago)).isoformat() + + for sensor_id, sensor in _device_state["sensors"].items(): + if sensor["last_triggered"] is None: + minutes_ago = random.randint(10, 240) + sensor["last_triggered"] = (now - timedelta(minutes=minutes_ago)).isoformat() + + +# Initialize with some activity +_simulate_activity() + + +# Lock Tools +def get_all_locks_status() -> dict[str, Any]: + """ + Get the status of all smart locks in your home. + + Returns a dictionary with lock statuses including: + - Lock name and location + - Whether it's locked or unlocked + - Battery level + - Last activity timestamp + """ + locks = {} + for lock_id, lock in _device_state["locks"].items(): + locks[lock_id] = { + "name": lock["name"], + "location": lock["location"], + "is_locked": lock["is_locked"], + "battery_level": lock["battery_level"], + "last_activity": lock["last_activity"], + "status": "Locked" if lock["is_locked"] else "UNLOCKED", + } + + unlocked_count = sum(1 for l in locks.values() if not l["is_locked"]) + + return { + "locks": locks, + "total_locks": len(locks), + "unlocked_count": unlocked_count, + "all_secure": unlocked_count == 0, + "summary": f"{len(locks) - unlocked_count}/{len(locks)} doors locked" + } + + +def get_lock_status(lock_name: str) -> dict[str, Any]: + """ + Get the status of a specific smart lock. + + Args: + lock_name: The name/ID of the lock (e.g., 'front_door', 'back_door', 'garage_door', 'side_gate') + + Returns lock status including locked state, battery level, and recent activity. + """ + lock_key = lock_name.lower().replace(" ", "_") + + if lock_key not in _device_state["locks"]: + available = list(_device_state["locks"].keys()) + return {"error": f"Lock '{lock_name}' not found. Available locks: {available}"} + + lock = _device_state["locks"][lock_key] + return { + "name": lock["name"], + "location": lock["location"], + "is_locked": lock["is_locked"], + "status": "Locked" if lock["is_locked"] else "UNLOCKED", + "battery_level": lock["battery_level"], + "battery_status": "Good" if lock["battery_level"] > 30 else "Low - Replace Soon", + "last_activity": lock["last_activity"], + } + + +def lock_door(lock_name: str) -> dict[str, Any]: + """ + Lock a specific door. + + Args: + lock_name: The name/ID of the lock to lock (e.g., 'front_door', 'back_door') + + Returns confirmation of the lock action. + """ + lock_key = lock_name.lower().replace(" ", "_") + + if lock_key not in _device_state["locks"]: + available = list(_device_state["locks"].keys()) + return {"error": f"Lock '{lock_name}' not found. Available locks: {available}"} + + lock = _device_state["locks"][lock_key] + was_locked = lock["is_locked"] + lock["is_locked"] = True + lock["last_activity"] = datetime.now().isoformat() + + _log_activity("lock", lock["name"], f"{lock['name']} was locked") + + return { + "success": True, + "lock_name": lock["name"], + "previous_state": "Locked" if was_locked else "Unlocked", + "current_state": "Locked", + "message": f"{lock['name']} is now locked" if not was_locked else f"{lock['name']} was already locked", + "timestamp": lock["last_activity"], + } + + +def unlock_door(lock_name: str, confirm: bool = False) -> dict[str, Any]: + """ + Unlock a specific door. Requires confirmation for security. + + Args: + lock_name: The name/ID of the lock to unlock + confirm: Must be True to confirm the unlock action + + Returns confirmation of the unlock action. + """ + if not confirm: + return { + "success": False, + "message": "Unlock action requires confirmation. Set confirm=True to proceed.", + "warning": "Unlocking doors remotely should be done with caution.", + } + + lock_key = lock_name.lower().replace(" ", "_") + + if lock_key not in _device_state["locks"]: + available = list(_device_state["locks"].keys()) + return {"error": f"Lock '{lock_name}' not found. Available locks: {available}"} + + lock = _device_state["locks"][lock_key] + was_locked = lock["is_locked"] + lock["is_locked"] = False + lock["last_activity"] = datetime.now().isoformat() + + _log_activity("unlock", lock["name"], f"{lock['name']} was unlocked") + + return { + "success": True, + "lock_name": lock["name"], + "previous_state": "Locked" if was_locked else "Unlocked", + "current_state": "Unlocked", + "message": f"{lock['name']} is now unlocked" if was_locked else f"{lock['name']} was already unlocked", + "timestamp": lock["last_activity"], + "warning": "Remember to lock this door when done.", + } + + +# Camera Tools +def get_all_cameras_status() -> dict[str, Any]: + """ + Get the status of all security cameras. + + Returns camera statuses including: + - Online/offline status + - Recording status + - Motion detection status + - Resolution and capabilities + """ + cameras = {} + offline_count = 0 + motion_count = 0 + + for cam_id, cam in _device_state["cameras"].items(): + cameras[cam_id] = { + "name": cam["name"], + "location": cam["location"], + "is_online": cam["is_online"], + "is_recording": cam["is_recording"], + "motion_detected": cam["motion_detected"], + "resolution": cam["resolution"], + "night_vision": cam["night_vision"], + "last_motion": cam["last_motion"], + "status": "Online" if cam["is_online"] else "OFFLINE", + } + if not cam["is_online"]: + offline_count += 1 + if cam["motion_detected"]: + motion_count += 1 + + return { + "cameras": cameras, + "total_cameras": len(cameras), + "online_count": len(cameras) - offline_count, + "offline_count": offline_count, + "cameras_with_motion": motion_count, + "all_online": offline_count == 0, + "summary": f"{len(cameras) - offline_count}/{len(cameras)} cameras online, {motion_count} detecting motion" + } + + +def get_camera_status(camera_name: str) -> dict[str, Any]: + """ + Get detailed status of a specific camera. + + Args: + camera_name: The name/ID of the camera (e.g., 'front_porch', 'backyard', 'driveway') + + Returns detailed camera information. + """ + cam_key = camera_name.lower().replace(" ", "_") + + if cam_key not in _device_state["cameras"]: + available = list(_device_state["cameras"].keys()) + return {"error": f"Camera '{camera_name}' not found. Available cameras: {available}"} + + cam = _device_state["cameras"][cam_key] + return { + "name": cam["name"], + "location": cam["location"], + "is_online": cam["is_online"], + "status": "Online" if cam["is_online"] else "OFFLINE", + "is_recording": cam["is_recording"], + "motion_detected": cam["motion_detected"], + "resolution": cam["resolution"], + "night_vision": "Enabled" if cam["night_vision"] else "Disabled", + "last_motion": cam["last_motion"], + } + + +def get_camera_snapshot(camera_name: str) -> dict[str, Any]: + """ + Request a snapshot from a specific camera. + + Args: + camera_name: The name/ID of the camera + + Returns snapshot metadata (in production would include image URL). + """ + cam_key = camera_name.lower().replace(" ", "_") + + if cam_key not in _device_state["cameras"]: + available = list(_device_state["cameras"].keys()) + return {"error": f"Camera '{camera_name}' not found. Available cameras: {available}"} + + cam = _device_state["cameras"][cam_key] + + if not cam["is_online"]: + return { + "success": False, + "camera_name": cam["name"], + "error": "Camera is offline. Cannot capture snapshot.", + } + + _log_activity("snapshot", cam["name"], f"Snapshot captured from {cam['name']}") + + return { + "success": True, + "camera_name": cam["name"], + "location": cam["location"], + "timestamp": datetime.now().isoformat(), + "resolution": cam["resolution"], + "snapshot_id": str(uuid.uuid4()), + "message": f"Snapshot captured from {cam['name']}", + "image_url": f"/api/cameras/{cam_key}/snapshot/{uuid.uuid4()}", + } + + +def get_motion_events(hours: int = 24) -> dict[str, Any]: + """ + Get recent motion detection events from all cameras. + + Args: + hours: Number of hours to look back (default 24) + + Returns list of motion events with timestamps and camera info. + """ + cutoff = datetime.now() - timedelta(hours=hours) + events = [] + + # Generate some simulated motion events + event_descriptions = [ + "Person detected", + "Vehicle detected", + "Animal detected", + "Package delivered", + "Motion near entrance", + "Movement in yard", + ] + + for cam_id, cam in _device_state["cameras"].items(): + if cam["is_online"] and cam["last_motion"]: + # Generate 1-5 events per camera + num_events = random.randint(1, 5) + for i in range(num_events): + minutes_ago = random.randint(1, hours * 60) + event_time = datetime.now() - timedelta(minutes=minutes_ago) + if event_time > cutoff: + events.append({ + "camera_id": cam_id, + "camera_name": cam["name"], + "location": cam["location"], + "timestamp": event_time.isoformat(), + "event_type": random.choice(event_descriptions), + "confidence": random.randint(75, 99), + }) + + # Sort by timestamp descending + events.sort(key=lambda x: x["timestamp"], reverse=True) + + return { + "events": events[:20], # Limit to 20 most recent + "total_events": len(events), + "time_range_hours": hours, + "summary": f"{len(events)} motion events in the last {hours} hours" + } + + +# Sensor Tools +def get_all_sensors_status() -> dict[str, Any]: + """ + Get the status of all home sensors (motion, door/window, smoke, water). + + Returns sensor statuses including: + - Triggered state + - Battery levels + - Sensor types and locations + """ + sensors = {} + triggered_count = 0 + low_battery_count = 0 + + for sensor_id, sensor in _device_state["sensors"].items(): + sensors[sensor_id] = { + "name": sensor["name"], + "type": sensor["type"], + "location": sensor["location"], + "is_triggered": sensor["is_triggered"], + "battery_level": sensor["battery_level"], + "battery_status": "Good" if sensor["battery_level"] > 30 else "Low", + "last_triggered": sensor["last_triggered"], + } + if sensor["is_triggered"]: + triggered_count += 1 + if sensor["battery_level"] <= 30: + low_battery_count += 1 + + return { + "sensors": sensors, + "total_sensors": len(sensors), + "triggered_count": triggered_count, + "low_battery_count": low_battery_count, + "all_normal": triggered_count == 0, + "summary": f"{len(sensors)} sensors monitored, {triggered_count} currently triggered" + } + + +# Security System Tools +def arm_security_system(mode: str = "away") -> dict[str, Any]: + """ + Arm the home security system. + + Args: + mode: Arm mode - 'away' (full), 'home' (perimeter only), or 'night' (night mode) + + Returns confirmation of the arm action. + """ + valid_modes = ["away", "home", "night"] + if mode.lower() not in valid_modes: + return {"error": f"Invalid mode. Valid modes are: {valid_modes}"} + + # Check if any doors are unlocked + unlocked_locks = [ + lock["name"] for lock in _device_state["locks"].values() + if not lock["is_locked"] + ] + + if unlocked_locks: + return { + "success": False, + "error": "Cannot arm system with doors unlocked", + "unlocked_doors": unlocked_locks, + "suggestion": "Please lock all doors before arming the security system.", + } + + _device_state["security_system"]["is_armed"] = True + _device_state["security_system"]["arm_mode"] = mode.lower() + _device_state["security_system"]["last_armed"] = datetime.now().isoformat() + + _log_activity("arm", "Security System", f"System armed in {mode} mode") + + mode_descriptions = { + "away": "All sensors active - full protection", + "home": "Perimeter sensors only - interior motion off", + "night": "Perimeter + selected interior sensors", + } + + return { + "success": True, + "is_armed": True, + "mode": mode.lower(), + "mode_description": mode_descriptions[mode.lower()], + "armed_at": _device_state["security_system"]["last_armed"], + "message": f"Security system armed in {mode} mode", + } + + +def disarm_security_system(pin: str | None = None) -> dict[str, Any]: + """ + Disarm the home security system. + + Args: + pin: Security PIN (optional in simulation, required in production) + + Returns confirmation of the disarm action. + """ + was_armed = _device_state["security_system"]["is_armed"] + + _device_state["security_system"]["is_armed"] = False + _device_state["security_system"]["arm_mode"] = "disarmed" + _device_state["security_system"]["last_disarmed"] = datetime.now().isoformat() + _device_state["security_system"]["alarm_triggered"] = False + + _log_activity("disarm", "Security System", "System disarmed") + + return { + "success": True, + "is_armed": False, + "previous_state": "Armed" if was_armed else "Already disarmed", + "disarmed_at": _device_state["security_system"]["last_disarmed"], + "message": "Security system disarmed" if was_armed else "System was already disarmed", + } + + +def get_security_system_status() -> dict[str, Any]: + """ + Get the current status of the security system. + + Returns: + - Armed/disarmed state + - Current arm mode + - Alarm status + - Last state changes + """ + system = _device_state["security_system"] + + return { + "is_armed": system["is_armed"], + "arm_mode": system["arm_mode"], + "status": "Armed" if system["is_armed"] else "Disarmed", + "alarm_triggered": system["alarm_triggered"], + "last_armed": system["last_armed"], + "last_disarmed": system["last_disarmed"], + } + + +# Summary Tools +def get_recent_activity(count: int = 10) -> dict[str, Any]: + """ + Get recent activity log from all home security devices. + + Args: + count: Number of recent events to return (default 10, max 50) + + Returns list of recent activity events. + """ + count = min(count, 50) + activities = _device_state["activity_log"][:count] + + return { + "activities": activities, + "count": len(activities), + "message": f"Showing {len(activities)} most recent activities" + } + + +def get_home_summary() -> dict[str, Any]: + """ + Get a comprehensive summary of your home security status. + + Returns an overview of: + - Lock status (all doors) + - Camera status (all cameras) + - Sensor status (all sensors) + - Security system status + - Recent alerts + """ + locks_status = get_all_locks_status() + cameras_status = get_all_cameras_status() + sensors_status = get_all_sensors_status() + system_status = get_security_system_status() + + # Determine overall security score + issues = [] + + if not locks_status["all_secure"]: + issues.append(f"{locks_status['unlocked_count']} door(s) unlocked") + + if cameras_status["offline_count"] > 0: + issues.append(f"{cameras_status['offline_count']} camera(s) offline") + + if sensors_status["low_battery_count"] > 0: + issues.append(f"{sensors_status['low_battery_count']} sensor(s) with low battery") + + if not system_status["is_armed"]: + issues.append("Security system is disarmed") + + # Calculate security score (0-100) + security_score = 100 + if not locks_status["all_secure"]: + security_score -= 25 + if cameras_status["offline_count"] > 0: + security_score -= 15 + if sensors_status["low_battery_count"] > 0: + security_score -= 10 + if not system_status["is_armed"]: + security_score -= 20 + + security_level = "Excellent" if security_score >= 90 else ( + "Good" if security_score >= 70 else ( + "Fair" if security_score >= 50 else "Needs Attention" + ) + ) + + return { + "timestamp": datetime.now().isoformat(), + "security_score": security_score, + "security_level": security_level, + "locks": { + "summary": locks_status["summary"], + "all_secure": locks_status["all_secure"], + "unlocked_count": locks_status["unlocked_count"], + }, + "cameras": { + "summary": cameras_status["summary"], + "all_online": cameras_status["all_online"], + "offline_count": cameras_status["offline_count"], + "motion_detected": cameras_status["cameras_with_motion"], + }, + "sensors": { + "summary": sensors_status["summary"], + "all_normal": sensors_status["all_normal"], + "triggered_count": sensors_status["triggered_count"], + "low_battery_count": sensors_status["low_battery_count"], + }, + "security_system": system_status, + "issues": issues if issues else ["No issues - home is secure"], + "recommendations": _get_recommendations(locks_status, cameras_status, sensors_status, system_status), + } + + +def _get_recommendations( + locks: dict[str, Any], + cameras: dict[str, Any], + sensors: dict[str, Any], + system: dict[str, Any], +) -> list[str]: + """Generate security recommendations based on current status.""" + recommendations = [] + + if not locks["all_secure"]: + recommendations.append("Lock all doors for better security") + + if cameras["offline_count"] > 0: + recommendations.append("Check offline cameras - they may need attention") + + if sensors["low_battery_count"] > 0: + recommendations.append("Replace batteries in sensors with low battery levels") + + if not system["is_armed"] and locks["all_secure"]: + recommendations.append("Consider arming the security system") + + if cameras["cameras_with_motion"] > 0: + recommendations.append("Review motion alerts from active cameras") + + if not recommendations: + recommendations.append("Your home security looks great! Keep up the good habits.") + + return recommendations diff --git a/src/mobile_api/__init__.py b/src/mobile_api/__init__.py new file mode 100644 index 0000000..ceeca5f --- /dev/null +++ b/src/mobile_api/__init__.py @@ -0,0 +1,5 @@ +"""Mobile API for Home Security Agent - iOS/Android compatible REST API.""" + +from .app import app + +__all__ = ["app"] diff --git a/src/mobile_api/app.py b/src/mobile_api/app.py new file mode 100644 index 0000000..2739bd0 --- /dev/null +++ b/src/mobile_api/app.py @@ -0,0 +1,601 @@ +"""FastAPI application for Home Security Mobile API.""" + +import uuid +from datetime import datetime +from typing import Any + +from fastapi import FastAPI, HTTPException, Query +from fastapi.middleware.cors import CORSMiddleware + +from src.agents.home_security_agent.tools import ( + get_all_locks_status, + get_lock_status, + lock_door, + unlock_door, + get_all_cameras_status, + get_camera_status, + get_camera_snapshot, + get_motion_events, + get_all_sensors_status, + arm_security_system, + disarm_security_system, + get_security_system_status, + get_recent_activity, + get_home_summary, +) + +from .models import ( + ActionResponse, + ArmSystemRequest, + ChatMessageRequest, + ChatMessageResponse, + DisarmSystemRequest, + HealthResponse, + LockActionRequest, + MotionEventsResponse, + RecentActivityResponse, + SecurityMode, + SnapshotResponse, + DeviceRegistration, + NotificationSettings, +) + +# Simple in-memory storage for chat sessions and notifications +_chat_sessions: dict[str, list[dict[str, str]]] = {} +_registered_devices: dict[str, DeviceRegistration] = {} +_notification_settings: dict[str, NotificationSettings] = {} + +app = FastAPI( + title="Home Security AI Agent API", + description=""" + REST API for the Home Security AI Agent, designed for iOS and Android mobile apps. + + ## Features + - **Lock Management**: View status and control smart locks + - **Camera Monitoring**: View camera feeds, snapshots, and motion events + - **Sensor Monitoring**: Check status of all security sensors + - **Security System**: Arm/disarm your security system + - **AI Chat**: Natural language interaction with your security agent + - **Push Notifications**: Real-time alerts for security events + + ## Authentication + In production, all endpoints require Bearer token authentication. + """, + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", +) + +# CORS middleware for mobile app access +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, restrict to your app's domain + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# Health Check +@app.get("/health", response_model=HealthResponse, tags=["System"]) +async def health_check() -> HealthResponse: + """Check API health status.""" + return HealthResponse( + status="healthy", + version="1.0.0", + timestamp=datetime.now().isoformat() + ) + + +# Home Summary +@app.get("/api/summary", tags=["Dashboard"]) +async def get_summary() -> dict[str, Any]: + """ + Get comprehensive home security summary. + + Returns security score, status of all devices, issues, and recommendations. + Ideal for the main dashboard view. + """ + return get_home_summary() + + +# Lock Endpoints +@app.get("/api/locks", tags=["Locks"]) +async def list_all_locks() -> dict[str, Any]: + """Get status of all smart locks.""" + return get_all_locks_status() + + +@app.get("/api/locks/{lock_id}", tags=["Locks"]) +async def get_single_lock(lock_id: str) -> dict[str, Any]: + """Get status of a specific lock.""" + result = get_lock_status(lock_id) + if "error" in result: + raise HTTPException(status_code=404, detail=result["error"]) + return result + + +@app.post("/api/locks/{lock_id}/lock", response_model=ActionResponse, tags=["Locks"]) +async def lock_single_door(lock_id: str) -> ActionResponse: + """Lock a specific door.""" + result = lock_door(lock_id) + if "error" in result: + raise HTTPException(status_code=404, detail=result["error"]) + return ActionResponse( + success=result["success"], + message=result["message"], + details=result + ) + + +@app.post("/api/locks/{lock_id}/unlock", response_model=ActionResponse, tags=["Locks"]) +async def unlock_single_door(lock_id: str, request: LockActionRequest) -> ActionResponse: + """ + Unlock a specific door. + + Requires confirmation (confirm=true) for security. + """ + result = unlock_door(lock_id, confirm=request.confirm) + if "error" in result: + raise HTTPException(status_code=404, detail=result["error"]) + return ActionResponse( + success=result["success"], + message=result.get("message", ""), + details=result, + warning=result.get("warning") + ) + + +@app.post("/api/locks/lock-all", response_model=ActionResponse, tags=["Locks"]) +async def lock_all_doors() -> ActionResponse: + """Lock all doors at once.""" + locks_status = get_all_locks_status() + locked_count = 0 + results = [] + + for lock_id in locks_status["locks"].keys(): + result = lock_door(lock_id) + if result["success"]: + locked_count += 1 + results.append(result) + + return ActionResponse( + success=True, + message=f"All {locked_count} doors are now locked", + details={"locked_doors": locked_count, "results": results} + ) + + +# Camera Endpoints +@app.get("/api/cameras", tags=["Cameras"]) +async def list_all_cameras() -> dict[str, Any]: + """Get status of all security cameras.""" + return get_all_cameras_status() + + +@app.get("/api/cameras/motion-events", response_model=MotionEventsResponse, tags=["Cameras"]) +async def get_camera_motion_events( + hours: int = Query(default=24, ge=1, le=168, description="Hours to look back") +) -> MotionEventsResponse: + """Get recent motion detection events from all cameras.""" + result = get_motion_events(hours=hours) + return MotionEventsResponse(**result) + + +@app.get("/api/cameras/{camera_id}", tags=["Cameras"]) +async def get_single_camera(camera_id: str) -> dict[str, Any]: + """Get status of a specific camera.""" + result = get_camera_status(camera_id) + if "error" in result: + raise HTTPException(status_code=404, detail=result["error"]) + return result + + +@app.post("/api/cameras/{camera_id}/snapshot", response_model=SnapshotResponse, tags=["Cameras"]) +async def capture_snapshot(camera_id: str) -> SnapshotResponse: + """Capture a snapshot from a specific camera.""" + result = get_camera_snapshot(camera_id) + if "error" in result: + if not result.get("success", True): + return SnapshotResponse( + success=False, + camera_name=result.get("camera_name", camera_id), + message="Failed to capture snapshot", + error=result["error"] + ) + raise HTTPException(status_code=404, detail=result["error"]) + return SnapshotResponse(**result) + + +# Sensor Endpoints +@app.get("/api/sensors", tags=["Sensors"]) +async def list_all_sensors() -> dict[str, Any]: + """Get status of all security sensors.""" + return get_all_sensors_status() + + +# Security System Endpoints +@app.get("/api/security-system", tags=["Security System"]) +async def get_system_status() -> dict[str, Any]: + """Get current security system status.""" + return get_security_system_status() + + +@app.post("/api/security-system/arm", response_model=ActionResponse, tags=["Security System"]) +async def arm_system(request: ArmSystemRequest) -> ActionResponse: + """ + Arm the security system. + + Modes: + - away: Full protection (all sensors active) + - home: Perimeter only (interior motion off) + - night: Perimeter + selected interior sensors + """ + result = arm_security_system(mode=request.mode.value) + if "error" in result: + return ActionResponse( + success=False, + message=result.get("error", "Failed to arm system"), + details=result, + error=result.get("error") + ) + return ActionResponse( + success=result["success"], + message=result["message"], + details=result + ) + + +@app.post("/api/security-system/disarm", response_model=ActionResponse, tags=["Security System"]) +async def disarm_system(request: DisarmSystemRequest) -> ActionResponse: + """Disarm the security system.""" + result = disarm_security_system(pin=request.pin) + return ActionResponse( + success=result["success"], + message=result["message"], + details=result + ) + + +# Activity Log Endpoints +@app.get("/api/activity", response_model=RecentActivityResponse, tags=["Activity"]) +async def get_activity_log( + count: int = Query(default=10, ge=1, le=50, description="Number of events to return") +) -> RecentActivityResponse: + """Get recent activity log.""" + result = get_recent_activity(count=count) + return RecentActivityResponse(**result) + + +# AI Chat Endpoints +@app.post("/api/chat", response_model=ChatMessageResponse, tags=["AI Assistant"]) +async def chat_with_agent(request: ChatMessageRequest) -> ChatMessageResponse: + """ + Send a message to the AI security assistant. + + Example queries: + - "What's going on at home?" + - "Are all doors locked?" + - "Lock all doors" + - "Show me the front porch camera" + - "Any motion detected today?" + """ + session_id = request.session_id or str(uuid.uuid4()) + + # Initialize session if needed + if session_id not in _chat_sessions: + _chat_sessions[session_id] = [] + + # Store user message + _chat_sessions[session_id].append({ + "role": "user", + "content": request.message, + "timestamp": datetime.now().isoformat() + }) + + # Process the message and generate response + response_text, suggestions = _process_chat_message(request.message) + + # Store assistant response + _chat_sessions[session_id].append({ + "role": "assistant", + "content": response_text, + "timestamp": datetime.now().isoformat() + }) + + return ChatMessageResponse( + response=response_text, + session_id=session_id, + timestamp=datetime.now().isoformat(), + suggestions=suggestions + ) + + +def _process_chat_message(message: str) -> tuple[str, list[str]]: + """ + Process a chat message and return response with suggestions. + + In production, this would use the actual ADK agent with LLM. + For now, it provides smart responses based on keywords. + """ + message_lower = message.lower() + suggestions = [] + + # Status/summary queries + if any(word in message_lower for word in ["status", "summary", "what's going on", "whats going on", "overview", "how is"]): + summary = get_home_summary() + response = f"""๐Ÿ  **Home Security Status** + +**Security Score:** {summary['security_score']}/100 ({summary['security_level']}) + +**๐Ÿ” Locks:** {summary['locks']['summary']} +{' โœ“ All doors locked' if summary['locks']['all_secure'] else ' โš  Some doors unlocked!'} + +**๐Ÿ“น Cameras:** {summary['cameras']['summary']} +{' โœ“ All cameras online' if summary['cameras']['all_online'] else ' โš  Some cameras offline!'} + +**๐Ÿ“ก Sensors:** {summary['sensors']['summary']} +{' โœ“ All sensors normal' if summary['sensors']['all_normal'] else ' โš  Some sensors triggered!'} + +**๐Ÿ›ก๏ธ System:** {summary['security_system']['status']} + +""" + if summary['issues'] and summary['issues'][0] != "No issues - home is secure": + response += "**Issues:**\n" + for issue in summary['issues']: + response += f"โ€ข {issue}\n" + + suggestions = ["Lock all doors", "Show cameras", "View activity log", "Arm system"] + return response, suggestions + + # Lock queries + if "lock" in message_lower: + if "all" in message_lower and ("lock" in message_lower or "secure" in message_lower): + # Lock all doors + locks = get_all_locks_status() + locked_count = 0 + for lock_id in locks["locks"].keys(): + result = lock_door(lock_id) + if result["success"]: + locked_count += 1 + response = f"โœ“ All {locked_count} doors are now locked." + suggestions = ["Check cameras", "Arm system", "View activity"] + elif any(door in message_lower for door in ["front", "back", "garage", "side"]): + # Lock specific door + if "front" in message_lower: + result = lock_door("front_door") + elif "back" in message_lower: + result = lock_door("back_door") + elif "garage" in message_lower: + result = lock_door("garage_door") + elif "side" in message_lower: + result = lock_door("side_gate") + else: + result = {"message": "Door not found"} + response = result.get("message", "Lock action completed") + suggestions = ["Check all locks", "Lock all doors", "View activity"] + else: + locks = get_all_locks_status() + response = f"**๐Ÿ” Lock Status:** {locks['summary']}\n\n" + for lock_id, lock in locks['locks'].items(): + status_icon = "๐Ÿ”’" if lock['is_locked'] else "๐Ÿ”“" + response += f"{status_icon} **{lock['name']}** ({lock['location']}): {lock['status']}\n" + response += f" Battery: {lock['battery_level']}%\n" + suggestions = ["Lock all doors", "Lock front door", "Lock garage door"] + return response, suggestions + + # Camera queries + if any(word in message_lower for word in ["camera", "cameras", "video", "watch", "see"]): + cameras = get_all_cameras_status() + response = f"**๐Ÿ“น Camera Status:** {cameras['summary']}\n\n" + for cam_id, cam in cameras['cameras'].items(): + status_icon = "๐ŸŸข" if cam['is_online'] else "๐Ÿ”ด" + motion_icon = "๐Ÿ“" if cam['motion_detected'] else "" + response += f"{status_icon} **{cam['name']}** ({cam['location']}): {cam['status']} {motion_icon}\n" + response += f" Resolution: {cam['resolution']} | Recording: {'Yes' if cam['is_recording'] else 'No'}\n" + suggestions = ["View motion events", "Capture snapshot", "Check front porch", "Check backyard"] + return response, suggestions + + # Motion queries + if "motion" in message_lower: + events = get_motion_events(24) + response = f"**๐Ÿ“ Motion Events:** {events['summary']}\n\n" + for event in events['events'][:5]: + response += f"โ€ข **{event['camera_name']}**: {event['event_type']} ({event['confidence']}% confidence)\n" + response += f" {event['timestamp']}\n" + if len(events['events']) > 5: + response += f"\n_... and {len(events['events']) - 5} more events_" + suggestions = ["View all cameras", "Capture snapshot", "Check activity log"] + return response, suggestions + + # Sensor queries + if any(word in message_lower for word in ["sensor", "sensors", "alarm", "smoke", "water"]): + sensors = get_all_sensors_status() + response = f"**๐Ÿ“ก Sensor Status:** {sensors['summary']}\n\n" + for sensor_id, sensor in sensors['sensors'].items(): + status_icon = "๐ŸŸข" if not sensor['is_triggered'] else "๐Ÿ”ด" + battery_icon = "๐Ÿ”‹" if sensor['battery_level'] > 30 else "โš ๏ธ" + response += f"{status_icon} **{sensor['name']}** ({sensor['location']})\n" + response += f" Type: {sensor['type']} | {battery_icon} Battery: {sensor['battery_level']}%\n" + suggestions = ["Check locks", "View cameras", "Arm system"] + return response, suggestions + + # Security system queries + if any(word in message_lower for word in ["arm", "disarm", "security system", "protect"]): + if "disarm" in message_lower: + result = disarm_security_system() + response = f"๐Ÿ›ก๏ธ {result['message']}" + suggestions = ["Check locks", "View activity", "Arm system"] + elif "arm" in message_lower: + mode = "away" + if "home" in message_lower: + mode = "home" + elif "night" in message_lower: + mode = "night" + result = arm_security_system(mode=mode) + if result["success"]: + response = f"๐Ÿ›ก๏ธ {result['message']}\n\n{result.get('mode_description', '')}" + else: + response = f"โš ๏ธ {result.get('error', 'Could not arm system')}" + if result.get('unlocked_doors'): + response += f"\n\nUnlocked doors: {', '.join(result['unlocked_doors'])}" + response += "\n\nPlease lock all doors first." + suggestions = ["Lock all doors", "Check status", "View activity"] + else: + status = get_security_system_status() + response = f"**๐Ÿ›ก๏ธ Security System Status**\n\n" + response += f"Status: {status['status']}\n" + response += f"Mode: {status['arm_mode']}\n" + if status['last_armed']: + response += f"Last armed: {status['last_armed']}\n" + suggestions = ["Arm system", "Disarm system", "Lock all doors"] + return response, suggestions + + # Activity queries + if any(word in message_lower for word in ["activity", "log", "history", "recent", "events"]): + activity = get_recent_activity(10) + response = f"**๐Ÿ“‹ Recent Activity**\n\n" + for event in activity['activities'][:10]: + response += f"โ€ข {event['timestamp']}: {event['device_name']} - {event['details']}\n" + if not activity['activities']: + response += "_No recent activity_" + suggestions = ["Check locks", "View cameras", "Check status"] + return response, suggestions + + # Default response + summary = get_home_summary() + response = f"""I'm your home security assistant. Here's a quick overview: + +**Security Score:** {summary['security_score']}/100 ({summary['security_level']}) + +You can ask me things like: +โ€ข "What's going on at home?" +โ€ข "Are all doors locked?" +โ€ข "Lock all doors" +โ€ข "Show me the cameras" +โ€ข "Any motion detected?" +โ€ข "Arm the security system" +""" + suggestions = ["Show status", "Lock all doors", "Check cameras", "View activity"] + return response, suggestions + + +# Push Notification Endpoints +@app.post("/api/notifications/register", response_model=ActionResponse, tags=["Notifications"]) +async def register_device(registration: DeviceRegistration) -> ActionResponse: + """Register a device for push notifications.""" + device_id = str(uuid.uuid4()) + _registered_devices[device_id] = registration + return ActionResponse( + success=True, + message="Device registered for push notifications", + details={"device_id": device_id} + ) + + +@app.get("/api/notifications/settings", tags=["Notifications"]) +async def get_notification_settings( + user_id: str = Query(default="default", description="User ID") +) -> NotificationSettings: + """Get notification settings for a user.""" + if user_id not in _notification_settings: + _notification_settings[user_id] = NotificationSettings() + return _notification_settings[user_id] + + +@app.put("/api/notifications/settings", response_model=ActionResponse, tags=["Notifications"]) +async def update_notification_settings( + settings: NotificationSettings, + user_id: str = Query(default="default", description="User ID") +) -> ActionResponse: + """Update notification settings for a user.""" + _notification_settings[user_id] = settings + return ActionResponse( + success=True, + message="Notification settings updated", + details=settings.model_dump() + ) + + +# Quick Actions (for iOS Shortcuts / Widgets) +@app.post("/api/quick-actions/goodnight", response_model=ActionResponse, tags=["Quick Actions"]) +async def goodnight_routine() -> ActionResponse: + """ + Execute goodnight routine: + - Lock all doors + - Arm system in night mode + """ + # Lock all doors first + locks = get_all_locks_status() + for lock_id in locks["locks"].keys(): + lock_door(lock_id) + + # Arm in night mode + arm_result = arm_security_system(mode="night") + + if arm_result["success"]: + return ActionResponse( + success=True, + message="Goodnight routine complete: All doors locked, system armed in night mode", + details={ + "doors_locked": len(locks["locks"]), + "system_mode": "night" + } + ) + return ActionResponse( + success=False, + message="Goodnight routine partially complete", + details=arm_result, + error=arm_result.get("error") + ) + + +@app.post("/api/quick-actions/leaving", response_model=ActionResponse, tags=["Quick Actions"]) +async def leaving_routine() -> ActionResponse: + """ + Execute leaving home routine: + - Lock all doors + - Arm system in away mode + """ + locks = get_all_locks_status() + for lock_id in locks["locks"].keys(): + lock_door(lock_id) + + arm_result = arm_security_system(mode="away") + + if arm_result["success"]: + return ActionResponse( + success=True, + message="Leaving routine complete: All doors locked, system armed in away mode", + details={ + "doors_locked": len(locks["locks"]), + "system_mode": "away" + } + ) + return ActionResponse( + success=False, + message="Leaving routine partially complete", + details=arm_result, + error=arm_result.get("error") + ) + + +@app.post("/api/quick-actions/arriving", response_model=ActionResponse, tags=["Quick Actions"]) +async def arriving_routine() -> ActionResponse: + """ + Execute arriving home routine: + - Disarm system + - Unlock front door + """ + disarm_result = disarm_security_system() + unlock_result = unlock_door("front_door", confirm=True) + + return ActionResponse( + success=True, + message="Welcome home! System disarmed, front door unlocked", + details={ + "system_disarmed": disarm_result["success"], + "front_door_unlocked": unlock_result["success"] + }, + warning="Remember to lock the door after entering" + ) diff --git a/src/mobile_api/models.py b/src/mobile_api/models.py new file mode 100644 index 0000000..0ac00e6 --- /dev/null +++ b/src/mobile_api/models.py @@ -0,0 +1,244 @@ +"""Pydantic models for the Home Security Mobile API.""" + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class SecurityMode(str, Enum): + """Security system arm modes.""" + DISARMED = "disarmed" + HOME = "home" + AWAY = "away" + NIGHT = "night" + + +class SensorType(str, Enum): + """Types of security sensors.""" + MOTION = "motion" + DOOR_WINDOW = "door_window" + SMOKE = "smoke" + WATER_LEAK = "water_leak" + GLASS_BREAK = "glass_break" + + +# Request Models +class LockActionRequest(BaseModel): + """Request to lock or unlock a door.""" + confirm: bool = Field( + default=False, + description="Confirmation required for unlock actions" + ) + + +class ArmSystemRequest(BaseModel): + """Request to arm the security system.""" + mode: SecurityMode = Field( + default=SecurityMode.AWAY, + description="Arm mode: away, home, or night" + ) + + +class DisarmSystemRequest(BaseModel): + """Request to disarm the security system.""" + pin: str | None = Field( + default=None, + description="Security PIN for verification" + ) + + +class ChatMessageRequest(BaseModel): + """Request to send a message to the AI agent.""" + message: str = Field( + ..., + description="User message to the AI agent", + min_length=1, + max_length=1000 + ) + session_id: str | None = Field( + default=None, + description="Optional session ID for conversation continuity" + ) + + +# Response Models +class LockStatus(BaseModel): + """Status of a single lock.""" + id: str + name: str + location: str + is_locked: bool + status: str + battery_level: int + battery_status: str + last_activity: str | None + + +class AllLocksResponse(BaseModel): + """Response with all locks status.""" + locks: dict[str, LockStatus] + total_locks: int + unlocked_count: int + all_secure: bool + summary: str + + +class CameraStatus(BaseModel): + """Status of a single camera.""" + id: str + name: str + location: str + is_online: bool + is_recording: bool + motion_detected: bool + resolution: str + night_vision: bool + last_motion: str | None + status: str + + +class AllCamerasResponse(BaseModel): + """Response with all cameras status.""" + cameras: dict[str, CameraStatus] + total_cameras: int + online_count: int + offline_count: int + cameras_with_motion: int + all_online: bool + summary: str + + +class SensorStatus(BaseModel): + """Status of a single sensor.""" + id: str + name: str + sensor_type: SensorType + location: str + is_triggered: bool + battery_level: int + battery_status: str + last_triggered: str | None + + +class AllSensorsResponse(BaseModel): + """Response with all sensors status.""" + sensors: dict[str, SensorStatus] + total_sensors: int + triggered_count: int + low_battery_count: int + all_normal: bool + summary: str + + +class SecuritySystemStatus(BaseModel): + """Status of the security system.""" + is_armed: bool + arm_mode: SecurityMode + status: str + alarm_triggered: bool + last_armed: str | None + last_disarmed: str | None + + +class MotionEvent(BaseModel): + """A motion detection event.""" + camera_id: str + camera_name: str + location: str + timestamp: str + event_type: str + confidence: int + + +class MotionEventsResponse(BaseModel): + """Response with motion events.""" + events: list[MotionEvent] + total_events: int + time_range_hours: int + summary: str + + +class ActivityEvent(BaseModel): + """An activity log event.""" + id: str + timestamp: str + event_type: str + device_name: str + details: str + + +class RecentActivityResponse(BaseModel): + """Response with recent activity.""" + activities: list[ActivityEvent] + count: int + message: str + + +class HomeSummaryResponse(BaseModel): + """Comprehensive home security summary.""" + timestamp: str + security_score: int + security_level: str + locks: dict[str, Any] + cameras: dict[str, Any] + sensors: dict[str, Any] + security_system: SecuritySystemStatus + issues: list[str] + recommendations: list[str] + + +class SnapshotResponse(BaseModel): + """Response from camera snapshot request.""" + success: bool + camera_name: str + location: str | None = None + timestamp: str | None = None + resolution: str | None = None + snapshot_id: str | None = None + message: str + image_url: str | None = None + error: str | None = None + + +class ActionResponse(BaseModel): + """Generic action response.""" + success: bool + message: str + details: dict[str, Any] | None = None + error: str | None = None + warning: str | None = None + + +class ChatMessageResponse(BaseModel): + """Response from the AI agent.""" + response: str + session_id: str + timestamp: str + suggestions: list[str] | None = None + + +class HealthResponse(BaseModel): + """API health check response.""" + status: str + version: str + timestamp: str + + +class NotificationSettings(BaseModel): + """User notification preferences.""" + push_enabled: bool = True + motion_alerts: bool = True + door_alerts: bool = True + system_alerts: bool = True + low_battery_alerts: bool = True + quiet_hours_start: str | None = None + quiet_hours_end: str | None = None + + +class DeviceRegistration(BaseModel): + """Device registration for push notifications.""" + device_token: str + device_type: str # "ios" or "android" + device_name: str | None = None diff --git a/src/mobile_api/server.py b/src/mobile_api/server.py new file mode 100644 index 0000000..cc7391c --- /dev/null +++ b/src/mobile_api/server.py @@ -0,0 +1,17 @@ +"""Server entry point for the Home Security Mobile API.""" + +import uvicorn + + +def main() -> None: + """Run the Home Security Mobile API server.""" + uvicorn.run( + "src.mobile_api.app:app", + host="0.0.0.0", + port=8000, + reload=True, + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_home_security_agent.py b/tests/test_home_security_agent.py new file mode 100644 index 0000000..bac202d --- /dev/null +++ b/tests/test_home_security_agent.py @@ -0,0 +1,267 @@ +"""Tests for Home Security Agent tools and functionality.""" + +import pytest +from datetime import datetime + +from src.agents.home_security_agent.tools.device_tools import ( + get_all_locks_status, + get_lock_status, + lock_door, + unlock_door, + get_all_cameras_status, + get_camera_status, + get_camera_snapshot, + get_motion_events, + get_all_sensors_status, + arm_security_system, + disarm_security_system, + get_security_system_status, + get_recent_activity, + get_home_summary, + _device_state, +) + + +class TestLockTools: + """Tests for smart lock management tools.""" + + def test_get_all_locks_status(self) -> None: + """Test getting status of all locks.""" + result = get_all_locks_status() + + assert "locks" in result + assert "total_locks" in result + assert "unlocked_count" in result + assert "all_secure" in result + assert "summary" in result + assert result["total_locks"] == 4 + assert isinstance(result["locks"], dict) + + def test_get_lock_status_valid(self) -> None: + """Test getting status of a valid lock.""" + result = get_lock_status("front_door") + + assert "name" in result + assert "location" in result + assert "is_locked" in result + assert "battery_level" in result + assert result["name"] == "Front Door" + + def test_get_lock_status_invalid(self) -> None: + """Test getting status of an invalid lock.""" + result = get_lock_status("nonexistent_lock") + + assert "error" in result + assert "not found" in result["error"] + + def test_lock_door(self) -> None: + """Test locking a door.""" + # First unlock to ensure we can test locking + _device_state["locks"]["front_door"]["is_locked"] = False + + result = lock_door("front_door") + + assert result["success"] is True + assert "now locked" in result["message"] or "already locked" in result["message"] + assert _device_state["locks"]["front_door"]["is_locked"] is True + + def test_lock_door_invalid(self) -> None: + """Test locking an invalid door.""" + result = lock_door("nonexistent_door") + + assert "error" in result + + def test_unlock_door_without_confirmation(self) -> None: + """Test unlocking without confirmation fails.""" + result = unlock_door("front_door", confirm=False) + + assert result["success"] is False + assert "confirmation" in result["message"].lower() + + def test_unlock_door_with_confirmation(self) -> None: + """Test unlocking with confirmation succeeds.""" + # First lock the door + _device_state["locks"]["front_door"]["is_locked"] = True + + result = unlock_door("front_door", confirm=True) + + assert result["success"] is True + assert _device_state["locks"]["front_door"]["is_locked"] is False + + +class TestCameraTools: + """Tests for camera monitoring tools.""" + + def test_get_all_cameras_status(self) -> None: + """Test getting status of all cameras.""" + result = get_all_cameras_status() + + assert "cameras" in result + assert "total_cameras" in result + assert "online_count" in result + assert "offline_count" in result + assert result["total_cameras"] == 4 + + def test_get_camera_status_valid(self) -> None: + """Test getting status of a valid camera.""" + result = get_camera_status("front_porch") + + assert "name" in result + assert "location" in result + assert "is_online" in result + assert "resolution" in result + + def test_get_camera_status_invalid(self) -> None: + """Test getting status of an invalid camera.""" + result = get_camera_status("nonexistent_camera") + + assert "error" in result + + def test_get_camera_snapshot_online(self) -> None: + """Test capturing snapshot from online camera.""" + _device_state["cameras"]["front_porch"]["is_online"] = True + + result = get_camera_snapshot("front_porch") + + assert result["success"] is True + assert "snapshot_id" in result + assert "image_url" in result + + def test_get_camera_snapshot_offline(self) -> None: + """Test capturing snapshot from offline camera.""" + _device_state["cameras"]["garage_interior"]["is_online"] = False + + result = get_camera_snapshot("garage_interior") + + assert result["success"] is False + assert "offline" in result["error"].lower() + + def test_get_motion_events(self) -> None: + """Test getting motion events.""" + result = get_motion_events(hours=24) + + assert "events" in result + assert "total_events" in result + assert "time_range_hours" in result + assert result["time_range_hours"] == 24 + + +class TestSensorTools: + """Tests for sensor monitoring tools.""" + + def test_get_all_sensors_status(self) -> None: + """Test getting status of all sensors.""" + result = get_all_sensors_status() + + assert "sensors" in result + assert "total_sensors" in result + assert "triggered_count" in result + assert "low_battery_count" in result + assert result["total_sensors"] == 5 + + +class TestSecuritySystemTools: + """Tests for security system control tools.""" + + def test_get_security_system_status(self) -> None: + """Test getting security system status.""" + result = get_security_system_status() + + assert "is_armed" in result + assert "arm_mode" in result + assert "alarm_triggered" in result + + def test_arm_security_system_with_unlocked_doors(self) -> None: + """Test arming fails when doors are unlocked.""" + # Unlock a door first + _device_state["locks"]["garage_door"]["is_locked"] = False + + result = arm_security_system(mode="away") + + assert result["success"] is False + assert "unlocked" in result["error"].lower() + assert "unlocked_doors" in result + + def test_arm_security_system_all_locked(self) -> None: + """Test arming succeeds when all doors are locked.""" + # Lock all doors first + for lock in _device_state["locks"].values(): + lock["is_locked"] = True + + result = arm_security_system(mode="away") + + assert result["success"] is True + assert result["mode"] == "away" + + def test_arm_security_system_invalid_mode(self) -> None: + """Test arming with invalid mode fails.""" + result = arm_security_system(mode="invalid") + + assert "error" in result + + def test_disarm_security_system(self) -> None: + """Test disarming security system.""" + # First arm it + for lock in _device_state["locks"].values(): + lock["is_locked"] = True + arm_security_system(mode="away") + + result = disarm_security_system() + + assert result["success"] is True + assert _device_state["security_system"]["is_armed"] is False + + +class TestSummaryTools: + """Tests for summary and activity tools.""" + + def test_get_recent_activity(self) -> None: + """Test getting recent activity.""" + result = get_recent_activity(count=10) + + assert "activities" in result + assert "count" in result + assert result["count"] <= 10 + + def test_get_recent_activity_limit(self) -> None: + """Test activity count limit.""" + result = get_recent_activity(count=100) + + # Should cap at 50 + assert result["count"] <= 50 + + def test_get_home_summary(self) -> None: + """Test getting home summary.""" + result = get_home_summary() + + assert "timestamp" in result + assert "security_score" in result + assert "security_level" in result + assert "locks" in result + assert "cameras" in result + assert "sensors" in result + assert "security_system" in result + assert "issues" in result + assert "recommendations" in result + + # Security score should be between 0 and 100 + assert 0 <= result["security_score"] <= 100 + + def test_security_score_calculation(self) -> None: + """Test security score reflects issues.""" + # Set up a secure state + for lock in _device_state["locks"].values(): + lock["is_locked"] = True + for camera in _device_state["cameras"].values(): + camera["is_online"] = True + for sensor in _device_state["sensors"].values(): + sensor["battery_level"] = 100 + _device_state["security_system"]["is_armed"] = True + + result = get_home_summary() + assert result["security_score"] == 100 + + # Unlock a door and check score drops + _device_state["locks"]["front_door"]["is_locked"] = False + result = get_home_summary() + assert result["security_score"] < 100 diff --git a/tests/test_mobile_api.py b/tests/test_mobile_api.py new file mode 100644 index 0000000..d440e6c --- /dev/null +++ b/tests/test_mobile_api.py @@ -0,0 +1,375 @@ +"""Tests for the Mobile API endpoints.""" + +import pytest +from fastapi.testclient import TestClient + +from src.mobile_api.app import app +from src.agents.home_security_agent.tools.device_tools import _device_state + + +@pytest.fixture +def client() -> TestClient: + """Create test client.""" + return TestClient(app) + + +@pytest.fixture(autouse=True) +def reset_device_state() -> None: + """Reset device state before each test.""" + # Reset locks + for lock in _device_state["locks"].values(): + lock["is_locked"] = True + _device_state["locks"]["garage_door"]["is_locked"] = False + + # Reset cameras + for camera in _device_state["cameras"].values(): + camera["is_online"] = True + _device_state["cameras"]["garage_interior"]["is_online"] = False + + # Reset security system + _device_state["security_system"]["is_armed"] = False + _device_state["security_system"]["arm_mode"] = "disarmed" + + +class TestHealthEndpoint: + """Tests for health check endpoint.""" + + def test_health_check(self, client: TestClient) -> None: + """Test health check returns healthy status.""" + response = client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert "version" in data + assert "timestamp" in data + + +class TestSummaryEndpoint: + """Tests for home summary endpoint.""" + + def test_get_summary(self, client: TestClient) -> None: + """Test getting home summary.""" + response = client.get("/api/summary") + + assert response.status_code == 200 + data = response.json() + assert "security_score" in data + assert "locks" in data + assert "cameras" in data + assert "sensors" in data + + +class TestLockEndpoints: + """Tests for lock management endpoints.""" + + def test_list_all_locks(self, client: TestClient) -> None: + """Test listing all locks.""" + response = client.get("/api/locks") + + assert response.status_code == 200 + data = response.json() + assert "locks" in data + assert data["total_locks"] == 4 + + def test_get_single_lock(self, client: TestClient) -> None: + """Test getting a single lock.""" + response = client.get("/api/locks/front_door") + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Front Door" + + def test_get_invalid_lock(self, client: TestClient) -> None: + """Test getting an invalid lock returns 404.""" + response = client.get("/api/locks/nonexistent") + + assert response.status_code == 404 + + def test_lock_door(self, client: TestClient) -> None: + """Test locking a door.""" + _device_state["locks"]["front_door"]["is_locked"] = False + + response = client.post("/api/locks/front_door/lock") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + def test_unlock_door_without_confirmation(self, client: TestClient) -> None: + """Test unlocking without confirmation.""" + response = client.post( + "/api/locks/front_door/unlock", + json={"confirm": False} + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + + def test_unlock_door_with_confirmation(self, client: TestClient) -> None: + """Test unlocking with confirmation.""" + response = client.post( + "/api/locks/front_door/unlock", + json={"confirm": True} + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + def test_lock_all_doors(self, client: TestClient) -> None: + """Test locking all doors.""" + response = client.post("/api/locks/lock-all") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "locked" in data["message"].lower() + + +class TestCameraEndpoints: + """Tests for camera monitoring endpoints.""" + + def test_list_all_cameras(self, client: TestClient) -> None: + """Test listing all cameras.""" + response = client.get("/api/cameras") + + assert response.status_code == 200 + data = response.json() + assert "cameras" in data + assert data["total_cameras"] == 4 + + def test_get_single_camera(self, client: TestClient) -> None: + """Test getting a single camera.""" + response = client.get("/api/cameras/front_porch") + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Front Porch Camera" + + def test_capture_snapshot(self, client: TestClient) -> None: + """Test capturing a snapshot.""" + response = client.post("/api/cameras/front_porch/snapshot") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "snapshot_id" in data + + def test_capture_snapshot_offline_camera(self, client: TestClient) -> None: + """Test capturing snapshot from offline camera.""" + response = client.post("/api/cameras/garage_interior/snapshot") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + assert "offline" in data["error"].lower() + + def test_get_motion_events(self, client: TestClient) -> None: + """Test getting motion events.""" + response = client.get("/api/cameras/motion-events?hours=24") + + assert response.status_code == 200 + data = response.json() + assert "events" in data + assert "total_events" in data + + +class TestSensorEndpoints: + """Tests for sensor monitoring endpoints.""" + + def test_list_all_sensors(self, client: TestClient) -> None: + """Test listing all sensors.""" + response = client.get("/api/sensors") + + assert response.status_code == 200 + data = response.json() + assert "sensors" in data + assert data["total_sensors"] == 5 + + +class TestSecuritySystemEndpoints: + """Tests for security system endpoints.""" + + def test_get_system_status(self, client: TestClient) -> None: + """Test getting security system status.""" + response = client.get("/api/security-system") + + assert response.status_code == 200 + data = response.json() + assert "is_armed" in data + assert "arm_mode" in data + + def test_arm_system_with_unlocked_doors(self, client: TestClient) -> None: + """Test arming with unlocked doors fails.""" + _device_state["locks"]["garage_door"]["is_locked"] = False + + response = client.post( + "/api/security-system/arm", + json={"mode": "away"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + + def test_arm_system_all_locked(self, client: TestClient) -> None: + """Test arming with all doors locked.""" + # Lock all doors + for lock in _device_state["locks"].values(): + lock["is_locked"] = True + + response = client.post( + "/api/security-system/arm", + json={"mode": "away"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + def test_disarm_system(self, client: TestClient) -> None: + """Test disarming the system.""" + response = client.post( + "/api/security-system/disarm", + json={} + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + +class TestActivityEndpoints: + """Tests for activity log endpoints.""" + + def test_get_activity(self, client: TestClient) -> None: + """Test getting activity log.""" + response = client.get("/api/activity?count=10") + + assert response.status_code == 200 + data = response.json() + assert "activities" in data + assert "count" in data + + +class TestChatEndpoints: + """Tests for AI chat endpoints.""" + + def test_chat_status_query(self, client: TestClient) -> None: + """Test chat with status query.""" + response = client.post( + "/api/chat", + json={"message": "What's going on at home?"} + ) + + assert response.status_code == 200 + data = response.json() + assert "response" in data + assert "session_id" in data + assert "suggestions" in data + + def test_chat_lock_query(self, client: TestClient) -> None: + """Test chat with lock query.""" + response = client.post( + "/api/chat", + json={"message": "Show me lock status"} + ) + + assert response.status_code == 200 + data = response.json() + assert "Lock" in data["response"] + + def test_chat_session_continuity(self, client: TestClient) -> None: + """Test chat maintains session.""" + # First message + response1 = client.post( + "/api/chat", + json={"message": "Hello"} + ) + session_id = response1.json()["session_id"] + + # Second message with same session + response2 = client.post( + "/api/chat", + json={"message": "Show locks", "session_id": session_id} + ) + + assert response2.json()["session_id"] == session_id + + +class TestQuickActionsEndpoints: + """Tests for quick action endpoints.""" + + def test_goodnight_routine(self, client: TestClient) -> None: + """Test goodnight routine.""" + response = client.post("/api/quick-actions/goodnight") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "goodnight" in data["message"].lower() + + def test_leaving_routine(self, client: TestClient) -> None: + """Test leaving routine.""" + response = client.post("/api/quick-actions/leaving") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + def test_arriving_routine(self, client: TestClient) -> None: + """Test arriving routine.""" + response = client.post("/api/quick-actions/arriving") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "welcome" in data["message"].lower() + + +class TestNotificationEndpoints: + """Tests for notification endpoints.""" + + def test_register_device(self, client: TestClient) -> None: + """Test device registration.""" + response = client.post( + "/api/notifications/register", + json={ + "device_token": "test-token-12345", + "device_type": "ios", + "device_name": "iPhone 15 Pro" + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "device_id" in data["details"] + + def test_get_notification_settings(self, client: TestClient) -> None: + """Test getting notification settings.""" + response = client.get("/api/notifications/settings?user_id=test_user") + + assert response.status_code == 200 + data = response.json() + assert "push_enabled" in data + assert "motion_alerts" in data + + def test_update_notification_settings(self, client: TestClient) -> None: + """Test updating notification settings.""" + response = client.put( + "/api/notifications/settings?user_id=test_user", + json={ + "push_enabled": True, + "motion_alerts": False, + "door_alerts": True, + "system_alerts": True, + "low_battery_alerts": True + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True From b6ec4b3b44f1479132763f74c8a777818c27e926 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 00:27:12 +0000 Subject: [PATCH 2/3] Switch home security agent to Gemma 2 9B model - Change default model from gpt-oss:20b to gemma2:9b via Ollama - Add HOME_SECURITY_MODEL environment variable for model override - Support multiple Gemma variants (7b, 9b, 27b) and Google AI Studio - Update README with Gemma setup instructions Co-authored-by: Nathan Faggian --- README.md | 21 ++++++++++++++++++++- src/agents/home_security_agent/agent.py | 18 +++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7304707..470c381 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ make prefect-server ### Agents #### Home Security Agent (NEW) -An AI-powered home security monitoring agent that manages smart locks, cameras, and sensors. +An AI-powered home security monitoring agent that manages smart locks, cameras, and sensors. Uses **Gemma 2 9B** by default via local Ollama. **Features:** - **Smart Lock Management**: View status, lock/unlock doors remotely @@ -185,10 +185,29 @@ struct HomeSecurityAPI { ## Configuration - **Ollama**: Set `OLLAMA_API_BASE` in `.env` or environment +- **Home Security Model**: Set `HOME_SECURITY_MODEL` to override the default Gemma model + - Default: `ollama_chat/gemma2:9b` (local Ollama) + - Options: `ollama_chat/gemma2:27b`, `ollama_chat/gemma:7b`, `gemini/gemma-2-9b-it` - **Google Docs**: Place `credentials.json` in project root (token.json auto-generated) - **Prefect UI**: Available at `http://127.0.0.1:4200` (or port specified by `PREFECT_PORT`) - **Mobile API**: Default port 8000, configurable via uvicorn options +### Setting up Gemma with Ollama + +```bash +# Install Ollama (if not already installed) +curl -fsSL https://ollama.com/install.sh | sh + +# Pull Gemma 2 9B model +ollama pull gemma2:9b + +# Or for the larger 27B model (requires more RAM) +ollama pull gemma2:27b + +# Start Ollama server +ollama serve +``` + ## Security Notes - In production, add authentication (JWT/OAuth2) to the Mobile API diff --git a/src/agents/home_security_agent/agent.py b/src/agents/home_security_agent/agent.py index efd83c8..ff8df74 100644 --- a/src/agents/home_security_agent/agent.py +++ b/src/agents/home_security_agent/agent.py @@ -1,5 +1,7 @@ """Home Security Monitoring Agent - AI-powered smart home security assistant.""" +import os + from google.adk.agents import Agent from google.adk.models.lite_llm import LiteLlm @@ -20,6 +22,13 @@ get_home_summary, ) +# Model configuration - supports both local Ollama and cloud providers +# For local Ollama with Gemma: ollama_chat/gemma2:9b or ollama_chat/gemma2:27b +# For Google AI Studio: gemini/gemma-2-9b-it or gemini/gemma-2-27b-it +# Set MODEL_NAME environment variable to override default +DEFAULT_MODEL = "ollama_chat/gemma2:9b" +MODEL_NAME = os.getenv("HOME_SECURITY_MODEL", DEFAULT_MODEL) + AGENT_INSTRUCTION = """You are a helpful and vigilant home security AI assistant. Your role is to help homeowners monitor and manage their home security systems including smart locks, cameras, and sensors. @@ -57,7 +66,14 @@ """ root_agent = Agent( - model=LiteLlm(model="ollama_chat/gpt-oss:20b"), + # Uses Gemma 2 9B by default via local Ollama + # Override with HOME_SECURITY_MODEL environment variable + # Examples: + # - ollama_chat/gemma2:9b (default, local) + # - ollama_chat/gemma2:27b (larger local model) + # - ollama_chat/gemma:7b (original Gemma) + # - gemini/gemma-2-9b-it (Google AI Studio) + model=LiteLlm(model=MODEL_NAME), name="home_security_agent", description="An AI assistant that monitors home security including locks, cameras, and sensors.", instruction=AGENT_INSTRUCTION, From 1eb58b2952ebf2f4da8bd1fcdcbb55e00967439e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 00:42:13 +0000 Subject: [PATCH 3/3] Add complete SwiftUI iOS app for home security monitoring Features: - Dashboard with security score gauge and quick actions (goodnight/leaving/arriving) - Locks view with real-time status, lock/unlock controls, battery indicators - Cameras view with motion events timeline and detection confidence - AI Chat assistant with conversation history and smart suggestions - Settings with server config, arm/disarm controls, activity log, sensors Architecture: - SwiftUI with MVVM pattern - Async/await networking via URLSession - Codable models matching API responses - iOS 17.0+ with modern SwiftUI features Includes: - Full Xcode project configuration - Asset catalogs for app icon and accent color - Detailed README with setup instructions Co-authored-by: Nathan Faggian --- .gitignore | 11 + README.md | 62 +- .../HomeSecurityApp.xcodeproj/project.pbxproj | 391 +++++++++++++ .../AccentColor.colorset/Contents.json | 20 + .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + .../HomeSecurityApp/ContentView.swift | 45 ++ .../HomeSecurityApp/HomeSecurityAppApp.swift | 22 + .../HomeSecurityApp/Models/Models.swift | 330 +++++++++++ .../HomeSecurityApp/Services/APIService.swift | 199 +++++++ .../HomeSecurityApp/Views/CamerasView.swift | 452 ++++++++++++++ .../HomeSecurityApp/Views/ChatView.swift | 286 +++++++++ .../HomeSecurityApp/Views/DashboardView.swift | 446 ++++++++++++++ .../HomeSecurityApp/Views/LocksView.swift | 310 ++++++++++ .../HomeSecurityApp/Views/SettingsView.swift | 550 ++++++++++++++++++ ios/README.md | 170 ++++++ 16 files changed, 3278 insertions(+), 35 deletions(-) create mode 100644 ios/HomeSecurityApp/HomeSecurityApp.xcodeproj/project.pbxproj create mode 100644 ios/HomeSecurityApp/HomeSecurityApp/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios/HomeSecurityApp/HomeSecurityApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/HomeSecurityApp/HomeSecurityApp/Assets.xcassets/Contents.json create mode 100644 ios/HomeSecurityApp/HomeSecurityApp/ContentView.swift create mode 100644 ios/HomeSecurityApp/HomeSecurityApp/HomeSecurityAppApp.swift create mode 100644 ios/HomeSecurityApp/HomeSecurityApp/Models/Models.swift create mode 100644 ios/HomeSecurityApp/HomeSecurityApp/Services/APIService.swift create mode 100644 ios/HomeSecurityApp/HomeSecurityApp/Views/CamerasView.swift create mode 100644 ios/HomeSecurityApp/HomeSecurityApp/Views/ChatView.swift create mode 100644 ios/HomeSecurityApp/HomeSecurityApp/Views/DashboardView.swift create mode 100644 ios/HomeSecurityApp/HomeSecurityApp/Views/LocksView.swift create mode 100644 ios/HomeSecurityApp/HomeSecurityApp/Views/SettingsView.swift create mode 100644 ios/README.md diff --git a/.gitignore b/.gitignore index 2cdfded..05e3efe 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,17 @@ venv.bak/ # VSCode .vscode/ + +# Xcode +ios/**/xcuserdata/ +ios/**/DerivedData/ +ios/**/*.xcworkspace/xcuserdata/ +ios/**/*.xcodeproj/project.xcworkspace/xcuserdata/ +ios/**/*.xcodeproj/xcuserdata/ +ios/**/build/ +*.ipa +*.dSYM.zip +*.dSYM ml_agent/__pycache__/__init__.cpython-313.pyc uv.lock ml_agent/__pycache__/__init__.cpython-313.pyc diff --git a/README.md b/README.md index 470c381..ff881f0 100644 --- a/README.md +++ b/README.md @@ -145,43 +145,35 @@ tests/ โ””โ”€โ”€ test_mobile_api.py # API endpoint tests ``` -## iOS App Integration - -The Mobile API is designed for easy iOS app integration: - -1. **SwiftUI/UIKit**: Use URLSession or Alamofire to call REST endpoints -2. **Widgets**: Use quick action endpoints for iOS widgets -3. **Siri Shortcuts**: Integrate quick actions with iOS Shortcuts app -4. **Push Notifications**: Register device tokens via `/api/notifications/register` - -**Example Swift code:** -```swift -struct HomeSecurityAPI { - let baseURL = "http://your-server:8000" - - func getSummary() async throws -> HomeSummary { - let url = URL(string: "\(baseURL)/api/summary")! - let (data, _) = try await URLSession.shared.data(from: url) - return try JSONDecoder().decode(HomeSummary.self, from: data) - } - - func lockAllDoors() async throws { - var request = URLRequest(url: URL(string: "\(baseURL)/api/locks/lock-all")!) - request.httpMethod = "POST" - let (_, _) = try await URLSession.shared.data(for: request) - } - - func chat(message: String) async throws -> ChatResponse { - var request = URLRequest(url: URL(string: "\(baseURL)/api/chat")!) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONEncoder().encode(["message": message]) - let (data, _) = try await URLSession.shared.data(for: request) - return try JSONDecoder().decode(ChatResponse.self, from: data) - } -} +## iOS App + +A complete SwiftUI iOS app is included in the `ios/` directory. + +### Features +- **Dashboard**: Security score gauge, quick actions, status overview +- **Locks**: View/control all smart locks with battery indicators +- **Cameras**: Camera grid with motion detection events +- **AI Chat**: Natural language assistant for security queries +- **Settings**: Server config, arm/disarm, notifications, activity log + +### Quick Start + +```bash +# 1. Start the backend +make mobile-api + +# 2. Open in Xcode +open ios/HomeSecurityApp/HomeSecurityApp.xcodeproj + +# 3. Build and run (โŒ˜R) ``` +### Requirements +- iOS 17.0+ +- Xcode 15.0+ + +See [ios/README.md](ios/README.md) for detailed setup instructions. + ## Configuration - **Ollama**: Set `OLLAMA_API_BASE` in `.env` or environment diff --git a/ios/HomeSecurityApp/HomeSecurityApp.xcodeproj/project.pbxproj b/ios/HomeSecurityApp/HomeSecurityApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..2beafc9 --- /dev/null +++ b/ios/HomeSecurityApp/HomeSecurityApp.xcodeproj/project.pbxproj @@ -0,0 +1,391 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 001 /* HomeSecurityAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101 /* HomeSecurityAppApp.swift */; }; + 002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102 /* ContentView.swift */; }; + 003 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 103 /* Models.swift */; }; + 004 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 104 /* APIService.swift */; }; + 005 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105 /* DashboardView.swift */; }; + 006 /* LocksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106 /* LocksView.swift */; }; + 007 /* CamerasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 107 /* CamerasView.swift */; }; + 008 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 108 /* ChatView.swift */; }; + 009 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109 /* SettingsView.swift */; }; + 010 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 110 /* Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 100 /* HomeSecurityApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HomeSecurityApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 101 /* HomeSecurityAppApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSecurityAppApp.swift; sourceTree = ""; }; + 102 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 103 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 104 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; + 105 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; + 106 /* LocksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocksView.swift; sourceTree = ""; }; + 107 /* CamerasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CamerasView.swift; sourceTree = ""; }; + 108 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; + 109 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 110 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 200 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 300 = { + isa = PBXGroup; + children = ( + 301 /* HomeSecurityApp */, + 302 /* Products */, + ); + sourceTree = ""; + }; + 301 /* HomeSecurityApp */ = { + isa = PBXGroup; + children = ( + 101 /* HomeSecurityAppApp.swift */, + 102 /* ContentView.swift */, + 303 /* Models */, + 304 /* Services */, + 305 /* Views */, + 110 /* Assets.xcassets */, + ); + path = HomeSecurityApp; + sourceTree = ""; + }; + 302 /* Products */ = { + isa = PBXGroup; + children = ( + 100 /* HomeSecurityApp.app */, + ); + name = Products; + sourceTree = ""; + }; + 303 /* Models */ = { + isa = PBXGroup; + children = ( + 103 /* Models.swift */, + ); + path = Models; + sourceTree = ""; + }; + 304 /* Services */ = { + isa = PBXGroup; + children = ( + 104 /* APIService.swift */, + ); + path = Services; + sourceTree = ""; + }; + 305 /* Views */ = { + isa = PBXGroup; + children = ( + 105 /* DashboardView.swift */, + 106 /* LocksView.swift */, + 107 /* CamerasView.swift */, + 108 /* ChatView.swift */, + 109 /* SettingsView.swift */, + ); + path = Views; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 400 /* HomeSecurityApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 500 /* Build configuration list for PBXNativeTarget "HomeSecurityApp" */; + buildPhases = ( + 401 /* Sources */, + 200 /* Frameworks */, + 402 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = HomeSecurityApp; + productName = HomeSecurityApp; + productReference = 100 /* HomeSecurityApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 600 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + 400 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = 601 /* Build configuration list for PBXProject "HomeSecurityApp" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 300; + productRefGroup = 302 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 400 /* HomeSecurityApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 402 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 010 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 401 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 001 /* HomeSecurityAppApp.swift in Sources */, + 002 /* ContentView.swift in Sources */, + 003 /* Models.swift in Sources */, + 004 /* APIService.swift in Sources */, + 005 /* DashboardView.swift in Sources */, + 006 /* LocksView.swift in Sources */, + 007 /* CamerasView.swift in Sources */, + 008 /* ChatView.swift in Sources */, + 009 /* SettingsView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 700 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 701 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 702 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Home Security"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.homesecurity.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 703 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Home Security"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.homesecurity.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 500 /* Build configuration list for PBXNativeTarget "HomeSecurityApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 702 /* Debug */, + 703 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 601 /* Build configuration list for PBXProject "HomeSecurityApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 700 /* Debug */, + 701 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 600 /* Project object */; +} diff --git a/ios/HomeSecurityApp/HomeSecurityApp/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/HomeSecurityApp/HomeSecurityApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..4af3ddf --- /dev/null +++ b/ios/HomeSecurityApp/HomeSecurityApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.898", + "green" : "0.439", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/HomeSecurityApp/HomeSecurityApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/HomeSecurityApp/HomeSecurityApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/ios/HomeSecurityApp/HomeSecurityApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/HomeSecurityApp/HomeSecurityApp/Assets.xcassets/Contents.json b/ios/HomeSecurityApp/HomeSecurityApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/HomeSecurityApp/HomeSecurityApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/HomeSecurityApp/HomeSecurityApp/ContentView.swift b/ios/HomeSecurityApp/HomeSecurityApp/ContentView.swift new file mode 100644 index 0000000..b653646 --- /dev/null +++ b/ios/HomeSecurityApp/HomeSecurityApp/ContentView.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var appState: AppState + + var body: some View { + TabView(selection: $appState.selectedTab) { + DashboardView() + .tabItem { + Label("Dashboard", systemImage: "house.fill") + } + .tag(AppState.Tab.dashboard) + + LocksView() + .tabItem { + Label("Locks", systemImage: "lock.fill") + } + .tag(AppState.Tab.locks) + + CamerasView() + .tabItem { + Label("Cameras", systemImage: "video.fill") + } + .tag(AppState.Tab.cameras) + + ChatView() + .tabItem { + Label("Assistant", systemImage: "bubble.left.and.bubble.right.fill") + } + .tag(AppState.Tab.chat) + + SettingsView() + .tabItem { + Label("Settings", systemImage: "gear") + } + .tag(AppState.Tab.settings) + } + .tint(.blue) + } +} + +#Preview { + ContentView() + .environmentObject(AppState()) +} diff --git a/ios/HomeSecurityApp/HomeSecurityApp/HomeSecurityAppApp.swift b/ios/HomeSecurityApp/HomeSecurityApp/HomeSecurityAppApp.swift new file mode 100644 index 0000000..113c26a --- /dev/null +++ b/ios/HomeSecurityApp/HomeSecurityApp/HomeSecurityAppApp.swift @@ -0,0 +1,22 @@ +import SwiftUI + +@main +struct HomeSecurityAppApp: App { + @StateObject private var appState = AppState() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(appState) + } + } +} + +class AppState: ObservableObject { + @Published var isAuthenticated = true + @Published var selectedTab: Tab = .dashboard + + enum Tab { + case dashboard, locks, cameras, chat, settings + } +} diff --git a/ios/HomeSecurityApp/HomeSecurityApp/Models/Models.swift b/ios/HomeSecurityApp/HomeSecurityApp/Models/Models.swift new file mode 100644 index 0000000..62b8bd9 --- /dev/null +++ b/ios/HomeSecurityApp/HomeSecurityApp/Models/Models.swift @@ -0,0 +1,330 @@ +import Foundation + +// MARK: - Home Summary +struct HomeSummary: Codable { + let timestamp: String + let securityScore: Int + let securityLevel: String + let locks: LocksSummary + let cameras: CamerasSummary + let sensors: SensorsSummary + let securitySystem: SecuritySystemStatus + let issues: [String] + let recommendations: [String] + + enum CodingKeys: String, CodingKey { + case timestamp + case securityScore = "security_score" + case securityLevel = "security_level" + case locks, cameras, sensors + case securitySystem = "security_system" + case issues, recommendations + } +} + +struct LocksSummary: Codable { + let summary: String + let allSecure: Bool + let unlockedCount: Int + + enum CodingKeys: String, CodingKey { + case summary + case allSecure = "all_secure" + case unlockedCount = "unlocked_count" + } +} + +struct CamerasSummary: Codable { + let summary: String + let allOnline: Bool + let offlineCount: Int + let motionDetected: Int + + enum CodingKeys: String, CodingKey { + case summary + case allOnline = "all_online" + case offlineCount = "offline_count" + case motionDetected = "motion_detected" + } +} + +struct SensorsSummary: Codable { + let summary: String + let allNormal: Bool + let triggeredCount: Int + let lowBatteryCount: Int + + enum CodingKeys: String, CodingKey { + case summary + case allNormal = "all_normal" + case triggeredCount = "triggered_count" + case lowBatteryCount = "low_battery_count" + } +} + +// MARK: - Security System +struct SecuritySystemStatus: Codable { + let isArmed: Bool + let armMode: String + let status: String + let alarmTriggered: Bool + let lastArmed: String? + let lastDisarmed: String? + + enum CodingKeys: String, CodingKey { + case isArmed = "is_armed" + case armMode = "arm_mode" + case status + case alarmTriggered = "alarm_triggered" + case lastArmed = "last_armed" + case lastDisarmed = "last_disarmed" + } +} + +// MARK: - Locks +struct LocksResponse: Codable { + let locks: [String: LockStatus] + let totalLocks: Int + let unlockedCount: Int + let allSecure: Bool + let summary: String + + enum CodingKeys: String, CodingKey { + case locks + case totalLocks = "total_locks" + case unlockedCount = "unlocked_count" + case allSecure = "all_secure" + case summary + } +} + +struct LockStatus: Codable, Identifiable { + var id: String { name } + let name: String + let location: String + let isLocked: Bool + let status: String + let batteryLevel: Int + let lastActivity: String? + + enum CodingKeys: String, CodingKey { + case name, location, status + case isLocked = "is_locked" + case batteryLevel = "battery_level" + case lastActivity = "last_activity" + } +} + +// MARK: - Cameras +struct CamerasResponse: Codable { + let cameras: [String: CameraStatus] + let totalCameras: Int + let onlineCount: Int + let offlineCount: Int + let camerasWithMotion: Int + let allOnline: Bool + let summary: String + + enum CodingKeys: String, CodingKey { + case cameras + case totalCameras = "total_cameras" + case onlineCount = "online_count" + case offlineCount = "offline_count" + case camerasWithMotion = "cameras_with_motion" + case allOnline = "all_online" + case summary + } +} + +struct CameraStatus: Codable, Identifiable { + var id: String { name } + let name: String + let location: String + let isOnline: Bool + let isRecording: Bool + let motionDetected: Bool + let resolution: String + let nightVision: Bool + let lastMotion: String? + let status: String + + enum CodingKeys: String, CodingKey { + case name, location, status, resolution + case isOnline = "is_online" + case isRecording = "is_recording" + case motionDetected = "motion_detected" + case nightVision = "night_vision" + case lastMotion = "last_motion" + } +} + +// MARK: - Motion Events +struct MotionEventsResponse: Codable { + let events: [MotionEvent] + let totalEvents: Int + let timeRangeHours: Int + let summary: String + + enum CodingKeys: String, CodingKey { + case events + case totalEvents = "total_events" + case timeRangeHours = "time_range_hours" + case summary + } +} + +struct MotionEvent: Codable, Identifiable { + var id: String { "\(cameraId)-\(timestamp)" } + let cameraId: String + let cameraName: String + let location: String + let timestamp: String + let eventType: String + let confidence: Int + + enum CodingKeys: String, CodingKey { + case cameraId = "camera_id" + case cameraName = "camera_name" + case location, timestamp + case eventType = "event_type" + case confidence + } +} + +// MARK: - Sensors +struct SensorsResponse: Codable { + let sensors: [String: SensorStatus] + let totalSensors: Int + let triggeredCount: Int + let lowBatteryCount: Int + let allNormal: Bool + let summary: String + + enum CodingKeys: String, CodingKey { + case sensors + case totalSensors = "total_sensors" + case triggeredCount = "triggered_count" + case lowBatteryCount = "low_battery_count" + case allNormal = "all_normal" + case summary + } +} + +struct SensorStatus: Codable, Identifiable { + var id: String { name } + let name: String + let type: String + let location: String + let isTriggered: Bool + let batteryLevel: Int + let batteryStatus: String + let lastTriggered: String? + + enum CodingKeys: String, CodingKey { + case name, type, location + case isTriggered = "is_triggered" + case batteryLevel = "battery_level" + case batteryStatus = "battery_status" + case lastTriggered = "last_triggered" + } +} + +// MARK: - Activity +struct ActivityResponse: Codable { + let activities: [ActivityEvent] + let count: Int + let message: String +} + +struct ActivityEvent: Codable, Identifiable { + let id: String + let timestamp: String + let eventType: String + let deviceName: String + let details: String + + enum CodingKeys: String, CodingKey { + case id, timestamp, details + case eventType = "event_type" + case deviceName = "device_name" + } +} + +// MARK: - Chat +struct ChatRequest: Codable { + let message: String + let sessionId: String? + + enum CodingKeys: String, CodingKey { + case message + case sessionId = "session_id" + } +} + +struct ChatResponse: Codable { + let response: String + let sessionId: String + let timestamp: String + let suggestions: [String]? + + enum CodingKeys: String, CodingKey { + case response + case sessionId = "session_id" + case timestamp, suggestions + } +} + +// MARK: - Action Response +struct ActionResponse: Codable { + let success: Bool + let message: String + let details: [String: AnyCodable]? + let error: String? + let warning: String? +} + +// Helper for dynamic JSON values +struct AnyCodable: Codable { + let value: Any + + init(_ value: Any) { + self.value = value + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let string = try? container.decode(String.self) { + value = string + } else if let int = try? container.decode(Int.self) { + value = int + } else if let bool = try? container.decode(Bool.self) { + value = bool + } else if let double = try? container.decode(Double.self) { + value = double + } else { + value = "" + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + if let string = value as? String { + try container.encode(string) + } else if let int = value as? Int { + try container.encode(int) + } else if let bool = value as? Bool { + try container.encode(bool) + } else if let double = value as? Double { + try container.encode(double) + } + } +} + +// MARK: - Arm Request +struct ArmRequest: Codable { + let mode: String +} + +struct UnlockRequest: Codable { + let confirm: Bool +} diff --git a/ios/HomeSecurityApp/HomeSecurityApp/Services/APIService.swift b/ios/HomeSecurityApp/HomeSecurityApp/Services/APIService.swift new file mode 100644 index 0000000..614d318 --- /dev/null +++ b/ios/HomeSecurityApp/HomeSecurityApp/Services/APIService.swift @@ -0,0 +1,199 @@ +import Foundation + +class APIService: ObservableObject { + static let shared = APIService() + + // Configure this to your server's address + // For simulator: use your Mac's IP or localhost + // For device: use your server's network IP + private let baseURL: String + + init() { + // Default to localhost for simulator, change for real device + self.baseURL = UserDefaults.standard.string(forKey: "serverURL") ?? "http://localhost:8000" + } + + func updateBaseURL(_ url: String) { + UserDefaults.standard.set(url, forKey: "serverURL") + } + + var currentBaseURL: String { + return baseURL + } + + // MARK: - Generic Request Methods + + private func get(_ endpoint: String) async throws -> T { + guard let url = URL(string: "\(baseURL)\(endpoint)") else { + throw APIError.invalidURL + } + + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + throw APIError.httpError(statusCode: httpResponse.statusCode) + } + + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } + + private func post(_ endpoint: String, body: B?) async throws -> T { + guard let url = URL(string: "\(baseURL)\(endpoint)") else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if let body = body { + request.httpBody = try JSONEncoder().encode(body) + } + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + throw APIError.httpError(statusCode: httpResponse.statusCode) + } + + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } + + private func postNoBody(_ endpoint: String) async throws -> T { + guard let url = URL(string: "\(baseURL)\(endpoint)") else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + throw APIError.httpError(statusCode: httpResponse.statusCode) + } + + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } + + // MARK: - Dashboard + + func getSummary() async throws -> HomeSummary { + return try await get("/api/summary") + } + + // MARK: - Locks + + func getLocks() async throws -> LocksResponse { + return try await get("/api/locks") + } + + func lockDoor(_ lockId: String) async throws -> ActionResponse { + return try await postNoBody("/api/locks/\(lockId)/lock") + } + + func unlockDoor(_ lockId: String, confirm: Bool = true) async throws -> ActionResponse { + let request = UnlockRequest(confirm: confirm) + return try await post("/api/locks/\(lockId)/unlock", body: request) + } + + func lockAllDoors() async throws -> ActionResponse { + return try await postNoBody("/api/locks/lock-all") + } + + // MARK: - Cameras + + func getCameras() async throws -> CamerasResponse { + return try await get("/api/cameras") + } + + func getMotionEvents(hours: Int = 24) async throws -> MotionEventsResponse { + return try await get("/api/cameras/motion-events?hours=\(hours)") + } + + // MARK: - Sensors + + func getSensors() async throws -> SensorsResponse { + return try await get("/api/sensors") + } + + // MARK: - Security System + + func getSecurityStatus() async throws -> SecuritySystemStatus { + return try await get("/api/security-system") + } + + func armSystem(mode: String = "away") async throws -> ActionResponse { + let request = ArmRequest(mode: mode) + return try await post("/api/security-system/arm", body: request) + } + + func disarmSystem() async throws -> ActionResponse { + struct EmptyBody: Codable {} + return try await post("/api/security-system/disarm", body: EmptyBody()) + } + + // MARK: - Activity + + func getActivity(count: Int = 20) async throws -> ActivityResponse { + return try await get("/api/activity?count=\(count)") + } + + // MARK: - Chat + + func sendMessage(_ message: String, sessionId: String? = nil) async throws -> ChatResponse { + let request = ChatRequest(message: message, sessionId: sessionId) + return try await post("/api/chat", body: request) + } + + // MARK: - Quick Actions + + func goodnightRoutine() async throws -> ActionResponse { + return try await postNoBody("/api/quick-actions/goodnight") + } + + func leavingRoutine() async throws -> ActionResponse { + return try await postNoBody("/api/quick-actions/leaving") + } + + func arrivingRoutine() async throws -> ActionResponse { + return try await postNoBody("/api/quick-actions/arriving") + } +} + +// MARK: - API Errors + +enum APIError: LocalizedError { + case invalidURL + case invalidResponse + case httpError(statusCode: Int) + case decodingError + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid URL" + case .invalidResponse: + return "Invalid response from server" + case .httpError(let statusCode): + return "HTTP Error: \(statusCode)" + case .decodingError: + return "Failed to decode response" + } + } +} diff --git a/ios/HomeSecurityApp/HomeSecurityApp/Views/CamerasView.swift b/ios/HomeSecurityApp/HomeSecurityApp/Views/CamerasView.swift new file mode 100644 index 0000000..5a47dc1 --- /dev/null +++ b/ios/HomeSecurityApp/HomeSecurityApp/Views/CamerasView.swift @@ -0,0 +1,452 @@ +import SwiftUI + +struct CamerasView: View { + @StateObject private var viewModel = CamerasViewModel() + @State private var selectedTab = 0 + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Tab Picker + Picker("View", selection: $selectedTab) { + Text("Cameras").tag(0) + Text("Motion Events").tag(1) + } + .pickerStyle(.segmented) + .padding() + + if selectedTab == 0 { + CamerasListView(viewModel: viewModel) + } else { + MotionEventsListView(viewModel: viewModel) + } + } + .navigationTitle("Cameras") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { viewModel.refresh() }) { + Image(systemName: "arrow.clockwise") + } + } + } + } + .task { + await viewModel.loadCameras() + await viewModel.loadMotionEvents() + } + } +} + +// MARK: - Cameras List + +struct CamerasListView: View { + @ObservedObject var viewModel: CamerasViewModel + + var body: some View { + ScrollView { + VStack(spacing: 16) { + if viewModel.isLoading { + ProgressView("Loading cameras...") + .frame(maxWidth: .infinity, minHeight: 200) + } else if let camerasResponse = viewModel.camerasResponse { + // Summary Header + CamerasSummaryHeader( + allOnline: camerasResponse.allOnline, + summary: camerasResponse.summary, + motionCount: camerasResponse.camerasWithMotion + ) + + // Camera Cards + ForEach(Array(camerasResponse.cameras.keys.sorted()), id: \.self) { key in + if let camera = camerasResponse.cameras[key] { + CameraCard(camera: camera) + } + } + } else if let error = viewModel.errorMessage { + ErrorCard(message: error) { + viewModel.refresh() + } + } + } + .padding() + } + .refreshable { + await viewModel.loadCameras() + } + } +} + +// MARK: - Summary Header + +struct CamerasSummaryHeader: View { + let allOnline: Bool + let summary: String + let motionCount: Int + + var body: some View { + VStack(spacing: 12) { + HStack(spacing: 16) { + Image(systemName: allOnline ? "video.fill" : "video.slash.fill") + .font(.largeTitle) + .foregroundColor(allOnline ? .green : .red) + + VStack(alignment: .leading, spacing: 4) { + Text(allOnline ? "All Online" : "Some Offline") + .font(.headline) + .foregroundColor(allOnline ? .green : .red) + Text(summary) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + } + + if motionCount > 0 { + HStack { + Image(systemName: "figure.walk.motion") + .foregroundColor(.blue) + Text("\(motionCount) camera(s) detecting motion") + .font(.caption) + .foregroundColor(.blue) + Spacer() + } + } + } + .padding() + .background(allOnline ? Color.green.opacity(0.1) : Color.red.opacity(0.1)) + .cornerRadius(12) + } +} + +// MARK: - Camera Card + +struct CameraCard: View { + let camera: CameraStatus + + var body: some View { + VStack(spacing: 0) { + // Camera Preview Placeholder + ZStack { + Rectangle() + .fill(Color.black) + .aspectRatio(16/9, contentMode: .fit) + + if camera.isOnline { + VStack(spacing: 8) { + Image(systemName: "video.fill") + .font(.largeTitle) + .foregroundColor(.white.opacity(0.5)) + Text("Live Feed") + .font(.caption) + .foregroundColor(.white.opacity(0.5)) + } + } else { + VStack(spacing: 8) { + Image(systemName: "video.slash.fill") + .font(.largeTitle) + .foregroundColor(.red) + Text("Offline") + .font(.caption) + .foregroundColor(.red) + } + } + + // Status Badges + VStack { + HStack { + // Recording indicator + if camera.isRecording && camera.isOnline { + HStack(spacing: 4) { + Circle() + .fill(Color.red) + .frame(width: 8, height: 8) + Text("REC") + .font(.caption2) + .fontWeight(.bold) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.black.opacity(0.6)) + .foregroundColor(.white) + .cornerRadius(4) + } + + Spacer() + + // Resolution + Text(camera.resolution) + .font(.caption2) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.black.opacity(0.6)) + .foregroundColor(.white) + .cornerRadius(4) + } + .padding(8) + + Spacer() + + // Motion indicator + if camera.motionDetected { + HStack { + Spacer() + HStack(spacing: 4) { + Image(systemName: "figure.walk.motion") + Text("Motion") + } + .font(.caption2) + .fontWeight(.bold) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.orange) + .foregroundColor(.white) + .cornerRadius(4) + .padding(8) + } + } + } + } + .cornerRadius(12, corners: [.topLeft, .topRight]) + + // Info Section + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(camera.name) + .font(.headline) + Text(camera.location) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + + // Features + HStack(spacing: 12) { + if camera.nightVision { + Image(systemName: "moon.fill") + .foregroundColor(.purple) + } + + Circle() + .fill(camera.isOnline ? Color.green : Color.red) + .frame(width: 10, height: 10) + } + } + .padding() + } + .background(Color(.systemBackground)) + .cornerRadius(16) + .shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2) + } +} + +// MARK: - Motion Events List + +struct MotionEventsListView: View { + @ObservedObject var viewModel: CamerasViewModel + + var body: some View { + ScrollView { + VStack(spacing: 12) { + if viewModel.isLoadingEvents { + ProgressView("Loading events...") + .frame(maxWidth: .infinity, minHeight: 200) + } else if let eventsResponse = viewModel.motionEventsResponse { + // Summary + HStack { + Image(systemName: "figure.walk.motion") + .foregroundColor(.blue) + Text(eventsResponse.summary) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + } + .padding() + .background(Color.blue.opacity(0.1)) + .cornerRadius(12) + + // Events + ForEach(eventsResponse.events) { event in + MotionEventCard(event: event) + } + + if eventsResponse.events.isEmpty { + VStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.largeTitle) + .foregroundColor(.green) + Text("No motion events") + .font(.headline) + Text("Your home has been quiet") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } + } else if let error = viewModel.errorMessage { + ErrorCard(message: error) { + viewModel.refresh() + } + } + } + .padding() + } + .refreshable { + await viewModel.loadMotionEvents() + } + } +} + +// MARK: - Motion Event Card + +struct MotionEventCard: View { + let event: MotionEvent + + var confidenceColor: Color { + if event.confidence >= 90 { return .green } + if event.confidence >= 75 { return .orange } + return .red + } + + var body: some View { + HStack(spacing: 12) { + // Icon + ZStack { + Circle() + .fill(Color.blue.opacity(0.15)) + .frame(width: 44, height: 44) + Image(systemName: eventIcon) + .foregroundColor(.blue) + } + + // Info + VStack(alignment: .leading, spacing: 4) { + Text(event.eventType) + .font(.headline) + Text(event.cameraName) + .font(.subheadline) + .foregroundColor(.secondary) + Text(formatTimestamp(event.timestamp)) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + // Confidence + VStack(spacing: 2) { + Text("\(event.confidence)%") + .font(.headline) + .foregroundColor(confidenceColor) + Text("confidence") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: .black.opacity(0.05), radius: 3, x: 0, y: 1) + } + + var eventIcon: String { + switch event.eventType.lowercased() { + case let type where type.contains("person"): + return "figure.stand" + case let type where type.contains("vehicle"): + return "car.fill" + case let type where type.contains("animal"): + return "pawprint.fill" + case let type where type.contains("package"): + return "shippingbox.fill" + default: + return "figure.walk.motion" + } + } + + func formatTimestamp(_ timestamp: String) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + if let date = formatter.date(from: timestamp) { + let displayFormatter = DateFormatter() + displayFormatter.dateStyle = .short + displayFormatter.timeStyle = .short + return displayFormatter.string(from: date) + } + return timestamp + } +} + +// MARK: - Corner Radius Extension + +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } +} + +struct RoundedCorner: Shape { + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath( + roundedRect: rect, + byRoundingCorners: corners, + cornerRadii: CGSize(width: radius, height: radius) + ) + return Path(path.cgPath) + } +} + +// MARK: - View Model + +@MainActor +class CamerasViewModel: ObservableObject { + @Published var camerasResponse: CamerasResponse? + @Published var motionEventsResponse: MotionEventsResponse? + @Published var isLoading = false + @Published var isLoadingEvents = false + @Published var errorMessage: String? + + private let api = APIService.shared + + func loadCameras() async { + isLoading = true + errorMessage = nil + + do { + camerasResponse = try await api.getCameras() + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + func loadMotionEvents() async { + isLoadingEvents = true + + do { + motionEventsResponse = try await api.getMotionEvents(hours: 24) + } catch { + errorMessage = error.localizedDescription + } + + isLoadingEvents = false + } + + func refresh() { + Task { + await loadCameras() + await loadMotionEvents() + } + } +} + +#Preview { + CamerasView() +} diff --git a/ios/HomeSecurityApp/HomeSecurityApp/Views/ChatView.swift b/ios/HomeSecurityApp/HomeSecurityApp/Views/ChatView.swift new file mode 100644 index 0000000..1a880d8 --- /dev/null +++ b/ios/HomeSecurityApp/HomeSecurityApp/Views/ChatView.swift @@ -0,0 +1,286 @@ +import SwiftUI + +struct ChatView: View { + @StateObject private var viewModel = ChatViewModel() + @FocusState private var isInputFocused: Bool + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Messages + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 12) { + ForEach(viewModel.messages) { message in + ChatBubble(message: message) + .id(message.id) + } + + if viewModel.isLoading { + TypingIndicator() + .id("typing") + } + } + .padding() + } + .onChange(of: viewModel.messages.count) { _, _ in + withAnimation { + proxy.scrollTo(viewModel.messages.last?.id, anchor: .bottom) + } + } + .onChange(of: viewModel.isLoading) { _, isLoading in + if isLoading { + withAnimation { + proxy.scrollTo("typing", anchor: .bottom) + } + } + } + } + + // Suggestions + if let suggestions = viewModel.suggestions, !suggestions.isEmpty, !viewModel.isLoading { + SuggestionsView(suggestions: suggestions) { suggestion in + viewModel.sendMessage(suggestion) + } + } + + Divider() + + // Input + ChatInputView( + text: $viewModel.inputText, + isLoading: viewModel.isLoading, + isFocused: $isInputFocused + ) { + viewModel.sendMessage(viewModel.inputText) + } + } + .navigationTitle("AI Assistant") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { viewModel.clearChat() }) { + Image(systemName: "trash") + } + } + } + } + } +} + +// MARK: - Chat Message Model + +struct ChatMessage: Identifiable { + let id = UUID() + let content: String + let isUser: Bool + let timestamp: Date +} + +// MARK: - Chat Bubble + +struct ChatBubble: View { + let message: ChatMessage + + var body: some View { + HStack { + if message.isUser { Spacer() } + + VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) { + Text(message.content) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(message.isUser ? Color.blue : Color(.systemGray5)) + .foregroundColor(message.isUser ? .white : .primary) + .cornerRadius(20) + + Text(formatTime(message.timestamp)) + .font(.caption2) + .foregroundColor(.secondary) + } + .frame(maxWidth: 280, alignment: message.isUser ? .trailing : .leading) + + if !message.isUser { Spacer() } + } + } + + func formatTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter.string(from: date) + } +} + +// MARK: - Typing Indicator + +struct TypingIndicator: View { + @State private var animationAmount = 0.0 + + var body: some View { + HStack { + HStack(spacing: 4) { + ForEach(0..<3) { index in + Circle() + .fill(Color.gray) + .frame(width: 8, height: 8) + .scaleEffect(animationAmount == Double(index) ? 1.2 : 0.8) + .animation( + .easeInOut(duration: 0.4) + .repeatForever() + .delay(Double(index) * 0.15), + value: animationAmount + ) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color(.systemGray5)) + .cornerRadius(20) + + Spacer() + } + .onAppear { + animationAmount = 2 + } + } +} + +// MARK: - Suggestions View + +struct SuggestionsView: View { + let suggestions: [String] + let onSelect: (String) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(suggestions, id: \.self) { suggestion in + Button(action: { onSelect(suggestion) }) { + Text(suggestion) + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.blue.opacity(0.1)) + .foregroundColor(.blue) + .cornerRadius(16) + } + } + } + .padding(.horizontal) + .padding(.vertical, 8) + } + .background(Color(.systemBackground)) + } +} + +// MARK: - Chat Input + +struct ChatInputView: View { + @Binding var text: String + let isLoading: Bool + var isFocused: FocusState.Binding + let onSend: () -> Void + + var body: some View { + HStack(spacing: 12) { + TextField("Ask about your home security...", text: $text) + .textFieldStyle(.plain) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color(.systemGray6)) + .cornerRadius(24) + .focused(isFocused) + .onSubmit { + if !text.isEmpty && !isLoading { + onSend() + } + } + + Button(action: onSend) { + Image(systemName: "arrow.up.circle.fill") + .font(.title) + .foregroundColor(text.isEmpty || isLoading ? .gray : .blue) + } + .disabled(text.isEmpty || isLoading) + } + .padding() + .background(Color(.systemBackground)) + } +} + +// MARK: - View Model + +@MainActor +class ChatViewModel: ObservableObject { + @Published var messages: [ChatMessage] = [] + @Published var inputText = "" + @Published var isLoading = false + @Published var suggestions: [String]? = ["What's going on?", "Lock all doors", "Show cameras", "Arm system"] + + private let api = APIService.shared + private var sessionId: String? + + init() { + // Add welcome message + messages.append(ChatMessage( + content: "๐Ÿ‘‹ Hi! I'm your home security assistant. Ask me anything about your locks, cameras, sensors, or security system.", + isUser: false, + timestamp: Date() + )) + } + + func sendMessage(_ text: String) { + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedText.isEmpty else { return } + + // Add user message + messages.append(ChatMessage( + content: trimmedText, + isUser: true, + timestamp: Date() + )) + + inputText = "" + isLoading = true + suggestions = nil + + Task { + do { + let response = try await api.sendMessage(trimmedText, sessionId: sessionId) + sessionId = response.sessionId + + // Add assistant response + messages.append(ChatMessage( + content: response.response, + isUser: false, + timestamp: Date() + )) + + // Update suggestions + suggestions = response.suggestions + } catch { + messages.append(ChatMessage( + content: "Sorry, I couldn't process your request. Please check your connection and try again.", + isUser: false, + timestamp: Date() + )) + suggestions = ["Try again", "Show status", "Help"] + } + + isLoading = false + } + } + + func clearChat() { + messages = [ChatMessage( + content: "๐Ÿ‘‹ Hi! I'm your home security assistant. Ask me anything about your locks, cameras, sensors, or security system.", + isUser: false, + timestamp: Date() + )] + sessionId = nil + suggestions = ["What's going on?", "Lock all doors", "Show cameras", "Arm system"] + } +} + +#Preview { + ChatView() +} diff --git a/ios/HomeSecurityApp/HomeSecurityApp/Views/DashboardView.swift b/ios/HomeSecurityApp/HomeSecurityApp/Views/DashboardView.swift new file mode 100644 index 0000000..90cb623 --- /dev/null +++ b/ios/HomeSecurityApp/HomeSecurityApp/Views/DashboardView.swift @@ -0,0 +1,446 @@ +import SwiftUI + +struct DashboardView: View { + @StateObject private var viewModel = DashboardViewModel() + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 20) { + if viewModel.isLoading { + ProgressView("Loading...") + .frame(maxWidth: .infinity, minHeight: 200) + } else if let summary = viewModel.summary { + // Security Score Card + SecurityScoreCard( + score: summary.securityScore, + level: summary.securityLevel + ) + + // Quick Actions + QuickActionsView(viewModel: viewModel) + + // Status Cards + VStack(spacing: 16) { + StatusCard( + title: "Locks", + icon: "lock.fill", + iconColor: summary.locks.allSecure ? .green : .orange, + summary: summary.locks.summary, + isGood: summary.locks.allSecure + ) + + StatusCard( + title: "Cameras", + icon: "video.fill", + iconColor: summary.cameras.allOnline ? .green : .red, + summary: summary.cameras.summary, + isGood: summary.cameras.allOnline + ) + + StatusCard( + title: "Sensors", + icon: "sensor.fill", + iconColor: summary.sensors.allNormal ? .green : .orange, + summary: summary.sensors.summary, + isGood: summary.sensors.allNormal + ) + + SecuritySystemCard(status: summary.securitySystem) + } + + // Issues Section + if !summary.issues.isEmpty && summary.issues.first != "No issues - home is secure" { + IssuesCard(issues: summary.issues) + } + + // Recommendations + if !summary.recommendations.isEmpty { + RecommendationsCard(recommendations: summary.recommendations) + } + } else if let error = viewModel.errorMessage { + ErrorCard(message: error) { + viewModel.refresh() + } + } + } + .padding() + } + .refreshable { + await viewModel.loadSummary() + } + .navigationTitle("Home Security") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { viewModel.refresh() }) { + Image(systemName: "arrow.clockwise") + } + } + } + } + .task { + await viewModel.loadSummary() + } + } +} + +// MARK: - Security Score Card + +struct SecurityScoreCard: View { + let score: Int + let level: String + + var scoreColor: Color { + if score >= 90 { return .green } + if score >= 70 { return .yellow } + if score >= 50 { return .orange } + return .red + } + + var body: some View { + VStack(spacing: 12) { + ZStack { + Circle() + .stroke(Color.gray.opacity(0.2), lineWidth: 12) + .frame(width: 120, height: 120) + + Circle() + .trim(from: 0, to: CGFloat(score) / 100) + .stroke(scoreColor, style: StrokeStyle(lineWidth: 12, lineCap: .round)) + .frame(width: 120, height: 120) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 1), value: score) + + VStack(spacing: 2) { + Text("\(score)") + .font(.system(size: 36, weight: .bold)) + .foregroundColor(scoreColor) + Text("/ 100") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Text(level) + .font(.headline) + .foregroundColor(scoreColor) + } + .padding() + .frame(maxWidth: .infinity) + .background(Color(.systemBackground)) + .cornerRadius(16) + .shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2) + } +} + +// MARK: - Quick Actions + +struct QuickActionsView: View { + @ObservedObject var viewModel: DashboardViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Quick Actions") + .font(.headline) + .foregroundColor(.secondary) + + HStack(spacing: 12) { + QuickActionButton( + title: "Goodnight", + icon: "moon.fill", + color: .indigo, + isLoading: viewModel.isPerformingAction + ) { + await viewModel.executeGoodnight() + } + + QuickActionButton( + title: "Leaving", + icon: "figure.walk", + color: .blue, + isLoading: viewModel.isPerformingAction + ) { + await viewModel.executeLeaving() + } + + QuickActionButton( + title: "Arriving", + icon: "house.fill", + color: .green, + isLoading: viewModel.isPerformingAction + ) { + await viewModel.executeArriving() + } + } + } + } +} + +struct QuickActionButton: View { + let title: String + let icon: String + let color: Color + let isLoading: Bool + let action: () async -> Void + + var body: some View { + Button(action: { + Task { await action() } + }) { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.title2) + Text(title) + .font(.caption) + .fontWeight(.medium) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(color.opacity(0.15)) + .foregroundColor(color) + .cornerRadius(12) + } + .disabled(isLoading) + } +} + +// MARK: - Status Card + +struct StatusCard: View { + let title: String + let icon: String + let iconColor: Color + let summary: String + let isGood: Bool + + var body: some View { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.title) + .foregroundColor(iconColor) + .frame(width: 50) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + Text(summary) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + + Image(systemName: isGood ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundColor(isGood ? .green : .orange) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: .black.opacity(0.05), radius: 3, x: 0, y: 1) + } +} + +// MARK: - Security System Card + +struct SecuritySystemCard: View { + let status: SecuritySystemStatus + + var body: some View { + HStack(spacing: 16) { + Image(systemName: status.isArmed ? "shield.checkered" : "shield.slash") + .font(.title) + .foregroundColor(status.isArmed ? .green : .orange) + .frame(width: 50) + + VStack(alignment: .leading, spacing: 4) { + Text("Security System") + .font(.headline) + Text(status.status) + .font(.subheadline) + .foregroundColor(.secondary) + if status.isArmed { + Text("Mode: \(status.armMode.capitalized)") + .font(.caption) + .foregroundColor(.blue) + } + } + + Spacer() + + Image(systemName: status.isArmed ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundColor(status.isArmed ? .green : .orange) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: .black.opacity(0.05), radius: 3, x: 0, y: 1) + } +} + +// MARK: - Issues Card + +struct IssuesCard: View { + let issues: [String] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("Issues") + .font(.headline) + } + + ForEach(issues, id: \.self) { issue in + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(Color.orange) + .frame(width: 6, height: 6) + .padding(.top, 6) + Text(issue) + .font(.subheadline) + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.1)) + .cornerRadius(12) + } +} + +// MARK: - Recommendations Card + +struct RecommendationsCard: View { + let recommendations: [String] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "lightbulb.fill") + .foregroundColor(.blue) + Text("Recommendations") + .font(.headline) + } + + ForEach(recommendations, id: \.self) { rec in + HStack(alignment: .top, spacing: 8) { + Image(systemName: "arrow.right.circle.fill") + .foregroundColor(.blue) + .font(.caption) + .padding(.top, 2) + Text(rec) + .font(.subheadline) + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.blue.opacity(0.1)) + .cornerRadius(12) + } +} + +// MARK: - Error Card + +struct ErrorCard: View { + let message: String + let retryAction: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "wifi.exclamationmark") + .font(.largeTitle) + .foregroundColor(.red) + + Text("Connection Error") + .font(.headline) + + Text(message) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Button("Retry") { + retryAction() + } + .buttonStyle(.borderedProminent) + } + .padding() + .frame(maxWidth: .infinity) + .background(Color(.systemBackground)) + .cornerRadius(16) + } +} + +// MARK: - View Model + +@MainActor +class DashboardViewModel: ObservableObject { + @Published var summary: HomeSummary? + @Published var isLoading = false + @Published var isPerformingAction = false + @Published var errorMessage: String? + @Published var actionMessage: String? + + private let api = APIService.shared + + func loadSummary() async { + isLoading = true + errorMessage = nil + + do { + summary = try await api.getSummary() + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + func refresh() { + Task { + await loadSummary() + } + } + + func executeGoodnight() async { + isPerformingAction = true + do { + let result = try await api.goodnightRoutine() + actionMessage = result.message + await loadSummary() + } catch { + errorMessage = error.localizedDescription + } + isPerformingAction = false + } + + func executeLeaving() async { + isPerformingAction = true + do { + let result = try await api.leavingRoutine() + actionMessage = result.message + await loadSummary() + } catch { + errorMessage = error.localizedDescription + } + isPerformingAction = false + } + + func executeArriving() async { + isPerformingAction = true + do { + let result = try await api.arrivingRoutine() + actionMessage = result.message + await loadSummary() + } catch { + errorMessage = error.localizedDescription + } + isPerformingAction = false + } +} + +#Preview { + DashboardView() +} diff --git a/ios/HomeSecurityApp/HomeSecurityApp/Views/LocksView.swift b/ios/HomeSecurityApp/HomeSecurityApp/Views/LocksView.swift new file mode 100644 index 0000000..0439e71 --- /dev/null +++ b/ios/HomeSecurityApp/HomeSecurityApp/Views/LocksView.swift @@ -0,0 +1,310 @@ +import SwiftUI + +struct LocksView: View { + @StateObject private var viewModel = LocksViewModel() + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 16) { + if viewModel.isLoading { + ProgressView("Loading locks...") + .frame(maxWidth: .infinity, minHeight: 200) + } else if let locksResponse = viewModel.locksResponse { + // Summary Header + LocksSummaryHeader( + allSecure: locksResponse.allSecure, + summary: locksResponse.summary + ) + + // Lock All Button + if !locksResponse.allSecure { + Button(action: { + Task { await viewModel.lockAllDoors() } + }) { + HStack { + Image(systemName: "lock.fill") + Text("Lock All Doors") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(viewModel.isPerformingAction) + } + + // Lock Cards + ForEach(Array(locksResponse.locks.keys.sorted()), id: \.self) { key in + if let lock = locksResponse.locks[key] { + LockCard( + lockId: key, + lock: lock, + isPerforming: viewModel.isPerformingAction, + onLock: { await viewModel.lockDoor(key) }, + onUnlock: { await viewModel.unlockDoor(key) } + ) + } + } + } else if let error = viewModel.errorMessage { + ErrorCard(message: error) { + viewModel.refresh() + } + } + } + .padding() + } + .refreshable { + await viewModel.loadLocks() + } + .navigationTitle("Locks") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { viewModel.refresh() }) { + Image(systemName: "arrow.clockwise") + } + } + } + .alert("Action Result", isPresented: $viewModel.showAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.alertMessage) + } + } + .task { + await viewModel.loadLocks() + } + } +} + +// MARK: - Summary Header + +struct LocksSummaryHeader: View { + let allSecure: Bool + let summary: String + + var body: some View { + HStack(spacing: 16) { + Image(systemName: allSecure ? "lock.shield.fill" : "lock.open.fill") + .font(.largeTitle) + .foregroundColor(allSecure ? .green : .orange) + + VStack(alignment: .leading, spacing: 4) { + Text(allSecure ? "All Secure" : "Attention Needed") + .font(.headline) + .foregroundColor(allSecure ? .green : .orange) + Text(summary) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding() + .background(allSecure ? Color.green.opacity(0.1) : Color.orange.opacity(0.1)) + .cornerRadius(12) + } +} + +// MARK: - Lock Card + +struct LockCard: View { + let lockId: String + let lock: LockStatus + let isPerforming: Bool + let onLock: () async -> Void + let onUnlock: () async -> Void + + @State private var showUnlockConfirmation = false + + var batteryColor: Color { + if lock.batteryLevel > 50 { return .green } + if lock.batteryLevel > 20 { return .orange } + return .red + } + + var body: some View { + VStack(spacing: 0) { + // Main Content + HStack(spacing: 16) { + // Lock Icon + ZStack { + Circle() + .fill(lock.isLocked ? Color.green.opacity(0.15) : Color.orange.opacity(0.15)) + .frame(width: 56, height: 56) + + Image(systemName: lock.isLocked ? "lock.fill" : "lock.open.fill") + .font(.title2) + .foregroundColor(lock.isLocked ? .green : .orange) + } + + // Info + VStack(alignment: .leading, spacing: 4) { + Text(lock.name) + .font(.headline) + + Text(lock.location) + .font(.subheadline) + .foregroundColor(.secondary) + + // Battery + HStack(spacing: 4) { + Image(systemName: batteryIcon) + .foregroundColor(batteryColor) + Text("\(lock.batteryLevel)%") + .font(.caption) + .foregroundColor(batteryColor) + } + } + + Spacer() + + // Status Badge + Text(lock.status) + .font(.caption) + .fontWeight(.semibold) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(lock.isLocked ? Color.green : Color.orange) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding() + + Divider() + + // Actions + HStack(spacing: 0) { + Button(action: { + Task { await onLock() } + }) { + HStack { + Image(systemName: "lock.fill") + Text("Lock") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .disabled(lock.isLocked || isPerforming) + .foregroundColor(lock.isLocked ? .gray : .blue) + + Divider() + .frame(height: 30) + + Button(action: { + showUnlockConfirmation = true + }) { + HStack { + Image(systemName: "lock.open.fill") + Text("Unlock") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .disabled(!lock.isLocked || isPerforming) + .foregroundColor(!lock.isLocked ? .gray : .orange) + } + } + .background(Color(.systemBackground)) + .cornerRadius(16) + .shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2) + .confirmationDialog("Unlock \(lock.name)?", isPresented: $showUnlockConfirmation, titleVisibility: .visible) { + Button("Unlock", role: .destructive) { + Task { await onUnlock() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Are you sure you want to unlock this door?") + } + } + + var batteryIcon: String { + if lock.batteryLevel > 75 { return "battery.100" } + if lock.batteryLevel > 50 { return "battery.75" } + if lock.batteryLevel > 25 { return "battery.50" } + return "battery.25" + } +} + +// MARK: - View Model + +@MainActor +class LocksViewModel: ObservableObject { + @Published var locksResponse: LocksResponse? + @Published var isLoading = false + @Published var isPerformingAction = false + @Published var errorMessage: String? + @Published var showAlert = false + @Published var alertMessage = "" + + private let api = APIService.shared + + func loadLocks() async { + isLoading = true + errorMessage = nil + + do { + locksResponse = try await api.getLocks() + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + func refresh() { + Task { + await loadLocks() + } + } + + func lockDoor(_ lockId: String) async { + isPerformingAction = true + do { + let result = try await api.lockDoor(lockId) + alertMessage = result.message + showAlert = true + await loadLocks() + } catch { + alertMessage = error.localizedDescription + showAlert = true + } + isPerformingAction = false + } + + func unlockDoor(_ lockId: String) async { + isPerformingAction = true + do { + let result = try await api.unlockDoor(lockId, confirm: true) + alertMessage = result.message + if let warning = result.warning { + alertMessage += "\n\nโš ๏ธ \(warning)" + } + showAlert = true + await loadLocks() + } catch { + alertMessage = error.localizedDescription + showAlert = true + } + isPerformingAction = false + } + + func lockAllDoors() async { + isPerformingAction = true + do { + let result = try await api.lockAllDoors() + alertMessage = result.message + showAlert = true + await loadLocks() + } catch { + alertMessage = error.localizedDescription + showAlert = true + } + isPerformingAction = false + } +} + +#Preview { + LocksView() +} diff --git a/ios/HomeSecurityApp/HomeSecurityApp/Views/SettingsView.swift b/ios/HomeSecurityApp/HomeSecurityApp/Views/SettingsView.swift new file mode 100644 index 0000000..424cada --- /dev/null +++ b/ios/HomeSecurityApp/HomeSecurityApp/Views/SettingsView.swift @@ -0,0 +1,550 @@ +import SwiftUI + +struct SettingsView: View { + @StateObject private var viewModel = SettingsViewModel() + @State private var showServerConfig = false + + var body: some View { + NavigationStack { + List { + // Server Configuration + Section { + Button(action: { showServerConfig = true }) { + HStack { + Image(systemName: "server.rack") + .foregroundColor(.blue) + VStack(alignment: .leading) { + Text("Server Configuration") + Text(viewModel.serverURL) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + } + } + .foregroundColor(.primary) + } header: { + Text("Connection") + } + + // Security System + Section { + SecuritySystemRow(viewModel: viewModel) + } header: { + Text("Security System") + } footer: { + Text("Control your home security system arm/disarm state") + } + + // Notifications + Section { + Toggle("Push Notifications", isOn: $viewModel.pushEnabled) + Toggle("Motion Alerts", isOn: $viewModel.motionAlerts) + .disabled(!viewModel.pushEnabled) + Toggle("Door Alerts", isOn: $viewModel.doorAlerts) + .disabled(!viewModel.pushEnabled) + Toggle("System Alerts", isOn: $viewModel.systemAlerts) + .disabled(!viewModel.pushEnabled) + Toggle("Low Battery Alerts", isOn: $viewModel.lowBatteryAlerts) + .disabled(!viewModel.pushEnabled) + } header: { + Text("Notifications") + } + + // Activity + Section { + NavigationLink { + ActivityLogView() + } label: { + HStack { + Image(systemName: "clock.arrow.circlepath") + .foregroundColor(.blue) + Text("Activity Log") + } + } + + NavigationLink { + SensorsView() + } label: { + HStack { + Image(systemName: "sensor.fill") + .foregroundColor(.green) + Text("All Sensors") + } + } + } header: { + Text("Monitoring") + } + + // About + Section { + HStack { + Text("Version") + Spacer() + Text("1.0.0") + .foregroundColor(.secondary) + } + + HStack { + Text("API Status") + Spacer() + HStack(spacing: 4) { + Circle() + .fill(viewModel.isConnected ? Color.green : Color.red) + .frame(width: 8, height: 8) + Text(viewModel.isConnected ? "Connected" : "Disconnected") + .foregroundColor(.secondary) + } + } + } header: { + Text("About") + } + } + .navigationTitle("Settings") + .sheet(isPresented: $showServerConfig) { + ServerConfigView(viewModel: viewModel) + } + } + .task { + await viewModel.checkConnection() + } + } +} + +// MARK: - Security System Row + +struct SecuritySystemRow: View { + @ObservedObject var viewModel: SettingsViewModel + + var body: some View { + VStack(spacing: 12) { + HStack { + Image(systemName: viewModel.isArmed ? "shield.checkered" : "shield.slash") + .font(.title2) + .foregroundColor(viewModel.isArmed ? .green : .orange) + + VStack(alignment: .leading) { + Text(viewModel.isArmed ? "Armed" : "Disarmed") + .font(.headline) + if viewModel.isArmed { + Text("Mode: \(viewModel.armMode.capitalized)") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + if viewModel.isUpdatingSystem { + ProgressView() + } + } + + if viewModel.isArmed { + Button(action: { + Task { await viewModel.disarmSystem() } + }) { + Text("Disarm System") + .frame(maxWidth: .infinity) + .padding() + .background(Color.orange) + .foregroundColor(.white) + .cornerRadius(10) + } + .disabled(viewModel.isUpdatingSystem) + } else { + HStack(spacing: 12) { + ArmButton(title: "Away", mode: "away", viewModel: viewModel) + ArmButton(title: "Home", mode: "home", viewModel: viewModel) + ArmButton(title: "Night", mode: "night", viewModel: viewModel) + } + } + } + .padding(.vertical, 8) + } +} + +struct ArmButton: View { + let title: String + let mode: String + @ObservedObject var viewModel: SettingsViewModel + + var body: some View { + Button(action: { + Task { await viewModel.armSystem(mode: mode) } + }) { + Text(title) + .font(.subheadline) + .fontWeight(.medium) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.green) + .foregroundColor(.white) + .cornerRadius(8) + } + .disabled(viewModel.isUpdatingSystem) + } +} + +// MARK: - Server Config View + +struct ServerConfigView: View { + @ObservedObject var viewModel: SettingsViewModel + @Environment(\.dismiss) var dismiss + @State private var serverURL: String = "" + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Server URL", text: $serverURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + } header: { + Text("Server URL") + } footer: { + Text("Enter the URL of your Home Security API server (e.g., http://192.168.1.100:8000)") + } + + Section { + Button("Test Connection") { + Task { await viewModel.testConnection(serverURL) } + } + + if let status = viewModel.connectionTestStatus { + HStack { + Image(systemName: status.success ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundColor(status.success ? .green : .red) + Text(status.message) + .font(.caption) + } + } + } + } + .navigationTitle("Server Configuration") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + viewModel.saveServerURL(serverURL) + dismiss() + } + } + } + .onAppear { + serverURL = viewModel.serverURL + } + } + } +} + +// MARK: - Activity Log View + +struct ActivityLogView: View { + @StateObject private var viewModel = ActivityLogViewModel() + + var body: some View { + List { + if viewModel.isLoading { + ProgressView("Loading...") + .frame(maxWidth: .infinity) + } else { + ForEach(viewModel.activities) { activity in + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: iconForEventType(activity.eventType)) + .foregroundColor(colorForEventType(activity.eventType)) + Text(activity.deviceName) + .font(.headline) + } + Text(activity.details) + .font(.subheadline) + .foregroundColor(.secondary) + Text(formatTimestamp(activity.timestamp)) + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + } + } + .navigationTitle("Activity Log") + .refreshable { + await viewModel.loadActivity() + } + .task { + await viewModel.loadActivity() + } + } + + func iconForEventType(_ type: String) -> String { + switch type.lowercased() { + case "lock": return "lock.fill" + case "unlock": return "lock.open.fill" + case "arm": return "shield.checkered" + case "disarm": return "shield.slash" + case "snapshot": return "camera.fill" + default: return "bell.fill" + } + } + + func colorForEventType(_ type: String) -> Color { + switch type.lowercased() { + case "lock": return .green + case "unlock": return .orange + case "arm": return .blue + case "disarm": return .orange + default: return .gray + } + } + + func formatTimestamp(_ timestamp: String) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + if let date = formatter.date(from: timestamp) { + let displayFormatter = DateFormatter() + displayFormatter.dateStyle = .medium + displayFormatter.timeStyle = .short + return displayFormatter.string(from: date) + } + return timestamp + } +} + +// MARK: - Sensors View + +struct SensorsView: View { + @StateObject private var viewModel = SensorsViewModel() + + var body: some View { + List { + if viewModel.isLoading { + ProgressView("Loading...") + .frame(maxWidth: .infinity) + } else if let sensors = viewModel.sensorsResponse { + Section { + HStack { + Text("Total Sensors") + Spacer() + Text("\(sensors.totalSensors)") + .foregroundColor(.secondary) + } + HStack { + Text("Triggered") + Spacer() + Text("\(sensors.triggeredCount)") + .foregroundColor(sensors.triggeredCount > 0 ? .orange : .secondary) + } + HStack { + Text("Low Battery") + Spacer() + Text("\(sensors.lowBatteryCount)") + .foregroundColor(sensors.lowBatteryCount > 0 ? .red : .secondary) + } + } header: { + Text("Summary") + } + + Section { + ForEach(Array(sensors.sensors.keys.sorted()), id: \.self) { key in + if let sensor = sensors.sensors[key] { + SensorRow(sensor: sensor) + } + } + } header: { + Text("All Sensors") + } + } + } + .navigationTitle("Sensors") + .refreshable { + await viewModel.loadSensors() + } + .task { + await viewModel.loadSensors() + } + } +} + +struct SensorRow: View { + let sensor: SensorStatus + + var body: some View { + HStack(spacing: 12) { + Image(systemName: iconForSensorType(sensor.type)) + .foregroundColor(sensor.isTriggered ? .orange : .green) + .font(.title2) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 2) { + Text(sensor.name) + .font(.headline) + Text(sensor.location) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + HStack(spacing: 4) { + Image(systemName: "battery.50") + .foregroundColor(sensor.batteryLevel > 30 ? .green : .red) + Text("\(sensor.batteryLevel)%") + .font(.caption) + } + Text(sensor.isTriggered ? "Triggered" : "Normal") + .font(.caption) + .foregroundColor(sensor.isTriggered ? .orange : .green) + } + } + .padding(.vertical, 4) + } + + func iconForSensorType(_ type: String) -> String { + switch type.lowercased() { + case "motion": return "figure.walk.motion" + case "door_window": return "door.left.hand.open" + case "smoke": return "smoke.fill" + case "water_leak": return "drop.fill" + default: return "sensor.fill" + } + } +} + +// MARK: - View Models + +@MainActor +class SettingsViewModel: ObservableObject { + @Published var serverURL: String = APIService.shared.currentBaseURL + @Published var isConnected = false + @Published var isArmed = false + @Published var armMode = "disarmed" + @Published var isUpdatingSystem = false + @Published var connectionTestStatus: (success: Bool, message: String)? + + // Notification settings + @Published var pushEnabled = true + @Published var motionAlerts = true + @Published var doorAlerts = true + @Published var systemAlerts = true + @Published var lowBatteryAlerts = true + + private let api = APIService.shared + + func checkConnection() async { + do { + let status = try await api.getSecurityStatus() + isConnected = true + isArmed = status.isArmed + armMode = status.armMode + } catch { + isConnected = false + } + } + + func testConnection(_ url: String) async { + // Temporarily test the new URL + connectionTestStatus = nil + + guard let testURL = URL(string: "\(url)/health") else { + connectionTestStatus = (false, "Invalid URL format") + return + } + + do { + let (_, response) = try await URLSession.shared.data(from: testURL) + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 { + connectionTestStatus = (true, "Connection successful!") + } else { + connectionTestStatus = (false, "Server returned an error") + } + } catch { + connectionTestStatus = (false, "Could not connect: \(error.localizedDescription)") + } + } + + func saveServerURL(_ url: String) { + serverURL = url + api.updateBaseURL(url) + Task { + await checkConnection() + } + } + + func armSystem(mode: String) async { + isUpdatingSystem = true + do { + let result = try await api.armSystem(mode: mode) + if result.success { + isArmed = true + armMode = mode + } + } catch { + // Handle error + } + isUpdatingSystem = false + } + + func disarmSystem() async { + isUpdatingSystem = true + do { + let result = try await api.disarmSystem() + if result.success { + isArmed = false + armMode = "disarmed" + } + } catch { + // Handle error + } + isUpdatingSystem = false + } +} + +@MainActor +class ActivityLogViewModel: ObservableObject { + @Published var activities: [ActivityEvent] = [] + @Published var isLoading = false + + private let api = APIService.shared + + func loadActivity() async { + isLoading = true + do { + let response = try await api.getActivity(count: 50) + activities = response.activities + } catch { + // Handle error + } + isLoading = false + } +} + +@MainActor +class SensorsViewModel: ObservableObject { + @Published var sensorsResponse: SensorsResponse? + @Published var isLoading = false + + private let api = APIService.shared + + func loadSensors() async { + isLoading = true + do { + sensorsResponse = try await api.getSensors() + } catch { + // Handle error + } + isLoading = false + } +} + +#Preview { + SettingsView() +} diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..4e5aee3 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,170 @@ +# Home Security iOS App + +A modern SwiftUI iOS application for monitoring and controlling your home security system. + +## Features + +### Dashboard +- **Security Score**: Visual gauge showing overall home security status (0-100) +- **Quick Actions**: One-tap routines for common scenarios + - **Goodnight**: Lock all doors + arm in night mode + - **Leaving**: Lock all doors + arm in away mode + - **Arriving**: Disarm + unlock front door +- **Status Overview**: At-a-glance view of locks, cameras, sensors, and security system +- **Issues & Recommendations**: AI-powered security suggestions + +### Locks +- View all smart locks with real-time status +- Lock/unlock individual doors with confirmation dialogs +- "Lock All" button for quick security +- Battery level monitoring +- Activity timestamps + +### Cameras +- View all security cameras with online/offline status +- Motion detection indicators +- Camera capabilities (resolution, night vision, recording) +- Motion events timeline with event types and confidence scores +- Simulated live feed placeholders + +### AI Assistant (Chat) +- Natural language interaction with your security system +- Ask questions like: + - "What's going on at home?" + - "Are all doors locked?" + - "Show me motion events" + - "Arm the security system" +- Smart suggestions for follow-up actions +- Conversation history with session continuity + +### Settings +- **Server Configuration**: Connect to your Home Security API +- **Security System Control**: Arm/disarm with mode selection +- **Notification Preferences**: Configure alert types +- **Activity Log**: Full history of security events +- **Sensors View**: Detailed sensor status and battery levels + +## Requirements + +- iOS 17.0+ +- Xcode 15.0+ +- Home Security API server running + +## Setup + +### 1. Start the Backend Server + +```bash +# From the project root +make mobile-api + +# Server will run at http://localhost:8000 +``` + +### 2. Open in Xcode + +```bash +open ios/HomeSecurityApp/HomeSecurityApp.xcodeproj +``` + +### 3. Configure Server URL + +- For **Simulator**: Uses `http://localhost:8000` by default +- For **Physical Device**: + 1. Find your Mac's IP address + 2. Go to Settings tab in the app + 3. Tap "Server Configuration" + 4. Enter `http://:8000` + 5. Test connection and save + +### 4. Build and Run + +- Select your target device (Simulator or Physical) +- Press โŒ˜R or click the Run button + +## Architecture + +``` +HomeSecurityApp/ +โ”œโ”€โ”€ HomeSecurityAppApp.swift # App entry point +โ”œโ”€โ”€ ContentView.swift # Main tab view +โ”œโ”€โ”€ Models/ +โ”‚ โ””โ”€โ”€ Models.swift # API data models (Codable) +โ”œโ”€โ”€ Services/ +โ”‚ โ””โ”€โ”€ APIService.swift # REST API client +โ”œโ”€โ”€ Views/ +โ”‚ โ”œโ”€โ”€ DashboardView.swift # Home dashboard +โ”‚ โ”œโ”€โ”€ LocksView.swift # Lock management +โ”‚ โ”œโ”€โ”€ CamerasView.swift # Camera monitoring +โ”‚ โ”œโ”€โ”€ ChatView.swift # AI assistant chat +โ”‚ โ””โ”€โ”€ SettingsView.swift # App settings +โ””โ”€โ”€ Assets.xcassets/ # App icons and colors +``` + +## API Integration + +The app communicates with the Home Security API via REST endpoints: + +| Feature | Endpoint | Method | +|---------|----------|--------| +| Dashboard | `/api/summary` | GET | +| Locks | `/api/locks` | GET | +| Lock Door | `/api/locks/{id}/lock` | POST | +| Unlock Door | `/api/locks/{id}/unlock` | POST | +| Cameras | `/api/cameras` | GET | +| Motion Events | `/api/cameras/motion-events` | GET | +| Sensors | `/api/sensors` | GET | +| Security Status | `/api/security-system` | GET | +| Arm System | `/api/security-system/arm` | POST | +| Disarm System | `/api/security-system/disarm` | POST | +| Activity | `/api/activity` | GET | +| Chat | `/api/chat` | POST | +| Quick Actions | `/api/quick-actions/*` | POST | + +## Customization + +### Colors +Edit `Assets.xcassets/AccentColor.colorset/Contents.json` to change the app's accent color. + +### Server URL +The default server URL can be changed in `APIService.swift`: +```swift +self.baseURL = UserDefaults.standard.string(forKey: "serverURL") ?? "http://localhost:8000" +``` + +## Screenshots + +The app includes: +- ๐Ÿ“Š Dashboard with security score gauge +- ๐Ÿ” Lock cards with battery indicators +- ๐Ÿ“น Camera grid with motion detection +- ๐Ÿ’ฌ Chat interface with typing indicators +- โš™๏ธ Settings with arm/disarm controls + +## Future Enhancements + +- [ ] Push notifications via APNs +- [ ] Live camera streaming +- [ ] Face ID/Touch ID authentication +- [ ] Apple Watch companion app +- [ ] Siri Shortcuts integration +- [ ] Home Widget for iOS +- [ ] Offline mode with local caching + +## Troubleshooting + +### "Connection Error" on Dashboard +1. Ensure the backend server is running (`make mobile-api`) +2. Check the server URL in Settings +3. For physical devices, ensure you're on the same network + +### App won't build +1. Ensure Xcode 15+ is installed +2. Check that iOS deployment target is 17.0 +3. Clean build folder (โ‡งโŒ˜K) and rebuild + +### Simulator network issues +The iOS simulator uses your Mac's network. Ensure `localhost:8000` is accessible from Terminal: +```bash +curl http://localhost:8000/health +```