Bläddra i källkod

feat:故障录波增加MPPT和aHub设备支持

Jerry Liang 2 veckor sedan
förälder
incheckning
4e52dae82b

+ 1 - 0
docs/fault-record/protocol-specification.md

@@ -431,6 +431,7 @@ https://<环境域名>/hes-upload/v1/file/download/logger/<equipNo>/<609.fileNam
 | 位置/来源                       | 已出现的值                             | 说明                                                                       |
 | -------------------------------- | -------------------------------------- | -------------------------------------------------------------------------- |
 | FaultFilter 文档                 | `pe`、`aGate`                      | 业务层仅定义这两种;`pe` 按 SN 寻址(最多 15 台),`aGate` 系统内唯一 |
+| 用户确认(2026-10-20)           | `pe`、`aGate`、`mppt`、`apbox`、`dif` | 377 `type` 全集:`mppt`=aPower-MPPT(与 aPower 同 SN)、`apbox`=aHub(aPbox2.0)、`dif`=Digital Interface(前端暂未开放) |
 | `377/379.list[].type` 样本     | `aGate`                              | `cmd=0/2`                                                                |
 | `377.list[].type` 已验证代码   | `pe`、`aGate`                      | 参数设置                                                                   |
 | `377.list[].type` 旧描述       | `pe`、`agate`、`mppt`、`apbox` | FaultFilter 文档未定义`agate`(小写)、`mppt`、`apbox`,不应使用  |

+ 1 - 1
docs/fault-record/requirements.md

@@ -68,7 +68,7 @@
 - **多条获取**:一次可获取多条录波记录,设备打成一个包(2026-10-19 用户确认,替换原“每次 1 条”)。
 - **设备忙**:本工具内对同一网关串行;设备因其他来源(如 FWH 平台网页)回复 `busy` 时,明确提示用户,不自动重试。
 - **取消**:进行中的操作可取消,必要时向设备发送 `cmd=4`(退出)结束当前处理。
-- **设备列表**:pe 子设备 SN 即 aPower SN,从站点信息中获取;生产服和测试服调用各自的接口。
+- **设备列表**:支持 aGate/MCA、aPower-PE、aPower-MPPT、aHub(即 aPbox)四种设备,来源与判断规则见 [technical-design.md](technical-design.md#5-设备与站点信息);生产服和测试服调用各自的接口。377 `type` 分别为 `aGate`、`pe`、`mppt`、`apbox`;另有 `dif`(Digital Interface)暂未开放。
 - **时间**:目录和录波中的时间暂按设备时间原样显示,测试有问题再改。
 - **记录**:操作过程记录在数据库中,原始文件存 MinIO;操作历史所有人共享,并记录操作人。暂不实现数据清理。
 - **权限**:功能全部开放;参数设置提交前必须弹窗确认,危险操作醒目标注。

+ 28 - 4
docs/fault-record/technical-design.md

@@ -158,10 +158,34 @@ asynq 会把服务重启时未完成的任务重新投递。执行器先读 Mong
 
 ## 5. 设备与站点信息
 
-- 用户输入网关 SN,Service 按环境调用 `fwh_pro.QuerySiteInfoList` 或 `fwh_test.QuerySiteInfoList` 获取站点信息。
-- 可选设备为:`aGate`(网关 SN)+ `fhpSn` 中逗号分隔的每个 aPower SN。**aPower SN 就是 `pe` SN**(用户确认,2026-10-16)。
-- 生产服和测试服是两个不同的接口(`fwh_pro` 与 `fwh_test`),必须按操作所属环境调用,不能用生产服站点信息去操作测试服设备,反之亦然。
-- 只调用 Repository 层接口,不复用 `services.GetSiteInfoData`:后者会把结果写入站点信息表,测试服数据不能写入。
+设备列表按操作所属环境(`fwh_pro` 或 `fwh_test`)查询,不复用 `services.GetSiteInfoData`(会写入站点信息表)。设备种类与平台设备类型对应(用户确认,2026-10-20):
+
+| 平台设备类型 | 名称 | 种类(代码) | SN 来源 | 377 `type` |
+| --- | --- | --- | --- | --- |
+| 1 | aGate/MCA | `aGate` | 网关 SN | `aGate` |
+| 0 | aPower-PE | `aPowerPE` | `/hes-gateway/manage/getDeviceInfo` 的 `fhpSn`(逗号分隔) | `pe` |
+| 2 | aPower-MPPT | `aPowerMPPT` | 与 aPower-PE 同一 SN;仅 PE 硬件版本为 21、24 的 aPower 支持 | `mppt` |
+| 3 | aHub(即 aPbox2.0) | `aHub` | `/hes-gateway/manage/selectGatewayPower` 的 `apbox20List[].apbox20Sn` | `apbox` |
+| — | DIF(Digital Interface) | — | 暂未知 | `dif`(后端协议层支持,前端暂未开放) |
+
+- `getDeviceInfo` 中 `peHwVerList`、`apowerList` 与 `fhpSn` 的 aPower 顺序一一对应;PE 硬件版本 21(aPower S)、24(aPower 2.1S)带 MPPT,后续可能扩展(集中在 `FaultRecordProtocol` 的 `SupportsMPPT`)。
+- 站点名称和在线状态仍取自站点信息接口(`QuerySiteInfoList`)。
+- `getDeviceInfo` 失败时退回站点信息的 `fhpSn`,无法判断 aPower-MPPT;`selectGatewayPower` 失败时不列出 aHub。两种情况都在页面提示。
+- 377 中的设备类型共 5 种:`pe`、`aGate`、`mppt`、`apbox`、`dif`(用户确认,2026-10-20)。
+- 样本:[设备信息.test.franklinwh.com.har](设备信息.test.franklinwh.com.har)(测试服网关 `10070052S00X25150083`:2 台 aPower,第 2 台为 aPower S,1 台 aHub)。
+
+PE 硬件版本对照(格式:版本值 - 版本名称 - 对外名称;代码中使用版本名称):
+
+| 值 | 版本名称 | 对外名称 |
+| --- | --- | --- |
+| 0 | aPower 1.0 | aPower X |
+| 2 | aPower 1.2(澳洲)/aPower 1.3(北美) | aPower X |
+| 4 | aPower 1.4 | aPower X |
+| 20 | aPower 2/aPower 2(LR) | aPower 2/aPower 2(LR) |
+| 21 | aPower S | aPower S |
+| 22 | aPower 2(SH) | aPower 2(SH) |
+| 23 | aPower 2.1 | aPower 2.1 |
+| 24 | aPower 2.1S | aPower 2.1S |
 
 ## 6. 测试服与生产服
 

+ 1 - 1
service/domain/batchDownloadDomain.go

@@ -79,7 +79,7 @@ type BatchDownloadData struct {
 
 // 下载任务数据 (保存于redis数据库)
 type BatchDownloadFileTaskInfo struct {
-	DevType     int    `bson:"devType" json:"devType"` // 设备类型:-1-忽略 0-aPower 1-aGate 2-MPPT 3-aHub
+	DevType     int    `bson:"devType" json:"devType"` // 设备类型:-1-忽略 0-aPower-PE 1-aGate/MCA 2-aPower-MPPT 3-aHub
 	Sn          string `bson:"sn" json:"sn"`
 	Date        string `bson:"date" json:"date"`
 	FileType    string `bson:"fileType" json:"fileType"`

+ 37 - 2
service/domain/fwhProDomain.go

@@ -92,7 +92,7 @@ type EmsLogRecordInfo struct {
 
 type FaultRecordInfo struct {
 	Id                int64   `json:"id"`
-	DeviceType        int     `json:"deviceType"`        // 0-aPower 1-aGate 2-MPPT 3-aHub
+	DeviceType        int     `json:"deviceType"`        // 0-aPower-PE 1-aGate/MCA 2-aPower-MPPT 3-aHub
 	ApowerId          *string `json:"apowerId"`          // aPower SN, deviceType不为0或2时为null
 	GatewayId         string  `json:"gatewayId"`         // aGate SN
 	PowerlinkId       *string `json:"powerlinkId"`       // aHub SN, deviceType不为3时为null
@@ -122,7 +122,7 @@ type FaultRecordChannelInfo struct {
 }
 
 type FaultRecordSubInfo struct {
-	DeviceType             int                      `json:"deviceType"` // 0-aPower 1-aGate 2-MPPT 3-aHub
+	DeviceType             int                      `json:"deviceType"` // 0-aPower-PE 1-aGate/MCA 2-aPower-MPPT 3-aHub
 	ApowerId               *string                  `json:"apowerId"`   // aPower SN
 	GatewayId              string                   `json:"gatewayId"`  // aGate SN
 	FileName               string                   `json:"fileName"`   // 不含后缀
@@ -333,3 +333,38 @@ type FwhSiteInfo struct {
 	SiteId  *uint   `json:"siteId"`  // 站点ID号
 	JobCode *string `json:"jobCode"` // Job Code (Sunrun相关)
 }
+
+/*************** 设备信息 /hes-gateway/manage/getDeviceInfo ***************/
+
+// FwhDeviceInfoAPower getDeviceInfo.apowerList 项
+type FwhDeviceInfoAPower struct {
+	Id         string `json:"id"`         // aPower SN
+	RatedPwr   *int   `json:"ratedPwr"`   // 额定功率
+	RateBatCap *int   `json:"rateBatCap"` // 额定电池容量
+	ModelName  string `json:"modelName"`  // 型号,如 aPower 2、aPower S
+}
+
+// FwhDeviceInfo 网关设备信息(只解析故障录波需要的字段)
+type FwhDeviceInfo struct {
+	FhpSn       string                `json:"fhpSn"`       // aPower SN,多设备用 , 分割
+	PeHwVerList []int                 `json:"peHwVerList"` // 各 aPower 的 PE 硬件版本,顺序与 fhpSn 一致;21、24 支持 MPPT
+	ApowerList  []FwhDeviceInfoAPower `json:"apowerList"`  // 各 aPower 信息
+	MpptEnFlag  *bool                 `json:"mpptEnFlag"`  // 站点是否启用 MPPT
+	Apbox20Num  *int                  `json:"apbox20Num"`  // aHub(aPbox2.0) 数量
+	EquipType   *int                  `json:"equipType"`
+}
+
+/*************** 网关功率 /hes-gateway/manage/selectGatewayPower ***************/
+
+// FwhApbox20Info selectGatewayPower.apbox20List 项(aHub 即 aPbox2.0)
+type FwhApbox20Info struct {
+	Apbox20Sn          string `json:"apbox20Sn"`          // aHub SN
+	Apbox20HardwareVer *int   `json:"apbox20HardwareVer"` // 硬件版本
+	Apbox20AppVer      string `json:"apbox20AppVer"`      // 软件版本
+}
+
+// FwhGatewayPower 网关功率信息(只解析故障录波需要的字段)
+type FwhGatewayPower struct {
+	Apbox20List []FwhApbox20Info `json:"apbox20List"`
+	MpptSta     []int            `json:"mpptSta"` // 各 aPower 的 MPPT 状态,顺序与 aPower 一致
+}

+ 8 - 2
service/dto/faultRecordDTO.go

@@ -23,8 +23,13 @@ type FaultRecordCreateOpReq struct {
 
 // FaultRecordDevice 可操作的设备
 type FaultRecordDevice struct {
-	Type string `json:"type"` // aGate / pe
-	Sn   string `json:"sn"`
+	Kind      string `json:"kind"`            // aGate / aPowerPE / aPowerMPPT / aHub
+	Type      string `json:"type"`            // 377 报文中的 type(aGate / pe);为空表示协议类型待确认
+	Sn        string `json:"sn"`              // aPower-MPPT 与 aPower-PE 使用同一 SN
+	Model     string `json:"model,omitempty"` // 型号,如 aPower S
+	HwVer     *int   `json:"hwVer,omitempty"` // aPower PE 硬件版本
+	Supported bool   `json:"supported"`       // 是否可发起操作
+	Note      string `json:"note,omitempty"`  // 不可用原因
 }
 
 // FaultRecordSiteDevices 网关及其设备
@@ -35,6 +40,7 @@ type FaultRecordSiteDevices struct {
 	Online     bool                `json:"online"`
 	OnlineTime string              `json:"onlineTime,omitempty"`
 	Devices    []FaultRecordDevice `json:"devices"`
+	Warnings   []string            `json:"warnings,omitempty"` // 部分设备信息获取失败时的提示
 }
 
 // FaultRecordOpSummary 操作历史列表项

+ 34 - 0
service/function/FaultRecordProtocol/dict.go

@@ -24,6 +24,40 @@ var DangerousFunctionCodes = map[int]string{
 	4: "修改设备时间",
 }
 
+// 设备种类(界面与平台使用;协议 type 见 DeviceKindProtocolType)
+const (
+	DeviceKindAGate      = "aGate"      // aGate/MCA,平台设备类型 1
+	DeviceKindAPowerPE   = "aPowerPE"   // aPower-PE,平台设备类型 0
+	DeviceKindAPowerMPPT = "aPowerMPPT" // aPower-MPPT,平台设备类型 2,SN 与 aPower 相同
+	DeviceKindAHub       = "aHub"       // aHub(即 aPbox2.0),平台设备类型 3
+)
+
+// DeviceKindProtocolType 设备种类对应 377 报文中的 type(用户确认,2026-10-20);空字符串表示暂不支持
+var DeviceKindProtocolType = map[string]string{
+	DeviceKindAGate:      DeviceTypeAGate,
+	DeviceKindAPowerPE:   DeviceTypePE,
+	DeviceKindAPowerMPPT: DeviceTypeMPPT,
+	DeviceKindAHub:       DeviceTypeAPbox,
+}
+
+// PeHwVerNames aPower PE 硬件版本 → 版本名称(内部名称)
+var PeHwVerNames = map[int]string{
+	0:  "aPower 1.0",
+	2:  "aPower 1.2(澳洲)/aPower 1.3(北美)",
+	4:  "aPower 1.4",
+	20: "aPower 2/aPower 2(LR)",
+	21: "aPower S",
+	22: "aPower 2(SH)",
+	23: "aPower 2.1",
+	24: "aPower 2.1S",
+}
+
+// mpptPeHwVers 支持 aPower-MPPT 的 PE 硬件版本(目前只有 aPower S、aPower 2.1S,后续可能扩展)
+var mpptPeHwVers = map[int]bool{21: true, 24: true}
+
+// SupportsMPPT 该 PE 硬件版本的 aPower 是否带 MPPT
+func SupportsMPPT(peHwVer int) bool { return mpptPeHwVers[peHwVer] }
+
 // RealtimeCodeNames 实时数据类别,FaultFilter 文档 5.1
 var RealtimeCodeNames = map[int]string{
 	1: "故障总体信息",

+ 11 - 2
service/function/FaultRecordProtocol/protocol.go

@@ -35,8 +35,11 @@ const (
 
 // 业务层设备类型
 const (
-	DeviceTypePE    = "pe"
-	DeviceTypeAGate = "aGate"
+	DeviceTypePE    = "pe"    // aPower-PE
+	DeviceTypeAGate = "aGate" // aGate/MCA
+	DeviceTypeMPPT  = "mppt"  // aPower-MPPT
+	DeviceTypeAPbox = "apbox" // aHub(aPbox2.0)
+	DeviceTypeDIF   = "dif"   // Digital Interface,前端暂未开放
 )
 
 // 整体结果
@@ -53,6 +56,12 @@ func NormalizeDeviceType(t string) (string, error) {
 		return DeviceTypePE, nil
 	case "agate":
 		return DeviceTypeAGate, nil
+	case "mppt":
+		return DeviceTypeMPPT, nil
+	case "apbox":
+		return DeviceTypeAPbox, nil
+	case "dif":
+		return DeviceTypeDIF, nil
 	default:
 		return "", fmt.Errorf("unknown device type: %s", t)
 	}

+ 6 - 1
service/function/FaultRecordProtocol/protocol_test.go

@@ -116,9 +116,14 @@ func TestBuildRequests(t *testing.T) {
 	if _, err := NewRealtimeRequest("pe", "X", []int{4}); err == nil {
 		t.Error("code=4 should be rejected for cmd=0")
 	}
-	if _, err := NewGetRecordRequest("mppt", "X", []int{1}); err == nil {
+	if _, err := NewGetRecordRequest("mpt", "X", []int{1}); err == nil {
 		t.Error("unknown device type should be rejected")
 	}
+	for in, want := range map[string]string{"pe": "pe", "AGATE": "aGate", "MPPT": "mppt", "apbox": "apbox", "dif": "dif"} {
+		if got, err := NormalizeDeviceType(in); err != nil || got != want {
+			t.Errorf("NormalizeDeviceType(%q) = %q, %v", in, got, err)
+		}
+	}
 	req, _ = NewGetRecordRequest("aGate", "X", []int{7, 9, 3})
 	if b, _ = json.Marshal(req); !strings.Contains(string(b), `"index":[7,9,3]`) {
 		t.Errorf("multi-record request: %s", b)

+ 38 - 0
service/repositories/api/fwh_pro/fwh_deviceInfo.go

@@ -0,0 +1,38 @@
+package fwh_pro
+
+import (
+	"fmt"
+	"net/url"
+
+	"fwh-toolbox-server/domain"
+)
+
+// QueryDeviceInfo 查询网关设备信息(/hes-gateway/manage/getDeviceInfo),用于获取 aPower 列表及各自的 PE 硬件版本
+func QueryDeviceInfo(gatewayId string) (*domain.FwhDeviceInfo, error) {
+	params := url.Values{}
+	params.Add("gatewayId", gatewayId)
+	params.Add("lang", "ZH_CN")
+	res, err := FwhHttpGetUnmarshal[domain.FwhDeviceInfo](getFullEnergyUrl("/hes-gateway/manage/getDeviceInfo"), params)
+	if err != nil {
+		return nil, err
+	}
+	if res.Code != 200 || res.Result == nil {
+		return nil, fmt.Errorf("[QueryDeviceInfo] 查询失败 code: %d, message: %s", res.Code, res.Message)
+	}
+	return res.Result, nil
+}
+
+// QueryGatewayPower 查询网关功率信息(/hes-gateway/manage/selectGatewayPower),用于获取 aHub(aPbox2.0) 列表
+func QueryGatewayPower(gatewayId string) (*domain.FwhGatewayPower, error) {
+	params := url.Values{}
+	params.Add("gatewayId", gatewayId)
+	params.Add("lang", "ZH_CN")
+	res, err := FwhHttpGetUnmarshal[domain.FwhGatewayPower](getFullEnergyUrl("/hes-gateway/manage/selectGatewayPower"), params)
+	if err != nil {
+		return nil, err
+	}
+	if res.Code != 200 || res.Result == nil {
+		return nil, fmt.Errorf("[QueryGatewayPower] 查询失败 code: %d, message: %s", res.Code, res.Message)
+	}
+	return res.Result, nil
+}

+ 1 - 1
service/repositories/api/fwh_pro/fwh_faultRecord.go

@@ -99,7 +99,7 @@ func queryFaultRecordCallableList(devType int, sn string) (*domain.FwhResponse[[
 }
 
 // 查询设备可召唤数据列表 - 仅自动录播
-// devType: 0-aPower 1-aGate 2-MPPT 3-aHub
+// devType: 0-aPower-PE 1-aGate/MCA 2-aPower-MPPT 3-aHub
 func QueryFaultRecordCallableList(devType int, sn string) ([]domain.FaultRecordCallableInfo, error) {
 	faultRecordCallableResp, err := queryFaultRecordCallableList(devType, sn)
 	if err != nil {

+ 38 - 0
service/repositories/api/fwh_test/fwh_deviceInfo.go

@@ -0,0 +1,38 @@
+package fwh_test
+
+import (
+	"fmt"
+	"net/url"
+
+	"fwh-toolbox-server/domain"
+)
+
+// QueryDeviceInfo 查询网关设备信息(/hes-gateway/manage/getDeviceInfo),用于获取 aPower 列表及各自的 PE 硬件版本
+func QueryDeviceInfo(gatewayId string) (*domain.FwhDeviceInfo, error) {
+	params := url.Values{}
+	params.Add("gatewayId", gatewayId)
+	params.Add("lang", "ZH_CN")
+	res, err := FwhHttpGetUnmarshal[domain.FwhDeviceInfo](getFullEnergyUrl("/hes-gateway/manage/getDeviceInfo"), params)
+	if err != nil {
+		return nil, err
+	}
+	if res.Code != 200 || res.Result == nil {
+		return nil, fmt.Errorf("[QueryDeviceInfo] 查询失败 code: %d, message: %s", res.Code, res.Message)
+	}
+	return res.Result, nil
+}
+
+// QueryGatewayPower 查询网关功率信息(/hes-gateway/manage/selectGatewayPower),用于获取 aHub(aPbox2.0) 列表
+func QueryGatewayPower(gatewayId string) (*domain.FwhGatewayPower, error) {
+	params := url.Values{}
+	params.Add("gatewayId", gatewayId)
+	params.Add("lang", "ZH_CN")
+	res, err := FwhHttpGetUnmarshal[domain.FwhGatewayPower](getFullEnergyUrl("/hes-gateway/manage/selectGatewayPower"), params)
+	if err != nil {
+		return nil, err
+	}
+	if res.Code != 200 || res.Result == nil {
+		return nil, fmt.Errorf("[QueryGatewayPower] 查询失败 code: %d, message: %s", res.Code, res.Message)
+	}
+	return res.Result, nil
+}

+ 1 - 1
service/repositories/api/fwh_test/fwh_faultRecord.go

@@ -86,7 +86,7 @@ func queryFaultRecordCallableList(devType int, sn string) (*domain.FwhResponse[[
 }
 
 // 查询设备可召唤数据列表 - 仅自动录播
-// devType: 0-aPower 1-aGate 2-MPPT 3-aHub
+// devType: 0-aPower-PE 1-aGate/MCA 2-aPower-MPPT 3-aHub
 func QueryFaultRecordCallableList(devType int, sn string) ([]domain.FaultRecordCallableInfo, error) {
 	faultRecordCallableResp, err := queryFaultRecordCallableList(devType, sn)
 	if err != nil {

+ 84 - 0
service/services/faultRecordDevices_test.go

@@ -0,0 +1,84 @@
+package services
+
+import (
+	"encoding/json"
+	"os"
+	"strings"
+	"testing"
+
+	"fwh-toolbox-server/domain"
+)
+
+const deviceInfoHar = "../../docs/fault-record/设备信息.test.franklinwh.com.har"
+
+// 从 HAR 中取出指定接口的 result
+func harResult[T any](t *testing.T, api string) *T {
+	t.Helper()
+	b, err := os.ReadFile(deviceInfoHar)
+	if err != nil {
+		t.Skipf("har not found: %v", err)
+	}
+	var har struct {
+		Log struct {
+			Entries []struct {
+				Request  struct{ URL string } `json:"request"`
+				Response struct {
+					Content struct{ Text string } `json:"content"`
+				} `json:"response"`
+			} `json:"entries"`
+		} `json:"log"`
+	}
+	if err := json.Unmarshal(b, &har); err != nil {
+		t.Fatal(err)
+	}
+	for _, e := range har.Log.Entries {
+		if strings.Contains(e.Request.URL, "/manage/"+api) {
+			var res domain.FwhResponse[T]
+			if err := json.Unmarshal([]byte(e.Response.Content.Text), &res); err != nil {
+				t.Fatal(err)
+			}
+			return res.Result
+		}
+	}
+	t.Fatalf("%s not in har", api)
+	return nil
+}
+
+func TestBuildFaultRecordDevicesFromHar(t *testing.T) {
+	devInfo := harResult[domain.FwhDeviceInfo](t, "getDeviceInfo")
+	power := harResult[domain.FwhGatewayPower](t, "selectGatewayPower")
+
+	got := BuildFaultRecordDevices("10070052S00X25150083", devInfo, power)
+	var lines []string
+	for _, d := range got {
+		ver := -1
+		if d.HwVer != nil {
+			ver = *d.HwVer
+		}
+		lines = append(lines, d.Kind+"|"+d.Sn+"|"+d.Model+"|"+map[bool]string{true: "ok", false: "pending"}[d.Supported]+"|"+string(rune('0'+ver%10)))
+		t.Logf("%-10s %-22s %-9s type=%-5q supported=%v hwVer=%d", d.Kind, d.Sn, d.Model, d.Type, d.Supported, ver)
+	}
+	want := []string{
+		"aGate|10070052S00X25150083||ok",
+		"aPowerPE|10050015S00W24240002|aPower 2|ok",
+		"aPowerPE|10050019S01X25350643|aPower S|ok",
+		"aPowerMPPT|10050019S01X25350643|aPower S|ok", // 版本 21 支持 MPPT
+		"aHub|10070053S00X25270092||ok",
+	}
+	if len(got) != len(want) {
+		t.Fatalf("devices = %d, want %d", len(got), len(want))
+	}
+	for i, w := range want {
+		if !strings.HasPrefix(lines[i], w) {
+			t.Errorf("device %d = %s, want prefix %s", i, lines[i], w)
+		}
+	}
+}
+
+func TestBuildFaultRecordDevicesFallback(t *testing.T) {
+	// getDeviceInfo 失败时退回站点信息的 fhpSn:没有硬件版本,不生成 aPower-MPPT
+	got := BuildFaultRecordDevices("GW", &domain.FwhDeviceInfo{FhpSn: "A1, A2"}, nil)
+	if len(got) != 3 || got[1].Sn != "A1" || got[2].Sn != "A2" || got[2].HwVer != nil {
+		t.Fatalf("devices = %+v", got)
+	}
+}

+ 65 - 5
service/services/faultRecordServices.go

@@ -70,7 +70,6 @@ func GetFaultRecordSiteDevices(envStr, gatewaySn string) (*dto.FaultRecordSiteDe
 		Env:       string(env),
 		GatewaySn: site.Id,
 		Online:    site.Status == 1,
-		Devices:   []dto.FaultRecordDevice{{Type: proto.DeviceTypeAGate, Sn: site.Id}},
 	}
 	if site.Name != nil {
 		out.SiteName = *site.Name
@@ -78,14 +77,75 @@ func GetFaultRecordSiteDevices(envStr, gatewaySn string) (*dto.FaultRecordSiteDe
 	if site.OnLineTime != nil {
 		out.OnlineTime = *site.OnLineTime
 	}
-	for _, sn := range strings.Split(site.FhpSn, ",") {
-		if sn = strings.TrimSpace(sn); sn != "" {
-			out.Devices = append(out.Devices, dto.FaultRecordDevice{Type: proto.DeviceTypePE, Sn: sn}) // aPower SN 即 pe SN
-		}
+
+	// aPower 及 PE 硬件版本来自 getDeviceInfo;aHub 来自 selectGatewayPower
+	var devInfo *domain.FwhDeviceInfo
+	var power *domain.FwhGatewayPower
+	var devErr, powerErr error
+	if env == fwh_mqtt.EnvTest {
+		devInfo, devErr = fwh_test.QueryDeviceInfo(site.Id)
+		power, powerErr = fwh_test.QueryGatewayPower(site.Id)
+	} else {
+		devInfo, devErr = fwh_pro.QueryDeviceInfo(site.Id)
+		power, powerErr = fwh_pro.QueryGatewayPower(site.Id)
+	}
+	if devErr != nil {
+		out.Warnings = append(out.Warnings, "获取 aPower 硬件版本失败,无法判断 aPower-MPPT:"+devErr.Error())
+		devInfo = &domain.FwhDeviceInfo{FhpSn: site.FhpSn} // 退回站点信息中的 aPower 列表
 	}
+	if powerErr != nil {
+		out.Warnings = append(out.Warnings, "获取 aHub 列表失败:"+powerErr.Error())
+	}
+	out.Devices = BuildFaultRecordDevices(site.Id, devInfo, power)
 	return out, nil
 }
 
+// BuildFaultRecordDevices 生成可操作的设备列表:aGate/MCA、各 aPower-PE、支持 MPPT 的 aPower-MPPT、各 aHub。
+// peHwVerList、apowerList 与 fhpSn 中的 aPower 顺序一一对应;PE 硬件版本为 21、24 的 aPower 带 MPPT。
+func BuildFaultRecordDevices(gatewaySn string, devInfo *domain.FwhDeviceInfo, power *domain.FwhGatewayPower) []dto.FaultRecordDevice {
+	newDevice := func(kind, sn string) dto.FaultRecordDevice {
+		d := dto.FaultRecordDevice{Kind: kind, Type: proto.DeviceKindProtocolType[kind], Sn: sn}
+		d.Supported = d.Type != ""
+		if !d.Supported {
+			d.Note = "该设备类型暂不支持"
+		}
+		return d
+	}
+
+	devices := []dto.FaultRecordDevice{newDevice(proto.DeviceKindAGate, gatewaySn)}
+	if devInfo != nil {
+		i := 0
+		for _, sn := range strings.Split(devInfo.FhpSn, ",") {
+			if sn = strings.TrimSpace(sn); sn == "" {
+				continue
+			}
+			pe := newDevice(proto.DeviceKindAPowerPE, sn)
+			if i < len(devInfo.ApowerList) && devInfo.ApowerList[i].Id == sn {
+				pe.Model = devInfo.ApowerList[i].ModelName
+			}
+			if i < len(devInfo.PeHwVerList) {
+				ver := devInfo.PeHwVerList[i]
+				pe.HwVer = &ver
+			}
+			devices = append(devices, pe)
+			if pe.HwVer != nil && proto.SupportsMPPT(*pe.HwVer) {
+				mppt := newDevice(proto.DeviceKindAPowerMPPT, sn)
+				mppt.Model, mppt.HwVer = pe.Model, pe.HwVer
+				devices = append(devices, mppt)
+			}
+			i++
+		}
+	}
+	if power != nil {
+		for _, a := range power.Apbox20List {
+			if sn := strings.TrimSpace(a.Apbox20Sn); sn != "" {
+				devices = append(devices, newDevice(proto.DeviceKindAHub, sn))
+			}
+		}
+	}
+	return devices
+}
+
 /************** 创建/取消 **************/
 
 // CreateFaultRecordOperation 校验并创建操作,提交异步执行

+ 12 - 3
src/service/faultRecord/interface.ts

@@ -1,6 +1,7 @@
 export type FaultRecordEnv = 'prod' | 'test'
 export type FaultRecordKind = 'device_info' | 'record_fetch' | 'param_read' | 'param_write'
-export type FaultRecordDeviceType = 'aGate' | 'pe'
+/** 377 报文中的设备类型;dif 为 Digital Interface,前端暂未开放 */
+export type FaultRecordDeviceType = 'pe' | 'aGate' | 'mppt' | 'apbox' | 'dif'
 
 export type FaultRecordStatus =
   | 'pending'
@@ -41,9 +42,16 @@ export interface FaultRecordCreateReq {
   paraList?: FaultRecordParaItem[]
 }
 
+export type FaultRecordDeviceKind = 'aGate' | 'aPowerPE' | 'aPowerMPPT' | 'aHub'
+
 export interface FaultRecordDevice {
-  type: FaultRecordDeviceType
-  sn: string
+  kind: FaultRecordDeviceKind
+  type: FaultRecordDeviceType // 377 报文中的 type
+  sn: string // aPower-MPPT 与 aPower-PE 使用同一 SN
+  model?: string
+  hwVer?: number // aPower PE 硬件版本
+  supported: boolean
+  note?: string
 }
 
 export interface FaultRecordSiteDevices {
@@ -53,6 +61,7 @@ export interface FaultRecordSiteDevices {
   online: boolean
   onlineTime?: string
   devices: FaultRecordDevice[]
+  warnings?: string[]
 }
 
 export interface FaultRecordOpSummary {

+ 19 - 8
src/views/fault-record/common/DeviceSelector.vue

@@ -31,7 +31,10 @@
         {{ site.online ? '在线' : '离线' }}
       </NTag>
       <span v-if="!site.online && site.onlineTime">最后上线 {{ site.onlineTime }}</span>
-      <span>aPower × {{ site.devices.length - 1 }}</span>
+      <span>{{ deviceSummary }}</span>
+    </div>
+    <div v-if="site?.warnings?.length" class="text-xs text-amber-600 w-full">
+      <div v-for="(w, i) in site.warnings" :key="i">⚠ {{ w }}</div>
     </div>
   </div>
 </template>
@@ -47,7 +50,7 @@ import type {
   FaultRecordSiteDevices,
 } from '@/service/faultRecord/interface'
 
-import { ENV_LABEL, errText } from './constants'
+import { DEVICE_KIND_LABEL, ENV_LABEL, errText } from './constants'
 
 const props = defineProps<{ env: FaultRecordEnv }>()
 const gatewaySn = defineModel<string>('gatewaySn', { default: '' })
@@ -81,15 +84,23 @@ const recentOptions = computed(() =>
 )
 
 /** ************ 设备 **************/
-const deviceKey = computed(() => (device.value ? `${device.value.type}:${device.value.sn}` : null))
+// aPower-MPPT 与 aPower-PE 同 SN,按“种类:SN”区分
+const keyOf = (d: FaultRecordDevice) => `${d.kind}:${d.sn}`
+const deviceKey = computed(() => (device.value ? keyOf(device.value) : null))
 const deviceOptions = computed(() =>
   (site.value?.devices ?? []).map((d) => ({
-    label: d.type === 'aGate' ? `aGate  ${d.sn}` : `pe(aPower)  ${d.sn}`,
-    value: `${d.type}:${d.sn}`,
+    label: `${DEVICE_KIND_LABEL[d.kind] ?? d.kind}  ${d.sn}${d.model ? `(${d.model})` : ''}${d.supported ? '' : `  - ${d.note ?? '暂不可用'}`}`,
+    value: keyOf(d),
+    disabled: !d.supported,
   })),
 )
+const deviceSummary = computed(() => {
+  const count = (kind: string) => (site.value?.devices ?? []).filter((d) => d.kind === kind).length
+  return `aPower × ${count('aPowerPE')},aPower-MPPT × ${count('aPowerMPPT')},aHub × ${count('aHub')}`
+})
+
 function selectDevice(key: string) {
-  device.value = site.value?.devices.find((d) => `${d.type}:${d.sn}` === key)
+  device.value = site.value?.devices.find((d) => keyOf(d) === key)
 }
 
 /** 选择历史项时,select 事件早于输入框值的更新,需直接使用选中值 */
@@ -111,8 +122,8 @@ async function query() {
     site.value = await getFaultRecordDevices(props.env, sn)
     gatewaySn.value = site.value.gatewaySn
     // 保留已选设备(如从参数读取页带入),否则默认 aGate
-    const keep = site.value.devices.find((d) => d.type === device.value?.type && d.sn === device.value?.sn)
-    device.value = keep ?? site.value.devices[0]
+    const keep = site.value.devices.find((d) => d.supported && device.value && keyOf(d) === keyOf(device.value))
+    device.value = keep ?? site.value.devices.find((d) => d.supported)
     saveRecent(site.value.gatewaySn)
   } catch (e) {
     site.value = undefined

+ 2 - 2
src/views/fault-record/common/OperationHistory.vue

@@ -31,7 +31,7 @@ import type {
   FaultRecordOpSummary,
 } from '@/service/faultRecord/interface'
 
-import { KIND_LABEL, STATUS_META, errText, fmtTime } from './constants'
+import { KIND_LABEL, STATUS_META, deviceTypeLabel, errText, fmtTime } from './constants'
 
 const props = defineProps<{
   env: FaultRecordEnv
@@ -67,7 +67,7 @@ const columns: DataTableColumns<FaultRecordOpSummary> = [
   { title: '时间', key: 'createdAt', width: 160, render: (r) => fmtTime(r.createdAt) },
   { title: '操作人', key: 'operator', width: 110, ellipsis: { tooltip: true } },
   { title: '网关', key: 'gatewaySn', width: 180, ellipsis: { tooltip: true } },
-  { title: '设备', key: 'deviceSn', width: 200, render: (r) => `${r.deviceType} ${r.deviceSn}` },
+  { title: '设备', key: 'deviceSn', width: 200, render: (r) => `${deviceTypeLabel(r.deviceType)} ${r.deviceSn}` },
   { title: '操作', key: 'kind', width: 90, render: (r) => KIND_LABEL[r.kind] },
   { title: '参数', key: 'summary', ellipsis: { tooltip: true } },
   {

+ 2 - 2
src/views/fault-record/common/OperationProgress.vue

@@ -3,7 +3,7 @@
     <div class="flex flex-wrap gap-2 items-center">
       <NTag :type="STATUS_META[detail.status].type">{{ STATUS_META[detail.status].label }}</NTag>
       <span class="text-sm text-gray-500">
-        {{ KIND_LABEL[detail.kind] }} · {{ detail.deviceType }} {{ detail.deviceSn }} · {{ detail.summary }}
+        {{ KIND_LABEL[detail.kind] }} · {{ deviceTypeLabel(detail.deviceType) }} {{ detail.deviceSn }} · {{ detail.summary }}
         · {{ detail.operator }} · {{ fmtTime(detail.createdAt) }}
       </span>
       <div class="flex-1" />
@@ -91,7 +91,7 @@ import {
 
 import type { FaultRecordOpDetail } from '@/service/faultRecord/interface'
 
-import { KIND_LABEL, KIND_STAGES, STATUS_META, errText, fmtTime } from './constants'
+import { KIND_LABEL, KIND_STAGES, STATUS_META, deviceTypeLabel, errText, fmtTime } from './constants'
 
 const props = defineProps<{
   detail?: FaultRecordOpDetail

+ 21 - 0
src/views/fault-record/common/constants.ts

@@ -11,6 +11,27 @@ export const ENV_LABEL: Record<FaultRecordEnv, string> = {
   test: '测试服',
 }
 
+/**
+ * 设备类型显示名(对应平台设备类型 0-aPower-PE 1-aGate/MCA 2-aPower-MPPT 3-aHub);
+ * 键为 MQTT 协议中的 type 值,协议值不变
+ */
+export const DEVICE_TYPE_LABEL: Record<string, string> = {
+  aGate: 'aGate/MCA',
+  pe: 'aPower-PE',
+  mppt: 'aPower-MPPT',
+  apbox: 'aHub',
+  dif: 'DIF',
+}
+export const deviceTypeLabel = (t?: string) => (t ? (DEVICE_TYPE_LABEL[t] ?? t) : '')
+
+/** 设备种类显示名(设备选择时使用;aPower-MPPT 与 aPower-PE 同 SN,需按种类区分) */
+export const DEVICE_KIND_LABEL: Record<string, string> = {
+  aGate: 'aGate/MCA',
+  aPowerPE: 'aPower-PE',
+  aPowerMPPT: 'aPower-MPPT',
+  aHub: 'aHub',
+}
+
 export const KIND_LABEL: Record<FaultRecordKind, string> = {
   device_info: '设备信息',
   record_fetch: '录波获取',

+ 2 - 1
src/views/fault-record/function/index.vue

@@ -82,7 +82,7 @@
       <div v-if="pendingRun" class="flex flex-col gap-2">
         <div>
           环境:<NTag :type="env === 'prod' ? 'error' : 'info'" size="small">{{ ENV_LABEL[env] }}</NTag>
-          网关:{{ gatewaySn }} 设备:{{ device?.type }} {{ device?.sn }}
+          网关:{{ gatewaySn }} 设备:{{ device ? DEVICE_KIND_LABEL[device.kind] : '' }} {{ device?.sn }}
         </div>
         <div :class="danger ? 'text-red-500' : ''">
           {{ pendingRun.id }} {{ functionName(pendingRun.id) }} {{ pendingRunDesc }}
@@ -132,6 +132,7 @@ import {
   encodeDeviceTime,
   errText,
   functionName,
+  DEVICE_KIND_LABEL,
 } from '../common/constants'
 import { useFaultRecordEnv, useOperation } from '../common/useFaultRecord'
 

+ 2 - 1
src/views/fault-record/param-write/index.vue

@@ -146,7 +146,7 @@
       <div class="flex flex-col gap-2">
         <div>
           环境:<NTag :type="env === 'prod' ? 'error' : 'info'" size="small">{{ ENV_LABEL[env] }}</NTag>
-          网关:{{ gatewaySn }} 设备:{{ device?.type }} {{ device?.sn }}
+          网关:{{ gatewaySn }} 设备:{{ device ? DEVICE_KIND_LABEL[device.kind] : '' }} {{ device?.sn }}
         </div>
         <div v-for="p in pending" :key="p.key" class="flex gap-2">
           <span class="w-6">{{ p.id }}</span>
@@ -203,6 +203,7 @@ import {
   functionName,
   hex,
   isHex,
+  DEVICE_KIND_LABEL,
 } from '../common/constants'
 import { useFaultRecordEnv, useOperation } from '../common/useFaultRecord'