Add support for service icons (#19507)

* Add service icons

* Fix lint

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
This commit is contained in:
Bram Kragten 2024-01-24 20:21:08 +01:00 committed by GitHub
parent c2d71ac789
commit 2925ef3db0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 166 additions and 54 deletions

View File

@ -9,8 +9,8 @@ import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
nothing,
PropertyValues,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@ -19,6 +19,7 @@ import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
import { supportsFeature } from "../common/entity/supports-feature";
import { nestedArrayMove } from "../common/util/array-move";
import {
fetchIntegrationManifest,
IntegrationManifest,
@ -31,6 +32,7 @@ import {
expandDeviceTarget,
Selector,
} from "../data/selector";
import { ReorderModeMixin } from "../state/reorder-mode-mixin";
import { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url";
import "./ha-checkbox";
@ -40,8 +42,6 @@ import "./ha-service-picker";
import "./ha-settings-row";
import "./ha-yaml-editor";
import type { HaYamlEditor } from "./ha-yaml-editor";
import { nestedArrayMove } from "../common/util/array-move";
import { ReorderModeMixin } from "../state/reorder-mode-mixin";
const attributeFilter = (values: any[], attribute: any) => {
if (typeof attribute === "object") {

View File

@ -0,0 +1,57 @@
import { mdiRoomService } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import { computeDomain } from "../common/entity/compute_domain";
import { domainIconWithoutDefault } from "../common/entity/domain_icon";
import { serviceIcon } from "../data/icons";
import { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
@customElement("ha-service-icon")
export class HaServiceIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public service?: string;
@property() public icon?: string;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
}
if (!this.service) {
return nothing;
}
if (!this.hass) {
return this._renderFallback();
}
const icon = serviceIcon(this.hass, this.service).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
return html`
<ha-svg-icon
.path=${domainIconWithoutDefault(computeDomain(this.service!)) ||
mdiRoomService}
></ha-svg-icon>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-service-icon": HaServiceIcon;
}
}

View File

@ -1,4 +1,3 @@
import "@material/mwc-list/mwc-list-item";
import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
@ -8,16 +7,9 @@ import { LocalizeFunc } from "../common/translations/localize";
import { domainToName } from "../data/integration";
import { HomeAssistant } from "../types";
import "./ha-combo-box";
const rowRenderer: ComboBoxLitRenderer<{ service: string; name: string }> = (
item
) =>
html`<mwc-list-item twoline>
<span>${item.name}</span>
<span slot="secondary"
>${item.name === item.service ? "" : item.service}</span
>
</mwc-list-item>`;
import "./ha-list-item";
import "./ha-service-icon";
import { getServiceIcons } from "../data/icons";
@customElement("ha-service-picker")
class HaServicePicker extends LitElement {
@ -32,9 +24,24 @@ class HaServicePicker extends LitElement {
protected willUpdate() {
if (!this.hasUpdated) {
this.hass.loadBackendTranslation("services");
getServiceIcons(this.hass);
}
}
private _rowRenderer: ComboBoxLitRenderer<{ service: string; name: string }> =
(item) =>
html`<ha-list-item twoline graphic="icon">
<ha-service-icon
slot="graphic"
.hass=${this.hass}
.service=${item.service}
></ha-service-icon>
<span>${item.name}</span>
<span slot="secondary"
>${item.name === item.service ? "" : item.service}</span
>
</ha-list-item>`;
protected render() {
return html`
<ha-combo-box
@ -47,7 +54,7 @@ class HaServicePicker extends LitElement {
)}
.value=${this.value}
.disabled=${this.disabled}
.renderer=${rowRenderer}
.renderer=${this._rowRenderer}
item-value-path="service"
item-label-path="name"
allow-custom-value

View File

@ -1,15 +1,17 @@
import { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { HomeAssistant } from "../types";
import {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "./entity_registry";
import { computeDomain } from "../common/entity/compute_domain";
const resources: Record<IconCategory, any> = {
entity: {},
entity_component: undefined,
services: {},
};
interface IconResources {
@ -46,7 +48,11 @@ interface ComponentIcons {
};
}
export type IconCategory = "entity" | "entity_component";
interface ServiceIcons {
[domain: string]: Record<string, string>;
}
export type IconCategory = "entity" | "entity_component" | "services";
export const getHassIcons = async (
hass: HomeAssistant,
@ -64,7 +70,7 @@ export const getPlatformIcons = async (
integration: string,
force = false
): Promise<PlatformIcons> => {
if (!force && integration && integration in resources.entity) {
if (!force && integration in resources.entity) {
return resources.entity[integration];
}
const result = getHassIcons(hass, "entity", integration);
@ -88,6 +94,37 @@ export const getComponentIcons = async (
return resources.entity_component.then((res) => res[domain]);
};
export const getServiceIcons = async (
hass: HomeAssistant,
domain?: string,
force = false
): Promise<ServiceIcons> => {
if (!domain) {
if (!force && resources.services.all) {
return resources.services.all;
}
resources.services.all = getHassIcons(hass, "services", domain).then(
(res) => {
resources.services = res.resources;
return res?.resources;
}
);
return resources.services.all;
}
if (!force && domain && domain in resources.services) {
return resources.services[domain];
}
if (resources.services.all && !force) {
await resources.services.all;
if (domain in resources.services) {
return resources.services[domain];
}
}
const result = getHassIcons(hass, "services", domain);
resources.services[domain] = result.then((res) => res?.resources[domain]);
return resources.services[domain];
};
export const entityIcon = async (
hass: HomeAssistant,
state: HassEntity,
@ -96,7 +133,6 @@ export const entityIcon = async (
const entity = hass.entities?.[state.entity_id] as
| EntityRegistryDisplayEntry
| undefined;
if (entity?.icon) {
return entity.icon;
}
@ -197,3 +233,13 @@ export const attributeIcon = async (
}
return icon;
};
export const serviceIcon = async (hass: HomeAssistant, service: string) => {
const domain = computeDomain(service);
const serviceName = computeObjectId(service);
const serviceIcons = await getServiceIcons(hass, domain);
if (serviceIcons) {
return serviceIcons[serviceName];
}
return undefined;
};

View File

@ -29,13 +29,12 @@ import { classMap } from "lit/directives/class-map";
import { storage } from "../../../../common/decorators/storage";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { domainIconWithoutDefault } from "../../../../common/entity/domain_icon";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/ha-alert";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-card";
import "../../../../components/ha-service-icon";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
@ -203,16 +202,18 @@ export default class HaAutomationActionRow extends LitElement {
: ""}
<ha-expansion-panel leftChevron>
<h3 slot="header">
<ha-svg-icon
class="action-icon"
.path=${type === "service" &&
"service" in this.action &&
this.action.service
? domainIconWithoutDefault(
computeDomain(this.action.service as string)
) || ACTION_ICONS[type!]
: ACTION_ICONS[type!]}
></ha-svg-icon>
${type === "service" &&
"service" in this.action &&
this.action.service
? html`<ha-service-icon
class="action-icon"
.hass=${this.hass}
.service=${this.action.service}
></ha-service-icon>`
: html`<ha-svg-icon
class="action-icon"
.path=${ACTION_ICONS[type!]}
></ha-svg-icon>`}
${capitalizeFirstLetter(
describeAction(this.hass, this._entityReg, this.action)
)}

View File

@ -5,6 +5,7 @@ import {
CSSResultGroup,
LitElement,
PropertyValues,
TemplateResult,
css,
html,
nothing,
@ -53,6 +54,7 @@ import { computeDomain } from "../../../common/entity/compute_domain";
import { deepEqual } from "../../../common/util/deep-equal";
import "../../../components/search-input";
import "@material/web/divider/divider";
import { getServiceIcons } from "../../../data/icons";
const TYPES = {
trigger: { groups: TRIGGER_GROUPS, icons: TRIGGER_ICONS },
@ -70,7 +72,8 @@ interface ListItem {
key: string;
name: string;
description: string;
icon?: string;
iconPath?: string;
icon?: TemplateResult;
image?: string;
group: boolean;
}
@ -124,6 +127,7 @@ class DialogAddAutomationElement extends LitElement implements HassDialog {
this.hass.loadBackendTranslation("services");
this._fetchManifests();
this._calculateUsedDomains();
getServiceIcons(this.hass);
}
this._fullScreen = matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
@ -174,7 +178,7 @@ class DialogAddAutomationElement extends LitElement implements HassDialog {
options.members ? "groups" : "type"
}.${key}.description${options.members ? "" : ".picker"}`
),
icon: options.icon || TYPES[type].icons[key],
iconPath: options.icon || TYPES[type].icons[key],
});
private _getFilteredItems = memoizeOne(
@ -317,7 +321,7 @@ class DialogAddAutomationElement extends LitElement implements HassDialog {
const icon = domainIconWithoutDefault(domain);
result.push({
group: true,
icon,
iconPath: icon,
image: !icon
? brandsUrl({
domain,
@ -358,17 +362,12 @@ class DialogAddAutomationElement extends LitElement implements HassDialog {
const services_keys = Object.keys(services[dmn]);
for (const service of services_keys) {
const icon = domainIconWithoutDefault(dmn);
result.push({
group: false,
icon,
image: !icon
? brandsUrl({
domain: dmn,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})
: undefined,
icon: html`<ha-service-icon
.hass=${this.hass}
.service=${`${dmn}.${service}`}
></ha-service-icon>`,
key: `${SERVICE_PREFIX}${dmn}.${service}`,
name: `${domain ? "" : `${domainToName(localize, dmn)}: `}${
this.hass.localize(`component.${dmn}.services.${service}.name`) ||
@ -573,17 +572,19 @@ class DialogAddAutomationElement extends LitElement implements HassDialog {
<div slot="headline">${item.name}</div>
<div slot="supporting-text">${item.description}</div>
${item.icon
? html`<ha-svg-icon
slot="start"
.path=${item.icon}
></ha-svg-icon>`
: html`<img
alt=""
slot="start"
src=${item.image!}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>`}
? html`<span slot="start">${item.icon}</span>`
: item.iconPath
? html`<ha-svg-icon
slot="start"
.path=${item.iconPath}
></ha-svg-icon>`
: html`<img
alt=""
slot="start"
src=${item.image!}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>`}
${item.group
? html`<ha-icon-next slot="end"></ha-icon-next>`
: html`<ha-svg-icon