diff --git a/.gitignore b/.gitignore index b3666ee..32ac57a 100644 --- a/.gitignore +++ b/.gitignore @@ -57,5 +57,9 @@ backend/backend # Go coverage output *.out +# Environment variables +.env +*.env + # Dependency directory (if you ever vendor dependencies) vendor/ \ No newline at end of file diff --git a/CAMERA_SETUP.md b/CAMERA_SETUP.md new file mode 100644 index 0000000..fd712f5 --- /dev/null +++ b/CAMERA_SETUP.md @@ -0,0 +1,130 @@ +# FiremeX Camera Setup Guide + +This guide explains how the camera integration works in FiremeX, specifically how to bridge your physical MacBook webcam into the system via Home Assistant (HA). + +## ๐Ÿ—๏ธ Architecture Overview + +Home Assistant runs in a Docker container, which means it cannot directly access the hardware of the host machine (your Mac's built-in webcam). To solve this, we create a small network bridge using a Python server. + +```mermaid +flowchart TD + subgraph Host Machine (Mac) + Cam[Built-in Webcam] + Py[Python MJPEG Server\n:8090] + end + + subgraph Docker + HA[Home Assistant\n:8123] + end + + subgraph FiremeX System + Go[Go Backend\n:8080] + React[Frontend App\n:5173] + end + + Cam -->|Video Frames| Py + Py -->|MJPEG Stream over HTTP| HA + HA -->|Proxy Stream Request| Go + Go -->|Proxied Stream| React +``` + +## ๐Ÿ› ๏ธ Step-by-Step Setup + +### Step 1: Install Python Dependencies on your Mac + +You need a small Python script to capture webcam frames and serve them over HTTP as an MJPEG stream. + +```bash +pip3 install opencv-python flask +``` + +### Step 2: Create the Webcam Server Script + +Create a file named `webcam_server.py` on your Mac (e.g., on your Desktop or in the project root): + +```python +import cv2 +from flask import Flask, Response + +app = Flask(__name__) + +# 0 is usually the default built-in webcam +camera = cv2.VideoCapture(0) + +def generate_frames(): + while True: + success, frame = camera.read() + if not success: + break + + # Encode frame as JPEG + _, buffer = cv2.imencode('.jpg', frame) + frame_bytes = buffer.tobytes() + + # Yield as MJPEG stream + yield ( + b'--frame\r\n' + b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n' + ) + +@app.route('/stream') +def stream(): + return Response( + generate_frames(), + mimetype='multipart/x-mixed-replace; boundary=frame' + ) + +@app.route('/') +def index(): + return '

Webcam stream running at /stream

