fix(filter): cleanup must work for flat-state entities (no parent channel)

The previous cleanup implementation only iterated objects with
type === 'channel' and read entity_id from native. Both assumptions
break for entities written by older adapter versions: those exist as
flat states under hass.0.entities.<domain>.<entity_id>.<sub> with no
parent channel object and often no native.entity_id.

The admin UI shows them as folders because sub-states are present, but
`iobroker object get hass.0.entities.text.iob_…` returns "not found".
So the cleanup found nothing to delete even with valid patterns.

Refactor: iterate every object under entities.*, derive entity_id from
the first two path components after the namespace prefix, group by
entity_id, then delete each id individually (longest first, no
recursive flag — there is no parent to recurse into). Custom-config
protection still applies per entity group.

Works for both old flat-state and new channel+sub-state structures.
This commit is contained in:
mokusone
2026-05-15 04:14:48 +02:00
parent a40dc58e35
commit 0512ee7cd3
+50 -42
View File
@@ -702,83 +702,91 @@ class HassAdapter extends Adapter {
}
const prefix = `${this.namespace}.entities.`;
const channelsToDelete: { id: string; entityId: string }[] = [];
// Group every object under entities.* by its derived entity_id. We extract
// the entity_id from the object id (first two path components after the
// prefix) instead of native.entity_id — older objects from previous adapter
// versions may have been written as flat states without a parent channel
// and without native.entity_id, but their id still encodes the entity.
const matchedByEntity = new Map<string, string[]>();
for (const id in allObjects) {
if (!Object.prototype.hasOwnProperty.call(allObjects, id) || !id.startsWith(prefix)) {
continue;
}
const obj = allObjects[id];
if (obj.type !== 'channel') {
const rest = id.substring(prefix.length);
const parts = rest.split('.');
if (parts.length < 2) {
continue;
}
const entityId = (obj.native as Record<string, any>)?.entity_id;
if (typeof entityId !== 'string') {
const entityId = `${parts[0]}.${parts[1]}`;
if (!isExcluded(entityId, this.excludePatterns)) {
continue;
}
if (isExcluded(entityId, this.excludePatterns)) {
channelsToDelete.push({ id, entityId });
const ids = matchedByEntity.get(entityId);
if (ids) {
ids.push(id);
} else {
matchedByEntity.set(entityId, [id]);
}
}
if (channelsToDelete.length === 0) {
if (matchedByEntity.size === 0) {
this.log.info('Cleanup: no existing objects matched exclude patterns');
return;
}
let deletedCount = 0;
let deletedEntityCount = 0;
let deletedIdCount = 0;
let keptForCustomCount = 0;
for (const { id, entityId } of channelsToDelete) {
const channelCustom = (allObjects[id].common as { custom?: Record<string, unknown> } | undefined)
?.custom;
let hasCustom = !!(channelCustom && Object.keys(channelCustom).length);
if (!hasCustom) {
const subPrefix = `${id}.`;
for (const subId in allObjects) {
if (!subId.startsWith(subPrefix)) {
continue;
}
const subCustom = (
allObjects[subId].common as { custom?: Record<string, unknown> } | undefined
)?.custom;
if (subCustom && Object.keys(subCustom).length) {
hasCustom = true;
break;
}
for (const [entityId, ids] of matchedByEntity) {
// Custom-config protection: scan all ids of the group; if any holds
// common.custom (history/influxdb/sql) keep the whole entity.
let hasCustom = false;
for (const id of ids) {
const custom = (allObjects[id].common as { custom?: Record<string, unknown> } | undefined)
?.custom;
if (custom && Object.keys(custom).length) {
hasCustom = true;
break;
}
}
if (hasCustom) {
keptForCustomCount++;
this.log.warn(
`Cleanup: keeping "${id}" (entity ${entityId}) — has custom adapter config (history/influxdb/sql); remove it manually if you really want to drop it`,
`Cleanup: keeping entity "${entityId}" — has custom adapter config (history/influxdb/sql); remove it manually if you really want to drop it`,
);
continue;
}
try {
await this.delObjectAsync(id, { recursive: true });
delete this.hassObjects[id];
const subPrefix = `${id}.`;
for (const cachedId of Object.keys(this.hassObjects)) {
if (cachedId.startsWith(subPrefix)) {
delete this.hassObjects[cachedId];
}
// Delete sub-states first (longest ids), then any parent channel last.
const sortedIds = [...ids].sort((a, b) => b.length - a.length);
let entityFullyDeleted = true;
for (const id of sortedIds) {
try {
await this.delObjectAsync(id);
delete this.hassObjects[id];
deletedIdCount++;
} catch (err) {
entityFullyDeleted = false;
this.log.error(`Cleanup: failed to delete "${id}": ${err}`);
}
deletedCount++;
}
if (entityFullyDeleted) {
deletedEntityCount++;
if (this.config.verboseFilterLog) {
this.log.info(`Cleanup: deleted "${id}" (entity ${entityId})`);
this.log.info(
`Cleanup: deleted entity "${entityId}" (${ids.length} object${ids.length === 1 ? '' : 's'})`,
);
}
} catch (err) {
this.log.error(`Cleanup: failed to delete "${id}": ${err}`);
}
}
this.log.info(
`Cleanup: deleted ${deletedCount} excluded entit${deletedCount === 1 ? 'y' : 'ies'}` +
`Cleanup: deleted ${deletedEntityCount} excluded entit${deletedEntityCount === 1 ? 'y' : 'ies'} (${deletedIdCount} object${deletedIdCount === 1 ? '' : 's'} total)` +
(keptForCustomCount > 0
? `, kept ${keptForCustomCount} with custom config (see warnings above)`
? `, kept ${keptForCustomCount} entit${keptForCustomCount === 1 ? 'y' : 'ies'} with custom config (see warnings above)`
: ''),
);
}