diff --git a/usermods/ina2xx_v2/Readme.md b/usermods/ina2xx_v2/Readme.md new file mode 100644 index 0000000000..116ce746bf --- /dev/null +++ b/usermods/ina2xx_v2/Readme.md @@ -0,0 +1,194 @@ +# INA2XX WLED Usermod + +This Usermod integrates the INA219 **and** INA226 sensors with WLED to monitor energy consumption. It can read voltage, current, power, and calculate daily, monthly and total energy usage for either sensor. + +## Features + +- Monitors bus voltage, shunt voltage, current, and power (both INA219 & INA226). +- Calculates total energy consumed in kilowatt-hours (kWh). +- Calculates daily and monthly energy usage (resets at midnight and on the first day of each month respectively). +- Supports MQTT publishing of sensor data. +- Publishes energy data to Home Assistant directly to the WLED entity for easy integration. +- Displays daily, monthly and total energy used in the WLED GUI under the info section. +- Configurable through WLED's web interface or `my_config.h`. + +## Screenshots + + +### Info, Home Assistant and Pin Selection under Usermod + +| Info screen | Home Assistant | Usermod Settings page - Pin Selection | +|------------------------------------------------|--------------------------------------------------------------|-------------------------------------------------------------------------------------| +| ![Info screen](./img/info.png "Info screen") | ![Home Assistant](./img/homeassistant.png "Home Assistant") | ![Pin Selection](./img/usermod_settings_pins_selection.png "Pin Selection") | + +### Settings - Usermod + +| Settings page - Usermod INA219 | Settings page - Usermod INA226 | +|---------------------------------------------------------------------|---------------------------------------------------------------------| +| ![Settings page](./img/usermod_settings_ina219.png "Settings page") | ![Settings page](./img/usermod_settings_ina226.png "Settings page") | + +--- + +## Configuration Parameters + +> **Note:** Before using, you **must** define the sensor type in your `my_config.h` or via PlatformIO build flags: +> ```cpp +> #define INA_SENSOR_TYPE 219 // or 226 +> ``` + +### Universal Parameters + +| Parameter | Description | Default Value | Possible Values | +|-------------------------------|------------------------------------------------------------|----------------|--------------------------------------------------------| +| `INA2XX_ENABLED` | Enable or disable the INA2XX Usermod | `false` | `true`, `false` | +| `INA2XX_I2C_ADDRESS` | I2C address of the INA2XX sensor | `0x40` | See options below for available addresses. | +| `INA2XX_CHECK_INTERVAL` | Interval for checking sensor values (seconds) | `5` | Any positive integer | +| `INA2XX_CONVERSION_TIME` | ADC conversion time | `BIT_MODE_12` (219)
`CONV_TIME_1100` (226) | Sensor-specific (see below) | +| `INA2XX_DECIMAL_FACTOR` | Number of decimal places for current and power readings | `3` | See options below for decimal places. | +| `INA2XX_SHUNT_RESISTOR` | Value of the shunt resistor in ohms | `0.1` | Any positive float value matching your INA2XX resistor | +| `INA2XX_CORRECTION_FACTOR` | Correction factor for measurements | `1.0` | Any positive float value | +| `INA2XX_MQTT_PUBLISH` | Publish sensor data to MQTT | `false` | `true`, `false` | +| `INA2XX_MQTT_PUBLISH_ALWAYS` | Always publish values, regardless of change | `false` | `true`, `false` | +| `INA2XX_HA_DISCOVERY` | Enable Home Assistant discovery for sensors | `false` | `true`, `false` | + +### INA219-Specific Parameters (`INA_SENSOR_TYPE == 219`) + +| Parameter | Description | Default | Possible Values | +|-------------------------------|------------------------------------------------------------|---------------|--------------------------------------------------------| +| `INA2XX_BUSRANGE` | Bus voltage range | `BRNG_32` | `BRNG_16`, `BRNG_32` | +| `INA2XX_P_GAIN` | Shunt voltage gain | `PG_320` | `PG_40`, `PG_80`, `PG_160`, `PG_320` | +| `INA2XX_SHUNT_VOLT_OFFSET` | Millivolt offset at zero current | `0.0` | Any float (mV) | + +#### Options for `INA2XX_CONVERSION_TIME` (INA219) + +| Value | Description | +|-------------------|----------------------------| +| `BIT_MODE_9` | 9-Bit (84 µs) | +| `BIT_MODE_10` | 10-Bit (148 µs) | +| `BIT_MODE_11` | 11-Bit (276 µs) | +| `BIT_MODE_12` | 12-Bit (532 µs) | +| `SAMPLE_MODE_2` | 2 samples (1.06 ms) | +| `SAMPLE_MODE_4` | 4 samples (2.13 ms) | +| `SAMPLE_MODE_8` | 8 samples (4.26 ms) | +| `SAMPLE_MODE_16` | 16 samples (8.51 ms) | +| `SAMPLE_MODE_32` | 32 samples (17.02 ms) | +| `SAMPLE_MODE_64` | 64 samples (34.05 ms) | +| `SAMPLE_MODE_128` | 128 samples (68.10 ms) | + +### INA226-Specific Parameters (`INA_SENSOR_TYPE == 226`) + +| Parameter | Description | Default | Possible Values | +|-------------------------------|------------------------------------------------------------|---------------|--------------------------------------| +| `INA2XX_AVERAGES` | Number of averaging samples | `AVERAGE_1` | `AVERAGE_1`, `AVERAGE_4`, `AVERAGE_16`, … | +| `INA2XX_RANGE` | Current measurement range (max in A) | `1.3` | Up to `10.0` | + +#### Options for `INA2XX_CONVERSION_TIME` (INA226) + +| Value | Conversion Time | +|-------------------|------------------------------| +| `CONV_TIME_140` | 140 µs | +| `CONV_TIME_204` | 204 µs | +| `CONV_TIME_556` | 556 µs | +| `CONV_TIME_1100` | 1.100 ms | +| `CONV_TIME_2116` | 2.116 ms | +| `CONV_TIME_4156` | 4.156 ms | +| `CONV_TIME_8244` | 8.244 ms | + + +### Options for `INA2XX_DECIMAL_FACTOR` + +The `decimalFactor` parameter can be set to: + +| Decimal Places | Value | Example | +|----------------|-------|------------------| +| 0 | 0 | 100 | +| 1 | 1 | 100.0 | +| 2 | 2 | 100.00 | +| 3 | 3 | 100.000 | + +### Options for `INA2XX_I2C_ADDRESS` + +The `i2cAddress` parameter can be set to the following options: + +| Address | Description | Value | +|---------------------|------------------------------------|---------| +| `0x40` | 0x40 - Default | 0x40 | +| `0x41` | 0x41 - A0 soldered | 0x41 | +| `0x44` | 0x44 - A1 soldered | 0x44 | +| `0x45` | 0x45 - A0 and A1 soldered | 0x45 | + +--- + +# Compiling + +To enable, add `INA2XX_v2` to your `custom_usermods` (e.g. in `platformio_override.ini`) +```ini +[env:usermod_ina2xx_d1_mini] +extends = env:d1_mini +custom_usermods = ${env:d1_mini.custom_usermods} INA2XX_v2 +``` + +## Usage + +1. Define your sensor in my_config.h or via build flags: + +```cpp +#define INA_SENSOR_TYPE 219 // or 226 +``` + +2. Setup SDA/SCL Pin - see at the readme bottom → ### I2C Configuration (Mandatory) + +3. Enable the usermod in WLED’s web interface (Settings → Usermod → INA2XX). + +4. Configure parameters in the web interface or predefine them in my_config.h: + +```cpp +#define INA2XX_ENABLED false +#define INA2XX_I2C_ADDRESS 0x40 +#define INA2XX_CHECK_INTERVAL 5 +#define INA2XX_CONVERSION_TIME BIT_MODE_12 // or CONV_TIME_1100 +#define INA2XX_DECIMAL_FACTOR 3 +#define INA2XX_SHUNT_RESISTOR 0.1 +#define INA2XX_CORRECTION_FACTOR 1.0 +#define INA2XX_BUSRANGE BRNG_32 // INA219 only +#define INA2XX_P_GAIN PG_320 // INA219 only +#define INA2XX_SHUNT_VOLT_OFFSET 0.0 // INA219 only +#define INA2XX_AVERAGES AVERAGE_1 // INA226 only +#define INA2XX_RANGE 1.3 // INA226 only +#define INA2XX_MQTT_PUBLISH false +#define INA2XX_MQTT_PUBLISH_ALWAYS false +#define INA2XX_HA_DISCOVERY false +``` + +5. Monitor your energy consumption through the WLED interface or via MQTT. + +## Energy Calculation + +- **Total Energy** is calculated continuously. +- **Daily Energy** resets after 24 hours. +- **Monthly Energy** resets on the first day of each month. + +To reset daily or monthly energy calculations, you can implement corresponding functions within your main application. + +### I2C Configuration (Mandatory) +The INA2XX sensor communicates via I2C, so the SDA and SCL pins must be correctly set before enabling the usermod. + +1. Open the **WLED Web Interface**. +2. Go to **Settings → Usermod → Global I2C GPIOs (HW)**. +3. Select the appropriate **SDA** and **SCL** pins for your hardware. +4. Save the settings and **reboot WLED**. + +🚀 **After rebooting, you can enable the **INA2xx** usermod in the settings.** + + +### INA226 Wiring Tip + +- Measuring bus voltage: Tie the INA226’s VIN– pin directly to the VBUS line (load side). This ensures the sensor reads the full voltage drop across your bus. + +### Sensor Pinouts + +The following diagrams show the typical I2C pin configurations for both INA219 and INA226 modules. + +| INA219 Pinout | INA226 Pinout | +|-----------------------------------------------------|-----------------------------------------------------| +| ![INA219 Pinout](./img/INA219-Pin-Outs.png) | ![INA226 Pinout](./img/INA226-Pin-Outs.png) | diff --git a/usermods/ina2xx_v2/img/INA219-Pin-Outs.png b/usermods/ina2xx_v2/img/INA219-Pin-Outs.png new file mode 100644 index 0000000000..a32d06245e Binary files /dev/null and b/usermods/ina2xx_v2/img/INA219-Pin-Outs.png differ diff --git a/usermods/ina2xx_v2/img/INA226-Pin-Outs.png b/usermods/ina2xx_v2/img/INA226-Pin-Outs.png new file mode 100644 index 0000000000..5345af907b Binary files /dev/null and b/usermods/ina2xx_v2/img/INA226-Pin-Outs.png differ diff --git a/usermods/ina2xx_v2/img/homeassistant.png b/usermods/ina2xx_v2/img/homeassistant.png new file mode 100644 index 0000000000..1df50c4a4d Binary files /dev/null and b/usermods/ina2xx_v2/img/homeassistant.png differ diff --git a/usermods/ina2xx_v2/img/info.png b/usermods/ina2xx_v2/img/info.png new file mode 100644 index 0000000000..0650fd66e6 Binary files /dev/null and b/usermods/ina2xx_v2/img/info.png differ diff --git a/usermods/ina2xx_v2/img/usermod_settings_ina219.png b/usermods/ina2xx_v2/img/usermod_settings_ina219.png new file mode 100644 index 0000000000..6412bd3722 Binary files /dev/null and b/usermods/ina2xx_v2/img/usermod_settings_ina219.png differ diff --git a/usermods/ina2xx_v2/img/usermod_settings_ina226.png b/usermods/ina2xx_v2/img/usermod_settings_ina226.png new file mode 100644 index 0000000000..77f32490a0 Binary files /dev/null and b/usermods/ina2xx_v2/img/usermod_settings_ina226.png differ diff --git a/usermods/ina2xx_v2/img/usermod_settings_pins_selection.png b/usermods/ina2xx_v2/img/usermod_settings_pins_selection.png new file mode 100644 index 0000000000..7b20b0cde0 Binary files /dev/null and b/usermods/ina2xx_v2/img/usermod_settings_pins_selection.png differ diff --git a/usermods/ina2xx_v2/ina2xx_v2.cpp b/usermods/ina2xx_v2/ina2xx_v2.cpp new file mode 100644 index 0000000000..ca4f880f62 --- /dev/null +++ b/usermods/ina2xx_v2/ina2xx_v2.cpp @@ -0,0 +1,1177 @@ +#include "ina2xx_v2.h" + +const char UsermodINA2xx::_name[] PROGMEM = "INA2xx"; + +UsermodINA2xx ina2xx_v2; +REGISTER_USERMOD(ina2xx_v2); + +// Function to truncate decimals based on the configured decimal factor +float UsermodINA2xx::roundDecimals(float val) { + if (_decimalFactor == 0) { + return roundf(val); + } + + static const float factorLUT[4] = {1.f, 10.f, 100.f, 1000.f}; + float factor = (_decimalFactor <= 3) ? factorLUT[_decimalFactor] + : powf(10.0f, _decimalFactor); + + return roundf(val * factor) / factor; +} + +bool UsermodINA2xx::hasSignificantChange(float oldValue, float newValue, float threshold) { + bool changed = fabsf(oldValue - newValue) > threshold; + if (changed) { + _logUsermodInaSensor("Significant change detected: old=%.6f, new=%.6f, diff=%.6f", oldValue, newValue, fabsf(oldValue - newValue)); + } + return changed; +} + +bool UsermodINA2xx::hasValueChanged() { + bool changed = hasSignificantChange(last_sent_shuntVoltage, shuntVoltage) || + hasSignificantChange(last_sent_busVoltage, busVoltage) || + hasSignificantChange(last_sent_loadVoltage, loadVoltage) || + hasSignificantChange(last_sent_current, current) || + hasSignificantChange(last_sent_current_mA, current_mA) || + hasSignificantChange(last_sent_power, power) || + hasSignificantChange(last_sent_power_mW, power_mW) || + (last_sent_overflow != overflow); + + if (changed) { + _logUsermodInaSensor("Values changed, need to publish"); + } + return changed; +} + +bool UsermodINA2xx::isTimeValid() const { + return localTime >= 1577836800UL && localTime <= 4102444800UL; +} + +void UsermodINA2xx::applyMqttRestoreIfReady() { + if (!mqttRestorePending || mqttStateRestored) { + return; + } + + if (!isTimeValid()) { + if (!mqttRestoreDeferredLogged) { + _logUsermodInaSensor("Deferring MQTT energy restore until time sync is valid"); + mqttRestoreDeferredLogged = true; + } + return; + } + + mqttRestoreDeferredLogged = false; + long currentDay = localTime / 86400; + long currentMonth = year(localTime) * 12 + month(localTime) - 1; + + if (mqttRestoreData.hasTotalEnergy) { + totalEnergy_kWh += mqttRestoreData.totalEnergy; + _logUsermodInaSensor("Applied total energy from MQTT: +%.6f kWh => %.6f kWh", + mqttRestoreData.totalEnergy, totalEnergy_kWh); + } + + auto restoredDailyDay = [&]() { + if (mqttRestoreData.hasDailyResetTime) { + return static_cast(mqttRestoreData.dailyResetTime); + } + if (mqttRestoreData.hasDailyResetTimestamp) { + return static_cast(mqttRestoreData.dailyResetTimestamp / 86400UL); + } + return static_cast(dailyResetTime); + }; + + auto dailyResetMatches = [&]() { + if (mqttRestoreData.hasDailyResetTime) { + return static_cast(mqttRestoreData.dailyResetTime) == currentDay; + } + if (mqttRestoreData.hasDailyResetTimestamp) { + return static_cast(mqttRestoreData.dailyResetTimestamp / 86400UL) == currentDay; + } + if (dailyResetTime != 0) { + return static_cast(dailyResetTime) == currentDay; + } + return true; // no reset info available, assume same day + }; + + if (mqttRestoreData.hasDailyEnergy) { + if (dailyResetMatches()) { + dailyEnergy_kWh += mqttRestoreData.dailyEnergy; + _logUsermodInaSensor("Applied daily energy from MQTT: +%.6f kWh => %.6f kWh", + mqttRestoreData.dailyEnergy, dailyEnergy_kWh); + if (mqttRestoreData.hasDailyResetTime) { + dailyResetTime = mqttRestoreData.dailyResetTime; + } + if (mqttRestoreData.hasDailyResetTimestamp) { + dailyResetTimestamp = mqttRestoreData.dailyResetTimestamp; + } + } else { + long restoredDay = restoredDailyDay(); + if (restoredDay > currentDay) { + dailyEnergy_kWh += mqttRestoreData.dailyEnergy; + dailyResetTime = currentDay; + dailyResetTimestamp = localTime - (localTime % 86400UL); + _logUsermodInaSensor("Restored daily energy with future reset; clamping reset window to today."); + } else { + dailyResetTime = currentDay; + dailyResetTimestamp = localTime - (localTime % 86400UL); + _logUsermodInaSensor("Skipped daily MQTT restore (different day). Resetting daily window to today."); + } + } + } + + auto restoredMonthlyId = [&]() { + if (mqttRestoreData.hasMonthlyResetTime) { + return static_cast(mqttRestoreData.monthlyResetTime); + } + if (mqttRestoreData.hasMonthlyResetTimestamp) { + return static_cast(year(mqttRestoreData.monthlyResetTimestamp)) * 12L + + static_cast(month(mqttRestoreData.monthlyResetTimestamp)) - 1L; + } + return static_cast(monthlyResetTime); + }; + + auto monthlyResetMatches = [&]() { + if (mqttRestoreData.hasMonthlyResetTime) { + return static_cast(mqttRestoreData.monthlyResetTime) == currentMonth; + } + if (mqttRestoreData.hasMonthlyResetTimestamp) { + long restoredMonth = year(mqttRestoreData.monthlyResetTimestamp) * 12 + + month(mqttRestoreData.monthlyResetTimestamp) - 1; + return restoredMonth == currentMonth; + } + if (monthlyResetTime != 0) { + return static_cast(monthlyResetTime) == currentMonth; + } + return true; // no reset info available, assume same month + }; + + if (mqttRestoreData.hasMonthlyEnergy) { + if (monthlyResetMatches()) { + monthlyEnergy_kWh += mqttRestoreData.monthlyEnergy; + _logUsermodInaSensor("Applied monthly energy from MQTT: +%.6f kWh => %.6f kWh", + mqttRestoreData.monthlyEnergy, monthlyEnergy_kWh); + if (mqttRestoreData.hasMonthlyResetTime) { + monthlyResetTime = mqttRestoreData.monthlyResetTime; + } + if (mqttRestoreData.hasMonthlyResetTimestamp) { + monthlyResetTimestamp = mqttRestoreData.monthlyResetTimestamp; + } + } else { + long restoredMonth = restoredMonthlyId(); + if (restoredMonth > currentMonth) { + monthlyEnergy_kWh += mqttRestoreData.monthlyEnergy; + monthlyResetTime = currentMonth; + monthlyResetTimestamp = localTime - ((day(localTime) - 1) * 86400UL) - (localTime % 86400UL); + _logUsermodInaSensor("Restored monthly energy with future reset; clamping reset window to current month."); + } else { + monthlyResetTime = currentMonth; + monthlyResetTimestamp = localTime - ((day(localTime) - 1) * 86400UL) - (localTime % 86400UL); + _logUsermodInaSensor("Skipped monthly MQTT restore (different month). Resetting monthly window to current month."); + } + } + } + + mqttStateRestored = true; + mqttRestorePending = false; + mqttRestoreData = MqttRestoreData{}; + _logUsermodInaSensor("MQTT energy restore applied"); +} + +// Update INA2xx settings and reinitialize sensor if necessary +bool UsermodINA2xx::updateINA2xxSettings() { + _logUsermodInaSensor("Updating INA2xx sensor settings"); + + // Validate I2C pins; if invalid, disable usermod and log message + if (i2c_scl < 0 || i2c_sda < 0) { + enabled = false; + _logUsermodInaSensor("INA2xx disabled: Invalid I2C pins. Check global I2C settings."); + return false; + } + _logUsermodInaSensor("Using I2C SDA: %d", i2c_sda); + _logUsermodInaSensor("Using I2C SCL: %d", i2c_scl); + + // Reinitialize the INA2xx instance with updated settings + if (_ina2xx != nullptr) { + _logUsermodInaSensor("Freeing existing INA2xx instance"); + delete _ina2xx; + _ina2xx = nullptr; + } + + if (!enabled) return true; + + _logUsermodInaSensor("Creating new INA2xx instance with address 0x%02X", _i2cAddress); + _ina2xx = new INA_SENSOR_CLASS(_i2cAddress); + + if (!_ina2xx) { + _logUsermodInaSensor("Failed to allocate memory for INA2xx sensor!"); + enabled = false; + return false; + } + + _logUsermodInaSensor("Initializing INA2xx sensor"); + if (!_ina2xx->init()) { + _logUsermodInaSensor("INA2xx initialization failed!"); + enabled = false; + delete _ina2xx; + _ina2xx = nullptr; + return false; + } + + _logUsermodInaSensor("Setting correction factor to %.4f", correctionFactor); + _ina2xx->setCorrectionFactor(correctionFactor); + +#if INA_SENSOR_TYPE == 219 + _logUsermodInaSensor("Setting shunt resistor to %.4f Ohms", shuntResistor); + _ina2xx->setShuntSizeInOhms(shuntResistor); + + _logUsermodInaSensor("Setting ADC mode to %d", conversionTime); + _ina2xx->setADCMode(conversionTime); + + _logUsermodInaSensor("Setting PGA gain to %d", pGain); + _ina2xx->setPGain(pGain); + + _logUsermodInaSensor("Setting bus range to %d", busRange); + _ina2xx->setBusRange(busRange); + + _logUsermodInaSensor("Setting shunt voltage offset to %.3f mV", shuntVoltOffset_mV); + _ina2xx->setShuntVoltOffset_mV(shuntVoltOffset_mV); + +#elif INA_SENSOR_TYPE == 226 + + _ina2xx->setMeasureMode(CONTINUOUS); + _ina2xx->setAverage(average); + _ina2xx->setConversionTime(conversionTime); + _ina2xx->setResistorRange(shuntResistor,currentRange); // choose resistor 100 mOhm (default )and gain range up to 10 A (1.3A default) + + _ina2xx->readAndClearFlags(); +#endif + + _logUsermodInaSensor("INA2xx sensor configured successfully"); + return true; +} + +// Sanitize the mqttClientID by replacing invalid characters. +String UsermodINA2xx::sanitizeMqttClientID(const String &clientID) { + String sanitizedID; + _logUsermodInaSensor("Sanitizing MQTT client ID: %s", clientID.c_str()); + + for (unsigned int i = 0; i < clientID.length(); i++) { + char c = clientID[i]; + + // Handle common accented characters using simple mapping + if (c == '\xC3' && i + 1 < clientID.length()) { + char next = clientID[i + 1]; + if (next == '\xBC' || next == '\x9C') { // ü or Ü + sanitizedID += (next == '\xBC' ? "u" : "U"); + i++; + continue; + } else if (next == '\xA4' || next == '\xC4') { // ä or Ä + sanitizedID += (next == '\xA4' ? "a" : "A"); + i++; + continue; + } else if (next == '\xB6' || next == '\xD6') { // ö or Ö + sanitizedID += (next == '\xB6' ? "o" : "O"); + i++; + continue; + } else if (next == '\x9F') { // ß + sanitizedID += "s"; + i++; + continue; + } + } + // Allow valid characters [a-zA-Z0-9_-] + if ((c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_') { + sanitizedID += c; + } else { // Replace any other invalid character with an underscore + sanitizedID += '_'; + } + } + _logUsermodInaSensor("Sanitized MQTT client ID: %s", sanitizedID.c_str()); + return sanitizedID; +} + +/** +** Function to update energy calculations based on power and duration +**/ +void UsermodINA2xx::updateEnergy(float power, unsigned long durationMs) { + float durationHours = durationMs / 3600000.0; // Milliseconds to hours + _logUsermodInaSensor("Updating energy - Power: %.3f W, Duration: %lu ms (%.6f hours)", power, durationMs, durationHours); + + // Skip time-based resets if time seems invalid (before 2020 or unrealistic future) + if (!isTimeValid()) { // Jan 1 2020 to Jan 1 2100 + _logUsermodInaSensor("SKIPPED: Invalid time detected (%lu), waiting for NTP sync", localTime); + return; + } + + float energy_kWh = 0.0f; + if (power < 0.01f) { + _logUsermodInaSensor("Power too low (%.3f W) – skipping accumulation.", power); + } else { + energy_kWh = (power / 1000.0f) * durationHours; // Watts to kilowatt-hours (kWh) + _logUsermodInaSensor("Calculated energy: %.6f kWh", energy_kWh); + + // Skip negative values or unrealistic spikes + if (energy_kWh < 0 || energy_kWh > 10.0f) { // 10 kWh in a few seconds is unrealistic + _logUsermodInaSensor("SKIPPED: Energy value out of range (%.6f kWh)", energy_kWh); + energy_kWh = 0.0f; + } + } + + if (energy_kWh > 0.0f) { + totalEnergy_kWh += energy_kWh; // Update total energy consumed + _logUsermodInaSensor("Total energy updated to: %.6f kWh", totalEnergy_kWh); + + // Sanity check on accumulated total (e.g., 100,000 kWh = ~11 years at 1kW continuous) + if (totalEnergy_kWh > 100000.0f) { + _logUsermodInaSensor("WARNING: Total energy suspiciously high (%.6f kWh), possible corruption", totalEnergy_kWh); + } + } + + // Calculate day identifier (days since epoch) + long currentDay = localTime / 86400; + _logUsermodInaSensor("Current day: %ld, Last reset day: %lu", currentDay, dailyResetTime); + + // --- initialize reset values if they are unset (first run) --- + if (dailyResetTime == 0) { + dailyResetTime = currentDay; + // Calculate midnight timestamp for current day + dailyResetTimestamp = localTime - (localTime % 86400UL); + _logUsermodInaSensor("Initializing daily reset: day=%ld, ts=%lu", dailyResetTime, dailyResetTimestamp); + } + + // Fix for missing dailyResetTimestamp when dailyResetTime was already restored + if (dailyResetTimestamp == 0 && dailyResetTime != 0) { + dailyResetTimestamp = localTime - (localTime % 86400UL); + _logUsermodInaSensor("Fixing missing daily reset timestamp: ts=%lu", dailyResetTimestamp); + } + + // Reset daily energy whenever day marker does not match current day + if (static_cast(currentDay) != dailyResetTime) { + _logUsermodInaSensor("Resetting daily energy counter (day marker mismatch: now=%ld, last=%lu)", currentDay, dailyResetTime); + dailyEnergy_kWh = 0; + dailyResetTime = currentDay; + // Set timestamp to midnight of the current day + dailyResetTimestamp = localTime - (localTime % 86400UL); + } + dailyEnergy_kWh += energy_kWh; + _logUsermodInaSensor("Daily energy updated to: %.6f kWh", dailyEnergy_kWh); + + // Calculate month identifier (year*12 + month) + long currentMonth = year(localTime) * 12 + month(localTime) - 1; // month() is 1-12 + _logUsermodInaSensor("Current month: %ld, Last reset month: %lu", currentMonth, monthlyResetTime); + + if (monthlyResetTime == 0) { + monthlyResetTime = currentMonth; + // Calculate midnight timestamp for first day of current month + // Formula: subtract (current_day - 1) days worth of seconds, then subtract time-of-day + monthlyResetTimestamp = localTime - ((day(localTime) - 1) * 86400UL) - (localTime % 86400UL); + _logUsermodInaSensor("Initializing monthly reset: month=%ld, ts=%lu", monthlyResetTime, monthlyResetTimestamp); + } + + // Fix for missing monthlyResetTimestamp when monthlyResetTime was already restored + if (monthlyResetTimestamp == 0 && monthlyResetTime != 0) { + monthlyResetTimestamp = localTime - ((day(localTime) - 1) * 86400UL) - (localTime % 86400UL); + _logUsermodInaSensor("Fixing missing monthly reset timestamp: ts=%lu", monthlyResetTimestamp); + } + + // Reset monthly energy whenever month marker does not match current month + if (static_cast(currentMonth) != monthlyResetTime) { + _logUsermodInaSensor("Resetting monthly energy counter (month marker mismatch: now=%ld, last=%lu)", currentMonth, monthlyResetTime); + monthlyEnergy_kWh = 0; + monthlyResetTime = currentMonth; + + // Calculate midnight timestamp for first day of current month + // Formula: subtract (current_day - 1) days worth of seconds, then subtract time-of-day + monthlyResetTimestamp = localTime - ((day(localTime) - 1) * 86400UL) - (localTime % 86400UL); + } + monthlyEnergy_kWh += energy_kWh; + _logUsermodInaSensor("Monthly energy updated to: %.6f kWh", monthlyEnergy_kWh); +} + +#ifndef WLED_DISABLE_MQTT +/** +** Function to publish INA2xx sensor data to MQTT +**/ +void UsermodINA2xx::publishMqtt(float shuntVoltage, float busVoltage, float loadVoltage, + float current, float current_mA, float power, + float power_mW, bool overflow) { + if (!WLED_MQTT_CONNECTED) { + _logUsermodInaSensor("MQTT not connected, skipping publish"); + return; + } + + // Create a JSON document to hold sensor data + StaticJsonDocument<1024> jsonDoc; + + // Populate the JSON document with sensor readings + jsonDoc["shunt_voltage_mV"] = shuntVoltage; + jsonDoc["bus_voltage_V"] = busVoltage; + jsonDoc["load_voltage_V"] = loadVoltage; + jsonDoc["current_A"] = current; + jsonDoc["current_mA"] = current_mA; + jsonDoc["power_W"] = power; + jsonDoc["power_mW"] = power_mW; + jsonDoc["overflow"] = overflow; + jsonDoc["shunt_resistor_Ohms"] = shuntResistor; + + if (mqttStateRestored) { // only add energy_kWh fields after retained state arrives from mqtt + // Energy calculations + jsonDoc["daily_energy_kWh"] = dailyEnergy_kWh; + jsonDoc["monthly_energy_kWh"] = monthlyEnergy_kWh; + jsonDoc["total_energy_kWh"] = totalEnergy_kWh; + + jsonDoc["dailyResetTime"] = dailyResetTime; + jsonDoc["monthlyResetTime"] = monthlyResetTime; + jsonDoc["dailyResetTimestamp"] = dailyResetTimestamp; + jsonDoc["monthlyResetTimestamp"] = monthlyResetTimestamp; + } else { + _logUsermodInaSensor("Skipping energy fields until MQTT state restored"); + } + + // Serialize the JSON document into a character buffer + char buffer[1024]; + size_t payload_size = serializeJson(jsonDoc, buffer, sizeof(buffer)); + + if (payload_size >= sizeof(buffer) - 1) { + _logUsermodInaSensor("JSON serialization truncated – buffer too small (%u bytes)", sizeof(buffer)); + } + + // Construct the MQTT topic using the device topic + char topic[128]; + snprintf_P(topic, sizeof(topic), "%s/sensor/ina2xx", mqttDeviceTopic); + _logUsermodInaSensor("MQTT topic: %s", topic); + + // Publish the serialized JSON data to the specified MQTT topic + mqtt->publish(topic, 0, true, buffer, payload_size); + _logUsermodInaSensor("MQTT publish complete, payload size: %d bytes", payload_size); +} + +/** +** Function to create Home Assistant sensor configuration +**/ +void UsermodINA2xx::mqttCreateHassSensor(const String &name, const String &topic, + const String &deviceClass, const String &unitOfMeasurement, + const String &jsonKey, const String &SensorType) { + String sanitizedName = name; + sanitizedName.replace(' ', '-'); + _logUsermodInaSensor("Creating HA sensor: %s", sanitizedName.c_str()); + + String sanitizedMqttClientID = sanitizeMqttClientID(mqttClientID); + sanitizedMqttClientID += "-" + String(escapedMac.c_str()); + + // Create a JSON document for the sensor configuration + StaticJsonDocument<1024> doc; + + // Populate the JSON document with sensor configuration details + doc[F("name")] = name; + doc[F("stat_t")] = topic; + + String uid = escapedMac.c_str(); + uid += "_" + sanitizedName; + doc[F("uniq_id")] = uid; + _logUsermodInaSensor("Sensor unique ID: %s", uid.c_str()); + doc[F("val_tpl")] = String("{{ value_json.") + jsonKey + String(" }}"); + if (unitOfMeasurement != "") + doc[F("unit_of_meas")] = unitOfMeasurement; + if (deviceClass != "") + doc[F("dev_cla")] = deviceClass; + if (SensorType != "binary_sensor") + doc[F("exp_aft")] = 1800; + + // --- set appropriate state_class and last_reset for energy/measurement sensors --- + if (jsonKey == "total_energy_kWh") { + // total energy never resets -> total_increasing + doc[F("stat_cla")] = "total_increasing"; + } else if (jsonKey == "daily_energy_kWh" || jsonKey == "monthly_energy_kWh") { + // daily/monthly energy resets -> use "total" with last_reset_value_template + doc[F("stat_cla")] = "total"; + + // Provide last_reset for daily/monthly + if (jsonKey == "daily_energy_kWh") { + doc[F("last_reset_value_template")] = "{{ value_json.dailyResetTimestamp | int | timestamp_local if value_json.dailyResetTimestamp | int > 0 else none }}"; + } else if (jsonKey == "monthly_energy_kWh") { + doc[F("last_reset_value_template")] = "{{ value_json.monthlyResetTimestamp | int | timestamp_local if value_json.monthlyResetTimestamp | int > 0 else none }}"; + } + } + else if (jsonKey == "current_A" || jsonKey == "current_mA" || + jsonKey == "power_W" || jsonKey == "power_mW" || + jsonKey == "bus_voltage_V" || jsonKey == "load_voltage_V" || + jsonKey == "shunt_voltage_mV") { + doc[F("stat_cla")] = "measurement"; + } + + // Device details nested object + JsonObject device = doc.createNestedObject(F("device")); + device[F("name")] = serverDescription; + device[F("ids")] = serverDescription; + device[F("mf")] = F(WLED_BRAND); + device[F("mdl")] = F(WLED_PRODUCT_NAME); + device[F("sw")] = versionString; + #ifdef ESP32 + device[F("hw")] = F("esp32"); + #else + device[F("hw")] = F("esp8266"); + #endif + JsonArray connections = device.createNestedArray(F("cns")); + JsonArray macPair = connections.createNestedArray(); + macPair.add(F("mac")); + macPair.add(WiFi.macAddress()); + + // Serialize the JSON document into a temporary string + char buffer[1024]; + size_t payload_size = serializeJson(doc, buffer, sizeof(buffer)); + + if (payload_size >= sizeof(buffer) - 1) { + _logUsermodInaSensor("HA config JSON truncated – buffer too small (%u bytes)", sizeof(buffer)); + } + + char topic_S[128]; + int length = snprintf_P(topic_S, sizeof(topic_S), "homeassistant/%s/%s/%s/config", SensorType.c_str(), sanitizedMqttClientID.c_str(), sanitizedName.c_str()); + if (length >= sizeof(topic_S)) { + _logUsermodInaSensor("HA topic truncated - potential buffer overflow"); + } + + // Debug output for the Home Assistant topic and configuration + _logUsermodInaSensor("Topic: %s", topic_S); + _logUsermodInaSensor("Buffer: %s", buffer); + + // Publish the sensor configuration to Home Assistant + mqtt->publish(topic_S, 0, true, buffer, payload_size); + _logUsermodInaSensor("Home Assistant sensor %s created", sanitizedName.c_str()); +} + +void UsermodINA2xx::mqttRemoveHassSensor(const String &name, const String &SensorType) { + String sanitizedName = name; + sanitizedName.replace(' ', '-'); + _logUsermodInaSensor("Removing HA sensor: %s", sanitizedName.c_str()); + + String sanitizedMqttClientID = sanitizeMqttClientID(mqttClientID); + sanitizedMqttClientID += "-" + String(escapedMac.c_str()); + + char sensorTopic[128]; + int length = snprintf_P(sensorTopic, sizeof(sensorTopic), "homeassistant/%s/%s/%s/config", SensorType.c_str(), sanitizedMqttClientID.c_str(), sanitizedName.c_str()); + if (length >= sizeof(sensorTopic)) { + _logUsermodInaSensor("HA sensor topic truncated - potential buffer overflow"); + } + + // Publish an empty message with retain to delete the sensor from Home Assistant + mqtt->publish(sensorTopic, 0, true, ""); + _logUsermodInaSensor("Published empty message to remove sensor: %s", sensorTopic); +} + +/** +** Function to publish sensor data to MQTT +**/ +bool UsermodINA2xx::onMqttMessage(char* topic, char* payload) { + if (!WLED_MQTT_CONNECTED || !enabled) return false; + + // Check if the message is for the correct topic + if (strstr(topic, "/sensor/ina2xx") != nullptr) { + _logUsermodInaSensor("MQTT message received on INA2xx topic"); + StaticJsonDocument<512> jsonDoc; + + // Parse the JSON payload + DeserializationError error = deserializeJson(jsonDoc, payload); + if (error) { + _logUsermodInaSensor("JSON Parse Error: %s", error.c_str()); + return false; + } + + // Update the energy values + if (!mqttStateRestored) { + mqttRestoreData = MqttRestoreData{}; + + if (jsonDoc.containsKey("daily_energy_kWh")) { + float restored = jsonDoc["daily_energy_kWh"]; + if (!isnan(restored) && restored >= 0) { + mqttRestoreData.hasDailyEnergy = true; + mqttRestoreData.dailyEnergy = restored; + } + } + if (jsonDoc.containsKey("monthly_energy_kWh")) { + float restored = jsonDoc["monthly_energy_kWh"]; + if (!isnan(restored) && restored >= 0) { + mqttRestoreData.hasMonthlyEnergy = true; + mqttRestoreData.monthlyEnergy = restored; + } + } + if (jsonDoc.containsKey("total_energy_kWh")) { + float restored = jsonDoc["total_energy_kWh"]; + if (!isnan(restored) && restored >= 0) { + mqttRestoreData.hasTotalEnergy = true; + mqttRestoreData.totalEnergy = restored; + } + } + if (jsonDoc.containsKey("dailyResetTime")) { + uint32_t restored = jsonDoc["dailyResetTime"].as(); + if (restored > 0 && restored < 1000000) { // reasonable day count since epoch + mqttRestoreData.hasDailyResetTime = true; + mqttRestoreData.dailyResetTime = restored; + } + } + if (jsonDoc.containsKey("monthlyResetTime")) { + uint32_t restored = jsonDoc["monthlyResetTime"].as(); + if (restored > 0 && restored < 100000) { // reasonable month count + mqttRestoreData.hasMonthlyResetTime = true; + mqttRestoreData.monthlyResetTime = restored; + } + } + if (jsonDoc.containsKey("dailyResetTimestamp")) { + uint32_t restored = jsonDoc["dailyResetTimestamp"].as(); + if (restored >= 1577836800UL && restored <= 4102444800UL) { + mqttRestoreData.hasDailyResetTimestamp = true; + mqttRestoreData.dailyResetTimestamp = restored; + } + } + if (jsonDoc.containsKey("monthlyResetTimestamp")) { + uint32_t restored = jsonDoc["monthlyResetTimestamp"].as(); + if (restored >= 1577836800UL && restored <= 4102444800UL) { + mqttRestoreData.hasMonthlyResetTimestamp = true; + mqttRestoreData.monthlyResetTimestamp = restored; + } + } + + mqttRestorePending = true; + applyMqttRestoreIfReady(); + } + return true; + } + return false; +} + +/** +** Subscribe to MQTT topic for controlling the usermod +**/ +void UsermodINA2xx::onMqttConnect(bool sessionPresent) { + if (!enabled) return; + if (WLED_MQTT_CONNECTED) { + char subuf[64]; + if (mqttDeviceTopic[0] != 0) { + snprintf_P(subuf, sizeof(subuf), PSTR("%s/sensor/ina2xx"), mqttDeviceTopic); + mqtt->subscribe(subuf, 0); + _logUsermodInaSensor("Subscribed to MQTT topic: %s", subuf); + } + } +} +#endif + +// Destructor to clean up INA2xx object +UsermodINA2xx::~UsermodINA2xx() { + if (_ina2xx) { + _logUsermodInaSensor("Cleaning up INA2xx sensor object"); + delete _ina2xx; + _ina2xx = nullptr; + } +} + +// Setup function called once on boot or restart +void UsermodINA2xx::setup() { + _logUsermodInaSensor("Setting up INA2xx sensor usermod"); + initDone = updateINA2xxSettings(); // Configure INA2xx settings +#if INA_SENSOR_TYPE == 226 + if (initDone && _ina2xx) { + _ina2xx->waitUntilConversionCompleted(); // first data can be zero without this in continuous mode + } +#endif + if (initDone) { + _logUsermodInaSensor("INA2xx setup complete and successful"); + } else { + _logUsermodInaSensor("INA2xx setup failed"); + } +} + +#if INA_SENSOR_TYPE == 226 +void UsermodINA2xx::checkForI2cErrors(){ + byte errorCode = _ina2xx->getI2cErrorCode(); + if(errorCode){ + _logUsermodInaSensor("I2C error: %u", errorCode); + switch(errorCode){ + case 1: + _logUsermodInaSensor("Data too long to fit in transmit buffer"); + break; + case 2: + _logUsermodInaSensor("Received NACK on transmit of address"); + break; + case 3: + _logUsermodInaSensor("Received NACK on transmit of data"); + break; + case 4: + _logUsermodInaSensor("Other error"); + break; + case 5: + _logUsermodInaSensor("Timeout"); + break; + default: + _logUsermodInaSensor("Can't identify the error"); + } + if(errorCode){ + enabled = false; + initDone = false; + _logUsermodInaSensor("Disabling INA2xx usermod after I2C error"); + } + } +} +#endif + +// Loop function called continuously +void UsermodINA2xx::loop() { + applyMqttRestoreIfReady(); + + // Check if the usermod is enabled and the check interval has elapsed + if (!enabled || !initDone || !_ina2xx || millis() - lastCheck < checkInterval) { + return; + } + + lastCheck = millis(); + _logUsermodInaSensor("Reading sensor data at %lu ms", lastCheck); + + // Fetch sensor data + shuntVoltage = roundDecimals(_ina2xx->getShuntVoltage_mV()); + busVoltage = roundDecimals(_ina2xx->getBusVoltage_V()); + + float rawCurrent_mA = _ina2xx->getCurrent_mA(); + current_mA = roundDecimals(rawCurrent_mA); + current = roundDecimals(rawCurrent_mA / 1000.0); // Convert from mA to A + + float rawPower_mW = _ina2xx->getBusPower(); + power_mW = roundDecimals(rawPower_mW); + power = roundDecimals(rawPower_mW / 1000.0); // Convert from mW to W + + loadVoltage = roundDecimals(busVoltage + (shuntVoltage / 1000)); + +#if INA_SENSOR_TYPE == 219 + overflow = _ina2xx->getOverflow() != 0; +#elif INA_SENSOR_TYPE == 226 + overflow = _ina2xx->overflow; + checkForI2cErrors(); +#endif + + _logUsermodInaSensor("Sensor readings - Shunt: %.3f mV, Bus: %.3f V, Load: %.3f V", shuntVoltage, busVoltage, loadVoltage); + _logUsermodInaSensor("Sensor readings - Current: %.3f A (%.3f mA), Power: %.3f W (%.3f mW)", current, current_mA, power, power_mW); + _logUsermodInaSensor("Overflow status: %s", overflow ? "TRUE" : "FALSE"); + + // Update energy consumption + if (lastPublishTime != 0) { + if (power >= 0) { + // Handle millis() overflow when calculating duration + unsigned long duration = (lastCheck >= lastPublishTime) + ? (lastCheck - lastPublishTime) + : (0xFFFFFFFFUL - lastPublishTime + lastCheck + 1); + updateEnergy(power, duration); + } else { + _logUsermodInaSensor("Skipping energy update due to negative power: %.3f W", power); + } + } else { + _logUsermodInaSensor("First reading - establishing baseline for energy calculation"); + } + lastPublishTime = lastCheck; + +#ifndef WLED_DISABLE_MQTT + // Publish sensor data via MQTT if connected and enabled + if (WLED_MQTT_CONNECTED) { + if (mqttPublish) { + bool valueChanged = hasValueChanged(); + if (mqttPublishAlways || valueChanged) { + _logUsermodInaSensor("Publishing MQTT data (always=%d, changed=%d)", mqttPublishAlways, valueChanged); + publishMqtt(shuntVoltage, busVoltage, loadVoltage, current, current_mA, power, power_mW, overflow); + + last_sent_shuntVoltage = shuntVoltage; + last_sent_busVoltage = busVoltage; + last_sent_loadVoltage = loadVoltage; + last_sent_current = current; + last_sent_current_mA = current_mA; + last_sent_power = power; + last_sent_power_mW = power_mW; + last_sent_overflow = overflow; + + mqttPublishSent = true; + } else { + _logUsermodInaSensor("No significant change in values, skipping MQTT publish"); + } + } else if (!mqttPublish && mqttPublishSent) { + _logUsermodInaSensor("MQTT publishing disabled, removing previous retained message"); + char sensorTopic[128]; + snprintf_P(sensorTopic, 127, "%s/sensor/ina2xx", mqttDeviceTopic); + + // Publishing an empty retained message to delete the sensor from Home Assistant + mqtt->publish(sensorTopic, 0, true, ""); + mqttPublishSent = false; + } + } + + // Publish Home Assistant discovery data if enabled + if (haDiscovery && !haDiscoverySent) { + if (WLED_MQTT_CONNECTED) { + _logUsermodInaSensor("Setting up Home Assistant discovery"); + char topic[128]; + snprintf_P(topic, 127, "%s/sensor/ina2xx", mqttDeviceTopic); // Common topic for all INA2xx data + + mqttCreateHassSensor(F("Current"), topic, F("current"), F("A"), F("current_A"), F("sensor")); + mqttCreateHassSensor(F("Voltage"), topic, F("voltage"), F("V"), F("bus_voltage_V"), F("sensor")); + mqttCreateHassSensor(F("Power"), topic, F("power"), F("W"), F("power_W"), F("sensor")); + mqttCreateHassSensor(F("Shunt Voltage"), topic, F("voltage"), F("mV"), F("shunt_voltage_mV"), F("sensor")); + mqttCreateHassSensor(F("Shunt Resistor"), topic, F(""), F("Ω"), F("shunt_resistor_Ohms"), F("sensor")); + mqttCreateHassSensor(F("Overflow"), topic, F(""), F(""), F("overflow"), F("sensor")); + mqttCreateHassSensor(F("Daily Energy"), topic, F("energy"), F("kWh"), F("daily_energy_kWh"), F("sensor")); + mqttCreateHassSensor(F("Monthly Energy"), topic, F("energy"), F("kWh"), F("monthly_energy_kWh"), F("sensor")); + mqttCreateHassSensor(F("Total Energy"), topic, F("energy"), F("kWh"), F("total_energy_kWh"), F("sensor")); + + haDiscoverySent = true; // Mark as sent to avoid repeating + _logUsermodInaSensor("Home Assistant discovery complete"); + } + } else if (!haDiscovery && haDiscoverySent) { + if (WLED_MQTT_CONNECTED) { + _logUsermodInaSensor("Removing Home Assistant discovery sensors"); + // Remove previously created sensors + mqttRemoveHassSensor(F("Current"), F("sensor")); + mqttRemoveHassSensor(F("Voltage"), F("sensor")); + mqttRemoveHassSensor(F("Power"), F("sensor")); + mqttRemoveHassSensor(F("Shunt Voltage"), F("sensor")); + mqttRemoveHassSensor(F("Shunt Resistor"), F("sensor")); + mqttRemoveHassSensor(F("Overflow"), F("sensor")); + mqttRemoveHassSensor(F("Daily Energy"), F("sensor")); + mqttRemoveHassSensor(F("Monthly Energy"), F("sensor")); + mqttRemoveHassSensor(F("Total Energy"), F("sensor")); + + haDiscoverySent = false; // Mark as sent to avoid repeating + _logUsermodInaSensor("Home Assistant discovery removal complete"); + } + } +#endif +} + +/** +** Add energy consumption data to a JSON object for reporting +**/ +void UsermodINA2xx::addToJsonInfo(JsonObject &root) { + JsonObject user = root[F("u")]; + if (user.isNull()) { + user = root.createNestedObject(F("u")); + } + + JsonArray energy_json = user.createNestedArray(F("INA2xx:")); + + if (!enabled || !initDone) { + energy_json.add(F("disabled")); + if (!alreadyLoggedDisabled) { + _logUsermodInaSensor("Adding disabled status to JSON info"); + alreadyLoggedDisabled = true; + } + return; + } + alreadyLoggedDisabled = false; + + // File needs to be UTF-8 to show an arrow that points down and right instead of an question mark + // Create a nested array for daily energy + JsonArray dailyEnergy_json = user.createNestedArray(F("⤷ Daily Energy")); + dailyEnergy_json.add(dailyEnergy_kWh); + dailyEnergy_json.add(F(" kWh")); + + // Create a nested array for monthly energy + JsonArray monthlyEnergy_json = user.createNestedArray(F("⤷ Monthly Energy")); + monthlyEnergy_json.add(monthlyEnergy_kWh); + monthlyEnergy_json.add(F(" kWh")); + + // Create a nested array for total energy + JsonArray totalEnergy_json = user.createNestedArray(F("⤷ Total Energy")); + totalEnergy_json.add(totalEnergy_kWh); + totalEnergy_json.add(F(" kWh")); + + _logUsermodInaSensor("Added energy data to JSON info: daily=%.6f, monthly=%.6f, total=%.6f kWh", dailyEnergy_kWh, monthlyEnergy_kWh, totalEnergy_kWh); +} + +/** +** Add the current state of energy consumption to a JSON object +**/ +void UsermodINA2xx::addToJsonState(JsonObject& root) { + if (!enabled) return; + if (!initDone) { + _logUsermodInaSensor("Not adding to JSON state - initialization not complete"); + return; + } + + JsonObject usermod = root[FPSTR(_name)]; + if (usermod.isNull()) { + usermod = root.createNestedObject(FPSTR(_name)); + } + + usermod["enabled"] = enabled; + usermod["shuntVoltage_mV"] = shuntVoltage; + usermod["busVoltage_V"] = busVoltage; + usermod["loadVoltage_V"] = loadVoltage; + usermod["current_A"] = current; + usermod["current_mA"] = current_mA; + usermod["power_W"] = power; + usermod["power_mW"] = power_mW; + usermod["overflow"] = overflow; + usermod["totalEnergy_kWh"] = totalEnergy_kWh; + usermod["dailyEnergy_kWh"] = dailyEnergy_kWh; + usermod["monthlyEnergy_kWh"] = monthlyEnergy_kWh; + usermod["dailyResetTime"] = dailyResetTime; + usermod["monthlyResetTime"] = monthlyResetTime; + usermod["dailyResetTimestamp"] = dailyResetTimestamp; + usermod["monthlyResetTimestamp"] = monthlyResetTimestamp; + + _logUsermodInaSensor("Added sensor readings to JSON state: V=%.3fV, I=%.3fA, P=%.3fW", loadVoltage, current, power); +} + +/** +** Read energy consumption data from a JSON object +**/ +void UsermodINA2xx::readFromJsonState(JsonObject& root) { + if (!enabled) return; + if (!initDone) { // Prevent crashes on boot if initialization is not done + _logUsermodInaSensor("Not reading from JSON state - initialization not complete"); + return; + } + + JsonObject usermod = root[FPSTR(_name)]; + if (!usermod.isNull()) { + // Read values from JSON or retain existing values if not present + if (usermod.containsKey("enabled")) { + bool prevEnabled = enabled; + enabled = usermod["enabled"] | enabled; + if (prevEnabled != enabled) { + _logUsermodInaSensor("Enabled state changed: %s", enabled ? "enabled" : "disabled"); + } + } + + if (usermod.containsKey("totalEnergy_kWh")) { + float prevTotal = totalEnergy_kWh; + totalEnergy_kWh = usermod["totalEnergy_kWh"] | totalEnergy_kWh; + if (totalEnergy_kWh != prevTotal) { + _logUsermodInaSensor("Total energy updated from JSON: %.6f kWh", totalEnergy_kWh); + } + } + + if (usermod.containsKey("dailyEnergy_kWh")) { + float prevDaily = dailyEnergy_kWh; + dailyEnergy_kWh = usermod["dailyEnergy_kWh"] | dailyEnergy_kWh; + if (dailyEnergy_kWh != prevDaily) { + _logUsermodInaSensor("Daily energy updated from JSON: %.6f kWh", dailyEnergy_kWh); + } + } + + if (usermod.containsKey("monthlyEnergy_kWh")) { + float prevMonthly = monthlyEnergy_kWh; + monthlyEnergy_kWh = usermod["monthlyEnergy_kWh"] | monthlyEnergy_kWh; + if (monthlyEnergy_kWh != prevMonthly) { + _logUsermodInaSensor("Monthly energy updated from JSON: %.6f kWh", monthlyEnergy_kWh); + } + } + + if (usermod.containsKey("dailyResetTime")) { + unsigned long prevDailyReset = dailyResetTime; + dailyResetTime = usermod["dailyResetTime"] | dailyResetTime; + if (dailyResetTime != prevDailyReset) { + _logUsermodInaSensor("Daily reset time updated from JSON: %lu", dailyResetTime); + } + } + + if (usermod.containsKey("monthlyResetTime")) { + unsigned long prevMonthlyReset = monthlyResetTime; + monthlyResetTime = usermod["monthlyResetTime"] | monthlyResetTime; + if (monthlyResetTime != prevMonthlyReset) { + _logUsermodInaSensor("Monthly reset time updated from JSON: %lu", monthlyResetTime); + } + } + if (usermod.containsKey("dailyResetTimestamp")) { + unsigned long prevDailyRT = dailyResetTimestamp; + dailyResetTimestamp = usermod["dailyResetTimestamp"] | dailyResetTimestamp; + if (dailyResetTimestamp != prevDailyRT) { + _logUsermodInaSensor("Daily reset timestamp updated from JSON: %lu", dailyResetTimestamp); + } + } + if (usermod.containsKey("monthlyResetTimestamp")) { + unsigned long prevMonthlyRT = monthlyResetTimestamp; + monthlyResetTimestamp = usermod["monthlyResetTimestamp"] | monthlyResetTimestamp; + if (monthlyResetTimestamp != prevMonthlyRT) { + _logUsermodInaSensor("Monthly reset timestamp updated from JSON: %lu", monthlyResetTimestamp); + } + } + } else { + _logUsermodInaSensor("No usermod data found in JSON state"); + } +} + +/** +** Append configuration options to the Usermod menu. +**/ +void UsermodINA2xx::addToConfig(JsonObject& root) { + JsonObject top = root.createNestedObject(F("INA2xx")); + top["Enabled"] = enabled; + top["i2c_address"] = static_cast(_i2cAddress); + top["check_interval"] = checkInterval / 1000; + top["conversion_time"] = conversionTime; + top["decimals"] = _decimalFactor; + top["shunt_resistor"] = shuntResistor; + top["correction_factor"] = correctionFactor; +#if INA_SENSOR_TYPE == 219 + top["pga_gain"] = pGain; + top["bus_range"] = busRange; + top["shunt_offset"] = shuntVoltOffset_mV; +#elif INA_SENSOR_TYPE == 226 + top["average"] = average; + top["currentRange"] = currentRange; +#endif + +#ifndef WLED_DISABLE_MQTT + // Store MQTT settings if MQTT is not disabled + top["mqtt_publish"] = mqttPublish; + top["mqtt_publish_always"] = mqttPublishAlways; + top["ha_discovery"] = haDiscovery; +#endif +} + +/** +** Append configuration UI data for the usermod menu. +**/ +void UsermodINA2xx::appendConfigData() { + // Append the dropdown for I2C address selection + oappend("dd=addDropdown('INA2xx','i2c_address');"); + oappend("addOption(dd,'0x40 - Default',0x40, true);"); + oappend("addOption(dd,'0x41 - A0 soldered',0x41);"); + oappend("addOption(dd,'0x44 - A1 soldered',0x44);"); + oappend("addOption(dd,'0x45 - A0 and A1 soldered',0x45);"); + + // Append the dropdown for decimal precision (0 to 3) + oappend("df=addDropdown('INA2xx','decimals');"); + for (int i = 0; i <= 3; i++) { + oappend(String("addOption(df,'" + String(i) + "'," + String(i) + (i == _decimalFactor ? ", true);" : ");")).c_str()); + } + +#if INA_SENSOR_TYPE == 219 + // Append the dropdown for ADC mode (resolution + samples) + oappend("ct=addDropdown('INA2xx','conversion_time');"); + oappend("addOption(ct,'9-Bit (84 µs)',0);"); + oappend("addOption(ct,'10-Bit (148 µs)',1);"); + oappend("addOption(ct,'11-Bit (276 µs)',2);"); + oappend("addOption(ct,'12-Bit (532 µs) (default)',3, true);"); + oappend("addOption(ct,'2 samples (1.06 ms)',9);"); + oappend("addOption(ct,'4 samples (2.13 ms)',10);"); + oappend("addOption(ct,'8 samples (4.26 ms)',11);"); + oappend("addOption(ct,'16 samples (8.51 ms)',12);"); + oappend("addOption(ct,'32 samples (17.02 ms)',13);"); + oappend("addOption(ct,'64 samples (34.05 ms)',14);"); + oappend("addOption(ct,'128 samples (68.10 ms)',15);"); + + oappend("pg=addDropdown('INA2xx','pga_gain');"); + oappend("addOption(pg,'40mV',0);"); + oappend("addOption(pg,'80mV',2048);"); + oappend("addOption(pg,'160mV',4096);"); + oappend("addOption(pg,'320mV (default)',6144, true);"); + + oappend("br=addDropdown('INA2xx','bus_range');"); + oappend("addOption(br,'16V',0);"); + oappend("addOption(br,'32V (default)',8192, true);"); +#elif INA_SENSOR_TYPE == 226 + oappend("ct=addDropdown('INA2xx','conversion_time');"); + oappend("addOption(ct,'140 µs',0);"); + oappend("addOption(ct,'204 µs',1);"); + oappend("addOption(ct,'332 µs',2);"); + oappend("addOption(ct,'588 µs',3);"); + oappend("addOption(ct,'1.1 ms (default)',4, true);"); + oappend("addOption(ct,'2.116 ms',5);"); + oappend("addOption(ct,'4.156 ms',6);"); + oappend("addOption(ct,'8.244 ms',7);"); + + oappend("dda=addDropdown('INA2xx','average');"); + oappend("addOption(dda,'1 (default)',0, true);"); + oappend("addOption(dda,'4',512);"); + oappend("addOption(dda,'16',1024);"); + oappend("addOption(dda,'64',1536);"); + oappend("addOption(dda,'128',2048);"); + oappend("addOption(dda,'256',2560);"); + oappend("addOption(dda,'512',3072);"); + oappend("addOption(dda,'1024',3584);"); + + oappend("df = addDropdown('INA2xx','currentRange');"); + for (int i = 1; i <= 100; i++) { + float amp = i * 0.1f; // 0.1, 0.2, …, 10.0 + String strVal = String(amp, 1); // “0.1”, “0.2”, …, “1.3”, …, “10.0” + + // Make “1.3” the default + bool selected = (fabs(amp - 1.3f) < 0.001f); + + // Build the label: e.g. “1.3A (default)” or “3.7A” + String label = strVal + "A"; + if (selected) label += " (default)"; + + // addOption(df,'3.7A',3.7); + String line = String("addOption(df,'") + + label + + "'," + + strVal + + (selected ? ", true);" : ");"); + oappend(line.c_str()); + } +#endif +} + +/** +** Read settings from the Usermod menu configuration +**/ +bool UsermodINA2xx::readFromConfig(JsonObject& root) { + JsonObject top = root[FPSTR(_name)]; + + bool configComplete = !top.isNull(); + + _logUsermodInaSensor("Checking if configuration has changed:"); + UPDATE_CONFIG(top, "Enabled", enabled, "%u"); + UPDATE_CONFIG(top, "i2c_address", _i2cAddress, "0x%02X"); + UPDATE_CONFIG(top, "conversion_time", conversionTime, "%u"); + UPDATE_CONFIG(top, "decimals", _decimalFactor, "%u"); + UPDATE_CONFIG(top, "shunt_resistor", shuntResistor, "%.6f Ohms"); + UPDATE_CONFIG(top, "correction_factor", correctionFactor, "%.3f"); +#if INA_SENSOR_TYPE == 219 + UPDATE_CONFIG(top, "pga_gain", pGain, "%d"); + UPDATE_CONFIG(top, "bus_range", busRange, "%d"); + UPDATE_CONFIG(top, "shunt_offset", shuntVoltOffset_mV,"%.3f mV"); +#elif INA_SENSOR_TYPE == 226 + UPDATE_CONFIG(top, "average", average, "%d"); + UPDATE_CONFIG(top, "currentRange", currentRange, "%.1f"); +#endif + +#ifndef WLED_DISABLE_MQTT + UPDATE_CONFIG(top, "mqtt_publish", mqttPublish, "%u"); + UPDATE_CONFIG(top, "mqtt_publish_always", mqttPublishAlways, "%u"); + + bool tempHaDiscovery = haDiscovery; + UPDATE_CONFIG(top, "ha_discovery", haDiscovery, "%u"); + if (haDiscovery != tempHaDiscovery) haDiscoverySent = !haDiscovery; +#endif + + uint16_t tempInterval = 0; + if (getJsonValue(top[F("check_interval")], tempInterval)) { + if (1 <= tempInterval && tempInterval <= 600) { + uint32_t newInterval = static_cast(tempInterval) * 1000UL; + if (newInterval != checkInterval) { + _logUsermodInaSensor("Check interval updated to: %u ms", newInterval); + checkInterval = newInterval; + } + } else { + _logUsermodInaSensor("Invalid check_interval value %u; using default %u seconds", tempInterval, INA2XX_CHECK_INTERVAL); + checkInterval = static_cast(_checkInterval) * 1000UL; + } + } else { + configComplete = false; + } + + bool prevInitDone = initDone; + initDone = updateINA2xxSettings(); // Configure INA2xx settings + + if (prevInitDone != initDone) { + _logUsermodInaSensor("Sensor initialization %s", initDone ? "succeeded" : "failed"); + } + + return configComplete; +} + +/** +** Get the unique identifier for this usermod. +**/ +uint16_t UsermodINA2xx::getId() { + return USERMOD_ID_INA2XX; +} diff --git a/usermods/ina2xx_v2/ina2xx_v2.h b/usermods/ina2xx_v2/ina2xx_v2.h new file mode 100644 index 0000000000..b746e70c55 --- /dev/null +++ b/usermods/ina2xx_v2/ina2xx_v2.h @@ -0,0 +1,221 @@ +// Configurable settings for the INA2xx Usermod +#pragma once + +#include "wled.h" + +// Choose sensor type: 219 or 226 +#ifndef INA_SENSOR_TYPE + #define INA_SENSOR_TYPE 219 +#endif + +#if INA_SENSOR_TYPE == 219 + #include + using INA_SENSOR_CLASS = INA219_WE; +#elif INA_SENSOR_TYPE == 226 + #include + using INA_SENSOR_CLASS = INA226_WE; +#else + #error "INA_SENSOR_TYPE must be 219 or 226" +#endif + +// logging macro: +#define _logUsermodInaSensor(fmt, ...) \ + DEBUG_PRINTF("[INA2xx_Sensor] " fmt "\n", ##__VA_ARGS__) + +#ifndef INA2XX_ENABLED + #define INA2XX_ENABLED false // Default disabled value +#endif +#ifndef INA2XX_I2C_ADDRESS + #define INA2XX_I2C_ADDRESS 0x40 // Default I2C address +#endif +#ifndef INA2XX_CHECK_INTERVAL + #define INA2XX_CHECK_INTERVAL 5 // Default 5 seconds (will be converted to ms) +#endif +#ifndef INA2XX_CORRECTION_FACTOR + #define INA2XX_CORRECTION_FACTOR 1.0 // Default correction factor. Default 1.0 +#endif +#ifndef INA2XX_CONVERSION_TIME + #if INA_SENSOR_TYPE == 219 + #define INA2XX_CONVERSION_TIME BIT_MODE_12 // Conversion Time, Default 12-bit resolution + #elif INA_SENSOR_TYPE == 226 + #define INA2XX_CONVERSION_TIME CONV_TIME_1100 // Conversion Time + #endif +#endif +#ifndef INA2XX_SHUNT_RESISTOR + #define INA2XX_SHUNT_RESISTOR 0.1 // Shunt Resistor value. Default 0.1 ohms +#endif +#ifndef INA2XX_DECIMAL_FACTOR + #define INA2XX_DECIMAL_FACTOR 3 // Decimal factor for current/power readings. Default 3 decimal places +#endif + +#if INA_SENSOR_TYPE == 219 + #ifndef INA2XX_BUSRANGE + #define INA2XX_BUSRANGE BRNG_32 // BRNG_16, BRNG_32 + #endif + #ifndef INA2XX_P_GAIN + #define INA2XX_P_GAIN PG_320 // PG_40, PG_80, PG_160, PG_320 + #endif + #ifndef INA2XX_SHUNT_VOLT_OFFSET + #define INA2XX_SHUNT_VOLT_OFFSET 0.0 // mV offset at zero current + #endif +#elif INA_SENSOR_TYPE == 226 + #ifndef INA2XX_AVERAGES + #define INA2XX_AVERAGES AVERAGE_1 + #endif + #ifndef INA2XX_RANGE + #define INA2XX_RANGE 1.3 //Current Range, Max 10.0 (10A) + #endif +#endif + +#ifndef INA2XX_MQTT_PUBLISH + #define INA2XX_MQTT_PUBLISH false // Default: do not publish to MQTT +#endif +#ifndef INA2XX_MQTT_PUBLISH_ALWAYS + #define INA2XX_MQTT_PUBLISH_ALWAYS false // Default: only publish on change +#endif +#ifndef INA2XX_HA_DISCOVERY + #define INA2XX_HA_DISCOVERY false // Default: Home Assistant discovery disabled +#endif + +#define UPDATE_CONFIG(obj, key, var, fmt) \ + do { \ + auto _tmp = var; \ + if ( getJsonValue((obj)[(key)], _tmp) ) { \ + if (_tmp != var) { \ + _logUsermodInaSensor("%s updated to: " fmt, key, _tmp);\ + var = _tmp; \ + } \ + } else { \ + configComplete = false; \ + } \ + } while(0) + +class UsermodINA2xx : public Usermod { +private: + static const char _name[]; + bool initDone = false; // Flag for successful initialization + unsigned long lastCheck = 0; // Timestamp for the last check + bool alreadyLoggedDisabled = false; + + // Define the variables using the pre-defined or default values + bool enabled = INA2XX_ENABLED; + uint8_t _i2cAddress = INA2XX_I2C_ADDRESS; + uint16_t _checkInterval = INA2XX_CHECK_INTERVAL; // seconds + uint32_t checkInterval = static_cast(_checkInterval) * 1000UL; // ms + uint8_t _decimalFactor = INA2XX_DECIMAL_FACTOR; + float shuntResistor = INA2XX_SHUNT_RESISTOR; + float correctionFactor = INA2XX_CORRECTION_FACTOR; + +#if INA_SENSOR_TYPE == 219 + INA219_PGAIN pGain = static_cast(INA2XX_P_GAIN); + INA219_BUS_RANGE busRange = static_cast(INA2XX_BUSRANGE); + float shuntVoltOffset_mV = INA2XX_SHUNT_VOLT_OFFSET; + + INA219_ADC_MODE conversionTime = static_cast(INA2XX_CONVERSION_TIME); +#elif INA_SENSOR_TYPE == 226 + INA226_AVERAGES average = static_cast(INA2XX_AVERAGES); + INA226_CONV_TIME conversionTime = static_cast(INA2XX_CONVERSION_TIME); + float currentRange = INA2XX_RANGE; +#endif + + bool mqttPublish = INA2XX_MQTT_PUBLISH; + bool mqttPublishSent = !INA2XX_MQTT_PUBLISH; + bool mqttPublishAlways = INA2XX_MQTT_PUBLISH_ALWAYS; + bool haDiscovery = INA2XX_HA_DISCOVERY; + bool haDiscoverySent = !INA2XX_HA_DISCOVERY; + + // Variables to store sensor readings + float busVoltage = 0.0; + float current = 0.0; + float current_mA = 0.0; + float power = 0.0; + float power_mW = 0.0; + float shuntVoltage = 0.0; + float loadVoltage = 0.0; + bool overflow = false; + + // Last sent variables + float last_sent_shuntVoltage = 0; + float last_sent_busVoltage = 0; + float last_sent_loadVoltage = 0; + float last_sent_current = 0; + float last_sent_current_mA = 0; + float last_sent_power = 0; + float last_sent_power_mW = 0; + bool last_sent_overflow = false; + + float dailyEnergy_kWh = 0.0; // Daily energy in kWh + float monthlyEnergy_kWh = 0.0; // Monthly energy in kWh + float totalEnergy_kWh = 0.0; // Total energy in kWh + unsigned long lastPublishTime = 0; // Track the last publish time + + // Variables to store last reset timestamps + unsigned long dailyResetTime = 0; + unsigned long monthlyResetTime = 0; + + // epoch timestamps (seconds since epoch) of the most recent resets (midnight) + unsigned long dailyResetTimestamp = 0; + unsigned long monthlyResetTimestamp = 0; + + bool mqttStateRestored = false; + bool mqttRestorePending = false; + bool mqttRestoreDeferredLogged = false; + + struct MqttRestoreData { + bool hasDailyEnergy = false; + bool hasMonthlyEnergy = false; + bool hasTotalEnergy = false; + bool hasDailyResetTime = false; + bool hasMonthlyResetTime = false; + bool hasDailyResetTimestamp = false; + bool hasMonthlyResetTimestamp = false; + float dailyEnergy = 0.0f; + float monthlyEnergy = 0.0f; + float totalEnergy = 0.0f; + uint32_t dailyResetTime = 0; + uint32_t monthlyResetTime = 0; + uint32_t dailyResetTimestamp = 0; + uint32_t monthlyResetTimestamp = 0; + }; + + MqttRestoreData mqttRestoreData; + + INA_SENSOR_CLASS *_ina2xx = nullptr; // INA2xx sensor object + + float roundDecimals(float val); + bool hasSignificantChange(float oldValue, float newValue, float threshold = 0.01f); + bool hasValueChanged(); + void checkForI2cErrors(); + bool isTimeValid() const; + void applyMqttRestoreIfReady(); + bool updateINA2xxSettings(); + String sanitizeMqttClientID(const String &clientID); + void updateEnergy(float power, unsigned long durationMs); +#ifndef WLED_DISABLE_MQTT + void publishMqtt(float shuntVoltage, float busVoltage, float loadVoltage, + float current, float current_mA, float power, + float power_mW, bool overflow); + void mqttCreateHassSensor(const String &name, const String &topic, + const String &deviceClass, const String &unitOfMeasurement, + const String &jsonKey, const String &SensorType); + void mqttRemoveHassSensor(const String &name, const String &SensorType); +#endif + +public: + ~UsermodINA2xx(); + void setup() override; + void loop() override; +#ifndef WLED_DISABLE_MQTT + bool onMqttMessage(char* topic, char* payload) override; + void onMqttConnect(bool sessionPresent) override; +#endif + void addToJsonInfo(JsonObject &root) override; + void addToJsonState(JsonObject &root) override; + void readFromJsonState(JsonObject &root) override; + void addToConfig(JsonObject& root) override; + void appendConfigData() override; + bool readFromConfig(JsonObject& root) override; + uint16_t getId() override; +}; + +extern UsermodINA2xx ina2xx_v2; diff --git a/usermods/ina2xx_v2/library.json b/usermods/ina2xx_v2/library.json new file mode 100644 index 0000000000..e0158373b3 --- /dev/null +++ b/usermods/ina2xx_v2/library.json @@ -0,0 +1,10 @@ +{ + "name": "ina2xx_v2", + "build": { + "libArchive": false + }, + "dependencies": { + "wollewald/INA219_WE":"~1.3.8", + "wollewald/INA226_WE":"~1.2.12" + } +} \ No newline at end of file diff --git a/wled00/const.h b/wled00/const.h index 8891dfcaee..dd864b1bf1 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -207,6 +207,7 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define USERMOD_ID_RF433 56 //Usermod "usermod_v2_RF433.h" #define USERMOD_ID_BRIGHTNESS_FOLLOW_SUN 57 //Usermod "usermod_v2_brightness_follow_sun.h" #define USERMOD_ID_USER_FX 58 //Usermod "user_fx" +#define USERMOD_ID_INA2XX 59 // Usermod "INA2XX_v2.h" //Access point behavior #define AP_BEHAVIOR_BOOT_NO_CONN 0 //Open AP when no connection after boot