' + +if __name__ == '__main__': + print("Webcam server starting on http://0.0.0.0:8090") + app.run(host='0.0.0.0', port=8090, threaded=True) +``` + +### Step 3: Run the Server + +Execute the script in your terminal: + +```bash +python3 webcam_server.py +``` +*(Your Mac may prompt you to grant the Terminal permission to access the camera).* + +You can verify the stream is working by opening `http://localhost:8090/stream` in your browser. + +### Step 4: Add the Camera to Home Assistant + +Home Assistant is running inside Docker. To reach the Python server running on your Mac host, we use Docker's internal host DNS: `host.docker.internal`. + +1. Go to your Home Assistant dashboard (`http://localhost:8123`). +2. Navigate to **Settings -> Devices & Services**. +3. Click **+ Add Integration** and search for **Generic Camera**. +4. Configure the integration with the following URLs: + * **Still Image URL:** `http://host.docker.internal:8090/stream` + * **Stream Source URL:** `http://host.docker.internal:8090/stream` +5. Submit the configuration. This creates an entity in HA, usually named `camera.webcam` or similar. + +### Step 5: Add the Camera in FiremeX + +Now that the camera is registered in Home Assistant, FiremeX can discover it. + +1. Open the FiremeX Admin Dashboard (`http://localhost:5173/FiremeX/admin/livefeed`). +2. Click the **Add Camera** button. +3. The dropdown will securely fetch available cameras from Home Assistant using your Long-Lived Access Token. You should see `camera.webcam` in the list. +4. Select the camera, assign it a name and zone, and save. +5. The live feed will now appear on the dashboard! + +--- + +## ๐Ÿ” How the Stream is Proxied + +Because the FiremeX Frontend runs on `:5173` and Home Assistant runs on `:8123`, trying to load the stream directly from HA in the browser would result in CORS (Cross-Origin Resource Sharing) blocks. + +To bypass this securely, the FiremeX Go backend (`:8080`) acts as a stream proxy: + +1. The React frontend requests `GET /api/cameras/stream/:entity_id` from the Go backend. +2. The Go backend fetches the stream from Home Assistant using the `HA_TOKEN` defined in `.env`. +3. The Go backend continuously pipes the incoming bytes from Home Assistant directly back to the React frontend using a `multipart/x-mixed-replace` HTTP response. +4. The browser effortlessly renders the stream as a standard `` without any CORS issues. diff --git a/backend/controllers/camera.go b/backend/controllers/camera.go new file mode 100644 index 0000000..8677afe --- /dev/null +++ b/backend/controllers/camera.go @@ -0,0 +1,204 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/firemex/backend/database" + "github.com/firemex/backend/models" + "github.com/gin-gonic/gin" +) + +// HAState represents state item returned from Home Assistant /api/states +type HAState struct { + EntityID string `json:"entity_id"` + State string `json:"state"` + Attributes map[string]interface{} `json:"attributes"` +} + +// GetAvailableCameras fetches all camera entities from Home Assistant API +func GetAvailableCameras(c *gin.Context) { + haURL := os.Getenv("HA_URL") + haToken := os.Getenv("HA_TOKEN") + + if haURL == "" { + haURL = "http://localhost:8123" + } + + req, err := http.NewRequest("GET", haURL+"/api/states", nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create HA request"}) + return + } + + req.Header.Set("Authorization", "Bearer "+haToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to connect to Home Assistant: " + err.Error()}) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + c.JSON(resp.StatusCode, gin.H{"error": "Home Assistant returned non-200 status"}) + return + } + + var states []HAState + if err := json.NewDecoder(resp.Body).Decode(&states); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse HA response"}) + return + } + + // Filter for camera.* entities + var cameras []gin.H + for _, s := range states { + if len(s.EntityID) >= 7 && s.EntityID[:7] == "camera." { + friendlyName, _ := s.Attributes["friendly_name"].(string) + if friendlyName == "" { + friendlyName = s.EntityID + } + cameras = append(cameras, gin.H{ + "entity_id": s.EntityID, + "friendly_name": friendlyName, + "state": s.State, + }) + } + } + + c.JSON(http.StatusOK, gin.H{"cameras": cameras}) +} + +// AddCamera saves a new camera into PostgreSQL linked to the user's organization +func AddCamera(c *gin.Context) { + var input struct { + EntityID string `json:"entity_id" binding:"required"` + DisplayName string `json:"display_name" binding:"required"` + Zone string `json:"zone"` + AiEnabled bool `json:"ai_enabled"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Get logged-in user + userIDFloat, _ := c.Get("userID") + userID := uint(userIDFloat.(float64)) + + var user models.User + if err := database.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) + return + } + + var orgID uint + if user.OrganizationID != nil { + orgID = *user.OrganizationID + } else { + // Fallback for primary system admin without org + orgID = 1 + } + + camera := models.Camera{ + EntityID: input.EntityID, + DisplayName: input.DisplayName, + Zone: input.Zone, + AiEnabled: input.AiEnabled, + OrganizationID: orgID, + } + + if err := database.DB.Create(&camera).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add camera: " + err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"message": "Camera added successfully", "camera": camera}) +} + +// GetCameras fetches all cameras for the user's organization +func GetCameras(c *gin.Context) { + userIDFloat, _ := c.Get("userID") + userID := uint(userIDFloat.(float64)) + + var user models.User + if err := database.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) + return + } + + var cameras []models.Camera + if user.OrganizationID != nil { + database.DB.Where("organization_id = ?", *user.OrganizationID).Find(&cameras) + } else { + // System admin gets all cameras + database.DB.Find(&cameras) + } + + c.JSON(http.StatusOK, gin.H{"cameras": cameras}) +} + +// DeleteCamera removes a camera record from DB +func DeleteCamera(c *gin.Context) { + id := c.Param("id") + + if err := database.DB.Delete(&models.Camera{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete camera"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Camera deleted successfully"}) +} + +// StreamCamera proxies the Home Assistant MJPEG stream to avoid CORS issues in browser +func StreamCamera(c *gin.Context) { + entityID := c.Param("entity_id") + haURL := os.Getenv("HA_URL") + haToken := os.Getenv("HA_TOKEN") + + if haURL == "" { + haURL = "http://localhost:8123" + } + + reqURL := fmt.Sprintf("%s/api/camera_proxy_stream/%s", haURL, entityID) + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create stream request"}) + return + } + + req.Header.Set("Authorization", "Bearer "+haToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to connect to HA stream: " + err.Error()}) + return + } + defer resp.Body.Close() + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "multipart/x-mixed-replace; boundary=--frame" + } + + c.Header("Content-Type", contentType) + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + + c.Stream(func(w io.Writer) bool { + buf := make([]byte, 4096) + n, err := resp.Body.Read(buf) + if n > 0 { + _, _ = w.Write(buf[:n]) + } + return err == nil + }) +} diff --git a/backend/go.mod b/backend/go.mod index ec1c530..d538f1b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -23,6 +23,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/leodido/go-urn v1.5.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 1bade5b..ebea7bc 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -41,6 +41,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= diff --git a/backend/main.go b/backend/main.go index c2f6c14..e9ee972 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,6 +4,7 @@ import ( "log" "github.com/gin-gonic/gin" + "github.com/joho/godotenv" "github.com/firemex/backend/controllers" "github.com/firemex/backend/database" @@ -13,12 +14,17 @@ import ( ) func main() { + // 0. Load environment variables from .env file + if err := godotenv.Load(); err != nil { + log.Println("Note: .env file not found or failed to load. Using default environment variables.") + } + // 1. Connect to the Database log.Println("Starting FiremeX backend...") database.ConnectDB() // 2. Run the AutoMigrate for all models - err := database.DB.AutoMigrate(&models.Organization{}, &models.User{}) + err := database.DB.AutoMigrate(&models.Organization{}, &models.User{}, &models.Camera{}) if err != nil { log.Fatal("Failed to migrate database: ", err) } @@ -58,6 +64,10 @@ func main() { "userID": userID, }) }) + + // Camera routes for authenticated users + protected.GET("/cameras", controllers.GetCameras) + protected.GET("/cameras/stream/:entity_id", controllers.StreamCamera) } // 7. Admin-Only Routes (Require JWT + Admin role) @@ -68,9 +78,15 @@ func main() { admin.PATCH("/users/:id/approve", controllers.ApproveUser) admin.DELETE("/users/:id/deny", controllers.DenyUser) admin.PATCH("/users/:id/revoke", controllers.RevokeUser) + + // Camera management routes for admins + admin.GET("/cameras/available", controllers.GetAvailableCameras) + admin.POST("/cameras", controllers.AddCamera) + admin.DELETE("/cameras/:id", controllers.DeleteCamera) } // 8. Start the server log.Println("Server is running on port 8080...") router.Run(":8080") } + diff --git a/backend/models/camera.go b/backend/models/camera.go new file mode 100644 index 0000000..1fa34b3 --- /dev/null +++ b/backend/models/camera.go @@ -0,0 +1,16 @@ +package models + +import ( + "gorm.io/gorm" +) + +// Camera represents a camera entity registered from Home Assistant +type Camera struct { + gorm.Model + EntityID string `json:"entity_id" gorm:"not null"` + DisplayName string `json:"display_name" gorm:"not null"` + Zone string `json:"zone"` + AiEnabled bool `json:"ai_enabled" gorm:"default:false"` + OrganizationID uint `json:"organization_id" gorm:"not null"` + Organization *Organization `json:"organization,omitempty" gorm:"foreignKey:OrganizationID"` +} diff --git a/frontend/src/pages/admin/AddDevice.tsx b/frontend/src/pages/admin/AddDevice.tsx index 67902a6..6f2ae68 100644 --- a/frontend/src/pages/admin/AddDevice.tsx +++ b/frontend/src/pages/admin/AddDevice.tsx @@ -1,89 +1,105 @@ -import { useState } from 'preact/hooks' +import { useEffect, useState } from 'preact/hooks' type Props = { onNavigate: (path: string) => void } -const initialCameras = [ - { - id: 'CAM-01', - name: 'CAM-01 Main Entrance', - zone: 'Warehouse A', - status: 'Normal', - ip: '192.168.1.101', - fps: '25 fps', - resolution: '1080p', - time: '06-26 14:42:08' - }, - { - id: 'CAM-02', - name: 'CAM-02 Entrance', - zone: 'Warehouse A', - status: 'Critical', - ip: '192.168.1.102', - fps: '25 fps', - resolution: '1080p', - time: '06-26 14:42:08' - }, - { - id: 'CAM-03', - name: 'CAM-03 Entrance', - zone: 'Warehouse C', - status: 'Normal', - ip: '192.168.1.103', - fps: '20 fps', - resolution: '1080p', - time: '06-26 14:42:08' - }, - { - id: 'CAM-04', - name: 'CAM-02 Server Room North', - zone: 'Secure IT', - status: 'Normal', - ip: '192.168.1.104', - fps: '30 fps', - resolution: '1080p', - time: '06-26 14:42:08' - } -] +type AvailableCamera = { + entity_id: string + friendly_name: string + state: string +} export function AddDevice({ onNavigate }: Props) { const [newCamName, setNewCamName] = useState('') - const [newCamIp, setNewCamIp] = useState('') + const [selectedEntityId, setSelectedEntityId] = useState('') + const [availableCameras, setAvailableCameras] = useState([]) + const [isLoadingHA, setIsLoadingHA] = useState(true) + const [haError, setHaError] = useState('') const [newCamZone, setNewCamZone] = useState('Main Entrance') - const [newCamResolution, setNewCamResolution] = useState('1080p (Full HD)') - const [newCamFps, setNewCamFps] = useState('30 FPS') const [newCamAi, setNewCamAi] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [errorMsg, setErrorMsg] = useState('') - const handleSubmitCamera = (e: any) => { - e.preventDefault() - if (!newCamName) return - - // Load current cameras to generate next ID and append - const saved = localStorage.getItem('firemex_cameras') - const currentCameras = saved ? JSON.parse(saved) : initialCameras - - // Generate dynamic next ID - const maxIdNum = currentCameras.reduce((max: number, cam: any) => { - const num = parseInt(cam.id.replace('CAM-', ''), 10) - return num > max ? num : max - }, 0) - const nextId = `CAM-${String(maxIdNum + 1).padStart(2, '0')}` - - const newCam = { - id: nextId, - name: `${nextId} ${newCamName}`, - zone: newCamZone, - status: 'Normal', - ip: newCamIp || '192.168.1.101', - fps: newCamFps.toLowerCase(), - resolution: newCamResolution.split(' ')[0].toLowerCase(), // get 1080p - time: new Date().toLocaleDateString('en-US', { month: '2-digit', day: '2-digit' }).replace(/\//g, '-') + ' ' + new Date().toLocaleTimeString('en-US', { hour12: false }) + // Fetch available Home Assistant cameras on mount + useEffect(() => { + const fetchHACameras = async () => { + setIsLoadingHA(true) + setHaError('') + try { + const token = localStorage.getItem('firemex_token') + const res = await fetch('http://localhost:8080/api/cameras/available', { + headers: { + Authorization: `Bearer ${token}` + } + }) + + if (!res.ok) { + const data = await res.json() + throw new Error(data.error || 'Failed to fetch Home Assistant cameras') + } + + const data = await res.json() + const cameras: AvailableCamera[] = data.cameras || [] + setAvailableCameras(cameras) + + if (cameras.length > 0) { + setSelectedEntityId(cameras[0].entity_id) + setNewCamName(cameras[0].friendly_name) + } + } catch (err: any) { + setHaError(err.message || 'Could not connect to Home Assistant API') + } finally { + setIsLoadingHA(false) + } } - const updated = [...currentCameras, newCam] - localStorage.setItem('firemex_cameras', JSON.stringify(updated)) - onNavigate('/admin/livefeed') + fetchHACameras() + }, []) + + // When user selects a different HA camera from dropdown, update display name default + const handleSelectEntity = (entityId: string) => { + setSelectedEntityId(entityId) + const found = availableCameras.find((c) => c.entity_id === entityId) + if (found && !newCamName) { + setNewCamName(found.friendly_name) + } + } + + const handleSubmitCamera = async (e: any) => { + e.preventDefault() + if (!selectedEntityId && availableCameras.length > 0) return + + setIsSubmitting(true) + setErrorMsg('') + + try { + const token = localStorage.getItem('firemex_token') + const res = await fetch('http://localhost:8080/api/cameras', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + entity_id: selectedEntityId || 'camera.demo_camera', + display_name: newCamName || 'New Camera Stream', + zone: newCamZone, + ai_enabled: newCamAi + }) + }) + + const data = await res.json() + if (!res.ok) { + throw new Error(data.error || 'Failed to add camera') + } + + onNavigate('/FiremeX/admin/livefeed') + } catch (err: any) { + setErrorMsg(err.message || 'Error creating camera record') + } finally { + setIsSubmitting(false) + } } return ( @@ -92,7 +108,7 @@ export function AddDevice({ onNavigate }: Props) {

