diff --git a/CODEOWNERS b/CODEOWNERS
index ca38e94d9a3e5..f9b28129999e5 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -9,6 +9,7 @@
/bundles/org.openhab.automation.jsscripting/ @jpg0
/bundles/org.openhab.automation.jythonscripting/ @openhab/add-ons-maintainers
/bundles/org.openhab.automation.pidcontroller/ @fwolter
+/bundles/org.openhab.automation.pwm/ @fwolter
/bundles/org.openhab.binding.adorne/ @theiding
/bundles/org.openhab.binding.ahawastecollection/ @soenkekueper
/bundles/org.openhab.binding.airq/ @aurelio1
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index b183a255f0598..defb5cbeded19 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -36,6 +36,11 @@
org.openhab.automation.pidcontroller
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.automation.pwm
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.binding.adorne
diff --git a/bundles/org.openhab.automation.pwm/NOTICE b/bundles/org.openhab.automation.pwm/NOTICE
new file mode 100644
index 0000000000000..38d625e349232
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/NOTICE
@@ -0,0 +1,13 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
diff --git a/bundles/org.openhab.automation.pwm/README.md b/bundles/org.openhab.automation.pwm/README.md
new file mode 100644
index 0000000000000..840b19df8715a
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/README.md
@@ -0,0 +1,62 @@
+# Pulse Width Modulation (PWM) Automation
+
+This automation module implements [Pulse Width Modulation (PWM)](https://en.wikipedia.org/wiki/Pulse-width_modulation).
+
+PWM can be used to control actuators continuously from 0 to 100% that only support ON/OFF commands.
+E.g. valves or heating burners.
+It accomplishes that by switching the actuator on and off with a fixed interval.
+The higher the control percentage (duty cycle), the longer the ON phase.
+
+Example: If you have an interval of 10 sec and the duty cycle is 30%, the output is ON for 3 sec and OFF for 7 sec.
+
+This module is **unsuitable** for controlling LED lights as the high PWM frequency can't be met.
+
+> Note: The module starts to work only if the duty cycle has been updated at least once.
+
+## Modules
+
+The PWM module can be used in openHAB's [rule engine](https://www.openhab.org/docs/configuration/rules-dsl.html).
+
+This automation provides a trigger module ("PWM triggers") with one input Item: `dutycycleItem` (0-100%).
+The module calculates the ON/OFF state and returns it.
+The return value is used to feed the Action module "Item Action" aka "send a command", which controls the actuator.
+
+To configure a rule, you need to add a Trigger ("PWM triggers") and an Action ("Item Action").
+Select the Item you like to control in the "Item Action" and leave the command empty.
+
+### Trigger
+
+| Name | Type | Description | Required |
+|-----------------|---------|----------------------------------------------------------------------------------------------|----------|
+| `dutycycleItem` | Item | The Item (PercentType) to read the duty cycle from | Yes |
+| `interval` | Decimal | The constant interval in which the output is switch ON and OFF again in sec. | Yes |
+| `minDutyCycle` | Decimal | Any duty cycle below this value will be increased to this value | No |
+| `maxDutycycle` | Decimal | Any duty cycle above this value will be decreased to this value | No |
+| `deadManSwitch` | Decimal | The output will be switched off, when the duty cycle is not updated within this time (in ms) | No |
+
+The duty cycle can be limited via the parameters `minDutycycle` and `maxDutyCycle`.
+This is helpful if you need to maintain a minimum time between the switching of the output.
+This is necessary for example for heating burners, which may not be switched on for very short times.
+The on time is than increased to `minDutycycle`.
+In this case one should also set a max duty cycle to prevent short off times.
+It makes sense to apply these symmetrically e.g. 10%/90% or 20%/80%.
+
+If the duty cycle is 0% or 100%, the min/max parameters are ignored and the output is switched ON or OFF continuously.
+
+If the duty cycle Item is not updated within the dead-man switch timeout, the output is switched off, regardless of the current duty cycle.
+The function can be used to save energy if the source of the duty cycle died for whatever reason and doesn't update the value anymore.
+When the duty cycle is updated again, the module returns to normal operation.
+
+> Note: The min/max ON/OFF times set via `minDutycycle` and `maxDutycycle` are not met if the dead-man switch triggers and recovers fast.
+
+## Control Algorithm
+
+This module is designed to respond fast to duty cycle changes, but at the same time maintain a constant interval and also the min/max ON/OFF parameters.
+For that reason, the module might seem to act peculiarly in some cases:
+
+- When the output is ON and the duty cycle is decreased, the output might switch off immediately, if applicable.
+Example: The interval is 10 sec and the current duty cycle is 80%.
+When the duty cycle is decreased to 20%, the output would switch off immediately, if it has been already ON for more than 2 sec.
+- When the duty cycle is 0% for a short interval and then increased again, the output will only switch on when the new interval starts.
+- When the duty cycle is 0% or 100% for more than a whole interval, a new interval will start as soon as the duty cycle is updated to a value other than 0%, respective 100%.
+- The module starts to work only if the duty cycle Item has been updated at least once.
diff --git a/bundles/org.openhab.automation.pwm/doc/statemachine.odg b/bundles/org.openhab.automation.pwm/doc/statemachine.odg
new file mode 100644
index 0000000000000..be28e78e32ba2
Binary files /dev/null and b/bundles/org.openhab.automation.pwm/doc/statemachine.odg differ
diff --git a/bundles/org.openhab.automation.pwm/doc/statemachine.png b/bundles/org.openhab.automation.pwm/doc/statemachine.png
new file mode 100644
index 0000000000000..aa99d12bd492f
Binary files /dev/null and b/bundles/org.openhab.automation.pwm/doc/statemachine.png differ
diff --git a/bundles/org.openhab.automation.pwm/pom.xml b/bundles/org.openhab.automation.pwm/pom.xml
new file mode 100644
index 0000000000000..d972adb157105
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/pom.xml
@@ -0,0 +1,17 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 3.1.0-SNAPSHOT
+
+
+ org.openhab.automation.pwm
+
+ openHAB Add-ons :: Bundles :: Automation :: PWM
+
+
diff --git a/bundles/org.openhab.automation.pwm/src/main/feature/feature.xml b/bundles/org.openhab.automation.pwm/src/main/feature/feature.xml
new file mode 100644
index 0000000000000..212e8c27b981d
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/feature/feature.xml
@@ -0,0 +1,9 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.automation.pwm/${project.version}
+
+
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/PWMConstants.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/PWMConstants.java
new file mode 100644
index 0000000000000..e2072322a7939
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/PWMConstants.java
@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Constants for the PWM automation module.
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@NonNullByDefault
+public class PWMConstants {
+ public static final String AUTOMATION_NAME = "pwm";
+
+ public static final String CONFIG_DUTY_CYCLE_ITEM = "dutycycleItem";
+ public static final String CONFIG_PERIOD = "interval";
+ public static final String CONFIG_MIN_DUTYCYCLE = "minDutycycle";
+ public static final String CONFIG_MAX_DUTYCYCLE = "maxDutycycle";
+ public static final String CONFIG_COMMAND_ITEM = "command";
+ public static final String CONFIG_DEAD_MAN_SWITCH = "deadManSwitch";
+ public static final String CONFIG_OUTPUT_ITEM = "outputItem";
+ public static final String INPUT = "input";
+ public static final String OUTPUT = "command";
+}
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/PWMException.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/PWMException.java
new file mode 100644
index 0000000000000..8b2f86b90a5ed
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/PWMException.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Common exception for the PWM automation module.
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@NonNullByDefault
+public class PWMException extends Exception {
+ private static final long serialVersionUID = -3029834022610530982L;
+
+ public PWMException(String message) {
+ super(message);
+ }
+}
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/factory/PWMModuleHandlerFactory.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/factory/PWMModuleHandlerFactory.java
new file mode 100644
index 0000000000000..87e54e0bb8693
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/factory/PWMModuleHandlerFactory.java
@@ -0,0 +1,64 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal.factory;
+
+import java.util.Collection;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.automation.pwm.internal.handler.PWMTriggerHandler;
+import org.openhab.core.automation.Module;
+import org.openhab.core.automation.Trigger;
+import org.openhab.core.automation.handler.BaseModuleHandlerFactory;
+import org.openhab.core.automation.handler.ModuleHandler;
+import org.openhab.core.automation.handler.ModuleHandlerFactory;
+import org.openhab.core.items.ItemRegistry;
+import org.osgi.framework.BundleContext;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * Factory for the PWM automation module.
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@NonNullByDefault
+@Component(service = ModuleHandlerFactory.class, configurationPid = "automation.pwm")
+public class PWMModuleHandlerFactory extends BaseModuleHandlerFactory {
+ private static final Collection TYPES = Set.of(PWMTriggerHandler.MODULE_TYPE_ID);
+ private ItemRegistry itemRegistry;
+ private BundleContext bundleContext;
+
+ @Activate
+ public PWMModuleHandlerFactory(@Reference ItemRegistry itemRegistry, BundleContext bundleContext) {
+ this.itemRegistry = itemRegistry;
+ this.bundleContext = bundleContext;
+ }
+
+ @Override
+ public Collection getTypes() {
+ return TYPES;
+ }
+
+ @Override
+ protected @Nullable ModuleHandler internalCreate(Module module, String ruleUID) {
+ switch (module.getTypeUID()) {
+ case PWMTriggerHandler.MODULE_TYPE_ID:
+ return new PWMTriggerHandler((Trigger) module, itemRegistry, bundleContext);
+ }
+
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/PWMTriggerHandler.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/PWMTriggerHandler.java
new file mode 100644
index 0000000000000..f5c1619841d64
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/PWMTriggerHandler.java
@@ -0,0 +1,240 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal.handler;
+
+import static org.openhab.automation.pwm.internal.PWMConstants.*;
+
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+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.automation.pwm.internal.PWMException;
+import org.openhab.automation.pwm.internal.handler.state.StateMachine;
+import org.openhab.core.automation.ModuleHandlerCallback;
+import org.openhab.core.automation.Trigger;
+import org.openhab.core.automation.handler.BaseTriggerModuleHandler;
+import org.openhab.core.automation.handler.TriggerHandlerCallback;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.events.Event;
+import org.openhab.core.events.EventFilter;
+import org.openhab.core.events.EventSubscriber;
+import org.openhab.core.items.Item;
+import org.openhab.core.items.ItemNotFoundException;
+import org.openhab.core.items.ItemRegistry;
+import org.openhab.core.items.events.ItemStateEvent;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.ServiceRegistration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Represents a Trigger module in the rules engine.
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@NonNullByDefault
+public class PWMTriggerHandler extends BaseTriggerModuleHandler implements EventSubscriber {
+ public static final String MODULE_TYPE_ID = AUTOMATION_NAME + ".trigger";
+ private static final Set SUBSCRIBED_EVENT_TYPES = Set.of(ItemStateEvent.TYPE);
+ private final Logger logger = LoggerFactory.getLogger(PWMTriggerHandler.class);
+ private final BundleContext bundleContext;
+ private final EventFilter eventFilter;
+ private final Optional minDutyCycle;
+ private final Optional maxDutyCycle;
+ private final Optional deadManSwitchTimeoutMs;
+ private final Item dutyCycleItem;
+ private @Nullable ServiceRegistration> eventSubscriberRegistration;
+ private @Nullable ScheduledFuture> deadMeanSwitchTimer;
+ private @Nullable StateMachine stateMachine;
+
+ public PWMTriggerHandler(Trigger module, ItemRegistry itemRegistry, BundleContext bundleContext) {
+ super(module);
+ this.bundleContext = bundleContext;
+
+ Configuration config = module.getConfiguration();
+
+ String dutycycleItemName = (String) Objects.requireNonNull(config.get(CONFIG_DUTY_CYCLE_ITEM),
+ "DutyCycle item is not set");
+
+ minDutyCycle = getOptionalDoubleFromConfig(config, CONFIG_MIN_DUTYCYCLE);
+ maxDutyCycle = getOptionalDoubleFromConfig(config, CONFIG_MAX_DUTYCYCLE);
+ deadManSwitchTimeoutMs = getOptionalDoubleFromConfig(config, CONFIG_DEAD_MAN_SWITCH);
+
+ try {
+ dutyCycleItem = itemRegistry.getItem(dutycycleItemName);
+ } catch (ItemNotFoundException e) {
+ throw new IllegalArgumentException("Dutycycle item not found: " + dutycycleItemName, e);
+ }
+
+ eventFilter = event -> event.getTopic().equals("openhab/items/" + dutycycleItemName + "/state");
+ }
+
+ @Override
+ public void setCallback(ModuleHandlerCallback callback) {
+ super.setCallback(callback);
+
+ double periodSec = getDoubleFromConfig(module.getConfiguration(), CONFIG_PERIOD);
+ stateMachine = new StateMachine(getCallback().getScheduler(), this::setOutput, (long) (periodSec * 1000));
+
+ eventSubscriberRegistration = bundleContext.registerService(EventSubscriber.class.getName(), this, null);
+ }
+
+ private double getDoubleFromConfig(Configuration config, String key) {
+ return ((BigDecimal) Objects.requireNonNull(config.get(key), key + " is not set")).doubleValue();
+ }
+
+ private Optional getOptionalDoubleFromConfig(Configuration config, String key) {
+ Object o = config.get(key);
+
+ if (o instanceof BigDecimal) {
+ return Optional.of(((BigDecimal) o).doubleValue());
+ }
+
+ return Optional.empty();
+ }
+
+ @Override
+ public void receive(Event event) {
+ if (!(event instanceof ItemStateEvent)) {
+ return;
+ }
+
+ ItemStateEvent changedEvent = (ItemStateEvent) event;
+ synchronized (this) {
+ try {
+ double newDutycycle = getDutyCycleValueInPercent(changedEvent.getItemState());
+ double newDutycycleBeforeLimit = newDutycycle;
+
+ restartDeadManSwitchTimer();
+
+ // set duty cycle to min duty cycle if it is smaller than min duty cycle
+ // set duty cycle to 0% if it is 0%, regardless of the min duty cycle
+ final double newDutyCycleFinal1 = newDutycycle;
+ newDutycycle = minDutyCycle.map(minDutycycle -> {
+ if (Math.round(newDutyCycleFinal1) <= 0) {
+ return 0d;
+ } else {
+ return Math.max(minDutycycle, newDutyCycleFinal1);
+ }
+ }).orElse(newDutycycle);
+
+ // set duty cycle to 100% if the current duty cycle is larger than the max duty cycle
+ final double newDutyCycleFinal2 = newDutycycle;
+ newDutycycle = maxDutyCycle.map(maxDutycycle -> {
+ if (Math.round(newDutyCycleFinal2) >= maxDutycycle) {
+ return 100d;
+ } else {
+ return newDutyCycleFinal2;
+ }
+ }).orElse(newDutycycle);
+
+ logger.debug("Received new duty cycle: {} {}", newDutycycleBeforeLimit,
+ newDutycycle != newDutycycleBeforeLimit ? "Limited to: " + newDutycycle : "");
+
+ StateMachine localStateMachine = stateMachine;
+ if (localStateMachine != null) {
+ localStateMachine.setDutycycle(newDutycycle);
+ } else {
+ logger.debug("Initialization not finished");
+ }
+ } catch (PWMException e) {
+ logger.warn("{}", e.getMessage());
+ }
+ }
+ }
+
+ private void restartDeadManSwitchTimer() {
+ ScheduledFuture> timer = deadMeanSwitchTimer;
+ if (timer != null) {
+ timer.cancel(true);
+ }
+
+ deadManSwitchTimeoutMs.ifPresent(timeout -> {
+ deadMeanSwitchTimer = getCallback().getScheduler().schedule(this::activateDeadManSwitch,
+ timeout.longValue(), TimeUnit.MILLISECONDS);
+ });
+ }
+
+ private void activateDeadManSwitch() {
+ logger.warn("Dead-man switch activated. Disabling output");
+
+ StateMachine localStateMachine = stateMachine;
+ if (localStateMachine != null) {
+ localStateMachine.stop();
+ }
+ }
+
+ private void setOutput(boolean enable) {
+ getCallback().triggered(module, Collections.singletonMap(OUTPUT, OnOffType.from(enable)));
+ }
+
+ private TriggerHandlerCallback getCallback() {
+ ModuleHandlerCallback localCallback = callback;
+ if (localCallback != null && localCallback instanceof TriggerHandlerCallback) {
+ return (TriggerHandlerCallback) localCallback;
+ }
+
+ throw new IllegalStateException();
+ }
+
+ private double getDutyCycleValueInPercent(State state) throws PWMException {
+ if (state instanceof DecimalType) {
+ return ((DecimalType) state).doubleValue();
+ } else if (state instanceof StringType) {
+ try {
+ return Integer.parseInt(state.toString());
+ } catch (NumberFormatException e) {
+ // nothing
+ }
+ } else if (state instanceof UnDefType) {
+ throw new PWMException("Duty cycle item '" + dutyCycleItem.getName() + "' has no valid value");
+ }
+ throw new PWMException("Duty cycle item not of type DecimalType: " + state.getClass().getSimpleName());
+ }
+
+ @Override
+ public Set getSubscribedEventTypes() {
+ return SUBSCRIBED_EVENT_TYPES;
+ }
+
+ @Override
+ public @Nullable EventFilter getEventFilter() {
+ return eventFilter;
+ }
+
+ @Override
+ public void dispose() {
+ ServiceRegistration> localEventSubscriberRegistration = eventSubscriberRegistration;
+ if (localEventSubscriberRegistration != null) {
+ localEventSubscriberRegistration.unregister();
+ }
+
+ StateMachine localStateMachine = stateMachine;
+ if (localStateMachine != null) {
+ localStateMachine.stop();
+ }
+
+ super.dispose();
+ }
+}
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/AlwaysOffState.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/AlwaysOffState.java
new file mode 100644
index 0000000000000..e8e21be7935c2
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/AlwaysOffState.java
@@ -0,0 +1,51 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal.handler.state;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Active when, the duty cycle is 0% for at least a whole period.
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@NonNullByDefault
+public class AlwaysOffState extends State {
+ public AlwaysOffState(StateMachine context) {
+ super(context);
+
+ controlOutput(false);
+ }
+
+ @Override
+ public void dutyCycleChanged() {
+ if (Math.round(context.getDutycycle()) >= 100) {
+ nextState(DutycycleHundredState::new);
+ } else {
+ nextState(OnState::new);
+ }
+ }
+
+ @Override
+ protected void dutyCycleUpdated() {
+ // in case we came here by the dead-man switch
+ if (Math.round(context.getDutycycle()) > 0) {
+ nextState(OnState::new);
+ }
+ }
+
+ @Override
+ public void dispose() {
+ // nothing
+ }
+}
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/AlwaysOnState.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/AlwaysOnState.java
new file mode 100644
index 0000000000000..53d49c0947561
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/AlwaysOnState.java
@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal.handler.state;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Active when, the duty cycle is 100% for at least a whole period.
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@NonNullByDefault
+public class AlwaysOnState extends State {
+ public AlwaysOnState(StateMachine context) {
+ super(context);
+
+ controlOutput(true);
+ }
+
+ @Override
+ public void dutyCycleChanged() {
+ nextState(OffState::new);
+ }
+
+ @Override
+ protected void dutyCycleUpdated() {
+ // nothing
+ }
+
+ @Override
+ public void dispose() {
+ // nothing
+ }
+}
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/DutycycleHundredState.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/DutycycleHundredState.java
new file mode 100644
index 0000000000000..121549c42c711
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/DutycycleHundredState.java
@@ -0,0 +1,87 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal.handler.state;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Active when, the PWM period ended with a duty cycle set to 100%.
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@NonNullByDefault
+public class DutycycleHundredState extends State {
+ private ScheduledFuture> periodTimer;
+ private @Nullable ScheduledFuture> offTimer;
+ private Instant enabledAt = Instant.now();
+ private boolean dutyCycleChanged;
+
+ public DutycycleHundredState(StateMachine context) {
+ super(context);
+
+ controlOutput(true);
+
+ periodTimer = scheduler.schedule(this::periodEnded, context.getPeriodMs(), TimeUnit.MILLISECONDS);
+ }
+
+ private void periodEnded() {
+ long dutycycleRounded = Math.round(context.getDutycycle());
+
+ if (!dutyCycleChanged && dutycycleRounded <= 0) {
+ nextState(AlwaysOffState::new);
+ } else if (!dutyCycleChanged && dutycycleRounded >= 100) {
+ nextState(AlwaysOnState::new);
+ } else {
+ nextState(OnState::new);
+ }
+ }
+
+ @Override
+ public void dutyCycleChanged() {
+ dutyCycleChanged = true;
+
+ long newOnTimeMs = calculateOnTimeMs(context.getDutycycle());
+ long elapsedMs = enabledAt.until(Instant.now(), ChronoUnit.MILLIS);
+
+ if (elapsedMs - newOnTimeMs > 0) {
+ controlOutput(false);
+ } else {
+ ScheduledFuture> timer = offTimer;
+ if (timer != null) {
+ timer.cancel(false);
+ }
+ offTimer = scheduler.schedule(() -> controlOutput(false), newOnTimeMs - elapsedMs, TimeUnit.MILLISECONDS);
+ }
+ }
+
+ @Override
+ protected void dutyCycleUpdated() {
+ // nothing
+ }
+
+ @Override
+ public void dispose() {
+ periodTimer.cancel(false);
+
+ ScheduledFuture> timer = offTimer;
+ if (timer != null) {
+ timer.cancel(false);
+ }
+ }
+}
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/DutycycleZeroState.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/DutycycleZeroState.java
new file mode 100644
index 0000000000000..59e3a12508a09
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/DutycycleZeroState.java
@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal.handler.state;
+
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Active when, the PWM period ended with a duty cycle set to 0%.
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@NonNullByDefault
+public class DutycycleZeroState extends State {
+ private ScheduledFuture> periodTimer;
+
+ public DutycycleZeroState(StateMachine context) {
+ super(context);
+
+ controlOutput(false);
+
+ periodTimer = scheduler.schedule(this::periodEnded, context.getPeriodMs(), TimeUnit.MILLISECONDS);
+ }
+
+ private void periodEnded() {
+ long dutycycleRounded = Math.round(context.getDutycycle());
+
+ if (dutycycleRounded <= 0) {
+ nextState(AlwaysOffState::new);
+ } else if (dutycycleRounded >= 100) {
+ nextState(DutycycleHundredState::new);
+ } else {
+ nextState(OnState::new);
+ }
+ }
+
+ @Override
+ public void dutyCycleChanged() {
+ // nothing
+ }
+
+ @Override
+ protected void dutyCycleUpdated() {
+ // nothing
+ }
+
+ @Override
+ public void dispose() {
+ periodTimer.cancel(false);
+ }
+}
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/OffState.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/OffState.java
new file mode 100644
index 0000000000000..0762d2da6cda7
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/OffState.java
@@ -0,0 +1,64 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal.handler.state;
+
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Active when, the output is currently OFF and the duty cycle is between 0% and 100% (exclusively).
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@NonNullByDefault
+public class OffState extends State {
+ ScheduledFuture> offTimer;
+
+ public OffState(StateMachine context) {
+ super(context);
+
+ controlOutput(false);
+
+ long offTimeMs = context.getPeriodMs() - calculateOnTimeMs(context.getDutycycle());
+ offTimer = scheduler.schedule(this::periodEnded, offTimeMs, TimeUnit.MILLISECONDS);
+ }
+
+ private void periodEnded() {
+ long dutycycleRounded = Math.round(context.getDutycycle());
+
+ if (dutycycleRounded <= 0) {
+ nextState(DutycycleZeroState::new);
+ } else if (dutycycleRounded >= 100) {
+ nextState(DutycycleHundredState::new);
+ } else {
+ nextState(OnState::new);
+ }
+ }
+
+ @Override
+ public void dutyCycleChanged() {
+ // nothing
+ }
+
+ @Override
+ protected void dutyCycleUpdated() {
+ // nothing
+ }
+
+ @Override
+ public void dispose() {
+ offTimer.cancel(false);
+ }
+}
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/OnState.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/OnState.java
new file mode 100644
index 0000000000000..e1c22c24cd30a
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/OnState.java
@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal.handler.state;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Active when, the output is currently ON and the duty cycle is between 0% and 100% (exclusively).
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@NonNullByDefault
+public class OnState extends State {
+ private @NonNullByDefault({}) ScheduledFuture> offTimer;
+ private Instant enabledAt = Instant.now();
+
+ public OnState(StateMachine context) {
+ super(context);
+
+ context.controlOutput(true);
+
+ startOnTimer(calculateOnTimeMs(context.getDutycycle()));
+ }
+
+ private void startOnTimer(long timeMs) {
+ offTimer = scheduler.schedule(() -> {
+ if (Math.round(context.getDutycycle()) >= 100) {
+ nextState(DutycycleHundredState::new);
+ } else {
+ nextState(OffState::new);
+ }
+ }, timeMs, TimeUnit.MILLISECONDS);
+ }
+
+ @Override
+ public void dutyCycleChanged() {
+ // end current ON phase prematurely or extend it if the new duty cycle demands it
+ offTimer.cancel(false);
+
+ long newOnTimeMs = calculateOnTimeMs(context.getDutycycle());
+ long elapsedMs = enabledAt.until(Instant.now(), ChronoUnit.MILLIS);
+
+ if (elapsedMs - newOnTimeMs > 0) {
+ nextState(OffState::new);
+ } else {
+ startOnTimer(newOnTimeMs - elapsedMs);
+ }
+ }
+
+ @Override
+ protected void dutyCycleUpdated() {
+ // nothing
+ }
+
+ @Override
+ public void dispose() {
+ offTimer.cancel(false);
+ }
+}
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/State.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/State.java
new file mode 100644
index 0000000000000..2bf490b5e9aab
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/State.java
@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal.handler.state;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.function.Function;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The base class of all states.
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@NonNullByDefault
+public abstract class State {
+ private final Logger logger = LoggerFactory.getLogger(State.class);
+ protected StateMachine context;
+ protected ScheduledExecutorService scheduler;
+
+ public State(StateMachine context) {
+ this.context = context;
+ this.scheduler = context.getScheduler();
+ }
+
+ /**
+ * Invoked when the duty cycle updated and changed.
+ */
+ public abstract void dutyCycleChanged();
+
+ /**
+ * Invoked when the duty cycle updated.
+ */
+ protected abstract void dutyCycleUpdated();
+
+ public abstract void dispose();
+
+ /**
+ * Sets a new state in the state machine.
+ */
+ public synchronized void nextState(Function nextState) {
+ if (context.getState() != this) { // compare identity
+ return;
+ }
+
+ context.getState().dispose();
+ State newState = nextState.apply(context);
+
+ logger.trace("{} -> {}", context.getState().getClass().getSimpleName(), newState.getClass().getSimpleName());
+
+ context.setState(newState);
+ }
+
+ /**
+ * Calculates the ON duration by the duty cycle.
+ *
+ * @param dutyCycleInPercent the duty cycle in percent
+ * @return the ON duration in ms
+ */
+ protected long calculateOnTimeMs(double dutyCycleInPercent) {
+ return (long) (context.getPeriodMs() / 100 * dutyCycleInPercent);
+ }
+
+ /**
+ * Switches the output on or off.
+ *
+ * @param on true, if the output shall be switched on.
+ */
+ protected void controlOutput(boolean on) {
+ context.controlOutput(on);
+ }
+}
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/StateMachine.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/StateMachine.java
new file mode 100644
index 0000000000000..47c8454e5dfe8
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/handler/state/StateMachine.java
@@ -0,0 +1,80 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal.handler.state;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.function.Consumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The context of all states.
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@NonNullByDefault
+public class StateMachine {
+ private ScheduledExecutorService scheduler;
+ private Consumer controlOutput;
+ private State state;
+ private long periodMs;
+ private double dutycycle;
+
+ public StateMachine(ScheduledExecutorService scheduler, Consumer controlOutput, long periodMs) {
+ this.scheduler = scheduler;
+ this.controlOutput = controlOutput;
+ this.periodMs = periodMs;
+ this.state = new AlwaysOffState(this);
+ }
+
+ public ScheduledExecutorService getScheduler() {
+ return scheduler;
+ }
+
+ public void setDutycycle(double newDutycycle) {
+ if (dutycycle != newDutycycle) {
+ this.dutycycle = newDutycycle;
+ state.dutyCycleChanged();
+ }
+
+ state.dutyCycleUpdated();
+ }
+
+ public double getDutycycle() {
+ return dutycycle;
+ }
+
+ public long getPeriodMs() {
+ return periodMs;
+ }
+
+ public State getState() {
+ return state;
+ }
+
+ public void setState(State current) {
+ this.state = current;
+ }
+
+ public void controlOutput(boolean on) {
+ controlOutput.accept(on);
+ }
+
+ public void reset() {
+ state.nextState(OnState::new);
+ }
+
+ public void stop() {
+ state.nextState(AlwaysOffState::new);
+ }
+}
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/template/PWMRuleTemplate.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/template/PWMRuleTemplate.java
new file mode 100644
index 0000000000000..cf715d72c64aa
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/template/PWMRuleTemplate.java
@@ -0,0 +1,64 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal.template;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.automation.pwm.internal.PWMConstants;
+import org.openhab.automation.pwm.internal.type.PWMTriggerType;
+import org.openhab.core.automation.Action;
+import org.openhab.core.automation.Condition;
+import org.openhab.core.automation.Trigger;
+import org.openhab.core.automation.Visibility;
+import org.openhab.core.automation.template.RuleTemplate;
+import org.openhab.core.automation.util.ModuleBuilder;
+import org.openhab.core.config.core.ConfigDescriptionParameter;
+
+/**
+ * Rule template for the PWM automation module.
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@NonNullByDefault
+public class PWMRuleTemplate extends RuleTemplate {
+ public static final String UID = "PWMRuleTemplate";
+
+ public static PWMRuleTemplate initialize() {
+ final String triggerId = UUID.randomUUID().toString();
+
+ final List triggers = Collections.singletonList(ModuleBuilder.createTrigger().withId(triggerId)
+ .withTypeUID(PWMTriggerType.UID).withLabel("PWM Trigger").build());
+
+ final Map actionInputs = new HashMap();
+ actionInputs.put(PWMConstants.INPUT, triggerId + "." + PWMConstants.OUTPUT);
+
+ Set tags = new HashSet();
+ tags.add("PWM");
+
+ return new PWMRuleTemplate(tags, triggers, Collections.emptyList(), Collections.emptyList(),
+ Collections.emptyList());
+ }
+
+ public PWMRuleTemplate(Set tags, List triggers, List conditions, List actions,
+ List configDescriptions) {
+ super(UID, "PWM", "Template for a PWM rule", tags, triggers, conditions, actions, configDescriptions,
+ Visibility.VISIBLE);
+ }
+}
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/template/PWMTemplateProvider.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/template/PWMTemplateProvider.java
new file mode 100644
index 0000000000000..87fc455d9f1a4
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/template/PWMTemplateProvider.java
@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal.template;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.automation.template.RuleTemplate;
+import org.openhab.core.automation.template.RuleTemplateProvider;
+import org.openhab.core.common.registry.ProviderChangeListener;
+import org.osgi.service.component.annotations.Component;
+
+/**
+ * Rule template provider for the PWM automation module.
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@Component
+@NonNullByDefault
+public class PWMTemplateProvider implements RuleTemplateProvider {
+ private final Map providedRuleTemplates = new HashMap();
+
+ public PWMTemplateProvider() {
+ providedRuleTemplates.put(PWMRuleTemplate.UID, PWMRuleTemplate.initialize());
+ }
+
+ @Override
+ @Nullable
+ public RuleTemplate getTemplate(String UID, @Nullable Locale locale) {
+ return providedRuleTemplates.get(UID);
+ }
+
+ @Override
+ public Collection getTemplates(@Nullable Locale locale) {
+ return providedRuleTemplates.values();
+ }
+
+ @Override
+ public void addProviderChangeListener(ProviderChangeListener listener) {
+ // does nothing because this provider does not change
+ }
+
+ @Override
+ public Collection getAll() {
+ return Collections.unmodifiableCollection(providedRuleTemplates.values());
+ }
+
+ @Override
+ public void removeProviderChangeListener(ProviderChangeListener listener) {
+ // does nothing because this provider does not change
+ }
+}
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/type/PWMModuleTypeProvider.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/type/PWMModuleTypeProvider.java
new file mode 100644
index 0000000000000..2db14925d489d
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/type/PWMModuleTypeProvider.java
@@ -0,0 +1,65 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal.type;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Locale;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.automation.pwm.internal.handler.PWMTriggerHandler;
+import org.openhab.core.automation.type.ModuleType;
+import org.openhab.core.automation.type.ModuleTypeProvider;
+import org.openhab.core.common.registry.ProviderChangeListener;
+import org.osgi.service.component.annotations.Component;
+
+/**
+ * Provides the module types for the rules engine.
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@Component
+@NonNullByDefault
+public class PWMModuleTypeProvider implements ModuleTypeProvider {
+ private static final Map PROVIDED_MODULE_TYPES = Map.of(PWMTriggerHandler.MODULE_TYPE_ID,
+ PWMTriggerType.initialize());
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public T getModuleType(@Nullable String UID, @Nullable Locale locale) {
+ return (T) PROVIDED_MODULE_TYPES.get(UID);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public Collection getModuleTypes(@Nullable Locale locale) {
+ return (Collection) PROVIDED_MODULE_TYPES.values();
+ }
+
+ @Override
+ public void addProviderChangeListener(ProviderChangeListener listener) {
+ // does nothing because this provider does not change
+ }
+
+ @Override
+ public Collection getAll() {
+ return Collections.unmodifiableCollection(PROVIDED_MODULE_TYPES.values());
+ }
+
+ @Override
+ public void removeProviderChangeListener(ProviderChangeListener listener) {
+ // does nothing because this provider does not change
+ }
+}
diff --git a/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/type/PWMTriggerType.java b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/type/PWMTriggerType.java
new file mode 100644
index 0000000000000..f0859328d6229
--- /dev/null
+++ b/bundles/org.openhab.automation.pwm/src/main/java/org/openhab/automation/pwm/internal/type/PWMTriggerType.java
@@ -0,0 +1,95 @@
+/**
+ * Copyright (c) 2010-2021 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.automation.pwm.internal.type;
+
+import static org.openhab.automation.pwm.internal.PWMConstants.*;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.automation.pwm.internal.handler.PWMTriggerHandler;
+import org.openhab.core.automation.Visibility;
+import org.openhab.core.automation.type.Output;
+import org.openhab.core.automation.type.TriggerType;
+import org.openhab.core.config.core.ConfigDescriptionParameter;
+import org.openhab.core.config.core.ConfigDescriptionParameter.Type;
+import org.openhab.core.config.core.ConfigDescriptionParameterBuilder;
+import org.openhab.core.library.types.OnOffType;
+
+/**
+ * Creates the configuration for the Trigger module in the rules engine.
+ *
+ * @author Fabian Wolter - Initial Contribution
+ */
+@NonNullByDefault
+public class PWMTriggerType extends TriggerType {
+ public static final String UID = PWMTriggerHandler.MODULE_TYPE_ID;
+
+ public static PWMTriggerType initialize() {
+ List configDescriptions = new ArrayList<>();
+ configDescriptions.add(ConfigDescriptionParameterBuilder.create(CONFIG_DUTY_CYCLE_ITEM, Type.TEXT) //
+ .withRequired(true) //
+ .withMultiple(false) //
+ .withContext("item") //
+ .withLabel("Dutycycle Item").withDescription("Item to read the current dutycycle from (PercentType)")
+ .build());
+ configDescriptions.add(ConfigDescriptionParameterBuilder.create(CONFIG_PERIOD, Type.DECIMAL) //
+ .withRequired(true) //
+ .withMultiple(false) //
+ .withDefault("600") //
+ .withLabel("PWM Interval") //
+ .withUnit("s") //
+ .withDescription("Duration of the PWM interval in sec.").build());
+ configDescriptions.add(ConfigDescriptionParameterBuilder.create(CONFIG_MIN_DUTYCYCLE, Type.DECIMAL) //
+ .withRequired(false) //
+ .withMultiple(false) //
+ .withMinimum(BigDecimal.ZERO) //
+ .withMaximum(BigDecimal.valueOf(100)) //
+ .withDefault("0") //
+ .withLabel("Min Dutycycle") //
+ .withUnit("%") //
+ .withDescription("The dutycycle will be min this value").build());
+ configDescriptions.add(ConfigDescriptionParameterBuilder.create(CONFIG_MAX_DUTYCYCLE, Type.DECIMAL) //
+ .withRequired(false) //
+ .withMultiple(false) //
+ .withMinimum(BigDecimal.ZERO) //
+ .withMaximum(BigDecimal.valueOf(100)) //
+ .withDefault("100") //
+ .withUnit("%") //
+ .withLabel("Max Dutycycle") //
+ .withDescription("The dutycycle will be max this value").build());
+ configDescriptions.add(ConfigDescriptionParameterBuilder.create(CONFIG_DEAD_MAN_SWITCH, Type.DECIMAL) //
+ .withRequired(false) //
+ .withMultiple(false) //
+ .withMinimum(BigDecimal.ZERO) //
+ .withDefault("") //
+ .withLabel("Dead Man Switch") //
+ .withUnit("ms") //
+ .withDescription(
+ "If the duty cycle Item is not updated within this time (in ms), the output is switched off")
+ .build());
+
+ List