From 4cba799c6a68cac4d3cb5500cadee91c55cd6552 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 10:08:06 +0000 Subject: [PATCH] Modernize vacuum more-info dialog to match new design Rewrite the vacuum more-info dialog to use the modern design patterns already used by cover, light, fan, lock, and other entity types. - Add vacuum to DOMAINS_WITH_NEW_MORE_INFO - Use ha-more-info-state-header with battery level in state display - Create custom SVG vacuum illustration component with state-specific CSS animations (cleaning rotation, returning bob, docked glow, paused breathing, error shake) - Use ha-control-button-group for primary commands (start/pause, stop, return home) with proper disabled states - Add secondary outlined buttons for locate, clean spot, and clean rooms - Use ha-control-select-menu for fan speed in select container - Add dedicated child view for room/segment cleaning (CLEAN_AREA feature) - Support legacy start_pause service for old vacuum entities - Update gallery demo with comprehensive vacuum entity examples - Respect prefers-reduced-motion for all animations https://claude.ai/code/session_0127L1LmZzts45jmvBXKTnYF --- gallery/src/pages/more-info/vacuum.ts | 87 ++- .../ha-more-info-view-vacuum-clean-rooms.ts | 301 +++++++++ .../vacuum/show-view-vacuum-clean-rooms.ts | 18 + src/dialogs/more-info/const.ts | 1 + .../more-info/controls/more-info-vacuum.ts | 601 +++++++++++------- .../vacuum/ha-state-control-vacuum-status.ts | 529 +++++++++++++++ src/translations/en.json | 5 +- 7 files changed, 1301 insertions(+), 241 deletions(-) create mode 100644 src/dialogs/more-info/components/vacuum/ha-more-info-view-vacuum-clean-rooms.ts create mode 100644 src/dialogs/more-info/components/vacuum/show-view-vacuum-clean-rooms.ts create mode 100644 src/state-control/vacuum/ha-state-control-vacuum-status.ts diff --git a/gallery/src/pages/more-info/vacuum.ts b/gallery/src/pages/more-info/vacuum.ts index 7f680f4dce..4b2dffbd1a 100644 --- a/gallery/src/pages/more-info/vacuum.ts +++ b/gallery/src/pages/more-info/vacuum.ts @@ -8,18 +8,101 @@ import { provideHass } from "../../../../src/fake_data/provide_hass"; import "../../components/demo-more-infos"; import { VacuumEntityFeature } from "../../../../src/data/vacuum"; +const ALL_FEATURES = + VacuumEntityFeature.STATE + + VacuumEntityFeature.START + + VacuumEntityFeature.PAUSE + + VacuumEntityFeature.STOP + + VacuumEntityFeature.RETURN_HOME + + VacuumEntityFeature.FAN_SPEED + + VacuumEntityFeature.BATTERY + + VacuumEntityFeature.STATUS + + VacuumEntityFeature.LOCATE + + VacuumEntityFeature.CLEAN_SPOT + + VacuumEntityFeature.CLEAN_AREA; + const ENTITIES = [ { - entity_id: "vacuum.first_floor_vacuum", + entity_id: "vacuum.full_featured", state: "docked", attributes: { - friendly_name: "First floor vacuum", + friendly_name: "Full featured vacuum", + supported_features: ALL_FEATURES, + battery_level: 85, + battery_icon: "mdi:battery-80", + fan_speed: "balanced", + fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"], + status: "Charged", + }, + }, + { + entity_id: "vacuum.cleaning_vacuum", + state: "cleaning", + attributes: { + friendly_name: "Cleaning vacuum", + supported_features: ALL_FEATURES, + battery_level: 62, + battery_icon: "mdi:battery-60", + fan_speed: "turbo", + fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"], + status: "Cleaning bedroom", + }, + }, + { + entity_id: "vacuum.returning_vacuum", + state: "returning", + attributes: { + friendly_name: "Returning vacuum", + supported_features: + VacuumEntityFeature.STATE + + VacuumEntityFeature.START + + VacuumEntityFeature.PAUSE + + VacuumEntityFeature.STOP + + VacuumEntityFeature.RETURN_HOME + + VacuumEntityFeature.BATTERY, + battery_level: 23, + battery_icon: "mdi:battery-20", + status: "Returning to dock", + }, + }, + { + entity_id: "vacuum.error_vacuum", + state: "error", + attributes: { + friendly_name: "Error vacuum", + supported_features: + VacuumEntityFeature.STATE + + VacuumEntityFeature.START + + VacuumEntityFeature.STOP + + VacuumEntityFeature.RETURN_HOME + + VacuumEntityFeature.LOCATE, + status: "Stuck on obstacle", + }, + }, + { + entity_id: "vacuum.basic_vacuum", + state: "docked", + attributes: { + friendly_name: "Basic vacuum", supported_features: VacuumEntityFeature.START + VacuumEntityFeature.STOP + VacuumEntityFeature.RETURN_HOME, }, }, + { + entity_id: "vacuum.paused_vacuum", + state: "paused", + attributes: { + friendly_name: "Paused vacuum", + supported_features: ALL_FEATURES, + battery_level: 45, + battery_icon: "mdi:battery-40", + fan_speed: "standard", + fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"], + status: "Paused", + }, + }, ]; @customElement("demo-more-info-vacuum") diff --git a/src/dialogs/more-info/components/vacuum/ha-more-info-view-vacuum-clean-rooms.ts b/src/dialogs/more-info/components/vacuum/ha-more-info-view-vacuum-clean-rooms.ts new file mode 100644 index 0000000000..d7f0115f99 --- /dev/null +++ b/src/dialogs/more-info/components/vacuum/ha-more-info-view-vacuum-clean-rooms.ts @@ -0,0 +1,301 @@ +import type { CSSResultGroup } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../../../components/ha-alert"; +import "../../../../components/ha-button"; +import "../../../../components/ha-check-list-item"; +import "../../../../components/ha-spinner"; +import type { Segment } from "../../../../data/vacuum"; +import { getVacuumSegments } from "../../../../data/vacuum"; +import { + getExtendedEntityRegistryEntry, + type ExtEntityRegistryEntry, +} from "../../../../data/entity/entity_registry"; +import type { HomeAssistant } from "../../../../types"; + +interface DisplaySegment extends Segment { + areaName?: string; +} + +@customElement("ha-more-info-view-vacuum-clean-rooms") +export class HaMoreInfoViewVacuumCleanRooms extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public params!: { entityId: string }; + + @state() private _segments?: DisplaySegment[]; + + @state() private _selectedSegmentIds = new Set(); + + @state() private _loading = true; + + @state() private _error?: string; + + @state() private _submitting = false; + + protected firstUpdated() { + this._loadSegments(); + } + + private async _loadSegments() { + if (!this.params.entityId) return; + this._loading = true; + this._error = undefined; + + try { + const [segmentsResult, entry] = await Promise.all([ + getVacuumSegments(this.hass, this.params.entityId), + getExtendedEntityRegistryEntry(this.hass, this.params.entityId).catch( + () => undefined as ExtEntityRegistryEntry | undefined + ), + ]); + + const areaMapping = entry?.options?.vacuum?.area_mapping || {}; + + // Build reverse mapping: segment ID -> area ID + const segmentToArea: Record = {}; + for (const [areaId, segmentIds] of Object.entries(areaMapping)) { + for (const segId of segmentIds) { + segmentToArea[segId] = areaId; + } + } + + this._segments = segmentsResult.segments.map((seg) => { + const areaId = segmentToArea[seg.id]; + const area = areaId ? this.hass.areas[areaId] : undefined; + return { + ...seg, + areaName: area?.name, + }; + }); + } catch (err: any) { + this._error = err.message || "Failed to load rooms"; + } finally { + this._loading = false; + } + } + + private _toggleSegment(ev: Event) { + const segmentId = (ev.currentTarget as HTMLElement).dataset.segmentId!; + const selected = new Set(this._selectedSegmentIds); + if (selected.has(segmentId)) { + selected.delete(segmentId); + } else { + selected.add(segmentId); + } + this._selectedSegmentIds = selected; + } + + private _selectAll() { + if (!this._segments) return; + this._selectedSegmentIds = new Set(this._segments.map((s) => s.id)); + } + + private _deselectAll() { + this._selectedSegmentIds = new Set(); + } + + private async _startCleaning() { + if (!this.params.entityId || this._selectedSegmentIds.size === 0) return; + this._submitting = true; + + try { + await this.hass.callService("vacuum", "clean_area", { + entity_id: this.params.entityId, + area: [...this._selectedSegmentIds], + }); + } catch (err: any) { + this._error = err.message || "Failed to start cleaning"; + } finally { + this._submitting = false; + } + } + + private _groupSegments( + segments: DisplaySegment[] + ): Map { + const groups = new Map(); + for (const seg of segments) { + const group = seg.group || ""; + if (!groups.has(group)) { + groups.set(group, []); + } + groups.get(group)!.push(seg); + } + return groups; + } + + protected render() { + if (this._loading) { + return html` +
+ +
+ `; + } + + if (this._error) { + return html` +
+ ${this._error} +
+ `; + } + + if (!this._segments || this._segments.length === 0) { + return html` +
+

