feat: udp stream
This commit is contained in:
127
examples/pixelstream/PixelStreamController.cpp
Normal file
127
examples/pixelstream/PixelStreamController.cpp
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
#include "PixelStreamController.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr int COMPONENTS_PER_PIXEL = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
PixelStreamController::PixelStreamController(NodeContext& ctxRef, const PixelStreamConfig& cfg)
|
||||||
|
: ctx(ctxRef), config(cfg), pixels(cfg.pixelCount, cfg.pin, cfg.pixelType) {
|
||||||
|
}
|
||||||
|
|
||||||
|
void PixelStreamController::begin() {
|
||||||
|
pixels.begin();
|
||||||
|
pixels.setBrightness(config.brightness);
|
||||||
|
// Default all pixels to green so we can verify hardware before streaming frames
|
||||||
|
for (uint16_t i = 0; i < config.pixelCount; ++i) {
|
||||||
|
pixels.setPixelColor(i, pixels.Color(0, 255, 0));
|
||||||
|
}
|
||||||
|
pixels.show();
|
||||||
|
|
||||||
|
ctx.on("udp/raw", [this](void* data) {
|
||||||
|
this->handleEvent(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
LOG_INFO("PixelStream", String("PixelStreamController ready on pin ") + String(config.pin) + " with " + String(config.pixelCount) + " pixels");
|
||||||
|
}
|
||||||
|
|
||||||
|
void PixelStreamController::handleEvent(void* data) {
|
||||||
|
if (data == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String* payload = static_cast<String*>(data);
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!applyFrame(*payload)) {
|
||||||
|
LOG_WARN("PixelStream", String("Ignoring RAW payload with invalid length (") + String(payload->length()) + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PixelStreamController::applyFrame(const String& payload) {
|
||||||
|
static constexpr std::size_t frameWidth = COMPONENTS_PER_PIXEL * 2;
|
||||||
|
const std::size_t payloadLength = static_cast<std::size_t>(payload.length());
|
||||||
|
|
||||||
|
if (payloadLength == 0 || (payloadLength % frameWidth) != 0) {
|
||||||
|
LOG_WARN("PixelStream", String("Payload size ") + String(payloadLength) + " is not a multiple of " + String(frameWidth));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint16_t framesProvided = static_cast<uint16_t>(payloadLength / frameWidth);
|
||||||
|
const uint16_t pixelsToUpdate = std::min(config.pixelCount, framesProvided);
|
||||||
|
|
||||||
|
for (uint16_t index = 0; index < pixelsToUpdate; ++index) {
|
||||||
|
const std::size_t base = static_cast<std::size_t>(index) * frameWidth;
|
||||||
|
FrameComponents components{};
|
||||||
|
if (!tryParsePixel(payload, base, components)) {
|
||||||
|
LOG_WARN("PixelStream", String("Invalid hex data at pixel index ") + String(index));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const uint16_t hardwareIndex = mapPixelIndex(index);
|
||||||
|
pixels.setPixelColor(hardwareIndex, pixels.Color(components.red, components.green, components.blue));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any remaining pixels so stale data is removed when fewer frames are provided
|
||||||
|
for (uint16_t index = pixelsToUpdate; index < config.pixelCount; ++index) {
|
||||||
|
const uint16_t hardwareIndex = mapPixelIndex(index);
|
||||||
|
pixels.setPixelColor(hardwareIndex, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
pixels.show();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t PixelStreamController::mapPixelIndex(uint16_t logicalIndex) const {
|
||||||
|
if (config.matrixWidth == 0) {
|
||||||
|
return logicalIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint16_t row = logicalIndex / config.matrixWidth;
|
||||||
|
const uint16_t col = logicalIndex % config.matrixWidth;
|
||||||
|
|
||||||
|
if (!config.matrixSerpentine || (row % 2 == 0)) {
|
||||||
|
return row * config.matrixWidth + col;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint16_t reversedCol = (config.matrixWidth - 1) - col;
|
||||||
|
return row * config.matrixWidth + reversedCol;
|
||||||
|
}
|
||||||
|
|
||||||
|
int PixelStreamController::hexToNibble(char c) {
|
||||||
|
if (c >= '0' && c <= '9') {
|
||||||
|
return c - '0';
|
||||||
|
}
|
||||||
|
if (c >= 'a' && c <= 'f') {
|
||||||
|
return 10 + c - 'a';
|
||||||
|
}
|
||||||
|
if (c >= 'A' && c <= 'F') {
|
||||||
|
return 10 + c - 'A';
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PixelStreamController::tryParsePixel(const String& payload, std::size_t startIndex, FrameComponents& components) const {
|
||||||
|
static constexpr std::size_t frameWidth = COMPONENTS_PER_PIXEL * 2;
|
||||||
|
if (startIndex + frameWidth > static_cast<std::size_t>(payload.length())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int rHi = hexToNibble(payload[startIndex]);
|
||||||
|
const int rLo = hexToNibble(payload[startIndex + 1]);
|
||||||
|
const int gHi = hexToNibble(payload[startIndex + 2]);
|
||||||
|
const int gLo = hexToNibble(payload[startIndex + 3]);
|
||||||
|
const int bHi = hexToNibble(payload[startIndex + 4]);
|
||||||
|
const int bLo = hexToNibble(payload[startIndex + 5]);
|
||||||
|
|
||||||
|
if (rHi < 0 || rLo < 0 || gHi < 0 || gLo < 0 || bHi < 0 || bLo < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
components.red = static_cast<uint8_t>((rHi << 4) | rLo);
|
||||||
|
components.green = static_cast<uint8_t>((gHi << 4) | gLo);
|
||||||
|
components.blue = static_cast<uint8_t>((bHi << 4) | bLo);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
42
examples/pixelstream/PixelStreamController.h
Normal file
42
examples/pixelstream/PixelStreamController.h
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <Adafruit_NeoPixel.h>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstddef>
|
||||||
|
#include "spore/core/NodeContext.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
|
||||||
|
struct PixelStreamConfig {
|
||||||
|
uint8_t pin;
|
||||||
|
uint16_t pixelCount;
|
||||||
|
uint8_t brightness;
|
||||||
|
uint16_t matrixWidth;
|
||||||
|
bool matrixSerpentine;
|
||||||
|
neoPixelType pixelType;
|
||||||
|
};
|
||||||
|
|
||||||
|
class PixelStreamController {
|
||||||
|
public:
|
||||||
|
PixelStreamController(NodeContext& ctx, const PixelStreamConfig& config);
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct FrameComponents {
|
||||||
|
uint8_t red;
|
||||||
|
uint8_t green;
|
||||||
|
uint8_t blue;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool tryParsePixel(const String& payload, std::size_t startIndex, FrameComponents& components) const;
|
||||||
|
void handleEvent(void* data);
|
||||||
|
bool applyFrame(const String& payload);
|
||||||
|
uint16_t mapPixelIndex(uint16_t logicalIndex) const;
|
||||||
|
static int hexToNibble(char c);
|
||||||
|
|
||||||
|
NodeContext& ctx;
|
||||||
|
PixelStreamConfig config;
|
||||||
|
Adafruit_NeoPixel pixels;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
33
examples/pixelstream/README.md
Normal file
33
examples/pixelstream/README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# PixelStream Example
|
||||||
|
|
||||||
|
This example demonstrates how to consume the `udp/raw` cluster event and drive a NeoPixel strip or matrix directly from streamed RGB data. Frames are provided as hex encoded byte triplets (`RRGGBB` per pixel).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Subscribes to `udp/raw` via `NodeContext::on`.
|
||||||
|
- Converts incoming frames into pixel colors for strips or matrices.
|
||||||
|
- Supports serpentine (zig-zag) matrix wiring.
|
||||||
|
|
||||||
|
## Payload Format
|
||||||
|
|
||||||
|
Each packet is expected to be `RAW:` followed by `pixelCount * 3 * 2` hexadecimal characters. For example, for 8 pixels:
|
||||||
|
|
||||||
|
```
|
||||||
|
RAW:FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Strip Mode
|
||||||
|
|
||||||
|
Upload the example with `PIXEL_MATRIX_WIDTH` set to 0 (default). Send frames containing `PIXEL_COUNT * 3` bytes as hex.
|
||||||
|
|
||||||
|
### Matrix Mode
|
||||||
|
|
||||||
|
Set `PIXEL_MATRIX_WIDTH` to the number of columns. The controller remaps even/odd rows to support serpentine wiring.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Adjust `PIXEL_PIN`, `PIXEL_COUNT`, `PIXEL_BRIGHTNESS`, `PIXEL_MATRIX_WIDTH`, `PIXEL_MATRIX_SERPENTINE`, and `PIXEL_TYPE` through build defines or editing `main.cpp`.
|
||||||
|
|
||||||
|
|
||||||
60
examples/pixelstream/main.cpp
Normal file
60
examples/pixelstream/main.cpp
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include "spore/Spore.h"
|
||||||
|
#include "spore/util/Logging.h"
|
||||||
|
#include "PixelStreamController.h"
|
||||||
|
|
||||||
|
#ifndef PIXEL_PIN
|
||||||
|
#define PIXEL_PIN 2
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef PIXEL_COUNT
|
||||||
|
#define PIXEL_COUNT 64
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef PIXEL_BRIGHTNESS
|
||||||
|
#define PIXEL_BRIGHTNESS 80
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef PIXEL_MATRIX_WIDTH
|
||||||
|
#define PIXEL_MATRIX_WIDTH 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef PIXEL_MATRIX_SERPENTINE
|
||||||
|
#define PIXEL_MATRIX_SERPENTINE 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef PIXEL_TYPE
|
||||||
|
#define PIXEL_TYPE NEO_GRB + NEO_KHZ800
|
||||||
|
#endif
|
||||||
|
|
||||||
|
Spore spore({
|
||||||
|
{"app", "pixelstream"},
|
||||||
|
{"role", "led"},
|
||||||
|
{"pixels", String(PIXEL_COUNT)}
|
||||||
|
});
|
||||||
|
|
||||||
|
PixelStreamController* controller = nullptr;
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
spore.setup();
|
||||||
|
|
||||||
|
PixelStreamConfig config{
|
||||||
|
static_cast<uint8_t>(PIXEL_PIN),
|
||||||
|
static_cast<uint16_t>(PIXEL_COUNT),
|
||||||
|
static_cast<uint8_t>(PIXEL_BRIGHTNESS),
|
||||||
|
static_cast<uint16_t>(PIXEL_MATRIX_WIDTH),
|
||||||
|
static_cast<bool>(PIXEL_MATRIX_SERPENTINE),
|
||||||
|
static_cast<neoPixelType>(PIXEL_TYPE)
|
||||||
|
};
|
||||||
|
|
||||||
|
controller = new PixelStreamController(spore.getContext(), config);
|
||||||
|
controller->begin();
|
||||||
|
|
||||||
|
spore.begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
spore.loop();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -40,11 +40,13 @@ private:
|
|||||||
static bool isResponseMsg(const char* msg);
|
static bool isResponseMsg(const char* msg);
|
||||||
static bool isNodeInfoMsg(const char* msg);
|
static bool isNodeInfoMsg(const char* msg);
|
||||||
static bool isClusterEventMsg(const char* msg);
|
static bool isClusterEventMsg(const char* msg);
|
||||||
|
static bool isRawMsg(const char* msg);
|
||||||
void onDiscovery(const char* msg);
|
void onDiscovery(const char* msg);
|
||||||
void onHeartbeat(const char* msg);
|
void onHeartbeat(const char* msg);
|
||||||
void onResponse(const char* msg);
|
void onResponse(const char* msg);
|
||||||
void onNodeInfo(const char* msg);
|
void onNodeInfo(const char* msg);
|
||||||
void onClusterEvent(const char* msg);
|
void onClusterEvent(const char* msg);
|
||||||
|
void onRawMessage(const char* msg);
|
||||||
unsigned long lastHeartbeatSentAt = 0;
|
unsigned long lastHeartbeatSentAt = 0;
|
||||||
std::vector<MessageHandler> messageHandlers;
|
std::vector<MessageHandler> messageHandlers;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ namespace ClusterProtocol {
|
|||||||
constexpr const char* HEARTBEAT_MSG = "CLUSTER_HEARTBEAT";
|
constexpr const char* HEARTBEAT_MSG = "CLUSTER_HEARTBEAT";
|
||||||
constexpr const char* NODE_INFO_MSG = "CLUSTER_NODE_INFO";
|
constexpr const char* NODE_INFO_MSG = "CLUSTER_NODE_INFO";
|
||||||
constexpr const char* CLUSTER_EVENT_MSG = "CLUSTER_EVENT";
|
constexpr const char* CLUSTER_EVENT_MSG = "CLUSTER_EVENT";
|
||||||
|
constexpr const char* RAW_MSG = "RAW";
|
||||||
constexpr uint16_t UDP_PORT = 4210;
|
constexpr uint16_t UDP_PORT = 4210;
|
||||||
// Increased buffer to accommodate node info JSON over UDP
|
// Increased buffer to accommodate node info JSON over UDP
|
||||||
constexpr size_t UDP_BUF_SIZE = 512;
|
constexpr size_t UDP_BUF_SIZE = 512;
|
||||||
|
|||||||
@@ -100,3 +100,24 @@ build_src_filter =
|
|||||||
+<src/spore/types/*.cpp>
|
+<src/spore/types/*.cpp>
|
||||||
+<src/spore/util/*.cpp>
|
+<src/spore/util/*.cpp>
|
||||||
+<src/internal/*.cpp>
|
+<src/internal/*.cpp>
|
||||||
|
|
||||||
|
[env:pixelstream]
|
||||||
|
platform = platformio/espressif8266@^4.2.1
|
||||||
|
board = esp01_1m
|
||||||
|
framework = arduino
|
||||||
|
upload_speed = 115200
|
||||||
|
monitor_speed = 115200
|
||||||
|
board_build.filesystem = littlefs
|
||||||
|
board_build.flash_mode = dout
|
||||||
|
board_build.ldscript = eagle.flash.1m64.ld
|
||||||
|
lib_deps = ${common.lib_deps}
|
||||||
|
adafruit/Adafruit NeoPixel@^1.15.1
|
||||||
|
build_flags =
|
||||||
|
build_src_filter =
|
||||||
|
+<examples/pixelstream/*.cpp>
|
||||||
|
+<src/spore/*.cpp>
|
||||||
|
+<src/spore/core/*.cpp>
|
||||||
|
+<src/spore/services/*.cpp>
|
||||||
|
+<src/spore/types/*.cpp>
|
||||||
|
+<src/spore/util/*.cpp>
|
||||||
|
+<src/internal/*.cpp>
|
||||||
|
|||||||
@@ -152,6 +152,11 @@ void ApiServer::setupWebSocket() {
|
|||||||
|
|
||||||
// Subscribe to all local events and forward to websocket clients
|
// Subscribe to all local events and forward to websocket clients
|
||||||
ctx.onAny([this](const std::string& event, void* dataPtr) {
|
ctx.onAny([this](const std::string& event, void* dataPtr) {
|
||||||
|
// Ignore raw UDP frames
|
||||||
|
if (event == "udp/raw") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String* payloadStrPtr = static_cast<String*>(dataPtr);
|
String* payloadStrPtr = static_cast<String*>(dataPtr);
|
||||||
String payloadStr = payloadStrPtr ? *payloadStrPtr : String("");
|
String payloadStr = payloadStrPtr ? *payloadStrPtr : String("");
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ void ClusterManager::listen() {
|
|||||||
|
|
||||||
void ClusterManager::initMessageHandlers() {
|
void ClusterManager::initMessageHandlers() {
|
||||||
messageHandlers.clear();
|
messageHandlers.clear();
|
||||||
|
messageHandlers.push_back({ &ClusterManager::isRawMsg, [this](const char* msg){ this->onRawMessage(msg); }, "RAW" });
|
||||||
messageHandlers.push_back({ &ClusterManager::isDiscoveryMsg, [this](const char* msg){ this->onDiscovery(msg); }, "DISCOVERY" });
|
messageHandlers.push_back({ &ClusterManager::isDiscoveryMsg, [this](const char* msg){ this->onDiscovery(msg); }, "DISCOVERY" });
|
||||||
messageHandlers.push_back({ &ClusterManager::isHeartbeatMsg, [this](const char* msg){ this->onHeartbeat(msg); }, "HEARTBEAT" });
|
messageHandlers.push_back({ &ClusterManager::isHeartbeatMsg, [this](const char* msg){ this->onHeartbeat(msg); }, "HEARTBEAT" });
|
||||||
messageHandlers.push_back({ &ClusterManager::isResponseMsg, [this](const char* msg){ this->onResponse(msg); }, "RESPONSE" });
|
messageHandlers.push_back({ &ClusterManager::isResponseMsg, [this](const char* msg){ this->onResponse(msg); }, "RESPONSE" });
|
||||||
@@ -113,6 +114,15 @@ bool ClusterManager::isClusterEventMsg(const char* msg) {
|
|||||||
return strncmp(msg, ClusterProtocol::CLUSTER_EVENT_MSG, strlen(ClusterProtocol::CLUSTER_EVENT_MSG)) == 0;
|
return strncmp(msg, ClusterProtocol::CLUSTER_EVENT_MSG, strlen(ClusterProtocol::CLUSTER_EVENT_MSG)) == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ClusterManager::isRawMsg(const char* msg) {
|
||||||
|
// RAW frames must be "RAW:<payload>"; enforce the delimiter so we skip things like "RAW_HEARTBEAT".
|
||||||
|
const std::size_t prefixLen = strlen(ClusterProtocol::RAW_MSG);
|
||||||
|
if (strncmp(msg, ClusterProtocol::RAW_MSG, prefixLen) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return msg[prefixLen] == ':';
|
||||||
|
}
|
||||||
|
|
||||||
void ClusterManager::onDiscovery(const char* /*msg*/) {
|
void ClusterManager::onDiscovery(const char* /*msg*/) {
|
||||||
ctx.udp->beginPacket(ctx.udp->remoteIP(), ctx.config.udp_port);
|
ctx.udp->beginPacket(ctx.udp->remoteIP(), ctx.config.udp_port);
|
||||||
String response = String(ClusterProtocol::RESPONSE_MSG) + ":" + ctx.hostname;
|
String response = String(ClusterProtocol::RESPONSE_MSG) + ":" + ctx.hostname;
|
||||||
@@ -240,6 +250,23 @@ void ClusterManager::onClusterEvent(const char* msg) {
|
|||||||
ctx.fire(eventKey, &data);
|
ctx.fire(eventKey, &data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ClusterManager::onRawMessage(const char* msg) {
|
||||||
|
const std::size_t prefixLen = strlen(ClusterProtocol::RAW_MSG);
|
||||||
|
if (msg[prefixLen] != ':') {
|
||||||
|
LOG_WARN("Cluster", "RAW message received without payload delimiter");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* payloadStart = msg + prefixLen + 1;
|
||||||
|
if (*payloadStart == '\0') {
|
||||||
|
LOG_WARN("Cluster", "RAW message received with empty payload");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String payload(payloadStart);
|
||||||
|
ctx.fire("udp/raw", &payload);
|
||||||
|
}
|
||||||
|
|
||||||
void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) {
|
void ClusterManager::addOrUpdateNode(const String& nodeHost, IPAddress nodeIP) {
|
||||||
auto& memberList = *ctx.memberList;
|
auto& memberList = *ctx.memberList;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ws": "^8.18.3"
|
"ws": "^8.18.3"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
80
test/pixelstream/bouncing-ball.js
Normal file
80
test/pixelstream/bouncing-ball.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
const dgram = require('dgram');
|
||||||
|
|
||||||
|
const host = process.argv[2];
|
||||||
|
const port = parseInt(process.argv[3] || '4210', 10);
|
||||||
|
const pixels = parseInt(process.argv[4] || '64', 10);
|
||||||
|
const intervalMs = parseInt(process.argv[5] || '30', 10);
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
console.error('Usage: node bouncing-ball.js <device-ip> [port] [pixels] [interval-ms]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = dgram.createSocket('udp4');
|
||||||
|
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||||
|
|
||||||
|
let position = Math.random() * (pixels - 1);
|
||||||
|
let velocity = randomVelocity();
|
||||||
|
|
||||||
|
function randomVelocity() {
|
||||||
|
const min = 0.15;
|
||||||
|
const max = 0.4;
|
||||||
|
const sign = Math.random() < 0.5 ? -1 : 1;
|
||||||
|
return (min + Math.random() * (max - min)) * sign;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebound(sign) {
|
||||||
|
velocity = randomVelocity() * sign;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mix(a, b, t) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFrame() {
|
||||||
|
const dt = intervalMs / 1000;
|
||||||
|
position += velocity * dt * 60; // scale velocity to 60 FPS reference
|
||||||
|
|
||||||
|
if (position < 0) {
|
||||||
|
position = -position;
|
||||||
|
rebound(1);
|
||||||
|
} else if (position > pixels - 1) {
|
||||||
|
position = (pixels - 1) - (position - (pixels - 1));
|
||||||
|
rebound(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIndex = Math.max(0, Math.min(pixels - 1, Math.round(position)));
|
||||||
|
|
||||||
|
let payload = 'RAW:';
|
||||||
|
for (let i = 0; i < pixels; i++) {
|
||||||
|
if (i === activeIndex) {
|
||||||
|
payload += 'ff8000';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = Math.abs(i - position);
|
||||||
|
const intensity = Math.max(0, 1 - distance);
|
||||||
|
const green = Math.round(mix(20, 200, intensity)).toString(16).padStart(2, '0');
|
||||||
|
const blue = Math.round(mix(40, 255, intensity)).toString(16).padStart(2, '0');
|
||||||
|
payload += '00' + green + blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFrame() {
|
||||||
|
const payload = generateFrame();
|
||||||
|
const message = Buffer.from(payload, 'utf8');
|
||||||
|
socket.send(message, port, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(sendFrame, intervalMs);
|
||||||
|
|
||||||
|
if (isBroadcast) {
|
||||||
|
socket.bind(() => {
|
||||||
|
socket.setBroadcast(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Streaming bouncing ball pattern to ${host}:${port} with ${pixels} pixels (interval=${intervalMs}ms)`);
|
||||||
|
|
||||||
55
test/pixelstream/fade-green-blue.js
Normal file
55
test/pixelstream/fade-green-blue.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
const dgram = require('dgram');
|
||||||
|
|
||||||
|
const host = process.argv[2];
|
||||||
|
const port = parseInt(process.argv[3] || '4210', 10);
|
||||||
|
const pixels = parseInt(process.argv[4] || '64', 10);
|
||||||
|
const speed = parseFloat(process.argv[5] || '0.5'); // cycles per second
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
console.error('Usage: node fade-green-blue.js <device-ip> [port] [pixels] [speed-hz]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = dgram.createSocket('udp4');
|
||||||
|
const intervalMs = 50;
|
||||||
|
let tick = 0;
|
||||||
|
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||||
|
|
||||||
|
function generateFrame() {
|
||||||
|
const timeSeconds = (tick * intervalMs) / 1000;
|
||||||
|
const phase = timeSeconds * speed * Math.PI * 2;
|
||||||
|
const blend = (Math.sin(phase) + 1) * 0.5; // 0..1
|
||||||
|
|
||||||
|
const green = Math.round(255 * (1 - blend));
|
||||||
|
const blue = Math.round(255 * blend);
|
||||||
|
|
||||||
|
let payload = 'RAW:';
|
||||||
|
const gHex = green.toString(16).padStart(2, '0');
|
||||||
|
const bHex = blue.toString(16).padStart(2, '0');
|
||||||
|
|
||||||
|
for (let i = 0; i < pixels; i++) {
|
||||||
|
payload += '00';
|
||||||
|
payload += gHex;
|
||||||
|
payload += bHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFrame() {
|
||||||
|
const payload = generateFrame();
|
||||||
|
const message = Buffer.from(payload, 'utf8');
|
||||||
|
socket.send(message, port, host);
|
||||||
|
tick += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(sendFrame, intervalMs);
|
||||||
|
|
||||||
|
if (isBroadcast) {
|
||||||
|
socket.bind(() => {
|
||||||
|
socket.setBroadcast(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Streaming green/blue fade to ${host}:${port} with ${pixels} pixels (speed=${speed}Hz)`);
|
||||||
|
|
||||||
59
test/pixelstream/rainbow.js
Normal file
59
test/pixelstream/rainbow.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
const dgram = require('dgram');
|
||||||
|
|
||||||
|
const host = process.argv[2];
|
||||||
|
const port = parseInt(process.argv[3] || '4210', 10);
|
||||||
|
const pixels = parseInt(process.argv[4] || '64', 10);
|
||||||
|
const intervalMs = parseInt(process.argv[5] || '30', 10);
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
console.error('Usage: node rainbow.js <device-ip> [port] [pixels] [interval-ms]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = dgram.createSocket('udp4');
|
||||||
|
let offset = 0;
|
||||||
|
const isBroadcast = host === '255.255.255.255' || host.endsWith('.255');
|
||||||
|
|
||||||
|
function wheel(pos) {
|
||||||
|
pos = 255 - pos;
|
||||||
|
if (pos < 85) {
|
||||||
|
return [255 - pos * 3, 0, pos * 3];
|
||||||
|
}
|
||||||
|
if (pos < 170) {
|
||||||
|
pos -= 85;
|
||||||
|
return [0, pos * 3, 255 - pos * 3];
|
||||||
|
}
|
||||||
|
pos -= 170;
|
||||||
|
return [pos * 3, 255 - pos * 3, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFrame() {
|
||||||
|
let payload = 'RAW:';
|
||||||
|
for (let i = 0; i < pixels; i++) {
|
||||||
|
const colorIndex = (i * 256 / pixels + offset) & 255;
|
||||||
|
const [r, g, b] = wheel(colorIndex);
|
||||||
|
payload += r.toString(16).padStart(2, '0');
|
||||||
|
payload += g.toString(16).padStart(2, '0');
|
||||||
|
payload += b.toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = (offset + 1) & 255;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFrame() {
|
||||||
|
const payload = generateFrame();
|
||||||
|
const message = Buffer.from(payload, 'utf8');
|
||||||
|
socket.send(message, port, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(sendFrame, intervalMs);
|
||||||
|
|
||||||
|
if (isBroadcast) {
|
||||||
|
socket.bind(() => {
|
||||||
|
socket.setBroadcast(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Streaming rainbow pattern to ${host}:${port} with ${pixels} pixels (interval=${intervalMs}ms)`);
|
||||||
|
|
||||||
Reference in New Issue
Block a user