Devices Configuration

-

Manage active network streams & devices

+

Manage Home Assistant camera streams & devices

@@ -102,7 +118,7 @@ export function AddDevice({ onNavigate }: Props) {

Add New Device

-

Configure a new camera stream for the secure network.

+

Select a Home Assistant camera entity to add to FiremeX.

@@ -112,39 +128,63 @@ export function AddDevice({ onNavigate }: Props) {
+ {errorMsg && ( +
+ {errorMsg} +
+ )} + {/* Grid Fields */}
- {/* Camera Name */} -
- + {/* Home Assistant Camera Entity Dropdown */} +
+ + {isLoadingHA ? ( +
+ Loading HA Entities... +
+ ) : availableCameras.length > 0 ? ( + + ) : ( + setSelectedEntityId(e.target.value)} + class="w-full bg-[#050B0D] border border-[#8B949E]/20 focus:border-accent text-slate-200 rounded-xl px-4 py-3 text-sm outline-none transition-colors font-mono" + /> + )} + {haError && ( + + โš ๏ธ HA API Warning: {haError} (Falling back to manual entity entry) + + )} +
+ + {/* Camera Display Name */} +
+ setNewCamName(e.target.value)} class="w-full bg-[#050B0D] border border-[#8B949E]/20 focus:border-accent text-slate-200 rounded-xl px-4 py-3 text-sm outline-none transition-colors" />
- {/* IP Address */} -
- -
- setNewCamIp(e.target.value)} - class="w-full bg-[#050B0D] border border-[#8B949E]/20 focus:border-accent text-slate-200 rounded-xl pl-4 pr-10 py-3 text-sm outline-none transition-colors font-mono" - /> - - - -
-
- {/* Zone Assignment */}
@@ -162,35 +202,6 @@ export function AddDevice({ onNavigate }: Props) {
- {/* Resolution */} -
- - -
- - {/* Frame Rate */} -
- - -
- {/* Enable AI Tracking (toggle switch) */}
- Enable AI Tracking + Enable FiremeX AI Tracking
{/* Test Connection Preview Block */}
-
- - - - NO ACTIVE STREAM SELECTED +
+ {selectedEntityId ? ( + HA Camera Preview { + e.target.onerror = null + e.target.style.display = 'none' + }} + /> + ) : ( + <> + + + + NO ACTIVE STREAM SELECTED + + )}
-
{/* Action Buttons */} @@ -235,9 +250,10 @@ export function AddDevice({ onNavigate }: Props) {