Everything the register map carries, sorted the way it gets asked about
The Modbus snapshot reported about half of what one poll already brings back. The rest was read into the block and thrown away: line-to-line voltages, reactive and apparent power per phase, the PWM flag, the control-pilot voltage, and the identity block's product number, rated power and current range. All of it now decodes — no extra requests, the registers were in hand already. Added alongside it: the control block, read back over FC03. It answers a question the live registers cannot, which is what the charger is *set* to as opposed to what it is doing — a boost that was asked for reads there while the live block still reports none running. Best effort, so a charger that refuses it still reports its state. Two registers the spec leaves blank are decoded on the hardware's evidence. The control-pilot voltage reads 11873 while the CP signal register reports state A, which that enum names as 12 V, so the register is millivolts. The identity block's current range is in amps, whatever its unit column says about watts and kVA. The charging card lays this out in sections rather than a wall of forty numbers: per-phase measurements as the matrix they are, then live state, then settings, then the device itself, with alarms surfacing only when a word is non-zero. Strings in en/da/pl. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
aaa89dfe10
commit
62cb691f98
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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") }}
|
||||
</button>
|
||||
|
||||
<!-- Everything else the register map carries. One poll brings back
|
||||
metering, the control settings and the charger's identity, so
|
||||
they are grouped rather than listed: doing, set to, is. -->
|
||||
<div v-if="ctlIsModbus" class="mt-4 flex flex-col gap-2">
|
||||
<section v-if="modbusPhases.length" class="rounded-control bg-sunken p-3">
|
||||
<h4 class="eyebrow">{{ t("charging.modbus.phases") }}</h4>
|
||||
<div class="mt-2 overflow-x-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-muted">
|
||||
<th class="py-1 text-left font-medium">{{ t("charging.modbus.phase") }}</th>
|
||||
<th class="py-1 text-right font-medium">{{ t("charging.modbus.voltage") }}</th>
|
||||
<th class="py-1 text-right font-medium">{{ t("charging.modbus.current") }}</th>
|
||||
<th class="py-1 text-right font-medium">{{ t("charging.modbus.activePower") }}</th>
|
||||
<th class="py-1 text-right font-medium">{{ t("charging.modbus.reactivePower") }}</th>
|
||||
<th class="py-1 text-right font-medium">{{ t("charging.modbus.apparentPower") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="data">
|
||||
<tr v-for="p in modbusPhases" :key="p.phase" class="border-t border-subtle">
|
||||
<td class="py-1 text-left text-muted">{{ p.phase }}</td>
|
||||
<td class="py-1 text-right text-strong">{{ p.volts }}</td>
|
||||
<td class="py-1 text-right text-strong">{{ p.amps }}</td>
|
||||
<td class="py-1 text-right text-strong">{{ p.watts }}</td>
|
||||
<td class="py-1 text-right text-strong">{{ p.reactive }}</td>
|
||||
<td class="py-1 text-right text-strong">{{ p.apparent }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="modbusLineVoltages.length" class="data mt-2 text-[11px] text-muted">
|
||||
{{ t("charging.modbus.lineToLine") }}: {{ modbusLineVoltages.join(" · ") }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section v-if="modbusLive.length" class="rounded-control bg-sunken p-3">
|
||||
<h4 class="eyebrow">{{ t("charging.modbus.live") }}</h4>
|
||||
<dl class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
<template v-for="r in modbusLive" :key="r.label">
|
||||
<dt class="text-xs text-muted">{{ r.label }}</dt>
|
||||
<dd class="data text-right text-xs text-strong">{{ r.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-if="modbusSettings.length" class="rounded-control bg-sunken p-3">
|
||||
<h4 class="eyebrow">{{ t("charging.modbus.settings") }}</h4>
|
||||
<dl class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
<template v-for="r in modbusSettings" :key="r.label">
|
||||
<dt class="text-xs text-muted">{{ r.label }}</dt>
|
||||
<dd class="data text-right text-xs text-strong">{{ r.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-if="modbusDevice.length" class="rounded-control bg-sunken p-3">
|
||||
<h4 class="eyebrow">{{ t("charging.modbus.device") }}</h4>
|
||||
<dl class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
<template v-for="r in modbusDevice" :key="r.label">
|
||||
<dt class="text-xs text-muted">{{ r.label }}</dt>
|
||||
<dd class="data text-right text-xs text-strong">{{ r.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- Alarms, when any word is non-zero. Which register is set is
|
||||
reportable even though the bit list is not published. -->
|
||||
<section v-if="modbusAlarms.length" class="rounded-control border border-warning/40 bg-warning-soft p-3">
|
||||
<h4 class="eyebrow" style="color: var(--warning-600)">{{ t("charging.modbus.alarms") }}</h4>
|
||||
<p class="data mt-2 text-xs text-strong">
|
||||
<span v-for="a in modbusAlarms" :key="a.n" class="mr-3 inline-block">
|
||||
{{ t("charging.modbus.alarmWord", { n: a.n }) }} {{ a.hex }}
|
||||
</span>
|
||||
</p>
|
||||
<p class="mt-1 text-[11px] text-muted">{{ t("charging.modbus.alarmsHint") }}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Reset reboots the charger over OCPP; the register map has no
|
||||
equivalent, so the button is not offered on the local path. -->
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user