Compare commits

..

4 Commits

Author SHA1 Message Date
Damian Schneider
01328a65c1 Fix blending style options list filter for iOS (#5513)
* fix blending style options list filter for iOS
* fix default fallback
2026-04-20 19:06:33 +02:00
Damian Schneider
a2d970e155 better packet queuing & pacing for custom palette live preview (#5515)
* better packet queuing / pacing for custom palette live preview
* fix leak
* remove window prefix from variables
2026-04-20 18:58:09 +02:00
Frank Möhle
cb666cedc5 Highlight target branch for pull requests
Emphasize the importance of targeting the main branch for PRs.
2026-04-20 15:23:06 +02:00
Frank Möhle
5e0a0a7561 Update rules for PR descriptions and change logs
Added a guideline to prevent agents from overwriting PR descriptions (see https://github.com/orgs/community/discussions/187027).
2026-04-20 14:19:21 +02:00
7 changed files with 51 additions and 70 deletions

View File

@@ -177,6 +177,7 @@ No automated linting is configured. Match existing code style in files you edit.
- Repository language is English
- Never edit or commit auto-generated `wled00/html_*.h` / `wled00/js_*.h`
- When updating an existing PR, retain the original description. Only modify it to ensure technical accuracy. Add change logs after the existing description.
- No force-push on open PRs
- Changes to `platformio.ini` require maintainer approval
- Remove dead/unused code — justify or delete it

View File

@@ -30,7 +30,8 @@ This lets you update your PR if needed, while you can work on other tasks in 'ma
### Target branch for pull requests
Please make all PRs against the `main` branch.
> [!IMPORTANT]
> Please make all PRs against the `main` branch.
### Describing your PR

View File

@@ -71,50 +71,29 @@ void WS2812FX::setUpMatrix() {
// allowed values are: -1 (missing pixel/no LED attached), 0 (inactive/unused pixel), 1 (active/used pixel)
char fileName[32]; strcpy_P(fileName, PSTR("/2d-gaps.json"));
bool isFile = WLED_FS.exists(fileName);
size_t gapSize = 0;
int8_t *gapTable = nullptr;
if (isFile) {
if (isFile && requestJSONBufferLock(JSON_LOCK_LEDGAP)) {
DEBUG_PRINT(F("Reading LED gap from "));
DEBUG_PRINTLN(fileName);
gapTable = static_cast<int8_t*>(p_malloc(matrixSize));
if (gapTable) {
// read the array into global JSON buffer
if (readObjectFromFile(fileName, nullptr, pDoc)) {
// the array is similar to ledmap, except it has only 3 values:
// -1 ... missing pixel (do not increase pixel count)
// 0 ... inactive pixel (it does count, but should be mapped out (-1))
// 1 ... active pixel (it will count and will be mapped)
// read entries directly from the file, one number at a time
// (no JSON buffer / pDoc needed — the file is a plain JSON array)
// follows the same parsing pattern used by deserializeMap() for ledmap.json
size_t gapIdx = 0;
File f = WLED_FS.open(fileName, "r");
if (f) {
f.find('['); // skip to start of array
while (f.available() && gapIdx < matrixSize) {
char number[8];
size_t numRead = f.readBytesUntil(',', number, sizeof(number) - 1); // last entry reads up to ']' (no trailing comma)
number[numRead] = 0;
if (numRead > 0) {
char *end = strchr(number, ']'); // check for end-of-array marker
bool foundDigit = (end == nullptr); // no ']' means the whole token is a number
if (end != nullptr) {
// ']' present — only accept if a digit (or '-') appears before it
for (int k = 0; &number[k] != end; k++) {
if (number[k] >= '0' && number[k] <= '9') { foundDigit = true; break; }
if (number[k] == '-') { foundDigit = true; break; }
}
}
if (!foundDigit) break; // ']' with no number — array ended
gapTable[gapIdx++] = constrain(atoi(number), -1, 1);
} else break;
JsonArray map = pDoc->as<JsonArray>();
gapSize = map.size();
if (!map.isNull() && gapSize >= matrixSize) { // not an empty map
gapTable = static_cast<int8_t*>(p_malloc(gapSize));
if (gapTable) for (size_t i = 0; i < gapSize; i++) {
gapTable[i] = constrain(map[i], -1, 1);
}
f.close();
}
if (gapIdx < matrixSize) { // file was too short or could not be read — discard
p_free(gapTable);
gapTable = nullptr;
}
}
DEBUG_PRINTLN(F("Gaps loaded."));
releaseJSONBufferLock();
}
unsigned x, y, pix=0; //pixel

View File

@@ -478,7 +478,7 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit");
#define JSON_LOCK_SERVEJSON 17
#define JSON_LOCK_NOTIFY 18
#define JSON_LOCK_PRESET_NAME 19
// JSON_LOCK 20 formerly used for LEDGAP (now parsed without JSON buffer)
#define JSON_LOCK_LEDGAP 20
#define JSON_LOCK_LEDMAP_ENUM 21
#define JSON_LOCK_REMOTE 22

View File

@@ -71,6 +71,8 @@
let copyColor = '#000';
let ws = null;
let maxCol; // max colors to send out in one chunk, ESP8266 is limited to ~50 (500 bytes), ESP32 can do ~128 (1340 bytes)
let _applySeq = 0; // incremented each time applyLED fires; used to cancel stale in-flight previews
let _httpQueue = [], _httpRun = 0;
// load external resources in sequence to avoid 503 errors if heap is low, repeats indefinitely until loaded
(function loadFiles() {
@@ -621,21 +623,16 @@
async function requestJson(cmd)
{
if (ws && ws.readyState == 1) {
if (ws && ws.readyState == 1 && ws.bufferedAmount < 32768) {
try {
ws.send(JSON.stringify(cmd));
await new Promise(r => setTimeout(r, 15)); // short delay to give ESP time to process (fewer packets dropped)
return 1;
} catch (e) {}
}
if (!window._httpQueue) {
window._httpQueue = [];
window._httpRun = 0;
}
if (_httpQueue.length >= 5) {
return Promise.resolve(-1); // reject if too many queued requests
}
// HTTP fallback
if (_httpQueue.length >= 5) return -1; // queue full; applyLED cancels stale queues before sending
return new Promise(resolve => {
_httpQueue.push({ cmd, resolve });
(async function run() {
@@ -650,7 +647,7 @@
cache: 'no-store'
});
} catch (e) {}
await new Promise(r => setTimeout(r, 120));
await new Promise(r => setTimeout(r, 120)); // delay between requests (go slow, this is the http fallback if WS fails)
q.resolve(0);
}
_httpRun = 0;
@@ -662,8 +659,12 @@
async function applyLED()
{
if (!palCache.length) return;
const seq = ++_applySeq;
// discard pending HTTP chunks from any previous preview so stale data doesn't drain slowly
while (_httpQueue.length) _httpQueue.shift().resolve(-1); // resolve dropped entries so their awaiters can observe the seq change and exit
try {
let st = await (await fetch(getURL('/json/state'), { cache: 'no-store' })).json();
if (seq !== _applySeq) return; // superseded by a newer preview request
if (!st.seg || !st.seg.length) return;
// get selected segments, use main segment if none selected
@@ -680,6 +681,7 @@
arr.push(palCache[len > 1 ? Math.round(i * 255 / (len - 1)) : 0]);
// send colors in chunks
for (let j = 0; j < arr.length; j += maxCol) {
if (seq !== _applySeq) return; // superseded mid-send
let chunk = [s.start + j, ...arr.slice(j, j + maxCol)];
await requestJson({ seg: { id: s.id, i: chunk } });
}

View File

@@ -25,6 +25,7 @@ var pN = "", pI = 0, pNum = 0;
var pmt = 1, pmtLS = 0;
var lastinfo = {};
var isM = false, mw = 0, mh=0;
var bsOpts = null; // blending style options snapshot, used for dynamic filtering based on matrix mode (iOS compatibility)
var ws, wsRpt=0;
var cfg = {
theme:{base:"dark", bg:{url:"", rnd: false, rndGrayscale: false, rndBlur: false}, alpha:{bg:0.6,tab:0.8}, color:{bg:""}},
@@ -660,12 +661,14 @@ function parseInfo(i) {
mw = i.leds.matrix ? i.leds.matrix.w : 0;
mh = i.leds.matrix ? i.leds.matrix.h : 0;
isM = mw>0 && mh>0;
if (!bsOpts) bsOpts = Array.from(gId('bs').options).map(o => o.cloneNode(true)); // snapshot all options on first call
const bsSel = gId('bs');
// note: style.display='none' for option elements is not supported on all browsers (notably iOS)
bsSel.replaceChildren(...bsOpts.filter(o => isM || o.dataset.type !== "2D").map(o => o.cloneNode(true))); // allow all in matrix mode, filter 2D blends otherwise
if (!isM) {
gId("filter2D").classList.add('hide');
gId('bs').querySelectorAll('option[data-type="2D"]').forEach((o,i)=>{o.style.display='none';});
gId("filter2D").classList.add('hide'); // hide 2D effects in non-matrix mode
} else {
gId("filter2D").classList.remove('hide');
gId('bs').querySelectorAll('option[data-type="2D"]').forEach((o,i)=>{o.style.display='';});
gId("filter2D").classList.remove('hide');
}
gId("updBt").style.display = (i.opt & 1) ? '':'none';
// if (i.noaudio) {
@@ -1457,7 +1460,9 @@ function readState(s,command=false)
tr = s.transition;
gId('tt').value = tr/10;
gId('bs').value = s.bs || 0;
const bsSel = gId('bs');
bsSel.value = s.bs || 0; // assign blending style
if (!bsSel.value) bsSel.value = 0; // fall back to Fade if option does not exist
if (tr===0) gId('bsp').classList.add('hide')
else gId('bsp').classList.remove('hide')

View File

@@ -1213,32 +1213,23 @@ static size_t writeJSONStringElement(uint8_t* dest, size_t maxLen, const char* s
return 1 + n;
}
// Generate a streamed JSON response for the mode data (namesOnly=false) or mode names
// (namesOnly=true). This uses sendChunked to send the reply in blocks based on how much
// fit in the outbound packet buffer, minimizing the required state (ie. just the next index
// to send). This allows us to send an arbitrarily large response without using any
// significant amount of memory (so no worries about buffer limits).
void respondModeData(AsyncWebServerRequest* request, bool namesOnly = false) {
// Generate a streamed JSON response for the mode data
// This uses sendChunked to send the reply in blocks based on how much fit in the outbound
// packet buffer, minimizing the required state (ie. just the next index to send). This
// allows us to send an arbitrarily large response without using any significant amount of
// memory (so no worries about buffer limits).
void respondModeData(AsyncWebServerRequest* request) {
size_t fx_index = 0;
request->sendChunked(FPSTR(CONTENT_TYPE_JSON),
[fx_index, namesOnly](uint8_t* data, size_t len, size_t) mutable {
[fx_index](uint8_t* data, size_t len, size_t) mutable {
size_t bytes_written = 0;
char lineBuffer[256];
while (fx_index < strip.getModeCount()) {
strncpy_P(lineBuffer, strip.getModeData(fx_index), sizeof(lineBuffer)-1); // Copy to stack buffer for strchr
if (lineBuffer[0] != 0) {
lineBuffer[sizeof(lineBuffer)-1] = '\0'; // terminate string (only needed if strncpy filled the buffer)
char* dataPtr = strchr(lineBuffer,'@'); // Find '@', if there is one; non-const so namesOnly mode can truncate lineBuffer here
const char* value;
// namesOnly=true → emit the display name (everything before '@')
// namesOnly=false → emit the fx-data string (everything after '@')
if (namesOnly) {
if (dataPtr) *dataPtr = '\0'; // truncate at '@' to get name only
value = lineBuffer;
} else {
value = dataPtr ? dataPtr + 1 : ""; // everything after '@' is the fx data
}
size_t mode_bytes = writeJSONStringElement(data, len, value);
const char* dataPtr = strchr(lineBuffer,'@'); // Find '@', if there is one
size_t mode_bytes = writeJSONStringElement(data, len, dataPtr ? dataPtr + 1 : "");
if (mode_bytes == 0) break; // didn't fit; break loop and try again next packet
if (fx_index == 0) *data = '[';
data += mode_bytes;
@@ -1285,7 +1276,7 @@ class LockedJsonResponse: public AsyncJsonResponse {
void serveJson(AsyncWebServerRequest* request)
{
enum class json_target {
all, state, info, state_info, nodes, palettes, networks, config, pins
all, state, info, state_info, nodes, effects, palettes, networks, config, pins
};
json_target subJson = json_target::all;
@@ -1294,7 +1285,7 @@ void serveJson(AsyncWebServerRequest* request)
else if (url.indexOf("info") > 0) subJson = json_target::info;
else if (url.indexOf("si") > 0) subJson = json_target::state_info;
else if (url.indexOf(F("nodes")) > 0) subJson = json_target::nodes;
else if (url.indexOf(F("eff")) > 0) { respondModeData(request, true); return; }
else if (url.indexOf(F("eff")) > 0) subJson = json_target::effects;
else if (url.indexOf(F("palx")) > 0) subJson = json_target::palettes;
else if (url.indexOf(F("fxda")) > 0) { respondModeData(request); return; }
else if (url.indexOf(F("net")) > 0) subJson = json_target::networks;
@@ -1321,7 +1312,7 @@ void serveJson(AsyncWebServerRequest* request)
}
// releaseJSONBufferLock() will be called when "response" is destroyed (from AsyncWebServer)
// make sure you delete "response" if no "request->send(response);" is made
LockedJsonResponse *response = new LockedJsonResponse(pDoc, false); // will clear JsonDocument
LockedJsonResponse *response = new LockedJsonResponse(pDoc, subJson==json_target::effects); // will clear and convert JsonDocument into JsonArray if necessary
JsonVariant lDoc = response->getRoot();
@@ -1335,6 +1326,8 @@ void serveJson(AsyncWebServerRequest* request)
serializeNodes(lDoc); break;
case json_target::palettes:
serializePalettes(lDoc, request->hasParam(F("page")) ? request->getParam(F("page"))->value().toInt() : 0); break;
case json_target::effects:
serializeModeNames(lDoc); break;
case json_target::networks:
serializeNetworks(lDoc); break;
case json_target::config: