Files
WLED_MM_Infinity/wled00/infinity_sync.cpp
jan ebc4498d89
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
Add BPM speed control and OTA update workflow
2026-04-25 22:48:13 +02:00

1635 lines
73 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_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_BLEND_REPLACE = 0;
constexpr uint8_t INFINITY_SPATIAL_BLEND_ADD = 1;
constexpr uint8_t INFINITY_SPATIAL_BLEND_MULTIPLY_MASK = 2;
constexpr uint8_t INFINITY_SPATIAL_BLEND_PALETTE_TINT = 3;
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_BPM_MIN = 20;
constexpr uint8_t INFINITY_BPM_MAX = 240;
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 strobe;
uint8_t safetyFade;
uint8_t custom[3];
uint8_t spatialMode;
uint8_t spatialStrength;
uint8_t spatialBlend;
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
128, // 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_BLEND_MULTIPLY_MASK,
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] = {};
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) {
const uint8_t count = strip.getModeCount();
if (count == 0) return 0;
return mode < count ? mode : mode % count;
}
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));
}
uint32_t addColorCapped(uint32_t base, uint32_t add) {
return RGBW32(qadd8(R(base), R(add)), qadd8(G(base), G(add)), qadd8(B(base), B(add)), qadd8(W(base), W(add)));
}
uint32_t applySpatialBlend(uint32_t base, uint32_t layer, uint8_t amount, uint8_t strength, uint8_t blendMode) {
const uint8_t mask = scaleByte(amount, strength);
const uint32_t scaledLayer = scaleColor(layer, mask);
switch (blendMode) {
case INFINITY_SPATIAL_BLEND_REPLACE:
return color_blend(base, layer, mask);
case INFINITY_SPATIAL_BLEND_ADD:
return addColorCapped(base, scaledLayer);
case INFINITY_SPATIAL_BLEND_PALETTE_TINT:
return color_blend(scaleColor(base, mask), layer, mask);
case INFINITY_SPATIAL_BLEND_MULTIPLY_MASK:
default:
return scaleColor(base, mask);
}
}
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 spatialPhase(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;
}
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);
}
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(spatialPhase(timeUs, speed) * span, 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>(floorf(spatialPhase(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 == 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>(floorf(spatialPhase(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>(floorf(spatialPhase(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 minProgress = -3.0f;
const float maxProgress = 8.0f;
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) + width;
float phase = fmodf(spatialPhase(timeUs, speed) * travel, travel);
if (direction == INFINITY_SPATIAL_DIRECTION_PINGPONG) {
phase = fmodf(spatialPhase(timeUs, speed) * travel, 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 + 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)));
}
uint8_t snakeAmount(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t speed, uint8_t size) {
const uint8_t pathLen = INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT;
const uint8_t panelIndex = (row & 0x01) ? (row * INFINITY_NODE_COUNT + (INFINITY_NODE_COUNT - 1 - column)) : (row * INFINITY_NODE_COUNT + column);
const uint32_t step = static_cast<uint32_t>(floorf(spatialPhase(timeUs, speed)));
const uint8_t head = (step + (infinityScene.seed % pathLen)) % pathLen;
const uint8_t length = 3 + max<uint8_t>(1, size / 64);
for (uint8_t i = 0; i < length; i++) {
if (panelIndex == (head + pathLen - (i % pathLen)) % pathLen) return 255 - min<uint8_t>(200, i * 38);
}
const uint8_t apple = (static_cast<uint32_t>(infinityScene.seed * 17U + (step / pathLen) * 11U + 7U) % pathLen);
return panelIndex == apple ? 180 : 0;
}
uint8_t spatialAmount(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs) {
switch (infinityScene.spatialMode) {
case INFINITY_SPATIAL_CENTER_PULSE:
return centerPulseOutlineAmount(column, row, led, centerPulseAmount(column, row, timeUs, infinityScene.speed, infinityScene.spatialVariant), infinityScene.spatialVariant);
case INFINITY_SPATIAL_CHECKER:
return checkerdAmount(column, row, led, timeUs, infinityScene.spatialVariant, infinityScene.speed);
case INFINITY_SPATIAL_ARROW:
return arrowAmount(column, row, timeUs, infinityScene.spatialDirection, infinityScene.speed, infinityScene.spatialSize);
case INFINITY_SPATIAL_SCAN:
return scanAmount(column, row, led, timeUs, infinityScene.speed, infinityScene.spatialSize, infinityScene.spatialAngle, infinityScene.spatialOption, infinityScene.spatialDirection);
case INFINITY_SPATIAL_SNAKE:
return snakeAmount(column, row, timeUs, infinityScene.speed, infinityScene.spatialSize);
case INFINITY_SPATIAL_WAVE_LINE:
return waveLineAmount(column, row, timeUs, infinityScene.spatialDirection, infinityScene.speed);
default:
return 0;
}
}
uint32_t spatialLayerColor(Segment& seg, uint16_t led, uint8_t amount) {
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, amount);
}
return color_blend(rgbToColor(infinityScene.secondary), rgbToColor(infinityScene.primary), amount);
}
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 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["strobe"] = infinityScene.strobe;
scene["safety_fade"] = infinityScene.safetyFade;
JsonObject spatial = scene.createNestedObject("spatial");
spatial["mode"] = infinityScene.spatialMode;
spatial["strength"] = infinityScene.spatialStrength;
spatial["blend"] = infinityScene.spatialBlend;
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 = 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.strobe = jsonU8(scene["strobe"], infinityScene.strobe);
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.spatialBlend = jsonU8(spatial["blend"], infinityScene.spatialBlend);
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.spatialBlend = jsonU8(scene["spatial_blend"], infinityScene.spatialBlend);
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_WAVE_LINE) infinityScene.spatialMode = INFINITY_SPATIAL_OFF;
if (infinityScene.spatialBlend > INFINITY_SPATIAL_BLEND_PALETTE_TINT) infinityScene.spatialBlend = INFINITY_SPATIAL_BLEND_MULTIPLY_MASK;
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;
if (infinityScene.spatialOption > INFINITY_SPATIAL_OPTION_BANDS) infinityScene.spatialOption = INFINITY_SPATIAL_OPTION_LINE;
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 = 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.strobe = ch[22];
infinityScene.safetyFade = ch[23];
infinityScene.spatialMode = map(ch[24], 0, 255, 0, INFINITY_SPATIAL_WAVE_LINE);
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);
infinityScene.spatialBlend = map(ch[27], 0, 255, 0, INFINITY_SPATIAL_BLEND_PALETTE_TINT);
infinityScene.spatialStrength = ch[28];
infinityScene.spatialSize = ch[29];
infinityScene.spatialAngle = map(ch[30], 0, 255, 0, 359);
infinityScene.spatialOption = ch[31] > 127 ? INFINITY_SPATIAL_OPTION_BANDS : INFINITY_SPATIAL_OPTION_LINE;
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 || infinityScene.spatialStrength == 0) 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 uint8_t amount = spatialAmount(column, row, led, nowUs);
if (amount == 0 && infinityScene.spatialBlend != INFINITY_SPATIAL_BLEND_REPLACE) {
if (infinityScene.spatialBlend == INFINITY_SPATIAL_BLEND_MULTIPLY_MASK || infinityScene.spatialBlend == INFINITY_SPATIAL_BLEND_PALETTE_TINT) {
seg.setPixelColor(led, BLACK);
}
continue;
}
const uint32_t base = seg.getPixelColor(led);
const uint32_t layer = spatialLayerColor(seg, led, amount);
seg.setPixelColor(led, applySpatialBlend(base, layer, amount, infinityScene.spatialStrength, infinityScene.spatialBlend));
}
}
}
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"));
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 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"]);
if (wasEnabled != infinityEnabled) {
if (infinityEnabled) {
infinityNetworkBegin();
} else if (infinityUdpStarted) {
infinityUdp.stop();
infinityUdpStarted = false;
}
}
JsonObject scene = root["scene"].is<JsonObject>() ? root["scene"].as<JsonObject>() : root;
deserializeScene(scene);
if (isMasterBuild() && infinityUdpStarted) {
sendClockSync();
if (hadSceneObject || !root["node_ips"].isNull()) 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"]);
}
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"));
}
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,.row-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))}
.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,.row-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,.row-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>
<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></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="spatialBlendField"><label for="spatialBlend"><span>Blend</span><span class="pill" id="spatialBlendVal">Multiply Mask</span></label><select id="spatialBlend"><option value="0">Replace</option><option value="1">Add</option><option value="2">Multiply Mask</option><option value="3">Palette Tint</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>Size</span><span class="pill" id="spatialSizeVal">64</span></label><input id="spatialSize" type="range" min="0" max="255" oninput="bindRangeValue('spatialSize')"></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="palette"><span>Palette</span><span class="pill" id="paletteVal">0</span></label><select id="palette"></select></div>
<div class="field"><label for="brightness"><span>Brightness</span><span class="pill" id="brightnessVal">128</span></label><input id="brightness" type="range" min="0" max="255" oninput="bindRangeValue('brightness')"></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"><label for="primary">Primary</label><input id="primary" type="color"></div>
<div class="field"><label for="secondary">Secondary</label><input id="secondary" type="color"></div>
<div class="field"><label for="tertiary">Tertiary</label><input id="tertiary" type="color"></div>
</div>
<h2 style="margin-top:18px">Rows</h2>
<div class="row-grid">
<div class="field"><label for="row0"><span>Top</span><span class="pill" id="row0Val">255</span></label><input id="row0" type="range" min="0" max="255" oninput="bindRangeValue('row0')"></div>
<div class="field"><label for="row1"><span>Middle</span><span class="pill" id="row1Val">255</span></label><input id="row1" type="range" min="0" max="255" oninput="bindRangeValue('row1')"></div>
<div class="field"><label for="row2"><span>Bottom</span><span class="pill" id="row2Val">255</span></label><input id="row2" type="range" min="0" max="255" oninput="bindRangeValue('row2')"></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. Changes stay local until you press Apply Scene or Save Targets.</div>
<button class="btn" type="button" onclick="saveTargets()">Save Targets</button>
<button class="btn primary" type="button" onclick="saveScene()">Apply Scene</button>
</div>
<script>
let state=null, info=null, effects=[], palettes=[], fxdata=[];
let sceneDirty=false, targetsDirty=false, saveInFlight=false;
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 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('.');
const effectName=i=>effects[i] ?? `Effect ${i}`;
const paletteName=i=>palettes[i] ?? `Palette ${i}`;
const spatialModeName=i=>['Off','Center Pulse','Checkerd','Arrow','Scan','Snake','Wave Line'][Number(i)||0] ?? 'Off';
const spatialVariantName=i=>['Expand / Classic / Line','Reverse / Diagonal / Bands','Outline / Checkerd','Outline Reverse'][Number(i)||0] ?? 'Expand';
const spatialDirectionName=i=>['Left Right','Right Left','Top Bottom','Bottom Top','Outward','Inward','Ping Pong'][Number(i)||0] ?? 'Left Right';
const spatialBlendName=i=>['Replace','Add','Multiply Mask','Palette Tint'][Number(i)||0] ?? 'Multiply Mask';
const spatialOptionName=i=>['Line','Bands'][Number(i)||0] ?? 'Line';
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']];
function effectParts(id) {
const fd = fxdata?.[Number(id)||0] || '';
return fd ? fd.split(';') : [];
}
function effectUsesPalette(id) {
const parts = effectParts(id);
const paletteField = parts.length > 2 ? parts[2] : '';
const tokens = paletteField.split(',').filter(Boolean);
return tokens.some(token => token !== '' && !/^-?\d+$/.test(token));
}
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) {
return Number(scene?.spatial?.mode || 0) !== 0;
}
function spatialUsesPalette(scene) {
return Number(scene?.spatial?.mode || 0) !== 0 && Number(scene?.spatial?.blend || 2) === 3;
}
function setActionStatus(message, kind='info') {
const el = q('actionStatus');
el.textContent = message;
el.className = `notice${kind === 'info' ? '' : ` ${kind}`}`;
}
function markSceneDirty() {
if (saveInFlight) return;
sceneDirty = true;
setActionStatus('Scene edited locally. Press Apply Scene to send and store it on the master.', 'warn');
}
function markTargetsDirty() {
if (saveInFlight) return;
targetsDirty = true;
setActionStatus('Node targets edited locally. Press Save Targets to update the master.', 'warn');
}
function bindDirtyInputs() {
['enabled','effect','palette','brightness','transition','masterBpm','intensity','reverse','spatialMode','spatialVariant','spatialDirection','spatialBlend','spatialStrength','spatialSize','spatialAngle','spatialOption','primary','secondary','tertiary','row0','row1','row2'].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, markSceneDirty);
el.dataset.infinityDirtyBound = '1';
});
}
function bindRangeValue(id, suffix='') { q(id+'Val').textContent = `${q(id).value}${suffix}`; }
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 setSpatialFieldVisible(id, visible) {
q(id+'Field')?.classList.toggle('hidden', !visible);
}
function setDirectionOptions(mode) {
const select = q('spatialDirection');
if (!select) return;
const current = Number(select.value || 0);
const options = Number(mode) === 4 ? directionOptionsScan : directionOptionsCommon;
select.innerHTML = options.map(([value,name])=>`<option value="${value}">${name}</option>`).join('');
select.value = options.some(([value])=>value === current) ? String(current) : '0';
bindSelectLabel('spatialDirection', spatialDirectionName);
}
function fillSelect(selectId, items, selectedIndex) {
const select=q(selectId);
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 (sceneDirty && !saveInFlight) return;
const scene = state?.scene || {};
q('enabled').checked = !!(scene.flags & 0x01);
q('reverse').checked = !!(scene.direction & 0x01);
q('brightness').value = scene.brightness ?? 128;
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('spatialBlend').value = spatial.blend ?? 2;
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('row0').value = scene.row_dimmer?.[0] ?? 255;
q('row1').value = scene.row_dimmer?.[1] ?? 255;
q('row2').value = scene.row_dimmer?.[2] ?? 255;
q('primary').value = rgbToHex(scene.primary);
q('secondary').value = rgbToHex(scene.secondary);
q('tertiary').value = rgbToHex(scene.tertiary);
bindRangeValue('brightness');
bindRangeValue('masterBpm',' BPM');
bindRangeValue('intensity');
bindRangeValue('transition',' ms');
bindRangeValue('spatialStrength');
bindRangeValue('spatialSize');
bindRangeValue('spatialAngle','°');
bindSelectLabel('spatialMode', spatialModeName);
bindSelectLabel('spatialVariant', spatialVariantName);
bindSelectLabel('spatialDirection', spatialDirectionName);
bindSelectLabel('spatialBlend', spatialBlendName);
bindSelectLabel('spatialOption', spatialOptionName);
bindRangeValue('row0');
bindRangeValue('row1');
bindRangeValue('row2');
fillSelect('effect', effects, scene.effect ?? 0);
fillSelect('palette', palettes, scene.palette ?? 0);
q('effectVal').textContent = `${scene.effect ?? 0}`;
q('paletteVal').textContent = `${scene.palette ?? 0}`;
q('effect').onchange = () => { q('effectVal').textContent = q('effect').value; updateControlAvailability(); };
q('palette').onchange = () => q('paletteVal').textContent = q('palette').value;
q('spatialMode').onchange = () => { bindSelectLabel('spatialMode', spatialModeName); updateControlAvailability(); };
q('spatialVariant').onchange = () => bindSelectLabel('spatialVariant', spatialVariantName);
q('spatialDirection').onchange = () => bindSelectLabel('spatialDirection', spatialDirectionName);
q('spatialBlend').onchange = () => { bindSelectLabel('spatialBlend', spatialBlendName); updateControlAvailability(); };
q('spatialOption').onchange = () => bindSelectLabel('spatialOption', spatialOptionName);
q('masterBpm').oninput = syncSpeedFromBpm;
updateControlAvailability();
bindDirtyInputs();
}
function updateControlAvailability() {
const scene = state?.scene || {};
const spatialMode = Number(q('spatialMode')?.value ?? scene.spatial?.mode ?? 0);
setDirectionOptions(spatialMode);
setSpatialFieldVisible('spatialVariant', spatialMode === 1 || spatialMode === 2);
setSpatialFieldVisible('spatialDirection', spatialMode === 3 || spatialMode === 4 || spatialMode === 6);
setSpatialFieldVisible('spatialBlend', spatialMode !== 0);
setSpatialFieldVisible('spatialStrength', spatialMode !== 0);
setSpatialFieldVisible('spatialSize', spatialMode === 3 || spatialMode === 4 || spatialMode === 5);
setSpatialFieldVisible('spatialAngle', spatialMode === 4);
setSpatialFieldVisible('spatialOption', spatialMode === 4);
const draft = {
spatial: {
mode: spatialMode,
blend: Number(q('spatialBlend')?.value ?? scene.spatial?.blend ?? 2)
}
};
const effectId = q('effect')?.value ?? scene.effect ?? 0;
const available = effectUsesPalette(effectId) || spatialUsesPalette(draft);
const field = q('palette')?.closest('.field');
if (field) field.classList.toggle('hidden', !available);
if (q('palette')) q('palette').disabled = !available;
const colorsAvailable = effectUsesColors(effectId) || spatialUsesColors(draft);
q('colorsTitle')?.classList.toggle('hidden', !colorsAvailable);
q('colorsGrid')?.classList.toggle('hidden', !colorsAvailable);
}
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() {
const body = {
scene: {
brightness: clampByte(q('brightness').value),
effect: clampByte(q('effect').value),
speed: bpmToSpeed(q('masterBpm').value),
intensity: clampByte(q('intensity').value),
palette: clampByte(q('palette').value),
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: [clampByte(q('row0').value), clampByte(q('row1').value), clampByte(q('row2').value)],
spatial: {
mode: clampByte(q('spatialMode').value),
variant: clampByte(q('spatialVariant').value),
direction: clampByte(q('spatialDirection').value),
blend: clampByte(q('spatialBlend').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);
sceneDirty = false;
setActionStatus('Scene stored on the master and queued for sync to all nodes.', 'good');
renderMetrics();
renderScene();
renderTargets();
renderNodes();
} catch (error) {
setActionStatus(`Scene update failed: ${error.message}`, 'bad');
} finally {
saveInFlight = false;
}
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;
if (!effects.length || !palettes.length || !fxdata.length) {
[effects, palettes, fxdata] = await Promise.all([
fetchJson('/json/effects').catch(() => []),
fetchJson('/json/palettes').catch(() => []),
fetchJson('/json/fxdata').catch(() => [])
]);
}
renderMetrics();
if (force || !sceneDirty || 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