Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,9 @@ backend/backend
# Go coverage output
*.out

# Environment variables
.env
*.env

# Dependency directory (if you ever vendor dependencies)
vendor/
130 changes: 130 additions & 0 deletions CAMERA_SETUP.md
Original file line number Diff line number Diff line change
@@ -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 '<h2>Webcam stream running at <a href="/stream">/stream</a></h2>'

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 `<img src="..." />` without any CORS issues.
204 changes: 204 additions & 0 deletions backend/controllers/camera.go
Original file line number Diff line number Diff line change
@@ -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
})
}
1 change: 1 addition & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading
Loading