Skip to content
2 changes: 1 addition & 1 deletion firmware/esp32-s3.ini
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build_flags =
${HP_ALL_DRIVERS.build_flags}
-D CONFIG_IDF_TARGET_ESP32S3=1
; -D ARDUINO_USB_MODE=1 ; which USB device classes are enabled on your ESP32 at boot. default 1 in board definition (serial only)
; -D ARDUINO_USB_CDC_ON_BOOT=1 ;Communications Device Class: controls whether the ESP32's USB serial port is enabled automatically at boot, default 1 in board definition
; -D ARDUINO_USB_CDC_ON_BOOT=1 ;Communications Device Class: controls whether the ESP32's USB serial port is enabled automatically at boot, not set in board definition!
; -D ARDUINO_USB_MSC_ON_BOOT=0 ;Mass Storage Class, disable
; -D ARDUINO_USB_DFU_ON_BOOT=0 ;download firmware update, disable
; -D ML_LIVE_MAPPING
Expand Down
16 changes: 11 additions & 5 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,18 @@ framework = arduino ;espidf will not work as libs rely on arduino (e.g. PhysicHT
; platform = https://github.com/tasmota/platform-espressif32/releases/download/2025.05.30/platform-espressif32.zip ;; Platform Tasmota Arduino Core 3.1.3.250504based on IDF 5.3.3.250501platform_packages
; platform_packages = framework-arduinoespressif32 @ 3.1.3

platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip ; Sep 20, check latest: https://github.com/pioarduino/platform-espressif32/releases
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.35/platform-espressif32.zip ; Sep 20, check latest: https://github.com/pioarduino/platform-espressif32/releases
; back from 55.03.37 to 55.03.35 due to https://github.com/pioarduino/platform-espressif32/issues/389

build_flags =
${factory_settings.build_flags}
${features.build_flags}
-D BUILD_TARGET=\"$PIOENV\"
-D APP_NAME=\"MoonLight\" ; 🌙 Must only contain characters from [a-zA-Z0-9-_] as this is converted into a filename
-D APP_VERSION=\"0.8.1\" ; semver compatible version string
-D APP_DATE=\"20260212\" ; 🌙
-D APP_DATE=\"20260214\" ; 🌙

-D PLATFORM_VERSION=\"pioarduino-55.03.37\" ; 🌙 make sure it matches with above plaftform
-D PLATFORM_VERSION=\"pioarduino-55.03.35\" ; 🌙 make sure it matches with above plaftform

-D FT_MOONBASE=1

Expand Down Expand Up @@ -141,7 +143,9 @@ extra_scripts =
scripts/save_elf.py
lib_deps =
ArduinoJson@>=7.0.0
elims/PsychicMqttClient@^0.2.4
elims/PsychicMqttClient@^0.2.4
ElectronicCats/MPU6050 @ 1.3.0 ; for D_IMU.h driver
; https://github.com/hanyazou/BMI160-Arduino.git ; hanyazou/BMI160-Arduino#057f36e002bee0473a54fcedf41b93acad059568 ; @ ^1.0.0 ; for BMI160

;💫
[moonlight]
Expand All @@ -154,7 +158,9 @@ build_flags =
; -D FASTLED_TESTING ; causes duplicate definition of initSpiHardware(); - workaround: removed implementation in spi_hw_manager_esp32.cpp.hpp
-D FASTLED_BUILD=\"20260212\"
lib_deps =
https://github.com/FastLED/FastLED#ea5d2d7aadcd5697f912a1c32bb3b7e9891f949b ; master 20260212
; https://github.com/FastLED/FastLED#878d87d39545228d266e07c6c8c91586b2037894 ; master 20260214
https://github.com/FastLED/FastLED#ea5d2d7aadcd5697f912a1c32bb3b7e9891f949b ; master 20260212 back to 0212 due to https://github.com/FastLED/FastLED/issues/2167#issuecomment-3901609607, wating on fix

https://github.com/ewowi/WLED-sync#25f280b5e8e47e49a95282d0b78a5ce5301af4fe ; sourceIP + fftUdp.clear() if arduino >=3 (20251104)

; 💫 currently only enabled on s3 as esp32dev runs over 100%
Expand Down
32 changes: 31 additions & 1 deletion src/MoonBase/Modules/ModuleDevices.h
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,14 @@ class ModuleDevices : public Module {
for (JsonObject dev : devices) {
if (time(nullptr) - dev["lastSync"].as<time_t>() < 86400) devicesVector.push_back(dev); // max 1 day
}
std::sort(devicesVector.begin(), devicesVector.end(), [](JsonObject a, JsonObject b) { return a["name"] < b["name"]; });

std::sort(devicesVector.begin(), devicesVector.end(), [](JsonObject a, JsonObject b) {
// Primary sort: by name
if (a["name"] != b["name"]) return a["name"] < b["name"];

// Tie-breaker: by IP address (ensures stable sort)
return a["ip"] < b["ip"];
});

doc2["devices"].to<JsonArray>();
for (JsonObject device : devicesVector) {
Expand All @@ -200,6 +207,29 @@ class ModuleDevices : public Module {
JsonObject newState = doc.as<JsonObject>();
update(newState, ModuleState::update, _moduleName);
}

// JsonDocument doc2;

// // Build deduplication map: key = "name|ip", value = device
// // std::map automatically keeps entries sorted by key (name|ip)
// std::map<String, JsonObject> uniqueDevices;

// for (JsonObject dev : devices) {
// if (time(nullptr) - dev["lastSync"].as<time_t>() < 86400) { // max 1 day
// String key = String(dev["name"].as<const char*>()) + "|" + String(dev["ip"].as<const char*>());

// // Only keep the most recent entry for each name+ip combination
// if (uniqueDevices.find(key) == uniqueDevices.end() || dev["lastSync"].as<time_t>() > uniqueDevices[key]["lastSync"].as<time_t>()) {
// uniqueDevices[key] = dev;
// }
// }
// }

// // Map is already sorted by key (name|ip), just iterate and add
// doc2["devices"].to<JsonArray>();
// for (auto& pair : uniqueDevices) {
// doc2["devices"].add(pair.second);
// }
}

void receiveUDP() {
Expand Down
140 changes: 117 additions & 23 deletions src/MoonBase/Modules/ModuleIO.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

#if FT_MOONBASE == 1

#include <Wire.h> // for i2C

#include "MoonBase/Module.h"
#include "driver/uart.h"

Expand Down Expand Up @@ -216,6 +218,16 @@ class ModuleIO : public Module {
addControl(rows, "Level", "text", 0, 32, true); // ro
addControl(rows, "DriveCap", "text", 0, 32, true); // ro
}

control = addControl(controls, "i2cFreq", "number", 10, 1000, false, "kHz");
control["default"] = 100; // 100 kHz standard mode

control = addControl(controls, "i2cBus", "rows");
control["crud"] = "r";
rows = control["n"].to<JsonArray>();
{
addControl(rows, "address", "number", 0, 255, true); // ro
}
}

class PinAssigner {
Expand All @@ -240,7 +252,7 @@ class ModuleIO : public Module {

void setBoardPresetDefaults(uint8_t boardID) {
JsonDocument doc;
current_board_id = boardID;
_current_board_id = boardID;
JsonObject newState = doc.to<JsonObject>();
newState["modded"] = false;

Expand Down Expand Up @@ -565,6 +577,23 @@ class ModuleIO : public Module {
pinAssigner.assignPin(16, pin_LED);
#endif

#ifdef CONFIG_IDF_TARGET_ESP32
pinAssigner.assignPin(21, pin_I2C_SDA); // ESP32 classic
pinAssigner.assignPin(22, pin_I2C_SCL);
#elif defined(CONFIG_IDF_TARGET_ESP32S2) || defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C3)
pinAssigner.assignPin(8, pin_I2C_SDA); // ESP32-C3
pinAssigner.assignPin(9, pin_I2C_SCL);
#elif defined(CONFIG_IDF_TARGET_ESP32C6)
pinAssigner.assignPin(23, pin_I2C_SDA); // ESP32-C6
pinAssigner.assignPin(22, pin_I2C_SCL);
#elif defined(CONFIG_IDF_TARGET_ESP32P4)
pinAssigner.assignPin(7, pin_I2C_SDA); // ESP32-P4 (common board default)
pinAssigner.assignPin(8, pin_I2C_SCL);
#else
pinAssigner.assignPin(21, pin_I2C_SDA); // Fallback
pinAssigner.assignPin(22, pin_I2C_SCL);
#endif
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// trying to add more pins, but these pins not liked by esp32-d0-16MB ... 🚧
// pinAssigner.assignPin(4, pin_LED_02;
// pinAssigner.assignPin(5, pin_LED_03;
Expand Down Expand Up @@ -609,6 +638,8 @@ class ModuleIO : public Module {
newState["modded"] = true;
} else if (updatedItem.name == "usage") {
newState["modded"] = true;
} else if (updatedItem.name == "i2cFreq") {
Wire.setClock(updatedItem.value.as<uint32_t>() * 1000); // uint32_t instead of uint16_t to multiply in 32-bit arithmetic
}

if (newState.size()) update(newState, ModuleState::update, _moduleName); // if changes made then update
Expand Down Expand Up @@ -701,17 +732,20 @@ class ModuleIO : public Module {
#endif // ethernet

#if FT_BATTERY
_pinVoltage = UINT8_MAX;
_pinCurrent = UINT8_MAX;
_pinBattery = UINT8_MAX;
for (JsonObject pinObject : _state.data["pins"].as<JsonArray>()) {
uint8_t usage = pinObject["usage"];
if (usage == pin_Voltage) {
pinVoltage = pinObject["GPIO"];
EXT_LOGD(ML_TAG, "pinVoltage found %d", pinVoltage);
_pinVoltage = pinObject["GPIO"];
EXT_LOGD(ML_TAG, "pinVoltage found %d", _pinVoltage);
} else if (usage == pin_Current) {
pinCurrent = pinObject["GPIO"];
EXT_LOGD(ML_TAG, "pinCurrent found %d", pinCurrent);
_pinCurrent = pinObject["GPIO"];
EXT_LOGD(ML_TAG, "pinCurrent found %d", _pinCurrent);
} else if (usage == pin_Battery) {
pinBattery = pinObject["GPIO"];
EXT_LOGD(ML_TAG, "pinBattery found %d", pinBattery);
_pinBattery = pinObject["GPIO"];
EXT_LOGD(ML_TAG, "pinBattery found %d", _pinBattery);
}
}
#endif
Expand Down Expand Up @@ -790,12 +824,40 @@ class ModuleIO : public Module {
}
#endif
}
}

uint8_t pinI2CSDA = UINT8_MAX;
uint8_t pinI2CSCL = UINT8_MAX;

for (JsonObject pinObject : _state.data["pins"].as<JsonArray>()) {
uint8_t usage = pinObject["usage"];
if (usage == pin_I2C_SDA) {
pinI2CSDA = pinObject["GPIO"];
EXT_LOGD(ML_TAG, "I2CSDA found %d", pinI2CSDA);
}
if (usage == pin_I2C_SCL) {
pinI2CSCL = pinObject["GPIO"];
EXT_LOGD(ML_TAG, "I2CSCL found %d", pinI2CSCL);
}
}

if (pinI2CSCL != UINT8_MAX && pinI2CSDA != UINT8_MAX) {
Wire.end(); // Clean up any previous I2C initialization
delay(100);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
uint32_t frequency = _state.data["i2cFreq"];
if (Wire.begin(pinI2CSDA, pinI2CSCL, frequency * 1000)) {
EXT_LOGI(ML_TAG, "initI2C Wire sda:%d scl:%d freq:%d kHz", pinI2CSDA, pinI2CSCL, frequency);
// delay(200); // Give I2C bus time to stabilize
// Wire.setClock(50000); // Explicitly set to 100kHz
_state.data["I2CReady"] = true;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
updateDevices();
} else {
_state.data["I2CReady"] = false;
EXT_LOGE(ML_TAG, "initI2C Wire failed");
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} // readPins

#if FT_BATTERY
uint8_t pinVoltage = UINT8_MAX;
uint8_t pinCurrent = UINT8_MAX;
uint8_t pinBattery = UINT8_MAX;

adc_attenuation_t adc_get_adjusted_gain(adc_attenuation_t current_gain, uint32_t adc_mv_readout) {
if (current_gain == ADC_11db && adc_mv_readout < 1700) {
Expand Down Expand Up @@ -825,38 +887,38 @@ class ModuleIO : public Module {
void loop1s() {
#if FT_BATTERY
BatteryService* batteryService = _sveltekit->getBatteryService();
if (pinBattery != UINT8_MAX) {
float mVB = analogReadMilliVolts(pinBattery) * 2.0;
if (_pinBattery != UINT8_MAX) {
float mVB = analogReadMilliVolts(_pinBattery) * 2.0;
float perc = (mVB - BATTERY_MV * 0.65) / (BATTERY_MV * 0.35); // 65% of full battery is 0%, showing 0-100%
// ESP_LOGD("", "bat mVB %f p:%f", mVB, perc);
batteryService->updateSOC(perc * 100);
}
if (pinVoltage != UINT8_MAX) {
if (_pinVoltage != UINT8_MAX) {
analogSetAttenuation(voltage_readout_current_adc_attenuation);
uint32_t adc_mv_vinput = analogReadMilliVolts(pinVoltage);
uint32_t adc_mv_vinput = analogReadMilliVolts(_pinVoltage);
analogSetAttenuation(ADC_11db);
float volts = 0;
if (current_board_id == board_SE16V1) {
if (_current_board_id == board_SE16V1) {
volts = ((float)adc_mv_vinput) * 2 / 1000;
} // /2 resistor divider
else if (current_board_id == board_LightCrafter16) {
else if (_current_board_id == board_LightCrafter16) {
volts = ((float)adc_mv_vinput) * 11.43 / (1.43 * 1000);
} // 1k43/10k resistor divider
batteryService->updateVoltage(volts);
voltage_readout_current_adc_attenuation = adc_get_adjusted_gain(voltage_readout_current_adc_attenuation, adc_mv_vinput);
}
if (pinCurrent != UINT8_MAX) {
if (_pinCurrent != UINT8_MAX) {
analogSetAttenuation(current_readout_current_adc_attenuation);
uint32_t adc_mv_cinput = analogReadMilliVolts(pinCurrent);
uint32_t adc_mv_cinput = analogReadMilliVolts(_pinCurrent);
analogSetAttenuation(ADC_11db);
current_readout_current_adc_attenuation = adc_get_adjusted_gain(current_readout_current_adc_attenuation, adc_mv_cinput);
if ((current_board_id == board_SE16V1) || (current_board_id == board_LightCrafter16)) {
if ((_current_board_id == board_SE16V1) || (_current_board_id == board_LightCrafter16)) {
if (adc_mv_cinput > 330) // datasheet quiescent output voltage of 0.5V, which is ~330mV after the 10k/5k1 voltage divider. Ideally, this value should be measured at boot when nothing is displayed on the LEDs
{
if (current_board_id == board_SE16V1) {
if (_current_board_id == board_SE16V1) {
batteryService->updateCurrent((((float)(adc_mv_cinput)-250) * 50.00) / 1000);
} // 40mV / A with a /2 resistor divider, so a 50mA/mV
else if (current_board_id == board_LightCrafter16) {
else if (_current_board_id == board_LightCrafter16) {
batteryService->updateCurrent((((float)(adc_mv_cinput)-330) * 37.75) / 1000);
} // 40mV / A with a 10k/5k1 resistor divider, so a 37.75mA/mV
} else {
Expand All @@ -867,9 +929,41 @@ class ModuleIO : public Module {
#endif
}

void updateDevices() {
JsonDocument doc;
doc["i2cBus"].to<JsonArray>();
JsonObject newState = doc.as<JsonObject>();

EXT_LOGI(ML_TAG, "Scanning I2C bus...");
byte count = 0;
for (byte i = 1; i < 127; i++) {
Wire.beginTransmission(i);
if (Wire.endTransmission() == 0) {
JsonObject i2cDevice = newState["i2cBus"].as<JsonArray>().add<JsonObject>();
i2cDevice["address"] = i;

EXT_LOGI(ML_TAG, "Found I2C device at address 0x%02X", i);
count++;
}
}
EXT_LOGI(ML_TAG, "Found %d device(s)", count);
JsonObject i2cDevice = newState["i2cBus"].as<JsonArray>().add<JsonObject>();
i2cDevice["address"] = 255;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Sentinel entry with address 255 pollutes the device list.

A fake device at address 255 is appended after the real scan results. This is not a valid I2C address (7-bit range is 0–127) and will appear as a spurious device in the UI. If this is meant to ensure the i2cBus array is non-empty or signal end-of-list, consider handling that in the UI layer instead.

Proposed fix: remove the sentinel
     EXT_LOGI(ML_TAG, "Found %d device(s)", count);
-    JsonObject i2cDevice = newState["i2cBus"].as<JsonArray>().add<JsonObject>();
-    i2cDevice["address"] = 255;
📝 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
JsonObject i2cDevice = newState["i2cBus"].as<JsonArray>().add<JsonObject>();
i2cDevice["address"] = 255;
// Lines 928-929 removed - sentinel entry deleted
🤖 Prompt for AI Agents
In `@src/MoonBase/Modules/ModuleIO.h` around lines 928 - 929, Remove the sentinel
fake I2C device being appended with address 255: stop creating the JsonObject
via newState["i2cBus"].as<JsonArray>().add<JsonObject>() and assigning
i2cDevice["address"] = 255 so the i2cBus array only contains real scan results;
if a non-empty-array indicator is needed, handle that in the UI layer instead of
adding a sentinel entry (look for usages of i2cDevice/newState["i2cBus"] in
ModuleIO.h and related scan/serialize functions to ensure no other code depends
on the sentinel).


doc["i2cFreq"] = Wire.getClock() / 1000;

update(newState, ModuleState::update, _moduleName);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

private:
ESP32SvelteKit* _sveltekit;
uint8_t current_board_id = UINT8_MAX;
uint8_t _current_board_id = UINT8_MAX;
#if FT_BATTERY
// used in loop1s()
uint8_t _pinVoltage = UINT8_MAX;
uint8_t _pinCurrent = UINT8_MAX;
uint8_t _pinBattery = UINT8_MAX;
#endif
};

#endif
Expand Down
3 changes: 3 additions & 0 deletions src/MoonBase/NodeManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,9 @@ class NodeManager : public Module {
if (nodeClass != nullptr) {
nodeClass->on = updatedItem.value.as<bool>(); // set nodeclass on/off
// EXT_LOGD(ML_TAG, " nodeclass 🔘:%d 🚥:%d 💎:%d", nodeClass->on, nodeClass->hasOnLayout(), nodeClass->hasModifier());
xSemaphoreTake(*nodeClass->layerMutex, portMAX_DELAY);
nodeClass->onUpdate(updatedItem.oldValue, nodeState); // custom onUpdate for the node
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Inconsistent mutex handling and argument semantics for onUpdate.

Two observations:

  1. No mutex: The controls-value path (line 282–285) holds layerMutex around onUpdate, but this new on-toggle path does not. For MPU6050Driver the hardware init may not need it, but other nodes' onUpdate could mutate shared layer state. Consider wrapping consistently:
+            xSemaphoreTake(*nodeClass->layerMutex, portMAX_DELAY);
             nodeClass->onUpdate(updatedItem.oldValue, nodeState);  // custom onUpdate for the node
+            xSemaphoreGive(*nodeClass->layerMutex);
  1. Argument type mismatch: Here nodeState (full node JSON with name, on, controls) is passed as the control parameter, while at line 284 a single control object is passed. The MPU6050Driver relies on checking control["on"] to distinguish, but this convention is implicit and fragile — a future node's onUpdate could misinterpret the JSON shape.
🤖 Prompt for AI Agents
In `@src/MoonBase/NodeManager.h` at line 267, The call to nodeClass->onUpdate
currently passes the full nodeState without holding layerMutex and thus is
inconsistent with the controls-value path; wrap the on-toggle path's
nodeClass->onUpdate call with layerMutex (same scope used in the controls-value
path) and normalize the argument semantics by passing a consistent control
object (e.g., construct a single-control JSON containing only the on field or an
explicit {"on": ...} object) instead of the full nodeState so onUpdate
implementations always receive the same JSON shape.

xSemaphoreGive(*nodeClass->layerMutex);
nodeClass->requestMappings();
} else
EXT_LOGW(ML_TAG, "Nodeclass %s not found", nodeState["name"].as<const char*>());
Expand Down
3 changes: 3 additions & 0 deletions src/MoonBase/Nodes.h
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,8 @@ static struct SharedData {
size_t connectedClients;
size_t activeClients;
size_t clientListSize;

Coord3D gravity;
Comment on lines +347 to +348
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Coord3D gravity may not be the right type for gravity data.

As noted in the D_IMU.h review, the MPU6050 gravity vector contains float values in the range [–1.0, 1.0]. If Coord3D uses integer fields (e.g., int16_t), this will silently truncate to 0/±1. Consider using a float-based struct (e.g., VectorFloat) or documenting the expected scale/encoding if you intend to pre-scale the values.

🤖 Prompt for AI Agents
In `@src/MoonBase/Nodes.h` around lines 347 - 348, The field Coord3D gravity
likely uses integer components and will truncate the MPU6050 float gravity
vector; update the type or handling: replace Coord3D gravity with a float-based
struct (e.g., VectorFloat or a new Coord3DFloat) or store gravity as an array of
floats and ensure any code using gravity performs the proper float
reads/conversions (see related D_IMU.h handling), or if you must keep Coord3D,
add clear documentation and explicit scaling/quantization conversions where
gravity is assigned so values in [−1.0,1.0] are preserved correctly.

} sharedData;

/**
Expand All @@ -360,6 +362,7 @@ static struct SharedData {
#include "MoonLight/Nodes/Drivers/D_FastLED.h"
#include "MoonLight/Nodes/Drivers/D_Hub75.h"
#include "MoonLight/Nodes/Drivers/D_Infrared.h"
#include "MoonLight/Nodes/Drivers/D_IMU.h"
#include "MoonLight/Nodes/Drivers/D_ParallelLEDDriver.h"
#include "MoonLight/Nodes/Drivers/D__Sandbox.h"
#include "MoonLight/Nodes/Effects/E_FastLED.h"
Expand Down
Loading