HOWTO: Generic Tag-Based Button Scene Handling

Hi everyone,

I want to share a clean and generic pattern I’ve been using in my OpenHAB setup to handle button input (e.g., HUE Zigbee switches) and trigger scenes cycling through them, using only groups and metadata without hardcoding item names or duplicating logic. This makes it super easy to extend later just by updating item files.

:brick: Structure Overview

For each button, I define two String items:

  1. Raw input item (e.g., ButtonHueSwitch01ActionRaw)
    This is directly bound to the MQTT topic exposed by Zigbee2MQTT it will read “raw” touch and map them to more commonly used actions, like “on”/“off”, i map target action using metadata actionItem="ButtonHueSwitch01Action" and group it in a group called ButtonTriggerGroup so i can listen for events

  2. Processed action item (e.g., ButtonHueSwitch01Action)
    This holds the interpreted action (on, up, down, etc.) with a timestamp, used to trigger scene logic. It’s part of a different group ButtonSceneGroup and carries a metadata block sceneMap like:

    on:BRIGHT,up:CINEMA,down:RELAX|RELAX_DX,off:SLEEP
    

    This will map actions to scene names, we can use multiple scenes per button, for example if down is pressed and RELAX is the current scene it will select RELAX_DX

Sample item:

Group     ButtonTriggerGroup
          "Button Trigger Group"

String    ButtonHueSwitch01ActionRaw
          "Button Hue Switch 01 Raw Action"
          (ButtonTriggerGroup)
          {channel="mqtt:topic:hueswitch01:action", actionItem="ButtonHueSwitch01Action"}

Group     ButtonSceneGroup
          "Button Scene Group"

String    ButtonHueSwitch01Action
          "Button Hue Switch 01 Action"
          (ButtonSceneGroup)
          {sceneMap="on:BRIGHT,up:CINEMA,down:RELAX|RELAX_DX,off:SLEEP"}

You can add a new button just adding a new raw and action item and you are done, script will handle them automatically.


:brain: JS Rule 1 – Raw → Interpreted Action Mapping

This rule triggers on all items in group ButtonTriggerGroup, extracts the target item from the actionItem metadata, maps raw Zigbee values (e.g., on_press_release) into simple actions (e.g., on), and updates the corresponding ButtonScene item:

const map = {
  "on_press_release": "on",
  "off_press_release": "off",
  "up_press_release": "up",
  "down_press_release": "down"
};

targetItem.postUpdate(`${interpreted}-${Date.now()}`);

This lets us uniformly handle all buttons regardless of how many we have.

Full source (JSR223)

'use strict';

const { rules, triggers, items } = require('openhab');

// ---------------------------------------------------------------------------
// Rule #1: Maps raw input from ButtonTrigger items to interpreted actions
// ---------------------------------------------------------------------------
// This rule listens for changes in ButtonTriggerGroup and maps their raw input
// to interpreted actions based on the metadata associated with the item.
// The mapping is done using a predefined map object that translates raw input
// values to interpreted action values.
rules.JSRule({
    name: `Button trigger mapper`,
    description: `Maps buttons raw input to interpreted action`,
    id: 'buttonTriggerMapper',
    triggers: [triggers.GroupStateChangeTrigger("ButtonTriggerGroup")],
    execute: (event) => {
        const rawItemName = event.itemName;
        const rawValue = event.newState;
        const rawItem = items[rawItemName];
        const targetItemName = items.metadata.getMetadata(rawItem, "actionItem").value;        
        const targetItem = items[targetItemName];
        if (!targetItem) {
            console.warn(`Target item ${targetItemName} not found`);
            return;
        }
        const map = {
            "on_press_release": "on",
            "on_hold_release": "on_hold",
            "off_press_release": "off",
            "off_hold_release": "off_hold",
            "up_press_release": "up",
            "down_press_release": "down"
        };
        const interpreted = map[rawValue];
        if (!interpreted) {
            console.debug(`Unhandled raw value: ${rawValue} from ${rawItemName}`);
            return;
        }
        targetItem.postUpdate(`${interpreted}-${Date.now()}`);
        console.info(`Mapped ${rawItemName} [${rawValue}] → ${targetItemName} [${interpreted}]`);
    }
});

:repeat_button: JS Rule 2 – Scene Cycling via Metadata

The second rule listens for updates on all ButtonSceneGroup items. It parses the sceneMap metadata and cycles through the mapped scene list based on the current value of a shared SceneSelector item.

For example:

  • down:RELAX|RELAX_DX will cycle between RELAX and RELAX_DX on consecutive presses.

Source (JSR223)

'use strict';

const { rules, triggers, items } = require('openhab');

