From 84c03ffaf65964cfefc93f1809a20a5ffd80f664 Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Fri, 9 Oct 2026 06:55:59 -0700 Subject: [PATCH 1/2] feat: add idempotent manual override control methods (#746) --- openevsehttp/commands_core.py | 82 ++++++++++++++ tests/test_commands.py | 200 ++++++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+) diff --git a/openevsehttp/commands_core.py b/openevsehttp/commands_core.py index 837e175..e893d0e 100644 --- a/openevsehttp/commands_core.py +++ b/openevsehttp/commands_core.py @@ -189,6 +189,88 @@ async def clear_override(self) -> None: _LOGGER.error("Problem clearing override: %s", response) raise CommandFailedError(f"Failed to clear override: {response}") + async def enable_override(self) -> None: + """Enable manual override idempotently. + + On firmware >= 4.0.1, sets override state to active. + On older firmware (< 4.0.1), sends $FE only if sleeping (state 254). + """ + lower = "4.0.1" + if self._version_check(lower): + _LOGGER.debug("Enabling manual override via HTTP API") + await self.set_override(state="active") + else: + _LOGGER.debug("Enabling manual override via RAPI") + if "state" not in self._status: + await self.update() + + if "state" not in self._status: + _LOGGER.error("Cannot enable override: unknown charger state.") + raise UnknownStateError( + "Cannot enable override: unknown charger state." + ) + + if self._status.get("state") == 254: + response, msg = await self.send_command("$FE") + _LOGGER.debug("Enable override response: %s", msg) + if response in [False, "NK"] or ( + isinstance(msg, str) + and (msg.startswith("$NK") or msg in RAPI_ERRORS) + ): + _LOGGER.error("Problem enabling override via RAPI: %s", msg) + raise CommandFailedError( + f"Failed to enable override via RAPI: {msg}" + ) + else: + _LOGGER.debug( + "Manual override already active (state %s), skipping $FE", + self._status.get("state"), + ) + + async def disable_override(self) -> None: + """Disable/clear manual override idempotently. + + On firmware >= 4.0.1, clears the override record via DELETE /override. + On older firmware (< 4.0.1), sends $FS only if not sleeping (state != 254). + """ + lower = "4.0.1" + if self._version_check(lower): + _LOGGER.debug("Disabling manual override via HTTP API") + await self.clear_override() + else: + _LOGGER.debug("Disabling manual override via RAPI") + if "state" not in self._status: + await self.update() + + if "state" not in self._status: + _LOGGER.error("Cannot disable override: unknown charger state.") + raise UnknownStateError( + "Cannot disable override: unknown charger state." + ) + + if self._status.get("state") != 254: + response, msg = await self.send_command("$FS") + _LOGGER.debug("Disable override response: %s", msg) + if response in [False, "NK"] or ( + isinstance(msg, str) + and (msg.startswith("$NK") or msg in RAPI_ERRORS) + ): + _LOGGER.error("Problem disabling override via RAPI: %s", msg) + raise CommandFailedError( + f"Failed to disable override via RAPI: {msg}" + ) + else: + _LOGGER.debug( + "Manual override already disabled (state 254), skipping $FS" + ) + + async def set_manual_override(self, enable: bool) -> None: + """Set the manual override state idempotently.""" + if enable: + await self.enable_override() + else: + await self.disable_override() + async def set_current(self, amps: int = 6) -> None: """Set the soft current limit.""" # 3.x - 4.1.0: use RAPI commands $SC diff --git a/tests/test_commands.py b/tests/test_commands.py index 8bf69ca..07d82e8 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -274,6 +274,206 @@ async def test_toggle_override_refresh_fail(mock_aioclient, caplog): assert "Cannot toggle override: unknown charger state." in caplog.text +async def test_idempotent_override_v4(test_charger, mock_aioclient, caplog): + """Test enable_override, disable_override, and set_manual_override on v4 firmware.""" + await test_charger.update() + + # 1. enable_override + value = { + "state": "active", + "charge_current": 0, + "max_current": 0, + "energy_limit": 0, + "time_limit": 0, + "auto_release": True, + } + mock_aioclient.get( + TEST_URL_OVERRIDE, + status=200, + body=json.dumps(value), + ) + mock_aioclient.post( + TEST_URL_OVERRIDE, + status=200, + body='{"msg": "OK"}', + ) + with caplog.at_level(logging.DEBUG): + await test_charger.enable_override() + assert "Enabling manual override via HTTP API" in caplog.text + + # 2. disable_override + mock_aioclient.delete( + TEST_URL_OVERRIDE, + status=200, + body='{"msg": "OK"}', + ) + with caplog.at_level(logging.DEBUG): + await test_charger.disable_override() + assert "Disabling manual override via HTTP API" in caplog.text + assert "Clearing manual override http" in caplog.text + + # 3. set_manual_override(True) + mock_aioclient.get( + TEST_URL_OVERRIDE, + status=200, + body=json.dumps(value), + ) + mock_aioclient.post( + TEST_URL_OVERRIDE, + status=200, + body='{"msg": "OK"}', + ) + with caplog.at_level(logging.DEBUG): + await test_charger.set_manual_override(True) + assert "Enabling manual override via HTTP API" in caplog.text + + # 4. set_manual_override(False) + mock_aioclient.delete( + TEST_URL_OVERRIDE, + status=200, + body='{"msg": "OK"}', + ) + with caplog.at_level(logging.DEBUG): + await test_charger.set_manual_override(False) + assert "Disabling manual override via HTTP API" in caplog.text + + +async def test_idempotent_override_v2(test_charger_v2, mock_aioclient, caplog): + """Test enable_override, disable_override, and set_manual_override on legacy v2 firmware.""" + await test_charger_v2.update() + + # Case 1: Charger is sleeping (state == 254) -> enable_override sends $FE + test_charger_v2._status["state"] = 254 + mock_aioclient.post( + TEST_URL_RAPI, + status=200, + body=json.dumps({"cmd": "OK", "ret": "$OK^20"}), + ) + with caplog.at_level(logging.DEBUG): + await test_charger_v2.enable_override() + assert "Enabling manual override via RAPI" in caplog.text + assert "Posting data: $FE" in caplog.text + + # Case 2: Charger is active (state == 2) -> enable_override skips $FE (no-op) + caplog.clear() + test_charger_v2._status["state"] = 2 + with caplog.at_level(logging.DEBUG): + await test_charger_v2.enable_override() + assert "Manual override already active (state 2), skipping $FE" in caplog.text + + # Case 3: Charger is active (state == 2) -> disable_override sends $FS + caplog.clear() + mock_aioclient.post( + TEST_URL_RAPI, + status=200, + body=json.dumps({"cmd": "OK", "ret": "$OK^20"}), + ) + with caplog.at_level(logging.DEBUG): + await test_charger_v2.disable_override() + assert "Disabling manual override via RAPI" in caplog.text + assert "Posting data: $FS" in caplog.text + + # Case 4: Charger is sleeping (state == 254) -> disable_override skips $FS (no-op) + caplog.clear() + test_charger_v2._status["state"] = 254 + with caplog.at_level(logging.DEBUG): + await test_charger_v2.disable_override() + assert "Manual override already disabled (state 254), skipping $FS" in caplog.text + + # Case 5: set_manual_override calls enable / disable + caplog.clear() + test_charger_v2._status["state"] = 254 + mock_aioclient.post( + TEST_URL_RAPI, + status=200, + body=json.dumps({"cmd": "OK", "ret": "$OK^20"}), + ) + with caplog.at_level(logging.DEBUG): + await test_charger_v2.set_manual_override(True) + assert "Posting data: $FE" in caplog.text + + caplog.clear() + test_charger_v2._status["state"] = 2 + mock_aioclient.post( + TEST_URL_RAPI, + status=200, + body=json.dumps({"cmd": "OK", "ret": "$OK^20"}), + ) + with caplog.at_level(logging.DEBUG): + await test_charger_v2.set_manual_override(False) + assert "Posting data: $FS" in caplog.text + + +async def test_idempotent_override_v2_errors(mock_aioclient, caplog): + """Test error handling in idempotent override methods on legacy firmware.""" + charger = main.OpenEVSE( + "openevse.test.tld", session=MockClientSession(mock_aioclient) + ) + # Register mock responses for status and config that will be invoked during update() + mock_aioclient.get( + "http://openevse.test.tld/status", + status=200, + body='{"version": "3.3.1"}', + ) + mock_aioclient.get( + "http://openevse.test.tld/config", + status=200, + body='{"version": "3.3.1"}', + ) + mock_aioclient.get( + "http://openevse.test.tld/status", + status=200, + body='{"version": "3.3.1"}', + ) + mock_aioclient.get( + "http://openevse.test.tld/config", + status=200, + body='{"version": "3.3.1"}', + ) + charger._status = {} + charger._config = {"version": "3.3.1"} + + # Missing state error on enable_override + with caplog.at_level(logging.ERROR): + with pytest.raises( + UnknownStateError, match=r"Cannot enable override: unknown charger state\." + ): + await charger.enable_override() + + # Missing state error on disable_override + with caplog.at_level(logging.ERROR): + with pytest.raises( + UnknownStateError, match=r"Cannot disable override: unknown charger state\." + ): + await charger.disable_override() + + # RAPI failure on enable_override ($FE) + charger._status = {"state": 254} + mock_aioclient.post( + "http://openevse.test.tld/r", + status=200, + body='{"cmd": "NK", "ret": "$NK^21"}', + ) + with caplog.at_level(logging.ERROR): + with pytest.raises( + CommandFailedError, match="Failed to enable override via RAPI:" + ): + await charger.enable_override() + + # RAPI failure on disable_override ($FS) + charger._status = {"state": 2} + mock_aioclient.post( + "http://openevse.test.tld/r", + status=200, + body='{"cmd": "NK", "ret": "$NK^21"}', + ) + with caplog.at_level(logging.ERROR): + with pytest.raises( + CommandFailedError, match="Failed to disable override via RAPI:" + ): + await charger.disable_override() + + # ── set_current ─────────────────────────────────────────────────────── From 08e84ae052dbea1ea1c41dab5edb954ee1f3750d Mon Sep 17 00:00:00 2001 From: "firstof9@gmail.com" Date: Fri, 9 Oct 2026 07:10:16 -0700 Subject: [PATCH 2/2] fix: ensure fresh status check on legacy overrides and clean test caplog --- openevsehttp/commands_core.py | 10 +++--- tests/test_commands.py | 68 +++++++++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/openevsehttp/commands_core.py b/openevsehttp/commands_core.py index e893d0e..3e19aac 100644 --- a/openevsehttp/commands_core.py +++ b/openevsehttp/commands_core.py @@ -201,8 +201,7 @@ async def enable_override(self) -> None: await self.set_override(state="active") else: _LOGGER.debug("Enabling manual override via RAPI") - if "state" not in self._status: - await self.update() + await self.update(force_status=True) if "state" not in self._status: _LOGGER.error("Cannot enable override: unknown charger state.") @@ -221,6 +220,8 @@ async def enable_override(self) -> None: raise CommandFailedError( f"Failed to enable override via RAPI: {msg}" ) + # Successful $FE wakes the charger from sleep (state 254) + self._status["state"] = 2 else: _LOGGER.debug( "Manual override already active (state %s), skipping $FE", @@ -239,8 +240,7 @@ async def disable_override(self) -> None: await self.clear_override() else: _LOGGER.debug("Disabling manual override via RAPI") - if "state" not in self._status: - await self.update() + await self.update(force_status=True) if "state" not in self._status: _LOGGER.error("Cannot disable override: unknown charger state.") @@ -259,6 +259,8 @@ async def disable_override(self) -> None: raise CommandFailedError( f"Failed to disable override via RAPI: {msg}" ) + # Successful $FS puts the charger into sleep (state 254) + self._status["state"] = 254 else: _LOGGER.debug( "Manual override already disabled (state 254), skipping $FS" diff --git a/tests/test_commands.py b/tests/test_commands.py index 07d82e8..dd05263 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -313,6 +313,7 @@ async def test_idempotent_override_v4(test_charger, mock_aioclient, caplog): assert "Clearing manual override http" in caplog.text # 3. set_manual_override(True) + caplog.clear() mock_aioclient.get( TEST_URL_OVERRIDE, status=200, @@ -328,6 +329,7 @@ async def test_idempotent_override_v4(test_charger, mock_aioclient, caplog): assert "Enabling manual override via HTTP API" in caplog.text # 4. set_manual_override(False) + caplog.clear() mock_aioclient.delete( TEST_URL_OVERRIDE, status=200, @@ -342,8 +344,19 @@ async def test_idempotent_override_v2(test_charger_v2, mock_aioclient, caplog): """Test enable_override, disable_override, and set_manual_override on legacy v2 firmware.""" await test_charger_v2.update() + mock_aioclient.get( + TEST_URL_CONFIG, + status=200, + body='{"version": "2.9.1"}', + repeat=True, + ) + # Case 1: Charger is sleeping (state == 254) -> enable_override sends $FE - test_charger_v2._status["state"] = 254 + mock_aioclient.get( + TEST_URL_STATUS, + status=200, + body='{"state": 254}', + ) mock_aioclient.post( TEST_URL_RAPI, status=200, @@ -353,16 +366,26 @@ async def test_idempotent_override_v2(test_charger_v2, mock_aioclient, caplog): await test_charger_v2.enable_override() assert "Enabling manual override via RAPI" in caplog.text assert "Posting data: $FE" in caplog.text + assert test_charger_v2._status["state"] == 2 # Case 2: Charger is active (state == 2) -> enable_override skips $FE (no-op) caplog.clear() - test_charger_v2._status["state"] = 2 + mock_aioclient.get( + TEST_URL_STATUS, + status=200, + body='{"state": 2}', + ) with caplog.at_level(logging.DEBUG): await test_charger_v2.enable_override() assert "Manual override already active (state 2), skipping $FE" in caplog.text # Case 3: Charger is active (state == 2) -> disable_override sends $FS caplog.clear() + mock_aioclient.get( + TEST_URL_STATUS, + status=200, + body='{"state": 2}', + ) mock_aioclient.post( TEST_URL_RAPI, status=200, @@ -372,17 +395,26 @@ async def test_idempotent_override_v2(test_charger_v2, mock_aioclient, caplog): await test_charger_v2.disable_override() assert "Disabling manual override via RAPI" in caplog.text assert "Posting data: $FS" in caplog.text + assert test_charger_v2._status["state"] == 254 # Case 4: Charger is sleeping (state == 254) -> disable_override skips $FS (no-op) caplog.clear() - test_charger_v2._status["state"] = 254 + mock_aioclient.get( + TEST_URL_STATUS, + status=200, + body='{"state": 254}', + ) with caplog.at_level(logging.DEBUG): await test_charger_v2.disable_override() assert "Manual override already disabled (state 254), skipping $FS" in caplog.text # Case 5: set_manual_override calls enable / disable caplog.clear() - test_charger_v2._status["state"] = 254 + mock_aioclient.get( + TEST_URL_STATUS, + status=200, + body='{"state": 254}', + ) mock_aioclient.post( TEST_URL_RAPI, status=200, @@ -393,7 +425,11 @@ async def test_idempotent_override_v2(test_charger_v2, mock_aioclient, caplog): assert "Posting data: $FE" in caplog.text caplog.clear() - test_charger_v2._status["state"] = 2 + mock_aioclient.get( + TEST_URL_STATUS, + status=200, + body='{"state": 2}', + ) mock_aioclient.post( TEST_URL_RAPI, status=200, @@ -448,7 +484,16 @@ async def test_idempotent_override_v2_errors(mock_aioclient, caplog): await charger.disable_override() # RAPI failure on enable_override ($FE) - charger._status = {"state": 254} + mock_aioclient.get( + "http://openevse.test.tld/status", + status=200, + body='{"version": "3.3.1", "state": 254}', + ) + mock_aioclient.get( + "http://openevse.test.tld/config", + status=200, + body='{"version": "3.3.1"}', + ) mock_aioclient.post( "http://openevse.test.tld/r", status=200, @@ -461,7 +506,16 @@ async def test_idempotent_override_v2_errors(mock_aioclient, caplog): await charger.enable_override() # RAPI failure on disable_override ($FS) - charger._status = {"state": 2} + mock_aioclient.get( + "http://openevse.test.tld/status", + status=200, + body='{"version": "3.3.1", "state": 2}', + ) + mock_aioclient.get( + "http://openevse.test.tld/config", + status=200, + body='{"version": "3.3.1"}', + ) mock_aioclient.post( "http://openevse.test.tld/r", status=200,