[mqtt.homeassistant] fix multi-speed fans (#17813)

* fix step math so that the state description represents the step
   scaled to 0-100%

Signed-off-by: Cody Cutrer <cody@cutrer.us>
This commit is contained in:
Cody Cutrer 2024-12-03 11:47:28 -07:00 committed by GitHub
parent d0ea14f441
commit 156e691d0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 122 additions and 30 deletions

View File

@ -54,9 +54,10 @@ public class PercentageValue extends Value {
private final BigDecimal stepPercent;
private final @Nullable String onValue;
private final @Nullable String offValue;
private final @Nullable String formatOverride;
public PercentageValue(@Nullable BigDecimal min, @Nullable BigDecimal max, @Nullable BigDecimal step,
@Nullable String onValue, @Nullable String offValue) {
@Nullable String onValue, @Nullable String offValue, @Nullable String formatOverride) {
super(CoreItemFactory.DIMMER, List.of(DecimalType.class, QuantityType.class, IncreaseDecreaseType.class,
OnOffType.class, UpDownType.class, StringType.class));
this.onValue = onValue;
@ -69,6 +70,7 @@ public class PercentageValue extends Value {
this.span = this.max.subtract(this.min);
this.step = step == null ? BigDecimal.ONE : step;
this.stepPercent = this.step.multiply(HUNDRED).divide(this.span, MathContext.DECIMAL128);
this.formatOverride = formatOverride;
}
@Override
@ -135,7 +137,10 @@ public class PercentageValue extends Value {
@Override
public String getMQTTpublishValue(Command command, @Nullable String pattern) {
String formatPattern = pattern;
String formatPattern = this.formatOverride;
if (formatPattern == null) {
formatPattern = pattern;
}
if (formatPattern == null) {
formatPattern = "%s";
}
@ -170,7 +175,7 @@ public class PercentageValue extends Value {
@Override
public StateDescriptionFragmentBuilder createStateDescription(boolean readOnly) {
return super.createStateDescription(readOnly).withMaximum(HUNDRED).withMinimum(BigDecimal.ZERO).withStep(step)
.withPattern("%.0f %%");
return super.createStateDescription(readOnly).withMaximum(HUNDRED).withMinimum(BigDecimal.ZERO)
.withStep(stepPercent).withPattern("%.0f %%");
}
}

View File

@ -54,7 +54,7 @@ public class ValueFactory {
value = new NumberValue(config.min, config.max, config.step, UnitUtils.parseUnit(config.unit));
break;
case MqttBindingConstants.DIMMER:
value = new PercentageValue(config.min, config.max, config.step, config.on, config.off);
value = new PercentageValue(config.min, config.max, config.step, config.on, config.off, null);
break;
case MqttBindingConstants.COLOR_HSB:
value = new ColorValue(ColorMode.HSB, config.on, config.off, config.onBrightness);

View File

@ -267,7 +267,7 @@ public class ChannelStateTests {
@Test
public void receivePercentageTest() {
PercentageValue value = new PercentageValue(new BigDecimal(-100), new BigDecimal(100), new BigDecimal(10), null,
null);
null, null);
ChannelState c = spy(new ChannelState(config, channelUIDMock, value, channelStateUpdateListenerMock));
c.start(connectionMock, mock(ScheduledExecutorService.class), 100);

View File

@ -17,6 +17,7 @@ import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigDecimal;
import java.math.MathContext;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
@ -99,7 +100,7 @@ public class ValueTests {
@Test
public void illegalPercentCommand() {
PercentageValue v = new PercentageValue(null, null, null, null, null);
PercentageValue v = new PercentageValue(null, null, null, null, null, null);
assertThrows(IllegalStateException.class, () -> v.parseCommand(new StringType("demo")));
}
@ -111,7 +112,7 @@ public class ValueTests {
@Test
public void illegalPercentUpdate() {
PercentageValue v = new PercentageValue(null, null, null, null, null);
PercentageValue v = new PercentageValue(null, null, null, null, null, null);
assertThrows(IllegalArgumentException.class, () -> v.parseCommand(new DecimalType(101.0)));
}
@ -304,7 +305,9 @@ public class ValueTests {
@Test
public void percentCalc() {
PercentageValue v = new PercentageValue(new BigDecimal(10.0), new BigDecimal(110.0), new BigDecimal(1.0), null,
null);
null, null);
assertThat(v.createStateDescription(false).build().getStep(), is(new BigDecimal(1)));
assertThat(v.parseCommand(new DecimalType("110.0")), is(PercentType.HUNDRED));
assertThat(v.getMQTTpublishValue(PercentType.HUNDRED, null), is("110"));
assertThat(v.parseCommand(new DecimalType(10.0)), is(PercentType.ZERO));
@ -316,9 +319,20 @@ public class ValueTests {
assertThat(v.getMQTTpublishValue(OnOffType.OFF, null), is("10"));
}
@Test
public void percentFormatOverride() {
PercentageValue v = new PercentageValue(BigDecimal.ZERO, new BigDecimal(3.0), null, null, null, "%.0f");
assertThat(v.createStateDescription(false).build().getStep(),
is(new BigDecimal(100).divide(new BigDecimal(3), MathContext.DECIMAL128)));
assertThat(v.getMQTTpublishValue(PercentType.HUNDRED, null), is("3"));
assertThat(v.getMQTTpublishValue(PercentType.valueOf("67"), null), is("2"));
assertThat(v.getMQTTpublishValue(PercentType.valueOf("33"), null), is("1"));
assertThat(v.getMQTTpublishValue(PercentType.ZERO, null), is("0"));
}
@Test
public void percentMQTTValue() {
PercentageValue v = new PercentageValue(null, null, null, null, null);
PercentageValue v = new PercentageValue(null, null, null, null, null, null);
assertThat(v.parseCommand(new DecimalType("10.10000")), is(new PercentType("10.1")));
assertThat(v.getMQTTpublishValue(new PercentType("10.1"), null), is("10.1"));
Command command;
@ -333,7 +347,7 @@ public class ValueTests {
@Test
public void percentCustomOnOff() {
PercentageValue v = new PercentageValue(new BigDecimal("0.0"), new BigDecimal("100.0"), new BigDecimal("1.0"),
"on", "off");
"on", "off", null);
assertThat(v.parseCommand(new StringType("on")), is(OnOffType.ON));
assertThat(v.getMQTTpublishValue(OnOffType.ON, "%s"), is("on"));
assertThat(v.parseCommand(new StringType("off")), is(OnOffType.OFF));
@ -343,7 +357,7 @@ public class ValueTests {
@Test
public void decimalCalc() {
PercentageValue v = new PercentageValue(new BigDecimal("0.1"), new BigDecimal("1.0"), new BigDecimal("0.1"),
null, null);
null, null, null);
assertThat(v.parseCommand(new DecimalType(1.0)), is(PercentType.HUNDRED));
assertThat(v.parseCommand(new DecimalType(0.1)), is(PercentType.ZERO));
PercentType command = (PercentType) v.parseCommand(new DecimalType(0.2));
@ -353,7 +367,7 @@ public class ValueTests {
@Test
public void increaseDecreaseCalc() {
PercentageValue v = new PercentageValue(new BigDecimal("1.0"), new BigDecimal("11.0"), new BigDecimal("0.5"),
null, null);
null, null, null);
// Normal operation.
PercentType command = (PercentType) v.parseCommand(new DecimalType("6.0"));
@ -382,7 +396,7 @@ public class ValueTests {
@Test
public void upDownCalc() {
PercentageValue v = new PercentageValue(new BigDecimal("1.0"), new BigDecimal("11.0"), new BigDecimal("0.5"),
null, null);
null, null, null);
// Normal operation.
PercentType command = (PercentType) v.parseCommand(new DecimalType("6.0"));
@ -411,7 +425,7 @@ public class ValueTests {
@Test
public void percentCalcInvalid() {
PercentageValue v = new PercentageValue(new BigDecimal(10.0), new BigDecimal(110.0), new BigDecimal(1.0), null,
null);
null, null);
assertThrows(IllegalArgumentException.class, () -> v.parseCommand(new DecimalType(9.0)));
}

View File

@ -50,6 +50,9 @@ public class Fan extends AbstractComponent<Fan.ChannelConfiguration> implements
public static final String OSCILLATION_CHANNEL_ID = "oscillation";
public static final String DIRECTION_CHANNEL_ID = "direction";
private static final BigDecimal BIG_DECIMAL_HUNDRED = new BigDecimal(100);
private static final String FORMAT_INTEGER = "%.0f";
/**
* Configuration class for MQTT component
*/
@ -60,6 +63,8 @@ public class Fan extends AbstractComponent<Fan.ChannelConfiguration> implements
protected @Nullable Boolean optimistic;
@SerializedName("state_value_template")
protected @Nullable String stateValueTemplate;
@SerializedName("state_topic")
protected @Nullable String stateTopic;
@SerializedName("command_template")
@ -136,7 +141,7 @@ public class Fan extends AbstractComponent<Fan.ChannelConfiguration> implements
: this;
onOffChannel = buildChannel(newStyleChannels ? SWITCH_CHANNEL_ID : SWITCH_CHANNEL_ID_DEPRECATED,
ComponentChannelType.SWITCH, onOffValue, "On/Off State", onOffListener)
.stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate())
.stateTopic(channelConfiguration.stateTopic, channelConfiguration.stateValueTemplate)
.commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
channelConfiguration.getQos(), channelConfiguration.commandTemplate)
.inferOptimistic(channelConfiguration.optimistic)
@ -144,10 +149,9 @@ public class Fan extends AbstractComponent<Fan.ChannelConfiguration> implements
rawSpeedState = UnDefType.NULL;
int speeds = Math.min(channelConfiguration.speedRangeMax, 100) - Math.max(channelConfiguration.speedRangeMin, 1)
+ 1;
speedValue = new PercentageValue(BigDecimal.ZERO, BigDecimal.valueOf(100), BigDecimal.valueOf(100.0d / speeds),
channelConfiguration.payloadOn, channelConfiguration.payloadOff);
speedValue = new PercentageValue(BigDecimal.valueOf(channelConfiguration.speedRangeMin - 1),
BigDecimal.valueOf(channelConfiguration.speedRangeMax), null, channelConfiguration.payloadOn,
channelConfiguration.payloadOff, FORMAT_INTEGER);
if (channelConfiguration.percentageCommandTopic != null) {
hiddenChannels.add(onOffChannel);

View File

@ -73,6 +73,8 @@ public abstract class Light extends AbstractComponent<Light.ChannelConfiguration
protected static final String ON_COMMAND_TYPE_BRIGHTNESS = "brightness";
protected static final String ON_COMMAND_TYPE_LAST = "last";
protected static final String FORMAT_INTEGER = "%.0f";
/**
* Configuration class for MQTT component
*/
@ -276,7 +278,7 @@ public abstract class Light extends AbstractComponent<Light.ChannelConfiguration
onOffValue = new OnOffValue(channelConfiguration.payloadOn, channelConfiguration.payloadOff);
brightnessValue = new PercentageValue(null, new BigDecimal(channelConfiguration.brightnessScale), null, null,
null);
null, FORMAT_INTEGER);
@Nullable
List<String> effectList = channelConfiguration.effectList;
if (effectList != null) {

View File

@ -84,7 +84,7 @@ public class TemplateSchemaLight extends AbstractRawSchemaLight {
}
onOffValue = new OnOffValue("on", "off");
brightnessValue = new PercentageValue(null, new BigDecimal(255), null, null, null);
brightnessValue = new PercentageValue(null, new BigDecimal(255), null, null, null, FORMAT_INTEGER);
if (channelConfiguration.redTemplate != null && channelConfiguration.greenTemplate != null
&& channelConfiguration.blueTemplate != null) {

View File

@ -173,7 +173,7 @@ public class Vacuum extends AbstractComponent<Vacuum.ChannelConfiguration> {
if (supportedFeatures.contains(FEATURE_BATTERY)) {
buildOptionalChannel(newStyleChannels ? BATTERY_LEVEL_CH_ID : BATTERY_LEVEL_CH_ID_DEPRECATED,
ComponentChannelType.DIMMER,
new PercentageValue(BigDecimal.ZERO, BigDecimal.valueOf(100), BigDecimal.ONE, null, null),
new PercentageValue(BigDecimal.ZERO, BigDecimal.valueOf(100), BigDecimal.ONE, null, null, null),
updateListener, null, null, "{{ value_json.battery_level }}", channelConfiguration.stateTopic);
}
}

View File

@ -61,6 +61,8 @@ public class Valve extends AbstractComponent<Valve.ChannelConfiguration> impleme
private static final String POSITION_KEY = "position";
private static final String STATE_KEY = "state";
private static final String FORMAT_INTEGER = "%.0f";
private final Logger logger = LoggerFactory.getLogger(Valve.class);
/**
@ -121,7 +123,7 @@ public class Valve extends AbstractComponent<Valve.ChannelConfiguration> impleme
onOffValue = new OnOffValue(channelConfiguration.stateOpen, channelConfiguration.stateClosed,
channelConfiguration.payloadOpen, channelConfiguration.payloadClose);
positionValue = new PercentageValue(BigDecimal.valueOf(channelConfiguration.positionClosed),
BigDecimal.valueOf(channelConfiguration.positionOpen), null, null, null);
BigDecimal.valueOf(channelConfiguration.positionOpen), null, null, null, FORMAT_INTEGER);
if (channelConfiguration.reportsPosition) {
buildChannel(VALVE_CHANNEL_ID, ComponentChannelType.DIMMER, positionValue, getName(), this)

View File

@ -16,6 +16,7 @@ import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import java.math.BigDecimal;
import java.math.MathContext;
import java.util.Objects;
import java.util.Set;
@ -90,6 +91,70 @@ public class FanTests extends AbstractComponentTests {
assertPublished("zigbee2mqtt/fan/set/state", "ON_");
}
@SuppressWarnings("null")
@Test
public void testPercentageWithTemplates() throws InterruptedException {
var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
"""
{
"availability": [
{
"topic": "zigbee2mqtt/bridge/state"
}
],
"device": {
"identifiers": [
"zigbee2mqtt_0x0000000000000000"
],
"manufacturer": "Fans inc",
"model": "Fan",
"name": "FanBlower",
"sw_version": "Zigbee2MQTT 1.18.2"
},
"name": "fan",
"state_topic": "zigbee2mqtt/fan",
"state_value_template": "{{ value_json.fan_state }}",
"command_topic": "zigbee2mqtt/fan/set/fan_state",
"percentage_command_template": "{{ {0:'off', 1:'low', 2:'medium', 3:'high'}[value] | default('') }}",
"percentage_command_topic": "zigbee2mqtt/fan/set/fan_mode",
"percentage_state_topic": "zigbee2mqtt/fan",
"percentage_value_template": "{{ {'off':0, 'low':1, 'medium':2, 'high':3}[value_json.fan_mode] | default('None') }}",
"speed_range_max": 3,
"speed_range_min": 1
}
""");
assertThat(component.channels.size(), is(1));
assertThat(component.getName(), is("fan"));
assertChannel(component, Fan.SPEED_CHANNEL_ID, "zigbee2mqtt/fan", "zigbee2mqtt/fan/set/fan_mode", "Speed",
PercentageValue.class, null);
publishMessage("zigbee2mqtt/fan", "{ \"fan_state\": \"OFF\", \"fan_mode\": \"high\"}");
assertState(component, Fan.SPEED_CHANNEL_ID, PercentType.ZERO);
publishMessage("zigbee2mqtt/fan", "{ \"fan_state\": \"ON\", \"fan_mode\": \"high\"}");
assertState(component, Fan.SPEED_CHANNEL_ID, PercentType.HUNDRED);
publishMessage("zigbee2mqtt/fan", "{ \"fan_state\": \"ON\", \"fan_mode\": \"medium\"}");
assertState(component, Fan.SPEED_CHANNEL_ID,
new PercentType(new BigDecimal(200).divide(new BigDecimal(3), MathContext.DECIMAL128)));
publishMessage("zigbee2mqtt/fan", "{ \"fan_state\": \"ON\", \"fan_mode\": \"low\"}");
assertState(component, Fan.SPEED_CHANNEL_ID,
new PercentType(new BigDecimal(100).divide(new BigDecimal(3), MathContext.DECIMAL128)));
component.getChannel(Fan.SPEED_CHANNEL_ID).getState().publishValue(OnOffType.OFF);
assertPublished("zigbee2mqtt/fan/set/fan_state", "OFF");
component.getChannel(Fan.SPEED_CHANNEL_ID).getState().publishValue(OnOffType.ON);
assertPublished("zigbee2mqtt/fan/set/fan_state", "ON");
component.getChannel(Fan.SPEED_CHANNEL_ID).getState().publishValue(PercentType.HUNDRED);
assertPublished("zigbee2mqtt/fan/set/fan_mode", "high");
component.getChannel(Fan.SPEED_CHANNEL_ID).getState().publishValue(PercentType.ZERO);
assertPublished("zigbee2mqtt/fan/set/fan_mode", "off");
component.getChannel(Fan.SPEED_CHANNEL_ID).getState().publishValue(new PercentType(33));
assertPublished("zigbee2mqtt/fan/set/fan_mode", "low");
component.getChannel(Fan.SPEED_CHANNEL_ID).getState().publishValue(new PercentType(66));
assertPublished("zigbee2mqtt/fan/set/fan_mode", "medium");
}
@SuppressWarnings("null")
@Test
public void testInferredOptimistic() throws InterruptedException {
@ -287,7 +352,7 @@ public class FanTests extends AbstractComponentTests {
assertChannel(component, Fan.SPEED_CHANNEL_ID, "bedroom_fan/speed/percentage_state",
"bedroom_fan/speed/percentage", "Speed", PercentageValue.class);
var channel = Objects.requireNonNull(component.getChannel(Fan.SPEED_CHANNEL_ID));
assertThat(channel.getStateDescription().getStep(), is(BigDecimal.valueOf(10.0d)));
assertThat(channel.getStateDescription().getStep(), is(BigDecimal.valueOf(10)));
assertChannel(component, Fan.OSCILLATION_CHANNEL_ID, "bedroom_fan/oscillation/state",
"bedroom_fan/oscillation/set", "Oscillation", OnOffValue.class);
assertChannel(component, Fan.DIRECTION_CHANNEL_ID, "bedroom_fan/direction/state", "bedroom_fan/direction/set",
@ -300,16 +365,16 @@ public class FanTests extends AbstractComponentTests {
publishMessage("bedroom_fan/on/state", "false");
assertState(component, Fan.SPEED_CHANNEL_ID, PercentType.ZERO);
publishMessage("bedroom_fan/on/state", "true");
publishMessage("bedroom_fan/speed/percentage_state", "50");
publishMessage("bedroom_fan/speed/percentage_state", "5");
assertState(component, Fan.SPEED_CHANNEL_ID, new PercentType(50));
publishMessage("bedroom_fan/on/state", "false");
// Off, even though we got an updated speed
assertState(component, Fan.SPEED_CHANNEL_ID, PercentType.ZERO);
publishMessage("bedroom_fan/speed/percentage_state", "25");
publishMessage("bedroom_fan/speed/percentage_state", "2");
assertState(component, Fan.SPEED_CHANNEL_ID, PercentType.ZERO);
publishMessage("bedroom_fan/on/state", "true");
// Now that it's on, the channel reflects the proper speed
assertState(component, Fan.SPEED_CHANNEL_ID, new PercentType(25));
assertState(component, Fan.SPEED_CHANNEL_ID, new PercentType(20));
publishMessage("bedroom_fan/oscillation/state", "true");
assertState(component, Fan.OSCILLATION_CHANNEL_ID, OnOffType.ON);
@ -333,7 +398,7 @@ public class FanTests extends AbstractComponentTests {
// Setting to a specific speed turns it on first
component.getChannel(Fan.SPEED_CHANNEL_ID).getState().publishValue(PercentType.HUNDRED);
assertPublished("bedroom_fan/on/set", "true");
assertPublished("bedroom_fan/speed/percentage", "100");
assertPublished("bedroom_fan/speed/percentage", "10");
component.getChannel(Fan.OSCILLATION_CHANNEL_ID).getState().publishValue(OnOffType.ON);
assertPublished("bedroom_fan/oscillation/set", "true");

View File

@ -191,7 +191,7 @@ public class Property implements AttributeChanged {
step = new BigDecimal(1);
}
if (attributes.unit.contains("%") && attributes.settable) {
value = new PercentageValue(min, max, step, null, null);
value = new PercentageValue(min, max, step, null, null, null);
} else {
value = new NumberValue(min, max, step, unit);
}