diff --git a/custom_components/zaptec/zaptec/api.py b/custom_components/zaptec/zaptec/api.py index b906c87a..ef61d6a2 100644 --- a/custom_components/zaptec/zaptec/api.py +++ b/custom_components/zaptec/zaptec/api.py @@ -32,6 +32,7 @@ API_TIMEOUT, API_URL, CHARGER_EXCLUDES, + CLEARABLE_OBSERVATIONS, DEFAULT_MAX_CURRENT, MAX_DEBUG_TEXT_LEN_ON_500, MISSING, @@ -209,6 +210,8 @@ def state_to_attrs( _LOGGER.debug("Excluding key %s entry: %s", skey, item) continue value = item.get("Value", item.get("ValueAsString", MISSING)) + if value is MISSING and str(skey) in CLEARABLE_OBSERVATIONS: + value = "" if value is not MISSING: kv = keydict.get(skey, f"{key} {skey}") if kv in out: diff --git a/custom_components/zaptec/zaptec/const.py b/custom_components/zaptec/zaptec/const.py index 10e9f589..0152334f 100644 --- a/custom_components/zaptec/zaptec/const.py +++ b/custom_components/zaptec/zaptec/const.py @@ -69,6 +69,14 @@ class Missing: TRUTHY = ["true", "1", "on", "yes", 1, True] FALSY = ["false", "0", "off", "no", 0, False] +CLEARABLE_OBSERVATIONS = { + "721", # SessionIdentifier + "722", # ChargerCurrentUserUuid +} +"""Observations that Zaptec clears by sending them without any value, rather +than with an empty one. Without mapping them to an empty value, the previous +session's value would persist indefinitely.""" + # Charger state attributes that should be excluded from being set as class # attributes. Use strings. CHARGER_EXCLUDES = { diff --git a/tests/zaptec/test_api.py b/tests/zaptec/test_api.py index df1c55ac..6213035f 100644 --- a/tests/zaptec/test_api.py +++ b/tests/zaptec/test_api.py @@ -381,6 +381,19 @@ def test_state_to_attrs_skips_missing_key_and_missing_value() -> None: assert out == {} +def test_state_to_attrs_clears_session_observation_without_value() -> None: + """A session observation sent without a value clears the attribute. + + Zaptec signals "no session" by sending SessionIdentifier (721) and + ChargerCurrentUserUuid (722) with no ValueAsString at all, rather than + with an empty value. + """ + data = [{"StateId": 721}, {"StateId": 722}] + keydict = {721: "SessionIdentifier", 722: "ChargerCurrentUserUuid"} + out = ZaptecBase.state_to_attrs(data, "StateId", keydict) + assert out == {"SessionIdentifier": "", "ChargerCurrentUserUuid": ""} + + def test_state_to_attrs_excludes() -> None: """Excluded ids are dropped.""" data = [ @@ -857,6 +870,30 @@ async def test_poll_state_happy_path(monkeypatch: pytest.MonkeyPatch) -> None: charger.set_attributes.assert_called_once() +@pytest.mark.asyncio +async def test_poll_state_clears_session_attribute_when_value_is_gone( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A session that ends clears the attribute instead of leaving it stale.""" + monkeypatch.setattr(ZCONST, "observations", {722: "ChargerCurrentUserUuid"}, raising=False) + monkeypatch.setattr("custom_components.zaptec.zaptec.api.validate", Mock()) + charger, _ = _charger_with_session( + [ + FakeResponse( + HTTPStatus.OK, + json_data=[{"StateId": 722, "ValueAsString": "nfc-abc123"}], + ), + FakeResponse(HTTPStatus.OK, json_data=[{"StateId": 722}]), + ] + ) + + await charger.poll_state() + assert charger.get("charger_current_user_uuid") == "nfc-abc123" + + await charger.poll_state() + assert charger.get("charger_current_user_uuid") == "" + + @pytest.mark.asyncio async def test_poll_state_forbidden_is_ignored() -> None: """A 403 on the state endpoint is swallowed (no attribute update)."""