From e400aafc2ad280bb5e6bd2a930614d8d158436cf Mon Sep 17 00:00:00 2001 From: Sven Schad Date: Tue, 31 Mar 2026 21:15:51 +0200 Subject: [PATCH] [enocean] Add generic state machine infrastructure (#20218) * [enocean] Add generic state machine infrastructure Signed-off-by: Sven Schad --- .../internal/EnOceanBindingConstants.java | 4 + .../internal/EnOceanHandlerFactory.java | 12 +- .../binding/enocean/internal/eep/EEP.java | 78 ++++++- .../binding/enocean/internal/eep/EEPType.java | 1 + .../internal/eep/StateMachineProvider.java | 82 +++++++ .../handler/EnOceanBaseActuatorHandler.java | 108 ++++++++- .../handler/EnOceanBaseSensorHandler.java | 8 +- .../handler/EnOceanBaseThingHandler.java | 6 +- .../handler/EnOceanClassicDeviceHandler.java | 16 +- .../internal/statemachine/ExampleAction.java | 28 +++ .../internal/statemachine/ExampleState.java | 28 +++ .../statemachine/ExampleTransitions.java | 56 +++++ .../statemachine/STMStateMachine.java | 219 ++++++++++++++++++ .../internal/statemachine/STMTransition.java | 47 ++++ .../STMTransitionConfiguration.java | 42 ++++ .../resources/OH-INF/i18n/enocean.properties | 2 + .../main/resources/OH-INF/thing/channels.xml | 8 + 17 files changed, 713 insertions(+), 32 deletions(-) create mode 100644 bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/eep/StateMachineProvider.java create mode 100644 bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/ExampleAction.java create mode 100644 bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/ExampleState.java create mode 100644 bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/ExampleTransitions.java create mode 100644 bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/STMStateMachine.java create mode 100644 bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/STMTransition.java create mode 100644 bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/STMTransitionConfiguration.java diff --git a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/EnOceanBindingConstants.java b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/EnOceanBindingConstants.java index 0ea65d86b2..bd97078316 100644 --- a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/EnOceanBindingConstants.java +++ b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/EnOceanBindingConstants.java @@ -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())), diff --git a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/EnOceanHandlerFactory.java b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/EnOceanHandlerFactory.java index 251b85451c..b151b855d9 100644 --- a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/EnOceanHandlerFactory.java +++ b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/EnOceanHandlerFactory.java @@ -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); } diff --git a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/eep/EEP.java b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/eep/EEP.java index a964189539..3c4a03e2f8 100644 --- a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/eep/EEP.java +++ b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/eep/EEP.java @@ -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 getCurrentStateFunc, @Nullable Configuration config) { + public EEP convertFromCommand(Thing thing, ChannelUID channelUID, Command command, + Function 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 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 getCurrentStateFunc) { + Function 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 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 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 getCurrentStateFunc, Configuration config) { + // legacy interface return UnDefType.UNDEF; } + protected State convertToStateImpl(String channelId, String channelTypeId, + Function 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!"); } diff --git a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/eep/EEPType.java b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/eep/EEPType.java index 65c5c7e223..91e507a9c0 100644 --- a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/eep/EEPType.java +++ b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/eep/EEPType.java @@ -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"); diff --git a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/eep/StateMachineProvider.java b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/eep/StateMachineProvider.java new file mode 100644 index 0000000000..f9af1eeea5 --- /dev/null +++ b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/eep/StateMachineProvider.java @@ -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. + *

+ * EEPs implementing this interface provide their state machine configuration + * and channel initialization logic, allowing the handler to remain generic. + * + * @param the action type (enum) + * @param the state type (enum) + * @author Sven Schad - Initial contribution + */ +@NonNullByDefault +public interface StateMachineProvider, S extends Enum> { + + /** + * 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 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 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 getChannelsToRemove(Thing thing); + + /** + * Returns the state to use when the state machine is initialized after a (re)start. + *

+ * 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); +} diff --git a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanBaseActuatorHandler.java b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanBaseActuatorHandler.java index 0c1b98c362..c809f9f3af 100644 --- a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanBaseActuatorHandler.java +++ b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanBaseActuatorHandler.java @@ -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 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. + *

+ * 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 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); + } + } } diff --git a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanBaseSensorHandler.java b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanBaseSensorHandler.java index cd01f94527..6e9bc5bb72 100644 --- a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanBaseSensorHandler.java +++ b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanBaseSensorHandler.java @@ -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); diff --git a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanBaseThingHandler.java b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanBaseThingHandler.java index 23977b8f22..68b853856d 100644 --- a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanBaseThingHandler.java +++ b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanBaseThingHandler.java @@ -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)); } diff --git a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanClassicDeviceHandler.java b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanClassicDeviceHandler.java index cc94822347..ebf0c04527 100644 --- a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanClassicDeviceHandler.java +++ b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/handler/EnOceanClassicDeviceHandler.java @@ -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(); diff --git a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/ExampleAction.java b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/ExampleAction.java new file mode 100644 index 0000000000..ce1fb85a8b --- /dev/null +++ b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/ExampleAction.java @@ -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 +} diff --git a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/ExampleState.java b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/ExampleState.java new file mode 100644 index 0000000000..28b1b85cea --- /dev/null +++ b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/ExampleState.java @@ -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 +} diff --git a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/ExampleTransitions.java b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/ExampleTransitions.java new file mode 100644 index 0000000000..b58826b283 --- /dev/null +++ b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/ExampleTransitions.java @@ -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. + *

+ * This minimal example shows how to: + *

+ *

+ * Usage example: + * + *

+ * STMStateMachine<ExampleAction, ExampleState> 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
+ * 
+ * + * @author Sven Schad - Initial contribution + */ +@NonNullByDefault +public class ExampleTransitions { + + /** + * Simple three-state workflow demonstrating basic state machine functionality. + */ + public static final STMTransitionConfiguration SIMPLE = new STMTransitionConfiguration<>( + Arrays.> 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))); +} diff --git a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/STMStateMachine.java b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/STMStateMachine.java new file mode 100644 index 0000000000..9904c06c41 --- /dev/null +++ b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/STMStateMachine.java @@ -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. + *

