Skip to content

added: KOBA_EGPU mod for controlling cooling and lighting in the self…#5349

Open
ArthurKoba wants to merge 1 commit intowled:mainfrom
ArthurKoba:main
Open

added: KOBA_EGPU mod for controlling cooling and lighting in the self…#5349
ArthurKoba wants to merge 1 commit intowled:mainfrom
ArthurKoba:main

Conversation

@ArthurKoba
Copy link

@ArthurKoba ArthurKoba commented Feb 5, 2026

The mod does not make significant changes to the WLED system, but only supplements it with an additional, optional mod. If we describe the mod most compactly, then it is intended for a self-made eGPU station.

Summary by CodeRabbit

  • New Features
    • Added KOBA eGPU module for real-time GPU and fan temperature monitoring
    • Automatic fan speed control with configurable temperature thresholds
    • Real-time fan RPM telemetry display
    • Integrated configuration and status UI within WLED

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 5, 2026

Walkthrough

Introduces a new ESP32C3-based KOBA eGPU usermod providing temperature sensing from thermistors, dynamic fan PWM control based on configurable temperature thresholds, tachometer-based RPM measurement via ISR, and JSON configuration integration with WLED.

Changes

Cohort / File(s) Summary
Core Implementation
usermods/KOBA_EGPU/koba_egpu.cpp
New usermod with NTCThermistor temperature calculations, KobaEGPUUserModCore for ADC reading, LEDC PWM fan control, and ISR-based tachometer RPM estimation. Includes KobaEGPUUsermod subclass with WLED integration (lifecycle, JSON serialization, config accessors).
Build Configuration
usermods/KOBA_EGPU/library.json
Adds library metadata defining package name and build configuration for the KOBA_EGPU usermod.
Documentation
usermods/KOBA_EGPU/readme.md
New documentation describing the usermod's purpose, hardware context, and PlatformIO configuration sample with build flags and dependencies.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding a KOBA_EGPU mod for controlling cooling and lighting. It clearly summarizes the primary purpose of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@usermods/KOBA_EGPU/koba_egpu.cpp`:
- Line 55: _pulses_count is subject to a race between the ISR and
_update_tachometer_speed; change its type to std::atomic<uint32_t> (include
<atomic>) and replace non-atomic reads/resets with atomic operations (e.g. use
fetch_exchange(0) or exchange(0) in _update_tachometer_speed to atomically
capture-and-clear the count) while keeping the ISR increment using fetch_add(1)
(or ++ on the atomic). Update all references to _pulses_count (ISR increment
site and _update_tachometer_speed read/reset) to use the atomic API so
reads/writes are safe and ordered.
- Around line 26-28: float_map can divide by zero when in_min == in_max; fix by
checking for that case (use a floating-point tolerant check like fabs(in_max -
in_min) < FLT_EPSILON) at the start of float_map and return a sensible fallback
(e.g., the midpoint of the output range (out_min+out_max)/2 or out_min) instead
of performing the division; keep the rest of the mapping logic unchanged so
float_map returns the mapped value when the denominator is non-zero.
- Around line 126-130: The destructor ~KobaEGPUUserModCore currently disables
the wrong GPIO (GPIO_NUM_8) while the tachometer ISR was installed on
FAN_TACHOMETER_PIN; update the destructor to call
gpio_isr_handler_remove(FAN_TACHOMETER_PIN) and then
gpio_intr_disable(FAN_TACHOMETER_PIN) (instead of GPIO_NUM_8) to properly remove
the ISR and disable interrupts, keeping the existing delete _adc_chars handling
intact.
- Around line 65-70: The voltage-to-resistance math in _calc_temp is using
inconsistent reference voltages (3.0f for voltage calculation and 3.3f in the
divider), causing wrong resistance/temperature; update the voltage computation
to use the same ADC reference as the divider (replace 3.0f with 3.3f or better,
centralize into a REF_VOLTAGE constant) so voltage = (float(adc_value) /
4095.0f) * REF_VOLTAGE and then compute rt = 10000.0f * (voltage / (REF_VOLTAGE
- voltage)); keep the bounds check and return via
_thermistor.calculateTemperatureFromResistance(rt).
- Around line 182-183: The call to gpio_install_isr_service(0) can return
ESP_ERR_INVALID_STATE if another usermod already installed the ISR service;
replace the unconditional ESP_ERROR_CHECK(gpio_install_isr_service(0)) with a
guarded check: call gpio_install_isr_service(0) into an esp_err_t variable, and
only treat it as an error if it's neither ESP_OK nor ESP_ERR_INVALID_STATE
(i.e., ignore ESP_ERR_INVALID_STATE), then proceed to call
gpio_isr_handler_add(gpio_num_t(FAN_TACHOMETER_PIN), tachometer_isr, this) as
before; keep references to gpio_install_isr_service, ESP_ERROR_CHECK,
gpio_isr_handler_add, FAN_TACHOMETER_PIN, tachometer_isr, and this so the change
is localized and clear.
- Around line 328-331: The string constants KobaEGPUUsermod::_gpu_temp_string,
KobaEGPUUsermod::_fan_temp_string,
KobaEGPUUsermod::_fan_min_speed_threshold_temperature_key, and
KobaEGPUUsermod::_fan_max_speed_threshold_temperature_key are missing the
PROGMEM attribute; update their declarations to store them in flash (add PROGMEM
to each const char array declaration to match the other strings in this class)
so they are defined like the other PROGMEM strings in KobaEGPUUsermod.
🧹 Nitpick comments (2)
usermods/KOBA_EGPU/koba_egpu.cpp (2)

