Spaces:
Paused
Paused
| # Smart Home Widget Architecture - Auto-Discovery & Communication | |
| ## π― CORE REQUIREMENTS | |
| Alle smart home widgets SKAL: | |
| 1. β **Auto-discover datakilder** via SourceWidgetDiscovery | |
| 2. β **Kommunikere med andre widgets** via WidgetCommunication | |
| 3. β **Anbefale manglende kilder** til brugeren | |
| 4. β **Subscribe til relevante data streams** via useLiveData | |
| --- | |
| ## ποΈ WIDGET ARCHITECTURE | |
| ### Universal Widget Structure | |
| Alle widgets implementeres med samme base architecture: | |
| ```typescript | |
| import { useWidgetCommunication } from '@/contexts/WidgetContext'; | |
| import { useLiveData } from '@/hooks/useLiveData'; | |
| import { useSourceDiscovery } from '@/services/SourceWidgetDiscovery'; | |
| interface SmartHomeWidgetProps { | |
| widgetId: string; | |
| config?: Record<string, any>; | |
| } | |
| export function SonosWidget({ widgetId, config }: SmartHomeWidgetProps) { | |
| // 1. AUTO-DISCOVERY: Connect to data sources automatically | |
| const { | |
| data: sonosData, | |
| connected, | |
| recommendedSources, | |
| connectionStatus | |
| } = useLiveData({ | |
| widgetId, | |
| widgetType: 'sonos', | |
| requiredSources: ['sonos-api', 'music-stream'], | |
| autoConnect: true, | |
| }); | |
| // 2. INTER-WIDGET COMMUNICATION: Send and receive events | |
| const { broadcastEvent, subscribeToEvent, getWidgetState } = useWidgetCommunication(widgetId); | |
| // 3. SOURCE RECOMMENDATIONS: Suggest missing sources | |
| const { discoverSources, canGenerateWidget } = useSourceDiscovery(); | |
| // Example: Broadcast when music starts playing | |
| const handlePlayMusic = async (track: Track) => { | |
| await playOnSonos(track); | |
| // Notify other widgets | |
| broadcastEvent({ | |
| type: 'music.started', | |
| source: widgetId, | |
| data: { | |
| track: track.name, | |
| artist: track.artist, | |
| room: getCurrentRoom(), | |
| } | |
| }); | |
| }; | |
| // Example: Listen for events from other widgets | |
| useEffect(() => { | |
| // If doorbell rings, pause music | |
| subscribeToEvent('doorbell.pressed', (event) => { | |
| pauseMusic(); | |
| setTimeout(() => resumeMusic(), 30000); // Resume after 30s | |
| }); | |
| // If TV turns on, lower music volume | |
| subscribeToEvent('tv.power.on', (event) => { | |
| setVolume(20); // Lower to background level | |
| }); | |
| }, []); | |
| return ( | |
| <div className="sonos-widget"> | |
| {/* Connection status indicator */} | |
| <ConnectionIndicator status={connectionStatus} /> | |
| {/* Recommend missing sources */} | |
| {recommendedSources.length > 0 && ( | |
| <SourceRecommendationPanel | |
| sources={recommendedSources} | |
| onEnable={(source) => enableSource(source)} | |
| /> | |
| )} | |
| {/* Widget content */} | |
| {connected && <MusicPlayer data={sonosData} />} | |
| </div> | |
| ); | |
| } | |
| ``` | |
| --- | |
| ## π‘ DATA SOURCE AUTO-DISCOVERY | |
| ### How Widgets Find Their Data: | |
| ```typescript | |
| // Backend: Register all smart home sources | |
| // apps/backend/src/routes/acquisition.ts | |
| const SMART_HOME_SOURCES = [ | |
| { | |
| id: 'sonos-api', | |
| type: 'music', | |
| protocol: 'http', | |
| endpoint: process.env.SONOS_API_URL, | |
| capabilities: ['play', 'pause', 'volume', 'queue'], | |
| requiredBy: ['sonos', 'spotify', 'music-player'], | |
| }, | |
| { | |
| id: 'nest-camera-stream', | |
| type: 'camera', | |
| protocol: 'webrtc', | |
| endpoint: process.env.NEST_CAMERA_API, | |
| capabilities: ['live-stream', 'snapshot', 'motion-detect'], | |
| requiredBy: ['nest-camera', 'security-camera', 'doorbell'], | |
| }, | |
| { | |
| id: 'roomba-local', | |
| type: 'vacuum', | |
| protocol: 'rest', | |
| endpoint: 'http://roomba.local:8080', | |
| capabilities: ['start', 'stop', 'dock', 'status'], | |
| requiredBy: ['robot-vacuum'], | |
| }, | |
| // ... etc | |
| ]; | |
| // Auto-register on backend startup | |
| app.post('/api/acquisition/register-smart-home-sources', async (req, res) => { | |
| for (const source of SMART_HOME_SOURCES) { | |
| await sourceRegistry.registerSource(source); | |
| } | |
| res.json({ registered: SMART_HOME_SOURCES.length }); | |
| }); | |
| ``` | |
| ### Widget Auto-Connection Flow: | |
| ``` | |
| 1. Widget mounts β useLiveData({ widgetType: 'sonos' }) | |
| 2. Hook queries: GET /api/acquisition/sources?type=music | |
| 3. Backend returns matching sources: ['sonos-api', 'spotify-web-api'] | |
| 4. Widget auto-connects to available sources | |
| 5. If source missing β Recommend to user via SourceRecommendationPanel | |
| 6. User clicks "Enable" β POST /api/acquisition/enable-source | |
| 7. Widget reconnects automatically | |
| ``` | |
| --- | |
| ## π INTER-WIDGET COMMUNICATION | |
| ### Event Bus Architecture: | |
| ```typescript | |
| // Shared event types across all widgets | |
| export enum WidgetEventType { | |
| // Music events | |
| MUSIC_STARTED = 'music.started', | |
| MUSIC_PAUSED = 'music.paused', | |
| MUSIC_STOPPED = 'music.stopped', | |
| VOLUME_CHANGED = 'music.volume.changed', | |
| // TV/Media events | |
| TV_POWER_ON = 'tv.power.on', | |
| TV_POWER_OFF = 'tv.power.off', | |
| TV_INPUT_CHANGED = 'tv.input.changed', | |
| MEDIA_PLAYING = 'media.playing', | |
| // Security events | |
| DOORBELL_PRESSED = 'doorbell.pressed', | |
| MOTION_DETECTED = 'camera.motion', | |
| DOOR_UNLOCKED = 'lock.unlocked', | |
| ALARM_TRIGGERED = 'alarm.triggered', | |
| // Automation events | |
| VACUUM_STARTED = 'vacuum.started', | |
| VACUUM_FINISHED = 'vacuum.finished', | |
| MOWER_STARTED = 'mower.started', | |
| // Climate events | |
| TEMPERATURE_CHANGED = 'thermostat.temp.changed', | |
| HVAC_MODE_CHANGED = 'thermostat.mode.changed', | |
| // General | |
| PRESENCE_DETECTED = 'presence.detected', | |
| PRESENCE_LEFT = 'presence.left', | |
| } | |
| // Example: Sonos Widget listening to multiple events | |
| export function SonosWidget({ widgetId }: SmartHomeWidgetProps) { | |
| const { subscribeToEvent, broadcastEvent } = useWidgetCommunication(widgetId); | |
| useEffect(() => { | |
| // Pause music when doorbell rings | |
| const unsubDoorbell = subscribeToEvent( | |
| WidgetEventType.DOORBELL_PRESSED, | |
| (event) => { | |
| if (isPlaying()) { | |
| pauseMusic(); | |
| showNotification('Paused music - Doorbell'); | |
| } | |
| } | |
| ); | |
| // Lower volume when TV turns on | |
| const unsubTV = subscribeToEvent( | |
| WidgetEventType.TV_POWER_ON, | |
| (event) => { | |
| if (isPlaying() && getCurrentVolume() > 30) { | |
| setVolume(20); | |
| showNotification('Lowered volume - TV is on'); | |
| } | |
| } | |
| ); | |
| // Resume when vacuum finishes | |
| const unsubVacuum = subscribeToEvent( | |
| WidgetEventType.VACUUM_FINISHED, | |
| (event) => { | |
| if (wasPausedByAutomation) { | |
| resumeMusic(); | |
| } | |
| } | |
| ); | |
| return () => { | |
| unsubDoorbell(); | |
| unsubTV(); | |
| unsubVacuum(); | |
| }; | |
| }, []); | |
| } | |
| ``` | |
| --- | |
| ## π€ SMART AUTOMATION SCENARIOS | |
| ### Scenario 1: Movie Night Mode | |
| ```typescript | |
| // TV Widget broadcasts "movie mode starting" | |
| function TvWidget() { | |
| const startMovieMode = () => { | |
| broadcastEvent({ | |
| type: 'media.movie-mode.start', | |
| data: { movie: currentMovie } | |
| }); | |
| }; | |
| } | |
| // Sonos Widget lowers music | |
| function SonosWidget() { | |
| subscribeToEvent('media.movie-mode.start', () => { | |
| setVolume(5); | |
| }); | |
| } | |
| // Smart Lights Widget dims lights | |
| function LightsWidget() { | |
| subscribeToEvent('media.movie-mode.start', () => { | |
| setScene('movie'); | |
| setBrightness(20); | |
| }); | |
| } | |
| // Thermostat lowers temp slightly | |
| function ThermostatWidget() { | |
| subscribeToEvent('media.movie-mode.start', () => { | |
| setTemp(currentTemp - 1); | |
| }); | |
| } | |
| ``` | |
| ### Scenario 2: Vacuum Cleaning Coordination | |
| ```typescript | |
| // Robot Vacuum broadcasts start | |
| function VacuumWidget() { | |
| const startCleaning = () => { | |
| broadcastEvent({ | |
| type: 'vacuum.started', | |
| data: { room: 'living-room' } | |
| }); | |
| }; | |
| } | |
| // Sonos pauses music in that room | |
| function SonosWidget() { | |
| subscribeToEvent('vacuum.started', (event) => { | |
| if (event.data.room === currentRoom) { | |
| pauseMusic(); | |
| } | |
| }); | |
| } | |
| // Pet Feeder delays feeding | |
| function PetFeederWidget() { | |
| subscribeToEvent('vacuum.started', () => { | |
| delayNextFeeding(30); // Wait 30 min | |
| }); | |
| } | |
| ``` | |
| ### Scenario 3: Security Alert Chain | |
| ```typescript | |
| // Camera detects motion | |
| function CameraWidget() { | |
| onMotionDetected(() => { | |
| broadcastEvent({ | |
| type: 'camera.motion', | |
| data: { | |
| camera: 'front-door', | |
| snapshot: snapshotUrl, | |
| confidence: 0.95 | |
| } | |
| }); | |
| }); | |
| } | |
| // Smart Lock checks status | |
| function LockWidget() { | |
| subscribeToEvent('camera.motion', (event) => { | |
| if (!isLocked()) { | |
| showAlert('Motion detected - Door unlocked!'); | |
| } | |
| }); | |
| } | |
| // Lights turn on | |
| function LightsWidget() { | |
| subscribeToEvent('camera.motion', (event) => { | |
| if (isNightTime() && event.data.camera === 'front-door') { | |
| turnOnPorchLights(); | |
| } | |
| }); | |
| } | |
| // Sonos announces | |
| function SonosWidget() { | |
| subscribeToEvent('camera.motion', (event) => { | |
| if (event.data.confidence > 0.9) { | |
| announceOnSpeakers('Motion detected at front door'); | |
| } | |
| }); | |
| } | |
| ``` | |
| --- | |
| ## π SOURCE RECOMMENDATION SYSTEM | |
| ### How Widgets Suggest Missing Sources: | |
| ```typescript | |
| // Backend endpoint to check widget requirements | |
| app.get('/api/widget-sources/:widgetType', async (req, res) => { | |
| const { widgetType } = req.params; | |
| const requirements = WIDGET_REQUIREMENTS[widgetType]; | |
| const availableSources = await sourceRegistry.getSources(); | |
| const missing = requirements.required.filter( | |
| req => !availableSources.find(s => s.id === req) | |
| ); | |
| const optional = requirements.optional.filter( | |
| opt => !availableSources.find(s => s.id === opt) | |
| ); | |
| res.json({ | |
| widgetType, | |
| available: availableSources, | |
| missing, | |
| optional, | |
| recommendations: missing.map(m => ({ | |
| sourceId: m, | |
| setupUrl: `/setup/${m}`, | |
| difficulty: 'easy', | |
| estimatedTime: '5 minutes' | |
| })) | |
| }); | |
| }); | |
| // Widget requirements mapping | |
| const WIDGET_REQUIREMENTS = { | |
| 'sonos': { | |
| required: ['sonos-api'], | |
| optional: ['spotify-web-api', 'apple-music-api'], | |
| }, | |
| 'nest-camera': { | |
| required: ['nest-camera-stream', 'google-sdm-api'], | |
| optional: ['object-detection-ml'], | |
| }, | |
| 'robot-vacuum': { | |
| required: ['roomba-local'], | |
| optional: ['floor-plan-image', 'home-assistant'], | |
| }, | |
| // ... | |
| }; | |
| ``` | |
| ### Frontend: Source Recommendation Panel | |
| ```typescript | |
| export function SourceRecommendationPanel({ sources, onEnable }) { | |
| return ( | |
| <div className="bg-orange-500/10 border border-orange-500/30 rounded p-3 mb-3"> | |
| <div className="flex items-center gap-2 mb-2"> | |
| <AlertTriangle className="w-4 h-4 text-orange-400" /> | |
| <span className="text-sm font-medium">Missing Data Sources</span> | |
| </div> | |
| {sources.map(source => ( | |
| <div key={source.id} className="flex items-center justify-between mb-2"> | |
| <div> | |
| <p className="text-xs font-medium">{source.name}</p> | |
| <p className="text-xs opacity-60">{source.description}</p> | |
| </div> | |
| <button | |
| onClick={() => onEnable(source)} | |
| className="px-3 py-1 bg-orange-500 text-white rounded text-xs" | |
| > | |
| Enable | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| ``` | |
| --- | |
| ## π§ IMPLEMENTATION CHECKLIST | |
| ### For Each New Smart Home Widget: | |
| - [ ] **1. Define Widget Requirements** | |
| ```typescript | |
| const WIDGET_CONFIG = { | |
| type: 'sonos', | |
| requiredSources: ['sonos-api'], | |
| optionalSources: ['spotify-web-api'], | |
| requiredCapabilities: ['play', 'pause', 'volume'], | |
| }; | |
| ``` | |
| - [ ] **2. Implement useLiveData Hook** | |
| ```typescript | |
| const { data, connected, recommendedSources } = useLiveData({ | |
| widgetId, | |
| widgetType: 'sonos', | |
| autoConnect: true, | |
| }); | |
| ``` | |
| - [ ] **3. Setup Widget Communication** | |
| ```typescript | |
| const { broadcastEvent, subscribeToEvent } = useWidgetCommunication(widgetId); | |
| ``` | |
| - [ ] **4. Define Events Widget Broadcasts** | |
| ```typescript | |
| // On state change | |
| broadcastEvent({ type: 'music.started', data: {...} }); | |
| ``` | |
| - [ ] **5. Subscribe to Relevant Events** | |
| ```typescript | |
| subscribeToEvent('doorbell.pressed', handleDoorbell); | |
| subscribeToEvent('tv.power.on', handleTVOn); | |
| ``` | |
| - [ ] **6. Add Source Recommendations UI** | |
| ```typescript | |
| {recommendedSources.length > 0 && ( | |
| <SourceRecommendationPanel sources={recommendedSources} /> | |
| )} | |
| ``` | |
| - [ ] **7. Register Backend Source** | |
| ```typescript | |
| await sourceRegistry.registerSource({ | |
| id: 'sonos-api', | |
| type: 'music', | |
| endpoint: process.env.SONOS_API_URL, | |
| }); | |
| ``` | |
| - [ ] **8. Test Inter-Widget Communication** | |
| - Verify events broadcast correctly | |
| - Verify other widgets respond | |
| - Test multiple widgets simultaneously | |
| --- | |
| ## π― EXAMPLE: Complete Sonos Widget | |
| ```typescript | |
| import React, { useEffect, useState } from 'react'; | |
| import { useLiveData } from '@/hooks/useLiveData'; | |
| import { useWidgetCommunication } from '@/contexts/WidgetContext'; | |
| import { SourceRecommendationPanel } from '@/components/SourceRecommendationPanel'; | |
| import { WidgetEventType } from '@/types/widget-events'; | |
| export function SonosWidget({ widgetId }: { widgetId: string }) { | |
| // 1. AUTO-CONNECT TO DATA SOURCES | |
| const { | |
| data: sonosData, | |
| connected, | |
| recommendedSources, | |
| connectionStatus, | |
| refetch, | |
| } = useLiveData({ | |
| widgetId, | |
| widgetType: 'sonos', | |
| requiredSources: ['sonos-api'], | |
| optionalSources: ['spotify-web-api'], | |
| autoConnect: true, | |
| pollInterval: 5000, | |
| }); | |
| // 2. WIDGET COMMUNICATION | |
| const { broadcastEvent, subscribeToEvent, getWidgetState } = | |
| useWidgetCommunication(widgetId); | |
| const [isPaused, setIsPaused] = useState(false); | |
| // 3. HANDLE EXTERNAL EVENTS | |
| useEffect(() => { | |
| // Pause on doorbell | |
| const unsubDoorbell = subscribeToEvent( | |
| WidgetEventType.DOORBELL_PRESSED, | |
| (event) => { | |
| if (sonosData?.isPlaying) { | |
| pauseMusic(); | |
| setTimeout(() => resumeMusic(), 30000); | |
| } | |
| } | |
| ); | |
| // Lower volume for TV | |
| const unsubTV = subscribeToEvent( | |
| WidgetEventType.TV_POWER_ON, | |
| (event) => { | |
| if (sonosData?.volume > 30) { | |
| setVolume(20); | |
| } | |
| } | |
| ); | |
| // Resume after vacuum | |
| const unsubVacuum = subscribeToEvent( | |
| WidgetEventType.VACUUM_FINISHED, | |
| () => { | |
| if (isPaused) { | |
| resumeMusic(); | |
| } | |
| } | |
| ); | |
| return () => { | |
| unsubDoorbell(); | |
| unsubTV(); | |
| unsubVacuum(); | |
| }; | |
| }, [sonosData, isPaused]); | |
| // 4. BROADCAST OWN EVENTS | |
| const handlePlay = async (track: Track) => { | |
| await playOnSonos(track); | |
| broadcastEvent({ | |
| type: WidgetEventType.MUSIC_STARTED, | |
| source: widgetId, | |
| data: { | |
| track: track.name, | |
| artist: track.artist, | |
| album: track.album, | |
| room: sonosData?.currentRoom, | |
| } | |
| }); | |
| }; | |
| const handleVolumeChange = async (newVolume: number) => { | |
| await setVolume(newVolume); | |
| broadcastEvent({ | |
| type: WidgetEventType.VOLUME_CHANGED, | |
| source: widgetId, | |
| data: { | |
| volume: newVolume, | |
| room: sonosData?.currentRoom, | |
| } | |
| }); | |
| }; | |
| return ( | |
| <div className="sonos-widget"> | |
| {/* Source Recommendations */} | |
| {recommendedSources.length > 0 && ( | |
| <SourceRecommendationPanel | |
| sources={recommendedSources} | |
| onEnable={async (source) => { | |
| await enableSource(source.id); | |
| refetch(); | |
| }} | |
| /> | |
| )} | |
| {/* Connection Status */} | |
| <ConnectionIndicator | |
| status={connectionStatus} | |
| sources={connected ? sonosData?.sources : []} | |
| /> | |
| {/* Widget Content */} | |
| {connected && sonosData && ( | |
| <div className="music-player"> | |
| <NowPlaying track={sonosData.currentTrack} /> | |
| <VolumeControl | |
| volume={sonosData.volume} | |
| onChange={handleVolumeChange} | |
| /> | |
| <PlaybackControls | |
| isPlaying={sonosData.isPlaying} | |
| onPlay={() => handlePlay(sonosData.queue[0])} | |
| onPause={pauseMusic} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| ``` | |
| --- | |
| ## π COMPLETE WIDGET LIST WITH COMMUNICATION | |
| | Widget | Broadcasts | Listens To | | |
| |--------|-----------|------------| | |
| | **Sonos** | `music.started`, `music.paused`, `volume.changed` | `doorbell.pressed`, `tv.power.on`, `vacuum.started` | | |
| | **Nest Camera** | `motion.detected`, `person.detected`, `doorbell.pressed` | `alarm.triggered`, `presence.left` | | |
| | **Robot Vacuum** | `vacuum.started`, `vacuum.finished`, `vacuum.error` | `presence.detected`, `music.started` | | |
| | **Smart TV** | `tv.power.on`, `tv.power.off`, `media.playing` | `doorbell.pressed`, `alarm.triggered` | | |
| | **Thermostat** | `temp.changed`, `mode.changed` | `presence.detected`, `presence.left`, `media.movie-mode.start` | | |
| | **Smart Lock** | `lock.unlocked`, `lock.locked` | `motion.detected`, `presence.detected` | | |
| | **Lawn Mower** | `mower.started`, `mower.finished` | `weather.rain`, `presence.detected` | | |
| | **Lights** | `lights.on`, `lights.off`, `scene.changed` | `motion.detected`, `tv.power.on`, `presence.left` | | |
| --- | |
| **Architecture:** Auto-Discovery + Inter-Widget Communication | |
| **Backend:** Source Registry + Event Bus | |
| **Frontend:** useLiveData + useWidgetCommunication | |
| **Date:** 2025-12-10 | |