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, CSSResultGroup,
html, html,
LitElement, LitElement,
PropertyValues,
nothing, nothing,
PropertyValues,
} from "lit"; } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
@ -19,6 +19,7 @@ import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id"; import { computeObjectId } from "../common/entity/compute_object_id";
import { supportsFeature } from "../common/entity/supports-feature"; import { supportsFeature } from "../common/entity/supports-feature";
import { nestedArrayMove } from "../common/util/array-move";
import { import {
fetchIntegrationManifest, fetchIntegrationManifest,
IntegrationManifest, IntegrationManifest,
@ -31,6 +32,7 @@ import {
expandDeviceTarget, expandDeviceTarget,
Selector, Selector,
} from "../data/selector"; } from "../data/selector";
import { ReorderModeMixin } from "../state/reorder-mode-mixin";
import { HomeAssistant, ValueChangedEvent } from "../types"; import { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url"; import { documentationUrl } from "../util/documentation-url";
import "./ha-checkbox"; import "./ha-checkbox";
@ -40,8 +42,6 @@ import "./ha-service-picker";
import "./ha-settings-row"; import "./ha-settings-row";
import "./ha-yaml-editor"; import "./ha-yaml-editor";
import type { HaYamlEditor } from "./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) => { const attributeFilter = (values: any[], attribute: any) => {
if (typeof attribute === "object") { 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 { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
@ -8,16 +7,9 @@ import { LocalizeFunc } from "../common/translations/localize";
import { domainToName } from "../data/integration"; import { domainToName } from "../data/integration";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
import "./ha-combo-box"; import "./ha-combo-box";
import "./ha-list-item";
const rowRenderer: ComboBoxLitRenderer<{ service: string; name: string }> = ( import "./ha-service-icon";
item import { getServiceIcons } from "../data/icons";
) =>
html`<mwc-list-item twoline>
<span>${item.name}</span>
<span slot="secondary"
>${item.name === item.service ? "" : item.service}</span
>
</mwc-list-item>`;
@customElement("ha-service-picker") @customElement("ha-service-picker")
class HaServicePicker extends LitElement { class HaServicePicker extends LitElement {
@ -32,9 +24,24 @@ class HaServicePicker extends LitElement {
protected willUpdate() { protected willUpdate() {
if (!this.hasUpdated) { if (!this.hasUpdated) {
this.hass.loadBackendTranslation("services"); 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() { protected render() {
return html` return html`
<ha-combo-box <ha-combo-box
@ -47,7 +54,7 @@ class HaServicePicker extends LitElement {
)} )}
.value=${this.value} .value=${this.value}
.disabled=${this.disabled} .disabled=${this.disabled}
.renderer=${rowRenderer} .renderer=${this._rowRenderer}
item-value-path="service" item-value-path="service"
item-label-path="name" item-label-path="name"
allow-custom-value allow-custom-value

View File

@ -1,15 +1,17 @@
import { HassEntity } from "home-assistant-js-websocket"; 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 { computeStateDomain } from "../common/entity/compute_state_domain";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
import { import {
EntityRegistryDisplayEntry, EntityRegistryDisplayEntry,
EntityRegistryEntry, EntityRegistryEntry,
} from "./entity_registry"; } from "./entity_registry";
import { computeDomain } from "../common/entity/compute_domain";
const resources: Record<IconCategory, any> = { const resources: Record<IconCategory, any> = {
entity: {}, entity: {},
entity_component: undefined, entity_component: undefined,
services: {},
}; };
interface IconResources { 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 ( export const getHassIcons = async (
hass: HomeAssistant, hass: HomeAssistant,
@ -64,7 +70,7 @@ export const getPlatformIcons = async (
integration: string, integration: string,
force = false force = false
): Promise<PlatformIcons> => { ): Promise<PlatformIcons> => {
if (!force && integration && integration in resources.entity) { if (!force && integration in resources.entity) {
return resources.entity[integration]; return resources.entity[integration];
} }
const result = getHassIcons(hass, "entity", integration); const result = getHassIcons(hass, "entity", integration);
@ -88,6 +94,37 @@ export const getComponentIcons = async (
return resources.entity_component.then((res) => res[domain]); 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 ( export const entityIcon = async (
hass: HomeAssistant, hass: HomeAssistant,
state: HassEntity, state: HassEntity,
@ -96,7 +133,6 @@ export const entityIcon = async (
const entity = hass.entities?.[state.entity_id] as const entity = hass.entities?.[state.entity_id] as
| EntityRegistryDisplayEntry | EntityRegistryDisplayEntry
| undefined; | undefined;
if (entity?.icon) { if (entity?.icon) {
return entity.icon; return entity.icon;
} }
@ -197,3 +233,13 @@ export const attributeIcon = async (
} }
return icon; 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 { storage } from "../../../../common/decorators/storage";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive"; import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event"; 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 { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { handleStructError } from "../../../../common/structs/handle-errors"; import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/ha-alert"; import "../../../../components/ha-alert";
import "../../../../components/ha-button-menu"; import "../../../../components/ha-button-menu";
import "../../../../components/ha-card"; import "../../../../components/ha-card";
import "../../../../components/ha-service-icon";
import "../../../../components/ha-expansion-panel"; import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button"; import "../../../../components/ha-icon-button";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor"; import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
@ -203,16 +202,18 @@ export default class HaAutomationActionRow extends LitElement {
: ""} : ""}
<ha-expansion-panel leftChevron> <ha-expansion-panel leftChevron>
<h3 slot="header"> <h3 slot="header">
<ha-svg-icon ${type === "service" &&
class="action-icon" "service" in this.action &&
.path=${type === "service" && this.action.service
"service" in this.action && ? html`<ha-service-icon
this.action.service class="action-icon"
? domainIconWithoutDefault( .hass=${this.hass}
computeDomain(this.action.service as string) .service=${this.action.service}
) || ACTION_ICONS[type!] ></ha-service-icon>`
: ACTION_ICONS[type!]} : html`<ha-svg-icon
></ha-svg-icon> class="action-icon"
.path=${ACTION_ICONS[type!]}
></ha-svg-icon>`}
${capitalizeFirstLetter( ${capitalizeFirstLetter(
describeAction(this.hass, this._entityReg, this.action) describeAction(this.hass, this._entityReg, this.action)
)} )}

View File

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