From f78dd8b843b66b33bc1b069498c0b2242e4343e8 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Thu, 2 Oct 2025 21:36:46 +0200 Subject: [PATCH] feat: matrix stream example --- examples/pixelstream/main.cpp | 8 +- include/spore/internal/Globals.h | 4 +- platformio.ini | 21 +++ test/package.json | 3 +- test/pixelstream/lava-lamp.js | 221 +++++++++++++++++++++++++++++++ 5 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 test/pixelstream/lava-lamp.js diff --git a/examples/pixelstream/main.cpp b/examples/pixelstream/main.cpp index 1685d2e..1b2a77f 100644 --- a/examples/pixelstream/main.cpp +++ b/examples/pixelstream/main.cpp @@ -8,19 +8,19 @@ #endif #ifndef PIXEL_COUNT -#define PIXEL_COUNT 64 +#define PIXEL_COUNT 16 #endif #ifndef PIXEL_BRIGHTNESS -#define PIXEL_BRIGHTNESS 80 +#define PIXEL_BRIGHTNESS 50 #endif #ifndef PIXEL_MATRIX_WIDTH -#define PIXEL_MATRIX_WIDTH 0 +#define PIXEL_MATRIX_WIDTH 16 #endif #ifndef PIXEL_MATRIX_SERPENTINE -#define PIXEL_MATRIX_SERPENTINE 1 +#define PIXEL_MATRIX_SERPENTINE 0 #endif #ifndef PIXEL_TYPE diff --git a/include/spore/internal/Globals.h b/include/spore/internal/Globals.h index ebb0fc8..e2468b4 100644 --- a/include/spore/internal/Globals.h +++ b/include/spore/internal/Globals.h @@ -12,8 +12,8 @@ namespace ClusterProtocol { constexpr const char* CLUSTER_EVENT_MSG = "CLUSTER_EVENT"; constexpr const char* RAW_MSG = "RAW"; constexpr uint16_t UDP_PORT = 4210; - // Increased buffer to accommodate node info JSON over UDP - constexpr size_t UDP_BUF_SIZE = 512; + // Increased buffer to accommodate larger RAW pixel streams and node info JSON over UDP + constexpr size_t UDP_BUF_SIZE = 2048; constexpr const char* API_NODE_STATUS = "/api/node/status"; } diff --git a/platformio.ini b/platformio.ini index ac9cce8..6fa4b77 100644 --- a/platformio.ini +++ b/platformio.ini @@ -121,3 +121,24 @@ build_src_filter = + + + + +[env:pixelstream_d1] +platform = platformio/espressif8266@^4.2.1 +board = d1_mini +framework = arduino +upload_speed = 115200 +monitor_speed = 115200 +board_build.filesystem = littlefs +board_build.flash_mode = dout +board_build.ldscript = eagle.flash.1m256.ld +lib_deps = ${common.lib_deps} + adafruit/Adafruit NeoPixel@^1.15.1 +build_flags = -DPIXEL_PIN=TX -DPIXEL_COUNT=256 -DMATRIX_WIDTH=16 +build_src_filter = + + + + + + + + + + + + + + diff --git a/test/package.json b/test/package.json index f22bb4b..db350ab 100644 --- a/test/package.json +++ b/test/package.json @@ -5,6 +5,7 @@ "scripts": { "pixelstream:fade-green-blue": "node pixelstream/fade-green-blue.js", "pixelstream:bouncing-ball": "node pixelstream/bouncing-ball.js", - "pixelstream:rainbow": "node pixelstream/rainbow.js" + "pixelstream:rainbow": "node pixelstream/rainbow.js", + "pixelstream:lava-lamp": "node pixelstream/lava-lamp.js" } } diff --git a/test/pixelstream/lava-lamp.js b/test/pixelstream/lava-lamp.js new file mode 100644 index 0000000..57b5fac --- /dev/null +++ b/test/pixelstream/lava-lamp.js @@ -0,0 +1,221 @@ +const dgram = require('dgram'); + +const DEFAULT_PORT = 4210; +const DEFAULT_WIDTH = 16; +const DEFAULT_HEIGHT = 16; +const DEFAULT_INTERVAL_MS = 60; +const DEFAULT_BLOB_COUNT = 6; +const SERPENTINE_WIRING = true; +const BASE_BLOB_SPEED = 0.18; +const PHASE_SPEED_MIN = 0.6; +const PHASE_SPEED_MAX = 1.2; + +const host = process.argv[2]; +const port = parseInt(process.argv[3] || String(DEFAULT_PORT), 10); +const width = parseInt(process.argv[4] || String(DEFAULT_WIDTH), 10); +const height = parseInt(process.argv[5] || String(DEFAULT_HEIGHT), 10); +const intervalMs = parseInt(process.argv[6] || String(DEFAULT_INTERVAL_MS), 10); +const blobCount = parseInt(process.argv[7] || String(DEFAULT_BLOB_COUNT), 10); + +if (!host) { + console.error('Usage: node lava-lamp.js [port] [width] [height] [interval-ms] [blob-count]'); + process.exit(1); +} + +if (Number.isNaN(port) || Number.isNaN(width) || Number.isNaN(height) || Number.isNaN(intervalMs) || Number.isNaN(blobCount)) { + console.error('Invalid numeric argument. Expected integers for port, width, height, interval-ms, and blob-count.'); + process.exit(1); +} + +if (width <= 0 || height <= 0) { + console.error('Matrix dimensions must be positive integers.'); + process.exit(1); +} + +if (blobCount <= 0) { + console.error('Blob count must be a positive integer.'); + process.exit(1); +} + +const totalPixels = width * height; +const socket = dgram.createSocket('udp4'); +const isBroadcast = host === '255.255.255.255' || host.endsWith('.255'); + +const maxAxis = Math.max(width, height); +const minBlobRadius = Math.max(3, maxAxis * 0.18); +const maxBlobRadius = Math.max(minBlobRadius + 1, maxAxis * 0.38); +const frameTimeSeconds = intervalMs / 1000; + +const paletteStops = [ + { stop: 0.0, color: hexToRgb('050319') }, + { stop: 0.28, color: hexToRgb('2a0c4f') }, + { stop: 0.55, color: hexToRgb('8f1f73') }, + { stop: 0.75, color: hexToRgb('ff4a22') }, + { stop: 0.9, color: hexToRgb('ff9333') }, + { stop: 1.0, color: hexToRgb('fff7b0') }, +]; + +const blobs = createBlobs(blobCount); + +if (isBroadcast) { + socket.bind(() => { + socket.setBroadcast(true); + }); +} + +socket.on('error', (error) => { + console.error('Socket error:', error.message); +}); + +function createBlobs(count) { + const blobList = []; + for (let index = 0; index < count; ++index) { + const angle = Math.random() * Math.PI * 2; + const speed = BASE_BLOB_SPEED * (0.6 + Math.random() * 0.8); + blobList.push({ + x: Math.random() * Math.max(1, width - 1), + y: Math.random() * Math.max(1, height - 1), + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + minRadius: minBlobRadius * (0.6 + Math.random() * 0.3), + maxRadius: maxBlobRadius * (0.8 + Math.random() * 0.4), + intensity: 0.8 + Math.random() * 0.7, + phase: Math.random() * Math.PI * 2, + phaseVelocity: PHASE_SPEED_MIN + Math.random() * (PHASE_SPEED_MAX - PHASE_SPEED_MIN), + }); + } + return blobList; +} + +function updateBlobs(deltaSeconds) { + const maxX = Math.max(0, width - 1); + const maxY = Math.max(0, height - 1); + + blobs.forEach((blob) => { + blob.x += blob.vx * deltaSeconds; + blob.y += blob.vy * deltaSeconds; + + if (blob.x < 0) { + blob.x = -blob.x; + blob.vx = Math.abs(blob.vx); + } else if (blob.x > maxX) { + blob.x = 2 * maxX - blob.x; + blob.vx = -Math.abs(blob.vx); + } + + if (blob.y < 0) { + blob.y = -blob.y; + blob.vy = Math.abs(blob.vy); + } else if (blob.y > maxY) { + blob.y = 2 * maxY - blob.y; + blob.vy = -Math.abs(blob.vy); + } + + blob.phase += blob.phaseVelocity * deltaSeconds; + }); +} + +function generateFrame() { + updateBlobs(frameTimeSeconds); + + const frame = new Array(totalPixels); + + for (let row = 0; row < height; ++row) { + for (let col = 0; col < width; ++col) { + const energy = calculateEnergyAt(col, row); + const color = samplePalette(energy); + frame[toIndex(col, row)] = color; + } + } + + return 'RAW:' + frame.join(''); +} + +function calculateEnergyAt(col, row) { + let energy = 0; + + blobs.forEach((blob) => { + const radius = getBlobRadius(blob); + const dx = col - blob.x; + const dy = row - blob.y; + const distance = Math.hypot(dx, dy); + const falloff = Math.max(0, 1 - distance / radius); + energy += blob.intensity * falloff * falloff; + }); + + return clamp(energy / blobs.length, 0, 1); +} + +function getBlobRadius(blob) { + const oscillation = (Math.sin(blob.phase) + 1) * 0.5; + return blob.minRadius + (blob.maxRadius - blob.minRadius) * oscillation; +} + +function toIndex(col, row) { + if (!SERPENTINE_WIRING || row % 2 === 0) { + return row * width + col; + } + return row * width + (width - 1 - col); +} + +function samplePalette(value) { + const clampedValue = clamp(value, 0, 1); + + for (let index = 0; index < paletteStops.length - 1; ++index) { + const left = paletteStops[index]; + const right = paletteStops[index + 1]; + if (clampedValue <= right.stop) { + const span = right.stop - left.stop || 1; + const t = clamp((clampedValue - left.stop) / span, 0, 1); + const interpolatedColor = lerpRgb(left.color, right.color, t); + return rgbToHex(interpolatedColor); + } + } + + return rgbToHex(paletteStops[paletteStops.length - 1].color); +} + +function lerpRgb(lhs, rhs, t) { + return { + r: Math.round(lhs.r + (rhs.r - lhs.r) * t), + g: Math.round(lhs.g + (rhs.g - lhs.g) * t), + b: Math.round(lhs.b + (rhs.b - lhs.b) * t), + }; +} + +function hexToRgb(hex) { + const normalizedHex = hex.trim().toLowerCase(); + const value = normalizedHex.startsWith('#') ? normalizedHex.slice(1) : normalizedHex; + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16), + }; +} + +function rgbToHex(rgb) { + return toHex(rgb.r) + toHex(rgb.g) + toHex(rgb.b); +} + +function toHex(value) { + const boundedValue = clamp(Math.round(value), 0, 255); + const hex = boundedValue.toString(16); + return hex.length === 1 ? '0' + hex : hex; +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function sendFrame() { + const payload = generateFrame(); + const message = Buffer.from(payload, 'utf8'); + socket.send(message, port, host); +} + +setInterval(sendFrame, intervalMs); + +console.log( + `Streaming lava lamp to ${host}:${port} (${width}x${height}, interval=${intervalMs}ms, blobs=${blobCount})`, +); +