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.
Structure Overview
For each button, I define two String
items:
-
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 metadataactionItem="ButtonHueSwitch01Action"
and group it in a group calledButtonTriggerGroup
so i can listen for events -
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 groupButtonSceneGroup
and carries a metadata blocksceneMap
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.
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}]`);
}
});
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 betweenRELAX
andRELAX_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);
}
});
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
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