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
+46 -38
View File
@@ -702,83 +702,91 @@ class HassAdapter extends Adapter {
} }
const prefix = `${this.namespace}.entities.`; 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) { for (const id in allObjects) {
if (!Object.prototype.hasOwnProperty.call(allObjects, id) || !id.startsWith(prefix)) { if (!Object.prototype.hasOwnProperty.call(allObjects, id) || !id.startsWith(prefix)) {
continue; continue;
} }
const obj = allObjects[id]; const rest = id.substring(prefix.length);
if (obj.type !== 'channel') { const parts = rest.split('.');
if (parts.length < 2) {
continue; continue;
} }
const entityId = (obj.native as Record<string, any>)?.entity_id; const entityId = `${parts[0]}.${parts[1]}`;
if (typeof entityId !== 'string') { if (!isExcluded(entityId, this.excludePatterns)) {
continue; continue;
} }
if (isExcluded(entityId, this.excludePatterns)) { const ids = matchedByEntity.get(entityId);
channelsToDelete.push({ id, 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'); this.log.info('Cleanup: no existing objects matched exclude patterns');
return; return;
} }
let deletedCount = 0; let deletedEntityCount = 0;
let deletedIdCount = 0;
let keptForCustomCount = 0; let keptForCustomCount = 0;
for (const { id, entityId } of channelsToDelete) { for (const [entityId, ids] of matchedByEntity) {
const channelCustom = (allObjects[id].common as { custom?: Record<string, unknown> } | undefined) // 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; ?.custom;
let hasCustom = !!(channelCustom && Object.keys(channelCustom).length); if (custom && Object.keys(custom).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; hasCustom = true;
break; break;
} }
} }
}
if (hasCustom) { if (hasCustom) {
keptForCustomCount++; keptForCustomCount++;
this.log.warn( 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; continue;
} }
// 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 { try {
await this.delObjectAsync(id, { recursive: true }); await this.delObjectAsync(id);
delete this.hassObjects[id]; delete this.hassObjects[id];
const subPrefix = `${id}.`; deletedIdCount++;
for (const cachedId of Object.keys(this.hassObjects)) {
if (cachedId.startsWith(subPrefix)) {
delete this.hassObjects[cachedId];
}
}
deletedCount++;
if (this.config.verboseFilterLog) {
this.log.info(`Cleanup: deleted "${id}" (entity ${entityId})`);
}
} catch (err) { } catch (err) {
entityFullyDeleted = false;
this.log.error(`Cleanup: failed to delete "${id}": ${err}`); this.log.error(`Cleanup: failed to delete "${id}": ${err}`);
} }
} }
if (entityFullyDeleted) {
deletedEntityCount++;
if (this.config.verboseFilterLog) {
this.log.info(
`Cleanup: deleted entity "${entityId}" (${ids.length} object${ids.length === 1 ? '' : 's'})`,
);
}
}
}
this.log.info( 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 (keptForCustomCount > 0
? `, kept ${keptForCustomCount} with custom config (see warnings above)` ? `, kept ${keptForCustomCount} entit${keptForCustomCount === 1 ? 'y' : 'ies'} with custom config (see warnings above)`
: ''), : ''),
); );
} }