Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions custom_components/zaptec/zaptec/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
API_TIMEOUT,
API_URL,
CHARGER_EXCLUDES,
CLEARABLE_OBSERVATIONS,
DEFAULT_MAX_CURRENT,
MAX_DEBUG_TEXT_LEN_ON_500,
MISSING,
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions custom_components/zaptec/zaptec/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
37 changes: 37 additions & 0 deletions tests/zaptec/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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)."""
Expand Down
Loading