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] Add new "Flat Line" profile #18301

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions bundles/org.openhab.transform.basicprofiles/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This bundle provides a list of useful Profiles:
| [Threshold Profile](#threshold-profile) | Translates numeric input data to `ON` or `OFF` based on a threshold value |
| [Time Range Command Profile](#time-range-command-profile) | An enhanced implementation of a follow profile which converts `OnOffType` to a `PercentType` |
| [State Filter Profile](#state-filter-profile) | Filters input data using arithmetic comparison conditions |
| [Flat Line Profile](#flat-line-profile) | Sets the linked Item On or Off depending whether the Channel has recently produced data |

## Generic Command Profile

Expand Down Expand Up @@ -334,3 +335,27 @@ Number:Power PowerUsage {
channel="mybinding:mything:mychannel" [ profile="basic-profiles:state-filter", conditions=">= MinimumPowerLimit", "< MaximumPowerLimit" ]
}
```

## Flat Line Profile

This profile sets the state of the item to `ON` (resp. `OFF`) if the binding has not provided any new data values within a given timeout period.
The purpose is to indicate an alarm condition if the binding is no longer providing values for the given channel.

### Flat Line Profile Configuration

| Configuration Parameter | Type | Description |
| ----------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `timeout` | text | The time out after which the profile will set the state of the item if the binding provides no updates. The value is in `QuantityType` format e.g. `1 h`, `60 s`, `2 d` |
| `inverted` | text | Optional string indicating if item shall be set to `ON` or `OFF` after the timeout expires. Where `false` => `ON` resp. `true` => `OFF`. The default value is `false`. |

### Flat Line Profile Example

```java
Switch myChannelFlatLineStatus {
channel="mybinding:mything:mychannel" [ profile="basic-profiles:flat-line", timeout="60 min" ]
}

Switch myChannelFlatLineStatus {
channel="mybinding:mything:mychannel" [ profile="basic-profiles:flat-line", timeout="1 d", inverted=true ]
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2010-2025 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.transform.basicprofiles.internal.config;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.transform.basicprofiles.internal.profiles.FlatLineProfile;

/**
* Configuration class for {@link FlatLineProfile}.
*
* @author Andrew Fiddian-Green - Initial contribution
*/
@NonNullByDefault
public class FlatLineProfileConfig {
public String timeout = "1 h"; // string of a {@link QuantityType} with a time value
public @Nullable Boolean inverted; // (optional) boolean value
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
import org.openhab.core.util.BundleResolver;
import org.openhab.transform.basicprofiles.internal.profiles.DebounceCountingStateProfile;
import org.openhab.transform.basicprofiles.internal.profiles.DebounceTimeStateProfile;
import org.openhab.transform.basicprofiles.internal.profiles.FlatLineProfile;
import org.openhab.transform.basicprofiles.internal.profiles.GenericCommandTriggerProfile;
import org.openhab.transform.basicprofiles.internal.profiles.GenericToggleSwitchTriggerProfile;
import org.openhab.transform.basicprofiles.internal.profiles.InvertStateProfile;
Expand All @@ -52,6 +53,7 @@
import org.osgi.framework.Bundle;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;

/**
Expand All @@ -72,6 +74,7 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider
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");
public static final ProfileTypeUID FLAT_LINE_UID = new ProfileTypeUID(SCOPE, "flat-line");

private static final ProfileType PROFILE_TYPE_GENERIC_COMMAND = ProfileTypeBuilder
.newTrigger(GENERIC_COMMAND_UID, "Generic Command") //
Expand Down Expand Up @@ -108,13 +111,17 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider
private static final ProfileType PROFILE_STATE_FILTER = ProfileTypeBuilder
.newState(STATE_FILTER_UID, "State Filter").build();

private static final ProfileType PROFILE_TYPE_FLAT_LINE = ProfileTypeBuilder
.newState(FLAT_LINE_UID, "Flat Line (No Input Activity)").withSupportedItemTypes(CoreItemFactory.SWITCH)
.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, STATE_FILTER_UID);
TIME_RANGE_COMMAND_UID, STATE_FILTER_UID, FLAT_LINE_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_STATE_FILTER);
PROFILE_STATE_FILTER, PROFILE_TYPE_FLAT_LINE);

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

Expand All @@ -123,6 +130,7 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider
private final Bundle bundle;
private final ItemRegistry itemRegistry;
private final TimeZoneProvider timeZoneProvider;
private final Set<AutoCloseable> closeables = ConcurrentHashMap.newKeySet();

@Activate
public BasicProfilesFactory(final @Reference ProfileTypeI18nLocalizationService profileTypeI18nLocalizationService,
Expand All @@ -137,26 +145,48 @@ public BasicProfilesFactory(final @Reference ProfileTypeI18nLocalizationService
@Override
public @Nullable Profile createProfile(ProfileTypeUID profileTypeUID, ProfileCallback callback,
ProfileContext context) {
Profile retVal;
if (GENERIC_COMMAND_UID.equals(profileTypeUID)) {
return new GenericCommandTriggerProfile(callback, context);
retVal = new GenericCommandTriggerProfile(callback, context);
} else if (GENERIC_TOGGLE_SWITCH_UID.equals(profileTypeUID)) {
return new GenericToggleSwitchTriggerProfile(callback, context);
retVal = new GenericToggleSwitchTriggerProfile(callback, context);
} else if (DEBOUNCE_COUNTING_UID.equals(profileTypeUID)) {
return new DebounceCountingStateProfile(callback, context);
retVal = new DebounceCountingStateProfile(callback, context);
} else if (DEBOUNCE_TIME_UID.equals(profileTypeUID)) {
return new DebounceTimeStateProfile(callback, context);
retVal = new DebounceTimeStateProfile(callback, context);
} else if (INVERT_UID.equals(profileTypeUID)) {
return new InvertStateProfile(callback);
retVal = new InvertStateProfile(callback);
} else if (ROUND_UID.equals(profileTypeUID)) {
return new RoundStateProfile(callback, context);
retVal = new RoundStateProfile(callback, context);
} else if (THRESHOLD_UID.equals(profileTypeUID)) {
return new ThresholdStateProfile(callback, context);
retVal = new ThresholdStateProfile(callback, context);
} else if (TIME_RANGE_COMMAND_UID.equals(profileTypeUID)) {
return new TimeRangeCommandProfile(callback, context, timeZoneProvider);
retVal = new TimeRangeCommandProfile(callback, context, timeZoneProvider);
} else if (STATE_FILTER_UID.equals(profileTypeUID)) {
return new StateFilterProfile(callback, context, itemRegistry);
retVal = new StateFilterProfile(callback, context, itemRegistry);
} else if (FLAT_LINE_UID.equals(profileTypeUID)) {
retVal = new FlatLineProfile(callback, context);
} else {
retVal = null;
}
return null;
if (retVal instanceof AutoCloseable closeable) {
closeables.add(closeable);
}
return retVal;
}

/**
* Note: I wonder if the OH Core {@link ProfileFactory} instances should do this too?
*/
@Deactivate
public void deactivate() {
for (AutoCloseable closeable : closeables) {
try {
closeable.close();
} catch (Exception e) {
}
}
closeables.clear();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/*
* Copyright (c) 2010-2025 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.transform.basicprofiles.internal.profiles;

import static org.openhab.transform.basicprofiles.internal.factory.BasicProfilesFactory.FLAT_LINE_UID;

import java.time.Duration;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.MetricPrefix;
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.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.openhab.transform.basicprofiles.internal.config.FlatLineProfileConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Changes the state of a {@link Switch} Item depending on whether data has been sent by the
* binding during the configured timeout window.
*
* @author Andrew Fiddian-Green - Initial contribution
*/
@NonNullByDefault
public class FlatLineProfile implements StateProfile, AutoCloseable {

private static final Duration DEFAULT_TIMEOUT = Duration.ofHours(1);

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

private final ProfileCallback callback;
private final ScheduledExecutorService scheduler;
private final Duration timeout;
private final boolean inverted;
private final Runnable onTimeout;
private boolean closed;

private @Nullable ScheduledFuture<?> timeoutTask;

public FlatLineProfile(ProfileCallback callback, ProfileContext context) {
FlatLineProfileConfig config = context.getConfiguration().as(FlatLineProfileConfig.class);
long mSec = 0;
try {
QuantityType<?> timeQty = QuantityType.valueOf(config.timeout);
if (!Units.SECOND.getDimension().equals(timeQty.getDimension())) {
throw new IllegalArgumentException();
}
mSec = timeQty.toUnit(MetricPrefix.MILLI(Units.SECOND)) instanceof QuantityType<?> mSecQty
? mSecQty.longValue()
: 0;
if (mSec <= 0) {
throw new IllegalArgumentException();
}
} catch (IllegalArgumentException e) {
logger.warn("Profile configuration timeout value \"{}\" is invalid", config.timeout);
}

this.callback = callback;
this.scheduler = context.getExecutorService();
this.timeout = mSec > 0 ? Duration.ofMillis(mSec) : DEFAULT_TIMEOUT;
this.inverted = config.inverted != null ? config.inverted : false;

this.onTimeout = () -> {
State itemState = OnOffType.from(!inverted);
logger.debug("timeout:{} => itemState:{}", timeout, itemState);
this.callback.sendUpdate(itemState);
};

logger.debug("Created(timeout:{}, inverted:{})", timeout, inverted);

onStateUpdateFromHandler(UnDefType.NULL); // dummy to set initial item state
}

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

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

@Override
public void onCommandFromItem(Command itemCommand) {
// do nothing
}

@Override
public void onCommandFromHandler(Command handlerCommand) {
Command itemCommand = OnOffType.from(inverted);
logger.debug("handlerCommand:{} => itemCommand:{}", handlerCommand, itemCommand);
callback.sendCommand(itemCommand);
rescheduleTimeoutTask();
}

@Override
public void onStateUpdateFromHandler(State handlerState) {
State itemState = OnOffType.from(inverted);
logger.debug("handlerState:{} => itemState:{}", handlerState, itemState);
callback.sendUpdate(itemState);
rescheduleTimeoutTask();
}

@Override
public void close() throws Exception {
cancelTimeoutTask();
closed = true;
}

private void cancelTimeoutTask() {
ScheduledFuture<?> priorTask = timeoutTask;
if (priorTask != null) {
priorTask.cancel(false);
}
}

private synchronized void rescheduleTimeoutTask() {
cancelTimeoutTask();
if (!closed) {
long mSec = timeout.toMillis();
timeoutTask = scheduler.scheduleWithFixedDelay(onTimeout, mSec, mSec, TimeUnit.MILLISECONDS);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?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:flat-line">
<parameter name="timeout" type="text">
<label>Timeout</label>
<description>Time with no input channel activity until the linked item is switched. Default is "1 h".</description>
<default>1 h</default>
</parameter>
<parameter name="inverted" type="boolean" required="false">
<label>Inverted</label>
<description>Selects if the linked item will be switched ON or OFF after the timeout expires.</description>
</parameter>
</config-description>

</config-description:config-descriptions>
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,8 @@ profile.config.basic-profiles.state-filter.mismatchState.label = State for filte
profile.config.basic-profiles.state-filter.mismatchState.description = State to pass to item instead if conditions are NOT met. Use quotes to treat as `StringType`. If not defined, the state update will not be passed to the item when conditions are not met.
profile.config.basic-profiles.state-filter.separator.label = Expression Separator
profile.config.basic-profiles.state-filter.separator.description = The character/string used to separate multiple conditions in a single line. Defaults to ",".
profile.config.basic-profiles.flat-line.label = Flat Line (No Input Activity)
profile.config.basic-profiles.flat-line.timeout.label = Timeout
profile.config.basic-profiles.flat-line.timeout.description = Time with no input channel activity until the linked item is switched. Default is "1 h".
profile.config.basic-profiles.flat-line.inverted.label = Inverted
profile.config.basic-profiles.flat-line.inverted.description = Selects if the linked item will be switched ON or OFF after the timeout expires.
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;

import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
Expand Down Expand Up @@ -54,7 +54,7 @@
@NonNullByDefault
public class BasicProfilesFactoryTest {

private static final int NUMBER_OF_PROFILES = 9;
private static final int NUMBER_OF_PROFILES = 10;

private static final Map<String, Object> PROPERTIES = Map.of(ThresholdStateProfile.PARAM_THRESHOLD, 15,
RoundStateProfile.PARAM_SCALE, 2, GenericCommandTriggerProfile.PARAM_EVENTS, "1002,1003",
Expand All @@ -68,6 +68,7 @@ public class BasicProfilesFactoryTest {
private @Mock @NonNullByDefault({}) ProfileCallback mockCallback;
private @Mock @NonNullByDefault({}) ProfileContext mockContext;
private @Mock @NonNullByDefault({}) ItemRegistry mockItemRegistry;
private @Mock @NonNullByDefault({}) ScheduledExecutorService mockScheduler;

private @NonNullByDefault({}) BasicProfilesFactory profileFactory;

Expand All @@ -77,6 +78,7 @@ public void setup() {
mockTimeZoneProvider);

when(mockContext.getConfiguration()).thenReturn(CONFIG);
when(mockContext.getExecutorService()).thenReturn(mockScheduler);
}

@Test
Expand Down
Loading