From 0512ee7cd3d76474b06bcab52cb99d99ce16a936 Mon Sep 17 00:00:00 2001 From: mokusone <14061880+mokusone@users.noreply.github.com> Date: Fri, 15 May 2026 00:08:07 +0200 Subject: [PATCH] fix(filter): cleanup must work for flat-state entities (no parent channel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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... 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. --- src/main.ts | 92 +++++++++++++++++++++++++++++------------------------ 1 file changed, 50 insertions(+), 42 deletions(-) diff --git a/src/main.ts b/src/main.ts index 875dbba..06899cd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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(); 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)?.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 } | 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 } | 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 } | 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)` : ''), ); }