Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[mqtt.homeassistant] Implement template schema lights #17399

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ public boolean isEmpty() {

@Override
public Optional<String> apply(String value) {
String transformationResult;
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);
Expand All @@ -77,6 +80,12 @@ public Optional<String> apply(String value) {
// 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ abstract class AbstractRawSchemaLight extends Light {
protected static final String RAW_CHANNEL_ID = "raw";

protected ComponentChannel rawChannel;
protected TextValue colorModeValue;

public AbstractRawSchemaLight(ComponentFactory.ComponentConfiguration builder, boolean newStyleChannels) {
super(builder, newStyleChannels);
Expand All @@ -39,6 +40,7 @@ public AbstractRawSchemaLight(ComponentFactory.ComponentConfiguration builder, b
.commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
channelConfiguration.getQos())
.build(false));
colorModeValue = new TextValue();
}

protected boolean handleCommand(Command command) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,8 @@ protected static class Color {
protected @Nullable Integer transition;
}

TextValue colorModeValue;

public JSONSchemaLight(ComponentFactory.ComponentConfiguration builder, boolean newStyleChannels) {
super(builder, newStyleChannels);
colorModeValue = new TextValue();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ public static Light create(ComponentFactory.ComponentConfiguration builder, bool
return new DefaultSchemaLight(builder, newStyleChannels);
case JSON_SCHEMA:
return new JSONSchemaLight(builder, newStyleChannels);
case TEMPLATE_SCHEMA:
return new TemplateSchemaLight(builder, newStyleChannels);
default:
throw new UnsupportedComponentException(
"Component '" + builder.getHaID() + "' of schema '" + schema + "' is not supported!");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
/**
* 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.component;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
import org.openhab.binding.mqtt.generic.values.OnOffValue;
import org.openhab.binding.mqtt.generic.values.PercentageValue;
import org.openhab.binding.mqtt.generic.values.TextValue;
import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannelType;
import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantChannelTransformation;
import org.openhab.binding.mqtt.homeassistant.internal.exception.UnsupportedComponentException;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.HSBType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.openhab.core.util.ColorUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* A MQTT light, following the https://www.home-assistant.io/components/light.mqtt/ specification.
*
* Specifically, the template schema. All channels are synthetic, and wrap the single internal raw
* state.
*
* @author Cody Cutrer - Initial contribution
*/
@NonNullByDefault
public class TemplateSchemaLight extends AbstractRawSchemaLight {
private final Logger logger = LoggerFactory.getLogger(TemplateSchemaLight.class);
private final HomeAssistantChannelTransformation transformation;

private static class TemplateVariables {
public static final String STATE = "state";
public static final String TRANSITION = "transition";
public static final String BRIGHTNESS = "brightness";
public static final String COLOR_TEMP = "color_temp";
public static final String RED = "red";
public static final String GREEN = "green";
public static final String BLUE = "blue";
public static final String HUE = "hue";
public static final String SAT = "sat";
public static final String FLASH = "flash";
public static final String EFFECT = "effect";
}

public TemplateSchemaLight(ComponentFactory.ComponentConfiguration builder, boolean newStyleChannels) {
super(builder, newStyleChannels);
transformation = new HomeAssistantChannelTransformation(getJinjava(), this, "");
}

@Override
protected void buildChannels() {
if (channelConfiguration.commandOnTemplate == null || channelConfiguration.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);

if (channelConfiguration.redTemplate != null && channelConfiguration.greenTemplate != null
&& channelConfiguration.blueTemplate != null) {
hasColorChannel = true;
buildChannel(COLOR_CHANNEL_ID, ComponentChannelType.COLOR, colorValue, "Color", this)
.commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleCommand(command)).build();
} else if (channelConfiguration.brightnessTemplate != null) {
brightnessChannel = buildChannel(BRIGHTNESS_CHANNEL_ID, ComponentChannelType.DIMMER, brightnessValue,
"Brightness", this).commandTopic(DUMMY_TOPIC, true, 1)
.commandFilter(command -> handleCommand(command)).build();
} else {
onOffChannel = buildChannel(ON_OFF_CHANNEL_ID, ComponentChannelType.SWITCH, onOffValue, "On/Off State",
this).commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleCommand(command)).build();
}

if (channelConfiguration.colorTempTemplate != null) {
buildChannel(COLOR_TEMP_CHANNEL_ID, ComponentChannelType.NUMBER, colorTempValue, "Color Temperature", this)
.commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleColorTempCommand(command))
.build();
}
TextValue localEffectValue = effectValue;
if (channelConfiguration.effectTemplate != null && localEffectValue != null) {
buildChannel(EFFECT_CHANNEL_ID, ComponentChannelType.STRING, localEffectValue, "Effect", this)
.commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleEffectCommand(command)).build();
}
}

private static BigDecimal factor = new BigDecimal("2.55"); // string to not lose precision

@Override
protected void publishState(HSBType state) {
Map<String, @Nullable Object> binding = new HashMap<>();
String template;

logger.trace("Publishing new state {} of light {} to MQTT.", state, getName());
if (state.getBrightness().equals(PercentType.ZERO)) {
template = Objects.requireNonNull(channelConfiguration.commandOffTemplate);
binding.put(TemplateVariables.STATE, "off");
} else {
template = Objects.requireNonNull(channelConfiguration.commandOnTemplate);
binding.put(TemplateVariables.STATE, "on");
if (channelConfiguration.brightnessTemplate != null) {
binding.put(TemplateVariables.BRIGHTNESS,
state.getBrightness().toBigDecimal().multiply(factor).intValue());
}
if (hasColorChannel) {
int[] rgb = ColorUtil.hsbToRgb(state);
binding.put(TemplateVariables.RED, rgb[0]);
binding.put(TemplateVariables.GREEN, rgb[1]);
binding.put(TemplateVariables.BLUE, rgb[2]);
binding.put(TemplateVariables.HUE, state.getHue().toBigDecimal());
binding.put(TemplateVariables.SAT, state.getSaturation().toBigDecimal());
}
}

publishState(binding, template);
}

