Skip to content
Merged
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
28 changes: 27 additions & 1 deletion homeassistant/components/victron_gx/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,39 @@ async def async_step_ssdp(
self, discovery_info: SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle SSDP discovery."""
self.hostname = str(urlparse(discovery_info.ssdp_location).hostname)
hostname = urlparse(discovery_info.ssdp_location).hostname
# Ensure we have a valid hostname before proceeding
if not hostname:
return self.async_abort(reason="cannot_connect")

self.hostname = str(hostname)
self.serial = discovery_info.upnp["serialNumber"]
self.installation_id = discovery_info.upnp["X_VrmPortalId"]
self.model_name = discovery_info.upnp["modelName"]
self.friendly_name = discovery_info.upnp["friendlyName"]

await self.async_set_unique_id(self.installation_id)

# Check if we need to update the host for an existing entry
existing_entry = self.hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, self.installation_id
)
if (
existing_entry is not None
and existing_entry.data[CONF_HOST] != self.hostname
):
# Update the entry with the new host and title, then reload and abort
return self.async_update_reload_and_abort(
existing_entry,
data_updates={CONF_HOST: self.hostname},
title=ENTRY_TITLE_FORMAT.format(
installation_id=self.installation_id,
host=self.hostname,
port=existing_entry.data[CONF_PORT],
),
reason="already_configured",
)

self._abort_if_unique_id_configured()

self.context["title_placeholders"] = {
Expand Down
6 changes: 5 additions & 1 deletion homeassistant/components/victron_gx/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ def __init__(
metric.generic_short_id not in ENTITIES_DISABLE_BY_DEFAULT
)

def _native_unit_of_measurement(self) -> str | None:
def _resolve_native_unit_of_measurement(self) -> str | None:
"""Resolve native unit of measurement for platforms that support it."""
if self._metric.metric_type is MetricType.COST:
return self.hass.config.currency

unit_of_measurement = self._metric.unit_of_measurement
# We need to provide a native unit in three cases:
if (
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/victron_gx/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"quality_scale": "platinum",
"requirements": ["victron-mqtt==2026.7.4"],
"requirements": ["victron-mqtt==2026.7.6"],
"ssdp": [
{
"X_MqttOnLan": "1",
Expand Down
7 changes: 6 additions & 1 deletion homeassistant/components/victron_gx/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ def __init__(
"""Initialize the number entity."""
super().__init__(device, metric, device_info, installation_id)
self._attr_device_class = METRIC_TYPE_TO_DEVICE_CLASS.get(metric.metric_type)
self._attr_native_unit_of_measurement = self._native_unit_of_measurement()
self._attr_native_value = metric.value
if metric.min_value is not None:
self._attr_native_min_value = metric.min_value
Expand All @@ -86,6 +85,12 @@ def __init__(
if metric.step is not None:
self._attr_native_step = metric.step

@property
@override
def native_unit_of_measurement(self) -> str | None:
"""Return the native unit of measurement."""
return self._resolve_native_unit_of_measurement()

@callback
@override
def _on_update_cb(self, value: Any) -> None:
Expand Down
7 changes: 6 additions & 1 deletion homeassistant/components/victron_gx/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,14 @@ def __init__(
self._attr_state_class = METRIC_NATURE_TO_STATE_CLASS.get(
metric.metric_nature
)
self._attr_native_unit_of_measurement = self._native_unit_of_measurement()
self._attr_native_value = VictronSensor._normalize_value(metric.value)

@property
@override
def native_unit_of_measurement(self) -> str | None:
"""Return the native unit of measurement."""
return self._resolve_native_unit_of_measurement()

@callback
@override
def _on_update_cb(self, value: Any) -> None:
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

66 changes: 66 additions & 0 deletions tests/components/victron_gx/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -831,3 +831,69 @@ async def test_reconfigure_flow_different_device(
assert result["reason"] == "different_device"
# Entry should be unchanged
assert mock_config_entry.data[CONF_HOST] == MOCK_HOST


@pytest.mark.usefixtures("mock_victron_hub")
async def test_ssdp_flow_updates_host_on_rediscovery(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test SSDP discovery updates host when device is found at a new IP."""
mock_config_entry.add_to_hass(hass)
assert mock_config_entry.data[CONF_HOST] == MOCK_HOST
original_title = mock_config_entry.title

discovery_info = SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="upnp:rootdevice",
ssdp_location="http://10.0.0.50:80/",
upnp={
"serialNumber": MOCK_SERIAL,
"X_VrmPortalId": MOCK_INSTALLATION_ID,
"modelName": MOCK_MODEL,
"friendlyName": MOCK_FRIENDLY_NAME,
"X_MqttOnLan": "1",
"manufacturer": "Victron Energy",
},
)

