diff --git a/.env.example b/.env.example index caf062d..3bc5ae9 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,27 @@ -# Ring Camera OAuth credentials -RING_REFRESH_TOKEN=your_refresh_token_here -RING_CLIENT_ID=test-ava-partner-prod -RING_CLIENT_SECRET=your_client_secret_here - -# Ring Device -NEXT_PUBLIC_RING_DEVICE_ID=your_device_id_here -NEXT_PUBLIC_RING_DEVICE_NAME=Camera - -# Ring Webhook Authentication -# Ring sends OAuth 2.0 Bearer token with webhook requests -# Set this to the partner access token Ring will use for authentication -RING_WEBHOOK_SECRET=your_partner_access_token_here +# ============================================================================= +# Authentication (choose ONE of the two options below) +# ============================================================================= + +# Option 1: Access Token (simplest — get from Ring Developer Playground) +# Get your token at: https://developer.amazon.com/ring/console/playground +# RING_ACCESS_TOKEN=your_access_token_here + +# Option 2: Refresh Token (auto-renewing — for production integrations) +# Get credentials by registering your app: https://developer.amazon.com/docs/ring/app-registration.html +# Get refresh token via OAuth account linking: https://developer.amazon.com/docs/ring/authentication.html +# RING_REFRESH_TOKEN=your_refresh_token_here +# RING_CLIENT_ID=your_client_id_here +# RING_CLIENT_SECRET=your_client_secret_here + +# ============================================================================= +# Device (optional — auto-discovered if not set) +# ============================================================================= +# Device IDs are returned by the /v1/devices API. Run: python scripts/list_devices.py --token "..." +# NEXT_PUBLIC_RING_DEVICE_ID=your_device_id_here +# NEXT_PUBLIC_RING_DEVICE_NAME=Camera + +# ============================================================================= +# Webhook (optional — for refresh token mode only) +# ============================================================================= +# See: https://developer.amazon.com/docs/ring/notifications.html +# RING_WEBHOOK_SECRET=your_partner_access_token_here diff --git a/README.md b/README.md index f387088..1dbf927 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,277 @@ -# Ring API Hello World +# Ring API Hello World -A real-time video streaming dashboard with WebRTC, webhook event processing, and extensible video processors including hand-tracking games, -using the brand new [Ring API](https://developer.amazon.com/ring) +Get started with the [Ring Partner API](https://developer.amazon.com/docs/ring/api-documentation.html) — explore device APIs from your terminal and stream live video in your browser. ![Next.js](https://img.shields.io/badge/Next.js-14-black) ![TypeScript](https://img.shields.io/badge/TypeScript-5-blue) -![MediaPipe](https://img.shields.io/badge/MediaPipe-Hands-green) +![Python](https://img.shields.io/badge/Python-3.8+-blue) -## Features +## What's Inside + +| Path | What it does | You need | +|------|-------------|----------| +| `scripts/` | Python scripts to call Ring APIs from your terminal | Python 3.8+ and a token | +| `app/` | Next.js web app with live video streaming and real-time event dashboard | Node.js 18+ and a token | + +--- + +> 🚀 **Coming from the [Ring Developer Playground](https://developer.amazon.com/ring/console/playground)?** +> +> You already have a token — jump straight to: +> - **[Explore APIs from your terminal →](#step-2-explore-apis-python-scripts)** (Python, no web app needed) +> - **[Live stream in your browser →](#step-3-live-video-stream-web-app)** (Node.js, one command) + +--- + +## Step 1: Get Your Token + +1. Go to the [Ring Developer Playground](https://developer.amazon.com/ring/console/playground) +2. Click **Generate Token** +3. Copy the access token + +The Playground gives you a short-lived access token (~30 minutes) that works with all Ring APIs. No app registration, OAuth setup, or client credentials needed — just the token. + +> **Note:** When the token expires, return to the Playground and generate a fresh one. + +--- + +## Step 2: Explore APIs (Python Scripts) + +Call any Ring API directly from your terminal. Each script is a self-contained code snippet you can copy into your own project. -- **Live Video Streaming** - WebRTC-based video streaming with low latency -- **Webhook Events** - Real-time SSE (Server-Sent Events) for Ring camera webhooks -- **Video Processors** - Plugin system for real-time video analysis -- **Hand Tracking Game** - Catch-the-box game using MediaPipe hand detection -- **Canvas Overlays** - Bounding boxes, heatmaps, and visual effects +### Setup + +```bash +cd scripts +pip install -r requirements.txt +``` + +### Usage + +Run the interactive explorer: + +```bash +python explore_apis.py --token "eyJ..." +``` + +This shows a menu where you pick which API to call: + +``` +=== Ring API Explorer === +Use your token to call any Ring API. + +1. List Devices +2. Device Status +3. Device Capabilities +4. Device Location +5. Device Configurations +6. Event History +7. User Profile +8. Run All +0. Exit + +Select an API to call: +``` -## Quick Start +Or run individual scripts directly: + +```bash +# List all your devices +python list_devices.py --token "eyJ..." + +# Check if a device is online +python device_status.py --token "eyJ..." --device-id "ava1.ring.device.XXX" + +# Get device capabilities (video codecs, motion detection, etc.) +python device_capabilities.py --token "eyJ..." --device-id "ava1.ring.device.XXX" + +# Get device location (country/state) +python device_location.py --token "eyJ..." --device-id "ava1.ring.device.XXX" + +# Get device configurations (motion zones, privacy zones) +python device_configurations.py --token "eyJ..." --device-id "ava1.ring.device.XXX" + +# Get event history (motion events, doorbell presses, live views) +python event_history.py --token "eyJ..." --device-id "ava1.ring.device.XXX" + +# Get your user profile +python user_profile.py --token "eyJ..." +``` + +> **Tip:** If you don't pass `--device-id`, scripts that need one will auto-discover your first device. + +Each script prints the equivalent `curl` command so you can copy it into Postman, your own code, or any HTTP client: + +``` +→ GET https://api.amazonvision.com/v1/devices + curl -X GET "https://api.amazonvision.com/v1/devices" \ + -H "Authorization: Bearer $TOKEN" +``` + +### Available Scripts + +| Script | API Endpoint | Description | +|--------|-------------|-------------| +| `list_devices.py` | `GET /v1/devices` | List all accessible devices | +| `device_status.py` | `GET /v1/devices/{id}/status` | Check if device is online/offline | +| `device_capabilities.py` | `GET /v1/devices/{id}/capabilities` | Video codecs, motion detection, image enhancements | +| `device_location.py` | `GET /v1/devices/{id}/location` | Country and state (for compliance) | +| `device_configurations.py` | `GET /v1/devices/{id}/configurations` | Motion zones, privacy zones, image settings | +| `event_history.py` | `GET /v1/history/devices/{id}/events` | Past motion, doorbell, and live view events | +| `user_profile.py` | `GET /v1/users/me` | Your Ring account ID, name, and email | +| `explore_apis.py` | All of the above | Interactive menu to call any API | + +--- + +## Step 3: Live Video Stream (Web App) + +Stream live video from a Ring device directly in your browser using WebRTC. + +### Setup ```bash # Install dependencies npm install -# Set up environment variables -cp .env.local.example .env.local -# Edit .env.local with your configuration and add the token to access your Ring API +# Create your environment file +cp .env.example .env.local +``` -# Start development server -npm run dev +Edit `.env.local` and paste your token from Step 1: -# Build for production -npm run build -npm start +```env +RING_ACCESS_TOKEN=eyJ...paste_your_token_here +``` + +### Run + +```bash +npm run dev ``` Open [http://localhost:3000](http://localhost:3000) in your browser. +The app will: +1. Auto-discover devices associated with your token +2. Show a **Start Live Stream** button +3. Click it to begin a WebRTC live video stream from your Ring device + +When the token expires, paste a fresh one from the Playground into `.env.local` and restart the server. + +--- + +## Advanced: Full Dashboard (Refresh Token Mode) + +For production integrations with long-lived sessions, the app supports OAuth refresh tokens with auto-renewal. This mode shows the full dashboard including webhook events, video processors, and canvas overlays. + +### Setup + +```env +RING_REFRESH_TOKEN=your_refresh_token_here +RING_CLIENT_ID=your_client_id_here +RING_CLIENT_SECRET=your_client_secret_here +``` + +All three variables are required. The app automatically refreshes the access token when it expires. + +See [Authentication](https://developer.amazon.com/docs/ring/authentication.html) for how to obtain refresh tokens through the OAuth account linking flow, and [Configure Your Ring Application](https://developer.amazon.com/docs/ring/app-registration.html) for how to get your client credentials. + +### Device ID (Optional) + +```env +NEXT_PUBLIC_RING_DEVICE_ID=your_device_id_here +``` + +If set, the app uses this device directly instead of auto-discovering. + +### Important + +Do not set both `RING_ACCESS_TOKEN` and `RING_REFRESH_TOKEN` — the app will show a configuration error. Use one or the other. + +--- + +## Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `RING_ACCESS_TOKEN` | Access token from the [Playground](https://developer.amazon.com/ring/console/playground) | Yes (if not using refresh token) | +| `RING_REFRESH_TOKEN` | OAuth refresh token from [account linking](https://developer.amazon.com/docs/ring/authentication.html) | Yes (if not using access token) | +| `RING_CLIENT_ID` | OAuth client ID from [app registration](https://developer.amazon.com/docs/ring/app-registration.html) | Yes (only with refresh token) | +| `RING_CLIENT_SECRET` | OAuth client secret from [app registration](https://developer.amazon.com/docs/ring/app-registration.html) | Yes (only with refresh token) | +| `NEXT_PUBLIC_RING_DEVICE_ID` | Device ID (skips auto-discovery) | No | +| `NEXT_PUBLIC_RING_DEVICE_NAME` | Display name for the device | No | +| `RING_WEBHOOK_SECRET` | Bearer token for [webhook auth](https://developer.amazon.com/docs/ring/notifications.html) | No | + +--- + +## Features + +### Access Token Mode (Playground) +- **Live Video Streaming** — WebRTC-based low-latency video from Ring devices +- **Auto Device Discovery** — Automatically finds devices linked to your token +- **Simplified UI** — Full-screen live view, no distractions + +### Refresh Token Mode (Production) +- Everything above, plus: +- **Webhook Events** — Real-time SSE for Ring camera webhooks (motion, doorbell, etc.) +- **Video Processors** — Plugin system for real-time video analysis +- **Hand Tracking Game** — Catch-the-box game using MediaPipe hand detection +- **Canvas Overlays** — Bounding boxes, heatmaps, and visual effects + +--- + ## Architecture ``` +scripts/ +├── explore_apis.py # Interactive API explorer +├── list_devices.py # GET /v1/devices +├── device_status.py # GET /v1/devices/{id}/status +├── device_capabilities.py # GET /v1/devices/{id}/capabilities +├── device_location.py # GET /v1/devices/{id}/location +├── device_configurations.py # GET /v1/devices/{id}/configurations +├── event_history.py # GET /v1/history/devices/{id}/events +├── user_profile.py # GET /v1/users/me +└── requirements.txt # Python dependencies + app/ -├── page.tsx # Main dashboard (composable components) -├── components/ -│ ├── Header.tsx # Status bar -│ ├── EventCard.tsx # Webhook event display -│ ├── EventPanel.tsx # Event list with filtering -│ ├── ProcessorPanel.tsx # Processor toggle UI -│ └── ErrorBoundary.tsx # Error handling +├── page.tsx # Main dashboard +├── components/ # UI components ├── hooks/ -│ ├── useWebRTCStream.ts # WebRTC connection management -│ ├── useEventStream.ts # SSE with auto-reconnect -│ └── useCanvasOverlay.ts # Optimized render loop +│ ├── useWebRTCStream.ts # WebRTC connection management +│ ├── useEventStream.ts # SSE with auto-reconnect +│ └── useCanvasOverlay.ts # Optimized render loop └── api/ - ├── webhook/ # Webhook receiver + SSE endpoint - └── ring/ # Ring API integration + ├── webhook/ # Webhook receiver + SSE endpoint + └── ring/ # Ring API integration + ├── config/ # Auth mode detection + ├── devices/ # Device discovery + status + ├── stream/ # WebRTC WHEP live streaming + ├── events/ # Event history + └── token/ # Token info lib/ -├── video-processors/ -│ ├── types.ts # VideoProcessor interface -│ ├── registry.ts # Processor registration -│ └── examples/ # Built-in processors -├── schemas/ -│ └── webhook.ts # Zod validation schemas -└── sse-broadcast.ts # SSE client management +├── auth.ts # Token management (access token / refresh token) +├── video-processors/ # Plugin system for video analysis +├── schemas/ # Zod validation schemas +└── sse-broadcast.ts # SSE client management ``` +--- + ## Video Processors -Built-in processors: +Built-in processors (available in refresh token mode): -| Processor | Description | -| ---------------------- | ------------------------------------ | -| 🎯 Catch the Logo | Hand-tracking game with fire effects | -| 🌡️ Motion Heatmap | Visualizes motion as color overlay | -| 💡 Brightness Analyzer | Analyzes frame brightness levels | +| Processor | Description | +|-----------|-------------| +| 🎯 Catch the Logo | Hand-tracking game with fire effects | +| 🌡️ Motion Heatmap | Visualizes motion as color overlay | +| 💡 Brightness Analyzer | Analyzes frame brightness levels | ### Creating Custom Processors ```typescript -// lib/video-processors/my-processor.ts import {VideoProcessor, ProcessorResult} from './types'; import {processorRegistry} from './registry'; @@ -92,14 +286,11 @@ class MyProcessor implements VideoProcessor { canvas: HTMLCanvasElement, video: HTMLVideoElement, ): Promise { - // Your processing logic return { id: `my-${Date.now()}`, processorId: this.id, timestamp: Date.now(), - data: { - /* metrics */ - }, + data: {}, boundingBoxes: [{x: 0, y: 0, width: 100, height: 100, label: 'Detected'}], }; } @@ -110,59 +301,58 @@ processorRegistry.register(new MyProcessor()); See [docs/video-processors.md](docs/video-processors.md) for the complete guide. +--- + ## Webhook Integration -The dashboard receives webhook events via POST and broadcasts them to connected clients via SSE. +Available in refresh token mode. The dashboard receives webhook events via POST and broadcasts them to connected clients via SSE. ```bash # Send a test event curl -X POST http://localhost:3000/api/webhook \ -H "Content-Type: application/json" \ -d '{"event_type": "motion_detected", "device_id": "camera-1"}' - -# Or use the built-in test endpoint -curl -X POST http://localhost:3000/api/webhook/test ``` -### Ring Camera Webhooks +Configure your Ring webhook to POST to `/api/webhook`. Set `RING_WEBHOOK_SECRET` in `.env.local` for authentication. -Configure your Ring webhook to POST to `/api/webhook`. The dashboard normalizes Ring's JSON:API format automatically. +--- -Set `RING_WEBHOOK_SECRET` in `.env.local` for authentication: +## API Reference -```env -RING_WEBHOOK_SECRET=your-secret-token -``` +For full API documentation: +- [Ring Partner API Documentation](https://developer.amazon.com/docs/ring/api-documentation.html) +- [Live Video Streaming (WHEP)](https://developer.amazon.com/docs/ring/live-video.html) +- [Device Discovery](https://developer.amazon.com/docs/ring/device-discovery.html) +- [Authentication Guide](https://developer.amazon.com/docs/ring/authentication.html) -## Environment Variables - -| Variable | Description | Required | -| ---------------------------- | ----------------------------- | -------- | -| `RING_WEBHOOK_SECRET` | Bearer token for webhook auth | No | -| `NEXT_PUBLIC_RING_DEVICE_ID` | Default device ID for testing | No | +--- ## Tech Stack -- **Framework**: Next.js 14 (App Router) -- **Language**: TypeScript -- **Styling**: Tailwind CSS -- **Hand Detection**: MediaPipe Hands +- **Scripts**: Python 3.8+ with `requests` +- **Web App**: Next.js 14 (App Router), TypeScript, Tailwind CSS +- **Video**: WebRTC (WHEP protocol), MediaPipe Hands - **Validation**: Zod -- **Streaming**: WebRTC, Server-Sent Events +- **Events**: Server-Sent Events (SSE) + +--- ## Development ```bash -# Run development server with hot reload +# Run web app with hot reload npm run dev # Type checking npx tsc --noEmit -# Build production bundle +# Build for production npm run build ``` +--- + ## License [MIT](LICENSE) diff --git a/app/api/ring/config/route.ts b/app/api/ring/config/route.ts new file mode 100644 index 0000000..b618ee2 --- /dev/null +++ b/app/api/ring/config/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server' +import { getAuthMode } from '@/lib/auth' + +export async function GET() { + const mode = getAuthMode() + + if (mode === null && process.env.RING_ACCESS_TOKEN && process.env.RING_REFRESH_TOKEN) { + return NextResponse.json( + { error: 'Both RING_ACCESS_TOKEN and RING_REFRESH_TOKEN are set. Please use only one.' }, + { status: 400 } + ) + } + + if (mode === null) { + return NextResponse.json( + { error: 'Authentication not configured. Set RING_ACCESS_TOKEN or RING_REFRESH_TOKEN in .env.local' }, + { status: 400 } + ) + } + + return NextResponse.json({ mode }) +} diff --git a/app/api/ring/devices/route.ts b/app/api/ring/devices/route.ts index 1bf0fae..ea0e0a4 100644 --- a/app/api/ring/devices/route.ts +++ b/app/api/ring/devices/route.ts @@ -7,30 +7,58 @@ const API_BASE = 'https://api.amazonvision.com' export async function GET() { try { - if (!DEVICE_ID) { - return NextResponse.json({ devices: [], error: 'No device ID configured' }) - } - const token = await getAccessToken() - const statusRes = await fetch(`${API_BASE}/v1/devices/${DEVICE_ID}/status`, { + // In access token mode, always auto-discover (ignore NEXT_PUBLIC_RING_DEVICE_ID) + const isAccessTokenMode = !!process.env.RING_ACCESS_TOKEN + const useConfiguredDevice = DEVICE_ID && !isAccessTokenMode + + // If device ID is configured (refresh token mode only), use it directly + if (useConfiguredDevice) { + const statusRes = await fetch(`${API_BASE}/v1/devices/${DEVICE_ID}/status`, { + headers: { Authorization: `Bearer ${token}` }, + }) + + let online = false + if (statusRes.ok) { + const statusData = await statusRes.json() + online = statusData?.data?.attributes?.online || false + } + + return NextResponse.json({ + devices: [{ + id: DEVICE_ID, + name: DEVICE_NAME, + online, + capabilities: { motionDetection: true }, + }], + }) + } + + // Auto-discover devices using the token + const res = await fetch(`${API_BASE}/v1/devices`, { headers: { Authorization: `Bearer ${token}` }, }) - let online = false - if (statusRes.ok) { - const statusData = await statusRes.json() - online = statusData?.data?.attributes?.online || false + if (!res.ok) { + const error = await res.text() + return NextResponse.json( + { devices: [], error: `Device discovery failed: ${res.status} - ${error}` }, + { status: res.status } + ) } - return NextResponse.json({ - devices: [{ - id: DEVICE_ID, - name: DEVICE_NAME, - online, - capabilities: { motionDetection: true }, - }], - }) + const data = await res.json() + + // Normalize JSON:API response to simple device list + const devices = (data?.data || []).map((device: any) => ({ + id: device.id, + name: device.attributes?.name || device.attributes?.description || 'Ring Device', + online: device.attributes?.online || false, + capabilities: device.attributes?.capabilities || {}, + })) + + return NextResponse.json({ devices }) } catch (error) { console.error('Devices error:', error) return NextResponse.json( diff --git a/app/api/ring/stream/route.ts b/app/api/ring/stream/route.ts index 610d11c..0aa04ca 100644 --- a/app/api/ring/stream/route.ts +++ b/app/api/ring/stream/route.ts @@ -1,22 +1,27 @@ import { NextRequest, NextResponse } from 'next/server' import { getAccessToken } from '@/lib/auth' -const DEVICE_ID = process.env.NEXT_PUBLIC_RING_DEVICE_ID const API_BASE = 'https://api.amazonvision.com' export async function POST(request: NextRequest) { try { - const { sdpOffer } = await request.json() + const { sdpOffer, deviceId } = await request.json() if (!sdpOffer) { return NextResponse.json({ error: 'Missing sdpOffer' }, { status: 400 }) } - if (!DEVICE_ID) { - return NextResponse.json({ error: 'No device ID configured' }, { status: 400 }) + + // Use deviceId from request body, fall back to env var + const resolvedDeviceId = deviceId || process.env.NEXT_PUBLIC_RING_DEVICE_ID + if (!resolvedDeviceId) { + return NextResponse.json( + { error: 'No device ID provided. Pass deviceId in request body or set NEXT_PUBLIC_RING_DEVICE_ID.' }, + { status: 400 } + ) } const token = await getAccessToken() - const whepUrl = `${API_BASE}/v1/devices/${DEVICE_ID}/media/streaming/whep/sessions` + const whepUrl = `${API_BASE}/v1/devices/${resolvedDeviceId}/media/streaming/whep/sessions` const response = await fetch(whepUrl, { method: 'POST', diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 98ca313..5352671 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -3,25 +3,28 @@ interface HeaderProps { connected: boolean enabledCount: number + simpleMode?: boolean } -export function Header({ connected, enabledCount }: HeaderProps) { +export function Header({ connected, enabledCount, simpleMode }: HeaderProps) { return (
📡

