Преглед изворни кода

@author liuqing
@commit 远程部署网关功能

lq пре 2 месеци
родитељ
комит
a3227fcc8f

+ 47 - 0
cloud/ipolesvr/gwhandler.go

@@ -49,6 +49,8 @@ func (o *GwHandler) SubscribeTopics() {
 	GetMQTTMgr().Subscribe(GetVagueTopic(protocol.DT_GATEWAY, protocol.TP_GW_SYS_ACK), mqtt.AtMostOnce, o.HandlerData)
 	GetMQTTMgr().Subscribe(GetVagueTopic(protocol.DT_GATEWAY, protocol.TP_GW_ITS_ACK), mqtt.AtMostOnce, o.HandlerData)
 	GetMQTTMgr().Subscribe(GetVagueTopic(protocol.DT_GATEWAY, protocol.TP_GW_ONVIFDEV_ACK), mqtt.AtMostOnce, o.HandlerData)
+	GetMQTTMgr().Subscribe(GetVagueTopic(protocol.DT_GATEWAY, protocol.TP_GW_DEPLOY_ACK), mqtt.AtMostOnce, o.HandlerData)
+	GetMQTTMgr().Subscribe(GetVagueTopic(protocol.DT_GATEWAY, protocol.TP_GW_DEPLOY_HEALTH), mqtt.AtMostOnce, o.HandlerData)
 }
 
 func (o *GwHandler) HandlerData(m mqtt.Message) {
@@ -315,6 +317,51 @@ func (o *GwHandler) Handler(args ...interface{}) interface{} {
 					}
 				}
 			}
+		case protocol.TP_GW_DEPLOY_ACK:
+			var ack protocol.Pack_DeployAck
+			if err := ack.DeCode(m.PayloadString()); err != nil {
+				util.GetTagLog().Errorf("sys", "DEPLOY_ACK DeCode失败: %v", err)
+				break
+			}
+			util.GetTagLog().Infof("sys", "DEPLOY_ACK: gid=%s version=%s success=%v", ack.Header.Gid, ack.Data.Version, ack.Data.Success)
+			status := uint8(1)
+			if !ack.Data.Success {
+				status = 2
+			}
+			models.G_db.Model(&models.GatewayDeploy{}).
+				Where("gid = ? AND to_version = ? AND status = 0", ack.Header.Gid, ack.Data.Version).
+				Updates(map[string]interface{}{
+					"status":      status,
+					"error_msg":   ack.Data.Error,
+					"update_time": time.Now(),
+				})
+
+		case protocol.TP_GW_DEPLOY_HEALTH:
+			var health protocol.Pack_HealthCheck
+			if err := health.DeCode(m.PayloadString()); err != nil {
+				util.GetTagLog().Errorf("sys", "DEPLOY_HEALTH DeCode失败: %v", err)
+				break
+			}
+			util.GetTagLog().Infof("sys", "DEPLOY_HEALTH: gid=%s version=%s phase=%d success=%v",
+				health.Header.Gid, health.Data.Version, health.Data.Phase, health.Data.Success)
+
+			resultJSON, _ := json.Marshal(health.Data)
+			updateFields := map[string]interface{}{
+				"update_time": time.Now(),
+			}
+			if health.Data.Phase == 1 {
+				updateFields["phase1_result"] = string(resultJSON)
+				if !health.Data.Success {
+					updateFields["status"] = uint8(3)
+				}
+			} else {
+				updateFields["phase2_result"] = string(resultJSON)
+			}
+
+			models.G_db.Model(&models.GatewayDeploy{}).
+				Where("gid = ? AND to_version = ?", health.Header.Gid, health.Data.Version).
+				Updates(updateFields)
+
 		default:
 			logrus.Warnf("GwHandler.Handler:收到暂不支持的主题:%s", topic)
 		}

+ 226 - 0
cloud/websvr/controllers/cgateway.go

@@ -1,8 +1,12 @@
 package controllers
 
 import (
+	"crypto/md5"
+	"encoding/base64"
 	"fmt"
+	"io"
 	"os"
+	"path/filepath"
 	"strconv"
 	"strings"
 	"time"
@@ -787,3 +791,225 @@ func (o *GatewayController) LogRead() {
 	}
 	o.Response(Success, "成功", content)
 }
+
+// DeployUpload @Title 上传部署文件
+// @Description 上传网关部署文件
+// @router /v1/deploy/upload [post]
+func (o *GatewayController) DeployUpload() {
+	gid := strings.Trim(o.GetString("gid"), " ")
+	tenant := strings.Trim(o.GetString("tenant"), " ")
+
+	file, header, err := o.Ctx.Request.FormFile("file")
+	if err != nil {
+		o.Response(Failure, "读取上传文件失败: "+err.Error(), nil)
+		return
+	}
+	defer file.Close()
+
+	data, err := io.ReadAll(file)
+	if err != nil {
+		o.Response(Failure, "读取文件内容失败: "+err.Error(), nil)
+		return
+	}
+
+	md5Hash := fmt.Sprintf("%x", md5.Sum(data))
+
+	// 保存到临时目录
+	tmpDir := filepath.Join(os.TempDir(), "ipole_deploy")
+	os.MkdirAll(tmpDir, os.ModePerm)
+	tmpFile := filepath.Join(tmpDir, md5Hash)
+	if err := os.WriteFile(tmpFile, data, os.ModePerm); err != nil {
+		o.Response(Failure, "保存文件失败: "+err.Error(), nil)
+		return
+	}
+
+	// 创建部署记录(原始SQL绕过GORM零值问题)
+	now := time.Now()
+	if err := models.G_db.Exec("INSERT INTO t_gateway_deploy (gid, tenant, from_version, to_version, md5, status, phase1_result, phase2_result, error_msg, create_time, update_time) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)", gid, tenant, "", "", md5Hash, "", "", "", now, now).Error; err != nil {
+		o.Response(Failure, "创建部署记录失败: "+err.Error(), nil)
+		return
+	}
+
+	o.Response(Success, "文件上传成功", map[string]interface{}{
+		"md5":      md5Hash,
+		"size":     len(data),
+		"path":     tmpFile,
+		"fileName": header.Filename,
+	})
+}
+
+// DeployPush @Title 下发部署指令
+// @Description 下发部署指令到网关
+// @router /v1/deploy/push [post]
+func (o *GatewayController) DeployPush() {
+	gid := strings.Trim(o.GetString("gid"), " ")
+	tenant := strings.Trim(o.GetString("tenant"), " ")
+	version := strings.Trim(o.GetString("version"), " ")
+	fileName := strings.Trim(o.GetString("fileName"), " ")
+	filePath := strings.Trim(o.GetString("filePath"), " ")
+	md5Hash := strings.Trim(o.GetString("md5"), " ")
+
+	if gid == "" || tenant == "" || filePath == "" {
+		o.Response(Failure, "参数不完整", nil)
+		return
+	}
+	if fileName == "" {
+		fileName = "ipole"
+	}
+	if version == "" {
+		version = fmt.Sprintf("v%s", time.Now().Format("20060102-150405"))
+	}
+
+	data, err := os.ReadFile(filePath)
+	if err != nil {
+		o.Response(Failure, "读取部署文件失败: "+err.Error(), nil)
+		return
+	}
+
+	const chunkSize = 64 * 1024 // 64KB
+	totalChunks := (len(data) + chunkSize - 1) / chunkSize
+
+	// 1. 发送部署指令
+	var cmd protocol.Pack_DeployCmd
+	seq := GetNextUint64()
+	cmdStr, err := cmd.EnCodeCmd(gid, gid, seq, version, fileName, md5Hash, totalChunks, chunkSize)
+	if err != nil {
+		o.Response(Failure, "编码部署指令失败: "+err.Error(), nil)
+		return
+	}
+	topic := GetTopic(tenant, protocol.DT_GATEWAY, gid, protocol.TP_GW_DEPLOY)
+	GetMqttHandler().PublishString(topic, cmdStr, mqtt.AtLeastOnce)
+
+	// 2. 逐片发送
+	for i := 0; i < totalChunks; i++ {
+		start := i * chunkSize
+		end := start + chunkSize
+		if end > len(data) {
+			end = len(data)
+		}
+		chunkData := base64.StdEncoding.EncodeToString(data[start:end])
+
+		seq := GetNextUint64()
+		chunkStr, err := cmd.EnCodeChunk(gid, gid, seq, version, i, chunkData)
+		if err != nil {
+			o.Response(Failure, fmt.Sprintf("编码分片%d失败: %s", i, err.Error()), nil)
+			return
+		}
+		GetMqttHandler().PublishString(topic, chunkStr, mqtt.AtLeastOnce)
+
+		if i%20 == 0 && i > 0 {
+			time.Sleep(50 * time.Millisecond)
+		}
+	}
+
+	models.G_db.Model(&models.GatewayDeploy{}).
+		Where("gid = ? AND status = 0", gid).
+		Updates(map[string]interface{}{
+			"to_version":   version,
+			"from_version": "",
+			"md5":          md5Hash,
+			"update_time":  time.Now(),
+		})
+
+	o.Response(Success, "部署指令已下发", map[string]interface{}{
+		"totalChunks": totalChunks,
+		"chunkSize":   chunkSize,
+		"fileSize":    len(data),
+	})
+}
+
+// DeployStatus @Title 查询部署状态
+// @Description 查询网关部署状态
+// @router /v1/deploy/status [get]
+func (o *GatewayController) DeployStatus() {
+	gid := strings.Trim(o.GetString("gid"), " ")
+
+	var deploy models.GatewayDeploy
+	err := models.G_db.Where("gid = ?", gid).Order("create_time DESC").First(&deploy).Error
+	if err != nil {
+		// record not found 返回空状态,方便前端轮询等待
+		o.Response(Success, "暂无部署记录", map[string]interface{}{
+			"status": -1,
+		})
+		return
+	}
+
+	statusText := map[uint8]string{0: "进行中", 1: "成功", 2: "失败", 3: "已回滚"}
+
+	o.Response(Success, "查询成功", map[string]interface{}{
+		"id":           deploy.ID,
+		"fromVersion":  deploy.FromVersion,
+		"toVersion":    deploy.ToVersion,
+		"status":       deploy.Status,
+		"statusText":   statusText[deploy.Status],
+		"errorMsg":     deploy.ErrorMsg,
+		"phase1Result": deploy.Phase1Result,
+		"phase2Result": deploy.Phase2Result,
+		"createTime":   deploy.CreateTime.Format("2006-01-02 15:04:05"),
+	})
+}
+
+// DeployBatchStatus @Title 批量查询部署状态
+// @Description 一次查询多个网关的最近一次部署状态
+// @router /v1/deploy/batch-status [get]
+func (o *GatewayController) DeployBatchStatus() {
+	tenant := strings.Trim(o.GetString("tenant"), " ")
+	gidsStr := strings.Trim(o.GetString("gids"), " ")
+
+	if tenant == "" {
+		o.Response(Failure, "tenant不能为空", nil)
+		return
+	}
+
+	// 两步查询:先取每个 gid 最新 id,再取完整记录
+	query := models.G_db.Table("t_gateway_deploy").
+		Where("tenant = ?", tenant)
+
+	if gidsStr != "" {
+		gids := strings.Split(gidsStr, ",")
+		for i := range gids {
+			gids[i] = strings.Trim(gids[i], " ")
+		}
+		query = query.Where("gid IN (?)", gids)
+	}
+
+	var maxIds []int64
+	dbResult := query.Select("MAX(id)").Group("gid").Pluck("MAX(id)", &maxIds)
+	if dbResult.Error != nil {
+		o.Response(Failure, "查询部署记录失败: "+dbResult.Error.Error(), nil)
+		return
+	}
+
+	var deploys []models.GatewayDeploy
+	if len(maxIds) > 0 {
+		models.G_db.Where("id IN (?)", maxIds).Find(&deploys)
+	}
+
+	result := make(map[string]interface{})
+	for _, deploy := range deploys {
+		result[deploy.GID] = map[string]interface{}{
+			"id":           deploy.ID,
+			"status":       deploy.Status,
+			"toVersion":    deploy.ToVersion,
+			"phase1Result": deploy.Phase1Result,
+			"phase2Result": deploy.Phase2Result,
+			"errorMsg":     deploy.ErrorMsg,
+			"createTime":   deploy.CreateTime.Format("2006-01-02 15:04:05"),
+		}
+	}
+
+	// 补充 gids 列表中有但无部署记录的网关(value = null)
+	if gidsStr != "" {
+		for _, gid := range strings.Split(gidsStr, ",") {
+			gid = strings.Trim(gid, " ")
+			if gid == "" {
+				continue
+			}
+			if _, ok := result[gid]; !ok {
+				result[gid] = nil
+			}
+		}
+	}
+
+	o.Response(Success, "查询成功", result)
+}

