2280 lines
99 KiB
C++
2280 lines
99 KiB
C++
#include "wled.h"
|
|
|
|
#ifdef WLED_ENABLE_INFINITY_CONTROLLER
|
|
|
|
#ifdef ARDUINO_ARCH_ESP32
|
|
#include "esp_timer.h"
|
|
#endif
|
|
|
|
namespace {
|
|
|
|
constexpr uint32_t INFINITY_MAGIC = 0x59534649UL; // "IFSY" little-endian
|
|
constexpr uint8_t INFINITY_VERSION = 1;
|
|
constexpr uint16_t INFINITY_DEFAULT_PORT = 21325;
|
|
constexpr uint8_t INFINITY_NODE_COUNT = 6;
|
|
constexpr uint8_t INFINITY_PANEL_COUNT = 3;
|
|
constexpr uint16_t INFINITY_LEDS_PER_PANEL = 106;
|
|
constexpr uint16_t INFINITY_LEDS_PER_NODE = INFINITY_PANEL_COUNT * INFINITY_LEDS_PER_PANEL;
|
|
constexpr uint32_t INFINITY_CLOCK_INTERVAL_MS = 250;
|
|
constexpr uint32_t INFINITY_SCENE_INTERVAL_MS = 100;
|
|
constexpr uint32_t INFINITY_STATUS_INTERVAL_MS = 1000;
|
|
constexpr uint32_t INFINITY_NODE_TIMEOUT_MS = 3000;
|
|
constexpr uint32_t INFINITY_APPLY_DELAY_US = 120000;
|
|
constexpr uint16_t INFINITY_DMX_FOOTPRINT = 32;
|
|
constexpr uint8_t INFINITY_CUSTOM_COLOR_MAX = 24;
|
|
constexpr uint8_t INFINITY_SPATIAL_OFF = 0;
|
|
constexpr uint8_t INFINITY_SPATIAL_CENTER_PULSE = 1;
|
|
constexpr uint8_t INFINITY_SPATIAL_CHECKER = 2;
|
|
constexpr uint8_t INFINITY_SPATIAL_ARROW = 3;
|
|
constexpr uint8_t INFINITY_SPATIAL_SCAN = 4;
|
|
constexpr uint8_t INFINITY_SPATIAL_SNAKE = 5;
|
|
constexpr uint8_t INFINITY_SPATIAL_WAVE_LINE = 6;
|
|
constexpr uint8_t INFINITY_SPATIAL_STROBE = 7;
|
|
constexpr uint8_t INFINITY_SPATIAL_SCHLAENGELN = 8;
|
|
constexpr uint8_t INFINITY_SPATIAL_SUNBURST = 9;
|
|
constexpr uint8_t INFINITY_SPATIAL_VARIANT_EXPAND = 0;
|
|
constexpr uint8_t INFINITY_SPATIAL_VARIANT_REVERSE = 1;
|
|
constexpr uint8_t INFINITY_SPATIAL_VARIANT_OUTLINE = 2;
|
|
constexpr uint8_t INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE = 3;
|
|
constexpr uint8_t INFINITY_SPATIAL_DIRECTION_LTR = 0;
|
|
constexpr uint8_t INFINITY_SPATIAL_DIRECTION_RTL = 1;
|
|
constexpr uint8_t INFINITY_SPATIAL_DIRECTION_TTB = 2;
|
|
constexpr uint8_t INFINITY_SPATIAL_DIRECTION_BTT = 3;
|
|
constexpr uint8_t INFINITY_SPATIAL_DIRECTION_OUTWARD = 4;
|
|
constexpr uint8_t INFINITY_SPATIAL_DIRECTION_INWARD = 5;
|
|
constexpr uint8_t INFINITY_SPATIAL_DIRECTION_PINGPONG = 6;
|
|
constexpr uint8_t INFINITY_SPATIAL_OPTION_LINE = 0;
|
|
constexpr uint8_t INFINITY_SPATIAL_OPTION_BANDS = 1;
|
|
constexpr uint8_t INFINITY_SPATIAL_OPTION_TWO_COLOR = 1;
|
|
constexpr uint8_t INFINITY_SPATIAL_OPTION_RAINBOW = 2;
|
|
constexpr uint8_t INFINITY_BPM_MIN = 20;
|
|
constexpr uint8_t INFINITY_BPM_MAX = 240;
|
|
constexpr float INFINITY_PANEL_GAP_RATIO = 0.50f; // 8 cm gap for roughly 16 cm active panel aperture.
|
|
constexpr float INFINITY_TAU = 6.28318530718f;
|
|
|
|
struct PanelTransform {
|
|
uint8_t rotation; // clockwise quarter turns: 0, 1, 2, 3
|
|
bool mirrorX;
|
|
bool mirrorY;
|
|
};
|
|
|
|
// Single source of truth for per-panel orientation used by global geometry effects.
|
|
// Keep the compact UI/visualizer grid in logical row/column order; transform only
|
|
// the local LED coordinates for radial/global calculations.
|
|
constexpr PanelTransform INFINITY_PANEL_TRANSFORMS[INFINITY_PANEL_COUNT][INFINITY_NODE_COUNT] = {
|
|
{{0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}},
|
|
{{0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}},
|
|
{{0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}, {0, false, false}},
|
|
};
|
|
|
|
enum SpatialRole : uint8_t {
|
|
SPATIAL_ROLE_GRADIENT = 0,
|
|
SPATIAL_ROLE_PRIMARY = 1,
|
|
SPATIAL_ROLE_SECONDARY = 2,
|
|
SPATIAL_ROLE_BLACK = 3,
|
|
SPATIAL_ROLE_EFFECT = 4,
|
|
SPATIAL_ROLE_RAINBOW = 5,
|
|
};
|
|
|
|
struct SpatialSample {
|
|
uint8_t amount;
|
|
SpatialRole role;
|
|
uint8_t hue;
|
|
};
|
|
|
|
enum InfinityPacketType : uint8_t {
|
|
INFINITY_PACKET_CLOCK_SYNC = 1,
|
|
INFINITY_PACKET_SCENE_STATE = 2,
|
|
INFINITY_PACKET_BEAT_TRIGGER = 3,
|
|
INFINITY_PACKET_NODE_STATUS = 4,
|
|
};
|
|
|
|
struct __attribute__((packed)) InfinityPacketHeader {
|
|
uint32_t magic;
|
|
uint8_t version;
|
|
uint8_t type;
|
|
uint16_t payloadSize;
|
|
uint32_t sequence;
|
|
uint64_t masterTimeUs;
|
|
};
|
|
|
|
struct __attribute__((packed)) InfinityScenePayload {
|
|
uint8_t effectId;
|
|
uint8_t presetId;
|
|
uint8_t brightness;
|
|
uint8_t speed;
|
|
uint8_t intensity;
|
|
uint8_t palette;
|
|
uint8_t primary[3];
|
|
uint8_t secondary[3];
|
|
uint8_t tertiary[3];
|
|
uint8_t groupMask;
|
|
uint8_t direction;
|
|
uint8_t flags;
|
|
uint16_t transitionMs;
|
|
uint32_t seed;
|
|
uint16_t phase;
|
|
uint64_t applyAtUs;
|
|
uint8_t rowDimmer[3];
|
|
uint8_t reserved0;
|
|
uint8_t safetyFade;
|
|
uint8_t custom[3];
|
|
uint8_t spatialMode;
|
|
uint8_t spatialStrength;
|
|
uint8_t spatialVariant;
|
|
uint8_t spatialDirection;
|
|
uint8_t spatialSize;
|
|
uint16_t spatialAngle;
|
|
uint8_t spatialOption;
|
|
};
|
|
|
|
constexpr uint16_t INFINITY_SCENE_PAYLOAD_LEGACY_SIZE = __builtin_offsetof(InfinityScenePayload, spatialDirection);
|
|
|
|
struct __attribute__((packed)) InfinityStatusPayload {
|
|
char nodeId[12];
|
|
uint32_t uptimeMs;
|
|
int32_t masterOffsetUs;
|
|
uint32_t lastSceneSequence;
|
|
uint32_t packetsReceived;
|
|
uint8_t flags;
|
|
uint8_t activeEffect;
|
|
uint8_t activePreset;
|
|
uint8_t reserved;
|
|
};
|
|
|
|
struct InfinityNodeStatus {
|
|
IPAddress ip;
|
|
char nodeId[12];
|
|
uint32_t lastSeenMs;
|
|
uint32_t uptimeMs;
|
|
int32_t masterOffsetUs;
|
|
uint32_t lastSceneSequence;
|
|
uint32_t packetsReceived;
|
|
uint8_t flags;
|
|
uint8_t activeEffect;
|
|
uint8_t activePreset;
|
|
};
|
|
|
|
WiFiUDP infinityUdp;
|
|
bool infinityUdpStarted = false;
|
|
bool infinityEnabled = true;
|
|
uint16_t infinityPort = INFINITY_DEFAULT_PORT;
|
|
IPAddress infinityMasterIp(192, 168, 178, 10);
|
|
IPAddress infinityNodeIps[INFINITY_NODE_COUNT] = {
|
|
IPAddress(192, 168, 178, 11),
|
|
IPAddress(192, 168, 178, 12),
|
|
IPAddress(192, 168, 178, 13),
|
|
IPAddress(192, 168, 178, 14),
|
|
IPAddress(192, 168, 178, 15),
|
|
IPAddress(192, 168, 178, 16),
|
|
};
|
|
char infinityConfiguredNodeId[12] = "auto";
|
|
char infinityRuntimeNodeId[12] = "node-xx";
|
|
|
|
InfinityScenePayload infinityScene = {
|
|
0, // effectId
|
|
0, // presetId
|
|
255, // brightness
|
|
128, // speed
|
|
128, // intensity
|
|
0, // palette
|
|
{255, 160, 80},
|
|
{0, 32, 255},
|
|
{0, 0, 0},
|
|
0x07,
|
|
0,
|
|
1,
|
|
750,
|
|
1,
|
|
0,
|
|
0,
|
|
{255, 255, 255},
|
|
0,
|
|
0,
|
|
{0, 0, 0},
|
|
INFINITY_SPATIAL_OFF,
|
|
180,
|
|
INFINITY_SPATIAL_VARIANT_EXPAND,
|
|
INFINITY_SPATIAL_DIRECTION_LTR,
|
|
64,
|
|
0,
|
|
INFINITY_SPATIAL_OPTION_LINE,
|
|
};
|
|
InfinityScenePayload pendingScene;
|
|
bool hasPendingScene = false;
|
|
uint32_t infinitySequence = 0;
|
|
uint32_t lastClockSentMs = 0;
|
|
uint32_t lastSceneSentMs = 0;
|
|
uint32_t lastStatusSentMs = 0;
|
|
uint32_t lastDmxBeatValue = 0;
|
|
uint32_t lastDmxSyncResetValue = 0;
|
|
uint32_t packetsReceived = 0;
|
|
uint32_t packetsSent = 0;
|
|
uint32_t lastSceneSequence = 0;
|
|
int64_t masterOffsetUs = 0;
|
|
uint8_t lastAppliedPreset = 0;
|
|
uint8_t lastAppliedEffect = 0;
|
|
InfinityNodeStatus nodeStatuses[INFINITY_NODE_COUNT] = {};
|
|
uint8_t infinityCustomColors[INFINITY_CUSTOM_COLOR_MAX][3] = {
|
|
{69, 57, 148},
|
|
{61, 55, 135},
|
|
{52, 48, 112},
|
|
{49, 45, 101},
|
|
{105, 153, 203},
|
|
{0, 64, 255},
|
|
{255, 158, 74},
|
|
{8, 18, 46},
|
|
};
|
|
uint8_t infinityCustomColorCount = 8;
|
|
|
|
bool isMasterBuild() {
|
|
#ifdef WLED_INFINITY_MASTER
|
|
return true;
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
bool isNodeBuild() {
|
|
#ifdef WLED_INFINITY_NODE
|
|
return true;
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
uint64_t localTimeUs() {
|
|
#ifdef ARDUINO_ARCH_ESP32
|
|
return static_cast<uint64_t>(esp_timer_get_time());
|
|
#else
|
|
return static_cast<uint64_t>(micros());
|
|
#endif
|
|
}
|
|
|
|
uint64_t masterTimeUs() {
|
|
if (isMasterBuild()) return localTimeUs();
|
|
return static_cast<uint64_t>(static_cast<int64_t>(localTimeUs()) + masterOffsetUs);
|
|
}
|
|
|
|
uint8_t clampMode(uint8_t mode) {
|
|
mode = rfpEffectSanitize(mode);
|
|
const uint8_t count = strip.getModeCount();
|
|
if (count == 0) return 0;
|
|
if (mode < count && strncmp_P("RSVD", strip.getModeData(mode), 4)) return mode;
|
|
return rfpEffectFallback();
|
|
}
|
|
|
|
String ipToString(const IPAddress& ip) {
|
|
return String(ip[0]) + '.' + String(ip[1]) + '.' + String(ip[2]) + '.' + String(ip[3]);
|
|
}
|
|
|
|
void copyNodeId(char* dest, size_t len, const char* src) {
|
|
if (!dest || len == 0) return;
|
|
if (!src || src[0] == 0) src = "auto";
|
|
strlcpy(dest, src, len);
|
|
}
|
|
|
|
void refreshRuntimeNodeId() {
|
|
if (strcmp(infinityConfiguredNodeId, "auto") != 0 && infinityConfiguredNodeId[0] != 0) {
|
|
copyNodeId(infinityRuntimeNodeId, sizeof(infinityRuntimeNodeId), infinityConfiguredNodeId);
|
|
return;
|
|
}
|
|
|
|
if (isMasterBuild()) {
|
|
copyNodeId(infinityRuntimeNodeId, sizeof(infinityRuntimeNodeId), "master");
|
|
return;
|
|
}
|
|
|
|
IPAddress ip = Network.localIP();
|
|
if (ip[0] == 192 && ip[1] == 168 && ip[2] == 178 && ip[3] >= 11 && ip[3] <= 16) {
|
|
snprintf(infinityRuntimeNodeId, sizeof(infinityRuntimeNodeId), "node-%02u", unsigned(ip[3] - 10));
|
|
} else {
|
|
copyNodeId(infinityRuntimeNodeId, sizeof(infinityRuntimeNodeId), "node-xx");
|
|
}
|
|
}
|
|
|
|
uint16_t dmxTransitionMs(uint8_t value) {
|
|
return static_cast<uint16_t>(value) * 40U;
|
|
}
|
|
|
|
void hsvToRgb(uint8_t hue, uint8_t sat, uint8_t value, uint8_t rgb[3]) {
|
|
byte temp[3] = {0, 0, 0};
|
|
colorHStoRGB(static_cast<uint16_t>(hue) * 257U, sat, temp);
|
|
rgb[0] = (static_cast<uint16_t>(temp[0]) * value) / 255U;
|
|
rgb[1] = (static_cast<uint16_t>(temp[1]) * value) / 255U;
|
|
rgb[2] = (static_cast<uint16_t>(temp[2]) * value) / 255U;
|
|
}
|
|
|
|
uint32_t rgbToColor(const uint8_t rgb[3]) {
|
|
return RGBW32(rgb[0], rgb[1], rgb[2], 0);
|
|
}
|
|
|
|
uint8_t scaleByte(uint8_t value, uint8_t scale) {
|
|
return (static_cast<uint16_t>(value) * scale) / 255U;
|
|
}
|
|
|
|
uint32_t scaleColor(uint32_t color, uint8_t scale) {
|
|
return RGBW32(scaleByte(R(color), scale), scaleByte(G(color), scale), scaleByte(B(color), scale), scaleByte(W(color), scale));
|
|
}
|
|
|
|
bool isDirectSpatialColorRole(SpatialRole role) {
|
|
return role == SPATIAL_ROLE_PRIMARY || role == SPATIAL_ROLE_SECONDARY || role == SPATIAL_ROLE_RAINBOW;
|
|
}
|
|
|
|
uint32_t composeSpatialColor(uint32_t base, uint32_t layer, uint8_t amount, uint8_t strength, SpatialRole role) {
|
|
if (isDirectSpatialColorRole(role)) {
|
|
// Direct color roles are literal output colors. Strength only dims
|
|
// effect-mask modes; it must not shift Primary/Secondary/Rainbow hues.
|
|
if (amount == 255) return layer;
|
|
return scaleColor(layer, amount);
|
|
}
|
|
if (role == SPATIAL_ROLE_BLACK) return BLACK;
|
|
const uint8_t mask = scaleByte(amount, strength);
|
|
return scaleColor(base, mask);
|
|
}
|
|
|
|
uint8_t normalizeSpatialOption(uint8_t mode, uint8_t option) {
|
|
if (mode == INFINITY_SPATIAL_SCAN) {
|
|
return option > INFINITY_SPATIAL_OPTION_BANDS ? INFINITY_SPATIAL_OPTION_LINE : option;
|
|
}
|
|
if (mode == INFINITY_SPATIAL_SUNBURST) {
|
|
return option > INFINITY_SPATIAL_OPTION_RAINBOW ? INFINITY_SPATIAL_OPTION_LINE : option;
|
|
}
|
|
return INFINITY_SPATIAL_OPTION_LINE;
|
|
}
|
|
|
|
SpatialSample spatialSampleOf(uint8_t amount, SpatialRole role = SPATIAL_ROLE_GRADIENT, uint8_t hue = 0) {
|
|
return { amount, role, hue };
|
|
}
|
|
|
|
bool parseNodeColumn(uint8_t& column) {
|
|
if (strncmp(infinityRuntimeNodeId, "node-", 5) == 0) {
|
|
const uint8_t n = atoi(infinityRuntimeNodeId + 5);
|
|
if (n >= 1 && n <= INFINITY_NODE_COUNT) {
|
|
column = n - 1;
|
|
return true;
|
|
}
|
|
}
|
|
IPAddress ip = Network.localIP();
|
|
if (ip[0] == 192 && ip[1] == 168 && ip[2] == 178 && ip[3] >= 11 && ip[3] <= 16) {
|
|
column = ip[3] - 11;
|
|
return true;
|
|
}
|
|
column = 0;
|
|
return false;
|
|
}
|
|
|
|
float smoothstepf(float edge0, float edge1, float x) {
|
|
if (edge0 == edge1) return x < edge0 ? 0.0f : 1.0f;
|
|
x = (x - edge0) / (edge1 - edge0);
|
|
if (x < 0.0f) x = 0.0f;
|
|
if (x > 1.0f) x = 1.0f;
|
|
return x * x * (3.0f - 2.0f * x);
|
|
}
|
|
|
|
uint8_t floatToAmount(float value) {
|
|
if (value <= 0.0f) return 0;
|
|
if (value >= 1.0f) return 255;
|
|
return static_cast<uint8_t>(value * 255.0f + 0.5f);
|
|
}
|
|
|
|
uint8_t speedToBpm(uint8_t speed) {
|
|
constexpr uint16_t range = INFINITY_BPM_MAX - INFINITY_BPM_MIN;
|
|
return INFINITY_BPM_MIN + ((static_cast<uint16_t>(speed) * range + 127U) / 255U);
|
|
}
|
|
|
|
float spatialBeatPosition(uint64_t timeUs, uint8_t speed) {
|
|
const float seconds = static_cast<float>(timeUs % 60000000ULL) / 1000000.0f;
|
|
const float cyclesPerSecond = static_cast<float>(speedToBpm(speed)) / 60.0f;
|
|
return seconds * cyclesPerSecond;
|
|
}
|
|
|
|
float spatialStepPosition(uint64_t timeUs, uint8_t speed) {
|
|
// Two visible animation phases make one measured on/off panel cycle match the BPM value.
|
|
return spatialBeatPosition(timeUs, speed) * 2.0f;
|
|
}
|
|
|
|
uint32_t spatialBeatIndex(uint64_t timeUs, uint8_t speed) {
|
|
return static_cast<uint32_t>(floorf(spatialBeatPosition(timeUs, speed)));
|
|
}
|
|
|
|
float spatialBeatFrac(uint64_t timeUs, uint8_t speed) {
|
|
const float beat = spatialBeatPosition(timeUs, speed);
|
|
return beat - floorf(beat);
|
|
}
|
|
|
|
uint32_t spatialStepIndex(uint64_t timeUs, uint8_t speed) {
|
|
return static_cast<uint32_t>(floorf(spatialStepPosition(timeUs, speed)));
|
|
}
|
|
|
|
uint8_t strobeAmount(uint64_t timeUs, uint8_t speed, uint8_t pulseWidth) {
|
|
const float duty = 0.01f + (static_cast<float>(pulseWidth) / 255.0f) * 0.34f;
|
|
const float phase = spatialBeatPosition(timeUs, speed) * 8.0f;
|
|
return (phase - floorf(phase)) < duty ? 255 : 0;
|
|
}
|
|
|
|
uint8_t serpentinePanelIndex(uint8_t column, uint8_t row) {
|
|
return (row & 0x01)
|
|
? (row * INFINITY_NODE_COUNT + (INFINITY_NODE_COUNT - 1 - column))
|
|
: (row * INFINITY_NODE_COUNT + column);
|
|
}
|
|
|
|
uint8_t mirroredSerpentinePanelIndex(uint8_t column, uint8_t row, uint8_t variant) {
|
|
if (variant == 1 || variant == 3) column = INFINITY_NODE_COUNT - 1 - column;
|
|
if (variant == 2 || variant == 3) row = INFINITY_PANEL_COUNT - 1 - row;
|
|
return serpentinePanelIndex(column, row);
|
|
}
|
|
|
|
uint8_t chainLengthFromSize(uint8_t size) {
|
|
if (size <= 64) return max<uint8_t>(1, size / 16);
|
|
return min<uint8_t>(18, 4 + ((static_cast<uint16_t>(size - 64) * 14U + 95U) / 191U));
|
|
}
|
|
|
|
uint8_t snakeLengthFromSize(uint8_t size) {
|
|
return min<uint8_t>(17, 3 + max<uint8_t>(1, size / 64));
|
|
}
|
|
|
|
bool snakeContains(uint8_t position, uint8_t head, uint8_t length, uint8_t pathLen) {
|
|
for (uint8_t offset = 0; offset < length; offset++) {
|
|
if (position == (head + pathLen - (offset % pathLen)) % pathLen) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
uint32_t spatialHash(uint32_t value) {
|
|
value ^= value >> 16;
|
|
value *= 0x7feb352dUL;
|
|
value ^= value >> 15;
|
|
value *= 0x846ca68bUL;
|
|
value ^= value >> 16;
|
|
return value;
|
|
}
|
|
|
|
uint8_t spawnSnakeApple(uint32_t seed, uint16_t generation, uint16_t step, uint8_t length, uint8_t pathLen) {
|
|
const uint8_t head = (step + (seed % pathLen)) % pathLen;
|
|
uint8_t candidate = spatialHash(seed ^ (static_cast<uint32_t>(generation) * 0x9e3779b9UL) ^ step) % pathLen;
|
|
for (uint8_t tries = 0; tries < pathLen; tries++) {
|
|
if (!snakeContains(candidate, head, length, pathLen)) return candidate;
|
|
candidate = (candidate + 5) % pathLen;
|
|
}
|
|
return (head + length + 1) % pathLen;
|
|
}
|
|
|
|
uint8_t snakeAppleAtStep(uint32_t seed, uint16_t localStep, uint8_t length, uint8_t pathLen) {
|
|
uint16_t generation = 0;
|
|
uint8_t apple = spawnSnakeApple(seed, generation, 0, length, pathLen);
|
|
for (uint16_t step = 0; step <= localStep; step++) {
|
|
const uint8_t head = (step + (seed % pathLen)) % pathLen;
|
|
if (head == apple) {
|
|
generation++;
|
|
apple = spawnSnakeApple(seed, generation, step + 1, length, pathLen);
|
|
}
|
|
}
|
|
return apple;
|
|
}
|
|
|
|
uint8_t gridPanelIndex(uint8_t column, uint8_t row) {
|
|
return row * INFINITY_NODE_COUNT + column;
|
|
}
|
|
|
|
uint8_t gridColumn(uint8_t index) {
|
|
return index % INFINITY_NODE_COUNT;
|
|
}
|
|
|
|
uint8_t gridRow(uint8_t index) {
|
|
return index / INFINITY_NODE_COUNT;
|
|
}
|
|
|
|
bool snakeBodyContains(const uint8_t* body, uint8_t length, uint8_t position, uint8_t skipTail = 0) {
|
|
const uint8_t end = length > skipTail ? length - skipTail : 0;
|
|
for (uint8_t i = 0; i < end; i++) {
|
|
if (body[i] == position) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
uint8_t snakeSpawnApple(const uint8_t* body, uint8_t length, uint32_t seed, uint16_t generation) {
|
|
uint8_t candidate = spatialHash(seed ^ (static_cast<uint32_t>(generation) * 0x9e3779b9UL)) % (INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT);
|
|
for (uint8_t tries = 0; tries < INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT; tries++) {
|
|
if (!snakeBodyContains(body, length, candidate)) return candidate;
|
|
candidate = (candidate + 7) % (INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT);
|
|
}
|
|
return body[0];
|
|
}
|
|
|
|
uint8_t snakeNeighbor(uint8_t position, uint8_t direction, bool& valid) {
|
|
const uint8_t col = gridColumn(position);
|
|
const uint8_t row = gridRow(position);
|
|
valid = true;
|
|
switch (direction) {
|
|
case 0: if (col + 1 < INFINITY_NODE_COUNT) return gridPanelIndex(col + 1, row); break;
|
|
case 1: if (col > 0) return gridPanelIndex(col - 1, row); break;
|
|
case 2: if (row + 1 < INFINITY_PANEL_COUNT) return gridPanelIndex(col, row + 1); break;
|
|
case 3: if (row > 0) return gridPanelIndex(col, row - 1); break;
|
|
}
|
|
valid = false;
|
|
return position;
|
|
}
|
|
|
|
uint8_t snakeDistance(uint8_t a, uint8_t b) {
|
|
const int8_t dx = static_cast<int8_t>(gridColumn(a)) - static_cast<int8_t>(gridColumn(b));
|
|
const int8_t dy = static_cast<int8_t>(gridRow(a)) - static_cast<int8_t>(gridRow(b));
|
|
return abs(dx) + abs(dy);
|
|
}
|
|
|
|
void snakeReset(uint8_t* body, uint8_t& length, uint8_t targetLength, uint8_t& apple, uint16_t& generation, uint32_t seed, uint16_t epoch) {
|
|
body[0] = spatialHash(seed ^ (static_cast<uint32_t>(epoch) * 0x45d9f3bUL)) % (INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT);
|
|
length = min<uint8_t>(targetLength, 3);
|
|
for (uint8_t i = 1; i < length; i++) body[i] = body[0];
|
|
generation++;
|
|
apple = snakeSpawnApple(body, length, seed ^ epoch, generation);
|
|
}
|
|
|
|
void snakeStateAt(uint16_t localStep, uint8_t targetLength, uint32_t seed, uint8_t* body, uint8_t& length, uint8_t& apple) {
|
|
uint16_t generation = 0;
|
|
snakeReset(body, length, targetLength, apple, generation, seed, 0);
|
|
static const uint8_t baseOrder[4] = {0, 2, 1, 3}; // right, down, left, up
|
|
for (uint16_t step = 0; step < localStep; step++) {
|
|
uint8_t best = body[0];
|
|
uint8_t bestDistance = 255;
|
|
const uint8_t orderOffset = spatialHash(seed ^ (static_cast<uint32_t>(step) * 0x9e3779b1UL) ^ generation) & 0x03;
|
|
for (uint8_t i = 0; i < 4; i++) {
|
|
bool valid = false;
|
|
const uint8_t next = snakeNeighbor(body[0], baseOrder[(i + orderOffset) & 0x03], valid);
|
|
if (!valid) continue;
|
|
const bool willEat = next == apple;
|
|
if (snakeBodyContains(body, length, next, willEat ? 0 : 1)) continue;
|
|
const uint8_t distance = snakeDistance(next, apple);
|
|
if (distance < bestDistance) {
|
|
bestDistance = distance;
|
|
best = next;
|
|
}
|
|
}
|
|
if (best == body[0] && body[0] != apple) {
|
|
snakeReset(body, length, targetLength, apple, generation, seed, step + 1);
|
|
continue;
|
|
}
|
|
const bool ate = best == apple;
|
|
const uint8_t newLength = ate ? min<uint8_t>(targetLength, length + 1) : length;
|
|
for (int8_t i = static_cast<int8_t>(newLength) - 1; i > 0; i--) body[i] = body[i - 1];
|
|
body[0] = best;
|
|
length = newLength;
|
|
if (ate) {
|
|
generation++;
|
|
apple = snakeSpawnApple(body, length, seed ^ step, generation);
|
|
}
|
|
}
|
|
}
|
|
|
|
uint16_t triangleStep(uint32_t step, uint8_t maxIndex) {
|
|
if (maxIndex == 0) return 0;
|
|
const uint16_t period = static_cast<uint16_t>(maxIndex) * 2U;
|
|
uint16_t phase = step % period;
|
|
if (phase > maxIndex) phase = period - phase;
|
|
return phase;
|
|
}
|
|
|
|
int16_t schlaengelnPingPongPosition(uint32_t step, uint8_t offset, uint8_t maxIndex) {
|
|
if (maxIndex == 0) return 0;
|
|
const uint16_t period = static_cast<uint16_t>(maxIndex) * 2U;
|
|
uint16_t phase = (step + period - (offset % period)) % period;
|
|
if (phase > maxIndex) phase = period - phase;
|
|
return phase;
|
|
}
|
|
|
|
void panelLedPosition(uint16_t led, float& x, float& y, uint8_t& side) {
|
|
if (led < 25) {
|
|
side = 0; // top, left -> right
|
|
x = (led + 0.5f) / 25.0f;
|
|
y = 0.0f;
|
|
return;
|
|
}
|
|
if (led < 52) {
|
|
side = 1; // right, top -> bottom
|
|
x = 1.0f;
|
|
y = (led - 25 + 0.5f) / 27.0f;
|
|
return;
|
|
}
|
|
if (led < 79) {
|
|
side = 2; // bottom, right -> left
|
|
x = 1.0f - ((led - 52 + 0.5f) / 27.0f);
|
|
y = 1.0f;
|
|
return;
|
|
}
|
|
side = 3; // left, bottom -> top
|
|
x = 0.0f;
|
|
y = 1.0f - ((led - 79 + 0.5f) / 27.0f);
|
|
}
|
|
|
|
void applyPanelTransform(uint8_t column, uint8_t row, float& x, float& y) {
|
|
if (row >= INFINITY_PANEL_COUNT || column >= INFINITY_NODE_COUNT) return;
|
|
const PanelTransform& transform = INFINITY_PANEL_TRANSFORMS[row][column];
|
|
if (transform.mirrorX) x = 1.0f - x;
|
|
if (transform.mirrorY) y = 1.0f - y;
|
|
for (uint8_t i = 0; i < (transform.rotation & 0x03); i++) {
|
|
const float oldX = x;
|
|
x = 1.0f - y;
|
|
y = oldX;
|
|
}
|
|
}
|
|
|
|
void physicalPanelLedPosition(uint8_t column, uint8_t row, uint16_t led, float& x, float& y) {
|
|
float lx = 0.0f, ly = 0.0f;
|
|
uint8_t side = 0;
|
|
panelLedPosition(led, lx, ly, side);
|
|
applyPanelTransform(column, row, lx, ly);
|
|
const float pitch = 1.0f + INFINITY_PANEL_GAP_RATIO;
|
|
x = static_cast<float>(column) * pitch + lx;
|
|
y = static_cast<float>(row) * pitch + ly;
|
|
}
|
|
|
|
void applySunburstPanelTransform(float& x, float& y) {
|
|
// Only Sunburst needs the observed 90-degree-left correction. Keep the
|
|
// global transform neutral so Scan and the other modes stay unrotated.
|
|
for (uint8_t i = 0; i < 3; i++) {
|
|
const float oldX = x;
|
|
x = 1.0f - y;
|
|
y = oldX;
|
|
}
|
|
}
|
|
|
|
void sunburstPanelLedPosition(uint8_t column, uint8_t row, uint16_t led, float& x, float& y) {
|
|
float lx = 0.0f, ly = 0.0f;
|
|
uint8_t side = 0;
|
|
panelLedPosition(led, lx, ly, side);
|
|
applySunburstPanelTransform(lx, ly);
|
|
const float pitch = 1.0f + INFINITY_PANEL_GAP_RATIO;
|
|
x = static_cast<float>(column) * pitch + lx;
|
|
y = static_cast<float>(row) * pitch + ly;
|
|
}
|
|
|
|
void physicalPanelCenter(float& cx, float& cy) {
|
|
const float pitch = 1.0f + INFINITY_PANEL_GAP_RATIO;
|
|
cx = ((static_cast<float>(INFINITY_NODE_COUNT - 1) * pitch) + 1.0f) * 0.5f;
|
|
cy = ((static_cast<float>(INFINITY_PANEL_COUNT - 1) * pitch) + 1.0f) * 0.5f;
|
|
}
|
|
|
|
uint8_t centerPulseAmount(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t speed, uint8_t variant) {
|
|
constexpr float centerRow = 1.0f;
|
|
constexpr float centerCol = 2.5f;
|
|
constexpr float maxDistance = 3.5f;
|
|
const float distance = fabsf(static_cast<float>(row) - centerRow) + fabsf(static_cast<float>(column) - centerCol);
|
|
const float span = maxDistance + 1.0f;
|
|
float front = fmodf(spatialStepPosition(timeUs, speed), span);
|
|
if (variant == INFINITY_SPATIAL_VARIANT_REVERSE || variant == INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE) front = maxDistance - front;
|
|
const float delta = fabsf(distance - front);
|
|
return floatToAmount(1.0f - smoothstepf(0.0f, 0.70f, delta));
|
|
}
|
|
|
|
uint8_t centerPulseOutlineAmount(uint8_t column, uint8_t row, uint16_t led, uint8_t panelAmount, uint8_t variant) {
|
|
float x = 0.0f, y = 0.0f;
|
|
uint8_t side = 0;
|
|
panelLedPosition(led, x, y, side);
|
|
if (variant == INFINITY_SPATIAL_VARIANT_OUTLINE || variant == INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE) {
|
|
if (row == 1 && (column == 2 || column == 3)) {
|
|
return (side == 0 || side == 2) ? panelAmount : 0;
|
|
}
|
|
if (column == 0) return side == 3 ? panelAmount : 0;
|
|
if (column == 5) return side == 1 ? panelAmount : 0;
|
|
if (row == 0) return side == 0 ? panelAmount : 0;
|
|
if (row == 2) return side == 2 ? panelAmount : 0;
|
|
return panelAmount;
|
|
}
|
|
return panelAmount;
|
|
}
|
|
|
|
uint8_t checkerdAmount(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs, uint8_t variant, uint8_t speed) {
|
|
const uint8_t parity = (row + column) & 0x01;
|
|
const uint16_t step = static_cast<uint16_t>(spatialStepIndex(timeUs, speed));
|
|
if (variant == 1 || variant == 2) {
|
|
float x = 0.0f, y = 0.0f;
|
|
uint8_t side = 0;
|
|
panelLedPosition(led, x, y, side);
|
|
const bool slash = variant == 2 && (step & 0x01);
|
|
const bool first = slash ? (y <= 1.0f - x) : (y <= x);
|
|
return ((((parity + step) & 0x01) == 0) == first) ? 255 : 0;
|
|
}
|
|
return (((parity + step) & 0x01) == 0) ? 255 : 0;
|
|
}
|
|
|
|
uint8_t waveLineAmount(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t direction, uint8_t speed) {
|
|
static const uint8_t triangle[4] = {0, 1, 2, 1};
|
|
const int32_t step = static_cast<int32_t>(spatialStepIndex(timeUs, speed));
|
|
if (direction == INFINITY_SPATIAL_DIRECTION_TTB || direction == INFINITY_SPATIAL_DIRECTION_BTT) {
|
|
const int32_t phase = direction == INFINITY_SPATIAL_DIRECTION_TTB ? step : -step;
|
|
const uint8_t target = static_cast<uint8_t>(roundf(triangle[((static_cast<int32_t>(row) - phase) % 4 + 4) % 4] * ((INFINITY_NODE_COUNT - 1) / 2.0f) / 2.0f));
|
|
return column == min<uint8_t>(target, INFINITY_NODE_COUNT - 1) ? 255 : 0;
|
|
}
|
|
const int32_t phase = direction == INFINITY_SPATIAL_DIRECTION_RTL ? -step : step;
|
|
const uint8_t target = static_cast<uint8_t>(roundf(triangle[((static_cast<int32_t>(column) - phase) % 4 + 4) % 4] * ((INFINITY_PANEL_COUNT - 1) / 2.0f)));
|
|
return row == min<uint8_t>(target, INFINITY_PANEL_COUNT - 1) ? 255 : 0;
|
|
}
|
|
|
|
uint8_t arrowAmount(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t direction, uint8_t speed, uint8_t size) {
|
|
const bool horizontal = !(direction == INFINITY_SPATIAL_DIRECTION_TTB || direction == INFINITY_SPATIAL_DIRECTION_BTT);
|
|
const uint8_t majorCount = horizontal ? INFINITY_NODE_COUNT : INFINITY_PANEL_COUNT;
|
|
const uint8_t minorCount = horizontal ? INFINITY_PANEL_COUNT : INFINITY_NODE_COUNT;
|
|
const uint8_t major = horizontal ? column : row;
|
|
const uint8_t minor = horizontal ? row : column;
|
|
const uint8_t gap = max<uint8_t>(1, 1 + (size / 86)) - 1;
|
|
const uint8_t span = 3 + gap;
|
|
const int32_t movement = static_cast<int32_t>(spatialStepIndex(timeUs, speed));
|
|
const float middleMinor = (static_cast<float>(minorCount) - 1.0f) / 2.0f;
|
|
const uint8_t band = fabsf(static_cast<float>(minor) - middleMinor) <= 0.55f ? 0 : 1;
|
|
const bool orientationRight = direction == INFINITY_SPATIAL_DIRECTION_LTR || direction == INFINITY_SPATIAL_DIRECTION_TTB || direction == INFINITY_SPATIAL_DIRECTION_OUTWARD;
|
|
const uint8_t target = orientationRight ? (band == 0 ? 1 : 0) : (band == 0 ? 1 : 2);
|
|
const int32_t local = orientationRight
|
|
? (static_cast<int32_t>(major) - movement)
|
|
: (static_cast<int32_t>(major) + movement);
|
|
return majorCount > 0 && ((local % span + span) % span) == target ? 255 : 0;
|
|
}
|
|
|
|
void scanVector(uint16_t angle, float& vx, float& vy) {
|
|
const float radians = static_cast<float>(angle) * 0.01745329252f;
|
|
vx = cosf(radians);
|
|
vy = sinf(radians);
|
|
}
|
|
|
|
uint8_t scanAmount(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs, uint8_t speed, uint8_t size, uint16_t angle, uint8_t option, uint8_t direction) {
|
|
float x = 0.0f, y = 0.0f;
|
|
uint8_t side = 0;
|
|
panelLedPosition(led, x, y, side);
|
|
float vx = 0.0f, vy = 0.0f;
|
|
const bool vertical = direction == INFINITY_SPATIAL_DIRECTION_TTB || direction == INFINITY_SPATIAL_DIRECTION_BTT;
|
|
scanVector((angle + (vertical ? 90 : 0)) % 360, vx, vy);
|
|
const float progress = (static_cast<float>(column) + x) * vx + (static_cast<float>(row) + y) * vy;
|
|
const float p00 = 0.0f;
|
|
const float p10 = static_cast<float>(INFINITY_NODE_COUNT) * vx;
|
|
const float p01 = static_cast<float>(INFINITY_PANEL_COUNT) * vy;
|
|
const float p11 = p10 + p01;
|
|
const float minProgress = fminf(fminf(p00, p10), fminf(p01, p11));
|
|
const float maxProgress = fmaxf(fmaxf(p00, p10), fmaxf(p01, p11));
|
|
const float width = 0.15f + (static_cast<float>(size) / 255.0f) * (option == INFINITY_SPATIAL_OPTION_BANDS ? 1.60f : 0.85f);
|
|
const float travel = maxProgress - minProgress;
|
|
if (travel <= 0.001f) return 0;
|
|
float phase = fmodf(spatialStepPosition(timeUs, speed), travel);
|
|
if (direction == INFINITY_SPATIAL_DIRECTION_PINGPONG) {
|
|
phase = fmodf(spatialStepPosition(timeUs, speed), travel * 2.0f);
|
|
if (phase > travel) phase = (travel * 2.0f) - phase;
|
|
} else if (direction == INFINITY_SPATIAL_DIRECTION_RTL || direction == INFINITY_SPATIAL_DIRECTION_BTT) {
|
|
phase = travel - phase;
|
|
}
|
|
const float center = minProgress + max(0.0f, min(travel, phase));
|
|
if (option == INFINITY_SPATIAL_OPTION_BANDS) {
|
|
const float period = width * 2.0f + 0.35f;
|
|
const float d = fabsf(fmodf(progress - center + period * 64.0f, period) - period * 0.5f);
|
|
return floatToAmount(1.0f - smoothstepf(width * 0.45f, width * 0.75f, d));
|
|
}
|
|
return floatToAmount(1.0f - smoothstepf(width * 0.5f, width * 0.5f + 0.55f, fabsf(progress - center)));
|
|
}
|
|
|
|
SpatialSample snakeSample(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t speed, uint8_t size) {
|
|
const uint8_t panelIndex = gridPanelIndex(column, row);
|
|
const uint8_t targetLength = snakeLengthFromSize(size);
|
|
const uint16_t localStep = spatialStepIndex(timeUs, speed) % 240U;
|
|
uint8_t body[INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT] = {0};
|
|
uint8_t length = 0;
|
|
uint8_t apple = 0;
|
|
snakeStateAt(localStep, targetLength, infinityScene.seed, body, length, apple);
|
|
for (uint8_t i = 0; i < length; i++) {
|
|
if (panelIndex == body[i]) {
|
|
return spatialSampleOf(255, SPATIAL_ROLE_PRIMARY);
|
|
}
|
|
}
|
|
return panelIndex == apple ? spatialSampleOf(255, SPATIAL_ROLE_SECONDARY) : spatialSampleOf(0);
|
|
}
|
|
|
|
SpatialSample schlaengelnSample(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t speed, uint8_t size, uint8_t variant, uint8_t direction) {
|
|
const uint8_t pathLen = INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT;
|
|
const uint8_t panelIndex = mirroredSerpentinePanelIndex(column, row, variant);
|
|
const uint8_t length = chainLengthFromSize(size);
|
|
const uint32_t step = spatialStepIndex(timeUs, speed);
|
|
for (uint8_t offset = 0; offset < length; offset++) {
|
|
uint8_t pos = 0;
|
|
if (direction == INFINITY_SPATIAL_DIRECTION_PINGPONG) {
|
|
pos = schlaengelnPingPongPosition(step, offset, pathLen - 1);
|
|
} else if (direction == INFINITY_SPATIAL_DIRECTION_RTL) {
|
|
const uint8_t head = (pathLen - 1) - (step % pathLen);
|
|
pos = (head + pathLen - (offset % pathLen)) % pathLen;
|
|
} else {
|
|
pos = (step + pathLen - (offset % pathLen)) % pathLen;
|
|
}
|
|
if (panelIndex == pos) return spatialSampleOf(255 - min<uint8_t>(200, offset * 30));
|
|
}
|
|
return spatialSampleOf(0);
|
|
}
|
|
|
|
SpatialSample sunburstSample(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs, uint8_t speed, uint8_t variant, uint8_t option) {
|
|
float x = 0.0f, y = 0.0f, cx = 0.0f, cy = 0.0f;
|
|
sunburstPanelLedPosition(column, row, led, x, y);
|
|
physicalPanelCenter(cx, cy);
|
|
const float dx = x - cx;
|
|
const float dy = y - cy;
|
|
float wobble = 0.0f;
|
|
if (variant == 1) wobble = sinf(spatialBeatPosition(timeUs, speed) * INFINITY_TAU) * 0.18f;
|
|
const float rotation = variant == 2 ? spatialBeatPosition(timeUs, speed) * (INFINITY_TAU / 24.0f) : 0.0f;
|
|
float angle = atan2f(dy, dx) + wobble - rotation;
|
|
if (angle < 0.0f) angle += INFINITY_TAU;
|
|
while (angle >= INFINITY_TAU) angle -= INFINITY_TAU;
|
|
constexpr float rays = 12.0f; // 12 bright rays + 12 dark gaps.
|
|
const bool active = cosf(angle * rays) >= 0.0f;
|
|
if (option == INFINITY_SPATIAL_OPTION_TWO_COLOR) {
|
|
return spatialSampleOf(255, active ? SPATIAL_ROLE_PRIMARY : SPATIAL_ROLE_SECONDARY);
|
|
}
|
|
if (option == INFINITY_SPATIAL_OPTION_RAINBOW) {
|
|
const uint8_t hue = static_cast<uint8_t>((angle / INFINITY_TAU) * 255.0f) + static_cast<uint8_t>(spatialBeatPosition(timeUs, speed) * 8.0f);
|
|
return active ? spatialSampleOf(255, SPATIAL_ROLE_RAINBOW, hue) : spatialSampleOf(255, SPATIAL_ROLE_BLACK);
|
|
}
|
|
return active ? spatialSampleOf(255) : spatialSampleOf(255, SPATIAL_ROLE_BLACK);
|
|
}
|
|
|
|
SpatialSample spatialSample(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs) {
|
|
switch (infinityScene.spatialMode) {
|
|
case INFINITY_SPATIAL_CENTER_PULSE:
|
|
return spatialSampleOf(centerPulseOutlineAmount(column, row, led, centerPulseAmount(column, row, timeUs, infinityScene.speed, infinityScene.spatialVariant), infinityScene.spatialVariant));
|
|
case INFINITY_SPATIAL_CHECKER:
|
|
return spatialSampleOf(checkerdAmount(column, row, led, timeUs, infinityScene.spatialVariant, infinityScene.speed));
|
|
case INFINITY_SPATIAL_ARROW:
|
|
return spatialSampleOf(arrowAmount(column, row, timeUs, infinityScene.spatialDirection, infinityScene.speed, infinityScene.spatialSize));
|
|
case INFINITY_SPATIAL_SCAN:
|
|
return spatialSampleOf(scanAmount(column, row, led, timeUs, infinityScene.speed, infinityScene.spatialSize, infinityScene.spatialAngle, infinityScene.spatialOption, infinityScene.spatialDirection));
|
|
case INFINITY_SPATIAL_SNAKE:
|
|
return snakeSample(column, row, timeUs, infinityScene.speed, infinityScene.spatialSize);
|
|
case INFINITY_SPATIAL_WAVE_LINE:
|
|
return spatialSampleOf(waveLineAmount(column, row, timeUs, infinityScene.spatialDirection, infinityScene.speed));
|
|
case INFINITY_SPATIAL_STROBE:
|
|
return spatialSampleOf(strobeAmount(timeUs, infinityScene.speed, infinityScene.spatialSize));
|
|
case INFINITY_SPATIAL_SCHLAENGELN:
|
|
return schlaengelnSample(column, row, timeUs, infinityScene.speed, infinityScene.spatialSize, infinityScene.spatialVariant, infinityScene.spatialDirection);
|
|
case INFINITY_SPATIAL_SUNBURST:
|
|
return sunburstSample(column, row, led, timeUs, infinityScene.speed, infinityScene.spatialVariant, infinityScene.spatialOption);
|
|
default:
|
|
return spatialSampleOf(0);
|
|
}
|
|
}
|
|
|
|
uint32_t spatialLayerColor(Segment& seg, uint16_t led, SpatialSample sample) {
|
|
if (sample.role == SPATIAL_ROLE_PRIMARY) return rgbToColor(infinityScene.primary);
|
|
if (sample.role == SPATIAL_ROLE_SECONDARY) return rgbToColor(infinityScene.secondary);
|
|
if (sample.role == SPATIAL_ROLE_BLACK) return BLACK;
|
|
if (sample.role == SPATIAL_ROLE_RAINBOW) {
|
|
uint8_t rgb[3] = {0, 0, 0};
|
|
hsvToRgb(sample.hue, 255, 255, rgb);
|
|
return rgbToColor(rgb);
|
|
}
|
|
if (infinityScene.palette > 0) {
|
|
seg.setCurrentPalette();
|
|
const uint16_t idx = (static_cast<uint32_t>(led) * 255U) / (INFINITY_LEDS_PER_PANEL - 1U);
|
|
return seg.color_from_palette(idx + ((masterTimeUs() / 20000ULL) & 0xFF), false, true, 0, sample.amount);
|
|
}
|
|
return color_blend(rgbToColor(infinityScene.secondary), rgbToColor(infinityScene.primary), sample.amount);
|
|
}
|
|
|
|
bool spatialExactRgb(SpatialSample sample, uint8_t rgb[3]) {
|
|
if (sample.role == SPATIAL_ROLE_PRIMARY) {
|
|
memcpy(rgb, infinityScene.primary, 3);
|
|
return true;
|
|
}
|
|
if (sample.role == SPATIAL_ROLE_SECONDARY) {
|
|
memcpy(rgb, infinityScene.secondary, 3);
|
|
return true;
|
|
}
|
|
if (sample.role == SPATIAL_ROLE_RAINBOW) {
|
|
hsvToRgb(sample.hue, 255, 255, rgb);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void setSegmentName(Segment& seg, const char* name) {
|
|
if (seg.name && strcmp(seg.name, name) == 0) return;
|
|
if (seg.name) {
|
|
delete[] seg.name;
|
|
seg.name = nullptr;
|
|
}
|
|
const size_t len = strlen(name);
|
|
seg.name = new(std::nothrow) char[len + 1];
|
|
if (seg.name) strlcpy(seg.name, name, len + 1);
|
|
}
|
|
|
|
void markSceneChanged(uint32_t delayUs = INFINITY_APPLY_DELAY_US) {
|
|
infinityScene.applyAtUs = masterTimeUs() + delayUs;
|
|
infinitySequence++;
|
|
lastSceneSentMs = 0;
|
|
}
|
|
|
|
void updateStripTimebase(uint16_t phase) {
|
|
const int64_t remoteMs = static_cast<int64_t>(masterTimeUs() / 1000ULL) + phase;
|
|
strip.timebase = static_cast<int32_t>(remoteMs - static_cast<int64_t>(millis()));
|
|
}
|
|
|
|
void applySceneToStrip(const InfinityScenePayload& scene) {
|
|
updateStripTimebase(scene.phase);
|
|
|
|
const bool outputEnabled = (scene.flags & 0x01) != 0;
|
|
bri = outputEnabled ? scene.brightness : 0;
|
|
strip.setBrightness(scaledBri(bri), true);
|
|
transitionDelayTemp = scene.transitionMs;
|
|
|
|
if (scene.presetId > 0 && scene.presetId != lastAppliedPreset) {
|
|
applyPreset(scene.presetId, CALL_MODE_NO_NOTIFY);
|
|
lastAppliedPreset = scene.presetId;
|
|
}
|
|
|
|
const uint8_t effect = clampMode(scene.effectId);
|
|
const uint32_t colors[3] = {
|
|
rgbToColor(scene.primary),
|
|
rgbToColor(scene.secondary),
|
|
rgbToColor(scene.tertiary),
|
|
};
|
|
const uint8_t segmentCount = strip.getSegmentsNum();
|
|
|
|
for (uint8_t i = 0; i < segmentCount; i++) {
|
|
Segment& seg = strip.getSegment(i);
|
|
if (!seg.isActive()) continue;
|
|
const uint8_t rowBit = (i < 3) ? (1U << i) : 0x07;
|
|
const bool rowEnabled = (scene.groupMask == 0) || ((scene.groupMask & rowBit) != 0);
|
|
const uint8_t rowDimmer = (i < 3) ? scene.rowDimmer[i] : 255;
|
|
seg.setOpacity(rowEnabled ? rowDimmer : 0);
|
|
const bool effectChanged = seg.mode != effect;
|
|
seg.setMode(effect, effectChanged, false);
|
|
seg.speed = scene.speed;
|
|
seg.intensity = scene.intensity;
|
|
seg.setPalette(scene.palette);
|
|
seg.setColor(0, colors[0]);
|
|
seg.setColor(1, colors[1]);
|
|
seg.setColor(2, colors[2]);
|
|
seg.setOption(SEG_OPTION_REVERSED, (scene.direction & 0x01) != 0);
|
|
seg.custom1 = scene.custom[0];
|
|
seg.custom2 = scene.custom[1];
|
|
seg.custom3 = scene.custom[2];
|
|
}
|
|
|
|
lastAppliedEffect = effect;
|
|
stateUpdated(CALL_MODE_NO_NOTIFY);
|
|
}
|
|
|
|
void maybeApplyPendingScene() {
|
|
if (!hasPendingScene) return;
|
|
if (pendingScene.applyAtUs != 0 && masterTimeUs() + 2000ULL < pendingScene.applyAtUs) return;
|
|
infinityScene = pendingScene;
|
|
hasPendingScene = false;
|
|
applySceneToStrip(infinityScene);
|
|
}
|
|
|
|
bool sendPacket(const IPAddress& target, InfinityPacketType type, const void* payload, uint16_t payloadSize) {
|
|
if (!infinityUdpStarted || !infinityEnabled) return false;
|
|
|
|
InfinityPacketHeader header;
|
|
header.magic = INFINITY_MAGIC;
|
|
header.version = INFINITY_VERSION;
|
|
header.type = static_cast<uint8_t>(type);
|
|
header.payloadSize = payloadSize;
|
|
header.sequence = ++infinitySequence;
|
|
header.masterTimeUs = masterTimeUs();
|
|
|
|
if (infinityUdp.beginPacket(target, infinityPort) == 0) return false;
|
|
infinityUdp.write(reinterpret_cast<const uint8_t*>(&header), sizeof(header));
|
|
if (payload != nullptr && payloadSize > 0) {
|
|
infinityUdp.write(reinterpret_cast<const uint8_t*>(payload), payloadSize);
|
|
}
|
|
const bool sent = infinityUdp.endPacket() != 0;
|
|
if (sent) packetsSent++;
|
|
return sent;
|
|
}
|
|
|
|
void sendToAllNodes(InfinityPacketType type, const void* payload, uint16_t payloadSize) {
|
|
for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) {
|
|
sendPacket(infinityNodeIps[i], type, payload, payloadSize);
|
|
}
|
|
}
|
|
|
|
void sendClockSync() {
|
|
sendToAllNodes(INFINITY_PACKET_CLOCK_SYNC, nullptr, 0);
|
|
}
|
|
|
|
void sendSceneState() {
|
|
sendToAllNodes(INFINITY_PACKET_SCENE_STATE, &infinityScene, sizeof(infinityScene));
|
|
}
|
|
|
|
void sendBeatTrigger() {
|
|
sendToAllNodes(INFINITY_PACKET_BEAT_TRIGGER, &infinityScene, sizeof(infinityScene));
|
|
}
|
|
|
|
void sendNodeStatus() {
|
|
InfinityStatusPayload status = {};
|
|
refreshRuntimeNodeId();
|
|
strlcpy(status.nodeId, infinityRuntimeNodeId, sizeof(status.nodeId));
|
|
status.uptimeMs = millis();
|
|
status.masterOffsetUs = static_cast<int32_t>(masterOffsetUs);
|
|
status.lastSceneSequence = lastSceneSequence;
|
|
status.packetsReceived = packetsReceived;
|
|
status.flags = infinityEnabled ? 0x01 : 0x00;
|
|
status.activeEffect = lastAppliedEffect;
|
|
status.activePreset = lastAppliedPreset;
|
|
sendPacket(infinityMasterIp, INFINITY_PACKET_NODE_STATUS, &status, sizeof(status));
|
|
}
|
|
|
|
void storeNodeStatus(const IPAddress& remote, const InfinityStatusPayload& payload) {
|
|
uint8_t slot = INFINITY_NODE_COUNT;
|
|
for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) {
|
|
if (nodeStatuses[i].ip == remote || strncmp(nodeStatuses[i].nodeId, payload.nodeId, sizeof(nodeStatuses[i].nodeId)) == 0) {
|
|
slot = i;
|
|
break;
|
|
}
|
|
}
|
|
if (slot == INFINITY_NODE_COUNT) {
|
|
for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) {
|
|
if (nodeStatuses[i].lastSeenMs == 0) {
|
|
slot = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (slot == INFINITY_NODE_COUNT) return;
|
|
|
|
nodeStatuses[slot].ip = remote;
|
|
strlcpy(nodeStatuses[slot].nodeId, payload.nodeId, sizeof(nodeStatuses[slot].nodeId));
|
|
nodeStatuses[slot].lastSeenMs = millis();
|
|
nodeStatuses[slot].uptimeMs = payload.uptimeMs;
|
|
nodeStatuses[slot].masterOffsetUs = payload.masterOffsetUs;
|
|
nodeStatuses[slot].lastSceneSequence = payload.lastSceneSequence;
|
|
nodeStatuses[slot].packetsReceived = payload.packetsReceived;
|
|
nodeStatuses[slot].flags = payload.flags;
|
|
nodeStatuses[slot].activeEffect = payload.activeEffect;
|
|
nodeStatuses[slot].activePreset = payload.activePreset;
|
|
}
|
|
|
|
void handleIncomingPacket(const IPAddress& remote, const uint8_t* data, uint16_t len) {
|
|
if (len < sizeof(InfinityPacketHeader)) return;
|
|
const InfinityPacketHeader* header = reinterpret_cast<const InfinityPacketHeader*>(data);
|
|
if (header->magic != INFINITY_MAGIC || header->version != INFINITY_VERSION) return;
|
|
if (sizeof(InfinityPacketHeader) + header->payloadSize > len) return;
|
|
|
|
const uint8_t* payload = data + sizeof(InfinityPacketHeader);
|
|
packetsReceived++;
|
|
|
|
if (isNodeBuild() && (header->type == INFINITY_PACKET_CLOCK_SYNC || header->type == INFINITY_PACKET_SCENE_STATE || header->type == INFINITY_PACKET_BEAT_TRIGGER)) {
|
|
masterOffsetUs = static_cast<int64_t>(header->masterTimeUs) - static_cast<int64_t>(localTimeUs());
|
|
}
|
|
|
|
switch (header->type) {
|
|
case INFINITY_PACKET_CLOCK_SYNC:
|
|
updateStripTimebase(infinityScene.phase);
|
|
break;
|
|
|
|
case INFINITY_PACKET_SCENE_STATE:
|
|
if (isNodeBuild() && header->payloadSize >= INFINITY_SCENE_PAYLOAD_LEGACY_SIZE && header->payloadSize <= sizeof(InfinityScenePayload)) {
|
|
pendingScene = infinityScene;
|
|
memcpy(&pendingScene, payload, header->payloadSize);
|
|
hasPendingScene = true;
|
|
lastSceneSequence = header->sequence;
|
|
}
|
|
break;
|
|
|
|
case INFINITY_PACKET_BEAT_TRIGGER:
|
|
if (isNodeBuild()) {
|
|
strip.timebase = -static_cast<int32_t>(millis());
|
|
for (uint8_t i = 0; i < strip.getSegmentsNum(); i++) strip.getSegment(i).markForReset();
|
|
}
|
|
break;
|
|
|
|
case INFINITY_PACKET_NODE_STATUS:
|
|
if (isMasterBuild() && header->payloadSize == sizeof(InfinityStatusPayload)) {
|
|
InfinityStatusPayload status;
|
|
memcpy(&status, payload, sizeof(status));
|
|
storeNodeStatus(remote, status);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void receivePackets() {
|
|
if (!infinityUdpStarted || !infinityEnabled) return;
|
|
for (uint8_t i = 0; i < 6; i++) {
|
|
const int packetSize = infinityUdp.parsePacket();
|
|
if (packetSize <= 0) return;
|
|
if (packetSize > 256) {
|
|
while (infinityUdp.available()) infinityUdp.read();
|
|
continue;
|
|
}
|
|
uint8_t buffer[256];
|
|
const int len = infinityUdp.read(buffer, sizeof(buffer));
|
|
if (len > 0) handleIncomingPacket(infinityUdp.remoteIP(), buffer, static_cast<uint16_t>(len));
|
|
}
|
|
}
|
|
|
|
uint8_t jsonU8(JsonVariant value, uint8_t current) {
|
|
if (value.isNull()) return current;
|
|
int v = value.as<int>();
|
|
if (v < 0) v = 0;
|
|
if (v > 255) v = 255;
|
|
return static_cast<uint8_t>(v);
|
|
}
|
|
|
|
uint16_t jsonU16(JsonVariant value, uint16_t current) {
|
|
if (value.isNull()) return current;
|
|
long v = value.as<long>();
|
|
if (v < 0) v = 0;
|
|
if (v > 65535) v = 65535;
|
|
return static_cast<uint16_t>(v);
|
|
}
|
|
|
|
uint32_t jsonU32(JsonVariant value, uint32_t current) {
|
|
if (value.isNull()) return current;
|
|
long v = value.as<long>();
|
|
if (v < 0) v = 0;
|
|
return static_cast<uint32_t>(v);
|
|
}
|
|
|
|
void readRgb(JsonVariant value, uint8_t rgb[3]) {
|
|
if (!value.is<JsonArray>()) return;
|
|
JsonArray arr = value.as<JsonArray>();
|
|
for (uint8_t i = 0; i < 3 && i < arr.size(); i++) rgb[i] = jsonU8(arr[i], rgb[i]);
|
|
}
|
|
|
|
void writeRgb(JsonArray arr, const uint8_t rgb[3]) {
|
|
arr.add(rgb[0]);
|
|
arr.add(rgb[1]);
|
|
arr.add(rgb[2]);
|
|
}
|
|
|
|
void serializeCustomColors(JsonArray arr) {
|
|
for (uint8_t i = 0; i < infinityCustomColorCount; i++) {
|
|
JsonArray color = arr.createNestedArray();
|
|
writeRgb(color, infinityCustomColors[i]);
|
|
}
|
|
}
|
|
|
|
void deserializeCustomColors(JsonVariant value) {
|
|
if (!value.is<JsonArray>()) return;
|
|
JsonArray arr = value.as<JsonArray>();
|
|
uint8_t count = 0;
|
|
for (size_t i = 0; i < arr.size() && count < INFINITY_CUSTOM_COLOR_MAX; i++) {
|
|
if (!arr[i].is<JsonArray>()) continue;
|
|
JsonArray color = arr[i].as<JsonArray>();
|
|
uint8_t rgb[3] = {0, 0, 0};
|
|
readRgb(color, rgb);
|
|
infinityCustomColors[count][0] = rgb[0];
|
|
infinityCustomColors[count][1] = rgb[1];
|
|
infinityCustomColors[count][2] = rgb[2];
|
|
count++;
|
|
}
|
|
infinityCustomColorCount = count;
|
|
}
|
|
|
|
void serializeNodeIps(JsonArray arr) {
|
|
for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) arr.add(ipToString(infinityNodeIps[i]));
|
|
}
|
|
|
|
void deserializeNodeIps(JsonVariant value) {
|
|
if (!value.is<JsonArray>()) return;
|
|
JsonArray arr = value.as<JsonArray>();
|
|
for (uint8_t i = 0; i < INFINITY_NODE_COUNT && i < arr.size(); i++) {
|
|
if (!arr[i].is<const char*>()) continue;
|
|
IPAddress parsed;
|
|
if (parsed.fromString(arr[i].as<const char*>())) infinityNodeIps[i] = parsed;
|
|
}
|
|
}
|
|
|
|
void serializeScene(JsonObject scene) {
|
|
scene["effect"] = infinityScene.effectId;
|
|
scene["preset"] = infinityScene.presetId;
|
|
scene["brightness"] = infinityScene.brightness;
|
|
scene["speed"] = infinityScene.speed;
|
|
scene["intensity"] = infinityScene.intensity;
|
|
scene["palette"] = infinityScene.palette;
|
|
writeRgb(scene.createNestedArray("primary"), infinityScene.primary);
|
|
writeRgb(scene.createNestedArray("secondary"), infinityScene.secondary);
|
|
writeRgb(scene.createNestedArray("tertiary"), infinityScene.tertiary);
|
|
scene["group_mask"] = infinityScene.groupMask;
|
|
scene["direction"] = infinityScene.direction;
|
|
scene["flags"] = infinityScene.flags;
|
|
scene["transition_ms"] = infinityScene.transitionMs;
|
|
scene["seed"] = infinityScene.seed;
|
|
scene["phase"] = infinityScene.phase;
|
|
scene["apply_at_us"] = static_cast<uint32_t>(infinityScene.applyAtUs & 0xFFFFFFFFUL);
|
|
JsonArray rows = scene.createNestedArray("row_dimmer");
|
|
rows.add(infinityScene.rowDimmer[0]);
|
|
rows.add(infinityScene.rowDimmer[1]);
|
|
rows.add(infinityScene.rowDimmer[2]);
|
|
scene["safety_fade"] = infinityScene.safetyFade;
|
|
JsonObject spatial = scene.createNestedObject("spatial");
|
|
spatial["mode"] = infinityScene.spatialMode;
|
|
spatial["strength"] = infinityScene.spatialStrength;
|
|
spatial["variant"] = infinityScene.spatialVariant;
|
|
spatial["direction"] = infinityScene.spatialDirection;
|
|
spatial["size"] = infinityScene.spatialSize;
|
|
spatial["angle"] = infinityScene.spatialAngle;
|
|
spatial["option"] = infinityScene.spatialOption;
|
|
}
|
|
|
|
void deserializeScene(JsonObject scene) {
|
|
if (scene.isNull()) return;
|
|
infinityScene.effectId = rfpEffectSanitize(jsonU8(scene["effect"], infinityScene.effectId));
|
|
infinityScene.presetId = jsonU8(scene["preset"], infinityScene.presetId);
|
|
infinityScene.brightness = jsonU8(scene["brightness"], infinityScene.brightness);
|
|
infinityScene.speed = jsonU8(scene["speed"], infinityScene.speed);
|
|
infinityScene.intensity = jsonU8(scene["intensity"], infinityScene.intensity);
|
|
infinityScene.palette = jsonU8(scene["palette"], infinityScene.palette);
|
|
readRgb(scene["primary"], infinityScene.primary);
|
|
readRgb(scene["secondary"], infinityScene.secondary);
|
|
readRgb(scene["tertiary"], infinityScene.tertiary);
|
|
infinityScene.groupMask = jsonU8(scene["group_mask"], infinityScene.groupMask) & 0x07;
|
|
if (infinityScene.groupMask == 0) infinityScene.groupMask = 0x07;
|
|
infinityScene.direction = jsonU8(scene["direction"], infinityScene.direction);
|
|
infinityScene.flags = jsonU8(scene["flags"], infinityScene.flags);
|
|
infinityScene.transitionMs = jsonU16(scene["transition_ms"], infinityScene.transitionMs);
|
|
infinityScene.seed = jsonU32(scene["seed"], infinityScene.seed);
|
|
infinityScene.phase = jsonU16(scene["phase"], infinityScene.phase);
|
|
if (scene["row_dimmer"].is<JsonArray>()) {
|
|
JsonArray rows = scene["row_dimmer"].as<JsonArray>();
|
|
for (uint8_t i = 0; i < 3 && i < rows.size(); i++) infinityScene.rowDimmer[i] = jsonU8(rows[i], infinityScene.rowDimmer[i]);
|
|
}
|
|
infinityScene.safetyFade = jsonU8(scene["safety_fade"], infinityScene.safetyFade);
|
|
if (scene["spatial"].is<JsonObject>()) {
|
|
JsonObject spatial = scene["spatial"].as<JsonObject>();
|
|
infinityScene.spatialMode = jsonU8(spatial["mode"], infinityScene.spatialMode);
|
|
infinityScene.spatialStrength = jsonU8(spatial["strength"], infinityScene.spatialStrength);
|
|
infinityScene.spatialVariant = jsonU8(spatial["variant"], infinityScene.spatialVariant);
|
|
infinityScene.spatialDirection = jsonU8(spatial["direction"], infinityScene.spatialDirection);
|
|
infinityScene.spatialSize = jsonU8(spatial["size"], infinityScene.spatialSize);
|
|
infinityScene.spatialAngle = jsonU16(spatial["angle"], infinityScene.spatialAngle);
|
|
infinityScene.spatialOption = jsonU8(spatial["option"], infinityScene.spatialOption);
|
|
} else {
|
|
infinityScene.spatialMode = jsonU8(scene["spatial_mode"], infinityScene.spatialMode);
|
|
infinityScene.spatialStrength = jsonU8(scene["spatial_strength"], infinityScene.spatialStrength);
|
|
infinityScene.spatialVariant = jsonU8(scene["spatial_variant"], infinityScene.spatialVariant);
|
|
infinityScene.spatialDirection = jsonU8(scene["spatial_direction"], infinityScene.spatialDirection);
|
|
infinityScene.spatialSize = jsonU8(scene["spatial_size"], infinityScene.spatialSize);
|
|
infinityScene.spatialAngle = jsonU16(scene["spatial_angle"], infinityScene.spatialAngle);
|
|
infinityScene.spatialOption = jsonU8(scene["spatial_option"], infinityScene.spatialOption);
|
|
}
|
|
if (infinityScene.spatialMode > INFINITY_SPATIAL_SUNBURST) infinityScene.spatialMode = INFINITY_SPATIAL_OFF;
|
|
if (infinityScene.spatialVariant > INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE) infinityScene.spatialVariant = INFINITY_SPATIAL_VARIANT_EXPAND;
|
|
if (infinityScene.spatialDirection > INFINITY_SPATIAL_DIRECTION_PINGPONG) infinityScene.spatialDirection = INFINITY_SPATIAL_DIRECTION_LTR;
|
|
if (infinityScene.spatialAngle >= 360) infinityScene.spatialAngle %= 360;
|
|
infinityScene.spatialOption = normalizeSpatialOption(infinityScene.spatialMode, infinityScene.spatialOption);
|
|
markSceneChanged();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void infinityInit() {
|
|
refreshRuntimeNodeId();
|
|
if (infinityScene.applyAtUs == 0) infinityScene.applyAtUs = masterTimeUs() + INFINITY_APPLY_DELAY_US;
|
|
}
|
|
|
|
void infinityNetworkBegin() {
|
|
if (!infinityEnabled || infinityPort == 0) return;
|
|
if (infinityUdpStarted) infinityUdp.stop();
|
|
infinityUdpStarted = infinityUdp.begin(infinityPort);
|
|
refreshRuntimeNodeId();
|
|
USER_PRINTF("Infinity Sync %s on UDP %u as %s\n", infinityUdpStarted ? "started" : "failed", infinityPort, infinityRuntimeNodeId);
|
|
}
|
|
|
|
void infinityLoop() {
|
|
refreshRuntimeNodeId();
|
|
receivePackets();
|
|
maybeApplyPendingScene();
|
|
|
|
const uint32_t now = millis();
|
|
if (isMasterBuild()) {
|
|
if (now - lastClockSentMs >= INFINITY_CLOCK_INTERVAL_MS) {
|
|
lastClockSentMs = now;
|
|
sendClockSync();
|
|
}
|
|
if (now - lastSceneSentMs >= INFINITY_SCENE_INTERVAL_MS) {
|
|
lastSceneSentMs = now;
|
|
sendSceneState();
|
|
}
|
|
} else if (isNodeBuild()) {
|
|
if (now - lastStatusSentMs >= INFINITY_STATUS_INTERVAL_MS) {
|
|
lastStatusSentMs = now;
|
|
sendNodeStatus();
|
|
}
|
|
}
|
|
}
|
|
|
|
void infinityPostStripInit() {
|
|
if (!isNodeBuild()) return;
|
|
if (busses.getNumBusses() < 3 || strip.getLengthTotal() != INFINITY_LEDS_PER_NODE) return;
|
|
|
|
bool expectedBusses = true;
|
|
for (uint8_t i = 0; i < 3; i++) {
|
|
Bus* bus = busses.getBus(i);
|
|
if (bus == nullptr || bus->getLength() != INFINITY_LEDS_PER_PANEL || bus->getStart() != i * INFINITY_LEDS_PER_PANEL) {
|
|
expectedBusses = false;
|
|
break;
|
|
}
|
|
}
|
|
if (!expectedBusses) return;
|
|
|
|
if (esp32SemTake(segmentMux, 2100) != pdTRUE) return;
|
|
strip._segments.clear();
|
|
strip._segments.reserve(3);
|
|
strip._segments.push_back(Segment(0, 106));
|
|
strip._segments.push_back(Segment(106, 212));
|
|
strip._segments.push_back(Segment(212, 318));
|
|
setSegmentName(strip._segments[0], "top");
|
|
setSegmentName(strip._segments[1], "middle");
|
|
setSegmentName(strip._segments[2], "bottom");
|
|
for (segment& seg : strip._segments) seg.refreshLightCapabilities();
|
|
strip.setMainSegmentId(0);
|
|
esp32SemGive(segmentMux);
|
|
}
|
|
|
|
void infinityApplyDmx(uint8_t* data, uint16_t availableChannels) {
|
|
if (!isMasterBuild() || data == nullptr || availableChannels < 1) return;
|
|
|
|
uint8_t ch[INFINITY_DMX_FOOTPRINT] = {0};
|
|
const uint16_t count = availableChannels < INFINITY_DMX_FOOTPRINT ? availableChannels : INFINITY_DMX_FOOTPRINT;
|
|
for (uint16_t i = 0; i < count; i++) ch[i] = data[i];
|
|
|
|
infinityScene.brightness = ch[0];
|
|
if (ch[1] == 0) infinityScene.flags &= ~0x01;
|
|
else infinityScene.flags |= 0x01;
|
|
infinityScene.presetId = ch[2];
|
|
infinityScene.effectId = rfpEffectFromDmx(ch[3]);
|
|
infinityScene.speed = ch[4];
|
|
infinityScene.intensity = ch[5];
|
|
infinityScene.palette = ch[6];
|
|
hsvToRgb(ch[7], ch[8], ch[9], infinityScene.primary);
|
|
infinityScene.direction = ch[10];
|
|
infinityScene.transitionMs = dmxTransitionMs(ch[11]);
|
|
infinityScene.groupMask = ch[12] ? (ch[12] & 0x07) : 0x07;
|
|
infinityScene.rowDimmer[0] = ch[13];
|
|
infinityScene.rowDimmer[1] = ch[14];
|
|
infinityScene.rowDimmer[2] = ch[15];
|
|
infinityScene.custom[0] = ch[18];
|
|
infinityScene.custom[1] = ch[19];
|
|
infinityScene.custom[2] = ch[20];
|
|
infinityScene.seed = static_cast<uint32_t>(ch[21]) * 0x01010101UL;
|
|
infinityScene.reserved0 = 0;
|
|
infinityScene.safetyFade = ch[23];
|
|
infinityScene.spatialMode = map(ch[24], 0, 255, 0, INFINITY_SPATIAL_SUNBURST);
|
|
infinityScene.spatialVariant = map(ch[25], 0, 255, 0, INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE);
|
|
infinityScene.spatialDirection = map(ch[26], 0, 255, 0, INFINITY_SPATIAL_DIRECTION_PINGPONG);
|
|
// DMX channel 27 is intentionally unused; Global-2D blend was removed.
|
|
infinityScene.spatialStrength = ch[28];
|
|
infinityScene.spatialSize = ch[29];
|
|
infinityScene.spatialAngle = map(ch[30], 0, 255, 0, 359);
|
|
if (infinityScene.spatialMode == INFINITY_SPATIAL_SUNBURST) {
|
|
infinityScene.spatialOption = map(ch[31], 0, 255, 0, INFINITY_SPATIAL_OPTION_RAINBOW);
|
|
} else if (infinityScene.spatialMode == INFINITY_SPATIAL_SCAN) {
|
|
infinityScene.spatialOption = ch[31] > 127 ? INFINITY_SPATIAL_OPTION_BANDS : INFINITY_SPATIAL_OPTION_LINE;
|
|
} else {
|
|
infinityScene.spatialOption = INFINITY_SPATIAL_OPTION_LINE;
|
|
}
|
|
infinityScene.spatialOption = normalizeSpatialOption(infinityScene.spatialMode, infinityScene.spatialOption);
|
|
|
|
markSceneChanged();
|
|
|
|
if (ch[16] > 127 && lastDmxBeatValue <= 127) sendBeatTrigger();
|
|
if (ch[17] > 127 && lastDmxSyncResetValue <= 127) {
|
|
masterOffsetUs = 0;
|
|
infinityScene.phase = 0;
|
|
markSceneChanged(50000);
|
|
sendClockSync();
|
|
}
|
|
lastDmxBeatValue = ch[16];
|
|
lastDmxSyncResetValue = ch[17];
|
|
}
|
|
|
|
void infinityHandleOverlayDraw() {
|
|
if (!infinityEnabled || !isNodeBuild()) return;
|
|
if (infinityScene.spatialMode == INFINITY_SPATIAL_OFF) return;
|
|
const bool directSpatialMode = infinityScene.spatialMode == INFINITY_SPATIAL_SNAKE
|
|
|| (infinityScene.spatialMode == INFINITY_SPATIAL_SUNBURST && infinityScene.spatialOption != INFINITY_SPATIAL_OPTION_LINE);
|
|
if (infinityScene.spatialStrength == 0 && !directSpatialMode) return;
|
|
if (strip.getSegmentsNum() < INFINITY_PANEL_COUNT || strip.getLengthTotal() < INFINITY_LEDS_PER_NODE) return;
|
|
|
|
uint8_t column = 0;
|
|
parseNodeColumn(column);
|
|
const uint64_t nowUs = masterTimeUs() + (static_cast<uint64_t>(infinityScene.phase) * 1000ULL);
|
|
for (uint8_t row = 0; row < INFINITY_PANEL_COUNT; row++) {
|
|
Segment& seg = strip.getSegment(row);
|
|
if (!seg.isActive()) continue;
|
|
|
|
const uint16_t len = min<uint16_t>(seg.length(), INFINITY_LEDS_PER_PANEL);
|
|
for (uint16_t led = 0; led < len; led++) {
|
|
const SpatialSample sample = spatialSample(column, row, led, nowUs);
|
|
if (sample.amount == 0 || sample.role == SPATIAL_ROLE_BLACK) {
|
|
seg.setPixelColor(led, BLACK);
|
|
continue;
|
|
}
|
|
const uint32_t layer = spatialLayerColor(seg, led, sample);
|
|
uint8_t exactRgb[3] = {0, 0, 0};
|
|
if (spatialExactRgb(sample, exactRgb)) {
|
|
if (sample.amount == 255) {
|
|
seg.setPixelColor(led, exactRgb[0], exactRgb[1], exactRgb[2], 0);
|
|
} else {
|
|
seg.setPixelColor(led, scaleByte(exactRgb[0], sample.amount), scaleByte(exactRgb[1], sample.amount), scaleByte(exactRgb[2], sample.amount), 0);
|
|
}
|
|
continue;
|
|
}
|
|
const uint32_t base = seg.getPixelColor(led);
|
|
seg.setPixelColor(led, composeSpatialColor(base, layer, sample.amount, infinityScene.spatialStrength, sample.role));
|
|
}
|
|
}
|
|
}
|
|
|
|
void infinitySerializeJson(JsonObject root) {
|
|
refreshRuntimeNodeId();
|
|
root["enabled"] = infinityEnabled;
|
|
root["role"] = isMasterBuild() ? "master" : "node";
|
|
root["node_id"] = infinityRuntimeNodeId;
|
|
root["configured_node_id"] = infinityConfiguredNodeId;
|
|
root["port"] = infinityPort;
|
|
root["udp_started"] = infinityUdpStarted;
|
|
root["master_ip"] = ipToString(infinityMasterIp);
|
|
root["master_time_us"] = static_cast<uint32_t>(masterTimeUs() & 0xFFFFFFFFUL);
|
|
root["master_offset_us"] = static_cast<int32_t>(masterOffsetUs);
|
|
root["packets_received"] = packetsReceived;
|
|
root["packets_sent"] = packetsSent;
|
|
serializeNodeIps(root.createNestedArray("node_ips"));
|
|
serializeCustomColors(root.createNestedArray("custom_colors"));
|
|
|
|
JsonObject scene = root.createNestedObject("scene");
|
|
serializeScene(scene);
|
|
|
|
JsonArray nodes = root.createNestedArray("nodes");
|
|
const uint32_t now = millis();
|
|
for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) {
|
|
JsonObject node = nodes.createNestedObject();
|
|
node["expected_ip"] = ipToString(infinityNodeIps[i]);
|
|
node["ip"] = nodeStatuses[i].lastSeenMs ? ipToString(nodeStatuses[i].ip) : "";
|
|
node["node_id"] = nodeStatuses[i].nodeId;
|
|
node["online"] = nodeStatuses[i].lastSeenMs && (now - nodeStatuses[i].lastSeenMs <= INFINITY_NODE_TIMEOUT_MS);
|
|
node["last_seen_ms"] = nodeStatuses[i].lastSeenMs ? now - nodeStatuses[i].lastSeenMs : 0;
|
|
node["uptime_ms"] = nodeStatuses[i].uptimeMs;
|
|
node["master_offset_us"] = nodeStatuses[i].masterOffsetUs;
|
|
node["scene_sequence"] = nodeStatuses[i].lastSceneSequence;
|
|
node["packets_received"] = nodeStatuses[i].packetsReceived;
|
|
node["effect"] = nodeStatuses[i].activeEffect;
|
|
node["preset"] = nodeStatuses[i].activePreset;
|
|
}
|
|
}
|
|
|
|
bool infinityDeserializeJson(JsonObject root) {
|
|
if (root.isNull()) return false;
|
|
const bool hadSceneObject = root["scene"].is<JsonObject>();
|
|
const bool hadNodeIps = !root["node_ips"].isNull();
|
|
const bool hasRootScenePayload =
|
|
!root["effect"].isNull() ||
|
|
!root["preset"].isNull() ||
|
|
!root["brightness"].isNull() ||
|
|
!root["speed"].isNull() ||
|
|
!root["intensity"].isNull() ||
|
|
!root["palette"].isNull() ||
|
|
!root["primary"].isNull() ||
|
|
!root["secondary"].isNull() ||
|
|
!root["tertiary"].isNull() ||
|
|
!root["group_mask"].isNull() ||
|
|
!root["direction"].isNull() ||
|
|
!root["flags"].isNull() ||
|
|
!root["transition_ms"].isNull() ||
|
|
!root["seed"].isNull() ||
|
|
!root["phase"].isNull() ||
|
|
!root["row_dimmer"].isNull() ||
|
|
!root["safety_fade"].isNull() ||
|
|
!root["spatial"].isNull() ||
|
|
!root["spatial_mode"].isNull();
|
|
const bool wasEnabled = infinityEnabled;
|
|
if (!root["enabled"].isNull()) infinityEnabled = root["enabled"].as<bool>();
|
|
if (root["node_id"].is<const char*>()) copyNodeId(infinityConfiguredNodeId, sizeof(infinityConfiguredNodeId), root["node_id"].as<const char*>());
|
|
if (root["master_ip"].is<const char*>()) {
|
|
IPAddress parsed;
|
|
if (parsed.fromString(root["master_ip"].as<const char*>())) infinityMasterIp = parsed;
|
|
}
|
|
if (!root["port"].isNull()) {
|
|
uint16_t newPort = jsonU16(root["port"], infinityPort);
|
|
if (newPort != infinityPort && newPort > 0) {
|
|
infinityPort = newPort;
|
|
infinityNetworkBegin();
|
|
}
|
|
}
|
|
deserializeNodeIps(root["node_ips"]);
|
|
deserializeCustomColors(root["custom_colors"]);
|
|
if (wasEnabled != infinityEnabled) {
|
|
if (infinityEnabled) {
|
|
infinityNetworkBegin();
|
|
} else if (infinityUdpStarted) {
|
|
infinityUdp.stop();
|
|
infinityUdpStarted = false;
|
|
}
|
|
}
|
|
if (hadSceneObject || hasRootScenePayload) {
|
|
JsonObject scene = hadSceneObject ? root["scene"].as<JsonObject>() : root;
|
|
deserializeScene(scene);
|
|
}
|
|
if (isMasterBuild() && infinityUdpStarted) {
|
|
sendClockSync();
|
|
if (hadSceneObject || hasRootScenePayload || hadNodeIps) sendSceneState();
|
|
}
|
|
doSerializeConfig = true;
|
|
return true;
|
|
}
|
|
|
|
void infinityDeserializeConfig(JsonObject interfaces) {
|
|
JsonObject cfg = interfaces["infinity"];
|
|
if (cfg.isNull()) return;
|
|
if (!cfg["enabled"].isNull()) infinityEnabled = cfg["enabled"].as<bool>();
|
|
if (!cfg["port"].isNull()) infinityPort = jsonU16(cfg["port"], infinityPort);
|
|
if (cfg["node_id"].is<const char*>()) copyNodeId(infinityConfiguredNodeId, sizeof(infinityConfiguredNodeId), cfg["node_id"].as<const char*>());
|
|
if (cfg["master_ip"].is<const char*>()) {
|
|
IPAddress parsed;
|
|
if (parsed.fromString(cfg["master_ip"].as<const char*>())) infinityMasterIp = parsed;
|
|
}
|
|
deserializeNodeIps(cfg["node_ips"]);
|
|
deserializeCustomColors(cfg["custom_colors"]);
|
|
}
|
|
|
|
void infinitySerializeConfig(JsonObject interfaces) {
|
|
JsonObject cfg = interfaces.createNestedObject("infinity");
|
|
cfg["enabled"] = infinityEnabled;
|
|
cfg["role"] = isMasterBuild() ? "master" : "node";
|
|
cfg["node_id"] = infinityConfiguredNodeId;
|
|
cfg["master_ip"] = ipToString(infinityMasterIp);
|
|
cfg["port"] = infinityPort;
|
|
serializeNodeIps(cfg.createNestedArray("node_ips"));
|
|
serializeCustomColors(cfg.createNestedArray("custom_colors"));
|
|
}
|
|
|
|
void serveInfinityJson(AsyncWebServerRequest* request) {
|
|
AsyncJsonResponse* response = new AsyncJsonResponse(6144, false);
|
|
JsonObject root = response->getRoot().as<JsonObject>();
|
|
infinitySerializeJson(root);
|
|
response->setLength();
|
|
request->send(response);
|
|
}
|
|
|
|
void serveInfinityPage(AsyncWebServerRequest* request) {
|
|
static const char PAGE_infinity[] PROGMEM = R"rawliteral(
|
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Infinity Controller</title>
|
|
<style>
|
|
:root{--bg:#111318;--panel:#1a1d23;--panel-2:#22262d;--line:#303640;--text:#f1f3f5;--muted:#a8b0bd;--accent:#4da3ff;--accent-2:#88c3ff;--good:#3ddc84;--warn:#ffb74d;--bad:#ff5c7a;--btn:#2b313a;--btn-hi:#4da3ff;--shadow:0 10px 30px rgba(0,0,0,.32);}
|
|
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}body{min-height:100vh}
|
|
a{color:inherit;text-decoration:none}button,input,select{font:inherit}main{width:min(1120px,100%);margin:0 auto;padding:18px 14px 120px}
|
|
.topbar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:4px 0 14px}.brand{display:flex;align-items:center;gap:12px}.logo{width:42px;height:42px;border-radius:14px;background:linear-gradient(135deg,#4da3ff,#7f89ff);display:grid;place-items:center;color:#fff;font-weight:800;box-shadow:var(--shadow)}h1{margin:0;font-size:1.55rem;letter-spacing:.02em}.sub{margin:2px 0 0;color:var(--muted);font-size:.92rem}
|
|
.actions{display:flex;gap:8px;flex-wrap:wrap}.btn{border:1px solid var(--line);background:var(--btn);color:var(--text);padding:10px 14px;border-radius:14px;font-weight:600}.btn.primary{background:linear-gradient(180deg,#56abff,#3d8df2);border-color:#4da3ff;color:#fff}.btn.ghost{background:transparent}.btn.good{background:rgba(61,220,132,.16);border-color:rgba(61,220,132,.45);color:#d9ffea}.btn.warn{background:rgba(255,183,77,.16);border-color:rgba(255,183,77,.45);color:#ffe4b5}
|
|
.hero{display:grid;grid-template-columns:1.35fr .95fr;gap:12px;margin-bottom:12px}.panel{background:linear-gradient(180deg,var(--panel),var(--panel-2));border:1px solid var(--line);border-radius:22px;padding:16px;box-shadow:var(--shadow)}.panel h2{margin:0 0 12px;font-size:1.05rem}
|
|
.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.metric{border:1px solid rgba(255,255,255,.04);border-radius:18px;background:rgba(255,255,255,.02);padding:12px 14px}.metric .k{display:block;color:var(--muted);font-size:.78rem;text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}.metric .v{display:block;font-size:1rem;font-weight:700}.metric .v.small{font-size:.9rem;font-weight:600}
|
|
.notice{margin-top:12px;padding:12px 14px;border-radius:16px;background:rgba(77,163,255,.08);border:1px solid rgba(77,163,255,.18);color:#d9ebff}.notice.warn{background:rgba(255,183,77,.08);border-color:rgba(255,183,77,.2);color:#ffe4b5}.notice.good{background:rgba(61,220,132,.08);border-color:rgba(61,220,132,.2);color:#d9ffea}.notice.bad{background:rgba(255,92,122,.08);border-color:rgba(255,92,122,.2);color:#ffd7df}
|
|
.layout{display:grid;grid-template-columns:1.2fr .8fr;gap:12px}.stack{display:grid;gap:12px}
|
|
.control-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.field{display:grid;gap:8px}.field label{display:flex;justify-content:space-between;gap:10px;font-size:.9rem;color:var(--muted)}.pill{padding:2px 8px;border-radius:999px;background:rgba(255,255,255,.06);color:var(--text);font-size:.85rem}
|
|
input[type=range]{width:100%;accent-color:var(--accent)}input[type=color]{width:100%;height:44px;padding:0;border-radius:14px;border:1px solid var(--line);background:#000}input[type=text],input[type=number],select{width:100%;padding:11px 12px;border-radius:14px;border:1px solid var(--line);background:#101319;color:var(--text)}
|
|
select:disabled,input:disabled{opacity:.48}.hidden{display:none!important}
|
|
.switch{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 14px;border-radius:18px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.05)}.switch input{width:22px;height:22px}
|
|
.color-grid,.target-grid,.nodes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.target-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.nodes{grid-template-columns:repeat(2,minmax(0,1fr))}
|
|
.color-slot{border:1px solid rgba(255,255,255,.08);border-radius:18px;padding:10px;background:rgba(255,255,255,.025);cursor:pointer}.color-slot.active{outline:2px solid var(--accent);background:rgba(77,163,255,.12)}.color-slot input{cursor:pointer}
|
|
.palette-panel{margin-top:12px;border:1px solid rgba(255,255,255,.06);border-radius:18px;padding:12px;background:rgba(0,0,0,.14)}.palette-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:10px}.palette-head strong{display:block}.palette-head span{display:block;color:var(--muted);font-size:.86rem;margin-top:2px}
|
|
.swatch-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(34px,1fr));gap:8px}.swatch{height:34px;border-radius:10px;border:1px solid rgba(255,255,255,.2);box-shadow:inset 0 0 0 1px rgba(0,0,0,.35);cursor:pointer}.swatch:hover,.swatch.selected{outline:2px solid #fff;outline-offset:1px}.swatch.custom{border-color:rgba(77,163,255,.7)}
|
|
.color-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}.color-actions .btn{padding:8px 10px;border-radius:12px}
|
|
.node{border:1px solid rgba(255,255,255,.05);border-radius:18px;padding:12px 13px;background:rgba(0,0,0,.18)}.node.online{outline:2px solid rgba(61,220,132,.45)}.node.offline{opacity:.88}.node h3{margin:0 0 8px;font-size:1rem}.node p{margin:4px 0;color:var(--muted);font-size:.88rem}
|
|
.toolbar{position:sticky;bottom:14px;display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;margin-top:12px}
|
|
@media (max-width:900px){.hero,.layout{grid-template-columns:1fr}.nodes,.color-grid{grid-template-columns:1fr 1fr}}
|
|
@media (max-width:620px){main{padding:14px 12px 120px}.topbar{flex-direction:column;align-items:flex-start}.actions{width:100%}.actions .btn{flex:1 1 auto;text-align:center}.metrics,.control-grid,.target-grid,.nodes,.color-grid{grid-template-columns:1fr}.panel{padding:14px;border-radius:20px}.toolbar{position:fixed;left:12px;right:12px;bottom:12px;margin:0}.toolbar .btn{flex:1 1 auto}}
|
|
</style></head>
|
|
<body><main>
|
|
<div class="topbar">
|
|
<div class="brand">
|
|
<div class="logo">∞</div>
|
|
<div>
|
|
<h1>Infinity Controller</h1>
|
|
<p class="sub">WLED-style master surface for your installation</p>
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn" id="modeToggle" type="button" onclick="toggleInfinityMode()">Loading mode…</button>
|
|
<a class="btn ghost" href="/">WLED UI</a>
|
|
<a class="btn ghost" href="/settings/sync">Sync</a>
|
|
<button class="btn" type="button" onclick="refreshAll()">Refresh</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="hero">
|
|
<section class="panel">
|
|
<h2>Master</h2>
|
|
<div class="metrics" id="metrics"></div>
|
|
<div class="notice" id="networkNotice">Loading master status…</div>
|
|
</section>
|
|
<section class="panel">
|
|
<h2>Sync</h2>
|
|
<div class="metrics" id="syncMetrics"></div>
|
|
<div class="notice warn" id="targetNotice">Checking node target layout…</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="layout">
|
|
<div class="stack">
|
|
<section class="panel">
|
|
<h2>Scene</h2>
|
|
<div class="switch">
|
|
<div>
|
|
<strong>Output Enabled</strong><br>
|
|
<span class="sub">Master sends scene state, nodes render locally on apply time.</span>
|
|
</div>
|
|
<input id="enabled" type="checkbox">
|
|
</div>
|
|
<div class="control-grid" style="margin-top:12px">
|
|
<div class="field"><label for="masterBpm"><span>Master Speed</span><span class="pill" id="masterBpmVal">130 BPM</span></label><input id="masterBpm" type="range" min="20" max="240" step="1" oninput="syncSpeedFromBpm()"></div>
|
|
<div class="field"><label for="brightness"><span>Brightness</span><span class="pill" id="brightnessVal">100%</span></label><input id="brightness" type="range" min="0" max="255" oninput="bindBrightnessValue()"></div>
|
|
</div>
|
|
<input id="speed" type="hidden" value="128">
|
|
<h2 style="margin-top:18px">Global 2D</h2>
|
|
<div class="control-grid">
|
|
<div class="field"><label for="spatialMode"><span>2D Mode</span><span class="pill" id="spatialModeVal">Off</span></label><select id="spatialMode"><option value="0">Off</option><option value="1">Center Pulse</option><option value="2">Checkerd</option><option value="3">Arrow</option><option value="4">Scan</option><option value="5">Snake</option><option value="6">Wave Line</option><option value="7">Strobe</option><option value="8">Schlängeln</option><option value="9">Sunburst</option></select></div>
|
|
<div class="field" id="spatialVariantField"><label for="spatialVariant"><span>Variant</span><span class="pill" id="spatialVariantVal">Expand</span></label><select id="spatialVariant"><option value="0">Expand / Classic / Line</option><option value="1">Reverse / Diagonal / Bands</option><option value="2">Outline / Checkerd</option><option value="3">Outline Reverse</option></select></div>
|
|
<div class="field" id="spatialDirectionField"><label for="spatialDirection"><span>Direction</span><span class="pill" id="spatialDirectionVal">Left → Right</span></label><select id="spatialDirection"><option value="0">Left → Right</option><option value="1">Right → Left</option><option value="2">Top → Bottom</option><option value="3">Bottom → Top</option><option value="4">Outward</option><option value="5">Inward</option></select></div>
|
|
<div class="field" id="spatialStrengthField"><label for="spatialStrength"><span>Strength</span><span class="pill" id="spatialStrengthVal">180</span></label><input id="spatialStrength" type="range" min="0" max="255" oninput="bindRangeValue('spatialStrength')"></div>
|
|
<div class="field" id="spatialSizeField"><label for="spatialSize"><span id="spatialSizeLabel">Size</span><span class="pill" id="spatialSizeVal">64</span></label><input id="spatialSize" type="range" min="0" max="255" oninput="bindSpatialSizeValue()"></div>
|
|
<div class="field" id="spatialAngleField"><label for="spatialAngle"><span>Scan Angle</span><span class="pill" id="spatialAngleVal">0°</span></label><input id="spatialAngle" type="range" min="0" max="359" oninput="bindRangeValue('spatialAngle','°')"></div>
|
|
<div class="field" id="spatialOptionField"><label for="spatialOption"><span>Scan Style</span><span class="pill" id="spatialOptionVal">Line</span></label><select id="spatialOption"><option value="0">Line</option><option value="1">Bands</option></select></div>
|
|
</div>
|
|
<div class="control-grid" style="margin-top:12px">
|
|
<div class="field"><label for="effect"><span>Effect</span><span class="pill" id="effectVal">0</span></label><select id="effect"></select></div>
|
|
<div class="field"><label for="transition"><span>Transition</span><span class="pill" id="transitionVal">750 ms</span></label><input id="transition" type="range" min="0" max="4000" step="50" oninput="bindRangeValue('transition',' ms')"></div>
|
|
<div class="field"><label for="intensity"><span>Intensity</span><span class="pill" id="intensityVal">128</span></label><input id="intensity" type="range" min="0" max="255" oninput="bindRangeValue('intensity')"></div>
|
|
</div>
|
|
<div class="switch" style="margin-top:12px">
|
|
<div><strong>Reverse Direction</strong><br><span class="sub">Maps to the Infinity direction flag.</span></div>
|
|
<input id="reverse" type="checkbox">
|
|
</div>
|
|
<h2 id="colorsTitle" style="margin-top:18px">Colors</h2>
|
|
<div class="color-grid" id="colorsGrid">
|
|
<div class="field color-slot active" data-color-slot="primary"><label for="primary">Primary</label><input id="primary" type="color"></div>
|
|
<div class="field color-slot" data-color-slot="secondary"><label for="secondary">Secondary</label><input id="secondary" type="color"></div>
|
|
<div class="field color-slot" data-color-slot="tertiary"><label for="tertiary">Tertiary</label><input id="tertiary" type="color"></div>
|
|
</div>
|
|
<div class="palette-panel" id="colorPaletteField">
|
|
<div class="palette-head">
|
|
<div><strong>RFP Main Palette</strong><span>Wipe-Random-Farben plus deine dauerhaft gespeicherten Custom-Farben.</span></div>
|
|
<span class="pill" id="activeColorSlotLabel">Primary</span>
|
|
</div>
|
|
<div class="swatch-grid" id="swatchGrid"></div>
|
|
<div class="color-actions">
|
|
<button class="btn" type="button" onclick="addCustomColor()">+ Custom</button>
|
|
<button class="btn" type="button" onclick="editSelectedCustomColor()">Edit Custom</button>
|
|
<button class="btn" type="button" onclick="deleteSelectedCustomColor()">Delete Custom</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2>Nodes</h2>
|
|
<div class="nodes" id="nodes"></div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="stack">
|
|
<section class="panel">
|
|
<h2>Node Targets</h2>
|
|
<p class="sub" style="margin-top:0">These are the unicast destinations the master sends Infinity Sync to.</p>
|
|
<div class="target-grid" id="targets"></div>
|
|
</section>
|
|
<section class="panel">
|
|
<h2>Usage</h2>
|
|
<p class="sub" style="margin-top:0">This page complements WLED. Use the regular WLED UI for generic device settings, Wi-Fi and firmware basics.</p>
|
|
<div class="metrics">
|
|
<div class="metric"><span class="k">Open</span><span class="v small"><a href="/">Main UI</a></span></div>
|
|
<div class="metric"><span class="k">JSON</span><span class="v small"><a href="/json/infinity">/json/infinity</a></span></div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toolbar">
|
|
<div class="notice" id="actionStatus" style="flex:1 1 320px;margin-top:0">Ready. Scene changes apply automatically.</div>
|
|
<button class="btn" type="button" onclick="saveTargets()">Save Targets</button>
|
|
</div>
|
|
|
|
<script>
|
|
let state=null, info=null, effects=[], fxdata=[];
|
|
let targetsDirty=false, saveInFlight=false, sceneApplyTimer=null, sceneApplyPending=false, sceneRenderLocked=false;
|
|
let activeColorSlot='primary', selectedCustomColorIndex=-1;
|
|
const q=id=>document.getElementById(id);
|
|
const fetchJson=async url=>{const r=await fetch(url);if(!r.ok)throw new Error(url);return await r.json();};
|
|
async function postInfinity(body) {
|
|
const response = await fetch('/json/infinity', {
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify(body)
|
|
});
|
|
const text = await response.text();
|
|
if (!response.ok) throw new Error(text || `HTTP ${response.status}`);
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (error) {
|
|
throw new Error('Invalid JSON response from master');
|
|
}
|
|
}
|
|
const clampByte=v=>Math.max(0,Math.min(255,Number(v)||0));
|
|
const bpmMin=20, bpmMax=240;
|
|
const speedToBpm=speed=>Math.round(bpmMin + (clampByte(speed) * (bpmMax - bpmMin) / 255));
|
|
const bpmToSpeed=bpm=>Math.round((Math.max(bpmMin,Math.min(bpmMax,Number(bpm)||speedToBpm(128))) - bpmMin) * 255 / (bpmMax - bpmMin));
|
|
const hex=n=>n.toString(16).padStart(2,'0');
|
|
const byteToPercent=v=>`${Math.round(clampByte(v) * 100 / 255)}%`;
|
|
const rgbToHex=rgb=>`#${hex(rgb?.[0]??0)}${hex(rgb?.[1]??0)}${hex(rgb?.[2]??0)}`;
|
|
const hexToRgb=hexColor=>{const v=(hexColor||'#000000').replace('#','');return [parseInt(v.slice(0,2),16)||0,parseInt(v.slice(2,4),16)||0,parseInt(v.slice(4,6),16)||0];};
|
|
const fmtMs=v=>`${v} ms`;
|
|
const hostIp=()=>location.hostname||'unknown';
|
|
const subnet=ip=>ip.split('.').slice(0,3).join('.');
|
|
function hsToRgb(hue, sat=255) {
|
|
const h = ((Number(hue)||0) % 65536) / 65535;
|
|
const s = sat / 255;
|
|
const i = Math.floor(h * 6);
|
|
const f = h * 6 - i;
|
|
const p = Math.round(255 * (1 - s));
|
|
const qv = Math.round(255 * (1 - f * s));
|
|
const t = Math.round(255 * (1 - (1 - f) * s));
|
|
switch (i % 6) {
|
|
case 0: return [255,t,p];
|
|
case 1: return [qv,255,p];
|
|
case 2: return [p,255,t];
|
|
case 3: return [p,qv,255];
|
|
case 4: return [t,p,255];
|
|
default: return [255,p,qv];
|
|
}
|
|
}
|
|
const wipeRandomColors=Array.from({length:32},(_,i)=>rgbToHex(hsToRgb(Math.round(i*65535/32),255)));
|
|
function normalizeHexColor(value) {
|
|
const v = String(value||'').trim();
|
|
const short = v.match(/^#?([0-9a-fA-F]{3})$/);
|
|
if (short) return '#'+short[1].split('').map(c=>c+c).join('').toLowerCase();
|
|
const full = v.match(/^#?([0-9a-fA-F]{6})$/);
|
|
return full ? '#'+full[1].toLowerCase() : null;
|
|
}
|
|
const customColorsFromState=()=>Array.isArray(state?.custom_colors)
|
|
? state.custom_colors.map(rgbToHex).map(normalizeHexColor).filter(Boolean)
|
|
: [];
|
|
let customColors=[];
|
|
async function syncCustomColorsToEsp() {
|
|
state = await postInfinity({custom_colors: customColors.slice(0,24).map(hexToRgb)});
|
|
customColors = customColorsFromState();
|
|
renderColorPalette();
|
|
}
|
|
function selectColorSlot(slot) {
|
|
activeColorSlot = ['primary','secondary','tertiary'].includes(slot) ? slot : 'primary';
|
|
document.querySelectorAll('.color-slot').forEach(el=>el.classList.toggle('active', el.dataset.colorSlot === activeColorSlot));
|
|
q('activeColorSlotLabel').textContent = activeColorSlot.charAt(0).toUpperCase()+activeColorSlot.slice(1);
|
|
renderColorPalette();
|
|
}
|
|
function applyPaletteColor(hexColor, customIndex=-1) {
|
|
const color = normalizeHexColor(hexColor);
|
|
if (!color) return;
|
|
selectedCustomColorIndex = customIndex;
|
|
q(activeColorSlot).value = color;
|
|
renderColorPalette();
|
|
markSceneDirty();
|
|
}
|
|
function renderColorPalette() {
|
|
const grid = q('swatchGrid');
|
|
if (!grid) return;
|
|
const activeValue = normalizeHexColor(q(activeColorSlot)?.value);
|
|
const base = wipeRandomColors.map((color,index)=>({color,index,custom:false}));
|
|
const custom = customColors.map((color,index)=>({color,index,custom:true}));
|
|
grid.innerHTML = [...base,...custom].map(item => {
|
|
const selected = item.color === activeValue || (item.custom && item.index === selectedCustomColorIndex);
|
|
const klass = `swatch${item.custom?' custom':''}${selected?' selected':''}`;
|
|
const title = item.custom ? `Custom ${item.index+1}` : `Wipe Random ${item.index+1}`;
|
|
return `<button class="${klass}" type="button" title="${title}" style="background:${item.color}" onclick="applyPaletteColor('${item.color}',${item.custom?item.index:-1})"></button>`;
|
|
}).join('');
|
|
}
|
|
async function addCustomColor() {
|
|
const color = normalizeHexColor(prompt('Custom color hex', q(activeColorSlot)?.value || '#ffffff'));
|
|
if (!color) return;
|
|
if (!customColors.includes(color)) {
|
|
if (customColors.length >= 24) {
|
|
setActionStatus('Custom palette is full. Delete one color first.', 'warn');
|
|
return;
|
|
}
|
|
customColors.push(color);
|
|
}
|
|
selectedCustomColorIndex = customColors.indexOf(color);
|
|
setActionStatus('Saving custom color on ESP…');
|
|
try {
|
|
await syncCustomColorsToEsp();
|
|
setActionStatus('Custom color saved on ESP.', 'good');
|
|
} catch (error) {
|
|
setActionStatus(`Custom color save failed: ${error.message}`, 'bad');
|
|
return;
|
|
}
|
|
applyPaletteColor(color, selectedCustomColorIndex);
|
|
}
|
|
async function editSelectedCustomColor() {
|
|
if (selectedCustomColorIndex < 0 || selectedCustomColorIndex >= customColors.length) {
|
|
setActionStatus('Select a custom color first, then edit it.', 'warn');
|
|
return;
|
|
}
|
|
const color = normalizeHexColor(prompt('Edit custom color hex', customColors[selectedCustomColorIndex]));
|
|
if (!color) return;
|
|
customColors[selectedCustomColorIndex] = color;
|
|
setActionStatus('Saving custom color on ESP…');
|
|
try {
|
|
await syncCustomColorsToEsp();
|
|
setActionStatus('Custom color saved on ESP.', 'good');
|
|
} catch (error) {
|
|
setActionStatus(`Custom color save failed: ${error.message}`, 'bad');
|
|
return;
|
|
}
|
|
applyPaletteColor(color, selectedCustomColorIndex);
|
|
}
|
|
async function deleteSelectedCustomColor() {
|
|
if (selectedCustomColorIndex < 0 || selectedCustomColorIndex >= customColors.length) {
|
|
setActionStatus('Select a custom color first, then delete it.', 'warn');
|
|
return;
|
|
}
|
|
customColors.splice(selectedCustomColorIndex, 1);
|
|
selectedCustomColorIndex = -1;
|
|
setActionStatus('Deleting custom color on ESP…');
|
|
try {
|
|
await syncCustomColorsToEsp();
|
|
} catch (error) {
|
|
setActionStatus(`Custom color delete failed: ${error.message}`, 'bad');
|
|
return;
|
|
}
|
|
renderColorPalette();
|
|
setActionStatus('Custom color deleted on ESP.', 'good');
|
|
}
|
|
function effectEntries() {
|
|
if (Array.isArray(effects)) {
|
|
return effects
|
|
.map((name, id)=>[id, name])
|
|
.filter(([, name])=>name && String(name).indexOf('RSVD') !== 0);
|
|
}
|
|
return Object.entries(effects || {})
|
|
.map(([id, name])=>[Number(id), name])
|
|
.filter(([id, name])=>Number.isFinite(id) && name && String(name).indexOf('RSVD') !== 0)
|
|
.sort((a,b)=>a[0]-b[0]);
|
|
}
|
|
const effectName=i=>{
|
|
const id = Number(i) || 0;
|
|
if (Array.isArray(effects)) return effects[id] ?? `Effect ${id}`;
|
|
return effects?.[id] ?? effects?.[String(id)] ?? `Effect ${id}`;
|
|
};
|
|
const spatialModeNames=['Off','Center Pulse','Checkerd','Arrow','Scan','Snake','Wave Line','Strobe','Schlängeln','Sunburst'];
|
|
const spatialModeName=i=>spatialModeNames[Number(i)||0] ?? 'Off';
|
|
const spatialVariantCommon=[[0,'Expand / Classic / Line'],[1,'Reverse / Diagonal / Bands'],[2,'Outline / Checkerd'],[3,'Outline Reverse']];
|
|
const spatialVariantSchlaengeln=[[0,'Top Left'],[1,'Top Right'],[2,'Bottom Left'],[3,'Bottom Right']];
|
|
const spatialVariantSunburst=[[0,'Still'],[1,'Wobble'],[2,'Rotate']];
|
|
const spatialVariantName=(i,mode=Number(q('spatialMode')?.value||0))=>{
|
|
const options = mode === 8 ? spatialVariantSchlaengeln : (mode === 9 ? spatialVariantSunburst : spatialVariantCommon);
|
|
return (options.find(([value])=>value===Number(i))?.[1]) ?? options[0][1];
|
|
};
|
|
const spatialDirectionName=(i,mode=Number(q('spatialMode')?.value||0))=>{
|
|
const options = mode === 8 ? directionOptionsSchlaengeln : (mode === 4 ? directionOptionsScan : directionOptionsCommon);
|
|
return (options.find(([value])=>value===Number(i))?.[1]) ?? options[0][1];
|
|
};
|
|
const spatialOptionName=(i,mode=Number(q('spatialMode')?.value||0))=>{
|
|
const options = mode === 9 ? [[0,'Effect Mask'],[1,'Two Color'],[2,'Rainbow']] : [[0,'Line'],[1,'Bands']];
|
|
return (options.find(([value])=>value===Number(i))?.[1]) ?? options[0][1];
|
|
};
|
|
const directionOptionsCommon=[[0,'Left → Right'],[1,'Right → Left'],[2,'Top → Bottom'],[3,'Bottom → Top'],[4,'Outward'],[5,'Inward']];
|
|
const directionOptionsScan=[[0,'Left → Right'],[1,'Right → Left'],[2,'Top → Bottom'],[3,'Bottom → Top'],[6,'Ping Pong']];
|
|
const directionOptionsSchlaengeln=[[0,'Forward'],[1,'Reverse'],[6,'Ping Pong']];
|
|
function effectParts(id) {
|
|
const fd = fxdata?.[Number(id)||0] || '';
|
|
return fd ? fd.split(';') : [];
|
|
}
|
|
function effectUsesColors(id) {
|
|
if (Number(id) === 0) return true;
|
|
const parts = effectParts(id);
|
|
if (!parts.length) return true;
|
|
const colorField = parts.length > 1 ? parts[1] : '';
|
|
return colorField.split(',').some(token => token !== '');
|
|
}
|
|
function spatialUsesColors(scene) {
|
|
const mode = Number(scene?.spatial?.mode || 0);
|
|
if (mode === 9 && Number(scene?.spatial?.option || 0) === 0) return false;
|
|
return mode !== 0;
|
|
}
|
|
function setActionStatus(message, kind='info') {
|
|
const el = q('actionStatus');
|
|
el.textContent = message;
|
|
el.className = `notice${kind === 'info' ? '' : ` ${kind}`}`;
|
|
}
|
|
function markSceneDirty() {
|
|
if (sceneRenderLocked) return;
|
|
sceneApplyPending = true;
|
|
setActionStatus('Scene edited. Auto-applying…');
|
|
scheduleSceneApply();
|
|
}
|
|
function markTargetsDirty() {
|
|
if (saveInFlight) return;
|
|
targetsDirty = true;
|
|
setActionStatus('Node targets edited locally. Press Save Targets to update the master.', 'warn');
|
|
}
|
|
function bindDirtyInputs() {
|
|
['enabled','effect','brightness','transition','masterBpm','intensity','reverse','spatialMode','spatialVariant','spatialDirection','spatialStrength','spatialSize','spatialAngle','spatialOption','primary','secondary','tertiary'].forEach(id => {
|
|
const el = q(id);
|
|
if (!el || el.dataset.infinityDirtyBound === '1') return;
|
|
const eventName = (el.tagName === 'SELECT' || el.type === 'checkbox' || el.type === 'color') ? 'change' : 'input';
|
|
el.addEventListener(eventName, () => {
|
|
if (el.type === 'color') {
|
|
selectColorSlot(id);
|
|
selectedCustomColorIndex = customColors.indexOf(normalizeHexColor(el.value));
|
|
}
|
|
markSceneDirty();
|
|
});
|
|
el.dataset.infinityDirtyBound = '1';
|
|
});
|
|
document.querySelectorAll('.color-slot').forEach(el => {
|
|
if (el.dataset.infinitySlotBound === '1') return;
|
|
el.addEventListener('click', event => {
|
|
const slot = el.dataset.colorSlot;
|
|
if (slot) selectColorSlot(slot);
|
|
});
|
|
el.dataset.infinitySlotBound = '1';
|
|
});
|
|
}
|
|
|
|
function scheduleSceneApply(delay=180) {
|
|
clearTimeout(sceneApplyTimer);
|
|
sceneApplyTimer = setTimeout(() => saveScene(false), delay);
|
|
}
|
|
|
|
function bindRangeValue(id, suffix='') { q(id+'Val').textContent = `${q(id).value}${suffix}`; }
|
|
function bindBrightnessValue() { q('brightnessVal').textContent = byteToPercent(q('brightness').value); }
|
|
function chainLengthFromSize(size) {
|
|
const v = clampByte(size);
|
|
if (v <= 64) return Math.max(1, Math.floor(v / 16));
|
|
return Math.min(18, 4 + Math.floor(((v - 64) * 14 + 95) / 191));
|
|
}
|
|
function snakeLengthFromSize(size) { return Math.min(17, 3 + Math.max(1, Math.floor(clampByte(size) / 64))); }
|
|
function bindSpatialSizeValue() {
|
|
const mode = Number(q('spatialMode')?.value || 0);
|
|
const value = clampByte(q('spatialSize')?.value || 0);
|
|
if (mode === 5) q('spatialSizeVal').textContent = `${snakeLengthFromSize(value)} panels`;
|
|
else if (mode === 8) q('spatialSizeVal').textContent = `${chainLengthFromSize(value)} panels`;
|
|
else if (mode === 7) q('spatialSizeVal').textContent = `${value}`;
|
|
else q('spatialSizeVal').textContent = `${value}`;
|
|
}
|
|
function syncSpeedFromBpm() {
|
|
q('speed').value = bpmToSpeed(q('masterBpm').value);
|
|
bindRangeValue('masterBpm',' BPM');
|
|
}
|
|
function bindSelectLabel(id, labelFn) { q(id+'Val').textContent = labelFn(q(id).value); }
|
|
function setSelectOptions(id, options, labelFn) {
|
|
const select = q(id);
|
|
if (!select) return;
|
|
const current = Number(select.value || 0);
|
|
select.innerHTML = options.map(([value,name])=>`<option value="${value}">${name}</option>`).join('');
|
|
select.value = options.some(([value])=>value === current) ? String(current) : String(options[0][0]);
|
|
bindSelectLabel(id, labelFn);
|
|
}
|
|
function setSpatialFieldVisible(id, visible) {
|
|
q(id+'Field')?.classList.toggle('hidden', !visible);
|
|
}
|
|
function setDirectionOptions(mode) {
|
|
const modeNumber = Number(mode);
|
|
const options = modeNumber === 8 ? directionOptionsSchlaengeln : (modeNumber === 4 ? directionOptionsScan : directionOptionsCommon);
|
|
setSelectOptions('spatialDirection', options, spatialDirectionName);
|
|
}
|
|
function setVariantOptions(mode) {
|
|
const modeNumber = Number(mode);
|
|
const options = modeNumber === 8 ? spatialVariantSchlaengeln : (modeNumber === 9 ? spatialVariantSunburst : spatialVariantCommon);
|
|
setSelectOptions('spatialVariant', options, spatialVariantName);
|
|
}
|
|
function setOptionOptions(mode) {
|
|
const options = Number(mode) === 9 ? [[0,'Effect Mask'],[1,'Two Color'],[2,'Rainbow']] : [[0,'Line'],[1,'Bands']];
|
|
setSelectOptions('spatialOption', options, spatialOptionName);
|
|
}
|
|
|
|
function fillSelect(selectId, items, selectedIndex) {
|
|
const select=q(selectId);
|
|
if (selectId === 'effect') {
|
|
const entries = effectEntries();
|
|
if (!entries.length) {
|
|
select.innerHTML = `<option value="${selectedIndex||0}">Effect ${selectedIndex||0}</option>`;
|
|
select.value = String(selectedIndex||0);
|
|
return;
|
|
}
|
|
const previous = String(selectedIndex ?? 0);
|
|
select.innerHTML = entries.map(([id,name])=>`<option value="${id}">${id} · ${name}</option>`).join('');
|
|
select.value = entries.some(([id])=>String(id) === previous) ? previous : String(entries[0][0]);
|
|
return;
|
|
}
|
|
if (!items.length) {
|
|
select.innerHTML = `<option value="${selectedIndex||0}">${selectId} ${selectedIndex||0}</option>`;
|
|
select.value = String(selectedIndex||0);
|
|
return;
|
|
}
|
|
const previous = String(selectedIndex ?? 0);
|
|
select.innerHTML = items.map((name, index)=>`<option value="${index}">${index} · ${name}</option>`).join('');
|
|
select.value = previous;
|
|
}
|
|
|
|
function renderMetrics() {
|
|
const nodes = state?.nodes || [];
|
|
const online = nodes.filter(n=>n.online).length;
|
|
const wifi = info?.wifi || {};
|
|
q('metrics').innerHTML = [
|
|
['Role', `${state?.role||'?'}`],
|
|
['Mode', state?.enabled ? 'Show / Infinity' : 'WLED Backup'],
|
|
['Device', `${info?.name || state?.node_id || 'Infinity'}`],
|
|
['Address', hostIp()],
|
|
['Wi-Fi', wifi.ap ? 'AP active' : 'Client mode']
|
|
].map(([k,v])=>`<div class="metric"><span class="k">${k}</span><span class="v">${v}</span></div>`).join('');
|
|
q('syncMetrics').innerHTML = [
|
|
['UDP', state?.udp_started ? 'Online' : 'Offline'],
|
|
['Port', `${state?.port ?? '-'}`],
|
|
['Nodes Online', `${online}/${nodes.length}`],
|
|
['Packets Out', `${state?.packets_sent ?? 0}`]
|
|
].map(([k,v])=>`<div class="metric"><span class="k">${k}</span><span class="v">${v}</span></div>`).join('');
|
|
|
|
q('networkNotice').textContent = wifi.ap
|
|
? `Master is currently advertising an access point. Open the main WLED UI if you want to switch back to your production Wi-Fi.`
|
|
: `Master is reachable at ${hostIp()} and serving the Infinity control surface inside the regular WLED web stack.`;
|
|
|
|
const targetIps = (state?.node_ips || []).filter(Boolean);
|
|
const foreignTargets = targetIps.filter(ip=>subnet(ip) !== subnet(hostIp()));
|
|
q('targetNotice').textContent = foreignTargets.length
|
|
? `Target nodes are still pointed at another subnet (${foreignTargets[0]} …). Update the node targets below for your current hotspot or show network.`
|
|
: `Node targets match the current subnet or are already custom configured.`;
|
|
renderModeButton();
|
|
}
|
|
|
|
function renderModeButton() {
|
|
const button = q('modeToggle');
|
|
if (!button) return;
|
|
const showMode = !!state?.enabled;
|
|
button.textContent = showMode ? 'Show Mode: ON' : 'WLED Backup: ON';
|
|
button.title = showMode
|
|
? 'Infinity Sync is active. Click to stop master sync so nodes can be controlled through regular WLED.'
|
|
: 'Infinity Sync is stopped. Click to resume master sync for show operation.';
|
|
button.className = showMode ? 'btn good' : 'btn warn';
|
|
}
|
|
|
|
function renderScene() {
|
|
if (sceneApplyPending || sceneRenderLocked) return;
|
|
const scene = state?.scene || {};
|
|
q('enabled').checked = !!(scene.flags & 0x01);
|
|
q('reverse').checked = !!(scene.direction & 0x01);
|
|
q('brightness').value = scene.brightness ?? 255;
|
|
const sceneSpeed = scene.speed ?? 128;
|
|
q('speed').value = sceneSpeed;
|
|
q('masterBpm').value = speedToBpm(sceneSpeed);
|
|
q('intensity').value = scene.intensity ?? 128;
|
|
q('transition').value = scene.transition_ms ?? 750;
|
|
const spatial = scene.spatial || {};
|
|
q('spatialMode').value = spatial.mode ?? 0;
|
|
q('spatialVariant').value = spatial.variant ?? 0;
|
|
q('spatialDirection').value = spatial.direction ?? 0;
|
|
q('spatialStrength').value = spatial.strength ?? 180;
|
|
q('spatialSize').value = spatial.size ?? 64;
|
|
q('spatialAngle').value = spatial.angle ?? 0;
|
|
q('spatialOption').value = spatial.option ?? 0;
|
|
q('primary').value = rgbToHex(scene.primary);
|
|
q('secondary').value = rgbToHex(scene.secondary);
|
|
q('tertiary').value = rgbToHex(scene.tertiary);
|
|
bindBrightnessValue();
|
|
bindRangeValue('masterBpm',' BPM');
|
|
bindRangeValue('intensity');
|
|
bindRangeValue('transition',' ms');
|
|
bindRangeValue('spatialStrength');
|
|
bindSpatialSizeValue();
|
|
bindRangeValue('spatialAngle','°');
|
|
bindSelectLabel('spatialMode', spatialModeName);
|
|
setVariantOptions(q('spatialMode').value);
|
|
setDirectionOptions(q('spatialMode').value);
|
|
setOptionOptions(q('spatialMode').value);
|
|
bindSelectLabel('spatialVariant', spatialVariantName);
|
|
bindSelectLabel('spatialDirection', spatialDirectionName);
|
|
bindSelectLabel('spatialOption', spatialOptionName);
|
|
fillSelect('effect', effects, scene.effect ?? 0);
|
|
q('effectVal').textContent = `${scene.effect ?? 0}`;
|
|
q('effect').onchange = () => { q('effectVal').textContent = q('effect').value; updateControlAvailability(); };
|
|
q('spatialMode').onchange = () => { bindSelectLabel('spatialMode', spatialModeName); updateControlAvailability(); };
|
|
q('spatialVariant').onchange = () => bindSelectLabel('spatialVariant', spatialVariantName);
|
|
q('spatialDirection').onchange = () => bindSelectLabel('spatialDirection', spatialDirectionName);
|
|
q('spatialOption').onchange = () => { bindSelectLabel('spatialOption', spatialOptionName); updateControlAvailability(); };
|
|
q('masterBpm').oninput = syncSpeedFromBpm;
|
|
updateControlAvailability();
|
|
bindDirtyInputs();
|
|
}
|
|
|
|
function updateControlAvailability() {
|
|
const scene = state?.scene || {};
|
|
const spatialMode = Number(q('spatialMode')?.value ?? scene.spatial?.mode ?? 0);
|
|
setVariantOptions(spatialMode);
|
|
setDirectionOptions(spatialMode);
|
|
setOptionOptions(spatialMode);
|
|
const spatialOption = Number(q('spatialOption')?.value ?? scene.spatial?.option ?? 0);
|
|
setSpatialFieldVisible('spatialVariant', spatialMode === 1 || spatialMode === 2 || spatialMode === 8 || spatialMode === 9);
|
|
setSpatialFieldVisible('spatialDirection', spatialMode === 3 || spatialMode === 4 || spatialMode === 6 || spatialMode === 8);
|
|
setSpatialFieldVisible('spatialStrength', spatialMode !== 0);
|
|
setSpatialFieldVisible('spatialSize', spatialMode === 3 || spatialMode === 4 || spatialMode === 5 || spatialMode === 7 || spatialMode === 8);
|
|
if (q('spatialSizeLabel')) {
|
|
q('spatialSizeLabel').textContent = spatialMode === 7 ? 'Pulse Width' : (spatialMode === 5 || spatialMode === 8 ? 'Length' : 'Size');
|
|
}
|
|
const variantLabel = q('spatialVariantField')?.querySelector('label span:first-child');
|
|
if (variantLabel) variantLabel.textContent = spatialMode === 8 ? 'Start Corner' : (spatialMode === 9 ? 'Motion' : 'Variant');
|
|
const optionLabel = q('spatialOptionField')?.querySelector('label span:first-child');
|
|
if (optionLabel) optionLabel.textContent = spatialMode === 9 ? 'Render' : 'Scan Style';
|
|
bindSpatialSizeValue();
|
|
setSpatialFieldVisible('spatialAngle', spatialMode === 4);
|
|
setSpatialFieldVisible('spatialOption', spatialMode === 4 || spatialMode === 9);
|
|
const draft = { spatial: { mode: spatialMode, option: spatialOption } };
|
|
const effectId = q('effect')?.value ?? scene.effect ?? 0;
|
|
const colorsAvailable = effectUsesColors(effectId) || spatialUsesColors(draft);
|
|
q('colorsTitle')?.classList.toggle('hidden', !colorsAvailable);
|
|
q('colorsGrid')?.classList.toggle('hidden', !colorsAvailable);
|
|
q('colorPaletteField')?.classList.toggle('hidden', !colorsAvailable);
|
|
renderColorPalette();
|
|
}
|
|
|
|
function renderTargets() {
|
|
if (targetsDirty && !saveInFlight) return;
|
|
const targets = state?.node_ips || [];
|
|
q('targets').innerHTML = Array.from({length:6}, (_,i) => `
|
|
<div class="field">
|
|
<label for="target${i}">Node ${String(i+1).padStart(2,'0')}</label>
|
|
<input id="target${i}" type="text" value="${targets[i] || ''}" placeholder="IP address">
|
|
</div>`).join('');
|
|
Array.from({length:6}, (_,i) => q(`target${i}`)).forEach(input => {
|
|
if (!input || input.dataset.infinityDirtyBound === '1') return;
|
|
input.addEventListener('input', markTargetsDirty);
|
|
input.dataset.infinityDirtyBound = '1';
|
|
});
|
|
}
|
|
|
|
function renderNodes() {
|
|
const nodes = state?.nodes || [];
|
|
q('nodes').innerHTML = nodes.map((node, index) => `
|
|
<article class="node ${node.online ? 'online' : 'offline'}">
|
|
<h3>${node.node_id || `node-${String(index+1).padStart(2,'0')}`}</h3>
|
|
<p><strong>Target:</strong> ${node.expected_ip || '-'}</p>
|
|
<p><strong>Seen at:</strong> ${node.online ? `${node.last_seen_ms} ms ago` : 'offline'}</p>
|
|
<p><strong>Live IP:</strong> ${node.ip || 'pending'}</p>
|
|
<p><strong>Offset:</strong> ${node.master_offset_us || 0} us</p>
|
|
<p><strong>Effect:</strong> ${effectName(node.effect || 0)}</p>
|
|
</article>`).join('');
|
|
}
|
|
|
|
async function saveScene(forceRefresh=true) {
|
|
if (saveInFlight && !forceRefresh) {
|
|
scheduleSceneApply(220);
|
|
return;
|
|
}
|
|
const body = {
|
|
scene: {
|
|
brightness: clampByte(q('brightness').value),
|
|
effect: clampByte(q('effect').value),
|
|
speed: bpmToSpeed(q('masterBpm').value),
|
|
intensity: clampByte(q('intensity').value),
|
|
palette: 0,
|
|
transition_ms: Number(q('transition').value) || 0,
|
|
direction: q('reverse').checked ? 1 : 0,
|
|
flags: q('enabled').checked ? 1 : 0,
|
|
primary: hexToRgb(q('primary').value),
|
|
secondary: hexToRgb(q('secondary').value),
|
|
tertiary: hexToRgb(q('tertiary').value),
|
|
row_dimmer: [255, 255, 255],
|
|
spatial: {
|
|
mode: clampByte(q('spatialMode').value),
|
|
variant: clampByte(q('spatialVariant').value),
|
|
direction: clampByte(q('spatialDirection').value),
|
|
strength: clampByte(q('spatialStrength').value),
|
|
size: clampByte(q('spatialSize').value),
|
|
angle: Math.max(0,Math.min(359,Number(q('spatialAngle').value)||0)),
|
|
option: clampByte(q('spatialOption').value)
|
|
},
|
|
group_mask: 7
|
|
}
|
|
};
|
|
saveInFlight = true;
|
|
setActionStatus('Applying scene on master and pushing Infinity Sync packets…');
|
|
try {
|
|
state = await postInfinity(body);
|
|
sceneApplyPending = false;
|
|
setActionStatus('Scene auto-applied and queued for sync to all nodes.', 'good');
|
|
sceneRenderLocked = true;
|
|
renderMetrics();
|
|
renderTargets();
|
|
renderNodes();
|
|
sceneRenderLocked = false;
|
|
} catch (error) {
|
|
sceneApplyPending = false;
|
|
setActionStatus(`Scene update failed: ${error.message}`, 'bad');
|
|
} finally {
|
|
sceneRenderLocked = false;
|
|
saveInFlight = false;
|
|
}
|
|
if (forceRefresh) await refreshAll(true);
|
|
}
|
|
|
|
async function saveTargets() {
|
|
const node_ips = Array.from({length:6}, (_,i) => q(`target${i}`).value.trim());
|
|
saveInFlight = true;
|
|
setActionStatus('Saving node targets on the master…');
|
|
try {
|
|
state = await postInfinity({node_ips});
|
|
targetsDirty = false;
|
|
setActionStatus('Node targets saved on the master.', 'good');
|
|
renderMetrics();
|
|
renderScene();
|
|
renderTargets();
|
|
renderNodes();
|
|
} catch (error) {
|
|
setActionStatus(`Target save failed: ${error.message}`, 'bad');
|
|
} finally {
|
|
saveInFlight = false;
|
|
}
|
|
await refreshAll(true);
|
|
}
|
|
|
|
async function toggleInfinityMode() {
|
|
const nextEnabled = !state?.enabled;
|
|
saveInFlight = true;
|
|
setActionStatus(nextEnabled ? 'Switching to Show Mode and enabling Infinity Sync…' : 'Switching to WLED Backup and stopping Infinity Sync…');
|
|
try {
|
|
state = await postInfinity({enabled: nextEnabled});
|
|
setActionStatus(
|
|
nextEnabled
|
|
? 'Show Mode enabled. Master is sending Infinity Sync to the nodes.'
|
|
: 'WLED Backup enabled. Master sync is stopped; regular WLED control can be used.',
|
|
nextEnabled ? 'good' : 'warn'
|
|
);
|
|
renderMetrics();
|
|
renderModeButton();
|
|
} catch (error) {
|
|
setActionStatus(`Mode switch failed: ${error.message}`, 'bad');
|
|
} finally {
|
|
saveInFlight = false;
|
|
}
|
|
await refreshAll(true);
|
|
}
|
|
|
|
async function refreshAll(force=false) {
|
|
const [nextState, nextInfo] = await Promise.all([
|
|
fetchJson('/json/infinity'),
|
|
fetchJson('/json/info').catch(() => null)
|
|
]);
|
|
state = nextState;
|
|
info = nextInfo;
|
|
customColors = customColorsFromState();
|
|
if (!effects.length || !fxdata.length) {
|
|
[effects, fxdata] = await Promise.all([
|
|
fetchJson('/json/effects').catch(() => []),
|
|
fetchJson('/json/fxdata').catch(() => [])
|
|
]);
|
|
}
|
|
renderMetrics();
|
|
if (force || (!sceneApplyPending && !saveInFlight)) renderScene();
|
|
if (force || !targetsDirty || saveInFlight) renderTargets();
|
|
renderNodes();
|
|
}
|
|
|
|
setInterval(refreshAll, 1500);
|
|
refreshAll();
|
|
</script></body></html>
|
|
)rawliteral";
|
|
request->send_P(200, "text/html", PAGE_infinity);
|
|
}
|
|
|
|
#endif // WLED_ENABLE_INFINITY_CONTROLLER
|