Compare commits

...

1 Commits

Author SHA1 Message Date
Franck Nijhof 33741db696 Remove orphaned vacuum segments when saving area mapping
When a vacuum reports a new set of segments, the segment to area mapping
could keep IDs that are no longer reported. Those orphaned IDs stayed
assigned to areas, and cleaning such an area later fails because the ID
is invalid.

Prune segment IDs that are no longer reported before saving the mapping,
dropping areas that end up without any segment. Pruning only happens when
the segments actually loaded, so a transient load error does not wipe the
mapping.
2026-06-19 10:55:02 +00:00
3 changed files with 66 additions and 2 deletions
+18
View File
@@ -79,3 +79,21 @@ export const getVacuumSegments = (
type: "vacuum/get_segments",
entity_id,
});
// Drop segment IDs the vacuum no longer reports from an area mapping. Orphaned
// IDs left behind make area cleaning fail, so they have to go. Areas left
// without any segment are removed entirely.
export const pruneOrphanedSegments = (
areaMapping: Record<string, string[]>,
segments: Segment[]
): Record<string, string[]> => {
const knownIds = new Set(segments.map((segment) => segment.id));
const pruned: Record<string, string[]> = {};
for (const [areaId, segmentIds] of Object.entries(areaMapping)) {
const kept = segmentIds.filter((id) => knownIds.has(id));
if (kept.length) {
pruned[areaId] = kept;
}
}
return pruned;
};
@@ -26,6 +26,7 @@ import {
getExtendedEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../../../data/entity/entity_registry";
import { pruneOrphanedSegments } from "../../../../data/vacuum";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
@@ -105,11 +106,18 @@ export class DialogVacuumSegmentMapping
try {
const mapper = this._mapper!;
const lastSeenSegments = mapper.lastSeenSegments;
// Only prune when segments actually loaded, to avoid wiping the mapping
// on a transient load error.
const areaMapping = lastSeenSegments
? pruneOrphanedSegments(this._areaMapping, lastSeenSegments)
: this._areaMapping;
const options: VacuumEntityOptions = {
...(this._entry?.options?.vacuum ?? {}),
area_mapping: this._areaMapping,
last_seen_segments: mapper.lastSeenSegments,
area_mapping: areaMapping,
last_seen_segments: lastSeenSegments,
};
await updateEntityRegistryEntry(this.hass, this._params.entityId, {
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { pruneOrphanedSegments } from "../../src/data/vacuum";
import type { Segment } from "../../src/data/vacuum";
const segment = (id: string): Segment => ({ id, name: `Segment ${id}` });
describe("pruneOrphanedSegments", () => {
it("removes segment IDs that are no longer reported", () => {
const result = pruneOrphanedSegments({ kitchen: ["1", "2"], hall: ["3"] }, [
segment("1"),
segment("3"),
]);
// "2" is gone from the kitchen, "3" stays in the hall.
expect(result).toEqual({ kitchen: ["1"], hall: ["3"] });
});
it("drops areas left without any reported segment", () => {
const result = pruneOrphanedSegments({ kitchen: ["1"], attic: ["99"] }, [
segment("1"),
]);
expect(result).toEqual({ kitchen: ["1"] });
});
it("keeps the mapping unchanged when all segments are still reported", () => {
const mapping = { kitchen: ["1", "2"], hall: ["3"] };
const result = pruneOrphanedSegments(mapping, [
segment("1"),
segment("2"),
segment("3"),
]);
expect(result).toEqual(mapping);
});
it("returns an empty mapping when no segments are reported", () => {
const result = pruneOrphanedSegments({ kitchen: ["1"], hall: ["2"] }, []);
expect(result).toEqual({});
});
});