From ed3e68308b3c1d98c37cb88fb95746214c375b7f Mon Sep 17 00:00:00 2001 From: Daniel Sadilek Date: Mon, 3 Aug 2026 21:50:45 +0200 Subject: [PATCH 1/3] Derive charging state from the wallbox instead of assuming it Register 261 holds the charging current limit: 0 A blocks charging, 6-16 A permits it. It keeps its value when this bridge restarts. Two defects let the bridge's RAM state drift away from it, and the result was a reported state of "charging enabled" while the wallbox delivered nothing, which the Home Assistant switch could not correct. Init() never read register 261. It configured 262, 258 and 257, so mChargingEnabled started at the header default of true regardless of what the wallbox was doing. If charging had been disabled when the bridge restarted -- a PV surplus controller pausing overnight, then an OTA update or a power blip -- the flag claimed enabled while the register held 0 A. Init() now reads the register and seeds both the flag and the requested limit from it. If that read fails the old assumption remains, but the mismatch is no longer permanent, since any later enable or disable rewrites the register. GetChargingCurrentLimit() overwrote the setpoint. It is telemetry, called from the publish rotation and from the Modbus TCP server, but it assigned its reading to the same member that held the desired limit. A single read of 0 A destroyed the value to be applied next, so enabling afterwards restored 0 A. Intent and observation now live in separate members, and the getter only touches the observed one. Because intent now survives a disable, mPreviousChargingCurrentLimitA has nothing left to do and is removed. SetChargingEnabled writes the requested limit or 0 on every call rather than only on a transition, since a request that matches our flag must still reach a wallbox that may disagree. Requests are clamped to the 0 or 6-16 A the wallbox accepts, which also removes an undefined float-to-uint16_t conversion for negative payloads. Writing 0 to stop charging still works, so Daheimladen clients are unaffected. Two deliberate behaviour changes: - After a client stops charging by writing 0 A rather than by disabling, enabling now starts charging at the configured default, where the old transition gate made it a no-op. It does not remember a pre-zero limit: "8 A, 0 A, enable" gives the default, "8 A, disable, enable" gives 8 A. - GetChargingCurrentLimit() returns 0 A rather than a stale 16 A before the first successful read. DummyWallbox keeps the previous logic, since its register is RAM that dies with the process and this class of desync cannot occur there. Happy to port it if you would rather the two implementations match. Tested on a Heidelberg Energy Control with an ESP32. The sequence that used to fail -- set 8 A, disable, wait for telemetry to read 0 A repeatedly, enable -- now restores 8 A, verified by reading register 261 over Modbus TCP rather than over MQTT, since the firmware only publishes that value within 6-16 A. --- src/Components/Wallbox/HeidelbergWallbox.cpp | 115 +++++++++++++------ src/Components/Wallbox/HeidelbergWallbox.h | 6 +- src/Configuration/Constants.h | 3 + 3 files changed, 87 insertions(+), 37 deletions(-) diff --git a/src/Components/Wallbox/HeidelbergWallbox.cpp b/src/Components/Wallbox/HeidelbergWallbox.cpp index c1bcf7b..ec94372 100644 --- a/src/Components/Wallbox/HeidelbergWallbox.cpp +++ b/src/Components/Wallbox/HeidelbergWallbox.cpp @@ -10,6 +10,37 @@ HeidelbergWallbox *HeidelbergWallbox::Instance() return &instance; } +namespace +{ + // The wallbox accepts 0 A or 6-16 A. Below the minimum means blocked, which is + // also what a client writing 0 to stop charging expects. + float ClampToWallboxRange(float currentLimitA) + { + if (currentLimitA < Constants::HeidelbergWallbox::MinChargingCurrentA) + { + return 0.0f; + } + if (currentLimitA > Constants::HeidelbergWallbox::MaxChargingCurrentA) + { + return Constants::HeidelbergWallbox::MaxChargingCurrentA; + } + return currentLimitA; + } + + // 0 A blocks charging, 6-16 A permits it + bool WriteCurrentLimitRegister(float currentLimitA) + { + const uint16_t rawCurrent = static_cast(currentLimitA / Constants::HeidelbergWallbox::CurrentFactor); + if (!ModbusRTU::Instance()->WriteHoldRegister16(Constants::HeidelbergRegisters::MaximalCurrent, rawCurrent)) + { + // Error writing modbus register + Logger::Error("Heidelberg wallbox: ERROR: Could not set maximum charging current"); + return false; + } + return true; + } +} + void HeidelbergWallbox::Init() { uint16_t rawCurrent = static_cast(Constants::HeidelbergWallbox::FailSafeCurrentA / Constants::HeidelbergWallbox::CurrentFactor); @@ -36,6 +67,30 @@ void HeidelbergWallbox::Init() // Error writing modbus register Logger::Error("ERROR: Could not set watchdog timeout"); } + + // Register 261 keeps its value when this bridge restarts, so seed our state + // from it rather than assuming charging is enabled. + uint16_t rawLimit[1]; + if (ModbusRTU::Instance()->ReadRegisters(Constants::HeidelbergRegisters::MaximalCurrent, 1, 0x3, rawLimit)) + { + mObservedChargingCurrentLimitA = static_cast(rawLimit[0] * Constants::HeidelbergWallbox::CurrentFactor); + mChargingEnabled = mObservedChargingCurrentLimitA >= Constants::HeidelbergWallbox::MinChargingCurrentA; + + if (mChargingEnabled) + { + // Adopt what the wallbox is already applying. Clamped because the + // register is 16 bit and another controller may have left a larger + // value in it. + mRequestedChargingCurrentLimitA = ClampToWallboxRange(mObservedChargingCurrentLimitA); + } + + Logger::Info("Heidelberg wallbox: seeded state from register: %f A, charging %s", + mObservedChargingCurrentLimitA, mChargingEnabled ? "enabled" : "disabled"); + } + else + { + Logger::Error("Heidelberg wallbox: ERROR: Could not read charging current limit during init"); + } } VehicleState HeidelbergWallbox::GetState() @@ -68,50 +123,40 @@ VehicleState HeidelbergWallbox::GetState() bool HeidelbergWallbox::SetChargingCurrentLimit(float currentLimitA) { - if (mChargingEnabled) - { - mChargingCurrentLimitA = currentLimitA; - Logger::Info("Heidelberg wallbox: setting charging current limit to %f A", mChargingCurrentLimitA); + currentLimitA = ClampToWallboxRange(currentLimitA); + mRequestedChargingCurrentLimitA = currentLimitA; - uint16_t rawCurrent = static_cast(mChargingCurrentLimitA / Constants::HeidelbergWallbox::CurrentFactor); - if (!ModbusRTU::Instance()->WriteHoldRegister16(Constants::HeidelbergRegisters::MaximalCurrent, rawCurrent)) - { - // Error writing modbus register - Logger::Error("Heidelberg wallbox: ERROR: Could not set maximum charging current"); - } - } - else + if (!mChargingEnabled) { - mPreviousChargingCurrentLimitA = currentLimitA; - Logger::Info("Heidelberg wallbox: charging is disabled. current limit %f A is not applied", mChargingCurrentLimitA); + Logger::Info("Heidelberg wallbox: charging is disabled. current limit %f A is not applied yet", currentLimitA); + return true; } - return true; + Logger::Info("Heidelberg wallbox: setting charging current limit to %f A", currentLimitA); + return WriteCurrentLimitRegister(currentLimitA); } bool HeidelbergWallbox::SetChargingEnabled(bool chargingEnabled) { - bool ok = true; + Logger::Info("Heidelberg wallbox: %s charging", chargingEnabled ? "enabling" : "disabling"); - if (!mChargingEnabled && chargingEnabled) - { - Logger::Info("Heidelberg wallbox: enabling charging"); + mChargingEnabled = chargingEnabled; - // Enable charging - mChargingEnabled = true; - ok = SetChargingCurrentLimit(mPreviousChargingCurrentLimitA); - } - else if (mChargingEnabled && !chargingEnabled) + // Written on every call, not only on a transition: the flag is ours, the + // register is the wallbox's, and the two can disagree. + if (!chargingEnabled) { - Logger::Info("Heidelberg wallbox: disabling charging"); + return WriteCurrentLimitRegister(0.0f); + } - // Disable charging - mPreviousChargingCurrentLimitA = mChargingCurrentLimitA; - ok = SetChargingCurrentLimit(0.0f); - mChargingEnabled = false; + // A setpoint below the minimum would leave charging blocked, which contradicts + // the request to enable it. Fall back to the default. + if (mRequestedChargingCurrentLimitA < Constants::HeidelbergWallbox::MinChargingCurrentA) + { + mRequestedChargingCurrentLimitA = Constants::HeidelbergWallbox::InitialChargingCurrentLimitA; } - return ok; + return WriteCurrentLimitRegister(mRequestedChargingCurrentLimitA); } bool HeidelbergWallbox::IsChargingEnabled() @@ -167,19 +212,21 @@ bool HeidelbergWallbox::GetStandbyEnabled() float HeidelbergWallbox::GetChargingCurrentLimit() { + // Telemetry only. Must not write mRequestedChargingCurrentLimitA: a reading of + // 0 A would destroy the limit to apply the next time charging is enabled. uint16_t registerValue[1]; if (ModbusRTU::Instance()->ReadRegisters(Constants::HeidelbergRegisters::MaximalCurrent, 1, 0x3, registerValue)) { - mChargingCurrentLimitA = static_cast(registerValue[0] * Constants::HeidelbergWallbox::CurrentFactor); - Logger::Debug("Heidelberg wallbox: Read max. charging current: %f", mChargingCurrentLimitA); - return mChargingCurrentLimitA; + mObservedChargingCurrentLimitA = static_cast(registerValue[0] * Constants::HeidelbergWallbox::CurrentFactor); + Logger::Debug("Heidelberg wallbox: Read max. charging current: %f", mObservedChargingCurrentLimitA); } else { // Error reading modbus register Logger::Error("Heidelberg wallbox: ERROR: Could not read max. charging current"); - return mChargingCurrentLimitA; // Return last valid value } + + return mObservedChargingCurrentLimitA; // last known value if the read failed } float HeidelbergWallbox::GetEnergyMeterValue() diff --git a/src/Components/Wallbox/HeidelbergWallbox.h b/src/Components/Wallbox/HeidelbergWallbox.h index e8b10c7..ca4f368 100644 --- a/src/Components/Wallbox/HeidelbergWallbox.h +++ b/src/Components/Wallbox/HeidelbergWallbox.h @@ -31,11 +31,11 @@ class HeidelbergWallbox : public IWallbox private: VehicleState mState{VehicleState::Disconnected}; - float mChargingCurrentLimitA{Constants::HeidelbergWallbox::InitialChargingCurrentLimitA}; + float mRequestedChargingCurrentLimitA{Constants::HeidelbergWallbox::InitialChargingCurrentLimitA}; // intent + float mObservedChargingCurrentLimitA{0.0f}; // last read back float mFailsafeCurrentA{0.0f}; float mLastPowerMeterValueW{0.0f}; float mLastEnergyMeterValueWh{0.0f}; - bool mChargingEnabled{true}; + bool mChargingEnabled{true}; // seeded from the wallbox in Init() bool mStandbyEnabled{true}; // default: standby enabled - float mPreviousChargingCurrentLimitA{Constants::HeidelbergWallbox::InitialChargingCurrentLimitA}; }; \ No newline at end of file diff --git a/src/Configuration/Constants.h b/src/Configuration/Constants.h index e9dc495..579dfc1 100644 --- a/src/Configuration/Constants.h +++ b/src/Configuration/Constants.h @@ -23,6 +23,9 @@ namespace Constants constexpr float VoltageFactor = 1.0f; constexpr float TemperatureFactor = 0.1f; constexpr float InitialChargingCurrentLimitA = 16.0f; + // The wallbox accepts 0 A (charging blocked) or 6-16 A. Nothing in between. + constexpr float MinChargingCurrentA = 6.0f; + constexpr float MaxChargingCurrentA = 16.0f; constexpr uint8_t ModbusServerId = 1; constexpr uint32_t ModbusBaudrate = 19200; constexpr uint16_t ModbusTimeoutMs = 2000; From 4f557727827458e4bb9a8ce02d74156a107c37c4 Mon Sep 17 00:00:00 2001 From: "Brock, Boris" Date: Tue, 4 Aug 2026 10:57:41 +0200 Subject: [PATCH 2/3] Minor refactoring --- src/Components/Wallbox/HeidelbergWallbox.cpp | 43 +++++++++----------- src/Configuration/Constants.h | 3 +- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/src/Components/Wallbox/HeidelbergWallbox.cpp b/src/Components/Wallbox/HeidelbergWallbox.cpp index ec94372..d58e332 100644 --- a/src/Components/Wallbox/HeidelbergWallbox.cpp +++ b/src/Components/Wallbox/HeidelbergWallbox.cpp @@ -10,35 +10,32 @@ HeidelbergWallbox *HeidelbergWallbox::Instance() return &instance; } -namespace +// The wallbox accepts 0 A or 6-16 A. Below the minimum means blocked, which is +// also what a client writing 0 to stop charging expects. +float HeidelbergWallbox::ClampToWallboxRange(float currentLimitA) { - // The wallbox accepts 0 A or 6-16 A. Below the minimum means blocked, which is - // also what a client writing 0 to stop charging expects. - float ClampToWallboxRange(float currentLimitA) + if (currentLimitA < Constants::HeidelbergWallbox::MinChargingCurrentA) { - if (currentLimitA < Constants::HeidelbergWallbox::MinChargingCurrentA) - { - return 0.0f; - } - if (currentLimitA > Constants::HeidelbergWallbox::MaxChargingCurrentA) - { - return Constants::HeidelbergWallbox::MaxChargingCurrentA; - } - return currentLimitA; + return 0.0f; + } + if (currentLimitA > Constants::HeidelbergWallbox::MaxChargingCurrentA) + { + return Constants::HeidelbergWallbox::MaxChargingCurrentA; } + return currentLimitA; +} - // 0 A blocks charging, 6-16 A permits it - bool WriteCurrentLimitRegister(float currentLimitA) +// 0 A blocks charging, 6-16 A permits it +bool HeidelbergWallbox::WriteCurrentLimitRegister(float currentLimitA) +{ + const uint16_t rawCurrent = static_cast(currentLimitA / Constants::HeidelbergWallbox::CurrentFactor); + if (!ModbusRTU::Instance()->WriteHoldRegister16(Constants::HeidelbergRegisters::MaximalCurrent, rawCurrent)) { - const uint16_t rawCurrent = static_cast(currentLimitA / Constants::HeidelbergWallbox::CurrentFactor); - if (!ModbusRTU::Instance()->WriteHoldRegister16(Constants::HeidelbergRegisters::MaximalCurrent, rawCurrent)) - { - // Error writing modbus register - Logger::Error("Heidelberg wallbox: ERROR: Could not set maximum charging current"); - return false; - } - return true; + // Error writing modbus register + Logger::Error("Heidelberg wallbox: ERROR: Could not set maximum charging current"); + return false; } + return true; } void HeidelbergWallbox::Init() diff --git a/src/Configuration/Constants.h b/src/Configuration/Constants.h index 579dfc1..a05217d 100644 --- a/src/Configuration/Constants.h +++ b/src/Configuration/Constants.h @@ -22,10 +22,9 @@ namespace Constants constexpr float CurrentFactor = 0.1f; constexpr float VoltageFactor = 1.0f; constexpr float TemperatureFactor = 0.1f; - constexpr float InitialChargingCurrentLimitA = 16.0f; - // The wallbox accepts 0 A (charging blocked) or 6-16 A. Nothing in between. constexpr float MinChargingCurrentA = 6.0f; constexpr float MaxChargingCurrentA = 16.0f; + constexpr float InitialChargingCurrentLimitA = MaxChargingCurrentA; constexpr uint8_t ModbusServerId = 1; constexpr uint32_t ModbusBaudrate = 19200; constexpr uint16_t ModbusTimeoutMs = 2000; From 55ed86f7f28b8b11f2798d120616b393a1cf4502 Mon Sep 17 00:00:00 2001 From: "Brock, Boris" Date: Tue, 4 Aug 2026 11:00:28 +0200 Subject: [PATCH 3/3] Build fixes --- src/Components/Wallbox/HeidelbergWallbox.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Components/Wallbox/HeidelbergWallbox.h b/src/Components/Wallbox/HeidelbergWallbox.h index ca4f368..d86b99d 100644 --- a/src/Components/Wallbox/HeidelbergWallbox.h +++ b/src/Components/Wallbox/HeidelbergWallbox.h @@ -7,6 +7,8 @@ class HeidelbergWallbox : public IWallbox { private: HeidelbergWallbox() {}; + float ClampToWallboxRange(float currentLimitA); + bool WriteCurrentLimitRegister(float currentLimitA); public: static HeidelbergWallbox *Instance();