[mqtt.homeassistant] Use GraalPy and import actual Home Assistant templating code (#18601)

* [mqtt.homeassistant] Use GraalPy and import actual Home Assistant transformation code

Signed-off-by: Cody Cutrer <cody@cutrer.us>
pull/18730/head
Cody Cutrer 2025-05-30 10:22:25 -06:00 committed by GitHub
parent 77e4d7fad5
commit 5a2179106e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 2571 additions and 582 deletions

View File

@ -0,0 +1 @@
/src/main/python/**/__pycache__/

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -14,6 +14,16 @@
<name>openHAB Add-ons :: Bundles :: MQTT HomeAssistant Convention</name>
<properties>
<bnd.importpackage>!sun.misc.*,
!sun.reflect.*,
!com.sun.management.*,
!jdk.internal.reflect.*,
!jdk.vm.ci.services</bnd.importpackage>
<graalpy.version>24.2.0</graalpy.version>
</properties>
<dependencies>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
@ -27,42 +37,170 @@
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.3.1-jre</version>
<scope>test</scope>
</dependency>
<!-- Graal Polyglot Framework -->
<dependency>
<groupId>org.openhab.osgiify</groupId>
<artifactId>com.hubspot.jinjava.jinjava</artifactId>
<version>2.7.4</version>
<scope>compile</scope>
<groupId>org.graalvm.polyglot</groupId>
<artifactId>polyglot</artifactId>
<version>${graalpy.version}</version>
</dependency>
<!-- Graal TRegex engine (internally used by Graal Python engine) -->
<dependency>
<groupId>org.graalvm.regex</groupId>
<artifactId>regex</artifactId>
<version>${graalpy.version}</version>
</dependency>
<!-- Graal Python engine (depends on Graal TRegex engine, must be added after it) -->
<dependency>
<groupId>org.graalvm.polyglot</groupId>
<artifactId>python-community</artifactId>
<version>${graalpy.version}</version>
<type>pom</type>
</dependency>
<dependency>
<groupId>org.openhab.osgiify</groupId>
<artifactId>com.google.re2j.re2j</artifactId>
<version>1.2</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>ch.obermuhlner</groupId>
<artifactId>big-math</artifactId>
<version>2.3.2</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jdk8</artifactId>
<version>${jackson.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.openhab.osgiify</groupId>
<artifactId>com.hubspot.immutables.immutables-exceptions</artifactId>
<version>1.9</version>
<scope>compile</scope>
<groupId>org.graalvm.python</groupId>
<artifactId>python-embedding</artifactId>
<version>${graalpy.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>embed-dependencies</id>
<goals>
<goal>unpack-dependencies</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
<configuration>
<argLine>-noverify</argLine>
</configuration>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>3.3.1</version>
<executions>
<execution>
<id>copy-homeassistant-python</id>
<goals>
<goal>copy-resources</goal>
</goals>
<phase>generate-resources</phase>
<configuration>
<outputDirectory>${project.build.directory}/classes/GRAALPY-VFS/${project.groupId}/${project.artifactId}/src</outputDirectory>
<resources>
<resource>
<directory>src/main/python</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.graalvm.python</groupId>
<artifactId>graalpy-maven-plugin</artifactId>
<version>${graalpy.version}</version>
<executions>
<execution>
<id>install-python-packages</id>
<goals>
<goal>process-graalpy-resources</goal>
</goals>
<phase>generate-resources</phase>
<configuration>
<resourceDirectory>GRAALPY-VFS/${project.groupId}/${project.artifactId}</resourceDirectory>
<packages>
<package>awesomeversion==24.6.0</package>
<package>Jinja2==3.1.6</package>
<package>python-slugify==8.0.4</package>
</packages>
</configuration>
</execution>
<!-- yes, this is the same as above, but it needs run again to regenerate the filelist with our .pyc files -->
<execution>
<id>generate-python-filelist</id>
<goals>
<goal>process-graalpy-resources</goal>
</goals>
<phase>process-resources</phase>
<configuration>
<resourceDirectory>GRAALPY-VFS/${project.groupId}/${project.artifactId}</resourceDirectory>
<packages>
<package>awesomeversion==24.6.0</package>
<package>Jinja2==3.1.6</package>
<package>python-slugify==8.0.4</package>
</packages>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>compile-python</id>
<goals>
<goal>exec</goal>
</goals>
<phase>generate-resources</phase>
<configuration>
<executable>${project.build.directory}/classes/GRAALPY-VFS/${project.groupId}/${project.artifactId}/venv/bin/python3</executable>
<arguments>
<argument>-m</argument>
<argument>compileall</argument>
<argument>${project.build.directory}/classes/GRAALPY-VFS/${project.groupId}/${project.artifactId}/src</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<goals>
<goal>shade</goal>
</goals>
<phase>package</phase>
<configuration>
<artifactSet>
<includes>
<include>org.graalvm.llvm:llvm-api</include>
<include>org.graalvm.polyglot:polyglot</include>
<include>org.graalvm.python:python-language</include>
<include>org.graalvm.python:python-resources</include>
<include>org.graalvm.regex:regex</include>
<include>org.graalvm.tools:profiler-tool</include>
<include>org.graalvm.truffle:truffle-api</include>
<include>org.graalvm.truffle:truffle-nfi</include>
<include>org.graalvm.truffle:truffle-nfi-libffi</include>
</includes>
</artifactSet>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<!-- Transformer to merge module-info.class
files, if needed -->
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -6,11 +6,14 @@
<feature>openhab-runtime-base</feature>
<feature>openhab-transport-mqtt</feature>
<feature dependency="true">openhab.tp-commons-net</feature>
<bundle dependency="true">mvn:org.openhab.osgiify/com.hubspot.jinjava.jinjava/2.7.4</bundle>
<bundle dependency="true">mvn:org.openhab.osgiify/com.google.re2j.re2j/1.2</bundle>
<bundle dependency="true">mvn:ch.obermuhlner/big-math/2.3.2</bundle>
<bundle dependency="true">mvn:com.fasterxml.jackson.datatype/jackson-datatype-jdk8/${jackson.version}</bundle>
<bundle dependency="true">mvn:org.openhab.osgiify/com.hubspot.immutables.immutables-exceptions/1.9</bundle>
<bundle dependency="true">mvn:org.openhab.osgiify/org.graalvm.sdk.collections/24.2.0</bundle>
<bundle dependency="true">mvn:org.openhab.osgiify/org.graalvm.sdk.jniutils/24.2.0</bundle>
<bundle dependency="true">mvn:org.openhab.osgiify/org.graalvm.sdk.nativeimage/24.2.0</bundle>
<bundle dependency="true">mvn:org.openhab.osgiify/org.graalvm.sdk.word/24.2.0</bundle>
<bundle dependency="true">mvn:org.openhab.osgiify/org.graalvm.shadowed.icu4j/24.2.0</bundle>
<bundle dependency="true">mvn:org.openhab.osgiify/org.graalvm.shadowed.xz/24.2.0</bundle>
<bundle dependency="true">mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-compiler/24.2.0</bundle>
<bundle dependency="true">mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-runtime/24.2.0</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt/${project.version}</bundle>
<bundle start-level="81">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.generic/${project.version}</bundle>
<bundle start-level="82">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.homeassistant/${project.version}</bundle>

View File

@ -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<ThingTypeUID> 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;
}
}

View File

@ -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,

View File

@ -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);

