diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/.gitignore b/bundles/org.openhab.binding.mqtt.homeassistant/.gitignore deleted file mode 100644 index 11bcd382c29..00000000000 --- a/bundles/org.openhab.binding.mqtt.homeassistant/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/src/main/python/**/__pycache__/ diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/DEVELOPMENT.md b/bundles/org.openhab.binding.mqtt.homeassistant/DEVELOPMENT.md deleted file mode 100644 index b73513dfca7..00000000000 --- a/bundles/org.openhab.binding.mqtt.homeassistant/DEVELOPMENT.md +++ /dev/null @@ -1,12 +0,0 @@ -src/main/python is forked from [Home Assistant core](https://github.com/home-assistant/core), in order to have near-perfect compatibility with for the Jinja templates. -It was forked from the dev branch as of 2025-04-23, corresponding to the 2025.4.3 release of Home Assistant. - -The following alterations have been made: -- Code not specifically used by this binding has been stripped out. -- Generics and some type checks have been removed, being incompatible with GraalPy 24.2.0, which roughly corresponds with Python 3.11. -- The standard json library is used, instead of orjson, since orjson requires a Rust compiler and would pre-compile native extensions for the architecture of the build environment, and embed them in the JAR, thus making it incompatible with other runtime architectures. - AFAICT this should still be fully compatible, since Home Assistant explicitly sets multiple options in order to disable features that are orjson specific. -- ciso8601 is not included, since it has a native extension. Instead, the stdlib parser is used. -- All asynchronous processing has been removed; the Java side threading model dominates. -- The `hass` variable has been removed from templates; Limited templates (which are what MQTT integrations use) set it to `None` anyway. -- Limited and strict template options have been removed; it's assumed that templates are limited and not strict. diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/NOTICE b/bundles/org.openhab.binding.mqtt.homeassistant/NOTICE index 54479cdbefd..38d625e3492 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/NOTICE +++ b/bundles/org.openhab.binding.mqtt.homeassistant/NOTICE @@ -11,10 +11,3 @@ https://www.eclipse.org/legal/epl-2.0/. == Source Code https://github.com/openhab/openhab-addons - -== Third-party Content - -Parts of this code (src/main/python/) have been forked. -* License: Apache License 2.0 -* Project: https://www.home-assistant.io/ -* Source: https://github.com/home-assistant/core diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/bnd.bnd b/bundles/org.openhab.binding.mqtt.homeassistant/bnd.bnd deleted file mode 100644 index 9b692df1603..00000000000 --- a/bundles/org.openhab.binding.mqtt.homeassistant/bnd.bnd +++ /dev/null @@ -1,21 +0,0 @@ -Bundle-SymbolicName: ${project.artifactId} -DynamicImport-Package: * -Import-Package: org.openhab.core.automation.module.script,org.openhab.core.items,org.openhab.core.library.types,javax.management,javax.script,javax.xml.datatype,javax.xml.stream;version="[1.0,2)",org.osgi.framework;version="[1.8,2)",org.slf4j;version="[1.7,2)" -Require-Capability: - osgi.extender:= - filter:="(osgi.extender=osgi.serviceloader.processor)", - osgi.serviceloader:= - filter:="(osgi.serviceloader=org.graalvm.polyglot.impl.AbstractPolyglotImpl)"; - cardinality:=multiple -Require-Bundle: org.graalvm.sdk.collections;bundle-version="24.2.0",\ - org.graalvm.sdk.jniutils;bundle-version="24.2.0",\ - org.graalvm.sdk.nativeimage;bundle-version="24.2.0",\ - org.graalvm.sdk.word;bundle-version="24.2.0",\ - org.graalvm.shadowed.icu4j;bundle-version="24.2.0",\ - org.graalvm.truffle.truffle-compiler;bundle-version="24.2.0",\ - org.graalvm.truffle.truffle-runtime;bundle-version="24.2.0" - -SPI-Provider: * -SPI-Consumer: * - --fixupmessages "Classes found in the wrong directory"; restrict:=error; is:=warning diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/noEmbedDependencies.profile b/bundles/org.openhab.binding.mqtt.homeassistant/noEmbedDependencies.profile new file mode 100644 index 00000000000..e69de29bb2d diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/pom.xml b/bundles/org.openhab.binding.mqtt.homeassistant/pom.xml index 3829b6f2dd0..df22ab61e78 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/pom.xml +++ b/bundles/org.openhab.binding.mqtt.homeassistant/pom.xml @@ -14,12 +14,6 @@ openHAB Add-ons :: Bundles :: MQTT HomeAssistant Convention - - 24.2.0 - - bin/python3 - - org.openhab.addons.bundles @@ -33,182 +27,42 @@ ${project.version} provided + + com.google.guava + guava + 33.3.1-jre + test + - - org.graalvm.polyglot - polyglot - ${graalpy.version} - - - - org.graalvm.regex - regex - ${graalpy.version} - - - - org.graalvm.polyglot - python-community - ${graalpy.version} - pom + org.openhab.osgiify + com.hubspot.jinjava.jinjava + 2.7.4 + compile - org.graalvm.python - python-embedding - ${graalpy.version} + org.openhab.osgiify + com.google.re2j.re2j + 1.2 + compile + + + ch.obermuhlner + big-math + 2.3.2 + compile + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + ${jackson.version} + compile + + + org.openhab.osgiify + com.hubspot.immutables.immutables-exceptions + 1.9 + compile - - - - - org.apache.maven.plugins - maven-dependency-plugin - - - embed-dependencies - - unpack-dependencies - - - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.5.2 - - -noverify - - - - maven-resources-plugin - 3.3.1 - - - copy-homeassistant-python - - copy-resources - - generate-resources - - ${project.build.directory}/classes/GRAALPY-VFS/${project.groupId}/${project.artifactId}/src - - - src/main/python - - - - - - - - org.graalvm.python - graalpy-maven-plugin - ${graalpy.version} - - - install-python-packages - - process-graalpy-resources - - generate-resources - - GRAALPY-VFS/${project.groupId}/${project.artifactId} - - awesomeversion==24.6.0 - Jinja2==3.1.6 - python-slugify==8.0.4 - - - - - - generate-python-filelist - - process-graalpy-resources - - process-resources - - GRAALPY-VFS/${project.groupId}/${project.artifactId} - - awesomeversion==24.6.0 - Jinja2==3.1.6 - python-slugify==8.0.4 - - - - - - - org.codehaus.mojo - exec-maven-plugin - 3.1.0 - - - compile-python - - exec - - generate-resources - - ${project.build.directory}/classes/GRAALPY-VFS/${project.groupId}/${project.artifactId}/venv/${graalpy.executable} - - -m - compileall - ${project.build.directory}/classes/GRAALPY-VFS/${project.groupId}/${project.artifactId}/src - - - - - - - org.apache.maven.plugins - maven-shade-plugin - 3.6.0 - - - - shade - - package - - - - org.graalvm.llvm:llvm-api - org.graalvm.polyglot:polyglot - org.graalvm.python:python-language - org.graalvm.python:python-resources - org.graalvm.regex:regex - org.graalvm.tools:profiler-tool - org.graalvm.truffle:truffle-api - org.graalvm.truffle:truffle-nfi - org.graalvm.truffle:truffle-nfi-libffi - - - false - - - - - - - - - - - - - - - windows - - - - Scripts/python - - - diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/feature/feature.xml b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/feature/feature.xml index 7fdf408c336..845c8065384 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/feature/feature.xml +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/feature/feature.xml @@ -7,14 +7,11 @@ openhab-runtime-base openhab-transport-mqtt openhab.tp-commons-net - mvn:org.openhab.osgiify/org.graalvm.sdk.collections/24.2.0 - mvn:org.openhab.osgiify/org.graalvm.sdk.jniutils/24.2.0 - mvn:org.openhab.osgiify/org.graalvm.sdk.nativeimage/24.2.0 - mvn:org.openhab.osgiify/org.graalvm.sdk.word/24.2.0 - mvn:org.openhab.osgiify/org.graalvm.shadowed.icu4j/24.2.0 - mvn:org.openhab.osgiify/org.graalvm.shadowed.xz/24.2.0 - mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-compiler/24.2.0 - mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-runtime/24.2.0 + mvn:org.openhab.osgiify/com.hubspot.jinjava.jinjava/2.7.4 + mvn:org.openhab.osgiify/com.google.re2j.re2j/1.2 + mvn:ch.obermuhlner/big-math/2.3.2 + mvn:com.fasterxml.jackson.datatype/jackson-datatype-jdk8/${jackson.version} + mvn:org.openhab.osgiify/com.hubspot.immutables.immutables-exceptions/1.9 mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.generic/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.homeassistant/${project.version} diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/generic/internal/MqttThingHandlerFactory.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/generic/internal/MqttThingHandlerFactory.java index f7604d0172a..808f272c3a5 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/generic/internal/MqttThingHandlerFactory.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/generic/internal/MqttThingHandlerFactory.java @@ -20,7 +20,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.mqtt.generic.MqttChannelStateDescriptionProvider; import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider; -import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantPythonBridge; +import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantJinjaFunctionLibrary; import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantStateDescriptionProvider; import org.openhab.binding.mqtt.homeassistant.internal.handler.HomeAssistantThingHandler; import org.openhab.core.i18n.UnitProvider; @@ -34,6 +34,8 @@ import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; +import com.hubspot.jinjava.Jinjava; + /** * The {@link MqttThingHandlerFactory} is responsible for creating things and thing * handlers. @@ -46,8 +48,8 @@ public class MqttThingHandlerFactory extends BaseThingHandlerFactory { private final MqttChannelTypeProvider typeProvider; private final MqttChannelStateDescriptionProvider stateDescriptionProvider; private final ChannelTypeRegistry channelTypeRegistry; + private final Jinjava jinjava = new Jinjava(); private final UnitProvider unitProvider; - private final HomeAssistantPythonBridge python; private static final Set SUPPORTED_THING_TYPES_UIDS = Stream .of(MqttBindingConstants.HOMEASSISTANT_MQTT_THING).collect(Collectors.toSet()); @@ -60,7 +62,8 @@ public class MqttThingHandlerFactory extends BaseThingHandlerFactory { this.stateDescriptionProvider = stateDescriptionProvider; this.channelTypeRegistry = channelTypeRegistry; this.unitProvider = unitProvider; - this.python = new HomeAssistantPythonBridge(); + + HomeAssistantJinjaFunctionLibrary.register(jinjava.getGlobalContext()); } @Override @@ -79,12 +82,12 @@ public class MqttThingHandlerFactory extends BaseThingHandlerFactory { if (supportsThingType(thingTypeUID)) { return new HomeAssistantThingHandler(thing, this, typeProvider, stateDescriptionProvider, - channelTypeRegistry, python, unitProvider, 10000, 2000); + channelTypeRegistry, jinjava, unitProvider, 10000, 2000); } return null; } - public HomeAssistantPythonBridge getPython() { - return python; + public Jinjava getJinjava() { + return jinjava; } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java index cbec34753d9..24a9c2529e9 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java @@ -136,7 +136,6 @@ public class ComponentChannel { private @Nullable String stateTopic; private @Nullable String commandTopic; - private boolean parseCommandValueAsInteger; private boolean retain; private boolean trigger; private boolean isAdvanced; @@ -207,11 +206,6 @@ public class ComponentChannel { return this; } - public Builder parseCommandValueAsInteger(boolean parseCommandValueAsInteger) { - this.parseCommandValueAsInteger = parseCommandValueAsInteger; - return this; - } - public Builder trigger(boolean trigger) { this.trigger = trigger; return this; @@ -271,13 +265,13 @@ public class ComponentChannel { String localTemplateIn = templateIn; if (localTemplateIn != null) { - incomingTransformation = new HomeAssistantChannelTransformation(component.getPython(), component, - localTemplateIn, false); + incomingTransformation = new HomeAssistantChannelTransformation(component.getJinjava(), component, + localTemplateIn); } String localTemplateOut = templateOut; if (localTemplateOut != null) { - outgoingTransformation = new HomeAssistantChannelTransformation(component.getPython(), component, - localTemplateOut, true, parseCommandValueAsInteger); + outgoingTransformation = new HomeAssistantChannelTransformation(component.getJinjava(), component, + localTemplateOut); } channelState = new HomeAssistantChannelState(channelConfigBuilder.build(), channelUID, valueState, diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/DiscoverComponents.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/DiscoverComponents.java index 3c187f3e7f2..9663e2d2243 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/DiscoverComponents.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/DiscoverComponents.java @@ -38,6 +38,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.gson.Gson; +import com.hubspot.jinjava.Jinjava; /** * Responsible for subscribing to the HomeAssistant MQTT components wildcard topic, either @@ -56,7 +57,7 @@ public class DiscoverComponents implements MqttMessageSubscriber { protected final CompletableFuture<@Nullable Void> discoverFinishedFuture = new CompletableFuture<>(); private final Gson gson; - private final HomeAssistantPythonBridge python; + private final Jinjava jinjava; private final UnitProvider unitProvider; private @Nullable ScheduledFuture stopDiscoveryFuture; @@ -83,13 +84,13 @@ public class DiscoverComponents implements MqttMessageSubscriber { */ public DiscoverComponents(ThingUID thingUID, ScheduledExecutorService scheduler, ChannelStateUpdateListener channelStateUpdateListener, HomeAssistantChannelLinkageChecker linkageChecker, - AvailabilityTracker tracker, Gson gson, HomeAssistantPythonBridge python, UnitProvider unitProvider) { + AvailabilityTracker tracker, Gson gson, Jinjava jinjava, UnitProvider unitProvider) { this.thingUID = thingUID; this.scheduler = scheduler; this.updateListener = channelStateUpdateListener; this.linkageChecker = linkageChecker; this.gson = gson; - this.python = python; + this.jinjava = jinjava; this.unitProvider = unitProvider; this.tracker = tracker; } @@ -107,7 +108,7 @@ public class DiscoverComponents implements MqttMessageSubscriber { if (config.length() > 0) { try { component = ComponentFactory.createComponent(thingUID, haID, config, updateListener, linkageChecker, - tracker, scheduler, gson, python, unitProvider); + tracker, scheduler, gson, jinjava, unitProvider); component.setConfigSeen(); logger.trace("Found HomeAssistant component {}", haID); diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformation.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformation.java index 484df982c3b..f29b05622b2 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformation.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformation.java @@ -12,109 +12,137 @@ */ package org.openhab.binding.mqtt.homeassistant.internal; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; import java.util.Map; +import java.util.Map.Entry; import java.util.Optional; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.graalvm.polyglot.PolyglotException; -import org.graalvm.polyglot.Value; import org.openhab.binding.mqtt.homeassistant.internal.component.AbstractComponent; import org.openhab.core.thing.binding.generic.ChannelTransformation; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.FatalTemplateErrorsException; +import com.hubspot.jinjava.interpret.InvalidInputException; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; + /** * Provides a channel transformation for a Home Assistant channel with a * Jinja2 template, providing the additional context and extensions required by Home Assistant + * Based in part on the JinjaTransformationService * * @author Cody Cutrer - Initial contribution */ @NonNullByDefault public class HomeAssistantChannelTransformation extends ChannelTransformation { - // These map to PayloadSentinen.NONE and PayloadSentinel.DEFAULT in mqtt/models.py - // NONE is used to indicate that errors should be ignored, and if any happen the original - // payload should be returned directly - public static final String PAYLOAD_SENTINEL_NONE = "none"; - public static final String PAYLOAD_SENTINEL_DEFAULT = "default"; + public static class UndefinedException extends InvalidInputException { + public UndefinedException(JinjavaInterpreter interpreter) { + super(interpreter, "is_defined", "Value is undefined"); + } + } private final Logger logger = LoggerFactory.getLogger(HomeAssistantChannelTransformation.class); - private final HomeAssistantPythonBridge python; - private final AbstractComponent component; - private final Value template; - private final boolean command; - private final String defaultValue; - private final boolean parseValueAsInteger; + private final Jinjava jinjava; + private final AbstractComponent component; + private final String template; + private final ObjectMapper objectMapper = new ObjectMapper(); - public HomeAssistantChannelTransformation(HomeAssistantPythonBridge python, AbstractComponent component, - String template, boolean command) { - this(python, component, template, command, PAYLOAD_SENTINEL_NONE, false); - } - - public HomeAssistantChannelTransformation(HomeAssistantPythonBridge python, AbstractComponent component, - String template, boolean command, boolean parseValueAsInteger) { - this(python, component, template, command, PAYLOAD_SENTINEL_NONE, parseValueAsInteger); - } - - public HomeAssistantChannelTransformation(HomeAssistantPythonBridge python, AbstractComponent component, - String template, String defaultValue) { - this(python, component, template, false, defaultValue, false); - } - - private HomeAssistantChannelTransformation(HomeAssistantPythonBridge python, AbstractComponent component, - String template, boolean command, String defaultValue, boolean parseValueAsInteger) { + public HomeAssistantChannelTransformation(Jinjava jinjava, AbstractComponent component, String template) { super((String) null); - this.python = python; + this.jinjava = jinjava; this.component = component; - this.command = command; - this.template = command ? python.newCommandTemplate(template) : python.newValueTemplate(template); - this.defaultValue = defaultValue; - this.parseValueAsInteger = parseValueAsInteger; + this.template = template; } @Override public boolean isEmpty() { - return false; + return template.isEmpty(); } @Override public Optional apply(String value) { - Object objValue = value; - if (parseValueAsInteger) { - try { - objValue = (int) Float.parseFloat(value); - } catch (NumberFormatException e) { - logger.warn("Failed to parse value {} as integer: {}", value, e.getMessage()); + return apply(template, value); + } + + public Optional apply(String template, String value) { + Map bindings = new HashMap<>(); + + logger.debug("about to transform '{}' by the function '{}'", value, template); + + bindings.put("value", value); + + try { + JsonNode tree = objectMapper.readTree(value); + bindings.put("value_json", toObject(tree)); + } catch (IOException e) { + // ok, then value_json is null... + } + + return apply(template, bindings); + } + + public Optional apply(String template, Map bindings) { + String transformationResult; + + try { + transformationResult = jinjava.render(template, bindings); + } catch (FatalTemplateErrorsException e) { + var error = e.getErrors().iterator(); + Exception exception = null; + if (error.hasNext()) { + exception = error.next().getException(); + } + if (exception instanceof UndefinedException) { + // They used the is_defined filter; it's expected to return null, with no warning return Optional.empty(); } - } - Object result = transform(objValue); - if (result == null) { + logger.warn("Applying template {} for component {} failed: {} ({})", template, + component.getHaID().toShortTopic(), e.getMessage(), e.getClass()); return Optional.empty(); } - return Optional.of(result.toString()); + + logger.debug("transformation resulted in '{}'", transformationResult); + + return Optional.of(transformationResult); } - public @Nullable String transform(Object value) { - try { - return command ? python.renderCommandTemplate(template, value) - : python.renderValueTemplate(template, value, defaultValue); - } catch (PolyglotException e) { - logger.warn("Applying template for component {} failed: {}", component.getHaID().toShortTopic(), - e.getMessage(), e); - return null; - } - } - - public @Nullable String transform(Object value, Map variables) { - try { - return command ? python.renderCommandTemplate(template, value, variables) - : python.renderValueTemplate(template, value, defaultValue, variables); - } catch (PolyglotException e) { - logger.warn("Applying template for component {} failed: {}", component.getHaID().toShortTopic(), - e.getMessage(), e); - return null; + private static @Nullable Object toObject(JsonNode node) { + switch (node.getNodeType()) { + case ARRAY: { + List<@Nullable Object> result = new ArrayList<>(); + for (JsonNode el : node) { + result.add(toObject(el)); + } + return result; + } + case NUMBER: + return node.decimalValue(); + case OBJECT: { + Map result = new HashMap<>(); + Iterator> it = node.fields(); + while (it.hasNext()) { + Entry field = it.next(); + result.put(field.getKey(), toObject(field.getValue())); + } + return result; + } + case STRING: + return node.asText(); + case BOOLEAN: + return node.asBoolean(); + case NULL: + default: + return null; } } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantJinjaFunctionLibrary.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantJinjaFunctionLibrary.java new file mode 100644 index 00000000000..de4a822a22c --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantJinjaFunctionLibrary.java @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2010-2025 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.binding.mqtt.homeassistant.internal; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +import com.google.re2j.Matcher; +import com.google.re2j.Pattern; +import com.google.re2j.PatternSyntaxException; +import com.hubspot.jinjava.interpret.Context; +import com.hubspot.jinjava.interpret.InterpretException; +import com.hubspot.jinjava.interpret.InvalidArgumentException; +import com.hubspot.jinjava.interpret.InvalidReason; +import com.hubspot.jinjava.interpret.JinjavaInterpreter; +import com.hubspot.jinjava.interpret.TemplateSyntaxException; +import com.hubspot.jinjava.lib.filter.Filter; +import com.hubspot.jinjava.lib.fn.ELFunctionDefinition; +import com.hubspot.jinjava.util.ObjectTruthValue; + +/** + * Contains extensions methods exposed in Jinja transformations + * + * @author Cody Cutrer - Initial contribution + */ +@NonNullByDefault +public class HomeAssistantJinjaFunctionLibrary { + public static void register(Context context) { + context.registerFunction( + new ELFunctionDefinition("", "iif", Functions.class, "iif", Object.class, Object[].class)); + context.registerFilter(new SimpleFilter("iif", Functions.class, "iif", Object.class, Object[].class)); + context.registerFilter(new IsDefinedFilter()); + context.registerFilter(new RegexFindAllFilter()); + context.registerFilter(new RegexFindAllIndexFilter()); + } + + @NonNullByDefault({}) + private static class SimpleFilter implements Filter { + private final String name; + private final Method method; + private final Class klass; + + public SimpleFilter(String name, Class klass, String methodName, Class... args) { + this.name = name; + this.klass = klass; + try { + this.method = klass.getDeclaredMethod(methodName, args); + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException(e); + } + } + + @Override + public String getName() { + return name; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, Object[] args, Map kwargs) { + Object[] allArgs = Stream.of(Arrays.stream(args), kwargs.values().stream()).flatMap(s -> s) + .toArray(Object[]::new); + + try { + return method.invoke(klass, var, allArgs); + } catch (IllegalAccessException e) { + // Not possible + return null; + } catch (InvocationTargetException e) { + throw new InterpretException(e.getMessage(), e, interpreter.getLineNumber(), interpreter.getPosition()); + } + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + // Object[] allArgs = Stream.concat(List.of(var).stream(), Arrays.stream(args)).toArray(Object[]::new); + + try { + return method.invoke(klass, var, args); + } catch (IllegalAccessException e) { + // Not possible + return null; + } catch (InvocationTargetException e) { + throw new InterpretException(e.getMessage(), e, interpreter.getLineNumber(), interpreter.getPosition()); + } + } + } + + // https://www.home-assistant.io/docs/configuration/templating/#is-defined + @NonNullByDefault({}) + private static class IsDefinedFilter implements Filter { + @Override + public String getName() { + return "is_defined"; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (var == null) { + throw new HomeAssistantChannelTransformation.UndefinedException(interpreter); + } + + return var; + } + } + + // https://www.home-assistant.io/docs/configuration/templating/#regular-expressions + // https://github.com/home-assistant/core/blob/2024.12.2/homeassistant/helpers/template.py#L2453 + @NonNullByDefault({}) + private static class RegexFindAllFilter implements Filter { + @Override + public String getName() { + return "regex_findall"; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (args.length > 2) { + throw new TemplateSyntaxException(interpreter, getName(), + "requires at most 2 arguments (regex string, ignore case)"); + } + + String find = null; + if (args.length >= 1) { + find = args[0]; + } + String ignoreCase = null; + if (args.length == 2) { + ignoreCase = args[1]; + } + + Matcher m = regexFindAll(var, interpreter, find, ignoreCase); + + List result = new ArrayList<>(); + while (m.find()) { + result.add(resultForMatcher(m)); + } + + return result; + } + + protected Object resultForMatcher(Matcher m) { + if (m.groupCount() == 0) { + return m.group(); + } else if (m.groupCount() == 1) { + return m.group(1); + } else { + List groups = new ArrayList<>(m.groupCount()); + for (int i = 1; i <= m.groupCount(); ++i) { + groups.add(m.group(i)); + } + return groups; + } + } + + protected Matcher regexFindAll(Object var, JinjavaInterpreter interpreter, String find, String ignoreCaseStr) { + String s; + if (var == null) { + s = "None"; + } else { + s = var.toString(); + } + + boolean ignoreCase = ObjectTruthValue.evaluate(ignoreCaseStr); + int flags = 0; + if (ignoreCase) { + flags = Pattern.CASE_INSENSITIVE; + } + + Pattern p; + try { + if (find instanceof String findString) { + p = Pattern.compile(findString, flags); + } else if (find == null) { + p = Pattern.compile("", flags); + } else { + throw new InvalidArgumentException(interpreter, this, InvalidReason.REGEX, 0, find); + } + + return p.matcher(s); + } catch (PatternSyntaxException e) { + throw new InvalidArgumentException(interpreter, this, InvalidReason.REGEX, 0, find); + } + } + } + + // https://www.home-assistant.io/docs/configuration/templating/#regular-expressions + // https://github.com/home-assistant/core/blob/2024.12.2/homeassistant/helpers/template.py#L2448 + @NonNullByDefault({}) + private static class RegexFindAllIndexFilter extends RegexFindAllFilter { + @Override + public String getName() { + return "regex_findall_index"; + } + + @Override + public Object filter(Object var, JinjavaInterpreter interpreter, String... args) { + if (args.length > 3) { + throw new TemplateSyntaxException(interpreter, getName(), + "requires at most 3 arguments (regex string, index, ignore case)"); + } + + String find = null; + if (args.length >= 1) { + find = args[0]; + } + int index = 0; + if (args.length >= 2) { + index = Integer.valueOf(args[1]); + if (index < 0) { + throw new InvalidArgumentException(interpreter, this, InvalidReason.POSITIVE_NUMBER, 1, args[1]); + } + } + + String ignoreCase = null; + if (args.length == 3) { + ignoreCase = args[2]; + } + + Matcher m = regexFindAll(var, interpreter, find, ignoreCase); + int i = 0; + while (i <= index) { + if (!m.find()) { + break; + } + i += 1; + } + + return resultForMatcher(m); + } + } + + private static class Functions { + // https://www.home-assistant.io/docs/configuration/templating/#immediate-if-iif + public static @Nullable Object iif(@Nullable Object value, @Nullable Object... results) { + if (results.length > 3) { + throw new IllegalArgumentException("Parameters for function 'iff' do not match"); + } + if (value == null && results.length >= 3) { + return results[2]; + } + if (ObjectTruthValue.evaluate(value)) { + if (results.length >= 1) { + return results[0]; + } + return true; + } + if (results.length >= 2) { + return results[1]; + } + return false; + } + } +} diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantPythonBridge.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantPythonBridge.java deleted file mode 100644 index 33ee9178795..00000000000 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantPythonBridge.java +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright (c) 2010-2025 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.binding.mqtt.homeassistant.internal; - -import java.util.Map; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.graalvm.polyglot.Context; -import org.graalvm.polyglot.Value; -import org.graalvm.python.embedding.GraalPyResources; -import org.graalvm.python.embedding.VirtualFileSystem; - -/** - * Centralizes all calls into python to ensure thread safety and a single cached context - * - * @author Cody Cutrer - Initial contribution - */ -@NonNullByDefault -public class HomeAssistantPythonBridge { - private static final String PYTHON = "python"; - private final Context context; - private final Value newCommandTemplateMeth, newValueTemplateMeth, renderCommandTemplateMeth, - renderValueTemplateMeth, renderCommandTemplateWithVariablesMeth, renderValueTemplateWithVariablesMeth; - - public HomeAssistantPythonBridge() { - VirtualFileSystem vfs = VirtualFileSystem.newBuilder() - .resourceDirectory("GRAALPY-VFS/org.openhab.addons.bundles/org.openhab.binding.mqtt.homeassistant") - .build(); - - context = GraalPyResources.contextBuilder(vfs).build(); - - Value bindings = context.getBindings(PYTHON); - - context.eval(PYTHON, - """ - from homeassistant.helpers.template import Template - from homeassistant.components.mqtt.models import MqttCommandTemplate, MqttValueTemplate - - def new_command_template(template_string): - return MqttCommandTemplate(Template(template_string)) - - def render_command_template(template, value): - return template.render(value=value) - - def render_command_template_with_variables(template, value, variables): - return template.render(value=value, variables=variables) - - def new_value_template(template_string): - return MqttValueTemplate(Template(template_string)) - - def render_value_template(template, payload, default): - return template.render_with_possible_json_value(payload=payload, default=default) - - def render_value_template_with_variables(template, payload, default, variables): - return template.render_with_possible_json_value(payload=payload, default=default, variables=variables) - """); - - newCommandTemplateMeth = bindings.getMember("new_command_template"); - renderCommandTemplateMeth = bindings.getMember("render_command_template"); - renderCommandTemplateWithVariablesMeth = bindings.getMember("render_command_template_with_variables"); - newValueTemplateMeth = bindings.getMember("new_value_template"); - renderValueTemplateMeth = bindings.getMember("render_value_template"); - renderValueTemplateWithVariablesMeth = bindings.getMember("render_value_template_with_variables"); - } - - public Value newCommandTemplate(String template) { - return newCommandTemplateMeth.execute(template); - } - - public String renderCommandTemplate(Value template, Object value) { - return renderCommandTemplateMeth.execute(template, value).asString(); - } - - public String renderCommandTemplate(Value template, Object value, Map variables) { - return renderCommandTemplateWithVariablesMeth.execute(template, value, variables).asString(); - } - - public Value newValueTemplate(String template) { - return newValueTemplateMeth.execute(template); - } - - public String renderValueTemplate(Value template, Object payload, String defaultValue) { - return renderValueTemplateMeth.execute(template, payload, defaultValue).asString(); - } - - public String renderValueTemplate(Value template, Object payload, String defaultValue, - Map variables) { - return renderValueTemplateWithVariablesMeth.execute(template, payload, defaultValue, variables).asString(); - } -} diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java index dc1e55ec574..f15d27936b9 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java @@ -38,7 +38,6 @@ import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel; import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannelType; import org.openhab.binding.mqtt.homeassistant.internal.HaID; import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantChannelTransformation; -import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantPythonBridge; import org.openhab.binding.mqtt.homeassistant.internal.component.ComponentFactory.ComponentConfiguration; import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration; import org.openhab.binding.mqtt.homeassistant.internal.config.dto.Availability; @@ -63,6 +62,7 @@ import org.openhab.core.types.StateDescription; import com.google.gson.Gson; import com.google.gson.annotations.SerializedName; +import com.hubspot.jinjava.Jinjava; /** * A HomeAssistant component is comparable to a channel group. @@ -155,8 +155,7 @@ public abstract class AbstractComponent String availabilityTemplate = availability.getValueTemplate(); ChannelTransformation transformation = null; if (availabilityTemplate != null) { - transformation = new HomeAssistantChannelTransformation(getPython(), this, availabilityTemplate, - false); + transformation = new HomeAssistantChannelTransformation(getJinjava(), this, availabilityTemplate); } componentConfiguration.getTracker().addAvailabilityTopic(availability.getTopic(), availability.getPayloadAvailable(), availability.getPayloadNotAvailable(), transformation); @@ -167,8 +166,7 @@ public abstract class AbstractComponent String availabilityTemplate = this.channelConfiguration.getAvailabilityTemplate(); ChannelTransformation transformation = null; if (availabilityTemplate != null) { - transformation = new HomeAssistantChannelTransformation(getPython(), this, availabilityTemplate, - false); + transformation = new HomeAssistantChannelTransformation(getJinjava(), this, availabilityTemplate); } componentConfiguration.getTracker().addAvailabilityTopic(availabilityTopic, this.channelConfiguration.getPayloadAvailable(), @@ -408,8 +406,8 @@ public abstract class AbstractComponent return componentConfiguration.getGson(); } - public HomeAssistantPythonBridge getPython() { - return componentConfiguration.getPython(); + public Jinjava getJinjava() { + return componentConfiguration.getJinjava(); } public C getChannelConfiguration() { diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java index 74f5ef59e19..bf56f2da9c0 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java @@ -19,7 +19,6 @@ import org.openhab.binding.mqtt.generic.AvailabilityTracker; import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener; import org.openhab.binding.mqtt.homeassistant.internal.HaID; import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantChannelLinkageChecker; -import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantPythonBridge; import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration; import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException; import org.openhab.binding.mqtt.homeassistant.internal.exception.UnsupportedComponentException; @@ -27,6 +26,7 @@ import org.openhab.core.i18n.UnitProvider; import org.openhab.core.thing.ThingUID; import com.google.gson.Gson; +import com.hubspot.jinjava.Jinjava; /** * A factory to create HomeAssistant MQTT components. Those components are specified at: @@ -49,10 +49,10 @@ public class ComponentFactory { */ public static AbstractComponent createComponent(ThingUID thingUID, HaID haID, String channelConfigurationJSON, ChannelStateUpdateListener updateListener, HomeAssistantChannelLinkageChecker linkageChecker, - AvailabilityTracker tracker, ScheduledExecutorService scheduler, Gson gson, - HomeAssistantPythonBridge python, UnitProvider unitProvider) throws ConfigurationException { + AvailabilityTracker tracker, ScheduledExecutorService scheduler, Gson gson, Jinjava jinjava, + UnitProvider unitProvider) throws ConfigurationException { ComponentConfiguration componentConfiguration = new ComponentConfiguration(thingUID, haID, - channelConfigurationJSON, gson, python, updateListener, linkageChecker, tracker, scheduler, + channelConfigurationJSON, gson, jinjava, updateListener, linkageChecker, tracker, scheduler, unitProvider); switch (haID.component) { case "alarm_control_panel": @@ -116,7 +116,7 @@ public class ComponentFactory { private final HomeAssistantChannelLinkageChecker linkageChecker; private final AvailabilityTracker tracker; private final Gson gson; - private final HomeAssistantPythonBridge python; + private final Jinjava jinjava; private final ScheduledExecutorService scheduler; private final UnitProvider unitProvider; @@ -128,15 +128,14 @@ public class ComponentFactory { * @param configJSON The configuration string * @param gson A Gson instance */ - protected ComponentConfiguration(ThingUID thingUID, HaID haID, String configJSON, Gson gson, - HomeAssistantPythonBridge python, ChannelStateUpdateListener updateListener, - HomeAssistantChannelLinkageChecker linkageChecker, AvailabilityTracker tracker, - ScheduledExecutorService scheduler, UnitProvider unitProvider) { + protected ComponentConfiguration(ThingUID thingUID, HaID haID, String configJSON, Gson gson, Jinjava jinjava, + ChannelStateUpdateListener updateListener, HomeAssistantChannelLinkageChecker linkageChecker, + AvailabilityTracker tracker, ScheduledExecutorService scheduler, UnitProvider unitProvider) { this.thingUID = thingUID; this.haID = haID; this.configJSON = configJSON; this.gson = gson; - this.python = python; + this.jinjava = jinjava; this.updateListener = updateListener; this.linkageChecker = linkageChecker; this.tracker = tracker; @@ -168,8 +167,8 @@ public class ComponentFactory { return gson; } - public HomeAssistantPythonBridge getPython() { - return python; + public Jinjava getJinjava() { + return jinjava; } public UnitProvider getUnitProvider() { diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Event.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Event.java index a1ae5cc6a6a..f1f3f2be31c 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Event.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Event.java @@ -57,7 +57,7 @@ public class Event extends AbstractComponent impleme public Event(ComponentFactory.ComponentConfiguration componentConfiguration) { super(componentConfiguration, ChannelConfiguration.class); - transformation = new HomeAssistantChannelTransformation(getPython(), this, EVENT_TYPE_TRANFORMATION, false); + transformation = new HomeAssistantChannelTransformation(getJinjava(), this, ""); buildChannel(EVENT_TYPE_CHANNEL_ID, ComponentChannelType.TRIGGER, new TextValue(), getName(), this) .stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate()).trigger(true) @@ -87,7 +87,7 @@ public class Event extends AbstractComponent impleme @Override public void triggerChannel(ChannelUID channel, String event) { - String eventType = transformation.apply(event).orElse(null); + String eventType = transformation.apply(EVENT_TYPE_TRANFORMATION, event).orElse(null); if (eventType == null) { // Warning logged from inside the transformation return; diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java index 6438fce484e..410e0db97f9 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java @@ -159,8 +159,8 @@ public class Fan extends AbstractComponent implements .stateTopic(channelConfiguration.percentageStateTopic, channelConfiguration.percentageValueTemplate) .commandTopic(channelConfiguration.percentageCommandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos(), channelConfiguration.percentageCommandTemplate) - .parseCommandValueAsInteger(true).inferOptimistic(channelConfiguration.optimistic) - .commandFilter(this::handlePercentageCommand).build(); + .inferOptimistic(channelConfiguration.optimistic).commandFilter(this::handlePercentageCommand) + .build(); } else { primaryChannel = onOffChannel; speedChannel = null; diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLight.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLight.java index b888357740a..236556ad103 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLight.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLight.java @@ -54,9 +54,7 @@ import org.slf4j.LoggerFactory; @NonNullByDefault public class TemplateSchemaLight extends AbstractRawSchemaLight { private final Logger logger = LoggerFactory.getLogger(TemplateSchemaLight.class); - private @Nullable HomeAssistantChannelTransformation commandOnTransformation, commandOffTransformation, - stateTransformation, brightnessTransformation, redTransformation, greenTransformation, blueTransformation, - effectTransformation, colorTempTransformation; + private final HomeAssistantChannelTransformation transformation; private static class TemplateVariables { public static final String STATE = "state"; @@ -74,36 +72,26 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { public TemplateSchemaLight(ComponentFactory.ComponentConfiguration builder) { super(builder); + transformation = new HomeAssistantChannelTransformation(getJinjava(), this, ""); } @Override protected void buildChannels() { AutoUpdatePolicy autoUpdatePolicy = optimistic ? AutoUpdatePolicy.RECOMMEND : null; - String commandOnTemplate = channelConfiguration.commandOnTemplate, - commandOffTemplate = channelConfiguration.commandOffTemplate; - if (commandOnTemplate == null || commandOffTemplate == null) { + if (channelConfiguration.commandOnTemplate == null || channelConfiguration.commandOffTemplate == null) { throw new UnsupportedComponentException("Template schema light component '" + getHaID() + "' does not define command_on_template or command_off_template!"); } onOffValue = new OnOffValue("on", "off"); brightnessValue = new PercentageValue(null, new BigDecimal(255), null, null, null, FORMAT_INTEGER); - commandOnTransformation = new HomeAssistantChannelTransformation(getPython(), this, commandOnTemplate, true); - commandOffTransformation = new HomeAssistantChannelTransformation(getPython(), this, commandOffTemplate, true); - String redTemplate = channelConfiguration.redTemplate, greenTemplate = channelConfiguration.greenTemplate, - blueTemplate = channelConfiguration.blueTemplate, - brightnessTemplate = channelConfiguration.brightnessTemplate; - if (redTemplate != null && greenTemplate != null && blueTemplate != null) { - redTransformation = new HomeAssistantChannelTransformation(getPython(), this, redTemplate, false); - greenTransformation = new HomeAssistantChannelTransformation(getPython(), this, greenTemplate, false); - blueTransformation = new HomeAssistantChannelTransformation(getPython(), this, blueTemplate, false); + if (channelConfiguration.redTemplate != null && channelConfiguration.greenTemplate != null + && channelConfiguration.blueTemplate != null) { colorChannel = buildChannel(COLOR_CHANNEL_ID, ComponentChannelType.COLOR, colorValue, "Color", this) .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleCommand(command)) .withAutoUpdatePolicy(autoUpdatePolicy).build(); - } else if (brightnessTemplate != null) { - brightnessTransformation = new HomeAssistantChannelTransformation(getPython(), this, brightnessTemplate, - false); + } else if (channelConfiguration.brightnessTemplate != null) { brightnessChannel = buildChannel(BRIGHTNESS_CHANNEL_ID, ComponentChannelType.DIMMER, brightnessValue, "Brightness", this).commandTopic(DUMMY_TOPIC, true, 1) .commandFilter(command -> handleCommand(command)).withAutoUpdatePolicy(autoUpdatePolicy).build(); @@ -113,29 +101,17 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { .withAutoUpdatePolicy(autoUpdatePolicy).build(); } - String colorTempTemplate = channelConfiguration.colorTempTemplate; - if (colorTempTemplate != null) { - colorTempTransformation = new HomeAssistantChannelTransformation(getPython(), this, colorTempTemplate, - false); - + if (channelConfiguration.colorTempTemplate != null) { buildChannel(COLOR_TEMP_CHANNEL_ID, ComponentChannelType.NUMBER, colorTempValue, "Color Temperature", this) .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleColorTempCommand(command)) .withAutoUpdatePolicy(autoUpdatePolicy).build(); } TextValue localEffectValue = effectValue; - String effectTemplate = channelConfiguration.effectTemplate; - if (effectTemplate != null && localEffectValue != null) { - effectTransformation = new HomeAssistantChannelTransformation(getPython(), this, effectTemplate, false); - + if (channelConfiguration.effectTemplate != null && localEffectValue != null) { buildChannel(EFFECT_CHANNEL_ID, ComponentChannelType.STRING, localEffectValue, "Effect", this) .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleEffectCommand(command)) .withAutoUpdatePolicy(autoUpdatePolicy).build(); } - - String stateTemplate = channelConfiguration.stateTemplate; - if (stateTemplate != null) { - stateTransformation = new HomeAssistantChannelTransformation(getPython(), this, stateTemplate, false); - } } private static BigDecimal factor = new BigDecimal("2.55"); // string to not lose precision @@ -143,14 +119,14 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { @Override protected void publishState(HSBType state) { Map binding = new HashMap<>(); - HomeAssistantChannelTransformation transformation; + String template; logger.trace("Publishing new state {} of light {} to MQTT.", state, getName()); if (state.getBrightness().equals(PercentType.ZERO)) { - transformation = Objects.requireNonNull(commandOffTransformation); + template = Objects.requireNonNull(channelConfiguration.commandOffTemplate); binding.put(TemplateVariables.STATE, "off"); } else { - transformation = Objects.requireNonNull(commandOnTransformation); + template = Objects.requireNonNull(channelConfiguration.commandOnTemplate); binding.put(TemplateVariables.STATE, "on"); if (channelConfiguration.brightnessTemplate != null) { binding.put(TemplateVariables.BRIGHTNESS, @@ -166,7 +142,7 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { } } - publishState(binding, transformation); + publishState(binding, template); } private boolean handleColorTempCommand(Command command) { @@ -185,7 +161,7 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { binding.put(TemplateVariables.STATE, "on"); binding.put(TemplateVariables.COLOR_TEMP, mireds.toBigDecimal().intValue()); - publishState(binding, Objects.requireNonNull(commandOnTransformation)); + publishState(binding, Objects.requireNonNull(channelConfiguration.commandOnTemplate)); } return false; } @@ -200,15 +176,14 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { binding.put(TemplateVariables.STATE, "on"); binding.put(TemplateVariables.EFFECT, command.toString()); - publishState(binding, Objects.requireNonNull(commandOnTransformation)); + publishState(binding, Objects.requireNonNull(channelConfiguration.commandOnTemplate)); return false; } - private void publishState(Map binding, - HomeAssistantChannelTransformation transformation) { + private void publishState(Map binding, String template) { String command; - command = transform(transformation, binding); + command = transform(template, binding); if (command == null) { return; } @@ -223,9 +198,9 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { String value; - HomeAssistantChannelTransformation stateTransformation = this.stateTransformation; - if (stateTransformation != null) { - value = transform(stateTransformation, state.toString()); + String template = channelConfiguration.stateTemplate; + if (template != null) { + value = transform(template, state.toString()); if (value == null || value.isEmpty()) { onOffValue.update(UnDefType.NULL); } else if ("on".equals(value)) { @@ -242,15 +217,14 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { brightnessValue.update( (PercentType) Objects.requireNonNull(onOffValue.getChannelState().as(PercentType.class))); } - if (colorValue.getChannelState() instanceof UnDefType - && onOffValue.getChannelState() instanceof OnOffType onOffValue) { - colorValue.update(onOffValue); + if (colorValue.getChannelState() instanceof UnDefType) { + colorValue.update((OnOffType) onOffValue.getChannelState()); } } - HomeAssistantChannelTransformation brightnessTransformation = this.brightnessTransformation; - if (brightnessTransformation != null) { - Integer brightness = getColorChannelValue(brightnessTransformation, state.toString()); + template = channelConfiguration.brightnessTemplate; + if (template != null) { + Integer brightness = getColorChannelValue(template, state.toString()); if (brightness == null) { brightnessValue.update(UnDefType.NULL); colorValue.update(UnDefType.NULL); @@ -267,13 +241,13 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { } @Nullable - HomeAssistantChannelTransformation redTransformation, greenTransformation, blueTransformation; - if ((redTransformation = this.redTransformation) != null - && (greenTransformation = this.greenTransformation) != null - && (blueTransformation = this.blueTransformation) != null) { - Integer red = getColorChannelValue(redTransformation, state.toString()); - Integer green = getColorChannelValue(greenTransformation, state.toString()); - Integer blue = getColorChannelValue(blueTransformation, state.toString()); + String redTemplate, greenTemplate, blueTemplate; + if ((redTemplate = channelConfiguration.redTemplate) != null + && (greenTemplate = channelConfiguration.greenTemplate) != null + && (blueTemplate = channelConfiguration.blueTemplate) != null) { + Integer red = getColorChannelValue(redTemplate, state.toString()); + Integer green = getColorChannelValue(greenTemplate, state.toString()); + Integer blue = getColorChannelValue(blueTemplate, state.toString()); if (red == null || green == null || blue == null) { colorValue.update(UnDefType.NULL); } else { @@ -291,9 +265,9 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { listener.updateChannelState(onOffChannel.getChannel().getUID(), onOffValue.getChannelState()); } - HomeAssistantChannelTransformation effectTransformation = this.effectTransformation; - if (effectTransformation != null) { - value = transform(effectTransformation, state.toString()); + template = channelConfiguration.effectTemplate; + if (template != null) { + value = transform(template, state.toString()); if (value == null || value.isEmpty()) { effectValue.update(UnDefType.NULL); } else { @@ -302,9 +276,9 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { listener.updateChannelState(buildChannelUID(EFFECT_CHANNEL_ID), effectValue.getChannelState()); } - HomeAssistantChannelTransformation colorTempTransformation = this.colorTempTransformation; - if (colorTempTransformation != null) { - Integer mireds = getColorChannelValue(colorTempTransformation, state.toString()); + template = channelConfiguration.colorTempTemplate; + if (template != null) { + Integer mireds = getColorChannelValue(template, state.toString()); if (mireds == null) { colorTempValue.update(UnDefType.NULL); } else { @@ -314,8 +288,8 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { } } - private @Nullable Integer getColorChannelValue(HomeAssistantChannelTransformation transformation, String value) { - Object result = transform(transformation, value); + private @Nullable Integer getColorChannelValue(String template, String value) { + Object result = transform(template, value); if (result == null) { return null; } @@ -327,21 +301,17 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { try { return Integer.parseInt(result.toString()); } catch (NumberFormatException e) { - logger.warn("Applying template for component {} failed: {}", getHaID().toShortTopic(), e.getMessage()); + logger.warn("Applying template {} for component {} failed: {}", template, getHaID().toShortTopic(), + e.getMessage()); return null; } } - private @Nullable String transform(HomeAssistantChannelTransformation transformation, - Map variables) { - Object result = transformation.transform("", variables); - if (result == null) { - return null; - } - return result.toString(); + private @Nullable String transform(String template, Map binding) { + return transformation.apply(template, binding).orElse(null); } - private @Nullable String transform(HomeAssistantChannelTransformation transformation, String value) { - return transformation.apply(value).orElse(null); + private @Nullable String transform(String template, String value) { + return transformation.apply(template, value).orElse(null); } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java index 01e776b863b..d20ed8c2e08 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java @@ -39,7 +39,6 @@ import org.openhab.binding.mqtt.homeassistant.internal.DiscoverComponents.Compon import org.openhab.binding.mqtt.homeassistant.internal.HaID; import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration; import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantChannelLinkageChecker; -import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantPythonBridge; import org.openhab.binding.mqtt.homeassistant.internal.actions.HomeAssistantUpdateThingActions; import org.openhab.binding.mqtt.homeassistant.internal.component.AbstractComponent; import org.openhab.binding.mqtt.homeassistant.internal.component.ComponentFactory; @@ -64,6 +63,7 @@ import org.slf4j.LoggerFactory; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.hubspot.jinjava.Jinjava; /** * Handles HomeAssistant MQTT object things. Such an HA Object can have multiple HA Components with different instances @@ -95,7 +95,7 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler protected final MqttChannelTypeProvider channelTypeProvider; protected final MqttChannelStateDescriptionProvider stateDescriptionProvider; protected final ChannelTypeRegistry channelTypeRegistry; - protected final HomeAssistantPythonBridge python; + protected final Jinjava jinjava; protected final UnitProvider unitProvider; public final int attributeReceiveTimeout; protected final DelayedBatchProcessing delayedProcessing; @@ -124,19 +124,19 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler */ public HomeAssistantThingHandler(Thing thing, BaseThingHandlerFactory thingHandlerFactory, MqttChannelTypeProvider channelTypeProvider, MqttChannelStateDescriptionProvider stateDescriptionProvider, - ChannelTypeRegistry channelTypeRegistry, HomeAssistantPythonBridge python, UnitProvider unitProvider, - int subscribeTimeout, int attributeReceiveTimeout) { + ChannelTypeRegistry channelTypeRegistry, Jinjava jinjava, UnitProvider unitProvider, int subscribeTimeout, + int attributeReceiveTimeout) { super(thing, subscribeTimeout); this.gson = new GsonBuilder().registerTypeAdapterFactory(new ChannelConfigurationTypeAdapterFactory()).create(); this.thingHandlerFactory = thingHandlerFactory; this.channelTypeProvider = channelTypeProvider; this.stateDescriptionProvider = stateDescriptionProvider; this.channelTypeRegistry = channelTypeRegistry; - this.python = python; + this.jinjava = jinjava; this.unitProvider = unitProvider; this.attributeReceiveTimeout = attributeReceiveTimeout; this.delayedProcessing = new DelayedBatchProcessing<>(attributeReceiveTimeout, this, scheduler); - this.discoverComponents = new DiscoverComponents(thing.getUID(), scheduler, this, this, this, gson, python, + this.discoverComponents = new DiscoverComponents(thing.getUID(), scheduler, this, this, this, gson, jinjava, unitProvider); } @@ -185,7 +185,7 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler String channelConfigurationJSON = (String) channelConfig.get("config"); try { AbstractComponent component = ComponentFactory.createComponent(thingUID, haID, - channelConfigurationJSON, this, this, this, scheduler, gson, python, unitProvider); + channelConfigurationJSON, this, this, this, scheduler, gson, jinjava, unitProvider); if (typeID.equals(MqttBindingConstants.HOMEASSISTANT_MQTT_THING)) { typeID = calculateThingTypeUID(component); } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/components/mqtt/const.py b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/components/mqtt/const.py deleted file mode 100644 index 2a9f3e67ac6..00000000000 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/components/mqtt/const.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Constants used by multiple MQTT modules.""" - -import jinja2 - -from homeassistant.exceptions import TemplateError - -TEMPLATE_ERRORS = (jinja2.TemplateError, TemplateError, TypeError, ValueError) diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/components/mqtt/models.py b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/components/mqtt/models.py deleted file mode 100644 index 480a742efe4..00000000000 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/components/mqtt/models.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Models used by multiple MQTT modules.""" - -from __future__ import annotations - -from ast import literal_eval -from collections.abc import Mapping -from enum import StrEnum -import logging -from typing import Any - -from homeassistant.exceptions import ServiceValidationError, TemplateError -from homeassistant.helpers import template - -from .const import TEMPLATE_ERRORS - -class PayloadSentinel(StrEnum): - """Sentinel for `render_with_possible_json_value`.""" - - NONE = "none" - DEFAULT = "default" - - -_LOGGER = logging.getLogger(__name__) - - -def convert_outgoing_mqtt_payload( - payload: str | bytes | int | float | None, -) -> str | bytes | int | float | None: - """Ensure correct raw MQTT payload is passed as bytes for publishing.""" - if isinstance(payload, str) and payload.startswith(("b'", 'b"')): - try: - native_object = literal_eval(payload) - except (ValueError, TypeError, SyntaxError, MemoryError): - pass - else: - if isinstance(native_object, bytes): - return native_object - - return payload - - -class MqttCommandTemplateException(ServiceValidationError): - """Handle MqttCommandTemplate exceptions.""" - - _message: str - - def __init__( - self, - *args: object, - base_exception: Exception, - command_template: str, - value: str | bytes | int | float | None, - ) -> None: - """Initialize exception.""" - super().__init__(base_exception, *args) - value_log = str(value) - self._message = ( - f"{type(base_exception).__name__}: {base_exception} rendering template" - f", template: '{command_template}' and payload: {value_log}" - ) - - def __str__(self) -> str: - """Return exception message string.""" - return self._message - - -class MqttCommandTemplate: - """Class for rendering MQTT payload with command templates.""" - - def __init__( - self, - command_template: template.Template | None, - ) -> None: - """Instantiate a command template.""" - self._template_state: template.TemplateStateFromEntityId | None = None - self._command_template = command_template - - def render( - self, - value: str | bytes | int | float | None = None, - variables: Mapping[str, Any] | None = None, - ) -> str | bytes | int | float | None: - """Render or convert the command template with given value or variables.""" - if self._command_template is None: - return value - - values: dict[str, Any] = {"value": value} - - if variables is not None: - values.update(variables) - _LOGGER.debug( - "Rendering outgoing payload with variables %s and %s", - values, - self._command_template, - ) - try: - return convert_outgoing_mqtt_payload( - self._command_template.render(values, parse_result=False) - ) - except TemplateError as exc: - raise MqttCommandTemplateException( - base_exception=exc, - command_template=self._command_template.template, - value=value, - ) from exc - - -class MqttValueTemplateException(TemplateError): - """Handle MqttValueTemplate exceptions.""" - - _message: str - - def __init__( - self, - *args: object, - base_exception: Exception, - value_template: str, - default: str | bytes | bytearray | PayloadSentinel, - payload: str | bytes | bytearray, - ) -> None: - """Initialize exception.""" - super().__init__(base_exception, *args) - default_log = str(default) - default_payload_log = ( - "" if default is PayloadSentinel.NONE else f", default value: {default_log}" - ) - payload_log = str(payload) - self._message = ( - f"{type(base_exception).__name__}: {base_exception} rendering template" - f", template: '{value_template}'{default_payload_log} and payload: {payload_log}" - ) - - def __str__(self) -> str: - """Return exception message string.""" - return self._message - - -class MqttValueTemplate: - """Class for rendering MQTT value template with possible json values.""" - - def __init__( - self, - value_template: template.Template | None, - ) -> None: - """Instantiate a value template.""" - self._value_template = value_template - - def render_with_possible_json_value( - self, - payload: str | bytes | bytearray, - default: str | bytes | bytearray | PayloadSentinel = PayloadSentinel.NONE, - variables: Mapping[str, Any] | None = None, - ) -> str | bytes | bytearray: - """Render with possible json value or pass-though a received MQTT value.""" - rendered_payload: str | bytes | bytearray - - if self._value_template is None: - return payload - - values: dict[str, Any] = {} - - if variables is not None: - values.update(variables) - - if default is PayloadSentinel.NONE: - _LOGGER.debug( - "Rendering incoming payload '%s' with variables %s and %s", - payload, - values, - self._value_template, - ) - try: - rendered_payload = ( - self._value_template.render_with_possible_json_value( - payload, variables=values - ) - ) - except TEMPLATE_ERRORS as exc: - raise MqttValueTemplateException( - base_exception=exc, - value_template=self._value_template.template, - default=default, - payload=payload, - ) from exc - return rendered_payload - - _LOGGER.debug( - ( - "Rendering incoming payload '%s' with variables %s with default value" - " '%s' and %s" - ), - payload, - values, - default, - self._value_template, - ) - try: - rendered_payload = ( - self._value_template.render_with_possible_json_value( - payload, default, variables=values - ) - ) - except TEMPLATE_ERRORS as exc: - raise MqttValueTemplateException( - base_exception=exc, - value_template=self._value_template.template, - default=default, - payload=payload, - ) from exc - return rendered_payload diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/exceptions.py b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/exceptions.py deleted file mode 100644 index 0d7118deadd..00000000000 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/exceptions.py +++ /dev/null @@ -1,26 +0,0 @@ -"""The exceptions used by Home Assistant.""" - -from __future__ import annotations - -# this is from Voluptuous -class Invalid(Exception): - """The data was invalid.""" - - -class HomeAssistantError(Exception): - """General Home Assistant exception occurred.""" - - -class ServiceValidationError(HomeAssistantError): - """A validation exception occurred when calling a service.""" - - -class TemplateError(HomeAssistantError): - """Error during template rendering.""" - - def __init__(self, exception: Exception | str) -> None: - """Init the error.""" - if isinstance(exception, str): - super().__init__(exception) - else: - super().__init__(f"{exception.__class__.__name__}: {exception}") diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/helpers/config_validation.py b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/helpers/config_validation.py deleted file mode 100644 index 0f2659e8bb7..00000000000 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/helpers/config_validation.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Helpers for config validation using voluptuous.""" - -from __future__ import annotations - -from numbers import Number -from typing import Any - -from homeassistant.exceptions import Invalid - -def boolean(value: Any) -> bool: - """Validate and coerce a boolean value.""" - if isinstance(value, bool): - return value - if isinstance(value, str): - value = value.lower().strip() - if value in ("1", "true", "yes", "on", "enable"): - return True - if value in ("0", "false", "no", "off", "disable"): - return False - elif isinstance(value, Number): - # type ignore: https://github.com/python/mypy/issues/3186 - return value != 0 # type: ignore[comparison-overlap] - raise Invalid(f"invalid boolean value {value}") diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/helpers/template.py b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/helpers/template.py deleted file mode 100644 index b766908d341..00000000000 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/helpers/template.py +++ /dev/null @@ -1,1393 +0,0 @@ -"""Template helper methods for rendering strings with Home Assistant data.""" - -from __future__ import annotations - -from ast import literal_eval -import base64 -import collections.abc -from collections.abc import Callable, Iterable, Mapping, MutableSequence -from contextlib import AbstractContextManager -from contextvars import ContextVar -from datetime import date, datetime, time, timedelta -from functools import lru_cache, wraps -import hashlib -import json -import logging -import math -from operator import contains -import random -import re -import statistics -from struct import error as StructError, pack, unpack_from -from types import CodeType, TracebackType -from typing import ( - Any, - Literal, - NoReturn, - Self, - overload, -) -from urllib.parse import urlencode as urllib_urlencode -import weakref - -from awesomeversion import AwesomeVersion -import jinja2 -from jinja2 import pass_context, pass_environment -from jinja2.runtime import AsyncLoopContext, LoopContext -from jinja2.sandbox import ImmutableSandboxedEnvironment -from jinja2.utils import Namespace - -from homeassistant.exceptions import Invalid, TemplateError -from homeassistant.util import ( - dt as dt_util, - slugify as slugify_util, -) - -# mypy: allow-untyped-defs, no-check-untyped-defs - -_LOGGER = logging.getLogger(__name__) -_SENTINEL = object() -DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" - -# Match "simple" ints and floats. -1.0, 1, +5, 5.0 -_IS_NUMERIC = re.compile(r"^[+-]?(?!0\d)\d*(?:\.\d*)?$") - - -template_cv: ContextVar[tuple[str, str] | None] = ContextVar( - "template_cv", default=None -) - -EVAL_CACHE_SIZE = 512 - -MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 -MAX_TEMPLATE_OUTPUT = 256 * 1024 # 256KiB - -def is_template_string(maybe_template: str) -> bool: - """Check if the input is a Jinja2 template.""" - return "{" in maybe_template and ( - "{%" in maybe_template or "{{" in maybe_template or "{#" in maybe_template - ) - - -class ResultWrapper: - """Result wrapper class to store render result.""" - - render_result: str | None - - -def gen_result_wrapper(kls: type[dict | list | set]) -> type: - """Generate a result wrapper.""" - - class Wrapper(kls, ResultWrapper): # type: ignore[valid-type,misc] - """Wrapper of a kls that can store render_result.""" - - def __init__(self, *args: Any, render_result: str | None = None) -> None: - super().__init__(*args) - self.render_result = render_result - - def __str__(self) -> str: - if self.render_result is None: - # Can't get set repr to work - if kls is set: - return str(set(self)) - - return kls.__str__(self) - - return self.render_result - - return Wrapper - - -class TupleWrapper(tuple, ResultWrapper): - """Wrap a tuple.""" - - __slots__ = () - - # This is all magic to be allowed to subclass a tuple. - - def __new__(cls, value: tuple, *, render_result: str | None = None) -> Self: - """Create a new tuple class.""" - return super().__new__(cls, tuple(value)) - - def __init__(self, value: tuple, *, render_result: str | None = None) -> None: - """Initialize a new tuple class.""" - self.render_result = render_result - - def __str__(self) -> str: - """Return string representation.""" - if self.render_result is None: - return super().__str__() - - return self.render_result - - -_types: tuple[type[dict | list | set], ...] = (dict, list, set) -RESULT_WRAPPERS: dict[type, type] = {kls: gen_result_wrapper(kls) for kls in _types} -RESULT_WRAPPERS[tuple] = TupleWrapper - - -@lru_cache(maxsize=EVAL_CACHE_SIZE) -def _cached_parse_result(render_result: str) -> Any: - """Parse a result and cache the result.""" - result = literal_eval(render_result) - if type(result) in RESULT_WRAPPERS: - result = RESULT_WRAPPERS[type(result)](result, render_result=render_result) - - # If the literal_eval result is a string, use the original - # render, by not returning right here. The evaluation of strings - # resulting in strings impacts quotes, to avoid unexpected - # output; use the original render instead of the evaluated one. - # Complex and scientific values are also unexpected. Filter them out. - if ( - # Filter out string and complex numbers - not isinstance(result, (str, complex)) - and ( - # Pass if not numeric and not a boolean - not isinstance(result, (int, float)) - # Or it's a boolean (inherit from int) - or isinstance(result, bool) - # Or if it's a digit - or _IS_NUMERIC.match(render_result) is not None - ) - ): - return result - - return render_result - - -class Template: - """Class to hold a template and manage caching and rendering.""" - - __slots__ = ( - "__weakref__", - "_compiled", - "_compiled_code", - "_hash_cache", - "_log_fn", - "_renders", - "is_static", - "template", - ) - - def __init__(self, template: str) -> None: - """Instantiate a template.""" - - if not isinstance(template, str): - raise TypeError("Expected template to be a string") - - self.template: str = template.strip() - self._compiled_code: CodeType | None = None - self._compiled: jinja2.Template | None = None - self.is_static = not is_template_string(template) - self._log_fn: Callable[[int, str], None] | None = None - self._hash_cache: int = hash(self.template) - self._renders: int = 0 - - @property - def _env(self) -> TemplateEnvironment: - return _NO_HASS_ENV - - def ensure_valid(self) -> None: - """Return if template is valid.""" - if self.is_static or self._compiled_code is not None: - return - - if compiled := self._env.template_cache.get(self.template): - self._compiled_code = compiled - return - - with _template_context_manager as cm: - cm.set_template(self.template, "compiling") - try: - self._compiled_code = self._env.compile(self.template) - except jinja2.TemplateError as err: - raise TemplateError(err) from err - - def render( - self, - variables: Mapping[str, Any] | None = None, - log_fn: Callable[[int, str], None] | None = None, - **kwargs: Any, - ) -> Any: - """Render given template.""" - if self.is_static: - return self.template - - self._renders += 1 - - if self.is_static: - return self.template - - compiled = self._compiled or self._ensure_compiled(log_fn) - - if variables is not None: - kwargs.update(variables) - - try: - render_result = _render_with_context(self.template, compiled, **kwargs) - except Exception as err: - raise TemplateError(err) from err - - if len(render_result) > MAX_TEMPLATE_OUTPUT: - raise TemplateError( - f"Template output exceeded maximum size of {MAX_TEMPLATE_OUTPUT} characters" - ) - - render_result = render_result.strip() - - return render_result - - def render_with_possible_json_value( - self, - value: Any, - error_value: Any = _SENTINEL, - variables: dict[str, Any] | None = None - ) -> Any: - """Render template with value exposed. - - If valid JSON will expose value_json too. - """ - if self.is_static: - return self.template - - self._renders += 1 - - if self.is_static: - return self.template - - compiled = self._compiled or self._ensure_compiled() - - variables = dict(variables or {}) - variables["value"] = value - - try: # noqa: SIM105 - suppress is much slower - variables["value_json"] = json.loads(value) - except json.decoder.JSONDecodeError: - pass - - try: - render_result = _render_with_context( - self.template, compiled, **variables - ).strip() - except jinja2.TemplateError as ex: - if error_value is _SENTINEL: - _LOGGER.error( - "Error parsing value: %s (value: %s, template: %s)", - ex, - value, - self.template, - ) - return value if error_value is _SENTINEL else error_value - - return render_result - - def _ensure_compiled( - self, - log_fn: Callable[[int, str], None] | None = None, - ) -> jinja2.Template: - """Bind a template to a specific hass instance.""" - self.ensure_valid() - - assert self._log_fn is None or self._log_fn == log_fn, ( - "can't change custom log function" - ) - assert self._compiled_code is not None, "template code was not compiled" - - self._log_fn = log_fn - env = self._env - - self._compiled = jinja2.Template.from_code( - env, self._compiled_code, env.globals, None - ) - - return self._compiled - - def __eq__(self, other): - """Compare template with another.""" - return ( - self.__class__ == other.__class__ - and self.template == other.template - ) - - def __hash__(self) -> int: - """Hash code for template.""" - return self._hash_cache - - def __repr__(self) -> str: - """Representation of Template.""" - return f"Template" - -def forgiving_boolean( - value: Any, default: object = _SENTINEL -) -> bool | object: - """Try to convert value to a boolean.""" - try: - # Import here, not at top-level to avoid circular import - from . import config_validation as cv # pylint: disable=import-outside-toplevel - - return cv.boolean(value) - except Invalid: - if default is _SENTINEL: - raise_no_default("bool", value) - return default - - -def now() -> datetime: - return dt_util.now() - - -def utcnow() -> datetime: - return dt_util.utcnow() - - -def raise_no_default(function: str, value: Any) -> NoReturn: - """Log warning if no default is specified.""" - template, action = template_cv.get() or ("", "rendering or compiling") - raise ValueError( - f"Template error: {function} got invalid input '{value}' when {action} template" - f" '{template}' but no default was specified" - ) - - -def forgiving_round(value, precision=0, method="common", default=_SENTINEL): - """Filter to round a value.""" - try: - # support rounding methods like jinja - multiplier = float(10**precision) - if method == "ceil": - value = math.ceil(float(value) * multiplier) / multiplier - elif method == "floor": - value = math.floor(float(value) * multiplier) / multiplier - elif method == "half": - value = round(float(value) * 2) / 2 - else: - # if method is common or something else, use common rounding - value = round(float(value), precision) - return int(value) if precision == 0 else value - except (ValueError, TypeError): - # If value can't be converted to float - if default is _SENTINEL: - raise_no_default("round", value) - return default - - -def multiply(value, amount, default=_SENTINEL): - """Filter to convert value to float and multiply it.""" - try: - return float(value) * amount - except (ValueError, TypeError): - # If value can't be converted to float - if default is _SENTINEL: - raise_no_default("multiply", value) - return default - - -def add(value, amount, default=_SENTINEL): - """Filter to convert value to float and add it.""" - try: - return float(value) + amount - except (ValueError, TypeError): - # If value can't be converted to float - if default is _SENTINEL: - raise_no_default("add", value) - return default - - -def logarithm(value, base=math.e, default=_SENTINEL): - """Filter and function to get logarithm of the value with a specific base.""" - try: - base_float = float(base) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("log", base) - return default - try: - value_float = float(value) - return math.log(value_float, base_float) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("log", value) - return default - - -def sine(value, default=_SENTINEL): - """Filter and function to get sine of the value.""" - try: - return math.sin(float(value)) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("sin", value) - return default - - -def cosine(value, default=_SENTINEL): - """Filter and function to get cosine of the value.""" - try: - return math.cos(float(value)) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("cos", value) - return default - - -def tangent(value, default=_SENTINEL): - """Filter and function to get tangent of the value.""" - try: - return math.tan(float(value)) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("tan", value) - return default - - -def arc_sine(value, default=_SENTINEL): - """Filter and function to get arc sine of the value.""" - try: - return math.asin(float(value)) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("asin", value) - return default - - -def arc_cosine(value, default=_SENTINEL): - """Filter and function to get arc cosine of the value.""" - try: - return math.acos(float(value)) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("acos", value) - return default - - -def arc_tangent(value, default=_SENTINEL): - """Filter and function to get arc tangent of the value.""" - try: - return math.atan(float(value)) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("atan", value) - return default - - -def arc_tangent2(*args, default=_SENTINEL): - """Filter and function to calculate four quadrant arc tangent of y / x. - - The parameters to atan2 may be passed either in an iterable or as separate arguments - The default value may be passed either as a positional or in a keyword argument - """ - try: - if 1 <= len(args) <= 2 and isinstance(args[0], (list, tuple)): - if len(args) == 2 and default is _SENTINEL: - # Default value passed as a positional argument - default = args[1] - args = args[0] - elif len(args) == 3 and default is _SENTINEL: - # Default value passed as a positional argument - default = args[2] - - return math.atan2(float(args[0]), float(args[1])) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("atan2", args) - return default - - -def version(value): - """Filter and function to get version object of the value.""" - return AwesomeVersion(value) - - -def square_root(value, default=_SENTINEL): - """Filter and function to get square root of the value.""" - try: - return math.sqrt(float(value)) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("sqrt", value) - return default - - -def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True, default=_SENTINEL): - """Filter to convert given timestamp to format.""" - try: - result = dt_util.utc_from_timestamp(value) - - if local: - result = dt_util.as_local(result) - - return result.strftime(date_format) - except (ValueError, TypeError): - # If timestamp can't be converted - if default is _SENTINEL: - raise_no_default("timestamp_custom", value) - return default - - -def timestamp_local(value, default=_SENTINEL): - """Filter to convert given timestamp to local date/time.""" - try: - return dt_util.as_local(dt_util.utc_from_timestamp(value)).isoformat() - except (ValueError, TypeError): - # If timestamp can't be converted - if default is _SENTINEL: - raise_no_default("timestamp_local", value) - return default - - -def timestamp_utc(value, default=_SENTINEL): - """Filter to convert given timestamp to UTC date/time.""" - try: - return dt_util.utc_from_timestamp(value).isoformat() - except (ValueError, TypeError): - # If timestamp can't be converted - if default is _SENTINEL: - raise_no_default("timestamp_utc", value) - return default - - -def forgiving_as_timestamp(value, default=_SENTINEL): - """Filter and function which tries to convert value to timestamp.""" - try: - return dt_util.as_timestamp(value) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("as_timestamp", value) - return default - - -def as_datetime(value: Any, default: Any = _SENTINEL) -> Any: - """Filter and to convert a time string or UNIX timestamp to datetime object.""" - # Return datetime.datetime object without changes - if type(value) is datetime: - return value - # Add midnight to datetime.date object - if type(value) is date: - return datetime.combine(value, time(0, 0, 0)) - try: - # Check for a valid UNIX timestamp string, int or float - timestamp = float(value) - return dt_util.utc_from_timestamp(timestamp) - except (ValueError, TypeError): - # Try to parse datetime string to datetime object - try: - return dt_util.parse_datetime(value, raise_on_error=True) - except (ValueError, TypeError): - if default is _SENTINEL: - # Return None on string input - # to ensure backwards compatibility with HA Core 2024.1 and before. - if isinstance(value, str): - return None - raise_no_default("as_datetime", value) - return default - - -def as_timedelta(value: str) -> timedelta | None: - """Parse a ISO8601 duration like 'PT10M' to a timedelta.""" - return dt_util.parse_duration(value) - - -def strptime(string, fmt, default=_SENTINEL): - """Parse a time string to datetime.""" - try: - return datetime.strptime(string, fmt) - except (ValueError, AttributeError, TypeError): - if default is _SENTINEL: - raise_no_default("strptime", string) - return default - - -def fail_when_undefined(value): - """Filter to force a failure when the value is undefined.""" - if isinstance(value, jinja2.Undefined): - value() - return value - - -def min_max_from_filter(builtin_filter: Any, name: str) -> Any: - """Convert a built-in min/max Jinja filter to a global function. - - The parameters may be passed as an iterable or as separate arguments. - """ - - @pass_environment - @wraps(builtin_filter) - def wrapper(environment: jinja2.Environment, *args: Any, **kwargs: Any) -> Any: - if len(args) == 0: - raise TypeError(f"{name} expected at least 1 argument, got 0") - - if len(args) == 1: - if isinstance(args[0], Iterable): - return builtin_filter(environment, args[0], **kwargs) - - raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") - - return builtin_filter(environment, args, **kwargs) - - return pass_environment(wrapper) - - -def average(*args: Any, default: Any = _SENTINEL) -> Any: - """Filter and function to calculate the arithmetic mean. - - Calculates of an iterable or of two or more arguments. - - The parameters may be passed as an iterable or as separate arguments. - """ - if len(args) == 0: - raise TypeError("average expected at least 1 argument, got 0") - - # If first argument is iterable and more than 1 argument provided but not a named - # default, then use 2nd argument as default. - if isinstance(args[0], Iterable): - average_list = args[0] - if len(args) > 1 and default is _SENTINEL: - default = args[1] - elif len(args) == 1: - raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") - else: - average_list = args - - try: - return statistics.fmean(average_list) - except (TypeError, statistics.StatisticsError): - if default is _SENTINEL: - raise_no_default("average", args) - return default - - -def median(*args: Any, default: Any = _SENTINEL) -> Any: - """Filter and function to calculate the median. - - Calculates median of an iterable of two or more arguments. - - The parameters may be passed as an iterable or as separate arguments. - """ - if len(args) == 0: - raise TypeError("median expected at least 1 argument, got 0") - - # If first argument is a list or tuple and more than 1 argument provided but not a named - # default, then use 2nd argument as default. - if isinstance(args[0], Iterable): - median_list = args[0] - if len(args) > 1 and default is _SENTINEL: - default = args[1] - elif len(args) == 1: - raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") - else: - median_list = args - - try: - return statistics.median(median_list) - except (TypeError, statistics.StatisticsError): - if default is _SENTINEL: - raise_no_default("median", args) - return default - - -def statistical_mode(*args: Any, default: Any = _SENTINEL) -> Any: - """Filter and function to calculate the statistical mode. - - Calculates mode of an iterable of two or more arguments. - - The parameters may be passed as an iterable or as separate arguments. - """ - if not args: - raise TypeError("statistical_mode expected at least 1 argument, got 0") - - # If first argument is a list or tuple and more than 1 argument provided but not a named - # default, then use 2nd argument as default. - if len(args) == 1 and isinstance(args[0], Iterable): - mode_list = args[0] - elif isinstance(args[0], list | tuple): - mode_list = args[0] - if len(args) > 1 and default is _SENTINEL: - default = args[1] - elif len(args) == 1: - raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") - else: - mode_list = args - - try: - return statistics.mode(mode_list) - except (TypeError, statistics.StatisticsError): - if default is _SENTINEL: - raise_no_default("statistical_mode", args) - return default - - -def forgiving_float(value, default=_SENTINEL): - """Try to convert value to a float.""" - try: - return float(value) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("float", value) - return default - - -def forgiving_float_filter(value, default=_SENTINEL): - """Try to convert value to a float.""" - try: - return float(value) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("float", value) - return default - - -def forgiving_int(value, default=_SENTINEL, base=10): - """Try to convert value to an int, and raise if it fails.""" - result = jinja2.filters.do_int(value, default=default, base=base) - if result is _SENTINEL: - raise_no_default("int", value) - return result - - -def forgiving_int_filter(value, default=_SENTINEL, base=10): - """Try to convert value to an int, and raise if it fails.""" - result = jinja2.filters.do_int(value, default=default, base=base) - if result is _SENTINEL: - raise_no_default("int", value) - return result - - -def is_number(value): - """Try to convert value to a float.""" - try: - fvalue = float(value) - except (ValueError, TypeError): - return False - if not math.isfinite(fvalue): - return False - return True - - -def _is_list(value: Any) -> bool: - """Return whether a value is a list.""" - return isinstance(value, list) - - -def _is_set(value: Any) -> bool: - """Return whether a value is a set.""" - return isinstance(value, set) - - -def _is_tuple(value: Any) -> bool: - """Return whether a value is a tuple.""" - return isinstance(value, tuple) - - -def _to_set(value: Any) -> set[Any]: - """Convert value to set.""" - return set(value) - - -def _to_tuple(value): - """Convert value to tuple.""" - return tuple(value) - - -def _is_datetime(value: Any) -> bool: - """Return whether a value is a datetime.""" - return isinstance(value, datetime) - - -def _is_string_like(value: Any) -> bool: - """Return whether a value is a string or string like object.""" - return isinstance(value, (str, bytes, bytearray)) - - -def regex_match(value, find="", ignorecase=False): - """Match value using regex.""" - if not isinstance(value, str): - value = str(value) - flags = re.IGNORECASE if ignorecase else 0 - return bool(_regex_cache(find, flags).match(value)) - - -_regex_cache = lru_cache(maxsize=128)(re.compile) - - -def regex_replace(value="", find="", replace="", ignorecase=False): - """Replace using regex.""" - if not isinstance(value, str): - value = str(value) - flags = re.IGNORECASE if ignorecase else 0 - return _regex_cache(find, flags).sub(replace, value) - - -def regex_search(value, find="", ignorecase=False): - """Search using regex.""" - if not isinstance(value, str): - value = str(value) - flags = re.IGNORECASE if ignorecase else 0 - return bool(_regex_cache(find, flags).search(value)) - - -def regex_findall_index(value, find="", index=0, ignorecase=False): - """Find all matches using regex and then pick specific match index.""" - return regex_findall(value, find, ignorecase)[index] - - -def regex_findall(value, find="", ignorecase=False): - """Find all matches using regex.""" - if not isinstance(value, str): - value = str(value) - flags = re.IGNORECASE if ignorecase else 0 - return _regex_cache(find, flags).findall(value) - - -def bitwise_and(first_value, second_value): - """Perform a bitwise and operation.""" - return first_value & second_value - - -def bitwise_or(first_value, second_value): - """Perform a bitwise or operation.""" - return first_value | second_value - - -def bitwise_xor(first_value, second_value): - """Perform a bitwise xor operation.""" - return first_value ^ second_value - - -def struct_pack(value: Any | None, format_string: str) -> bytes | None: - """Pack an object into a bytes object.""" - try: - return pack(format_string, value) - except StructError: - _LOGGER.warning( - ( - "Template warning: 'pack' unable to pack object '%s' with type '%s' and" - " format_string '%s' see https://docs.python.org/3/library/struct.html" - " for more information" - ), - str(value), - type(value).__name__, - format_string, - ) - return None - - -def struct_unpack(value: bytes, format_string: str, offset: int = 0) -> Any | None: - """Unpack an object from bytes an return the first native object.""" - try: - return unpack_from(format_string, value, offset)[0] - except StructError: - _LOGGER.warning( - ( - "Template warning: 'unpack' unable to unpack object '%s' with" - " format_string '%s' and offset %s see" - " https://docs.python.org/3/library/struct.html for more information" - ), - value, - format_string, - offset, - ) - return None - - -def base64_encode(value: str) -> str: - """Perform base64 encode.""" - return base64.b64encode(value.encode("utf-8")).decode("utf-8") - - -def base64_decode(value: str, encoding: str | None = "utf-8") -> str | bytes: - """Perform base64 decode.""" - decoded = base64.b64decode(value) - if encoding: - return decoded.decode(encoding) - - return decoded - - -def ordinal(value): - """Perform ordinal conversion.""" - suffixes = ["th", "st", "nd", "rd"] + ["th"] * 6 # codespell:ignore nd - return str(value) + ( - suffixes[(int(str(value)[-1])) % 10] - if int(str(value)[-2:]) % 100 not in range(11, 14) - else "th" - ) - - -def from_json(value): - """Convert a JSON string to an object.""" - return json.loads(value) - - -def to_json( - value: Any, - ensure_ascii: bool = False, - pretty_print: bool = False, - sort_keys: bool = False, -) -> str: - """Convert an object to a JSON string.""" - return json.dumps( - value, - ensure_ascii=ensure_ascii, - indent=2 if pretty_print else None, - sort_keys=sort_keys, - ) - - -@pass_context -def random_every_time(context, values): - """Choose a random value. - - Unlike Jinja's random filter, - this is context-dependent to avoid caching the chosen value. - """ - return random.choice(values) - - -def today_at(time_str: str = "") -> datetime: - today = dt_util.start_of_local_day() - if not time_str: - return today - - if (time_today := dt_util.parse_time(time_str)) is None: - raise ValueError( - f"could not convert {type(time_str).__name__} to datetime: '{time_str}'" - ) - - return datetime.combine(today, time_today, today.tzinfo) - - -def relative_time(value: Any) -> Any: - """Take a datetime and return its "age" as a string. - - The age can be in second, minute, hour, day, month or year. Only the - biggest unit is considered, e.g. if it's 2 days and 3 hours, "2 days" will - be returned. - If the input datetime is in the future, - the input datetime will be returned. - - If the input are not a datetime object the input will be returned unmodified. - - Note: This template function is deprecated in favor of `time_until`, but is still - supported so as not to break old templates. - """ - - if not isinstance(value, datetime): - return value - if not value.tzinfo: - value = dt_util.as_local(value) - if dt_util.now() < value: - return value - return dt_util.get_age(value) - - -def time_since(value: Any | datetime, precision: int = 1) -> Any: - """Take a datetime and return its "age" as a string. - - The age can be in seconds, minutes, hours, days, months and year. - - precision is the number of units to return, with the last unit rounded. - - If the value not a datetime object the input will be returned unmodified. - """ - if not isinstance(value, datetime): - return value - if not value.tzinfo: - value = dt_util.as_local(value) - if dt_util.now() < value: - return value - - return dt_util.get_age(value, precision) - - -def time_until(value: Any | datetime, precision: int = 1) -> Any: - """Take a datetime and return the amount of time until that time as a string. - - The time until can be in seconds, minutes, hours, days, months and years. - - precision is the number of units to return, with the last unit rounded. - - If the value not a datetime object the input will be returned unmodified. - """ - if not isinstance(value, datetime): - return value - if not value.tzinfo: - value = dt_util.as_local(value) - if dt_util.now() > value: - return value - - return dt_util.get_time_remaining(value, precision) - - -def urlencode(value): - """Urlencode dictionary and return as UTF-8 string.""" - return urllib_urlencode(value).encode("utf-8") - - -def slugify(value, separator="_"): - """Convert a string into a slug, such as what is used for entity ids.""" - return slugify_util(value, separator=separator) - - -def iif( - value: Any, if_true: Any = True, if_false: Any = False, if_none: Any = _SENTINEL -) -> Any: - """Immediate if function/filter that allow for common if/else constructs. - - https://en.wikipedia.org/wiki/IIf - - Examples: - {{ is_state("device_tracker.frenck", "home") | iif("yes", "no") }} - {{ iif(1==2, "yes", "no") }} - {{ (1 == 1) | iif("yes", "no") }} - - """ - if value is None and if_none is not _SENTINEL: - return if_none - if bool(value): - return if_true - return if_false - - -def shuffle(*args: Any, seed: Any = None) -> MutableSequence[Any]: - """Shuffle a list, either with a seed or without.""" - if not args: - raise TypeError("shuffle expected at least 1 argument, got 0") - - # If first argument is iterable and more than 1 argument provided - # but not a named seed, then use 2nd argument as seed. - if isinstance(args[0], Iterable): - items = list(args[0]) - if len(args) > 1 and seed is None: - seed = args[1] - elif len(args) == 1: - raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") - else: - items = list(args) - - if seed: - r = random.Random(seed) - r.shuffle(items) - else: - random.shuffle(items) - return items - - -def typeof(value: Any) -> Any: - """Return the type of value passed to debug types.""" - return value.__class__.__name__ - - -def flatten(value: Iterable[Any], levels: int | None = None) -> list[Any]: - """Flattens list of lists.""" - if not isinstance(value, Iterable) or isinstance(value, str): - raise TypeError(f"flatten expected a list, got {type(value).__name__}") - - flattened: list[Any] = [] - for item in value: - if isinstance(item, Iterable) and not isinstance(item, str): - if levels is None: - flattened.extend(flatten(item)) - elif levels >= 1: - flattened.extend(flatten(item, levels=(levels - 1))) - else: - flattened.append(item) - else: - flattened.append(item) - return flattened - - -def intersect(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: - """Return the common elements between two lists.""" - if not isinstance(value, Iterable) or isinstance(value, str): - raise TypeError(f"intersect expected a list, got {type(value).__name__}") - if not isinstance(other, Iterable) or isinstance(other, str): - raise TypeError(f"intersect expected a list, got {type(other).__name__}") - - return list(set(value) & set(other)) - - -def difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: - """Return elements in first list that are not in second list.""" - if not isinstance(value, Iterable) or isinstance(value, str): - raise TypeError(f"difference expected a list, got {type(value).__name__}") - if not isinstance(other, Iterable) or isinstance(other, str): - raise TypeError(f"difference expected a list, got {type(other).__name__}") - - return list(set(value) - set(other)) - - -def union(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: - """Return all unique elements from both lists combined.""" - if not isinstance(value, Iterable) or isinstance(value, str): - raise TypeError(f"union expected a list, got {type(value).__name__}") - if not isinstance(other, Iterable) or isinstance(other, str): - raise TypeError(f"union expected a list, got {type(other).__name__}") - - return list(set(value) | set(other)) - - -def symmetric_difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: - """Return elements that are in either list but not in both.""" - if not isinstance(value, Iterable) or isinstance(value, str): - raise TypeError( - f"symmetric_difference expected a list, got {type(value).__name__}" - ) - if not isinstance(other, Iterable) or isinstance(other, str): - raise TypeError( - f"symmetric_difference expected a list, got {type(other).__name__}" - ) - - return list(set(value) ^ set(other)) - - -def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]: - """Combine multiple dictionaries into one.""" - if not args: - raise TypeError("combine expected at least 1 argument, got 0") - - result: dict[Any, Any] = {} - for arg in args: - if not isinstance(arg, dict): - raise TypeError(f"combine expected a dict, got {type(arg).__name__}") - - if recursive: - for key, value in arg.items(): - if ( - key in result - and isinstance(result[key], dict) - and isinstance(value, dict) - ): - result[key] = combine(result[key], value, recursive=True) - else: - result[key] = value - else: - result |= arg - - return result - - -def md5(value: str) -> str: - """Generate md5 hash from a string.""" - return hashlib.md5(value.encode()).hexdigest() - - -def sha1(value: str) -> str: - """Generate sha1 hash from a string.""" - return hashlib.sha1(value.encode()).hexdigest() - - -def sha256(value: str) -> str: - """Generate sha256 hash from a string.""" - return hashlib.sha256(value.encode()).hexdigest() - - -def sha512(value: str) -> str: - """Generate sha512 hash from a string.""" - return hashlib.sha512(value.encode()).hexdigest() - - -class TemplateContextManager(AbstractContextManager): - """Context manager to store template being parsed or rendered in a ContextVar.""" - - def set_template(self, template_str: str, action: str) -> None: - """Store template being parsed or rendered in a Contextvar to aid error handling.""" - template_cv.set((template_str, action)) - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - """Raise any exception triggered within the runtime context.""" - template_cv.set(None) - - -_template_context_manager = TemplateContextManager() - - -def _render_with_context( - template_str: str, template: jinja2.Template, **kwargs: Any -) -> str: - """Store template being rendered in a ContextVar to aid error handling.""" - with _template_context_manager as cm: - cm.set_template(template_str, "rendering") - return template.render(**kwargs) - -class TemplateEnvironment(ImmutableSandboxedEnvironment): - """The Home Assistant template environment.""" - - def __init__( - self, - ) -> None: - """Initialise template environment.""" - super().__init__(undefined=jinja2.StrictUndefined) - self.template_cache: weakref.WeakValueDictionary[ - str | jinja2.nodes.Template, CodeType | None - ] = weakref.WeakValueDictionary() - self.add_extension("jinja2.ext.loopcontrols") - - self.globals["acos"] = arc_cosine - self.globals["as_datetime"] = as_datetime - self.globals["as_local"] = dt_util.as_local - self.globals["as_timedelta"] = as_timedelta - self.globals["as_timestamp"] = forgiving_as_timestamp - self.globals["asin"] = arc_sine - self.globals["atan"] = arc_tangent - self.globals["atan2"] = arc_tangent2 - self.globals["average"] = average - self.globals["bool"] = forgiving_boolean - self.globals["combine"] = combine - self.globals["cos"] = cosine - self.globals["difference"] = difference - self.globals["e"] = math.e - self.globals["flatten"] = flatten - self.globals["float"] = forgiving_float - self.globals["iif"] = iif - self.globals["int"] = forgiving_int - self.globals["intersect"] = intersect - self.globals["is_number"] = is_number - self.globals["log"] = logarithm - self.globals["max"] = min_max_from_filter(self.filters["max"], "max") - self.globals["md5"] = md5 - self.globals["median"] = median - self.globals["min"] = min_max_from_filter(self.filters["min"], "min") - self.globals["pack"] = struct_pack - self.globals["pi"] = math.pi - self.globals["set"] = _to_set - self.globals["sha1"] = sha1 - self.globals["sha256"] = sha256 - self.globals["sha512"] = sha512 - self.globals["shuffle"] = shuffle - self.globals["sin"] = sine - self.globals["slugify"] = slugify - self.globals["sqrt"] = square_root - self.globals["statistical_mode"] = statistical_mode - self.globals["strptime"] = strptime - self.globals["symmetric_difference"] = symmetric_difference - self.globals["tan"] = tangent - self.globals["tau"] = math.pi * 2 - self.globals["timedelta"] = timedelta - self.globals["tuple"] = _to_tuple - self.globals["typeof"] = typeof - self.globals["union"] = union - self.globals["unpack"] = struct_unpack - self.globals["urlencode"] = urlencode - self.globals["version"] = version - self.globals["zip"] = zip - - self.filters["acos"] = arc_cosine - self.filters["add"] = add - self.filters["as_datetime"] = as_datetime - self.filters["as_local"] = dt_util.as_local - self.filters["as_timedelta"] = as_timedelta - self.filters["as_timestamp"] = forgiving_as_timestamp - self.filters["asin"] = arc_sine - self.filters["atan"] = arc_tangent - self.filters["atan2"] = arc_tangent2 - self.filters["average"] = average - self.filters["base64_decode"] = base64_decode - self.filters["base64_encode"] = base64_encode - self.filters["bitwise_and"] = bitwise_and - self.filters["bitwise_or"] = bitwise_or - self.filters["bitwise_xor"] = bitwise_xor - self.filters["bool"] = forgiving_boolean - self.filters["combine"] = combine - self.filters["contains"] = contains - self.filters["cos"] = cosine - self.filters["difference"] = difference - self.filters["flatten"] = flatten - self.filters["float"] = forgiving_float_filter - self.filters["from_json"] = from_json - self.filters["iif"] = iif - self.filters["int"] = forgiving_int_filter - self.filters["intersect"] = intersect - self.filters["is_defined"] = fail_when_undefined - self.filters["is_number"] = is_number - self.filters["log"] = logarithm - self.filters["md5"] = md5 - self.filters["median"] = median - self.filters["multiply"] = multiply - self.filters["ord"] = ord - self.filters["ordinal"] = ordinal - self.filters["pack"] = struct_pack - self.filters["random"] = random_every_time - self.filters["regex_findall_index"] = regex_findall_index - self.filters["regex_findall"] = regex_findall - self.filters["regex_match"] = regex_match - self.filters["regex_replace"] = regex_replace - self.filters["regex_search"] = regex_search - self.filters["round"] = forgiving_round - self.filters["sha1"] = sha1 - self.filters["sha256"] = sha256 - self.filters["sha512"] = sha512 - self.filters["shuffle"] = shuffle - self.filters["sin"] = sine - self.filters["slugify"] = slugify - self.filters["sqrt"] = square_root - self.filters["statistical_mode"] = statistical_mode - self.filters["symmetric_difference"] = symmetric_difference - self.filters["tan"] = tangent - self.filters["timestamp_custom"] = timestamp_custom - self.filters["timestamp_local"] = timestamp_local - self.filters["timestamp_utc"] = timestamp_utc - self.filters["to_json"] = to_json - self.filters["typeof"] = typeof - self.filters["union"] = union - self.filters["unpack"] = struct_unpack - self.filters["version"] = version - - self.tests["contains"] = contains - self.tests["datetime"] = _is_datetime - self.tests["is_number"] = is_number - self.tests["list"] = _is_list - self.tests["match"] = regex_match - self.tests["search"] = regex_search - self.tests["set"] = _is_set - self.tests["string_like"] = _is_string_like - self.tests["tuple"] = _is_tuple - - def is_safe_attribute(self, obj, attr, value): - """Test if attribute is safe.""" - if isinstance( - obj, (LoopContext, AsyncLoopContext) - ): - return attr[0] != "_" - - if isinstance(obj, Namespace): - return True - - return super().is_safe_attribute(obj, attr, value) - - @overload - def compile( - self, - source: str | jinja2.nodes.Template, - name: str | None = None, - filename: str | None = None, - raw: Literal[False] = False, - defer_init: bool = False, - ) -> CodeType: ... - - @overload - def compile( - self, - source: str | jinja2.nodes.Template, - name: str | None = None, - filename: str | None = None, - raw: Literal[True] = ..., - defer_init: bool = False, - ) -> str: ... - - def compile( - self, - source: str | jinja2.nodes.Template, - ) -> CodeType | str: - """Compile the template.""" - - compiled = super().compile(source) - self.template_cache[source] = compiled - return compiled - - -_NO_HASS_ENV = TemplateEnvironment() \ No newline at end of file diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/util/__init__.py b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/util/__init__.py deleted file mode 100644 index 93dfd557133..00000000000 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/util/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Helper methods for various modules.""" - -from __future__ import annotations - -import slugify as unicode_slug - -def slugify(text: str | None, *, separator: str = "_") -> str: - """Slugify a given text.""" - if text == "" or text is None: - return "" - slug = unicode_slug.slugify(text, separator=separator) - return "unknown" if slug == "" else slug diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/util/dt.py b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/util/dt.py deleted file mode 100644 index 349db2ac04f..00000000000 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/util/dt.py +++ /dev/null @@ -1,321 +0,0 @@ -"""Helper methods to handle the time in Home Assistant.""" - -from __future__ import annotations - -from contextlib import suppress -import datetime as dt -from functools import partial -import re -from typing import Any, Literal, overload -import zoneinfo - -from datetime import datetime - -UTC = dt.UTC -DEFAULT_TIME_ZONE: dt.tzinfo = dt.UTC - -# EPOCHORDINAL is not exposed as a constant -# https://github.com/python/cpython/blob/3.10/Lib/zoneinfo/_zoneinfo.py#L12 -EPOCHORDINAL = dt.datetime(1970, 1, 1).toordinal() - -# Copyright (c) Django Software Foundation and individual contributors. -# All rights reserved. -# https://github.com/django/django/blob/main/LICENSE -DATETIME_RE = re.compile( - r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})" - r"[T ](?P\d{1,2}):(?P\d{1,2})" - r"(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?" - r"(?PZ|[+-]\d{2}(?::?\d{2})?)?$" -) - -# Copyright (c) Django Software Foundation and individual contributors. -# All rights reserved. -# https://github.com/django/django/blob/main/LICENSE -STANDARD_DURATION_RE = re.compile( - r"^" - r"(?:(?P-?\d+) (days?, )?)?" - r"(?P-?)" - r"((?:(?P\d+):)(?=\d+:\d+))?" - r"(?:(?P\d+):)?" - r"(?P\d+)" - r"(?:[\.,](?P\d{1,6})\d{0,6})?" - r"$" -) - -# Copyright (c) Django Software Foundation and individual contributors. -# All rights reserved. -# https://github.com/django/django/blob/main/LICENSE -ISO8601_DURATION_RE = re.compile( - r"^(?P[-+]?)" - r"P" - r"(?:(?P\d+([\.,]\d+)?)D)?" - r"(?:T" - r"(?:(?P\d+([\.,]\d+)?)H)?" - r"(?:(?P\d+([\.,]\d+)?)M)?" - r"(?:(?P\d+([\.,]\d+)?)S)?" - r")?" - r"$" -) - -# Copyright (c) Django Software Foundation and individual contributors. -# All rights reserved. -# https://github.com/django/django/blob/main/LICENSE -POSTGRES_INTERVAL_RE = re.compile( - r"^" - r"(?:(?P-?\d+) (days? ?))?" - r"(?:(?P[-+])?" - r"(?P\d+):" - r"(?P\d\d):" - r"(?P\d\d)" - r"(?:\.(?P\d{1,6}))?" - r")?$" -) - - -def set_default_time_zone(time_zone: dt.tzinfo) -> None: - """Set a default time zone to be used when none is specified. - - Async friendly. - """ - # pylint: disable-next=global-statement - global DEFAULT_TIME_ZONE # noqa: PLW0603 - - assert isinstance(time_zone, dt.tzinfo) - - DEFAULT_TIME_ZONE = time_zone - - -def get_time_zone(time_zone_str: str) -> zoneinfo.ZoneInfo | None: - """Get time zone from string. Return None if unable to determine.""" - try: - return zoneinfo.ZoneInfo(time_zone_str) - except zoneinfo.ZoneInfoNotFoundError: - return None - - -# We use a partial here since it is implemented in native code -# and avoids the global lookup of UTC -utcnow = partial(dt.datetime.now, UTC) -utcnow.__doc__ = "Get now in UTC time." - - -def now(time_zone: dt.tzinfo | None = None) -> dt.datetime: - """Get now in specified time zone.""" - return dt.datetime.now(time_zone or DEFAULT_TIME_ZONE) - - -def as_timestamp(dt_value: dt.datetime | str) -> float: - """Convert a date/time into a unix time (seconds since 1970).""" - parsed_dt: dt.datetime | None - if isinstance(dt_value, dt.datetime): - parsed_dt = dt_value - else: - parsed_dt = parse_datetime(str(dt_value)) - if parsed_dt is None: - raise ValueError("not a valid date/time.") - return parsed_dt.timestamp() - - -def as_local(dattim: dt.datetime) -> dt.datetime: - """Convert a UTC datetime object to local time zone.""" - if dattim.tzinfo == DEFAULT_TIME_ZONE: - return dattim - if dattim.tzinfo is None: - dattim = dattim.replace(tzinfo=DEFAULT_TIME_ZONE) - - return dattim.astimezone(DEFAULT_TIME_ZONE) - - -# We use a partial here to improve performance by avoiding the global lookup -# of UTC and the function call overhead. -utc_from_timestamp = partial(dt.datetime.fromtimestamp, tz=UTC) -"""Return a UTC time from a timestamp.""" - - -def start_of_local_day(dt_or_d: dt.date | dt.datetime | None = None) -> dt.datetime: - """Return local datetime object of start of day from date or datetime.""" - if dt_or_d is None: - date: dt.date = now().date() - elif isinstance(dt_or_d, dt.datetime): - date = dt_or_d.date() - else: - date = dt_or_d - - return dt.datetime.combine(date, dt.time(), tzinfo=DEFAULT_TIME_ZONE) - - -# Copyright (c) Django Software Foundation and individual contributors. -# All rights reserved. -# https://github.com/django/django/blob/main/LICENSE -@overload -def parse_datetime(dt_str: str) -> dt.datetime | None: ... - - -@overload -def parse_datetime(dt_str: str, *, raise_on_error: Literal[True]) -> dt.datetime: ... - - -@overload -def parse_datetime( - dt_str: str, *, raise_on_error: Literal[False] -) -> dt.datetime | None: ... - - -def parse_datetime(dt_str: str, *, raise_on_error: bool = False) -> dt.datetime | None: - """Parse a string and return a datetime.datetime. - - This function supports time zone offsets. When the input contains one, - the output uses a timezone with a fixed offset from UTC. - Raises ValueError if the input is well formatted but not a valid datetime. - - If the input isn't well formatted, returns None if raise_on_error is False - or raises ValueError if it's True. - """ - # First try if the string can be parsed by the stdlib - with suppress(ValueError, IndexError): - return datetime.fromisoformat(dt_str) - - # stdlib failed to parse the string, fall back to regex - if not (match := DATETIME_RE.match(dt_str)): - if raise_on_error: - raise ValueError - return None - kws: dict[str, Any] = match.groupdict() - if kws["microsecond"]: - kws["microsecond"] = kws["microsecond"].ljust(6, "0") - tzinfo_str = kws.pop("tzinfo") - - tzinfo: dt.tzinfo | None = None - if tzinfo_str == "Z": - tzinfo = UTC - elif tzinfo_str is not None: - offset_mins = int(tzinfo_str[-2:]) if len(tzinfo_str) > 3 else 0 - offset_hours = int(tzinfo_str[1:3]) - offset = dt.timedelta(hours=offset_hours, minutes=offset_mins) - if tzinfo_str[0] == "-": - offset = -offset - tzinfo = dt.timezone(offset) - kws = {k: int(v) for k, v in kws.items() if v is not None} - kws["tzinfo"] = tzinfo - return dt.datetime(**kws) - - -# Copyright (c) Django Software Foundation and individual contributors. -# All rights reserved. -# https://github.com/django/django/blob/master/LICENSE -def parse_duration(value: str) -> dt.timedelta | None: - """Parse a duration string and return a datetime.timedelta. - - Also supports ISO 8601 representation and PostgreSQL's day-time interval - format. - """ - match = ( - STANDARD_DURATION_RE.match(value) - or ISO8601_DURATION_RE.match(value) - or POSTGRES_INTERVAL_RE.match(value) - ) - if match: - kws = match.groupdict() - sign = -1 if kws.pop("sign", "+") == "-" else 1 - if kws.get("microseconds"): - kws["microseconds"] = kws["microseconds"].ljust(6, "0") - time_delta_args: dict[str, float] = { - k: float(v.replace(",", ".")) for k, v in kws.items() if v is not None - } - days = dt.timedelta(float(time_delta_args.pop("days", 0.0) or 0.0)) - if match.re == ISO8601_DURATION_RE: - days *= sign - return days + sign * dt.timedelta(**time_delta_args) - return None - - -def parse_time(time_str: str) -> dt.time | None: - """Parse a time string (00:20:00) into Time object. - - Return None if invalid. - """ - parts = str(time_str).split(":") - if len(parts) < 2: - return None - try: - hour = int(parts[0]) - minute = int(parts[1]) - second = int(parts[2]) if len(parts) > 2 else 0 - return dt.time(hour, minute, second) - except ValueError: - # ValueError if value cannot be converted to an int or not in range - return None - - -def _get_timestring(timediff: float, precision: int = 1) -> str: - """Return a string representation of a time diff.""" - - def formatn(number: int, unit: str) -> str: - """Add "unit" if it's plural.""" - if number == 1: - return f"1 {unit} " - return f"{number:d} {unit}s " - - if timediff == 0.0: - return "0 seconds" - - units = ("year", "month", "day", "hour", "minute", "second") - - factors = (365 * 24 * 60 * 60, 30 * 24 * 60 * 60, 24 * 60 * 60, 60 * 60, 60, 1) - - result_string: str = "" - current_precision = 0 - - for i, current_factor in enumerate(factors): - selected_unit = units[i] - if timediff < current_factor: - continue - current_precision = current_precision + 1 - if current_precision == precision: - return ( - result_string + formatn(round(timediff / current_factor), selected_unit) - ).rstrip() - curr_diff = int(timediff // current_factor) - result_string += formatn(curr_diff, selected_unit) - timediff -= (curr_diff) * current_factor - - return result_string.rstrip() - - -def get_age(date: dt.datetime, precision: int = 1) -> str: - """Take a datetime and return its "age" as a string. - - The age can be in second, minute, hour, day, month and year. - - depth number of units will be returned, with the last unit rounded - - The date must be in the past or a ValueException will be raised. - """ - - delta = (now() - date).total_seconds() - - rounded_delta = round(delta) - - if rounded_delta < 0: - raise ValueError("Time value is in the future") - return _get_timestring(rounded_delta, precision) - - -def get_time_remaining(date: dt.datetime, precision: int = 1) -> str: - """Take a datetime and return its "age" as a string. - - The age can be in second, minute, hour, day, month and year. - - depth number of units will be returned, with the last unit rounded - - The date must be in the future or a ValueException will be raised. - """ - - delta = (date - now()).total_seconds() - - rounded_delta = round(delta) - - if rounded_delta < 0: - raise ValueError("Time value is in the past") - - return _get_timestring(rounded_delta, precision) diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/AbstractHomeAssistantTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/AbstractHomeAssistantTests.java index b34d1da4067..866710b685b 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/AbstractHomeAssistantTests.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/AbstractHomeAssistantTests.java @@ -57,7 +57,11 @@ import org.openhab.core.thing.type.ChannelTypeRegistry; import org.openhab.core.thing.type.ThingType; import org.openhab.core.thing.type.ThingTypeBuilder; import org.openhab.core.thing.type.ThingTypeRegistry; +import org.openhab.core.transform.TransformationHelper; +import org.openhab.core.transform.TransformationService; import org.openhab.core.util.BundleResolver; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceReference; /** * Abstract class for HomeAssistant unit tests. @@ -82,7 +86,6 @@ public abstract class AbstractHomeAssistantTests extends JavaTest { public static final ThingUID HA_UID = new ThingUID(MqttBindingConstants.HOMEASSISTANT_MQTT_THING, HA_ID); public static final ThingType HA_THING_TYPE = ThingTypeBuilder .instance(MqttBindingConstants.HOMEASSISTANT_MQTT_THING, HA_TYPE_LABEL).build(); - protected static final HomeAssistantPythonBridge python = new HomeAssistantPythonBridge(); protected @Mock @NonNullByDefault({}) MqttBrokerConnection bridgeConnection; protected @Mock @NonNullByDefault({}) ThingTypeRegistry thingTypeRegistry; @@ -96,11 +99,20 @@ public abstract class AbstractHomeAssistantTests extends JavaTest { protected Thing haThing = ThingBuilder.create(HA_TYPE_UID, HA_UID).withBridge(BRIDGE_UID).build(); protected final ConcurrentMap> subscriptions = new ConcurrentHashMap<>(); + private @Mock @NonNullByDefault({}) TransformationService transformationService1Mock; + + private @Mock @NonNullByDefault({}) BundleContext bundleContextMock; private @Mock @NonNullByDefault({}) TranslationProvider translationProvider; private @Mock @NonNullByDefault({}) BundleResolver bundleResolver; + private @Mock @NonNullByDefault({}) ServiceReference serviceRefMock; + + private @NonNullByDefault({}) TransformationHelper transformationHelper; @BeforeEach public void beforeEachAbstractHomeAssistantTests() { + transformationHelper = new TransformationHelper(bundleContextMock); + transformationHelper.setTransformationService(serviceRefMock); + when(thingTypeRegistry.getThingType(BRIDGE_TYPE_UID)) .thenReturn(ThingTypeBuilder.instance(BRIDGE_TYPE_UID, BRIDGE_TYPE_LABEL).build()); when(thingTypeRegistry.getThingType(MqttBindingConstants.HOMEASSISTANT_MQTT_THING)).thenReturn(HA_THING_TYPE); diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformationTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformationTests.java index 89d83af0e56..9e17da9558f 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformationTests.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformationTests.java @@ -24,10 +24,19 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider; +import org.openhab.binding.mqtt.homeassistant.generic.internal.MqttThingHandlerFactory; import org.openhab.binding.mqtt.homeassistant.internal.component.AbstractComponent; +import org.openhab.core.i18n.TranslationProvider; +import org.openhab.core.i18n.UnitProvider; +import org.openhab.core.test.storage.VolatileStorageService; +import org.openhab.core.thing.type.ChannelTypeRegistry; +import org.openhab.core.thing.type.ThingTypeRegistry; +import org.openhab.core.util.BundleResolver; /** * @author Jochen Klein - Initial contribution @@ -35,62 +44,72 @@ import org.openhab.binding.mqtt.homeassistant.internal.component.AbstractCompone @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @NonNullByDefault -public class HomeAssistantChannelTransformationTests extends AbstractHomeAssistantTests { +public class HomeAssistantChannelTransformationTests { + protected @Mock @NonNullByDefault({}) ThingTypeRegistry thingTypeRegistry; + protected @Mock @NonNullByDefault({}) UnitProvider unitProvider; - private @Mock @NonNullByDefault({}) AbstractComponent component; + protected @NonNullByDefault({}) HomeAssistantChannelTransformation transformation; + private @Mock @NonNullByDefault({}) BundleResolver bundleResolver; + private @Mock @NonNullByDefault({}) TranslationProvider translationProvider; @BeforeEach public void beforeEachChannelTransformationTest() { + MqttChannelTypeProvider channelTypeProvider = new MqttChannelTypeProvider(thingTypeRegistry, + new VolatileStorageService()); + HomeAssistantStateDescriptionProvider stateDescriptionProvider = new HomeAssistantStateDescriptionProvider( + translationProvider, bundleResolver); + ChannelTypeRegistry channelTypeRegistry = new ChannelTypeRegistry(); + MqttThingHandlerFactory thingHandlerFactory = new MqttThingHandlerFactory(channelTypeProvider, + stateDescriptionProvider, channelTypeRegistry, unitProvider); + + AbstractComponent component = Mockito.mock(AbstractComponent.class); HaID haID = new HaID("homeassistant/light/pool/light/config"); when(component.getHaID()).thenReturn(haID); - } - - @Test - public void testInvalidTemplate() { - assertThat(transform("{{}}", ""), is(nullValue())); + transformation = new HomeAssistantChannelTransformation(thingHandlerFactory.getJinjava(), component, ""); } @Test public void testIif() { - assertThat(transform("{{ iif(true) }}", ""), is("True")); - assertThat(transform("{{ iif(false) }}", ""), is("False")); - assertThat(transform("{{ iif(none) }}", ""), is("False")); - assertThat(transform("{{ iif(true, 'Yes') }}", ""), is("Yes")); - assertThat(transform("{{ iif(false, 'Yes') }}", ""), is("False")); - assertThat(transform("{{ iif(none, 'Yes') }}", ""), is("False")); - assertThat(transform("{{ iif(true, 'Yes', 'No') }}", ""), is("Yes")); - assertThat(transform("{{ iif(false, 'Yes', 'No') }}", ""), is("No")); - assertThat(transform("{{ iif(none, 'Yes', 'No') }}", ""), is("No")); - assertThat(transform("{{ iif(true, 'Yes', 'No', none) }}", ""), is("Yes")); - assertThat(transform("{{ iif(false, 'Yes', 'No', none) }}", ""), is("No")); - assertThat(transform("{{ iif(none, 'Yes', 'No', 'NULL') }}", ""), is("NULL")); - assertThat(transform("{{ iif(none, 'Yes', 'No', none) }}", ""), is("None")); + assertThat(transform("{{ iif(True) }}", ""), is("true")); + assertThat(transform("{{ iif(False) }}", ""), is("false")); + assertThat(transform("{{ iif(Null) }}", ""), is("false")); + assertThat(transform("{{ iif(True, 'Yes') }}", ""), is("Yes")); + assertThat(transform("{{ iif(False, 'Yes') }}", ""), is("false")); + assertThat(transform("{{ iif(Null, 'Yes') }}", ""), is("false")); + assertThat(transform("{{ iif(True, 'Yes', 'No') }}", ""), is("Yes")); + assertThat(transform("{{ iif(False, 'Yes', 'No') }}", ""), is("No")); + assertThat(transform("{{ iif(Null, 'Yes', 'No') }}", ""), is("No")); + assertThat(transform("{{ iif(True, 'Yes', 'No', null) }}", ""), is("Yes")); + assertThat(transform("{{ iif(False, 'Yes', 'No', null) }}", ""), is("No")); + assertThat(transform("{{ iif(Null, 'Yes', 'No', 'NULL') }}", ""), is("NULL")); + assertThat(transform("{{ iif(Null, 'Yes', 'No', null) }}", ""), is("")); + assertThat(transform("{{ iif(True, 'Yes', 'No', null, null) }}", ""), is(nullValue())); - assertThat(transform("{{ true | iif('Yes') }}", ""), is("Yes")); - assertThat(transform("{{ false | iif('Yes') }}", ""), is("False")); - assertThat(transform("{{ none | iif('Yes') }}", ""), is("False")); - assertThat(transform("{{ true | iif('Yes', 'No') }}", ""), is("Yes")); - assertThat(transform("{{ false | iif('Yes', 'No') }}", ""), is("No")); - assertThat(transform("{{ none | iif('Yes', 'No') }}", ""), is("No")); - assertThat(transform("{{ true | iif('Yes', 'No', none) }}", ""), is("Yes")); - assertThat(transform("{{ false | iif('Yes', 'No', none) }}", ""), is("No")); - assertThat(transform("{{ none | iif('Yes', 'No', 'NULL') }}", ""), is("NULL")); - assertThat(transform("{{ none | iif('Yes', 'No', none) }}", ""), is("None")); + assertThat(transform("{{ True | iif('Yes') }}", ""), is("Yes")); + assertThat(transform("{{ False | iif('Yes') }}", ""), is("false")); + assertThat(transform("{{ Null | iif('Yes') }}", ""), is("false")); + assertThat(transform("{{ True | iif('Yes', 'No') }}", ""), is("Yes")); + assertThat(transform("{{ False | iif('Yes', 'No') }}", ""), is("No")); + assertThat(transform("{{ Null | iif('Yes', 'No') }}", ""), is("No")); + assertThat(transform("{{ True | iif('Yes', 'No', null) }}", ""), is("Yes")); + assertThat(transform("{{ False | iif('Yes', 'No', null) }}", ""), is("No")); + assertThat(transform("{{ Null | iif('Yes', 'No', 'NULL') }}", ""), is("NULL")); + assertThat(transform("{{ Null | iif('Yes', 'No', null) }}", ""), is("")); + assertThat(transform("{{ True | iif('Yes', 'No', null, null) }}", ""), is(nullValue())); } @Test public void testIsDefined() { - assertThat(transform("{{ value_json.val }}", "{ \"val\": \"abc\" }", "default"), is("abc")); - assertThat(transform("{{ value_json.val }}", "{ \"val\": null }", "default"), is("None")); - assertThat(transform("{{ value_json.something | is_defined }}", "{ \"val\": null }", "default"), is("default")); + assertThat(transform("{{ value_json.val | is_defined }}", "{}"), is(nullValue())); + assertThat(transform("{{ 'hi' | is_defined }}", "{}"), is("hi")); } @Test public void testRegexFindall() { - assertThat(transform("{{ 'Flight from JFK to LHR' | regex_findall('([A-Z]{3})') }}", ""), is("['JFK', 'LHR']")); + assertThat(transform("{{ 'Flight from JFK to LHR' | regex_findall('([A-Z]{3})') }}", ""), is("[JFK, LHR]")); assertThat(transform( "{{ 'button_up_press' | regex_findall('^(?P