[mqtt.homeassistant] implement iif and is_defined jinja function and filters (#17435)

Signed-off-by: Cody Cutrer <cody@cutrer.us>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
Cody Cutrer 2024-09-26 13:25:36 -06:00 committed by Ciprian Pascu
parent 7902c1c29d
commit 53937d0c08
4 changed files with 264 additions and 2 deletions

View 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;
}
}

View 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();
}

View File

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

View File

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