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

Add a Range Filter profile #3534

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,15 @@ public class SystemProfileFactory implements ProfileFactory, ProfileAdvisor, Pro
private final ChannelTypeRegistry channelTypeRegistry;

private static final Set<ProfileType> SUPPORTED_PROFILE_TYPES = Set.of(DEFAULT_TYPE, FOLLOW_TYPE, HYSTERESIS_TYPE,
OFFSET_TYPE, RANGE_TYPE, RAWBUTTON_ON_OFF_SWITCH_TYPE, RAWBUTTON_TOGGLE_PLAYER_TYPE,
OFFSET_TYPE, RANGE_TYPE, RANGE_FILTER_TYPE, RAWBUTTON_ON_OFF_SWITCH_TYPE, RAWBUTTON_TOGGLE_PLAYER_TYPE,
RAWBUTTON_TOGGLE_ROLLERSHUTTER_TYPE, RAWBUTTON_TOGGLE_SWITCH_TYPE, RAWROCKER_DIMMER_TYPE,
RAWROCKER_NEXT_PREVIOUS_TYPE, RAWROCKER_ON_OFF_TYPE, RAWROCKER_PLAY_PAUSE_TYPE,
RAWROCKER_REWIND_FASTFORWARD_TYPE, RAWROCKER_STOP_MOVE_TYPE, RAWROCKER_UP_DOWN_TYPE,
TRIGGER_EVENT_STRING_TYPE, TIMESTAMP_CHANGE_TYPE, TIMESTAMP_OFFSET_TYPE, TIMESTAMP_TRIGGER_TYPE,
TIMESTAMP_UPDATE_TYPE);