+ 36 - 0
cloud/websvr/routers/commentsRouter_.go

@@ -610,6 +610,42 @@ func init() {
 			Filters:          nil,
 			Params:           nil})
 
+	beego.GlobalControllerRouter["lc/cloud/websvr/controllers:GatewayController"] = append(beego.GlobalControllerRouter["lc/cloud/websvr/controllers:GatewayController"],
+		beego.ControllerComments{
+			Method:           "DeployUpload",
+			Router:           `/v1/deploy/upload`,
+			AllowHTTPMethods: []string{"post"},
+			MethodParams:     param.Make(),
+			Filters:          nil,
+			Params:           nil})
+
+	beego.GlobalControllerRouter["lc/cloud/websvr/controllers:GatewayController"] = append(beego.GlobalControllerRouter["lc/cloud/websvr/controllers:GatewayController"],
+		beego.ControllerComments{
+			Method:           "DeployPush",
+			Router:           `/v1/deploy/push`,
+			AllowHTTPMethods: []string{"post"},
+			MethodParams:     param.Make(),
+			Filters:          nil,
+			Params:           nil})
+
+	beego.GlobalControllerRouter["lc/cloud/websvr/controllers:GatewayController"] = append(beego.GlobalControllerRouter["lc/cloud/websvr/controllers:GatewayController"],
+		beego.ControllerComments{
+			Method:           "DeployStatus",
+			Router:           `/v1/deploy/status`,
+			AllowHTTPMethods: []string{"get"},
+			MethodParams:     param.Make(),
+			Filters:          nil,
+			Params:           nil})
+
+	beego.GlobalControllerRouter["lc/cloud/websvr/controllers:GatewayController"] = append(beego.GlobalControllerRouter["lc/cloud/websvr/controllers:GatewayController"],
+		beego.ControllerComments{
+			Method:           "DeployBatchStatus",
+			Router:           `/v1/deploy/batch-status`,
+			AllowHTTPMethods: []string{"get"},
+			MethodParams:     param.Make(),
+			Filters:          nil,
+			Params:           nil})
+
 	beego.GlobalControllerRouter["lc/cloud/websvr/controllers:IotModelController"] = append(beego.GlobalControllerRouter["lc/cloud/websvr/controllers:IotModelController"],
 		beego.ControllerComments{
 			Method:           "ModelUpload",

+ 23 - 0
common/models/gateway_deploy.go

@@ -0,0 +1,23 @@
+package models
+
+import "time"
+
+// GatewayDeploy 网关部署记录
+type GatewayDeploy struct {
+	ID           int64     `gorm:"column:id;type:bigint(20);primary_key;AUTO_INCREMENT"`
+	GID          string    `gorm:"column:gid;type:varchar(64);NOT NULL;index:idx_gid"`
+	Tenant       string    `gorm:"column:tenant;type:varchar(64);NOT NULL;index:idx_tenant_time"`
+	FromVersion  string    `gorm:"column:from_version;type:varchar(32)"`
+	ToVersion    string    `gorm:"column:to_version;type:varchar(32);NOT NULL"`
+	MD5          string    `gorm:"column:md5;type:varchar(64)"`
+	Status       uint8     `gorm:"column:status;type:tinyint(4);default:0"` // 0进行中 1成功 2失败 3回滚
+	Phase1Result string    `gorm:"column:phase1_result;type:text"`          // Phase 1 健康检测结果 JSON
+	Phase2Result string    `gorm:"column:phase2_result;type:text"`          // Phase 2 健康检测结果 JSON
+	ErrorMsg     string    `gorm:"column:error_msg;type:text"`              // 失败原因
+	CreateTime   time.Time `gorm:"column:create_time;type:datetime"`
+	UpdateTime   time.Time `gorm:"column:update_time;type:datetime"`
+}
+
+func (GatewayDeploy) TableName() string {
+	return "t_gateway_deploy"
+}

+ 3 - 0
common/models/init.go

@@ -188,6 +188,9 @@ func CreateTable() {
 	if !G_db.HasTable(&CableGuardianStatus{}) {
 		G_db.Set("gorm:table_options", "ENGINE=InnoDB").CreateTable(&CableGuardianStatus{})
 	}
+	if !G_db.HasTable(&GatewayDeploy{}) {
+		G_db.Set("gorm:table_options", "ENGINE=InnoDB").CreateTable(&GatewayDeploy{})
+	}
 	if !G_db.HasTable(&GatewayModel{}) {
 		G_db.Set("gorm:table_options", "ENGINE=InnoDB").CreateTable(&GatewayModel{})
 	}

+ 120 - 0
common/protocol/deploy.go

@@ -0,0 +1,120 @@
+package protocol
+
+// ============================================================
+// 部署指令/分片 (Cloud → Edge)
+// ============================================================
+
+// Pack_DeployCmd 部署指令或分片
+type Pack_DeployCmd struct {
+	Header
+	Data DeployCmdData `json:"data"`
+}
+
+type DeployCmdData struct {
+	Version     string `json:"Version"`     // 目标版本号
+	FileName    string `json:"FileName"`    // 文件名, e.g. "ipole"
+	TotalChunks int    `json:"TotalChunks"` // 分片总数(Action="start"时有效)
+	ChunkSize   int    `json:"ChunkSize"`   // 单片大小(Action="start"时有效)
+	ChunkIndex  int    `json:"ChunkIndex"`  // 当前分片索引(Action="chunk"时有效)
+	MD5         string `json:"MD5"`         // 完整文件 MD5(Action="start"时有效)
+	Data        string `json:"Data"`        // base64 编码的分片数据(Action="chunk"时有效)
+	Action      string `json:"Action"`      // "start" | "chunk" | "abort"
+}
+
+func (o *Pack_DeployCmd) EnCodeCmd(id, gid string, seq uint64, version, fileName, md5 string, totalChunks, chunkSize int) (string, error) {
+	o.Header.SetHeaderData(id, gid, seq)
+	o.Data.Version = version
+	o.Data.FileName = fileName
+	o.Data.TotalChunks = totalChunks
+	o.Data.ChunkSize = chunkSize
+	o.Data.MD5 = md5
+	o.Data.Action = "start"
+	return json.MarshalToString(o)
+}
+
+func (o *Pack_DeployCmd) EnCodeChunk(id, gid string, seq uint64, version string, chunkIndex int, data string) (string, error) {
+	o.Header.SetHeaderData(id, gid, seq)
+	o.Data.Version = version
+	o.Data.ChunkIndex = chunkIndex
+	o.Data.Data = data
+	o.Data.Action = "chunk"
+	return json.MarshalToString(o)
+}
+
+func (o *Pack_DeployCmd) EnCodeAbort(id, gid string, seq uint64, version string) (string, error) {
+	o.Header.SetHeaderData(id, gid, seq)
+	o.Data.Version = version
+	o.Data.Action = "abort"
+	return json.MarshalToString(o)
+}
+
+func (o *Pack_DeployCmd) DeCode(message string) error {
+	return json.UnmarshalFromString(message, o)
+}
+
+// ============================================================
+// 部署执行 ACK (Edge → Cloud)
+// ============================================================
+
+// Pack_DeployAck 部署执行结果(文件接收+替换的成功/失败)
+type Pack_DeployAck struct {
+	Header
+	Data DeployAckData `json:"data"`
+}
+
+type DeployAckData struct {
+	Version string `json:"Version"`
+	Success bool   `json:"Success"`
+	Error   string `json:"Error"` // 失败时填写原因
+}
+
+func (o *Pack_DeployAck) EnCode(id, gid string, seq uint64, version string, success bool, errMsg string) (string, error) {
+	o.Header.SetHeaderData(id, gid, seq)
+	o.Data.Version = version
+	o.Data.Success = success
+	o.Data.Error = errMsg
+	return json.MarshalToString(o)
+}
+
+func (o *Pack_DeployAck) DeCode(message string) error {
+	return json.UnmarshalFromString(message, o)
+}
+
+// ============================================================
+// 健康检测结果 (Edge → Cloud)
+// ============================================================
+
+// Pack_HealthCheck 健康检测结果上报
+type Pack_HealthCheck struct {
+	Header
+	Data HealthCheckData `json:"data"`
+}
+
+type HealthCheckData struct {
+	Version   string        `json:"Version"`
+	Phase     int           `json:"Phase"`   // 1 或 2
+	Success   bool          `json:"Success"` // 当前 Phase 是否全部通过
+	Timestamp int64         `json:"Timestamp"`
+	Checks    []HealthCheck `json:"Checks"`
+}
+
+type HealthCheck struct {
+	Name       string `json:"Name"`       // "process" | "mqtt" | "redis" | "serial" | "modbus" | "device_online"
+	Status     string `json:"Status"`     // "ok" | "fail" | "pending" | "timeout"
+	Detail     string `json:"Detail"`     // 人类可读的详情
+	DurationMs int    `json:"DurationMs"` // 检测耗时ms
+}
+
+func (o *Pack_HealthCheck) EnCode(id, gid string, seq uint64, version string, phase int, success bool, checks []HealthCheck) (string, error) {
+	o.Header.SetHeaderData(id, gid, seq)
+	o.Data.Version = version
+	o.Data.Phase = phase
+	o.Data.Success = success
+	o.Data.Timestamp = BJNow().Unix()
+	o.Data.Checks = checks
+	return json.MarshalToString(o)
+}
+
+func (o *Pack_HealthCheck) DeCode(message string) error {
+	return json.UnmarshalFromString(message, o)
+}

+ 91 - 0
common/protocol/deploy_test.go

@@ -0,0 +1,91 @@
+package protocol
+
+import (
+	"encoding/base64"
+	"testing"
+)
+
+func TestPack_DeployCmd_EnCodeCmd_DeCode(t *testing.T) {
+	var obj Pack_DeployCmd
+	str, err := obj.EnCodeCmd("gid1", "gid1", 100, "1.5.0", "ipole", "abc123def", 320, 65536)
+	if err != nil {
+		t.Fatalf("EnCodeCmd failed: %v", err)
+	}
+	if str == "" {
+		t.Fatal("EnCodeCmd returned empty string")
+	}
+
+	var obj2 Pack_DeployCmd
+	if err := obj2.DeCode(str); err != nil {
+		t.Fatalf("DeCode failed: %v", err)
+	}
+	if obj2.Data.Version != "1.5.0" {
+		t.Errorf("Version mismatch: got %s, want 1.5.0", obj2.Data.Version)
+	}
+	if obj2.Data.Action != "start" {
+		t.Errorf("Action mismatch: got %s, want start", obj2.Data.Action)
+	}
+	if obj2.Data.TotalChunks != 320 {
+		t.Errorf("TotalChunks mismatch: got %d, want 320", obj2.Data.TotalChunks)
+	}
+}
+
+func TestPack_DeployCmd_EnCodeChunk_DeCode(t *testing.T) {
+	var obj Pack_DeployCmd
+	testData := base64.StdEncoding.EncodeToString([]byte("hello world test chunk data"))
+	str, err := obj.EnCodeChunk("gid1", "gid1", 101, "1.5.0", 5, testData)
+	if err != nil {
+		t.Fatalf("EnCodeChunk failed: %v", err)
+	}
+
+	var obj2 Pack_DeployCmd
+	if err := obj2.DeCode(str); err != nil {
+		t.Fatalf("DeCode failed: %v", err)
+	}
+	if obj2.Data.ChunkIndex != 5 {
+		t.Errorf("ChunkIndex mismatch: got %d, want 5", obj2.Data.ChunkIndex)
+	}
+	if obj2.Data.Action != "chunk" {
+		t.Errorf("Action mismatch: got %s, want chunk", obj2.Data.Action)
+	}
+}
+
+func TestPack_DeployAck_EnCode_DeCode(t *testing.T) {
+	var obj Pack_DeployAck
+	str, err := obj.EnCode("gid1", "gid1", 200, "1.5.0", true, "")
+	if err != nil {
+		t.Fatalf("EnCode failed: %v", err)
+	}
+
+	var obj2 Pack_DeployAck
+	if err := obj2.DeCode(str); err != nil {
+		t.Fatalf("DeCode failed: %v", err)
+	}
+	if !obj2.Data.Success {
+		t.Error("Expected Success=true")
+	}
+}
+
+func TestPack_HealthCheck_EnCode_DeCode(t *testing.T) {
+	var obj Pack_HealthCheck
+	checks := []HealthCheck{
+		{Name: "mqtt", Status: "ok", Detail: "connected", DurationMs: 150},
+		{Name: "redis", Status: "ok", Detail: "PING ok", DurationMs: 10},
+	}
+
+	str, err := obj.EnCode("gid1", "gid1", 300, "1.5.0", 1, true, checks)
+	if err != nil {
+		t.Fatalf("EnCode failed: %v", err)
+	}
+
+	var obj2 Pack_HealthCheck
+	if err := obj2.DeCode(str); err != nil {
+		t.Fatalf("DeCode failed: %v", err)
+	}
+	if obj2.Data.Phase != 1 {
+		t.Errorf("Phase mismatch: got %d, want 1", obj2.Data.Phase)
+	}
+	if len(obj2.Data.Checks) != 2 {
+		t.Errorf("Checks count: got %d, want 2", len(obj2.Data.Checks))
+	}
+}

+ 5 - 2
common/protocol/topic.go

@@ -47,8 +47,11 @@ var (
 	TP_GW_SYS_ACK        string = "sys/ack"
 	TP_GW_ITS            string = "its"
 	TP_GW_ITS_ACK        string = "its/ack"
-	TP_GW_ONVIFDEV       string = "onvifdev"     //海康一键告警或摄像头设备上报
-	TP_GW_ONVIFDEV_ACK   string = "onvifdev/ack" //海康一键告警或摄像头设备上报
+	TP_GW_ONVIFDEV       string = "onvifdev"      //海康一键告警或摄像头设备上报
+	TP_GW_ONVIFDEV_ACK   string = "onvifdev/ack"  //海康一键告警或摄像头设备上报
+	TP_GW_DEPLOY         string = "deploy"        //一键部署: 下发部署命令+二进制分片
+	TP_GW_DEPLOY_ACK     string = "deploy/ack"    //一键部署: 部署结果上报
+	TP_GW_DEPLOY_HEALTH  string = "deploy/health" //一键部署: 健康检查结果上报
 )
 
 // modbus协议设备通用

+ 239 - 0
edge/ipole/deploy.html

@@ -0,0 +1,239 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>网关部署 - IPole</title>
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f7fa;color:#333;min-height:100vh}
+.header{background:#1a73e8;color:#fff;padding:16px 24px;font-size:18px;font-weight:600}
+.container{max-width:640px;margin:24px auto;padding:0 16px}
+.card{background:#fff;border-radius:8px;padding:24px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.1)}
+.card h2{font-size:16px;margin-bottom:16px}
+.info-row{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #eee;font-size:14px}
+.info-row:last-child{border-bottom:none}
+.info-label{color:#888}
+.upload-area{border:2px dashed #ccc;border-radius:8px;padding:40px;text-align:center;cursor:pointer;transition:border-color .2s}
+.upload-area:hover,.upload-area.dragover{border-color:#1a73e8}
+.upload-area p{color:#888;margin-top:8px;font-size:13px}
+.progress-bar{height:8px;background:#e8eaed;border-radius:4px;overflow:hidden;margin-top:12px}
+.progress-fill{height:100%;background:#1a73e8;transition:width .3s;width:0}
+.btn{display:inline-block;padding:10px 24px;background:#1a73e8;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:14px}
+.btn:disabled{opacity:.5;cursor:not-allowed}
+.check-item{display:flex;align-items:center;padding:8px 0;border-bottom:1px solid #eee;font-size:14px}
+.check-item:last-child{border-bottom:none}
+.check-icon{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin-right:12px;font-size:12px;flex-shrink:0}
+.check-ok{background:#e6f4ea;color:#1e8e3e}
+.check-fail{background:#fce8e6;color:#d93025}
+.check-pending{background:#e8eaed;color:#888}
+.check-detail{color:#888;font-size:12px;margin-left:auto}
+.status-msg{padding:12px;border-radius:4px;text-align:center;font-size:14px;margin-top:12px}
+.status-ok{background:#e6f4ea;color:#1e8e3e}
+.status-fail{background:#fce8e6;color:#d93025}
+.status-progress{background:#e8f0fe;color:#1a73e8}
+.hidden{display:none}
+.error-msg{color:#d93025;font-size:13px;margin-top:4px}
+</style>
+</head>
+<body>
+<div class="header">网关部署工具</div>
+<div class="container">
+  <div class="card" id="infoCard">
+    <h2>系统信息</h2>
+    <div class="info-row"><span class="info-label">当前版本</span><span id="currentVersion">--</span></div>
+    <div class="info-row"><span class="info-label">上次部署</span><span id="lastDeploy">--</span></div>
+    <div class="info-row"><span class="info-label">上次结果</span><span id="lastResult">--</span></div>
+  </div>
+
+  <div class="card" id="uploadCard">
+    <h2>上传新版本</h2>
+    <div class="upload-area" id="dropZone">
+      <div style="font-size:36px">+</div>
+      <p>拖拽或点击上传 ipole 可执行文件</p>
+      <input type="file" id="fileInput" style="display:none">
+    </div>
+    <div id="fileInfo" class="hidden" style="margin-top:12px">
+      <div class="info-row"><span class="info-label">文件名</span><span id="fileName">--</span></div>
+      <div class="info-row"><span class="info-label">大小</span><span id="fileSize">--</span></div>
+    </div>
+    <div id="uploadProgress" class="hidden">
+      <div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
+      <p style="font-size:12px;color:#888;margin-top:4px" id="progressText">0%</p>
+    </div>
+    <button class="btn" id="deployBtn" style="margin-top:12px;width:100%" disabled>开始部署</button>
+    <p id="uploadError" class="error-msg hidden"></p>
+  </div>
+
+  <div class="card hidden" id="statusCard">
+    <h2 id="statusTitle">部署状态</h2>
+    <div id="statusMsg" class="status-msg status-progress">正在部署...</div>
+    <div id="checkList"></div>
+  </div>
+
+  <div class="card hidden" id="reconnectCard">
+    <div class="status-msg status-progress">等待网关重启...<br><span style="font-size:12px">正在自动重连(每2秒)</span></div>
+  </div>
+</div>
+
+<script>
+var selectedFile = null;
+var dropZone = document.getElementById('dropZone');
+var fileInput = document.getElementById('fileInput');
+var checkNames = {mqtt:'MQTT连接', redis:'Redis', serial:'串口', modbus:'设备采集', device_online:'设备在线'};
+
+// 加载系统信息
+fetch('/deploy/result').then(function(r){return r.json()}).then(function(data){
+  if (data.version) {
+    document.getElementById('currentVersion').textContent = data.version;
+    var d = new Date(data.timestamp * 1000);
+    document.getElementById('lastDeploy').textContent = d.toLocaleString();
+    var ok = data.phase1 && data.phase1.success;
+    document.getElementById('lastResult').textContent = ok ? '成功' : '失败';
+    if (ok) document.getElementById('lastResult').style.color = '#1e8e3e';
+    else document.getElementById('lastResult').style.color = '#d93025';
+  }
+}).catch(function(){});
+
+// 文件选择
+dropZone.addEventListener('click', function(){ fileInput.click(); });
+dropZone.addEventListener('dragover', function(e){ e.preventDefault(); dropZone.classList.add('dragover'); });
+dropZone.addEventListener('dragleave', function(){ dropZone.classList.remove('dragover'); });
+dropZone.addEventListener('drop', function(e){
+  e.preventDefault();
+  dropZone.classList.remove('dragover');
+  handleFile(e.dataTransfer.files[0]);
+});
+fileInput.addEventListener('change', function(){ handleFile(fileInput.files[0]); });
+
+function handleFile(file) {
+  if (!file) return;
+  selectedFile = file;
+  document.getElementById('fileName').textContent = file.name;
+  document.getElementById('fileSize').textContent = formatSize(file.size);
+  document.getElementById('fileInfo').classList.remove('hidden');
+  document.getElementById('deployBtn').disabled = false;
+  document.getElementById('uploadError').classList.add('hidden');
+}
+
+function formatSize(bytes) {
+  if (bytes < 1024) return bytes + ' B';
+  if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
+  return (bytes/1048576).toFixed(1) + ' MB';
+}
+
+document.getElementById('deployBtn').addEventListener('click', function(){
+  if (!selectedFile) return;
+  var btn = this;
+  btn.disabled = true;
+  btn.textContent = '上传中...';
+
+  var formData = new FormData();
+  formData.append('file', selectedFile);
+
+  var xhr = new XMLHttpRequest();
+  xhr.open('POST', '/deploy/upload');
+
+  xhr.upload.onprogress = function(e){
+    if (e.lengthComputable){
+      var pct = Math.round(e.loaded / e.total * 100);
+      document.getElementById('uploadProgress').classList.remove('hidden');
+      document.getElementById('progressFill').style.width = pct + '%';
+      document.getElementById('progressText').textContent = pct + '%';
+    }
+  };
+
+  xhr.onload = function(){
+    try {
+      var resp = JSON.parse(xhr.responseText);
+      if (resp.success) {
+        document.getElementById('uploadCard').classList.add('hidden');
+        showReconnect();
+      } else {
+        showError(resp.error || '部署失败');
+      }
+    } catch(e) {
+      showError('解析响应失败');
+    }
+    btn.disabled = false;
+    btn.textContent = '开始部署';
+  };
+
+  xhr.onerror = function(){
+    document.getElementById('uploadCard').classList.add('hidden');
+    showReconnect();
+  };
+
+  xhr.send(formData);
+});
+
+function showReconnect(){
+  document.getElementById('reconnectCard').classList.remove('hidden');
+  pollReconnect();
+}
+
+function pollReconnect(){
+  var attempts = 0;
+  var interval = setInterval(function(){
+    attempts++;
+    fetch('/deploy/result')
+      .then(function(r){ return r.json(); })
+      .then(function(data){
+        clearInterval(interval);
+        document.getElementById('reconnectCard').classList.add('hidden');
+        showHealthResult(data);
+      })
+      .catch(function(){
+        if (attempts > 30){
+          clearInterval(interval);
+          document.getElementById('reconnectCard').innerHTML =
+            '<div class="status-msg status-fail">连接超时,请检查网关是否正常启动</div>';
+        }
+      });
+  }, 2000);
+}
+
+function showHealthResult(data){
+  var card = document.getElementById('statusCard');
+  card.classList.remove('hidden');
+
+  var checks = (data.phase1 && data.phase1.checks) || [];
+  var allOk = data.phase1 && data.phase1.success;
+
+  document.getElementById('statusTitle').textContent = allOk ? '部署成功' : '部署失败';
+  document.getElementById('statusMsg').className = 'status-msg ' + (allOk ? 'status-ok' : 'status-fail');
+  document.getElementById('statusMsg').textContent = allOk ? 'Phase 1 健康检测全部通过' : 'Phase 1 健康检测失败';
+
+  var list = document.getElementById('checkList');
+  list.innerHTML = '';
+  checks.forEach(function(c){
+    var cls = c.Status === 'ok' ? 'check-ok' : 'check-fail';
+    var icon = c.Status === 'ok' ? 'OK' : '!!';
+    list.innerHTML += '<div class="check-item">' +
+      '<span class="check-icon ' + cls + '">' + icon + '</span>' +
+      '<span>' + (checkNames[c.Name] || c.Name) + '</span>' +
+      '<span class="check-detail">' + c.Detail + '</span></div>';
+  });
+
+  if (data.phase2){
+    var p2 = data.phase2;
+    list.innerHTML += '<div style="margin-top:12px;padding-top:12px;border-top:2px solid #eee">' +
+      '<p style="font-size:13px;color:#888;margin-bottom:8px">Phase 2 业务恢复确认</p></div>';
+    (data.phase2.checks||[]).forEach(function(c){
+      var cls = c.Status === 'ok' ? 'check-ok' : 'check-fail';
+      list.innerHTML += '<div class="check-item">' +
+        '<span class="check-icon ' + cls + '">' + (c.Status === 'ok' ? 'OK' : '!!') + '</span>' +
+        '<span>' + (checkNames[c.Name] || c.Name) + '</span>' +
+        '<span class="check-detail">' + c.Detail + '</span></div>';
+    });
+  }
+}
+
+function showError(msg){
+  var el = document.getElementById('uploadError');
+  el.textContent = msg;
+  el.classList.remove('hidden');
+}
+</script>
+</body>
+</html>

+ 6 - 0
edge/ipole/deployhtml.go

@@ -0,0 +1,6 @@
+package main
+
+import _ "embed"
+
+//go:embed deploy.html
+var deployHTML string

+ 217 - 0
edge/ipole/deployhttp.go

@@ -0,0 +1,217 @@
+package main
+
+import (
+	stdjson "encoding/json" // 标准库,避免 json-iterator nil map panic
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"runtime/debug"
+	"time"
+
+	"lc/common/util"
+)
+
+// StartDeployHTTP 启动本地部署 HTTP 服务(端口 9998)
+func StartDeployHTTP() {
+	mux := http.NewServeMux()
+
+	// 简易健康探针:无依赖直接返回 OK,用于验证 HTTP 服务是否正常
+	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+		w.Write([]byte("OK"))
+	})
+
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path == "/favicon.ico" {
+			w.WriteHeader(204)
+			return
+		}
+		if r.URL.Path != "/" {
+			http.NotFound(w, r)
+			return
+		}
+		util.GetTagLog().Infof("sys", "DeployHTTP: GET / from %s", r.RemoteAddr)
+		w.Header().Set("Content-Type", "text/html; charset=utf-8")
+		w.Write([]byte(deployHTML))
+	})
+
+	mux.HandleFunc("/deploy/upload", withRecover(handleDeployUpload))
+	mux.HandleFunc("/deploy/status", handleDeployStatusSSE)
+	mux.HandleFunc("/deploy/result", withRecover(handleDeployResult))
+
+	server := &http.Server{
+		Addr:         ":9998",
+		Handler:      withLogging(mux),
+		ReadTimeout:  30 * time.Second,
+		WriteTimeout: 60 * time.Second,
+		IdleTimeout:  120 * time.Second,
+	}
+
+	util.GetTagLog().Infof("sys", "本地部署服务启动在 :9998")
+	go func() {
+		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+			util.GetTagLog().Errorf("sys", "本地部署服务启动失败:%s", err.Error())
+		}
+	}()
+}
+
+// withLogging 请求日志中间件
+func withLogging(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		util.GetTagLog().Infof("sys", "DeployHTTP: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
+		next.ServeHTTP(w, r)
+	})
+}
+
+// withRecover panic 恢复包装
+func withRecover(fn http.HandlerFunc) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		defer func() {
+			if err := recover(); err != nil {
+				util.GetTagLog().Errorf("sys", "DeployHTTP: panic in %s %s: %v\n%s", r.Method, r.URL.Path, err, string(debug.Stack()))
+				http.Error(w, "Internal Server Error", 500)
+			}
+		}()
+		fn(w, r)
+	}
+}
+
+func handleDeployUpload(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		writeJSON(w, 405, map[string]interface{}{"success": false, "error": "Method not allowed"})
+		return
+	}
+
+	r.Body = http.MaxBytesReader(w, r.Body, 50<<20)
+
+	file, header, err := r.FormFile("file")
+	if err != nil {
+		writeJSON(w, 400, map[string]interface{}{"success": false, "error": "读取文件失败: " + err.Error()})
+		return
+	}
+	defer file.Close()
+
+	tmpDir := filepath.Join(util.GetPath(4), deployTmpDir)
+	os.MkdirAll(tmpDir, os.ModePerm)
+	tmpFile := filepath.Join(tmpDir, header.Filename)
+	out, err := os.Create(tmpFile)
+	if err != nil {
+		writeJSON(w, 500, map[string]interface{}{"success": false, "error": "创建临时文件失败: " + err.Error()})
+		return
+	}
+	defer out.Close()
+
+	if _, err := io.Copy(out, file); err != nil {
+		writeJSON(w, 500, map[string]interface{}{"success": false, "error": "写入文件失败: " + err.Error()})
+		return
+	}
+
+	if !isELF(tmpFile) {
+		os.Remove(tmpFile)
+		writeJSON(w, 400, map[string]interface{}{"success": false, "error": "文件格式错误:非Linux可执行文件"})
+		return
+	}
+
+	md5Hash, _ := fileMD5(tmpFile)
+
+	cwd, _ := os.Getwd()
+	targetPath := filepath.Join(cwd, appname)
+	backupPath := targetPath + ".bak"
+
+	version := fmt.Sprintf("local-%s", time.Now().Format("20060102-150405"))
+
+	os.Remove(backupPath)
+	if _, err := os.Stat(targetPath); err == nil {
+		if err := os.Rename(targetPath, backupPath); err != nil {
+			writeJSON(w, 500, map[string]interface{}{"success": false, "error": "备份旧版失败: " + err.Error()})
+			return
+		}
+	}
+
+	if err := os.Rename(tmpFile, targetPath); err != nil {
+		os.Rename(backupPath, targetPath)
+		writeJSON(w, 500, map[string]interface{}{"success": false, "error": "替换文件失败: " + err.Error()})
+		return
+	}
+	os.Chmod(targetPath, 0755)
+
+	marker := DeployMarker{Version: version, Timestamp: time.Now().Unix(), Action: "deploy"}
+	markerContent, _ := json.MarshalToString(marker)
+	os.WriteFile(filepath.Join(util.GetPath(0), "deploy_marker.json"), []byte(markerContent), os.ModePerm)
+
+	util.GetTagLog().Infof("sys", "DeployHTTP: 本地上传部署完成 version=%s md5=%s", version, md5Hash)
+
+	writeJSON(w, 200, map[string]interface{}{
+		"success": true,
+		"version": version,
+		"md5":     md5Hash,
+		"message": "文件已部署,即将重启...",
+	})
+
+	go func() {
+		time.Sleep(500 * time.Millisecond)
+		os.Exit(0)
+	}()
+}
+
+func handleDeployStatusSSE(w http.ResponseWriter, r *http.Request) {
+	util.GetTagLog().Infof("sys", "DeployHTTP: SSE连接 from %s", r.RemoteAddr)
+
+	w.Header().Set("Content-Type", "text/event-stream")
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Connection", "keep-alive")
+	w.Header().Set("Access-Control-Allow-Origin", "*")
+
+	flusher, ok := w.(http.Flusher)
+	if !ok {
+		http.Error(w, "SSE not supported", 500)
+		return
+	}
+
+	result := DeployResultGet()
+	if result != nil {
+		data, _ := json.MarshalToString(result)
+		fmt.Fprintf(w, "data: %s\n\n", data)
+		flusher.Flush()
+	} else {
+		fmt.Fprintf(w, "data: {\"status\":\"waiting\"}\n\n")
+		flusher.Flush()
+	}
+
+	ticker := time.NewTicker(15 * time.Second)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-r.Context().Done():
+			util.GetTagLog().Infof("sys", "DeployHTTP: SSE断开 from %s", r.RemoteAddr)
+			return
+		case <-ticker.C:
+			result := DeployResultGet()
+			if result != nil {
+				data, _ := json.MarshalToString(result)
+				fmt.Fprintf(w, "data: %s\n\n", data)
+				flusher.Flush()
+			}
+		}
+	}
+}
+
+func handleDeployResult(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Access-Control-Allow-Origin", "*")
+	result := DeployResultGet()
+	if result == nil {
+		writeJSON(w, 200, map[string]interface{}{"status": "no_deployment"})
+		return
+	}
+	writeJSON(w, 200, result)
+}
+
+func writeJSON(w http.ResponseWriter, code int, data interface{}) {
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+	w.WriteHeader(code)
+	body, _ := stdjson.Marshal(data)
+	w.Write(body)
+}

+ 382 - 0
edge/ipole/deploymgr.go

@@ -0,0 +1,382 @@
+package main
+
+import (
+	"crypto/md5"
+	"encoding/base64"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+
+	"lc/common/mqtt"
+	"lc/common/protocol"
+	"lc/common/util"
+)
+
+const (
+	deployChunkTimeout = 10 * time.Minute
+	deployTmpDir       = "deploy"
+)
+
+// DeployMgr 部署管理器
+type DeployMgr struct {
+	mu          sync.Mutex
+	active      bool
+	version     string
+	fileName    string
+	totalChunks int
+	chunkSize   int
+	expectedMD5 string
+	chunkBits   []bool
+	receivedAt  time.Time
+	deployTmp   string
+	cancel      chan struct{}
+}
+
+var _deployMgrOnce sync.Once
+var _deployMgr *DeployMgr
+
+func GetDeployMgr() *DeployMgr {
+	_deployMgrOnce.Do(func() {
+		_deployMgr = &DeployMgr{}
+	})
+	return _deployMgr
+}
+
+// HandleTpDeploy 处理来自 MQTT 的部署指令/分片
+func HandleTpDeploy(m mqtt.Message) {
+	var obj protocol.Pack_DeployCmd
+	if err := obj.DeCode(string(m.Payload())); err != nil {
+		util.GetTagLog().Errorf("sys", "HandleTpDeploy:DeCode失败,err=%v", err)
+		return
+	}
+	mgr := GetDeployMgr()
+
+	switch obj.Data.Action {
+	case "start":
+		mgr.start(&obj)
+	case "chunk":
+		mgr.receiveChunk(&obj)
+	case "abort":
+		mgr.abort(&obj)
+	default:
+		util.GetTagLog().Warnf("sys", "HandleTpDeploy:未知Action=%s", obj.Data.Action)
+	}
+}
+
+func (o *DeployMgr) start(cmd *protocol.Pack_DeployCmd) {
+	o.mu.Lock()
+	defer o.mu.Unlock()
+
+	if o.active {
+		util.GetTagLog().Warnf("sys", "DeployMgr:已有部署进行中,拒绝新部署指令 version=%s", cmd.Data.Version)
+		o.sendAck(cmd.Data.Version, false, "已有部署进行中")
+		return
+	}
+
+	o.active = true
+	o.version = cmd.Data.Version
+	o.fileName = cmd.Data.FileName
+	o.totalChunks = cmd.Data.TotalChunks
+	o.chunkSize = cmd.Data.ChunkSize
+	o.expectedMD5 = cmd.Data.MD5
+	o.chunkBits = make([]bool, cmd.Data.TotalChunks)
+	o.receivedAt = time.Now()
+	o.deployTmp = filepath.Join(util.GetPath(4), deployTmpDir)
+	o.cancel = make(chan struct{}, 1)
+
+	if err := os.MkdirAll(o.deployTmp, os.ModePerm); err != nil {
+		util.GetTagLog().Errorf("sys", "DeployMgr:创建临时目录失败,path=%s,err=%v", o.deployTmp, err)
+		o.cleanup("创建临时目录失败: " + err.Error())
+		return
+	}
+
+	util.GetTagLog().Infof("sys", "DeployMgr:开始部署 version=%s totalChunks=%d chunkSize=%d md5=%s",
+		cmd.Data.Version, cmd.Data.TotalChunks, cmd.Data.ChunkSize, cmd.Data.MD5)
+
+	// 启动超时监控 goroutine(独立于 gopool,避免 worker 饥饿问题)
+	go o.watchTimeout()
+}
+
+func (o *DeployMgr) receiveChunk(cmd *protocol.Pack_DeployCmd) {
+	o.mu.Lock()
+	defer o.mu.Unlock()
+
+	if !o.active {
+		return
+	}
+	if cmd.Data.Version != o.version {
+		util.GetTagLog().Warnf("sys", "DeployMgr:分片版本不匹配,expect=%s,got=%s", o.version, cmd.Data.Version)
+		return
+	}
+
+	idx := cmd.Data.ChunkIndex
+	if idx < 0 || idx >= o.totalChunks {
+		util.GetTagLog().Errorf("sys", "DeployMgr:分片索引越界,idx=%d,total=%d", idx, o.totalChunks)
+		return
+	}
+
+	// base64 decode the chunk data
+	decoded, err := base64.StdEncoding.DecodeString(cmd.Data.Data)
+	if err != nil {
+		util.GetTagLog().Errorf("sys", "DeployMgr:base64解码分片%d失败,err=%v", idx, err)
+		return
+	}
+
+	chunkFile := filepath.Join(o.deployTmp, fmt.Sprintf("chunk_%d", idx))
+	if err := os.WriteFile(chunkFile, decoded, os.ModePerm); err != nil {
+		util.GetTagLog().Errorf("sys", "DeployMgr:写分片失败,idx=%d,err=%v", idx, err)
+		return
+	}
+
+	o.chunkBits[idx] = true
+	o.receivedAt = time.Now()
+
+	allReceived := true
+	for _, b := range o.chunkBits {
+		if !b {
+			allReceived = false
+			break
+		}
+	}
+
+	if allReceived {
+		go o.assembleAndDeploy()
+	}
+}
+
+func (o *DeployMgr) abort(cmd *protocol.Pack_DeployCmd) {
+	o.mu.Lock()
+	defer o.mu.Unlock()
+
+	if !o.active || cmd.Data.Version != o.version {
+		return
+	}
+
+	util.GetTagLog().Infof("sys", "DeployMgr:收到取消指令 version=%s", o.version)
+	if o.cancel != nil {
+		close(o.cancel)
+	}
+	o.cleanup("已取消")
+}
+
+func (o *DeployMgr) assembleAndDeploy() {
+	o.mu.Lock()
+	defer o.mu.Unlock()
+
+	if !o.active {
+		return
+	}
+
+	util.GetTagLog().Infof("sys", "DeployMgr:所有分片已收齐,开始重组 version=%s", o.version)
+
+	// 1. 重组文件
+	assembledFile := filepath.Join(o.deployTmp, o.fileName)
+	outFile, err := os.Create(assembledFile)
+	if err != nil {
+		o.cleanup("创建重组文件失败: " + err.Error())
+		return
+	}
+	defer outFile.Close()
+
+	for i := 0; i < o.totalChunks; i++ {
+		chunkFile := filepath.Join(o.deployTmp, fmt.Sprintf("chunk_%d", i))
+		data, err := os.ReadFile(chunkFile)
+		if err != nil {
+			outFile.Close()
+			o.cleanup(fmt.Sprintf("读取分片%d失败: %s", i, err.Error()))
+			return
+		}
+		if _, err := outFile.Write(data); err != nil {
+			outFile.Close()
+			o.cleanup(fmt.Sprintf("写入重组文件失败: %s", err.Error()))
+			return
+		}
+	}
+
+	// 2. MD5 校验
+	actualMD5, err := fileMD5(assembledFile)
+	if err != nil {
+		o.cleanup("计算MD5失败: " + err.Error())
+		return
+	}
+	if !strings.EqualFold(actualMD5, o.expectedMD5) {
+		o.cleanup(fmt.Sprintf("MD5校验失败:期望%s 实际%s", o.expectedMD5, actualMD5))
+		return
+	}
+	util.GetTagLog().Infof("sys", "DeployMgr:MD5校验通过 md5=%s", actualMD5)
+
+	// 3. ELF 魔数校验
+	if !isELF(assembledFile) {
+		o.cleanup("文件格式错误:非Linux可执行文件")
+		return
+	}
+
+	// 4. 磁盘空间检查(rename 不消耗额外空间,只需 1x 文件大小)
+	cwd, _ := os.Getwd()
+	targetPath := filepath.Join(cwd, o.fileName)
+	fi, _ := os.Stat(assembledFile)
+	needed := fi.Size() + 1*1024*1024 // 文件大小 + 1MB 安全余量
+	free, err := getFreeSpace(cwd)
+	if err == nil && free < needed {
+		o.cleanup(fmt.Sprintf("磁盘空间不足:需要%dMB 可用%dMB", needed/(1024*1024), free/(1024*1024)))
+		return
+	}
+
+	// 5. 备份当前二进制,替换新文件
+	backupPath := targetPath + ".bak"
+
+	os.Remove(backupPath)
+	if _, err := os.Stat(targetPath); err == nil {
+		if err := os.Rename(targetPath, backupPath); err != nil {
+			o.cleanup("备份旧版失败: " + err.Error())
+			return
+		}
+		util.GetTagLog().Infof("sys", "DeployMgr:旧版已备份到 %s", backupPath)
+	}
+
+	if err := os.Rename(assembledFile, targetPath); err != nil {
+		os.Rename(backupPath, targetPath)
+		o.cleanup("替换文件失败: " + err.Error())
+		return
+	}
+	if err := os.Chmod(targetPath, 0755); err != nil {
+		util.GetTagLog().Warnf("sys", "DeployMgr:chmod失败,err=%v", err)
+	}
+
+	util.GetTagLog().Infof("sys", "DeployMgr:文件替换成功 version=%s path=%s", o.version, targetPath)
+
+	// 6. 写部署标记
+	marker := DeployMarker{
+		Version:   o.version,
+		Timestamp: time.Now().Unix(),
+		Action:    "deploy",
+	}
+	markerPath := filepath.Join(util.GetPath(0), "deploy_marker.json")
+	markerContent, _ := json.MarshalToString(marker)
+	if err := os.WriteFile(markerPath, []byte(markerContent), os.ModePerm); err != nil {
+		util.GetTagLog().Errorf("sys", "DeployMgr:写部署标记失败,err=%v", err)
+	}
+
+	// 7. 发送 ACK
+	o.active = false
+	o.sendAck(o.version, true, "")
+	util.GetTagLog().Infof("sys", "DeployMgr:即将退出进程以完成部署")
+
+	// 8. 退出让 goforever 拉起新版本
+	time.Sleep(500 * time.Millisecond)
+	os.Exit(0)
+}
+
+// watchTimeout 分片接收超时监控(独立 goroutine,不走 gopool 避免 worker 饥饿)
+func (o *DeployMgr) watchTimeout() {
+	ticker := time.NewTicker(30 * time.Second)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-o.cancel:
+			return
+		case <-ticker.C:
+			o.mu.Lock()
+			if !o.active {
+				o.mu.Unlock()
+				return
+			}
+			elapsed := time.Since(o.receivedAt)
+			if elapsed > deployChunkTimeout {
+				received := o.countReceived()
+				o.mu.Unlock()
+				o.cleanup(fmt.Sprintf("分片接收超时:已收 %d/%d", received, o.totalChunks))
+				return
+			}
+			o.mu.Unlock()
+		}
+	}
+}
+
+func (o *DeployMgr) cleanup(errMsg string) {
+	util.GetTagLog().Errorf("sys", "DeployMgr:部署失败,原因=%s", errMsg)
+
+	if o.deployTmp != "" {
+		os.RemoveAll(o.deployTmp)
+	}
+
+	if o.active {
+		o.sendAck(o.version, false, errMsg)
+	}
+
+	o.active = false
+	o.version = ""
+	o.chunkBits = nil
+	o.cancel = nil
+}
+
+func (o *DeployMgr) sendAck(version string, success bool, errMsg string) {
+	var ack protocol.Pack_DeployAck
+	seq := GetNextUint64()
+	if str, err := ack.EnCode(appConfig.GID, appConfig.GID, seq, version, success, errMsg); err == nil {
+		topic := GetTopic(protocol.DT_GATEWAY, appConfig.GID, protocol.TP_GW_DEPLOY_ACK)
+		GetMQTTMgr().Publish(topic, str, 0, ToCloud)
+		util.GetTagLog().Infof("sys", "DeployMgr:发送ACK version=%s success=%v", version, success)
+	}
+}
+
+func (o *DeployMgr) IsActive() bool {
+	o.mu.Lock()
+	defer o.mu.Unlock()
+	return o.active
+}
+
+func (o *DeployMgr) countReceived() int {
+	n := 0
+	for _, b := range o.chunkBits {
+		if b {
+			n++
+		}
+	}
+	return n
+}
+
+// DeployMarker 部署/回滚标记
+type DeployMarker struct {
+	Version   string `json:"version"`
+	Timestamp int64  `json:"timestamp"`
+	Action    string `json:"action"`
+}
+
+// ---- 工具函数 ----
+
+func fileMD5(path string) (string, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return "", err
+	}
+	defer f.Close()
+	h := md5.New()
+	if _, err := io.Copy(h, f); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(h.Sum(nil)), nil
+}
+
+func isELF(path string) bool {
+	f, err := os.Open(path)
+	if err != nil {
+		return false
+	}
+	defer f.Close()
+	header := make([]byte, 4)
+	if _, err := io.ReadFull(f, header); err != nil {
+		return false
+	}
+	return header[0] == 0x7f && header[1] == 'E' && header[2] == 'L' && header[3] == 'F'
+}
+
+// init registers the deploy handler
+func init() {
+	// Deploy handler will be registered in InitCloudMqttSubscribeTopics after appConfig is loaded
+}

+ 23 - 0
edge/ipole/deploymgr_linux.go

@@ -0,0 +1,23 @@
+//go:build linux
+// +build linux
+
+package main
+
+import (
+	"syscall"
+
+	"lc/common/util"
+)
+
+func getFreeSpace(dir string) (int64, error) {
+	var stat syscall.Statfs_t
+	if err := syscall.Statfs(dir, &stat); err != nil {
+		return 0, err
+	}
+	free := int64(stat.Bfree) * int64(stat.Bsize)
+	total := int64(stat.Blocks) * int64(stat.Bsize)
+	util.GetTagLog().Infof("sys", "DeployMgr:磁盘空间检查 dir=%s total=%dMB free=%dMB avail=%dMB bsize=%d blocks=%d bfree=%d bavail=%d",
+		dir, total/(1024*1024), int64(stat.Bfree)*int64(stat.Bsize)/(1024*1024), free/(1024*1024),
+		stat.Bsize, stat.Blocks, stat.Bfree, stat.Bavail)
+	return free, nil
+}

+ 9 - 0
edge/ipole/deploymgr_win.go

@@ -0,0 +1,9 @@
+//go:build !linux
+// +build !linux
+
+package main
+
+// getFreeSpace 非 Linux 平台跳过磁盘空间检查(仅用于开发编译)
+func getFreeSpace(dir string) (int64, error) {
+	return 0, nil
+}

+ 339 - 0
edge/ipole/healthcheck.go

@@ -0,0 +1,339 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync/atomic"
+	"time"
+
+	"lc/common/protocol"
+	"lc/common/util"
+)
+
+const (
+	phase1Timeout = 30 * time.Second
+	phase2Delay   = 5 * time.Minute
+	phase2Window  = 5 * time.Minute
+)
+
+// HealthCheckRunner 健康检测运行器
+type HealthCheckRunner struct {
+	marker     *DeployMarker
+	running    int32
+	phase2Done int32
+}
+
+var _hcRunner *HealthCheckRunner
+
+// StartHealthCheckIfNeeded 启动时检查部署标记,如有则运行健康检测
+func StartHealthCheckIfNeeded() {
+	markerPath := filepath.Join(util.GetPath(0), "deploy_marker.json")
+	data, err := os.ReadFile(markerPath)
+	if err != nil {
+		// 检查是否有回滚标记
+		rollbackPath := filepath.Join(util.GetPath(0), "rollback_marker.json")
+		if rbData, err := os.ReadFile(rollbackPath); err == nil {
+			var marker DeployMarker
+			json.Unmarshal(rbData, &marker)
+			util.GetTagLog().Infof("sys", "检测到回滚标记,已回滚到版本:%s", marker.Version)
+			reportRollback(marker)
+			os.Remove(rollbackPath)
+		}
+		return
+	}
+
+	var marker DeployMarker
+	if err := json.Unmarshal(data, &marker); err != nil {
+		util.GetTagLog().Errorf("sys", "解析部署标记失败,err=%v", err)
+		return
+	}
+
+	util.GetTagLog().Infof("sys", "检测到部署标记,开始健康检测 version=%s", marker.Version)
+
+	_hcRunner = &HealthCheckRunner{
+		marker: &marker,
+	}
+
+	go func() {
+		time.Sleep(3 * time.Second) // 等待 MQTT 初始化
+		_hcRunner.runPhase1()
+	}()
+}
+
+func (o *HealthCheckRunner) runPhase1() {
+	if !atomic.CompareAndSwapInt32(&o.running, 0, 1) {
+		return
+	}
+	defer atomic.StoreInt32(&o.running, 0)
+
+	util.GetTagLog().Infof("sys", "HealthCheck:Phase 1 开始 version=%s", o.marker.Version)
+	deadline := time.Now().Add(phase1Timeout)
+
+	checks := []protocol.HealthCheck{
+		o.checkMQTT(deadline),
+		o.checkRedis(deadline),
+	}
+
+	allOK := true
+	for _, c := range checks {
+		if c.Status != "ok" {
+			allOK = false
+			break
+		}
+	}
+
+	o.reportPhase(1, allOK, checks)
+
+	if !allOK {
+		util.GetTagLog().Errorf("sys", "HealthCheck:Phase 1 失败,开始回滚")
+		o.rollback(checks)
+		return
+	}
+
+	util.GetTagLog().Infof("sys", "HealthCheck:Phase 1 全部通过,清理部署标记")
+	os.Remove(filepath.Join(util.GetPath(0), "deploy_marker.json"))
+	DeployResultSave(o.marker.Version, 1, allOK, checks)
+
+	go func() {
+		time.Sleep(phase2Delay)
+		o.runPhase2()
+	}()
+}
+
+func (o *HealthCheckRunner) checkMQTT(deadline time.Time) protocol.HealthCheck {
+	start := time.Now()
+	check := protocol.HealthCheck{Name: "mqtt"}
+
+	for time.Now().Before(deadline) {
+		mgr := GetMQTTMgr()
+		if mgr.Cloud != nil && mgr.Cloud.IsConnected() {
+			check.Status = "ok"
+			check.Detail = "MQTT Broker 已连接"
+			check.DurationMs = int(time.Since(start).Milliseconds())
+			util.GetTagLog().Infof("sys", "HealthCheck:MQTT检测通过")
+			return check
+		}
+		time.Sleep(1 * time.Second)
+	}
+
+	check.Status = "timeout"
+	check.Detail = "MQTT Broker 不可达,请检查 Server/User/Password 配置、网络连通性"
+	check.DurationMs = int(time.Since(start).Milliseconds())
+	util.GetTagLog().Errorf("sys", "HealthCheck:MQTT检测失败")
+	return check
+}
+
+func (o *HealthCheckRunner) checkRedis(deadline time.Time) protocol.HealthCheck {
+	start := time.Now()
+	check := protocol.HealthCheck{Name: "redis"}
+
+	for time.Now().Before(deadline) {
+		if redisEdgeData != nil {
+			if _, err := redisEdgeData.Ping().Result(); err == nil {
+				check.Status = "ok"
+				check.Detail = "Redis PING 正常"
+				check.DurationMs = int(time.Since(start).Milliseconds())
+				util.GetTagLog().Infof("sys", "HealthCheck:Redis检测通过")
+				return check
+			}
+		}
+		time.Sleep(2 * time.Second)
+	}
+
+	check.Status = "timeout"
+	check.Detail = "Redis 连接失败,请检查 Redis Server/Password 配置"
+	check.DurationMs = int(time.Since(start).Milliseconds())
+	util.GetTagLog().Errorf("sys", "HealthCheck:Redis检测失败")
+	return check
+}
+
+func (o *HealthCheckRunner) runPhase2() {
+	atomic.StoreInt32(&o.phase2Done, 1)
+	util.GetTagLog().Infof("sys", "HealthCheck:Phase 2 开始 version=%s", o.marker.Version)
+
+	checks := []protocol.HealthCheck{
+		o.checkSerial(),
+		o.checkModbusData(),
+	}
+
+	allOK := true
+	for _, c := range checks {
+		if c.Status != "ok" {
+			allOK = false
+			break
+		}
+	}
+
+	o.reportPhase(2, allOK, checks)
+	DeployResultSave(o.marker.Version, 2, allOK, checks)
+
+	if !allOK {
+		util.GetTagLog().Warnf("sys", "HealthCheck:Phase 2 存在告警,不回滚")
+	} else {
+		util.GetTagLog().Infof("sys", "HealthCheck:Phase 2 全部通过")
+	}
+}
+
+func (o *HealthCheckRunner) checkSerial() protocol.HealthCheck {
+	start := time.Now()
+	check := protocol.HealthCheck{Name: "serial"}
+
+	sc := GetSerialMgr()
+	failedPorts := sc.GetFailedPorts()
+	if len(failedPorts) == 0 {
+		check.Status = "ok"
+		check.Detail = "所有串口打开正常"
+	} else {
+		check.Status = "fail"
+		parts := make([]string, len(failedPorts))
+		for i, p := range failedPorts {
+			parts[i] = fmt.Sprintf("%d", p)
+		}
+		check.Detail = "串口打开失败: " + strings.Join(parts, ", ")
+	}
+	check.DurationMs = int(time.Since(start).Milliseconds())
+	return check
+}
+
+func (o *HealthCheckRunner) checkModbusData() protocol.HealthCheck {
+	start := time.Now()
+	check := protocol.HealthCheck{Name: "modbus"}
+
+	deviceCount := 0
+	hasData := false
+	mapRtuUploadManager.Range(func(key, value interface{}) bool {
+		mgr, ok := value.(*RtuUploadManager)
+		if !ok {
+			return true
+		}
+		deviceCount++
+		mgr.DataLock.Lock()
+		lastDataTime := mgr.Datatime
+		mgr.DataLock.Unlock()
+		if time.Since(lastDataTime) < phase2Window {
+			hasData = true
+			return false
+		}
+		return true
+	})
+
+	if deviceCount == 0 {
+		check.Status = "ok"
+		check.Detail = fmt.Sprintf("无Modbus设备配置,跳过采集检测")
+	} else if hasData {
+		check.Status = "ok"
+		check.Detail = fmt.Sprintf("设备数据采集正常(%d台设备)", deviceCount)
+	} else {
+		check.Status = "fail"
+		check.Detail = fmt.Sprintf("%d台设备5分钟内无数据上报,请检查设备配置和物模型是否正确加载", deviceCount)
+	}
+	check.DurationMs = int(time.Since(start).Milliseconds())
+	return check
+}
+
+func (o *HealthCheckRunner) reportPhase(phase int, success bool, checks []protocol.HealthCheck) {
+	var obj protocol.Pack_HealthCheck
+	seq := GetNextUint64()
+	if str, err := obj.EnCode(appConfig.GID, appConfig.GID, seq, o.marker.Version, phase, success, checks); err == nil {
+		topic := GetTopic(protocol.DT_GATEWAY, appConfig.GID, protocol.TP_GW_DEPLOY_HEALTH)
+		GetMQTTMgr().Publish(topic, str, 0, ToCloud)
+		util.GetTagLog().Infof("sys", "HealthCheck:Phase %d 结果已上报 success=%v", phase, success)
+	}
+}
+
+func (o *HealthCheckRunner) rollback(checks []protocol.HealthCheck) {
+	cwd, _ := os.Getwd()
+	targetPath := filepath.Join(cwd, appname)
+	backupPath := targetPath + ".bak"
+
+	rbMarker := DeployMarker{
+		Version:   o.marker.Version,
+		Timestamp: time.Now().Unix(),
+		Action:    "rollback",
+	}
+	rbContent, _ := json.MarshalToString(rbMarker)
+	os.WriteFile(filepath.Join(util.GetPath(0), "rollback_marker.json"), []byte(rbContent), os.ModePerm)
+
+	os.Remove(filepath.Join(util.GetPath(0), "deploy_marker.json"))
+
+	DeployResultSave(o.marker.Version, 1, false, checks)
+
+	if _, err := os.Stat(backupPath); os.IsNotExist(err) {
+		util.GetTagLog().Errorf("sys", "HealthCheck:备份文件不存在,无法自动回滚")
+		return
+	}
+
+	if err := os.Rename(backupPath, targetPath); err != nil {
+		util.GetTagLog().Errorf("sys", "HealthCheck:回滚失败,err=%v", err)
+		return
+	}
+	os.Chmod(targetPath, 0755)
+	util.GetTagLog().Infof("sys", "HealthCheck:回滚成功,退出进程")
+
+	time.Sleep(500 * time.Millisecond)
+	os.Exit(2)
+}
+
+func reportRollback(marker DeployMarker) {
+	var obj protocol.Pack_DeployAck
+	seq := GetNextUint64()
+	if str, err := obj.EnCode(appConfig.GID, appConfig.GID, seq, marker.Version, false,
+		"已自动回滚到版本 "+marker.Version); err == nil {
+		topic := GetTopic(protocol.DT_GATEWAY, appConfig.GID, protocol.TP_GW_DEPLOY_ACK)
+		GetMQTTMgr().Publish(topic, str, 0, ToCloud)
+	}
+}
+
+// ---- 部署结果持久化 ----
+
+type DeployResult struct {
+	Version   string             `json:"version"`
+	Timestamp int64              `json:"timestamp"`
+	Phase1    *DeployPhaseResult `json:"phase1,omitempty"`
+	Phase2    *DeployPhaseResult `json:"phase2,omitempty"`
+}
+
+type DeployPhaseResult struct {
+	Success bool                   `json:"success"`
+	Checks  []protocol.HealthCheck `json:"checks"`
+}
+
+var _deployResult *DeployResult
+
+func DeployResultSave(version string, phase int, success bool, checks []protocol.HealthCheck) {
+	if _deployResult == nil || _deployResult.Version != version {
+		_deployResult = &DeployResult{
+			Version:   version,
+			Timestamp: time.Now().Unix(),
+		}
+	}
+	pr := &DeployPhaseResult{Success: success, Checks: checks}
+	if phase == 1 {
+		_deployResult.Phase1 = pr
+	} else {
+		_deployResult.Phase2 = pr
+	}
+
+	content, _ := json.MarshalToString(_deployResult)
+	resultPath := filepath.Join(util.GetPath(0), "deploy_result.json")
+	os.WriteFile(resultPath, []byte(content), os.ModePerm)
+}
+
+func DeployResultGet() *DeployResult {
+	if _deployResult != nil {
+		return _deployResult
+	}
+	resultPath := filepath.Join(util.GetPath(0), "deploy_result.json")
+	data, err := os.ReadFile(resultPath)
+	if err != nil {
+		return nil
+	}
+	var result DeployResult
+	if err := json.Unmarshal(data, &result); err != nil {
+		return nil
+	}
+	_deployResult = &result
+	return &result
+}

+ 6 - 0
edge/ipole/main.go

@@ -161,6 +161,9 @@ func main() {
 	//初始化日志配置(加载持久化级别或使用默认值)
 	InitLogCfg()
 
+	// 启动健康检测(检测部署标记文件)
+	go StartHealthCheckIfNeeded()
+
 	//打开串口
 	GetSerialMgr().AddSerialPorts(serialConfig.Serial)
 
@@ -174,6 +177,9 @@ func main() {
 		GetDeviceMgr().AddDevices(devinfos)
 	}
 
+	// 启动本地部署 HTTP 服务(端口9998)
+	StartDeployHTTP()
+
 	gopool = util.NewPool(10)
 	GetMQTTMgr().SetRestartFn(func(fn func(args ...interface{}) interface{}, args ...interface{}) {
 		gopool.Add(fn, args)

+ 6 - 0
edge/ipole/mqtthandle.go

@@ -177,6 +177,11 @@ func Heartbeat(args ...interface{}) interface{} {
 	}
 }
 
+// HandleTpWDeploy 转换签名供 Subscribe 使用
+func HandleTpWDeploy(m mqtt.Message) {
+	HandleTpDeploy(m)
+}
+
 // InitCloudMqttSubscribeTopics 初始化网关级别的主题订阅及路由
 func InitCloudMqttSubscribeTopics() {
 	GetMQTTMgr().Subscribe(GetTopic(protocol.DT_GATEWAY, appConfig.GID, protocol.TP_GW_APP), mqtt.AtMostOnce, HandleTpQApp, ToCloud)
@@ -191,4 +196,5 @@ func InitCloudMqttSubscribeTopics() {
 	GetMQTTMgr().Subscribe(GetTopic(protocol.DT_GATEWAY, appConfig.GID, protocol.TP_GW_REMOVE_LOG), mqtt.AtMostOnce, HandleTpRLog, ToCloud)
 	GetMQTTMgr().Subscribe(GetTopic(protocol.DT_GATEWAY, appConfig.GID, protocol.TP_GW_LOG_CFG), mqtt.AtMostOnce, HandleTpLogCfg, ToCloud)
 	GetMQTTMgr().Subscribe(GetTopic(protocol.DT_GATEWAY, appConfig.GID, protocol.TP_GW_SYS), mqtt.AtMostOnce, HandleTpQSys, ToCloud)
+	GetMQTTMgr().Subscribe(GetTopic(protocol.DT_GATEWAY, appConfig.GID, protocol.TP_GW_DEPLOY), mqtt.AtMostOnce, HandleTpWDeploy, ToCloud)
 }

+ 12 - 0
edge/ipole/serialmgr.go

@@ -120,3 +120,15 @@ func (o *SerialMgr) GetSerialPort(code uint8) *serialPort {
 	}
 	return nil
 }
+
+// GetFailedPorts 返回打开失败的串口号列表(供健康检测使用)
+func (o *SerialMgr) GetFailedPorts() []uint8 {
+	var failed []uint8
+	for portNum := range serialConfig.Serial {
+		addr := o.GetSerialPort(portNum)
+		if addr == nil {
+			failed = append(failed, portNum)
+		}
+	}
+	return failed
+}