+ * 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 the action type (enum) + * @param the state type (enum) + * @author Sven Schad - Initial contribution + */ +@NonNullByDefault +public class STMStateMachine, S extends Enum> { + + private final List> transitions; + private final HashMap callbackActions; + private final ScheduledExecutorService scheduler; + private final @Nullable Consumer 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 , S extends Enum> STMStateMachine build( + STMTransitionConfiguration config, S initState, ScheduledExecutorService scheduler, + @Nullable Consumer stateChangeCallback) { + return new STMStateMachine<>(config, initState, scheduler, stateChangeCallback); + } + + private STMStateMachine(STMTransitionConfiguration config, S initState, ScheduledExecutorService scheduler, + @Nullable Consumer 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 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. + *

+ * 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. + *

+ * 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 apply(A action) { + for (STMTransition 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 callback = stateChangeCallback; + if (callback != null) { + callback.accept(state); + } + + break; + } + } + + return this; + } + + /** + * Schedules a runnable to be executed after a delay. + *

+ * 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. + *

+ * 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; + } +} diff --git a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/STMTransition.java b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/STMTransition.java new file mode 100644 index 0000000000..5bc963418f --- /dev/null +++ b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/STMTransition.java @@ -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 the action type (enum) + * @param the state type (enum) + * @author Sven Schad - Initial contribution + */ +@NonNullByDefault +public class STMTransition, S extends Enum> { + 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; + } +} diff --git a/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/STMTransitionConfiguration.java b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/STMTransitionConfiguration.java new file mode 100644 index 0000000000..ccfc4d4ac7 --- /dev/null +++ b/bundles/org.openhab.binding.enocean/src/main/java/org/openhab/binding/enocean/internal/statemachine/STMTransitionConfiguration.java @@ -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. + *

+ * Contains the list of valid transitions for a specific device type. + * + * @param the action type (enum) + * @param the state type (enum) + * @author Sven Schad - Initial contribution + */ +@NonNullByDefault +public class STMTransitionConfiguration, S extends Enum> { + + private final List> transitions; + + public STMTransitionConfiguration(List> transitions) { + this.transitions = new ArrayList<>(transitions); + } + + public List> getTransitions() { + return Collections.unmodifiableList(transitions); + } +} diff --git a/bundles/org.openhab.binding.enocean/src/main/resources/OH-INF/i18n/enocean.properties b/bundles/org.openhab.binding.enocean/src/main/resources/OH-INF/i18n/enocean.properties index 0899f0d9f8..2090145495 100644 --- a/bundles/org.openhab.binding.enocean/src/main/resources/OH-INF/i18n/enocean.properties +++ b/bundles/org.openhab.binding.enocean/src/main/resources/OH-INF/i18n/enocean.properties @@ -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 diff --git a/bundles/org.openhab.binding.enocean/src/main/resources/OH-INF/thing/channels.xml b/bundles/org.openhab.binding.enocean/src/main/resources/OH-INF/thing/channels.xml index 1e50857645..73f46c6314 100644 --- a/bundles/org.openhab.binding.enocean/src/main/resources/OH-INF/thing/channels.xml +++ b/bundles/org.openhab.binding.enocean/src/main/resources/OH-INF/thing/channels.xml @@ -78,6 +78,14 @@ + + + String + + Current state of the internal state machine + + + Dimmer