private static final Set<ProfileTypeUID> SUPPORTED_PROFILE_TYPE_UIDS = Set.of(DEFAULT, FOLLOW, HYSTERESIS, OFFSET,
RANGE, RAWBUTTON_ON_OFF_SWITCH, RAWBUTTON_TOGGLE_PLAYER, RAWBUTTON_TOGGLE_ROLLERSHUTTER,
RANGE, RANGE_FILTER, RAWBUTTON_ON_OFF_SWITCH, RAWBUTTON_TOGGLE_PLAYER, RAWBUTTON_TOGGLE_ROLLERSHUTTER,
RAWBUTTON_TOGGLE_SWITCH, RAWROCKER_DIMMER, RAWROCKER_NEXT_PREVIOUS, RAWROCKER_ON_OFF, RAWROCKER_PLAY_PAUSE,
RAWROCKER_REWIND_FASTFORWARD, RAWROCKER_STOP_MOVE, RAWROCKER_UP_DOWN, TRIGGER_EVENT_STRING,
TIMESTAMP_CHANGE, TIMESTAMP_OFFSET, TIMESTAMP_TRIGGER, TIMESTAMP_UPDATE);
Expand Down Expand Up @@ -107,6 +107,8 @@ public SystemProfileFactory(final @Reference ChannelTypeRegistry channelTypeRegi
return new SystemOffsetProfile(callback, context);
} else if (RANGE.equals(profileTypeUID)) {
return new SystemRangeStateProfile(callback, context);
} else if (RANGE_FILTER.equals(profileTypeUID)) {
return new SystemRangeFilterProfile(callback, context);
} else if (BUTTON_TOGGLE_SWITCH.equals(profileTypeUID)) {
return new ToggleProfile<OnOffType>(callback, context, BUTTON_TOGGLE_SWITCH,
DefaultSystemChannelTypeProvider.SYSTEM_BUTTON, OnOffType.ON, OnOffType.OFF,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
/**
* Copyright (c) 2010-2023 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.core.thing.internal.profiles;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.measure.Unit;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.config.core.ConfigParser;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.profiles.ProfileCallback;
import org.openhab.core.thing.profiles.ProfileContext;
import org.openhab.core.thing.profiles.ProfileTypeUID;
import org.openhab.core.thing.profiles.StateProfile;
import org.openhab.core.thing.profiles.SystemProfiles;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.Type;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/***
* This is the default implementation for a {@link SystemRangeFilterProfile}.
*
* @author Jimmy Tanagra - Initial contribution
*/
@NonNullByDefault
public class SystemRangeFilterProfile implements StateProfile {

static final String RANGE_PARAM = "range";
static final String RANGE_ACTION_PARAM = "action";
static final String RANGE_ACTION_ALLOW = "allow";
static final String RANGE_ACTION_DISCARD = "discard";

private static final Pattern RANGE_PATTERN = Pattern.compile(
"^(?<beginType>\\[|\\()\\s*(?<begin>[^\\]\\)]*?)\\s*\\.\\.\\s*(?<end>[^\\]\\)]*?)\\s*(?<endType>\\]|\\))$");

private final Logger logger = LoggerFactory.getLogger(SystemRangeFilterProfile.class);

private final ProfileCallback callback;

private final List<Range> ranges;
private final boolean inverted;

public SystemRangeFilterProfile(ProfileCallback callback, ProfileContext context) {
this.callback = callback;

String rangeAction = ConfigParser.valueAsOrElse(context.getConfiguration().get(RANGE_ACTION_PARAM),
String.class, RANGE_ACTION_ALLOW);

this.inverted = switch (rangeAction) {
case RANGE_ACTION_ALLOW -> false;
case RANGE_ACTION_DISCARD -> true;
default -> throw new IllegalArgumentException(
String.format("Invalid %s option: '%s'. Valid options are: '%s' or '%s'", linkUID(),
RANGE_ACTION_PARAM, rangeAction, RANGE_ACTION_ALLOW, RANGE_ACTION_DISCARD));
};

Object rangeConfig = context.getConfiguration().get(RANGE_PARAM);
if (!(rangeConfig instanceof String rangeStr)) {
throw new IllegalArgumentException(
String.format("%s: Invalid range parameter: '%s'", linkUID(), rangeConfig));
}

List<Range> ranges = new ArrayList<>();
Arrays.stream(rangeStr.split(",")).map(String::trim).filter(s -> !s.isBlank()).forEach(rangeElement -> {
Matcher rangeMatcher = RANGE_PATTERN.matcher(rangeElement);
if (rangeMatcher.matches()) {
Optional<Range> range = createRange(rangeElement, rangeMatcher.group("beginType"),
rangeMatcher.group("begin"), rangeMatcher.group("end"), rangeMatcher.group("endType"));
range.ifPresent(r -> ranges.add(r));
} else {
throw new IllegalArgumentException(
String.format("%s: Invalid range syntax '%s'.", linkUID(), rangeElement));
}
});

if (ranges.isEmpty()) {
throw new IllegalArgumentException(
linkUID() + ": No valid range specifications found. Everything will be discarded.");
}
this.ranges = Collections.unmodifiableList(ranges);
}

private Optional<Range> createRange(String range, String beginType, String rangeBegin, String rangeEnd,
String endType) {
Optional<Comparator> beginComparator = Optional.empty();
Optional<Comparator> endComparator = Optional.empty();
Unit unit = Units.ONE;
Optional<BigDecimal> beginValueCheck = Optional.empty();

if (!rangeBegin.isBlank()) {
try {
QuantityType<?> value = QuantityType.valueOf(rangeBegin);
final BigDecimal beginValue = value.toBigDecimal();
beginComparator = Optional.of(switch (beginType) {
case "[" -> (input) -> input.compareTo(beginValue) >= 0;
case "(" -> (input) -> input.compareTo(beginValue) > 0;
default -> throw new IllegalStateException(String.format(
"Invalid begin type '%s' of the filter range '%s'. This is a bug.", beginType, range));
});
unit = value.getUnit();
beginValueCheck = Optional.of(beginValue);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
String.format("%s: Invalid filter range begin: '%s'. %s", linkUID(), range, e.getMessage()));
}
}

if (!rangeEnd.isBlank()) {
try {
@Nullable
QuantityType<?> value = QuantityType.valueOf(rangeEnd);
if (beginComparator.isPresent()) {
value = value.toInvertibleUnit(unit);
}
if (value != null) {
final BigDecimal endValue = value.toBigDecimal();
endComparator = Optional.of(switch (endType) {
case "]" -> (input) -> input.compareTo(endValue) <= 0;
case ")" -> (input) -> input.compareTo(endValue) < 0;
default -> throw new IllegalStateException(String.format(
"Invalid end type '%s' of the filter range '%s'. This is a bug.", endType, range));
});
if (beginComparator.map(begin -> !begin.check(endValue)).orElse(false)) {
throw new IllegalArgumentException("The end value is smaller than the begin limit.");
}
if (beginValueCheck.isPresent() && !endComparator.get().check(beginValueCheck.get())) {
throw new IllegalArgumentException("The begin value is bigger than the end limit.");
}
} else {
throw new IllegalArgumentException("Begin and end have incompatible units.");
}
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
String.format("%s: Invalid filter range end: '%s'. %s", linkUID(), range, e.getMessage()));
}
}

if (beginComparator.isEmpty() && endComparator.isEmpty()) {
throw new IllegalArgumentException(String.format("%s: The range '%s' is empty.", linkUID(), range));
}

return Optional.of(new Range(range, beginComparator, endComparator, unit));
}

@Override
public ProfileTypeUID getProfileTypeUID() {
return SystemProfiles.RANGE;
}

@Override
public void onCommandFromItem(Command command) {
callback.handleCommand(command);
}

@Override
public void onStateUpdateFromItem(State state) {
// do nothing
}

private @Nullable QuantityType<?> toQuantity(final Type value) {
if (value instanceof QuantityType quantity) {
return quantity;
} else if (value instanceof DecimalType decimal) {
return new QuantityType(decimal, Units.ONE);
}
return null;
}

@Override
public void onCommandFromHandler(Command command) {
if (isAllowed(command)) {
callback.sendCommand(command);
} else {
logger.debug("{}: Command '{}' discarded by filter profile.", linkUID(), command);
}
}

@Override
public void onStateUpdateFromHandler(State state) {
if (isAllowed(state)) {
callback.sendUpdate(state);
} else {
logger.debug("{}: State update '{}' discarded by filter profile.", linkUID(), state);
}
}

private boolean isAllowed(Type value) {
@Nullable
QuantityType<?> quantityValue = toQuantity(value);
if (quantityValue == null) {
return false;
}

boolean withinRange = isWithinAnyRange(quantityValue);
return inverted ? !withinRange : withinRange;
}

private boolean isWithinAnyRange(QuantityType<?> value) {
return ranges.stream().anyMatch(range -> {
@Nullable
QuantityType<?> checkValue = value;
if (!range.unit().equals(Units.ONE)) {
checkValue = value.toInvertibleUnit(range.unit());
if (checkValue == null) {
logger.warn("{}: Incompatible units between the incoming value '{}' and the range '{}'.", linkUID(),
value, range.range());
return false;
}
}
return range.covers(checkValue.toBigDecimal());
});
}

private String linkUID() {
return callback.getItemChannelLink().getUID();
}

private interface Comparator {
boolean check(BigDecimal value);
}

private record Range(String range, Optional<Comparator> begin, Optional<Comparator> end, Unit unit) {
boolean covers(BigDecimal value) {
return begin.map(c -> c.check(value)).orElse(true) && end.map(c -> c.check(value)).orElse(true);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public interface SystemProfiles {
ProfileTypeUID OFFSET = new ProfileTypeUID(SYSTEM_SCOPE, "offset");
ProfileTypeUID HYSTERESIS = new ProfileTypeUID(SYSTEM_SCOPE, "hysteresis");
ProfileTypeUID RANGE = new ProfileTypeUID(SYSTEM_SCOPE, "range");
ProfileTypeUID RANGE_FILTER = new ProfileTypeUID(SYSTEM_SCOPE, "range-filter");
ProfileTypeUID BUTTON_TOGGLE_SWITCH = new ProfileTypeUID(SYSTEM_SCOPE, "button-toggle-switch");
ProfileTypeUID BUTTON_TOGGLE_PLAYER = new ProfileTypeUID(SYSTEM_SCOPE, "button-toggle-player");
ProfileTypeUID BUTTON_TOGGLE_ROLLERSHUTTER = new ProfileTypeUID(SYSTEM_SCOPE, "button-toggle-rollershutter");
Expand Down Expand Up @@ -64,6 +65,11 @@ public interface SystemProfiles {
.withSupportedItemTypes(CoreItemFactory.SWITCH) //
.build();

ProfileType RANGE_FILTER_TYPE = ProfileTypeBuilder.newState(RANGE_FILTER, "Range Filter") //
.withSupportedItemTypesOfChannel(CoreItemFactory.DIMMER, CoreItemFactory.NUMBER) //
.withSupportedItemTypes(CoreItemFactory.NUMBER) //
.build();

ProfileType RANGE_TYPE = ProfileTypeBuilder.newState(RANGE, "Range") //
.withSupportedItemTypesOfChannel(CoreItemFactory.DIMMER, CoreItemFactory.NUMBER) //
.withSupportedItemTypes(CoreItemFactory.SWITCH) //
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<config-description:config-descriptions
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">

<config-description uri="profile:system:range-filter">
<parameter name="range" type="text" required="true">
<label>Range</label>
<description><![CDATA[
The range to allow (or reject when inverted) using interval notation.
<ul>
<li><tt>[1..2]</tt> means to allow <tt>1 <= value <= 2</tt>.</li>
<li><tt>[1..2)</tt> means to allow <tt>1 <= value < 2</tt>.</li>
</ul>
Unit of Measurement can be specified against a dimensioned channel, e.g. <tt>( -18 °C .. -2 °C )</tt>.

Begin-less and end-less ranges can be defined by omitting the corresponding value.
For example: <tt>[..10]</tt> for <= 10 and <tt>[10..]<tt> for >= 10.

Multiple ranges can be specified, e.g. <tt>[1..2], [5..8], [9..]</tt>
]]></description>
</parameter>
<parameter name="action" type="text">
<label>Range Action</label>
<description>Whether to allow or discard values within the given range.</description>
<options>
<option value="allow">Only allow values within the range</option>
<option value="discard">Only allow values outside the range</option>
</options>
<default>allow</default>
</parameter>
</config-description>
</config-description:config-descriptions>
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ profile.config.system.hysteresis.upper.label = Upper Bound
profile.config.system.hysteresis.upper.description = Maps to ON if value is above upper bound (plain number or number with unit).
profile.config.system.hysteresis.inverted.label = Inverted
profile.config.system.hysteresis.inverted.description = Inverts resulting mapping of ON / OFF, if true.
profile-type.system.filter.label = Filter
profile.config.system.filter.range.label = Range
profile.config.system.filter.range.description = The range to allow (or reject when inverted) using interval notation. <ul> <li><tt>[1..2]</tt> means to allow <tt>1 <= value <= 2</tt>.</li> <li><tt>[1..2)</tt> means to allow <tt>1 <= value < 2</tt>.</li> </ul> Unit of Measurement can be specified against a dimensioned channel, e.g. <tt>( -18 °C .. -2 °C )</tt>. Begin-less and end-less ranges can be defined by omitting the corresponding value. For example: <tt>[..10]</tt> for <= 10 and <tt>[10..]<tt> for >= 10. Multiple ranges can be specified, e.g. <tt>[1..2], [5..8], [9..]</tt>
profile.config.system.filter.rangeAction.label = Range Action
profile.config.system.filter.rangeAction.description = Whether to allow or discard values within the given range.
profile.config.system.filter.rangeAction.option.allow = Only allow values within the range
profile.config.system.filter.rangeAction.option.discard = Only allow values outside the range
profile-type.system.range.label = Range
profile.config.system.range.lower.label = Lower Bound
profile.config.system.range.lower.description = Maps to ON if value is between lower and upper bound (plain number or number with unit).
Expand Down