diff --git a/openevsehttp/commands_core.py b/openevsehttp/commands_core.py index 837e175..3e19aac 100644 --- a/openevsehttp/commands_core.py +++ b/openevsehttp/commands_core.py @@ -189,6 +189,90 @@ 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") + await self.update(force_status=True) + + 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}" + ) + # Successful $FE wakes the charger from sleep (state 254) + self._status["state"] = 2 + 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") + await self.update(force_status=True) + + 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}" + ) + # Successful $FS puts the charger into sleep (state 254) + self._status["state"] = 254 + 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..dd05263 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -274,6 +274,260 @@ 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) + caplog.clear() + 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) + caplog.clear() + 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() + + 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 + mock_aioclient.get( + TEST_URL_STATUS, + status=200, + body='{"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 + assert test_charger_v2._status["state"] == 2 + + # Case 2: Charger is active (state == 2) -> enable_override skips $FE (no-op) + caplog.clear() + 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, + 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 + assert test_charger_v2._status["state"] == 254 + + # Case 4: Charger is sleeping (state == 254) -> disable_override skips $FS (no-op) + caplog.clear() + 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() + mock_aioclient.get( + TEST_URL_STATUS, + status=200, + body='{"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() + mock_aioclient.get( + TEST_URL_STATUS, + status=200, + body='{"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) + 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, + 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) + 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, + 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 ───────────────────────────────────────────────────────