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

from datetime import date, datetime
from enum import Enum
from typing import Any

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."""
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
return str(val)
Comment thread
firstof9 marked this conversation as resolved.
Outdated


async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: OpenEVSEConfigEntry
Comment thread
firstof9 marked this conversation as resolved.
Outdated
) -> 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:
try:
val = getattr(charger, prop)
except AttributeError:
continue
Comment thread
firstof9 marked this conversation as resolved.
except Exception as err: # noqa: BLE001
charger_data[prop] = f"Error: {type(err).__name__}: {err}"
Comment thread
firstof9 marked this conversation as resolved.
Outdated
continue
Comment thread
firstof9 marked this conversation as resolved.
Comment thread
firstof9 marked this conversation as resolved.

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,
}
134 changes: 134 additions & 0 deletions tests/components/openevse/test_diagnostics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Test OpenEVSE diagnostics."""

from datetime import datetime
from enum import Enum
from unittest.mock import MagicMock, PropertyMock

import pytest

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)
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)
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,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test OpenEVSE diagnostics handles exceptions and JSON coercion correctly."""

class MockEnum(Enum):
TEST = "test_value"

# Set up one property to raise AttributeError (should be skipped)
monkeypatch.setattr(
type(mock_charger),
"status",
PropertyMock(side_effect=AttributeError),
raising=False,
)
Comment thread
firstof9 marked this conversation as resolved.
Outdated

# Set up another property to raise a different Exception (should record the error)
monkeypatch.setattr(
type(mock_charger),
"charging_voltage",
PropertyMock(side_effect=ValueError("Connection error")),
raising=False,
)

# Set up datetime property to verify JSON coercion
monkeypatch.setattr(
type(mock_charger),
"vehicle_eta",
PropertyMock(return_value=datetime(2000, 1, 1, 12, 0, 0)),
raising=False,
)

# Set up Enum property to verify JSON coercion
monkeypatch.setattr(
type(mock_charger),
"mode",
PropertyMock(return_value=MockEnum.TEST),
raising=False,
)

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

diagnostics = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)

# status should be omitted due to AttributeError
Comment thread
firstof9 marked this conversation as resolved.
Outdated
assert "status" not in diagnostics["charger"]

# charging_voltage should show the recorded error
assert (
diagnostics["charger"]["charging_voltage"]
== "Error: ValueError: Connection error"
)
Comment thread
firstof9 marked this conversation as resolved.
Outdated

# 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"