diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1ae009b033..c7d3d8fa9c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -10,10 +10,18 @@ env: NODE_VERSION: 14 NODE_OPTIONS: --max_old_space_size=6144 +# Set default workflow permissions +# All scopes not mentioned here are set to no access +# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token +permissions: + actions: none + jobs: release: name: Release runs-on: ubuntu-latest + permissions: + contents: write # Required to upload release assets steps: - name: Checkout the repository uses: actions/checkout@v2 @@ -47,6 +55,13 @@ jobs: script/release + - name: Upload release assets + uses: softprops/action-gh-release@v0.1.14 + with: + files: | + dist/*.whl + dist/*.tar.gz + wheels-init: name: Init wheels build needs: release diff --git a/gallery/script/netlify_build_gallery b/gallery/script/netlify_build_gallery index a5732d4c83..db8be615d1 100755 --- a/gallery/script/netlify_build_gallery +++ b/gallery/script/netlify_build_gallery @@ -23,7 +23,7 @@ if [[ "${PULL_REQUEST}" == "true" ]]; then createStatus "pending" "Building design preview" "https://app.netlify.com/sites/home-assistant-gallery/deploys/$BUILD_ID" gulp build-gallery if [ $? -eq 0 ]; then - createStatus "success" "Build complete" "$DEPLOY_URL" + createStatus "success" "Build complete" "$DEPLOY_PRIME_URL" else createStatus "error" "Build failed" "https://app.netlify.com/sites/home-assistant-gallery/deploys/$BUILD_ID" fi diff --git a/gallery/sidebar.js b/gallery/sidebar.js index d98d361224..14a8d38fe8 100644 --- a/gallery/sidebar.js +++ b/gallery/sidebar.js @@ -42,10 +42,11 @@ module.exports = [ }, { category: "user-test", - header: "User Tests", + header: "Users", + pages: ["user-types", "configuration-menu"], }, { category: "design.home-assistant.io", - header: "Design Documentation", + header: "About", }, ]; diff --git a/gallery/src/ha-gallery.ts b/gallery/src/ha-gallery.ts index e602a1916d..4f22bac519 100644 --- a/gallery/src/ha-gallery.ts +++ b/gallery/src/ha-gallery.ts @@ -45,6 +45,10 @@ class HaGallery extends LitElement { for (const page of group.pages!) { const key = `${group.category}/${page}`; const active = this._page === key; + if (!(key in PAGES)) { + console.error("Undefined page referenced in sidebar.js:", key); + continue; + } const title = PAGES[key].metadata.title || page; links.push(html` ${title} diff --git a/gallery/src/pages/more-info/update.markdown b/gallery/src/pages/more-info/update.markdown new file mode 100644 index 0000000000..e7540412e3 --- /dev/null +++ b/gallery/src/pages/more-info/update.markdown @@ -0,0 +1,3 @@ +--- +title: Update +--- diff --git a/gallery/src/pages/more-info/update.ts b/gallery/src/pages/more-info/update.ts new file mode 100644 index 0000000000..1488e318dc --- /dev/null +++ b/gallery/src/pages/more-info/update.ts @@ -0,0 +1,140 @@ +import { html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { customElement, property, query } from "lit/decorators"; +import "../../../../src/components/ha-card"; +import { + UPDATE_SUPPORT_BACKUP, + UPDATE_SUPPORT_PROGRESS, + UPDATE_SUPPORT_INSTALL, +} from "../../../../src/data/update"; +import "../../../../src/dialogs/more-info/more-info-content"; +import { getEntity } from "../../../../src/fake_data/entity"; +import { + MockHomeAssistant, + provideHass, +} from "../../../../src/fake_data/provide_hass"; +import "../../components/demo-more-infos"; + +const base_attributes = { + title: "Awesome", + current_version: "1.2.2", + latest_version: "1.2.3", + release_url: "https://home-assistant.io", + supported_features: UPDATE_SUPPORT_INSTALL, + skipped_version: null, + in_progress: false, + release_summary: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. In nec metus aliquet, porta mi ut, ultrices odio. Etiam egestas orci tellus, non semper metus blandit tincidunt. Praesent elementum turpis vel tempor pharetra. Sed quis cursus diam. Proin sem justo.", +}; + +const ENTITIES = [ + getEntity("update", "update1", "on", { + ...base_attributes, + friendly_name: "Update", + }), + getEntity("update", "update2", "on", { + ...base_attributes, + title: null, + friendly_name: "Update without title", + }), + getEntity("update", "update3", "on", { + ...base_attributes, + release_url: null, + friendly_name: "Update without release_url", + }), + getEntity("update", "update4", "on", { + ...base_attributes, + release_summary: null, + friendly_name: "Update without release_summary", + }), + getEntity("update", "update5", "off", { + ...base_attributes, + current_version: "1.2.3", + friendly_name: "No update", + }), + getEntity("update", "update6", "off", { + ...base_attributes, + skipped_version: "1.2.3", + friendly_name: "Skipped version", + }), + getEntity("update", "update7", "on", { + ...base_attributes, + supported_features: + base_attributes.supported_features + UPDATE_SUPPORT_BACKUP, + friendly_name: "With backup support", + }), + getEntity("update", "update8", "on", { + ...base_attributes, + in_progress: true, + friendly_name: "With true in_progress", + }), + getEntity("update", "update9", "on", { + ...base_attributes, + in_progress: 25, + supported_features: + base_attributes.supported_features + UPDATE_SUPPORT_PROGRESS, + friendly_name: "With 25 in_progress", + }), + getEntity("update", "update10", "on", { + ...base_attributes, + in_progress: 50, + supported_features: + base_attributes.supported_features + UPDATE_SUPPORT_PROGRESS, + friendly_name: "With 50 in_progress", + }), + getEntity("update", "update11", "on", { + ...base_attributes, + in_progress: 75, + supported_features: + base_attributes.supported_features + UPDATE_SUPPORT_PROGRESS, + friendly_name: "With 75 in_progress", + }), + getEntity("update", "update12", "unavailable", { + ...base_attributes, + in_progress: 50, + friendly_name: "Unavailable", + }), + getEntity("update", "update13", "on", { + ...base_attributes, + supported_features: 0, + friendly_name: "No install support", + }), + getEntity("update", "update14", "off", { + ...base_attributes, + current_version: null, + friendly_name: "Update without current_version", + }), + getEntity("update", "update15", "off", { + ...base_attributes, + latest_version: null, + friendly_name: "Update without latest_version", + }), +]; + +@customElement("demo-more-info-update") +class DemoMoreInfoUpdate extends LitElement { + @property() public hass!: MockHomeAssistant; + + @query("demo-more-infos") private _demoRoot!: HTMLElement; + + protected render(): TemplateResult { + return html` + ent.entityId)} + > + `; + } + + protected firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + const hass = provideHass(this._demoRoot); + hass.updateTranslations(null, "en"); + hass.addEntities(ENTITIES); + } +} + +declare global { + interface HTMLElementTagNameMap { + "demo-more-info-update": DemoMoreInfoUpdate; + } +} diff --git a/gallery/src/pages/user-test/user-types.markdown b/gallery/src/pages/user-test/user-types.markdown new file mode 100644 index 0000000000..eacc108cd4 --- /dev/null +++ b/gallery/src/pages/user-test/user-types.markdown @@ -0,0 +1,17 @@ +--- +title: "User types" +--- + +We have defined three user types for Home Assistant. They are a lean segmentation of users that helps us make decisions throughout the product. User types differ from traditional personas in that the segmentation criteria aren’t demographic and don’t personify a group into a single character with a fictitious background story. + +# Outgrowers + +Users that outgrow big tech smart home solutions. It just needs to work with easy setup via an app. + +# Tinkerers + +Technoid users in home networking and development that know how to code. + +# Questioner + +Users who want more advanced home automation, but need support to make it work. diff --git a/setup.cfg b/setup.cfg index 2afbb59467..e8f04faf57 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = home-assistant-frontend -version = 20220317.0 +version = 20220322.0 author = The Home Assistant Authors author_email = hello@home-assistant.io license = Apache-2.0 diff --git a/setup.py b/setup.py deleted file mode 100644 index 69bf65dd8a..0000000000 --- a/setup.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Entry point for setuptools. Required for editable installs. -TODO: Remove file after updating to pip 21.3 -""" -from setuptools import setup - -setup() diff --git a/src/common/const.ts b/src/common/const.ts index f11e7d7380..ee9f94000c 100644 --- a/src/common/const.ts +++ b/src/common/const.ts @@ -187,6 +187,7 @@ export const DOMAINS_WITH_MORE_INFO = [ "scene", "sun", "timer", + "update", "vacuum", "water_heater", "weather", @@ -200,6 +201,7 @@ export const DOMAINS_HIDE_DEFAULT_MORE_INFO = [ "input_text", "number", "scene", + "update", "select", ]; diff --git a/src/common/entity/compute_state_display.ts b/src/common/entity/compute_state_display.ts index 3838218ea4..e78b889457 100644 --- a/src/common/entity/compute_state_display.ts +++ b/src/common/entity/compute_state_display.ts @@ -1,12 +1,18 @@ import { HassEntity } from "home-assistant-js-websocket"; import { UNAVAILABLE, UNKNOWN } from "../../data/entity"; import { FrontendLocaleData } from "../../data/translation"; +import { + updateIsInstalling, + UpdateEntity, + UPDATE_SUPPORT_PROGRESS, +} from "../../data/update"; import { formatDate } from "../datetime/format_date"; import { formatDateTime } from "../datetime/format_date_time"; import { formatTime } from "../datetime/format_time"; import { formatNumber, isNumericState } from "../number/format_number"; import { LocalizeFunc } from "../translations/localize"; import { computeStateDomain } from "./compute_state_domain"; +import { supportsFeature } from "./supports-feature"; export const computeStateDisplay = ( localize: LocalizeFunc, @@ -130,6 +136,28 @@ export const computeStateDisplay = ( } } + if (domain === "update") { + // When updating, and entity does not support % show "Installing" + // When updating, and entity does support % show "Installing (xx%)" + // When update available, show the version + // When the latest version is skipped, show the latest version + // When update is not available, show "Up-to-date" + // When update is not available and there is no latest_version show "Unavailable" + return compareState === "on" + ? updateIsInstalling(stateObj as UpdateEntity) + ? supportsFeature(stateObj, UPDATE_SUPPORT_PROGRESS) + ? localize("ui.card.update.installing_with_progress", { + progress: stateObj.attributes.in_progress, + }) + : localize("ui.card.update.installing") + : stateObj.attributes.latest_version + : stateObj.attributes.skipped_version === + stateObj.attributes.latest_version + ? stateObj.attributes.latest_version ?? + localize("state.default.unavailable") + : localize("ui.card.update.up_to_date"); + } + return ( // Return device class translation (stateObj.attributes.device_class && diff --git a/src/common/entity/compute_state_domain.ts b/src/common/entity/compute_state_domain.ts index b4408257a6..1b972ea22f 100644 --- a/src/common/entity/compute_state_domain.ts +++ b/src/common/entity/compute_state_domain.ts @@ -1,4 +1,4 @@ -import { HassEntity } from "home-assistant-js-websocket"; +import type { HassEntity } from "home-assistant-js-websocket"; import { computeDomain } from "./compute_domain"; export const computeStateDomain = (stateObj: HassEntity) => diff --git a/src/common/entity/domain_icon.ts b/src/common/entity/domain_icon.ts index 40b0106216..af89f00d16 100644 --- a/src/common/entity/domain_icon.ts +++ b/src/common/entity/domain_icon.ts @@ -26,8 +26,11 @@ import { mdiCheckCircleOutline, mdiCloseCircleOutline, mdiWeatherNight, + mdiPackage, + mdiPackageDown, } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; +import { updateIsInstalling, UpdateEntity } from "../../data/update"; /** * Return the icon to be used for a domain. * @@ -133,6 +136,13 @@ export const domainIcon = ( return stateObj?.state === "above_horizon" ? FIXED_DOMAIN_ICONS[domain] : mdiWeatherNight; + + case "update": + return compareState === "on" + ? updateIsInstalling(stateObj as UpdateEntity) + ? mdiPackageDown + : mdiPackageUp + : mdiPackage; } if (domain in FIXED_DOMAIN_ICONS) { diff --git a/src/components/entity/ha-entities-picker.ts b/src/components/entity/ha-entities-picker.ts index 062b79eced..3f6127e303 100644 --- a/src/components/entity/ha-entities-picker.ts +++ b/src/components/entity/ha-entities-picker.ts @@ -46,6 +46,22 @@ class HaEntitiesPickerLight extends LitElement { @property({ type: Array, attribute: "include-unit-of-measurement" }) public includeUnitOfMeasurement?: string[]; + /** + * List of allowed entities to show. Will ignore all other filters. + * @type {Array} + * @attr include-entities + */ + @property({ type: Array, attribute: "include-entities" }) + public includeEntities?: string[]; + + /** + * List of entities to be excluded. + * @type {Array} + * @attr exclude-entities + */ + @property({ type: Array, attribute: "exclude-entities" }) + public excludeEntities?: string[]; + @property({ attribute: "picked-entity-label" }) public pickedEntityLabel?: string; @@ -69,6 +85,8 @@ class HaEntitiesPickerLight extends LitElement { .hass=${this.hass} .includeDomains=${this.includeDomains} .excludeDomains=${this.excludeDomains} + .includeEntities=${this.includeEntities} + .excludeEntities=${this.excludeEntities} .includeDeviceClasses=${this.includeDeviceClasses} .includeUnitOfMeasurement=${this.includeUnitOfMeasurement} .entityFilter=${this._entityFilter} @@ -84,6 +102,8 @@ class HaEntitiesPickerLight extends LitElement { .hass=${this.hass} .includeDomains=${this.includeDomains} .excludeDomains=${this.excludeDomains} + .includeEntities=${this.includeEntities} + .excludeEntities=${this.excludeEntities} .includeDeviceClasses=${this.includeDeviceClasses} .includeUnitOfMeasurement=${this.includeUnitOfMeasurement} .entityFilter=${this._entityFilter} diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index a8d17084c6..9b13c75515 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -7,6 +7,7 @@ import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; import { computeDomain } from "../../common/entity/compute_domain"; import { computeStateName } from "../../common/entity/compute_state_name"; +import { caseInsensitiveStringCompare } from "../../common/string/compare"; import { PolymerChangedEvent } from "../../polymer-types"; import { HomeAssistant } from "../../types"; import "../ha-combo-box"; @@ -77,6 +78,22 @@ export class HaEntityPicker extends LitElement { @property({ type: Array, attribute: "include-unit-of-measurement" }) public includeUnitOfMeasurement?: string[]; + /** + * List of allowed entities to show. Will ignore all other filters. + * @type {Array} + * @attr include-entities + */ + @property({ type: Array, attribute: "include-entities" }) + public includeEntities?: string[]; + + /** + * List of entities to be excluded. + * @type {Array} + * @attr exclude-entities + */ + @property({ type: Array, attribute: "exclude-entities" }) + public excludeEntities?: string[]; + @property() public entityFilter?: HaEntityPickerEntityFilterFunc; @property({ type: Boolean }) public hideClearIcon = false; @@ -109,7 +126,9 @@ export class HaEntityPicker extends LitElement { excludeDomains: this["excludeDomains"], entityFilter: this["entityFilter"], includeDeviceClasses: this["includeDeviceClasses"], - includeUnitOfMeasurement: this["includeUnitOfMeasurement"] + includeUnitOfMeasurement: this["includeUnitOfMeasurement"], + includeEntities: this["includeEntities"], + excludeEntities: this["excludeEntities"] ): HassEntityWithCachedName[] => { let states: HassEntityWithCachedName[] = []; @@ -139,6 +158,30 @@ export class HaEntityPicker extends LitElement { ]; } + if (includeEntities) { + entityIds = entityIds.filter((entityId) => + this.includeEntities!.includes(entityId) + ); + + return entityIds + .map((key) => ({ + ...hass!.states[key], + friendly_name: computeStateName(hass!.states[key]) || key, + })) + .sort((entityA, entityB) => + caseInsensitiveStringCompare( + entityA.friendly_name, + entityB.friendly_name + ) + ); + } + + if (excludeEntities) { + entityIds = entityIds.filter( + (entityId) => !excludeEntities!.includes(entityId) + ); + } + if (includeDomains) { entityIds = entityIds.filter((eid) => includeDomains.includes(computeDomain(eid)) @@ -151,10 +194,17 @@ export class HaEntityPicker extends LitElement { ); } - states = entityIds.sort().map((key) => ({ - ...hass!.states[key], - friendly_name: computeStateName(hass!.states[key]) || key, - })); + states = entityIds + .map((key) => ({ + ...hass!.states[key], + friendly_name: computeStateName(hass!.states[key]) || key, + })) + .sort((entityA, entityB) => + caseInsensitiveStringCompare( + entityA.friendly_name, + entityB.friendly_name + ) + ); if (includeDeviceClasses) { states = states.filter( @@ -231,7 +281,9 @@ export class HaEntityPicker extends LitElement { this.excludeDomains, this.entityFilter, this.includeDeviceClasses, - this.includeUnitOfMeasurement + this.includeUnitOfMeasurement, + this.includeEntities, + this.excludeEntities ); if (this._initedStates) { (this.comboBox as any).filteredItems = this._states; diff --git a/src/components/ha-form/compute-initial-ha-form-data.ts b/src/components/ha-form/compute-initial-ha-form-data.ts index 0e80433c74..7c5d728d91 100644 --- a/src/components/ha-form/compute-initial-ha-form-data.ts +++ b/src/components/ha-form/compute-initial-ha-form-data.ts @@ -1,4 +1,5 @@ -import { HaFormSchema } from "./types"; +import type { Selector } from "../../data/selector"; +import type { HaFormSchema } from "./types"; export const computeInitialHaFormData = ( schema: HaFormSchema[] @@ -31,6 +32,25 @@ export const computeInitialHaFormData = ( minutes: 0, seconds: 0, }; + } else if ("selector" in field) { + const selector: Selector = field.selector; + if ("boolean" in selector) { + data[field.name] = false; + } else if ("text" in selector) { + data[field.name] = ""; + } else if ("number" in selector) { + data[field.name] = "min" in selector.number ? selector.number.min : 0; + } else if ("select" in selector) { + if (selector.select.options.length) { + data[field.name] = selector.select.options[0][0]; + } + } else if ("duration" in selector) { + data[field.name] = { + hours: 0, + minutes: 0, + seconds: 0, + }; + } } }); return data; diff --git a/src/components/ha-selector/ha-selector-area.ts b/src/components/ha-selector/ha-selector-area.ts index 5f307dd013..704c79282a 100644 --- a/src/components/ha-selector/ha-selector-area.ts +++ b/src/components/ha-selector/ha-selector-area.ts @@ -28,7 +28,11 @@ export class HaAreaSelector extends LitElement { oldSelector !== this.selector && this.selector.area.device?.integration ) { - this._loadConfigEntries(); + getConfigEntries(this.hass, { + domain: this.selector.area.device.integration, + }).then((entries) => { + this._configEntries = entries; + }); } } } @@ -85,12 +89,6 @@ export class HaAreaSelector extends LitElement { } return true; }; - - private async _loadConfigEntries() { - this._configEntries = (await getConfigEntries(this.hass)).filter( - (entry) => entry.domain === this.selector.area.device?.integration - ); - } } declare global { diff --git a/src/components/ha-selector/ha-selector-datetime.ts b/src/components/ha-selector/ha-selector-datetime.ts index 80add84bd4..caee6d8457 100644 --- a/src/components/ha-selector/ha-selector-datetime.ts +++ b/src/components/ha-selector/ha-selector-datetime.ts @@ -26,6 +26,7 @@ export class HaDateTimeSelector extends LitElement { protected render() { const values = this.value?.split(" "); + return html` { + this._configEntries = entries; + }); } } } @@ -88,12 +92,6 @@ export class HaDeviceSelector extends LitElement { } return true; }; - - private async _loadConfigEntries() { - this._configEntries = (await getConfigEntries(this.hass)).filter( - (entry) => entry.domain === this.selector.device.integration - ); - } } declare global { diff --git a/src/components/ha-selector/ha-selector-duration.ts b/src/components/ha-selector/ha-selector-duration.ts index 1471750d90..1f5e88b146 100644 --- a/src/components/ha-selector/ha-selector-duration.ts +++ b/src/components/ha-selector/ha-selector-duration.ts @@ -1,8 +1,8 @@ -import "../ha-duration-input"; import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; -import { DurationSelector } from "../../data/selector"; -import { HomeAssistant } from "../../types"; +import type { DurationSelector } from "../../data/selector"; +import type { HomeAssistant } from "../../types"; +import "../ha-duration-input"; @customElement("ha-selector-duration") export class HaTimeDuration extends LitElement { diff --git a/src/components/ha-selector/ha-selector-entity.ts b/src/components/ha-selector/ha-selector-entity.ts index cbc5366953..8bf085f79f 100644 --- a/src/components/ha-selector/ha-selector-entity.ts +++ b/src/components/ha-selector/ha-selector-entity.ts @@ -6,8 +6,8 @@ import { subscribeEntityRegistry } from "../../data/entity_registry"; import { EntitySelector } from "../../data/selector"; import { SubscribeMixin } from "../../mixins/subscribe-mixin"; import { HomeAssistant } from "../../types"; -import "../entity/ha-entity-picker"; import "../entity/ha-entities-picker"; +import "../entity/ha-entity-picker"; @customElement("ha-selector-entity") export class HaEntitySelector extends SubscribeMixin(LitElement) { @@ -29,6 +29,8 @@ export class HaEntitySelector extends SubscribeMixin(LitElement) { .hass=${this.hass} .value=${this.value} .label=${this.label} + .includeEntities=${this.selector.entity.includeEntities} + .excludeEntities=${this.selector.entity.excludeEntities} .entityFilter=${this._filterEntities} .disabled=${this.disabled} allow-custom-entity @@ -41,6 +43,8 @@ export class HaEntitySelector extends SubscribeMixin(LitElement) { .hass=${this.hass} .value=${this.value} .entityFilter=${this._filterEntities} + .includeEntities=${this.selector.entity.includeEntities} + .excludeEntities=${this.selector.entity.excludeEntities} > `; } diff --git a/src/components/ha-selector/ha-selector-number.ts b/src/components/ha-selector/ha-selector-number.ts index 0094192f1b..8a9dfb1c3a 100644 --- a/src/components/ha-selector/ha-selector-number.ts +++ b/src/components/ha-selector/ha-selector-number.ts @@ -46,7 +46,7 @@ export class HaNumberSelector extends LitElement { class=${classMap({ single: this.selector.number.mode === "box" })} .min=${this.selector.number.min} .max=${this.selector.number.max} - .value=${this.value || ""} + .value=${this.value ?? ""} .step=${this.selector.number.step ?? 1} .disabled=${this.disabled} .required=${this.required} diff --git a/src/components/ha-selector/ha-selector-target.ts b/src/components/ha-selector/ha-selector-target.ts index 4d81be67df..cf25317cce 100644 --- a/src/components/ha-selector/ha-selector-target.ts +++ b/src/components/ha-selector/ha-selector-target.ts @@ -134,9 +134,8 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { private async _loadConfigEntries() { this._configEntries = (await getConfigEntries(this.hass)).filter( (entry) => - entry.domain === - (this.selector.target.device?.integration || - this.selector.target.entity?.integration) + entry.domain === this.selector.target.device?.integration || + entry.domain === this.selector.target.entity?.integration ); } diff --git a/src/data/config_entries.ts b/src/data/config_entries.ts index f2e84ddac0..b9e555998c 100644 --- a/src/data/config_entries.ts +++ b/src/data/config_entries.ts @@ -34,8 +34,24 @@ export const ERROR_STATES: ConfigEntry["state"][] = [ "setup_retry", ]; -export const getConfigEntries = (hass: HomeAssistant) => - hass.callApi("GET", "config/config_entries/entry"); +export const getConfigEntries = ( + hass: HomeAssistant, + filters?: { type?: "helper" | "integration"; domain?: string } +): Promise => { + const params = new URLSearchParams(); + if (filters) { + if (filters.type) { + params.append("type", filters.type); + } + if (filters.domain) { + params.append("domain", filters.domain); + } + } + return hass.callApi( + "GET", + `config/config_entries/entry?${params.toString()}` + ); +}; export const updateConfigEntry = ( hass: HomeAssistant, diff --git a/src/data/config_flow.ts b/src/data/config_flow.ts index 39019393c5..c236a1b05c 100644 --- a/src/data/config_flow.ts +++ b/src/data/config_flow.ts @@ -65,8 +65,14 @@ export const ignoreConfigFlow = ( export const deleteConfigFlow = (hass: HomeAssistant, flowId: string) => hass.callApi("DELETE", `config/config_entries/flow/${flowId}`); -export const getConfigFlowHandlers = (hass: HomeAssistant) => - hass.callApi("GET", "config/config_entries/flow_handlers"); +export const getConfigFlowHandlers = ( + hass: HomeAssistant, + type?: "helper" | "integration" +) => + hass.callApi( + "GET", + `config/config_entries/flow_handlers${type ? `?type=${type}` : ""}` + ); export const fetchConfigFlowInProgress = ( conn: Connection diff --git a/src/data/energy.ts b/src/data/energy.ts index d86419e9b2..d9f4f1c0c2 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -12,7 +12,12 @@ import { subscribeOne } from "../common/util/subscribe-one"; import { HomeAssistant } from "../types"; import { ConfigEntry, getConfigEntries } from "./config_entries"; import { subscribeEntityRegistry } from "./entity_registry"; -import { fetchStatistics, Statistics } from "./history"; +import { + fetchStatistics, + Statistics, + StatisticsMetaData, + getStatisticMetadata, +} from "./history"; const energyCollectionKeys: (string | undefined)[] = []; @@ -136,6 +141,7 @@ export interface GasSourceTypeEnergyPreference { entity_energy_from: string | null; entity_energy_price: string | null; number_energy_price: number | null; + unit_of_measurement?: string | null; } type EnergySource = @@ -241,14 +247,14 @@ const getEnergyData = async ( end?: Date ): Promise => { const [configEntries, entityRegistryEntries, info] = await Promise.all([ - getConfigEntries(hass), + getConfigEntries(hass, { domain: "co2signal" }), subscribeOne(hass.connection, subscribeEntityRegistry), getEnergyInfo(hass), ]); - const co2SignalConfigEntry = configEntries.find( - (entry) => entry.domain === "co2signal" - ); + const co2SignalConfigEntry = configEntries.length + ? configEntries[0] + : undefined; let co2SignalEntity: string | undefined; @@ -271,6 +277,15 @@ const getEnergyData = async ( const consumptionStatIDs: string[] = []; const statIDs: string[] = []; + const gasSources: GasSourceTypeEnergyPreference[] = + prefs.energy_sources.filter( + (source) => source.type === "gas" + ) as GasSourceTypeEnergyPreference[]; + const gasStatisticIdsWithMeta: StatisticsMetaData[] = + await getStatisticMetadata( + hass, + gasSources.map((source) => source.stat_energy_from) + ); for (const source of prefs.energy_sources) { if (source.type === "solar") { @@ -280,6 +295,20 @@ const getEnergyData = async ( if (source.type === "gas") { statIDs.push(source.stat_energy_from); + const entity = hass.states[source.stat_energy_from]; + if (!entity) { + for (const statisticIdWithMeta of gasStatisticIdsWithMeta) { + if ( + statisticIdWithMeta?.statistic_id === source.stat_energy_from && + statisticIdWithMeta?.unit_of_measurement + ) { + source.unit_of_measurement = + statisticIdWithMeta?.unit_of_measurement === "Wh" + ? "kWh" + : statisticIdWithMeta?.unit_of_measurement; + } + } + } if (source.stat_cost) { statIDs.push(source.stat_cost); } @@ -559,6 +588,9 @@ export const getEnergyGasUnit = ( ? "kWh" : entity.attributes.unit_of_measurement; } + if (source.unit_of_measurement) { + return source.unit_of_measurement; + } } return undefined; }; diff --git a/src/data/helpers_crud.ts b/src/data/helpers_crud.ts new file mode 100644 index 0000000000..c03ce664ac --- /dev/null +++ b/src/data/helpers_crud.ts @@ -0,0 +1,71 @@ +import { fetchCounter, updateCounter, deleteCounter } from "./counter"; +import { + fetchInputBoolean, + updateInputBoolean, + deleteInputBoolean, +} from "./input_boolean"; +import { + fetchInputButton, + updateInputButton, + deleteInputButton, +} from "./input_button"; +import { + fetchInputDateTime, + updateInputDateTime, + deleteInputDateTime, +} from "./input_datetime"; +import { + fetchInputNumber, + updateInputNumber, + deleteInputNumber, +} from "./input_number"; +import { + fetchInputSelect, + updateInputSelect, + deleteInputSelect, +} from "./input_select"; +import { fetchInputText, updateInputText, deleteInputText } from "./input_text"; +import { fetchTimer, updateTimer, deleteTimer } from "./timer"; + +export const HELPERS_CRUD = { + input_boolean: { + fetch: fetchInputBoolean, + update: updateInputBoolean, + delete: deleteInputBoolean, + }, + input_button: { + fetch: fetchInputButton, + update: updateInputButton, + delete: deleteInputButton, + }, + input_text: { + fetch: fetchInputText, + update: updateInputText, + delete: deleteInputText, + }, + input_number: { + fetch: fetchInputNumber, + update: updateInputNumber, + delete: deleteInputNumber, + }, + input_datetime: { + fetch: fetchInputDateTime, + update: updateInputDateTime, + delete: deleteInputDateTime, + }, + input_select: { + fetch: fetchInputSelect, + update: updateInputSelect, + delete: deleteInputSelect, + }, + counter: { + fetch: fetchCounter, + update: updateCounter, + delete: deleteCounter, + }, + timer: { + fetch: fetchTimer, + update: updateTimer, + delete: deleteTimer, + }, +}; diff --git a/src/data/history.ts b/src/data/history.ts index c81c592637..8b8e708f5c 100644 --- a/src/data/history.ts +++ b/src/data/history.ts @@ -373,6 +373,15 @@ export const getStatisticIds = ( statistic_type, }); +export const getStatisticMetadata = ( + hass: HomeAssistant, + statistic_ids?: string[] +) => + hass.callWS({ + type: "recorder/get_statistics_metadata", + statistic_ids, + }); + export const fetchStatistics = ( hass: HomeAssistant, startTime: Date, @@ -456,3 +465,16 @@ export const statisticsHaveType = ( stats: StatisticValue[], type: StatisticType ) => stats.some((stat) => stat[type] !== null); + +export const adjustStatisticsSum = ( + hass: HomeAssistant, + statistic_id: string, + start_time: string, + adjustment: number +): Promise => + hass.callWS({ + type: "recorder/adjust_sum_statistics", + statistic_id, + start_time, + adjustment, + }); diff --git a/src/data/selector.ts b/src/data/selector.ts index b48c11b179..aba717b4a7 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -28,6 +28,8 @@ export interface EntitySelector { domain?: string | string[]; device_class?: string; multiple?: boolean; + includeEntities?: string[]; + excludeEntities?: string[]; }; } diff --git a/src/data/update.ts b/src/data/update.ts new file mode 100644 index 0000000000..22891a92ac --- /dev/null +++ b/src/data/update.ts @@ -0,0 +1,36 @@ +import type { + HassEntityAttributeBase, + HassEntityBase, +} from "home-assistant-js-websocket"; +import { supportsFeature } from "../common/entity/supports-feature"; + +export const UPDATE_SUPPORT_INSTALL = 1; +export const UPDATE_SUPPORT_SPECIFIC_VERSION = 2; +export const UPDATE_SUPPORT_PROGRESS = 4; +export const UPDATE_SUPPORT_BACKUP = 8; + +interface UpdateEntityAttributes extends HassEntityAttributeBase { + current_version: string | null; + in_progress: boolean | number; + latest_version: string | null; + release_summary: string | null; + release_url: string | null; + skipped_version: string | null; + title: string | null; +} + +export interface UpdateEntity extends HassEntityBase { + attributes: UpdateEntityAttributes; +} + +export const updateUsesProgress = (entity: UpdateEntity): boolean => + supportsFeature(entity, UPDATE_SUPPORT_PROGRESS) && + typeof entity.attributes.in_progress === "number"; + +export const updateCanInstall = (entity: UpdateEntity): boolean => + supportsFeature(entity, UPDATE_SUPPORT_INSTALL) && + entity.attributes.latest_version !== entity.attributes.current_version && + entity.attributes.latest_version !== entity.attributes.skipped_version; + +export const updateIsInstalling = (entity: UpdateEntity): boolean => + updateUsesProgress(entity) || !!entity.attributes.in_progress; diff --git a/src/dialogs/config-flow/dialog-data-entry-flow.ts b/src/dialogs/config-flow/dialog-data-entry-flow.ts index c35216021e..d52c9edd7f 100644 --- a/src/dialogs/config-flow/dialog-data-entry-flow.ts +++ b/src/dialogs/config-flow/dialog-data-entry-flow.ts @@ -1,5 +1,5 @@ import "@material/mwc-button"; -import { mdiClose } from "@mdi/js"; +import { mdiClose, mdiHelpCircle } from "@mdi/js"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, @@ -33,6 +33,7 @@ import { } from "../../data/device_registry"; import { haStyleDialog } from "../../resources/styles"; import type { HomeAssistant } from "../../types"; +import { documentationUrl } from "../../util/documentation-url"; import { showAlertDialog } from "../generic/show-dialog-box"; import { DataEntryFlowDialogParams, @@ -236,14 +237,33 @@ class DataEntryFlowDialog extends LitElement { // to reset the element. "" : html` - +
+ ${this._step + ? html` + + ` + : ""} + +
${this._step === null ? this._handler ? html` * { + color: var(--secondary-text-color); + } `, ]; } diff --git a/src/dialogs/config-flow/show-dialog-config-flow.ts b/src/dialogs/config-flow/show-dialog-config-flow.ts index 42337b40ff..5b7c0722b7 100644 --- a/src/dialogs/config-flow/show-dialog-config-flow.ts +++ b/src/dialogs/config-flow/show-dialog-config-flow.ts @@ -24,7 +24,7 @@ export const showConfigFlowDialog = ( loadDevicesAndAreas: true, getFlowHandlers: async (hass) => { const [handlers] = await Promise.all([ - getConfigFlowHandlers(hass), + getConfigFlowHandlers(hass, "integration"), hass.loadBackendTranslation("title", undefined, true), ]); diff --git a/src/dialogs/config-flow/step-flow-pick-handler.ts b/src/dialogs/config-flow/step-flow-pick-handler.ts index 9f2a7e6740..0beb4ac26f 100644 --- a/src/dialogs/config-flow/step-flow-pick-handler.ts +++ b/src/dialogs/config-flow/step-flow-pick-handler.ts @@ -216,15 +216,16 @@ class StepFlowPickHandler extends LitElement { if (handler.is_add) { if (handler.slug === "zwave_js") { - const entries = await getConfigEntries(this.hass); - const entry = entries.find((ent) => ent.domain === "zwave_js"); + const entries = await getConfigEntries(this.hass, { + domain: "zwave_js", + }); - if (!entry) { + if (!entries.length) { return; } showZWaveJSAddNodeDialog(this, { - entry_id: entry.entry_id, + entry_id: entries[0].entry_id, }); } else if (handler.slug === "zha") { navigate("/config/zha/add"); diff --git a/src/dialogs/more-info/controls/more-info-update.ts b/src/dialogs/more-info/controls/more-info-update.ts new file mode 100644 index 0000000000..de1dbe1f10 --- /dev/null +++ b/src/dialogs/more-info/controls/more-info-update.ts @@ -0,0 +1,212 @@ +import "@material/mwc-button/mwc-button"; +import "@material/mwc-linear-progress/mwc-linear-progress"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; +import { supportsFeature } from "../../../common/entity/supports-feature"; +import "../../../components/ha-checkbox"; +import "../../../components/ha-formfield"; +import "../../../components/ha-markdown"; +import { UNAVAILABLE_STATES } from "../../../data/entity"; +import { + updateIsInstalling, + UpdateEntity, + UPDATE_SUPPORT_BACKUP, + UPDATE_SUPPORT_INSTALL, + UPDATE_SUPPORT_PROGRESS, + UPDATE_SUPPORT_SPECIFIC_VERSION, +} from "../../../data/update"; +import type { HomeAssistant } from "../../../types"; + +@customElement("more-info-update") +class MoreInfoUpdate extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public stateObj?: UpdateEntity; + + protected render(): TemplateResult { + if ( + !this.hass || + !this.stateObj || + UNAVAILABLE_STATES.includes(this.stateObj.state) + ) { + return html``; + } + + const skippedVersion = + this.stateObj.attributes.latest_version && + this.stateObj.attributes.skipped_version === + this.stateObj.attributes.latest_version; + + return html` + ${this.stateObj.attributes.in_progress + ? supportsFeature(this.stateObj, UPDATE_SUPPORT_PROGRESS) && + typeof this.stateObj.attributes.in_progress === "number" + ? html`` + : html`` + : ""} + ${this.stateObj.attributes.title + ? html`

${this.stateObj.attributes.title}

` + : ""} + +
+
+ ${this.hass.localize( + "ui.dialogs.more_info_control.update.current_version" + )} +
+
+ ${this.stateObj.attributes.current_version ?? + this.hass.localize("state.default.unavailable")} +
+
+
+
+ ${this.hass.localize( + "ui.dialogs.more_info_control.update.latest_version" + )} +
+
+ ${this.stateObj.attributes.latest_version ?? + this.hass.localize("state.default.unavailable")} +
+
+ + ${this.stateObj.attributes.release_url + ? html`` + : ""} + ${this.stateObj.attributes.release_summary + ? html`
+ ` + : ""} + ${supportsFeature(this.stateObj, UPDATE_SUPPORT_BACKUP) + ? html`
+ + + ` + : ""} +
+
+ + ${this.hass.localize("ui.dialogs.more_info_control.update.skip")} + + ${supportsFeature(this.stateObj, UPDATE_SUPPORT_INSTALL) + ? html` + + ${this.hass.localize( + "ui.dialogs.more_info_control.update.install" + )} + + ` + : ""} +
+ `; + } + + get _shouldCreateBackup(): boolean | null { + if (!supportsFeature(this.stateObj!, UPDATE_SUPPORT_BACKUP)) { + return null; + } + const checkbox = this.shadowRoot?.querySelector("ha-checkbox"); + if (checkbox) { + return checkbox.checked; + } + return true; + } + + private _handleInstall(): void { + const installData: Record = { + entity_id: this.stateObj!.entity_id, + }; + + if (this._shouldCreateBackup) { + installData.backup = true; + } + + if ( + supportsFeature(this.stateObj!, UPDATE_SUPPORT_SPECIFIC_VERSION) && + this.stateObj!.attributes.latest_version + ) { + installData.version = this.stateObj!.attributes.latest_version; + } + + this.hass.callService("update", "install", installData); + } + + private _handleSkip(): void { + this.hass.callService("update", "skip", { + entity_id: this.stateObj!.entity_id, + }); + } + + static get styles(): CSSResultGroup { + return css` + hr { + border-color: var(--divider-color); + border-bottom: none; + margin: 16px 0; + } + ha-expansion-panel { + margin: 16px 0; + } + .row { + margin: 0; + display: flex; + flex-direction: row; + justify-content: space-between; + } + .actions { + margin: 8px 0 0; + display: flex; + flex-wrap: wrap; + justify-content: center; + } + + .actions mwc-button { + margin: 0 4px 4px; + } + a { + color: var(--primary-color); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "more-info-update": MoreInfoUpdate; + } +} diff --git a/src/dialogs/more-info/state_more_info_control.ts b/src/dialogs/more-info/state_more_info_control.ts index 02abe5e731..3df5e85b4b 100644 --- a/src/dialogs/more-info/state_more_info_control.ts +++ b/src/dialogs/more-info/state_more_info_control.ts @@ -25,6 +25,7 @@ const LAZY_LOADED_MORE_INFO_CONTROL = { script: () => import("./controls/more-info-script"), sun: () => import("./controls/more-info-sun"), timer: () => import("./controls/more-info-timer"), + update: () => import("./controls/more-info-update"), vacuum: () => import("./controls/more-info-vacuum"), water_heater: () => import("./controls/more-info-water_heater"), weather: () => import("./controls/more-info-weather"), diff --git a/src/onboarding/onboarding-integrations.ts b/src/onboarding/onboarding-integrations.ts index 6ac3504e5e..9fc15b0c07 100644 --- a/src/onboarding/onboarding-integrations.ts +++ b/src/onboarding/onboarding-integrations.ts @@ -169,8 +169,8 @@ class OnboardingIntegrations extends LitElement { } private async _loadConfigEntries() { - const entries = await getConfigEntries(this.hass!); - // We filter out the config entry for the local weather and rpi_power. + const entries = await getConfigEntries(this.hass!, { type: "integration" }); + // We filter out the config entries that are automatically created during onboarding. // It is one that we create automatically and it will confuse the user // if it starts showing up during onboarding. this._entries = entries.filter( diff --git a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts index 99f48c8989..78d56a0b1b 100644 --- a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts +++ b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts @@ -58,12 +58,11 @@ export class HaDeviceInfoZWaveJS extends LitElement { return; } - const configEntries = await getConfigEntries(this.hass); + const configEntries = await getConfigEntries(this.hass, { + domain: "zwave_js", + }); let zwaveJsConfEntries = 0; for (const entry of configEntries) { - if (entry.domain !== "zwave_js") { - continue; - } if (zwaveJsConfEntries) { this._multipleConfigEntries = true; } diff --git a/src/panels/config/energy/components/ha-energy-gas-settings.ts b/src/panels/config/energy/components/ha-energy-gas-settings.ts index 3f92d1a023..4e65150f2b 100644 --- a/src/panels/config/energy/components/ha-energy-gas-settings.ts +++ b/src/panels/config/energy/components/ha-energy-gas-settings.ts @@ -121,6 +121,7 @@ export class EnergyGasSettings extends LitElement { showEnergySettingsGasDialog(this, { unit: getEnergyGasUnitCategory(this.hass, this.preferences), saveCallback: async (source) => { + delete source.unit_of_measurement; await this._savePreferences({ ...this.preferences, energy_sources: this.preferences.energy_sources.concat(source), diff --git a/src/panels/config/energy/components/ha-energy-grid-settings.ts b/src/panels/config/energy/components/ha-energy-grid-settings.ts index 700329dd1c..46ac49d885 100644 --- a/src/panels/config/energy/components/ha-energy-grid-settings.ts +++ b/src/panels/config/energy/components/ha-energy-grid-settings.ts @@ -54,7 +54,7 @@ export class EnergyGridSettings extends LitElement { @property({ attribute: false }) public validationResult?: EnergyPreferencesValidation; - @state() private _configEntries?: ConfigEntry[]; + @state() private _co2ConfigEntry?: ConfigEntry; protected firstUpdated() { this._fetchCO2SignalConfigEntries(); @@ -195,28 +195,28 @@ export class EnergyGridSettings extends LitElement { "ui.panel.config.energy.grid.grid_carbon_footprint" )} - ${this._configEntries?.map( - (entry) => html`
- - ${entry.title} - - - - -
` - )} - ${this._configEntries?.length === 0 - ? html` + ${this._co2ConfigEntry + ? html`
+ + ${this._co2ConfigEntry.title} + + + + +
` + : html`
- ` - : ""} + `} `; } private async _fetchCO2SignalConfigEntries() { - this._configEntries = (await getConfigEntries(this.hass)).filter( - (entry) => entry.domain === "co2signal" - ); + const entries = await getConfigEntries(this.hass, { domain: "co2signal" }); + this._co2ConfigEntry = entries.length ? entries[0] : undefined; } private _addCO2Sensor() { diff --git a/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts b/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts index 1f2acaf624..8ec83a64b8 100644 --- a/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts +++ b/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts @@ -176,9 +176,17 @@ export class DialogEnergySolarSettings private async _fetchSolarForecastConfigEntries() { const domains = this._params!.info.solar_forecast_domains; - this._configEntries = (await getConfigEntries(this.hass)).filter((entry) => - domains.includes(entry.domain) - ); + this._configEntries = + domains.length === 0 + ? [] + : domains.length === 1 + ? await getConfigEntries(this.hass, { + type: "integration", + domain: domains[0], + }) + : (await getConfigEntries(this.hass, { type: "integration" })).filter( + (entry) => domains.includes(entry.domain) + ); } private _handleForecastChanged(ev: CustomEvent) { diff --git a/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts b/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts index 913bcd8353..210d931a6c 100644 --- a/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts +++ b/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts @@ -10,50 +10,11 @@ import { customElement, property, state, query } from "lit/decorators"; import { isComponentLoaded } from "../../../../../common/config/is_component_loaded"; import { dynamicElement } from "../../../../../common/dom/dynamic-element-directive"; import { fireEvent } from "../../../../../common/dom/fire_event"; -import { - deleteCounter, - fetchCounter, - updateCounter, -} from "../../../../../data/counter"; import { ExtEntityRegistryEntry, removeEntityRegistryEntry, } from "../../../../../data/entity_registry"; -import { - deleteInputBoolean, - fetchInputBoolean, - updateInputBoolean, -} from "../../../../../data/input_boolean"; -import { - deleteInputButton, - fetchInputButton, - updateInputButton, -} from "../../../../../data/input_button"; -import { - deleteInputDateTime, - fetchInputDateTime, - updateInputDateTime, -} from "../../../../../data/input_datetime"; -import { - deleteInputNumber, - fetchInputNumber, - updateInputNumber, -} from "../../../../../data/input_number"; -import { - deleteInputSelect, - fetchInputSelect, - updateInputSelect, -} from "../../../../../data/input_select"; -import { - deleteInputText, - fetchInputText, - updateInputText, -} from "../../../../../data/input_text"; -import { - deleteTimer, - fetchTimer, - updateTimer, -} from "../../../../../data/timer"; +import { HELPERS_CRUD } from "../../../../../data/helpers_crud"; import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../../../../resources/styles"; import type { HomeAssistant } from "../../../../../types"; @@ -69,49 +30,6 @@ import "../../../helpers/forms/ha-timer-form"; import "../../entity-registry-basic-editor"; import type { HaEntityRegistryBasicEditor } from "../../entity-registry-basic-editor"; -const HELPERS = { - input_boolean: { - fetch: fetchInputBoolean, - update: updateInputBoolean, - delete: deleteInputBoolean, - }, - input_button: { - fetch: fetchInputButton, - update: updateInputButton, - delete: deleteInputButton, - }, - input_text: { - fetch: fetchInputText, - update: updateInputText, - delete: deleteInputText, - }, - input_number: { - fetch: fetchInputNumber, - update: updateInputNumber, - delete: deleteInputNumber, - }, - input_datetime: { - fetch: fetchInputDateTime, - update: updateInputDateTime, - delete: deleteInputDateTime, - }, - input_select: { - fetch: fetchInputSelect, - update: updateInputSelect, - delete: deleteInputSelect, - }, - counter: { - fetch: fetchCounter, - update: updateCounter, - delete: deleteCounter, - }, - timer: { - fetch: fetchTimer, - update: updateTimer, - delete: deleteTimer, - }, -}; - @customElement("entity-settings-helper-tab") export class EntityRegistrySettingsHelper extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -198,7 +116,7 @@ export class EntityRegistrySettingsHelper extends LitElement { } private async _getItem() { - const items = await HELPERS[this.entry.platform].fetch(this.hass!); + const items = await HELPERS_CRUD[this.entry.platform].fetch(this.hass!); this._item = items.find((item) => item.id === this.entry.unique_id) || null; } @@ -206,7 +124,7 @@ export class EntityRegistrySettingsHelper extends LitElement { this._submitting = true; try { if (this._componentLoaded && this._item) { - await HELPERS[this.entry.platform].update( + await HELPERS_CRUD[this.entry.platform].update( this.hass!, this._item.id, this._item @@ -236,7 +154,10 @@ export class EntityRegistrySettingsHelper extends LitElement { try { if (this._componentLoaded && this._item) { - await HELPERS[this.entry.platform].delete(this.hass!, this._item.id); + await HELPERS_CRUD[this.entry.platform].delete( + this.hass!, + this._item.id + ); } else { const stateObj = this.hass.states[this.entry.entity_id]; if (!stateObj?.attributes.restored) { diff --git a/src/panels/config/entities/entity-registry-basic-editor.ts b/src/panels/config/entities/entity-registry-basic-editor.ts index 10d3bdf994..de7d374318 100644 --- a/src/panels/config/entities/entity-registry-basic-editor.ts +++ b/src/panels/config/entities/entity-registry-basic-editor.ts @@ -1,13 +1,13 @@ -import "../../../components/ha-expansion-panel"; import "@material/mwc-formfield/mwc-formfield"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import { computeDomain } from "../../../common/entity/compute_domain"; import "../../../components/ha-area-picker"; +import "../../../components/ha-expansion-panel"; +import "../../../components/ha-radio"; import "../../../components/ha-switch"; import "../../../components/ha-textfield"; -import "../../../components/ha-radio"; import { DeviceRegistryEntry, subscribeDeviceRegistry, @@ -182,9 +182,12 @@ export class HaEntityRegistryBasicEditor extends SubscribeMixin(LitElement) { name="hiddendisabled" value="enabled" .checked=${!this._hiddenBy && !this._disabledBy} - .disabled=${(this._hiddenBy && this._hiddenBy !== "user") || - this._device?.disabled_by || - (this._disabledBy && this._disabledBy !== "user")} + .disabled=${this._device?.disabled_by || + (this._disabledBy && + !( + this._disabledBy === "user" || + this._disabledBy === "integration" + ))} @change=${this._viewStatusChanged} > @@ -197,9 +200,12 @@ export class HaEntityRegistryBasicEditor extends SubscribeMixin(LitElement) { name="hiddendisabled" value="hidden" .checked=${this._hiddenBy !== null} - .disabled=${(this._hiddenBy && this._hiddenBy !== "user") || - Boolean(this._device?.disabled_by) || - (this._disabledBy && this._disabledBy !== "user")} + .disabled=${this._device?.disabled_by || + (this._disabledBy && + !( + this._disabledBy === "user" || + this._disabledBy === "integration" + ))} @change=${this._viewStatusChanged} > @@ -212,9 +218,12 @@ export class HaEntityRegistryBasicEditor extends SubscribeMixin(LitElement) { name="hiddendisabled" value="disabled" .checked=${this._disabledBy !== null} - .disabled=${(this._hiddenBy && this._hiddenBy !== "user") || - Boolean(this._device?.disabled_by) || - (this._disabledBy && this._disabledBy !== "user")} + .disabled=${this._device?.disabled_by || + (this._disabledBy && + !( + this._disabledBy === "user" || + this._disabledBy === "integration" + ))} @change=${this._viewStatusChanged} > diff --git a/src/panels/config/entities/entity-registry-settings.ts b/src/panels/config/entities/entity-registry-settings.ts index c4278a7936..34d099db0d 100644 --- a/src/panels/config/entities/entity-registry-settings.ts +++ b/src/panels/config/entities/entity-registry-settings.ts @@ -42,6 +42,12 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; import { showDeviceRegistryDetailDialog } from "../devices/device-registry-detail/show-dialog-device-registry-detail"; +import { + ConfigEntry, + deleteConfigEntry, + getConfigEntries, +} from "../../../data/config_entries"; +import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow"; const OVERRIDE_DEVICE_CLASSES = { cover: [ @@ -83,6 +89,8 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { @state() private _device?: DeviceRegistryEntry; + @state() private _helperConfigEntry?: ConfigEntry; + @state() private _error?: string; @state() private _submitting?: boolean; @@ -103,6 +111,20 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { ]; } + protected firstUpdated(changedProps: PropertyValues): void { + super.firstUpdated(changedProps); + if (this.entry.config_entry_id) { + getConfigEntries(this.hass, { + type: "helper", + domain: this.entry.platform, + }).then((entries) => { + this._helperConfigEntry = entries.find( + (ent) => ent.entry_id === this.entry.config_entry_id + ); + }); + } + } + protected updated(changedProperties: PropertyValues) { super.updated(changedProperties); if (changedProperties.has("entry")) { @@ -215,6 +237,21 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { @value-changed=${this._areaPicked} >` : ""} + ${this._helperConfigEntry + ? html` +
+ + ${this.hass.localize( + "ui.dialogs.entity_registry.editor.configure_state" + )} + +
+ ` + : ""} + ${this.hass.localize("ui.dialogs.entity_registry.editor.delete")} @@ -471,13 +508,21 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { this._submitting = true; try { - await removeEntityRegistryEntry(this.hass!, this._origEntityId); + if (this._helperConfigEntry) { + await deleteConfigEntry(this.hass, this._helperConfigEntry.entry_id); + } else { + await removeEntityRegistryEntry(this.hass!, this._origEntityId); + } fireEvent(this, "close-dialog"); } finally { this._submitting = false; } } + private async _showOptionsFlow() { + showOptionsFlowDialog(this, this._helperConfigEntry!); + } + static get styles(): CSSResultGroup { return [ haStyle, diff --git a/src/panels/config/helpers/const.ts b/src/panels/config/helpers/const.ts index 2e927f66ad..c103332573 100644 --- a/src/panels/config/helpers/const.ts +++ b/src/panels/config/helpers/const.ts @@ -1,11 +1,11 @@ -import { Counter } from "../../../data/counter"; -import { InputBoolean } from "../../../data/input_boolean"; -import { InputButton } from "../../../data/input_button"; -import { InputDateTime } from "../../../data/input_datetime"; -import { InputNumber } from "../../../data/input_number"; -import { InputSelect } from "../../../data/input_select"; -import { InputText } from "../../../data/input_text"; -import { Timer } from "../../../data/timer"; +import type { Counter } from "../../../data/counter"; +import type { InputBoolean } from "../../../data/input_boolean"; +import type { InputButton } from "../../../data/input_button"; +import type { InputDateTime } from "../../../data/input_datetime"; +import type { InputNumber } from "../../../data/input_number"; +import type { InputSelect } from "../../../data/input_select"; +import type { InputText } from "../../../data/input_text"; +import type { Timer } from "../../../data/timer"; export const HELPER_DOMAINS = [ "input_boolean", diff --git a/src/panels/config/helpers/dialog-helper-detail.ts b/src/panels/config/helpers/dialog-helper-detail.ts index bbd0b645e1..c3adf6a826 100644 --- a/src/panels/config/helpers/dialog-helper-detail.ts +++ b/src/panels/config/helpers/dialog-helper-detail.ts @@ -8,6 +8,8 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { dynamicElement } from "../../../common/dom/dynamic-element-directive"; import { domainIcon } from "../../../common/entity/domain_icon"; import "../../../components/ha-dialog"; +import "../../../components/ha-circular-progress"; +import { getConfigFlowHandlers } from "../../../data/config_flow"; import { createCounter } from "../../../data/counter"; import { createInputBoolean } from "../../../data/input_boolean"; import { createInputButton } from "../../../data/input_button"; @@ -16,6 +18,7 @@ import { createInputNumber } from "../../../data/input_number"; import { createInputSelect } from "../../../data/input_select"; import { createInputText } from "../../../data/input_text"; import { createTimer } from "../../../data/timer"; +import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; import { haStyleDialog } from "../../../resources/styles"; import { HomeAssistant } from "../../../types"; import { Helper } from "./const"; @@ -27,6 +30,8 @@ import "./forms/ha-input_number-form"; import "./forms/ha-input_select-form"; import "./forms/ha-input_text-form"; import "./forms/ha-timer-form"; +import { domainToName } from "../../../data/integration"; +import type { ShowDialogHelperDetailParams } from "./show-dialog-helper-detail"; const HELPERS = { input_boolean: createInputBoolean, @@ -47,7 +52,7 @@ export class DialogHelperDetail extends LitElement { @state() private _opened = false; - @state() private _platform?: string; + @state() private _domain?: string; @state() private _error?: string; @@ -55,102 +60,135 @@ export class DialogHelperDetail extends LitElement { @query(".form") private _form?: HTMLDivElement; - public async showDialog(): Promise { - this._platform = undefined; + @state() private _helperFlows?: string[]; + + private _params?: ShowDialogHelperDetailParams; + + public async showDialog(params: ShowDialogHelperDetailParams): Promise { + this._params = params; + this._domain = undefined; this._item = undefined; this._opened = true; await this.updateComplete; + Promise.all([ + getConfigFlowHandlers(this.hass, "helper"), + // Ensure the titles are loaded before we render the flows. + this.hass.loadBackendTranslation("title", undefined, true), + ]).then(([flows]) => { + this._helperFlows = flows; + }); } public closeDialog(): void { this._opened = false; this._error = ""; + this._params = undefined; } protected render(): TemplateResult { + let content: TemplateResult; + + if (this._domain) { + content = html` +
+ ${this._error ? html`
${this._error}
` : ""} + ${dynamicElement(`ha-${this._domain}-form`, { + hass: this.hass, + item: this._item, + new: true, + })} +
+ + ${this.hass!.localize("ui.panel.config.helpers.dialog.create")} + + + ${this.hass!.localize("ui.common.back")} + + `; + } else if (this._helperFlows === undefined) { + content = html``; + } else { + const items: [string, string][] = []; + + for (const helper of Object.keys(HELPERS)) { + items.push([ + helper, + this.hass.localize(`ui.panel.config.helpers.types.${helper}`) || + helper, + ]); + } + + for (const domain of this._helperFlows) { + items.push([domain, domainToName(this.hass.localize, domain)]); + } + + items.sort((a, b) => a[1].localeCompare(b[1])); + + content = html` + ${items.map(([domain, label]) => { + // Only OG helpers need to be loaded prior adding one + const isLoaded = + !(domain in HELPERS) || isComponentLoaded(this.hass, domain); + return html` + + + ${label} + + ${!isLoaded + ? html` + ${this.hass.localize( + "ui.dialogs.helper_settings.platform_not_loaded", + "platform", + domain + )} + ` + : ""} + `; + })} + + ${this.hass!.localize("ui.common.cancel")} + + `; + } + return html` - ${this._platform - ? html` -
- ${this._error - ? html`
${this._error}
` - : ""} - ${dynamicElement(`ha-${this._platform}-form`, { - hass: this.hass, - item: this._item, - new: true, - })} -
- - ${this.hass!.localize("ui.panel.config.helpers.dialog.create")} - - - ${this.hass!.localize("ui.common.back")} - - ` - : html` - ${Object.keys(HELPERS).map((platform: string) => { - const isLoaded = isComponentLoaded(this.hass, platform); - return html` - - - - ${this.hass.localize( - `ui.panel.config.helpers.types.${platform}` - ) || platform} - - - ${!isLoaded - ? html` - ${this.hass.localize( - "ui.dialogs.helper_settings.platform_not_loaded", - "platform", - platform - )} - ` - : ""} - `; - })} - - ${this.hass!.localize("ui.common.cancel")} - - `} + ${content}
`; } @@ -160,13 +198,13 @@ export class DialogHelperDetail extends LitElement { } private async _createItem(): Promise { - if (!this._platform || !this._item) { + if (!this._domain || !this._item) { return; } this._submitting = true; this._error = ""; try { - await HELPERS[this._platform](this.hass, this._item); + await HELPERS[this._domain](this.hass, this._item); this.closeDialog(); } catch (err: any) { this._error = err.message || "Unknown error"; @@ -181,12 +219,22 @@ export class DialogHelperDetail extends LitElement { } ev.stopPropagation(); ev.preventDefault(); - this._platformPicked(ev); + this._domainPicked(ev); } - private _platformPicked(ev: Event): void { - this._platform = (ev.currentTarget! as any).platform; - this._focusForm(); + private _domainPicked(ev: Event): void { + const domain = (ev.currentTarget! as any).domain; + + if (domain in HELPERS) { + this._domain = domain; + this._focusForm(); + } else { + showConfigFlowDialog(this, { + startFlowHandler: domain, + dialogClosedCallback: this._params!.dialogClosedCallback, + }); + this.closeDialog(); + } } private async _focusForm(): Promise { @@ -195,7 +243,7 @@ export class DialogHelperDetail extends LitElement { } private _goBack() { - this._platform = undefined; + this._domain = undefined; this._item = undefined; this._error = undefined; } diff --git a/src/panels/config/helpers/ha-config-helpers.ts b/src/panels/config/helpers/ha-config-helpers.ts index ac58f83e44..4ce9d00118 100644 --- a/src/panels/config/helpers/ha-config-helpers.ts +++ b/src/panels/config/helpers/ha-config-helpers.ts @@ -1,28 +1,58 @@ import { mdiPencilOff, mdiPlus } from "@mdi/js"; import "@polymer/paper-tooltip/paper-tooltip"; -import { HassEntity } from "home-assistant-js-websocket"; +import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; import { html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; -import memoize from "memoize-one"; +import memoizeOne from "memoize-one"; import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { domainIcon } from "../../../common/entity/domain_icon"; +import { LocalizeFunc } from "../../../common/translations/localize"; import { DataTableColumnContainer, RowClickedEvent, } from "../../../components/data-table/ha-data-table"; import "../../../components/ha-fab"; +import "../../../components/ha-icon-overflow-menu"; import "../../../components/ha-icon"; import "../../../components/ha-svg-icon"; +import { ConfigEntry, getConfigEntries } from "../../../data/config_entries"; +import { + EntityRegistryEntry, + subscribeEntityRegistry, +} from "../../../data/entity_registry"; +import { domainToName } from "../../../data/integration"; import "../../../layouts/hass-loading-screen"; import "../../../layouts/hass-tabs-subpage-data-table"; +import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { HomeAssistant, Route } from "../../../types"; import { showEntityEditorDialog } from "../entities/show-dialog-entity-editor"; import { configSections } from "../ha-panel-config"; import { HELPER_DOMAINS } from "./const"; import { showHelperDetailDialog } from "./show-dialog-helper-detail"; +// This groups items by a key but only returns last entry per key. +const groupByOne = ( + items: T[], + keySelector: (item: T) => string +): Record => { + const result: Record = {}; + for (const item of items) { + result[keySelector(item)] = item; + } + return result; +}; + +const getConfigEntry = ( + entityEntries: Record, + configEntries: Record, + entityId: string +) => { + const configEntryId = entityEntries![entityId]?.config_entry_id; + return configEntryId ? configEntries![configEntryId] : undefined; +}; + @customElement("ha-config-helpers") -export class HaConfigHelpers extends LitElement { +export class HaConfigHelpers extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property() public isWide!: boolean; @@ -33,98 +63,122 @@ export class HaConfigHelpers extends LitElement { @state() private _stateItems: HassEntity[] = []; - private _columns = memoize((narrow, _language): DataTableColumnContainer => { - const columns: DataTableColumnContainer = { - icon: { + @state() private _entityEntries?: Record; + + @state() private _configEntries?: Record; + + private _columns = memoizeOne( + (narrow: boolean, localize: LocalizeFunc): DataTableColumnContainer => { + const columns: DataTableColumnContainer = { + icon: { + title: "", + label: localize("ui.panel.config.helpers.picker.headers.icon"), + type: "icon", + template: (icon, helper: any) => + icon + ? html` ` + : html``, + }, + name: { + title: localize("ui.panel.config.helpers.picker.headers.name"), + sortable: true, + filterable: true, + grows: true, + direction: "asc", + template: (name, item: any) => + html` + ${name} + ${narrow + ? html`
${item.entity_id}
` + : ""} + `, + }, + }; + if (!narrow) { + columns.entity_id = { + title: localize("ui.panel.config.helpers.picker.headers.entity_id"), + sortable: true, + filterable: true, + width: "25%", + }; + } + columns.type = { + title: localize("ui.panel.config.helpers.picker.headers.type"), + sortable: true, + width: "25%", + filterable: true, + template: (type, row) => + row.configEntry + ? domainToName(localize, type) + : html` + ${localize(`ui.panel.config.helpers.types.${type}`) || type} + `, + }; + columns.editable = { title: "", label: this.hass.localize( - "ui.panel.config.helpers.picker.headers.icon" + "ui.panel.config.helpers.picker.headers.editable" ), type: "icon", - template: (icon, helper: any) => - icon - ? html` ` - : html``, - }, - name: { - title: this.hass.localize( - "ui.panel.config.helpers.picker.headers.name" - ), - sortable: true, - filterable: true, - grows: true, - direction: "asc", - template: (name, item: any) => - html` - ${name} - ${narrow - ? html`
${item.entity_id}
` - : ""} - `, - }, - }; - if (!narrow) { - columns.entity_id = { - title: this.hass.localize( - "ui.panel.config.helpers.picker.headers.entity_id" - ), - sortable: true, - filterable: true, - width: "25%", - }; - } - columns.type = { - title: this.hass.localize("ui.panel.config.helpers.picker.headers.type"), - sortable: true, - width: "25%", - filterable: true, - template: (type) => - html` - ${this.hass.localize(`ui.panel.config.helpers.types.${type}`) || type} + template: (editable) => html` + ${!editable + ? html` +
+ + + ${this.hass.localize( + "ui.panel.config.entities.picker.status.readonly" + )} + +
+ ` + : ""} `, - }; - columns.editable = { - title: "", - label: this.hass.localize( - "ui.panel.config.helpers.picker.headers.editable" - ), - type: "icon", - template: (editable) => html` - ${!editable - ? html` -
- - - ${this.hass.localize( - "ui.panel.config.entities.picker.status.readonly" - )} - -
- ` - : ""} - `, - }; - return columns; - }); + }; + return columns; + } + ); - private _getItems = memoize((stateItems: HassEntity[]) => - stateItems.map((entityState) => ({ - id: entityState.entity_id, - icon: entityState.attributes.icon, - name: entityState.attributes.friendly_name || "", - entity_id: entityState.entity_id, - editable: entityState.attributes.editable, - type: computeStateDomain(entityState), - })) + private _getItems = memoizeOne( + ( + stateItems: HassEntity[], + entityEntries: Record, + configEntries: Record + ) => + stateItems.map((entityState) => { + const configEntry = getConfigEntry( + entityEntries, + configEntries, + entityState.entity_id + ); + + return { + id: entityState.entity_id, + icon: entityState.attributes.icon, + name: entityState.attributes.friendly_name || "", + entity_id: entityState.entity_id, + editable: + configEntry !== undefined || entityState.attributes.editable, + type: configEntry + ? configEntry.domain + : computeStateDomain(entityState), + configEntry, + }; + }) ); protected render(): TemplateResult { - if (!this.hass || this._stateItems === undefined) { + if ( + !this.hass || + this._stateItems === undefined || + this._entityEntries === undefined || + this._configEntries === undefined + ) { return html` `; } @@ -135,8 +189,12 @@ export class HaConfigHelpers extends LitElement { back-path="/config" .route=${this.route} .tabs=${configSections.automations} - .columns=${this._columns(this.narrow, this.hass.language)} - .data=${this._getItems(this._stateItems)} + .columns=${this._columns(this.narrow, this.hass.localize)} + .data=${this._getItems( + this._stateItems, + this._entityEntries, + this._configEntries + )} @row-click=${this._openEditDialog} hasFab clickable @@ -160,32 +218,67 @@ export class HaConfigHelpers extends LitElement { protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); - this._getStates(); + this._getConfigEntries(); } - protected updated(changedProps: PropertyValues) { - super.updated(changedProps); - const oldHass = changedProps.get("hass") as HomeAssistant | undefined; - if (oldHass && this._stateItems) { - this._getStates(oldHass); + protected willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + + if (!this._entityEntries || !this._configEntries) { + return; + } + + let changed = + !this._stateItems || + changedProps.has("_entityEntries") || + changedProps.has("_configEntries"); + + if (!changed && changedProps.has("hass")) { + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + changed = !oldHass || oldHass.states !== this.hass.states; + } + if (!changed) { + return; + } + + const extraEntities = new Set(); + + for (const entityEntry of Object.values(this._entityEntries)) { + if ( + entityEntry.config_entry_id && + entityEntry.config_entry_id in this._configEntries + ) { + extraEntities.add(entityEntry.entity_id); + } + } + + const newStates = Object.values(this.hass!.states).filter( + (entity) => + extraEntities.has(entity.entity_id) || + HELPER_DOMAINS.includes(computeStateDomain(entity)) + ); + + if ( + this._stateItems.length !== newStates.length || + !this._stateItems.every((val, idx) => newStates[idx] === val) + ) { + this._stateItems = newStates; } } - private _getStates(oldHass?: HomeAssistant) { - let changed = false; - const tempStates = Object.values(this.hass!.states).filter((entity) => { - if (!HELPER_DOMAINS.includes(computeStateDomain(entity))) { - return false; - } - if (oldHass?.states[entity.entity_id] !== entity) { - changed = true; - } - return true; - }); + public hassSubscribe(): UnsubscribeFunc[] { + return [ + subscribeEntityRegistry(this.hass.connection!, (entries) => { + this._entityEntries = groupByOne(entries, (entry) => entry.entity_id); + }), + ]; + } - if (changed || this._stateItems.length !== tempStates.length) { - this._stateItems = tempStates; - } + private async _getConfigEntries() { + this._configEntries = groupByOne( + await getConfigEntries(this.hass, { type: "helper" }), + (entry) => entry.entry_id + ); } private async _openEditDialog(ev: CustomEvent): Promise { @@ -196,6 +289,12 @@ export class HaConfigHelpers extends LitElement { } private _createHelpler() { - showHelperDetailDialog(this); + showHelperDetailDialog(this, { + dialogClosedCallback: (params) => { + if (params.flowFinished) { + this._getConfigEntries(); + } + }, + }); } } diff --git a/src/panels/config/helpers/show-dialog-helper-detail.ts b/src/panels/config/helpers/show-dialog-helper-detail.ts index 959f92ad75..83fbbce4ee 100644 --- a/src/panels/config/helpers/show-dialog-helper-detail.ts +++ b/src/panels/config/helpers/show-dialog-helper-detail.ts @@ -1,11 +1,20 @@ import { fireEvent } from "../../../common/dom/fire_event"; +import { DataEntryFlowDialogParams } from "../../../dialogs/config-flow/show-dialog-data-entry-flow"; export const loadHelperDetailDialog = () => import("./dialog-helper-detail"); -export const showHelperDetailDialog = (element: HTMLElement) => { +export interface ShowDialogHelperDetailParams { + // Only used for config entries + dialogClosedCallback: DataEntryFlowDialogParams["dialogClosedCallback"]; +} + +export const showHelperDetailDialog = ( + element: HTMLElement, + params: ShowDialogHelperDetailParams +) => { fireEvent(element, "show-dialog", { dialogTag: "dialog-helper-detail", dialogImport: loadHelperDetailDialog, - dialogParams: {}, + dialogParams: params, }); }; diff --git a/src/panels/config/integrations/ha-config-integrations.ts b/src/panels/config/integrations/ha-config-integrations.ts index 5255d1197e..c860953efc 100644 --- a/src/panels/config/integrations/ha-config-integrations.ts +++ b/src/panels/config/integrations/ha-config-integrations.ts @@ -521,24 +521,26 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { } private _loadConfigEntries() { - getConfigEntries(this.hass).then((configEntries) => { - this._configEntries = configEntries - .map( - (entry: ConfigEntry): ConfigEntryExtended => ({ - ...entry, - localized_domain_name: domainToName( - this.hass.localize, - entry.domain - ), - }) - ) - .sort((conf1, conf2) => - caseInsensitiveStringCompare( - conf1.localized_domain_name + conf1.title, - conf2.localized_domain_name + conf2.title + getConfigEntries(this.hass, { type: "integration" }).then( + (configEntries) => { + this._configEntries = configEntries + .map( + (entry: ConfigEntry): ConfigEntryExtended => ({ + ...entry, + localized_domain_name: domainToName( + this.hass.localize, + entry.domain + ), + }) ) - ); - }); + .sort((conf1, conf2) => + caseInsensitiveStringCompare( + conf1.localized_domain_name + conf1.title, + conf2.localized_domain_name + conf2.title + ) + ); + } + ); } private async _scanUSBDevices() { @@ -656,7 +658,7 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { if (!domain) { return; } - const handlers = await getConfigFlowHandlers(this.hass); + const handlers = await getConfigFlowHandlers(this.hass, "integration"); if (!handlers.includes(domain)) { showAlertDialog(this, { diff --git a/src/panels/config/integrations/integration-panels/mqtt/mqtt-config-panel.ts b/src/panels/config/integrations/integration-panels/mqtt/mqtt-config-panel.ts index 9337854c8e..eaa03dc98f 100644 --- a/src/panels/config/integrations/integration-panels/mqtt/mqtt-config-panel.ts +++ b/src/panels/config/integrations/integration-panels/mqtt/mqtt-config-panel.ts @@ -111,7 +111,9 @@ class HaPanelDevMqtt extends LitElement { return; } const configEntryId = searchParams.get("config_entry") as string; - const configEntries = await getConfigEntries(this.hass); + const configEntries = await getConfigEntries(this.hass, { + domain: "mqtt", + }); const configEntry = configEntries.find( (entry) => entry.entry_id === configEntryId ); diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts index 3c75b59df0..9b5a5c8e37 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts @@ -384,7 +384,9 @@ class ZWaveJSConfigDashboard extends LitElement { if (!this.configEntryId) { return; } - const configEntries = await getConfigEntries(this.hass); + const configEntries = await getConfigEntries(this.hass, { + domain: "zwave_js", + }); this._configEntry = configEntries.find( (entry) => entry.entry_id === this.configEntryId! ); @@ -467,7 +469,9 @@ class ZWaveJSConfigDashboard extends LitElement { if (!this.configEntryId) { return; } - const configEntries = await getConfigEntries(this.hass); + const configEntries = await getConfigEntries(this.hass, { + domain: "zwave_js", + }); const configEntry = configEntries.find( (entry) => entry.entry_id === this.configEntryId ); diff --git a/src/panels/developer-tools/statistics/developer-tools-statistics.ts b/src/panels/developer-tools/statistics/developer-tools-statistics.ts index 4b48ebb116..3183133beb 100644 --- a/src/panels/developer-tools/statistics/developer-tools-statistics.ts +++ b/src/panels/developer-tools/statistics/developer-tools-statistics.ts @@ -1,10 +1,12 @@ import "@material/mwc-button/mwc-button"; +import { mdiSlopeUphill } from "@mdi/js"; import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, CSSResultGroup, html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../../common/dom/fire_event"; import { computeStateName } from "../../../common/entity/compute_state_name"; +import "../../../components/ha-icon-overflow-menu"; import "../../../components/data-table/ha-data-table"; import type { DataTableColumnContainer } from "../../../components/data-table/ha-data-table"; import { subscribeEntityRegistry } from "../../../data/entity_registry"; @@ -24,6 +26,7 @@ import { haStyle } from "../../../resources/styles"; import { HomeAssistant } from "../../../types"; import { showFixStatisticsUnitsChangedDialog } from "./show-dialog-statistics-fix-units-changed"; import { showFixStatisticsUnsupportedUnitMetadataDialog } from "./show-dialog-statistics-fix-unsupported-unit-meta"; +import { showStatisticsAdjustSumDialog } from "./show-dialog-statistics-adjust-sum"; const FIX_ISSUES_ORDER = { no_state: 0, @@ -111,6 +114,30 @@ class HaPanelDevStatistics extends SubscribeMixin(LitElement) { : ""}`, width: "113px", }, + actions: { + title: "", + type: "overflow-menu", + template: ( + _info, + statistic: StatisticsMetaData + ) => html` + showStatisticsAdjustSumDialog(this, { + statistic: statistic, + }), + }, + ]} + style="color: var(--secondary-text-color)" + >`, + }, }) ); diff --git a/src/panels/developer-tools/statistics/dialog-statistics-adjust-sum.ts b/src/panels/developer-tools/statistics/dialog-statistics-adjust-sum.ts new file mode 100644 index 0000000000..9819177799 --- /dev/null +++ b/src/panels/developer-tools/statistics/dialog-statistics-adjust-sum.ts @@ -0,0 +1,166 @@ +import "@material/mwc-button/mwc-button"; +import { LitElement, TemplateResult, html, CSSResultGroup } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import "../../../components/ha-dialog"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { haStyle, haStyleDialog } from "../../../resources/styles"; +import { HomeAssistant } from "../../../types"; +import "../../../components/ha-formfield"; +import "../../../components/ha-radio"; +import "../../../components/ha-form/ha-form"; +import type { DialogStatisticsAdjustSumParams } from "./show-dialog-statistics-adjust-sum"; +import type { + HaFormBaseSchema, + HaFormSchema, +} from "../../../components/ha-form/types"; +import { adjustStatisticsSum } from "../../../data/history"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; +import { showToast } from "../../../util/toast"; + +let lastMoment: string | undefined; + +@customElement("dialog-statistics-adjust-sum") +export class DialogStatisticsFixUnsupportedUnitMetadata extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: DialogStatisticsAdjustSumParams; + + @state() private _data?: { + moment: string; + amount: number; + }; + + @state() private _busy = false; + + public showDialog(params: DialogStatisticsAdjustSumParams): void { + this._params = params; + this._busy = false; + const now = new Date(); + this._data = { + moment: + lastMoment || + `${now.getFullYear()}-${ + now.getMonth() + 1 + }-${now.getDate()} ${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}`, + amount: 0, + }; + } + + public closeDialog(): void { + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render(): TemplateResult | void { + if (!this._params) { + return html``; + } + + return html` + + + + + + + `; + } + + private _getSchema = memoizeOne((statistic): HaFormSchema[] => [ + { + type: "constant", + name: "name", + value: statistic.name || statistic.statistic_id, + }, + { + name: "moment", + required: true, + selector: { + datetime: {}, + }, + }, + { + name: "amount", + required: true, + default: 0, + selector: { + number: { + mode: "box", + step: 0.1, + unit_of_measurement: statistic.unit_of_measurement, + }, + }, + }, + ]); + + private _computeLabel(value: HaFormBaseSchema) { + switch (value.name) { + case "name": + return "Statistic"; + case "moment": + return "Moment to adjust"; + case "amount": + return "Amount"; + default: + return value.name; + } + } + + private _valueChanged(ev) { + this._data = ev.detail.value; + } + + private async _fixIssue(): Promise { + this._busy = true; + try { + await adjustStatisticsSum( + this.hass, + this._params!.statistic.statistic_id, + this._data!.moment, + this._data!.amount + ); + } catch (err: any) { + this._busy = false; + showAlertDialog(this, { + text: `Error adjusting sum: ${err.message || err}`, + }); + return; + } + showToast(this, { + message: "Statistic sum adjusted", + }); + lastMoment = this._data!.moment; + this.closeDialog(); + } + + static get styles(): CSSResultGroup { + return [haStyle, haStyleDialog]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-statistics-adjust-sum": DialogStatisticsFixUnsupportedUnitMetadata; + } +} diff --git a/src/panels/developer-tools/statistics/dialog-statistics-fix-units-changed.ts b/src/panels/developer-tools/statistics/dialog-statistics-fix-units-changed.ts index 1ce0c607ce..3168bffc9f 100644 --- a/src/panels/developer-tools/statistics/dialog-statistics-fix-units-changed.ts +++ b/src/panels/developer-tools/statistics/dialog-statistics-fix-units-changed.ts @@ -11,7 +11,7 @@ import { } from "../../../data/history"; import "../../../components/ha-formfield"; import "../../../components/ha-radio"; -import { DialogStatisticsUnitsChangedParams } from "./show-dialog-statistics-fix-units-changed"; +import type { DialogStatisticsUnitsChangedParams } from "./show-dialog-statistics-fix-units-changed"; @customElement("dialog-statistics-fix-units-changed") export class DialogStatisticsFixUnitsChanged extends LitElement { diff --git a/src/panels/developer-tools/statistics/dialog-statistics-fix-unsupported-unit-meta.ts b/src/panels/developer-tools/statistics/dialog-statistics-fix-unsupported-unit-meta.ts index 93d1e320b1..4bfaebe489 100644 --- a/src/panels/developer-tools/statistics/dialog-statistics-fix-unsupported-unit-meta.ts +++ b/src/panels/developer-tools/statistics/dialog-statistics-fix-unsupported-unit-meta.ts @@ -8,7 +8,7 @@ import { HomeAssistant } from "../../../types"; import { updateStatisticsMetadata } from "../../../data/history"; import "../../../components/ha-formfield"; import "../../../components/ha-radio"; -import { DialogStatisticsUnsupportedUnitMetaParams } from "./show-dialog-statistics-fix-unsupported-unit-meta"; +import type { DialogStatisticsUnsupportedUnitMetaParams } from "./show-dialog-statistics-fix-unsupported-unit-meta"; @customElement("dialog-statistics-fix-unsupported-unit-meta") export class DialogStatisticsFixUnsupportedUnitMetadata extends LitElement { diff --git a/src/panels/developer-tools/statistics/show-dialog-statistics-adjust-sum.ts b/src/panels/developer-tools/statistics/show-dialog-statistics-adjust-sum.ts new file mode 100644 index 0000000000..1db2c76307 --- /dev/null +++ b/src/panels/developer-tools/statistics/show-dialog-statistics-adjust-sum.ts @@ -0,0 +1,20 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import { StatisticsMetaData } from "../../../data/history"; + +export const loadAdjustSumDialog = () => + import("./dialog-statistics-adjust-sum"); + +export interface DialogStatisticsAdjustSumParams { + statistic: StatisticsMetaData; +} + +export const showStatisticsAdjustSumDialog = ( + element: HTMLElement, + detailParams: DialogStatisticsAdjustSumParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-statistics-adjust-sum", + dialogImport: loadAdjustSumDialog, + dialogParams: detailParams, + }); +}; diff --git a/src/panels/lovelace/editor/config-elements/config-elements-style.ts b/src/panels/lovelace/editor/config-elements/config-elements-style.ts index 061d3aff36..2931974935 100644 --- a/src/panels/lovelace/editor/config-elements/config-elements-style.ts +++ b/src/panels/lovelace/editor/config-elements/config-elements-style.ts @@ -1,6 +1,10 @@ import { css } from "lit"; export const configElementStyle = css` + .card-config { + /* Cancels overlapping Margins for HAForm + Card Config options */ + overflow: auto; + } ha-switch { padding: 16px 6px; } @@ -25,5 +29,6 @@ export const configElementStyle = css` ha-textfield, ha-icon-picker { margin-top: 8px; + display: block; } `; diff --git a/src/panels/lovelace/editor/config-elements/hui-button-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-button-card-editor.ts index e5f8525d99..2445a1e045 100644 --- a/src/panels/lovelace/editor/config-elements/hui-button-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-button-card-editor.ts @@ -1,22 +1,22 @@ +import type { HassEntity } from "home-assistant-js-websocket"; import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { assert, boolean, object, optional, string, assign } from "superstruct"; -import type { HassEntity } from "home-assistant-js-websocket"; import memoizeOne from "memoize-one"; +import { assert, assign, boolean, object, optional, string } from "superstruct"; import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeDomain } from "../../../../common/entity/compute_domain"; +import { domainIcon } from "../../../../common/entity/domain_icon"; +import "../../../../components/ha-form/ha-form"; +import type { HaFormSchema } from "../../../../components/ha-form/types"; import { ActionConfig } from "../../../../data/lovelace"; import type { HomeAssistant } from "../../../../types"; import type { ButtonCardConfig } from "../../cards/types"; import "../../components/hui-action-editor"; -import "../../../../components/ha-form/ha-form"; import type { LovelaceCardEditor } from "../../types"; import { actionConfigStruct } from "../structs/action-struct"; +import { baseLovelaceCardConfig } from "../structs/base-card-struct"; import type { EditorTarget } from "../types"; import { configElementStyle } from "./config-elements-style"; -import { baseLovelaceCardConfig } from "../structs/base-card-struct"; -import { computeDomain } from "../../../../common/entity/compute_domain"; -import { domainIcon } from "../../../../common/entity/domain_icon"; -import type { HaFormSchema } from "../../../../components/ha-form/types"; const cardConfigStruct = assign( baseLovelaceCardConfig, @@ -149,38 +149,36 @@ export class HuiButtonCardEditor @value-changed=${this._valueChanged} >
-
- - -
+ +
`; } diff --git a/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts index 0df731ba97..a5e156c42d 100644 --- a/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts @@ -1,6 +1,6 @@ import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { assert, object, optional, string, assign } from "superstruct"; +import { assert, assign, object, optional, string } from "superstruct"; import { fireEvent } from "../../../../common/dom/fire_event"; import { ActionConfig } from "../../../../data/lovelace"; import { HomeAssistant } from "../../../../types"; @@ -9,9 +9,9 @@ import "../../components/hui-action-editor"; import "../../components/hui-theme-select-editor"; import { LovelaceCardEditor } from "../../types"; import { actionConfigStruct } from "../structs/action-struct"; +import { baseLovelaceCardConfig } from "../structs/base-card-struct"; import { EditorTarget } from "../types"; import { configElementStyle } from "./config-elements-style"; -import { baseLovelaceCardConfig } from "../structs/base-card-struct"; const cardConfigStruct = assign( baseLovelaceCardConfig, @@ -78,32 +78,30 @@ export class HuiPictureCardEditor .configValue=${"theme"} @value-changed=${this._valueChanged} > -
- - -
+ + `; } diff --git a/src/panels/lovelace/editor/config-elements/hui-picture-entity-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-picture-entity-card-editor.ts index 0d315cc889..ea529a2a35 100644 --- a/src/panels/lovelace/editor/config-elements/hui-picture-entity-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-picture-entity-card-editor.ts @@ -108,32 +108,30 @@ export class HuiPictureEntityCardEditor @value-changed=${this._valueChanged} >
-
- - -
+ +
`; } diff --git a/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts index adcd07a57e..d6c6e1a0ac 100644 --- a/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts @@ -1,13 +1,13 @@ -import "../../components/hui-action-editor"; -import "../../../../components/ha-form/ha-form"; import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import { array, assert, assign, object, optional, string } from "superstruct"; import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-form/ha-form"; import type { HaFormSchema } from "../../../../components/ha-form/types"; import type { ActionConfig } from "../../../../data/lovelace"; import type { HomeAssistant } from "../../../../types"; import type { PictureGlanceCardConfig } from "../../cards/types"; +import "../../components/hui-action-editor"; import "../../components/hui-entity-editor"; import type { EntityConfig } from "../../entity-rows/types"; import type { LovelaceCardEditor } from "../../types"; @@ -96,28 +96,26 @@ export class HuiPictureGlanceCardEditor @value-changed=${this._valueChanged} >
-
- - -
+ +