2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.transform.basicprofiles.internal.profiles;
15 import static java.util.function.Predicate.not;
16 import static org.openhab.transform.basicprofiles.internal.factory.BasicProfilesFactory.STATE_FILTER_UID;
18 import java.util.ArrayList;
19 import java.util.Comparator;
20 import java.util.List;
21 import java.util.Locale;
22 import java.util.Objects;
23 import java.util.Optional;
24 import java.util.regex.Matcher;
25 import java.util.regex.Pattern;
26 import java.util.stream.Collectors;
27 import java.util.stream.Stream;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.core.items.Item;
32 import org.openhab.core.items.ItemNotFoundException;
33 import org.openhab.core.items.ItemRegistry;
34 import org.openhab.core.library.types.QuantityType;
35 import org.openhab.core.library.types.StringType;
36 import org.openhab.core.thing.profiles.ProfileCallback;
37 import org.openhab.core.thing.profiles.ProfileContext;
38 import org.openhab.core.thing.profiles.ProfileTypeUID;
39 import org.openhab.core.thing.profiles.StateProfile;
40 import org.openhab.core.types.Command;
41 import org.openhab.core.types.State;
42 import org.openhab.core.types.TypeParser;
43 import org.openhab.core.types.UnDefType;
44 import org.openhab.transform.basicprofiles.internal.config.StateFilterProfileConfig;
45 import org.slf4j.Logger;
46 import org.slf4j.LoggerFactory;
49 * Accepts updates to state as long as conditions are met. Support for sending fixed state if conditions are *not*
52 * @author Arne Seime - Initial contribution
53 * @author Jimmy Tanagra - Expanded the comparison types
56 public class StateFilterProfile implements StateProfile {
58 private final static String OPERATOR_NAME_PATTERN = Stream.of(StateCondition.ComparisonType.values())
59 .map(StateCondition.ComparisonType::name)
60 // We want to match the longest operator first, e.g. `GTE` before `GT`
61 .sorted(Comparator.comparingInt(String::length).reversed())
62 // Require a leading space only when it is preceded by a non-space character, e.g. `Item1 GTE 0`
63 // so we can have conditions against input data without needing a leading space, e.g. `GTE 0`
64 .collect(Collectors.joining("|", "(?:(?<=\\S)\\s+|^\\s*)(?:", ")\\s"));
66 private final static String OPERATOR_SYMBOL_PATTERN = Stream.of(StateCondition.ComparisonType.values())
67 .map(StateCondition.ComparisonType::symbol)
68 // We want to match the longest operator first, e.g. `<=` before `<`
69 .sorted(Comparator.comparingInt(String::length).reversed()) //
70 .collect(Collectors.joining("|", "(?:", ")"));
72 private final static Pattern EXPRESSION_PATTERN = Pattern.compile(
73 // - Without the non-greedy operator in the first capture group,
74 // it will match `Item<` when encountering `Item<>X` condition
75 // - Symbols may be more prevalently used, so check them first
76 "(.*?)(" + OPERATOR_SYMBOL_PATTERN + "|" + OPERATOR_NAME_PATTERN + ")(.*)", Pattern.CASE_INSENSITIVE);
78 private final Logger logger = LoggerFactory.getLogger(StateFilterProfile.class);
80 private final ProfileCallback callback;
82 private final ItemRegistry itemRegistry;
84 private final List<StateCondition> conditions;
86 private final @Nullable State configMismatchState;
88 public StateFilterProfile(ProfileCallback callback, ProfileContext context, ItemRegistry itemRegistry) {
89 this.callback = callback;
90 this.itemRegistry = itemRegistry;
92 StateFilterProfileConfig config = context.getConfiguration().as(StateFilterProfileConfig.class);
94 conditions = parseConditions(config.conditions, config.separator);
95 if (conditions.isEmpty()) {
96 logger.warn("No valid conditions defined for StateFilterProfile. Link: {}. Conditions: {}",
97 callback.getItemChannelLink(), config.conditions);
99 configMismatchState = parseState(config.mismatchState, context.getAcceptedDataTypes());
101 conditions = List.of();
102 configMismatchState = null;
106 private List<StateCondition> parseConditions(List<String> conditions, String separator) {
107 List<StateCondition> parsedConditions = new ArrayList<>();
109 conditions.stream() //
110 .flatMap(c -> Stream.of(c.split(separator))) //
111 .map(String::trim) //
112 .filter(not(String::isBlank)) //
113 .forEach(expression -> {
114 Matcher matcher = EXPRESSION_PATTERN.matcher(expression);
115 if (!matcher.matches()) {
117 "Malformed condition expression: '{}' in link '{}'. Expected format ITEM_NAME OPERATOR ITEM_OR_STATE, where OPERATOR is one of: {}",
118 expression, callback.getItemChannelLink(),
119 StateCondition.ComparisonType.namesAndSymbols());
123 String itemName = matcher.group(1).trim();
124 String operator = matcher.group(2).trim();
125 String value = matcher.group(3).trim();
127 StateCondition.ComparisonType comparisonType = StateCondition.ComparisonType
128 .fromSymbol(operator).orElseGet(
129 () -> StateCondition.ComparisonType.valueOf(operator.toUpperCase(Locale.ROOT)));
130 parsedConditions.add(new StateCondition(itemName, comparisonType, value));
131 } catch (IllegalArgumentException e) {
132 logger.warn("Invalid comparison operator: '{}' in link '{}'. Expected one of: {}", operator,
133 callback.getItemChannelLink(), StateCondition.ComparisonType.namesAndSymbols());
137 return parsedConditions;
141 public ProfileTypeUID getProfileTypeUID() {
142 return STATE_FILTER_UID;
146 public void onStateUpdateFromItem(State state) {
151 public void onCommandFromItem(Command command) {
152 callback.handleCommand(command);
156 public void onCommandFromHandler(Command command) {
157 callback.sendCommand(command);
161 public void onStateUpdateFromHandler(State state) {
162 State resultState = checkCondition(state);
163 if (resultState != null) {
164 logger.debug("Received state update from handler: {}, forwarded as {}", state, resultState);
165 callback.sendUpdate(resultState);
167 logger.debug("Received state update from handler: {}, not forwarded to item", state);
172 private State checkCondition(State state) {
173 if (conditions.isEmpty()) {
175 "No valid configuration defined for StateFilterProfile (check for log messages when instantiating profile) - skipping state update. Link: '{}'",
176 callback.getItemChannelLink());
180 String linkedItemName = callback.getItemChannelLink().getItemName();
182 if (conditions.stream().allMatch(c -> c.check(linkedItemName, state))) {
185 return configMismatchState;
190 static State parseState(@Nullable String stateString, List<Class<? extends State>> acceptedDataTypes) {
191 // Quoted strings are parsed as StringType
192 if (stateString == null) {
194 } else if (stateString.startsWith("'") && stateString.endsWith("'")) {
195 return new StringType(stateString.substring(1, stateString.length() - 1));
197 return TypeParser.parseState(acceptedDataTypes, stateString);
201 class StateCondition {
202 private String itemName;
203 private ComparisonType comparisonType;
204 private String value;
205 private @Nullable State parsedValue;
207 public StateCondition(String itemName, ComparisonType comparisonType, String value) {
208 this.itemName = itemName;
209 this.comparisonType = comparisonType;
211 // Convert quoted strings to StringType, and UnDefTypes to UnDefType
212 // UnDefType gets special treatment because we don't want `UNDEF` to be parsed as a string
213 // Anything else, defer parsing until we're checking the condition
214 // so we can try based on the item's accepted data types
215 this.parsedValue = parseState(value, List.of(UnDefType.class));
219 * Check if the condition is met.
221 * If the itemName is not empty, the condition is checked against the item's state.
222 * Otherwise, the condition is checked against the input state.
224 * @param input the state to check against
225 * @return true if the condition is met, false otherwise
227 public boolean check(String linkedItemName, State input) {
232 if (logger.isDebugEnabled()) {
233 logger.debug("Evaluating {} with input: {} ({}). Link: '{}'", this, input,
234 input.getClass().getSimpleName(), callback.getItemChannelLink());
236 if (itemName.isEmpty()) {
237 item = itemRegistry.getItem(linkedItemName);
240 item = itemRegistry.getItem(itemName);
241 state = item.getState();
244 // Using Object because we could be comparing State or String objects
248 // Java Enums (e.g. OnOffType) are Comparable, but we want to treat them as not Comparable
249 if (state instanceof Comparable && !(state instanceof Enum)) {
252 // Only allow EQ and NEQ for non-comparable states
253 if (!(comparisonType == ComparisonType.EQ || comparisonType == ComparisonType.NEQ
254 || comparisonType == ComparisonType.NEQ_ALT)) {
255 logger.debug("Condition state: '{}' ({}) only supports '==' and '!==' comparisons", state,
256 state.getClass().getSimpleName());
259 lhs = state instanceof Enum ? state : state.toString();
262 if (parsedValue == null) {
263 // don't parse bare strings as StringType, because they are identifiers,
264 // e.g. referring to other items
265 List<Class<? extends State>> acceptedValueTypes = item.getAcceptedDataTypes().stream()
266 .filter(not(StringType.class::isAssignableFrom)).toList();
267 parsedValue = TypeParser.parseState(acceptedValueTypes, value);
268 // Don't convert QuantityType to other types, so that 1500 != 1500 W
269 if (parsedValue != null && !(parsedValue instanceof QuantityType)) {
270 // Try to convert it to the same type as the state
271 // This allows comparing compatible types, e.g. PercentType vs OnOffType
272 parsedValue = parsedValue.as(state.getClass());
275 // If the values can't be converted to a type, check to see if it's an Item name
276 if (parsedValue == null) {
278 Item valueItem = itemRegistry.getItem(value);
279 if (valueItem != null) { // ItemRegistry.getItem can return null in tests
280 parsedValue = valueItem.getState();
281 // Don't convert QuantityType to other types
282 if (!(parsedValue instanceof QuantityType)) {
283 parsedValue = parsedValue.as(state.getClass());
285 logger.debug("Condition value: '{}' is an item state: '{}' ({})", value, parsedValue,
286 parsedValue == null ? "null" : parsedValue.getClass().getSimpleName());
288 } catch (ItemNotFoundException ignore) {
292 if (parsedValue == null) {
293 if (comparisonType == ComparisonType.NEQ || comparisonType == ComparisonType.NEQ_ALT) {
294 // They're not even type compatible, so return true for NEQ comparison
297 logger.debug("Condition value: '{}' is not compatible with state '{}' ({})", value, state,
298 state.getClass().getSimpleName());
304 rhs = Objects.requireNonNull(parsedValue instanceof StringType ? parsedValue.toString() : parsedValue);
306 if (logger.isDebugEnabled()) {
307 if (itemName.isEmpty()) {
308 logger.debug("Performing a comparison between input '{}' ({}) and value '{}' ({})", lhs,
309 lhs.getClass().getSimpleName(), rhs, rhs.getClass().getSimpleName());
311 logger.debug("Performing a comparison between item '{}' state '{}' ({}) and value '{}' ({})",
312 itemName, lhs, lhs.getClass().getSimpleName(), rhs, rhs.getClass().getSimpleName());
316 return switch (comparisonType) {
317 case EQ -> lhs.equals(rhs);
318 case NEQ, NEQ_ALT -> !lhs.equals(rhs);
319 case GT -> ((Comparable) lhs).compareTo(rhs) > 0;
320 case GTE -> ((Comparable) lhs).compareTo(rhs) >= 0;
321 case LT -> ((Comparable) lhs).compareTo(rhs) < 0;
322 case LTE -> ((Comparable) lhs).compareTo(rhs) <= 0;
324 } catch (ItemNotFoundException | IllegalArgumentException | ClassCastException e) {
325 logger.warn("Error evaluating condition: {} in link '{}': {}", this, callback.getItemChannelLink(),
331 enum ComparisonType {
340 private final String symbol;
342 ComparisonType(String symbol) {
343 this.symbol = symbol;
350 static Optional<ComparisonType> fromSymbol(String symbol) {
351 for (ComparisonType type : values()) {
352 if (type.symbol.equals(symbol)) {
353 return Optional.of(type);
356 return Optional.empty();
359 static List<String> namesAndSymbols() {
360 return Stream.of(values()).flatMap(entry -> Stream.of(entry.name(), entry.symbol())).toList();
365 public String toString() {
369 state = itemRegistry.getItem(itemName).getState();
370 } catch (ItemNotFoundException ignored) {
373 String stateClass = state == null ? "null" : state.getClass().getSimpleName();
374 return "Condition(itemName='" + itemName + "', state='" + state + "' (" + stateClass + "), comparisonType="
375 + comparisonType + ", value='" + value + "')";