1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-02 00:27:49 +01:00

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
This commit is contained in:
Claude
2026-04-01 10:08:06 +00:00
parent b8d08ccb05
commit 4cba799c6a
7 changed files with 1301 additions and 241 deletions

View File

@@ -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")

View File

@@ -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<string>();
@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<string, string> = {};
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<string, DisplaySegment[]> {
const groups = new Map<string, DisplaySegment[]>();
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`
<div class="center">
<ha-spinner active></ha-spinner>
</div>
`;
}
if (this._error) {
return html`
<div class="content">
<ha-alert alert-type="error">${this._error}</ha-alert>
</div>
`;
}
if (!this._segments || this._segments.length === 0) {
return html`
<div class="content">
<p class="empty">
${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.no_rooms_available"
)}
</p>
</div>
`;
}
const allSelected = this._selectedSegmentIds.size === this._segments.length;
const groups = this._groupSegments(this._segments);
return html`
<div class="content">
<div class="select-actions">
<ha-button
@click=${allSelected ? this._deselectAll : this._selectAll}
>
${allSelected
? this.hass.localize(
"ui.components.subpage-data-table.select_none"
)
: this.hass.localize(
"ui.components.subpage-data-table.select_all"
)}
</ha-button>
</div>
<div class="segments-list">
${[...groups.entries()].map(([groupName, segments]) => {
const showGroupHeader = groupName && groups.size > 1;
return html`
${showGroupHeader
? html`<div class="group-header">${groupName}</div>`
: nothing}
${segments.map(
(segment) => html`
<ha-check-list-item
left
.selected=${this._selectedSegmentIds.has(segment.id)}
data-segment-id=${segment.id}
@request-selected=${this._toggleSegment}
>
<span class="segment-name">
${segment.areaName || segment.name}
</span>
${segment.areaName && segment.areaName !== segment.name
? html`<span class="segment-original"
>${segment.name}</span
>`
: nothing}
</ha-check-list-item>
`
)}
`;
})}
</div>
</div>
<div class="footer">
<ha-button
@click=${this._startCleaning}
.disabled=${this._selectedSegmentIds.size === 0 || this._submitting}
>
${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.start_cleaning_rooms"
)}
${this._selectedSegmentIds.size > 0
? ` (${this._selectedSegmentIds.size})`
: ""}
</ha-button>
</div>
`;
}
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;
}
}

View File

@@ -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 },
});
};

View File