7-21: Overly restrictive pin requirements reduce portability.

The preprocessor errors require specific GPIO numbers (0, 1, 6, 10), which makes this usermod inflexible for users with different hardware configurations. Consider making the pins configurable at compile time without requiring exact values, or documenting why these specific pins are mandatory (e.g., ADC channel constraints).

♻️ Suggested change to allow any valid pin
-#if not defined(THERMISTOR_GPU_PIN) or THERMISTOR_GPU_PIN not_eq 0
-#error THERMISTOR_GPU_PIN NOT defined or not equals GPIO0
+#if not defined(THERMISTOR_GPU_PIN)
+#error THERMISTOR_GPU_PIN NOT defined
 `#endif`

-#if not defined(THERMISTOR_FAN_PIN) or THERMISTOR_FAN_PIN not_eq 1
-#error THERMISTOR_FAN_PIN NOT defined or not equals GPIO1
+#if not defined(THERMISTOR_FAN_PIN)
+#error THERMISTOR_FAN_PIN NOT defined
 `#endif`

-#if not defined(FAN_TACHOMETER_PIN) or FAN_TACHOMETER_PIN not_eq 6
+#if not defined(FAN_TACHOMETER_PIN)
 `#error` FAN_TACHOMETER_PIN NOT defined
 `#endif`

-#if not defined(FAN_PWM_PIN) or FAN_PWM_PIN not_eq 10
+#if not defined(FAN_PWM_PIN)
 `#error` FAN_PWM_PIN NOT defined
 `#endif`

175-176: ADC calibration data allocated but never used.

_adc_chars is populated via esp_adc_cal_characterize but the code uses adc1_get_raw() directly instead of esp_adc_cal_raw_to_voltage(). Either use the calibration for more accurate readings or remove the unused allocation.

♻️ Option 1: Use calibration for better accuracy
         float _calc_temp(int adc_value) {
             if (adc_value > 4000 or adc_value < 100) return NAN;
-            float voltage = (static_cast<float>(adc_value) / 4095.0f) * 3.0f;
+            uint32_t voltage_mv = esp_adc_cal_raw_to_voltage(adc_value, _adc_chars);
+            float voltage = voltage_mv / 1000.0f;
             float rt = 10000.0f * (voltage / (3.3f - voltage));
             return _thermistor.calculateTemperatureFromResistance(rt);
         }

Comment on lines +26 to +28
float float_map(float x, float in_min, float in_max, float out_min, float out_max) {
return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
}
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential division by zero if in_min == in_max.

While current usage validates thresholds, this helper could cause undefined behavior if called with equal min/max values.

🛡️ Defensive fix
     float float_map(float x, float in_min, float in_max, float out_min, float out_max) {
+        if (in_max == in_min) return out_min;
         return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
     }
🤖 Prompt for AI Agents
In `@usermods/KOBA_EGPU/koba_egpu.cpp` around lines 26 - 28, float_map can divide
by zero when in_min == in_max; fix by checking for that case (use a
floating-point tolerant check like fabs(in_max - in_min) < FLT_EPSILON) at the
start of float_map and return a sensible fallback (e.g., the midpoint of the
output range (out_min+out_max)/2 or out_min) instead of performing the division;
keep the rest of the mapping logic unchanged so float_map returns the mapped
value when the denominator is non-zero.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

proposed fix:

    if (in_min == in_max) return out_min;
    else return ....

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@softhack007, perfect! That fix looks good and will properly handle the edge case where in_min == in_max. Returning out_min in that scenario is a sensible fallback behavior. 👍


🧠 Learnings used
Learnt from: DedeHai
Repo: wled/WLED PR: 4456
File: usermods/deep_sleep/deep_sleep.cpp:224-230
Timestamp: 2025-11-30T15:29:00.726Z
Learning: In WLED, `touchThreshold` is a global variable defined in wled00/wled.h and is accessible to all usermods. It stores the touch sensitivity threshold for touch-capable GPIO pins on ESP32 variants and should not be flagged as undefined when used in usermods.

Learnt from: DedeHai
Repo: wled/WLED PR: 4798
File: wled00/FX.cpp:7531-7533
Timestamp: 2025-08-26T11:51:21.817Z
Learning: In WLED PR `#4798`, DedeHai confirmed that certain gamma-related calls in FX.cpp/FX_fcn.cpp/particle systems are intentional for effect-level shaping (e.g., brightness curves, TV sim, Pride 2015 pre-mix), distinct from final output gamma. Do not flag or remove these in future reviews; add comments when feasible to clarify intent.

private:
bool _configured = false;
uint32_t _delta_update_time_ms = 100;
uint32_t _pulses_count = 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Race condition on _pulses_count between ISR and main thread.

_pulses_count is modified by the ISR (line 120) and read/reset by _update_tachometer_speed (lines 106-108) without synchronization. This can cause lost pulse counts or corrupted reads.

