feat(platform): harden telemetry pipeline and unify Semi UI workspaces
This commit is contained in:
481
go/vehicle-gateway/internal/feichibridge/encoder.go
Normal file
481
go/vehicle-gateway/internal/feichibridge/encoder.go
Normal file
@@ -0,0 +1,481 @@
|
||||
package feichibridge
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CommandRealtime = byte(0x02)
|
||||
CommandReissue = byte(0x03)
|
||||
CommandLogin = byte(0x05)
|
||||
)
|
||||
|
||||
type Encoder struct{}
|
||||
|
||||
func (Encoder) DataFrame(command byte, vin string, at time.Time, record Record) ([]byte, error) {
|
||||
if command != CommandRealtime && command != CommandReissue {
|
||||
return nil, fmt.Errorf("unsupported data command 0x%02X", command)
|
||||
}
|
||||
body := encodeTime(at)
|
||||
units := 0
|
||||
if unit, ok := wholeVehicleUnit(record); ok {
|
||||
body = append(body, 0x01)
|
||||
body = append(body, unit...)
|
||||
units++
|
||||
}
|
||||
if unit, ok := motorUnit(record); ok {
|
||||
body = append(body, 0x02)
|
||||
body = append(body, unit...)
|
||||
units++
|
||||
}
|
||||
if unit, ok := fuelCellUnit(record); ok {
|
||||
body = append(body, 0x03)
|
||||
body = append(body, unit...)
|
||||
units++
|
||||
}
|
||||
if unit, ok := positionUnit(record); ok {
|
||||
body = append(body, 0x05)
|
||||
body = append(body, unit...)
|
||||
units++
|
||||
}
|
||||
if unit, ok := extremeUnit(record); ok {
|
||||
body = append(body, 0x06)
|
||||
body = append(body, unit...)
|
||||
units++
|
||||
}
|
||||
if unit, ok := alarmUnit(record); ok {
|
||||
body = append(body, 0x07)
|
||||
body = append(body, unit...)
|
||||
units++
|
||||
}
|
||||
for _, unit := range voltageUnits(record) {
|
||||
body = append(body, 0x08)
|
||||
body = append(body, unit...)
|
||||
units++
|
||||
}
|
||||
for _, unit := range temperatureUnits(record) {
|
||||
body = append(body, 0x09)
|
||||
body = append(body, unit...)
|
||||
units++
|
||||
}
|
||||
if units == 0 {
|
||||
return nil, errors.New("source record has no mappable GB/T 32960 units")
|
||||
}
|
||||
return buildFrame('#', command, 0xFE, vin, body)
|
||||
}
|
||||
|
||||
func LoginFrame(platformID, username, password string, serial uint16, now time.Time) ([]byte, error) {
|
||||
if len(username) > 12 {
|
||||
return nil, errors.New("GB/T 32960 platform username exceeds 12 bytes")
|
||||
}
|
||||
if len(password) > 20 {
|
||||
return nil, errors.New("GB/T 32960 platform password exceeds 20 bytes")
|
||||
}
|
||||
body := encodeTime(now)
|
||||
body = binary.BigEndian.AppendUint16(body, serial)
|
||||
body = appendPaddedASCII(body, username, 12)
|
||||
body = appendPaddedASCII(body, password, 20)
|
||||
body = append(body, 0x01)
|
||||
return buildFrame('#', CommandLogin, 0xFE, platformID, body)
|
||||
}
|
||||
|
||||
func buildFrame(start, command, response byte, vin string, body []byte) ([]byte, error) {
|
||||
if len(vin) != 17 {
|
||||
return nil, fmt.Errorf("GB/T 32960 identifier must be exactly 17 bytes, got %d", len(vin))
|
||||
}
|
||||
if len(body) > math.MaxUint16 {
|
||||
return nil, errors.New("GB/T 32960 body exceeds 65535 bytes")
|
||||
}
|
||||
frame := make([]byte, 24, 25+len(body))
|
||||
frame[0], frame[1] = start, start
|
||||
frame[2], frame[3] = command, response
|
||||
copy(frame[4:21], vin)
|
||||
frame[21] = 0x01
|
||||
binary.BigEndian.PutUint16(frame[22:24], uint16(len(body)))
|
||||
frame = append(frame, body...)
|
||||
frame = append(frame, bcc(frame[2:]))
|
||||
return frame, nil
|
||||
}
|
||||
|
||||
func wholeVehicleUnit(record Record) ([]byte, bool) {
|
||||
if !hasAny(record, "2201", "2202", "3201", "7615") {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]byte, 20)
|
||||
out[0] = enum(recordValue(record, "3201"), map[string]byte{
|
||||
"启动": 1, "启动状态": 1, "行驶": 1, "熄火": 2, "熄火状态": 2, "其他": 3,
|
||||
}, 0xFE)
|
||||
out[1] = enum(recordValue(record, "2301"), map[string]byte{
|
||||
"停车充电": 1, "行驶充电": 2, "未充电": 3, "未充电状态": 3, "充电完成": 4,
|
||||
}, 0xFE)
|
||||
out[2] = enum(recordValue(record, "2213"), map[string]byte{
|
||||
"纯电": 1, "纯电动": 1, "混动": 2, "混合动力": 2, "燃油": 3,
|
||||
}, 0xFE)
|
||||
putScaledU16(out[3:5], recordValue(record, "2201"), 10, 0)
|
||||
putScaledU32(out[5:9], recordValue(record, "2202"), 10)
|
||||
putScaledU16(out[9:11], recordValue(record, "2613"), 10, 0)
|
||||
putScaledU16(out[11:13], recordValue(record, "2614"), 10, 1000)
|
||||
out[13] = byteValue(recordValue(record, "7615"))
|
||||
out[14] = enum(recordValue(record, "2214"), map[string]byte{"工作": 1, "断开": 2}, 0xFE)
|
||||
out[15] = gearValue(firstField(recordValue(record, "2203")))
|
||||
putU16(out[16:18], recordValue(record, "2617"))
|
||||
out[18] = byteValue(recordValue(record, "2208"))
|
||||
out[19] = byteValue(recordValue(record, "2209"))
|
||||
return out, true
|
||||
}
|
||||
|
||||
func motorUnit(record Record) ([]byte, bool) {
|
||||
// The source carries every motor as a key:value composite separated by "|".
|
||||
// Fields are the GB/T 32960 sequence numbers exposed by the platform metadata.
|
||||
composite := recordValue(record, "2308")
|
||||
if composite == "" {
|
||||
return nil, false
|
||||
}
|
||||
groups := parseCompositeGroups(composite)
|
||||
if len(groups) == 0 || len(groups) > 253 {
|
||||
return nil, false
|
||||
}
|
||||
out := []byte{byte(len(groups))}
|
||||
for index, group := range groups {
|
||||
out = append(out, byteValue(firstNonempty(group["2302"], strconv.Itoa(index+1))))
|
||||
out = append(out, enum(group["2303"], map[string]byte{
|
||||
"耗电": 1, "发电": 2, "关闭": 3, "准备": 4, "准备状态": 4,
|
||||
}, 0xFE))
|
||||
out = append(out, tempByte(group["2304"]))
|
||||
out = binary.BigEndian.AppendUint16(out, offsetU16(group["2305"], 1, 20000))
|
||||
out = binary.BigEndian.AppendUint16(out, offsetU16(group["2306"], 10, 2000))
|
||||
out = append(out, tempByte(group["2309"]))
|
||||
out = binary.BigEndian.AppendUint16(out, scaledU16(group["2311"], 10, 0))
|
||||
out = binary.BigEndian.AppendUint16(out, scaledU16(group["2312"], 10, 1000))
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func fuelCellUnit(record Record) ([]byte, bool) {
|
||||
if !hasAny(record, "2110", "2111", "2112", "2117", "2119") {
|
||||
return nil, false
|
||||
}
|
||||
temperatureGroups := parseSeriesGroups(recordValue(record, "2103"))
|
||||
var temperatures []string
|
||||
for _, group := range temperatureGroups {
|
||||
temperatures = append(temperatures, group.values...)
|
||||
}
|
||||
if len(temperatures) > math.MaxUint16 {
|
||||
temperatures = temperatures[:math.MaxUint16]
|
||||
}
|
||||
out := binary.BigEndian.AppendUint16(nil, scaledU16(recordValue(record, "2110"), 10, 0))
|
||||
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2111"), 10, 0))
|
||||
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2112"), 100, 0))
|
||||
out = binary.BigEndian.AppendUint16(out, uint16(len(temperatures)))
|
||||
for _, value := range temperatures {
|
||||
out = append(out, tempByte(value))
|
||||
}
|
||||
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2115"), 10, 40))
|
||||
out = append(out, byteValue(recordValue(record, "2116")))
|
||||
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2117"), 1, 0))
|
||||
out = append(out, byteValue(recordValue(record, "2118")))
|
||||
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2119"), 10, 0))
|
||||
out = append(out, byteValue(recordValue(record, "2120")))
|
||||
out = append(out, enum(recordValue(record, "2121"), map[string]byte{"工作": 1, "断开": 2}, 0xFE))
|
||||
return out, true
|
||||
}
|
||||
|
||||
func positionUnit(record Record) ([]byte, bool) {
|
||||
longitude, lonOK := floatValue(recordValue(record, "2502"))
|
||||
latitude, latOK := floatValue(recordValue(record, "2503"))
|
||||
if !lonOK || !latOK || longitude < 0 || latitude < 0 {
|
||||
return nil, false
|
||||
}
|
||||
status := byte(0)
|
||||
text := strings.TrimSpace(recordValue(record, "2501"))
|
||||
if text != "" && (strings.Contains(text, "无效") || text == "1") {
|
||||
status = 1
|
||||
}
|
||||
out := []byte{status}
|
||||
out = binary.BigEndian.AppendUint32(out, uint32(math.Round(longitude*1_000_000)))
|
||||
out = binary.BigEndian.AppendUint32(out, uint32(math.Round(latitude*1_000_000)))
|
||||
return out, true
|
||||
}
|
||||
|
||||
func extremeUnit(record Record) ([]byte, bool) {
|
||||
if !hasAny(record, "2601", "2602", "2603", "2604", "2605", "2606", "2607", "2608", "2609", "2610", "2611", "2612") {
|
||||
return nil, false
|
||||
}
|
||||
out := []byte{
|
||||
byteValue(recordValue(record, "2601")),
|
||||
byteValue(recordValue(record, "2602")),
|
||||
}
|
||||
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2603"), 1000, 0))
|
||||
out = append(out, byteValue(recordValue(record, "2604")), byteValue(recordValue(record, "2605")))
|
||||
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2606"), 1000, 0))
|
||||
out = append(out,
|
||||
byteValue(recordValue(record, "2607")),
|
||||
byteValue(recordValue(record, "2608")),
|
||||
tempByte(recordValue(record, "2609")),
|
||||
byteValue(recordValue(record, "2610")),
|
||||
byteValue(recordValue(record, "2611")),
|
||||
tempByte(recordValue(record, "2612")),
|
||||
)
|
||||
return out, true
|
||||
}
|
||||
|
||||
func alarmUnit(record Record) ([]byte, bool) {
|
||||
levelRaw := recordValue(record, "2900", "2901")
|
||||
var general uint32
|
||||
for bit := 0; bit < 32; bit++ {
|
||||
value := recordValue(record, strconv.Itoa(2901+bit))
|
||||
if truthy(value) {
|
||||
general |= 1 << bit
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(levelRaw) == "" && general == 0 {
|
||||
return nil, false
|
||||
}
|
||||
out := []byte{byteValue(levelRaw)}
|
||||
out = binary.BigEndian.AppendUint32(out, general)
|
||||
out = append(out, 0, 0, 0, 0)
|
||||
return out, true
|
||||
}
|
||||
|
||||
func voltageUnits(record Record) [][]byte {
|
||||
raw := recordValue(record, "2003", "batteryVoltages")
|
||||
groups := parseSeriesGroups(raw)
|
||||
var units [][]byte
|
||||
for _, group := range groups {
|
||||
for start := 0; start < len(group.values); start += 255 {
|
||||
end := start + 255
|
||||
if end > len(group.values) {
|
||||
end = len(group.values)
|
||||
}
|
||||
out := []byte{1, byte(group.id)}
|
||||
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2613"), 10, 0))
|
||||
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2614"), 10, 1000))
|
||||
out = binary.BigEndian.AppendUint16(out, uint16(len(group.values)))
|
||||
out = binary.BigEndian.AppendUint16(out, uint16(start+1))
|
||||
out = append(out, byte(end-start))
|
||||
for _, value := range group.values[start:end] {
|
||||
out = binary.BigEndian.AppendUint16(out, scaledU16(value, 1000, 0))
|
||||
}
|
||||
units = append(units, out)
|
||||
}
|
||||
}
|
||||
return units
|
||||
}
|
||||
|
||||
func temperatureUnits(record Record) [][]byte {
|
||||
groups := parseSeriesGroups(recordValue(record, "2103", "batteryTemperatures"))
|
||||
var units [][]byte
|
||||
for _, group := range groups {
|
||||
for start := 0; start < len(group.values); start += math.MaxUint16 {
|
||||
end := start + math.MaxUint16
|
||||
if end > len(group.values) {
|
||||
end = len(group.values)
|
||||
}
|
||||
out := []byte{1, byte(group.id)}
|
||||
out = binary.BigEndian.AppendUint16(out, uint16(end-start))
|
||||
for _, value := range group.values[start:end] {
|
||||
out = append(out, tempByte(value))
|
||||
}
|
||||
units = append(units, out)
|
||||
}
|
||||
}
|
||||
return units
|
||||
}
|
||||
|
||||
func parseCompositeGroups(raw string) []map[string]string {
|
||||
var groups []map[string]string
|
||||
for _, encoded := range strings.Split(raw, "|") {
|
||||
group := map[string]string{}
|
||||
for _, field := range strings.Split(encoded, ",") {
|
||||
key, value, found := strings.Cut(field, ":")
|
||||
if found {
|
||||
group[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
if len(group) > 0 {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
type seriesGroup struct {
|
||||
id int
|
||||
values []string
|
||||
}
|
||||
|
||||
func parseSeriesGroups(raw string) []seriesGroup {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var groups []seriesGroup
|
||||
for index, part := range strings.FieldsFunc(raw, func(r rune) bool { return r == ';' || r == '|' }) {
|
||||
group := seriesGroup{id: index + 1}
|
||||
if before, after, found := strings.Cut(part, ":"); found {
|
||||
if parsed, err := strconv.Atoi(strings.TrimSpace(before)); err == nil && parsed > 0 && parsed <= 255 {
|
||||
group.id = parsed
|
||||
}
|
||||
part = after
|
||||
}
|
||||
for _, value := range strings.FieldsFunc(part, func(r rune) bool {
|
||||
return r == '_' || r == ',' || r == ' ' || r == '[' || r == ']'
|
||||
}) {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
group.values = append(group.values, strings.TrimSpace(value))
|
||||
}
|
||||
}
|
||||
if len(group.values) > 0 {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func recordValue(record Record, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(record[key]); value != "" && value != "--" && value != "null" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstField(value string) string {
|
||||
fields := strings.Fields(value)
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fields[0]
|
||||
}
|
||||
|
||||
func firstNonempty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasAny(record Record, keys ...string) bool { return recordValue(record, keys...) != "" }
|
||||
|
||||
func floatValue(raw string) (float64, bool) {
|
||||
raw = strings.TrimSpace(strings.TrimSuffix(raw, "%"))
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.ParseFloat(raw, 64)
|
||||
return value, err == nil && !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
|
||||
func byteValue(raw string) byte {
|
||||
value, ok := floatValue(raw)
|
||||
if !ok || value < 0 || value > 253 {
|
||||
return 0xFE
|
||||
}
|
||||
return byte(math.Round(value))
|
||||
}
|
||||
|
||||
func tempByte(raw string) byte {
|
||||
value, ok := floatValue(raw)
|
||||
if !ok || value < -40 || value > 210 {
|
||||
return 0xFE
|
||||
}
|
||||
return byte(math.Round(value + 40))
|
||||
}
|
||||
|
||||
func putU16(out []byte, raw string) { binary.BigEndian.PutUint16(out, scaledU16(raw, 1, 0)) }
|
||||
|
||||
func putScaledU16(out []byte, raw string, scale, offset float64) {
|
||||
binary.BigEndian.PutUint16(out, scaledU16(raw, scale, offset))
|
||||
}
|
||||
|
||||
func putScaledU32(out []byte, raw string, scale float64) {
|
||||
value, ok := floatValue(raw)
|
||||
if !ok || value < 0 || value*scale > math.MaxUint32-2 {
|
||||
binary.BigEndian.PutUint32(out, 0xFFFFFFFE)
|
||||
return
|
||||
}
|
||||
binary.BigEndian.PutUint32(out, uint32(math.Round(value*scale)))
|
||||
}
|
||||
|
||||
func scaledU16(raw string, scale, offset float64) uint16 {
|
||||
value, ok := floatValue(raw)
|
||||
encoded := (value + offset) * scale
|
||||
if !ok || encoded < 0 || encoded > math.MaxUint16-2 {
|
||||
return 0xFFFE
|
||||
}
|
||||
return uint16(math.Round(encoded))
|
||||
}
|
||||
|
||||
func offsetU16(raw string, scale, offset float64) uint16 {
|
||||
return scaledU16(raw, scale, offset)
|
||||
}
|
||||
|
||||
func enum(raw string, values map[string]byte, missing byte) byte {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return missing
|
||||
}
|
||||
if numeric, err := strconv.ParseUint(raw, 10, 8); err == nil {
|
||||
return byte(numeric)
|
||||
}
|
||||
for label, value := range values {
|
||||
if raw == label || strings.Contains(raw, label) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func gearValue(raw string) byte {
|
||||
raw = strings.ToUpper(strings.TrimSpace(raw))
|
||||
switch raw {
|
||||
case "P", "P档":
|
||||
return 15
|
||||
case "R", "R档":
|
||||
return 13
|
||||
case "N", "N档":
|
||||
return 0
|
||||
case "D", "D档":
|
||||
return 14
|
||||
}
|
||||
raw = strings.TrimSuffix(raw, "档")
|
||||
raw = strings.TrimPrefix(raw, "D")
|
||||
return byteValue(raw)
|
||||
}
|
||||
|
||||
func truthy(raw string) bool {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
return raw != "" && raw != "0" && raw != "false" && raw != "无" && raw != "正常"
|
||||
}
|
||||
|
||||
func appendPaddedASCII(out []byte, value string, width int) []byte {
|
||||
start := len(out)
|
||||
out = append(out, make([]byte, width)...)
|
||||
copy(out[start:start+width], value)
|
||||
return out
|
||||
}
|
||||
|
||||
func encodeTime(value time.Time) []byte {
|
||||
local := value.In(shanghai)
|
||||
return []byte{
|
||||
byte(local.Year() - 2000), byte(local.Month()), byte(local.Day()),
|
||||
byte(local.Hour()), byte(local.Minute()), byte(local.Second()),
|
||||
}
|
||||
}
|
||||
|
||||
func bcc(value []byte) byte {
|
||||
var result byte
|
||||
for _, current := range value {
|
||||
result ^= current
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user