openhab / openhab-core

Core framework of openHAB
https://www.openhab.org/
Eclipse Public License 2.0
916 stars 422 forks source link

Extend `Event-Bus` - `ItemUpdatedEvent` to also trigger on metadata changes - or add new event like `ItemMetadataUpdatedEvent` #4409

Open ThaDaVos opened 3 days ago

ThaDaVos commented 3 days ago

Your Environment

It would be great if one can use the triggers.GenericEventTrigger in the Javascript library to also trigger when an items metadata gets updated - or by using ItemUpdatedEvent or a new event like ItemMetadataUpdatedEvent on a topic of openhab/items/*/metadata/updated or openhab/items/*/updated/metadata

As to why, I am storing information inside metadata to configure my rules - this way it's cached but also accessible from the UI instead of relying on the caching functionality. (See below rule, compiled from Typescript to Javascript for an example, it uses an item to store configuration for the Rule as I couldn't think of another way to do this and have it available from the UI for editing)

Example Rule: Typescript ```ts import { Item } from "openhab/types/items/items"; type ConfigurationMetadataConfiguration = { tags: string[]; groups: string[]; }; type ConfigurationMetadata = { value: string; configuration: ConfigurationMetadataConfiguration; }; //#jsvariables const javaList = Java.type('java.util.List'); const javaArrayList = Java.type('java.util.ArrayList'); const javaSet = Java.type("java.util.Set"); const configurationItemName = 'Group_Items_By_Tag_Configuration'; const metadataKey = 'config:%{JSRULE:ID}%'.replace('Typescript:', '').replace(/:/g, '-').toLowerCase(); const baseMetadata: ConfigurationMetadataConfiguration = { tags: utils.jsArrayToJavaList(['Configuration']) as string[], groups: utils.jsArrayToJavaList([]) as string[] }; let configurationItem: Item | null; let configuration: ConfigurationMetadata; //#jsvariablesend //#jsrule const jsRuleName = "Smart | Create groups from tags and group items" //#jsrule // const jsRuleNamespace = null; //#jsrule const jsRuleTags = []; //#jsrule const jsRuleTriggers = [ triggers.GenericEventTrigger( //TODO: Implement when last item is removed from a group, remove the group /* eventTopic */ `openhab/items/*/updated`, /* eventSource */ '', /* eventTypes */ 'ItemUpdatedEvent' ), triggers.GenericEventTrigger( /* eventTopic */ `openhab/items/*/added`, /* eventSource */ '', /* eventTypes */ 'ItemAddedEvent' ), triggers.GenericEventTrigger( //TODO: Implement this and also, when last item is removed from a group, remove the group /* eventTopic */ `openhab/items/*/removed`, /* eventSource */ '', /* eventTypes */ 'ItemRemovedEvent' ), triggers.SystemStartlevelTrigger(40), ]; function ensureArray(obj: typeof javaList | Array): Array { const isJavaObject = Java.isJavaObject(obj); if (isJavaObject == false) { return obj; } switch (Java.typeName(obj.getClass())) { case 'java.util.ArrayList': return utils.javaListToJsArray(obj); case 'java.util.List': return utils.javaListToJsArray(obj); default: throw new Error(`Unsupported Java object type: ${Java.typeName(obj.getClass())}`); } } function loadConfiguration(): [Item | null, ConfigurationMetadata] { let configurationItem = items.getItem(configurationItemName, true); if (configurationItem == null) { console.warn('Configuration item not found, creating new item'); configurationItem = items.addItem({ name: configurationItemName, type: 'String', label: 'Group Items By Tag Configuration', category: 'Configuration', tags: ['Configuration'], metadata: { [metadataKey]: { value: '', config: baseMetadata } } }); } let configurationMetadata = configurationItem.getMetadata(metadataKey) as { value: string; configuration: typeof baseMetadata; }; if (configurationMetadata == null) { console.warn('Configuration metadata not found, creating new metadata'); configurationItem.replaceMetadata(metadataKey, '', baseMetadata); configurationMetadata = configurationItem.getMetadata(metadataKey) as { value: string; configuration: typeof baseMetadata; }; } return [ configurationItem, { value: configurationMetadata.value, configuration: { tags: ensureArray(configurationMetadata.configuration.tags), groups: ensureArray(configurationMetadata.configuration.groups) } } ]; } function setup() { loadConfiguration(); } function getGroupForTag(tag: string): Item | null; function getGroupForTag(tag: string, allowCreation: true): Item; function getGroupForTag(tag: string, allowCreation: false): Item | null; function getGroupForTag(tag: string, allowCreation: boolean = false): Item | null { const groupName = `SMART_GROUP__${tag.replace(/[^a-zA-Z0-9]/g, '_')}`; const groupItem = items.getItem(groupName, true); if (allowCreation == true && groupItem == null) { return items.addItem({ name: groupName, type: 'Group', label: `Smart Group for tag '${tag}'`, category: 'Group', tags: ['Smart', `GROUP:${tag}`] }); } return groupItem; } function onUpdatedConfiguration() { for (const tag of configuration.configuration.tags) { const groupItems = items.getItemsByTag(tag); if (groupItems.length === 0) { console.warn(`No items found for tag '${tag}'`); continue; } const groupItem = getGroupForTag(tag, true); if (groupItem == null) { console.warn(`No group item found for tag '${tag}'`); continue; } if (configuration.configuration.groups.includes(groupItem.name) === false) { configuration.configuration.groups.push(groupItem.name); } for (const item of groupItems) { if (item.name === groupItem.name) { continue; } if (Array.from(item.groupNames).includes(groupItem.name) === false) { item.addGroups(groupItem.name); } } } const newConfiguration = { tags: utils.jsArrayToJavaList(configuration.configuration.tags), groups: utils.jsArrayToJavaList(configuration.configuration.groups) }; configurationItem?.replaceMetadata(metadataKey, configuration.value, newConfiguration); } [configurationItem, configuration] = loadConfiguration(); switch (event.eventClass.split('.').pop()) { case 'ExecutionEvent': case 'StartlevelEvent': { onUpdatedConfiguration(); break; } case 'ItemUpdatedEvent': { const [newPayload, oldPayload] = event.payload; if (newPayload.name === configurationItemName) { console.info('Configuration updated'); onUpdatedConfiguration(); break; } let tagsChanged = false; if ( newPayload.tags.length != oldPayload.tags.length || newPayload.tags.some((tag: string) => oldPayload.tags.includes(tag) == false) || oldPayload.tags.some((tag: string) => newPayload.tags.includes(tag) == false) ) { tagsChanged = true; } let groupsChanged = false; if ( newPayload.groupNames.length != oldPayload.groupNames.length || newPayload.groupNames.some((groupName: string) => oldPayload.groupNames.includes(groupName) == false) || oldPayload.groupNames.some((groupName: string) => newPayload.groupNames.includes(groupName) == false) ) { groupsChanged = true; } if (tagsChanged === false && groupsChanged === false) { console.info(`Item[${newPayload.name}] => No changes detected`); break; } const tagsAdded = tagsChanged ? newPayload.tags.filter((tag: string) => oldPayload.tags.includes(tag) == false) : []; const tagsRemoved = tagsChanged ? oldPayload.tags.filter((tag: string) => newPayload.tags.includes(tag) == false) : []; const groupsAdded = groupsChanged ? newPayload.groupNames.filter((groupName: string) => oldPayload.groupNames.includes(groupName) == false) : []; const groupsRemoved = groupsChanged ? oldPayload.groupNames.filter((groupName: string) => newPayload.groupNames.includes(groupName) == false) : []; console.info(`Item[${newPayload.name}] => Tags added: ${tagsAdded.join(', ')}`); console.info(`Item[${newPayload.name}] => Tags removed: ${tagsRemoved.join(', ')}`); console.info(`Item[${newPayload.name}] => Groups added: ${groupsAdded.join(', ')}`); console.info(`Item[${newPayload.name}] => Groups removed: ${groupsRemoved.join(', ')}`); const groupsToAdd = []; const groupsToRemove = []; for (const tag of tagsAdded) { if (configuration.configuration.tags.includes(tag) === false) { continue; } const groupItem: Item | null = getGroupForTag(tag); if (groupItem == null) { console.warn(`Item[${newPayload.name}] => No group item found for tag '${tag}' when adding item '${newPayload.name}'`); continue; } if (groupsAdded.includes(groupItem.name) === false) { console.info(`Item[${newPayload.name}] => Adding group '${groupItem.name}' for tag '${tag}'`); groupsToAdd.push(groupItem); } } for (const tag of tagsRemoved) { if (configuration.configuration.tags.includes(tag) === false) { continue; } const groupItem: Item | null = getGroupForTag(tag); if (groupItem == null) { console.warn(`Item[${newPayload.name}] => No group item found for tag '${tag}' when removing item '${newPayload.name}'`); continue; } if (groupsRemoved.includes(groupItem.name) === false) { console.info(`Item[${newPayload.name}] => Removing group '${groupItem.name}' for tag '${tag}'`); groupsToRemove.push(groupItem); } } if (groupsToAdd.length === 0 && groupsToRemove.length === 0) { console.info(`Item[${newPayload.name}] => No groups to add or remove`); break; } const item = items.getItem(newPayload.name) as Item; item.addGroups(...groupsToAdd); item.removeGroups(...groupsToRemove); console.info(`Item[${newPayload.name}] => Added groups: ${groupsToAdd.map((group: Item) => group.name).join(', ')}`); break; } case 'ItemAddedEvent': { const item = items.getItem(event.payload.name) as Item; for (const tag of event.payload.tags) { if (configuration.configuration.tags.includes(tag) === false) { continue; } const groupItem: Item | null = getGroupForTag(tag); if (groupItem == null) { console.warn(`No group item found for tag '${tag}' when adding item '${event.payload.name}'`); continue; } if (Array.from(item.groupNames).includes(groupItem.name) === false) { item.addGroups(groupItem.name); } } break; } default: console.error('Unsupported event type', event); } ```
Example Rule: Compiled Javascript (EsBuild with custom plugin) ```js /* Global variables -- @preserve */ const javaList = Java.type("java.util.List"); const javaArrayList = Java.type("java.util.ArrayList"); const javaSet = Java.type("java.util.Set"); const configurationItemName = "Group_Items_By_Tag_Configuration"; const metadataKey = "config:TypeScript:smart:group-items-by-tag".replace("Typescript:", "").replace(/:/g, "-").toLowerCase(); const baseMetadata = { tags: utils.jsArrayToJavaList(["Configuration"]), groups: utils.jsArrayToJavaList([]) }; let configurationItem; let configuration; /* Setup script -- @preserve */ function setup() { loadConfiguration(); } setup(); /* Rule functions -- @preserve */ function ensureArray(obj) { const isJavaObject = Java.isJavaObject(obj); if (isJavaObject == false) { return obj; } switch (Java.typeName(obj.getClass())) { case "java.util.ArrayList": return utils.javaListToJsArray(obj); case "java.util.List": return utils.javaListToJsArray(obj); default: throw new Error(`Unsupported Java object type: ${Java.typeName(obj.getClass())}`); } } function loadConfiguration() { let configurationItem2 = items.getItem(configurationItemName, true); if (configurationItem2 == null) { console.warn("Configuration item not found, creating new item"); configurationItem2 = items.addItem({ name: configurationItemName, type: "String", label: "Group Items By Tag Configuration", category: "Configuration", tags: ["Configuration"], metadata: { [metadataKey]: { value: "", config: baseMetadata } } }); } let configurationMetadata = configurationItem2.getMetadata(metadataKey); if (configurationMetadata == null) { console.warn("Configuration metadata not found, creating new metadata"); configurationItem2.replaceMetadata(metadataKey, "", baseMetadata); configurationMetadata = configurationItem2.getMetadata(metadataKey); } return [ configurationItem2, { value: configurationMetadata.value, configuration: { tags: ensureArray(configurationMetadata.configuration.tags), groups: ensureArray(configurationMetadata.configuration.groups) } } ]; } function getGroupForTag(tag, allowCreation = false) { const groupName = `SMART_GROUP__${tag.replace(/[^a-zA-Z0-9]/g, "_")}`; const groupItem = items.getItem(groupName, true); if (allowCreation == true && groupItem == null) { return items.addItem({ name: groupName, type: "Group", label: `Smart Group for tag '${tag}'`, category: "Group", tags: ["Smart", `GROUP:${tag}`] }); } return groupItem; } function onUpdatedConfiguration() { for (const tag of configuration.configuration.tags) { const groupItems = items.getItemsByTag(tag); if (groupItems.length === 0) { console.warn(`No items found for tag '${tag}'`); continue; } const groupItem = getGroupForTag(tag, true); if (groupItem == null) { console.warn(`No group item found for tag '${tag}'`); continue; } if (configuration.configuration.groups.includes(groupItem.name) === false) { configuration.configuration.groups.push(groupItem.name); } for (const item of groupItems) { if (item.name === groupItem.name) { continue; } if (Array.from(item.groupNames).includes(groupItem.name) === false) { item.addGroups(groupItem.name); } } } const newConfiguration = { tags: utils.jsArrayToJavaList(configuration.configuration.tags), groups: utils.jsArrayToJavaList(configuration.configuration.groups) }; configurationItem?.replaceMetadata(metadataKey, configuration.value, newConfiguration); } /* Rule definition -- @preserve */ rules.JSRule({ id: "TypeScript:smart:group-items-by-tag", name: "Smart | Create groups from tags and group items", triggers: [ triggers.GenericEventTrigger( /* eventTopic */ `openhab/items/*/updated`, /* eventSource */ "", /* eventTypes */ "ItemUpdatedEvent" ), triggers.GenericEventTrigger( /* eventTopic */ `openhab/items/*/added`, /* eventSource */ "", /* eventTypes */ "ItemAddedEvent" ), triggers.SystemStartlevelTrigger(40) ], tags: [], execute(event) { [configurationItem, configuration] = loadConfiguration(); switch (event.eventClass.split(".").pop()) { case "ExecutionEvent": case "StartlevelEvent": { onUpdatedConfiguration(); break; } case "ItemUpdatedEvent": { const [newPayload, oldPayload] = event.payload; if (newPayload.name === configurationItemName) { console.info("Configuration updated"); onUpdatedConfiguration(); break; } let tagsChanged = false; if (newPayload.tags.length != oldPayload.tags.length || newPayload.tags.some((tag) => oldPayload.tags.includes(tag) == false) || oldPayload.tags.some((tag) => newPayload.tags.includes(tag) == false)) { tagsChanged = true; } let groupsChanged = false; if (newPayload.groupNames.length != oldPayload.groupNames.length || newPayload.groupNames.some((groupName) => oldPayload.groupNames.includes(groupName) == false) || oldPayload.groupNames.some((groupName) => newPayload.groupNames.includes(groupName) == false)) { groupsChanged = true; } if (tagsChanged === false && groupsChanged === false) { console.info(`Item[${newPayload.name}] => No changes detected`); break; } const tagsAdded = tagsChanged ? newPayload.tags.filter((tag) => oldPayload.tags.includes(tag) == false) : []; const tagsRemoved = tagsChanged ? oldPayload.tags.filter((tag) => newPayload.tags.includes(tag) == false) : []; const groupsAdded = groupsChanged ? newPayload.groupNames.filter((groupName) => oldPayload.groupNames.includes(groupName) == false) : []; const groupsRemoved = groupsChanged ? oldPayload.groupNames.filter((groupName) => newPayload.groupNames.includes(groupName) == false) : []; console.info(`Item[${newPayload.name}] => Tags added: ${tagsAdded.join(", ")}`); console.info(`Item[${newPayload.name}] => Tags removed: ${tagsRemoved.join(", ")}`); console.info(`Item[${newPayload.name}] => Groups added: ${groupsAdded.join(", ")}`); console.info(`Item[${newPayload.name}] => Groups removed: ${groupsRemoved.join(", ")}`); const groupsToAdd = []; const groupsToRemove = []; for (const tag of tagsAdded) { if (configuration.configuration.tags.includes(tag) === false) { continue; } const groupItem = getGroupForTag(tag); if (groupItem == null) { console.warn(`Item[${newPayload.name}] => No group item found for tag '${tag}' when adding item '${newPayload.name}'`); continue; } if (groupsAdded.includes(groupItem.name) === false) { console.info(`Item[${newPayload.name}] => Adding group '${groupItem.name}' for tag '${tag}'`); groupsToAdd.push(groupItem); } } for (const tag of tagsRemoved) { if (configuration.configuration.tags.includes(tag) === false) { continue; } const groupItem = getGroupForTag(tag); if (groupItem == null) { console.warn(`Item[${newPayload.name}] => No group item found for tag '${tag}' when removing item '${newPayload.name}'`); continue; } if (groupsRemoved.includes(groupItem.name) === false) { console.info(`Item[${newPayload.name}] => Removing group '${groupItem.name}' for tag '${tag}'`); groupsToRemove.push(groupItem); } } if (groupsToAdd.length === 0 && groupsToRemove.length === 0) { console.info(`Item[${newPayload.name}] => No groups to add or remove`); break; } const item = items.getItem(newPayload.name); item.addGroups(...groupsToAdd); item.removeGroups(...groupsToRemove); console.info(`Item[${newPayload.name}] => Added groups: ${groupsToAdd.map((group) => group.name).join(", ")}`); break; } case "ItemAddedEvent": { const item = items.getItem(event.payload.name); for (const tag of event.payload.tags) { if (configuration.configuration.tags.includes(tag) === false) { continue; } const groupItem = getGroupForTag(tag); if (groupItem == null) { console.warn(`No group item found for tag '${tag}' when adding item '${event.payload.name}'`); continue; } if (Array.from(item.groupNames).includes(groupItem.name) === false) { item.addGroups(groupItem.name); } } break; } default: console.error("Unsupported event type", event); } } }); ```
rkoshak commented 1 day ago

If it's configuration data for the rule, why do you need to trigger the rule to run when the configuration changes? You need to load the metadata when the rule runs anyway so the next time the rule runs it will get the new version of the metadata.

I use this in a couple of my published rule templates on the marketplace.

I don't quite understand your need for an event to trigger a rule in this case.

ThaDaVos commented 1 day ago

If you check my rule - you can see it has a special coding path when the Configuration changes, in this case it prepares the groups and cleans up the groups it no longer watches. This is just one scenario where triggering a rule based on Metadata changes of an item can be useful.

The request is not specific to configuration containing items, but just items in general and responding to Metadata changes - perhaps even go a little further and also allow subfiltering to the namespace you want - in my opinion this is a valid request in general, my example was just about a configuration item for a rule - but it can also be used for metadata on items which are used to set stuff like temperature etc.

rkoshak commented 19 hours ago

Adding a whole new set of events has performance and load impacts on the whole OH system. One needs to be careful about the impacts. Given that the vast majorioty of users would not use this event, is the overall impact low enought that it doesn't matter that the load is increased on their system? I can't answer that but I do want to make sure that it does get answered.

If this is going to slow down people's MainUI or something like that the use case needs to be pretty compelling. Given there are other ways to accomplish what you've done without an event I personally don't find the use case to be that compelling, especially since there are several other approches to accomplish the same thing.

But if the impact is overall really small then the use case doesn't have to be all that compelling.

I'm not a developer so it's up to whom ever volunteers to implement it to decide if the use case is compelling enough to justify the amount of work it will take to implement.

Some of the alternative approaches include:

I'm not saying this isn't a valid request. I'm just cautious that we don't go down some path that causes a huge impact to all users to solve a pretty niche problem with alternative solutions.

spacemanspiff2007 commented 19 hours ago

This is a duplicate of #3281 and #2877 . From a logical point of view it makes sense that metadata is bound to an item, but unfortunately it is not and it seems there are no plans to change this.