private boolean handleColorTempCommand(Command command) {
if (command instanceof DecimalType) {
command = new QuantityType<>(((DecimalType) command).toBigDecimal(), Units.MIRED);
}
if (command instanceof QuantityType quantity) {
QuantityType<?> mireds = quantity.toInvertibleUnit(Units.MIRED);
if (mireds == null) {
ccutrer marked this conversation as resolved.
Show resolved Hide resolved
logger.warn("Unable to convert {} to mireds", command);
return false;
}

Map<String, @Nullable Object> binding = new HashMap<>();

binding.put(TemplateVariables.STATE, "on");
binding.put(TemplateVariables.COLOR_TEMP, mireds.toBigDecimal().intValue());

publishState(binding, Objects.requireNonNull(channelConfiguration.commandOnTemplate));
}
return false;
}

private boolean handleEffectCommand(Command command) {
if (!(command instanceof StringType)) {
return false;
}

Map<String, @Nullable Object> binding = new HashMap<>();

binding.put(TemplateVariables.STATE, "on");
binding.put(TemplateVariables.EFFECT, command.toString());

publishState(binding, Objects.requireNonNull(channelConfiguration.commandOnTemplate));
return false;
}

private void publishState(Map<String, @Nullable Object> binding, String template) {
String command;

command = transform(template, binding);
if (command == null) {
return;
}

logger.debug("Publishing new state '{}' of light {} to MQTT.", command, getHaID().toShortTopic());
rawChannel.getState().publishValue(new StringType(command));
}

@Override
public void updateChannelState(ChannelUID channel, State state) {
ChannelStateUpdateListener listener = this.channelStateUpdateListener;

String value;

String template = channelConfiguration.stateTemplate;
if (template != null) {
value = transform(template, state.toString());
if (value == null || value.isEmpty()) {
onOffValue.update(UnDefType.NULL);
} else if ("on".equals(value)) {
onOffValue.update(OnOffType.ON);
} else if ("off".equals(value)) {
onOffValue.update(OnOffType.OFF);
} else {
logger.warn("Invalid state value '{}' for component {}; expected 'on' or 'off'.", value,
getHaID().toShortTopic());
onOffValue.update(UnDefType.UNDEF);
}
if (brightnessValue.getChannelState() instanceof UnDefType
&& !(onOffValue.getChannelState() instanceof UnDefType)) {
brightnessValue.update(
(PercentType) Objects.requireNonNull(onOffValue.getChannelState().as(PercentType.class)));
}
if (colorValue.getChannelState() instanceof UnDefType) {
colorValue.update((OnOffType) onOffValue.getChannelState());
}
}

template = channelConfiguration.brightnessTemplate;
if (template != null) {
Integer brightness = getColorChannelValue(template, state.toString());
if (brightness == null) {
brightnessValue.update(UnDefType.NULL);
colorValue.update(UnDefType.NULL);
} else {
brightnessValue.update((PercentType) brightnessValue.parseMessage(new DecimalType(brightness)));
if (colorValue.getChannelState() instanceof HSBType color) {
colorValue.update(new HSBType(color.getHue(), color.getSaturation(),
ccutrer marked this conversation as resolved.
Show resolved Hide resolved
(PercentType) brightnessValue.getChannelState()));
} else {
colorValue.update(new HSBType(DecimalType.ZERO, PercentType.ZERO,
(PercentType) brightnessValue.getChannelState()));
}
}
}

@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());
if (red == null || green == null || blue == null) {
colorValue.update(UnDefType.NULL);
} else {
colorValue.update(HSBType.fromRGB(red, green, blue));
}
}

if (hasColorChannel) {
listener.updateChannelState(buildChannelUID(COLOR_CHANNEL_ID), colorValue.getChannelState());
} else if (brightnessChannel != null) {
listener.updateChannelState(buildChannelUID(BRIGHTNESS_CHANNEL_ID), brightnessValue.getChannelState());
} else {
listener.updateChannelState(buildChannelUID(ON_OFF_CHANNEL_ID), onOffValue.getChannelState());
}

template = channelConfiguration.effectTemplate;
if (template != null) {
value = transform(template, state.toString());
if (value == null || value.isEmpty()) {
effectValue.update(UnDefType.NULL);
} else {
effectValue.update(new StringType(value));
}
listener.updateChannelState(buildChannelUID(EFFECT_CHANNEL_ID), effectValue.getChannelState());
}

template = channelConfiguration.colorTempTemplate;
if (template != null) {
Integer mireds = getColorChannelValue(template, state.toString());
if (mireds == null) {
colorTempValue.update(UnDefType.NULL);
} else {
colorTempValue.update(new QuantityType(mireds, Units.MIRED));
}
listener.updateChannelState(buildChannelUID(COLOR_TEMP_CHANNEL_ID), colorTempValue.getChannelState());
}
}

private @Nullable Integer getColorChannelValue(String template, String value) {
Object result = transform(template, value);
if (result == null) {
return null;
}

String string = result.toString();
if (string.isEmpty()) {
return null;
}
try {
return Integer.parseInt(result.toString());
} catch (NumberFormatException e) {
logger.warn("Applying template {} for component {} failed: {}", template, 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(String template, String value) {
return transformation.apply(template, value).orElse(null);
}
ccutrer marked this conversation as resolved.
Show resolved Hide resolved
}
Loading