Add support for vacuum segment mapping to areas

This commit is contained in:
Bram Kragten 2026-02-02 15:10:52 +01:00
parent a7f9b93018
commit ea88cd8f9c
10 changed files with 725 additions and 6 deletions

View File

@ -0,0 +1,258 @@
import "@material/mwc-list/mwc-list-item";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { Segment } from "../data/vacuum";
import { getVacuumSegments } from "../data/vacuum";
import type { HomeAssistant } from "../types";
import "./ha-alert";
import "./ha-area-picker";
type AreaSegmentMapping = Record<string, string[]>; // area ID -> segment IDs
@customElement("ha-vacuum-segment-area-mapper")
export class HaVacuumSegmentAreaMapper extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "entity-id" }) public entityId!: string;
@property({ attribute: false }) public value?: AreaSegmentMapping;
@state() private _segments?: Segment[];
@state() private _loading = false;
@state() private _error?: string;
public get lastSeenSegments() {
return this._segments?.map((seg: Segment) => ({
id: seg.id,
name: seg.name,
...(seg.group && { group: seg.group }),
}));
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("entityId") && this.entityId) {
this._loadSegments();
}
}
private async _loadSegments() {
this._loading = true;
this._error = undefined;
try {
const result = await getVacuumSegments(this.hass, this.entityId);
this._segments = result.segments;
} catch (err: any) {
this._error = err.message || "Failed to load segments";
this._segments = undefined;
} finally {
this._loading = false;
}
}
protected render() {
if (this._loading) {
return html`
<div class="loading">${this.hass.localize("ui.common.loading")}...</div>
`;
}
if (this._error) {
return html` <ha-alert alert-type="error">${this._error}</ha-alert> `;
}
if (!this._segments || this._segments.length === 0) {
return html`
<ha-alert alert-type="info"> No segments available </ha-alert>
`;
}
// Group segments by group (if available)
const groupedSegments = this._groupSegments(this._segments);
return html`
<div class="segments">
${Object.entries(groupedSegments).map(
([groupName, segments]) => html`
${groupName !== "undefined"
? html`<div class="group-header">${groupName}</div>`
: nothing}
${segments.map((segment) => this._renderSegment(segment))}
`
)}
</div>
`;
}
private _groupSegments(segments: Segment[]): Record<string, Segment[]> {
const grouped: Record<string, Segment[]> = {};
for (const segment of segments) {
const group = segment.group || "undefined";
if (!grouped[group]) {
grouped[group] = [];
}
grouped[group].push(segment);
}
return grouped;
}
private _renderSegment(segment: Segment) {
const mappedAreas = this._getSegmentAreas(segment.id);
return html`
<div class="segment-row">
<div class="segment-info">
<div class="segment-name">${segment.name}</div>
<div class="segment-id">${segment.id}</div>
</div>
<ha-area-picker
.hass=${this.hass}
.value=${mappedAreas}
.label=${"Area"}
allow-custom-entity
@value-changed=${this._handleAreaChanged}
data-segment-id=${segment.id}
></ha-area-picker>
</div>
`;
}
private _handleAreaChanged = (ev: CustomEvent) => {
const target = ev.currentTarget as HTMLElement;
const segmentId = target.dataset.segmentId;
if (segmentId) {
this._areaChanged(segmentId, ev);
}
};
private _getSegmentAreas(segmentId: string): string | undefined {
if (!this.value) {
return undefined;
}
// Find which area(s) contain this segment
for (const [areaId, segmentIds] of Object.entries(this.value)) {
if (segmentIds.includes(segmentId)) {
return areaId;
}
}
return undefined;
}
private _areaChanged(segmentId: string, ev: CustomEvent) {
ev.stopPropagation();
const newAreaId = ev.detail.value as string | undefined;
// Create a copy of the current mapping
const newMapping: AreaSegmentMapping = { ...this.value };
// Remove segment from all areas
for (const areaId of Object.keys(newMapping)) {
newMapping[areaId] = newMapping[areaId].filter((id) => id !== segmentId);
// Remove empty area entries
if (newMapping[areaId].length === 0) {
delete newMapping[areaId];
}
}
// Add segment to new area if specified
if (newAreaId) {
if (!newMapping[newAreaId]) {
newMapping[newAreaId] = [];
}
newMapping[newAreaId].push(segmentId);
}
fireEvent(this, "value-changed", { value: newMapping });
}
static styles: CSSResultGroup = css`
:host {
display: block;
}
.loading {
padding: var(--ha-space-4);
text-align: center;
color: var(--secondary-text-color);
}
.segments {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.group-header {
font-weight: 500;
color: var(--primary-text-color);
padding: var(--ha-space-3) var(--ha-space-2);
margin-top: var(--ha-space-2);
background-color: var(--secondary-background-color);
border-radius: var(--ha-card-border-radius, 12px);
}
.segment-row {
display: flex;
align-items: center;
gap: var(--ha-space-4);
padding: var(--ha-space-2);
border-radius: var(--ha-card-border-radius, 12px);
background-color: var(--card-background-color);
border: 1px solid var(--divider-color);
}
.segment-info {
flex: 1;
min-width: 0;
}
.segment-name {
font-weight: 500;
color: var(--primary-text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.segment-id {
font-size: 0.875rem;
color: var(--secondary-text-color);
font-family: var(--ha-font-family-code);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
ha-area-picker {
flex: 1;
min-width: 200px;
}
@media (max-width: 600px) {
.segment-row {
flex-direction: column;
align-items: stretch;
}
ha-area-picker {
min-width: 0;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-vacuum-segment-area-mapper": HaVacuumSegmentAreaMapper;
}
}

View File

@ -9,6 +9,7 @@ import { debounce } from "../../common/util/debounce";
import type { HomeAssistant } from "../../types";
import type { LightColor } from "../light";
import type { RegistryEntry } from "../registry";
import type { Segment } from "../vacuum";
type EntityCategory = "config" | "diagnostic";
@ -120,6 +121,11 @@ export interface SwitchAsXEntityOptions {
invert: boolean;
}
export interface VacuumEntityOptions {
area_mapping?: Record<string, string[]>;
last_seen_segments?: Segment[];
}
export interface EntityRegistryOptions {
number?: NumberEntityOptions;
sensor?: SensorEntityOptions;
@ -128,6 +134,7 @@ export interface EntityRegistryOptions {
lock?: LockEntityOptions;
weather?: WeatherEntityOptions;
light?: LightEntityOptions;
vacuum?: VacuumEntityOptions;
switch_as_x?: SwitchAsXEntityOptions;
conversation?: Record<string, unknown>;
"cloud.alexa"?: Record<string, unknown>;
@ -150,7 +157,8 @@ export interface EntityRegistryEntryUpdateParams {
| AlarmControlPanelEntityOptions
| CalendarEntityOptions
| WeatherEntityOptions
| LightEntityOptions;
| LightEntityOptions
| VacuumEntityOptions;
aliases?: string[];
labels?: string[];
categories?: Record<string, string | null>;

View File

@ -2,6 +2,7 @@ import type {
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE } from "./entity/entity";
export type VacuumEntityState =
@ -29,6 +30,7 @@ export const enum VacuumEntityFeature {
MAP = 2048,
STATE = 4096,
START = 8192,
CLEAN_AREA = 16384,
}
interface VacuumEntityAttributes extends HassEntityAttributeBase {
@ -62,3 +64,18 @@ export function canReturnHome(stateObj: VacuumEntity): boolean {
}
return stateObj.state !== "returning";
}
export interface Segment {
id: string;
name: string;
group?: string;
}
export const getVacuumSegments = (
hass: HomeAssistant,
entity_id: string
): Promise<{ segments: Segment[] }> =>
hass.callWS({
type: "vacuum/get_segments",
entity_id,
});

View File

@ -0,0 +1,152 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-button";
import "../../../../components/ha-vacuum-segment-area-mapper";
import type { HaVacuumSegmentAreaMapper } from "../../../../components/ha-vacuum-segment-area-mapper";
import type {
ExtEntityRegistryEntry,
VacuumEntityOptions,
} from "../../../../data/entity/entity_registry";
import {
getExtendedEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../types";
@customElement("ha-more-info-view-vacuum-segment-mapping")
export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public params!: { entityId: string };
@state() private _areaMapping?: Record<string, string[]>;
@state() private _submitting = false;
@state() private _dirty = false;
@state() private _error?: string;
private _entry?: ExtEntityRegistryEntry;
protected firstUpdated() {
this._loadCurrentMapping();
}
private async _loadCurrentMapping() {
if (!this.params.entityId) return;
this._entry = await getExtendedEntityRegistryEntry(
this.hass,
this.params.entityId
);
if (this._entry?.options?.vacuum) {
this._areaMapping = this._entry.options.vacuum.area_mapping || {};
} else {
this._areaMapping = {};
}
}
private _valueChanged(ev: CustomEvent) {
this._areaMapping = ev.detail.value;
this._dirty = true;
}
private async _save() {
if (!this.params.entityId || !this._areaMapping) return;
this._error = undefined;
this._submitting = true;
// Get current segments from the mapper component
const mapper = this.shadowRoot!.querySelector(
"ha-vacuum-segment-area-mapper"
) as HaVacuumSegmentAreaMapper;
const options: VacuumEntityOptions = {
...(this._entry?.options?.vacuum ?? {}),
area_mapping: this._areaMapping,
last_seen_segments: mapper.lastSeenSegments,
};
try {
await updateEntityRegistryEntry(this.hass, this.params.entityId, {
options_domain: "vacuum",
options: options,
});
this._dirty = false;
} catch (err: any) {
this._error = err.message;
} finally {
this._submitting = false;
}
}
protected render() {
if (!this._areaMapping) {
return html`<ha-spinner active></ha-spinner>`;
}
return html`
<div class="content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-vacuum-segment-area-mapper
.hass=${this.hass}
.entityId=${this.params.entityId}
.value=${this._areaMapping}
@value-changed=${this._valueChanged}
></ha-vacuum-segment-area-mapper>
<div class="footer">
<ha-button
@click=${this._save}
.disabled=${!this._dirty || this._submitting}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</div>
</div>
`;
}
static styles: CSSResultGroup = css`
:host {
display: block;
height: 100%;
}
.content {
display: flex;
flex-direction: column;
height: 100%;
}
ha-spinner {
margin: var(--ha-space-8);
display: flex;
justify-self: center;
}
ha-vacuum-segment-area-mapper {
flex: 1;
padding: var(--ha-space-4);
}
.footer {
display: flex;
justify-content: flex-end;
padding: var(--ha-space-4);
border-top: 1px solid var(--divider-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-view-vacuum-segment-mapping": HaMoreInfoViewVacuumSegmentMapping;
}
}

View File

@ -0,0 +1,16 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export const loadVacuumSegmentMappingView = () =>
import("./ha-more-info-view-vacuum-segment-mapping");
export const showVacuumSegmentMappingView = (
element: HTMLElement,
entityId: string
): void => {
fireEvent(element, "show-child-view", {
viewTag: "ha-more-info-view-vacuum-segment-mapping",
viewImport: loadVacuumSegmentMappingView,
viewTitle: "Map vacuum segments to areas",
viewParams: { entityId },
});
};

View File

@ -0,0 +1,203 @@
import type { CSSResultGroup } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import {
computeEntityEntryName,
computeEntityName,
} from "../../../../common/entity/compute_entity_name";
import {
getEntityContext,
getEntityEntryContext,
} from "../../../../common/entity/context/get_entity_context";
import { computeRTL } from "../../../../common/util/compute_rtl";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-vacuum-segment-area-mapper";
import type { HaVacuumSegmentAreaMapper } from "../../../../components/ha-vacuum-segment-area-mapper";
import "../../../../components/ha-wa-dialog";
import type {
ExtEntityRegistryEntry,
VacuumEntityOptions,
} from "../../../../data/entity/entity_registry";
import {
getExtendedEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../../../data/entity/entity_registry";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
export interface VacuumSegmentMappingDialogParams {
entityId: string;
}
@customElement("dialog-vacuum-segment-mapping")
export class DialogVacuumSegmentMapping
extends LitElement
implements HassDialog<VacuumSegmentMappingDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: VacuumSegmentMappingDialogParams;
@state() private _open = false;
@state() private _areaMapping?: Record<string, string[]>;
@state() private _submitting = false;
private _entry?: ExtEntityRegistryEntry;
public async showDialog(
params: VacuumSegmentMappingDialogParams
): Promise<void> {
this._params = params;
this._open = true;
await this._loadCurrentMapping();
}
public closeDialog(): boolean {
this._open = false;
this._params = undefined;
this._areaMapping = undefined;
return true;
}
private async _loadCurrentMapping() {
if (!this._params) return;
const entityId = this._params.entityId;
this._entry = await getExtendedEntityRegistryEntry(this.hass, entityId);
if (this._entry?.options?.vacuum) {
this._areaMapping = this._entry.options.vacuum.area_mapping || {};
} else {
this._areaMapping = {};
}
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _valueChanged(ev: CustomEvent) {
this._areaMapping = ev.detail.value;
}
private async _save() {
if (!this._params || !this._areaMapping) return;
this._submitting = true;
try {
const mapper = this.renderRoot.querySelector(
"ha-vacuum-segment-area-mapper"
) as HaVacuumSegmentAreaMapper;
const options: VacuumEntityOptions = {
area_mapping: this._areaMapping,
last_seen_segments: mapper.lastSeenSegments,
};
await updateEntityRegistryEntry(this.hass, this._params.entityId, {
options_domain: "vacuum",
options: options,
});
this.closeDialog();
} catch (_err: any) {
// Error will be shown by the system
} finally {
this._submitting = false;
}
}
protected render() {
if (!this._params) {
return nothing;
}
const stateObj = this.hass.states[this._params.entityId];
const context = stateObj
? getEntityContext(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: this._entry
? getEntityEntryContext(
this._entry,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: undefined;
const entityName = stateObj
? computeEntityName(stateObj, this.hass.entities, this.hass.devices)
: this._entry
? computeEntityEntryName(this._entry, this.hass.devices)
: this._params.entityId;
const deviceName = context?.device
? computeDeviceName(context.device)
: undefined;
const areaName = context?.area ? computeAreaName(context.area) : undefined;
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)
);
return html`
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
@closed=${this._dialogClosed}
header-title="Map vacuum segments to areas"
.headerSubtitle=${breadcrumb.join(
computeRTL(this.hass) ? " ◂ " : " ▸ "
)}
>
<ha-vacuum-segment-area-mapper
.hass=${this.hass}
entity-id=${this._params.entityId}
.value=${this._areaMapping}
@value-changed=${this._valueChanged}
></ha-vacuum-segment-area-mapper>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
static styles: CSSResultGroup = [haStyleDialog];
}
declare global {
interface HTMLElementTagNameMap {
"dialog-vacuum-segment-mapping": DialogVacuumSegmentMapping;
}
}

View File

@ -0,0 +1,19 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export interface VacuumSegmentMappingDialogParams {
entityId: string;
}
export const loadVacuumSegmentMappingDialog = () =>
import("./dialog-vacuum-segment-mapping");
export const showVacuumSegmentMappingDialog = (
element: HTMLElement,
params: VacuumSegmentMappingDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-vacuum-segment-mapping",
dialogImport: loadVacuumSegmentMappingDialog,
dialogParams: params,
});
};

View File

@ -80,6 +80,7 @@ import {
getSensorDeviceClassConvertibleUnits,
getSensorNumericDeviceClasses,
} from "../../../data/sensor";
import { VacuumEntityFeature } from "../../../data/vacuum";
import type { WeatherUnits } from "../../../data/weather";
import { getWeatherConvertibleUnits } from "../../../data/weather";
import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow";
@ -87,6 +88,7 @@ import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { showVacuumSegmentMappingView } from "../../../dialogs/more-info/components/vacuum/show-view-vacuum-segment-mapping";
import { showVoiceAssistantsView } from "../../../dialogs/more-info/components/voice/show-view-voice-assistants";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import { haStyle } from "../../../resources/styles";
@ -925,6 +927,25 @@ export class EntityRegistrySettingsEditor extends LitElement {
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
${domain === "vacuum" &&
stateObj &&
supportsFeature(stateObj, VacuumEntityFeature.CLEAN_AREA)
? html`
<ha-list-item
class="menu-item"
twoline
hasMeta
.disabled=${this.disabled}
@click=${this._handleVacuumSegmentMappingClicked}
>
<span>Map vacuum segments to areas</span>
<span slot="secondary">
Configure which areas each vacuum segment should clean
</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
: ""}
${this._disabledBy &&
this._disabledBy !== "user" &&
this._disabledBy !== "integration"
@ -1512,6 +1533,10 @@ export class EntityRegistrySettingsEditor extends LitElement {
);
}
private _handleVacuumSegmentMappingClicked() {
showVacuumSegmentMappingView(this, this.entry.entity_id);
}
private async _showOptionsFlow() {
showOptionsFlowDialog(this, this.helperConfigEntry!, {
manifest: await fetchIntegrationManifest(

View File

@ -5,6 +5,11 @@ import { capitalizeFirstLetter } from "../../../common/string/capitalize-first-l
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import { domainToName } from "../../../data/integration";
import type { StatisticsValidationResult } from "../../../data/recorder";
import {
STATISTIC_TYPES,
updateStatisticsIssues,
} from "../../../data/recorder";
import {
fetchRepairsIssueData,
type RepairsIssue,
@ -15,11 +20,7 @@ import { brandsUrl } from "../../../util/brands-url";
import { fixStatisticsIssue } from "../developer-tools/statistics/fix-statistics";
import { showRepairsFlowDialog } from "./show-dialog-repair-flow";
import { showRepairsIssueDialog } from "./show-repair-issue-dialog";
import type { StatisticsValidationResult } from "../../../data/recorder";
import {
STATISTIC_TYPES,
updateStatisticsIssues,
} from "../../../data/recorder";
import { showVacuumSegmentMappingDialog } from "../entities/dialogs/show-dialog-vacuum-segment-mapping";
@customElement("ha-config-repairs")
class HaConfigRepairs extends LitElement {
@ -139,6 +140,23 @@ class HaConfigRepairs extends LitElement {
continueFlowId: data.issue_data.flow_id as string,
});
}
} else if (
issue.domain === "vacuum" &&
issue.translation_key === "segments_changed"
) {
const data = await fetchRepairsIssueData(
this.hass.connection,
issue.domain,
issue.issue_id
);
if (
"entity_id" in data.issue_data &&
typeof data.issue_data.entity_id === "string"
) {
showVacuumSegmentMappingDialog(this, {
entityId: data.issue_data.entity_id,
});
}
} else if (
issue.domain === "sensor" &&
issue.translation_key &&

View File

@ -1358,6 +1358,9 @@
}
},
"dialogs": {
"vacuum_segment_mapping": {
"title": "Map vacuum segments to areas"
},
"safe_mode": {
"title": "Safe mode",
"text": "Home Assistant is running in safe mode, custom integrations and frontend modules are not available. Restart Home Assistant to exit safe mode."