Live Detection Dashboard

-
- - {connected ? '● Connected' : '○ Disconnected'} - - {enabledCount > 0 && ( - - {enabledCount} processor{enabledCount > 1 ? 's' : ''} active + {!simpleMode && ( +
+ + {connected ? '● Connected' : '○ Disconnected'} - )} -
+ {enabledCount > 0 && ( + + {enabledCount} processor{enabledCount > 1 ? 's' : ''} active + + )} +
+ )}
) } diff --git a/app/hooks/useWebRTCStream.ts b/app/hooks/useWebRTCStream.ts index 655f70c..1694cb4 100644 --- a/app/hooks/useWebRTCStream.ts +++ b/app/hooks/useWebRTCStream.ts @@ -4,6 +4,7 @@ import { useState, useRef, useCallback, RefObject } from 'react' interface UseWebRTCStreamOptions { videoRef: RefObject + deviceId?: string } interface UseWebRTCStreamReturn { @@ -17,7 +18,7 @@ interface UseWebRTCStreamReturn { * Manages WebRTC connection to Ring camera stream. * Handles ICE gathering, SDP negotiation, and cleanup. */ -export function useWebRTCStream({ videoRef }: UseWebRTCStreamOptions): UseWebRTCStreamReturn { +export function useWebRTCStream({ videoRef, deviceId }: UseWebRTCStreamOptions): UseWebRTCStreamReturn { const [streamActive, setStreamActive] = useState(false) const [streamError, setStreamError] = useState(null) const pcRef = useRef(null) @@ -62,7 +63,7 @@ export function useWebRTCStream({ videoRef }: UseWebRTCStreamOptions): UseWebRTC const res = await fetch('/api/ring/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sdpOffer: pc.localDescription!.sdp }), + body: JSON.stringify({ sdpOffer: pc.localDescription!.sdp, deviceId }), }) if (!res.ok) { @@ -79,7 +80,7 @@ export function useWebRTCStream({ videoRef }: UseWebRTCStreamOptions): UseWebRTC setStreamError(message) setStreamActive(false) } - }, [videoRef]) + }, [videoRef, deviceId]) const stopStream = useCallback(async () => { pcRef.current?.close() diff --git a/app/page.tsx b/app/page.tsx index a94edbf..f955005 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,16 +12,52 @@ import { useEventStream } from './hooks/useEventStream' import { useCanvasOverlay } from './hooks/useCanvasOverlay' type RightPanelTab = 'events' | 'processors' +type AuthMode = 'access_token' | 'refresh_token' | null export default function Dashboard() { const [rightPanelTab, setRightPanelTab] = useState('events') const [webhookUrl, setWebhookUrl] = useState('') + const [deviceId, setDeviceId] = useState(undefined) + const [deviceError, setDeviceError] = useState(null) + const [authMode, setAuthMode] = useState(null) + const [authError, setAuthError] = useState(null) + const [loading, setLoading] = useState(true) const videoRef = useRef(null) const canvasRef = useRef(null) + // Fetch auth mode on mount + useEffect(() => { + fetch('/api/ring/config') + .then(res => res.json()) + .then(data => { + if (data.error) { + setAuthError(data.error) + } else { + setAuthMode(data.mode) + } + }) + .catch(err => setAuthError(err.message)) + .finally(() => setLoading(false)) + }, []) + + // Auto-discover device on mount (after auth mode is confirmed) + useEffect(() => { + if (!authMode) return + fetch('/api/ring/devices') + .then(res => res.json()) + .then(data => { + if (data.devices && data.devices.length > 0) { + setDeviceId(data.devices[0].id) + } else { + setDeviceError(data.error || 'No devices found') + } + }) + .catch(err => setDeviceError(err.message)) + }, [authMode]) + // Custom hooks - const { streamActive, streamError, startStream, stopStream } = useWebRTCStream({ videoRef }) + const { streamActive, streamError, startStream, stopStream } = useWebRTCStream({ videoRef, deviceId }) const { events, connected } = useEventStream({}) const { processors, results, toggleProcessor, enabledCount } = useVideoProcessing({ video: videoRef.current, @@ -38,17 +74,41 @@ export default function Dashboard() { setWebhookUrl(`${window.location.origin}/api/webhook`) }, []) + const isSimpleMode = authMode === 'access_token' + + // Loading state + if (loading) { + return ( +
+

Loading...

+
+ ) + } + + // Auth error state + if (authError) { + return ( +
+
+

Configuration Error

+

{authError}

+
+
+ ) + } + return (
{/* Main content */}
- {/* Left: Video (60%) */} -
+ {/* Video section — full width in simple mode, 60% in full mode */} +
- {/* Webhook URL helper */} -
- Webhook URL: - {webhookUrl} - -
+ {/* Webhook URL helper — only in full mode */} + {!isSimpleMode && ( +
+ Webhook URL: + {webhookUrl} + +
+ )}
- {/* Right: Events/Processors panel (40%) */} -
- {/* Tab switcher */} -
- - -
+ {/* Right panel — only shown in refresh_token (full) mode */} + {!isSimpleMode && ( +
+ {/* Tab switcher */} +
+ + +
- {rightPanelTab === 'events' ? ( - - ) : ( - - )} -
+ {rightPanelTab === 'events' ? ( + + ) : ( + + )} +
+ )}
) diff --git a/lib/auth.ts b/lib/auth.ts index 6b0afe8..3460c8c 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,33 +1,72 @@ -// Shared Ring OAuth token management — same pattern as birdwatcher +// Ring API authentication — supports two modes: +// 1. Access token (RING_ACCESS_TOKEN) — use directly, no other credentials needed +// 2. Refresh token (RING_REFRESH_TOKEN) — auto-renewing, requires client credentials + +export type AuthMode = 'access_token' | 'refresh_token' + let cachedToken: { token: string; expiresAt: number } | null = null +export function getAuthMode(): AuthMode | null { + if (process.env.RING_ACCESS_TOKEN && process.env.RING_REFRESH_TOKEN) { + return null // conflict + } + if (process.env.RING_ACCESS_TOKEN) return 'access_token' + if (process.env.RING_REFRESH_TOKEN) return 'refresh_token' + return null +} + export async function getAccessToken(): Promise { - if (cachedToken && Date.now() < cachedToken.expiresAt) { - return cachedToken.token + // Conflict check + if (process.env.RING_ACCESS_TOKEN && process.env.RING_REFRESH_TOKEN) { + throw new Error( + 'Both RING_ACCESS_TOKEN and RING_REFRESH_TOKEN are set. Please use only one.' + ) } - const params = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: process.env.RING_REFRESH_TOKEN!, - client_id: process.env.RING_CLIENT_ID || 'test-ava-partner-prod', - client_secret: process.env.RING_CLIENT_SECRET || '', - }) - - const res = await fetch('https://oauth.ring.com/oauth/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), - }) - - if (!res.ok) { - const error = await res.text() - throw new Error(`Token refresh failed: ${error}`) + // Mode 1: Direct access token + if (process.env.RING_ACCESS_TOKEN) { + return process.env.RING_ACCESS_TOKEN } - const data = await res.json() - cachedToken = { - token: data.access_token, - expiresAt: Date.now() + (data.expires_in * 1000) - 60000, + // Mode 2: Refresh token flow (requires client credentials) + if (process.env.RING_REFRESH_TOKEN) { + if (!process.env.RING_CLIENT_ID || !process.env.RING_CLIENT_SECRET) { + throw new Error( + 'RING_CLIENT_ID and RING_CLIENT_SECRET are required when using RING_REFRESH_TOKEN' + ) + } + + if (cachedToken && Date.now() < cachedToken.expiresAt) { + return cachedToken.token + } + + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: process.env.RING_REFRESH_TOKEN, + client_id: process.env.RING_CLIENT_ID, + client_secret: process.env.RING_CLIENT_SECRET, + }) + + const res = await fetch('https://oauth.ring.com/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }) + + if (!res.ok) { + const error = await res.text() + throw new Error(`Token refresh failed: ${error}`) + } + + const data = await res.json() + cachedToken = { + token: data.access_token, + expiresAt: Date.now() + data.expires_in * 1000 - 60000, + } + return data.access_token } - return data.access_token + + throw new Error( + 'Authentication not configured. Set RING_ACCESS_TOKEN or RING_REFRESH_TOKEN in .env.local' + ) } diff --git a/scripts/device_capabilities.py b/scripts/device_capabilities.py new file mode 100644 index 0000000..e8c5da6 --- /dev/null +++ b/scripts/device_capabilities.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Get the capabilities of a Ring device (video codecs, motion detection, etc.).""" + +import argparse +import json +import requests + +API_BASE = "https://api.amazonvision.com" + + +def get_device_capabilities(token, device_id): + """Get device capabilities.""" + url = f"{API_BASE}/v1/devices/{device_id}/capabilities" + headers = {"Authorization": f"Bearer {token}"} + + print(f"\n→ GET {url}") + print(f' curl -X GET "{url}" \\') + print(f' -H "Authorization: Bearer $TOKEN"\n') + + response = requests.get(url, headers=headers) + response.raise_for_status() + + data = response.json() + attrs = data.get("data", {}).get("attributes", {}) + + if "video" in attrs: + video = attrs["video"] + print(f"Video: {video.get('max_resolution', '?')}p, codecs: {video.get('codecs', [])}") + if "motion_detection" in attrs: + print(f"Motion Detection: supported") + if "image_enhancements" in attrs: + enhancements = attrs["image_enhancements"].get("configurations", []) + print(f"Image Enhancements: {', '.join(enhancements)}") + + print(f"\nFull response:\n{json.dumps(data, indent=2)}") + return data + + +def _resolve_device_id(token, device_id): + if device_id: + return device_id + from list_devices import list_devices + devices = list_devices(token) + if not devices: + raise SystemExit("No devices found.") + return devices[0]["id"] + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Get Ring device capabilities") + parser.add_argument("--token", required=True, help="Ring API access token") + parser.add_argument("--device-id", help="Device ID (auto-discovered if not provided)") + args = parser.parse_args() + resolved_id = _resolve_device_id(args.token, args.device_id) + get_device_capabilities(args.token, resolved_id) diff --git a/scripts/device_configurations.py b/scripts/device_configurations.py new file mode 100644 index 0000000..f785fbc --- /dev/null +++ b/scripts/device_configurations.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Get the configurations (motion zones, privacy zones, settings) of a Ring device.""" + +import argparse +import json +import requests + +API_BASE = "https://api.amazonvision.com" + + +def get_device_configurations(token, device_id): + """Get device configurations.""" + url = f"{API_BASE}/v1/devices/{device_id}/configurations" + headers = {"Authorization": f"Bearer {token}"} + + print(f"\n→ GET {url}") + print(f' curl -X GET "{url}" \\') + print(f' -H "Authorization: Bearer $TOKEN"\n') + + response = requests.get(url, headers=headers) + response.raise_for_status() + + data = response.json() + attrs = data.get("data", {}).get("attributes", {}) + + motion = attrs.get("motion_detection", {}) + print(f"Motion detection: {motion.get('enabled', 'unknown')}") + zones = motion.get("motion_zones", []) + print(f"Motion zones: {len(zones)} configured") + + enhancements = attrs.get("image_enhancements", {}) + privacy_zones = enhancements.get("privacy_zones", []) + print(f"Privacy zones: {len(privacy_zones)} configured") + + for key, value in enhancements.items(): + if key != "privacy_zones": + print(f" {key}: {value}") + + print(f"\nFull response:\n{json.dumps(data, indent=2)}") + return data + + +def _resolve_device_id(token, device_id): + if device_id: + return device_id + from list_devices import list_devices + devices = list_devices(token) + if not devices: + raise SystemExit("No devices found.") + return devices[0]["id"] + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Get Ring device configurations") + parser.add_argument("--token", required=True, help="Ring API access token") + parser.add_argument("--device-id", help="Device ID (auto-discovered if not provided)") + args = parser.parse_args() + resolved_id = _resolve_device_id(args.token, args.device_id) + get_device_configurations(args.token, resolved_id) diff --git a/scripts/device_location.py b/scripts/device_location.py new file mode 100644 index 0000000..a80a513 --- /dev/null +++ b/scripts/device_location.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Get the location (country/state) of a Ring device.""" + +import argparse +import json +import requests + +API_BASE = "https://api.amazonvision.com" + + +def get_device_location(token, device_id): + """Get device location.""" + url = f"{API_BASE}/v1/devices/{device_id}/location" + headers = {"Authorization": f"Bearer {token}"} + + print(f"\n→ GET {url}") + print(f' curl -X GET "{url}" \\') + print(f' -H "Authorization: Bearer $TOKEN"\n') + + response = requests.get(url, headers=headers) + response.raise_for_status() + + data = response.json() + attrs = data.get("data", {}).get("attributes", {}) + country = attrs.get("country", "Unknown") + state = attrs.get("state", "") + + location = f"{state}, {country}" if state else country + print(f"Device location: {location}") + print(f"\nFull response:\n{json.dumps(data, indent=2)}") + return data + + +def _resolve_device_id(token, device_id): + if device_id: + return device_id + from list_devices import list_devices + devices = list_devices(token) + if not devices: + raise SystemExit("No devices found.") + return devices[0]["id"] + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Get Ring device location") + parser.add_argument("--token", required=True, help="Ring API access token") + parser.add_argument("--device-id", help="Device ID (auto-discovered if not provided)") + args = parser.parse_args() + resolved_id = _resolve_device_id(args.token, args.device_id) + get_device_location(args.token, resolved_id) diff --git a/scripts/device_status.py b/scripts/device_status.py new file mode 100644 index 0000000..3bd1774 --- /dev/null +++ b/scripts/device_status.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Get the online/offline status of a Ring device.""" + +import argparse +import json +import requests + +API_BASE = "https://api.amazonvision.com" + + +def get_device_status(token, device_id): + """Get device online/offline status.""" + url = f"{API_BASE}/v1/devices/{device_id}/status" + headers = {"Authorization": f"Bearer {token}"} + + print(f"\n→ GET {url}") + print(f' curl -X GET "{url}" \\') + print(f' -H "Authorization: Bearer $TOKEN"\n') + + response = requests.get(url, headers=headers) + response.raise_for_status() + + data = response.json() + online = data.get("data", {}).get("attributes", {}).get("online", False) + print(f"Device status: {'🟢 Online' if online else '🔴 Offline'}") + print(f"\nFull response:\n{json.dumps(data, indent=2)}") + return data + + +def _resolve_device_id(token, device_id): + """Auto-discover first device if no device_id provided.""" + if device_id: + return device_id + from list_devices import list_devices + devices = list_devices(token) + if not devices: + raise SystemExit("No devices found. Cannot auto-discover device ID.") + return devices[0]["id"] + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Get Ring device status") + parser.add_argument("--token", required=True, help="Ring API access token") + parser.add_argument("--device-id", help="Device ID (auto-discovered if not provided)") + args = parser.parse_args() + resolved_id = _resolve_device_id(args.token, args.device_id) + get_device_status(args.token, resolved_id) diff --git a/scripts/event_history.py b/scripts/event_history.py new file mode 100644 index 0000000..50caccf --- /dev/null +++ b/scripts/event_history.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Get event history (motion, doorbell, live view) for a Ring device.""" + +import argparse +import json +import requests + +API_BASE = "https://api.amazonvision.com" + + +def get_event_history(token, device_id, event_types=None): + """Get event history for a device.""" + url = f"{API_BASE}/v1/history/devices/{device_id}/events" + headers = {"Authorization": f"Bearer {token}"} + params = {} + if event_types: + params["event_types"] = event_types + + print(f"\n→ GET {url}") + curl_params = f'?event_types={event_types}' if event_types else '' + print(f' curl -X GET "{url}{curl_params}" \\') + print(f' -H "Authorization: Bearer $TOKEN"\n') + + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + + data = response.json() + events = data.get("data", []) + + print(f"Found {len(events)} event(s):\n") + for event in events: + attrs = event.get("attributes", {}) + event_type = attrs.get("event_type", "unknown") + start = attrs.get("start", "") + print(f" • {event_type} (start: {start})") + + print(f"\nFull response:\n{json.dumps(data, indent=2)}") + return data + + +def _resolve_device_id(token, device_id): + if device_id: + return device_id + from list_devices import list_devices + devices = list_devices(token) + if not devices: + raise SystemExit("No devices found.") + return devices[0]["id"] + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Get Ring device event history") + parser.add_argument("--token", required=True, help="Ring API access token") + parser.add_argument("--device-id", help="Device ID (auto-discovered if not provided)") + parser.add_argument("--event-types", help="Filter by event types (comma-separated, e.g. motion.human,ding)") + args = parser.parse_args() + resolved_id = _resolve_device_id(args.token, args.device_id) + get_event_history(args.token, resolved_id, args.event_types) diff --git a/scripts/explore_apis.py b/scripts/explore_apis.py new file mode 100644 index 0000000..9d768fd --- /dev/null +++ b/scripts/explore_apis.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Interactive Ring API explorer. Call any Ring API with your token.""" + +import argparse +import sys + +from list_devices import list_devices +from device_status import get_device_status, _resolve_device_id +from device_capabilities import get_device_capabilities +from device_location import get_device_location +from device_configurations import get_device_configurations +from event_history import get_event_history +from user_profile import get_user_profile + + +def main(token): + print("\n=== Ring API Explorer ===") + print("Use your token to call any Ring API.\n") + + # Auto-discover device for device-specific APIs + device_id = None + + while True: + print("\n1. List Devices") + print("2. Device Status") + print("3. Device Capabilities") + print("4. Device Location") + print("5. Device Configurations") + print("6. Event History") + print("7. User Profile") + print("8. Run All") + print("0. Exit") + + choice = input("\nSelect an API to call: ").strip() + + if choice == "0": + print("Goodbye!") + sys.exit(0) + + try: + if choice == "1": + devices = list_devices(token) + if devices and not device_id: + device_id = devices[0]["id"] + print(f"\n (Using device: {device_id} for subsequent calls)") + + elif choice in ("2", "3", "4", "5", "6"): + if not device_id: + print("\n Discovering devices first...") + devices = list_devices(token) + if not devices: + print(" No devices found.") + continue + device_id = devices[0]["id"] + print(f" Using device: {device_id}\n") + + if choice == "2": + get_device_status(token, device_id) + elif choice == "3": + get_device_capabilities(token, device_id) + elif choice == "4": + get_device_location(token, device_id) + elif choice == "5": + get_device_configurations(token, device_id) + elif choice == "6": + get_event_history(token, device_id) + + elif choice == "7": + get_user_profile(token) + + elif choice == "8": + print("\n--- Running all APIs ---") + devices = list_devices(token) + if devices: + device_id = devices[0]["id"] + get_device_status(token, device_id) + get_device_capabilities(token, device_id) + get_device_location(token, device_id) + get_device_configurations(token, device_id) + get_event_history(token, device_id) + get_user_profile(token) + print("\n--- All APIs complete ---") + + else: + print("Invalid choice. Please select 0-8.") + + except Exception as e: + print(f"\n Error: {e}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Interactive Ring API Explorer") + parser.add_argument("--token", required=True, help="Ring API access token") + args = parser.parse_args() + main(args.token) diff --git a/scripts/list_devices.py b/scripts/list_devices.py new file mode 100644 index 0000000..4a282af --- /dev/null +++ b/scripts/list_devices.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""List all Ring devices accessible with your token.""" + +import argparse +import json +import requests + +API_BASE = "https://api.amazonvision.com" + + +def list_devices(token): + """List all accessible devices.""" + url = f"{API_BASE}/v1/devices" + headers = {"Authorization": f"Bearer {token}"} + + print(f"\n→ GET {url}") + print(f' curl -X GET "{url}" \\') + print(f' -H "Authorization: Bearer $TOKEN"\n') + + response = requests.get(url, headers=headers) + response.raise_for_status() + + data = response.json() + devices = data.get("data", []) + + print(f"Found {len(devices)} device(s):\n") + for device in devices: + name = device.get("attributes", {}).get("name", "Unknown") + print(f" • {name} (ID: {device['id']})") + + print(f"\nFull response:\n{json.dumps(data, indent=2)}") + return devices + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="List all Ring devices") + parser.add_argument("--token", required=True, help="Ring API access token") + args = parser.parse_args() + list_devices(args.token) diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000..a8608b2 --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1 @@ +requests>=2.28.0 diff --git a/scripts/user_profile.py b/scripts/user_profile.py new file mode 100644 index 0000000..6b0f31b --- /dev/null +++ b/scripts/user_profile.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Get your Ring user profile (Account ID, name, email).""" + +import argparse +import json +import requests + +API_BASE = "https://api.amazonvision.com" + + +def get_user_profile(token): + """Get the authenticated user's profile.""" + url = f"{API_BASE}/v1/users/me" + headers = {"Authorization": f"Bearer {token}"} + + print(f"\n→ GET {url}") + print(f' curl -X GET "{url}" \\') + print(f' -H "Authorization: Bearer $TOKEN"\n') + + response = requests.get(url, headers=headers) + response.raise_for_status() + + data = response.json() + attrs = data.get("data", {}).get("attributes", {}) + account_id = data.get("data", {}).get("id", "Unknown") + + print(f"Account ID: {account_id}") + print(f"Name: {attrs.get('first_name', '')} {attrs.get('last_name', '')}") + print(f"Email: {attrs.get('email', 'N/A')}") + + print(f"\nFull response:\n{json.dumps(data, indent=2)}") + return data + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Get Ring user profile") + parser.add_argument("--token", required=True, help="Ring API access token") + args = parser.parse_args() + get_user_profile(args.token)