Skip to content
18 changes: 18 additions & 0 deletions src/data/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ const HOME_ASSISTANT_CORE_TITLE = "Home Assistant Core";
const HOME_ASSISTANT_SUPERVISOR_TITLE = "Home Assistant Supervisor";
const HOME_ASSISTANT_OS_TITLE = "Home Assistant Operating System";

// The hassio integration sets these as hard-coded `_attr_title` on the Core,
// Operating System, and Supervisor update entities. They are not translated,
// so a title comparison is the reliable way to identify them without depending
// on the (lazily-fetched) entity sources.
export const isSystemUpdate = (entity: UpdateEntity): boolean => {
const title = entity.attributes.title || "";
return (
title === HOME_ASSISTANT_CORE_TITLE ||
title === HOME_ASSISTANT_OS_TITLE ||
title === HOME_ASSISTANT_SUPERVISOR_TITLE
);
};

export const filterUpdateEntities = (
entities: HassEntities,
language?: string
Expand Down Expand Up @@ -133,6 +146,11 @@ export const filterUpdateEntitiesParameterized = (
return updateCanInstall(entity, showSkipped);
});

export const installUpdates = (hass: HomeAssistant, entityIds: string[]) =>
hass.callService("update", "install", {
entity_id: entityIds,
});

export const checkForEntityUpdates = async (
element: HTMLElement,
hass: HomeAssistant
Expand Down
242 changes: 216 additions & 26 deletions src/panels/config/core/ha-config-section-updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type {
HassioSupervisorInfo,
Expand All @@ -25,16 +29,31 @@ import {
reloadSupervisor,
setSupervisorOption,
} from "../../../data/hassio/supervisor";
import { domainToName } from "../../../data/integration";
import type { UpdateEntity } from "../../../data/update";
import {
checkForEntityUpdates,
filterUpdateEntitiesParameterized,
installUpdates,
isSystemUpdate,
} from "../../../data/update";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import "../dashboard/ha-config-updates";
import { showJoinBetaDialog } from "./updates/show-dialog-join-beta";

interface UpdateGroup {
key: string;
title: string;
entities: UpdateEntity[];
showUpdateAll: boolean;
}

const SYSTEM_KEY = "__system__";
const APPS_KEY = "__apps__";
const INTEGRATIONS_KEY = "__integrations__";

@customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
Expand All @@ -47,16 +66,51 @@ class HaConfigSectionUpdates extends LitElement {

@state() private _supervisorInfo?: HassioSupervisorInfo;

@state() private _entitySources?: EntitySources;

@state() private _loadedIntegrationTitles = new Set<string>();

protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);

if (isComponentLoaded(this.hass.config, "hassio")) {
this._refreshSupervisorInfo();
}

this._loadEntitySources();
}

private async _loadEntitySources() {
try {
this._entitySources = await fetchEntitySourcesWithCache(this.hass);
} catch (_err) {
// Non-fatal: grouping falls back to entity registry platform lookup.
}
}

protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
this._loadIntegrationTitles();
}

private async _loadIntegrationTitles() {
const domains = new Set<string>();
for (const entity of Object.values(this.hass.states)) {
if (!entity.entity_id.startsWith("update.")) continue;
const platform = this.hass.entities[entity.entity_id]?.platform;
if (platform && !this._loadedIntegrationTitles.has(platform)) {
domains.add(platform);
}
}
if (!domains.size) return;
const toLoad = Array.from(domains);
toLoad.forEach((d) => this._loadedIntegrationTitles.add(d));
await this.hass.loadBackendTranslation("title", toLoad);
this.requestUpdate();
}
Comment thread
MindFreeze marked this conversation as resolved.

