diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/generic/internal/MqttThingHandlerFactory.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/generic/internal/MqttThingHandlerFactory.java index 6c5ebdd8886..5b2c8e9fdc9 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/generic/internal/MqttThingHandlerFactory.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/generic/internal/MqttThingHandlerFactory.java @@ -20,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; + } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformation.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformation.java index 50731305faa..75e4e71a5a2 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformation.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformation.java @@ -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 index 00000000000..e3dad068378 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantJinjaFunctionLibrary.java @@ -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 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 index 00000000000..7ef078cc534 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformationTests.java @@ -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); + } +}