+ ${this.hass.localize( + "ui.dialogs.more_info_control.vacuum.no_rooms_available" + )} +

+
+ `; + } + + const allSelected = this._selectedSegmentIds.size === this._segments.length; + const groups = this._groupSegments(this._segments); + + return html` +
+
+ + ${allSelected + ? this.hass.localize( + "ui.components.subpage-data-table.select_none" + ) + : this.hass.localize( + "ui.components.subpage-data-table.select_all" + )} + +
+ +
+ ${[...groups.entries()].map(([groupName, segments]) => { + const showGroupHeader = groupName && groups.size > 1; + return html` + ${showGroupHeader + ? html`
${groupName}
` + : nothing} + ${segments.map( + (segment) => html` + + + ${segment.areaName || segment.name} + + ${segment.areaName && segment.areaName !== segment.name + ? html`${segment.name}` + : nothing} + + ` + )} + `; + })} +
+
+ + + `; + } + + static styles: CSSResultGroup = css` + :host { + display: flex; + flex-direction: column; + height: 100%; + } + + .center { + display: flex; + align-items: center; + justify-content: center; + padding: var(--ha-space-8); + } + + .content { + flex: 1; + overflow-y: auto; + } + + .empty { + text-align: center; + color: var(--secondary-text-color); + padding: var(--ha-space-8); + } + + .select-actions { + display: flex; + justify-content: flex-end; + padding: var(--ha-space-2) var(--ha-space-4); + } + + .segments-list { + padding: 0 var(--ha-space-2); + } + + .group-header { + font-weight: var(--ha-font-weight-medium); + font-size: var(--ha-font-size-s); + color: var(--secondary-text-color); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-1); + } + + ha-check-list-item { + --mdc-theme-secondary: var(--primary-color); + } + + .segment-name { + font-size: var(--ha-font-size-m); + } + + .segment-original { + font-size: var(--ha-font-size-s); + color: var(--secondary-text-color); + margin-inline-start: var(--ha-space-2); + } + + .footer { + display: flex; + justify-content: flex-end; + padding: var(--ha-space-4); + border-top: 1px solid var(--divider-color); + background: var( + --ha-dialog-surface-background, + var(--mdc-theme-surface, #fff) + ); + position: sticky; + bottom: 0; + z-index: 10; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-more-info-view-vacuum-clean-rooms": HaMoreInfoViewVacuumCleanRooms; + } +} diff --git a/src/dialogs/more-info/components/vacuum/show-view-vacuum-clean-rooms.ts b/src/dialogs/more-info/components/vacuum/show-view-vacuum-clean-rooms.ts new file mode 100644 index 0000000000..10c1cb7f88 --- /dev/null +++ b/src/dialogs/more-info/components/vacuum/show-view-vacuum-clean-rooms.ts @@ -0,0 +1,18 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; +import type { LocalizeFunc } from "../../../../common/translations/localize"; + +export const loadVacuumCleanRoomsView = () => + import("./ha-more-info-view-vacuum-clean-rooms"); + +export const showVacuumCleanRoomsView = ( + element: HTMLElement, + localize: LocalizeFunc, + entityId: string +): void => { + fireEvent(element, "show-child-view", { + viewTag: "ha-more-info-view-vacuum-clean-rooms", + viewImport: loadVacuumCleanRoomsView, + viewTitle: localize("ui.dialogs.more_info_control.vacuum.clean_rooms"), + viewParams: { entityId }, + }); +}; diff --git a/src/dialogs/more-info/const.ts b/src/dialogs/more-info/const.ts index 8c6a6ea094..d7d6af231c 100644 --- a/src/dialogs/more-info/const.ts +++ b/src/dialogs/more-info/const.ts @@ -47,6 +47,7 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [ "siren", "script", "switch", + "vacuum", "valve", "water_heater", "weather", diff --git a/src/dialogs/more-info/controls/more-info-vacuum.ts b/src/dialogs/more-info/controls/more-info-vacuum.ts index 33efc4745a..0413b41e49 100644 --- a/src/dialogs/more-info/controls/more-info-vacuum.ts +++ b/src/dialogs/more-info/controls/more-info-vacuum.ts @@ -7,93 +7,39 @@ import { mdiPlayPause, mdiStop, mdiTargetVariant, + mdiViewDashboardVariant, } from "@mdi/js"; +import type { CSSResultGroup } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import memoizeOne from "memoize-one"; import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { supportsFeature } from "../../../common/entity/supports-feature"; +import "../../../components/ha-control-button"; +import "../../../components/ha-control-button-group"; +import "../../../components/ha-control-select-menu"; +import "../../../components/ha-outlined-icon-button"; +import "../../../components/ha-svg-icon"; import "../../../components/entity/ha-battery-icon"; -import type { HaSelectSelectEvent } from "../../../components/ha-select"; -import "../../../components/ha-icon"; -import "../../../components/ha-icon-button"; -import "../../../components/ha-select"; import { UNAVAILABLE } from "../../../data/entity/entity"; import type { EntityRegistryDisplayEntry } from "../../../data/entity/entity_registry"; -import { - findBatteryChargingEntity, - findBatteryEntity, -} from "../../../data/entity/entity_registry"; +import { findBatteryEntity } from "../../../data/entity/entity_registry"; import type { VacuumEntity } from "../../../data/vacuum"; -import { VacuumEntityFeature } from "../../../data/vacuum"; +import { + VacuumEntityFeature, + canReturnHome, + canStart, + canStop, + isCleaning, +} from "../../../data/vacuum"; +import { forwardHaptic } from "../../../data/haptics"; +import "../../../state-control/vacuum/ha-state-control-vacuum-status"; import type { HomeAssistant } from "../../../types"; - -interface VacuumCommand { - translationKey: string; - icon: string; - serviceName: string; - isVisible: (stateObj: VacuumEntity) => boolean; -} - -const VACUUM_COMMANDS: VacuumCommand[] = [ - { - translationKey: "start", - icon: mdiPlay, - serviceName: "start", - isVisible: (stateObj) => - supportsFeature(stateObj, VacuumEntityFeature.START), - }, - { - translationKey: "pause", - icon: mdiPause, - serviceName: "pause", - isVisible: (stateObj) => - // We need also to check if Start is supported because if not we show start-pause - // Start-pause service is only available for old vacuum entities, new entities have the `STATE` feature - supportsFeature(stateObj, VacuumEntityFeature.PAUSE) && - (supportsFeature(stateObj, VacuumEntityFeature.STATE) || - supportsFeature(stateObj, VacuumEntityFeature.START)), - }, - { - translationKey: "start_pause", - icon: mdiPlayPause, - serviceName: "start_pause", - isVisible: (stateObj) => - // If start is supported, we don't show this button - // This service is only available for old vacuum entities, new entities have the `STATE` feature - !supportsFeature(stateObj, VacuumEntityFeature.STATE) && - !supportsFeature(stateObj, VacuumEntityFeature.START) && - supportsFeature(stateObj, VacuumEntityFeature.PAUSE), - }, - { - translationKey: "stop", - icon: mdiStop, - serviceName: "stop", - isVisible: (stateObj) => - supportsFeature(stateObj, VacuumEntityFeature.STOP), - }, - { - translationKey: "clean_spot", - icon: mdiTargetVariant, - serviceName: "clean_spot", - isVisible: (stateObj) => - supportsFeature(stateObj, VacuumEntityFeature.CLEAN_SPOT), - }, - { - translationKey: "locate", - icon: mdiMapMarker, - serviceName: "locate", - isVisible: (stateObj) => - supportsFeature(stateObj, VacuumEntityFeature.LOCATE), - }, - { - translationKey: "return_home", - icon: mdiHomeImportOutline, - serviceName: "return_to_base", - isVisible: (stateObj) => - supportsFeature(stateObj, VacuumEntityFeature.RETURN_HOME), - }, -]; +import "../components/ha-more-info-control-select-container"; +import "../components/ha-more-info-state-header"; +import { moreInfoControlStyle } from "../components/more-info-control-style"; +import { showVacuumCleanRoomsView } from "../components/vacuum/show-view-vacuum-clean-rooms"; +import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown"; @customElement("more-info-vacuum") class MoreInfoVacuum extends LitElement { @@ -101,107 +47,6 @@ class MoreInfoVacuum extends LitElement { @property({ attribute: false }) public stateObj?: VacuumEntity; - protected render() { - if (!this.hass || !this.stateObj) { - return nothing; - } - - const stateObj = this.stateObj; - - return html` - ${stateObj.state !== UNAVAILABLE - ? html`
-
- ${this.hass!.localize( - "ui.dialogs.more_info_control.vacuum.status" - )}: - - - - ${supportsFeature(stateObj, VacuumEntityFeature.STATUS) && - stateObj.attributes.status - ? this.hass.formatEntityAttributeValue(stateObj, "status") - : this.hass.formatEntityState(stateObj)} - - -
- ${this._renderBattery()} -
` - : ""} - ${VACUUM_COMMANDS.some((item) => item.isVisible(stateObj)) - ? html` -
-

-
- ${this.hass!.localize( - "ui.dialogs.more_info_control.vacuum.commands" - )} -
-
- ${VACUUM_COMMANDS.filter((item) => - item.isVisible(stateObj) - ).map( - (item) => html` -
- -
- ` - )} -
-
- ` - : ""} - ${supportsFeature(stateObj, VacuumEntityFeature.FAN_SPEED) - ? html` -
-
- ({ - value: mode, - label: this.hass!.formatEntityAttributeValue( - stateObj, - "fan_speed", - mode - ), - }) - )} - > - -
- - - ${this.hass.formatEntityAttributeValue( - stateObj, - "fan_speed" - )} - -
-
-

-
- ` - : ""} - `; - } - private _deviceEntities = memoizeOne( ( deviceId: string, @@ -212,11 +57,32 @@ class MoreInfoVacuum extends LitElement { } ); - private _renderBattery() { - const stateObj = this.stateObj!; + private get _stateOverride(): string | undefined { + if (!this.stateObj || !this.hass) { + return undefined; + } - const deviceId = this.hass.entities[stateObj.entity_id]?.device_id; + const stateDisplay = supportsFeature( + this.stateObj, + VacuumEntityFeature.STATUS + ) + ? this.hass.formatEntityAttributeValue(this.stateObj, "status") || + this.hass.formatEntityState(this.stateObj) + : this.hass.formatEntityState(this.stateObj); + const batteryText = this._getBatteryText(); + if (batteryText) { + return `${stateDisplay} ยท ${batteryText}`; + } + return stateDisplay; + } + + private _getBatteryText(): string | undefined { + if (!this.stateObj || !this.hass) { + return undefined; + } + + const deviceId = this.hass.entities[this.stateObj.entity_id]?.device_id; const entities = deviceId ? this._deviceEntities(deviceId, this.hass.entities) : []; @@ -232,90 +98,349 @@ class MoreInfoVacuum extends LitElement { battery && (batteryDomain === "binary_sensor" || !isNaN(battery.state as any)) ) { - const batteryChargingEntity = findBatteryChargingEntity( - this.hass, - entities - ); - const batteryCharging = batteryChargingEntity - ? this.hass.states[batteryChargingEntity?.entity_id] - : undefined; - - return html` -
- - ${batteryDomain === "sensor" - ? this.hass.formatEntityState(battery) - : nothing} - - -
- `; + if (batteryDomain === "sensor") { + return this.hass.formatEntityState(battery); + } + return undefined; } - // Use battery_level and battery_icon deprecated attributes + // Use deprecated battery_level attribute if ( - supportsFeature(stateObj, VacuumEntityFeature.BATTERY) && - stateObj.attributes.battery_level + supportsFeature(this.stateObj, VacuumEntityFeature.BATTERY) && + this.stateObj.attributes.battery_level ) { - return html` -
- - ${this.hass.formatEntityAttributeValue( - stateObj, - "battery_level", - Math.round(stateObj.attributes.battery_level) - )} - - - -
- `; + return this.hass.formatEntityAttributeValue( + this.stateObj, + "battery_level", + Math.round(this.stateObj.attributes.battery_level) + ); } - return nothing; + return undefined; } - private _callService(ev: CustomEvent) { - const entry = (ev.target! as any).entry as VacuumCommand; - this.hass.callService("vacuum", entry.serviceName, { + private _callVacuumService(service: string) { + forwardHaptic(this, "light"); + this.hass.callService("vacuum", service, { entity_id: this.stateObj!.entity_id, }); } - private _handleFanSpeedChanged(ev: HaSelectSelectEvent) { - const oldVal = this.stateObj!.attributes.fan_speed; - const newVal = ev.detail.value; + private _handleStartPause() { + const stateObj = this.stateObj!; - if (!newVal || oldVal === newVal) { + // Legacy start_pause for old vacuum entities without STATE feature + if ( + !supportsFeature(stateObj, VacuumEntityFeature.STATE) && + !supportsFeature(stateObj, VacuumEntityFeature.START) && + supportsFeature(stateObj, VacuumEntityFeature.PAUSE) + ) { + this._callVacuumService("start_pause"); return; } + if (isCleaning(stateObj)) { + this._callVacuumService("pause"); + } else { + this._callVacuumService("start"); + } + } + + private _handleStop() { + this._callVacuumService("stop"); + } + + private _handleReturnHome() { + this._callVacuumService("return_to_base"); + } + + private _handleLocate() { + this._callVacuumService("locate"); + } + + private _handleCleanSpot() { + this._callVacuumService("clean_spot"); + } + + private _handleCleanRooms() { + showVacuumCleanRoomsView( + this, + this.hass.localize, + this.stateObj!.entity_id + ); + } + + private _handleFanSpeedChanged(ev: HaDropdownSelectEvent) { + const newVal = ev.detail.item.value; + const oldVal = this.stateObj!.attributes.fan_speed; + + if (!newVal || oldVal === newVal) return; + this.hass.callService("vacuum", "set_fan_speed", { entity_id: this.stateObj!.entity_id, fan_speed: newVal, }); } - static styles = css` - :host { - line-height: var(--ha-line-height-normal); + private get _supportsStartPause(): boolean { + if (!this.stateObj) return false; + return ( + supportsFeature(this.stateObj, VacuumEntityFeature.START) || + supportsFeature(this.stateObj, VacuumEntityFeature.PAUSE) + ); + } + + private get _startPauseIcon(): string { + if (!this.stateObj) return mdiPlay; + + // Legacy mode + if ( + !supportsFeature(this.stateObj, VacuumEntityFeature.STATE) && + !supportsFeature(this.stateObj, VacuumEntityFeature.START) + ) { + return mdiPlayPause; } - .status-subtitle { - color: var(--secondary-text-color); + + return isCleaning(this.stateObj) && + supportsFeature(this.stateObj, VacuumEntityFeature.PAUSE) + ? mdiPause + : mdiPlay; + } + + private get _startPauseLabel(): string { + if (!this.stateObj || !this.hass) return ""; + + // Legacy mode + if ( + !supportsFeature(this.stateObj, VacuumEntityFeature.STATE) && + !supportsFeature(this.stateObj, VacuumEntityFeature.START) + ) { + return this.hass.localize( + "ui.dialogs.more_info_control.vacuum.start_pause" + ); } - .flex-horizontal { - display: flex; - flex-direction: row; - justify-content: space-between; + + return isCleaning(this.stateObj) && + supportsFeature(this.stateObj, VacuumEntityFeature.PAUSE) + ? this.hass.localize("ui.dialogs.more_info_control.vacuum.pause") + : this.hass.localize("ui.dialogs.more_info_control.vacuum.start"); + } + + private get _startPauseDisabled(): boolean { + if (!this.stateObj) return true; + if (this.stateObj.state === UNAVAILABLE) return true; + + // Legacy mode - never disabled + if ( + !supportsFeature(this.stateObj, VacuumEntityFeature.STATE) && + !supportsFeature(this.stateObj, VacuumEntityFeature.START) + ) { + return false; } - .space-around { - justify-content: space-around; + + // If cleaning, pause is always available + if (isCleaning(this.stateObj)) return false; + + return !canStart(this.stateObj); + } + + protected render() { + if (!this.hass || !this.stateObj) { + return nothing; } - `; + + const stateObj = this.stateObj; + const isUnavailable = stateObj.state === UNAVAILABLE; + + const supportsStop = supportsFeature(stateObj, VacuumEntityFeature.STOP); + const supportsReturnHome = supportsFeature( + stateObj, + VacuumEntityFeature.RETURN_HOME + ); + const supportsLocate = supportsFeature( + stateObj, + VacuumEntityFeature.LOCATE + ); + const supportsCleanSpot = supportsFeature( + stateObj, + VacuumEntityFeature.CLEAN_SPOT + ); + const supportsCleanArea = supportsFeature( + stateObj, + VacuumEntityFeature.CLEAN_AREA + ); + const supportsFanSpeed = supportsFeature( + stateObj, + VacuumEntityFeature.FAN_SPEED + ); + + const hasSecondaryControls = + supportsLocate || supportsCleanSpot || supportsCleanArea; + + return html` + + +
+ + + ${this._supportsStartPause || supportsStop || supportsReturnHome + ? html` +
+ + ${this._supportsStartPause + ? html` + + + + ` + : nothing} + ${supportsStop + ? html` + + + + ` + : nothing} + ${supportsReturnHome + ? html` + + + + ` + : nothing} + +
+ ` + : nothing} + ${hasSecondaryControls + ? html` +
+ ${supportsLocate + ? html` + + + + ` + : nothing} + ${supportsCleanSpot + ? html` + + + + ` + : nothing} + ${supportsCleanArea + ? html` + + + + ` + : nothing} +
+ ` + : nothing} +
+ + ${supportsFanSpeed && stateObj.attributes.fan_speed_list + ? html` + + ({ + value: mode, + label: this.hass.formatEntityAttributeValue( + stateObj, + "fan_speed", + mode + ), + }))} + > + + + + ` + : nothing} + `; + } + + static get styles(): CSSResultGroup { + return [ + moreInfoControlStyle, + css` + ha-state-control-vacuum-status { + margin-bottom: var(--ha-space-4); + } + + ha-control-button-group { + --control-button-group-thickness: 60px; + width: 100%; + max-width: 400px; + } + + .secondary-buttons { + display: flex; + align-items: center; + justify-content: center; + gap: var(--ha-space-3); + margin-top: var(--ha-space-3); + } + `, + ]; + } } declare global { diff --git a/src/state-control/vacuum/ha-state-control-vacuum-status.ts b/src/state-control/vacuum/ha-state-control-vacuum-status.ts new file mode 100644 index 0000000000..9d96023db0 --- /dev/null +++ b/src/state-control/vacuum/ha-state-control-vacuum-status.ts @@ -0,0 +1,529 @@ +import type { CSSResultGroup, TemplateResult } from "lit"; +import { css, html, LitElement, svg } from "lit"; +import { customElement, property } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { styleMap } from "lit/directives/style-map"; +import { stateColorCss } from "../../common/entity/state_color"; +import { UNAVAILABLE } from "../../data/entity/entity"; +import type { VacuumEntity } from "../../data/vacuum"; +import { isCleaning } from "../../data/vacuum"; +import type { HomeAssistant } from "../../types"; + +type VacuumVisualState = + | "cleaning" + | "docked" + | "returning" + | "paused" + | "error" + | "idle"; + +const computeVisualState = (stateObj: VacuumEntity): VacuumVisualState => { + if (stateObj.state === UNAVAILABLE) { + return "idle"; + } + if (stateObj.state === "error") { + return "error"; + } + if (isCleaning(stateObj)) { + return "cleaning"; + } + if (stateObj.state === "returning") { + return "returning"; + } + if (stateObj.state === "paused") { + return "paused"; + } + if (stateObj.state === "docked") { + return "docked"; + } + return "idle"; +}; + +@customElement("ha-state-control-vacuum-status") +export class HaStateControlVacuumStatus extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public stateObj!: VacuumEntity; + + protected render(): TemplateResult { + const visualState = computeVisualState(this.stateObj); + const color = stateColorCss(this.stateObj); + + const style = { + "--vacuum-color": color || "var(--state-inactive-color)", + }; + + return html` +
+ + + + + + + + + + + + + + ${svg` + + `} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ! + + + +
+ `; + } + + static styles: CSSResultGroup = css` + :host { + display: flex; + align-items: center; + justify-content: center; + } + + .container { + width: 200px; + height: 200px; + position: relative; + } + + .vacuum-svg { + width: 100%; + height: 100%; + } + + /* -- Hide state-specific elements by default -- */ + .particles, + .dock-indicator, + .return-path, + .error-indicator { + opacity: 0; + transition: opacity 300ms ease; + } + + /* -- CLEANING state -- */ + .cleaning .vacuum-body { + animation: vacuum-rotate 8s linear infinite; + transform-origin: 100px 100px; + } + + .cleaning .brush-left { + animation: brush-spin-left 0.6s linear infinite; + transform-origin: 52px 108px; + } + + .cleaning .brush-right { + animation: brush-spin-right 0.6s linear infinite; + transform-origin: 148px 108px; + } + + .cleaning .particles { + opacity: 1; + animation: particles-fade 2s ease-in-out infinite; + } + + .cleaning .sensor { + animation: sensor-blink 1.5s ease-in-out infinite; + } + + /* -- RETURNING state -- */ + .returning .vacuum-body { + animation: vacuum-bob 2s ease-in-out infinite; + transform-origin: 100px 100px; + } + + .returning .return-path { + opacity: 1; + animation: path-dash 1.5s linear infinite; + } + + /* -- DOCKED state -- */ + .docked .dock-indicator { + opacity: 1; + } + + .docked .glow { + animation: docked-glow 3s ease-in-out infinite; + } + + .docked .vacuum-body { + opacity: 0.8; + } + + /* -- CHARGING (docked) state with glow -- */ + .docked .center-display { + animation: charge-pulse 2s ease-in-out infinite; + } + + /* -- PAUSED state -- */ + .paused .vacuum-body { + animation: paused-breathe 3s ease-in-out infinite; + transform-origin: 100px 100px; + } + + .paused .center-button { + animation: paused-blink 3s ease-in-out infinite; + } + + /* -- ERROR state -- */ + .error .vacuum-body { + animation: error-shake 0.5s ease-in-out infinite; + transform-origin: 100px 100px; + } + + .error .error-indicator { + opacity: 1; + } + + .error .center-button, + .error .center-display { + opacity: 0; + } + + /* -- IDLE state -- */ + .idle .vacuum-body { + opacity: 0.7; + } + + /* ---- Keyframe Animations ---- */ + + @keyframes vacuum-rotate { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + + @keyframes brush-spin-left { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(-360deg); + } + } + + @keyframes brush-spin-right { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + + @keyframes particles-fade { + 0%, + 100% { + opacity: 0.3; + } + 50% { + opacity: 0.8; + } + } + + @keyframes sensor-blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } + } + + @keyframes vacuum-bob { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-4px); + } + } + + @keyframes path-dash { + to { + stroke-dashoffset: -14; + } + } + + @keyframes docked-glow { + 0%, + 100% { + opacity: 0.1; + } + 50% { + opacity: 0.2; + } + } + + @keyframes charge-pulse { + 0%, + 100% { + opacity: 0.15; + r: 16; + } + 50% { + opacity: 0.35; + r: 18; + } + } + + @keyframes paused-breathe { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(0.97); + } + } + + @keyframes paused-blink { + 0%, + 40%, + 100% { + opacity: 0.4; + } + 20% { + opacity: 0.1; + } + } + + @keyframes error-shake { + 0%, + 100% { + transform: translateX(0); + } + 20% { + transform: translateX(-3px); + } + 40% { + transform: translateX(3px); + } + 60% { + transform: translateX(-2px); + } + 80% { + transform: translateX(2px); + } + } + + /* Respect prefers-reduced-motion */ + @media (prefers-reduced-motion: reduce) { + .cleaning .vacuum-body, + .cleaning .brush-left, + .cleaning .brush-right, + .cleaning .particles, + .cleaning .sensor, + .returning .vacuum-body, + .returning .return-path, + .docked .glow, + .docked .center-display, + .paused .vacuum-body, + .paused .center-button, + .error .vacuum-body { + animation: none; + } + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-state-control-vacuum-status": HaStateControlVacuumStatus; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index 2993ae671c..8f6fdc7ad6 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1659,7 +1659,10 @@ "clean_spot": "Clean spot", "locate": "Locate", "return_home": "Return home", - "start_pause": "Start/pause" + "start_pause": "Start/pause", + "clean_rooms": "Clean rooms", + "start_cleaning_rooms": "Start cleaning", + "no_rooms_available": "No rooms available" }, "person": { "create_zone": "Create zone from current location"