// ---------------------------------------------------------------------------
// Rule #2: Dynamically map ButtonScene action map to SceneSelector command
// ---------------------------------------------------------------------------
// This rule listens for changes in items with in "ButtonSceneGroup" and maps
// the interpreted actions to scene commands based on the item "sceneMap" 
// metadata. If the item has a "|" it will interpret it as a list of scenes.
// The rule will cycle through the scenes based on the current state of the
rules.JSRule({
    name: "Scene Trigger from ButtonScene Actions",
    description: "Maps ButtonScene actions to SceneSelector with cycling",
    id: "sceneTriggerFromButtonScene",
    triggers: [triggers.GroupStateChangeTrigger("ButtonSceneGroup")],
    execute: (event) => {
        const buttonItem = event.itemName;
        const action = event.newState.split("-")[0];
        const currentScene = items["SceneSelector"].state;
        const metadata = items.metadata.getMetadata(buttonItem, "sceneMap").value;
        if (!metadata) {
            console.warn(`No mapping found for ${buttonItem}`);
            return;
        }
        const sceneMap = Object.fromEntries(
            metadata.split(",").map(pair => {
                const [key, value] = pair.split(":");
                return [key.trim(), value.split("|").map(s => s.trim())];
            })
        );
        const scenes = sceneMap[action];
        if (!scenes) {
            console.debug(`Unhandled action: ${action}`);
            return;
        }
        // If current scene is in the array select next, otherwise first
        const index = scenes.indexOf(currentScene);
        console.info(`Current scene: ${currentScene} (${index})`);
        const next = index === -1 ? scenes[0] : scenes[(index + 1) % scenes.length];
        // Trigger
        console.info(`Triggering scene ${currentScene} => ${next} from ${buttonItem} with action ${action}`);
        items["SceneSelector"].sendCommand(next);
    }
});

:clapper_board: Scene Executor Rule

The actual work is then done by a simple DSL rule that reacts to SceneSelector commands:

rule "Scene managment"
when
    Item SceneSelector received command
then
    val scene = receivedCommand.toString
    logInfo("Scene", "Received scene: " + scene)
    switch (scene) {
        case "BRIGHT" : {
            // Do bright stuff
        }
        case "CINEMA": {
            // Do cinema stuff
        }
        ...
    }
end

:white_check_mark: Why This Pattern?

  • No duplicated logic per button
  • No hardcoded names in scripts
  • Adding a new switch = just define 2 items + metadata
  • Reusable, clean, and declarative

You should definitely add an ID and to the mapping rules in addition to the name and description. The logger names and any errors and such will use a random ID instead of something meaningful which can make debugging more challenging than necessary, especially since the IDs will change every time the .js file is loaded.

You could implement this without creating a separate rule per Item for your mapping rule. Just generate the triggers same as you do for the second rule (just change “ButtonScene” to “ButtonTrigger” in the map).

Of course, you could also just add all the Items to a Group instead of adding a tag and use a member of trigger for the one rule. You can even create the Group Item dynamically.

items.addItem({ type: 'Group', name: 'MappingGroup'});
items.getItemsByTag("ButtonTrigger").forEach(item => items.addGroups('MappingGroup'));
rules.JSRule({
  name: 'Mapper for ButtonTrigger Items',
  description: 'Maps raw input to interpreted actions for all ButtonTrigger tagged Items',
  id: 'buttonTriggerMapper'
  triggers: [ triggers.GroupStateChangeTrigger('MappingGroup') ],
  execute: (event) => {
            const rawItemName = event.itemName;
            const rawValue = event.newState;
            const actionTag = items[rawItemName].tags.find(tag => tag.startsWith("ACTION:"));
            if (!actionTag) {
                console.warn(`No ACTION tag found on ${rawItemName}`);
                return;
            }
            const targetItemName = actionTag.split(":")[1];
            const targetItem = items[targetItemName];
            if (!targetItem) {
                console.warn(`Target item ${targetItemName} not found`);
                return;
            }
            const map = {
                "on_press_release": "on",
                "on_hold_release": "on_hold",
                "off_press_release": "off",
                "off_hold_release": "off_hold",
                "up_press_release": "up",
                "down_press_release": "down"
            };
            const interpreted = map[rawValue];
            if (!interpreted) {
                console.debug(`Unhandled raw value: ${rawValue} from ${rawItemName}`);
                return;
            }
            targetItem.postUpdate(`${interpreted}-${Date.now()}`);
            console.info(`Mapped ${rawItemName} [${rawValue}] → ${targetItemName} [${interpreted}]`);
  }
})

This should do the same with only the one rule which should lower RAM and other resource requirements and potentially make it easier to adjust and manage. In addition, the advantage that would come from using Group membership instead of the original approach or generating the triggers like the above is that you can change the Group membership without reloading the rule. When creating a separate rule per Item or creating the triggers individually you have to reload the rule to pick up changes to the Items.

These could be converted to a rule template with a little work. For now, only UI rules can be rule templates but the same things is possible as shown above. However, what would have to be done is to use a GenericTrigger with a filter on the “openhab/items/**” topic and “ItemStateChangedEvents”. That would trigger the rule on all state changes for all Items.

Then a rule condition would be added to ignore events from any Item except those with the expected tag.

items.[event.itemName].tags.includes('ButtonTrigger')
1 Like

Will do thanks!

The group approach is a lot cleaner!!! I didn’t think about using a group trigger at all, will do that and update the post thanks.

Updated original post as per @rlkoshak comments:

  • Using group trigger instead of creating a rule for every item
  • Added ID
  • Using metadata instead of tags to point to the action item i guess its cleaner