Files
WLED_MM_Infinity/wled00/infinity_sync.cpp
jan 4bc4e1257e
Some checks failed
WLED CI / wled_build (push) Has been cancelled
Deploy Nightly / wled_build (push) Has been cancelled
Deploy Nightly / Deploy nightly (push) Has been cancelled
Backup RFP Infinity controller state before Resolume changes
2026-05-14 12:31:13 +02:00

2280 lines
99 KiB
C++

#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<uint64_t>(esp_timer_get_time());
#else
return static_cast<uint64_t>(micros());
#endif
}
uint64_t masterTimeUs() {
if (isMasterBuild()) return localTimeUs();
return static_cast<uint64_t>(static_cast<int64_t>(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<uint16_t>(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<uint16_t>(hue) * 257U, sat, temp);
rgb[0] = (static_cast<uint16_t>(temp[0]) * value) / 255U;
rgb[1] = (static_cast<uint16_t>(temp[1]) * value) / 255U;
rgb[2] = (static_cast<uint16_t>(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<uint16_t>(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<uint8_t>(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<uint16_t>(speed) * range + 127U) / 255U);
}
float spatialBeatPosition(uint64_t timeUs, uint8_t speed) {
const float seconds = static_cast<float>(timeUs % 60000000ULL) / 1000000.0f;
const float cyclesPerSecond = static_cast<float>(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<uint32_t>(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<uint32_t>(floorf(spatialStepPosition(timeUs, speed)));
}
uint8_t strobeAmount(uint64_t timeUs, uint8_t speed, uint8_t pulseWidth) {
const float duty = 0.01f + (static_cast<float>(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<uint8_t>(1, size / 16);
return min<uint8_t>(18, 4 + ((static_cast<uint16_t>(size - 64) * 14U + 95U) / 191U));
}
uint8_t snakeLengthFromSize(uint8_t size) {
return min<uint8_t>(17, 3 + max<uint8_t>(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<uint32_t>(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<uint32_t>(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<int8_t>(gridColumn(a)) - static_cast<int8_t>(gridColumn(b));
const int8_t dy = static_cast<int8_t>(gridRow(a)) - static_cast<int8_t>(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<uint32_t>(epoch) * 0x45d9f3bUL)) % (INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT);
length = min<uint8_t>(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<uint32_t>(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<uint8_t>(targetLength, length + 1) : length;
for (int8_t i = static_cast<int8_t>(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<uint16_t>(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<uint16_t>(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<float>(column) * pitch + lx;
y = static_cast<float>(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<float>(column) * pitch + lx;
y = static_cast<float>(row) * pitch + ly;
}
void physicalPanelCenter(float& cx, float& cy) {
const float pitch = 1.0f + INFINITY_PANEL_GAP_RATIO;
cx = ((static_cast<float>(INFINITY_NODE_COUNT - 1) * pitch) + 1.0f) * 0.5f;
cy = ((static_cast<float>(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<float>(row) - centerRow) + fabsf(static_cast<float>(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<uint16_t>(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<int32_t>(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<uint8_t>(roundf(triangle[((static_cast<int32_t>(row) - phase) % 4 + 4) % 4] * ((INFINITY_NODE_COUNT - 1) / 2.0f) / 2.0f));
return column == min<uint8_t>(target, INFINITY_NODE_COUNT - 1) ? 255 : 0;
}
const int32_t phase = direction == INFINITY_SPATIAL_DIRECTION_RTL ? -step : step;
const uint8_t target = static_cast<uint8_t>(roundf(triangle[((static_cast<int32_t>(column) - phase) % 4 + 4) % 4] * ((INFINITY_PANEL_COUNT - 1) / 2.0f)));
return row == min<uint8_t>(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<uint8_t>(1, 1 + (size / 86)) - 1;
const uint8_t span = 3 + gap;
const int32_t movement = static_cast<int32_t>(spatialStepIndex(timeUs, speed));
const float middleMinor = (static_cast<float>(minorCount) - 1.0f) / 2.0f;
const uint8_t band = fabsf(static_cast<float>(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<int32_t>(major) - movement)
: (static_cast<int32_t>(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<float>(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<float>(column) + x) * vx + (static_cast<float>(row) + y) * vy;
const float p00 = 0.0f;
const float p10 = static_cast<float>(INFINITY_NODE_COUNT) * vx;
const float p01 = static_cast<float>(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<float>(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<uint8_t>(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<uint8_t>((angle / INFINITY_TAU) * 255.0f) + static_cast<uint8_t>(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<uint32_t>(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<int64_t>(masterTimeUs() / 1000ULL) + phase;
strip.timebase = static_cast<int32_t>(remoteMs - static_cast<int64_t>(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<uint8_t>(type);
header.payloadSize = payloadSize;
header.sequence = ++infinitySequence;
header.masterTimeUs = masterTimeUs();
if (infinityUdp.beginPacket(target, infinityPort) == 0) return false;
infinityUdp.write(reinterpret_cast<const uint8_t*>(&header), sizeof(header));
if (payload != nullptr && payloadSize > 0) {
infinityUdp.write(reinterpret_cast<const uint8_t*>(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<int32_t>(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<const InfinityPacketHeader*>(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<int64_t>(header->masterTimeUs) - static_cast<int64_t>(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<int32_t>(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<uint16_t>(len));
}
}
uint8_t jsonU8(JsonVariant value, uint8_t current) {
if (value.isNull()) return current;
int v = value.as<int>();
if (v < 0) v = 0;
if (v > 255) v = 255;
return static_cast<uint8_t>(v);
}
uint16_t jsonU16(JsonVariant value, uint16_t current) {
if (value.isNull()) return current;
long v = value.as<long>();
if (v < 0) v = 0;
if (v > 65535) v = 65535;
return static_cast<uint16_t>(v);
}
uint32_t jsonU32(JsonVariant value, uint32_t current) {
if (value.isNull()) return current;
long v = value.as<long>();
if (v < 0) v = 0;
return static_cast<uint32_t>(v);
}
void readRgb(JsonVariant value, uint8_t rgb[3]) {
if (!value.is<JsonArray>()) return;
JsonArray arr = value.as<JsonArray>();
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<JsonArray>()) return;
JsonArray arr = value.as<JsonArray>();
uint8_t count = 0;
for (size_t i = 0; i < arr.size() && count < INFINITY_CUSTOM_COLOR_MAX; i++) {
if (!arr[i].is<JsonArray>()) continue;
JsonArray color = arr[i].as<JsonArray>();
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<JsonArray>()) return;
JsonArray arr = value.as<JsonArray>();
for (uint8_t i = 0; i < INFINITY_NODE_COUNT && i < arr.size(); i++) {
if (!arr[i].is<const char*>()) continue;
IPAddress parsed;
if (parsed.fromString(arr[i].as<const char*>())) 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<uint32_t>(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>()) {
JsonArray rows = scene["row_dimmer"].as<JsonArray>();
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>()) {
JsonObject spatial = scene["spatial"].as<JsonObject>();
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<uint32_t>(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<uint64_t>(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<uint16_t>(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<uint32_t>(masterTimeUs() & 0xFFFFFFFFUL);
root["master_offset_us"] = static_cast<int32_t>(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<JsonObject>();
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<bool>();
if (root["node_id"].is<const char*>()) copyNodeId(infinityConfiguredNodeId, sizeof(infinityConfiguredNodeId), root["node_id"].as<const char*>());
if (root["master_ip"].is<const char*>()) {
IPAddress parsed;
if (parsed.fromString(root["master_ip"].as<const char*>())) 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<JsonObject>() : 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<bool>();
if (!cfg["port"].isNull()) infinityPort = jsonU16(cfg["port"], infinityPort);
if (cfg["node_id"].is<const char*>()) copyNodeId(infinityConfiguredNodeId, sizeof(infinityConfiguredNodeId), cfg["node_id"].as<const char*>());
if (cfg["master_ip"].is<const char*>()) {
IPAddress parsed;
if (parsed.fromString(cfg["master_ip"].as<const char*>())) 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<JsonObject>();
infinitySerializeJson(root);
response->setLength();
request->send(response);
}
void serveInfinityPage(AsyncWebServerRequest* request) {
static const char PAGE_infinity[] PROGMEM = R"rawliteral(
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Infinity Controller</title>
<style>
:root{--bg:#111318;--panel:#1a1d23;--panel-2:#22262d;--line:#303640;--text:#f1f3f5;--muted:#a8b0bd;--accent:#4da3ff;--accent-2:#88c3ff;--good:#3ddc84;--warn:#ffb74d;--bad:#ff5c7a;--btn:#2b313a;--btn-hi:#4da3ff;--shadow:0 10px 30px rgba(0,0,0,.32);}
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}body{min-height:100vh}
a{color:inherit;text-decoration:none}button,input,select{font:inherit}main{width:min(1120px,100%);margin:0 auto;padding:18px 14px 120px}
.topbar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:4px 0 14px}.brand{display:flex;align-items:center;gap:12px}.logo{width:42px;height:42px;border-radius:14px;background:linear-gradient(135deg,#4da3ff,#7f89ff);display:grid;place-items:center;color:#fff;font-weight:800;box-shadow:var(--shadow)}h1{margin:0;font-size:1.55rem;letter-spacing:.02em}.sub{margin:2px 0 0;color:var(--muted);font-size:.92rem}
.actions{display:flex;gap:8px;flex-wrap:wrap}.btn{border:1px solid var(--line);background:var(--btn);color:var(--text);padding:10px 14px;border-radius:14px;font-weight:600}.btn.primary{background:linear-gradient(180deg,#56abff,#3d8df2);border-color:#4da3ff;color:#fff}.btn.ghost{background:transparent}.btn.good{background:rgba(61,220,132,.16);border-color:rgba(61,220,132,.45);color:#d9ffea}.btn.warn{background:rgba(255,183,77,.16);border-color:rgba(255,183,77,.45);color:#ffe4b5}
.hero{display:grid;grid-template-columns:1.35fr .95fr;gap:12px;margin-bottom:12px}.panel{background:linear-gradient(180deg,var(--panel),var(--panel-2));border:1px solid var(--line);border-radius:22px;padding:16px;box-shadow:var(--shadow)}.panel h2{margin:0 0 12px;font-size:1.05rem}
.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.metric{border:1px solid rgba(255,255,255,.04);border-radius:18px;background:rgba(255,255,255,.02);padding:12px 14px}.metric .k{display:block;color:var(--muted);font-size:.78rem;text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}.metric .v{display:block;font-size:1rem;font-weight:700}.metric .v.small{font-size:.9rem;font-weight:600}
.notice{margin-top:12px;padding:12px 14px;border-radius:16px;background:rgba(77,163,255,.08);border:1px solid rgba(77,163,255,.18);color:#d9ebff}.notice.warn{background:rgba(255,183,77,.08);border-color:rgba(255,183,77,.2);color:#ffe4b5}.notice.good{background:rgba(61,220,132,.08);border-color:rgba(61,220,132,.2);color:#d9ffea}.notice.bad{background:rgba(255,92,122,.08);border-color:rgba(255,92,122,.2);color:#ffd7df}
.layout{display:grid;grid-template-columns:1.2fr .8fr;gap:12px}.stack{display:grid;gap:12px}
.control-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.field{display:grid;gap:8px}.field label{display:flex;justify-content:space-between;gap:10px;font-size:.9rem;color:var(--muted)}.pill{padding:2px 8px;border-radius:999px;background:rgba(255,255,255,.06);color:var(--text);font-size:.85rem}
input[type=range]{width:100%;accent-color:var(--accent)}input[type=color]{width:100%;height:44px;padding:0;border-radius:14px;border:1px solid var(--line);background:#000}input[type=text],input[type=number],select{width:100%;padding:11px 12px;border-radius:14px;border:1px solid var(--line);background:#101319;color:var(--text)}
select:disabled,input:disabled{opacity:.48}.hidden{display:none!important}
.switch{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 14px;border-radius:18px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.05)}.switch input{width:22px;height:22px}
.color-grid,.target-grid,.nodes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.target-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.nodes{grid-template-columns:repeat(2,minmax(0,1fr))}
.color-slot{border:1px solid rgba(255,255,255,.08);border-radius:18px;padding:10px;background:rgba(255,255,255,.025);cursor:pointer}.color-slot.active{outline:2px solid var(--accent);background:rgba(77,163,255,.12)}.color-slot input{cursor:pointer}
.palette-panel{margin-top:12px;border:1px solid rgba(255,255,255,.06);border-radius:18px;padding:12px;background:rgba(0,0,0,.14)}.palette-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:10px}.palette-head strong{display:block}.palette-head span{display:block;color:var(--muted);font-size:.86rem;margin-top:2px}
.swatch-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(34px,1fr));gap:8px}.swatch{height:34px;border-radius:10px;border:1px solid rgba(255,255,255,.2);box-shadow:inset 0 0 0 1px rgba(0,0,0,.35);cursor:pointer}.swatch:hover,.swatch.selected{outline:2px solid #fff;outline-offset:1px}.swatch.custom{border-color:rgba(77,163,255,.7)}
.color-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}.color-actions .btn{padding:8px 10px;border-radius:12px}
.node{border:1px solid rgba(255,255,255,.05);border-radius:18px;padding:12px 13px;background:rgba(0,0,0,.18)}.node.online{outline:2px solid rgba(61,220,132,.45)}.node.offline{opacity:.88}.node h3{margin:0 0 8px;font-size:1rem}.node p{margin:4px 0;color:var(--muted);font-size:.88rem}
.toolbar{position:sticky;bottom:14px;display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;margin-top:12px}
@media (max-width:900px){.hero,.layout{grid-template-columns:1fr}.nodes,.color-grid{grid-template-columns:1fr 1fr}}
@media (max-width:620px){main{padding:14px 12px 120px}.topbar{flex-direction:column;align-items:flex-start}.actions{width:100%}.actions .btn{flex:1 1 auto;text-align:center}.metrics,.control-grid,.target-grid,.nodes,.color-grid{grid-template-columns:1fr}.panel{padding:14px;border-radius:20px}.toolbar{position:fixed;left:12px;right:12px;bottom:12px;margin:0}.toolbar .btn{flex:1 1 auto}}
</style></head>
<body><main>
<div class="topbar">
<div class="brand">
<div class="logo">∞</div>
<div>
<h1>Infinity Controller</h1>
<p class="sub">WLED-style master surface for your installation</p>
</div>
</div>
<div class="actions">
<button class="btn" id="modeToggle" type="button" onclick="toggleInfinityMode()">Loading mode…</button>
<a class="btn ghost" href="/">WLED UI</a>
<a class="btn ghost" href="/settings/sync">Sync</a>
<button class="btn" type="button" onclick="refreshAll()">Refresh</button>
</div>
</div>
<div class="hero">
<section class="panel">
<h2>Master</h2>
<div class="metrics" id="metrics"></div>
<div class="notice" id="networkNotice">Loading master status…</div>
</section>
<section class="panel">
<h2>Sync</h2>
<div class="metrics" id="syncMetrics"></div>
<div class="notice warn" id="targetNotice">Checking node target layout…</div>
</section>
</div>
<div class="layout">
<div class="stack">
<section class="panel">
<h2>Scene</h2>
<div class="switch">
<div>
<strong>Output Enabled</strong><br>
<span class="sub">Master sends scene state, nodes render locally on apply time.</span>
</div>
<input id="enabled" type="checkbox">
</div>
<div class="control-grid" style="margin-top:12px">
<div class="field"><label for="masterBpm"><span>Master Speed</span><span class="pill" id="masterBpmVal">130 BPM</span></label><input id="masterBpm" type="range" min="20" max="240" step="1" oninput="syncSpeedFromBpm()"></div>
<div class="field"><label for="brightness"><span>Brightness</span><span class="pill" id="brightnessVal">100%</span></label><input id="brightness" type="range" min="0" max="255" oninput="bindBrightnessValue()"></div>
</div>
<input id="speed" type="hidden" value="128">
<h2 style="margin-top:18px">Global 2D</h2>
<div class="control-grid">
<div class="field"><label for="spatialMode"><span>2D Mode</span><span class="pill" id="spatialModeVal">Off</span></label><select id="spatialMode"><option value="0">Off</option><option value="1">Center Pulse</option><option value="2">Checkerd</option><option value="3">Arrow</option><option value="4">Scan</option><option value="5">Snake</option><option value="6">Wave Line</option><option value="7">Strobe</option><option value="8">Schlängeln</option><option value="9">Sunburst</option></select></div>
<div class="field" id="spatialVariantField"><label for="spatialVariant"><span>Variant</span><span class="pill" id="spatialVariantVal">Expand</span></label><select id="spatialVariant"><option value="0">Expand / Classic / Line</option><option value="1">Reverse / Diagonal / Bands</option><option value="2">Outline / Checkerd</option><option value="3">Outline Reverse</option></select></div>
<div class="field" id="spatialDirectionField"><label for="spatialDirection"><span>Direction</span><span class="pill" id="spatialDirectionVal">Left → Right</span></label><select id="spatialDirection"><option value="0">Left → Right</option><option value="1">Right → Left</option><option value="2">Top → Bottom</option><option value="3">Bottom → Top</option><option value="4">Outward</option><option value="5">Inward</option></select></div>
<div class="field" id="spatialStrengthField"><label for="spatialStrength"><span>Strength</span><span class="pill" id="spatialStrengthVal">180</span></label><input id="spatialStrength" type="range" min="0" max="255" oninput="bindRangeValue('spatialStrength')"></div>
<div class="field" id="spatialSizeField"><label for="spatialSize"><span id="spatialSizeLabel">Size</span><span class="pill" id="spatialSizeVal">64</span></label><input id="spatialSize" type="range" min="0" max="255" oninput="bindSpatialSizeValue()"></div>
<div class="field" id="spatialAngleField"><label for="spatialAngle"><span>Scan Angle</span><span class="pill" id="spatialAngleVal">0°</span></label><input id="spatialAngle" type="range" min="0" max="359" oninput="bindRangeValue('spatialAngle','°')"></div>
<div class="field" id="spatialOptionField"><label for="spatialOption"><span>Scan Style</span><span class="pill" id="spatialOptionVal">Line</span></label><select id="spatialOption"><option value="0">Line</option><option value="1">Bands</option></select></div>
</div>
<div class="control-grid" style="margin-top:12px">
<div class="field"><label for="effect"><span>Effect</span><span class="pill" id="effectVal">0</span></label><select id="effect"></select></div>
<div class="field"><label for="transition"><span>Transition</span><span class="pill" id="transitionVal">750 ms</span></label><input id="transition" type="range" min="0" max="4000" step="50" oninput="bindRangeValue('transition',' ms')"></div>
<div class="field"><label for="intensity"><span>Intensity</span><span class="pill" id="intensityVal">128</span></label><input id="intensity" type="range" min="0" max="255" oninput="bindRangeValue('intensity')"></div>
</div>
<div class="switch" style="margin-top:12px">
<div><strong>Reverse Direction</strong><br><span class="sub">Maps to the Infinity direction flag.</span></div>
<input id="reverse" type="checkbox">
</div>
<h2 id="colorsTitle" style="margin-top:18px">Colors</h2>
<div class="color-grid" id="colorsGrid">
<div class="field color-slot active" data-color-slot="primary"><label for="primary">Primary</label><input id="primary" type="color"></div>
<div class="field color-slot" data-color-slot="secondary"><label for="secondary">Secondary</label><input id="secondary" type="color"></div>
<div class="field color-slot" data-color-slot="tertiary"><label for="tertiary">Tertiary</label><input id="tertiary" type="color"></div>
</div>
<div class="palette-panel" id="colorPaletteField">
<div class="palette-head">
<div><strong>RFP Main Palette</strong><span>Wipe-Random-Farben plus deine dauerhaft gespeicherten Custom-Farben.</span></div>
<span class="pill" id="activeColorSlotLabel">Primary</span>
</div>
<div class="swatch-grid" id="swatchGrid"></div>
<div class="color-actions">
<button class="btn" type="button" onclick="addCustomColor()">+ Custom</button>
<button class="btn" type="button" onclick="editSelectedCustomColor()">Edit Custom</button>
<button class="btn" type="button" onclick="deleteSelectedCustomColor()">Delete Custom</button>
</div>
</div>
</section>
<section class="panel">
<h2>Nodes</h2>
<div class="nodes" id="nodes"></div>
</section>
</div>
<div class="stack">
<section class="panel">
<h2>Node Targets</h2>
<p class="sub" style="margin-top:0">These are the unicast destinations the master sends Infinity Sync to.</p>
<div class="target-grid" id="targets"></div>
</section>
<section class="panel">
<h2>Usage</h2>
<p class="sub" style="margin-top:0">This page complements WLED. Use the regular WLED UI for generic device settings, Wi-Fi and firmware basics.</p>
<div class="metrics">
<div class="metric"><span class="k">Open</span><span class="v small"><a href="/">Main UI</a></span></div>
<div class="metric"><span class="k">JSON</span><span class="v small"><a href="/json/infinity">/json/infinity</a></span></div>
</div>
</section>
</div>
</div>
<div class="toolbar">
<div class="notice" id="actionStatus" style="flex:1 1 320px;margin-top:0">Ready. Scene changes apply automatically.</div>
<button class="btn" type="button" onclick="saveTargets()">Save Targets</button>
</div>
<script>
let state=null, info=null, effects=[], fxdata=[];
let targetsDirty=false, saveInFlight=false, sceneApplyTimer=null, sceneApplyPending=false, sceneRenderLocked=false;
let activeColorSlot='primary', selectedCustomColorIndex=-1;
const q=id=>document.getElementById(id);
const fetchJson=async url=>{const r=await fetch(url);if(!r.ok)throw new Error(url);return await r.json();};
async function postInfinity(body) {
const response = await fetch('/json/infinity', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(body)
});
const text = await response.text();
if (!response.ok) throw new Error(text || `HTTP ${response.status}`);
try {
return JSON.parse(text);
} catch (error) {
throw new Error('Invalid JSON response from master');
}
}
const clampByte=v=>Math.max(0,Math.min(255,Number(v)||0));
const bpmMin=20, bpmMax=240;
const speedToBpm=speed=>Math.round(bpmMin + (clampByte(speed) * (bpmMax - bpmMin) / 255));
const bpmToSpeed=bpm=>Math.round((Math.max(bpmMin,Math.min(bpmMax,Number(bpm)||speedToBpm(128))) - bpmMin) * 255 / (bpmMax - bpmMin));
const hex=n=>n.toString(16).padStart(2,'0');
const byteToPercent=v=>`${Math.round(clampByte(v) * 100 / 255)}%`;
const rgbToHex=rgb=>`#${hex(rgb?.[0]??0)}${hex(rgb?.[1]??0)}${hex(rgb?.[2]??0)}`;
const hexToRgb=hexColor=>{const v=(hexColor||'#000000').replace('#','');return [parseInt(v.slice(0,2),16)||0,parseInt(v.slice(2,4),16)||0,parseInt(v.slice(4,6),16)||0];};
const fmtMs=v=>`${v} ms`;
const hostIp=()=>location.hostname||'unknown';
const subnet=ip=>ip.split('.').slice(0,3).join('.');
function hsToRgb(hue, sat=255) {
const h = ((Number(hue)||0) % 65536) / 65535;
const s = sat / 255;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = Math.round(255 * (1 - s));
const qv = Math.round(255 * (1 - f * s));
const t = Math.round(255 * (1 - (1 - f) * s));
switch (i % 6) {
case 0: return [255,t,p];
case 1: return [qv,255,p];
case 2: return [p,255,t];
case 3: return [p,qv,255];
case 4: return [t,p,255];
default: return [255,p,qv];
}
}
const wipeRandomColors=Array.from({length:32},(_,i)=>rgbToHex(hsToRgb(Math.round(i*65535/32),255)));
function normalizeHexColor(value) {
const v = String(value||'').trim();
const short = v.match(/^#?([0-9a-fA-F]{3})$/);
if (short) return '#'+short[1].split('').map(c=>c+c).join('').toLowerCase();
const full = v.match(/^#?([0-9a-fA-F]{6})$/);
return full ? '#'+full[1].toLowerCase() : null;
}
const customColorsFromState=()=>Array.isArray(state?.custom_colors)
? state.custom_colors.map(rgbToHex).map(normalizeHexColor).filter(Boolean)
: [];
let customColors=[];
async function syncCustomColorsToEsp() {
state = await postInfinity({custom_colors: customColors.slice(0,24).map(hexToRgb)});
customColors = customColorsFromState();
renderColorPalette();
}
function selectColorSlot(slot) {
activeColorSlot = ['primary','secondary','tertiary'].includes(slot) ? slot : 'primary';
document.querySelectorAll('.color-slot').forEach(el=>el.classList.toggle('active', el.dataset.colorSlot === activeColorSlot));
q('activeColorSlotLabel').textContent = activeColorSlot.charAt(0).toUpperCase()+activeColorSlot.slice(1);
renderColorPalette();
}
function applyPaletteColor(hexColor, customIndex=-1) {
const color = normalizeHexColor(hexColor);
if (!color) return;
selectedCustomColorIndex = customIndex;
q(activeColorSlot).value = color;
renderColorPalette();
markSceneDirty();
}
function renderColorPalette() {
const grid = q('swatchGrid');
if (!grid) return;
const activeValue = normalizeHexColor(q(activeColorSlot)?.value);
const base = wipeRandomColors.map((color,index)=>({color,index,custom:false}));
const custom = customColors.map((color,index)=>({color,index,custom:true}));
grid.innerHTML = [...base,...custom].map(item => {
const selected = item.color === activeValue || (item.custom && item.index === selectedCustomColorIndex);
const klass = `swatch${item.custom?' custom':''}${selected?' selected':''}`;
const title = item.custom ? `Custom ${item.index+1}` : `Wipe Random ${item.index+1}`;
return `<button class="${klass}" type="button" title="${title}" style="background:${item.color}" onclick="applyPaletteColor('${item.color}',${item.custom?item.index:-1})"></button>`;
}).join('');
}
async function addCustomColor() {
const color = normalizeHexColor(prompt('Custom color hex', q(activeColorSlot)?.value || '#ffffff'));
if (!color) return;
if (!customColors.includes(color)) {
if (customColors.length >= 24) {
setActionStatus('Custom palette is full. Delete one color first.', 'warn');
return;
}
customColors.push(color);
}
selectedCustomColorIndex = customColors.indexOf(color);
setActionStatus('Saving custom color on ESP');
try {
await syncCustomColorsToEsp();
setActionStatus('Custom color saved on ESP.', 'good');
} catch (error) {
setActionStatus(`Custom color save failed: ${error.message}`, 'bad');
return;
}
applyPaletteColor(color, selectedCustomColorIndex);
}
async function editSelectedCustomColor() {
if (selectedCustomColorIndex < 0 || selectedCustomColorIndex >= customColors.length) {
setActionStatus('Select a custom color first, then edit it.', 'warn');
return;
}
const color = normalizeHexColor(prompt('Edit custom color hex', customColors[selectedCustomColorIndex]));
if (!color) return;
customColors[selectedCustomColorIndex] = color;
setActionStatus('Saving custom color on ESP');
try {
await syncCustomColorsToEsp();
setActionStatus('Custom color saved on ESP.', 'good');
} catch (error) {
setActionStatus(`Custom color save failed: ${error.message}`, 'bad');
return;
}
applyPaletteColor(color, selectedCustomColorIndex);
}
async function deleteSelectedCustomColor() {
if (selectedCustomColorIndex < 0 || selectedCustomColorIndex >= customColors.length) {
setActionStatus('Select a custom color first, then delete it.', 'warn');
return;
}
customColors.splice(selectedCustomColorIndex, 1);
selectedCustomColorIndex = -1;
setActionStatus('Deleting custom color on ESP');
try {
await syncCustomColorsToEsp();
} catch (error) {
setActionStatus(`Custom color delete failed: ${error.message}`, 'bad');
return;
}
renderColorPalette();
setActionStatus('Custom color deleted on ESP.', 'good');
}
function effectEntries() {
if (Array.isArray(effects)) {
return effects
.map((name, id)=>[id, name])
.filter(([, name])=>name && String(name).indexOf('RSVD') !== 0);
}
return Object.entries(effects || {})
.map(([id, name])=>[Number(id), name])
.filter(([id, name])=>Number.isFinite(id) && name && String(name).indexOf('RSVD') !== 0)
.sort((a,b)=>a[0]-b[0]);
}
const effectName=i=>{
const id = Number(i) || 0;
if (Array.isArray(effects)) return effects[id] ?? `Effect ${id}`;
return effects?.[id] ?? effects?.[String(id)] ?? `Effect ${id}`;
};
const spatialModeNames=['Off','Center Pulse','Checkerd','Arrow','Scan','Snake','Wave Line','Strobe','Schlängeln','Sunburst'];
const spatialModeName=i=>spatialModeNames[Number(i)||0] ?? 'Off';
const spatialVariantCommon=[[0,'Expand / Classic / Line'],[1,'Reverse / Diagonal / Bands'],[2,'Outline / Checkerd'],[3,'Outline Reverse']];
const spatialVariantSchlaengeln=[[0,'Top Left'],[1,'Top Right'],[2,'Bottom Left'],[3,'Bottom Right']];
const spatialVariantSunburst=[[0,'Still'],[1,'Wobble'],[2,'Rotate']];
const spatialVariantName=(i,mode=Number(q('spatialMode')?.value||0))=>{
const options = mode === 8 ? spatialVariantSchlaengeln : (mode === 9 ? spatialVariantSunburst : spatialVariantCommon);
return (options.find(([value])=>value===Number(i))?.[1]) ?? options[0][1];
};
const spatialDirectionName=(i,mode=Number(q('spatialMode')?.value||0))=>{
const options = mode === 8 ? directionOptionsSchlaengeln : (mode === 4 ? directionOptionsScan : directionOptionsCommon);
return (options.find(([value])=>value===Number(i))?.[1]) ?? options[0][1];
};
const spatialOptionName=(i,mode=Number(q('spatialMode')?.value||0))=>{
const options = mode === 9 ? [[0,'Effect Mask'],[1,'Two Color'],[2,'Rainbow']] : [[0,'Line'],[1,'Bands']];
return (options.find(([value])=>value===Number(i))?.[1]) ?? options[0][1];
};
const directionOptionsCommon=[[0,'Left → Right'],[1,'Right → Left'],[2,'Top → Bottom'],[3,'Bottom → Top'],[4,'Outward'],[5,'Inward']];
const directionOptionsScan=[[0,'Left → Right'],[1,'Right → Left'],[2,'Top → Bottom'],[3,'Bottom → Top'],[6,'Ping Pong']];
const directionOptionsSchlaengeln=[[0,'Forward'],[1,'Reverse'],[6,'Ping Pong']];
function effectParts(id) {
const fd = fxdata?.[Number(id)||0] || '';
return fd ? fd.split(';') : [];
}
function effectUsesColors(id) {
if (Number(id) === 0) return true;
const parts = effectParts(id);
if (!parts.length) return true;
const colorField = parts.length > 1 ? parts[1] : '';
return colorField.split(',').some(token => token !== '');
}
function spatialUsesColors(scene) {
const mode = Number(scene?.spatial?.mode || 0);
if (mode === 9 && Number(scene?.spatial?.option || 0) === 0) return false;
return mode !== 0;
}
function setActionStatus(message, kind='info') {
const el = q('actionStatus');
el.textContent = message;
el.className = `notice${kind === 'info' ? '' : ` ${kind}`}`;
}
function markSceneDirty() {
if (sceneRenderLocked) return;
sceneApplyPending = true;
setActionStatus('Scene edited. Auto-applying');
scheduleSceneApply();
}
function markTargetsDirty() {
if (saveInFlight) return;
targetsDirty = true;
setActionStatus('Node targets edited locally. Press Save Targets to update the master.', 'warn');
}
function bindDirtyInputs() {
['enabled','effect','brightness','transition','masterBpm','intensity','reverse','spatialMode','spatialVariant','spatialDirection','spatialStrength','spatialSize','spatialAngle','spatialOption','primary','secondary','tertiary'].forEach(id => {
const el = q(id);
if (!el || el.dataset.infinityDirtyBound === '1') return;
const eventName = (el.tagName === 'SELECT' || el.type === 'checkbox' || el.type === 'color') ? 'change' : 'input';
el.addEventListener(eventName, () => {
if (el.type === 'color') {
selectColorSlot(id);
selectedCustomColorIndex = customColors.indexOf(normalizeHexColor(el.value));
}
markSceneDirty();
});
el.dataset.infinityDirtyBound = '1';
});
document.querySelectorAll('.color-slot').forEach(el => {
if (el.dataset.infinitySlotBound === '1') return;
el.addEventListener('click', event => {
const slot = el.dataset.colorSlot;
if (slot) selectColorSlot(slot);
});
el.dataset.infinitySlotBound = '1';
});
}
function scheduleSceneApply(delay=180) {
clearTimeout(sceneApplyTimer);
sceneApplyTimer = setTimeout(() => saveScene(false), delay);
}
function bindRangeValue(id, suffix='') { q(id+'Val').textContent = `${q(id).value}${suffix}`; }
function bindBrightnessValue() { q('brightnessVal').textContent = byteToPercent(q('brightness').value); }
function chainLengthFromSize(size) {
const v = clampByte(size);
if (v <= 64) return Math.max(1, Math.floor(v / 16));
return Math.min(18, 4 + Math.floor(((v - 64) * 14 + 95) / 191));
}
function snakeLengthFromSize(size) { return Math.min(17, 3 + Math.max(1, Math.floor(clampByte(size) / 64))); }
function bindSpatialSizeValue() {
const mode = Number(q('spatialMode')?.value || 0);
const value = clampByte(q('spatialSize')?.value || 0);
if (mode === 5) q('spatialSizeVal').textContent = `${snakeLengthFromSize(value)} panels`;
else if (mode === 8) q('spatialSizeVal').textContent = `${chainLengthFromSize(value)} panels`;
else if (mode === 7) q('spatialSizeVal').textContent = `${value}`;
else q('spatialSizeVal').textContent = `${value}`;
}
function syncSpeedFromBpm() {
q('speed').value = bpmToSpeed(q('masterBpm').value);
bindRangeValue('masterBpm',' BPM');
}
function bindSelectLabel(id, labelFn) { q(id+'Val').textContent = labelFn(q(id).value); }
function setSelectOptions(id, options, labelFn) {
const select = q(id);
if (!select) return;
const current = Number(select.value || 0);
select.innerHTML = options.map(([value,name])=>`<option value="${value}">${name}</option>`).join('');
select.value = options.some(([value])=>value === current) ? String(current) : String(options[0][0]);
bindSelectLabel(id, labelFn);
}
function setSpatialFieldVisible(id, visible) {
q(id+'Field')?.classList.toggle('hidden', !visible);
}
function setDirectionOptions(mode) {
const modeNumber = Number(mode);
const options = modeNumber === 8 ? directionOptionsSchlaengeln : (modeNumber === 4 ? directionOptionsScan : directionOptionsCommon);
setSelectOptions('spatialDirection', options, spatialDirectionName);
}
function setVariantOptions(mode) {
const modeNumber = Number(mode);
const options = modeNumber === 8 ? spatialVariantSchlaengeln : (modeNumber === 9 ? spatialVariantSunburst : spatialVariantCommon);
setSelectOptions('spatialVariant', options, spatialVariantName);
}
function setOptionOptions(mode) {
const options = Number(mode) === 9 ? [[0,'Effect Mask'],[1,'Two Color'],[2,'Rainbow']] : [[0,'Line'],[1,'Bands']];
setSelectOptions('spatialOption', options, spatialOptionName);
}
function fillSelect(selectId, items, selectedIndex) {
const select=q(selectId);
if (selectId === 'effect') {
const entries = effectEntries();
if (!entries.length) {
select.innerHTML = `<option value="${selectedIndex||0}">Effect ${selectedIndex||0}</option>`;
select.value = String(selectedIndex||0);
return;
}
const previous = String(selectedIndex ?? 0);
select.innerHTML = entries.map(([id,name])=>`<option value="${id}">${id} · ${name}</option>`).join('');
select.value = entries.some(([id])=>String(id) === previous) ? previous : String(entries[0][0]);
return;
}
if (!items.length) {
select.innerHTML = `<option value="${selectedIndex||0}">${selectId} ${selectedIndex||0}</option>`;
select.value = String(selectedIndex||0);
return;
}
const previous = String(selectedIndex ?? 0);
select.innerHTML = items.map((name, index)=>`<option value="${index}">${index} · ${name}</option>`).join('');
select.value = previous;
}
function renderMetrics() {
const nodes = state?.nodes || [];
const online = nodes.filter(n=>n.online).length;
const wifi = info?.wifi || {};
q('metrics').innerHTML = [
['Role', `${state?.role||'?'}`],
['Mode', state?.enabled ? 'Show / Infinity' : 'WLED Backup'],
['Device', `${info?.name || state?.node_id || 'Infinity'}`],
['Address', hostIp()],
['Wi-Fi', wifi.ap ? 'AP active' : 'Client mode']
].map(([k,v])=>`<div class="metric"><span class="k">${k}</span><span class="v">${v}</span></div>`).join('');
q('syncMetrics').innerHTML = [
['UDP', state?.udp_started ? 'Online' : 'Offline'],
['Port', `${state?.port ?? '-'}`],
['Nodes Online', `${online}/${nodes.length}`],
['Packets Out', `${state?.packets_sent ?? 0}`]
].map(([k,v])=>`<div class="metric"><span class="k">${k}</span><span class="v">${v}</span></div>`).join('');
q('networkNotice').textContent = wifi.ap
? `Master is currently advertising an access point. Open the main WLED UI if you want to switch back to your production Wi-Fi.`
: `Master is reachable at ${hostIp()} and serving the Infinity control surface inside the regular WLED web stack.`;
const targetIps = (state?.node_ips || []).filter(Boolean);
const foreignTargets = targetIps.filter(ip=>subnet(ip) !== subnet(hostIp()));
q('targetNotice').textContent = foreignTargets.length
? `Target nodes are still pointed at another subnet (${foreignTargets[0]} ). Update the node targets below for your current hotspot or show network.`
: `Node targets match the current subnet or are already custom configured.`;
renderModeButton();
}
function renderModeButton() {
const button = q('modeToggle');
if (!button) return;
const showMode = !!state?.enabled;
button.textContent = showMode ? 'Show Mode: ON' : 'WLED Backup: ON';
button.title = showMode
? 'Infinity Sync is active. Click to stop master sync so nodes can be controlled through regular WLED.'
: 'Infinity Sync is stopped. Click to resume master sync for show operation.';
button.className = showMode ? 'btn good' : 'btn warn';
}
function renderScene() {
if (sceneApplyPending || sceneRenderLocked) return;
const scene = state?.scene || {};
q('enabled').checked = !!(scene.flags & 0x01);
q('reverse').checked = !!(scene.direction & 0x01);
q('brightness').value = scene.brightness ?? 255;
const sceneSpeed = scene.speed ?? 128;
q('speed').value = sceneSpeed;
q('masterBpm').value = speedToBpm(sceneSpeed);
q('intensity').value = scene.intensity ?? 128;
q('transition').value = scene.transition_ms ?? 750;
const spatial = scene.spatial || {};
q('spatialMode').value = spatial.mode ?? 0;
q('spatialVariant').value = spatial.variant ?? 0;
q('spatialDirection').value = spatial.direction ?? 0;
q('spatialStrength').value = spatial.strength ?? 180;
q('spatialSize').value = spatial.size ?? 64;
q('spatialAngle').value = spatial.angle ?? 0;
q('spatialOption').value = spatial.option ?? 0;
q('primary').value = rgbToHex(scene.primary);
q('secondary').value = rgbToHex(scene.secondary);
q('tertiary').value = rgbToHex(scene.tertiary);
bindBrightnessValue();
bindRangeValue('masterBpm',' BPM');
bindRangeValue('intensity');
bindRangeValue('transition',' ms');
bindRangeValue('spatialStrength');
bindSpatialSizeValue();
bindRangeValue('spatialAngle','°');
bindSelectLabel('spatialMode', spatialModeName);
setVariantOptions(q('spatialMode').value);
setDirectionOptions(q('spatialMode').value);
setOptionOptions(q('spatialMode').value);
bindSelectLabel('spatialVariant', spatialVariantName);
bindSelectLabel('spatialDirection', spatialDirectionName);
bindSelectLabel('spatialOption', spatialOptionName);
fillSelect('effect', effects, scene.effect ?? 0);
q('effectVal').textContent = `${scene.effect ?? 0}`;
q('effect').onchange = () => { q('effectVal').textContent = q('effect').value; updateControlAvailability(); };
q('spatialMode').onchange = () => { bindSelectLabel('spatialMode', spatialModeName); updateControlAvailability(); };
q('spatialVariant').onchange = () => bindSelectLabel('spatialVariant', spatialVariantName);
q('spatialDirection').onchange = () => bindSelectLabel('spatialDirection', spatialDirectionName);
q('spatialOption').onchange = () => { bindSelectLabel('spatialOption', spatialOptionName); updateControlAvailability(); };
q('masterBpm').oninput = syncSpeedFromBpm;
updateControlAvailability();
bindDirtyInputs();
}
function updateControlAvailability() {
const scene = state?.scene || {};
const spatialMode = Number(q('spatialMode')?.value ?? scene.spatial?.mode ?? 0);
setVariantOptions(spatialMode);
setDirectionOptions(spatialMode);
setOptionOptions(spatialMode);
const spatialOption = Number(q('spatialOption')?.value ?? scene.spatial?.option ?? 0);
setSpatialFieldVisible('spatialVariant', spatialMode === 1 || spatialMode === 2 || spatialMode === 8 || spatialMode === 9);
setSpatialFieldVisible('spatialDirection', spatialMode === 3 || spatialMode === 4 || spatialMode === 6 || spatialMode === 8);
setSpatialFieldVisible('spatialStrength', spatialMode !== 0);
setSpatialFieldVisible('spatialSize', spatialMode === 3 || spatialMode === 4 || spatialMode === 5 || spatialMode === 7 || spatialMode === 8);
if (q('spatialSizeLabel')) {
q('spatialSizeLabel').textContent = spatialMode === 7 ? 'Pulse Width' : (spatialMode === 5 || spatialMode === 8 ? 'Length' : 'Size');
}
const variantLabel = q('spatialVariantField')?.querySelector('label span:first-child');
if (variantLabel) variantLabel.textContent = spatialMode === 8 ? 'Start Corner' : (spatialMode === 9 ? 'Motion' : 'Variant');
const optionLabel = q('spatialOptionField')?.querySelector('label span:first-child');
if (optionLabel) optionLabel.textContent = spatialMode === 9 ? 'Render' : 'Scan Style';
bindSpatialSizeValue();
setSpatialFieldVisible('spatialAngle', spatialMode === 4);
setSpatialFieldVisible('spatialOption', spatialMode === 4 || spatialMode === 9);
const draft = { spatial: { mode: spatialMode, option: spatialOption } };
const effectId = q('effect')?.value ?? scene.effect ?? 0;
const colorsAvailable = effectUsesColors(effectId) || spatialUsesColors(draft);
q('colorsTitle')?.classList.toggle('hidden', !colorsAvailable);
q('colorsGrid')?.classList.toggle('hidden', !colorsAvailable);
q('colorPaletteField')?.classList.toggle('hidden', !colorsAvailable);
renderColorPalette();
}
function renderTargets() {
if (targetsDirty && !saveInFlight) return;
const targets = state?.node_ips || [];
q('targets').innerHTML = Array.from({length:6}, (_,i) => `
<div class="field">
<label for="target${i}">Node ${String(i+1).padStart(2,'0')}</label>
<input id="target${i}" type="text" value="${targets[i] || ''}" placeholder="IP address">
</div>`).join('');
Array.from({length:6}, (_,i) => q(`target${i}`)).forEach(input => {
if (!input || input.dataset.infinityDirtyBound === '1') return;
input.addEventListener('input', markTargetsDirty);
input.dataset.infinityDirtyBound = '1';
});
}
function renderNodes() {
const nodes = state?.nodes || [];
q('nodes').innerHTML = nodes.map((node, index) => `
<article class="node ${node.online ? 'online' : 'offline'}">
<h3>${node.node_id || `node-${String(index+1).padStart(2,'0')}`}</h3>
<p><strong>Target:</strong> ${node.expected_ip || '-'}</p>
<p><strong>Seen at:</strong> ${node.online ? `${node.last_seen_ms} ms ago` : 'offline'}</p>
<p><strong>Live IP:</strong> ${node.ip || 'pending'}</p>
<p><strong>Offset:</strong> ${node.master_offset_us || 0} us</p>
<p><strong>Effect:</strong> ${effectName(node.effect || 0)}</p>
</article>`).join('');
}
async function saveScene(forceRefresh=true) {
if (saveInFlight && !forceRefresh) {
scheduleSceneApply(220);
return;
}
const body = {
scene: {
brightness: clampByte(q('brightness').value),
effect: clampByte(q('effect').value),
speed: bpmToSpeed(q('masterBpm').value),
intensity: clampByte(q('intensity').value),
palette: 0,
transition_ms: Number(q('transition').value) || 0,
direction: q('reverse').checked ? 1 : 0,
flags: q('enabled').checked ? 1 : 0,
primary: hexToRgb(q('primary').value),
secondary: hexToRgb(q('secondary').value),
tertiary: hexToRgb(q('tertiary').value),
row_dimmer: [255, 255, 255],
spatial: {
mode: clampByte(q('spatialMode').value),
variant: clampByte(q('spatialVariant').value),
direction: clampByte(q('spatialDirection').value),
strength: clampByte(q('spatialStrength').value),
size: clampByte(q('spatialSize').value),
angle: Math.max(0,Math.min(359,Number(q('spatialAngle').value)||0)),
option: clampByte(q('spatialOption').value)
},
group_mask: 7
}
};
saveInFlight = true;
setActionStatus('Applying scene on master and pushing Infinity Sync packets');
try {
state = await postInfinity(body);
sceneApplyPending = false;
setActionStatus('Scene auto-applied and queued for sync to all nodes.', 'good');
sceneRenderLocked = true;
renderMetrics();
renderTargets();
renderNodes();
sceneRenderLocked = false;
} catch (error) {
sceneApplyPending = false;
setActionStatus(`Scene update failed: ${error.message}`, 'bad');
} finally {
sceneRenderLocked = false;
saveInFlight = false;
}
if (forceRefresh) await refreshAll(true);
}
async function saveTargets() {
const node_ips = Array.from({length:6}, (_,i) => q(`target${i}`).value.trim());
saveInFlight = true;
setActionStatus('Saving node targets on the master');
try {
state = await postInfinity({node_ips});
targetsDirty = false;
setActionStatus('Node targets saved on the master.', 'good');
renderMetrics();
renderScene();
renderTargets();
renderNodes();
} catch (error) {
setActionStatus(`Target save failed: ${error.message}`, 'bad');
} finally {
saveInFlight = false;
}
await refreshAll(true);
}
async function toggleInfinityMode() {
const nextEnabled = !state?.enabled;
saveInFlight = true;
setActionStatus(nextEnabled ? 'Switching to Show Mode and enabling Infinity Sync' : 'Switching to WLED Backup and stopping Infinity Sync');
try {
state = await postInfinity({enabled: nextEnabled});
setActionStatus(
nextEnabled
? 'Show Mode enabled. Master is sending Infinity Sync to the nodes.'
: 'WLED Backup enabled. Master sync is stopped; regular WLED control can be used.',
nextEnabled ? 'good' : 'warn'
);
renderMetrics();
renderModeButton();
} catch (error) {
setActionStatus(`Mode switch failed: ${error.message}`, 'bad');
} finally {
saveInFlight = false;
}
await refreshAll(true);
}
async function refreshAll(force=false) {
const [nextState, nextInfo] = await Promise.all([
fetchJson('/json/infinity'),
fetchJson('/json/info').catch(() => null)
]);
state = nextState;
info = nextInfo;
customColors = customColorsFromState();
if (!effects.length || !fxdata.length) {
[effects, fxdata] = await Promise.all([
fetchJson('/json/effects').catch(() => []),
fetchJson('/json/fxdata').catch(() => [])
]);
}
renderMetrics();
if (force || (!sceneApplyPending && !saveInFlight)) renderScene();
if (force || !targetsDirty || saveInFlight) renderTargets();
renderNodes();
}
setInterval(refreshAll, 1500);
refreshAll();
</script></body></html>
)rawliteral";
request->send_P(200, "text/html", PAGE_infinity);
}
#endif // WLED_ENABLE_INFINITY_CONTROLLER