diff --git a/homeassistant/components/openevse/icons.json b/homeassistant/components/openevse/icons.json new file mode 100644 index 0000000000000..3705c15590966 --- /dev/null +++ b/homeassistant/components/openevse/icons.json @@ -0,0 +1,46 @@ +{ + "entity": { + "binary_sensor": { + "divert_active": { + "default": "mdi:call-split" + }, + "has_limit": { + "default": "mdi:car-speed-limiter" + }, + "shaper_active": { + "default": "mdi:tune-variant" + } + }, + "sensor": { + "gfi_trip_count": { + "default": "mdi:counter" + }, + "no_gnd_trip_count": { + "default": "mdi:counter" + }, + "service_level": { + "default": "mdi:home-lightning-bolt-outline" + }, + "status": { + "default": "mdi:ev-station", + "state": { + "charging": "mdi:battery-charging", + "connected": "mdi:power-plug", + "diode_check_failed": "mdi:alert-circle", + "disabled": "mdi:stop-circle-outline", + "gfci_fault": "mdi:alert-circle", + "gfci_self_test_failure": "mdi:alert-circle", + "no_ground": "mdi:alert", + "not_connected": "mdi:power-plug-off", + "over_temperature": "mdi:thermometer-alert", + "sleeping": "mdi:sleep", + "stuck_relay": "mdi:alert", + "vent_required": "mdi:fan-alert" + } + }, + "stuck_relay_trip_count": { + "default": "mdi:counter" + } + } + } +} diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py index a1bd4d7e9fe42..aac20577b4370 100644 --- a/homeassistant/components/openevse/sensor.py +++ b/homeassistant/components/openevse/sensor.py @@ -44,6 +44,7 @@ ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify from .const import DOMAIN, INTEGRATION_TITLE from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator @@ -53,6 +54,29 @@ PARALLEL_UPDATES = 0 +STATUS_OPTIONS: list[str] = [ + "charging", + "connected", + "diode_check_failed", + "disabled", + "gfci_fault", + "gfci_self_test_failure", + "no_ground", + "not_connected", + "over_temperature", + "sleeping", + "stuck_relay", + "vent_required", +] + + +def _map_status(status: str | None) -> str | None: + """Map raw status string to enum option.""" + if status is not None and (slug := slugify(status)) in STATUS_OPTIONS: + return slug + return None + + @dataclass(frozen=True, kw_only=True) class OpenEVSESensorDescription(SensorEntityDescription): """Describes an OpenEVSE sensor entity.""" @@ -65,7 +89,9 @@ class OpenEVSESensorDescription(SensorEntityDescription): OpenEVSESensorDescription( key="status", translation_key="status", - value_fn=lambda ev: ev.status, + device_class=SensorDeviceClass.ENUM, + options=STATUS_OPTIONS, + value_fn=lambda ev: _map_status(ev.status), ), OpenEVSESensorDescription( key="service_level", diff --git a/homeassistant/components/openevse/strings.json b/homeassistant/components/openevse/strings.json index c2810b37097ac..b74d9c43758c2 100644 --- a/homeassistant/components/openevse/strings.json +++ b/homeassistant/components/openevse/strings.json @@ -172,7 +172,21 @@ "name": "Smoothed available current" }, "status": { - "name": "Charging status" + "name": "Charging status", + "state": { + "charging": "Charging", + "connected": "Connected", + "diode_check_failed": "Diode check failed", + "disabled": "Disabled", + "gfci_fault": "GFCI fault", + "gfci_self_test_failure": "GFCI self-test failure", + "no_ground": "No ground", + "not_connected": "Not connected", + "over_temperature": "Over temperature", + "sleeping": "Sleeping", + "stuck_relay": "Stuck relay", + "vent_required": "Vent required" + } }, "stuck_relay_trip_count": { "name": "Stuck relay trip count" diff --git a/tests/components/openevse/snapshots/test_sensor.ambr b/tests/components/openevse/snapshots/test_sensor.ambr index af153bcea947b..f73e58f0061f8 100644 --- a/tests/components/openevse/snapshots/test_sensor.ambr +++ b/tests/components/openevse/snapshots/test_sensor.ambr @@ -246,7 +246,22 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : list([ + 'charging', + 'connected', + 'diode_check_failed', + 'disabled', + 'gfci_fault', + 'gfci_self_test_failure', + 'no_ground', + 'not_connected', + 'over_temperature', + 'sleeping', + 'stuck_relay', + 'vent_required', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -265,7 +280,7 @@ 'object_id_base': 'Charging status', 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Charging status', 'platform': 'openevse', @@ -280,14 +295,29 @@ # name: test_entities[sensor.openevse_mock_config_charging_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'enum', : 'openevse_mock_config Charging status', + : list([ + 'charging', + 'connected', + 'diode_check_failed', + 'disabled', + 'gfci_fault', + 'gfci_self_test_failure', + 'no_ground', + 'not_connected', + 'over_temperature', + 'sleeping', + 'stuck_relay', + 'vent_required', + ]), }), 'context': , 'entity_id': 'sensor.openevse_mock_config_charging_status', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Charging', + 'state': 'charging', }) # --- # name: test_entities[sensor.openevse_mock_config_charging_voltage-entry] diff --git a/tests/components/openevse/test_sensor.py b/tests/components/openevse/test_sensor.py index bab07b27e976e..7ee8ac8273ccb 100644 --- a/tests/components/openevse/test_sensor.py +++ b/tests/components/openevse/test_sensor.py @@ -77,7 +77,7 @@ async def test_missing_sensor_graceful_handling( # Other sensors should still work state = hass.states.get("sensor.openevse_mock_config_charging_status") assert state is not None - assert state.state == "Charging" + assert state.state == "charging" async def test_websocket_callback_updates_entities( @@ -92,7 +92,7 @@ async def test_websocket_callback_updates_entities( state = hass.states.get("sensor.openevse_mock_config_charging_status") assert state - assert state.state == "Charging" + assert state.state == "charging" mock_charger.status = "Sleeping" await mock_charger.callback() @@ -100,7 +100,7 @@ async def test_websocket_callback_updates_entities( state = hass.states.get("sensor.openevse_mock_config_charging_status") assert state - assert state.state == "Sleeping" + assert state.state == "sleeping" async def test_sensor_unavailable_on_coordinator_timeout( @@ -187,3 +187,46 @@ async def test_yaml_import_already_configured( issue = issue_registry.async_get_issue("homeassistant", "deprecated_yaml") assert issue is not None assert issue.issue_domain == DOMAIN + + +@pytest.mark.parametrize( + ("raw_status", "expected_state"), + [ + pytest.param("not connected", "not_connected", id="not_connected"), + pytest.param("connected", "connected", id="connected"), + pytest.param("charging", "charging", id="charging"), + pytest.param("vent required", "vent_required", id="vent_required"), + pytest.param( + "diode check failed", "diode_check_failed", id="diode_check_failed" + ), + pytest.param("gfci fault", "gfci_fault", id="gfci_fault"), + pytest.param("no ground", "no_ground", id="no_ground"), + pytest.param("stuck relay", "stuck_relay", id="stuck_relay"), + pytest.param( + "gfci self-test failure", + "gfci_self_test_failure", + id="gfci_self_test_failure", + ), + pytest.param("over temperature", "over_temperature", id="over_temperature"), + pytest.param("sleeping", "sleeping", id="sleeping"), + pytest.param("disabled", "disabled", id="disabled"), + pytest.param("unknown", STATE_UNKNOWN, id="unknown"), + pytest.param("unrecognized_raw_status", STATE_UNKNOWN, id="fallback_unknown"), + pytest.param(None, STATE_UNKNOWN, id="none_status"), + ], +) +async def test_status_sensor_mapping( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, + raw_status: str | None, + expected_state: str, +) -> None: + """Test status sensor mapping to enum options.""" + mock_charger.status = raw_status + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get("sensor.openevse_mock_config_charging_status") + assert state is not None + assert state.state == expected_state