Skip to content

Commit

Permalink
Migrate from entry unique id to emoncms unique id (#129133)
Browse files Browse the repository at this point in the history
* Migrate from entry unique id to emoncms unique id

* Use a placeholder for the documentation URL

* Use async_set_unique_id in config_flow

* use _abort_if_unique_id_configured in config_flow

* Avoid single-use variable

Co-authored-by: epenet <[email protected]>

* Add async_migrate_entry

* Remove commented code

* Downgrade version if user add server without uuid

* Improve code quality

* Move code migrating HA to emoncms uuid to init

* Fit doc url in less than 88 chars

Co-authored-by: epenet <[email protected]>

* Improve code quality

* Only update unique_id with async_update_entry

Co-authored-by: epenet <[email protected]>

* Make emoncms_client compulsory to get_feed_list

* Improve readability with unique id functions

* Rmv test to give more sense to _migrate_unique_id

---------

Co-authored-by: epenet <[email protected]>
  • Loading branch information
alexandrecuer and epenet authored Nov 8, 2024
1 parent e3dfa84 commit 24b47b5
Show file tree
Hide file tree
Showing 9 changed files with 167 additions and 21 deletions.
47 changes: 47 additions & 0 deletions homeassistant/components/emoncms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,69 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue

from .const import DOMAIN, EMONCMS_UUID_DOC_URL, LOGGER
from .coordinator import EmoncmsCoordinator

PLATFORMS: list[Platform] = [Platform.SENSOR]

type EmonCMSConfigEntry = ConfigEntry[EmoncmsCoordinator]


def _migrate_unique_id(
hass: HomeAssistant, entry: EmonCMSConfigEntry, emoncms_unique_id: str
) -> None:
"""Migrate to emoncms unique id if needed."""
ent_reg = er.async_get(hass)
entry_entities = ent_reg.entities.get_entries_for_config_entry_id(entry.entry_id)
for entity in entry_entities:
if entity.unique_id.split("-")[0] == entry.entry_id:
feed_id = entity.unique_id.split("-")[-1]
LOGGER.debug(f"moving feed {feed_id} to hardware uuid")
ent_reg.async_update_entity(
entity.entity_id, new_unique_id=f"{emoncms_unique_id}-{feed_id}"
)
hass.config_entries.async_update_entry(
entry,
unique_id=emoncms_unique_id,
)


async def _check_unique_id_migration(
hass: HomeAssistant, entry: EmonCMSConfigEntry, emoncms_client: EmoncmsClient
) -> None:
"""Check if we can migrate to the emoncms uuid."""
emoncms_unique_id = await emoncms_client.async_get_uuid()
if emoncms_unique_id:
if entry.unique_id != emoncms_unique_id:
_migrate_unique_id(hass, entry, emoncms_unique_id)
else:
async_create_issue(
hass,
DOMAIN,
"migrate database",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="migrate_database",
translation_placeholders={
"url": entry.data[CONF_URL],
"doc_url": EMONCMS_UUID_DOC_URL,
},
)


async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool:
"""Load a config entry."""
emoncms_client = EmoncmsClient(
entry.data[CONF_URL],
entry.data[CONF_API_KEY],
session=async_get_clientsession(hass),
)
await _check_unique_id_migration(hass, entry, emoncms_client)
coordinator = EmoncmsCoordinator(hass, emoncms_client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
Expand Down
33 changes: 19 additions & 14 deletions homeassistant/components/emoncms/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
OptionsFlow,
)
from homeassistant.const import CONF_API_KEY, CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import selector
from homeassistant.helpers.typing import ConfigType
Expand Down Expand Up @@ -48,13 +48,10 @@ def sensor_name(url: str) -> str:
return f"emoncms@{sensorip}"


async def get_feed_list(hass: HomeAssistant, url: str, api_key: str) -> dict[str, Any]:
async def get_feed_list(
emoncms_client: EmoncmsClient,
) -> dict[str, Any]:
"""Check connection to emoncms and return feed list if successful."""
emoncms_client = EmoncmsClient(
url,
api_key,
session=async_get_clientsession(hass),
)
return await emoncms_client.async_request("/feed/list.json")


Expand Down Expand Up @@ -82,22 +79,25 @@ async def async_step_user(
description_placeholders = {}

if user_input is not None:
self.url = user_input[CONF_URL]
self.api_key = user_input[CONF_API_KEY]
self._async_abort_entries_match(
{
CONF_API_KEY: user_input[CONF_API_KEY],
CONF_URL: user_input[CONF_URL],
CONF_API_KEY: self.api_key,
CONF_URL: self.url,
}
)
result = await get_feed_list(
self.hass, user_input[CONF_URL], user_input[CONF_API_KEY]
emoncms_client = EmoncmsClient(
self.url, self.api_key, session=async_get_clientsession(self.hass)
)
result = await get_feed_list(emoncms_client)
if not result[CONF_SUCCESS]:
errors["base"] = "api_error"
description_placeholders = {"details": result[CONF_MESSAGE]}
else:
self.include_only_feeds = user_input.get(CONF_ONLY_INCLUDE_FEEDID)
self.url = user_input[CONF_URL]
self.api_key = user_input[CONF_API_KEY]
await self.async_set_unique_id(await emoncms_client.async_get_uuid())
self._abort_if_unique_id_configured()
options = get_options(result[CONF_MESSAGE])
self.dropdown = {
"options": options,
Expand Down Expand Up @@ -191,7 +191,12 @@ async def async_step_init(
self.config_entry.data.get(CONF_ONLY_INCLUDE_FEEDID, []),
)
options: list = include_only_feeds
result = await get_feed_list(self.hass, self._url, self._api_key)
emoncms_client = EmoncmsClient(
self._url,
self._api_key,
session=async_get_clientsession(self.hass),
)
result = await get_feed_list(emoncms_client)
if not result[CONF_SUCCESS]:
errors["base"] = "api_error"
description_placeholders = {"details": result[CONF_MESSAGE]}
Expand Down
4 changes: 4 additions & 0 deletions homeassistant/components/emoncms/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
CONF_MESSAGE = "message"
CONF_SUCCESS = "success"
DOMAIN = "emoncms"
EMONCMS_UUID_DOC_URL = (
"https://docs.openenergymonitor.org/emoncms/update.html"
"#upgrading-to-a-version-producing-a-unique-identifier"
)
FEED_ID = "id"
FEED_NAME = "name"
FEED_TAG = "tag"
Expand Down
10 changes: 5 additions & 5 deletions homeassistant/components/emoncms/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,20 +148,20 @@ async def async_setup_entry(
return

coordinator = entry.runtime_data
# uuid was added in emoncms database 11.5.7
unique_id = entry.unique_id if entry.unique_id else entry.entry_id
elems = coordinator.data
if not elems:
return

sensors: list[EmonCmsSensor] = []

for idx, elem in enumerate(elems):
if include_only_feeds is not None and elem[FEED_ID] not in include_only_feeds:
continue

sensors.append(
EmonCmsSensor(
coordinator,
entry.entry_id,
unique_id,
elem["unit"],
name,
idx,
Expand All @@ -176,7 +176,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
def __init__(
self,
coordinator: EmoncmsCoordinator,
entry_id: str,
unique_id: str,
unit_of_measurement: str | None,
name: str,
idx: int,
Expand All @@ -189,7 +189,7 @@ def __init__(
elem = self.coordinator.data[self.idx]
self._attr_name = f"{name} {elem[FEED_NAME]}"
self._attr_native_unit_of_measurement = unit_of_measurement
self._attr_unique_id = f"{entry_id}-{elem[FEED_ID]}"
self._attr_unique_id = f"{unique_id}-{elem[FEED_ID]}"
if unit_of_measurement in ("kWh", "Wh"):
self._attr_device_class = SensorDeviceClass.ENERGY
self._attr_state_class = SensorStateClass.TOTAL_INCREASING
Expand Down
7 changes: 7 additions & 0 deletions homeassistant/components/emoncms/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"include_only_feed_id": "Choose feeds to include"
}
}
},
"abort": {
"already_configured": "This server is already configured"
}
},
"options": {
Expand All @@ -41,6 +44,10 @@
"missing_include_only_feed_id": {
"title": "No feed synchronized with the {domain} sensor",
"description": "Configuring {domain} using YAML is being removed.\n\nPlease add manually the feeds you want to synchronize with the `configure` button of the integration."
},
"migrate_database": {
"title": "Upgrade your emoncms version",
"description": "Your [emoncms]({url}) does not ship a unique identifier.\n\n Please upgrade to at least version 11.5.7 and migrate your emoncms database.\n\n More info on [emoncms documentation]({doc_url})"
}
}
}
16 changes: 16 additions & 0 deletions tests/components/emoncms/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,21 @@ def config_entry() -> MockConfigEntry:
)


FLOW_RESULT_SECOND_URL = copy.deepcopy(FLOW_RESULT)
FLOW_RESULT_SECOND_URL[CONF_URL] = "http://1.1.1.2"


@pytest.fixture
def config_entry_unique_id() -> MockConfigEntry:
"""Mock emoncms config entry."""
return MockConfigEntry(
domain=DOMAIN,
title=SENSOR_NAME,
data=FLOW_RESULT_SECOND_URL,
unique_id="123-53535292",
)


FLOW_RESULT_NO_FEED = copy.deepcopy(FLOW_RESULT)
FLOW_RESULT_NO_FEED[CONF_ONLY_INCLUDE_FEEDID] = None

Expand Down Expand Up @@ -143,4 +158,5 @@ async def emoncms_client() -> AsyncGenerator[AsyncMock]:
):
client = mock_client.return_value
client.async_request.return_value = {"success": True, "message": FEEDS}
client.async_get_uuid.return_value = "123-53535292"
yield client
2 changes: 1 addition & 1 deletion tests/components/emoncms/snapshots/test_sensor.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'XXXXXXXX-1',
'unique_id': '123-53535292-1',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
Expand Down
18 changes: 18 additions & 0 deletions tests/components/emoncms/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,21 @@ async def test_options_flow_failure(
assert result["description_placeholders"]["details"] == "failure"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"


async def test_unique_id_exists(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
emoncms_client: AsyncMock,
config_entry_unique_id: MockConfigEntry,
) -> None:
"""Test when entry with same unique id already exists."""
config_entry_unique_id.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], USER_INPUT
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
51 changes: 50 additions & 1 deletion tests/components/emoncms/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@

from unittest.mock import AsyncMock

from homeassistant.components.emoncms.const import DOMAIN, FEED_ID, FEED_NAME
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er, issue_registry as ir

from . import setup_integration
from .conftest import EMONCMS_FAILURE
from .conftest import EMONCMS_FAILURE, FEEDS

from tests.common import MockConfigEntry

Expand Down Expand Up @@ -38,3 +41,49 @@ async def test_failure(
emoncms_client.async_request.return_value = EMONCMS_FAILURE
config_entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(config_entry.entry_id)


async def test_migrate_uuid(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
emoncms_client: AsyncMock,
) -> None:
"""Test migration from home assistant uuid to emoncms uuid."""
config_entry.add_to_hass(hass)
assert config_entry.unique_id is None
for _, feed in enumerate(FEEDS):
entity_registry.async_get_or_create(
Platform.SENSOR,
DOMAIN,
f"{config_entry.entry_id}-{feed[FEED_ID]}",
config_entry=config_entry,
suggested_object_id=f"{DOMAIN}_{feed[FEED_NAME]}",
)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
emoncms_uuid = emoncms_client.async_get_uuid.return_value
assert config_entry.unique_id == emoncms_uuid
entity_entries = er.async_entries_for_config_entry(
entity_registry, config_entry.entry_id
)

for nb, feed in enumerate(FEEDS):
assert entity_entries[nb].unique_id == f"{emoncms_uuid}-{feed[FEED_ID]}"
assert (
entity_entries[nb].previous_unique_id
== f"{config_entry.entry_id}-{feed[FEED_ID]}"
)


async def test_no_uuid(
hass: HomeAssistant,
config_entry: MockConfigEntry,
issue_registry: ir.IssueRegistry,
emoncms_client: AsyncMock,
) -> None:
"""Test an issue is created when the emoncms server does not ship an uuid."""
emoncms_client.async_get_uuid.return_value = None
await setup_integration(hass, config_entry)

assert issue_registry.async_get_issue(domain=DOMAIN, issue_id="migrate database")

0 comments on commit 24b47b5

Please sign in to comment.