🔒 Proposed fix using atomic operations
+#include <atomic>
+
 namespace {
     // ...
     class KobaEGPUUserModCore {
     private:
         bool _configured = false;
         uint32_t _delta_update_time_ms = 100;
-        uint32_t _pulses_count = 0;
+        std::atomic<uint32_t> _pulses_count{0};
         // ...

         void _update_tachometer_speed(uint32_t delta_ms) {
-            float new_rpm = float(_pulses_count) * 60.0f * 1000.0f / float(delta_ms);
+            uint32_t pulses = _pulses_count.exchange(0);
+            float new_rpm = float(pulses) * 60.0f * 1000.0f / float(delta_ms);
             _last_check_tachometer_speed = millis();
-            _pulses_count = 0;
             if (not isnan(new_rpm) and new_rpm not_eq _current_rpm) {
                 _current_rpm = new_rpm;
             }
         }

         IRAM_ATTR static void tachometer_isr(void *ctx) {
-            reinterpret_cast<KobaEGPUUserModCore *>(ctx)->_pulses_count++;
+            reinterpret_cast<KobaEGPUUserModCore *>(ctx)->_pulses_count.fetch_add(1, std::memory_order_relaxed);
         }

Also applies to: 105-112, 119-121

🤖 Prompt for AI Agents
In `@usermods/KOBA_EGPU/koba_egpu.cpp` at line 55, _pulses_count is subject to a
race between the ISR and _update_tachometer_speed; change its type to
std::atomic<uint32_t> (include <atomic>) and replace non-atomic reads/resets
with atomic operations (e.g. use fetch_exchange(0) or exchange(0) in
_update_tachometer_speed to atomically capture-and-clear the count) while
keeping the ISR increment using fetch_add(1) (or ++ on the atomic). Update all
references to _pulses_count (ISR increment site and _update_tachometer_speed
read/reset) to use the atomic API so reads/writes are safe and ordered.

Comment on lines +65 to +70
float _calc_temp(int adc_value) {
if (adc_value > 4000 or adc_value < 100) return NAN;
float voltage = (static_cast<float>(adc_value) / 4095.0f) * 3.0f;
float rt = 10000.0f * (voltage / (3.3f - voltage));
return _thermistor.calculateTemperatureFromResistance(rt);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Inconsistent reference voltage (3.0V vs 3.3V) causes incorrect temperature calculation.

Line 67 calculates voltage using 3.0V reference, but line 68 uses 3.3V for the voltage divider formula. This inconsistency will produce incorrect resistance and temperature values.

🐛 Proposed fix
         float _calc_temp(int adc_value) {
             if (adc_value > 4000 or adc_value < 100) return NAN;
-            float voltage = (static_cast<float>(adc_value) / 4095.0f) * 3.0f;
-            float rt = 10000.0f * (voltage / (3.3f - voltage));
+            constexpr float vref = 3.3f;
+            float voltage = (static_cast<float>(adc_value) / 4095.0f) * vref;
+            float rt = 10000.0f * (voltage / (vref - voltage));
             return _thermistor.calculateTemperatureFromResistance(rt);
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
float _calc_temp(int adc_value) {
if (adc_value > 4000 or adc_value < 100) return NAN;
float voltage = (static_cast<float>(adc_value) / 4095.0f) * 3.0f;
float rt = 10000.0f * (voltage / (3.3f - voltage));
return _thermistor.calculateTemperatureFromResistance(rt);
}
float _calc_temp(int adc_value) {
if (adc_value > 4000 or adc_value < 100) return NAN;
constexpr float vref = 3.3f;
float voltage = (static_cast<float>(adc_value) / 4095.0f) * vref;
float rt = 10000.0f * (voltage / (vref - voltage));
return _thermistor.calculateTemperatureFromResistance(rt);
}
🤖 Prompt for AI Agents
In `@usermods/KOBA_EGPU/koba_egpu.cpp` around lines 65 - 70, The
voltage-to-resistance math in _calc_temp is using inconsistent reference
voltages (3.0f for voltage calculation and 3.3f in the divider), causing wrong
resistance/temperature; update the voltage computation to use the same ADC
reference as the divider (replace 3.0f with 3.3f or better, centralize into a
REF_VOLTAGE constant) so voltage = (float(adc_value) / 4095.0f) * REF_VOLTAGE
and then compute rt = 10000.0f * (voltage / (REF_VOLTAGE - voltage)); keep the
bounds check and return via _thermistor.calculateTemperatureFromResistance(rt).

Comment on lines +126 to +130
~KobaEGPUUserModCore() {
// todo destroy adc
delete _adc_chars;
gpio_intr_disable(GPIO_NUM_8);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Destructor disables wrong GPIO pin.

gpio_intr_disable(GPIO_NUM_8) is hardcoded, but the tachometer ISR was installed on FAN_TACHOMETER_PIN (GPIO 6). The ISR on the correct pin remains active after destruction.

🐛 Proposed fix
         ~KobaEGPUUserModCore() {
             //  todo destroy adc
             delete _adc_chars;
-            gpio_intr_disable(GPIO_NUM_8);
+            gpio_isr_handler_remove(gpio_num_t(FAN_TACHOMETER_PIN));
+            gpio_intr_disable(gpio_num_t(FAN_TACHOMETER_PIN));
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
~KobaEGPUUserModCore() {
// todo destroy adc
delete _adc_chars;
gpio_intr_disable(GPIO_NUM_8);
}
~KobaEGPUUserModCore() {
// todo destroy adc
delete _adc_chars;
gpio_isr_handler_remove(gpio_num_t(FAN_TACHOMETER_PIN));
gpio_intr_disable(gpio_num_t(FAN_TACHOMETER_PIN));
}
🤖 Prompt for AI Agents
In `@usermods/KOBA_EGPU/koba_egpu.cpp` around lines 126 - 130, The destructor
~KobaEGPUUserModCore currently disables the wrong GPIO (GPIO_NUM_8) while the
tachometer ISR was installed on FAN_TACHOMETER_PIN; update the destructor to
call gpio_isr_handler_remove(FAN_TACHOMETER_PIN) and then
gpio_intr_disable(FAN_TACHOMETER_PIN) (instead of GPIO_NUM_8) to properly remove
the ISR and disable interrupts, keeping the existing delete _adc_chars handling
intact.

Comment on lines +182 to +183
ESP_ERROR_CHECK(gpio_install_isr_service(0));
ESP_ERROR_CHECK(gpio_isr_handler_add(gpio_num_t(FAN_TACHOMETER_PIN), tachometer_isr, this));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

gpio_install_isr_service may abort if already installed by another usermod.

If another usermod has already installed the ISR service, this call returns ESP_ERR_INVALID_STATE and ESP_ERROR_CHECK will abort. Handle this case gracefully.

🛡️ Proposed fix
-            ESP_ERROR_CHECK(gpio_install_isr_service(0));
+            esp_err_t err = gpio_install_isr_service(0);
+            if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
+                ESP_ERROR_CHECK(err);  // Will abort on unexpected errors
+            }
             ESP_ERROR_CHECK(gpio_isr_handler_add(gpio_num_t(FAN_TACHOMETER_PIN), tachometer_isr, this));
🤖 Prompt for AI Agents
In `@usermods/KOBA_EGPU/koba_egpu.cpp` around lines 182 - 183, The call to
gpio_install_isr_service(0) can return ESP_ERR_INVALID_STATE if another usermod
already installed the ISR service; replace the unconditional
ESP_ERROR_CHECK(gpio_install_isr_service(0)) with a guarded check: call
gpio_install_isr_service(0) into an esp_err_t variable, and only treat it as an
error if it's neither ESP_OK nor ESP_ERR_INVALID_STATE (i.e., ignore
ESP_ERR_INVALID_STATE), then proceed to call
gpio_isr_handler_add(gpio_num_t(FAN_TACHOMETER_PIN), tachometer_isr, this) as
before; keep references to gpio_install_isr_service, ESP_ERROR_CHECK,
gpio_isr_handler_add, FAN_TACHOMETER_PIN, tachometer_isr, and this so the change
is localized and clear.

Comment on lines +328 to +331
const char KobaEGPUUsermod::_gpu_temp_string[] = "GPU Temp";
const char KobaEGPUUsermod::_fan_temp_string[] = "FAN Temp";
const char KobaEGPUUsermod::_fan_min_speed_threshold_temperature_key[] = "Fan min speed threshold temperature (C)";
const char KobaEGPUUsermod::_fan_max_speed_threshold_temperature_key[] = "Fan max speed threshold temperature (C)";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing PROGMEM on string constants wastes RAM.

These strings are missing PROGMEM unlike the others above, causing them to be stored in RAM instead of flash.

♻️ Add PROGMEM for consistency
-const char KobaEGPUUsermod::_gpu_temp_string[] = "GPU Temp";
-const char KobaEGPUUsermod::_fan_temp_string[] = "FAN Temp";
-const char KobaEGPUUsermod::_fan_min_speed_threshold_temperature_key[] = "Fan min speed threshold temperature (C)";
-const char KobaEGPUUsermod::_fan_max_speed_threshold_temperature_key[] = "Fan max speed threshold temperature (C)";
+const char KobaEGPUUsermod::_gpu_temp_string[] PROGMEM = "GPU Temp";
+const char KobaEGPUUsermod::_fan_temp_string[] PROGMEM = "FAN Temp";
+const char KobaEGPUUsermod::_fan_min_speed_threshold_temperature_key[] PROGMEM = "Fan min speed threshold temperature (C)";
+const char KobaEGPUUsermod::_fan_max_speed_threshold_temperature_key[] PROGMEM = "Fan max speed threshold temperature (C)";
🤖 Prompt for AI Agents
In `@usermods/KOBA_EGPU/koba_egpu.cpp` around lines 328 - 331, The string
constants KobaEGPUUsermod::_gpu_temp_string, KobaEGPUUsermod::_fan_temp_string,
KobaEGPUUsermod::_fan_min_speed_threshold_temperature_key, and
KobaEGPUUsermod::_fan_max_speed_threshold_temperature_key are missing the
PROGMEM attribute; update their declarations to store them in flash (add PROGMEM
to each const char array declaration to match the other strings in this class)
so they are defined like the other PROGMEM strings in KobaEGPUUsermod.

@DedeHai
Copy link
Collaborator

DedeHai commented Feb 5, 2026

This is very highly application specific and has to be an out of tree usermod.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants