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

[basicprofiles] State filter profile #531

Merged
merged 15 commits into from
Mar 24, 2024
31 changes: 31 additions & 0 deletions bundles/org.smarthomej.transform.basicprofiles/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,34 @@ Switch motionSensorFirstFloor {
channel="deconz:colortemperaturelight:AAA:BBB:brightness" [profile="basic-profiles:time-range-command", inRangeValue=100, outOfRangeValue=15, start="08:00", end="23:00", restoreValue="PREVIOUS"]
}
```

## State Filter Profile

This filter passes on state updates from a (binding) handler to the item if and only if all listed item state conditions
are met (conditions are ANDed together).
Option to instead pass different state update in case the conditions are not met.
State values may be quoted to treat as `StringType`.

Use case: Ignore values from a binding unless some other item(s) have a specific state.

### Configuration

| Configuration Parameter | Type | Description |
|-------------------------|------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `conditions` | text | Comma separated list of expressions on the format `ITEM_NAME OPERATOR ITEM_STATE`, ie `MyItem EQ OFF`. Use quotes around `ITEM_STATE` to treat value as string ie `'OFF'` and not `OnOffType.OFF` |
| `mismatchState` | text | Optional state to pass instead if conditions are NOT met. Use single quotes to treat as `StringType`. Defaults to `UNDEF` |
| `separator` | text | Optional separator string to separate expressions when using multiple. Defaults to `,` |

Possible values for token `OPERATOR` in `conditions`:

- `EQ` - Equals
- `NEQ` - Not equals


### Full Example

```Java
Number:Temperature airconTemperature{
channel="mybinding:mything:mychannel"[profile="basic-profiles:state-filter",conditions="airconPower_item EQ ON",mismatchState="UNDEF"]
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Copyright (c) 2021-2023 Contributors to the SmartHome/J 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.smarthomej.transform.basicprofiles.internal.config;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.types.UnDefType;
import org.smarthomej.transform.basicprofiles.internal.profiles.StateFilterProfile;

/**
* Configuration class for {@link StateFilterProfile}.
*
* @author Arne Seime - Initial contribution
*/
@NonNullByDefault
public class StateFilterProfileConfig {

public String conditions = "";

public String mismatchState = UnDefType.UNDEF.toString();

public String separator = ",";
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.i18n.LocalizedKey;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.library.CoreItemFactory;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.DefaultSystemChannelTypeProvider;
Expand All @@ -49,6 +50,7 @@
import org.smarthomej.transform.basicprofiles.internal.profiles.GenericToggleSwitchTriggerProfile;
import org.smarthomej.transform.basicprofiles.internal.profiles.InvertStateProfile;
import org.smarthomej.transform.basicprofiles.internal.profiles.RoundStateProfile;
import org.smarthomej.transform.basicprofiles.internal.profiles.StateFilterProfile;
import org.smarthomej.transform.basicprofiles.internal.profiles.ThresholdStateProfile;
import org.smarthomej.transform.basicprofiles.internal.profiles.TimeRangeCommandProfile;

Expand All @@ -69,6 +71,7 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider
public static final ProfileTypeUID ROUND_UID = new ProfileTypeUID(SCOPE, "round");
public static final ProfileTypeUID THRESHOLD_UID = new ProfileTypeUID(SCOPE, "threshold");
public static final ProfileTypeUID TIME_RANGE_COMMAND_UID = new ProfileTypeUID(SCOPE, "time-range-command");
public static final ProfileTypeUID STATE_FILTER_UID = new ProfileTypeUID(SCOPE, "state-filter");

private static final ProfileType PROFILE_TYPE_GENERIC_COMMAND = ProfileTypeBuilder
.newTrigger(GENERIC_COMMAND_UID, "Generic Command") //
Expand Down Expand Up @@ -102,24 +105,29 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider
.withSupportedItemTypes(CoreItemFactory.SWITCH) //
.withSupportedChannelTypeUIDs(DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_MOTION) //
.build();
private static final ProfileType PROFILE_STATE_FILTER = ProfileTypeBuilder
.newState(STATE_FILTER_UID, "Filter handler state updates based on any item state").build();

private static final Set<ProfileTypeUID> SUPPORTED_PROFILE_TYPE_UIDS = Set.of(GENERIC_COMMAND_UID,
GENERIC_TOGGLE_SWITCH_UID, DEBOUNCE_COUNTING_UID, DEBOUNCE_TIME_UID, INVERT_UID, ROUND_UID, THRESHOLD_UID,
TIME_RANGE_COMMAND_UID);
TIME_RANGE_COMMAND_UID, STATE_FILTER_UID);
private static final Set<ProfileType> SUPPORTED_PROFILE_TYPES = Set.of(PROFILE_TYPE_GENERIC_COMMAND,
PROFILE_TYPE_GENERIC_TOGGLE_SWITCH, PROFILE_TYPE_DEBOUNCE_COUNTING, PROFILE_TYPE_DEBOUNCE_TIME,
PROFILE_TYPE_INVERT, PROFILE_TYPE_ROUND, PROFILE_TYPE_THRESHOLD, PROFILE_TYPE_TIME_RANGE_COMMAND);
PROFILE_TYPE_INVERT, PROFILE_TYPE_ROUND, PROFILE_TYPE_THRESHOLD, PROFILE_TYPE_TIME_RANGE_COMMAND,
PROFILE_STATE_FILTER);