result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_SSDP},
data=discovery_info,
)

assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config_entry.data[CONF_HOST] == "10.0.0.50"
# Verify the entry title was also updated with the new host
assert mock_config_entry.title != original_title
assert "10.0.0.50" in mock_config_entry.title


@pytest.mark.usefixtures("mock_victron_hub")
async def test_ssdp_flow_abort_on_invalid_hostname(
hass: HomeAssistant,
) -> None:
"""Test SSDP discovery aborts when hostname is invalid."""
discovery_info = SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="upnp:rootdevice",
ssdp_location="http://:80/", # No hostname in URL
upnp={
"serialNumber": MOCK_SERIAL,
"X_VrmPortalId": MOCK_INSTALLATION_ID,
"modelName": MOCK_MODEL,
"friendlyName": MOCK_FRIENDLY_NAME,
"X_MqttOnLan": "1",
"manufacturer": "Victron Energy",
},
)

result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_SSDP},
data=discovery_info,
)

assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
63 changes: 63 additions & 0 deletions tests/components/victron_gx/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,66 @@ async def test_victron_main_topic_sensor(
assert state.state == "mppt_active"
# Entity uses device name only (no separate entity name)
assert state.attributes["friendly_name"] == "Multi RS Solar"


async def test_native_unit_of_measurement_cost_metric(
hass: HomeAssistant,
init_integration: tuple[VictronVenusHub, MockConfigEntry],
) -> None:
"""Test native_unit_of_measurement returns currency for COST metric type."""
victron_hub, _mock_config_entry = init_integration

hass.config.currency = "USD"

await inject_message(
victron_hub,
f"N/{MOCK_INSTALLATION_ID}/evcharger/0/Session/Cost",
'{"value": 12.34}',
)
await finalize_injection(victron_hub)
await hass.async_block_till_done()

state = hass.states.get("sensor.ev_charging_station_last_session_cost")
assert state is not None
assert state.attributes["unit_of_measurement"] == "USD"
assert state.state == "12.34"


async def test_native_unit_of_measurement_with_device_class(
hass: HomeAssistant,
init_integration: tuple[VictronVenusHub, MockConfigEntry],
) -> None:
"""Test native_unit_of_measurement returns unit for metrics with device class."""
victron_hub, _mock_config_entry = init_integration

await inject_message(
victron_hub,
f"N/{MOCK_INSTALLATION_ID}/battery/0/Dc/0/Current",
'{"value": 10.5}',
)
await finalize_injection(victron_hub)
await hass.async_block_till_done()

state = hass.states.get("sensor.battery_dc_bus_current")
assert state is not None
assert state.attributes["unit_of_measurement"] == "A"


async def test_native_unit_of_measurement_special_unit(
hass: HomeAssistant,
init_integration: tuple[VictronVenusHub, MockConfigEntry],
) -> None:
"""Test native_unit_of_measurement returns special units like %."""
victron_hub, _mock_config_entry = init_integration

await inject_message(
victron_hub,
f"N/{MOCK_INSTALLATION_ID}/battery/0/Soc",
'{"value": 85}',
)
await finalize_injection(victron_hub)
await hass.async_block_till_done()

state = hass.states.get("sensor.battery_charge")
assert state is not None
assert state.attributes["unit_of_measurement"] == "%"
Loading