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:
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
};
|
||||
@@ -47,6 +47,7 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [
|
||||
"siren",
|
||||
"script",
|
||||
"switch",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"water_heater",
|
||||
"weather",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
529
src/state-control/vacuum/ha-state-control-vacuum-status.ts
Normal file
529
src/state-control/vacuum/ha-state-control-vacuum-status.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user