ソースを参照

获取设备成功

liuq 3 ヶ月 前
コミット
12bd83d359

+ 326 - 7
backend/controllers/resource_controller.go

@@ -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

+ 3 - 1
backend/routes/routes.go

@@ -25,7 +25,9 @@ func SetupRoutes(r *gin.Engine) {
 		api.PUT("/sources/:id", controllers.UpdateSource)
 		api.DELETE("/sources/:id", controllers.DeleteSource)
 		api.POST("/sources/test", controllers.TestSourceConnection) // 测试连接
-		api.GET("/sources/:id/candidates", controllers.GetSourceCandidates) // 获取候选设备
+		api.GET("/sources/:id/candidates", controllers.GetSourceCandidates) // 获取候选设备 (Legacy)
+		api.GET("/sources/:id/devices", controllers.GetSourceDevices) // 获取HA设备列表
+		api.GET("/sources/:id/devices/:deviceId/entities", controllers.GetSourceDeviceEntities) // 获取HA设备实体
 		api.POST("/sources/:id/sync", controllers.SyncSource)       // 立即同步
 
 		// Devices

+ 305 - 0
documents/系统运维方案.pdf

@@ -0,0 +1,305 @@
+Docker 容器化 的企业级能耗系统运维
+本方案重点解决你最关心的三个核心问题:
+ 1. 数据安全性: 容器删了数据还在。
+ 2. 版本迭代: 数据库加字段怎么自动生效,不需人工介入。
+ 3. 灾难恢复: 如何自动备份,以及炸机后如何恢复。
+
+一、 运维目录结构设计 (Standard Directory Layout)
+
+首先,规范服务器上的目录结构。所有数据和配置必须挂载到宿主机,严禁散落在容器内部。
+
+/opt/ems-platform
+
+├── docker-compose.yml         # 核心编排文件
+
+├── .env                       # 环境变量(密码、密钥)
+
+├── /configs                   # 配置文件挂载
+
+│ ├── nginx/conf.d/default.conf
+
+│ └── emqx/emqx.conf
+
+├── /data                      # 【核心】数据持久化目录 (自动生成)
+
+│ ├── postgres/                # PG 数据
+
+│ ├── taos/                    # TDengine 数据
+
+│ └── redis/                   # Redis 数据
+
+├── /backups                   # 【核心】自动备份落地目录
+
+│ ├── postgres/
+
+│ └── tdengine/
+
+├── /db-migrations             # 数据库升级脚本 (SQL文件)
+
+│ ├── 000001_init.up.sql
+
+│ └── 000002_add_field.up.sql
+
+└── /scripts                   # 运维脚本
+
+└── backup_tdengine.sh
+
+二、 数据库变更方案 (Schema Migration)
+
+场景: 业务迭代,需要在 表新增字段 。 device
+                                        last_maintenance_date
+原则: 禁止进入数据库手动执行 ALTER TABLE ,必须代码化管理。
+1. 选型
+
+使用 Golang-Migrate。它能保证数据库结构的版本与代码版本严格一致。
+
+2. 工作流
+
+开发阶段: 开发者在代码库生成 。 1.
+                    000002_add_field.up.sql
+2. 部署阶段: Go 服务启动时,会自动检测并执行这个 。 SQL
+
+代码集成 3.  (main.go)
+
+在你的 Go 后端入口加入自动迁移逻辑:
+
+import (
+      "github.com/golang-migrate/migrate/v4"
+      _ "github.com/golang-migrate/migrate/v4/database/postgres"
+      _ "github.com/golang-migrate/migrate/v4/source/file"
+
+)
+
+func initSchema(dbUrl string) {
+      // 指向容器内的 SQL 文件目录
+      m, err := migrate.New("file:///app/migrations", dbUrl)
+      if err != nil {
+             log.Fatal("迁移初始化失败:", err)
+      }
+      // 自动执行 Up 操作,追平最新版本
+      if err := m.Up(); err != nil && err != migrate.ErrNoChange {
+             log.Fatal("数据库升级失败:", err)
+      }
+      log.Println("数据库结构已升级到最新版本")
+
+}
+
+调整 4. Dockerfile
+
+确保 SQL 文件被打入镜像:
+
+  # ... build stage ...
+  COPY ./db/migrations /app/migrations
+  CMD ["./ems-server"]
+三、 自动化备份方案 (Auto Backup)
+
+自动备份 模式 1. PostgreSQL
+                       (Sidecar )
+
+在 docker-compose.yml 中增加一个专用容器,它是 PG 的“僚机”,负责每天半夜把数据打包出来。
+
+# 数据库服务
+postgres:
+
+   image: postgres:15
+   volumes:
+
+      - ./data/postgres:/var/lib/postgresql/data # 数据持久化
+   # ...
+
+# 备份服务 (Sidecar)
+
+pg-backup:
+
+image: prodrigestivill/postgres-backup-local
+
+restart: always
+
+environment:
+
+- POSTGRES_HOST=postgres
+
+- POSTGRES_DB=ems_db
+
+- POSTGRES_USER=ems_user
+
+- POSTGRES_PASSWORD=${DB_PASSWORD}
+
+- SCHEDULE=@daily         # 每天 00:00 备份
+- BACKUP_KEEP_DAYS=7      # 只保留最近 7 天
+
+- BACKUP_DIR=/backups
+
+volumes:
+
+- ./backups/postgres:/backups # 映射到宿主机
+
+depends_on:
+
+- postgres
+
+自动备份 模式 2. TDengine
+                  (Host Script )
+
+由于 TDengine 数据量极大,且官方暂无 Sidecar 镜像,建议使用宿主机 Crontab 调度。
+
+脚本: **  /opt/ems-platform/scripts/backup_td.sh**
+  #!/bin/bash
+  # 设置变量
+  CONTAINER_NAME="ems-tdengine"
+  BACKUP_ROOT="/opt/ems-platform/backups/tdengine"
+  DATE=$(date +%Y%m%d_%H%M)
+  TARGET_DIR="$BACKUP_ROOT/$DATE"
+
+  mkdir -p $TARGET_DIR
+
+  # 1. 调用容器内的 taosdump 工具导出元数据和数据
+  echo "Starting TDengine backup..."
+  docker exec $CONTAINER_NAME taosdump -o /tmp/dump_out -D power_db
+
+  # 2. 将备份文件从容器复制到宿主机
+  docker cp $CONTAINER_NAME:/tmp/dump_out/. $TARGET_DIR/
+
+  # 3. 清理容器内临时文件
+  docker exec $CONTAINER_NAME rm -rf /tmp/dump_out
+
+  # 4. 压缩 (时序数据通常很大,建议压缩)
+  cd $BACKUP_ROOT
+  tar -czf $DATE.tar.gz $DATE
+  rm -rf $DATE
+
+  # 5. 清理超过 30 天的备份
+  find $BACKUP_ROOT -name "*.tar.gz" -mtime +30 -delete
+
+  echo "Backup completed: $TARGET_DIR"
+
+加入宿主机 : Crontab
+
+  # 每天凌晨 2 点执行
+  0 2 * * * /bin/bash /opt/ems-platform/scripts/backup_td.sh >> /var/log/ems_backup.log 2>&1
+
+四、 灾难恢复演练 (Disaster Recovery)
+
+假设服务器硬盘损坏,或者有人误删库,如何通过备份文件恢复?
+恢复 1.  PostgreSQL
+
+# 1. 停止后端服务,防止写入
+docker stop ems-server
+
+# 2. 获取备份文件 (例如昨天的备份)
+BACKUP_FILE="./backups/postgres/ems_db-20231027.sql.gz"
+
+# 3. 强行恢复 (Drop 现有库并重建)
+gunzip -c $BACKUP_FILE | docker exec -i ems-postgres psql -U ems_user -d ems_db
+
+恢复 2.  TDengine
+
+# 1. 解压备份
+tar -xzf 20231027_0200.tar.gz
+cd 20231027_0200
+
+# 2. 拷贝进容器
+docker cp . ems-tdengine:/tmp/restore_data
+
+# 3. 执行恢复
+docker exec ems-tdengine taosdump -i /tmp/restore_data
+
+五、 完整的 Docker Compose 配置
+
+这是集成了上述所有理念的最终配置。
+version: '3.8'
+
+services:
+   # ----------------------------------------
+   # 业务服务
+   # ----------------------------------------
+   app-server:
+      image: my-ems/server:latest
+      restart: always
+      env_file: .env
+      volumes:
+         - ./configs/app.yaml:/app/config.yaml
+         - ./logs:/app/logs
+      depends_on:
+         - postgres
+         - tdengine
+         - redis
+
+   nginx:
+      image: nginx:alpine
+      ports: ["80:80", "443:443"]
+      volumes:
+         - ./configs/nginx:/etc/nginx/conf.d
+         - ./html:/usr/share/nginx/html
+         - ./certs:/etc/nginx/certs
+
+   # ----------------------------------------
+   # 数据服务 (带持久化)
+   # ----------------------------------------
+   postgres:
+
+      image: postgres:15
+      container_name: ems-postgres
+      restart: always
+      environment:
+
+         POSTGRES_DB: ems_db
+         POSTGRES_USER: ems_user
+         POSTGRES_PASSWORD: ${DB_PASSWORD}
+      volumes:
+         - ./data/postgres:/var/lib/postgresql/data # 数据持久化
+         - /etc/localtime:/etc/localtime:ro
+
+   tdengine:
+      image: tdengine/tdengine:3.0
+         container_name: ems-tdengine
+         ports: ["6030:6030"]
+         volumes:
+
+            - ./data/taos/data:/var/lib/taos # 数据持久化
+            - ./data/taos/log:/var/log/taos
+            - /etc/localtime:/etc/localtime:ro
+
+      redis:
+         image: redis:7
+         volumes:
+            - ./data/redis:/data # 数据持久化
+
+      # ----------------------------------------
+      # 运维服务 (Sidecar)
+      # ----------------------------------------
+      pg-backup:
+
+         image: prodrigestivill/postgres-backup-local
+         restart: always
+         environment:
+
+            - POSTGRES_HOST=postgres
+            - POSTGRES_DB=ems_db
+            - POSTGRES_USER=ems_user
+            - POSTGRES_PASSWORD=${DB_PASSWORD}
+            - SCHEDULE=@daily
+            - BACKUP_KEEP_DAYS=7
+            - BACKUP_DIR=/backups
+         volumes:
+            - ./backups/postgres:/backups
+         depends_on:
+            - postgres
+
+六、 日常运维命令手册 (Cheat Sheet)
+
+ 1. 发布新版本(含数据库变更):
+
+  # 这一步会自动拉取新镜像,重建容器,启动时 Go 程序会自动执行 SQL 迁移
+  docker-compose up -d --build app-server
+
+ 2. 查看实时日志:
+docker-compose logs -f --tail=100 app-server
+
+3. 检查数据库状态:
+
+# 进 PG
+docker exec -it ems-postgres psql -U ems_user -d ems_db
+# 进 TDengine
+docker exec -it ems-tdengine taos
+

+ 10 - 0
frontend/src/api/resource.ts

@@ -16,6 +16,8 @@ export interface HAEntity {
   attributes: any;
   last_changed: string;
   last_updated: string;
+  device_id?: string;
+  device_name?: string;
 }
 
 export interface Device {
@@ -70,6 +72,14 @@ export const getSourceCandidates = (id: string) => {
   return api.get<any, HAEntity[]>(`/sources/${id}/candidates`);
 };
 
+export const getSourceDevices = (id: string) => {
+  return api.get<any, {id: string, name: string}[]>(`/sources/${id}/devices`);
+};
+
+export const getSourceDeviceEntities = (id: string, deviceId: string) => {
+  return api.get<any, HAEntity[]>(`/sources/${id}/devices/${deviceId}/entities`);
+};
+
 // --- Device APIs ---
 
 export const getDevices = (params?: any) => {

+ 116 - 139
frontend/src/views/resource/DataSource.vue

@@ -45,73 +45,56 @@
 
     <!-- Import Dialog -->
     <a-modal v-model:visible="importVisible" title="从 Home Assistant 导入设备" width="900px" :footer="false">
-      <a-tabs default-active-key="simple">
-        <a-tab-pane key="simple" title="简单导入 (一对一)">
-          <div style="margin-bottom: 16px; display: flex; justify-content: flex-end;">
-            <a-button type="primary" @click="handleSimpleImport" :loading="importLoading">确认导入选定项</a-button>
-          </div>
-          <a-table :data="candidateData" :loading="candidateLoading" row-key="entity_id" v-model:selected-keys="selectedCandidateKeys" :row-selection="{ type: 'checkbox', showCheckedAll: true }" :pagination="{ pageSize: 10 }" :scroll="{ y: 400 }">
-            <template #columns>
-              <a-table-column title="Entity ID" data-index="entity_id" :width="250" />
-              <a-table-column title="名称" data-index="attributes.friendly_name">
-                <template #cell="{ record }">
-                    {{ record.attributes?.friendly_name || '-' }}
-                </template>
-              </a-table-column>
-              <a-table-column title="状态" data-index="state" :width="100" />
-              <a-table-column title="推断类型" :width="120">
-                <template #cell="{ record }">
-                    <a-tag>{{ getDeviceType(record.entity_id) }}</a-tag>
-                </template>
-              </a-table-column>
-            </template>
-          </a-table>
-        </a-tab-pane>
+      <a-alert style="margin-bottom: 20px">将多个 Home Assistant 实体组合为一个 EMS 设备</a-alert>
+      
+      <a-form :model="compositeForm" layout="vertical">
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <a-form-item label="设备名称" required>
+              <a-input v-model="compositeForm.Name" placeholder="例如:客厅空调" />
+            </a-form-item>
+          </a-col>
+          <a-col :span="12">
+             <a-form-item label="设备类型" required>
+                <a-select v-model="compositeForm.DeviceType" @change="handleCompositeTypeChange">
+                  <a-option value="ELEC">电力设备 (电)</a-option>
+                  <a-option value="WATER">水务设备 (水)</a-option>
+                  <a-option value="GAS">燃气设备 (气)</a-option>
+                </a-select>
+             </a-form-item>
+          </a-col>
+        </a-row>
+
+        <a-divider orientation="left">实体关联</a-divider>
+
+        <a-form-item label="快速筛选 (关联主实体)">
+             <a-select v-model="compositeForm.BaseEntity" allow-search placeholder="请选择 HA 设备 (先选设备可自动筛选关联实体)" @change="handleDeviceChange" allow-clear>
+                <a-option v-for="dev in haDevices" :key="dev.id" :value="dev.id">
+                    {{ dev.name }}
+                </a-option>
+             </a-select>
+             <template #help>选择设备后,下方属性列表将自动筛选出该设备下的所有实体</template>
+        </a-form-item>
         
-        <a-tab-pane key="composite" title="组合导入 (多合一)">
-          <a-alert style="margin-bottom: 20px">将多个 Home Assistant 实体组合为一个 EMS 设备 (例如:将开关实体、电压传感器、功率传感器组合为一个“智能电表”)</a-alert>
-          
-          <a-form :model="compositeForm" layout="vertical">
-            <a-row :gutter="16">
-              <a-col :span="12">
-                <a-form-item label="设备名称" required>
-                  <a-input v-model="compositeForm.Name" placeholder="例如:客厅空调" />
-                </a-form-item>
-              </a-col>
-              <a-col :span="12">
-                 <a-form-item label="设备类型" required>
-                    <a-select v-model="compositeForm.DeviceType" @change="handleCompositeTypeChange">
-                      <a-option value="ELEC">电力设备 (电)</a-option>
-                      <a-option value="WATER">水务设备 (水)</a-option>
-                      <a-option value="GAS">燃气设备 (气)</a-option>
-                    </a-select>
-                 </a-form-item>
-              </a-col>
-            </a-row>
-
-            <a-divider orientation="left">属性映射</a-divider>
-            
-            <template v-if="compositeForm.DeviceType">
-               <div v-for="(label, key) in currentTemplate" :key="key" style="margin-bottom: 16px">
-                  <a-form-item :label="label">
-                    <a-select v-model="compositeForm.AttributeMapping[key]" allow-search placeholder="选择关联的 HA 实体">
-                       <a-option v-for="item in candidateData" :key="item.entity_id" :value="item.entity_id">
-                          {{ item.entity_id }} ({{ item.attributes?.friendly_name || item.state }})
-                       </a-option>
-                    </a-select>
-                  </a-form-item>
-               </div>
-            </template>
-            <div v-else style="text-align: center; color: #999; padding: 20px">
-              请先选择设备类型
-            </div>
-
-            <div style="margin-top: 20px; display: flex; justify-content: flex-end">
-               <a-button type="primary" @click="handleCompositeImport" :loading="importLoading">创建组合设备</a-button>
-            </div>
-          </a-form>
-        </a-tab-pane>
-      </a-tabs>
+        <template v-if="compositeForm.DeviceType">
+           <div v-for="(label, key) in currentTemplate" :key="key" style="margin-bottom: 16px">
+              <a-form-item :label="label">
+                <a-select v-model="compositeForm.AttributeMapping[key]" allow-search placeholder="选择关联的 HA 实体" allow-clear>
+                   <a-option v-for="item in getFilteredCandidates()" :key="item.entity_id" :value="item.entity_id">
+                      {{ item.entity_id }} ({{ item.attributes?.friendly_name || item.state }})
+                   </a-option>
+                </a-select>
+              </a-form-item>
+           </div>
+        </template>
+        <div v-else style="text-align: center; color: #999; padding: 20px">
+          请先选择设备类型
+        </div>
+
+        <div style="margin-top: 20px; display: flex; justify-content: flex-end">
+           <a-button type="primary" @click="handleCompositeImport" :loading="importLoading">创建组合设备</a-button>
+        </div>
+      </a-form>
     </a-modal>
 
     <!-- Dialog -->
@@ -180,7 +163,7 @@
 import { ref, reactive, onMounted } from 'vue';
 import { Message, Modal } from '@arco-design/web-vue';
 import { IconPlus } from '@arco-design/web-vue/es/icon';
-import { getSources, createSource, updateSource, deleteSource, testSourceConnection, syncSource, getSourceCandidates, createDevice } from '@/api/resource';
+import { getSources, createSource, updateSource, deleteSource, testSourceConnection, syncSource, getSourceDevices, getSourceDeviceEntities, createDevice } from '@/api/resource';
 import type { IntegrationSource, HAEntity } from '@/api/resource';
 
 // --- State ---
@@ -204,11 +187,18 @@ const currentImportSourceId = ref('');
 const compositeForm = reactive({
   Name: '',
   DeviceType: '',
+  BaseEntity: '',
   AttributeMapping: {} as Record<string, string>
 });
 
 const currentTemplate = ref<Record<string, string>>({});
 
+// Device list cache
+const haDevices = ref<{id: string, name: string}[]>([]);
+
+// Filtered candidates cache or computed logic
+const filterKeyword = ref('');
+
 const templates: Record<string, Record<string, string>> = {
   'ELEC': {
     'switch': '开关控制 (Switch)',
@@ -377,80 +367,64 @@ const openImportDialog = async (row: IntegrationSource) => {
   candidateLoading.value = true;
   candidateData.value = [];
   selectedCandidateKeys.value = [];
+  haDevices.value = [];
   
   try {
-    const res = await getSourceCandidates(row.ID);
-    // Adjust based on API response structure
-    candidateData.value = (res as any).data || res;
-  } catch (error) {
-    Message.error('获取设备列表失败');
+    const res = await getSourceDevices(row.ID);
+    const devices = (res as any).data || res;
+    
+    haDevices.value = devices.map((d: any) => ({
+        id: d.id,
+        name: `${d.name} (${d.id})`
+    })).sort((a: any, b: any) => a.name.localeCompare(b.name));
+
+  } catch (error: any) {
+    Message.error(error.response?.data?.error || '获取设备列表失败');
   } finally {
     candidateLoading.value = false;
   }
 };
 
-const getDeviceType = (entityId: string) => {
-  if (!entityId) return 'OTHER';
-  const domain = entityId.split('.')[0];
-  const map: Record<string, string> = {
-    'light': 'LIGHT',
-    'switch': 'SWITCH',
-    'sensor': 'SENSOR',
-    'climate': 'CLIMATE',
-    'fan': 'FAN',
-    'lock': 'LOCK',
-    'cover': 'COVER',
-    'binary_sensor': 'SENSOR'
-  };
-  return map[domain] || 'OTHER';
+const handleCompositeTypeChange = (val: any) => {
+  const typeStr = val as string;
+  currentTemplate.value = templates[typeStr] || {};
+  compositeForm.AttributeMapping = {};
 };
 
-const handleSimpleImport = async () => {
-  if (selectedCandidateKeys.value.length === 0) {
-    Message.warning('请选择至少一个设备');
+const handleDeviceChange = async (val: any) => {
+  if (!val) {
+    filterKeyword.value = '';
+    candidateData.value = [];
+    compositeForm.Name = '';
     return;
   }
   
-  importLoading.value = true;
-  let successCount = 0;
-  let failCount = 0;
-
-  for (const entityId of selectedCandidateKeys.value) {
-    const entity = candidateData.value.find(e => e.entity_id === entityId);
-    if (!entity) continue;
-
-    try {
-      await createDevice({
-        SourceID: currentImportSourceId.value,
-        ExternalID: entity.entity_id,
-        Name: entity.attributes?.friendly_name || entity.entity_id,
-        OriginalName: entity.attributes?.friendly_name || entity.entity_id,
-        DeviceType: getDeviceType(entity.entity_id),
-        Status: 'NORMAL',
-        MeteringMode: 'VIRTUAL',
-        RatedPower: 0,
-        AttributeMapping: {}
-      });
-      successCount++;
-    } catch (error) {
-      failCount++;
-    }
-  }
-
-  if (failCount > 0) {
-     Message.warning(`导入完成: 成功 ${successCount}, 失败 ${failCount}`);
-  } else {
-     Message.success(`成功导入 ${successCount} 个设备`);
+  // val is device_id
+  const device = haDevices.value.find((d: any) => d.id === val);
+  if (device) {
+      // Set name from device name (remove the id suffix)
+      const name = device.name.replace(` (${val})`, '');
+      if (!compositeForm.Name) {
+          compositeForm.Name = name;
+      }
+      
+      // Load entities for this device
+      candidateLoading.value = true;
+      try {
+        const res = await getSourceDeviceEntities(currentImportSourceId.value, val);
+        candidateData.value = (res as any).data || res;
+      } catch (error: any) {
+        Message.error(error.response?.data?.error || '加载设备实体失败');
+        candidateData.value = [];
+      } finally {
+        candidateLoading.value = false;
+      }
   }
-  
-  importLoading.value = false;
-  importVisible.value = false;
 };
 
-const handleCompositeTypeChange = (val: any) => {
-  const typeStr = val as string;
-  currentTemplate.value = templates[typeStr] || {};
-  compositeForm.AttributeMapping = {};
+const getFilteredCandidates = () => {
+  // Since we load entities only for the selected device, just return them.
+  return candidateData.value;
 };
 
 const handleCompositeImport = async () => {
@@ -459,27 +433,28 @@ const handleCompositeImport = async () => {
     return;
   }
   
-  // Verify at least one attribute is mapped? Or optional.
-  const hasMapping = Object.keys(compositeForm.AttributeMapping).some(k => !!compositeForm.AttributeMapping[k]);
-  if (!hasMapping) {
-      Message.warning('请至少关联一个属性');
-      return;
-  }
-
+  // Relaxed validation: Allow creating device even if no attributes are mapped, 
+  // as long as user is aware (or maybe just silently allow as per user request "can be not bound").
+  
   importLoading.value = true;
   try {
+      // Determine ExternalID
+      let externalID = '';
+      const mappedValues = Object.values(compositeForm.AttributeMapping).filter(v => !!v);
+      if (mappedValues.length > 0) {
+          externalID = mappedValues[0];
+      } else {
+          externalID = `virtual-${Date.now()}`;
+      }
+
       await createDevice({
         SourceID: currentImportSourceId.value,
-        // For composite, ExternalID might be the 'main' switch or just a UUID. 
-        // Let's use the first mapped entity as ExternalID reference or generate one.
-        // Or simply leave ExternalID empty if it's purely virtual composition.
-        // Let's use the 'switch' or 'consumption' entity as the primary ID if available.
-        ExternalID: Object.values(compositeForm.AttributeMapping)[0], 
+        ExternalID: externalID, 
         Name: compositeForm.Name,
         OriginalName: compositeForm.Name,
         DeviceType: compositeForm.DeviceType,
         Status: 'NORMAL',
-        MeteringMode: 'VIRTUAL', // Or REAL if we have a meter
+        MeteringMode: 'VIRTUAL', 
         RatedPower: 0,
         AttributeMapping: compositeForm.AttributeMapping
       });
@@ -488,7 +463,9 @@ const handleCompositeImport = async () => {
       // Reset form
       compositeForm.Name = '';
       compositeForm.DeviceType = '';
+      compositeForm.BaseEntity = '';
       compositeForm.AttributeMapping = {};
+      filterKeyword.value = '';
   } catch (error) {
       Message.error('创建失败');
   } finally {