View File

@ -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<String> apply(String value) {
return apply(template, value);
}
public Optional<String> apply(String template, String value) {
Map<String, @Nullable Object> 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<String> apply(String template, Map<String, @Nullable Object> 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<String, @Nullable Object> result = new HashMap<>();
Iterator<Entry<String, JsonNode>> it = node.fields();
while (it.hasNext()) {
Entry<String, JsonNode> 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<String, @Nullable Object> 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;
}
}
}

View File

@ -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<String, Object> 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<Object> 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<String> 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;
}
}
}

View File

@ -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<String, @Nullable Object> 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<String, @Nullable Object> variables) {
return renderValueTemplateWithVariablesMeth.execute(template, payload, defaultValue, variables).asString();
}
}

View File

@ -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<C extends AbstractChannelConfiguration>
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<C extends AbstractChannelConfiguration>
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<C extends AbstractChannelConfiguration>
return componentConfiguration.getGson();
}
public Jinjava getJinjava() {
return componentConfiguration.getJinjava();
public HomeAssistantPythonBridge getPython() {
return componentConfiguration.getPython();
}
public C getChannelConfiguration() {

View File

@ -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() {

View File

@ -57,7 +57,7 @@ public class Event extends AbstractComponent<Event.ChannelConfiguration> 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<Event.ChannelConfiguration> 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;

View File

@ -159,8 +159,8 @@ public class Fan extends AbstractComponent<Fan.ChannelConfiguration> 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;

View File

@ -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<String, @Nullable Object> 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<String, @Nullable Object> binding, String template) {
private void publishState(Map<String, @Nullable Object> 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<String, @Nullable Object> binding) {
return transformation.apply(template, binding).orElse(null);
private @Nullable String transform(HomeAssistantChannelTransformation transformation,
Map<String, @Nullable Object> 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);
}
}

View File

@ -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<Object> 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);
}

View File

@ -0,0 +1,7 @@
"""Constants used by multiple MQTT modules."""
import jinja2
from homeassistant.exceptions import TemplateError
TEMPLATE_ERRORS = (jinja2.TemplateError, TemplateError, TypeError, ValueError)

View File

@ -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

View File

@ -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}")

View File

@ -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}")

View File

@ -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

View File

@ -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<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})"
r"[T ](?P<hour>\d{1,2}):(?P<minute>\d{1,2})"
r"(?::(?P<second>\d{1,2})(?:\.(?P<microsecond>\d{1,6})\d{0,6})?)?"
r"(?P<tzinfo>Z|[+-]\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<days>-?\d+) (days?, )?)?"
r"(?P<sign>-?)"
r"((?:(?P<hours>\d+):)(?=\d+:\d+))?"
r"(?:(?P<minutes>\d+):)?"
r"(?P<seconds>\d+)"
r"(?:[\.,](?P<microseconds>\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<sign>[-+]?)"
r"P"
r"(?:(?P<days>\d+([\.,]\d+)?)D)?"
r"(?:T"
r"(?:(?P<hours>\d+([\.,]\d+)?)H)?"
r"(?:(?P<minutes>\d+([\.,]\d+)?)M)?"
r"(?:(?P<seconds>\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<days>-?\d+) (days? ?))?"
r"(?:(?P<sign>[-+])?"
r"(?P<hours>\d+):"
r"(?P<minutes>\d\d):"
r"(?P<seconds>\d\d)"
r"(?:\.(?P<microseconds>\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)

View File

@ -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<String, Set<MqttMessageSubscriber>> 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<TransformationService> 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);

View File

@ -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<button>(?:button_)?[a-z0-9]+)_(?P<action>(?:press|hold)(?:_release)?)$') }}",
""), is("[[button_up, press]]"));
""), is("[('button_up', 'press')]"));
}
@Test
@ -119,10 +100,24 @@ public class HomeAssistantChannelTransformationTests {
assertThat(transform("{{ ['JFK', 'LHR'] | regex_findall_index('([A-Z]{3})', 1) }}", ""), is("LHR"));
assertThat(transform(
"{{ 'button_up_press' | regex_findall_index('^(?P<button>(?:button_)?[a-z0-9]+)_(?P<action>(?:press|hold)(?:_release)?)$') }}",
""), is("[button_up, press]"));
""), is("('button_up', 'press')"));
}
protected @Nullable String transform(String template, String value) {
return transformation.apply(template, value).orElse(null);
@Test
public void testIntegerDictLookup() {
assertThat(transform("{{ {0:'off', 1:'low', 2:'medium', 3:'high'}[value] | default('') }}", 0, true),
is("off"));
}
protected @Nullable Object transform(String template, Object value) {
return new HomeAssistantChannelTransformation(python, component, template, false).transform(value);
}
protected @Nullable Object transform(String template, Object value, boolean command) {
return new HomeAssistantChannelTransformation(python, component, template, command).transform(value);
}
protected @Nullable Object transform(String template, Object value, String defaultValue) {
return new HomeAssistantChannelTransformation(python, component, template, defaultValue).transform(value);
}
}

View File

