1635 lines
73 KiB
C++
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
|