private final Map<LocalizedKey, ProfileType> localizedProfileTypeCache = new ConcurrentHashMap<>();

private final ProfileTypeI18nLocalizationService profileTypeI18nLocalizationService;
private final Bundle bundle;
private final ItemRegistry itemRegistry;

@Activate
public BasicProfilesFactory(final @Reference ProfileTypeI18nLocalizationService profileTypeI18nLocalizationService,
final @Reference BundleResolver bundleResolver) {
final @Reference BundleResolver bundleResolver, @Reference ItemRegistry itemRegistry) {
this.profileTypeI18nLocalizationService = profileTypeI18nLocalizationService;
this.bundle = bundleResolver.resolveBundle(BasicProfilesFactory.class);
this.itemRegistry = itemRegistry;
}

@Override
Expand All @@ -141,6 +149,8 @@ public BasicProfilesFactory(final @Reference ProfileTypeI18nLocalizationService
return new ThresholdStateProfile(callback, context);
} else if (TIME_RANGE_COMMAND_UID.equals(profileTypeUID)) {
return new TimeRangeCommandProfile(callback, context);
} else if (STATE_FILTER_UID.equals(profileTypeUID)) {
return new StateFilterProfile(callback, context, itemRegistry);
}
return null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/**
* Copyright (c) 2021-2023 Contributors to the SmartHome/J 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.smarthomej.transform.basicprofiles.internal.profiles;

import static org.smarthomej.transform.basicprofiles.internal.factory.BasicProfilesFactory.STATE_FILTER_UID;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.items.Item;
import org.openhab.core.items.ItemNotFoundException;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.library.types.StringType;
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.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.TypeParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.smarthomej.transform.basicprofiles.internal.config.StateFilterProfileConfig;

/**
* Accepts updates to state as long as conditions are met. Support for sending fixed state if conditions are *not*
* met.
*
* @author Arne Seime - Initial contribution
*/
@NonNullByDefault
public class StateFilterProfile implements StateProfile {

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

private final ItemRegistry itemRegistry;
private final ProfileCallback callback;
private List<Class<? extends State>> acceptedDataTypes;

private List<StateCondition> conditions = List.of();

private @Nullable State configMismatchState = null;

public StateFilterProfile(ProfileCallback callback, ProfileContext context, ItemRegistry itemRegistry) {
this.callback = callback;
acceptedDataTypes = context.getAcceptedDataTypes();
this.itemRegistry = itemRegistry;

StateFilterProfileConfig config = context.getConfiguration().as(StateFilterProfileConfig.class);
if (config != null) {
conditions = parseConditions(config.conditions, config.separator);
configMismatchState = parseState(config.mismatchState);
}
}

private List<StateCondition> parseConditions(@Nullable String config, String separator) {
if (config == null) {
return List.of();
}

List<StateCondition> parsedConditions = new ArrayList<>();
try {
String[] expressions = config.split(separator);
for (String expression : expressions) {
String[] parts = expression.trim().split("\s");
if (parts.length == 3) {
String itemName = parts[0];
StateCondition.ComparisonType conditionType = StateCondition.ComparisonType
.valueOf(parts[1].toUpperCase(Locale.ROOT));
String value = parts[2];
parsedConditions.add(new StateCondition(itemName, conditionType, value));
} else {
logger.warn("Malformed condition expression: '{}'", expression);
}
}

return parsedConditions;
} catch (IllegalArgumentException e) {
logger.warn("Cannot parse condition {}. Expected format ITEM_NAME <EQ|NEQ> STATE_VALUE: '{}'", config,
e.getMessage());
return List.of();
}
}

@Override
public ProfileTypeUID getProfileTypeUID() {
return STATE_FILTER_UID;
}

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

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

@Override
public void onCommandFromHandler(Command command) {
callback.sendCommand(command);
}

@Override
public void onStateUpdateFromHandler(State state) {
State resultState = checkCondition(state);
if (resultState != null) {
logger.debug("Received state update from handler: {}, forwarded as {}", state, resultState);
callback.sendUpdate(resultState);
} else {
logger.debug("Received state update from handler: {}, not forwarded to item", state);
}
}

@Nullable
private State checkCondition(State state) {
if (!conditions.isEmpty()) {
boolean allConditionsMet = true;
for (StateCondition condition : conditions) {
logger.debug("Evaluting condition: {}", condition);
try {
Item item = itemRegistry.getItem(condition.itemName);
String itemState = item.getState().toString();

if (!condition.matches(itemState)) {
allConditionsMet = false;
}
} catch (ItemNotFoundException e) {
logger.warn(
"Cannot find item '{}' in registry - check your condition expression - skipping state update",
condition.itemName);
allConditionsMet = false;
}

}
if (allConditionsMet) {
return state;
} else {
return configMismatchState;
}
} else {
logger.warn(
"No configuration defined for StateFilterProfile (check for log messages when instantiating profile) - skipping state update");
}

return null;
}

@Nullable
State parseState(@Nullable String stateString) {
// Quoted strings are parsed as StringType
if (stateString == null) {
return null;
} else if (stateString.startsWith("'") && stateString.endsWith("'")) {
return new StringType(stateString.substring(1, stateString.length() - 1));
} else {
return TypeParser.parseState(acceptedDataTypes, stateString);
}
}

class StateCondition {
String itemName;

ComparisonType comparisonType;
String value;

boolean quoted = false;

public StateCondition(String itemName, ComparisonType comparisonType, String value) {
this.itemName = itemName;
this.comparisonType = comparisonType;
this.value = value;
this.quoted = value.startsWith("'") && value.endsWith("'");
if (quoted) {
this.value = value.substring(1, value.length() - 1);
}
}

public boolean matches(String state) {
switch (comparisonType) {
case EQ:
return state.equals(value);
case NEQ: {
return !state.equals(value);
}
default:
logger.warn("Unknown condition type {}. Expected 'eq' or 'neq' - skipping state update",
comparisonType);
return false;

}
}

enum ComparisonType {
EQ,
NEQ
}

@Override
public String toString() {
return "Condition{itemName='" + itemName + "', comparisonType=" + comparisonType + ", value='" + value
+ "'}'";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?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:basic-profiles:state-filter">
<parameter name="conditions" type="text" required="true">
<label>Conditions</label>
<description>Comma separated list of expressions on the format ITEM_NAME OPERATOR ITEM_STATE, ie "MyItem EQ OFF". Use
quotes around ITEM_STATE to treat value as string ie "'OFF'".</description>
</parameter>
<parameter name="mismatchState" type="text">
<label>State for filter rejects</label>
<description>State to pass to item instead if conditions are NOT met. Use quotes to treat as `StringType`</description>
</parameter>
</config-description>
</config-description:config-descriptions>
Loading
Loading