From bfb4bba664f0660b06b5288e46d086bd371bd768 Mon Sep 17 00:00:00 2001
From: Joachim Dick <62520542+JoaDick@users.noreply.github.com>
Date: Wed, 21 Jan 2026 02:11:35 +0100
Subject: [PATCH 1/4] Usermods on Steroids: A Plugin Framework
Plugins: MVP for effects to get data from unknown usermods
Plugins: implemented class PluginManager + plugin API PinUser and HumiditySensor
Plugins: plugins can be accessed directly from other plugins and effects
Plugins: optimized datastructures inside PluginManager
Plugins: documentation
Plugins: added PluginManager to UI info page
Plugins: added development notes
... and some final polishing
---
usermods/DHT/DHT.cpp | 18 +-
usermods/Temperature/Temperature.cpp | 9 +-
usermods/Temperature/UsermodTemperature.h | 4 +-
usermods/UM_DummySensor/README.md | 3 +
usermods/UM_DummySensor/UM_DummySensor.cpp | 104 +++++
usermods/UM_DummySensor/library.json | 4 +
usermods/UM_PluginDemo/README.md | 3 +
usermods/UM_PluginDemo/UM_PluginDemo.cpp | 174 ++++++++
usermods/UM_PluginDemo/library.json | 4 +
wled00/PluginAPI/DevNotes.md | 42 ++
wled00/PluginAPI/HumiditySensor.h | 28 ++
wled00/PluginAPI/PinUser.h | 83 ++++
wled00/PluginAPI/PluginMacros.h | 68 +++
wled00/PluginAPI/PluginManager.cpp | 418 ++++++++++++++++++
wled00/PluginAPI/PluginManager.h | 120 +++++
wled00/PluginAPI/README.md | 222 ++++++++++
wled00/PluginAPI/TemperatureSensor.h | 46 ++
.../custom/DummySensor/DummySensor.cpp | 7 +
.../custom/DummySensor/DummySensor.h | 30 ++
wled00/PluginAPI/custom/README.md | 4 +
wled00/pin_manager.h | 1 +
wled00/um_manager.cpp | 3 +-
wled00/wled.h | 1 +
23 files changed, 1387 insertions(+), 9 deletions(-)
create mode 100644 usermods/UM_DummySensor/README.md
create mode 100644 usermods/UM_DummySensor/UM_DummySensor.cpp
create mode 100644 usermods/UM_DummySensor/library.json
create mode 100644 usermods/UM_PluginDemo/README.md
create mode 100644 usermods/UM_PluginDemo/UM_PluginDemo.cpp
create mode 100644 usermods/UM_PluginDemo/library.json
create mode 100644 wled00/PluginAPI/DevNotes.md
create mode 100644 wled00/PluginAPI/HumiditySensor.h
create mode 100644 wled00/PluginAPI/PinUser.h
create mode 100644 wled00/PluginAPI/PluginMacros.h
create mode 100644 wled00/PluginAPI/PluginManager.cpp
create mode 100644 wled00/PluginAPI/PluginManager.h
create mode 100644 wled00/PluginAPI/README.md
create mode 100644 wled00/PluginAPI/TemperatureSensor.h
create mode 100644 wled00/PluginAPI/custom/DummySensor/DummySensor.cpp
create mode 100644 wled00/PluginAPI/custom/DummySensor/DummySensor.h
create mode 100644 wled00/PluginAPI/custom/README.md
diff --git a/usermods/DHT/DHT.cpp b/usermods/DHT/DHT.cpp
index 2ed3dd0ace..6840856c2f 100644
--- a/usermods/DHT/DHT.cpp
+++ b/usermods/DHT/DHT.cpp
@@ -20,6 +20,8 @@
#define DHTTYPE DHT_TYPE_21
#elif USERMOD_DHT_DHTTYPE == 22
#define DHTTYPE DHT_TYPE_22
+#else
+#error Invalid USERMOD_DHT_DHTTYPE
#endif
// Connect pin 1 (on the left) of the sensor to +5V
@@ -57,11 +59,11 @@
DHT_nonblocking dht_sensor(DHTPIN, DHTTYPE);
-class UsermodDHT : public Usermod {
+class UsermodDHT : public Usermod, public TemperatureSensor, public HumiditySensor {
private:
unsigned long nextReadTime = 0;
unsigned long lastReadTime = 0;
- float humidity, temperature = 0;
+ float tempC, humidity, temperature = 0;
bool initializing = true;
bool disabled = false;
#ifdef USERMOD_DHT_MQTT
@@ -89,6 +91,8 @@ class UsermodDHT : public Usermod {
#ifdef USERMOD_DHT_STATS
nextResetStatsTime = millis() + 60*60*1000;
#endif
+ pluginManager.registerTemperatureSensor(*this, "DHT");
+ pluginManager.registerHumiditySensor(*this, "DHT");
}
void loop() {
@@ -112,13 +116,14 @@ class UsermodDHT : public Usermod {
}
#endif
- float tempC;
if (dht_sensor.measure(&tempC, &humidity)) {
#ifdef USERMOD_DHT_CELSIUS
temperature = tempC;
#else
temperature = tempC * 9 / 5 + 32;
#endif
+ _isTemperatureValid = true;
+ _isHumidityValid = true;
#ifdef USERMOD_DHT_MQTT
// 10^n where n is number of decimal places to display in mqtt message. Please adjust buff size together with this constant
@@ -168,6 +173,8 @@ class UsermodDHT : public Usermod {
if (((millis() - lastReadTime) > 10*USERMOD_DHT_MEASUREMENT_INTERVAL)) {
disabled = true;
+ _isTemperatureValid = false;
+ _isHumidityValid = false;
}
}
@@ -242,8 +249,11 @@ class UsermodDHT : public Usermod {
return USERMOD_ID_DHT;
}
+ private:
+ float do_getTemperatureC() override { return tempC; }
+ float do_getHumidity() override { return humidity; }
};
static UsermodDHT dht;
-REGISTER_USERMOD(dht);
\ No newline at end of file
+REGISTER_USERMOD(dht);
diff --git a/usermods/Temperature/Temperature.cpp b/usermods/Temperature/Temperature.cpp
index c86b9e9842..ff3bdff5ec 100644
--- a/usermods/Temperature/Temperature.cpp
+++ b/usermods/Temperature/Temperature.cpp
@@ -141,6 +141,7 @@ void UsermodTemperature::setup() {
}
lastMeasurement = millis() - readingInterval + 10000;
initDone = true;
+ pluginManager.registerTemperatureSensor(*this, _name);
}
void UsermodTemperature::loop() {
@@ -165,11 +166,15 @@ void UsermodTemperature::loop() {
if (now - lastTemperaturesRequest >= 750 /* 93.75ms per the datasheet but can be up to 750ms */) {
readTemperature();
if (getTemperatureC() < -100.0f) {
- if (++errorCount > 10) sensorFound = 0;
+ if (++errorCount > 10) {
+ sensorFound = 0;
+ _isTemperatureValid = false;
+ }
lastMeasurement = now - readingInterval + 300; // force new measurement in 300ms
return;
}
errorCount = 0;
+ _isTemperatureValid = true;
#ifndef WLED_DISABLE_MQTT
if (WLED_MQTT_CONNECTED) {
@@ -380,4 +385,4 @@ static uint16_t mode_temperature() {
static UsermodTemperature temperature;
-REGISTER_USERMOD(temperature);
\ No newline at end of file
+REGISTER_USERMOD(temperature);
diff --git a/usermods/Temperature/UsermodTemperature.h b/usermods/Temperature/UsermodTemperature.h
index 555b57cf7a..4ffde065e8 100644
--- a/usermods/Temperature/UsermodTemperature.h
+++ b/usermods/Temperature/UsermodTemperature.h
@@ -16,7 +16,7 @@
#define USERMOD_DALLASTEMPERATURE_MEASUREMENT_INTERVAL 60000
#endif
-class UsermodTemperature : public Usermod {
+class UsermodTemperature : public Usermod, public TemperatureSensor {
private:
@@ -71,6 +71,7 @@ class UsermodTemperature : public Usermod {
#ifndef WLED_DISABLE_MQTT
void publishHomeAssistantAutodiscovery();
#endif
+ float do_getTemperatureC() override { return temperature; }
static UsermodTemperature* _instance; // to overcome nonstatic getTemperatureC() method and avoid UsermodManager::lookup(USERMOD_ID_TEMPERATURE);
@@ -107,4 +108,3 @@ class UsermodTemperature : public Usermod {
void appendConfigData() override;
};
-
diff --git a/usermods/UM_DummySensor/README.md b/usermods/UM_DummySensor/README.md
new file mode 100644
index 0000000000..85988da363
--- /dev/null
+++ b/usermods/UM_DummySensor/README.md
@@ -0,0 +1,3 @@
+# Dummy usermod to simulate random sensor readings.
+
+Just as an example for the plugin API.
diff --git a/usermods/UM_DummySensor/UM_DummySensor.cpp b/usermods/UM_DummySensor/UM_DummySensor.cpp
new file mode 100644
index 0000000000..c7e200505d
--- /dev/null
+++ b/usermods/UM_DummySensor/UM_DummySensor.cpp
@@ -0,0 +1,104 @@
+/**
+ * (c) 2026 Joachim Dick
+ * Licensed under the EUPL v. 1.2 or later
+ */
+
+#include "wled.h"
+#include "PluginAPI/custom/DummySensor/DummySensor.h"
+
+//--------------------------------------------------------------------------------------------------
+
+static constexpr char _name[] = "Dummy-Sensor";
+
+/** Dummy usermod implementation that simulates random sensor readings.
+ */
+class UM_DummySensor : public Usermod, public DummySensor, public TemperatureSensor, public HumiditySensor
+{
+
+ // ----- usermod functions -----
+
+ void setup() override
+ {
+ // register sensor plugins
+ pluginManager.registerTemperatureSensor(*this, _name);
+ enableTemperatureSensor();
+ enableHumiditySensor();
+ _isTemperatureValid = true;
+ _isHumidityValid = true;
+ }
+
+ void loop() override {}
+
+ // ----- DummySensor (our own) custom plugin functions -----
+
+ void enableTemperatureSensor() override
+ {
+ if (!_isTemperatureSensorEnabled)
+ pluginManager.registerTemperatureSensor(*this, _name);
+ _isTemperatureSensorEnabled = true;
+ }
+
+ void disableTemperatureSensor() override
+ {
+ if (_isTemperatureSensorEnabled)
+ pluginManager.unregisterTemperatureSensor(*this);
+ _isTemperatureSensorEnabled = false;
+ }
+
+ bool isTemperatureSensorEnabled() override { return _isTemperatureSensorEnabled; }
+
+ void enableHumiditySensor() override
+ {
+ if (!_isHumiditySensorEnabled)
+ pluginManager.registerHumiditySensor(*this, _name);
+ _isHumiditySensorEnabled = true;
+ }
+
+ void disableHumiditySensor() override
+ {
+ if (_isHumiditySensorEnabled)
+ pluginManager.unregisterHumiditySensor(*this);
+ _isHumiditySensorEnabled = false;
+ }
+
+ bool isHumiditySensorEnabled() override { return _isHumiditySensorEnabled; }
+
+ // ----- TemperatureSensor plugin functions -----
+
+ /// @copydoc TemperatureSensor::do_getTemperatureC()
+ float do_getTemperatureC() override { return readTemperature(); }
+
+ // ----- HumiditySensor plugin functions -----
+
+ /// @copydoc HumiditySensor::do_getHumidityC()
+ float do_getHumidity() override { return readHumidity(); }
+
+ // ----- internal processing functions -----
+
+ /// The dummy implementation to simulate temperature values (based on perlin noise).
+ float readTemperature()
+ {
+ const int32_t raw = perlin16(strip.now * 8) - 0x8000;
+ // simulate some random temperature around 20°C
+ return 20.0f + raw / 65535.0f * 30.0f;
+ }
+
+ /// The dummy implementation to simulate humidity values (a sine wave).
+ float readHumidity()
+ {
+ const int32_t raw = beatsin16_t(1);
+ // simulate some random humidity between 10% and 90%
+ return 10.0f + raw / 65535.0f * 80.0f;
+ }
+
+ // ----- member variables -----
+
+ bool _isTemperatureSensorEnabled = false;
+ bool _isHumiditySensorEnabled = false;
+};
+
+//--------------------------------------------------------------------------------------------------
+
+static UM_DummySensor um_DummySensor;
+REGISTER_USERMOD(um_DummySensor);
+DEFINE_PLUGIN_API(DummySensor, um_DummySensor);
diff --git a/usermods/UM_DummySensor/library.json b/usermods/UM_DummySensor/library.json
new file mode 100644
index 0000000000..6ff42239d3
--- /dev/null
+++ b/usermods/UM_DummySensor/library.json
@@ -0,0 +1,4 @@
+{
+ "name": "UM_DummySensor",
+ "build": { "libArchive": false }
+}
diff --git a/usermods/UM_PluginDemo/README.md b/usermods/UM_PluginDemo/README.md
new file mode 100644
index 0000000000..fd3f88e026
--- /dev/null
+++ b/usermods/UM_PluginDemo/README.md
@@ -0,0 +1,3 @@
+# Examples custom plugins.
+
+This usermod provides examples for custom plugin implementations.
diff --git a/usermods/UM_PluginDemo/UM_PluginDemo.cpp b/usermods/UM_PluginDemo/UM_PluginDemo.cpp
new file mode 100644
index 0000000000..7cd33c2ae8
--- /dev/null
+++ b/usermods/UM_PluginDemo/UM_PluginDemo.cpp
@@ -0,0 +1,174 @@
+/**
+ * (c) 2026 Joachim Dick
+ * Licensed under the EUPL v. 1.2 or later
+ */
+
+#include "wled.h"
+#include "PluginAPI/custom/DummySensor/DummySensor.h"
+
+// override this via PlatformIO with your custom LED / relay GPIO
+#ifndef UM_PLUGIN_DEMO_RELAY_PIN
+#define UM_PLUGIN_DEMO_RELAY_PIN 8 // Pin number of a relay or LED.
+#endif
+
+extern uint16_t mode_static(void);
+
+//--------------------------------------------------------------------------------------------------
+
+/** Example effect for processing the readings of a TemperatureSensor and a HumiditySensor plugin.
+ * It also shows how to access the custom API of a plugin, which may or may not be compiled into
+ * the WLED binary.
+ */
+uint16_t mode_Thermometer()
+{
+ SEGMENT.clear();
+
+ // try to get the specific API of the DummySensor
+ DummySensor *dummySensor = GET_PLUGIN_API(DummySensor);
+ if (dummySensor)
+ {
+ // fill the background with light yellow when we're connected to the DummySensor
+ SEGMENT.fill(0x080800);
+ // enable/disable its TemperatureSensor
+ if (SEGMENT.check3)
+ {
+ if (!dummySensor->isTemperatureSensorEnabled())
+ dummySensor->enableTemperatureSensor();
+ }
+ else
+ {
+ if (dummySensor->isTemperatureSensorEnabled())
+ dummySensor->disableTemperatureSensor();
+ }
+ // enable/disable its HumiditySensor
+ if (SEGMENT.check2)
+ {
+ if (!dummySensor->isHumiditySensorEnabled())
+ dummySensor->enableHumiditySensor();
+ }
+ else
+ {
+ if (dummySensor->isHumiditySensorEnabled())
+ dummySensor->disableHumiditySensor();
+ }
+ }
+
+ // try to get an external Temperature- and HumiditySensor
+ TemperatureSensor *tempSensor = pluginManager.getTemperatureSensor();
+ HumiditySensor *humSensor = pluginManager.getHumiditySensor();
+ if (!tempSensor && !humSensor)
+ return mode_static();
+
+ // got a TemperatureSensor? --> draw its reading as a bar
+ if (tempSensor && tempSensor->isReady())
+ {
+ // read the temperature value from the plugin
+ const float temp = tempSensor->temperatureC();
+ // and draw a representing bar in the FX color
+ int tempPos = temp * (SEGLEN - 1) / 40.0f; // 20° shall be in the middle
+ while (tempPos >= 0)
+ SEGMENT.setPixelColor(tempPos--, fast_color_scale(SEGCOLOR(0), 64));
+ }
+
+ // draw some dots as scale
+ SEGMENT.setPixelColor(SEGLEN / 4, 0x8080F8); // 10°C
+ SEGMENT.setPixelColor(SEGLEN / 2, 0x808080); // 20°C
+ SEGMENT.setPixelColor(SEGLEN * 3 / 4, 0xF88080); // 30°C
+
+ // got a HumiditySensor? --> draw its reading as a dot
+ if (humSensor && humSensor->isReady())
+ {
+ // read the humidity value from the plugin
+ const float hum = humSensor->humidity();
+ // and draw a representing dot in magenta
+ const int humPos = hum * (SEGLEN - 1) / 100.0f;
+ SEGMENT.setPixelColor(humPos, 0xFF00FF);
+ }
+
+ return FRAMETIME;
+}
+static const char _data_FX_MODE_EX_UM_THERMOMETER[] PROGMEM = "Ex: Thermometer@,,,,,,Humidity Dummy,Temperature Dummy;!;;o2=1,o3=1";
+
+//--------------------------------------------------------------------------------------------------
+
+static constexpr char _name[] = "Demo-Plugin";
+
+/** A usermod as plugin example.
+ */
+class UM_PluginDemo : public Usermod, public PinUser
+{
+ // ----- usermod functions -----
+
+ void setup() override
+ {
+ registerEffects();
+ registerPins();
+ }
+
+ void loop() override
+ {
+ processFanControl();
+ }
+
+ // ----- initialization helper functions -----
+
+ void registerEffects()
+ {
+ strip.addEffect(255, &mode_Thermometer, _data_FX_MODE_EX_UM_THERMOMETER);
+ }
+
+ void registerPins()
+ {
+ // for single pin configuration:
+ // _pinConfig.pinNr = UM_PLUGIN_DEMO_RELAY_PIN;
+ _pinConfig[0].pinNr = UM_PLUGIN_DEMO_RELAY_PIN;
+ if (pluginManager.registerPinUser(*this, _pinConfig, _name))
+ {
+ pinMode(_pinConfig[0].pinNr, OUTPUT);
+ }
+ }
+
+ // ----- internal processing functions -----
+
+ /// Just an example for custom plugin sensor data processing.
+ void processFanControl()
+ {
+ // quit if we didn't get a GPIO
+ if (!_pinConfig[0].isPinValid())
+ return;
+
+ // try to get a TemperatureSensor - and quit if that fails
+ TemperatureSensor *tempSensor = pluginManager.getTemperatureSensor();
+ if (!tempSensor)
+ return;
+
+ // read the temperature value from the other plugin
+ const float temp = tempSensor->temperatureC();
+ // and control a connected fan via relay
+ const bool isHot = temp > 20.0f;
+ digitalWrite(_pinConfig[0].pinNr, isHot ? HIGH : LOW);
+ }
+
+ // ----- member variables -----
+
+ PinConfigs<12> _pinConfig{{{PinType::Digital_out, "Relay"},
+ // the following are just demo examples:
+ {PinType::Digital_in},
+ {PinType::Digital_out},
+ {PinType::PWM_out},
+ {PinType::I2C_scl},
+ {PinType::I2C_sda},
+ {PinType::OneWire},
+ {PinType::OneWire, "1W-aux"}, // 2nd bus with different name
+ {PinType::SPI_miso},
+ {PinType::SPI_mosi},
+ {PinType::SPI_sclk},
+ {PinType::Analog_in}}};
+ // how a single pin configuration would look like:
+ // PinConfig _pinConfig{PinType::Digital_out, "Relay"};
+};
+
+//--------------------------------------------------------------------------------------------------
+
+static UM_PluginDemo um_PluginDemo;
+REGISTER_USERMOD(um_PluginDemo);
diff --git a/usermods/UM_PluginDemo/library.json b/usermods/UM_PluginDemo/library.json
new file mode 100644
index 0000000000..b6927ed5c9
--- /dev/null
+++ b/usermods/UM_PluginDemo/library.json
@@ -0,0 +1,4 @@
+{
+ "name": "UM_PluginDemo",
+ "build": { "libArchive": false }
+}
diff --git a/wled00/PluginAPI/DevNotes.md b/wled00/PluginAPI/DevNotes.md
new file mode 100644
index 0000000000..7c5351c9de
--- /dev/null
+++ b/wled00/PluginAPI/DevNotes.md
@@ -0,0 +1,42 @@
+
+# Development Notes on Plugins
+
+The igniting spark for this new WLED feature came from [this issue](https://github.com/wled/WLED/issues/5290) by @blazoncek , and some comments from @softhack007 and @willmmiles in the discussion there. Kudos to them for the great ideas!
+
+These notes describe why some parts of this plugin design are as they are. And which problems during development led to those design decisions.
+
+
+## Why having a dedicated API for a usermod; why not using the usermod class directly by other usermods?
+
+(1) To break dependencies. Imagine, your usermod wants to call functions on another usermod, that for examples controls a display via I2C. The class definition (yet not the implementation) of that other usermod would have to be inside its headerfile, which your usermod must include.
+Now, since that usermod uses I2C, it will depend on any kind of I2C library, and have an 'I2C-driver' as member variable. As a consequence, your usermod will implicitly pull in all these dependencies of the other usermod. This is _working only if the other usermod is also enabled_, so its dependencies are getting installed automatically by PlatformIO, and the include paths are set appropriately by the build system.
+The problem arises, when your usermod wants to access the other usermod _optionally_ - i.e. only when it has been compiled into WLED. If it is missing, you want to detect that inside your usermod at runtime, and maybe just skip the part with the display interaction. You'll end up with a compiler error: your usermod includes the other's headerfile, which in turn includes the I2C driver's headerfile. Unfortunately, that I2C driver is not installed (and won't be), because no one else needed it yet. So, the compiler quits because of a missing headerfile - Game Over.
+
+(2) An alternative solution would be to use `#define`s, which are set by the other usermod. Ideas about how to do this have already been pointed out in the issue's thread by @softhack007 . However, that approach does _not_ work out of the box, because of some magic performed by the Arduino framework; see below for more details on that problem.
+
+(3) See the failing build as a feature and not as a problem. Just include the other's headerfile in your usermod, and use it like any other class. Therefore, make its object accesible through its headerfile. (How to do that has also been shown in the issue's thread by @willmmiles )
+Now, when the build fails, this is a reminder for you that you forgot to enable the other usermod as well. But as a consequence, the 'optional' part of detecting the other usermod is then gone.
+
+In the end, it will be the developer's choice which option to chose; there is not only one right way. This PR shows an additional clean and interface based way - at the expense of virtual functions. However, when weighing the cost of the vtable against the feature gain and simplicity, I am happiliy willing to pay that price.
+
+
+## Why a dedicated folder for custom usermod APIs - shouldn't they live in the same folder as the usermod itself?
+
+Short answer: Yes, absolutely! Long answer: Unfortunately that doesn't work in practice.
+The problem here is some magic that the underlying Arduino framework (I assume) is performing under the hood:
+Whenever your sourcecode includes a headerfile from the directory of another usermod, that usermod's cpp files will be compiled automatically. Regardles if that usermod is enabled or not! This always ends with a failed build in the latter case, because e.g. include paths are not set appropriately. At least that was my experience during development. If anyone knows how to prevent that from happening: solutions are really welcome!
+
+
+## Why does a custom usermod API need a cpp file, when it is just an interface?
+
+This is necessary because of the way _weak symbols_ are handled by the linker. At least how far I understand them as of now, after some contradictory adventures with AI. Nevertheless, this idea was with weak linking was brought up by @softhack007 in the issue's thread - and was the initial spark for me to start tinkering on that topic; just out of curiosity.
+My experience during development was that weak symbols are **not** NULL when they are missing. It seems as if they are undefined (i.e. random), which leads to a crash. Apparently, we need an additional _weak implementation_ of such a _weak symbol_, which is initialized with NULL (that's happening inside this cpp file). When the (optional) usermod is missing, that _weak implementation_ is picked by the linker, and can be checked for NULL by the user.
+Now, when the actual usermod is enabled, it contains a _strong implementation_ of that _weak symbol_ - which the linker will pick instead of the weak one from this cpp file. Thus the user obtained a valid pointer to the usermod object (not NULL), and can now directly interact with it.
+If anyone knows a solution how the linker will default missing weak symbols to NULL, please share! Then we can get rid of this cpp file and the corresponding macro completely!
+
+
+## The PluginManger's info section in the UI is a bit bloated and messy...
+
+Yes, indeed... The current implementation is intended as demontration and for debugging.
+For release code, it may also be removed completely since it isn't essentially needed.
+My idea would be to make its 'bloatyness' configurable via `#define`s, like `PLUGINMGR_DISABLE_UI`. Any suggestions about what would be of interest are highly appreciated!
diff --git a/wled00/PluginAPI/HumiditySensor.h b/wled00/PluginAPI/HumiditySensor.h
new file mode 100644
index 0000000000..1c70304101
--- /dev/null
+++ b/wled00/PluginAPI/HumiditySensor.h
@@ -0,0 +1,28 @@
+/**
+ * (c) 2026 Joachim Dick
+ * Licensed under the EUPL v. 1.2 or later
+ */
+
+#pragma once
+
+//--------------------------------------------------------------------------------------------------
+
+/// Interface of a sensor that can provide humidity readings.
+class HumiditySensor
+{
+public:
+ /// Returns \c false when the sensor is not ready.
+ bool isReady() { return _isHumidityValid; }
+
+ /// Get the humidity in % rel.
+ float humidity() { return do_getHumidity(); }
+
+protected:
+ /// Get the plugin's humidity reading in % rel.
+ virtual float do_getHumidity() = 0;
+
+ /// The plugin must set this to \c true after the sensor has been initialized.
+ bool _isHumidityValid = false;
+};
+
+//--------------------------------------------------------------------------------------------------
diff --git a/wled00/PluginAPI/PinUser.h b/wled00/PluginAPI/PinUser.h
new file mode 100644
index 0000000000..2d36cd229e
--- /dev/null
+++ b/wled00/PluginAPI/PinUser.h
@@ -0,0 +1,83 @@
+/**
+ * (c) 2026 Joachim Dick
+ * Licensed under the EUPL v. 1.2 or later
+ */
+
+#pragma once
+
+#include
+
+//--------------------------------------------------------------------------------------------------
+
+/// Type of GPIO pin.
+enum class PinType : uint8_t
+{
+ undefined = 0,
+ Digital_in,
+ Digital_out,
+ Analog_in,
+ PWM_out,
+ I2C_scl,
+ I2C_sda,
+ SPI_sclk,
+ SPI_mosi,
+ SPI_miso,
+ OneWire
+};
+
+const char *getPinName(PinType pinType);
+
+/** Properties of a GPIO pin that a plugin wants to use.
+ * @note With the current design of WLED, the PinUser (i.e. the usermod) is responsible for
+ * obtaining the pin numbers from the UI. This "wanted pin to use" shall be specified here.
+ * In a future optimization, this burden can be eliminated completely: \n
+ * The PinUser leaves the pin number uninitialized. An appropriate pin number will be assigned by
+ * the PluginManager upon registration of the PinUser. All the UI interaction will then be under
+ * full control of the PluginManager; a PinUser won't have to care about that anymore. \n
+ * Changes in the pin configuration (via UI) will be stored directly inside the PinUser's PinConfig
+ * (since the PluginManager keeps track of them), and announced via \c onPinConfigurationChanged()
+ * This callback acts as a trigger for the PinUser to re-initialize with the updated pin numbers
+ * from inside its PinConfig.
+ */
+struct PinConfig
+{
+ PinConfig(PinType pinType_ = PinType::undefined, const char *pinName_ = nullptr)
+ : pinType{pinType_}, pinName{pinName_} {}
+
+ /// The designated pin number.
+ uint8_t pinNr = 0xFF;
+
+ /// Type of the requested pin.
+ PinType pinType;
+
+ /** Name of the pin (optional).
+ * To be displayed in UI configuration page; it is derived from \c pinType when omitted.
+ * @note Pin names are not deeply copied.
+ * They must remain valid as long as the plugin is registered.
+ */
+ const char *pinName;
+
+ /** Pins are marked as invalid when the plugin registration fails.
+ * @note The PinUser must then assign a different pin number and try to register again.
+ */
+ bool isPinValid() const { return pinNr != 0xFF; }
+ void invalidatePin() { pinNr = 0xFF; }
+};
+
+/// Array with multiple pin configurations.
+template
+using PinConfigs = std::array;
+
+/// Interface of a plugin that wants to use GPIO pins.
+class PinUser
+{
+#if (0) // only with PluginManager's UI optimization for pin selection
+ /// The pin confuguration (from the UI) has changed. Not implemented yet.
+ virtual void onPinConfigurationChanged() {}
+#else
+ // Intentionally empty.
+ // Any pin users must just have this as base class.
+#endif
+};
+
+//--------------------------------------------------------------------------------------------------
diff --git a/wled00/PluginAPI/PluginMacros.h b/wled00/PluginAPI/PluginMacros.h
new file mode 100644
index 0000000000..977162bfb6
--- /dev/null
+++ b/wled00/PluginAPI/PluginMacros.h
@@ -0,0 +1,68 @@
+/**
+ * (c) 2026 Joachim Dick
+ * Licensed under the EUPL v. 1.2 or later
+ */
+
+#pragma once
+
+//--------------------------------------------------------------------------------------------------
+
+/** Place this macro in the headerfile of the custom plugin API.
+ * Argument \a API_type is the class name of the plugin's interface:
+ * @code
+ * // in PluginAPI/custom/MyStuff/MyStuff.h
+ * #include "PluginAPI/PluginMacros.h"
+ * class MyStuff
+ * {
+ * public:
+ * virtual void doSomething() = 0;
+ * };
+ * DECLARE_PLUGIN_API(MyStuff);
+ * @endcode
+ */
+#define DECLARE_PLUGIN_API(API_type) extern API_type *PLUGINAPI_##API_type __attribute__((weak))
+
+/** Place this macro in the sourcefile of the custom plugin API.
+ * @note Every API needs its own sourcefile, which must always be compiled; regardless whether the
+ * corresponding usermod is enabled or not. It usually contains just the few lines that are shown
+ * below. Be aware that the header and the cpp file of the API \e must be located in a different
+ * directory than the actual usermod implementation!
+ * @code
+ * // in PluginAPI/custom/MyStuff/MyStuff.cpp
+ * #include "MyStuff.h"
+ * CPPFILE_PLUGIN_API(MyStuff);
+ * @endcode
+ */
+#define CPPFILE_PLUGIN_API(API_type) API_type *PLUGINAPI_##API_type __attribute__((weak)) = nullptr
+
+/** Place this macro in the sourcefile of the corresponding usermod implementation.
+ * @note The usermod itself doesn't necessarily need to have its own headerfile; everything may be
+ * contained inside its cpp file.
+ * @code
+ * // in usermods/UM_MyStuff/UM_MyStuff.cpp
+ * #include "PluginAPI/custom/MyStuff/MyStuff.h"
+ * class UM_MyStuff : public MyStuff
+ * {
+ * void doSomething() override { ... }
+ * };
+ * static UM_MyStuff um_MyStuff;
+ * REGISTER_USERMOD(um_MyStuff);
+ * DEFINE_PLUGIN_API(MyStuff, um_MyStuff);
+ * @endcode
+ */
+#define DEFINE_PLUGIN_API(API_type, instance) API_type *PLUGINAPI_##API_type = &instance
+
+/** Use this macro at all places where you want to interact through the plugin API.
+ * @code
+ * // in usermods/UM_AnotherUsermod/UM_AnotherUsermod.cpp
+ * #include "PluginAPI/custom/MyStuff/MyStuff.h"
+ * // ...
+ * MyStuff* myStuff = GET_PLUGIN_API(MyStuff);
+ * if (myStuff)
+ * myStuff->doSomething();
+ * // else: WLED was compiled without the corresponding usermod
+ * @endcode
+ */
+#define GET_PLUGIN_API(API_typee) PLUGINAPI_##API_typee
+
+//--------------------------------------------------------------------------------------------------
diff --git a/wled00/PluginAPI/PluginManager.cpp b/wled00/PluginAPI/PluginManager.cpp
new file mode 100644
index 0000000000..8205f81912
--- /dev/null
+++ b/wled00/PluginAPI/PluginManager.cpp
@@ -0,0 +1,418 @@
+/**
+ * (c) 2026 Joachim Dick
+ * Licensed under the EUPL v. 1.2 or later
+ */
+
+#include
+#include