[enocean] Add generic state machine infrastructure (#20218)

* [enocean] Add generic state machine infrastructure

Signed-off-by: Sven Schad <svnsssd@gmail.com>
This commit is contained in:
Sven Schad
2026-03-31 21:15:51 +02:00
committed by GitHub
parent 7dfe35bea0
commit e400aafc2a
17 changed files with 713 additions and 32 deletions
@@ -102,6 +102,7 @@ public class EnOceanBindingConstants {
public static final String CHANNEL_DIMMER = "dimmer";
public static final String CHANNEL_ROLLERSHUTTER = "rollershutter";
public static final String CHANNEL_STATEMACHINESTATE = "state-machine";
public static final String CHANNEL_ANGLE = "angle";
public static final String CHANNEL_TEMPERATURE = "temperature";
public static final String CHANNEL_HUMIDITY = "humidity";
@@ -243,6 +244,9 @@ public class EnOceanBindingConstants {
Map.entry(CHANNEL_ROLLERSHUTTER,
new EnOceanChannelDescription(new ChannelTypeUID(BINDING_ID, CHANNEL_ROLLERSHUTTER),
CoreItemFactory.ROLLERSHUTTER)),
Map.entry(CHANNEL_STATEMACHINESTATE,
new EnOceanChannelDescription(new ChannelTypeUID(BINDING_ID, CHANNEL_STATEMACHINESTATE),
CoreItemFactory.STRING)),
Map.entry(CHANNEL_ANGLE,
new EnOceanChannelDescription(new ChannelTypeUID(BINDING_ID, CHANNEL_ANGLE),
CoreItemFactory.NUMBER + ItemUtil.EXTENSION_SEPARATOR + Angle.class.getSimpleName())),
@@ -29,6 +29,7 @@ import org.openhab.binding.enocean.internal.handler.EnOceanClassicDeviceHandler;
import org.openhab.binding.enocean.internal.handler.EnOceanDatagramInjectorHandler;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.io.transport.serial.SerialPortManager;
import org.openhab.core.storage.StorageService;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingManager;
@@ -63,15 +64,18 @@ public class EnOceanHandlerFactory extends BaseThingHandlerFactory {
private final SerialPortManager serialPortManager;
private final ThingManager thingManager;
private final ItemChannelLinkRegistry itemChannelLinkRegistry;
private final StorageService storageService;
@Activate
public EnOceanHandlerFactory(final @Reference SerialPortManager serialPortManager,
final @Reference ThingManager thingManager,
final @Reference ItemChannelLinkRegistry itemChannelLinkRegistry) {
// Obtain references to thes service using an OSGi reference
final @Reference ItemChannelLinkRegistry itemChannelLinkRegistry,
final @Reference StorageService storageService) {
// Obtain references to these services using an OSGi reference
this.serialPortManager = serialPortManager;
this.thingManager = thingManager;
this.itemChannelLinkRegistry = itemChannelLinkRegistry;
this.storageService = storageService;
}
@Override
@@ -87,11 +91,11 @@ public class EnOceanHandlerFactory extends BaseThingHandlerFactory {
registerDeviceDiscoveryService(bridgeHandler);
return bridgeHandler;
} else if (EnOceanBaseActuatorHandler.SUPPORTED_THING_TYPES.contains(thingTypeUID)) {
return new EnOceanBaseActuatorHandler(thing, itemChannelLinkRegistry);
return new EnOceanBaseActuatorHandler(thing, itemChannelLinkRegistry, storageService);
} else if (EnOceanBaseSensorHandler.SUPPORTED_THING_TYPES.contains(thingTypeUID)) {
return new EnOceanBaseSensorHandler(thing, itemChannelLinkRegistry);
} else if (EnOceanClassicDeviceHandler.SUPPORTED_THING_TYPES.contains(thingTypeUID)) {
return new EnOceanClassicDeviceHandler(thing, itemChannelLinkRegistry);
return new EnOceanClassicDeviceHandler(thing, itemChannelLinkRegistry, storageService);
} else if (EnOceanDatagramInjectorHandler.SUPPORTED_THING_TYPES.contains(thingTypeUID)) {
return new EnOceanDatagramInjectorHandler(thing, itemChannelLinkRegistry);
}
@@ -23,12 +23,17 @@ import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.enocean.internal.Helper;
import org.openhab.binding.enocean.internal.messages.ERP1Message;
import org.openhab.binding.enocean.internal.messages.ERP1Message.RORG;
import org.openhab.binding.enocean.internal.statemachine.STMStateMachine;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.type.ChannelTypeUID;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
@@ -72,8 +77,22 @@ public abstract class EEP {
setOptionalData(packet.getOptionalPayload());
}
public EEP convertFromCommand(String channelId, String channelTypeId, Command command,
Function<String, State> getCurrentStateFunc, @Nullable Configuration config) {
public EEP convertFromCommand(Thing thing, ChannelUID channelUID, Command command,
Function<String, State> getCurrentStateFunc, @Nullable STMStateMachine<?, ?> stm) {
String channelId = channelUID.getId();
Channel channel = thing.getChannel(channelUID);
// Virtual channels (e.g. sendCommand) are not registered as real Thing channels.
// Allow those through; for any other unknown channel, log and bail out.
if (channel == null && !VIRTUALCHANNEL_SEND_COMMAND.equals(channelId)) {
logger.debug("Channel {} not found on thing {}; ignoring command {}", channelUID, thing.getUID(), command);
return this;
}
ChannelTypeUID channelTypeUID = (channel != null) ? channel.getChannelTypeUID() : null;
String channelTypeId = (channelTypeUID != null) ? channelTypeUID.getId() : "";
Configuration config = (channel != null) ? channel.getConfiguration() : new Configuration();
if (!getEEPType().isChannelSupported(channelId, channelTypeId)) {
throw new IllegalArgumentException(String.format("Command %s of channel %s(%s) is not supported",
command.toString(), channelId, channelTypeId));
@@ -82,13 +101,27 @@ public abstract class EEP {
if (channelTypeId.equals(CHANNEL_TEACHINCMD) && command == OnOffType.ON) {
teachInQueryImpl(config);
} else {
convertFromCommandImpl(channelId, channelTypeId, command, getCurrentStateFunc, config);
convertFromCommandImpl(thing, channelUID, command, getCurrentStateFunc, stm);
}
return this;
}
public EEP convertFromCommand(String channelId, String channelTypeId, Command command,
Function<String, State> getCurrentStateFunc, @Nullable Configuration channelConfig) {
if (!getEEPType().isChannelSupported(channelId, channelTypeId)) {
throw new IllegalArgumentException(String.format("Command %s of channel %s(%s) is not supported",
command.toString(), channelId, channelTypeId));
}
if (channelTypeId.equals(CHANNEL_TEACHINCMD) && command == OnOffType.ON) {
teachInQueryImpl(channelConfig);
} else {
convertFromCommandImpl(channelId, channelTypeId, command, getCurrentStateFunc, channelConfig);
}
return this;
}
public State convertToState(String channelId, String channelTypeId, Configuration config,
Function<String, @Nullable State> getCurrentStateFunc) {
Function<String, @Nullable State> getCurrentStateFunc, @Nullable STMStateMachine<?, ?> stm) {
if (!getEEPType().isChannelSupported(channelId, channelTypeId)) {
throw new IllegalArgumentException(
String.format("Channel %s(%s) is not supported", channelId, channelTypeId));
@@ -111,17 +144,17 @@ public abstract class EEP {
return new DateTimeType();
}
return convertToStateImpl(channelId, channelTypeId, getCurrentStateFunc, config);
return convertToStateImpl(channelId, channelTypeId, getCurrentStateFunc, config, stm);
}
public @Nullable String convertToEvent(String channelId, String channelTypeId, @Nullable String lastEvent,
Configuration config) {
Configuration config, @Nullable STMStateMachine<?, ?> stm) {
if (!getEEPType().isChannelSupported(channelId, channelTypeId)) {
throw new IllegalArgumentException(
String.format("Channel %s(%s) is not supported", channelId, channelTypeId));
}
return convertToEventImpl(channelId, channelTypeId, lastEvent, config);
return convertToEventImpl(channelId, channelTypeId, lastEvent, config, stm);
}
public EEP setRORG(RORG newRORG) {
@@ -215,19 +248,50 @@ public abstract class EEP {
protected void convertFromCommandImpl(String channelId, String channelTypeId, Command command,
Function<String, State> getCurrentStateFunc, @Nullable Configuration config) {
// legacy interface
logger.warn("No implementation for sending data from channel {}/{} for this EEP!", channelId, channelTypeId);
}
protected void convertFromCommandImpl(Thing thing, ChannelUID channelUID, Command command,
Function<String, State> getCurrentStateFunc, @Nullable STMStateMachine<?, ?> stm) {
// interface for EEPs using the state machine
Channel channel = thing.getChannel(channelUID);
if (channel != null) {
Configuration channelConfig = channel.getConfiguration();
String channelId = channelUID.getId();
ChannelTypeUID channelTypeUID = channel.getChannelTypeUID();
String channelTypeId = (channelTypeUID != null) ? channelTypeUID.getId() : "";
convertFromCommandImpl(channelId, channelTypeId, command, getCurrentStateFunc, channelConfig);
}
}
protected State convertToStateImpl(String channelId, String channelTypeId,
Function<String, @Nullable State> getCurrentStateFunc, Configuration config) {
// legacy interface
return UnDefType.UNDEF;
}
protected State convertToStateImpl(String channelId, String channelTypeId,
Function<String, @Nullable State> getCurrentStateFunc, Configuration config,
@Nullable STMStateMachine<?, ?> stm) {
// interface for EEPs using the state machine
return convertToStateImpl(channelId, channelTypeId, getCurrentStateFunc, config);
}
protected @Nullable String convertToEventImpl(String channelId, String channelTypeId, @Nullable String lastEvent,
Configuration config) {
// legacy interface
return null;
}
protected @Nullable String convertToEventImpl(String channelId, String channelTypeId, @Nullable String lastEvent,
Configuration config, @Nullable STMStateMachine<?, ?> stm) {
// interface for EEPs using the state machine
return convertToEventImpl(channelId, channelTypeId, lastEvent, config);
}
protected void teachInQueryImpl(@Nullable Configuration config) {
logger.warn("No implementation for sending a response for this teach in!");
}
@@ -425,6 +425,7 @@ public enum EEPType {
{
put(CHANNEL_ROLLERSHUTTER, new Configuration());
put(CHANNEL_STATEMACHINESTATE, new Configuration());
put(CHANNEL_TEACHINCMD, new Configuration() {
{
put(PARAMETER_CHANNEL_TEACHINMSG, "fff80d80");
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2010-2026 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.binding.enocean.internal.eep;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.enocean.internal.statemachine.STMTransitionConfiguration;
import org.openhab.core.thing.Thing;
/**
* Interface for EEPs that provide a state machine for operation.
* <p>
* EEPs implementing this interface provide their state machine configuration
* and channel initialization logic, allowing the handler to remain generic.
*
* @param <A> the action type (enum)
* @param <S> the state type (enum)
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
public interface StateMachineProvider<A extends Enum<A>, S extends Enum<S>> {
/**
* Gets the transition configuration for the state machine.
* The implementation may read device-specific configuration from the Thing.
*
* @param thing the Thing to read configuration from
* @return the transition configuration, or null if STM should not be used (e.g., legacy mode)
*/
@Nullable
STMTransitionConfiguration<A, S> getTransitionConfiguration(Thing thing);
/**
* Gets the initial state for the state machine.
*
* @return the initial state
*/
S getInitialState();
/**
* Gets the actions that require callback registration.
* These actions will trigger processStoredCommand when completed.
*
* @param thing the Thing to read configuration from
* @return set of actions requiring callbacks
*/
Set<A> getRequiredCallbackActions(Thing thing);
/**
* Gets the channel IDs that should be removed based on the device configuration.
*
* @param thing the Thing to read configuration from
* @return set of channel IDs to remove (empty if none should be removed)
*/
Set<String> getChannelsToRemove(Thing thing);
/**
* Returns the state to use when the state machine is initialized after a (re)start.
* <p>
* The handler reads the previously persisted state (if any) from storage and passes it here.
* The implementation decides how to handle startup: it may restore the persisted state,
* replace unsafe transient states (e.g. an interrupted movement) with a safe fallback,
* or ignore the persisted state entirely.
*
* @param persistedState the state loaded from storage, or null if no state was persisted
* @return the state to restore the STM to, or null to leave the STM at its initial state
*/
@Nullable
S getStateOnStartup(@Nullable S persistedState);
}
@@ -28,9 +28,14 @@ import org.openhab.binding.enocean.internal.config.EnOceanActuatorConfig;
import org.openhab.binding.enocean.internal.eep.EEP;
import org.openhab.binding.enocean.internal.eep.EEPFactory;
import org.openhab.binding.enocean.internal.eep.EEPType;
import org.openhab.binding.enocean.internal.eep.StateMachineProvider;
import org.openhab.binding.enocean.internal.messages.BasePacket;
import org.openhab.binding.enocean.internal.statemachine.STMStateMachine;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.storage.Storage;
import org.openhab.core.storage.StorageService;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
@@ -45,6 +50,7 @@ import org.openhab.core.util.HexUtils;
/**
*
* @author Daniel Weber - Initial contribution
* @author Sven Schad - added state machine for blinds/rollershutter
* This class defines base functionality for sending eep messages. This class extends EnOceanBaseSensorHandler
* class as most actuator things send status or response messages, too.
*/
@@ -64,8 +70,14 @@ public class EnOceanBaseActuatorHandler extends EnOceanBaseSensorHandler {
private @Nullable ScheduledFuture<?> refreshJob; // used for polling current status of thing
public EnOceanBaseActuatorHandler(Thing thing, ItemChannelLinkRegistry itemChannelLinkRegistry) {
private final Storage<String> storage;
private static final String STORAGE_KEY_STM_STATE = "stmstate";
public EnOceanBaseActuatorHandler(Thing thing, ItemChannelLinkRegistry itemChannelLinkRegistry,
StorageService storageService) {
super(thing, itemChannelLinkRegistry);
this.storage = storageService.getStorage(thing.getUID().toString(), String.class.getClassLoader());
}
/**
@@ -200,7 +212,7 @@ public class EnOceanBaseActuatorHandler extends EnOceanBaseSensorHandler {
// Generic things are treated as actuator things, however to support also generic sensors one can omit
// senderIdOffset
// TODO: seperate generic actuators from generic sensors?
// TODO: separate generic actuators from generic sensors?
Integer senderOffset = getConfiguration().senderIdOffset;
if ((senderOffset == null && THING_TYPE_GENERICTHING.equals(this.getThing().getThingTypeUID()))) {
@@ -239,19 +251,19 @@ public class EnOceanBaseActuatorHandler extends EnOceanBaseSensorHandler {
@Override
protected void sendRequestResponse() {
sendMessage(VIRTUALCHANNEL_SEND_COMMAND, VIRTUALCHANNEL_SEND_COMMAND, OnOffType.ON, null);
sendMessage(new ChannelUID(thing.getUID(), VIRTUALCHANNEL_SEND_COMMAND), OnOffType.ON);
}
protected void sendMessage(String channelId, String channelTypeId, Command command,
@Nullable Configuration channelConfig) {
protected void sendMessage(ChannelUID channelUID, Command command) {
EEPType sendType = sendingEEPType;
if (sendType == null) {
logger.warn("cannot send a message with an empty EEPType");
return;
}
EEP eep = EEPFactory.createEEP(sendType);
if (eep.convertFromCommand(channelId, channelTypeId, command, id -> getCurrentState(id), channelConfig)
.hasData()) {
if (eep.convertFromCommand(thing, channelUID, command, id -> getCurrentState(id), stm).hasData()) {
BasePacket msg = eep.setSenderId(senderId).setDestinationId(destinationId)
.setSuppressRepeating(getConfiguration().suppressRepeating).getERP1Message();
if (msg == null) {
@@ -274,7 +286,6 @@ public class EnOceanBaseActuatorHandler extends EnOceanBaseSensorHandler {
}
// check if the channel is linked otherwise do nothing
String channelId = channelUID.getId();
Channel channel = getThing().getChannel(channelUID);
if (channel == null || !isLinked(channelUID)) {
return;
@@ -299,8 +310,7 @@ public class EnOceanBaseActuatorHandler extends EnOceanBaseSensorHandler {
}
try {
Configuration channelConfig = channel.getConfiguration();
sendMessage(channelId, channelTypeId, command, channelConfig);
sendMessage(channelUID, command);
} catch (IllegalArgumentException e) {
logger.warn("Exception while sending telegram!", e);
}
@@ -331,4 +341,82 @@ public class EnOceanBaseActuatorHandler extends EnOceanBaseSensorHandler {
this.refreshJob = null;
}
}
/**
* Callback invoked when the state machine changes state.
* Updates the state channel for UI visibility and persists the new state
* using the openHAB {@link StorageService} (JSON file in userdata).
* No item linking or persistence service configuration is required.
*
* @param newState the new state
*/
private void onStateChanged(Enum<?> newState) {
updateState(CHANNEL_STATEMACHINESTATE, new StringType(newState.name()));
storage.put(STORAGE_KEY_STM_STATE, newState.name());
logger.debug("STM state changed to {}", newState);
}
/**
* Restores the state machine state from the StorageService after a restart.
* The state is automatically persisted to a JSON file in the openHAB userdata
* directory whenever the state machine transitions. No item linking or
* persistence service configuration is required.
* <p>
* The provider's {@link StateMachineProvider#getStateOnStartup} method is responsible
* for deciding which state to restore (e.g. replacing unsafe transient states).
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
private void restoreStateMachineState(StateMachineProvider<?, ?> stmEEP) {
STMStateMachine<?, ?> localStm = stm;
if (localStm == null) {
return;
}
@Nullable
Enum<?> persistedState = null;
@Nullable
String lastState = storage.get(STORAGE_KEY_STM_STATE);
if (lastState != null) {
try {
Class<? extends Enum> stateClass = localStm.getState().getDeclaringClass();
persistedState = Enum.valueOf(stateClass, lastState);
} catch (IllegalArgumentException e) {
logger.debug("Could not parse persisted STM state '{}', passing null to provider", lastState);
}
}
@Nullable
Enum<?> startupState = ((StateMachineProvider) stmEEP).getStateOnStartup(persistedState);
if (startupState != null) {
((STMStateMachine) localStm).restoreState(startupState);
logger.debug("STM startup state: {}", startupState);
}
}
/**
* Processes a stored command after a state transition.
* This is called via callback when calibration or positioning completes.
*/
private void processStoredCommand() {
STMStateMachine<?, ?> stateMachine = stm;
if (stateMachine == null) {
return;
}
@Nullable
String channel = stateMachine.getStoredChannel();
@Nullable
Command command = stateMachine.getStoredCommand();
if (channel != null && command != null) {
logger.debug("Processing stored command {} for channel {}", command, channel);
stateMachine.clearStoredCommand();
// Schedule the command processing with a short delay
stateMachine.scheduleDelayed(() -> {
Channel ch = getThing().getChannel(channel);
if (ch != null) {
handleCommand(ch.getUID(), command);
}
}, 100);
}
}
}
@@ -33,6 +33,7 @@ import org.openhab.binding.enocean.internal.eep.EEPType;
import org.openhab.binding.enocean.internal.messages.BasePacket;
import org.openhab.binding.enocean.internal.messages.ERP1Message;
import org.openhab.binding.enocean.internal.messages.ERP1Message.RORG;
import org.openhab.binding.enocean.internal.statemachine.STMStateMachine;
import org.openhab.binding.enocean.internal.transceiver.PacketListener;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.thing.Channel;
@@ -66,6 +67,8 @@ public class EnOceanBaseSensorHandler extends EnOceanBaseThingHandler implements
protected @Nullable ScheduledFuture<?> responseFuture = null;
protected @Nullable STMStateMachine<?, ?> stm = null;
public EnOceanBaseSensorHandler(Thing thing, ItemChannelLinkRegistry itemChannelLinkRegistry) {
super(thing, itemChannelLinkRegistry);
}
@@ -181,7 +184,7 @@ public class EnOceanBaseSensorHandler extends EnOceanBaseThingHandler implements
switch (channel.getKind()) {
case STATE:
State result = eep.convertToState(channelId, channelTypeId, channelConfig,
this::getCurrentState);
this::getCurrentState, stm);
// if message can be interpreted (result != UnDefType.UNDEF) => update item state
if (result != UnDefType.UNDEF) {
@@ -190,7 +193,8 @@ public class EnOceanBaseSensorHandler extends EnOceanBaseThingHandler implements
break;
case TRIGGER:
String lastEvent = lastEvents.get(channelId);
String event = eep.convertToEvent(channelId, channelTypeId, lastEvent, channelConfig);
String event = eep.convertToEvent(channelId, channelTypeId, lastEvent, channelConfig,
stm);
if (event != null) {
triggerChannel(channel.getUID(), event);
lastEvents.put(channelId, event);
@@ -12,7 +12,7 @@
*/
package org.openhab.binding.enocean.internal.handler;
import static org.openhab.binding.enocean.internal.EnOceanBindingConstants.*;
import static org.openhab.binding.enocean.internal.EnOceanBindingConstants.CHANNEL_STATUS_REQUEST_EVENT;
import java.util.AbstractMap.SimpleEntry;
import java.util.Collection;
@@ -198,6 +198,10 @@ public abstract class EnOceanBaseThingHandler extends ConfigStatusThingHandler {
return UnDefType.UNDEF;
}
protected void setState(ChannelUID channelUID, State state) {
updateState(channelUID, state);
}
protected State getCurrentState(String channelId) {
return getCurrentState(getThing().getChannel(channelId));
}
@@ -33,6 +33,7 @@ import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.StopMoveType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.types.UpDownType;
import org.openhab.core.storage.StorageService;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.CommonTriggerEvents;
@@ -62,8 +63,9 @@ public class EnOceanClassicDeviceHandler extends EnOceanBaseActuatorHandler {
@Nullable
ScheduledFuture<?> releaseFuture = null;
public EnOceanClassicDeviceHandler(Thing thing, ItemChannelLinkRegistry itemChannelLinkRegistry) {
super(thing, itemChannelLinkRegistry);
public EnOceanClassicDeviceHandler(Thing thing, ItemChannelLinkRegistry itemChannelLinkRegistry,
StorageService storageService) {
super(thing, itemChannelLinkRegistry, storageService);
}
@Override
@@ -222,7 +224,6 @@ public class EnOceanClassicDeviceHandler extends EnOceanBaseActuatorHandler {
return;
}
String channelId = channelUID.getId();
Channel channel = getThing().getChannel(channelUID);
if (channel == null) {
return;
@@ -244,8 +245,8 @@ public class EnOceanClassicDeviceHandler extends EnOceanBaseActuatorHandler {
EEPType localSendType = sendingEEPType;
if (localSendType != null) {
EEP eep = EEPFactory.createEEP(localSendType);
if (eep.setSenderId(senderId).setDestinationId(destinationId).convertFromCommand(channelId,
channelTypeId, result, id -> this.getCurrentState(id), channel.getConfiguration()).hasData()) {
if (eep.setSenderId(senderId).setDestinationId(destinationId)
.convertFromCommand(thing, channelUID, command, id -> getCurrentState(id), stm).hasData()) {
BasePacket press = eep.setSuppressRepeating(getConfiguration().suppressRepeating).getERP1Message();
if (press != null) {
EnOceanBridgeHandler handler = getBridgeHandler();
@@ -256,9 +257,8 @@ public class EnOceanClassicDeviceHandler extends EnOceanBaseActuatorHandler {
if (channelConfig.duration > 0) {
releaseFuture = scheduler.schedule(() -> {
if (eep.convertFromCommand(channelId, channelTypeId,
convertToReleasedCommand(lastTriggerEvent), id -> this.getCurrentState(id),
channel.getConfiguration()).hasData()) {
if (eep.convertFromCommand(thing, channelUID, convertToReleasedCommand(lastTriggerEvent),
id -> getCurrentState(id), stm).hasData()) {
BasePacket release = eep.getERP1Message();
if (release != null) {
EnOceanBridgeHandler handler = getBridgeHandler();
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2010-2026 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.binding.enocean.internal.statemachine;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Example actions for demonstrating state machine infrastructure.
* This is a minimal example showing how to define custom actions for a state machine.
*
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
public enum ExampleAction {
START, // Begin operation
READY, // Warmup complete
FINISH // Work done
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2010-2026 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.binding.enocean.internal.statemachine;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Example states for demonstrating state machine infrastructure.
* This is a minimal example showing how to define custom states for a state machine.
*
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
public enum ExampleState {
IDLE, // Ready to start
WARMUP, // Preparing
ACTIVE // Working
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2010-2026 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.binding.enocean.internal.statemachine;
import java.util.Arrays;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Example transition configuration demonstrating state machine infrastructure usage.
* <p>
* This minimal example shows how to:
* <ul>
* <li>Define state transitions between custom states</li>
* <li>Trigger actions that cause state changes</li>
* <li>Create a simple three-state workflow: IDLE → WARMUP → ACTIVE → IDLE</li>
* </ul>
* <p>
* Usage example:
*
* <pre>
* STMStateMachine&lt;ExampleAction, ExampleState&gt; stm = STMStateMachine.build(ExampleTransitions.SIMPLE,
* ExampleState.IDLE, scheduler, this::onStateChanged);
*
* stm.apply(ExampleAction.START); // IDLE → WARMUP
* stm.apply(ExampleAction.READY); // WARMUP → ACTIVE
* stm.apply(ExampleAction.FINISH); // ACTIVE → IDLE
* </pre>
*
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
public class ExampleTransitions {
/**
* Simple three-state workflow demonstrating basic state machine functionality.
*/
public static final STMTransitionConfiguration<ExampleAction, ExampleState> SIMPLE = new STMTransitionConfiguration<>(
Arrays.<STMTransition<ExampleAction, ExampleState>> asList(
// IDLE → WARMUP when START action occurs
new STMTransition<>(ExampleState.IDLE, ExampleAction.START, ExampleState.WARMUP),
// WARMUP → ACTIVE when READY action occurs
new STMTransition<>(ExampleState.WARMUP, ExampleAction.READY, ExampleState.ACTIVE),
// ACTIVE → IDLE when FINISH action occurs
new STMTransition<>(ExampleState.ACTIVE, ExampleAction.FINISH, ExampleState.IDLE)));
}
@@ -0,0 +1,219 @@
/*
* Copyright (c) 2010-2026 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.binding.enocean.internal.statemachine;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.types.Command;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Generic state machine for managing multi-stage device movements.
* <p>
* This state machine is decoupled from specific device handlers and uses
* a callback mechanism to notify state changes. The caller is responsible
* for persisting state and updating channels.
*
* @param <A> the action type (enum)
* @param <S> the state type (enum)
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
public class STMStateMachine<A extends Enum<A>, S extends Enum<S>> {
private final List<STMTransition<A, S>> transitions;
private final HashMap<A, Runnable> callbackActions;
private final ScheduledExecutorService scheduler;
private final @Nullable Consumer<S> stateChangeCallback;
private S state;
private S prevState;
private @Nullable String storedChannel;
private @Nullable Command storedCommand;
protected @Nullable ScheduledFuture<?> responseFuture = null;
protected Logger logger = LoggerFactory.getLogger(STMStateMachine.class);
/**
* Creates a new state machine instance.
*
* @param config the transition configuration to use
* @param initState the initial state
* @param scheduler the scheduler for delayed operations
* @param stateChangeCallback optional callback invoked on state changes
* @return new state machine instance
*/
public static <A extends Enum<A>, S extends Enum<S>> STMStateMachine<A, S> build(
STMTransitionConfiguration<A, S> config, S initState, ScheduledExecutorService scheduler,
@Nullable Consumer<S> stateChangeCallback) {
return new STMStateMachine<>(config, initState, scheduler, stateChangeCallback);
}
private STMStateMachine(STMTransitionConfiguration<A, S> config, S initState, ScheduledExecutorService scheduler,
@Nullable Consumer<S> stateChangeCallback) {
this.callbackActions = new HashMap<>();
this.state = initState;
this.prevState = initState;
this.transitions = config.getTransitions();
this.scheduler = scheduler;
this.stateChangeCallback = stateChangeCallback;
}
/**
* Registers a callback to be executed when a specific action is applied.
*
* @param action the action that triggers the callback
* @param callback the callback to execute
* @return this state machine for fluent configuration
*/
public synchronized STMStateMachine<A, S> register(A action, Runnable callback) {
this.callbackActions.put(action, callback);
return this;
}
/**
* Gets the current state.
*
* @return current state
*/
public synchronized S getState() {
return state;
}
/**
* Gets the previous state before the last transition.
*
* @return previous state
*/
public synchronized S getPrevState() {
return prevState;
}
/**
* Restores a previously persisted state.
* <p>
* This method is intended to be called during handler initialization
* to restore state from Thing properties. No callback is triggered.
*
* @param restoredState the state to restore
*/
public synchronized void restoreState(S restoredState) {
this.prevState = this.state;
this.state = restoredState;
logger.debug("STM: State restored to {}", restoredState);
}
/**
* Applies an action to trigger a state transition.
* <p>
* If a matching transition is found, the state changes and registered
* callbacks are invoked.
*
* @param action the action to apply
* @return this state machine for fluent usage
*/
public synchronized STMStateMachine<A, S> apply(A action) {
for (STMTransition<A, S> transition : transitions) {
boolean currentStateMatches = transition.getFrom().equals(state);
boolean conditionsMatch = transition.getAction().equals(action);
if (currentStateMatches && conditionsMatch) {
logger.debug("STM: State change from {} to {} by action {}, prevState {}", state, transition.getTo(),
action, prevState);
prevState = state;
state = transition.getTo();
// Execute action-specific callback if registered
Runnable actionCallback = callbackActions.get(action);
if (actionCallback != null) {
actionCallback.run();
}
// Notify state change via callback
Consumer<S> callback = stateChangeCallback;
if (callback != null) {
callback.accept(state);
}
break;
}
}
return this;
}
/**
* Schedules a runnable to be executed after a delay.
* <p>
* Useful for scheduling follow-up commands after movement completion.
*
* @param runnable the task to execute
* @param delayMs delay in milliseconds
*/
public synchronized void scheduleDelayed(Runnable runnable, long delayMs) {
ScheduledFuture<?> future = responseFuture;
if (future == null || future.isDone()) {
this.responseFuture = scheduler.schedule(runnable, delayMs, TimeUnit.MILLISECONDS);
}
}
/**
* Stores a command for later processing.
* <p>
* This is used when a command cannot be executed immediately (e.g., during calibration)
* and needs to be processed after a state transition completes.
*
* @param channel the channel ID for the command
* @param command the command to store
*/
public synchronized void storeCommand(String channel, Command command) {
this.storedChannel = channel;
this.storedCommand = command;
logger.debug("STM: Stored command {} for channel {}", command, channel);
}
/**
* Gets the stored channel ID.
*
* @return the stored channel ID, or null if none stored
*/
public synchronized @Nullable String getStoredChannel() {
return storedChannel;
}
/**
* Gets the stored command.
*
* @return the stored command, or null if none stored
*/
public synchronized @Nullable Command getStoredCommand() {
return storedCommand;
}
/**
* Clears the stored command.
*/
public synchronized void clearStoredCommand() {
this.storedChannel = null;
this.storedCommand = null;
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2010-2026 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.binding.enocean.internal.statemachine;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Represents a single transition in the state machine.
*
* @param <A> the action type (enum)
* @param <S> the state type (enum)
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
public class STMTransition<A extends Enum<A>, S extends Enum<S>> {
private final S from;
private final A action;
private final S to;
public STMTransition(S from, A action, S to) {
this.from = from;
this.action = action;
this.to = to;
}
public S getFrom() {
return from;
}
public A getAction() {
return action;
}
public S getTo() {
return to;
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2010-2026 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.binding.enocean.internal.statemachine;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Transition configuration for state machines.
* <p>
* Contains the list of valid transitions for a specific device type.
*
* @param <A> the action type (enum)
* @param <S> the state type (enum)
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
public class STMTransitionConfiguration<A extends Enum<A>, S extends Enum<S>> {
private final List<STMTransition<A, S>> transitions;
public STMTransitionConfiguration(List<STMTransition<A, S>> transitions) {
this.transitions = new ArrayList<>(transitions);
}
public List<STMTransition<A, S>> getTransitions() {
return Collections.unmodifiableList(transitions);
}
}
@@ -591,6 +591,8 @@ channel-type.enocean.setPoint.label = Set Point
channel-type.enocean.setPoint.description = linear set point
channel-type.enocean.smokeDetection.label = Smoke Detected
channel-type.enocean.smokeDetection.description = Smoke detection sensor state.
channel-type.enocean.state-machine.label = State Machine State
channel-type.enocean.state-machine.description = Current state of the internal state machine
channel-type.enocean.statusRequestEvent.label = Status Request Event
channel-type.enocean.statusRequestEvent.description = Is triggered when the actuator wakes up from sleep and asks for the current status.
channel-type.enocean.supplyAirFanAirFlowRate.label = Supply Air Fan Air Flow Rate
@@ -78,6 +78,14 @@
</config-description>
</channel-type>
<!-- State Machine State Channel -->
<channel-type id="state-machine" advanced="true">
<item-type>String</item-type>
<label>State Machine State</label>
<description>Current state of the internal state machine</description>
<state readOnly="true"/>
</channel-type>
<!-- Dimmer Channel -->
<channel-type id="dimmer">
<item-type>Dimmer</item-type>