Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e1cb86b
Repurpose the Updater integration
ludeeus Feb 15, 2022
9b395da
Remove debug
ludeeus Feb 15, 2022
014b9b8
Merge branch 'dev' of github.com:home-assistant/core into repurpose_u…
ludeeus Feb 15, 2022
58c2e97
Pass updater_data to _get_update_details
ludeeus Feb 17, 2022
cda5149
Merge branch 'dev' of github.com:home-assistant/core into repurpose_u…
ludeeus Feb 17, 2022
bf1bce0
split update to own module, and leave updater as is
ludeeus Feb 17, 2022
e76b217
Add changelog_content
ludeeus Feb 17, 2022
cad343b
adjustments after split
ludeeus Feb 17, 2022
7f5e525
Update script/scaffold/templates/update/integration/update.py
ludeeus Feb 17, 2022
7324fe2
Remove scaffold
ludeeus Feb 18, 2022
52554b9
update
ludeeus Feb 18, 2022
4b4cb32
Add delay
ludeeus Feb 18, 2022
3b4c4c7
Merge branch 'dev' of github.com:home-assistant/core into repurpose_u…
ludeeus Feb 18, 2022
36a6d0f
fix type conflict
ludeeus Feb 18, 2022
ba2c188
Add missing coverage
ludeeus Feb 18, 2022
05260ed
Merge branch 'dev' of github.com:home-assistant/core into repurpose_u…
ludeeus Feb 21, 2022
0a7a43d
Load when needed
ludeeus Feb 21, 2022
3c7b1b8
updates
ludeeus Feb 21, 2022
3592149
rename WS function
ludeeus Feb 21, 2022
e846eb6
remove
ludeeus Feb 21, 2022
d95c552
Update homeassistant/components/update/__init__.py
ludeeus Feb 21, 2022
0c914f5
Update homeassistant/components/update/__init__.py
ludeeus Feb 21, 2022
eefa61a
Move platform registration
ludeeus Feb 21, 2022
6f0821a
Changes needed after suggestion
ludeeus Feb 21, 2022
0f154ee
Only register /info during setup
ludeeus Feb 21, 2022
5f4a41f
Better error
ludeeus Feb 21, 2022
36b86f3
Use supported_features
ludeeus Feb 21, 2022
d6023cf
Move assert
ludeeus Feb 21, 2022
b184f3a
Use dataclasses.field
ludeeus Feb 21, 2022
95741d2
Fix test
ludeeus Feb 21, 2022
c580b23
Adjust working
ludeeus Feb 21, 2022
e0a9e87
Remove assert
ludeeus Feb 21, 2022
394ca7b
Load if not loaded in domain_is_valid
ludeeus Feb 21, 2022
d74e862
Remove _register_update_platform
ludeeus Feb 21, 2022
69fa06e
Use supports_backup
ludeeus Feb 21, 2022
a4a8f1a
Merge branch 'dev' of github.com:home-assistant/core into repurpose_u…
ludeeus Feb 28, 2022
29a4898
Merge branch 'dev' of github.com:home-assistant/core into repurpose_u…
ludeeus Mar 1, 2022
0977a15
print the stack trace if it's an unexpected exception
ludeeus Mar 1, 2022
51e87bf
Merge branch 'dev' of github.com:home-assistant/core into repurpose_u…
ludeeus Mar 2, 2022
a1a3475
Add UpdateFailed exception
ludeeus Mar 2, 2022
a4801eb
coverage
ludeeus Mar 2, 2022
903d2ce
Apply suggestions from code review
ludeeus Mar 3, 2022
fda8db3
adjust
ludeeus Mar 3, 2022
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
1 change: 1 addition & 0 deletions .core_files.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ components: &components
- homeassistant/components/tag/*
- homeassistant/components/template/*
- homeassistant/components/timer/*
- homeassistant/components/update/*
- homeassistant/components/usb/*
- homeassistant/components/webhook/*
- homeassistant/components/websocket_api/*
Expand Down
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ homeassistant.components.tts.*
homeassistant.components.twentemilieu.*
homeassistant.components.unifiprotect.*
homeassistant.components.upcloud.*
homeassistant.components.update.*
homeassistant.components.uptime.*
homeassistant.components.uptimerobot.*
homeassistant.components.vacuum.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,8 @@ tests/components/upb/* @gwww
homeassistant/components/upc_connect/* @pvizeli @fabaff
homeassistant/components/upcloud/* @scop
tests/components/upcloud/* @scop
homeassistant/components/update/* @home-assistant/core
tests/components/update/* @home-assistant/core
homeassistant/components/updater/* @home-assistant/core
tests/components/updater/* @home-assistant/core
homeassistant/components/upnp/* @StevenLooman @ehendrix23
Expand Down
3 changes: 2 additions & 1 deletion homeassistant/components/default_config/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@
"tag",
"timer",
"usb",
"update",
"webhook",
"zeroconf",
"zone"
],
"codeowners": ["@home-assistant/core"],
"quality_scale": "internal"
}
}
273 changes: 273 additions & 0 deletions homeassistant/components/update/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
"""Support for Update."""
from __future__ import annotations

import asyncio
import dataclasses
import logging
from typing import Any, Protocol

import async_timeout
import voluptuous as vol

from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import integration_platform, storage
from homeassistant.helpers.typing import ConfigType

_LOGGER = logging.getLogger(__name__)

DOMAIN = "update"
INFO_CALLBACK_TIMEOUT = 5
STORAGE_VERSION = 1


class IntegrationUpdateFailed(HomeAssistantError):
"""Error to indicate an update has failed."""


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Update integration."""
hass.data[DOMAIN] = UpdateManager(hass=hass)
websocket_api.async_register_command(hass, handle_info)
websocket_api.async_register_command(hass, handle_update)
websocket_api.async_register_command(hass, handle_skip)
return True


@websocket_api.websocket_command({vol.Required("type"): "update/info"})
@websocket_api.async_response
async def handle_info(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Get pending updates from all platforms."""
manager: UpdateManager = hass.data[DOMAIN]
updates = await manager.gather_updates()
connection.send_result(msg["id"], updates)


@websocket_api.websocket_command(
{
vol.Required("type"): "update/skip",
vol.Required("domain"): str,
vol.Required("identifier"): str,
vol.Required("version"): str,
}
)
@websocket_api.async_response
async def handle_skip(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Skip an update."""
manager: UpdateManager = hass.data[DOMAIN]

if not await manager.domain_is_valid(msg["domain"]):
connection.send_error(
msg["id"], websocket_api.ERR_NOT_FOUND, "Domain not supported"
)
return

manager.skip_update(msg["domain"], msg["identifier"], msg["version"])
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if identifier or version is no longer valid ? We only validate domain. Would it help to drop domain_is_valid and just raise some exception if something is off?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we do not store updates anymore, that would mean that on each call info/update/skip we would need to gather all updates, then iterate over all updates for the domain to ensure the identifier and version are present.
That's expensive, and this I would say is a rare edge case, and if needed is something that the individual update platforms should handle, they get the identifier and version, and can raise or just return early if the update no longer exists.

connection.send_result(msg["id"])


@websocket_api.websocket_command(
{
vol.Required("type"): "update/update",
vol.Required("domain"): str,
vol.Required("identifier"): str,
vol.Required("version"): str,
vol.Optional("backup"): bool,
}
)
@websocket_api.async_response
async def handle_update(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Handle an update."""
manager: UpdateManager = hass.data[DOMAIN]

if not await manager.domain_is_valid(msg["domain"]):
connection.send_error(
msg["id"],
websocket_api.ERR_NOT_FOUND,
f"{msg['domain']} is not a supported domain",
)
return

try:
await manager.perform_update(
domain=msg["domain"],
identifier=msg["identifier"],
version=msg["version"],
backup=msg.get("backup"),
)
except IntegrationUpdateFailed as err:
connection.send_error(
msg["id"],
"update_failed",
str(err),
)
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Update of %s to version %s failed",
msg["identifier"],
msg["version"],
)
connection.send_error(
msg["id"],
"update_failed",
"Unknown Error",
)
else:
connection.send_result(msg["id"])


class UpdatePlatformProtocol(Protocol):
"""Define the format that update platforms can have."""

async def async_list_updates(self, hass: HomeAssistant) -> list[UpdateDescription]:
"""List all updates available in the integration."""

async def async_perform_update(
self,
hass: HomeAssistant,
identifier: str,
version: str,
**kwargs: Any,
) -> None:
"""Perform an update."""


@dataclasses.dataclass()
class UpdateDescription:
"""Describe an update update."""

identifier: str
name: str
current_version: str
available_version: str
changelog_content: str | None = None
changelog_url: str | None = None
icon_url: str | None = None
supports_backup: bool = False


class UpdateManager:
"""Update manager for the update integration."""

def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the update manager."""
self._hass = hass
self._store = storage.Store(
hass=hass,
version=STORAGE_VERSION,
key=DOMAIN,
)
self._skip: set[str] = set()
self._platforms: dict[str, UpdatePlatformProtocol] = {}
self._loaded = False

async def add_platform(
self,
hass: HomeAssistant,
integration_domain: str,
platform: UpdatePlatformProtocol,
) -> None:
"""Add a platform to the update manager."""
self._platforms[integration_domain] = platform

async def _load(self) -> None:
"""Load platforms and data from storage."""
await integration_platform.async_process_integration_platforms(
self._hass, DOMAIN, self.add_platform
)
from_storage = await self._store.async_load()
if isinstance(from_storage, dict):
self._skip = set(from_storage["skipped"])

self._loaded = True

async def gather_updates(self) -> list[dict[str, Any]]:
"""Gather updates."""
if not self._loaded:
await self._load()

updates: dict[str, list[UpdateDescription] | None] = {}

for domain, update_descriptions in zip(
self._platforms,
await asyncio.gather(
*(
self._get_integration_info(integration_domain, registration)
for integration_domain, registration in self._platforms.items()
)
),
):
updates[domain] = update_descriptions

return [
{
"domain": integration_domain,
**dataclasses.asdict(description),
}
for integration_domain, update_descriptions in updates.items()
if update_descriptions is not None
for description in update_descriptions
if f"{integration_domain}_{description.identifier}_{description.available_version}"
not in self._skip
]

async def domain_is_valid(self, domain: str) -> bool:
"""Return if the domain is valid."""
if not self._loaded:
await self._load()
return domain in self._platforms

@callback
def _data_to_save(self) -> dict[str, Any]:
"""Schedule storing the data."""
return {"skipped": list(self._skip)}

async def perform_update(
self,
domain: str,
identifier: str,
version: str,
**kwargs: Any,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When will the arbitrary keyword arguments be used?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for backup and whatever we decide to add in the future.

) -> None:
"""Perform an update."""
await self._platforms[domain].async_perform_update(
hass=self._hass,
identifier=identifier,
version=version,
**kwargs,
)

@callback
def skip_update(self, domain: str, identifier: str, version: str) -> None:
"""Skip an update."""
self._skip.add(f"{domain}_{identifier}_{version}")
self._store.async_delay_save(self._data_to_save, 60)

async def _get_integration_info(
self,
integration_domain: str,
platform: UpdatePlatformProtocol,
) -> list[UpdateDescription] | None:
"""Get integration update details."""

try:
async with async_timeout.timeout(INFO_CALLBACK_TIMEOUT):
return await platform.async_list_updates(hass=self._hass)
except asyncio.TimeoutError:
_LOGGER.warning("Timeout while getting updates from %s", integration_domain)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error fetching info from %s", integration_domain)
return None
10 changes: 10 additions & 0 deletions homeassistant/components/update/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"domain": "update",
"name": "Update",
"documentation": "https://www.home-assistant.io/integrations/update",
"codeowners": [
"@home-assistant/core"
],
"quality_scale": "internal",
"iot_class": "calculated"
}
3 changes: 3 additions & 0 deletions homeassistant/components/update/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"title": "Update"
}
3 changes: 3 additions & 0 deletions homeassistant/components/update/translations/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"title": "Update"
}
11 changes: 11 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2045,6 +2045,17 @@ no_implicit_optional = true
warn_return_any = true
warn_unreachable = true

[mypy-homeassistant.components.update.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
warn_unreachable = true

[mypy-homeassistant.components.uptime.*]
check_untyped_defs = true
disallow_incomplete_defs = true
Expand Down
1 change: 1 addition & 0 deletions tests/components/update/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the Update integration."""
Loading