From c0ca28d73e6237dbaa8fa29105a08bd5e323d387 Mon Sep 17 00:00:00 2001 From: EsanduEpa Date: Mon, 10 Aug 2026 15:40:30 +0530 Subject: [PATCH 1/2] camera setup developed --- .gitignore | 4 + backend/controllers/camera.go | 204 ++++++++++++++++ backend/go.mod | 1 + backend/go.sum | 2 + backend/main.go | 18 +- backend/models/camera.go | 16 ++ frontend/src/pages/admin/AddDevice.tsx | 304 +++++++++++++----------- frontend/src/pages/admin/Livefeed.tsx | 106 +++++++-- homeassistant_config/configuration.yaml | 4 + 9 files changed, 491 insertions(+), 168 deletions(-) create mode 100644 backend/controllers/camera.go create mode 100644 backend/models/camera.go 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/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) {