package controllers import ( "fmt" "ems-backend/db" "ems-backend/models" "encoding/json" "net/http" "sort" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/datatypes" "bytes" "io" "strings" ) // --- Integration Source Controllers --- type HAConfig struct { URL string `json:"url"` Token string `json:"token"` } type HAEntity struct { EntityID string `json:"entity_id"` State string `json:"state"` Attributes map[string]interface{} `json:"attributes"` LastChanged time.Time `json:"last_changed"` LastUpdated time.Time `json:"last_updated"` DeviceID string `json:"device_id"` // Augmented field DeviceName string `json:"device_name"` // Augmented field } // HA Template Request type HATemplateReq struct { Template string `json:"template"` } // Struct for template result parsing type HATemplateResult struct { ID string `json:"id"` State string `json:"s"` Name string `json:"n"` DID string `json:"did"` DName string `json:"dn"` } // HADevice represents a Home Assistant Device type HADevice struct { ID string `json:"id"` Name string `json:"name"` Model string `json:"model"` Manufacturer string `json:"manufacturer"` } func fetchHADevices(config datatypes.JSON) ([]HADevice, error) { var haConfig HAConfig b, err := config.MarshalJSON() if err != nil { return nil, fmt.Errorf("config error: %v", err) } if err := json.Unmarshal(b, &haConfig); err != nil { return nil, fmt.Errorf("invalid configuration format: %v", err) } if haConfig.URL == "" || haConfig.Token == "" { return nil, fmt.Errorf("URL and Token are required") } client := &http.Client{Timeout: 10 * time.Second} url := haConfig.URL // Robust URL handling: remove trailing slash and /api suffix url = strings.TrimSuffix(url, "/") url = strings.TrimSuffix(url, "/api") // Use Template API to get devices efficiently // Simplified template avoiding list.append due to sandbox restrictions template := ` {% set ns = namespace(result=[], devs=[]) %} {% for state in states %} {% set d = device_id(state.entity_id) %} {% if d and d not in ns.devs %} {% set ns.devs = ns.devs + [d] %} {% set name = device_attr(d, 'name_by_user') %} {% if not name %} {% set name = device_attr(d, 'name') %} {% endif %} {% if not name %} {% set name = 'Unknown' %} {% endif %} {% set entry = { "id": d, "name": name, "model": device_attr(d, 'model') or "", "manufacturer": device_attr(d, 'manufacturer') or "" } %} {% set ns.result = ns.result + [entry] %} {% endif %} {% endfor %} {{ ns.result | to_json }} ` reqBody, _ := json.Marshal(HATemplateReq{Template: template}) req, err := http.NewRequest("POST", url+"/api/template", bytes.NewBuffer(reqBody)) if err != nil { return nil, fmt.Errorf("failed to create request: %v", err) } req.Header.Set("Authorization", "Bearer "+haConfig.Token) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("connection failed: %v", err) } defer resp.Body.Close() // Read body for better error reporting bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %v", err) } if resp.StatusCode != 200 { fmt.Printf("DEBUG: HA Status Error: %s, Body: %s\n", resp.Status, string(bodyBytes)) return nil, fmt.Errorf("Home Assistant returned status: %s. Body: %s", resp.Status, string(bodyBytes)) } var devices []HADevice if err := json.Unmarshal(bodyBytes, &devices); err != nil { // Try to see if it's because empty result or format fmt.Printf("DEBUG: Failed to decode HA response: %s\nError: %v\n", string(bodyBytes), err) return nil, fmt.Errorf("failed to decode response: %v. Body: %s", err, string(bodyBytes)) } fmt.Printf("DEBUG: Successfully fetched %d devices\n", len(devices)) return devices, nil } func fetchHAEntitiesByDevice(config datatypes.JSON, deviceID string) ([]HAEntity, error) { var haConfig HAConfig b, err := config.MarshalJSON() if err != nil { return nil, fmt.Errorf("config error: %v", err) } if err := json.Unmarshal(b, &haConfig); err != nil { return nil, fmt.Errorf("invalid configuration format: %v", err) } if haConfig.URL == "" || haConfig.Token == "" { return nil, fmt.Errorf("URL and Token are required") } client := &http.Client{Timeout: 10 * time.Second} url := haConfig.URL // Robust URL handling url = strings.TrimSuffix(url, "/") url = strings.TrimSuffix(url, "/api") // Template to fetch entities for a specific device // Using strings.Replace to avoid fmt.Sprintf interpreting Jinja2 tags {% as format specifiers rawTemplate := ` {% set ns = namespace(result=[]) %} {% set device_entities = device_entities('__DEVICE_ID__') %} {% for entity_id in device_entities %} {% set state = states[entity_id] %} {% if state %} {% set name = state.attributes.friendly_name %} {% if name is not defined or name is none %} {% set name = entity_id %} {% endif %} {% set entry = { "id": entity_id, "s": state.state, "n": name, "did": '__DEVICE_ID__', "dn": '' } %} {% set ns.result = ns.result + [entry] %} {% endif %} {% endfor %} {{ ns.result | to_json }} ` template := strings.ReplaceAll(rawTemplate, "__DEVICE_ID__", deviceID) reqBody, _ := json.Marshal(HATemplateReq{Template: template}) req, err := http.NewRequest("POST", url+"/api/template", bytes.NewBuffer(reqBody)) if err != nil { return nil, fmt.Errorf("failed to create request: %v", err) } req.Header.Set("Authorization", "Bearer "+haConfig.Token) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("connection failed: %v", err) } defer resp.Body.Close() // Read body for better error reporting bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %v", err) } if resp.StatusCode != 200 { fmt.Printf("DEBUG: HA Status Error (Entities): %s, Body: %s\n", resp.Status, string(bodyBytes)) return nil, fmt.Errorf("Home Assistant returned status: %s. Body: %s", resp.Status, string(bodyBytes)) } var tmplResults []HATemplateResult if err := json.Unmarshal(bodyBytes, &tmplResults); err != nil { fmt.Printf("DEBUG: Failed to decode HA response (Entities): %s\nError: %v\n", string(bodyBytes), err) return nil, fmt.Errorf("failed to decode response: %v. Body: %s", err, string(bodyBytes)) } fmt.Printf("DEBUG: Successfully fetched %d entities for device %s\n", len(tmplResults), deviceID) entities := make([]HAEntity, len(tmplResults)) for i, r := range tmplResults { entities[i] = HAEntity{ EntityID: r.ID, State: r.State, Attributes: map[string]interface{}{"friendly_name": r.Name}, DeviceID: r.DID, DeviceName: r.DName, } } // Sort by friendly_name sort.Slice(entities, func(i, j int) bool { nameI, okI := entities[i].Attributes["friendly_name"].(string) nameJ, okJ := entities[j].Attributes["friendly_name"].(string) if !okI { nameI = entities[i].EntityID } if !okJ { nameJ = entities[j].EntityID } return nameI < nameJ }) return entities, nil } func fetchHAEntities(config datatypes.JSON) ([]HAEntity, error) { var haConfig HAConfig b, err := config.MarshalJSON() if err != nil { return nil, fmt.Errorf("config error: %v", err) } if err := json.Unmarshal(b, &haConfig); err != nil { return nil, fmt.Errorf("invalid configuration format: %v", err) } if haConfig.URL == "" || haConfig.Token == "" { return nil, fmt.Errorf("URL and Token are required") } client := &http.Client{Timeout: 10 * time.Second} url := haConfig.URL // Robust URL handling url = strings.TrimSuffix(url, "/") url = strings.TrimSuffix(url, "/api") // Try Template API first to get device info // Using namespace to avoid list.append security restriction template := ` {% set ns = namespace(result=[]) %} {% for state in states %} {% set name = state.attributes.friendly_name %} {% if name is not defined or name is none %} {% set name = state.entity_id %} {% endif %} {% set d = device_id(state.entity_id) %} {% if d %} {% set d_name = device_attr(d, 'name_by_user') or device_attr(d, 'name') or 'Unknown' %} {% set entry = { "id": state.entity_id, "s": state.state, "n": name, "did": d, "dn": d_name } %} {% set ns.result = ns.result + [entry] %} {% else %} {% set entry = { "id": state.entity_id, "s": state.state, "n": name, "did": "", "dn": "" } %} {% set ns.result = ns.result + [entry] %} {% endif %} {% endfor %} {{ ns.result | to_json }} ` // Clean up newlines/spaces for template req? Not strictly needed for JSON but good practice // Actually JSON marshalling handles it. reqBody, _ := json.Marshal(HATemplateReq{Template: template}) req, err := http.NewRequest("POST", url+"/api/template", bytes.NewBuffer(reqBody)) if err == nil { req.Header.Set("Authorization", "Bearer "+haConfig.Token) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err == nil && resp.StatusCode == 200 { defer resp.Body.Close() // Parse template result // HA returns string body which IS the rendered template (JSON) // But careful: sometimes it's plain text. // "to_json" filter ensures it's JSON. var tmplResults []HATemplateResult if err := json.NewDecoder(resp.Body).Decode(&tmplResults); err == nil { // Convert to HAEntity entities := make([]HAEntity, len(tmplResults)) for i, r := range tmplResults { entities[i] = HAEntity{ EntityID: r.ID, State: r.State, Attributes: map[string]interface{}{"friendly_name": r.Name}, // Simplified attributes DeviceID: r.DID, DeviceName: r.DName, } } // Sort by friendly_name sort.Slice(entities, func(i, j int) bool { nameI, okI := entities[i].Attributes["friendly_name"].(string) nameJ, okJ := entities[j].Attributes["friendly_name"].(string) if !okI { nameI = entities[i].EntityID } if !okJ { nameJ = entities[j].EntityID } return nameI < nameJ }) return entities, nil } // If decode failed, fallthrough to legacy method } } // Fallback to /api/states req, err = http.NewRequest("GET", url+"/api/states", nil) if err != nil { return nil, fmt.Errorf("failed to create request: %v", err) } req.Header.Set("Authorization", "Bearer "+haConfig.Token) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("connection failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("Home Assistant returned status: %s", resp.Status) } var entities []HAEntity if err := json.NewDecoder(resp.Body).Decode(&entities); err != nil { return nil, fmt.Errorf("failed to decode response: %v", err) } // Sort by friendly_name sort.Slice(entities, func(i, j int) bool { nameI, okI := entities[i].Attributes["friendly_name"].(string) nameJ, okJ := entities[j].Attributes["friendly_name"].(string) if !okI { nameI = entities[i].EntityID } if !okJ { nameJ = entities[j].EntityID } return nameI < nameJ }) return entities, nil } func testHAConnection(config datatypes.JSON) (bool, string) { var haConfig HAConfig b, err := config.MarshalJSON() if err != nil { return false, "Config error" } if err := json.Unmarshal(b, &haConfig); err != nil { return false, "Invalid configuration format" } if haConfig.URL == "" || haConfig.Token == "" { return false, "URL and Token are required" } client := &http.Client{Timeout: 5 * time.Second} // Removing trailing slash if present to avoid double slash url := haConfig.URL url = strings.TrimSuffix(url, "/") url = strings.TrimSuffix(url, "/api") req, err := http.NewRequest("GET", url+"/api/", nil) if err != nil { return false, "Failed to create request: " + err.Error() } req.Header.Set("Authorization", "Bearer "+haConfig.Token) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { return false, "Connection failed: " + err.Error() } defer resp.Body.Close() if resp.StatusCode == 200 { return true, "Success" } return false, "Home Assistant returned status: " + resp.Status } func GetSources(c *gin.Context) { var sources []models.IntegrationSource if err := models.DB.Find(&sources).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, sources) } func CreateSource(c *gin.Context) { var source models.IntegrationSource if err := c.ShouldBindJSON(&source); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // DEBUG LOG fmt.Printf("DEBUG: CreateSource received: %+v, Status: %s\n", source, source.Status) if err := models.DB.Create(&source).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, source) } func UpdateSource(c *gin.Context) { id := c.Param("id") var source models.IntegrationSource if err := models.DB.First(&source, "id = ?", id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Source not found"}) return } if err := c.ShouldBindJSON(&source); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // DEBUG LOG fmt.Printf("DEBUG: UpdateSource received: %+v, Status: %s\n", source, source.Status) models.DB.Save(&source) c.JSON(http.StatusOK, source) } func DeleteSource(c *gin.Context) { id := c.Param("id") if err := models.DB.Delete(&models.IntegrationSource{}, "id = ?", id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "Source deleted"}) } // TestSourceConnection 测试连接 func TestSourceConnection(c *gin.Context) { var source models.IntegrationSource // 允许直接传参测试,或者传 ID 测试已有 if err := c.ShouldBindJSON(&source); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var success bool var msg string switch source.DriverType { case "HOME_ASSISTANT": success, msg = testHAConnection(source.Config) default: // Mock others for now success = true msg = "Connection simulated (driver not implemented)" } if success { // If the source has an ID, update its status in DB if source.ID != uuid.Nil { models.DB.Model(&models.IntegrationSource{}).Where("id = ?", source.ID).Update("status", "ONLINE") } c.JSON(http.StatusOK, gin.H{"success": true, "message": "Connection successful"}) } else { if source.ID != uuid.Nil { models.DB.Model(&models.IntegrationSource{}).Where("id = ?", source.ID).Update("status", "OFFLINE") } c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": msg}) } } // SyncSource 同步数据 func SyncSource(c *gin.Context) { id := c.Param("id") // TODO: 触发异步任务同步设备 c.JSON(http.StatusOK, gin.H{"message": "Sync started for source " + id}) } // GetSourceDevices 获取设备列表 func GetSourceDevices(c *gin.Context) { id := c.Param("id") var source models.IntegrationSource if err := models.DB.First(&source, "id = ?", id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Source not found"}) return } if source.DriverType != "HOME_ASSISTANT" { c.JSON(http.StatusBadRequest, gin.H{"error": "Only Home Assistant sources are supported"}) return } devices, err := fetchHADevices(source.Config) if err != nil { fmt.Printf("DEBUG: GetSourceDevices error: %v\n", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, devices) } // GetSourceDeviceEntities 获取指定设备的实体列表 func GetSourceDeviceEntities(c *gin.Context) { id := c.Param("id") deviceID := c.Param("deviceId") var source models.IntegrationSource if err := models.DB.First(&source, "id = ?", id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Source not found"}) return } if source.DriverType != "HOME_ASSISTANT" { c.JSON(http.StatusBadRequest, gin.H{"error": "Only Home Assistant sources are supported"}) return } entities, err := fetchHAEntitiesByDevice(source.Config, deviceID) if err != nil { fmt.Printf("DEBUG: GetSourceDeviceEntities error: %v\n", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, entities) } // GetSourceCandidates 获取数据源候选设备列表 (Deprecated or kept for backward compat) func GetSourceCandidates(c *gin.Context) { id := c.Param("id") var source models.IntegrationSource if err := models.DB.First(&source, "id = ?", id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Source not found"}) return } if source.DriverType != "HOME_ASSISTANT" { c.JSON(http.StatusBadRequest, gin.H{"error": "Only Home Assistant sources are supported for candidate fetching currently"}) return } entities, err := fetchHAEntities(source.Config) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, entities) } // --- Device Controllers --- func GetDevices(c *gin.Context) { var devices []models.Device // Support filtering by location_id or source_id if provided locationID := c.Query("location_id") sourceID := c.Query("source_id") query := models.DB if locationID != "" { query = query.Where("location_id = ?", locationID) } if sourceID != "" { query = query.Where("source_id = ?", sourceID) } if err := query.Find(&devices).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, devices) } func CreateDevice(c *gin.Context) { var device models.Device if err := c.ShouldBindJSON(&device); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := models.DB.Create(&device).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, device) } func UpdateDevice(c *gin.Context) { id := c.Param("id") var device models.Device if err := models.DB.First(&device, "id = ?", id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"}) return } if err := c.ShouldBindJSON(&device); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } models.DB.Save(&device) c.JSON(http.StatusOK, device) } func DeleteDevice(c *gin.Context) { id := c.Param("id") if err := models.DB.Delete(&models.Device{}, "id = ?", id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "Device deleted"}) } // --- Location Controllers --- func GetLocations(c *gin.Context) { var locations []models.SysLocation if err := models.DB.Find(&locations).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, locations) } func CreateLocation(c *gin.Context) { var location models.SysLocation if err := c.ShouldBindJSON(&location); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := models.DB.Create(&location).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, location) } func UpdateLocation(c *gin.Context) { id := c.Param("id") var location models.SysLocation if err := models.DB.First(&location, "id = ?", id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Location not found"}) return } if err := c.ShouldBindJSON(&location); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } models.DB.Save(&location) c.JSON(http.StatusOK, location) } func DeleteLocation(c *gin.Context) { id := c.Param("id") if err := models.DB.Delete(&models.SysLocation{}, "id = ?", id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "Location deleted"}) } // GetDeviceHistory fetches historical data for devices func GetDeviceHistory(c *gin.Context) { // Parse Query Params // device_ids (comma separated) // metric (default power) // start, end (timestamps or ISO strings) // interval (e.g. 1m, 1h) deviceIDsStr := c.Query("device_ids") if deviceIDsStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "device_ids is required"}) return } deviceIDs := strings.Split(deviceIDsStr, ",") metric := c.Query("metric") if metric == "" { metric = "power" } startStr := c.Query("start") endStr := c.Query("end") interval := c.Query("interval") // Default time range: last 24h end := time.Now() start := end.Add(-24 * time.Hour) if startStr != "" { if t, err := time.Parse(time.RFC3339, startStr); err == nil { start = t } else if t, err := time.Parse("2006-01-02 15:04:05", startStr); err == nil { start = t } } if endStr != "" { if t, err := time.Parse(time.RFC3339, endStr); err == nil { end = t } else if t, err := time.Parse("2006-01-02 15:04:05", endStr); err == nil { end = t } } data, err := db.GetReadings(deviceIDs, metric, start, end, interval) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, data) }