protected render(): TemplateResult {
const canInstallUpdates = this._filterInstallableUpdateEntities(
const installableUpdates = this._filterInstallableUpdateEntities(
this.hass.states,
this._showSkipped
);
Expand All @@ -65,6 +119,8 @@ class HaConfigSectionUpdates extends LitElement {
this._showSkipped
);

const groups = this._groupUpdates(installableUpdates, this._entitySources);

return html`
<hass-subpage
.backPath=${this._searchParms.has("historyBack")
Expand Down Expand Up @@ -118,36 +174,52 @@ class HaConfigSectionUpdates extends LitElement {
</ha-dropdown>
</div>
<div class="content">
${canInstallUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
${groups.map(
(group) => html`
<ha-card outlined>
<div class="card-content">
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${this.hass.localize("ui.panel.config.updates.title", {
count: canInstallUpdates.length,
})}
${group.title}
</div>
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.updateEntities=${canInstallUpdates}
showAll
></ha-config-updates>
${group.showUpdateAll
? html`
<ha-button
appearance="plain"
size="small"
.group=${group}
@click=${this._updateAll}
>
${this.hass.localize(
"ui.panel.config.updates.update_all"
)}
</ha-button>
Comment thread
MindFreeze marked this conversation as resolved.
`
: nothing}
</div>
</ha-card>
`
: nothing}
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.updateEntities=${group.entities}
showAll
></ha-config-updates>
</div>
</ha-card>
`
)}
${notInstallableUpdates.length
? html`
<ha-card outlined>
<div class="card-content">
<div class="title" role="heading" aria-level="2">
${this.hass.localize(
"ui.panel.config.updates.title_not_installable",
{
count: notInstallableUpdates.length,
}
)}
<div class="card-header">
<div class="title" role="heading" aria-level="2">
${this.hass.localize(
"ui.panel.config.updates.title_not_installable",
{
count: notInstallableUpdates.length,
}
)}
</div>
</div>
<ha-config-updates
.hass=${this.hass}
Expand All @@ -159,7 +231,7 @@ class HaConfigSectionUpdates extends LitElement {
</ha-card>
`
: nothing}
${canInstallUpdates.length + notInstallableUpdates.length
${groups.length + notInstallableUpdates.length
? nothing
: html`
<ha-card outlined>
Expand Down Expand Up @@ -211,6 +283,22 @@ class HaConfigSectionUpdates extends LitElement {
checkForEntityUpdates(this, this.hass);
}

private async _updateAll(ev: Event) {
const group = (ev.currentTarget as any).group as UpdateGroup;
try {
await installUpdates(
this.hass,
group.entities.map((entity) => entity.entity_id)
);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.updates.update_all_failed"),
text: extractApiErrorMessage(err),
warning: true,
});
}
}

private _filterInstallableUpdateEntities = memoizeOne(
(entities: HassEntities, showSkipped: boolean) =>
filterUpdateEntitiesParameterized(entities, showSkipped, false)
Expand All @@ -221,6 +309,101 @@ class HaConfigSectionUpdates extends LitElement {
filterUpdateEntitiesParameterized(entities, showSkipped, true)
);

private _groupUpdates = memoizeOne(
(
entities: UpdateEntity[],
entitySources: EntitySources | undefined
): UpdateGroup[] => {
if (!entities.length) {
return [];
}

const localize = this.hass.localize;

const systemEntities: UpdateEntity[] = [];
const appEntities: UpdateEntity[] = [];
const byDomain = new Map<string, UpdateEntity[]>();
const otherIntegrationEntities: UpdateEntity[] = [];

for (const entity of entities) {
if (isSystemUpdate(entity)) {
systemEntities.push(entity);
continue;
}
const domain =
entitySources?.[entity.entity_id]?.domain ??
this.hass.entities[entity.entity_id]?.platform;
if (domain === "hassio") {
appEntities.push(entity);
continue;
}
if (!domain) {
otherIntegrationEntities.push(entity);
continue;
}
if (!byDomain.has(domain)) {
byDomain.set(domain, []);
}
byDomain.get(domain)!.push(entity);
}

const multiInstanceGroups: UpdateGroup[] = [];
byDomain.forEach((entries, domain) => {
if (entries.length >= 2) {
multiInstanceGroups.push({
key: domain,
title: domainToName(localize, domain),
entities: entries,
showUpdateAll: true,
});
} else {
otherIntegrationEntities.push(...entries);
}
});

multiInstanceGroups.sort((a, b) =>
caseInsensitiveStringCompare(
a.title,
b.title,
this.hass.locale.language
)
);

const groups: UpdateGroup[] = [];

if (systemEntities.length) {
groups.push({
key: SYSTEM_KEY,
title: localize("ui.panel.config.updates.group_system"),
entities: systemEntities,
showUpdateAll: false,
});
}

groups.push(...multiInstanceGroups);

if (otherIntegrationEntities.length) {
groups.push({
key: INTEGRATIONS_KEY,
title: localize("ui.panel.config.updates.group_integrations"),
entities: otherIntegrationEntities,
showUpdateAll: true,
Comment thread
MindFreeze marked this conversation as resolved.
});
}

if (appEntities.length) {
groups.push({
key: APPS_KEY,
title: localize("ui.panel.config.updates.group_apps"),
entities: appEntities,
showUpdateAll: true,
Comment thread
MindFreeze marked this conversation as resolved.
});
}

return groups;
}
);

static styles = css`
.content {
padding: 28px 20px 0;
Expand All @@ -247,8 +430,15 @@ class HaConfigSectionUpdates extends LitElement {
padding: 0;
}

.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-2);
padding: var(--ha-space-4) var(--ha-space-2) 0 var(--ha-space-4);
}

.title {
padding: var(--ha-space-4) var(--ha-space-4) 0;
font-size: var(--ha-font-size-l);
}

Expand Down
5 changes: 5 additions & 0 deletions src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2744,6 +2744,11 @@
"updates": {
"caption": "Updates",
"description": "Manage updates of Home Assistant, apps, and devices",
"group_system": "Home Assistant",
"group_integrations": "Integrations",
"group_apps": "Apps",
"update_all": "Update all",
"update_all_failed": "Failed to start updates",
"no_updates": "No updates available",
"no_update_entities": {
"title": "Unable to check for updates",
Expand Down
Loading