|
|
@@ -10,6 +10,9 @@ import (
|
|
|
"github.com/gin-gonic/gin"
|
|
|
"github.com/google/uuid"
|
|
|
"gorm.io/datatypes"
|
|
|
+ "bytes"
|
|
|
+ "io"
|
|
|
+ "strings"
|
|
|
)
|
|
|
|
|
|
// --- Integration Source Controllers ---
|
|
|
@@ -25,6 +28,203 @@ type HAEntity struct {
|
|
|
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,
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return entities, nil
|
|
|
}
|
|
|
|
|
|
func fetchHAEntities(config datatypes.JSON) ([]HAEntity, error) {
|
|
|
@@ -42,11 +242,81 @@ func fetchHAEntities(config datatypes.JSON) ([]HAEntity, error) {
|
|
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
|
url := haConfig.URL
|
|
|
- if url[len(url)-1] == '/' {
|
|
|
- url = url[:len(url)-1]
|
|
|
+ // 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,
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return entities, nil
|
|
|
+ }
|
|
|
+ // If decode failed, fallthrough to legacy method
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- req, err := http.NewRequest("GET", url+"/api/states", nil)
|
|
|
+ // 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)
|
|
|
}
|
|
|
@@ -87,9 +357,8 @@ func testHAConnection(config datatypes.JSON) (bool, string) {
|
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
|
// Removing trailing slash if present to avoid double slash
|
|
|
url := haConfig.URL
|
|
|
- if url[len(url)-1] == '/' {
|
|
|
- url = url[:len(url)-1]
|
|
|
- }
|
|
|
+ url = strings.TrimSuffix(url, "/")
|
|
|
+ url = strings.TrimSuffix(url, "/api")
|
|
|
|
|
|
req, err := http.NewRequest("GET", url+"/api/", nil)
|
|
|
if err != nil {
|
|
|
@@ -204,7 +473,57 @@ func SyncSource(c *gin.Context) {
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Sync started for source " + id})
|
|
|
}
|
|
|
|
|
|
-// GetSourceCandidates 获取数据源候选设备列表
|
|
|
+// 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
|