@@ -47,6 +47,7 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [
"siren",
"script",
"switch",
"vacuum",
"valve",
"water_heater",
"weather",

View File

@@ -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` <div class="flex-horizontal">
<div>
<span class="status-subtitle"
>${this.hass!.localize(
"ui.dialogs.more_info_control.vacuum.status"
)}:
</span>
<span>
<strong>
${supportsFeature(stateObj, VacuumEntityFeature.STATUS) &&
stateObj.attributes.status
? this.hass.formatEntityAttributeValue(stateObj, "status")
: this.hass.formatEntityState(stateObj)}
</strong>
</span>
</div>
${this._renderBattery()}
</div>`
: ""}
${VACUUM_COMMANDS.some((item) => item.isVisible(stateObj))
? html`
<div>
<p></p>
<div class="status-subtitle">
${this.hass!.localize(
"ui.dialogs.more_info_control.vacuum.commands"
)}
</div>
<div class="flex-horizontal space-around">
${VACUUM_COMMANDS.filter((item) =>
item.isVisible(stateObj)
).map(
(item) => html`
<div>
<ha-icon-button
.path=${item.icon}
.entry=${item}
@click=${this._callService}
.label=${this.hass!.localize(
`ui.dialogs.more_info_control.vacuum.${item.translationKey}`
)}
.disabled=${stateObj.state === UNAVAILABLE}
></ha-icon-button>
</div>
`
)}
</div>
</div>
`
: ""}
${supportsFeature(stateObj, VacuumEntityFeature.FAN_SPEED)
? html`
<div>
<div class="flex-horizontal">
<ha-select
.label=${this.hass!.localize(
"ui.dialogs.more_info_control.vacuum.fan_speed"
)}
.disabled=${stateObj.state === UNAVAILABLE}
.value=${stateObj.attributes.fan_speed}
@selected=${this._handleFanSpeedChanged}
.options=${stateObj.attributes.fan_speed_list!.map(
(mode) => ({
value: mode,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"fan_speed",
mode
),
})
)}
>
</ha-select>
<div
style="justify-content: center; align-self: center; padding-top: 1.3em"
>
<span>
<ha-svg-icon .path=${mdiFan}></ha-svg-icon>
${this.hass.formatEntityAttributeValue(
stateObj,
"fan_speed"
)}
</span>
</div>
</div>
<p></p>
</div>
`
: ""}
`;
}
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`
<div>
<span>
${batteryDomain === "sensor"
? this.hass.formatEntityState(battery)
: nothing}
<ha-battery-icon
.hass=${this.hass}
.batteryStateObj=${battery}
.batteryChargingStateObj=${batteryCharging}
></ha-battery-icon>
</span>
</div>
`;
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`
<div>
<span>
${this.hass.formatEntityAttributeValue(
stateObj,
"battery_level",
Math.round(stateObj.attributes.battery_level)
)}
<ha-icon .icon=${stateObj.attributes.battery_icon}></ha-icon>
</span>
</div>
`;
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`
<ha-more-info-state-header
.hass=${this.hass}
.stateObj=${this.stateObj}
.stateOverride=${this._stateOverride}
></ha-more-info-state-header>
<div class="controls">
<ha-state-control-vacuum-status
.stateObj=${this.stateObj}
.hass=${this.hass}
></ha-state-control-vacuum-status>
${this._supportsStartPause || supportsStop || supportsReturnHome
? html`
<div class="buttons">
<ha-control-button-group>
${this._supportsStartPause
? html`
<ha-control-button
.label=${this._startPauseLabel}
@click=${this._handleStartPause}
.disabled=${this._startPauseDisabled}
>
<ha-svg-icon
.path=${this._startPauseIcon}
></ha-svg-icon>
</ha-control-button>
`
: nothing}
${supportsStop
? html`
<ha-control-button
.label=${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.stop"
)}
@click=${this._handleStop}
.disabled=${isUnavailable || !canStop(stateObj)}
>
<ha-svg-icon .path=${mdiStop}></ha-svg-icon>
</ha-control-button>
`
: nothing}
${supportsReturnHome
? html`
<ha-control-button
.label=${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.return_home"
)}
@click=${this._handleReturnHome}
.disabled=${isUnavailable || !canReturnHome(stateObj)}
>
<ha-svg-icon
.path=${mdiHomeImportOutline}
></ha-svg-icon>
</ha-control-button>
`
: nothing}
</ha-control-button-group>
</div>
`
: nothing}
${hasSecondaryControls
? html`
<div class="secondary-buttons">
${supportsLocate
? html`
<ha-outlined-icon-button
.label=${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.locate"
)}
@click=${this._handleLocate}
.disabled=${isUnavailable}
>
<ha-svg-icon .path=${mdiMapMarker}></ha-svg-icon>
</ha-outlined-icon-button>
`
: nothing}
${supportsCleanSpot
? html`
<ha-outlined-icon-button
.label=${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.clean_spot"
)}
@click=${this._handleCleanSpot}
.disabled=${isUnavailable}
>
<ha-svg-icon .path=${mdiTargetVariant}></ha-svg-icon>
</ha-outlined-icon-button>
`
: nothing}
${supportsCleanArea
? html`
<ha-outlined-icon-button
.label=${this.hass.localize(
"ui.dialogs.more_info_control.vacuum.clean_rooms"
)}
@click=${this._handleCleanRooms}
.disabled=${isUnavailable}
>
<ha-svg-icon
.path=${mdiViewDashboardVariant}
></ha-svg-icon>
</ha-outlined-icon-button>
`
: nothing}
</div>
`
: nothing}
</div>
${supportsFanSpeed && stateObj.attributes.fan_speed_list
? html`
<ha-more-info-control-select-container>
<ha-control-select-menu
.hass=${this.hass}
.label=${this.hass.formatEntityAttributeName(
stateObj,
"fan_speed"
)}
.value=${stateObj.attributes.fan_speed}
.disabled=${isUnavailable}
@wa-select=${this._handleFanSpeedChanged}
.options=${stateObj.attributes.fan_speed_list.map((mode) => ({
value: mode,
label: this.hass.formatEntityAttributeValue(
stateObj,
"fan_speed",
mode
),
}))}
>
<ha-svg-icon slot="icon" .path=${mdiFan}></ha-svg-icon>
</ha-control-select-menu>
</ha-more-info-control-select-container>
`
: 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 {

View File

@@ -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`
<div
class="container ${classMap({
[visualState]: true,
})}"
style=${styleMap(style)}
>
<svg
viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg"
class="vacuum-svg"
>
<!-- Background circle glow -->
<circle
cx="100"
cy="100"
r="90"
class="glow"
fill="var(--vacuum-color)"
opacity="0.1"
/>
<!-- Vacuum body -->
<g class="vacuum-body">
<!-- Main circular body -->
<circle
cx="100"
cy="100"
r="60"
fill="var(--card-background-color, #fff)"
stroke="var(--vacuum-color)"
stroke-width="3"
/>
<!-- Inner ring detail -->
<circle
cx="100"
cy="100"
r="48"
fill="none"
stroke="var(--vacuum-color)"
stroke-width="1.5"
opacity="0.3"
/>
<!-- Top bumper sensor -->
${svg`
<path
d="M 60 72 A 50 50 0 0 1 140 72"
fill="none"
stroke="var(--vacuum-color)"
stroke-width="3"
stroke-linecap="round"
class="bumper"
/>
`}
<!-- Front sensor dot -->
<circle
cx="100"
cy="50"
r="4"
fill="var(--vacuum-color)"
class="sensor"
/>
<!-- Left brush -->
<g class="brush brush-left">
<line
x1="52"
y1="108"
x2="38"
y2="98"
stroke="var(--vacuum-color)"
stroke-width="2"
stroke-linecap="round"
opacity="0.6"
/>
<line
x1="52"
y1="108"
x2="36"
y2="112"
stroke="var(--vacuum-color)"
stroke-width="2"
stroke-linecap="round"
opacity="0.6"
/>
<line
x1="52"
y1="108"
x2="42"
y2="122"
stroke="var(--vacuum-color)"
stroke-width="2"
stroke-linecap="round"
opacity="0.6"
/>
</g>
<!-- Right brush -->
<g class="brush brush-right">
<line
x1="148"
y1="108"
x2="162"
y2="98"
stroke="var(--vacuum-color)"
stroke-width="2"
stroke-linecap="round"
opacity="0.6"
/>
<line
x1="148"
y1="108"
x2="164"
y2="112"
stroke="var(--vacuum-color)"
stroke-width="2"
stroke-linecap="round"
opacity="0.6"
/>
<line
x1="148"
y1="108"
x2="158"
y2="122"
stroke="var(--vacuum-color)"
stroke-width="2"
stroke-linecap="round"
opacity="0.6"
/>
</g>
<!-- Center button/display -->
<circle
cx="100"
cy="100"
r="16"
fill="var(--vacuum-color)"
opacity="0.15"
class="center-display"
/>
<circle
cx="100"
cy="100"
r="8"
fill="var(--vacuum-color)"
opacity="0.4"
class="center-button"
/>
</g>
<!-- Dust particles (only visible when cleaning) -->
<g class="particles">
<circle cx="30" cy="80" r="2.5" fill="var(--vacuum-color)" />
<circle cx="170" cy="120" r="2" fill="var(--vacuum-color)" />
<circle cx="25" cy="130" r="1.5" fill="var(--vacuum-color)" />
<circle cx="175" cy="80" r="2" fill="var(--vacuum-color)" />
<circle cx="50" cy="160" r="1.5" fill="var(--vacuum-color)" />
<circle cx="150" cy="45" r="2" fill="var(--vacuum-color)" />
</g>
<!-- Dock indicator (only visible when docked) -->
<g class="dock-indicator">
<rect
x="82"
y="170"
width="36"
height="6"
rx="3"
fill="var(--vacuum-color)"
opacity="0.3"
/>
</g>
<!-- Return home path (only visible when returning) -->
<g class="return-path">
<path
d="M 85 165 Q 80 150 90 140"
fill="none"
stroke="var(--vacuum-color)"
stroke-width="1.5"
stroke-dasharray="4 3"
opacity="0.4"
/>
<path
d="M 100 175 L 95 168 L 105 168 Z"
fill="var(--vacuum-color)"
opacity="0.4"
/>
</g>
<!-- Error indicator -->
<g class="error-indicator">
<text
x="100"
y="106"
text-anchor="middle"
font-size="28"
font-weight="bold"
fill="var(--vacuum-color)"
>
!
</text>
</g>
</svg>
</div>
`;
}
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;
}
}

View File

@@ -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"