From fe0f49ea63f05627ac7e3fb34a50da3c7d225dc6 Mon Sep 17 00:00:00 2001 From: jsjames Date: Sun, 26 Feb 2023 13:54:27 -0800 Subject: [PATCH] [rollershutterposition] Initial contribution (#13259) * Initial contribution Signed-off-by: Jeff James --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + .../NOTICE | 13 + .../README.md | 19 ++ .../pom.xml | 17 ++ .../src/main/feature/feature.xml | 9 + .../RollerShutterPositionConstants.java | 39 +++ .../RollerShutterPositionProfile.java | 282 ++++++++++++++++++ .../RollerShutterPositionProfileFactory.java | 58 ++++ .../config/RollerShutterPositionConfig.java | 30 ++ .../OH-INF/config/rollerShutterPosition.xml | 27 ++ .../OH-INF/i18n/rollershutter.properties | 6 + bundles/pom.xml | 1 + 13 files changed, 507 insertions(+) create mode 100644 bundles/org.openhab.transform.rollershutterposition/NOTICE create mode 100644 bundles/org.openhab.transform.rollershutterposition/README.md create mode 100644 bundles/org.openhab.transform.rollershutterposition/pom.xml create mode 100644 bundles/org.openhab.transform.rollershutterposition/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/RollerShutterPositionConstants.java create mode 100644 bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/RollerShutterPositionProfile.java create mode 100644 bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/RollerShutterPositionProfileFactory.java create mode 100644 bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/config/RollerShutterPositionConfig.java create mode 100644 bundles/org.openhab.transform.rollershutterposition/src/main/resources/OH-INF/config/rollerShutterPosition.xml create mode 100644 bundles/org.openhab.transform.rollershutterposition/src/main/resources/OH-INF/i18n/rollershutter.properties diff --git a/CODEOWNERS b/CODEOWNERS index 3aa65f284..f1f204fa4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -399,6 +399,7 @@ /bundles/org.openhab.transform.jsonpath/ @clinique /bundles/org.openhab.transform.map/ @openhab/add-ons-maintainers /bundles/org.openhab.transform.regex/ @openhab/add-ons-maintainers +/bundles/org.openhab.transform.rollershutterposition/ @jsjames /bundles/org.openhab.transform.scale/ @clinique /bundles/org.openhab.transform.xpath/ @openhab/add-ons-maintainers /bundles/org.openhab.transform.xslt/ @openhab/add-ons-maintainers diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index d446bf5b4..d74ffb240 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -1986,6 +1986,11 @@ org.openhab.transform.regex ${project.version} + + org.openhab.addons.bundles + org.openhab.transform.rollershutterposition + ${project.version} + org.openhab.addons.bundles org.openhab.transform.scale diff --git a/bundles/org.openhab.transform.rollershutterposition/NOTICE b/bundles/org.openhab.transform.rollershutterposition/NOTICE new file mode 100644 index 000000000..38d625e34 --- /dev/null +++ b/bundles/org.openhab.transform.rollershutterposition/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.transform.rollershutterposition/README.md b/bundles/org.openhab.transform.rollershutterposition/README.md new file mode 100644 index 000000000..dd1a0bf22 --- /dev/null +++ b/bundles/org.openhab.transform.rollershutterposition/README.md @@ -0,0 +1,19 @@ +# Rollershutter Position Emulation Profile Service + +The Rollershutter Position emulates absolute position setting for Rollershutter devices which only support basic UP/DOWN/STOP commands. +This allows a Rollershutter to be set to an absolution position from 0..100 even if the controller does not support this feature (i.e. Somfy controllers). + +The logic code used for this profile service was adapted from Tarag Gautier's JavaScript implementation VASRollershutter.js. +By implementing as a profile, it eliminates the need for setting up a jsr233 js environment and simplifies the configuration. + +## Configuration + +To use this profile, simply include the profile on the Rollershutter item which is assigned to the Rollershutter channel. +The parameters and are the time it takes for the Rollershutter to fully extend or close in seconds. +The precision parameter can be used to specify the minimum movement that can be made. +This is useful when latencies in the system limit prevent very small movements and will reduce the accuracy of the position estimation. + +```java +Rollershutter { channel=""[profile="rollershutter:position", uptime=, downtime=, precision=]]} +``` + diff --git a/bundles/org.openhab.transform.rollershutterposition/pom.xml b/bundles/org.openhab.transform.rollershutterposition/pom.xml new file mode 100644 index 000000000..aed1db8b5 --- /dev/null +++ b/bundles/org.openhab.transform.rollershutterposition/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 4.0.0-SNAPSHOT + + + org.openhab.transform.rollershutterposition + + openHAB Add-ons :: Bundles :: Transformation Service :: Roller Shutter Position + + diff --git a/bundles/org.openhab.transform.rollershutterposition/src/main/feature/feature.xml b/bundles/org.openhab.transform.rollershutterposition/src/main/feature/feature.xml new file mode 100644 index 000000000..f442520f8 --- /dev/null +++ b/bundles/org.openhab.transform.rollershutterposition/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.transform.rollershutterposition/${project.version} + + diff --git a/bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/RollerShutterPositionConstants.java b/bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/RollerShutterPositionConstants.java new file mode 100644 index 000000000..b0cc6ec64 --- /dev/null +++ b/bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/RollerShutterPositionConstants.java @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.transform.rollershutterposition.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.profiles.ProfileTypeUID; +import org.openhab.core.transform.TransformationService; + +/** + * The {@link RollerShutterPositionConstants} class to define transform constants + * used across the whole binding. + * + * @author Jeff James - Initial contribution + */ +@NonNullByDefault +public class RollerShutterPositionConstants { + + // Profile Type UID + public static final ProfileTypeUID PROFILE_TYPE_UID = new ProfileTypeUID( + TransformationService.TRANSFORM_PROFILE_SCOPE, "ROLLERSHUTTERPOSITION"); + + // Parameters + public static final String UPTIME_PARAM = "uptime"; + public static final String DOWNTIME_PARAM = "downtime"; + public static final String PRECISION_PARAM = "precision"; + + public static final int POSITION_UPDATE_PERIOD_MILLISECONDS = 800; + public static final int DEFAULT_PRECISION = 5; +} diff --git a/bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/RollerShutterPositionProfile.java b/bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/RollerShutterPositionProfile.java new file mode 100644 index 000000000..192a544b3 --- /dev/null +++ b/bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/RollerShutterPositionProfile.java @@ -0,0 +1,282 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.transform.rollershutterposition.internal; + +import static org.openhab.transform.rollershutterposition.internal.RollerShutterPositionConstants.*; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.common.ThreadPoolManager; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.StopMoveType; +import org.openhab.core.library.types.UpDownType; +import org.openhab.core.thing.profiles.ProfileCallback; +import org.openhab.core.thing.profiles.ProfileContext; +import org.openhab.core.thing.profiles.ProfileTypeUID; +import org.openhab.core.thing.profiles.StateProfile; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.transform.rollershutterposition.internal.config.RollerShutterPositionConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Profile to implement the RollerShutterPosition ItemChannelLink + * + * @author Jeff James - Initial contribution + * + * Core logic in this module has been heavily adapted from Tarag Gautier js script implementation + * VASRollershutter.js + */ +@NonNullByDefault +public class RollerShutterPositionProfile implements StateProfile { + private static final String PROFILE_THREADPOOL_NAME = "profile-rollershutterposition"; + private final Logger logger = LoggerFactory.getLogger(RollerShutterPositionProfile.class); + + private final ProfileCallback callback; + RollerShutterPositionConfig configuration; + + private int position = 0; // current position of the roller shutter (assumes 0 when system starts) + private int targetPosition; + private boolean isValidConfiguration = false; + private Instant movingSince = Instant.MIN; + private UpDownType direction = UpDownType.DOWN; + + private final ScheduledExecutorService scheduler = ThreadPoolManager.getScheduledPool(PROFILE_THREADPOOL_NAME); + protected @Nullable ScheduledFuture stopTimer = null; + protected @Nullable ScheduledFuture updateTimer = null; + + public RollerShutterPositionProfile(final ProfileCallback callback, final ProfileContext context) { + this.callback = callback; + this.configuration = context.getConfiguration().as(RollerShutterPositionConfig.class); + + if (configuration.uptime == 0) { + logger.info("Profile paramater {} must not be 0", UPTIME_PARAM); + return; + } + + if (configuration.downtime == 0) { + configuration.downtime = configuration.uptime; + } + + if (configuration.precision == 0) { + configuration.precision = DEFAULT_PRECISION; + } + + this.isValidConfiguration = true; + + logger.debug("Profile configured with '{}'='{}' ms, '{}'={} ms, '{}'={}", UPTIME_PARAM, configuration.uptime, + DOWNTIME_PARAM, configuration.downtime, PRECISION_PARAM, configuration.precision); + } + + @Override + public ProfileTypeUID getProfileTypeUID() { + return PROFILE_TYPE_UID; + } + + @Override + public void onCommandFromItem(Command command) { + logger.debug("onCommandFromItem: {}", command); + + // pass through command if profile has not been configured properly + if (!isValidConfiguration) { + callback.handleCommand(command); + return; + } + + if (command instanceof UpDownType) { + if (command == UpDownType.UP) { + moveTo(0); + } else if (command == UpDownType.DOWN) { + moveTo(100); + } + } else if (command instanceof StopMoveType) { + stop(); + } else { + moveTo(((PercentType) command).intValue()); + } + } + + private boolean isMoving() { + return (!movingSince.equals(Instant.MIN)); + } + + private void moveTo(int targetPos) { + boolean alreadyMoving = false; + + if (targetPos < 0 || targetPos > 100) { + logger.debug("moveTo() position is invalid: {}", targetPos); + return; + } + + int curPos = currentPosition(); + int posOffset = targetPos - curPos; + + UpDownType newCmd; + + if (targetPos == position && !isMoving()) { + logger.debug("moveTo() position already current: {}", targetPos); + if (targetPos == 0) { // always send command if either 0 or 100 in case it is not already in that position + callback.handleCommand(UpDownType.UP); + } else if (targetPos == 100) { + callback.handleCommand(UpDownType.DOWN); + } + return; + } else if (targetPos == 0 || targetPos == 100) { + logger.debug("moveTo() bounding position"); + newCmd = targetPos == 0 ? UpDownType.UP : UpDownType.DOWN; + } else if (Math.abs(posOffset) < configuration.precision) { + callback.sendUpdate(new PercentType(position)); // update position because autoupdate will assume the + // movement happened + logger.info("moveTo() is less than the precision setting of {}", configuration.precision); + return; + } else { + newCmd = posOffset > 0 ? UpDownType.DOWN : UpDownType.UP; + } + + logger.debug("moveTo() targetPosition: {} from currentPosition: {}", targetPos, curPos); + + long time = (long) ((Math.abs(posOffset) / 100d) + * (posOffset > 0 ? (double) configuration.downtime * 1000 : (double) configuration.uptime * 1000)); + logger.debug("moveTo() computed movement offset: {} / {} / {} ms", posOffset, newCmd, time); + + if (isMoving()) { + position = curPos; // Update "starting" position if already in motion since the last move did not finish + + if (direction == newCmd) { + alreadyMoving = true; + } + } + + this.targetPosition = targetPos; + this.direction = newCmd; + this.movingSince = Instant.now(); + + if (stopTimer != null) { + Objects.requireNonNull(stopTimer).cancel(true); + } + this.stopTimer = scheduler.schedule(stopTimeoutTask, time, TimeUnit.MILLISECONDS); + + if (updateTimer != null) { + Objects.requireNonNull(updateTimer).cancel(true); + } + this.updateTimer = scheduler.scheduleWithFixedDelay(updateTimeoutTask, 0, POSITION_UPDATE_PERIOD_MILLISECONDS, + TimeUnit.MILLISECONDS); + + if (!alreadyMoving) { + logger.debug("moveTo() sending command for movement: {}, timer set in {} ms", direction, time); + callback.handleCommand(direction); + } else { + logger.debug("moveTo() updating timing but already moving in right directio: {}, timer set in {} ms", + direction, time); + } + } + + private void stop() { + callback.handleCommand(StopMoveType.STOP); + + this.position = currentPosition(); + this.movingSince = Instant.MIN; + + if (stopTimer != null) { + Objects.requireNonNull(stopTimer).cancel(true); + this.stopTimer = null; + } + if (updateTimer != null) { + Objects.requireNonNull(updateTimer).cancel(true); + this.updateTimer = null; + } + + callback.sendUpdate(new PercentType(position)); + } + + private int currentPosition() { + if (isMoving()) { + logger.trace("currentPosition() while moving"); + + // movingSince is always set if moving + long millis = movingSince.until(Instant.now(), ChronoUnit.MILLIS); + double delta = 0; + + if (direction == UpDownType.UP) { + delta = -(millis / (configuration.uptime * 1000)) * 100d; + } else { + delta = (millis / (configuration.downtime * 1000)) * 100d; + } + + return (int) Math.max(0, Math.min(100, Math.round(position + delta))); + } else { + return position; + } + } + + // Runnable task to time duration of the move to make + private Runnable stopTimeoutTask = new Runnable() { + @Override + public void run() { + if (targetPosition == 0 || targetPosition == 100) { + // Don't send stop command to re-sync position using the motor end stop + logger.debug("arrived at end position, not stopping for calibration"); + } else { + callback.handleCommand(StopMoveType.STOP); + logger.debug("arrived at position, sending STOP command"); + } + + logger.trace("stopTimeoutTask() position: {}", targetPosition); + + if (updateTimer != null) { + Objects.requireNonNull(updateTimer).cancel(true); + updateTimer = null; + } + + movingSince = Instant.MIN; + position = targetPosition; + targetPosition = -1; + callback.sendUpdate(new PercentType(position)); + } + }; + + // Runnable task to update the item on position while the roller shutter is moving + private Runnable updateTimeoutTask = new Runnable() { + @Override + public void run() { + if (isMoving()) { + int pos = currentPosition(); + if (pos < 0 || pos > 100) { + return; + } + callback.sendUpdate(new PercentType(pos)); + logger.trace("updateTimeoutTask(): {}", pos); + } + } + }; + + @Override + public void onStateUpdateFromItem(State state) { + } + + @Override + public void onCommandFromHandler(Command command) { + } + + @Override + public void onStateUpdateFromHandler(State state) { + } +} diff --git a/bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/RollerShutterPositionProfileFactory.java b/bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/RollerShutterPositionProfileFactory.java new file mode 100644 index 000000000..19faf866c --- /dev/null +++ b/bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/RollerShutterPositionProfileFactory.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.transform.rollershutterposition.internal; + +import static org.openhab.transform.rollershutterposition.internal.RollerShutterPositionConstants.PROFILE_TYPE_UID; + +import java.util.Collection; +import java.util.List; +import java.util.Locale; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.library.CoreItemFactory; +import org.openhab.core.thing.profiles.Profile; +import org.openhab.core.thing.profiles.ProfileCallback; +import org.openhab.core.thing.profiles.ProfileContext; +import org.openhab.core.thing.profiles.ProfileFactory; +import org.openhab.core.thing.profiles.ProfileType; +import org.openhab.core.thing.profiles.ProfileTypeBuilder; +import org.openhab.core.thing.profiles.ProfileTypeProvider; +import org.openhab.core.thing.profiles.ProfileTypeUID; +import org.osgi.service.component.annotations.Component; + +/** + * {@link RollerShutterPositionProfileFactory} Factory to create the profile + * + * @author Jeff James - Initial contribution + */ +@NonNullByDefault +@Component(service = { ProfileFactory.class, ProfileTypeProvider.class }) +public class RollerShutterPositionProfileFactory implements ProfileFactory, ProfileTypeProvider { + @Override + public Collection getProfileTypes(@Nullable Locale locale) { + return List.of(ProfileTypeBuilder.newState(PROFILE_TYPE_UID, PROFILE_TYPE_UID.getId()) + .withSupportedItemTypes(CoreItemFactory.ROLLERSHUTTER).build()); + } + + @Override + public @Nullable Profile createProfile(ProfileTypeUID profileTypeUID, ProfileCallback callback, + ProfileContext profileContext) { + return new RollerShutterPositionProfile(callback, profileContext); + } + + @Override + public Collection getSupportedProfileTypeUIDs() { + return List.of(PROFILE_TYPE_UID); + } +} diff --git a/bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/config/RollerShutterPositionConfig.java b/bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/config/RollerShutterPositionConfig.java new file mode 100644 index 000000000..f9ba83c18 --- /dev/null +++ b/bundles/org.openhab.transform.rollershutterposition/src/main/java/org/openhab/transform/rollershutterposition/internal/config/RollerShutterPositionConfig.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.transform.rollershutterposition.internal.config; + +import static org.openhab.transform.rollershutterposition.internal.RollerShutterPositionConstants.DEFAULT_PRECISION; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link RollerShutterPositionConfig} class contains the parameters for RollerShutterPosition + * + * @author Jeff James - initial contribution + * + */ +@NonNullByDefault +public class RollerShutterPositionConfig { + public float uptime; // uptime in seconds (set by param) + public float downtime; // downtime in seconds (set by param) + public int precision = DEFAULT_PRECISION; // minimum movement +} diff --git a/bundles/org.openhab.transform.rollershutterposition/src/main/resources/OH-INF/config/rollerShutterPosition.xml b/bundles/org.openhab.transform.rollershutterposition/src/main/resources/OH-INF/config/rollerShutterPosition.xml new file mode 100644 index 000000000..79fea2939 --- /dev/null +++ b/bundles/org.openhab.transform.rollershutterposition/src/main/resources/OH-INF/config/rollerShutterPosition.xml @@ -0,0 +1,27 @@ + + + + + + + Time it takes for roller shutter to fully open (in seconds). + true + + + + Time it takes for roller shutter to extend the full length (in seconds). Defaults to Up Time if not + specified. + + + + Minimum movement (in percent) that can be requested. If the requested change is less than this amount, + no action will be taken. This may be required for systems where there is a lag in the stop command and + consequently + it is not possible for fine control of movement. (default = 5) + 5 + + + diff --git a/bundles/org.openhab.transform.rollershutterposition/src/main/resources/OH-INF/i18n/rollershutter.properties b/bundles/org.openhab.transform.rollershutterposition/src/main/resources/OH-INF/i18n/rollershutter.properties new file mode 100644 index 000000000..8ca40892c --- /dev/null +++ b/bundles/org.openhab.transform.rollershutterposition/src/main/resources/OH-INF/i18n/rollershutter.properties @@ -0,0 +1,6 @@ +profile.config.rollershutter.position.downtime.label = Down Time +profile.config.rollershutter.position.downtime.description = Time it takes for roller shutter to extend the full length (in seconds). Defaults to Up Time if not specified. +profile.config.rollershutter.position.precision.label = Precision +profile.config.rollershutter.position.precision.description = Minimum movement (in percent) that can be requested. If the requested change is less than this amount, no action will be taken. This may be required for systems where there is a lag in the stop command and consequently it is not possible for fine control of movement. (default = 5) +profile.config.rollershutter.position.uptime.label = Up Time +profile.config.rollershutter.position.uptime.description = Time it takes for roller shutter to fully open (in seconds). diff --git a/bundles/pom.xml b/bundles/pom.xml index 7ceb398af..acbacfc1b 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -39,6 +39,7 @@ org.openhab.transform.jsonpath org.openhab.transform.map org.openhab.transform.regex + org.openhab.transform.rollershutterposition org.openhab.transform.scale org.openhab.transform.xpath org.openhab.transform.xslt