diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/.gitignore b/bundles/org.openhab.binding.mqtt.homeassistant/.gitignore new file mode 100644 index 00000000000..11bcd382c29 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/.gitignore @@ -0,0 +1 @@ +/src/main/python/**/__pycache__/ diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/DEVELOPMENT.md b/bundles/org.openhab.binding.mqtt.homeassistant/DEVELOPMENT.md new file mode 100644 index 00000000000..b73513dfca7 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/DEVELOPMENT.md @@ -0,0 +1,12 @@ +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 38d625e3492..54479cdbefd 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/NOTICE +++ b/bundles/org.openhab.binding.mqtt.homeassistant/NOTICE @@ -11,3 +11,10 @@ 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 new file mode 100644 index 00000000000..9b692df1603 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/bnd.bnd @@ -0,0 +1,21 @@ +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 deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/pom.xml b/bundles/org.openhab.binding.mqtt.homeassistant/pom.xml index df22ab61e78..62b4a2a281a 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/pom.xml +++ b/bundles/org.openhab.binding.mqtt.homeassistant/pom.xml @@ -14,6 +14,16 @@ openHAB Add-ons :: Bundles :: MQTT HomeAssistant Convention + + !sun.misc.*, + !sun.reflect.*, + !com.sun.management.*, + !jdk.internal.reflect.*, + !jdk.vm.ci.services + + 24.2.0 + + org.openhab.addons.bundles @@ -27,42 +37,170 @@ ${project.version} provided - - com.google.guava - guava - 33.3.1-jre - test - + - org.openhab.osgiify - com.hubspot.jinjava.jinjava - 2.7.4 - compile + org.graalvm.polyglot + polyglot + ${graalpy.version} + + + + org.graalvm.regex + regex + ${graalpy.version} + + + + org.graalvm.polyglot + python-community + ${graalpy.version} + pom - 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.graalvm.python + python-embedding + ${graalpy.version} + + + + + 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/bin/python3 + + -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 + + + + + + + + + + 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 59a216a0bd9..df50619a45c 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 @@ -6,11 +6,14 @@ openhab-runtime-base openhab-transport-mqtt openhab.tp-commons-net - 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.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.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 808f272c3a5..f7604d0172a 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.HomeAssistantJinjaFunctionLibrary; +import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantPythonBridge; import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantStateDescriptionProvider; import org.openhab.binding.mqtt.homeassistant.internal.handler.HomeAssistantThingHandler; import org.openhab.core.i18n.UnitProvider; @@ -34,8 +34,6 @@ 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. @@ -48,8 +46,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()); @@ -62,8 +60,7 @@ public class MqttThingHandlerFactory extends BaseThingHandlerFactory { this.stateDescriptionProvider = stateDescriptionProvider; this.channelTypeRegistry = channelTypeRegistry; this.unitProvider = unitProvider; - - HomeAssistantJinjaFunctionLibrary.register(jinjava.getGlobalContext()); + this.python = new HomeAssistantPythonBridge(); } @Override @@ -82,12 +79,12 @@ public class MqttThingHandlerFactory extends BaseThingHandlerFactory { if (supportsThingType(thingTypeUID)) { return new HomeAssistantThingHandler(thing, this, typeProvider, stateDescriptionProvider, - channelTypeRegistry, jinjava, unitProvider, 10000, 2000); + channelTypeRegistry, python, unitProvider, 10000, 2000); } return null; } - public Jinjava getJinjava() { - return jinjava; + public HomeAssistantPythonBridge getPython() { + return python; } } 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 24a9c2529e9..cbec34753d9 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,6 +136,7 @@ public class ComponentChannel { private @Nullable String stateTopic; private @Nullable String commandTopic; + private boolean parseCommandValueAsInteger; private boolean retain; private boolean trigger; private boolean isAdvanced; @@ -206,6 +207,11 @@ 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; @@ -265,13 +271,13 @@ public class ComponentChannel { String localTemplateIn = templateIn; if (localTemplateIn != null) { - incomingTransformation = new HomeAssistantChannelTransformation(component.getJinjava(), component, - localTemplateIn); + incomingTransformation = new HomeAssistantChannelTransformation(component.getPython(), component, + localTemplateIn, false); } String localTemplateOut = templateOut; if (localTemplateOut != null) { - outgoingTransformation = new HomeAssistantChannelTransformation(component.getJinjava(), component, - localTemplateOut); + outgoingTransformation = new HomeAssistantChannelTransformation(component.getPython(), component, + localTemplateOut, true, parseCommandValueAsInteger); } 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 9663e2d2243..3c187f3e7f2 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,7 +38,6 @@ 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 @@ -57,7 +56,7 @@ public class DiscoverComponents implements MqttMessageSubscriber { protected final CompletableFuture<@Nullable Void> discoverFinishedFuture = new CompletableFuture<>(); private final Gson gson; - private final Jinjava jinjava; + private final HomeAssistantPythonBridge python; private final UnitProvider unitProvider; private @Nullable ScheduledFuture stopDiscoveryFuture; @@ -84,13 +83,13 @@ public class DiscoverComponents implements MqttMessageSubscriber { */ public DiscoverComponents(ThingUID thingUID, ScheduledExecutorService scheduler, ChannelStateUpdateListener channelStateUpdateListener, HomeAssistantChannelLinkageChecker linkageChecker, - AvailabilityTracker tracker, Gson gson, Jinjava jinjava, UnitProvider unitProvider) { + AvailabilityTracker tracker, Gson gson, HomeAssistantPythonBridge python, UnitProvider unitProvider) { this.thingUID = thingUID; this.scheduler = scheduler; this.updateListener = channelStateUpdateListener; this.linkageChecker = linkageChecker; this.gson = gson; - this.jinjava = jinjava; + this.python = python; this.unitProvider = unitProvider; this.tracker = tracker; } @@ -108,7 +107,7 @@ public class DiscoverComponents implements MqttMessageSubscriber { if (config.length() > 0) { try { component = ComponentFactory.createComponent(thingUID, haID, config, updateListener, linkageChecker, - tracker, scheduler, gson, jinjava, unitProvider); + tracker, scheduler, gson, python, 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 f29b05622b2..484df982c3b 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,137 +12,109 @@ */ 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 { - public static class UndefinedException extends InvalidInputException { - public UndefinedException(JinjavaInterpreter interpreter) { - super(interpreter, "is_defined", "Value is undefined"); - } - } + // 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"; private final Logger logger = LoggerFactory.getLogger(HomeAssistantChannelTransformation.class); - private final Jinjava jinjava; - private final AbstractComponent component; - private final String template; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final HomeAssistantPythonBridge python; + private final AbstractComponent component; + private final Value template; + private final boolean command; + private final String defaultValue; + private final boolean parseValueAsInteger; - public HomeAssistantChannelTransformation(Jinjava jinjava, AbstractComponent component, String template) { + 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) { super((String) null); - this.jinjava = jinjava; + this.python = python; this.component = component; - this.template = template; + this.command = command; + this.template = command ? python.newCommandTemplate(template) : python.newValueTemplate(template); + this.defaultValue = defaultValue; + this.parseValueAsInteger = parseValueAsInteger; } @Override public boolean isEmpty() { - return template.isEmpty(); + return false; } @Override public Optional apply(String value) { - 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 + 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 Optional.empty(); } - logger.warn("Applying template {} for component {} failed: {} ({})", template, - component.getHaID().toShortTopic(), e.getMessage(), e.getClass()); + } + Object result = transform(objValue); + if (result == null) { return Optional.empty(); } - - logger.debug("transformation resulted in '{}'", transformationResult); - - return Optional.of(transformationResult); + return Optional.of(result.toString()); } - 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; + 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; } } } 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 deleted file mode 100644 index de4a822a22c..00000000000 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantJinjaFunctionLibrary.java +++ /dev/null @@ -1,271 +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.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 new file mode 100644 index 00000000000..33ee9178795 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantPythonBridge.java @@ -0,0 +1,101 @@ +/* + * 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 f15d27936b9..dc1e55ec574 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,6 +38,7 @@ 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; @@ -62,7 +63,6 @@ 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,7 +155,8 @@ public abstract class AbstractComponent String availabilityTemplate = availability.getValueTemplate(); ChannelTransformation transformation = null; if (availabilityTemplate != null) { - transformation = new HomeAssistantChannelTransformation(getJinjava(), this, availabilityTemplate); + transformation = new HomeAssistantChannelTransformation(getPython(), this, availabilityTemplate, + false); } componentConfiguration.getTracker().addAvailabilityTopic(availability.getTopic(), availability.getPayloadAvailable(), availability.getPayloadNotAvailable(), transformation); @@ -166,7 +167,8 @@ public abstract class AbstractComponent String availabilityTemplate = this.channelConfiguration.getAvailabilityTemplate(); ChannelTransformation transformation = null; if (availabilityTemplate != null) { - transformation = new HomeAssistantChannelTransformation(getJinjava(), this, availabilityTemplate); + transformation = new HomeAssistantChannelTransformation(getPython(), this, availabilityTemplate, + false); } componentConfiguration.getTracker().addAvailabilityTopic(availabilityTopic, this.channelConfiguration.getPayloadAvailable(), @@ -406,8 +408,8 @@ public abstract class AbstractComponent return componentConfiguration.getGson(); } - public Jinjava getJinjava() { - return componentConfiguration.getJinjava(); + public HomeAssistantPythonBridge getPython() { + return componentConfiguration.getPython(); } 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 bf56f2da9c0..74f5ef59e19 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,6 +19,7 @@ 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; @@ -26,7 +27,6 @@ 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, Jinjava jinjava, - UnitProvider unitProvider) throws ConfigurationException { + AvailabilityTracker tracker, ScheduledExecutorService scheduler, Gson gson, + HomeAssistantPythonBridge python, UnitProvider unitProvider) throws ConfigurationException { ComponentConfiguration componentConfiguration = new ComponentConfiguration(thingUID, haID, - channelConfigurationJSON, gson, jinjava, updateListener, linkageChecker, tracker, scheduler, + channelConfigurationJSON, gson, python, 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 Jinjava jinjava; + private final HomeAssistantPythonBridge python; private final ScheduledExecutorService scheduler; private final UnitProvider unitProvider; @@ -128,14 +128,15 @@ public class ComponentFactory { * @param configJSON The configuration string * @param gson A Gson instance */ - protected ComponentConfiguration(ThingUID thingUID, HaID haID, String configJSON, Gson gson, Jinjava jinjava, - ChannelStateUpdateListener updateListener, HomeAssistantChannelLinkageChecker linkageChecker, - AvailabilityTracker tracker, ScheduledExecutorService scheduler, UnitProvider unitProvider) { + protected ComponentConfiguration(ThingUID thingUID, HaID haID, String configJSON, Gson gson, + HomeAssistantPythonBridge python, ChannelStateUpdateListener updateListener, + HomeAssistantChannelLinkageChecker linkageChecker, AvailabilityTracker tracker, + ScheduledExecutorService scheduler, UnitProvider unitProvider) { this.thingUID = thingUID; this.haID = haID; this.configJSON = configJSON; this.gson = gson; - this.jinjava = jinjava; + this.python = python; this.updateListener = updateListener; this.linkageChecker = linkageChecker; this.tracker = tracker; @@ -167,8 +168,8 @@ public class ComponentFactory { return gson; } - public Jinjava getJinjava() { - return jinjava; + public HomeAssistantPythonBridge getPython() { + return python; } 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 f1f3f2be31c..a1ae5cc6a6a 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(getJinjava(), this, ""); + transformation = new HomeAssistantChannelTransformation(getPython(), this, EVENT_TYPE_TRANFORMATION, false); 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_TYPE_TRANFORMATION, event).orElse(null); + String eventType = transformation.apply(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 410e0db97f9..6438fce484e 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) - .inferOptimistic(channelConfiguration.optimistic).commandFilter(this::handlePercentageCommand) - .build(); + .parseCommandValueAsInteger(true).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 236556ad103..b888357740a 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,7 +54,9 @@ import org.slf4j.LoggerFactory; @NonNullByDefault public class TemplateSchemaLight extends AbstractRawSchemaLight { private final Logger logger = LoggerFactory.getLogger(TemplateSchemaLight.class); - private final HomeAssistantChannelTransformation transformation; + private @Nullable HomeAssistantChannelTransformation commandOnTransformation, commandOffTransformation, + stateTransformation, brightnessTransformation, redTransformation, greenTransformation, blueTransformation, + effectTransformation, colorTempTransformation; private static class TemplateVariables { public static final String STATE = "state"; @@ -72,26 +74,36 @@ 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; - if (channelConfiguration.commandOnTemplate == null || channelConfiguration.commandOffTemplate == null) { + String commandOnTemplate = channelConfiguration.commandOnTemplate, + commandOffTemplate = channelConfiguration.commandOffTemplate; + if (commandOnTemplate == null || 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); - if (channelConfiguration.redTemplate != null && channelConfiguration.greenTemplate != null - && channelConfiguration.blueTemplate != null) { + 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); colorChannel = buildChannel(COLOR_CHANNEL_ID, ComponentChannelType.COLOR, colorValue, "Color", this) .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleCommand(command)) .withAutoUpdatePolicy(autoUpdatePolicy).build(); - } else if (channelConfiguration.brightnessTemplate != null) { + } else if (brightnessTemplate != null) { + brightnessTransformation = new HomeAssistantChannelTransformation(getPython(), this, brightnessTemplate, + false); brightnessChannel = buildChannel(BRIGHTNESS_CHANNEL_ID, ComponentChannelType.DIMMER, brightnessValue, "Brightness", this).commandTopic(DUMMY_TOPIC, true, 1) .commandFilter(command -> handleCommand(command)).withAutoUpdatePolicy(autoUpdatePolicy).build(); @@ -101,17 +113,29 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { .withAutoUpdatePolicy(autoUpdatePolicy).build(); } - if (channelConfiguration.colorTempTemplate != null) { + String colorTempTemplate = channelConfiguration.colorTempTemplate; + if (colorTempTemplate != null) { + colorTempTransformation = new HomeAssistantChannelTransformation(getPython(), this, colorTempTemplate, + false); + 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; - if (channelConfiguration.effectTemplate != null && localEffectValue != null) { + String effectTemplate = channelConfiguration.effectTemplate; + if (effectTemplate != null && localEffectValue != null) { + effectTransformation = new HomeAssistantChannelTransformation(getPython(), this, effectTemplate, false); + 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 @@ -119,14 +143,14 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { @Override protected void publishState(HSBType state) { Map binding = new HashMap<>(); - String template; + HomeAssistantChannelTransformation transformation; logger.trace("Publishing new state {} of light {} to MQTT.", state, getName()); if (state.getBrightness().equals(PercentType.ZERO)) { - template = Objects.requireNonNull(channelConfiguration.commandOffTemplate); + transformation = Objects.requireNonNull(commandOffTransformation); binding.put(TemplateVariables.STATE, "off"); } else { - template = Objects.requireNonNull(channelConfiguration.commandOnTemplate); + transformation = Objects.requireNonNull(commandOnTransformation); binding.put(TemplateVariables.STATE, "on"); if (channelConfiguration.brightnessTemplate != null) { binding.put(TemplateVariables.BRIGHTNESS, @@ -142,7 +166,7 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { } } - publishState(binding, template); + publishState(binding, transformation); } private boolean handleColorTempCommand(Command command) { @@ -161,7 +185,7 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { binding.put(TemplateVariables.STATE, "on"); binding.put(TemplateVariables.COLOR_TEMP, mireds.toBigDecimal().intValue()); - publishState(binding, Objects.requireNonNull(channelConfiguration.commandOnTemplate)); + publishState(binding, Objects.requireNonNull(commandOnTransformation)); } return false; } @@ -176,14 +200,15 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { binding.put(TemplateVariables.STATE, "on"); binding.put(TemplateVariables.EFFECT, command.toString()); - publishState(binding, Objects.requireNonNull(channelConfiguration.commandOnTemplate)); + publishState(binding, Objects.requireNonNull(commandOnTransformation)); return false; } - private void publishState(Map binding, String template) { + private void publishState(Map binding, + HomeAssistantChannelTransformation transformation) { String command; - command = transform(template, binding); + command = transform(transformation, binding); if (command == null) { return; } @@ -198,9 +223,9 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { String value; - String template = channelConfiguration.stateTemplate; - if (template != null) { - value = transform(template, state.toString()); + HomeAssistantChannelTransformation stateTransformation = this.stateTransformation; + if (stateTransformation != null) { + value = transform(stateTransformation, state.toString()); if (value == null || value.isEmpty()) { onOffValue.update(UnDefType.NULL); } else if ("on".equals(value)) { @@ -217,14 +242,15 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { brightnessValue.update( (PercentType) Objects.requireNonNull(onOffValue.getChannelState().as(PercentType.class))); } - if (colorValue.getChannelState() instanceof UnDefType) { - colorValue.update((OnOffType) onOffValue.getChannelState()); + if (colorValue.getChannelState() instanceof UnDefType + && onOffValue.getChannelState() instanceof OnOffType onOffValue) { + colorValue.update(onOffValue); } } - template = channelConfiguration.brightnessTemplate; - if (template != null) { - Integer brightness = getColorChannelValue(template, state.toString()); + HomeAssistantChannelTransformation brightnessTransformation = this.brightnessTransformation; + if (brightnessTransformation != null) { + Integer brightness = getColorChannelValue(brightnessTransformation, state.toString()); if (brightness == null) { brightnessValue.update(UnDefType.NULL); colorValue.update(UnDefType.NULL); @@ -241,13 +267,13 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { } @Nullable - 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()); + 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()); if (red == null || green == null || blue == null) { colorValue.update(UnDefType.NULL); } else { @@ -265,9 +291,9 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { listener.updateChannelState(onOffChannel.getChannel().getUID(), onOffValue.getChannelState()); } - template = channelConfiguration.effectTemplate; - if (template != null) { - value = transform(template, state.toString()); + HomeAssistantChannelTransformation effectTransformation = this.effectTransformation; + if (effectTransformation != null) { + value = transform(effectTransformation, state.toString()); if (value == null || value.isEmpty()) { effectValue.update(UnDefType.NULL); } else { @@ -276,9 +302,9 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { listener.updateChannelState(buildChannelUID(EFFECT_CHANNEL_ID), effectValue.getChannelState()); } - template = channelConfiguration.colorTempTemplate; - if (template != null) { - Integer mireds = getColorChannelValue(template, state.toString()); + HomeAssistantChannelTransformation colorTempTransformation = this.colorTempTransformation; + if (colorTempTransformation != null) { + Integer mireds = getColorChannelValue(colorTempTransformation, state.toString()); if (mireds == null) { colorTempValue.update(UnDefType.NULL); } else { @@ -288,8 +314,8 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { } } - private @Nullable Integer getColorChannelValue(String template, String value) { - Object result = transform(template, value); + private @Nullable Integer getColorChannelValue(HomeAssistantChannelTransformation transformation, String value) { + Object result = transform(transformation, value); if (result == null) { return null; } @@ -301,17 +327,21 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight { try { return Integer.parseInt(result.toString()); } catch (NumberFormatException e) { - logger.warn("Applying template {} for component {} failed: {}", template, getHaID().toShortTopic(), - e.getMessage()); + logger.warn("Applying template for component {} failed: {}", getHaID().toShortTopic(), e.getMessage()); return null; } } - private @Nullable String transform(String template, Map binding) { - return transformation.apply(template, binding).orElse(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, String value) { - return transformation.apply(template, value).orElse(null); + private @Nullable String transform(HomeAssistantChannelTransformation transformation, String value) { + return transformation.apply(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 d20ed8c2e08..01e776b863b 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,6 +39,7 @@ 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; @@ -63,7 +64,6 @@ 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 Jinjava jinjava; + protected final HomeAssistantPythonBridge python; 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, Jinjava jinjava, UnitProvider unitProvider, int subscribeTimeout, - int attributeReceiveTimeout) { + ChannelTypeRegistry channelTypeRegistry, HomeAssistantPythonBridge python, 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.jinjava = jinjava; + this.python = python; this.unitProvider = unitProvider; this.attributeReceiveTimeout = attributeReceiveTimeout; this.delayedProcessing = new DelayedBatchProcessing<>(attributeReceiveTimeout, this, scheduler); - this.discoverComponents = new DiscoverComponents(thing.getUID(), scheduler, this, this, this, gson, jinjava, + this.discoverComponents = new DiscoverComponents(thing.getUID(), scheduler, this, this, this, gson, python, 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, jinjava, unitProvider); + channelConfigurationJSON, this, this, this, scheduler, gson, python, 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 new file mode 100644 index 00000000000..2a9f3e67ac6 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/components/mqtt/const.py @@ -0,0 +1,7 @@ +"""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 new file mode 100644 index 00000000000..480a742efe4 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/components/mqtt/models.py @@ -0,0 +1,210 @@ +"""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 new file mode 100644 index 00000000000..0d7118deadd --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/exceptions.py @@ -0,0 +1,26 @@ +"""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 new file mode 100644 index 00000000000..0f2659e8bb7 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/helpers/config_validation.py @@ -0,0 +1,23 @@ +"""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 new file mode 100644 index 00000000000..b766908d341 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/helpers/template.py @@ -0,0 +1,1393 @@ +"""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 new file mode 100644 index 00000000000..93dfd557133 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/util/__init__.py @@ -0,0 +1,12 @@ +"""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 new file mode 100644 index 00000000000..349db2ac04f --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/python/homeassistant/util/dt.py @@ -0,0 +1,321 @@ +"""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 866710b685b..b34d1da4067 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,11 +57,7 @@ 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. @@ -86,6 +82,7 @@ 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; @@ -99,20 +96,11 @@ 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 9e17da9558f..89d83af0e56 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,19 +24,10 @@ 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 @@ -44,72 +35,62 @@ import org.openhab.core.util.BundleResolver; @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @NonNullByDefault -public class HomeAssistantChannelTransformationTests { - protected @Mock @NonNullByDefault({}) ThingTypeRegistry thingTypeRegistry; - protected @Mock @NonNullByDefault({}) UnitProvider unitProvider; +public class HomeAssistantChannelTransformationTests extends AbstractHomeAssistantTests { - protected @NonNullByDefault({}) HomeAssistantChannelTransformation transformation; - private @Mock @NonNullByDefault({}) BundleResolver bundleResolver; - private @Mock @NonNullByDefault({}) TranslationProvider translationProvider; + private @Mock @NonNullByDefault({}) AbstractComponent component; @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); - transformation = new HomeAssistantChannelTransformation(thingHandlerFactory.getJinjava(), component, ""); + } + + @Test + public void testInvalidTemplate() { + assertThat(transform("{{}}", ""), is(nullValue())); } @Test public void testIif() { - 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("{{ 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("{{ 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())); + 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")); } @Test public void testIsDefined() { - assertThat(transform("{{ value_json.val | is_defined }}", "{}"), is(nullValue())); - assertThat(transform("{{ 'hi' | is_defined }}", "{}"), is("hi")); + 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")); } @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