#include "wled.h" #ifdef WLED_ENABLE_INFINITY_CONTROLLER #ifdef ARDUINO_ARCH_ESP32 #include "esp_timer.h" #endif namespace { constexpr uint32_t INFINITY_MAGIC = 0x59534649UL; // "IFSY" little-endian constexpr uint8_t INFINITY_VERSION = 1; constexpr uint16_t INFINITY_DEFAULT_PORT = 21325; constexpr uint8_t INFINITY_NODE_COUNT = 6; constexpr uint8_t INFINITY_PANEL_COUNT = 3; constexpr uint16_t INFINITY_LEDS_PER_PANEL = 106; constexpr uint16_t INFINITY_LEDS_PER_NODE = INFINITY_PANEL_COUNT * INFINITY_LEDS_PER_PANEL; constexpr uint32_t INFINITY_CLOCK_INTERVAL_MS = 250; constexpr uint32_t INFINITY_SCENE_INTERVAL_MS = 100; constexpr uint32_t INFINITY_STATUS_INTERVAL_MS = 1000; constexpr uint32_t INFINITY_NODE_TIMEOUT_MS = 3000; constexpr uint32_t INFINITY_APPLY_DELAY_US = 120000; constexpr uint16_t INFINITY_DMX_FOOTPRINT = 32; constexpr uint8_t INFINITY_CUSTOM_COLOR_MAX = 24; constexpr uint8_t INFINITY_SPATIAL_OFF = 0; constexpr uint8_t INFINITY_SPATIAL_CENTER_PULSE = 1; constexpr uint8_t INFINITY_SPATIAL_CHECKER = 2; constexpr uint8_t INFINITY_SPATIAL_ARROW = 3; constexpr uint8_t INFINITY_SPATIAL_SCAN = 4; constexpr uint8_t INFINITY_SPATIAL_SNAKE = 5; constexpr uint8_t INFINITY_SPATIAL_WAVE_LINE = 6; constexpr uint8_t INFINITY_SPATIAL_STROBE = 7; constexpr uint8_t INFINITY_SPATIAL_SCHLAENGELN = 8; constexpr uint8_t INFINITY_SPATIAL_SUNBURST = 9; constexpr uint8_t INFINITY_SPATIAL_VARIANT_EXPAND = 0; constexpr uint8_t INFINITY_SPATIAL_VARIANT_REVERSE = 1; constexpr uint8_t INFINITY_SPATIAL_VARIANT_OUTLINE = 2; constexpr uint8_t INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE = 3; constexpr uint8_t INFINITY_SPATIAL_DIRECTION_LTR = 0; constexpr uint8_t INFINITY_SPATIAL_DIRECTION_RTL = 1; constexpr uint8_t INFINITY_SPATIAL_DIRECTION_TTB = 2; constexpr uint8_t INFINITY_SPATIAL_DIRECTION_BTT = 3; constexpr uint8_t INFINITY_SPATIAL_DIRECTION_OUTWARD = 4; constexpr uint8_t INFINITY_SPATIAL_DIRECTION_INWARD = 5; constexpr uint8_t INFINITY_SPATIAL_DIRECTION_PINGPONG = 6; constexpr uint8_t INFINITY_SPATIAL_OPTION_LINE = 0; constexpr uint8_t INFINITY_SPATIAL_OPTION_BANDS = 1; constexpr uint8_t INFINITY_SPATIAL_OPTION_TWO_COLOR = 1; constexpr uint8_t INFINITY_SPATIAL_OPTION_RAINBOW = 2; constexpr uint8_t INFINITY_BPM_MIN = 20; constexpr uint8_t INFINITY_BPM_MAX = 240; constexpr float INFINITY_PANEL_GAP_RATIO = 0.50f; // 8 cm gap for roughly 16 cm active panel aperture. constexpr float INFINITY_TAU = 6.28318530718f; struct PanelTransform { uint8_t rotation; // clockwise quarter turns: 0, 1, 2, 3 bool mirrorX; bool mirrorY; }; // Single source of truth for per-panel orientation used by global geometry effects. // Keep the compact UI/visualizer grid in logical row/column order; transform only // the local LED coordinates for radial/global calculations. constexpr PanelTransform INFINITY_PANEL_TRANSFORMS[INFINITY_PANEL_COUNT][INFINITY_NODE_COUNT] = { {{0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}}, {{0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}}, {{0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}}, }; enum SpatialRole : uint8_t { SPATIAL_ROLE_GRADIENT = 0, SPATIAL_ROLE_PRIMARY = 1, SPATIAL_ROLE_SECONDARY = 2, SPATIAL_ROLE_BLACK = 3, SPATIAL_ROLE_EFFECT = 4, SPATIAL_ROLE_RAINBOW = 5, }; struct SpatialSample { uint8_t amount; SpatialRole role; uint8_t hue; }; enum InfinityPacketType : uint8_t { INFINITY_PACKET_CLOCK_SYNC = 1, INFINITY_PACKET_SCENE_STATE = 2, INFINITY_PACKET_BEAT_TRIGGER = 3, INFINITY_PACKET_NODE_STATUS = 4, }; struct __attribute__((packed)) InfinityPacketHeader { uint32_t magic; uint8_t version; uint8_t type; uint16_t payloadSize; uint32_t sequence; uint64_t masterTimeUs; }; struct __attribute__((packed)) InfinityScenePayload { uint8_t effectId; uint8_t presetId; uint8_t brightness; uint8_t speed; uint8_t intensity; uint8_t palette; uint8_t primary[3]; uint8_t secondary[3]; uint8_t tertiary[3]; uint8_t groupMask; uint8_t direction; uint8_t flags; uint16_t transitionMs; uint32_t seed; uint16_t phase; uint64_t applyAtUs; uint8_t rowDimmer[3]; uint8_t reserved0; uint8_t safetyFade; uint8_t custom[3]; uint8_t spatialMode; uint8_t spatialStrength; uint8_t spatialVariant; uint8_t spatialDirection; uint8_t spatialSize; uint16_t spatialAngle; uint8_t spatialOption; }; constexpr uint16_t INFINITY_SCENE_PAYLOAD_LEGACY_SIZE = __builtin_offsetof(InfinityScenePayload, spatialDirection); struct __attribute__((packed)) InfinityStatusPayload { char nodeId[12]; uint32_t uptimeMs; int32_t masterOffsetUs; uint32_t lastSceneSequence; uint32_t packetsReceived; uint8_t flags; uint8_t activeEffect; uint8_t activePreset; uint8_t reserved; }; struct InfinityNodeStatus { IPAddress ip; char nodeId[12]; uint32_t lastSeenMs; uint32_t uptimeMs; int32_t masterOffsetUs; uint32_t lastSceneSequence; uint32_t packetsReceived; uint8_t flags; uint8_t activeEffect; uint8_t activePreset; }; WiFiUDP infinityUdp; bool infinityUdpStarted = false; bool infinityEnabled = true; uint16_t infinityPort = INFINITY_DEFAULT_PORT; IPAddress infinityMasterIp(192, 168, 178, 10); IPAddress infinityNodeIps[INFINITY_NODE_COUNT] = { IPAddress(192, 168, 178, 11), IPAddress(192, 168, 178, 12), IPAddress(192, 168, 178, 13), IPAddress(192, 168, 178, 14), IPAddress(192, 168, 178, 15), IPAddress(192, 168, 178, 16), }; char infinityConfiguredNodeId[12] = "auto"; char infinityRuntimeNodeId[12] = "node-xx"; InfinityScenePayload infinityScene = { 0, // effectId 0, // presetId 255, // brightness 128, // speed 128, // intensity 0, // palette {255, 160, 80}, {0, 32, 255}, {0, 0, 0}, 0x07, 0, 1, 750, 1, 0, 0, {255, 255, 255}, 0, 0, {0, 0, 0}, INFINITY_SPATIAL_OFF, 180, INFINITY_SPATIAL_VARIANT_EXPAND, INFINITY_SPATIAL_DIRECTION_LTR, 64, 0, INFINITY_SPATIAL_OPTION_LINE, }; InfinityScenePayload pendingScene; bool hasPendingScene = false; uint32_t infinitySequence = 0; uint32_t lastClockSentMs = 0; uint32_t lastSceneSentMs = 0; uint32_t lastStatusSentMs = 0; uint32_t lastDmxBeatValue = 0; uint32_t lastDmxSyncResetValue = 0; uint32_t packetsReceived = 0; uint32_t packetsSent = 0; uint32_t lastSceneSequence = 0; int64_t masterOffsetUs = 0; uint8_t lastAppliedPreset = 0; uint8_t lastAppliedEffect = 0; InfinityNodeStatus nodeStatuses[INFINITY_NODE_COUNT] = {}; uint8_t infinityCustomColors[INFINITY_CUSTOM_COLOR_MAX][3] = { {69, 57, 148}, {61, 55, 135}, {52, 48, 112}, {49, 45, 101}, {105, 153, 203}, {0, 64, 255}, {255, 158, 74}, {8, 18, 46}, }; uint8_t infinityCustomColorCount = 8; bool isMasterBuild() { #ifdef WLED_INFINITY_MASTER return true; #else return false; #endif } bool isNodeBuild() { #ifdef WLED_INFINITY_NODE return true; #else return false; #endif } uint64_t localTimeUs() { #ifdef ARDUINO_ARCH_ESP32 return static_cast(esp_timer_get_time()); #else return static_cast(micros()); #endif } uint64_t masterTimeUs() { if (isMasterBuild()) return localTimeUs(); return static_cast(static_cast(localTimeUs()) + masterOffsetUs); } uint8_t clampMode(uint8_t mode) { mode = rfpEffectSanitize(mode); const uint8_t count = strip.getModeCount(); if (count == 0) return 0; if (mode < count && strncmp_P("RSVD", strip.getModeData(mode), 4)) return mode; return rfpEffectFallback(); } String ipToString(const IPAddress& ip) { return String(ip[0]) + '.' + String(ip[1]) + '.' + String(ip[2]) + '.' + String(ip[3]); } void copyNodeId(char* dest, size_t len, const char* src) { if (!dest || len == 0) return; if (!src || src[0] == 0) src = "auto"; strlcpy(dest, src, len); } void refreshRuntimeNodeId() { if (strcmp(infinityConfiguredNodeId, "auto") != 0 && infinityConfiguredNodeId[0] != 0) { copyNodeId(infinityRuntimeNodeId, sizeof(infinityRuntimeNodeId), infinityConfiguredNodeId); return; } if (isMasterBuild()) { copyNodeId(infinityRuntimeNodeId, sizeof(infinityRuntimeNodeId), "master"); return; } IPAddress ip = Network.localIP(); if (ip[0] == 192 && ip[1] == 168 && ip[2] == 178 && ip[3] >= 11 && ip[3] <= 16) { snprintf(infinityRuntimeNodeId, sizeof(infinityRuntimeNodeId), "node-%02u", unsigned(ip[3] - 10)); } else { copyNodeId(infinityRuntimeNodeId, sizeof(infinityRuntimeNodeId), "node-xx"); } } uint16_t dmxTransitionMs(uint8_t value) { return static_cast(value) * 40U; } void hsvToRgb(uint8_t hue, uint8_t sat, uint8_t value, uint8_t rgb[3]) { byte temp[3] = {0, 0, 0}; colorHStoRGB(static_cast(hue) * 257U, sat, temp); rgb[0] = (static_cast(temp[0]) * value) / 255U; rgb[1] = (static_cast(temp[1]) * value) / 255U; rgb[2] = (static_cast(temp[2]) * value) / 255U; } uint32_t rgbToColor(const uint8_t rgb[3]) { return RGBW32(rgb[0], rgb[1], rgb[2], 0); } uint8_t scaleByte(uint8_t value, uint8_t scale) { return (static_cast(value) * scale) / 255U; } uint32_t scaleColor(uint32_t color, uint8_t scale) { return RGBW32(scaleByte(R(color), scale), scaleByte(G(color), scale), scaleByte(B(color), scale), scaleByte(W(color), scale)); } bool isDirectSpatialColorRole(SpatialRole role) { return role == SPATIAL_ROLE_PRIMARY || role == SPATIAL_ROLE_SECONDARY || role == SPATIAL_ROLE_RAINBOW; } uint32_t composeSpatialColor(uint32_t base, uint32_t layer, uint8_t amount, uint8_t strength, SpatialRole role) { if (isDirectSpatialColorRole(role)) { // Direct color roles are literal output colors. Strength only dims // effect-mask modes; it must not shift Primary/Secondary/Rainbow hues. if (amount == 255) return layer; return scaleColor(layer, amount); } if (role == SPATIAL_ROLE_BLACK) return BLACK; const uint8_t mask = scaleByte(amount, strength); return scaleColor(base, mask); } uint8_t normalizeSpatialOption(uint8_t mode, uint8_t option) { if (mode == INFINITY_SPATIAL_SCAN) { return option > INFINITY_SPATIAL_OPTION_BANDS ? INFINITY_SPATIAL_OPTION_LINE : option; } if (mode == INFINITY_SPATIAL_SUNBURST) { return option > INFINITY_SPATIAL_OPTION_RAINBOW ? INFINITY_SPATIAL_OPTION_LINE : option; } return INFINITY_SPATIAL_OPTION_LINE; } SpatialSample spatialSampleOf(uint8_t amount, SpatialRole role = SPATIAL_ROLE_GRADIENT, uint8_t hue = 0) { return { amount, role, hue }; } bool parseNodeColumn(uint8_t& column) { if (strncmp(infinityRuntimeNodeId, "node-", 5) == 0) { const uint8_t n = atoi(infinityRuntimeNodeId + 5); if (n >= 1 && n <= INFINITY_NODE_COUNT) { column = n - 1; return true; } } IPAddress ip = Network.localIP(); if (ip[0] == 192 && ip[1] == 168 && ip[2] == 178 && ip[3] >= 11 && ip[3] <= 16) { column = ip[3] - 11; return true; } column = 0; return false; } float smoothstepf(float edge0, float edge1, float x) { if (edge0 == edge1) return x < edge0 ? 0.0f : 1.0f; x = (x - edge0) / (edge1 - edge0); if (x < 0.0f) x = 0.0f; if (x > 1.0f) x = 1.0f; return x * x * (3.0f - 2.0f * x); } uint8_t floatToAmount(float value) { if (value <= 0.0f) return 0; if (value >= 1.0f) return 255; return static_cast(value * 255.0f + 0.5f); } uint8_t speedToBpm(uint8_t speed) { constexpr uint16_t range = INFINITY_BPM_MAX - INFINITY_BPM_MIN; return INFINITY_BPM_MIN + ((static_cast(speed) * range + 127U) / 255U); } float spatialBeatPosition(uint64_t timeUs, uint8_t speed) { const float seconds = static_cast(timeUs % 60000000ULL) / 1000000.0f; const float cyclesPerSecond = static_cast(speedToBpm(speed)) / 60.0f; return seconds * cyclesPerSecond; } float spatialStepPosition(uint64_t timeUs, uint8_t speed) { // Two visible animation phases make one measured on/off panel cycle match the BPM value. return spatialBeatPosition(timeUs, speed) * 2.0f; } uint32_t spatialBeatIndex(uint64_t timeUs, uint8_t speed) { return static_cast(floorf(spatialBeatPosition(timeUs, speed))); } float spatialBeatFrac(uint64_t timeUs, uint8_t speed) { const float beat = spatialBeatPosition(timeUs, speed); return beat - floorf(beat); } uint32_t spatialStepIndex(uint64_t timeUs, uint8_t speed) { return static_cast(floorf(spatialStepPosition(timeUs, speed))); } uint8_t strobeAmount(uint64_t timeUs, uint8_t speed, uint8_t pulseWidth) { const float duty = 0.01f + (static_cast(pulseWidth) / 255.0f) * 0.34f; const float phase = spatialBeatPosition(timeUs, speed) * 8.0f; return (phase - floorf(phase)) < duty ? 255 : 0; } uint8_t serpentinePanelIndex(uint8_t column, uint8_t row) { return (row & 0x01) ? (row * INFINITY_NODE_COUNT + (INFINITY_NODE_COUNT - 1 - column)) : (row * INFINITY_NODE_COUNT + column); } uint8_t mirroredSerpentinePanelIndex(uint8_t column, uint8_t row, uint8_t variant) { if (variant == 1 || variant == 3) column = INFINITY_NODE_COUNT - 1 - column; if (variant == 2 || variant == 3) row = INFINITY_PANEL_COUNT - 1 - row; return serpentinePanelIndex(column, row); } uint8_t chainLengthFromSize(uint8_t size) { if (size <= 64) return max(1, size / 16); return min(18, 4 + ((static_cast(size - 64) * 14U + 95U) / 191U)); } uint8_t snakeLengthFromSize(uint8_t size) { return min(17, 3 + max(1, size / 64)); } bool snakeContains(uint8_t position, uint8_t head, uint8_t length, uint8_t pathLen) { for (uint8_t offset = 0; offset < length; offset++) { if (position == (head + pathLen - (offset % pathLen)) % pathLen) return true; } return false; } uint32_t spatialHash(uint32_t value) { value ^= value >> 16; value *= 0x7feb352dUL; value ^= value >> 15; value *= 0x846ca68bUL; value ^= value >> 16; return value; } uint8_t spawnSnakeApple(uint32_t seed, uint16_t generation, uint16_t step, uint8_t length, uint8_t pathLen) { const uint8_t head = (step + (seed % pathLen)) % pathLen; uint8_t candidate = spatialHash(seed ^ (static_cast(generation) * 0x9e3779b9UL) ^ step) % pathLen; for (uint8_t tries = 0; tries < pathLen; tries++) { if (!snakeContains(candidate, head, length, pathLen)) return candidate; candidate = (candidate + 5) % pathLen; } return (head + length + 1) % pathLen; } uint8_t snakeAppleAtStep(uint32_t seed, uint16_t localStep, uint8_t length, uint8_t pathLen) { uint16_t generation = 0; uint8_t apple = spawnSnakeApple(seed, generation, 0, length, pathLen); for (uint16_t step = 0; step <= localStep; step++) { const uint8_t head = (step + (seed % pathLen)) % pathLen; if (head == apple) { generation++; apple = spawnSnakeApple(seed, generation, step + 1, length, pathLen); } } return apple; } uint8_t gridPanelIndex(uint8_t column, uint8_t row) { return row * INFINITY_NODE_COUNT + column; } uint8_t gridColumn(uint8_t index) { return index % INFINITY_NODE_COUNT; } uint8_t gridRow(uint8_t index) { return index / INFINITY_NODE_COUNT; } bool snakeBodyContains(const uint8_t* body, uint8_t length, uint8_t position, uint8_t skipTail = 0) { const uint8_t end = length > skipTail ? length - skipTail : 0; for (uint8_t i = 0; i < end; i++) { if (body[i] == position) return true; } return false; } uint8_t snakeSpawnApple(const uint8_t* body, uint8_t length, uint32_t seed, uint16_t generation) { uint8_t candidate = spatialHash(seed ^ (static_cast(generation) * 0x9e3779b9UL)) % (INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT); for (uint8_t tries = 0; tries < INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT; tries++) { if (!snakeBodyContains(body, length, candidate)) return candidate; candidate = (candidate + 7) % (INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT); } return body[0]; } uint8_t snakeNeighbor(uint8_t position, uint8_t direction, bool& valid) { const uint8_t col = gridColumn(position); const uint8_t row = gridRow(position); valid = true; switch (direction) { case 0: if (col + 1 < INFINITY_NODE_COUNT) return gridPanelIndex(col + 1, row); break; case 1: if (col > 0) return gridPanelIndex(col - 1, row); break; case 2: if (row + 1 < INFINITY_PANEL_COUNT) return gridPanelIndex(col, row + 1); break; case 3: if (row > 0) return gridPanelIndex(col, row - 1); break; } valid = false; return position; } uint8_t snakeDistance(uint8_t a, uint8_t b) { const int8_t dx = static_cast(gridColumn(a)) - static_cast(gridColumn(b)); const int8_t dy = static_cast(gridRow(a)) - static_cast(gridRow(b)); return abs(dx) + abs(dy); } void snakeReset(uint8_t* body, uint8_t& length, uint8_t targetLength, uint8_t& apple, uint16_t& generation, uint32_t seed, uint16_t epoch) { body[0] = spatialHash(seed ^ (static_cast(epoch) * 0x45d9f3bUL)) % (INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT); length = min(targetLength, 3); for (uint8_t i = 1; i < length; i++) body[i] = body[0]; generation++; apple = snakeSpawnApple(body, length, seed ^ epoch, generation); } void snakeStateAt(uint16_t localStep, uint8_t targetLength, uint32_t seed, uint8_t* body, uint8_t& length, uint8_t& apple) { uint16_t generation = 0; snakeReset(body, length, targetLength, apple, generation, seed, 0); static const uint8_t baseOrder[4] = {0, 2, 1, 3}; // right, down, left, up for (uint16_t step = 0; step < localStep; step++) { uint8_t best = body[0]; uint8_t bestDistance = 255; const uint8_t orderOffset = spatialHash(seed ^ (static_cast(step) * 0x9e3779b1UL) ^ generation) & 0x03; for (uint8_t i = 0; i < 4; i++) { bool valid = false; const uint8_t next = snakeNeighbor(body[0], baseOrder[(i + orderOffset) & 0x03], valid); if (!valid) continue; const bool willEat = next == apple; if (snakeBodyContains(body, length, next, willEat ? 0 : 1)) continue; const uint8_t distance = snakeDistance(next, apple); if (distance < bestDistance) { bestDistance = distance; best = next; } } if (best == body[0] && body[0] != apple) { snakeReset(body, length, targetLength, apple, generation, seed, step + 1); continue; } const bool ate = best == apple; const uint8_t newLength = ate ? min(targetLength, length + 1) : length; for (int8_t i = static_cast(newLength) - 1; i > 0; i--) body[i] = body[i - 1]; body[0] = best; length = newLength; if (ate) { generation++; apple = snakeSpawnApple(body, length, seed ^ step, generation); } } } uint16_t triangleStep(uint32_t step, uint8_t maxIndex) { if (maxIndex == 0) return 0; const uint16_t period = static_cast(maxIndex) * 2U; uint16_t phase = step % period; if (phase > maxIndex) phase = period - phase; return phase; } int16_t schlaengelnPingPongPosition(uint32_t step, uint8_t offset, uint8_t maxIndex) { if (maxIndex == 0) return 0; const uint16_t period = static_cast(maxIndex) * 2U; uint16_t phase = (step + period - (offset % period)) % period; if (phase > maxIndex) phase = period - phase; return phase; } void panelLedPosition(uint16_t led, float& x, float& y, uint8_t& side) { if (led < 25) { side = 0; // top, left -> right x = (led + 0.5f) / 25.0f; y = 0.0f; return; } if (led < 52) { side = 1; // right, top -> bottom x = 1.0f; y = (led - 25 + 0.5f) / 27.0f; return; } if (led < 79) { side = 2; // bottom, right -> left x = 1.0f - ((led - 52 + 0.5f) / 27.0f); y = 1.0f; return; } side = 3; // left, bottom -> top x = 0.0f; y = 1.0f - ((led - 79 + 0.5f) / 27.0f); } void applyPanelTransform(uint8_t column, uint8_t row, float& x, float& y) { if (row >= INFINITY_PANEL_COUNT || column >= INFINITY_NODE_COUNT) return; const PanelTransform& transform = INFINITY_PANEL_TRANSFORMS[row][column]; if (transform.mirrorX) x = 1.0f - x; if (transform.mirrorY) y = 1.0f - y; for (uint8_t i = 0; i < (transform.rotation & 0x03); i++) { const float oldX = x; x = 1.0f - y; y = oldX; } } void physicalPanelLedPosition(uint8_t column, uint8_t row, uint16_t led, float& x, float& y) { float lx = 0.0f, ly = 0.0f; uint8_t side = 0; panelLedPosition(led, lx, ly, side); applyPanelTransform(column, row, lx, ly); const float pitch = 1.0f + INFINITY_PANEL_GAP_RATIO; x = static_cast(column) * pitch + lx; y = static_cast(row) * pitch + ly; } void applySunburstPanelTransform(float& x, float& y) { // Only Sunburst needs the observed 90-degree-left correction. Keep the // global transform neutral so Scan and the other modes stay unrotated. for (uint8_t i = 0; i < 3; i++) { const float oldX = x; x = 1.0f - y; y = oldX; } } void sunburstPanelLedPosition(uint8_t column, uint8_t row, uint16_t led, float& x, float& y) { float lx = 0.0f, ly = 0.0f; uint8_t side = 0; panelLedPosition(led, lx, ly, side); applySunburstPanelTransform(lx, ly); const float pitch = 1.0f + INFINITY_PANEL_GAP_RATIO; x = static_cast(column) * pitch + lx; y = static_cast(row) * pitch + ly; } void physicalPanelCenter(float& cx, float& cy) { const float pitch = 1.0f + INFINITY_PANEL_GAP_RATIO; cx = ((static_cast(INFINITY_NODE_COUNT - 1) * pitch) + 1.0f) * 0.5f; cy = ((static_cast(INFINITY_PANEL_COUNT - 1) * pitch) + 1.0f) * 0.5f; } uint8_t centerPulseAmount(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t speed, uint8_t variant) { constexpr float centerRow = 1.0f; constexpr float centerCol = 2.5f; constexpr float maxDistance = 3.5f; const float distance = fabsf(static_cast(row) - centerRow) + fabsf(static_cast(column) - centerCol); const float span = maxDistance + 1.0f; float front = fmodf(spatialStepPosition(timeUs, speed), span); if (variant == INFINITY_SPATIAL_VARIANT_REVERSE || variant == INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE) front = maxDistance - front; const float delta = fabsf(distance - front); return floatToAmount(1.0f - smoothstepf(0.0f, 0.70f, delta)); } uint8_t centerPulseOutlineAmount(uint8_t column, uint8_t row, uint16_t led, uint8_t panelAmount, uint8_t variant) { float x = 0.0f, y = 0.0f; uint8_t side = 0; panelLedPosition(led, x, y, side); if (variant == INFINITY_SPATIAL_VARIANT_OUTLINE || variant == INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE) { if (row == 1 && (column == 2 || column == 3)) { return (side == 0 || side == 2) ? panelAmount : 0; } if (column == 0) return side == 3 ? panelAmount : 0; if (column == 5) return side == 1 ? panelAmount : 0; if (row == 0) return side == 0 ? panelAmount : 0; if (row == 2) return side == 2 ? panelAmount : 0; return panelAmount; } return panelAmount; } uint8_t checkerdAmount(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs, uint8_t variant, uint8_t speed) { const uint8_t parity = (row + column) & 0x01; const uint16_t step = static_cast(spatialStepIndex(timeUs, speed)); if (variant == 1 || variant == 2) { float x = 0.0f, y = 0.0f; uint8_t side = 0; panelLedPosition(led, x, y, side); const bool slash = variant == 2 && (step & 0x01); const bool first = slash ? (y <= 1.0f - x) : (y <= x); return ((((parity + step) & 0x01) == 0) == first) ? 255 : 0; } return (((parity + step) & 0x01) == 0) ? 255 : 0; } uint8_t waveLineAmount(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t direction, uint8_t speed) { static const uint8_t triangle[4] = {0, 1, 2, 1}; const int32_t step = static_cast(spatialStepIndex(timeUs, speed)); if (direction == INFINITY_SPATIAL_DIRECTION_TTB || direction == INFINITY_SPATIAL_DIRECTION_BTT) { const int32_t phase = direction == INFINITY_SPATIAL_DIRECTION_TTB ? step : -step; const uint8_t target = static_cast(roundf(triangle[((static_cast(row) - phase) % 4 + 4) % 4] * ((INFINITY_NODE_COUNT - 1) / 2.0f) / 2.0f)); return column == min(target, INFINITY_NODE_COUNT - 1) ? 255 : 0; } const int32_t phase = direction == INFINITY_SPATIAL_DIRECTION_RTL ? -step : step; const uint8_t target = static_cast(roundf(triangle[((static_cast(column) - phase) % 4 + 4) % 4] * ((INFINITY_PANEL_COUNT - 1) / 2.0f))); return row == min(target, INFINITY_PANEL_COUNT - 1) ? 255 : 0; } uint8_t arrowAmount(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t direction, uint8_t speed, uint8_t size) { const bool horizontal = !(direction == INFINITY_SPATIAL_DIRECTION_TTB || direction == INFINITY_SPATIAL_DIRECTION_BTT); const uint8_t majorCount = horizontal ? INFINITY_NODE_COUNT : INFINITY_PANEL_COUNT; const uint8_t minorCount = horizontal ? INFINITY_PANEL_COUNT : INFINITY_NODE_COUNT; const uint8_t major = horizontal ? column : row; const uint8_t minor = horizontal ? row : column; const uint8_t gap = max(1, 1 + (size / 86)) - 1; const uint8_t span = 3 + gap; const int32_t movement = static_cast(spatialStepIndex(timeUs, speed)); const float middleMinor = (static_cast(minorCount) - 1.0f) / 2.0f; const uint8_t band = fabsf(static_cast(minor) - middleMinor) <= 0.55f ? 0 : 1; const bool orientationRight = direction == INFINITY_SPATIAL_DIRECTION_LTR || direction == INFINITY_SPATIAL_DIRECTION_TTB || direction == INFINITY_SPATIAL_DIRECTION_OUTWARD; const uint8_t target = orientationRight ? (band == 0 ? 1 : 0) : (band == 0 ? 1 : 2); const int32_t local = orientationRight ? (static_cast(major) - movement) : (static_cast(major) + movement); return majorCount > 0 && ((local % span + span) % span) == target ? 255 : 0; } void scanVector(uint16_t angle, float& vx, float& vy) { const float radians = static_cast(angle) * 0.01745329252f; vx = cosf(radians); vy = sinf(radians); } uint8_t scanAmount(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs, uint8_t speed, uint8_t size, uint16_t angle, uint8_t option, uint8_t direction) { float x = 0.0f, y = 0.0f; uint8_t side = 0; panelLedPosition(led, x, y, side); float vx = 0.0f, vy = 0.0f; const bool vertical = direction == INFINITY_SPATIAL_DIRECTION_TTB || direction == INFINITY_SPATIAL_DIRECTION_BTT; scanVector((angle + (vertical ? 90 : 0)) % 360, vx, vy); const float progress = (static_cast(column) + x) * vx + (static_cast(row) + y) * vy; const float p00 = 0.0f; const float p10 = static_cast(INFINITY_NODE_COUNT) * vx; const float p01 = static_cast(INFINITY_PANEL_COUNT) * vy; const float p11 = p10 + p01; const float minProgress = fminf(fminf(p00, p10), fminf(p01, p11)); const float maxProgress = fmaxf(fmaxf(p00, p10), fmaxf(p01, p11)); const float width = 0.15f + (static_cast(size) / 255.0f) * (option == INFINITY_SPATIAL_OPTION_BANDS ? 1.60f : 0.85f); const float travel = maxProgress - minProgress; if (travel <= 0.001f) return 0; float phase = fmodf(spatialStepPosition(timeUs, speed), travel); if (direction == INFINITY_SPATIAL_DIRECTION_PINGPONG) { phase = fmodf(spatialStepPosition(timeUs, speed), travel * 2.0f); if (phase > travel) phase = (travel * 2.0f) - phase; } else if (direction == INFINITY_SPATIAL_DIRECTION_RTL || direction == INFINITY_SPATIAL_DIRECTION_BTT) { phase = travel - phase; } const float center = minProgress + max(0.0f, min(travel, phase)); if (option == INFINITY_SPATIAL_OPTION_BANDS) { const float period = width * 2.0f + 0.35f; const float d = fabsf(fmodf(progress - center + period * 64.0f, period) - period * 0.5f); return floatToAmount(1.0f - smoothstepf(width * 0.45f, width * 0.75f, d)); } return floatToAmount(1.0f - smoothstepf(width * 0.5f, width * 0.5f + 0.55f, fabsf(progress - center))); } SpatialSample snakeSample(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t speed, uint8_t size) { const uint8_t panelIndex = gridPanelIndex(column, row); const uint8_t targetLength = snakeLengthFromSize(size); const uint16_t localStep = spatialStepIndex(timeUs, speed) % 240U; uint8_t body[INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT] = {0}; uint8_t length = 0; uint8_t apple = 0; snakeStateAt(localStep, targetLength, infinityScene.seed, body, length, apple); for (uint8_t i = 0; i < length; i++) { if (panelIndex == body[i]) { return spatialSampleOf(255, SPATIAL_ROLE_PRIMARY); } } return panelIndex == apple ? spatialSampleOf(255, SPATIAL_ROLE_SECONDARY) : spatialSampleOf(0); } SpatialSample schlaengelnSample(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t speed, uint8_t size, uint8_t variant, uint8_t direction) { const uint8_t pathLen = INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT; const uint8_t panelIndex = mirroredSerpentinePanelIndex(column, row, variant); const uint8_t length = chainLengthFromSize(size); const uint32_t step = spatialStepIndex(timeUs, speed); for (uint8_t offset = 0; offset < length; offset++) { uint8_t pos = 0; if (direction == INFINITY_SPATIAL_DIRECTION_PINGPONG) { pos = schlaengelnPingPongPosition(step, offset, pathLen - 1); } else if (direction == INFINITY_SPATIAL_DIRECTION_RTL) { const uint8_t head = (pathLen - 1) - (step % pathLen); pos = (head + pathLen - (offset % pathLen)) % pathLen; } else { pos = (step + pathLen - (offset % pathLen)) % pathLen; } if (panelIndex == pos) return spatialSampleOf(255 - min(200, offset * 30)); } return spatialSampleOf(0); } SpatialSample sunburstSample(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs, uint8_t speed, uint8_t variant, uint8_t option) { float x = 0.0f, y = 0.0f, cx = 0.0f, cy = 0.0f; sunburstPanelLedPosition(column, row, led, x, y); physicalPanelCenter(cx, cy); const float dx = x - cx; const float dy = y - cy; float wobble = 0.0f; if (variant == 1) wobble = sinf(spatialBeatPosition(timeUs, speed) * INFINITY_TAU) * 0.18f; const float rotation = variant == 2 ? spatialBeatPosition(timeUs, speed) * (INFINITY_TAU / 24.0f) : 0.0f; float angle = atan2f(dy, dx) + wobble - rotation; if (angle < 0.0f) angle += INFINITY_TAU; while (angle >= INFINITY_TAU) angle -= INFINITY_TAU; constexpr float rays = 12.0f; // 12 bright rays + 12 dark gaps. const bool active = cosf(angle * rays) >= 0.0f; if (option == INFINITY_SPATIAL_OPTION_TWO_COLOR) { return spatialSampleOf(255, active ? SPATIAL_ROLE_PRIMARY : SPATIAL_ROLE_SECONDARY); } if (option == INFINITY_SPATIAL_OPTION_RAINBOW) { const uint8_t hue = static_cast((angle / INFINITY_TAU) * 255.0f) + static_cast(spatialBeatPosition(timeUs, speed) * 8.0f); return active ? spatialSampleOf(255, SPATIAL_ROLE_RAINBOW, hue) : spatialSampleOf(255, SPATIAL_ROLE_BLACK); } return active ? spatialSampleOf(255) : spatialSampleOf(255, SPATIAL_ROLE_BLACK); } SpatialSample spatialSample(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs) { switch (infinityScene.spatialMode) { case INFINITY_SPATIAL_CENTER_PULSE: return spatialSampleOf(centerPulseOutlineAmount(column, row, led, centerPulseAmount(column, row, timeUs, infinityScene.speed, infinityScene.spatialVariant), infinityScene.spatialVariant)); case INFINITY_SPATIAL_CHECKER: return spatialSampleOf(checkerdAmount(column, row, led, timeUs, infinityScene.spatialVariant, infinityScene.speed)); case INFINITY_SPATIAL_ARROW: return spatialSampleOf(arrowAmount(column, row, timeUs, infinityScene.spatialDirection, infinityScene.speed, infinityScene.spatialSize)); case INFINITY_SPATIAL_SCAN: return spatialSampleOf(scanAmount(column, row, led, timeUs, infinityScene.speed, infinityScene.spatialSize, infinityScene.spatialAngle, infinityScene.spatialOption, infinityScene.spatialDirection)); case INFINITY_SPATIAL_SNAKE: return snakeSample(column, row, timeUs, infinityScene.speed, infinityScene.spatialSize); case INFINITY_SPATIAL_WAVE_LINE: return spatialSampleOf(waveLineAmount(column, row, timeUs, infinityScene.spatialDirection, infinityScene.speed)); case INFINITY_SPATIAL_STROBE: return spatialSampleOf(strobeAmount(timeUs, infinityScene.speed, infinityScene.spatialSize)); case INFINITY_SPATIAL_SCHLAENGELN: return schlaengelnSample(column, row, timeUs, infinityScene.speed, infinityScene.spatialSize, infinityScene.spatialVariant, infinityScene.spatialDirection); case INFINITY_SPATIAL_SUNBURST: return sunburstSample(column, row, led, timeUs, infinityScene.speed, infinityScene.spatialVariant, infinityScene.spatialOption); default: return spatialSampleOf(0); } } uint32_t spatialLayerColor(Segment& seg, uint16_t led, SpatialSample sample) { if (sample.role == SPATIAL_ROLE_PRIMARY) return rgbToColor(infinityScene.primary); if (sample.role == SPATIAL_ROLE_SECONDARY) return rgbToColor(infinityScene.secondary); if (sample.role == SPATIAL_ROLE_BLACK) return BLACK; if (sample.role == SPATIAL_ROLE_RAINBOW) { uint8_t rgb[3] = {0, 0, 0}; hsvToRgb(sample.hue, 255, 255, rgb); return rgbToColor(rgb); } if (infinityScene.palette > 0) { seg.setCurrentPalette(); const uint16_t idx = (static_cast(led) * 255U) / (INFINITY_LEDS_PER_PANEL - 1U); return seg.color_from_palette(idx + ((masterTimeUs() / 20000ULL) & 0xFF), false, true, 0, sample.amount); } return color_blend(rgbToColor(infinityScene.secondary), rgbToColor(infinityScene.primary), sample.amount); } bool spatialExactRgb(SpatialSample sample, uint8_t rgb[3]) { if (sample.role == SPATIAL_ROLE_PRIMARY) { memcpy(rgb, infinityScene.primary, 3); return true; } if (sample.role == SPATIAL_ROLE_SECONDARY) { memcpy(rgb, infinityScene.secondary, 3); return true; } if (sample.role == SPATIAL_ROLE_RAINBOW) { hsvToRgb(sample.hue, 255, 255, rgb); return true; } return false; } void setSegmentName(Segment& seg, const char* name) { if (seg.name && strcmp(seg.name, name) == 0) return; if (seg.name) { delete[] seg.name; seg.name = nullptr; } const size_t len = strlen(name); seg.name = new(std::nothrow) char[len + 1]; if (seg.name) strlcpy(seg.name, name, len + 1); } void markSceneChanged(uint32_t delayUs = INFINITY_APPLY_DELAY_US) { infinityScene.applyAtUs = masterTimeUs() + delayUs; infinitySequence++; lastSceneSentMs = 0; } void updateStripTimebase(uint16_t phase) { const int64_t remoteMs = static_cast(masterTimeUs() / 1000ULL) + phase; strip.timebase = static_cast(remoteMs - static_cast(millis())); } void applySceneToStrip(const InfinityScenePayload& scene) { updateStripTimebase(scene.phase); const bool outputEnabled = (scene.flags & 0x01) != 0; bri = outputEnabled ? scene.brightness : 0; strip.setBrightness(scaledBri(bri), true); transitionDelayTemp = scene.transitionMs; if (scene.presetId > 0 && scene.presetId != lastAppliedPreset) { applyPreset(scene.presetId, CALL_MODE_NO_NOTIFY); lastAppliedPreset = scene.presetId; } const uint8_t effect = clampMode(scene.effectId); const uint32_t colors[3] = { rgbToColor(scene.primary), rgbToColor(scene.secondary), rgbToColor(scene.tertiary), }; const uint8_t segmentCount = strip.getSegmentsNum(); for (uint8_t i = 0; i < segmentCount; i++) { Segment& seg = strip.getSegment(i); if (!seg.isActive()) continue; const uint8_t rowBit = (i < 3) ? (1U << i) : 0x07; const bool rowEnabled = (scene.groupMask == 0) || ((scene.groupMask & rowBit) != 0); const uint8_t rowDimmer = (i < 3) ? scene.rowDimmer[i] : 255; seg.setOpacity(rowEnabled ? rowDimmer : 0); const bool effectChanged = seg.mode != effect; seg.setMode(effect, effectChanged, false); seg.speed = scene.speed; seg.intensity = scene.intensity; seg.setPalette(scene.palette); seg.setColor(0, colors[0]); seg.setColor(1, colors[1]); seg.setColor(2, colors[2]); seg.setOption(SEG_OPTION_REVERSED, (scene.direction & 0x01) != 0); seg.custom1 = scene.custom[0]; seg.custom2 = scene.custom[1]; seg.custom3 = scene.custom[2]; } lastAppliedEffect = effect; stateUpdated(CALL_MODE_NO_NOTIFY); } void maybeApplyPendingScene() { if (!hasPendingScene) return; if (pendingScene.applyAtUs != 0 && masterTimeUs() + 2000ULL < pendingScene.applyAtUs) return; infinityScene = pendingScene; hasPendingScene = false; applySceneToStrip(infinityScene); } bool sendPacket(const IPAddress& target, InfinityPacketType type, const void* payload, uint16_t payloadSize) { if (!infinityUdpStarted || !infinityEnabled) return false; InfinityPacketHeader header; header.magic = INFINITY_MAGIC; header.version = INFINITY_VERSION; header.type = static_cast(type); header.payloadSize = payloadSize; header.sequence = ++infinitySequence; header.masterTimeUs = masterTimeUs(); if (infinityUdp.beginPacket(target, infinityPort) == 0) return false; infinityUdp.write(reinterpret_cast(&header), sizeof(header)); if (payload != nullptr && payloadSize > 0) { infinityUdp.write(reinterpret_cast(payload), payloadSize); } const bool sent = infinityUdp.endPacket() != 0; if (sent) packetsSent++; return sent; } void sendToAllNodes(InfinityPacketType type, const void* payload, uint16_t payloadSize) { for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) { sendPacket(infinityNodeIps[i], type, payload, payloadSize); } } void sendClockSync() { sendToAllNodes(INFINITY_PACKET_CLOCK_SYNC, nullptr, 0); } void sendSceneState() { sendToAllNodes(INFINITY_PACKET_SCENE_STATE, &infinityScene, sizeof(infinityScene)); } void sendBeatTrigger() { sendToAllNodes(INFINITY_PACKET_BEAT_TRIGGER, &infinityScene, sizeof(infinityScene)); } void sendNodeStatus() { InfinityStatusPayload status = {}; refreshRuntimeNodeId(); strlcpy(status.nodeId, infinityRuntimeNodeId, sizeof(status.nodeId)); status.uptimeMs = millis(); status.masterOffsetUs = static_cast(masterOffsetUs); status.lastSceneSequence = lastSceneSequence; status.packetsReceived = packetsReceived; status.flags = infinityEnabled ? 0x01 : 0x00; status.activeEffect = lastAppliedEffect; status.activePreset = lastAppliedPreset; sendPacket(infinityMasterIp, INFINITY_PACKET_NODE_STATUS, &status, sizeof(status)); } void storeNodeStatus(const IPAddress& remote, const InfinityStatusPayload& payload) { uint8_t slot = INFINITY_NODE_COUNT; for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) { if (nodeStatuses[i].ip == remote || strncmp(nodeStatuses[i].nodeId, payload.nodeId, sizeof(nodeStatuses[i].nodeId)) == 0) { slot = i; break; } } if (slot == INFINITY_NODE_COUNT) { for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) { if (nodeStatuses[i].lastSeenMs == 0) { slot = i; break; } } } if (slot == INFINITY_NODE_COUNT) return; nodeStatuses[slot].ip = remote; strlcpy(nodeStatuses[slot].nodeId, payload.nodeId, sizeof(nodeStatuses[slot].nodeId)); nodeStatuses[slot].lastSeenMs = millis(); nodeStatuses[slot].uptimeMs = payload.uptimeMs; nodeStatuses[slot].masterOffsetUs = payload.masterOffsetUs; nodeStatuses[slot].lastSceneSequence = payload.lastSceneSequence; nodeStatuses[slot].packetsReceived = payload.packetsReceived; nodeStatuses[slot].flags = payload.flags; nodeStatuses[slot].activeEffect = payload.activeEffect; nodeStatuses[slot].activePreset = payload.activePreset; } void handleIncomingPacket(const IPAddress& remote, const uint8_t* data, uint16_t len) { if (len < sizeof(InfinityPacketHeader)) return; const InfinityPacketHeader* header = reinterpret_cast(data); if (header->magic != INFINITY_MAGIC || header->version != INFINITY_VERSION) return; if (sizeof(InfinityPacketHeader) + header->payloadSize > len) return; const uint8_t* payload = data + sizeof(InfinityPacketHeader); packetsReceived++; if (isNodeBuild() && (header->type == INFINITY_PACKET_CLOCK_SYNC || header->type == INFINITY_PACKET_SCENE_STATE || header->type == INFINITY_PACKET_BEAT_TRIGGER)) { masterOffsetUs = static_cast(header->masterTimeUs) - static_cast(localTimeUs()); } switch (header->type) { case INFINITY_PACKET_CLOCK_SYNC: updateStripTimebase(infinityScene.phase); break; case INFINITY_PACKET_SCENE_STATE: if (isNodeBuild() && header->payloadSize >= INFINITY_SCENE_PAYLOAD_LEGACY_SIZE && header->payloadSize <= sizeof(InfinityScenePayload)) { pendingScene = infinityScene; memcpy(&pendingScene, payload, header->payloadSize); hasPendingScene = true; lastSceneSequence = header->sequence; } break; case INFINITY_PACKET_BEAT_TRIGGER: if (isNodeBuild()) { strip.timebase = -static_cast(millis()); for (uint8_t i = 0; i < strip.getSegmentsNum(); i++) strip.getSegment(i).markForReset(); } break; case INFINITY_PACKET_NODE_STATUS: if (isMasterBuild() && header->payloadSize == sizeof(InfinityStatusPayload)) { InfinityStatusPayload status; memcpy(&status, payload, sizeof(status)); storeNodeStatus(remote, status); } break; default: break; } } void receivePackets() { if (!infinityUdpStarted || !infinityEnabled) return; for (uint8_t i = 0; i < 6; i++) { const int packetSize = infinityUdp.parsePacket(); if (packetSize <= 0) return; if (packetSize > 256) { while (infinityUdp.available()) infinityUdp.read(); continue; } uint8_t buffer[256]; const int len = infinityUdp.read(buffer, sizeof(buffer)); if (len > 0) handleIncomingPacket(infinityUdp.remoteIP(), buffer, static_cast(len)); } } uint8_t jsonU8(JsonVariant value, uint8_t current) { if (value.isNull()) return current; int v = value.as(); if (v < 0) v = 0; if (v > 255) v = 255; return static_cast(v); } uint16_t jsonU16(JsonVariant value, uint16_t current) { if (value.isNull()) return current; long v = value.as(); if (v < 0) v = 0; if (v > 65535) v = 65535; return static_cast(v); } uint32_t jsonU32(JsonVariant value, uint32_t current) { if (value.isNull()) return current; long v = value.as(); if (v < 0) v = 0; return static_cast(v); } void readRgb(JsonVariant value, uint8_t rgb[3]) { if (!value.is()) return; JsonArray arr = value.as(); for (uint8_t i = 0; i < 3 && i < arr.size(); i++) rgb[i] = jsonU8(arr[i], rgb[i]); } void writeRgb(JsonArray arr, const uint8_t rgb[3]) { arr.add(rgb[0]); arr.add(rgb[1]); arr.add(rgb[2]); } void serializeCustomColors(JsonArray arr) { for (uint8_t i = 0; i < infinityCustomColorCount; i++) { JsonArray color = arr.createNestedArray(); writeRgb(color, infinityCustomColors[i]); } } void deserializeCustomColors(JsonVariant value) { if (!value.is()) return; JsonArray arr = value.as(); uint8_t count = 0; for (size_t i = 0; i < arr.size() && count < INFINITY_CUSTOM_COLOR_MAX; i++) { if (!arr[i].is()) continue; JsonArray color = arr[i].as(); uint8_t rgb[3] = {0, 0, 0}; readRgb(color, rgb); infinityCustomColors[count][0] = rgb[0]; infinityCustomColors[count][1] = rgb[1]; infinityCustomColors[count][2] = rgb[2]; count++; } infinityCustomColorCount = count; } void serializeNodeIps(JsonArray arr) { for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) arr.add(ipToString(infinityNodeIps[i])); } void deserializeNodeIps(JsonVariant value) { if (!value.is()) return; JsonArray arr = value.as(); for (uint8_t i = 0; i < INFINITY_NODE_COUNT && i < arr.size(); i++) { if (!arr[i].is()) continue; IPAddress parsed; if (parsed.fromString(arr[i].as())) infinityNodeIps[i] = parsed; } } void serializeScene(JsonObject scene) { scene["effect"] = infinityScene.effectId; scene["preset"] = infinityScene.presetId; scene["brightness"] = infinityScene.brightness; scene["speed"] = infinityScene.speed; scene["intensity"] = infinityScene.intensity; scene["palette"] = infinityScene.palette; writeRgb(scene.createNestedArray("primary"), infinityScene.primary); writeRgb(scene.createNestedArray("secondary"), infinityScene.secondary); writeRgb(scene.createNestedArray("tertiary"), infinityScene.tertiary); scene["group_mask"] = infinityScene.groupMask; scene["direction"] = infinityScene.direction; scene["flags"] = infinityScene.flags; scene["transition_ms"] = infinityScene.transitionMs; scene["seed"] = infinityScene.seed; scene["phase"] = infinityScene.phase; scene["apply_at_us"] = static_cast(infinityScene.applyAtUs & 0xFFFFFFFFUL); JsonArray rows = scene.createNestedArray("row_dimmer"); rows.add(infinityScene.rowDimmer[0]); rows.add(infinityScene.rowDimmer[1]); rows.add(infinityScene.rowDimmer[2]); scene["safety_fade"] = infinityScene.safetyFade; JsonObject spatial = scene.createNestedObject("spatial"); spatial["mode"] = infinityScene.spatialMode; spatial["strength"] = infinityScene.spatialStrength; spatial["variant"] = infinityScene.spatialVariant; spatial["direction"] = infinityScene.spatialDirection; spatial["size"] = infinityScene.spatialSize; spatial["angle"] = infinityScene.spatialAngle; spatial["option"] = infinityScene.spatialOption; } void deserializeScene(JsonObject scene) { if (scene.isNull()) return; infinityScene.effectId = rfpEffectSanitize(jsonU8(scene["effect"], infinityScene.effectId)); infinityScene.presetId = jsonU8(scene["preset"], infinityScene.presetId); infinityScene.brightness = jsonU8(scene["brightness"], infinityScene.brightness); infinityScene.speed = jsonU8(scene["speed"], infinityScene.speed); infinityScene.intensity = jsonU8(scene["intensity"], infinityScene.intensity); infinityScene.palette = jsonU8(scene["palette"], infinityScene.palette); readRgb(scene["primary"], infinityScene.primary); readRgb(scene["secondary"], infinityScene.secondary); readRgb(scene["tertiary"], infinityScene.tertiary); infinityScene.groupMask = jsonU8(scene["group_mask"], infinityScene.groupMask) & 0x07; if (infinityScene.groupMask == 0) infinityScene.groupMask = 0x07; infinityScene.direction = jsonU8(scene["direction"], infinityScene.direction); infinityScene.flags = jsonU8(scene["flags"], infinityScene.flags); infinityScene.transitionMs = jsonU16(scene["transition_ms"], infinityScene.transitionMs); infinityScene.seed = jsonU32(scene["seed"], infinityScene.seed); infinityScene.phase = jsonU16(scene["phase"], infinityScene.phase); if (scene["row_dimmer"].is()) { JsonArray rows = scene["row_dimmer"].as(); for (uint8_t i = 0; i < 3 && i < rows.size(); i++) infinityScene.rowDimmer[i] = jsonU8(rows[i], infinityScene.rowDimmer[i]); } infinityScene.safetyFade = jsonU8(scene["safety_fade"], infinityScene.safetyFade); if (scene["spatial"].is()) { JsonObject spatial = scene["spatial"].as(); infinityScene.spatialMode = jsonU8(spatial["mode"], infinityScene.spatialMode); infinityScene.spatialStrength = jsonU8(spatial["strength"], infinityScene.spatialStrength); infinityScene.spatialVariant = jsonU8(spatial["variant"], infinityScene.spatialVariant); infinityScene.spatialDirection = jsonU8(spatial["direction"], infinityScene.spatialDirection); infinityScene.spatialSize = jsonU8(spatial["size"], infinityScene.spatialSize); infinityScene.spatialAngle = jsonU16(spatial["angle"], infinityScene.spatialAngle); infinityScene.spatialOption = jsonU8(spatial["option"], infinityScene.spatialOption); } else { infinityScene.spatialMode = jsonU8(scene["spatial_mode"], infinityScene.spatialMode); infinityScene.spatialStrength = jsonU8(scene["spatial_strength"], infinityScene.spatialStrength); infinityScene.spatialVariant = jsonU8(scene["spatial_variant"], infinityScene.spatialVariant); infinityScene.spatialDirection = jsonU8(scene["spatial_direction"], infinityScene.spatialDirection); infinityScene.spatialSize = jsonU8(scene["spatial_size"], infinityScene.spatialSize); infinityScene.spatialAngle = jsonU16(scene["spatial_angle"], infinityScene.spatialAngle); infinityScene.spatialOption = jsonU8(scene["spatial_option"], infinityScene.spatialOption); } if (infinityScene.spatialMode > INFINITY_SPATIAL_SUNBURST) infinityScene.spatialMode = INFINITY_SPATIAL_OFF; if (infinityScene.spatialVariant > INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE) infinityScene.spatialVariant = INFINITY_SPATIAL_VARIANT_EXPAND; if (infinityScene.spatialDirection > INFINITY_SPATIAL_DIRECTION_PINGPONG) infinityScene.spatialDirection = INFINITY_SPATIAL_DIRECTION_LTR; if (infinityScene.spatialAngle >= 360) infinityScene.spatialAngle %= 360; infinityScene.spatialOption = normalizeSpatialOption(infinityScene.spatialMode, infinityScene.spatialOption); markSceneChanged(); } } // namespace void infinityInit() { refreshRuntimeNodeId(); if (infinityScene.applyAtUs == 0) infinityScene.applyAtUs = masterTimeUs() + INFINITY_APPLY_DELAY_US; } void infinityNetworkBegin() { if (!infinityEnabled || infinityPort == 0) return; if (infinityUdpStarted) infinityUdp.stop(); infinityUdpStarted = infinityUdp.begin(infinityPort); refreshRuntimeNodeId(); USER_PRINTF("Infinity Sync %s on UDP %u as %s\n", infinityUdpStarted ? "started" : "failed", infinityPort, infinityRuntimeNodeId); } void infinityLoop() { refreshRuntimeNodeId(); receivePackets(); maybeApplyPendingScene(); const uint32_t now = millis(); if (isMasterBuild()) { if (now - lastClockSentMs >= INFINITY_CLOCK_INTERVAL_MS) { lastClockSentMs = now; sendClockSync(); } if (now - lastSceneSentMs >= INFINITY_SCENE_INTERVAL_MS) { lastSceneSentMs = now; sendSceneState(); } } else if (isNodeBuild()) { if (now - lastStatusSentMs >= INFINITY_STATUS_INTERVAL_MS) { lastStatusSentMs = now; sendNodeStatus(); } } } void infinityPostStripInit() { if (!isNodeBuild()) return; if (busses.getNumBusses() < 3 || strip.getLengthTotal() != INFINITY_LEDS_PER_NODE) return; bool expectedBusses = true; for (uint8_t i = 0; i < 3; i++) { Bus* bus = busses.getBus(i); if (bus == nullptr || bus->getLength() != INFINITY_LEDS_PER_PANEL || bus->getStart() != i * INFINITY_LEDS_PER_PANEL) { expectedBusses = false; break; } } if (!expectedBusses) return; if (esp32SemTake(segmentMux, 2100) != pdTRUE) return; strip._segments.clear(); strip._segments.reserve(3); strip._segments.push_back(Segment(0, 106)); strip._segments.push_back(Segment(106, 212)); strip._segments.push_back(Segment(212, 318)); setSegmentName(strip._segments[0], "top"); setSegmentName(strip._segments[1], "middle"); setSegmentName(strip._segments[2], "bottom"); for (segment& seg : strip._segments) seg.refreshLightCapabilities(); strip.setMainSegmentId(0); esp32SemGive(segmentMux); } void infinityApplyDmx(uint8_t* data, uint16_t availableChannels) { if (!isMasterBuild() || data == nullptr || availableChannels < 1) return; uint8_t ch[INFINITY_DMX_FOOTPRINT] = {0}; const uint16_t count = availableChannels < INFINITY_DMX_FOOTPRINT ? availableChannels : INFINITY_DMX_FOOTPRINT; for (uint16_t i = 0; i < count; i++) ch[i] = data[i]; infinityScene.brightness = ch[0]; if (ch[1] == 0) infinityScene.flags &= ~0x01; else infinityScene.flags |= 0x01; infinityScene.presetId = ch[2]; infinityScene.effectId = rfpEffectFromDmx(ch[3]); infinityScene.speed = ch[4]; infinityScene.intensity = ch[5]; infinityScene.palette = ch[6]; hsvToRgb(ch[7], ch[8], ch[9], infinityScene.primary); infinityScene.direction = ch[10]; infinityScene.transitionMs = dmxTransitionMs(ch[11]); infinityScene.groupMask = ch[12] ? (ch[12] & 0x07) : 0x07; infinityScene.rowDimmer[0] = ch[13]; infinityScene.rowDimmer[1] = ch[14]; infinityScene.rowDimmer[2] = ch[15]; infinityScene.custom[0] = ch[18]; infinityScene.custom[1] = ch[19]; infinityScene.custom[2] = ch[20]; infinityScene.seed = static_cast(ch[21]) * 0x01010101UL; infinityScene.reserved0 = 0; infinityScene.safetyFade = ch[23]; infinityScene.spatialMode = map(ch[24], 0, 255, 0, INFINITY_SPATIAL_SUNBURST); infinityScene.spatialVariant = map(ch[25], 0, 255, 0, INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE); infinityScene.spatialDirection = map(ch[26], 0, 255, 0, INFINITY_SPATIAL_DIRECTION_PINGPONG); // DMX channel 27 is intentionally unused; Global-2D blend was removed. infinityScene.spatialStrength = ch[28]; infinityScene.spatialSize = ch[29]; infinityScene.spatialAngle = map(ch[30], 0, 255, 0, 359); if (infinityScene.spatialMode == INFINITY_SPATIAL_SUNBURST) { infinityScene.spatialOption = map(ch[31], 0, 255, 0, INFINITY_SPATIAL_OPTION_RAINBOW); } else if (infinityScene.spatialMode == INFINITY_SPATIAL_SCAN) { infinityScene.spatialOption = ch[31] > 127 ? INFINITY_SPATIAL_OPTION_BANDS : INFINITY_SPATIAL_OPTION_LINE; } else { infinityScene.spatialOption = INFINITY_SPATIAL_OPTION_LINE; } infinityScene.spatialOption = normalizeSpatialOption(infinityScene.spatialMode, infinityScene.spatialOption); markSceneChanged(); if (ch[16] > 127 && lastDmxBeatValue <= 127) sendBeatTrigger(); if (ch[17] > 127 && lastDmxSyncResetValue <= 127) { masterOffsetUs = 0; infinityScene.phase = 0; markSceneChanged(50000); sendClockSync(); } lastDmxBeatValue = ch[16]; lastDmxSyncResetValue = ch[17]; } void infinityHandleOverlayDraw() { if (!infinityEnabled || !isNodeBuild()) return; if (infinityScene.spatialMode == INFINITY_SPATIAL_OFF) return; const bool directSpatialMode = infinityScene.spatialMode == INFINITY_SPATIAL_SNAKE || (infinityScene.spatialMode == INFINITY_SPATIAL_SUNBURST && infinityScene.spatialOption != INFINITY_SPATIAL_OPTION_LINE); if (infinityScene.spatialStrength == 0 && !directSpatialMode) return; if (strip.getSegmentsNum() < INFINITY_PANEL_COUNT || strip.getLengthTotal() < INFINITY_LEDS_PER_NODE) return; uint8_t column = 0; parseNodeColumn(column); const uint64_t nowUs = masterTimeUs() + (static_cast(infinityScene.phase) * 1000ULL); for (uint8_t row = 0; row < INFINITY_PANEL_COUNT; row++) { Segment& seg = strip.getSegment(row); if (!seg.isActive()) continue; const uint16_t len = min(seg.length(), INFINITY_LEDS_PER_PANEL); for (uint16_t led = 0; led < len; led++) { const SpatialSample sample = spatialSample(column, row, led, nowUs); if (sample.amount == 0 || sample.role == SPATIAL_ROLE_BLACK) { seg.setPixelColor(led, BLACK); continue; } const uint32_t layer = spatialLayerColor(seg, led, sample); uint8_t exactRgb[3] = {0, 0, 0}; if (spatialExactRgb(sample, exactRgb)) { if (sample.amount == 255) { seg.setPixelColor(led, exactRgb[0], exactRgb[1], exactRgb[2], 0); } else { seg.setPixelColor(led, scaleByte(exactRgb[0], sample.amount), scaleByte(exactRgb[1], sample.amount), scaleByte(exactRgb[2], sample.amount), 0); } continue; } const uint32_t base = seg.getPixelColor(led); seg.setPixelColor(led, composeSpatialColor(base, layer, sample.amount, infinityScene.spatialStrength, sample.role)); } } } void infinitySerializeJson(JsonObject root) { refreshRuntimeNodeId(); root["enabled"] = infinityEnabled; root["role"] = isMasterBuild() ? "master" : "node"; root["node_id"] = infinityRuntimeNodeId; root["configured_node_id"] = infinityConfiguredNodeId; root["port"] = infinityPort; root["udp_started"] = infinityUdpStarted; root["master_ip"] = ipToString(infinityMasterIp); root["master_time_us"] = static_cast(masterTimeUs() & 0xFFFFFFFFUL); root["master_offset_us"] = static_cast(masterOffsetUs); root["packets_received"] = packetsReceived; root["packets_sent"] = packetsSent; serializeNodeIps(root.createNestedArray("node_ips")); serializeCustomColors(root.createNestedArray("custom_colors")); JsonObject scene = root.createNestedObject("scene"); serializeScene(scene); JsonArray nodes = root.createNestedArray("nodes"); const uint32_t now = millis(); for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) { JsonObject node = nodes.createNestedObject(); node["expected_ip"] = ipToString(infinityNodeIps[i]); node["ip"] = nodeStatuses[i].lastSeenMs ? ipToString(nodeStatuses[i].ip) : ""; node["node_id"] = nodeStatuses[i].nodeId; node["online"] = nodeStatuses[i].lastSeenMs && (now - nodeStatuses[i].lastSeenMs <= INFINITY_NODE_TIMEOUT_MS); node["last_seen_ms"] = nodeStatuses[i].lastSeenMs ? now - nodeStatuses[i].lastSeenMs : 0; node["uptime_ms"] = nodeStatuses[i].uptimeMs; node["master_offset_us"] = nodeStatuses[i].masterOffsetUs; node["scene_sequence"] = nodeStatuses[i].lastSceneSequence; node["packets_received"] = nodeStatuses[i].packetsReceived; node["effect"] = nodeStatuses[i].activeEffect; node["preset"] = nodeStatuses[i].activePreset; } } bool infinityDeserializeJson(JsonObject root) { if (root.isNull()) return false; const bool hadSceneObject = root["scene"].is(); const bool hadNodeIps = !root["node_ips"].isNull(); const bool hasRootScenePayload = !root["effect"].isNull() || !root["preset"].isNull() || !root["brightness"].isNull() || !root["speed"].isNull() || !root["intensity"].isNull() || !root["palette"].isNull() || !root["primary"].isNull() || !root["secondary"].isNull() || !root["tertiary"].isNull() || !root["group_mask"].isNull() || !root["direction"].isNull() || !root["flags"].isNull() || !root["transition_ms"].isNull() || !root["seed"].isNull() || !root["phase"].isNull() || !root["row_dimmer"].isNull() || !root["safety_fade"].isNull() || !root["spatial"].isNull() || !root["spatial_mode"].isNull(); const bool wasEnabled = infinityEnabled; if (!root["enabled"].isNull()) infinityEnabled = root["enabled"].as(); if (root["node_id"].is()) copyNodeId(infinityConfiguredNodeId, sizeof(infinityConfiguredNodeId), root["node_id"].as()); if (root["master_ip"].is()) { IPAddress parsed; if (parsed.fromString(root["master_ip"].as())) infinityMasterIp = parsed; } if (!root["port"].isNull()) { uint16_t newPort = jsonU16(root["port"], infinityPort); if (newPort != infinityPort && newPort > 0) { infinityPort = newPort; infinityNetworkBegin(); } } deserializeNodeIps(root["node_ips"]); deserializeCustomColors(root["custom_colors"]); if (wasEnabled != infinityEnabled) { if (infinityEnabled) { infinityNetworkBegin(); } else if (infinityUdpStarted) { infinityUdp.stop(); infinityUdpStarted = false; } } if (hadSceneObject || hasRootScenePayload) { JsonObject scene = hadSceneObject ? root["scene"].as() : root; deserializeScene(scene); } if (isMasterBuild() && infinityUdpStarted) { sendClockSync(); if (hadSceneObject || hasRootScenePayload || hadNodeIps) sendSceneState(); } doSerializeConfig = true; return true; } void infinityDeserializeConfig(JsonObject interfaces) { JsonObject cfg = interfaces["infinity"]; if (cfg.isNull()) return; if (!cfg["enabled"].isNull()) infinityEnabled = cfg["enabled"].as(); if (!cfg["port"].isNull()) infinityPort = jsonU16(cfg["port"], infinityPort); if (cfg["node_id"].is()) copyNodeId(infinityConfiguredNodeId, sizeof(infinityConfiguredNodeId), cfg["node_id"].as()); if (cfg["master_ip"].is()) { IPAddress parsed; if (parsed.fromString(cfg["master_ip"].as())) infinityMasterIp = parsed; } deserializeNodeIps(cfg["node_ips"]); deserializeCustomColors(cfg["custom_colors"]); } void infinitySerializeConfig(JsonObject interfaces) { JsonObject cfg = interfaces.createNestedObject("infinity"); cfg["enabled"] = infinityEnabled; cfg["role"] = isMasterBuild() ? "master" : "node"; cfg["node_id"] = infinityConfiguredNodeId; cfg["master_ip"] = ipToString(infinityMasterIp); cfg["port"] = infinityPort; serializeNodeIps(cfg.createNestedArray("node_ips")); serializeCustomColors(cfg.createNestedArray("custom_colors")); } void serveInfinityJson(AsyncWebServerRequest* request) { AsyncJsonResponse* response = new AsyncJsonResponse(6144, false); JsonObject root = response->getRoot().as(); infinitySerializeJson(root); response->setLength(); request->send(response); } void serveInfinityPage(AsyncWebServerRequest* request) { static const char PAGE_infinity[] PROGMEM = R"rawliteral( Infinity Controller

Infinity Controller

WLED-style master surface for your installation

WLED UI Sync

Master

Loading master status…

Sync

Checking node target layout…

Scene

Output Enabled
Master sends scene state, nodes render locally on apply time.

Global 2D

Reverse Direction
Maps to the Infinity direction flag.

Colors

RFP Main PaletteWipe-Random-Farben plus deine dauerhaft gespeicherten Custom-Farben.
Primary

Nodes

Node Targets

These are the unicast destinations the master sends Infinity Sync to.

Usage

This page complements WLED. Use the regular WLED UI for generic device settings, Wi-Fi and firmware basics.

Ready. Scene changes apply automatically.
)rawliteral"; request->send_P(200, "text/html", PAGE_infinity); } #endif // WLED_ENABLE_INFINITY_CONTROLLER