@ -40,6 +40,7 @@ import org.openhab.binding.mqtt.homeassistant.internal.AbstractHomeAssistantTest
import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
import org.openhab.binding.mqtt.homeassistant.internal.HaID;
import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration;
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.handler.HomeAssistantThingHandler;
import org.openhab.core.i18n.UnitProvider;
@ -55,8 +56,6 @@ import org.openhab.core.thing.type.ChannelTypeRegistry;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import com.hubspot.jinjava.Jinjava;
/**
* Abstract class for components tests.
*
@ -88,7 +87,7 @@ public abstract class AbstractComponentTests extends AbstractHomeAssistantTests
when(callbackMock.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing);
thingHandler = new LatchThingHandler(haThing, thingHandlerFactory, channelTypeProvider,
stateDescriptionProvider, channelTypeRegistry, unitProvider, SUBSCRIBE_TIMEOUT,
stateDescriptionProvider, channelTypeRegistry, python, unitProvider, SUBSCRIBE_TIMEOUT,
ATTRIBUTE_RECEIVE_TIMEOUT);
thingHandler.setConnection(bridgeConnection);
thingHandler.setCallback(callbackMock);
@ -367,9 +366,10 @@ public abstract class AbstractComponentTests extends AbstractHomeAssistantTests
public LatchThingHandler(Thing thing, BaseThingHandlerFactory thingHandlerFactory,
MqttChannelTypeProvider channelTypeProvider,
MqttChannelStateDescriptionProvider stateDescriptionProvider, ChannelTypeRegistry channelTypeRegistry,
UnitProvider unitProvider, int subscribeTimeout, int attributeReceiveTimeout) {
HomeAssistantPythonBridge python, UnitProvider unitProvider, int subscribeTimeout,
int attributeReceiveTimeout) {
super(thing, thingHandlerFactory, channelTypeProvider, stateDescriptionProvider, channelTypeRegistry,
new Jinjava(), unitProvider, subscribeTimeout, attributeReceiveTimeout);
python, unitProvider, subscribeTimeout, attributeReceiveTimeout);
}
@Override

View File

@ -48,8 +48,6 @@ import org.openhab.core.thing.binding.builder.ChannelBuilder;
import org.openhab.core.thing.binding.builder.ThingBuilder;
import org.openhab.core.types.StateDescription;
import com.hubspot.jinjava.Jinjava;
/**
* Tests for {@link HomeAssistantThingHandler}
*
@ -91,7 +89,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
protected void setupThingHandler() {
thingHandler = new HomeAssistantThingHandler(haThing, thingHandlerFactory, channelTypeProvider,
stateDescriptionProvider, channelTypeRegistry, new Jinjava(), unitProvider, SUBSCRIBE_TIMEOUT,
stateDescriptionProvider, channelTypeRegistry, python, unitProvider, SUBSCRIBE_TIMEOUT,
ATTRIBUTE_RECEIVE_TIMEOUT);
thingHandler.setConnection(bridgeConnection);
thingHandler.setCallback(callbackMock);
@ -361,7 +359,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
@Test
public void testDuplicateChannelId() {
thingHandler = new HomeAssistantThingHandler(haThing, thingHandlerFactory, channelTypeProvider,
stateDescriptionProvider, channelTypeRegistry, new Jinjava(), unitProvider, SUBSCRIBE_TIMEOUT,
stateDescriptionProvider, channelTypeRegistry, python, unitProvider, SUBSCRIBE_TIMEOUT,
ATTRIBUTE_RECEIVE_TIMEOUT);
thingHandler.setConnection(bridgeConnection);
thingHandler.setCallback(callbackMock);
@ -418,7 +416,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
@Test
public void testDuplicateChannelIdComplex() {
thingHandler = new HomeAssistantThingHandler(haThing, thingHandlerFactory, channelTypeProvider,
stateDescriptionProvider, channelTypeRegistry, new Jinjava(), unitProvider, SUBSCRIBE_TIMEOUT,
stateDescriptionProvider, channelTypeRegistry, python, unitProvider, SUBSCRIBE_TIMEOUT,
ATTRIBUTE_RECEIVE_TIMEOUT);
thingHandler.setConnection(bridgeConnection);
thingHandler.setCallback(callbackMock);

View File

@ -113,20 +113,15 @@ Import-Package: \
org.openhab.core.test;version='[5.0.0,5.0.1)',\
org.openhab.core.thing;version='[5.0.0,5.0.1)',\
org.openhab.core.transform;version='[5.0.0,5.0.1)',\
ch.obermuhlner.math.big;version='[2.3.2,2.3.3)',\
com.fasterxml.jackson.core.jackson-annotations;version='[2.18.2,2.18.3)',\
com.fasterxml.jackson.core.jackson-core;version='[2.18.2,2.18.3)',\
com.fasterxml.jackson.core.jackson-databind;version='[2.18.2,2.18.3)',\
com.google.guava.failureaccess;version='[1.0.2,1.0.3)',\
com.google.re2j.re2j;version='[1.2.0,1.2.1)',\
com.hubspot.jinjava.jinjava;version='[2.7.4,2.7.5)',\
javassist;version='[3.30.2,3.30.3)',\
org.apache.commons.commons-net;version='[3.11.1,3.11.2)',\
org.apache.commons.lang3;version='[3.17.0,3.17.1)',\
org.yaml.snakeyaml;version='[2.3.0,2.3.1)',\
com.fasterxml.jackson.datatype.jackson-datatype-jdk8;version='[2.18.2,2.18.3)',\
com.google.guava;version='[33.3.1,33.3.2)',\
com.hubspot.immutables.immutables-exceptions;version='[1.9.0,1.9.1)',\
biz.aQute.tester.junit-platform;version='[7.1.0,7.1.1)',\
org.osgi.service.cm;version='[1.6.0,1.6.1)',\
com.fasterxml.jackson.dataformat.jackson-dataformat-yaml;version='[2.18.2,2.18.3)',\

View File

@ -41,6 +41,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.config.ChannelConfigurationTypeAdapterFactory;
import org.openhab.core.i18n.UnitProvider;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
@ -48,7 +49,6 @@ import org.openhab.core.test.java.JavaOSGiTest;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.hubspot.jinjava.Jinjava;
/**
* Tests the {@link DiscoverComponents} class.
@ -66,6 +66,8 @@ public class DiscoverComponentsTest extends JavaOSGiTest {
private @Mock @NonNullByDefault({}) HomeAssistantChannelLinkageChecker linkageChecker;
private @Mock @NonNullByDefault({}) AvailabilityTracker availabilityTracker;
private static final HomeAssistantPythonBridge python = new HomeAssistantPythonBridge();
@BeforeEach
public void beforeEach() {
CompletableFuture<@Nullable Void> voidFutureComplete = new CompletableFuture<>();
@ -83,12 +85,11 @@ public class DiscoverComponentsTest extends JavaOSGiTest {
ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(1);
Gson gson = new GsonBuilder().registerTypeAdapterFactory(new ChannelConfigurationTypeAdapterFactory()).create();
Jinjava jinjava = new Jinjava();
UnitProvider unitProvider = mock(UnitProvider.class);
DiscoverComponents discover = spy(
new DiscoverComponents(ThingChannelConstants.TEST_HOME_ASSISTANT_THING, scheduler,
channelStateUpdateListener, linkageChecker, availabilityTracker, gson, jinjava, unitProvider));
channelStateUpdateListener, linkageChecker, availabilityTracker, gson, python, unitProvider));
HandlerConfiguration config = new HandlerConfiguration("homeassistant", List.of("switch/object"));

View File

@ -46,6 +46,7 @@ import org.openhab.binding.mqtt.homeassistant.internal.DiscoverComponents;
import org.openhab.binding.mqtt.homeassistant.internal.DiscoverComponents.ComponentDiscovered;
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.component.AbstractComponent;
import org.openhab.binding.mqtt.homeassistant.internal.component.Switch;
import org.openhab.binding.mqtt.homeassistant.internal.config.ChannelConfigurationTypeAdapterFactory;
@ -59,7 +60,6 @@ import org.openhab.core.types.UnDefType;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.hubspot.jinjava.Jinjava;
/**
* A full implementation test, that starts the embedded MQTT broker and publishes a homeassistant MQTT discovery device
@ -80,6 +80,8 @@ public class HomeAssistantMQTTImplementationTest extends MqttOSGiTest {
private @Mock @NonNullByDefault({}) HomeAssistantChannelLinkageChecker linkageChecker;
private @Mock @NonNullByDefault({}) AvailabilityTracker availabilityTracker;
private static final HomeAssistantPythonBridge python = new HomeAssistantPythonBridge();
/**
* Create an observer that fails the test as soon as the broker client connection changes its connection state
* to something else then CONNECTED.
@ -168,12 +170,11 @@ public class HomeAssistantMQTTImplementationTest extends MqttOSGiTest {
final Map<String, AbstractComponent<?>> haComponents = new HashMap<>();
Gson gson = new GsonBuilder().registerTypeAdapterFactory(new ChannelConfigurationTypeAdapterFactory()).create();
Jinjava jinjava = new Jinjava();
ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(4);
DiscoverComponents discover = spy(
new DiscoverComponents(ThingChannelConstants.TEST_HOME_ASSISTANT_THING, scheduler,
channelStateUpdateListener, linkageChecker, availabilityTracker, gson, jinjava, unitProvider));
channelStateUpdateListener, linkageChecker, availabilityTracker, gson, python, unitProvider));
when(linkageChecker.isChannelLinked(any())).thenReturn(true);