Add FoxESS EV charger with Modbus TCP support#31412
Conversation
|
@GurliGebis thanks for the PR! Would you mind testing this before we merge? Happy to wait. I'd also ask you to contact info@evcc.io please. |
|
@tammycutzet I'll look into those in a few minutes. |
|
@andig I have sent an email now |
ebcbd83 to
008fce4
Compare
|
Maybe this comes in handy later for you @GurliGebis. I made a small python script to read the data from the wallbox for troubleshooting my script back in the time: import time
from pymodbus.client import ModbusTcpClient
from rich.console import Console
from rich.table import Table
from rich import box
# =========================
# CONFIG
# =========================
IP = "127.0.0.1"
PORT = 1502
SLAVE_ID = 1
console = Console()
# =========================
# LOOKUP TABLES
# =========================
evc_status = {
0: "Idle",
1: "Connected",
2: "Start Requested",
3: "Charging",
4: "Paused",
5: "Finished",
6: "Fault",
7: "Reserved",
8: "Locked",
9: "Phase Switching?",
}
cp_status = {
0: "CP Fault",
1: "12V - Not Connected",
2: "9V - Vehicle Connected",
3: "6V - Vehicle Ready"
}
cc_status = {
0: "Plug Unconnected",
1: "Plug Connected"
}
stop_reason = {
0: "None",
1: "Stopped on Command",
2: "Timed Charging Completed",
3: "S2 Timeout",
4: "Charging Pause Timeout",
5: "Emergency Stop",
6: "Abnormal CP Voltage",
7: "Connector Removed",
8: "AC Contactor Fault",
9: "Electronic Lock Fault",
10: "Card Reader Fault",
11: "Overcurrent",
12: "Overvoltage",
13: "Undervoltage",
14: "Port Overtemperature",
15: "Leakage Current",
16: "N Line Reverse",
17: "Frequency Fault",
18: "Stop Button Pressed",
19: "Breaker Fault",
20: "Phase Loss",
21: "PE Fault",
}
electric_lock_status = {
0: "Unlocked",
1: "Locked"
}
# =========================
# FAULT BITS (0x101A)
# =========================
fault_bits = {
0: "Emergency stop",
1: "Overvoltage",
2: "Undervoltage",
3: "Overcurrent",
4: "Charging port temperature",
5: "PE grounding",
6: "Leakage current",
7: "Frequency fault",
8: "CP fault",
9: "Connector",
10: "AC contactor",
11: "Electronic lock",
12: "Breaker",
13: "CC fault",
14: "External meter communication",
15: "Metering chip",
16: "Environment temperature",
17: "Access control",
}
# =========================
# REGISTER MAP
# =========================
registers = {
0x1000: "Device Address",
0x1001: "Software Version",
0x1002: "Last Stop Reason",
0x1003: "EVC Status",
0x1004: "CP Status",
0x1005: "CC Status",
0x3005: "Time Validity",
0x3006: "Default Current",
0x300A: "Auto Phase Switch",
0x300B: "Hysteresis Phase Switch",
0x1007: "Ambient Temp",
0x1008: "A Phase Volt",
0x1009: "B Phase Volt",
0x100A: "C Phase Volt",
0x100B: "A Phase Current",
0x100C: "B Phase Current",
0x100D: "C Phase Current",
0x100E: "Active Power",
0x3002: "Power Setpoint",
0x100F: "Electronic Lock Status",
0x3001: "Max Charging Current",
0x3003: "Allowable Charge Time",
0x3004: "Allowable Charge Energy",
0x101A: "System Fault Information",
}
# =========================
# HELPERS
# =========================
def read_reg(client, reg):
try:
res = client.read_holding_registers(address=reg, count=1, slave=SLAVE_ID)
if res.isError():
return None
return res.registers[0]
except:
return None
def decode(reg, value):
if value is None:
return "ERROR"
if reg == 0x1001:
return f"{value >> 8}.{value & 0xFF}"
if reg == 0x1002:
return f"{value} ({stop_reason.get(value, 'Unknown')})"
if reg == 0x1003:
return f"{value} ({evc_status.get(value, 'Unknown')})"
if reg == 0x1004:
return f"{value} ({cp_status.get(value, 'Unknown')})"
if reg == 0x1005:
return f"{value} ({cc_status.get(value, 'Unknown')})"
if reg == 0x1007:
return f"{(value * 0.1) - 50:.1f} °C"
if reg in [0x1008, 0x1009, 0x100A]:
return f"{value * 0.1:.1f} V"
if reg in [0x100B, 0x100C, 0x100D, 0x3001]:
return f"{value * 0.1:.1f} A"
if reg == 0x100E:
return f"{value * 0.1:.1f} kW"
if reg == 0x100F:
return f"{value} ({electric_lock_status.get(value, 'Unknown')})"
if reg == 0x3002:
return f"{value * 0.1:.1f} kW"
if reg == 0x3003:
return f"{value * 0.1:.0f} min"
if reg == 0x3004:
return f"{value * 0.1:.1f} kWh"
# =========================
# FAULT REGISTER (NEW)
# =========================
if reg == 0x101A:
active = []
for bit, name in fault_bits.items():
if value & (1 << bit):
active.append(name)
if not active:
return "🟢 OK"
return "🔴 " + ", ".join(active)
return str(value)
# =========================
# MAIN LOOP
# =========================
def main():
client = ModbusTcpClient(IP, port=PORT)
client.connect()
while True:
table = Table(
title="⚡ EV Charger SCADA Monitor",
box=box.SIMPLE_HEAVY
)
table.add_column("Register", style="cyan")
table.add_column("Name", style="white")
table.add_column("Value", style="green")
for reg, name in registers.items():
value = read_reg(client, reg)
decoded = decode(reg, value)
table.add_row(f"0x{reg:04X}", name, decoded)
console.clear()
console.print(table)
current_time = time.strftime("%Y-%m-%d %H:%M:%S")
console.print(f"\n🕒 Last update: {current_time}")
time.sleep(8)
if __name__ == "__main__":
main() |
9f3961a to
406901a
Compare
Feedback: FoxESS AC charger Modbus — no-PBOX (auto phase switching) has a current-mismatch / control-loop issueTested against a FoxESS A022KP1-E-B (firmware 1. Current mismatch corrupts evcc's control loop (no-PBOX mode)In no-PBOX mode the driver controls via the power setpoint Because the driver implements and then does Root cause: the driver reports per-phase currents, but controls via a 3-phase-equivalent power setpoint, so the measured per-phase current never matches evcc's commanded current whenever the charger's actual phase count differs from evcc's assumption. Possible directions (for discussion):
Configuring the evcc loadpoint as single-phase does not fix it, because the driver's formula hard-codes 2. Heartbeat vs. clean stopThe heartbeat re-sends the setpoint every 3. Minor: validity window range
The
|
47a79e1 to
1db0b14
Compare
|
@scruysberghs can you test the changes I just pushed? |
| return err | ||
| } | ||
|
|
||
| // ensureReg writes a value to a read/write register and verifies the result. |
There was a problem hiding this comment.
Some firmware returns Modbus exception 3 (illegal data value) when you write a value that the register already holds.
So, ensureReg works around this by treating a write rejection as success if the register already contains the value we tried to write.
There was a problem hiding this comment.
Evcc will retry a failed write anyway on next cycle?
There was a problem hiding this comment.
It was done to prevent the problem tammycutzet was having when trying to write the already existing value to the charger.
I have changed it, so it instead reads the value, compares it, and only writes it to the charger if they differ.
That way, we should still guard against the issue where an existing value is being written (which can cause issues it seems).
c6b71c3 to
fbebc96
Compare
|
With the latest code, I got |
|
Issue is still there - will debug tomorrow and see if I can figure out what write causes it. |
|
Your writeReg calls wb.conn.WriteMultipleRegisters for a single register- WB doesn't like that |
|
@premultiply I'm changing it (the pbox checkbox) back to a manual setting. So even though it would be great to have it autodetect, it seems like something that I cannot get it to do. (At least not without having two identical chagers where one of them has a pbox, and the other ones doesn't - which is not happening) |
Implements the FoxESS EV Charger (A/L/C-Series) via Modbus TCP, based on the official Modbus TCP Protocol 1.6 specification. - Status: maps EVC states 0-5 to A/B/C, fault/locked to error - Enable/Disable: start (0x4001=1) and stop (0x4001=2) via FC 0x06 - MaxCurrentMillis: writes 0x3001 at 0.1A resolution via FC 0x10 - Meter: active power from 0x100E (0.1kW resolution) - ChargeRater/MeterEnergy: session and total energy from 0x1016/0x1018 - PhaseCurrents/PhaseVoltages: per-phase readings from 0x100B-0x100D/0x1008-0x100A - Identifier: RFID card UID from 0x101C - PhaseSwitcher/PhaseGetter: conditional on pbox config option, uses direct phase switching register 0x4002 (requires hardware PBOX) On startup the driver forces work mode 0 (Controlled) and sets the command validity window to 60s, the maximum allowed by the spec. refs: evcc-io#26218
Adds the YAML template for the FoxESS EV Charger (A/L/C-Series). Supports Modbus TCP with default slave ID 1 and port 502. The optional 'pbox' parameter enables 1p/3p phase switching via the hardware phase-cutting box (PBOX). Without it the charger handles phase selection internally via its own auto-switch logic.
Disambiguates from the new Modbus integration in the device picker.
EVC status 9 means auto phase switch in progress. During this transition the charger is still actively charging, so map it to StatusC and treat it as enabled.
…e switching When no PBOX is present, the charger manages phase selection itself based on the power setpoint (0x3002) rather than a current setpoint (0x3001). Convert evcc's current value to power using P = sqrt(3) * 400V * I and write to the power register so the charger can decide the correct phase count.
The charger reverts to its fallback current if no Modbus command is received within the time validity window (60s). Add a goroutine that re-sends the last setpoint every 30s to keep the charger from going full power when evcc stops sending commands.
… registers Some charger firmware versions reject writes to work mode (0x3000) or time validity (0x3005) with Modbus exception 3 (illegal data value) when the register already holds the desired value. Add ensureReg helper that falls back to reading the register after a failed write. If the read-back matches the intended value the init proceeds normally, avoiding a fatal startup error.
…urrent mismatch In no-PBOX mode the charger controls via a power setpoint and auto-selects phases internally. evcc's sync loop previously fell through to the measured phase current branch (core/loadpoint.go), which logged a current mismatch warning every cycle and corrupted the control loop by adopting the measured per-phase current as the new offered current. Adding GetMaxCurrent() moves evcc to the primary CurrentGetter branch, which compares against the intended setpoint and stays in sync regardless of which phase count the charger chooses. PhaseCurrents remains unconditional so per-phase readings are still visible in the UI.
… disable The charger considers evcc offline if no Modbus message arrives within the time validity window (60s) and reverts to its fallback current. The heartbeat must keep firing even when stopped to prevent this. Previously the heartbeat was silenced on disable by zeroing wb.current, which risked the charger going to fallback current during extended stopped periods. Instead, track enabled state separately. When disabled, the heartbeat re-sends the stop command (0x4001=2) every 30s. The spec states that setpoint registers (0x3001/0x3002) only take effect in the charging state, but at least one firmware version resumes charging on a re-sent setpoint after a stop, so actively repeating the stop command is safer than repeating the setpoint.
- drop unnecessary struct wrapper in NewFoxESSEVCFromConfig - combine separate var declarations into var reg, val uint16 - use var val uint16 instead of val := uint16(0) in phases1p3p - remove trivial unit-conversion comments
- Enabled() tracks the local enabled flag via verifyEnabled instead of reading the activity status register; enabled means the charger is permitted to charge, not that it is currently charging - assign detectPbox result directly to wb.pbox, drop intermediate var - remove redundant debug log on PBOX detection - inline alarm variable into if condition in detectPbox
…wer setpoint In no-PBOX mode, the driver sends a power setpoint so the charger can auto-select phase count. The previous formula hardcoded 3 phases; use lp.GetPhases() via loadpoint.Controller instead so the setpoint matches the loadpoint's actual phase configuration. - implement loadpoint.Controller / LoadpointControl - drop commandedCurrent field and GetMaxCurrent (not the charger's actual setpoint) - compute power as voltage * current * phases / 100 (0.1kW units)
…dware Read the actual setpoint register rather than a cached value: - PBOX mode: read foxRegMaxCurrent (0x3001), divide by 10 (0.1A units) - no-PBOX mode: read foxRegMaxPower (0x3002), invert the power formula using the loadpoint phase count to recover amps from 0.1kW units
…stead of write-then-verify Some registers reject redundant writes with Modbus exception 3 (illegal data value). Switch from write-then-read-back-on-error to read-first, skip write if value already matches.
…point writes Some firmware rejects writes with exception 3 when the register already holds the value being written. Use ensureReg in the heartbeat so the write is skipped when the setpoint is unchanged.
…ble->disable transition Register 0x4001 is write-only so there is no read-back to detect whether the stop command was already sent. Re-sending stop every 30s causes the charger to reject it with exception 3 when already stopped. Track the last enabled state actually sent to the charger (lastEnabled) and only send the stop command when transitioning from enabled to disabled. The setpoint heartbeat path is unchanged: it still uses ensureReg which skips the write when the value is already current.
…to maintain validity window The charger times out and falls back to its default current if no Modbus command is received within the validity window (60s), regardless of whether charging is active. Always re-send the last setpoint in the heartbeat rather than only when enabled. Setpoint registers (0x3001/0x3002) only take effect in the charging state per the spec, so writing them while stopped is safe and keeps the validity window alive without risking unintended charging.
…rom charger on startup On startup wb.current is 0 and wb.lastEnabled is false, so the heartbeat has nothing to send until evcc issues its first command. If a car is already charging when evcc connects, the validity window can expire and the charger falls back to its default current. Read the active setpoint register and status register at the end of New() to seed wb.current and wb.lastEnabled from the actual charger state, so the heartbeat can maintain the existing setpoint from the first tick.
e790af6 to
37dda04
Compare
|
Now it is back to having pbox as a checkbox. The cleanup of the So next step is to have it tested against a charger with a car connected (which I cannot do yet). If anyone is up for testing it with a FoxESS charger, the docker image of the changes here can be found here: |
This comment has been minimized.
This comment has been minimized.
|
✅ Build finished.
|
Currently, FoxESS EV chargers can only be connected using OCPP, which has certain limitations (and sometimes outright doesn't work, particularly for phase switching).
This adds support for connecting them using Modbus TCP instead, while preserving the existing OCPP implementation. The existing OCPP product has been renamed to "AC EV Charger (OCPP)" to disambiguate the two in the device picker.
Implemented using the discussion at #26218, the ringaction/foxess_charger Home Assistant integration and the official FoxESS Modbus TCP Protocol 1.6 specification, with assistance from Claude AI.
The Modbus protocol specification covers the A, L and C series. Tested on an A022KP1-E-B.
Phase switching is supported via Modbus (requires a hardware phase-cutting box, PBOX). FoxESS does not support phase switching over OCPP.
My testing has been limited to adding the charger to evcc and checking that everything shows up correctly. I have not been able to test actual charging yet, as we are 2-3 weeks away from receiving our EV.
This fixes #30377