[mqtt.homeassistant] support non-RGB lights (#13413)
* [mqtt.homeassistant] support non-RGB lights dynamically decide which type of channel to expose. also send "down-typed" commands to the proper topic. this also sets the groundwork for supporting template and JSON schemas Signed-off-by: Cody Cutrer <cody@cutrer.us>
This commit is contained in:
parent
969fef8612
commit
db0ca281ca
|
@ -22,7 +22,8 @@ These can be installed under `Settings` → `Addon` → `Transformations`
|
|||
|
||||
* The HomeAssistant Fan Components only support ON/OFF.
|
||||
* The HomeAssistant Cover Components only support OPEN/CLOSE/STOP.
|
||||
* The HomeAssistant Light Component only supports RGB color changes.
|
||||
* The HomeAssistant Light Component only support on/off, brightness, and RGB.
|
||||
Other color spaces, color temperature, effects, and white channel may work, but are untested.
|
||||
* The HomeAssistant Climate Components is not yet supported.
|
||||
|
||||
## Tasmota auto discovery
|
||||
|
|
|
@ -136,6 +136,8 @@ public class ComponentChannel {
|
|||
private @Nullable String templateIn;
|
||||
private @Nullable String templateOut;
|
||||
|
||||
private String format = "%s";
|
||||
|
||||
public Builder(AbstractComponent<?> component, String channelID, Value valueState, String label,
|
||||
ChannelStateUpdateListener channelStateUpdateListener) {
|
||||
this.component = component;
|
||||
|
@ -206,6 +208,11 @@ public class ComponentChannel {
|
|||
return this;
|
||||
}
|
||||
|
||||
public Builder withFormat(String format) {
|
||||
this.format = format;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ComponentChannel build() {
|
||||
return build(true);
|
||||
}
|
||||
|
@ -222,11 +229,10 @@ public class ComponentChannel {
|
|||
channelUID.getGroupId() + "_" + channelID);
|
||||
channelState = new HomeAssistantChannelState(
|
||||
ChannelConfigBuilder.create().withRetain(retain).withQos(qos).withStateTopic(stateTopic)
|
||||
.withCommandTopic(commandTopic).makeTrigger(trigger).build(),
|
||||
.withCommandTopic(commandTopic).makeTrigger(trigger).withFormatter(format).build(),
|
||||
channelUID, valueState, channelStateUpdateListener, commandFilter);
|
||||
|
||||
String localStateTopic = stateTopic;
|
||||
if (localStateTopic == null || localStateTopic.isBlank() || this.trigger) {
|
||||
if (this.trigger) {
|
||||
type = ChannelTypeBuilder.trigger(channelTypeUID, label)
|
||||
.withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HA_CHANNEL))
|
||||
.isAdvanced(isAdvanced).build();
|
||||
|
|
|
@ -56,9 +56,18 @@ public class HomeAssistantChannelState extends ChannelState {
|
|||
|
||||
@Override
|
||||
public CompletableFuture<Boolean> publishValue(Command command) {
|
||||
if (commandFilter != null && !commandFilter.test(command)) {
|
||||
logger.trace("Channel {} updates are disabled by command filter, ignoring command {}", channelUID, command);
|
||||
return CompletableFuture.completedFuture(false);
|
||||
if (commandFilter != null) {
|
||||
try {
|
||||
if (!commandFilter.test(command)) {
|
||||
logger.trace("Channel {} updates are disabled by command filter, ignoring command {}", channelUID,
|
||||
command);
|
||||
return CompletableFuture.completedFuture(false);
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
CompletableFuture<Boolean> f = new CompletableFuture<>();
|
||||
f.completeExceptionally(e);
|
||||
return f;
|
||||
}
|
||||
}
|
||||
return super.publishValue(command);
|
||||
}
|
||||
|
|
|
@ -66,7 +66,7 @@ public class ComponentFactory {
|
|||
case "climate":
|
||||
return new Climate(componentConfiguration);
|
||||
case "light":
|
||||
return new Light(componentConfiguration);
|
||||
return Light.create(componentConfiguration);
|
||||
case "lock":
|
||||
return new Lock(componentConfiguration);
|
||||
case "sensor":
|
||||
|
|
|
@ -0,0 +1,350 @@
|
|||
/**
|
||||
* Copyright (c) 2010-2022 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.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.mapping.ColorMode;
|
||||
import org.openhab.binding.mqtt.generic.values.ColorValue;
|
||||
import org.openhab.binding.mqtt.generic.values.TextValue;
|
||||
import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
|
||||
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.StringType;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.State;
|
||||
import org.openhab.core.types.UnDefType;
|
||||
|
||||
/**
|
||||
* A MQTT light, following the https://www.home-assistant.io/components/light.mqtt/ specification.
|
||||
*
|
||||
* Specifically, the default schema. This class will present a single channel for color, brightness,
|
||||
* or on/off as appropriate. Additional attributes are still exposed as dedicated channels.
|
||||
*
|
||||
* @author Cody Cutrer - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class DefaultSchemaLight extends Light {
|
||||
protected static final String HS_CHANNEL_ID = "hs";
|
||||
protected static final String RGB_CHANNEL_ID = "rgb";
|
||||
protected static final String RGBW_CHANNEL_ID = "rgbw";
|
||||
protected static final String RGBWW_CHANNEL_ID = "rgbww";
|
||||
protected static final String XY_CHANNEL_ID = "xy";
|
||||
protected static final String WHITE_CHANNEL_ID = "white";
|
||||
|
||||
protected @Nullable ComponentChannel hsChannel;
|
||||
protected @Nullable ComponentChannel rgbChannel;
|
||||
protected @Nullable ComponentChannel xyChannel;
|
||||
|
||||
public DefaultSchemaLight(ComponentFactory.ComponentConfiguration builder) {
|
||||
super(builder);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void buildChannels() {
|
||||
ComponentChannel localOnOffChannel;
|
||||
localOnOffChannel = onOffChannel = buildChannel(ON_OFF_CHANNEL_ID, onOffValue, "On/Off State", this)
|
||||
.stateTopic(channelConfiguration.stateTopic, channelConfiguration.stateValueTemplate)
|
||||
.commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
|
||||
channelConfiguration.getQos())
|
||||
.commandFilter(this::handleRawOnOffCommand).build(false);
|
||||
|
||||
@Nullable
|
||||
ComponentChannel localBrightnessChannel = null;
|
||||
if (channelConfiguration.brightnessStateTopic != null || channelConfiguration.brightnessCommandTopic != null) {
|
||||
localBrightnessChannel = brightnessChannel = buildChannel(BRIGHTNESS_CHANNEL_ID, brightnessValue,
|
||||
"Brightness", this)
|
||||
.stateTopic(channelConfiguration.brightnessStateTopic,
|
||||
channelConfiguration.brightnessValueTemplate)
|
||||
.commandTopic(channelConfiguration.brightnessCommandTopic, channelConfiguration.isRetain(),
|
||||
channelConfiguration.getQos())
|
||||
.withFormat("%.0f").commandFilter(this::handleBrightnessCommand).build(false);
|
||||
}
|
||||
|
||||
if (channelConfiguration.whiteCommandTopic != null) {
|
||||
buildChannel(WHITE_CHANNEL_ID, brightnessValue, "Go directly to white of a specific brightness", this)
|
||||
.commandTopic(channelConfiguration.whiteCommandTopic, channelConfiguration.isRetain(),
|
||||
channelConfiguration.getQos())
|
||||
.isAdvanced(true).build();
|
||||
}
|
||||
|
||||
if (channelConfiguration.colorModeStateTopic != null) {
|
||||
buildChannel(COLOR_MODE_CHANNEL_ID, new TextValue(), "Current color mode", this)
|
||||
.stateTopic(channelConfiguration.colorModeStateTopic, channelConfiguration.colorModeValueTemplate)
|
||||
.build();
|
||||
}
|
||||
|
||||
if (channelConfiguration.colorTempStateTopic != null || channelConfiguration.colorTempCommandTopic != null) {
|
||||
buildChannel(COLOR_TEMP_CHANNEL_ID, colorTempValue, "Color Temperature", this)
|
||||
.stateTopic(channelConfiguration.colorTempStateTopic, channelConfiguration.colorTempValueTemplate)
|
||||
.commandTopic(channelConfiguration.colorTempCommandTopic, channelConfiguration.isRetain(),
|
||||
channelConfiguration.getQos())
|
||||
.build();
|
||||
}
|
||||
|
||||
if (channelConfiguration.effectStateTopic != null || channelConfiguration.effectCommandTopic != null) {
|
||||
buildChannel(EFFECT_CHANNEL_ID, effectValue, "Lighting effect", this)
|
||||
.stateTopic(channelConfiguration.effectStateTopic, channelConfiguration.effectValueTemplate)
|
||||
.commandTopic(channelConfiguration.effectCommandTopic, channelConfiguration.isRetain(),
|
||||
channelConfiguration.getQos())
|
||||
.build();
|
||||
}
|
||||
|
||||
if (channelConfiguration.rgbStateTopic != null || channelConfiguration.rgbCommandTopic != null) {
|
||||
hasColorChannel = true;
|
||||
hiddenChannels.add(rgbChannel = buildChannel(RGB_CHANNEL_ID, new ColorValue(ColorMode.RGB, null, null, 100),
|
||||
"RGB state", this)
|
||||
.stateTopic(channelConfiguration.rgbStateTopic, channelConfiguration.rgbValueTemplate)
|
||||
.commandTopic(channelConfiguration.rgbCommandTopic, channelConfiguration.isRetain(),
|
||||
channelConfiguration.getQos())
|
||||
.build(false));
|
||||
}
|
||||
|
||||
if (channelConfiguration.rgbwStateTopic != null || channelConfiguration.rgbwCommandTopic != null) {
|
||||
hasColorChannel = true;
|
||||
hiddenChannels.add(buildChannel(RGBW_CHANNEL_ID, new TextValue(), "RGBW state", this)
|
||||
.stateTopic(channelConfiguration.rgbwStateTopic, channelConfiguration.rgbwValueTemplate)
|
||||
.commandTopic(channelConfiguration.rgbwCommandTopic, channelConfiguration.isRetain(),
|
||||
channelConfiguration.getQos())
|
||||
.build(false));
|
||||
}
|
||||
|
||||
if (channelConfiguration.rgbwwStateTopic != null || channelConfiguration.rgbwwCommandTopic != null) {
|
||||
hasColorChannel = true;
|
||||
hiddenChannels.add(buildChannel(RGBWW_CHANNEL_ID, new TextValue(), "RGBWW state", this)
|
||||
.stateTopic(channelConfiguration.rgbwwStateTopic, channelConfiguration.rgbwwValueTemplate)
|
||||
.commandTopic(channelConfiguration.rgbwwCommandTopic, channelConfiguration.isRetain(),
|
||||
channelConfiguration.getQos())
|
||||
.build(false));
|
||||
}
|
||||
|
||||
if (channelConfiguration.xyStateTopic != null || channelConfiguration.xyCommandTopic != null) {
|
||||
hasColorChannel = true;
|
||||
hiddenChannels.add(
|
||||
xyChannel = buildChannel(XY_CHANNEL_ID, new ColorValue(ColorMode.XYY, null, null, 100), "XY State",
|
||||
this).stateTopic(channelConfiguration.xyStateTopic, channelConfiguration.xyValueTemplate)
|
||||
.commandTopic(channelConfiguration.xyCommandTopic, channelConfiguration.isRetain(),
|
||||
channelConfiguration.getQos())
|
||||
.build(false));
|
||||
}
|
||||
|
||||
if (channelConfiguration.hsStateTopic != null || channelConfiguration.hsCommandTopic != null) {
|
||||
hasColorChannel = true;
|
||||
hiddenChannels.add(this.hsChannel = buildChannel(HS_CHANNEL_ID, new TextValue(), "Hue and Saturation", this)
|
||||
.stateTopic(channelConfiguration.hsStateTopic, channelConfiguration.hsValueTemplate)
|
||||
.commandTopic(channelConfiguration.hsCommandTopic, channelConfiguration.isRetain(),
|
||||
channelConfiguration.getQos())
|
||||
.build(false));
|
||||
}
|
||||
|
||||
if (hasColorChannel) {
|
||||
hiddenChannels.add(localOnOffChannel);
|
||||
if (localBrightnessChannel != null) {
|
||||
hiddenChannels.add(localBrightnessChannel);
|
||||
}
|
||||
buildChannel(COLOR_CHANNEL_ID, colorValue, "Color", this)
|
||||
.commandTopic(DUMMY_TOPIC, channelConfiguration.isRetain(), channelConfiguration.getQos())
|
||||
.commandFilter(this::handleColorCommand).build();
|
||||
} else if (localBrightnessChannel != null) {
|
||||
hiddenChannels.add(localOnOffChannel);
|
||||
channels.put(BRIGHTNESS_CHANNEL_ID, localBrightnessChannel);
|
||||
} else {
|
||||
channels.put(ON_OFF_CHANNEL_ID, localOnOffChannel);
|
||||
}
|
||||
}
|
||||
|
||||
// all handle*Command methods return false if they've been handled,
|
||||
// or true if default handling should continue
|
||||
|
||||
// The commandFilter for onOffChannel
|
||||
private boolean handleRawOnOffCommand(Command command) {
|
||||
// on_command_type of brightness is not allowed to send an actual on command
|
||||
if (command.equals(OnOffType.ON) && channelConfiguration.onCommandType.equals(ON_COMMAND_TYPE_BRIGHTNESS)) {
|
||||
// No prior state (or explicit off); set to 100%
|
||||
if (brightnessValue.getChannelState() instanceof UnDefType
|
||||
|| brightnessValue.getChannelState().equals(PercentType.ZERO)) {
|
||||
brightnessChannel.getState().publishValue(PercentType.HUNDRED);
|
||||
} else {
|
||||
brightnessChannel.getState().publishValue((Command) brightnessValue.getChannelState());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// The helper method the other commandFilters call
|
||||
private boolean handleOnOffCommand(Command command) {
|
||||
if (!handleRawOnOffCommand(command)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// OnOffType commands to go the regular command topic
|
||||
if (command instanceof OnOffType) {
|
||||
onOffChannel.getState().publishValue(command);
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean needsOn = !onOffValue.getChannelState().equals(OnOffType.ON);
|
||||
if (command.equals(PercentType.ZERO) || command.equals(HSBType.BLACK)) {
|
||||
needsOn = false;
|
||||
}
|
||||
if (needsOn) {
|
||||
if (channelConfiguration.onCommandType.equals(ON_COMMAND_TYPE_FIRST)) {
|
||||
onOffChannel.getState().publishValue(OnOffType.ON);
|
||||
} else if (channelConfiguration.onCommandType.equals(ON_COMMAND_TYPE_LAST)) {
|
||||
// TODO: schedule the ON publish for after this is sent
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean handleBrightnessCommand(Command command) {
|
||||
// if it's OnOffType, it'll get handled by this; otherwise it'll return
|
||||
// true and PercentType will be handled as normal
|
||||
return handleOnOffCommand(command);
|
||||
}
|
||||
|
||||
private boolean handleColorCommand(Command command) {
|
||||
if (!handleOnOffCommand(command)) {
|
||||
return false;
|
||||
} else if (command instanceof HSBType) {
|
||||
HSBType color = (HSBType) command;
|
||||
if (channelConfiguration.hsCommandTopic != null) {
|
||||
// If we don't have a brightness channel, something is probably busted
|
||||
// but don't choke
|
||||
if (channelConfiguration.brightnessCommandTopic != null) {
|
||||
brightnessChannel.getState().publishValue(color.getBrightness());
|
||||
}
|
||||
String hs = String.format("%d,%d", color.getHue().intValue(), color.getSaturation().intValue());
|
||||
hsChannel.getState().publishValue(new StringType(hs));
|
||||
} else if (channelConfiguration.rgbCommandTopic != null) {
|
||||
rgbChannel.getState().publishValue(command);
|
||||
// } else if (channelConfiguration.rgbwCommandTopic != null) {
|
||||
// TODO
|
||||
// } else if (channelConfiguration.rgbwwCommandTopic != null) {
|
||||
// TODO
|
||||
} else if (channelConfiguration.xyCommandTopic != null) {
|
||||
PercentType[] xy = color.toXY();
|
||||
// If we don't have a brightness channel, something is probably busted
|
||||
// but don't choke
|
||||
if (channelConfiguration.brightnessCommandTopic != null) {
|
||||
brightnessChannel.getState().publishValue(color.getBrightness());
|
||||
}
|
||||
String xyString = String.format("%f,%f", xy[0].doubleValue(), xy[1].doubleValue());
|
||||
xyChannel.getState().publishValue(new StringType(xyString));
|
||||
}
|
||||
} else if (command instanceof PercentType) {
|
||||
if (channelConfiguration.brightnessCommandTopic != null) {
|
||||
brightnessChannel.getState().publishValue(command);
|
||||
} else {
|
||||
// No brightness command topic?! must be RGB only
|
||||
// so re-calculatate
|
||||
State color = colorValue.getChannelState();
|
||||
if (color instanceof UnDefType) {
|
||||
color = HSBType.WHITE;
|
||||
}
|
||||
HSBType existingColor = (HSBType) color;
|
||||
HSBType newCommand = new HSBType(existingColor.getHue(), existingColor.getSaturation(),
|
||||
(PercentType) command);
|
||||
// re-process
|
||||
handleColorCommand(newCommand);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateChannelState(ChannelUID channel, State state) {
|
||||
ChannelStateUpdateListener listener = this.channelStateUpdateListener;
|
||||
switch (channel.getIdWithoutGroup()) {
|
||||
case ON_OFF_CHANNEL_ID:
|
||||
if (hasColorChannel) {
|
||||
HSBType newOnState = colorValue.getChannelState() instanceof HSBType
|
||||
? (HSBType) colorValue.getChannelState()
|
||||
: HSBType.WHITE;
|
||||
if (state.equals(OnOffType.ON)) {
|
||||
colorValue.update(newOnState);
|
||||
}
|
||||
|
||||
listener.updateChannelState(new ChannelUID(getGroupUID(), COLOR_CHANNEL_ID),
|
||||
state.equals(OnOffType.ON) ? newOnState : HSBType.BLACK);
|
||||
} else if (brightnessChannel != null) {
|
||||
listener.updateChannelState(new ChannelUID(channel.getThingUID(), BRIGHTNESS_CHANNEL_ID),
|
||||
state.equals(OnOffType.ON) ? brightnessValue.getChannelState() : PercentType.ZERO);
|
||||
} else {
|
||||
listener.updateChannelState(channel, state);
|
||||
}
|
||||
return;
|
||||
case BRIGHTNESS_CHANNEL_ID:
|
||||
onOffValue.update(Objects.requireNonNull(state.as(OnOffType.class)));
|
||||
if (hasColorChannel) {
|
||||
if (colorValue.getChannelState() instanceof HSBType) {
|
||||
HSBType hsb = (HSBType) (colorValue.getChannelState());
|
||||
colorValue.update(new HSBType(hsb.getHue(), hsb.getSaturation(),
|
||||
(PercentType) brightnessValue.getChannelState()));
|
||||
} else {
|
||||
colorValue.update(new HSBType(DecimalType.ZERO, PercentType.ZERO,
|
||||
(PercentType) brightnessValue.getChannelState()));
|
||||
}
|
||||
listener.updateChannelState(new ChannelUID(getGroupUID(), COLOR_CHANNEL_ID),
|
||||
colorValue.getChannelState());
|
||||
} else {
|
||||
listener.updateChannelState(channel, state);
|
||||
}
|
||||
return;
|
||||
case COLOR_TEMP_CHANNEL_ID:
|
||||
case EFFECT_CHANNEL_ID:
|
||||
// Real channels; pass through
|
||||
listener.updateChannelState(channel, state);
|
||||
return;
|
||||
case HS_CHANNEL_ID:
|
||||
case XY_CHANNEL_ID:
|
||||
if (brightnessValue.getChannelState() instanceof UnDefType) {
|
||||
brightnessValue.update(PercentType.HUNDRED);
|
||||
}
|
||||
String[] split = state.toString().split(",");
|
||||
if (split.length != 2) {
|
||||
throw new IllegalArgumentException(state.toString() + " is not a valid string syntax");
|
||||
}
|
||||
float x = Float.parseFloat(split[0]);
|
||||
float y = Float.parseFloat(split[1]);
|
||||
PercentType brightness = (PercentType) brightnessValue.getChannelState();
|
||||
if (channel.getIdWithoutGroup().equals(HS_CHANNEL_ID)) {
|
||||
colorValue.update(new HSBType(new DecimalType(x), new PercentType(new BigDecimal(y)), brightness));
|
||||
} else {
|
||||
HSBType xyColor = HSBType.fromXY(x, y);
|
||||
colorValue.update(new HSBType(xyColor.getHue(), xyColor.getSaturation(), brightness));
|
||||
}
|
||||
listener.updateChannelState(new ChannelUID(getGroupUID(), COLOR_CHANNEL_ID),
|
||||
colorValue.getChannelState());
|
||||
return;
|
||||
case RGB_CHANNEL_ID:
|
||||
colorValue.update((HSBType) state);
|
||||
listener.updateChannelState(new ChannelUID(getGroupUID(), COLOR_CHANNEL_ID),
|
||||
colorValue.getChannelState());
|
||||
break;
|
||||
case RGBW_CHANNEL_ID:
|
||||
case RGBWW_CHANNEL_ID:
|
||||
// TODO: update color value
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,7 +12,10 @@
|
|||
*/
|
||||
package org.openhab.binding.mqtt.homeassistant.internal.component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.stream.Stream;
|
||||
|
@ -22,28 +25,56 @@ import org.eclipse.jdt.annotation.Nullable;
|
|||
import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
|
||||
import org.openhab.binding.mqtt.generic.mapping.ColorMode;
|
||||
import org.openhab.binding.mqtt.generic.values.ColorValue;
|
||||
import org.openhab.binding.mqtt.generic.values.NumberValue;
|
||||
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.ComponentChannel;
|
||||
import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
|
||||
import org.openhab.binding.mqtt.homeassistant.internal.exception.UnsupportedComponentException;
|
||||
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
|
||||
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 com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* A MQTT light, following the https://www.home-assistant.io/components/light.mqtt/ specification.
|
||||
* A MQTT light, following the
|
||||
* https://www.home-assistant.io/components/light.mqtt/ specification.
|
||||
*
|
||||
* This class condenses the three state/command topics (for ON/OFF, Brightness, Color) to one
|
||||
* color channel.
|
||||
* Individual concrete classes implement the differing semantics of the
|
||||
* three different schemas.
|
||||
*
|
||||
* As of now, only on/off, brightness, and RGB are fully implemented and tested.
|
||||
* HS and XY are implemented, but not tested. Color temp and effect are only
|
||||
* implemented (but not tested) for the default schema.
|
||||
*
|
||||
* @author David Graeff - Initial contribution
|
||||
* @author Cody Cutrer - Re-write for (nearly) full support
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class Light extends AbstractComponent<Light.ChannelConfiguration> implements ChannelStateUpdateListener {
|
||||
public static final String SWITCH_CHANNEL_ID = "light"; // Randomly chosen channel "ID"
|
||||
public static final String BRIGHTNESS_CHANNEL_ID = "brightness"; // Randomly chosen channel "ID"
|
||||
public static final String COLOR_CHANNEL_ID = "color"; // Randomly chosen channel "ID"
|
||||
public abstract class Light extends AbstractComponent<Light.ChannelConfiguration>
|
||||
implements ChannelStateUpdateListener {
|
||||
protected static final String DEFAULT_SCHEMA = "default";
|
||||
protected static final String JSON_SCHEMA = "json";
|
||||
protected static final String TEMPLATE_SCHEMA = "template";
|
||||
|
||||
protected static final String STATE_CHANNEL_ID = "state";
|
||||
protected static final String ON_OFF_CHANNEL_ID = "on_off";
|
||||
protected static final String BRIGHTNESS_CHANNEL_ID = "brightness";
|
||||
protected static final String COLOR_MODE_CHANNEL_ID = "color_mode";
|
||||
protected static final String COLOR_TEMP_CHANNEL_ID = "color_temp";
|
||||
protected static final String EFFECT_CHANNEL_ID = "effect";
|
||||
// This channel is a synthetic channel that may send to other channels
|
||||
// underneath
|
||||
protected static final String COLOR_CHANNEL_ID = "color";
|
||||
|
||||
protected static final String DUMMY_TOPIC = "dummy";
|
||||
|
||||
protected static final String ON_COMMAND_TYPE_FIRST = "first";
|
||||
protected static final String ON_COMMAND_TYPE_BRIGHTNESS = "brightness";
|
||||
protected static final String ON_COMMAND_TYPE_LAST = "last";
|
||||
|
||||
/**
|
||||
* Configuration class for MQTT component
|
||||
|
@ -53,155 +84,238 @@ public class Light extends AbstractComponent<Light.ChannelConfiguration> impleme
|
|||
super("MQTT Light");
|
||||
}
|
||||
|
||||
@SerializedName("brightness_scale")
|
||||
protected int brightnessScale = 255;
|
||||
protected boolean optimistic = false;
|
||||
@SerializedName("effect_list")
|
||||
protected @Nullable List<String> effectList;
|
||||
/* Attributes that control the basic structure of the light */
|
||||
|
||||
// Defines when on the payload_on is sent. Using last (the default) will send any style (brightness, color, etc)
|
||||
// topics first and then a payload_on to the command_topic. Using first will send the payload_on and then any
|
||||
// style topics. Using brightness will only send brightness commands instead of the payload_on to turn the light
|
||||
protected String schema = DEFAULT_SCHEMA;
|
||||
protected @Nullable Boolean optimistic; // All schemas
|
||||
protected boolean brightness = false; // JSON schema only
|
||||
@SerializedName("color_mode")
|
||||
protected boolean colorMode = false; // JSON schema only
|
||||
@SerializedName("supported_color_modes")
|
||||
protected @Nullable List<String> supportedColorModes; // JSON schema only
|
||||
// Defines when on the payload_on is sent. Using last (the default) will send
|
||||
// any style (brightness, color, etc)
|
||||
// topics first and then a payload_on to the command_topic. Using first will
|
||||
// send the payload_on and then any
|
||||
// style topics. Using brightness will only send brightness commands instead of
|
||||
// the payload_on to turn the light
|
||||
// on.
|
||||
@SerializedName("on_command_type")
|
||||
protected String onCommandType = "last";
|
||||
protected String onCommandType = ON_COMMAND_TYPE_LAST; // Default schema only
|
||||
|
||||
/* Basic control attributes */
|
||||
|
||||
@SerializedName("state_topic")
|
||||
protected @Nullable String stateTopic;
|
||||
@SerializedName("command_topic")
|
||||
protected @Nullable String commandTopic;
|
||||
protected @Nullable String stateTopic; // All Schemas
|
||||
@SerializedName("state_value_template")
|
||||
protected @Nullable String stateValueTemplate;
|
||||
|
||||
@SerializedName("brightness_state_topic")
|
||||
protected @Nullable String brightnessStateTopic;
|
||||
@SerializedName("brightness_command_topic")
|
||||
protected @Nullable String brightnessCommandTopic;
|
||||
@SerializedName("brightness_value_template")
|
||||
protected @Nullable String brightnessValueTemplate;
|
||||
|
||||
@SerializedName("color_temp_state_topic")
|
||||
protected @Nullable String colorTempStateTopic;
|
||||
@SerializedName("color_temp_command_topic")
|
||||
protected @Nullable String colorTempCommandTopic;
|
||||
@SerializedName("color_temp_value_template")
|
||||
protected @Nullable String colorTempValueTemplate;
|
||||
|
||||
@SerializedName("effect_command_topic")
|
||||
protected @Nullable String effectCommandTopic;
|
||||
@SerializedName("effect_state_topic")
|
||||
protected @Nullable String effectStateTopic;
|
||||
@SerializedName("effect_value_template")
|
||||
protected @Nullable String effectValueTemplate;
|
||||
|
||||
@SerializedName("rgb_command_topic")
|
||||
protected @Nullable String rgbCommandTopic;
|
||||
@SerializedName("rgb_state_topic")
|
||||
protected @Nullable String rgbStateTopic;
|
||||
@SerializedName("rgb_value_template")
|
||||
protected @Nullable String rgbValueTemplate;
|
||||
@SerializedName("rgb_command_template")
|
||||
protected @Nullable String rgbCommandTemplate;
|
||||
|
||||
@SerializedName("white_value_command_topic")
|
||||
protected @Nullable String whiteValueCommandTopic;
|
||||
@SerializedName("white_value_state_topic")
|
||||
protected @Nullable String whiteValueStateTopic;
|
||||
@SerializedName("white_value_template")
|
||||
protected @Nullable String whiteValueTemplate;
|
||||
|
||||
@SerializedName("xy_command_topic")
|
||||
protected @Nullable String xyCommandTopic;
|
||||
@SerializedName("xy_state_topic")
|
||||
protected @Nullable String xyStateTopic;
|
||||
@SerializedName("xy_value_template")
|
||||
protected @Nullable String xyValueTemplate;
|
||||
|
||||
protected @Nullable String stateValueTemplate; // Default schema only
|
||||
@SerializedName("state_template")
|
||||
protected @Nullable String stateTemplate; // Template schema only
|
||||
@SerializedName("payload_on")
|
||||
protected String payloadOn = "ON";
|
||||
protected String payloadOn = "ON"; // Default schema only
|
||||
@SerializedName("payload_off")
|
||||
protected String payloadOff = "OFF";
|
||||
protected String payloadOff = "OFF"; // Default schema only
|
||||
@SerializedName("command_topic")
|
||||
protected @Nullable String commandTopic; // All schemas
|
||||
@SerializedName("command_on_template")
|
||||
protected @Nullable String commandOnTemplate; // Template schema only; required
|
||||
@SerializedName("command_off_template")
|
||||
protected @Nullable String commandOffTemplate; // Template schema only; required
|
||||
|
||||
/* Brightness attributes */
|
||||
|
||||
@SerializedName("brightness_scale")
|
||||
protected int brightnessScale = 255; // Default, JSON schemas only
|
||||
@SerializedName("brightness_state_topic")
|
||||
protected @Nullable String brightnessStateTopic; // Default schema only
|
||||
@SerializedName("brightness_value_template")
|
||||
protected @Nullable String brightnessValueTemplate; // Default schema only
|
||||
@SerializedName("brightness_template")
|
||||
protected @Nullable String brightnessTemplate; // Template schema only
|
||||
@SerializedName("brightness_command_topic")
|
||||
protected @Nullable String brightnessCommandTopic; // Default schema only
|
||||
@SerializedName("brightness_command_template")
|
||||
protected @Nullable String brightnessCommandTemplate; // Default schema only
|
||||
|
||||
/* White value attributes */
|
||||
|
||||
@SerializedName("white_scale")
|
||||
protected int whiteScale = 255; // Default, JSON schemas only
|
||||
@SerializedName("white_command_topic")
|
||||
protected @Nullable String whiteCommandTopic; // Default schema only
|
||||
|
||||
/* Color mode attributes */
|
||||
|
||||
@SerializedName("color_mode_state_topic")
|
||||
protected @Nullable String colorModeStateTopic; // Default schema only
|
||||
@SerializedName("color_mode_value_template")
|
||||
protected @Nullable String colorModeValueTemplate; // Default schema only
|
||||
|
||||
/* Color temp attributes */
|
||||
|
||||
@SerializedName("min_mireds")
|
||||
protected @Nullable Integer minMireds; // All schemas
|
||||
@SerializedName("max_mireds")
|
||||
protected @Nullable Integer maxMireds; // All schemas
|
||||
@SerializedName("color_temp_state_topic")
|
||||
protected @Nullable String colorTempStateTopic; // Default schema only
|
||||
@SerializedName("color_temp_value_template")
|
||||
protected @Nullable String colorTempValueTemplate; // Default schema only
|
||||
@SerializedName("color_temp_template")
|
||||
protected @Nullable String colorTempTemplate; // Template schema only
|
||||
@SerializedName("color_temp_command_topic")
|
||||
protected @Nullable String colorTempCommandTopic; // Default schema only
|
||||
@SerializedName("color_temp_command_template")
|
||||
protected @Nullable String colorTempCommandTemplate; // Default schema only
|
||||
|
||||
/* Effect attributes */
|
||||
@SerializedName("effect_list")
|
||||
protected @Nullable List<String> effectList; // All schemas
|
||||
@SerializedName("effect_state_topic")
|
||||
protected @Nullable String effectStateTopic; // Default schema only
|
||||
@SerializedName("effect_value_template")
|
||||
protected @Nullable String effectValueTemplate; // Default schema only
|
||||
@SerializedName("effect_template")
|
||||
protected @Nullable String effectTemplate; // Template schema only
|
||||
@SerializedName("effect_command_topic")
|
||||
protected @Nullable String effectCommandTopic; // Default schema only
|
||||
@SerializedName("effect_command_template")
|
||||
protected @Nullable String effectCommandTemplate; // Default schema only
|
||||
|
||||
/* HS attributes */
|
||||
@SerializedName("hs_state_topic")
|
||||
protected @Nullable String hsStateTopic; // Default schema only
|
||||
@SerializedName("hs_value_template")
|
||||
protected @Nullable String hsValueTemplate; // Default schema only
|
||||
@SerializedName("hs_command_topic")
|
||||
protected @Nullable String hsCommandTopic; // Default schema only
|
||||
|
||||
/* RGB attributes */
|
||||
@SerializedName("rgb_state_topic")
|
||||
protected @Nullable String rgbStateTopic; // Default schema only
|
||||
@SerializedName("rgb_value_template")
|
||||
protected @Nullable String rgbValueTemplate; // Default schema only
|
||||
@SerializedName("red_template")
|
||||
protected @Nullable String redTemplate; // Template schema only
|
||||
@SerializedName("green_template")
|
||||
protected @Nullable String greenTemplate; // Template schema only
|
||||
@SerializedName("blue_template")
|
||||
protected @Nullable String blueTemplate; // Template schema only
|
||||
@SerializedName("rgb_command_topic")
|
||||
protected @Nullable String rgbCommandTopic; // Default schema only
|
||||
@SerializedName("rgb_command_template")
|
||||
protected @Nullable String rgbCommandTemplate; // Default schema only
|
||||
|
||||
/* RGBW attributes */
|
||||
@SerializedName("rgbw_state_topic")
|
||||
protected @Nullable String rgbwStateTopic; // Default schema only
|
||||
@SerializedName("rgbw_value_template")
|
||||
protected @Nullable String rgbwValueTemplate; // Default schema only
|
||||
@SerializedName("rgbw_command_topic")
|
||||
protected @Nullable String rgbwCommandTopic; // Default schema only
|
||||
@SerializedName("rgbw_command_template")
|
||||
protected @Nullable String rgbwCommandTemplate; // Default schema only
|
||||
|
||||
/* RGBWW attributes */
|
||||
@SerializedName("rgbww_state_topic")
|
||||
protected @Nullable String rgbwwStateTopic; // Default schema only
|
||||
@SerializedName("rgbww_value_template")
|
||||
protected @Nullable String rgbwwValueTemplate; // Default schema only
|
||||
@SerializedName("rgbww_command_topic")
|
||||
protected @Nullable String rgbwwCommandTopic; // Default schema only
|
||||
@SerializedName("rgbww_command_template")
|
||||
protected @Nullable String rgbwwCommandTemplate; // Default schema only
|
||||
|
||||
/* XY attributes */
|
||||
@SerializedName("xy_command_topic")
|
||||
protected @Nullable String xyCommandTopic; // Default schema only
|
||||
@SerializedName("xy_state_topic")
|
||||
protected @Nullable String xyStateTopic; // Default schema only
|
||||
@SerializedName("xy_value_template")
|
||||
protected @Nullable String xyValueTemplate; // Default schema only
|
||||
}
|
||||
|
||||
protected ComponentChannel colorChannel;
|
||||
protected ComponentChannel switchChannel;
|
||||
protected ComponentChannel brightnessChannel;
|
||||
private final @Nullable ChannelStateUpdateListener channelStateUpdateListener;
|
||||
protected final boolean optimistic;
|
||||
protected boolean hasColorChannel = false;
|
||||
|
||||
public Light(ComponentFactory.ComponentConfiguration builder) {
|
||||
protected @Nullable ComponentChannel onOffChannel;
|
||||
protected @Nullable ComponentChannel brightnessChannel;
|
||||
|
||||
// State has to be stored here, in order to mux multiple
|
||||
// MQTT sources into single OpenHAB channels
|
||||
protected OnOffValue onOffValue;
|
||||
protected PercentageValue brightnessValue;
|
||||
protected final NumberValue colorTempValue;
|
||||
protected final TextValue effectValue = new TextValue();
|
||||
protected final ColorValue colorValue = new ColorValue(ColorMode.HSB, null, null, 100);
|
||||
|
||||
protected final List<ComponentChannel> hiddenChannels = new ArrayList<>();
|
||||
protected final ChannelStateUpdateListener channelStateUpdateListener;
|
||||
|
||||
public static Light create(ComponentFactory.ComponentConfiguration builder) throws UnsupportedComponentException {
|
||||
String schema = builder.getConfig(ChannelConfiguration.class).schema;
|
||||
switch (schema) {
|
||||
case DEFAULT_SCHEMA:
|
||||
return new DefaultSchemaLight(builder);
|
||||
default:
|
||||
throw new UnsupportedComponentException(
|
||||
"Component '" + builder.getHaID() + "' of schema '" + schema + "' is not supported!");
|
||||
}
|
||||
}
|
||||
|
||||
protected Light(ComponentFactory.ComponentConfiguration builder) {
|
||||
super(builder, ChannelConfiguration.class);
|
||||
this.channelStateUpdateListener = builder.getUpdateListener();
|
||||
ColorValue value = new ColorValue(ColorMode.RGB, channelConfiguration.payloadOn,
|
||||
channelConfiguration.payloadOff, 100);
|
||||
|
||||
// Create three MQTT subscriptions and use this class object as update listener
|
||||
switchChannel = buildChannel(SWITCH_CHANNEL_ID, value, channelConfiguration.getName(), this)
|
||||
.stateTopic(channelConfiguration.stateTopic, channelConfiguration.stateValueTemplate,
|
||||
channelConfiguration.getValueTemplate())
|
||||
.commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
|
||||
channelConfiguration.getQos())
|
||||
.build(false);
|
||||
@Nullable
|
||||
Boolean optimistic = channelConfiguration.optimistic;
|
||||
if (optimistic != null) {
|
||||
this.optimistic = optimistic;
|
||||
} else {
|
||||
this.optimistic = (channelConfiguration.stateTopic == null);
|
||||
}
|
||||
|
||||
colorChannel = buildChannel(COLOR_CHANNEL_ID, value, channelConfiguration.getName(), this)
|
||||
.stateTopic(channelConfiguration.rgbStateTopic, channelConfiguration.rgbValueTemplate)
|
||||
.commandTopic(channelConfiguration.rgbCommandTopic, channelConfiguration.isRetain(),
|
||||
channelConfiguration.getQos())
|
||||
.build(false);
|
||||
onOffValue = new OnOffValue(channelConfiguration.payloadOn, channelConfiguration.payloadOff);
|
||||
brightnessValue = new PercentageValue(null, new BigDecimal(channelConfiguration.brightnessScale), null, null,
|
||||
null);
|
||||
@Nullable
|
||||
BigDecimal min = null, max = null;
|
||||
if (channelConfiguration.minMireds != null) {
|
||||
min = new BigDecimal(channelConfiguration.minMireds);
|
||||
}
|
||||
if (channelConfiguration.maxMireds != null) {
|
||||
max = new BigDecimal(channelConfiguration.maxMireds);
|
||||
}
|
||||
colorTempValue = new NumberValue(min, max, BigDecimal.ONE, Units.MIRED);
|
||||
|
||||
brightnessChannel = buildChannel(BRIGHTNESS_CHANNEL_ID, value, channelConfiguration.getName(), this)
|
||||
.stateTopic(channelConfiguration.brightnessStateTopic, channelConfiguration.brightnessValueTemplate)
|
||||
.commandTopic(channelConfiguration.brightnessCommandTopic, channelConfiguration.isRetain(),
|
||||
channelConfiguration.getQos())
|
||||
.build(false);
|
||||
|
||||
channels.put(COLOR_CHANNEL_ID, colorChannel);
|
||||
buildChannels();
|
||||
}
|
||||
|
||||
protected abstract void buildChannels();
|
||||
|
||||
@Override
|
||||
public CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection, ScheduledExecutorService scheduler,
|
||||
int timeout) {
|
||||
return Stream.of(switchChannel, brightnessChannel, colorChannel) //
|
||||
return Stream.concat(channels.values().stream(), hiddenChannels.stream()) //
|
||||
.map(v -> v.start(connection, scheduler, timeout)) //
|
||||
.reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<@Nullable Void> stop() {
|
||||
return Stream.of(switchChannel, brightnessChannel, colorChannel) //
|
||||
.map(v -> v.stop()) //
|
||||
return Stream.concat(channels.values().stream(), hiddenChannels.stream()) //
|
||||
.filter(Objects::nonNull) //
|
||||
.map(ComponentChannel::stop) //
|
||||
.reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy method to condense all three MQTT subscriptions to one channel
|
||||
*/
|
||||
@Override
|
||||
public void updateChannelState(ChannelUID channelUID, State value) {
|
||||
ChannelStateUpdateListener listener = channelStateUpdateListener;
|
||||
if (listener != null) {
|
||||
listener.updateChannelState(colorChannel.getChannelUID(), value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy method to condense all three MQTT subscriptions to one channel
|
||||
*/
|
||||
@Override
|
||||
public void postChannelCommand(ChannelUID channelUID, Command value) {
|
||||
ChannelStateUpdateListener listener = channelStateUpdateListener;
|
||||
if (listener != null) {
|
||||
listener.postChannelCommand(colorChannel.getChannelUID(), value);
|
||||
}
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy method to condense all three MQTT subscriptions to one channel
|
||||
*/
|
||||
@Override
|
||||
public void triggerChannel(ChannelUID channelUID, String eventPayload) {
|
||||
ChannelStateUpdateListener listener = channelStateUpdateListener;
|
||||
if (listener != null) {
|
||||
listener.triggerChannel(colorChannel.getChannelUID(), eventPayload);
|
||||
}
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,6 +51,7 @@ import org.openhab.binding.mqtt.homeassistant.internal.handler.HomeAssistantThin
|
|||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingStatusInfo;
|
||||
import org.openhab.core.thing.binding.ThingHandlerCallback;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.State;
|
||||
|
||||
/**
|
||||
|
@ -244,6 +245,19 @@ public abstract class AbstractComponentTests extends AbstractHomeAssistantTests
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send command to a thing's channel
|
||||
*
|
||||
* @param component component
|
||||
* @param channelId channel
|
||||
* @param command command to send
|
||||
*/
|
||||
protected void sendCommand(AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> component,
|
||||
String channelId, Command command) {
|
||||
var channel = Objects.requireNonNull(component.getChannel(channelId));
|
||||
thingHandler.handleCommand(channel.getChannelUID(), command);
|
||||
}
|
||||
|
||||
protected static class LatchThingHandler extends HomeAssistantThingHandler {
|
||||
private @Nullable CountDownLatch latch;
|
||||
private @Nullable AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> discoveredComponent;
|
||||
|
|
|
@ -0,0 +1,282 @@
|
|||
/**
|
||||
* Copyright (c) 2010-2022 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 static org.hamcrest.CoreMatchers.is;
|
||||
import static org.hamcrest.CoreMatchers.notNullValue;
|
||||
import static org.hamcrest.CoreMatchers.nullValue;
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.mqtt.generic.values.ColorValue;
|
||||
import org.openhab.binding.mqtt.generic.values.OnOffValue;
|
||||
import org.openhab.binding.mqtt.generic.values.PercentageValue;
|
||||
import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Tests for {@link Light} confirming to the default schema
|
||||
*
|
||||
* @author Anton Kharuzhy - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class DefaultSchemaLightTests extends AbstractComponentTests {
|
||||
public static final String CONFIG_TOPIC = "light/0x0000000000000000_light_zigbee2mqtt";
|
||||
|
||||
@Test
|
||||
public void testRgb() throws InterruptedException {
|
||||
// @formatter:off
|
||||
var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
|
||||
"{ " +
|
||||
" \"availability\": [ " +
|
||||
" { " +
|
||||
" \"topic\": \"zigbee2mqtt/bridge/state\" " +
|
||||
" } " +
|
||||
" ], " +
|
||||
" \"device\": { " +
|
||||
" \"identifiers\": [ " +
|
||||
" \"zigbee2mqtt_0x0000000000000000\" " +
|
||||
" ], " +
|
||||
" \"manufacturer\": \"Lights inc\", " +
|
||||
" \"model\": \"light v1\", " +
|
||||
" \"name\": \"Light\", " +
|
||||
" \"sw_version\": \"Zigbee2MQTT 1.18.2\" " +
|
||||
" }, " +
|
||||
" \"name\": \"light\", " +
|
||||
" \"state_topic\": \"zigbee2mqtt/light/state\", " +
|
||||
" \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
|
||||
" \"state_value_template\": \"{{ value_json.power }}\", " +
|
||||
" \"payload_on\": \"ON_\", " +
|
||||
" \"payload_off\": \"OFF_\", " +
|
||||
" \"rgb_state_topic\": \"zigbee2mqtt/light/rgb\", " +
|
||||
" \"rgb_command_topic\": \"zigbee2mqtt/light/set/rgb\", " +
|
||||
" \"rgb_value_template\": \"{{ value_json.rgb }}\", " +
|
||||
" \"brightness_state_topic\": \"zigbee2mqtt/light/brightness\", " +
|
||||
" \"brightness_command_topic\": \"zigbee2mqtt/light/set/brightness\", " +
|
||||
" \"brightness_value_template\": \"{{ value_json.br }}\" " +
|
||||
"}");
|
||||
// @formatter:on
|
||||
|
||||
assertThat(component.channels.size(), is(1));
|
||||
assertThat(component.getName(), is("light"));
|
||||
|
||||
assertChannel(component, Light.COLOR_CHANNEL_ID, "", "dummy", "Color", ColorValue.class);
|
||||
|
||||
@Nullable
|
||||
ComponentChannel onOffChannel = component.onOffChannel;
|
||||
assertThat(onOffChannel, is(notNullValue()));
|
||||
if (onOffChannel != null) {
|
||||
assertChannel(onOffChannel, "zigbee2mqtt/light/state", "zigbee2mqtt/light/set/state", "On/Off State",
|
||||
OnOffValue.class);
|
||||
}
|
||||
@Nullable
|
||||
ComponentChannel brightnessChannel = component.brightnessChannel;
|
||||
assertThat(brightnessChannel, is(notNullValue()));
|
||||
if (brightnessChannel != null) {
|
||||
assertChannel(brightnessChannel, "zigbee2mqtt/light/brightness", "zigbee2mqtt/light/set/brightness",
|
||||
"Brightness", PercentageValue.class);
|
||||
}
|
||||
|
||||
publishMessage("zigbee2mqtt/light/state", "{\"power\": \"ON_\"}");
|
||||
assertState(component, Light.COLOR_CHANNEL_ID, HSBType.WHITE);
|
||||
publishMessage("zigbee2mqtt/light/rgb", "{\"rgb\": \"10,20,30\"}");
|
||||
assertState(component, Light.COLOR_CHANNEL_ID, HSBType.fromRGB(10, 20, 30));
|
||||
publishMessage("zigbee2mqtt/light/rgb", "{\"rgb\": \"255,255,255\"}");
|
||||
assertState(component, Light.COLOR_CHANNEL_ID, HSBType.WHITE);
|
||||
|
||||
sendCommand(component, Light.COLOR_CHANNEL_ID, HSBType.BLUE);
|
||||
assertPublished("zigbee2mqtt/light/set/rgb", "0,0,255");
|
||||
|
||||
// Brightness commands should route to the correct topic
|
||||
sendCommand(component, Light.COLOR_CHANNEL_ID, new PercentType(50));
|
||||
assertPublished("zigbee2mqtt/light/set/brightness", "128");
|
||||
|
||||
// OnOff commands should route to the correct topic
|
||||
sendCommand(component, Light.COLOR_CHANNEL_ID, OnOffType.OFF);
|
||||
assertPublished("zigbee2mqtt/light/set/state", "OFF_");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRgbWithoutBrightness() throws InterruptedException {
|
||||
// @formatter:off
|
||||
var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
|
||||
"{ " +
|
||||
" \"name\": \"light\", " +
|
||||
" \"state_topic\": \"zigbee2mqtt/light/state\", " +
|
||||
" \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
|
||||
" \"state_value_template\": \"{{ value_json.power }}\", " +
|
||||
" \"payload_on\": \"ON_\", " +
|
||||
" \"payload_off\": \"OFF_\", " +
|
||||
" \"rgb_state_topic\": \"zigbee2mqtt/light/rgb\", " +
|
||||
" \"rgb_command_topic\": \"zigbee2mqtt/light/set/rgb\", " +
|
||||
" \"rgb_value_template\": \"{{ value_json.rgb }}\"" +
|
||||
"}");
|
||||
// @formatter:on
|
||||
|
||||
publishMessage("zigbee2mqtt/light/rgb", "{\"rgb\": \"255,255,255\"}");
|
||||
assertState(component, Light.COLOR_CHANNEL_ID, HSBType.WHITE);
|
||||
|
||||
// Brightness commands should route to the correct topic, converted to RGB
|
||||
sendCommand(component, Light.COLOR_CHANNEL_ID, new PercentType(50));
|
||||
assertPublished("zigbee2mqtt/light/set/rgb", "127,127,127");
|
||||
|
||||
// OnOff commands should route to the correct topic
|
||||
sendCommand(component, Light.COLOR_CHANNEL_ID, OnOffType.OFF);
|
||||
assertPublished("zigbee2mqtt/light/set/state", "OFF_");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHsb() throws InterruptedException {
|
||||
// @formatter:off
|
||||
var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
|
||||
"{ " +
|
||||
" \"name\": \"light\", " +
|
||||
" \"state_topic\": \"zigbee2mqtt/light/state\", " +
|
||||
" \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
|
||||
" \"state_value_template\": \"{{ value_json.power }}\", " +
|
||||
" \"payload_on\": \"ON_\", " +
|
||||
" \"payload_off\": \"OFF_\", " +
|
||||
" \"hs_state_topic\": \"zigbee2mqtt/light/hs\", " +
|
||||
" \"hs_command_topic\": \"zigbee2mqtt/light/set/hs\", " +
|
||||
" \"hs_value_template\": \"{{ value_json.hs }}\", " +
|
||||
" \"brightness_state_topic\": \"zigbee2mqtt/light/brightness\", " +
|
||||
" \"brightness_command_topic\": \"zigbee2mqtt/light/set/brightness\", " +
|
||||
" \"brightness_value_template\": \"{{ value_json.br }}\" " +
|
||||
"}");
|
||||
// @formatter:on
|
||||
|
||||
assertThat(component.channels.size(), is(1));
|
||||
assertThat(component.getName(), is("light"));
|
||||
|
||||
assertChannel(component, Light.COLOR_CHANNEL_ID, "", "dummy", "Color", ColorValue.class);
|
||||
|
||||
@Nullable
|
||||
ComponentChannel onOffChannel = component.onOffChannel;
|
||||
assertThat(onOffChannel, is(notNullValue()));
|
||||
if (onOffChannel != null) {
|
||||
assertChannel(onOffChannel, "zigbee2mqtt/light/state", "zigbee2mqtt/light/set/state", "On/Off State",
|
||||
OnOffValue.class);
|
||||
}
|
||||
@Nullable
|
||||
ComponentChannel brightnessChannel = component.brightnessChannel;
|
||||
assertThat(brightnessChannel, is(notNullValue()));
|
||||
if (brightnessChannel != null) {
|
||||
assertChannel(brightnessChannel, "zigbee2mqtt/light/brightness", "zigbee2mqtt/light/set/brightness",
|
||||
"Brightness", PercentageValue.class);
|
||||
}
|
||||
|
||||
publishMessage("zigbee2mqtt/light/hs", "{\"hs\": \"180,50\"}");
|
||||
publishMessage("zigbee2mqtt/light/brightness", "{\"br\": \"128\"}");
|
||||
assertState(component, Light.COLOR_CHANNEL_ID, new HSBType(new DecimalType(180), new PercentType(50),
|
||||
new PercentType(new BigDecimal(128 * 100).divide(new BigDecimal(255), MathContext.DECIMAL128))));
|
||||
|
||||
sendCommand(component, Light.COLOR_CHANNEL_ID, HSBType.BLUE);
|
||||
assertPublished("zigbee2mqtt/light/set/brightness", "255");
|
||||
assertPublished("zigbee2mqtt/light/set/hs", "240,100");
|
||||
|
||||
// Brightness commands should route to the correct topic
|
||||
sendCommand(component, Light.COLOR_CHANNEL_ID, new PercentType(50));
|
||||
assertPublished("zigbee2mqtt/light/set/brightness", "128");
|
||||
|
||||
// OnOff commands should route to the correct topic
|
||||
sendCommand(component, Light.COLOR_CHANNEL_ID, OnOffType.OFF);
|
||||
assertPublished("zigbee2mqtt/light/set/state", "OFF_");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBrightnessAndOnOff() throws InterruptedException {
|
||||
// @formatter:off
|
||||
var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
|
||||
"{ " +
|
||||
" \"name\": \"light\", " +
|
||||
" \"state_topic\": \"zigbee2mqtt/light/state\", " +
|
||||
" \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
|
||||
" \"state_value_template\": \"{{ value_json.power }}\", " +
|
||||
" \"brightness_state_topic\": \"zigbee2mqtt/light/brightness\", " +
|
||||
" \"brightness_command_topic\": \"zigbee2mqtt/light/set/brightness\", " +
|
||||
" \"payload_on\": \"ON_\", " +
|
||||
" \"payload_off\": \"OFF_\" " +
|
||||
"}");
|
||||
// @formatter:on
|
||||
|
||||
assertThat(component.channels.size(), is(1));
|
||||
assertThat(component.getName(), is("light"));
|
||||
|
||||
assertChannel(component, Light.BRIGHTNESS_CHANNEL_ID, "zigbee2mqtt/light/brightness",
|
||||
"zigbee2mqtt/light/set/brightness", "Brightness", PercentageValue.class);
|
||||
@Nullable
|
||||
ComponentChannel onOffChannel = component.onOffChannel;
|
||||
assertThat(onOffChannel, is(notNullValue()));
|
||||
if (onOffChannel != null) {
|
||||
assertChannel(onOffChannel, "zigbee2mqtt/light/state", "zigbee2mqtt/light/set/state", "On/Off State",
|
||||
OnOffValue.class);
|
||||
}
|
||||
|
||||
publishMessage("zigbee2mqtt/light/brightness", "128");
|
||||
assertState(component, Light.BRIGHTNESS_CHANNEL_ID,
|
||||
new PercentType(new BigDecimal(128 * 100).divide(new BigDecimal(255), MathContext.DECIMAL128)));
|
||||
publishMessage("zigbee2mqtt/light/brightness", "64");
|
||||
assertState(component, Light.BRIGHTNESS_CHANNEL_ID,
|
||||
new PercentType(new BigDecimal(64 * 100).divide(new BigDecimal(255), MathContext.DECIMAL128)));
|
||||
|
||||
sendCommand(component, Light.BRIGHTNESS_CHANNEL_ID, OnOffType.OFF);
|
||||
assertPublished("zigbee2mqtt/light/set/state", "OFF_");
|
||||
|
||||
sendCommand(component, Light.BRIGHTNESS_CHANNEL_ID, OnOffType.ON);
|
||||
assertPublished("zigbee2mqtt/light/set/state", "ON_");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testOnOffOnly() throws InterruptedException {
|
||||
// @formatter:off
|
||||
var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
|
||||
"{ " +
|
||||
" \"name\": \"light\", " +
|
||||
" \"state_topic\": \"zigbee2mqtt/light/state\", " +
|
||||
" \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
|
||||
" \"state_value_template\": \"{{ value_json.power }}\", " +
|
||||
" \"payload_on\": \"ON_\", " +
|
||||
" \"payload_off\": \"OFF_\" " +
|
||||
"}");
|
||||
// @formatter:on
|
||||
|
||||
assertThat(component.channels.size(), is(1));
|
||||
assertThat(component.getName(), is("light"));
|
||||
|
||||
assertChannel(component, Light.ON_OFF_CHANNEL_ID, "zigbee2mqtt/light/state", "zigbee2mqtt/light/set/state",
|
||||
"On/Off State", OnOffValue.class);
|
||||
assertThat(component.brightnessChannel, is(nullValue()));
|
||||
|
||||
publishMessage("zigbee2mqtt/light/state", "{\"power\": \"ON_\"}");
|
||||
assertState(component, Light.ON_OFF_CHANNEL_ID, OnOffType.ON);
|
||||
publishMessage("zigbee2mqtt/light/state", "{\"power\": \"OFF_\"}");
|
||||
assertState(component, Light.ON_OFF_CHANNEL_ID, OnOffType.OFF);
|
||||
|
||||
sendCommand(component, Light.ON_OFF_CHANNEL_ID, OnOffType.OFF);
|
||||
assertPublished("zigbee2mqtt/light/set/state", "OFF_");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Set<String> getConfigTopics() {
|
||||
return Set.of(CONFIG_TOPIC);
|
||||
}
|
||||
}
|
|
@ -1,94 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2010-2022 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 static org.hamcrest.CoreMatchers.is;
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.mqtt.generic.values.ColorValue;
|
||||
import org.openhab.core.library.types.HSBType;
|
||||
import org.openhab.core.library.types.OnOffType;
|
||||
|
||||
/**
|
||||
* Tests for {@link Light}
|
||||
* The current {@link Light} is non-compliant with the Specification and must be rewritten from scratch.
|
||||
*
|
||||
* @author Anton Kharuzhy - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class LightTests extends AbstractComponentTests {
|
||||
public static final String CONFIG_TOPIC = "light/0x0000000000000000_light_zigbee2mqtt";
|
||||
|
||||
@Test
|
||||
public void test() throws InterruptedException {
|
||||
// @formatter:off
|
||||
var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
|
||||
"{ " +
|
||||
" \"availability\": [ " +
|
||||
" { " +
|
||||
" \"topic\": \"zigbee2mqtt/bridge/state\" " +
|
||||
" } " +
|
||||
" ], " +
|
||||
" \"device\": { " +
|
||||
" \"identifiers\": [ " +
|
||||
" \"zigbee2mqtt_0x0000000000000000\" " +
|
||||
" ], " +
|
||||
" \"manufacturer\": \"Lights inc\", " +
|
||||
" \"model\": \"light v1\", " +
|
||||
" \"name\": \"Light\", " +
|
||||
" \"sw_version\": \"Zigbee2MQTT 1.18.2\" " +
|
||||
" }, " +
|
||||
" \"name\": \"light\", " +
|
||||
" \"state_topic\": \"zigbee2mqtt/light/state\", " +
|
||||
" \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
|
||||
" \"state_value_template\": \"{{ value_json.power }}\", " +
|
||||
" \"payload_on\": \"ON_\", " +
|
||||
" \"payload_off\": \"OFF_\", " +
|
||||
" \"rgb_state_topic\": \"zigbee2mqtt/light/rgb\", " +
|
||||
" \"rgb_command_topic\": \"zigbee2mqtt/light/set/rgb\", " +
|
||||
" \"rgb_value_template\": \"{{ value_json.rgb }}\", " +
|
||||
" \"brightness_state_topic\": \"zigbee2mqtt/light/brightness\", " +
|
||||
" \"brightness_command_topic\": \"zigbee2mqtt/light/set/brightness\", " +
|
||||
" \"brightness_value_template\": \"{{ value_json.br }}\" " +
|
||||
"}");
|
||||
// @formatter:on
|
||||
|
||||
assertThat(component.channels.size(), is(1));
|
||||
assertThat(component.getName(), is("light"));
|
||||
|
||||
assertChannel(component, Light.COLOR_CHANNEL_ID, "zigbee2mqtt/light/rgb", "zigbee2mqtt/light/set/rgb", "light",
|
||||
ColorValue.class);
|
||||
|
||||
assertChannel(component.switchChannel, "zigbee2mqtt/light/state", "zigbee2mqtt/light/set/state", "light",
|
||||
ColorValue.class);
|
||||
assertChannel(component.brightnessChannel, "zigbee2mqtt/light/brightness", "zigbee2mqtt/light/set/brightness",
|
||||
"light", ColorValue.class);
|
||||
|
||||
publishMessage("zigbee2mqtt/light/rgb", "{\"rgb\": \"255,255,255\"}");
|
||||
assertState(component, Light.COLOR_CHANNEL_ID, HSBType.fromRGB(255, 255, 255));
|
||||
publishMessage("zigbee2mqtt/light/rgb", "{\"rgb\": \"10,20,30\"}");
|
||||
assertState(component, Light.COLOR_CHANNEL_ID, HSBType.fromRGB(10, 20, 30));
|
||||
|
||||
component.switchChannel.getState().publishValue(OnOffType.OFF);
|
||||
assertPublished("zigbee2mqtt/light/set/state", "0,0,0");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Set<String> getConfigTopics() {
|
||||
return Set.of(CONFIG_TOPIC);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue