]> git.basschouten.com Git - openhab-addons.git/blob
e482a1ba58afeca5656b5d3bbd22e719039c7563
[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
276                 // From hereon, don't override this.parsedValue,
277                 // so it gets checked against Item's state on each call
278                 State parsedValue = this.parsedValue;
279
280                 // If the values couldn't be converted to a type, check to see if it's an Item name
281                 if (parsedValue == null) {
282                     try {
283                         Item valueItem = itemRegistry.getItem(value);
284                         if (valueItem != null) { // ItemRegistry.getItem can return null in tests
285                             parsedValue = valueItem.getState();
286                             // Don't convert QuantityType to other types
287                             if (!(parsedValue instanceof QuantityType)) {
288                                 parsedValue = parsedValue.as(state.getClass());
289                             }
290                             logger.debug("Condition value: '{}' is an item state: '{}' ({})", value, parsedValue,
291                                     parsedValue == null ? "null" : parsedValue.getClass().getSimpleName());
292                         }
293                     } catch (ItemNotFoundException ignore) {
294                     }
295                 }
296
297                 if (parsedValue == null) {
298                     if (comparisonType == ComparisonType.NEQ || comparisonType == ComparisonType.NEQ_ALT) {
299                         // They're not even type compatible, so return true for NEQ comparison
300                         return true;
301                     } else {
302                         logger.debug("Condition value: '{}' is not compatible with state '{}' ({})", value, state,
303                                 state.getClass().getSimpleName());
304                         return false;
305                     }
306                 }
307
308                 rhs = Objects.requireNonNull(parsedValue instanceof StringType ? parsedValue.toString() : parsedValue);
309
310                 if (logger.isDebugEnabled()) {
311                     if (itemName.isEmpty()) {
312                         logger.debug("Performing a comparison between input '{}' ({}) and value '{}' ({})", lhs,
313                                 lhs.getClass().getSimpleName(), rhs, rhs.getClass().getSimpleName());
314                     } else {
315                         logger.debug("Performing a comparison between item '{}' state '{}' ({}) and value '{}' ({})",
316                                 itemName, lhs, lhs.getClass().getSimpleName(), rhs, rhs.getClass().getSimpleName());
317                     }
318                 }
319
320                 return switch (comparisonType) {
321                     case EQ -> lhs.equals(rhs);
322                     case NEQ, NEQ_ALT -> !lhs.equals(rhs);
323                     case GT -> ((Comparable) lhs).compareTo(rhs) > 0;
324                     case GTE -> ((Comparable) lhs).compareTo(rhs) >= 0;
325                     case LT -> ((Comparable) lhs).compareTo(rhs) < 0;
326                     case LTE -> ((Comparable) lhs).compareTo(rhs) <= 0;
327                 };
328             } catch (ItemNotFoundException | IllegalArgumentException | ClassCastException e) {
329                 logger.warn("Error evaluating condition: {} in link '{}': {}", this, callback.getItemChannelLink(),
330                         e.getMessage());
331             }
332             return false;
333         }
334
335         enum ComparisonType {
336             EQ("=="),
337             NEQ("!="),
338             NEQ_ALT("<>"),
339             GT(">"),
340             GTE(">="),
341             LT("<"),
342             LTE("<=");
343
344             private final String symbol;
345
346             ComparisonType(String symbol) {
347                 this.symbol = symbol;
348             }
349
350             String symbol() {
351                 return symbol;
352             }
353
354             static Optional<ComparisonType> fromSymbol(String symbol) {
355                 for (ComparisonType type : values()) {
356                     if (type.symbol.equals(symbol)) {
357                         return Optional.of(type);
358                     }
359                 }
360                 return Optional.empty();
361             }
362
363             static List<String> namesAndSymbols() {
364                 return Stream.of(values()).flatMap(entry -> Stream.of(entry.name(), entry.symbol())).toList();
365             }
366         }
367
368         @Override
369         public String toString() {
370             Object state = null;
371
372             try {
373                 state = itemRegistry.getItem(itemName).getState();
374             } catch (ItemNotFoundException ignored) {
375             }
376
377             String stateClass = state == null ? "null" : state.getClass().getSimpleName();
378             return "Condition(itemName='" + itemName + "', state='" + state + "' (" + stateClass + "), comparisonType="
379                     + comparisonType + ", value='" + value + "')";
380         }
381     }
382 }