/* 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" : } ".../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 . */ #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(); 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() * (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(); // 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(), i); } snprintf(key, sizeof(key), "max-%s", channelJsonKeys[i]); value = jsonData[key]; if (!value.isNull()) { calibrationMaxLinear[i] = perceptualToLinear(value.as(), i); } } } } # endif #endif