]> git.basschouten.com Git - openhab-addons.git/commitdiff
[mqtt.homeassistant] implement iif and is_defined jinja function and filters (#17435)
authorCody Cutrer <cody@cutrer.us>
Thu, 26 Sep 2024 19:25:36 +0000 (13:25 -0600)
committerGitHub <noreply@github.com>
Thu, 26 Sep 2024 19:25:36 +0000 (21:25 +0200)
Signed-off-by: Cody Cutrer <cody@cutrer.us>
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/generic/internal/MqttThingHandlerFactory.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformation.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantJinjaFunctionLibrary.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformationTests.java [new file with mode: 0644]

index 6c5ebdd8886534295db9bcd4740d86fa49da90e7..5b2c8e9fdc96b824c59871e83bc311063ec1edc8 100644 (file)
@@ -20,6 +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.handler.HomeAssistantThingHandler;
 import org.openhab.core.thing.Thing;
 import org.openhab.core.thing.ThingTypeUID;
@@ -57,6 +58,8 @@ public class MqttThingHandlerFactory extends BaseThingHandlerFactory {
         this.typeProvider = typeProvider;
         this.stateDescriptionProvider = stateDescriptionProvider;
         this.channelTypeRegistry = channelTypeRegistry;
+
+        HomeAssistantJinjaFunctionLibrary.register(jinjava.getGlobalContext());
     }
 
     @Override
@@ -79,4 +82,8 @@ public class MqttThingHandlerFactory extends BaseThingHandlerFactory {
         }
         return null;
     }
+
+    public Jinjava getJinjava() {
+        return jinjava;
+    }
 }
index 50731305faa03004fc90e37ec80e9757ca33c752..75e4e71a5a212331e7424f0126987601afba4cdb 100644 (file)
@@ -32,6 +32,8 @@ 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
@@ -42,6 +44,12 @@ import com.hubspot.jinjava.interpret.FatalTemplateErrorsException;
  */
 @NonNullByDefault
 public class HomeAssistantChannelTransformation extends ChannelTransformation {
+    public static class UndefinedException extends InvalidInputException {
+        public UndefinedException(JinjavaInterpreter interpreter) {
+            super(interpreter, "is_defined", "Value is undefined");
+        }
+    }
+
     private final Logger logger = LoggerFactory.getLogger(HomeAssistantChannelTransformation.class);
 
     private final Jinjava jinjava;
@@ -89,8 +97,17 @@ public class HomeAssistantChannelTransformation extends ChannelTransformation {
         try {
             transformationResult = jinjava.render(template, bindings);
         } catch (FatalTemplateErrorsException e) {
-            logger.warn("Applying template {} for component {} failed: {}", template,
-                    component.getHaID().toShortTopic(), e.getMessage());
+            var error = e.getErrors().iterator();
+            Exception exception = null;
+            if (error.hasNext()) {
+                exception = error.next().getException();
+            }
+            if (exception instanceof UndefinedException) {
+                // They used the is_defined filter; it's expected to return null, with no warning
+                return Optional.empty();
+            }
+            logger.warn("Applying template {} for component {} failed: {} ({})", template,
+                    component.getHaID().toShortTopic(), e.getMessage(), e.getClass());
             return Optional.empty();
         }
 
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantJinjaFunctionLibrary.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantJinjaFunctionLibrary.java
new file mode 100644 (file)
index 0000000..e3dad06
--- /dev/null
@@ -0,0 +1,134 @@
+/**
+ * Copyright (c) 2010-2024 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.Arrays;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.hubspot.jinjava.interpret.Context;
+import com.hubspot.jinjava.interpret.InterpretException;
+import com.hubspot.jinjava.interpret.JinjavaInterpreter;
+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());
+    }
+
+    @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;
+        }
+    }
+
+    private static class Functions {
+        // https://www.home-assistant.io/docs/configuration/templating/#immediate-if-iif
+        public static Object iif(Object value, Object... results) {
+            if (results.length > 3) {
+                throw new IllegalArgumentException("Parameters for function 'iff' do not match");
+            }
+            if (value == null && results.length >= 3) {
+                return results[2];
+            }
+            if (ObjectTruthValue.evaluate(value)) {
+                if (results.length >= 1) {
+                    return results[0];
+                }
+                return true;
+            }
+            if (results.length >= 2) {
+                return results[1];
+            }
+            return false;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformationTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformationTests.java
new file mode 100644 (file)
index 0000000..7ef078c
--- /dev/null
@@ -0,0 +1,104 @@
+/**
+ * Copyright (c) 2010-2024 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 static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.nullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+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.MqttChannelStateDescriptionProvider;
+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.test.storage.VolatileStorageService;
+import org.openhab.core.thing.type.ChannelTypeRegistry;
+import org.openhab.core.thing.type.ThingTypeRegistry;
+
+/**
+ * @author Jochen Klein - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@NonNullByDefault
+public class HomeAssistantChannelTransformationTests {
+    protected @Mock @NonNullByDefault({}) ThingTypeRegistry thingTypeRegistry;
+
+    protected @NonNullByDefault({}) HomeAssistantChannelTransformation transformation;
+
+    @BeforeEach
+    public void beforeEachChannelTransformationTest() {
+        MqttChannelTypeProvider channelTypeProvider = new MqttChannelTypeProvider(thingTypeRegistry,
+                new VolatileStorageService());
+        MqttChannelStateDescriptionProvider stateDescriptionProvider = new MqttChannelStateDescriptionProvider();
+        ChannelTypeRegistry channelTypeRegistry = new ChannelTypeRegistry();
+        MqttThingHandlerFactory thingHandlerFactory = new MqttThingHandlerFactory(channelTypeProvider,
+                stateDescriptionProvider, channelTypeRegistry);
+
+        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 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("{{ True | iif('Yes') }}", ""), is("Yes"));
+        assertThat(transform("{{ False | iif('Yes') }}", ""), is("false"));
+        assertThat(transform("{{ Null | iif('Yes') }}", ""), is("false"));
+        assertThat(transform("{{ True | iif('Yes', 'No') }}", ""), is("Yes"));
+        assertThat(transform("{{ False | iif('Yes', 'No') }}", ""), is("No"));
+        assertThat(transform("{{ Null | iif('Yes', 'No') }}", ""), is("No"));
+        assertThat(transform("{{ True | iif('Yes', 'No', null) }}", ""), is("Yes"));
+        assertThat(transform("{{ False | iif('Yes', 'No', null) }}", ""), is("No"));
+        assertThat(transform("{{ Null | iif('Yes', 'No', 'NULL') }}", ""), is("NULL"));
+        assertThat(transform("{{ Null | iif('Yes', 'No', null) }}", ""), is(""));
+        assertThat(transform("{{ True | iif('Yes', 'No', null, null) }}", ""), is(nullValue()));
+    }
+
+    @Test
+    public void testIsDefined() {
+        assertThat(transform("{{ value_json.val | is_defined }}", "{}"), is(nullValue()));
+        assertThat(transform("{{ 'hi' | is_defined }}", "{}"), is("hi"));
+    }
+
+    protected @Nullable String transform(String template, String value) {
+        return transformation.apply(template, value).orElse(null);
+    }
+}