]> git.basschouten.com Git - openhab-addons.git/blob
e48236a070453614ba4822e9bbee3cf102536fda
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.transform.basicprofiles.internal.profiles;
14
15 import static java.util.function.Predicate.not;
16 import static org.openhab.transform.basicprofiles.internal.factory.BasicProfilesFactory.STATE_FILTER_UID;
17
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;
28
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;
47
48 /**
49  * Accepts updates to state as long as conditions are met. Support for sending fixed state if conditions are *not*
50  * met.
51  *
52  * @author Arne Seime - Initial contribution
53  * @author Jimmy Tanagra - Expanded the comparison types
54  */
55 @NonNullByDefault
56 public class StateFilterProfile implements StateProfile {
57
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"));
65
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("|", "(?:", ")"));
71
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);
77
78     private final Logger logger = LoggerFactory.getLogger(StateFilterProfile.class);
79
80     private final ProfileCallback callback;
81
82     private final ItemRegistry itemRegistry;
83
84     private final List<StateCondition> conditions;
85
86     private final @Nullable State configMismatchState;
87
88     public StateFilterProfile(ProfileCallback callback, ProfileContext context, ItemRegistry itemRegistry) {
89         this.callback = callback;
90         this.itemRegistry = itemRegistry;
91
92         StateFilterProfileConfig config = context.getConfiguration().as(StateFilterProfileConfig.class);
93         if (config != null) {
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);
98             }
99             configMismatchState = parseState(config.mismatchState, context.getAcceptedDataTypes());
100         } else {
101             conditions = List.of();
102             configMismatchState = null;
103         }
104     }
105
106     private List<StateCondition> parseConditions(List<String> conditions, String separator) {
107         List<StateCondition> parsedConditions = new ArrayList<>();
108
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()) {
116                         logger.warn(
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());
120                         return;
121                     }
122
123                     String itemName = matcher.group(1).trim();
124                     String operator = matcher.group(2).trim();
125                     String value = matcher.group(3).trim();
126                     try {
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());
134                     }
135                 });
136
137         return parsedConditions;
138     }
139
140     @Override
141     public ProfileTypeUID getProfileTypeUID() {
142         return STATE_FILTER_UID;
143     }
144
145     @Override
146     public void onStateUpdateFromItem(State state) {
147         // do nothing
148     }
149
150     @Override
151     public void onCommandFromItem(Command command) {
152         callback.handleCommand(command);
153     }
154
155     @Override
156     public void onCommandFromHandler(Command command) {
157         callback.sendCommand(command);
158     }
159
160     @Override
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);
166         } else {
167             logger.debug("Received state update from handler: {}, not forwarded to item", state);
168         }
169     }
170
171     @Nullable
172     private State checkCondition(State state) {
173         if (conditions.isEmpty()) {
174             logger.warn(
175                     "No valid configuration defined for StateFilterProfile (check for log messages when instantiating profile) - skipping state update. Link: '{}'",
176                     callback.getItemChannelLink());
177             return null;
178         }
179
180         String linkedItemName = callback.getItemChannelLink().getItemName();
181
182         if (conditions.stream().allMatch(c -> c.check(linkedItemName, state))) {
183             return state;
184         } else {
185             return configMismatchState;
186         }
187     }
188
189     @Nullable
190     static State parseState(@Nullable String stateString, List<Class<? extends State>> acceptedDataTypes) {
191         // Quoted strings are parsed as StringType
192         if (stateString == null) {
193             return null;
194         } else if (stateString.startsWith("'") && stateString.endsWith("'")) {
195             return new StringType(stateString.substring(1, stateString.length() - 1));
196         } else {
197             return TypeParser.parseState(acceptedDataTypes, stateString);
198         }
199     }
200
201     class StateCondition {
202         private String itemName;
203         private ComparisonType comparisonType;
204         private String value;
205         private @Nullable State parsedValue;
206
207         public StateCondition(String itemName, ComparisonType comparisonType, String value) {
208             this.itemName = itemName;
209             this.comparisonType = comparisonType;
210             this.value = value;
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));
216         }
217
218         /**
219          * Check if the condition is met.
220          * 
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.
223          *
224          * @param input the state to check against
225          * @return true if the condition is met, false otherwise
226          */
227         public boolean check(String linkedItemName, State input) {
228             try {
229                 State state;
230                 Item item = null;
231
232                 if (logger.isDebugEnabled()) {
233                     logger.debug("Evaluating {} with input: {} ({}). Link: '{}'", this, input,
234                             input.getClass().getSimpleName(), callback.getItemChannelLink());
235                 }
236                 if (itemName.isEmpty()) {
237                     item = itemRegistry.getItem(linkedItemName);
238                     state = input;
239                 } else {
240                     item = itemRegistry.getItem(itemName);
241                     state = item.getState();
242                 }
243
244                 // Using Object because we could be comparing State or String objects
245                 Object lhs;
246                 Object rhs;
247
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)) {
250                     lhs = state;
251                 } else {
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());
257                         return false;
258                     }
259                     lhs = state instanceof Enum ? state : state.toString();
260                 }
261
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());
273                     }
274
275                     // If the values can't be converted to a type, check to see if it's an Item name
276                     if (parsedValue == null) {
277                         try {
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());
284                                 }
285                                 logger.debug("Condition value: '{}' is an item state: '{}' ({})", value, parsedValue,
286                                         parsedValue == null ? "null" : parsedValue.getClass().getSimpleName());
287                             }
288                         } catch (ItemNotFoundException ignore) {
289                         }
290                     }
291
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
295                             return true;
296                         } else {
297                             logger.debug("Condition value: '{}' is not compatible with state '{}' ({})", value, state,
298                                     state.getClass().getSimpleName());
299                             return false;
300                         }
301                     }
302                 }
303
304                 rhs = Objects.requireNonNull(parsedValue instanceof StringType ? parsedValue.toString() : parsedValue);
305
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());
310                     } else {
311                         logger.debug("Performing a comparison between item '{}' state '{}' ({}) and value '{}' ({})",
312                                 itemName, lhs, lhs.getClass().getSimpleName(), rhs, rhs.getClass().getSimpleName());
313                     }
314                 }
315
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;
323                 };
324             } catch (ItemNotFoundException | IllegalArgumentException | ClassCastException e) {
325                 logger.warn("Error evaluating condition: {} in link '{}': {}", this, callback.getItemChannelLink(),
326                         e.getMessage());
327             }
328             return false;
329         }
330
331         enum ComparisonType {
332             EQ("=="),
333             NEQ("!="),
334             NEQ_ALT("<>"),
335             GT(">"),
336             GTE(">="),
337             LT("<"),
338             LTE("<=");
339
340             private final String symbol;
341
342             ComparisonType(String symbol) {
343                 this.symbol = symbol;
344             }
345
346             String symbol() {
347                 return symbol;
348             }
349
350             static Optional<ComparisonType> fromSymbol(String symbol) {
351                 for (ComparisonType type : values()) {
352                     if (type.symbol.equals(symbol)) {
353                         return Optional.of(type);
354                     }
355                 }
356                 return Optional.empty();
357             }
358
359             static List<String> namesAndSymbols() {
360                 return Stream.of(values()).flatMap(entry -> Stream.of(entry.name(), entry.symbol())).toList();
361             }
362         }
363
364         @Override
365         public String toString() {
366             Object state = null;
367
368             try {
369                 state = itemRegistry.getItem(itemName).getState();
370             } catch (ItemNotFoundException ignored) {
371             }
372
373             String stateClass = state == null ? "null" : state.getClass().getSimpleName();
374             return "Condition(itemName='" + itemName + "', state='" + state + "' (" + stateClass + "), comparisonType="
375                     + comparisonType + ", value='" + value + "')";
376         }
377     }
378 }