diff --git a/API Server/internal/plugins/builtin/ankersolix/modbus.go b/API Server/internal/plugins/builtin/ankersolix/modbus.go index cd1b728..b1bd0a8 100644 --- a/API Server/internal/plugins/builtin/ankersolix/modbus.go +++ b/API Server/internal/plugins/builtin/ankersolix/modbus.go @@ -51,19 +51,22 @@ const ( regAlarm1 = 20041 // UINT16 x12, each bit an alarm - regVoltageL1N = 20053 // UINT16, gain 10 - regCurrentL1 = 20059 // UINT16, gain 100 - regPowerL1 = 20062 // UINT32, W - regPowerTotal = 20068 // UINT32, W - regSessionSec = 20082 // UINT32, s - regSessionWh = 20084 // UINT32, Wh + regVoltageL1N = 20053 // UINT16, gain 10 + regVoltageL1L2 = 20056 // UINT16, gain 10, line to line + regCurrentL1 = 20059 // UINT16, gain 100 + regPowerL1 = 20062 // UINT32, W + regPowerTotal = 20068 // UINT32, W + regReactiveL1 = 20070 // UINT32, var + regApparentL1 = 20076 // UINT32, VA + regSessionSec = 20082 // UINT32, s + regSessionWh = 20084 // UINT32, Wh regPWMEnabled = 20086 regPhaseMode = 20087 regChargingMode = 20088 // 0 solar+grid, 1 solar only regLoadBalancing = 20089 regSolarBalancing = 20090 - regCPVoltage = 20091 + regCPVoltage = 20091 // UINT16, millivolts — see decodeLive regCPSignal = 20092 regRelay1Temp = 20093 // INT16, decidegC — see decodeLive on the gain regRelay2Temp = 20094 // INT16, decidegC @@ -80,6 +83,15 @@ const ( regPhaseCountSet = 21005 // 0 automatic, 1 fixed single, 2 fixed three ) +// The control block, read back rather than written. It is six holding registers +// over FC03 — the one part of the map that answers that function code — and it +// is the only way to see what the charger is actually set to, as opposed to what +// it is doing. +const ( + settingsStart = regChargingCommand + settingsCount = 6 // 21000-21005 +) + // The two blocks read in one request each. Both are well inside the 125-register // limit of a read, and splitting them keeps the hot path (live state) small. const ( @@ -139,10 +151,14 @@ func (c ModbusConfig) Address() string { // accountCharger treats a value no cloud view knew. type ModbusSnapshot struct { // Identity, present only when the identity block was read too. - Model string `json:"model,omitempty"` - Serial string `json:"serial,omitempty"` - Firmware string `json:"firmware,omitempty"` - Hardware string `json:"hardware,omitempty"` + Model string `json:"model,omitempty"` + Serial string `json:"serial,omitempty"` + Firmware string `json:"firmware,omitempty"` + Hardware string `json:"hardware,omitempty"` + ProductNumber *int `json:"productNumber,omitempty"` + RatedPowerW *int `json:"ratedPowerW,omitempty"` + MinCurrentA *int `json:"minCurrentA,omitempty"` + MaxCurrentA *int `json:"maxCurrentA,omitempty"` Status *int `json:"status,omitempty"` StatusDesc string `json:"statusDesc,omitempty"` @@ -154,22 +170,37 @@ type ModbusSnapshot struct { CurrentL2 *float64 `json:"currentL2,omitempty"` CurrentL3 *float64 `json:"currentL3,omitempty"` + // Line-to-line voltages, which say something the phase voltages do not on a + // three-phase supply and sit at zero on a single-phase one. + VoltageL1L2 *float64 `json:"voltageL1L2,omitempty"` + VoltageL2L3 *float64 `json:"voltageL2L3,omitempty"` + VoltageL3L1 *float64 `json:"voltageL3L1,omitempty"` + PowerL1 *uint32 `json:"powerL1,omitempty"` PowerL2 *uint32 `json:"powerL2,omitempty"` PowerL3 *uint32 `json:"powerL3,omitempty"` PowerTotal *uint32 `json:"powerTotal,omitempty"` + ReactiveL1 *uint32 `json:"reactiveL1,omitempty"` + ReactiveL2 *uint32 `json:"reactiveL2,omitempty"` + ReactiveL3 *uint32 `json:"reactiveL3,omitempty"` + ApparentL1 *uint32 `json:"apparentL1,omitempty"` + ApparentL2 *uint32 `json:"apparentL2,omitempty"` + ApparentL3 *uint32 `json:"apparentL3,omitempty"` + SessionSeconds *uint32 `json:"sessionSeconds,omitempty"` SessionWh *uint32 `json:"sessionWh,omitempty"` - PhaseMode *int `json:"phaseMode,omitempty"` - ChargingMode *int `json:"chargingMode,omitempty"` - LoadBalancing *bool `json:"loadBalancing,omitempty"` - SolarBalancing *bool `json:"solarBalancing,omitempty"` - BoostMode *bool `json:"boostMode,omitempty"` - LEDBrightness *int `json:"ledBrightness,omitempty"` - CPSignal *int `json:"cpSignal,omitempty"` - CPSignalDesc string `json:"cpSignalDesc,omitempty"` + PhaseMode *int `json:"phaseMode,omitempty"` + ChargingMode *int `json:"chargingMode,omitempty"` + LoadBalancing *bool `json:"loadBalancing,omitempty"` + SolarBalancing *bool `json:"solarBalancing,omitempty"` + BoostMode *bool `json:"boostMode,omitempty"` + LEDBrightness *int `json:"ledBrightness,omitempty"` + PWMEnabled *bool `json:"pwmEnabled,omitempty"` + CPVoltage *float64 `json:"cpVoltage,omitempty"` + CPSignal *int `json:"cpSignal,omitempty"` + CPSignalDesc string `json:"cpSignalDesc,omitempty"` Relay1TempC *float64 `json:"relay1TempC,omitempty"` Relay2TempC *float64 `json:"relay2TempC,omitempty"` @@ -179,6 +210,11 @@ type ModbusSnapshot struct { MqttStatus *int `json:"mqttStatus,omitempty"` MqttStatusDesc string `json:"mqttStatusDesc,omitempty"` + // Settings is what the charger is set to, read back from the control block. + // It answers a different question from the live fields beside it: BoostMode + // says a boost is running, Settings.Boost says one was asked for. + Settings *ModbusSettings `json:"settings,omitempty"` + // Alarms holds the twelve alarm words verbatim. The spec defers the bit // meanings to a separate alarm list, so they are surfaced undecoded rather // than guessed at; Alarm reports whether any bit is set at all. @@ -186,6 +222,24 @@ type ModbusSnapshot struct { Alarm bool `json:"alarm"` } +// ModbusSettings mirrors the writable control block. Every field is what the +// charger reports for a register it also accepts writes on, so a view built from +// this shows the settings in force rather than the ones last sent. +type ModbusSettings struct { + LastCommand *int `json:"lastCommand,omitempty"` // 0 none, 1 start, 2 stop + MaxCurrentA *float64 `json:"maxCurrentA,omitempty"` // deciamps on the wire + Boost *bool `json:"boost,omitempty"` // for the current session only + TimeoutSeconds *int `json:"timeoutSeconds,omitempty"` // control falls back after this silence + PhaseSetting *int `json:"phaseSetting,omitempty"` // 0 automatic, 1 single, 2 three + PhaseDesc string `json:"phaseDesc,omitempty"` +} + +// phaseSettingNames decodes the write register's 0/1/2, which is not the 1/3 the +// charger reports for the phase mode it is currently running in. +var phaseSettingNames = map[int]string{ + 0: "automatic", 1: "fixed single-phase", 2: "fixed three-phase", +} + // ModbusDial opens a connection to one charger. Callers must Close it; the // charger only tolerates two clients at a time. func ModbusDial(ctx context.Context, cfg ModbusConfig) (*modbus.Client, error) { @@ -212,6 +266,13 @@ func ModbusRead(ctx context.Context, c *modbus.Client, withIdentity bool) (Modbu } decodeLive(&snap, live) + // The control block is a second table on a second function code, so it is read + // separately and best effort: a charger that refuses it still has live state + // worth reporting. + if set, err := c.ReadHolding(ctx, settingsStart, settingsCount); err == nil { + decodeSettings(&snap, set) + } + if withIdentity { ident, err := c.ReadInput(ctx, identityStart, identityCount) if err != nil { @@ -289,6 +350,9 @@ func decodeLive(snap *ModbusSnapshot, regs []uint16) { snap.VoltageL1 = scaled(regVoltageL1N, 10) snap.VoltageL2 = scaled(regVoltageL1N+1, 10) snap.VoltageL3 = scaled(regVoltageL1N+2, 10) + snap.VoltageL1L2 = scaled(regVoltageL1L2, 10) + snap.VoltageL2L3 = scaled(regVoltageL1L2+1, 10) + snap.VoltageL3L1 = scaled(regVoltageL1L2+2, 10) snap.CurrentL1 = scaled(regCurrentL1, 100) snap.CurrentL2 = scaled(regCurrentL1+1, 100) snap.CurrentL3 = scaled(regCurrentL1+2, 100) @@ -297,9 +361,21 @@ func decodeLive(snap *ModbusSnapshot, regs []uint16) { snap.PowerL2 = word(regPowerL1 + 2) snap.PowerL3 = word(regPowerL1 + 4) snap.PowerTotal = word(regPowerTotal) + snap.ReactiveL1 = word(regReactiveL1) + snap.ReactiveL2 = word(regReactiveL1 + 2) + snap.ReactiveL3 = word(regReactiveL1 + 4) + snap.ApparentL1 = word(regApparentL1) + snap.ApparentL2 = word(regApparentL1 + 2) + snap.ApparentL3 = word(regApparentL1 + 4) snap.SessionSeconds = word(regSessionSec) snap.SessionWh = word(regSessionWh) + snap.PWMEnabled = flag(regPWMEnabled) + // The spec leaves this register's unit and gain blank. It reads 11873 while + // the CP signal register reports state A, which that enum itself names as + // 12 V — so the register is millivolts, and the state beside it is the + // cross-check. + snap.CPVoltage = scaled(regCPVoltage, 1000) snap.PhaseMode = num(regPhaseMode) snap.ChargingMode = num(regChargingMode) snap.LoadBalancing = flag(regLoadBalancing) @@ -347,10 +423,68 @@ func decodeIdentity(snap *ModbusSnapshot, regs []uint16) { } return strings.TrimSpace(strings.TrimRight(string(b), "\x00")) } + // The identity block's numbers are INT32 pairs, big-endian across the two + // registers like the power readings in the live block. + num := func(addr int) *int { + i := addr - identityStart + if i < 0 || i+2 > len(regs) { + return nil + } + v := int(int32(uint32(regs[i])<<16 | uint32(regs[i+1]))) + return &v + } + word := func(addr int) *int { + i := addr - identityStart + if i < 0 || i >= len(regs) { + return nil + } + v := int(regs[i]) + return &v + } + snap.Model = text(regModelName, 10) snap.Serial = text(regSerialNumber, 12) snap.Firmware = text(regSoftwareVersion, 6) snap.Hardware = text(regHardwareVersion, 6) + snap.ProductNumber = word(regProductNumber) + snap.RatedPowerW = num(regRatedPower) + // The spec's unit column calls these watts and kVA; they are amps, which is + // what the charger reports and what the current limit is set in. + snap.MinCurrentA = num(regMinOutCurrent) + snap.MaxCurrentA = num(regMaxOutCurrent) +} + +// decodeSettings fills the snapshot from the 21000-21005 control block. +func decodeSettings(snap *ModbusSnapshot, regs []uint16) { + at := func(addr int) (uint16, bool) { + i := addr - settingsStart + if i < 0 || i >= len(regs) { + return 0, false + } + return regs[i], true + } + set := &ModbusSettings{} + if v, ok := at(regChargingCommand); ok { + n := int(v) + set.LastCommand = &n + } + if v, ok := at(regMaxCurrentSet); ok { + a := float64(v) / 10 + set.MaxCurrentA = &a + } + if v, ok := at(regBoostSet); ok { + b := v != 0 + set.Boost = &b + } + if v, ok := at(regTimeoutSet); ok { + n := int(v) + set.TimeoutSeconds = &n + } + if v, ok := at(regPhaseCountSet); ok { + n := int(v) + set.PhaseSetting, set.PhaseDesc = &n, phaseSettingNames[n] + } + snap.Settings = set } // ModbusStartCharging asks the charger to begin a session. diff --git a/API Server/internal/plugins/builtin/ankersolix/modbus_test.go b/API Server/internal/plugins/builtin/ankersolix/modbus_test.go index 1b863c6..ac731cc 100644 --- a/API Server/internal/plugins/builtin/ankersolix/modbus_test.go +++ b/API Server/internal/plugins/builtin/ankersolix/modbus_test.go @@ -190,6 +190,101 @@ func TestDecodeIdentityReadsStrings(t *testing.T) { } } +func TestDecodeIdentityReadsRatings(t *testing.T) { + regs := make([]uint16, identityCount) + put32 := func(addr int, v uint32) { + regs[addr-identityStart] = uint16(v >> 16) + regs[addr-identityStart+1] = uint16(v) + } + regs[regProductNumber-identityStart] = 7 + put32(regRatedPower, 7400) + put32(regMinOutCurrent, 6) + put32(regMaxOutCurrent, 32) + + var snap ModbusSnapshot + decodeIdentity(&snap, regs) + + for _, c := range []struct { + name string + got *int + want int + }{ + {"ProductNumber", snap.ProductNumber, 7}, + {"RatedPowerW", snap.RatedPowerW, 7400}, + {"MinCurrentA", snap.MinCurrentA, 6}, + {"MaxCurrentA", snap.MaxCurrentA, 32}, + } { + if c.got == nil || *c.got != c.want { + t.Errorf("%s = %v, want %d", c.name, c.got, c.want) + } + } +} + +// The control block says what the charger is set to, which is a different +// question from what it is doing: a boost that was asked for reads here even +// while the live block reports no boost running. +func TestDecodeSettingsReadsTheControlBlock(t *testing.T) { + regs := make([]uint16, settingsCount) + regs[regChargingCommand-settingsStart] = 1 + regs[regMaxCurrentSet-settingsStart] = 160 // 16.0 A, gain 10 + regs[regBoostSet-settingsStart] = 1 + regs[regTimeoutSet-settingsStart] = 120 + regs[regPhaseCountSet-settingsStart] = 2 + + var snap ModbusSnapshot + decodeSettings(&snap, regs) + + if snap.Settings == nil { + t.Fatal("Settings is nil") + } + got := snap.Settings + if got.LastCommand == nil || *got.LastCommand != 1 { + t.Errorf("LastCommand = %v, want 1", got.LastCommand) + } + if got.MaxCurrentA == nil || *got.MaxCurrentA != 16 { + t.Errorf("MaxCurrentA = %v, want 16", got.MaxCurrentA) + } + if got.Boost == nil || !*got.Boost { + t.Errorf("Boost = %v, want true", got.Boost) + } + if got.TimeoutSeconds == nil || *got.TimeoutSeconds != 120 { + t.Errorf("TimeoutSeconds = %v, want 120", got.TimeoutSeconds) + } + if got.PhaseSetting == nil || *got.PhaseSetting != 2 || got.PhaseDesc != "fixed three-phase" { + t.Errorf("PhaseSetting = %v (%q), want 2 (fixed three-phase)", got.PhaseSetting, got.PhaseDesc) + } +} + +// The registers below were read into the live block all along but never +// decoded, so the block's own alignment is what these guard. +func TestDecodeLiveReadsLineAndReactivePower(t *testing.T) { + b := newLiveBlock() + b.set(regVoltageL1L2, 3809) // 380.9 V, gain 10 + b.set32(regReactiveL1, 120) + b.set32(regApparentL1+4, 340) + b.set(regPWMEnabled, 1) + b.set(regCPVoltage, 11873) // 11.873 V, millivolts + + var snap ModbusSnapshot + decodeLive(&snap, b) + + if snap.VoltageL1L2 == nil || *snap.VoltageL1L2 != 380.9 { + t.Errorf("VoltageL1L2 = %v, want 380.9", snap.VoltageL1L2) + } + if snap.ReactiveL1 == nil || *snap.ReactiveL1 != 120 { + t.Errorf("ReactiveL1 = %v, want 120", snap.ReactiveL1) + } + if snap.ApparentL3 == nil || *snap.ApparentL3 != 340 { + t.Errorf("ApparentL3 = %v, want 340", snap.ApparentL3) + } + if snap.PWMEnabled == nil || !*snap.PWMEnabled { + t.Errorf("PWMEnabled = %v, want true", snap.PWMEnabled) + } + if snap.CPVoltage == nil || *snap.CPVoltage != 11.873 { + t.Errorf("CPVoltage = %v, want 11.873", snap.CPVoltage) + } +} + // Below 6 A the charger stops instead of charging slowly, so a limit in that // range has to be refused rather than quietly turned into a pause. func TestModbusSetMaxCurrentRefusesBelowFloor(t *testing.T) { diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 2020511..a074e2c 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -106,6 +106,62 @@ "boost": "Boost denne session", "modbusHint": "Indtast laderens adresse på dette netværk og gem den. Laderen skal være tændt og på samme netværk som DriverVault." }, + "modbus": { + "phases": "Pr. fase", + "live": "Live data", + "settings": "Indstillinger", + "device": "Enhed", + "alarms": "Alarmer", + "phase": "Fase", + "voltage": "Spænding", + "current": "Strøm", + "activePower": "Aktiv", + "reactivePower": "Reaktiv", + "apparentPower": "Tilsyneladende", + "lineToLine": "Mellem faser", + "power": "Samlet effekt", + "sessionDuration": "Sessionens længde", + "cpSignal": "Control pilot", + "cpVoltage": "Pilotspænding", + "phaseMode": "Kører på", + "relayTemps": "Relætemperaturer", + "pwm": "PWM aktiv", + "maxCurrentSet": "Strømgrænse", + "timeout": "Styringstimeout", + "phaseSetting": "Faseindstilling", + "boostSet": "Boost anmodet", + "lastCommand": "Seneste kommando", + "chargingMode": "Opladningstilstand", + "loadBalancing": "Lastbalancering", + "solarBalancing": "Solbalancering", + "ledBrightness": "LED-lysstyrke", + "model": "Model", + "serial": "Serienummer", + "firmware": "Firmware", + "hardware": "Hardware", + "productNumber": "Produktnummer", + "ratedPower": "Nominel effekt", + "currentRange": "Strømområde", + "ocppLink": "OCPP", + "mqttLink": "MQTT", + "alarmWord": "Ord {n}", + "alarmsHint": "Laderen melder en alarm. Anker offentliggør ikke, hvad de enkelte bit betyder, så ordene vises, som de kommer.", + "phaseMode1": "Enfaset", + "phaseMode3": "Trefaset", + "phaseSet0": "Automatisk", + "phaseSet1": "Fast enfaset", + "phaseSet2": "Fast trefaset", + "chargingMode0": "Sol + net", + "chargingMode1": "Kun sol", + "command0": "Ingen", + "command1": "Start", + "command2": "Stop", + "ocpp0": "Ikke forbundet", + "ocpp1": "Forbinder", + "ocpp2": "Forbundet", + "mqtt0": "Ikke forbundet", + "mqtt1": "Forbundet" + }, "info": { "title": "Laderoplysninger", "empty": "Intet importeret endnu — en lader, du importerer, viser alt, den melder, her.", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index f6f7bf9..21e15bc 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -92,6 +92,62 @@ "boost": "Boost this session", "modbusHint": "Enter the charger's address on this network and save it. The charger must be powered on and on the same network as DriverVault." }, + "modbus": { + "phases": "Per phase", + "live": "Live data", + "settings": "Settings", + "device": "Device", + "alarms": "Alarms", + "phase": "Phase", + "voltage": "Voltage", + "current": "Current", + "activePower": "Active", + "reactivePower": "Reactive", + "apparentPower": "Apparent", + "lineToLine": "Line to line", + "power": "Total power", + "sessionDuration": "Session length", + "cpSignal": "Control pilot", + "cpVoltage": "Pilot voltage", + "phaseMode": "Running on", + "relayTemps": "Relay temperatures", + "pwm": "PWM active", + "maxCurrentSet": "Current limit", + "timeout": "Control timeout", + "phaseSetting": "Phase setting", + "boostSet": "Boost requested", + "lastCommand": "Last command", + "chargingMode": "Charging mode", + "loadBalancing": "Load balancing", + "solarBalancing": "Solar balancing", + "ledBrightness": "LED brightness", + "model": "Model", + "serial": "Serial", + "firmware": "Firmware", + "hardware": "Hardware", + "productNumber": "Product number", + "ratedPower": "Rated power", + "currentRange": "Current range", + "ocppLink": "OCPP", + "mqttLink": "MQTT", + "alarmWord": "Word {n}", + "alarmsHint": "The charger reports an alarm. Anker does not publish what the individual bits mean, so the words are shown as they arrive.", + "phaseMode1": "Single-phase", + "phaseMode3": "Three-phase", + "phaseSet0": "Automatic", + "phaseSet1": "Fixed single-phase", + "phaseSet2": "Fixed three-phase", + "chargingMode0": "Solar + grid", + "chargingMode1": "Solar only", + "command0": "None", + "command1": "Start", + "command2": "Stop", + "ocpp0": "Not connected", + "ocpp1": "Connecting", + "ocpp2": "Connected", + "mqtt0": "Not connected", + "mqtt1": "Connected" + }, "info": { "title": "Charger information", "empty": "Nothing imported yet — a charger you import shows everything it reports here.", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 7cf79bc..dc7e718 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -108,6 +108,62 @@ "boost": "Boost w tej sesji", "modbusHint": "Podaj adres ładowarki w tej sieci i zapisz go. Ładowarka musi być włączona i w tej samej sieci co DriverVault." }, + "modbus": { + "phases": "Na fazę", + "live": "Dane na żywo", + "settings": "Ustawienia", + "device": "Urządzenie", + "alarms": "Alarmy", + "phase": "Faza", + "voltage": "Napięcie", + "current": "Prąd", + "activePower": "Czynna", + "reactivePower": "Bierna", + "apparentPower": "Pozorna", + "lineToLine": "Międzyfazowe", + "power": "Moc całkowita", + "sessionDuration": "Czas sesji", + "cpSignal": "Control pilot", + "cpVoltage": "Napięcie pilota", + "phaseMode": "Pracuje na", + "relayTemps": "Temperatury przekaźników", + "pwm": "PWM aktywny", + "maxCurrentSet": "Limit prądu", + "timeout": "Limit czasu sterowania", + "phaseSetting": "Ustawienie faz", + "boostSet": "Boost zażądany", + "lastCommand": "Ostatnie polecenie", + "chargingMode": "Tryb ładowania", + "loadBalancing": "Balansowanie obciążenia", + "solarBalancing": "Balansowanie solarne", + "ledBrightness": "Jasność LED", + "model": "Model", + "serial": "Numer seryjny", + "firmware": "Firmware", + "hardware": "Hardware", + "productNumber": "Numer produktu", + "ratedPower": "Moc znamionowa", + "currentRange": "Zakres prądu", + "ocppLink": "OCPP", + "mqttLink": "MQTT", + "alarmWord": "Słowo {n}", + "alarmsHint": "Ładowarka zgłasza alarm. Anker nie publikuje znaczenia poszczególnych bitów, więc słowa pokazane są tak, jak przychodzą.", + "phaseMode1": "Jednofazowo", + "phaseMode3": "Trójfazowo", + "phaseSet0": "Automatycznie", + "phaseSet1": "Na stałe jednofazowo", + "phaseSet2": "Na stałe trójfazowo", + "chargingMode0": "Słońce + sieć", + "chargingMode1": "Tylko słońce", + "command0": "Brak", + "command1": "Start", + "command2": "Stop", + "ocpp0": "Nierozłączona", + "ocpp1": "Łączenie", + "ocpp2": "Połączona", + "mqtt0": "Nierozłączona", + "mqtt1": "Połączona" + }, "info": { "title": "Informacje o ładowarce", "empty": "Nic jeszcze nie zaimportowano — zaimportowana ładowarka pokaże tutaj wszystko, co zgłasza.", diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index d9c535c..f383122 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -86,6 +86,127 @@ const ctlStatusLabel = computed(() => { return (ctlIsModbus.value ? s.statusDesc : s.connectorStatus) || "—"; }); +// --- The Modbus snapshot, grouped for reading --- +// +// The local path reports far more than the OCPP one: one poll carries metering, +// the control settings and the charger's identity. Shown as a flat list that is +// a wall of forty numbers, so it is sorted the way it gets asked about — what +// the charger is doing, what it is set to, and what it is. +const mb = computed(() => (ctlIsModbus.value && ctl.value?.status) || {}); + +const isSet = (v) => v !== undefined && v !== null; +const unit = (v, digits, u) => (isSet(v) ? `${Number(v).toFixed(digits)} ${u}` : null); +const yesNo = (v) => (isSet(v) ? (v ? t("common.yes") : t("common.no")) : null); +const label = (key) => t(`charging.modbus.${key}`); + +// An enum the charger reports as a number, named through the catalogue so it +// translates; an unlisted value falls back to the number rather than a blank. +const enumLabel = (prefix, v) => { + if (!isSet(v)) return null; + const key = `charging.modbus.${prefix}${v}`; + const text = t(key); + return text === key ? String(v) : text; +}; + +function sessionLength(sec) { + if (!isSet(sec)) return null; + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + return h > 0 ? `${h} h ${m} min` : `${m} min`; +} + +// Pairs with no value drop out: a charger on older firmware, or one that refused +// the control block, should show a shorter list rather than a column of dashes. +const rows = (pairs) => + pairs.filter(([, v]) => isSet(v) && v !== "").map(([k, v]) => ({ label: label(k), value: v })); + +const modbusLive = computed(() => { + const s = mb.value; + return rows([ + ["power", unit(s.powerTotal, 0, "W")], + ["sessionDuration", sessionLength(s.sessionSeconds)], + ["cpSignal", s.cpSignalDesc], + ["cpVoltage", unit(s.cpVoltage, 2, "V")], + ["phaseMode", enumLabel("phaseMode", s.phaseMode)], + ["relayTemps", + isSet(s.relay1TempC) && isSet(s.relay2TempC) + ? `${s.relay1TempC.toFixed(1)} / ${s.relay2TempC.toFixed(1)} °C` + : unit(s.relay1TempC, 1, "°C")], + ["pwm", yesNo(s.pwmEnabled)], + ]); +}); + +const modbusSettings = computed(() => { + const s = mb.value; + const set = s.settings || {}; + return rows([ + ["maxCurrentSet", unit(set.maxCurrentA, 1, "A")], + ["timeout", isSet(set.timeoutSeconds) ? `${set.timeoutSeconds} s` : null], + ["phaseSetting", enumLabel("phaseSet", set.phaseSetting)], + ["boostSet", yesNo(set.boost)], + ["lastCommand", enumLabel("command", set.lastCommand)], + ["chargingMode", enumLabel("chargingMode", s.chargingMode)], + ["loadBalancing", yesNo(s.loadBalancing)], + ["solarBalancing", yesNo(s.solarBalancing)], + ["ledBrightness", isSet(s.ledBrightness) ? `${s.ledBrightness} %` : null], + ]); +}); + +const modbusDevice = computed(() => { + const s = mb.value; + return rows([ + ["model", s.model], + ["serial", s.serial], + ["firmware", s.firmware], + ["hardware", s.hardware], + ["productNumber", isSet(s.productNumber) ? String(s.productNumber) : null], + ["ratedPower", unit(s.ratedPowerW, 0, "W")], + ["currentRange", + isSet(s.minCurrentA) && isSet(s.maxCurrentA) ? `${s.minCurrentA}–${s.maxCurrentA} A` : null], + ["ocppLink", enumLabel("ocpp", s.ocppStatus)], + ["mqttLink", enumLabel("mqtt", s.mqttStatus)], + ]); +}); + +// The per-phase readings are a matrix, not a list: three phases against five +// measurements. A table says that; twenty labelled pairs hide it. +const modbusPhases = computed(() => { + const s = mb.value; + const cell = (v, digits, u) => (isSet(v) ? `${Number(v).toFixed(digits)} ${u}` : "—"); + const any = ["voltageL1", "currentL1", "powerL1"].some((k) => isSet(s[k])); + if (!any) return []; + return [1, 2, 3].map((n) => ({ + phase: `L${n}`, + volts: cell(s[`voltageL${n}`], 1, "V"), + amps: cell(s[`currentL${n}`], 2, "A"), + watts: cell(s[`powerL${n}`], 0, "W"), + reactive: cell(s[`reactiveL${n}`], 0, "var"), + apparent: cell(s[`apparentL${n}`], 0, "VA"), + })); +}); + +// Line-to-line voltages only mean anything on a three-phase supply, so they are +// shown when the charger reports one rather than as three more zeroes. +const modbusLineVoltages = computed(() => { + const s = mb.value; + const pairs = [ + ["L1–L2", s.voltageL1L2], + ["L2–L3", s.voltageL2L3], + ["L3–L1", s.voltageL3L1], + ].filter(([, v]) => isSet(v) && v > 10); + return pairs.map(([name, v]) => `${name} ${v.toFixed(1)} V`); +}); + +// The spec defers the alarm bits to a list it does not publish, so the words are +// shown as they arrive: which one is set is still the thing to report. +const modbusAlarms = computed(() => { + const s = mb.value; + if (!s.alarm || !Array.isArray(s.alarms)) return []; + return s.alarms + .map((w, i) => ({ n: i + 1, hex: "0x" + w.toString(16).toUpperCase().padStart(4, "0"), set: w !== 0 })) + .filter((w) => w.set); +}); + async function loadCtlMode() { try { const v = await api.getAnkerSolix(); @@ -734,6 +855,84 @@ onMounted(async () => { {{ t("charging.control.boost") }} + +
+
+

{{ t("charging.modbus.phases") }}

+
+ + + + + + + + + + + + + + + + + + + + + +
{{ t("charging.modbus.phase") }}{{ t("charging.modbus.voltage") }}{{ t("charging.modbus.current") }}{{ t("charging.modbus.activePower") }}{{ t("charging.modbus.reactivePower") }}{{ t("charging.modbus.apparentPower") }}
{{ p.phase }}{{ p.volts }}{{ p.amps }}{{ p.watts }}{{ p.reactive }}{{ p.apparent }}
+
+

+ {{ t("charging.modbus.lineToLine") }}: {{ modbusLineVoltages.join(" · ") }} +

+
+ +
+

{{ t("charging.modbus.live") }}

+
+ +
+
+ +
+

{{ t("charging.modbus.settings") }}

+
+ +
+
+ +
+

{{ t("charging.modbus.device") }}

+
+ +
+
+ + +
+

{{ t("charging.modbus.alarms") }}

+

+ + {{ t("charging.modbus.alarmWord", { n: a.n }) }} {{ a.hex }} + +

+

{{ t("charging.modbus.alarmsHint") }}

+
+
+