Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
128 changes: 128 additions & 0 deletions homeassistant/components/openevse/diagnostics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Provide diagnostics for OpenEVSE."""

from datetime import date, datetime
from enum import Enum
import inspect
from typing import Any
from unittest.mock import Mock, NonCallableMock
Comment thread
firstof9 marked this conversation as resolved.
Outdated

from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant

from .coordinator import OpenEVSEConfigEntry

REDACT_CONFIG_DATA = {CONF_PASSWORD, CONF_USERNAME}

CHARGER_PROPERTIES = (
"status",
"vehicle",
"mode",
"charge_mode",
"divertmode",
"manual_override",
"ota_update",
"service_level",
"charge_time_elapsed",
"vehicle_eta",
"charging_current",
"charging_voltage",
"charging_power",
"current_power",
"current_capacity",
"max_current",
"min_amps",
"max_amps",
"max_current_soft",
"available_current",
"smoothed_available_current",
"charge_rate",
"ambient_temperature",
"ir_temperature",
"rtc_temperature",
"esp_temperature",
"usage_session",
"usage_total",
"total_day",
"total_week",
"total_month",
"total_year",
"vehicle_soc",
"vehicle_range",
"wifi_signal",
"shaper_live_power",
"shaper_available_current",
"shaper_max_power",
"gfi_trip_count",
"no_gnd_trip_count",
"stuck_relay_trip_count",
"uptime",
"freeram",
"wifi_firmware",
"openevse_firmware",
)


def _to_json_safe(val: Any) -> Any:
"""Coerce value to be JSON-serializable.

Top-level callables on the charger object are skipped entirely in the main
diagnostics loop. For nested structures (lists, dicts, tuples, sets), any
encountered callable elements are coerced to None here to preserve the
structure while remaining JSON-safe.
"""
if isinstance(val, (str, int, float, bool)) or val is None:
return val
if isinstance(val, (datetime, date)):
return val.isoformat()
if isinstance(val, Enum):
return val.value
Comment thread
firstof9 marked this conversation as resolved.
Outdated
if isinstance(val, (set, frozenset)):
return [_to_json_safe(v) for v in sorted(val, key=str)]
if isinstance(val, (list, tuple)):
return [_to_json_safe(v) for v in val]
if isinstance(val, dict):
return {str(k): _to_json_safe(v) for k, v in val.items()}
Comment thread
firstof9 marked this conversation as resolved.
Outdated
if callable(val):
return None
return f"<{type(val).__name__} object>"
Comment thread
firstof9 marked this conversation as resolved.


async def async_get_config_entry_diagnostics(
_hass: HomeAssistant, config_entry: OpenEVSEConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = config_entry.runtime_data
charger = coordinator.charger

charger_data: dict[str, Any] = {}

for prop in CHARGER_PROPERTIES:
# To prevent auto-creating mock attributes during tests when using MagicMock,
# we statically check for the attribute's existence on mock objects.
# This is restricted to mock objects to support dynamic runtime attributes in production.
if isinstance(charger, (Mock, NonCallableMock)):
Comment thread
firstof9 marked this conversation as resolved.
Outdated
try:
inspect.getattr_static(charger, prop)
except AttributeError:
continue

try:
val = getattr(charger, prop)
except AttributeError:
continue
except Exception as err: # noqa: BLE001
charger_data[prop] = f"Error: {type(err).__name__}"
continue
Comment thread
firstof9 marked this conversation as resolved.
Comment thread
firstof9 marked this conversation as resolved.

# Top-level callables on the charger object are omitted from diagnostics.
# Any nested callables within collections are coerced to None by _to_json_safe.
if callable(val):
continue
Comment thread
firstof9 marked this conversation as resolved.

charger_data[prop] = _to_json_safe(val)
Comment thread
firstof9 marked this conversation as resolved.

return {
"config_entry": async_redact_data(config_entry.as_dict(), REDACT_CONFIG_DATA),
"charger": charger_data,
}
182 changes: 182 additions & 0 deletions tests/components/openevse/test_diagnostics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""Test OpenEVSE diagnostics."""

from datetime import datetime
from enum import Enum
from typing import Any
from unittest.mock import MagicMock

from homeassistant.core import HomeAssistant

from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator


async def test_entry_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
mock_charger: MagicMock,
) -> None:
"""Test OpenEVSE diagnostics."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
Comment thread
firstof9 marked this conversation as resolved.

diagnostics = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)

assert diagnostics["config_entry"]["data"] == {
"host": "192.168.1.100",
}
assert diagnostics["charger"]["status"] == "Charging"
assert diagnostics["charger"]["charging_voltage"] == 240
assert diagnostics["charger"]["charging_current"] == 32000.0


async def test_entry_diagnostics_redact(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_charger: MagicMock,
) -> None:
"""Test OpenEVSE diagnostics with auth data redacted."""
entry = MockConfigEntry(
title="openevse_mock_config",
domain="openevse",
data={
"host": "192.168.1.100",
"username": "my_username",
"password": "my_password",
},
entry_id="FAKE_AUTH",
unique_id="deadbeeffeed",
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()

diagnostics = await get_diagnostics_for_config_entry(hass, hass_client, entry)

assert diagnostics["config_entry"]["data"] == {
"host": "192.168.1.100",
"username": "**REDACTED**",
"password": "**REDACTED**",
}


async def test_entry_diagnostics_exceptions(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
mock_charger: MagicMock,
) -> None:
"""Test OpenEVSE diagnostics handles exceptions and JSON coercion correctly."""

class MockEnum(Enum):
TEST = "test_value"

class FakeCharger:
"""Fake charger to raise exceptions and return custom values for properties."""

def __init__(self, original_charger: MagicMock) -> None:
self._original_charger = original_charger
# Copy other properties so that inspect.getattr_static finds them in __dict__
Comment thread
firstof9 marked this conversation as resolved.
Outdated
excluded = {
"status",
"charging_voltage",
"vehicle_eta",
"mode",
"divertmode",
"manual_override",
"ota_update",
"service_level",
"uptime",
"wifi_firmware",
}
# Copy all mock attributes except the overridden ones
for key, val in original_charger.__dict__.items():
if key not in excluded:
self.__dict__[key] = val

Comment thread
firstof9 marked this conversation as resolved.
Comment thread
firstof9 marked this conversation as resolved.
@property
def charging_voltage(self) -> int:
raise ValueError("Connection error")

@property
def vehicle_eta(self) -> datetime:
return datetime(2000, 1, 1, 12, 0, 0)

@property
def mode(self) -> MockEnum:
return MockEnum.TEST

@property
def divertmode(self) -> set[str]:
return {"solar", "eco"}

@property
def manual_override(self) -> frozenset[str]:
return frozenset({"override"})

@property
def ota_update(self) -> tuple[Any, ...]:
return ("v1.0", lambda: "nested_callable")

@property
def service_level(self) -> dict[MockEnum, str]:
return {MockEnum.TEST: "level_2"}

@property
def uptime(self) -> object:
class CustomObj:
def __str__(self) -> str:
return "custom_str"

return CustomObj()

@property
def wifi_firmware(self) -> Any:
return lambda: "callable_value"

mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

# Inject the FakeCharger into the coordinator to isolate side effects
coordinator = mock_config_entry.runtime_data
coordinator.charger = FakeCharger(mock_charger)

diagnostics = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)

# status should be omitted because the attribute is not present
assert "status" not in diagnostics["charger"]

# charging_voltage should show the recorded error type only
assert diagnostics["charger"]["charging_voltage"] == "Error: ValueError"

# vehicle_eta should be coerced to ISO format string
assert diagnostics["charger"]["vehicle_eta"] == "2000-01-01T12:00:00"

# mode should be coerced to Enum raw value
assert diagnostics["charger"]["mode"] == "test_value"

# divertmode should be sorted and coerced to list
assert diagnostics["charger"]["divertmode"] == ["eco", "solar"]

# manual_override should be sorted and coerced to list
assert diagnostics["charger"]["manual_override"] == ["override"]

# ota_update should be coerced to list, with callable elements coerced to None
assert diagnostics["charger"]["ota_update"] == ["v1.0", None]

# service_level should have keys coerced to str
assert diagnostics["charger"]["service_level"] == {"MockEnum.TEST": "level_2"}

# uptime should fallback to type name representation
assert diagnostics["charger"]["uptime"] == "<CustomObj object>"

# wifi_firmware should be omitted because it is callable
assert "wifi_firmware" not in diagnostics["charger"]