Files
OpenMQTTGateway/main/actuatorPWM.cpp
Florian 32174e37a5 [SYS] Reduce Logs flash footprint (#2232)
By building only the log level required into LOG_LEVEL

Co-authored-by: Florian <1technophile@users.noreply.github.com>
2025-08-19 09:15:41 -05:00

261 lines
9.4 KiB
C++

/*
Theengs OpenMQTTGateway - We Unite Sensors in One Open-Source Interface
This actuator enables control over the PWM outputs of microcontrollers.
It uses the highest resolution duty cycles available, to try to
give good control over LEDs, particularly at low brightness settings.
It supports different gamma curves to try to give good perceptually
linear results when used to control LEDs, and it supports calibration
of the min and max levels for each channel.
It supports as many channels as the micro-controller can output PWM
signals for. Each channel can be named in the config header.
Supported MQTT topics...
".../commands/MQTTtoPWM/set" : Set the state of one or more channels.
All values are floating point.
{
"r" : 0.0 - 1.0,
"g" : 0.0 - 1.0,
"b" : 0.0 - 1.0,
"w0" : 0.0 - 1.0,
"w1" : 0.0 - 1.0,
"fade" : <fade time in seconds>
}
".../commands/MQTTtoPWM/calibrate" : Set calibration data
All values support floating point for greater precision.
It can be convenient to use the 'retain' feature of MQTT to
store calibration data.
Gamma is a power value that is used help make the intensity
curves respond in a more perceptually linear way.
If you would like the values to remain linear, set the gamma
values to 1.0
Min and max set the levels that 0 and 1 will correspond to.
Be aware the the min and max values are in the current gamma space
before the conversion to linear - so if you change the gamma level
then you will probably need to tune the min and max values again
{
"gamma-r" : 0.5 - 4.0,
"min-r" : 0.0 - 1.0,
"max-r" : 0.0 - 1.0,
"gamma-g" : 0.5 - 4.0,
etc...
}
Copyright: (c)
This file is part of OpenMQTTGateway.
OpenMQTTGateway is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
OpenMQTTGateway is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "User_config.h"
#ifdef ZactuatorPWM
# include "TheengsCommon.h"
# include "config_PWM.h"
static long previousUpdateTime = 0; // milliseconds
static long currentUpdateTime = 0; // milliseconds
static const char* channelJsonKeys[] = PWM_CHANNEL_NAMES;
static const int channelPins[] = PWM_CHANNEL_PINS;
static const int kNumChannels = sizeof(channelPins) / sizeof(int);
// These are all in a perceptually linear colour space, but scaled 0-1
static float currentValues[kNumChannels] = {};
static float fadeStartValues[kNumChannels] = {};
static float targetValues[kNumChannels] = {};
static long fadeStartUpdateTime[kNumChannels] = {}; // milliseconds
static long fadeEndUpdateTime[kNumChannels] = {}; // milliseconds
static bool fadeIsComplete = false;
// Calibration data (initialised during setupPWM)
static float calibrationMinLinear[kNumChannels];
static float calibrationMaxLinear[kNumChannels];
static float calibrationGamma[kNumChannels];
# if defined(ESP32)
// We use the highest resolution duty cycle mode (16-bit) for ESP32, which
// gives us good control LEDs at the very dim end of the range.
static const int kNumDutyCycleBits = 16;
# elif defined(ESP8266)
// ESP8266 has a 10-bit duty cycle, which is better than 8 bits, but still
// not good enough to give really good dim control over LEDs.
static const int kNumDutyCycleBits = 10;
# else
// Assume Arduino with 8-bit duty cycle.
// With an 8-bit duty cycle, it's difficult to get good control over
// LEDs at the dark end of the range.
static const int kNumDutyCycleBits = 8;
# endif
static const float kUNormToDutyCycle = (float)((1 << kNumDutyCycleBits) - 1);
void setupPWM() {
// Setup the PWM channels at the highest frequency we can for full 16-bit
// duty cycle control. These channels will be assigned to the
// associated output pins.
// PWM outputs vary the light intensity linearly when used to control
// LEDs. But our eyes don't perceive actual linear changes as linear.
// This manifests as a problem when trying to have fine control
// over LEDs at very low levels.
// Using an 8-bit duty cycle for example only allows for 256 different
// linear levels of brightness. Perceptually, the difference in
// brightness between levels 1 and 2 is very large - so to get fine
// control at dark levels, it's important that we use as high
// resolution PWM as we can.
for (int i = 0; i < kNumChannels; ++i) {
# if defined(ESP32)
// Configure the pin for PWM output.
// I think this is the fastest frequency that allows for a 16-bit
// duty cycle on an ESP32
ledcAttachChannel(channelPins[i], 625.0, kNumDutyCycleBits, i);
# endif
calibrationMinLinear[i] = 0.f;
calibrationMaxLinear[i] = 1.f;
calibrationGamma[i] = PWM_DEFAULT_GAMMA;
}
THEENGS_LOG_TRACE(F("actuatorPWM setup done " CR));
}
// This applies a power curve to the input to try to make the inputs
// be 'perceptually linear', as opposed to actually linear.
static float perceptualToLinear(float perceptual, int channelIdx) {
return pow(perceptual, calibrationGamma[channelIdx]);
}
// If we're currently fading between states, then update those states
void PWMLoop() {
previousUpdateTime = currentUpdateTime;
currentUpdateTime = millis();
if (fadeIsComplete) {
return;
}
fadeIsComplete = true; //< If any channel isn't finished, we'll set this to false
for (int i = 0; i < kNumChannels; ++i) {
// Calculate our lerp value through the current fade
long totalFadeDuration = fadeEndUpdateTime[i] - fadeStartUpdateTime[i];
float fadeLerpValue = 1.f;
if (totalFadeDuration > 0) {
fadeLerpValue = (float)(currentUpdateTime - fadeStartUpdateTime[i]) / (float)totalFadeDuration;
}
if (fadeLerpValue >= 1.f) {
currentValues[i] = targetValues[i];
} else {
currentValues[i] = ((targetValues[i] - fadeStartValues[i]) * fadeLerpValue) + fadeStartValues[i];
fadeIsComplete = false;
}
}
// Now convert these perceptually linear values into actually linear values
// and set the appropriate duty cycle for the outputs.
for (int i = 0; i < kNumChannels; ++i) {
float linear = perceptualToLinear(currentValues[i], i);
// We always treat zero as zero so that it's truly off, regardless of the calibration data.
if (linear > 0.f) {
// Remap according to the calibration
linear = (linear * (calibrationMaxLinear[i] - calibrationMinLinear[i])) + calibrationMinLinear[i];
}
long dutyCycle = (long)(linear * kUNormToDutyCycle);
# if defined(ESP32)
ledcWrite(i, dutyCycle);
# else
analogWrite(channelPins[i], dutyCycle);
# endif
//THEENGS_LOG_NOTICE(F("Setting channel %d : %d" CR),i,dutyCycle);
}
}
boolean PWMtoX() {
return false;
}
# if jsonReceiving
void XtoPWM(const char* topicOri, JsonObject& jsonData) {
if (cmpToMainTopic(topicOri, subjectMQTTtoPWMset)) {
THEENGS_LOG_TRACE(F("MQTTtoPWM JSON analysis" CR));
// Parse the target value for each channel
int modifiedChannelBits = 0;
for (int i = 0; i < kNumChannels; ++i) {
fadeStartValues[i] = currentValues[i];
JsonVariant value = jsonData[channelJsonKeys[i]];
if (!value.isNull()) {
float targetValue = value.as<float>();
targetValue = std::min(targetValue, 1.f);
targetValue = std::max(targetValue, 0.f);
targetValues[i] = targetValue;
// Initially configure as an instantaneous change....
fadeStartUpdateTime[i] = currentUpdateTime;
fadeEndUpdateTime[i] = currentUpdateTime;
modifiedChannelBits |= (1 << i);
}
}
// ...unless there is a "fade" value in the JSON
JsonVariant fade = jsonData["fade"];
if (!fade.isNull()) {
// "fade" json value is in seconds. Convert to milliseconds
long endUpdateTime = currentUpdateTime + (long)(fade.as<float>() * (1000.f));
// Set the fadeEndUpdateTime for the channels that were modified in the JSON
for (int i = 0; i < kNumChannels; ++i) {
if (modifiedChannelBits & (1 << i)) {
fadeEndUpdateTime[i] = endUpdateTime;
}
}
}
fadeIsComplete = false; // The values will start to change during PWMLoop
} else if (cmpToMainTopic(topicOri, subjectMQTTtoPWMcalibrate)) {
// Read the optional calibration data for each channel
for (int i = 0; i < kNumChannels; ++i) {
char key[64];
snprintf(key, sizeof(key), "gamma-%s", channelJsonKeys[i]);
JsonVariant value = jsonData[key];
if (!value.isNull()) {
float gamma = value.as<float>();
// Sanity check
gamma = std::min(gamma, 4.f);
gamma = std::max(gamma, 0.5f);
calibrationGamma[i] = gamma;
}
snprintf(key, sizeof(key), "min-%s", channelJsonKeys[i]);
value = jsonData[key];
if (!value.isNull()) {
calibrationMinLinear[i] = perceptualToLinear(value.as<float>(), i);
}
snprintf(key, sizeof(key), "max-%s", channelJsonKeys[i]);
value = jsonData[key];
if (!value.isNull()) {
calibrationMaxLinear[i] = perceptualToLinear(value.as<float>(), i);
}
}
}
}
# endif
#endif