From 30814807aa75584df1bb54d1ef867c64387466fc Mon Sep 17 00:00:00 2001 From: 0x1d Date: Sat, 11 Oct 2025 17:46:32 +0200 Subject: [PATCH] feat: ledlab --- .gitignore | 1 + README.md | 23 + package-lock.json | 863 ++++++++++++++++++++++++++++++ package.json | 18 + presets/aurora-curtains-preset.js | 111 ++++ presets/base-preset.js | 86 +++ presets/bouncing-ball-preset.js | 113 ++++ presets/circuit-pulse-preset.js | 152 ++++++ presets/fade-green-blue-preset.js | 51 ++ presets/frame-utils.js | 133 +++++ presets/lava-lamp-preset.js | 146 +++++ presets/meteor-rain-preset.js | 108 ++++ presets/nebula-drift-preset.js | 81 +++ presets/ocean-glimmer-preset.js | 79 +++ presets/preset-registry.js | 74 +++ presets/rainbow-preset.js | 53 ++ presets/spiral-bloom-preset.js | 81 +++ presets/voxel-fireflies-preset.js | 141 +++++ presets/wormhole-tunnel-preset.js | 90 ++++ public/index.html | 100 ++++ public/scripts/constants.js | 51 ++ public/scripts/framework.js | 759 ++++++++++++++++++++++++++ public/scripts/ledlab-app.js | 245 +++++++++ public/scripts/matrix-display.js | 203 +++++++ public/scripts/node-discovery.js | 178 ++++++ public/scripts/preset-controls.js | 372 +++++++++++++ public/scripts/theme-manager.js | 120 +++++ public/styles/main.css | 562 +++++++++++++++++++ server/index.js | 483 +++++++++++++++++ server/udp-discovery.js | 213 ++++++++ 30 files changed, 5690 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 presets/aurora-curtains-preset.js create mode 100644 presets/base-preset.js create mode 100644 presets/bouncing-ball-preset.js create mode 100644 presets/circuit-pulse-preset.js create mode 100644 presets/fade-green-blue-preset.js create mode 100644 presets/frame-utils.js create mode 100644 presets/lava-lamp-preset.js create mode 100644 presets/meteor-rain-preset.js create mode 100644 presets/nebula-drift-preset.js create mode 100644 presets/ocean-glimmer-preset.js create mode 100644 presets/preset-registry.js create mode 100644 presets/rainbow-preset.js create mode 100644 presets/spiral-bloom-preset.js create mode 100644 presets/voxel-fireflies-preset.js create mode 100644 presets/wormhole-tunnel-preset.js create mode 100644 public/index.html create mode 100644 public/scripts/constants.js create mode 100644 public/scripts/framework.js create mode 100644 public/scripts/ledlab-app.js create mode 100644 public/scripts/matrix-display.js create mode 100644 public/scripts/node-discovery.js create mode 100644 public/scripts/preset-controls.js create mode 100644 public/scripts/theme-manager.js create mode 100644 public/styles/main.css create mode 100644 server/index.js create mode 100644 server/udp-discovery.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a189d9 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# SPORE LEDLab + +LEDLab is a tool for streaming animations to SPORE nodes that implement the PixelStreamController. + +## Components + +### Server + +- listens on UDP port 4210 to discover SPORE nodes +- provides a websocket server API to interact with SPORE nodes over UDP +- can stream PixelStream presets either directly to a SPORE node or to all nodes using the UDP broadcast address +- preset parameters can be adjusted / configured on-the-fly directly through websockets +- if a websocket client is connected (UI), the stream is also sent to the websocket client +- can forward a stream received from websocket to UDP +- default matrix size is 16x16 pixels + +### Web-UI + +- default matrix size can be configured in the UI, default is 16x16 pixel +- shows canvas of the currently running stream (scaled up); the stream is received over websocket from the server +- preset selection: select any of the presets defined in the backend to be streamed (by the backend) to one or all SPORE nodes +- preset configuration: the parameters of a preset can be changed on-the-fly and changes the stream in realtime +- the UI is based on the spore-ui project and uses the same component framework and styling. components and other required JS files used in spore-ui are just copied over if needed diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..aad9fff --- /dev/null +++ b/package-lock.json @@ -0,0 +1,863 @@ +{ + "name": "spore-ledlab", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spore-ledlab", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "dgram": "^1.0.1", + "express": "^4.18.2", + "ws": "^8.14.2" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dgram": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dgram/-/dgram-1.0.1.tgz", + "integrity": "sha512-zJVFL1EWfKtE0z2VN6qfpn/a+qG1viEzcwJA0EjtzS76ONSE3sEyWBwEbo32hS4IFw/EWVuWN+8b89aPW6It2A==", + "deprecated": "npm is holding this package for security reasons. As it's a core Node module, we will not transfer it over to other users. You may safely remove the package from your dependencies.", + "license": "ISC" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..63e64fe --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "spore-ledlab", + "version": "1.0.0", + "description": "LED Lab for streaming animations to SPORE nodes", + "main": "server/index.js", + "scripts": { + "start": "node server/index.js", + "dev": "node server/index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": ["spore", "led", "animation", "streaming"], + "author": "SPORE Team", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "ws": "^8.14.2" + } +} diff --git a/presets/aurora-curtains-preset.js b/presets/aurora-curtains-preset.js new file mode 100644 index 0000000..999004f --- /dev/null +++ b/presets/aurora-curtains-preset.js @@ -0,0 +1,111 @@ +// Aurora Curtains preset for LEDLab + +const BasePreset = require('./base-preset'); +const { createFrame, toIndex, samplePalette, hexToRgb } = require('./frame-utils'); + +class AuroraCurtainsPreset extends BasePreset { + constructor(width = 16, height = 16) { + super(width, height); + this.bands = []; + this.defaultParameters = { + bandCount: 5, + waveSpeed: 0.35, + horizontalSway: 0.45, + brightness: 1.0, + }; + } + + init() { + super.init(); + this.createBands(); + } + + createBands() { + const bandCount = this.getParameter('bandCount') || 5; + this.bands = []; + + for (let index = 0; index < bandCount; ++index) { + this.bands.push({ + center: Math.random() * (this.width - 1), + phase: Math.random() * Math.PI * 2, + width: 1.2 + Math.random() * 1.8, + }); + } + } + + renderFrame() { + const frame = createFrame(this.width, this.height); + const waveSpeed = this.getParameter('waveSpeed') || 0.35; + const horizontalSway = this.getParameter('horizontalSway') || 0.45; + const brightness = this.getParameter('brightness') || 1.0; + const timeSeconds = this.frameCount * 0.05; // Convert frame count to time + + const paletteStops = [ + { stop: 0.0, color: hexToRgb('01010a') }, + { stop: 0.2, color: hexToRgb('041332') }, + { stop: 0.4, color: hexToRgb('0c3857') }, + { stop: 0.65, color: hexToRgb('1aa07a') }, + { stop: 0.85, color: hexToRgb('68d284') }, + { stop: 1.0, color: hexToRgb('f4f5c6') }, + ]; + + for (let row = 0; row < this.height; ++row) { + const verticalRatio = row / Math.max(1, this.height - 1); + + for (let col = 0; col < this.width; ++col) { + let intensity = 0; + + this.bands.forEach((band, index) => { + const sway = Math.sin(timeSeconds * waveSpeed + band.phase + verticalRatio * Math.PI * 2) * horizontalSway; + const center = band.center + sway * (index % 2 === 0 ? 1 : -1); + const distance = Math.abs(col - center); + const blurred = Math.exp(-(distance * distance) / (2 * band.width * band.width)); + intensity += blurred * (0.8 + Math.sin(timeSeconds * 0.4 + index) * 0.2); + }); + + const normalized = Math.min(intensity / this.bands.length, 1); + const gradientBlend = Math.min((normalized * 0.7 + verticalRatio * 0.3), 1); + + let colorHex = samplePalette(paletteStops, gradientBlend); + + // Apply brightness + if (brightness < 1.0) { + const rgb = hexToRgb(colorHex); + colorHex = Math.round(rgb.r * brightness).toString(16).padStart(2, '0') + + Math.round(rgb.g * brightness).toString(16).padStart(2, '0') + + Math.round(rgb.b * brightness).toString(16).padStart(2, '0'); + } + + frame[toIndex(col, row, this.width)] = colorHex; + } + } + + return frame; + } + + setParameter(name, value) { + super.setParameter(name, value); + + // Recreate bands if band-related parameters change + if (name === 'bandCount') { + this.createBands(); + } + } + + getMetadata() { + return { + name: 'Aurora Curtains', + description: 'Flowing aurora-like curtains with wave motion', + parameters: { + bandCount: { type: 'range', min: 3, max: 10, step: 1, default: 5 }, + waveSpeed: { type: 'range', min: 0.1, max: 2.0, step: 0.05, default: 0.35 }, + horizontalSway: { type: 'range', min: 0.1, max: 1.0, step: 0.05, default: 0.45 }, + brightness: { type: 'range', min: 0.1, max: 1.0, step: 0.1, default: 1.0 }, + }, + width: this.width, + height: this.height, + }; + } +} + +module.exports = AuroraCurtainsPreset; diff --git a/presets/base-preset.js b/presets/base-preset.js new file mode 100644 index 0000000..89bd29e --- /dev/null +++ b/presets/base-preset.js @@ -0,0 +1,86 @@ +// Base preset class for LEDLab + +const { createFrame, frameToPayload } = require('./frame-utils'); + +class BasePreset { + constructor(width = 16, height = 16) { + this.width = width; + this.height = height; + this.frameCount = 0; + this.isActive = false; + this.parameters = {}; + this.defaultParameters = {}; + } + + // Initialize the preset with default parameters + init() { + this.frameCount = 0; + this.resetToDefaults(); + } + + // Reset parameters to their default values + resetToDefaults() { + this.parameters = { ...this.defaultParameters }; + } + + // Set a parameter value + setParameter(name, value) { + this.parameters[name] = value; + } + + // Get a parameter value + getParameter(name) { + return this.parameters[name]; + } + + // Get all parameters + getParameters() { + return { ...this.parameters }; + } + + // Start the preset + start() { + this.isActive = true; + this.init(); + } + + // Stop the preset + stop() { + this.isActive = false; + } + + // Generate the next frame + generateFrame() { + if (!this.isActive) { + return null; + } + + const frame = this.renderFrame(); + this.frameCount++; + + if (frame) { + return frameToPayload(frame); + } + + return null; + } + + // Override in subclasses to render a frame + renderFrame() { + // Default implementation returns a blank frame + return createFrame(this.width, this.height, '000000'); + } + + // Get preset metadata + getMetadata() { + return { + name: this.constructor.name, + description: 'Base preset class', + parameters: this.defaultParameters, + width: this.width, + height: this.height, + }; + } +} + +module.exports = BasePreset; diff --git a/presets/bouncing-ball-preset.js b/presets/bouncing-ball-preset.js new file mode 100644 index 0000000..15d805e --- /dev/null +++ b/presets/bouncing-ball-preset.js @@ -0,0 +1,113 @@ +// Bouncing Ball preset for LEDLab + +const BasePreset = require('./base-preset'); +const { createFrame, toIndex } = require('./frame-utils'); + +class BouncingBallPreset extends BasePreset { + constructor(width = 16, height = 16) { + super(width, height); + this.position = 0; + this.velocity = 0; + this.defaultParameters = { + speed: 1.0, + ballSize: 1, + trailLength: 5, + color: 'ff8000', + brightness: 1.0, + }; + } + + init() { + super.init(); + this.position = Math.random() * (this.width * this.height - 1); + this.velocity = this.randomVelocity(); + } + + randomVelocity() { + const min = 0.15; + const max = 0.4; + const sign = Math.random() < 0.5 ? -1 : 1; + return (min + Math.random() * (max - min)) * sign; + } + + rebound(sign) { + this.velocity = this.randomVelocity() * sign; + } + + mix(a, b, t) { + return a + (b - a) * t; + } + + renderFrame() { + const frame = createFrame(this.width, this.height); + const speed = this.getParameter('speed') || 1.0; + const ballSize = this.getParameter('ballSize') || 1; + const trailLength = this.getParameter('trailLength') || 5; + const color = this.getParameter('color') || 'ff8000'; + const brightness = this.getParameter('brightness') || 1.0; + + const dt = 0.016; // Assume 60 FPS + this.position += this.velocity * speed * dt * 60; + + // Handle boundary collisions + if (this.position < 0) { + this.position = -this.position; + this.rebound(1); + } else if (this.position > this.width * this.height - 1) { + this.position = (this.width * this.height - 1) - (this.position - (this.width * this.height - 1)); + this.rebound(-1); + } + + const activeIndex = Math.max(0, Math.min(this.width * this.height - 1, Math.round(this.position))); + + // Render ball and trail + for (let i = 0; i < frame.length; i++) { + if (i === activeIndex) { + // Main ball + frame[i] = color; + } else { + // Trail effect + const distance = Math.abs(i - this.position); + if (distance <= trailLength) { + const intensity = Math.max(0, 1 - distance / trailLength); + const trailColor = this.hexToRgb(color); + + const r = Math.round(this.mix(0, trailColor.r, intensity * brightness)); + const g = Math.round(this.mix(20, trailColor.g, intensity * brightness)); + const b = Math.round(this.mix(40, trailColor.b, intensity * brightness)); + + frame[i] = r.toString(16).padStart(2, '0') + + g.toString(16).padStart(2, '0') + + b.toString(16).padStart(2, '0'); + } + } + } + + return frame; + } + + hexToRgb(hex) { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return { r, g, b }; + } + + getMetadata() { + return { + name: 'Bouncing Ball', + description: 'A ball that bounces around the matrix with a trailing effect', + parameters: { + speed: { type: 'range', min: 0.1, max: 3.0, step: 0.1, default: 1.0 }, + ballSize: { type: 'range', min: 1, max: 5, step: 1, default: 1 }, + trailLength: { type: 'range', min: 1, max: 10, step: 1, default: 5 }, + color: { type: 'color', default: 'ff8000' }, + brightness: { type: 'range', min: 0.1, max: 1.0, step: 0.1, default: 1.0 }, + }, + width: this.width, + height: this.height, + }; + } +} + +module.exports = BouncingBallPreset; diff --git a/presets/circuit-pulse-preset.js b/presets/circuit-pulse-preset.js new file mode 100644 index 0000000..41ba78b --- /dev/null +++ b/presets/circuit-pulse-preset.js @@ -0,0 +1,152 @@ +// Circuit Pulse preset for LEDLab + +const BasePreset = require('./base-preset'); +const { createFrame, fadeFrame, frameToPayload, hexToRgb, samplePalette, toIndex, addHexColor } = require('./frame-utils'); + +class CircuitPulsePreset extends BasePreset { + constructor(width = 16, height = 16) { + super(width, height); + this.paths = []; + this.pulses = []; + this.paletteStops = [ + { stop: 0.0, color: hexToRgb('020209') }, + { stop: 0.3, color: hexToRgb('023047') }, + { stop: 0.6, color: hexToRgb('115173') }, + { stop: 0.8, color: hexToRgb('1ca78f') }, + { stop: 1.0, color: hexToRgb('94fdf3') }, + ]; + this.accentColors = ['14f5ff', 'a7ff4d', 'ffcc3f']; + this.defaultParameters = { + pathFade: 0.85, + pulseLength: 6, + pulseSpeed: 5.0, + pulseCount: 3, + }; + } + + init() { + super.init(); + this.paths = this.createPaths(this.width, this.height); + this.pulses = this.createPulses(this.paths.length); + } + + createPaths(matrixWidth, matrixHeight) { + const horizontalStep = Math.max(2, Math.floor(matrixHeight / 4)); + const verticalStep = Math.max(2, Math.floor(matrixWidth / 4)); + const generatedPaths = []; + + // Horizontal paths + for (let y = 1; y < matrixHeight; y += horizontalStep) { + const path = []; + for (let x = 0; x < matrixWidth; ++x) { + path.push({ x, y }); + } + generatedPaths.push(path); + } + + // Vertical paths + for (let x = 2; x < matrixWidth; x += verticalStep) { + const path = []; + for (let y = 0; y < matrixHeight; ++y) { + path.push({ x, y }); + } + generatedPaths.push(path); + } + + return generatedPaths; + } + + createPulses(count) { + const pulseList = []; + for (let index = 0; index < count; ++index) { + pulseList.push(this.spawnPulse(index)); + } + return pulseList; + } + + spawnPulse(pathIndex) { + const color = this.accentColors[pathIndex % this.accentColors.length]; + return { + pathIndex, + position: 0, + speed: 3 + Math.random() * 2, + color, + }; + } + + updatePulse(pulse, deltaSeconds) { + pulse.position += pulse.speed * deltaSeconds; + const path = this.paths[pulse.pathIndex]; + + if (!path || path.length === 0) { + return; + } + + if (pulse.position >= path.length + this.getParameter('pulseLength')) { + Object.assign(pulse, this.spawnPulse(pulse.pathIndex)); + pulse.position = 0; + } + } + + renderPulse(pulse) { + const path = this.paths[pulse.pathIndex]; + if (!path) { + return; + } + + const pulseLength = this.getParameter('pulseLength'); + + for (let offset = 0; offset < pulseLength; ++offset) { + const index = Math.floor(pulse.position) - offset; + if (index < 0 || index >= path.length) { + continue; + } + + const { x, y } = path[index]; + const intensity = Math.max(0, 1 - offset / pulseLength); + const baseColor = samplePalette(this.paletteStops, intensity); + this.frame[toIndex(x, y, this.width)] = baseColor; + addHexColor(this.frame, toIndex(x, y, this.width), pulse.color, intensity * 1.4); + } + } + + renderFrame() { + this.frame = createFrame(this.width, this.height); + const pathFade = this.getParameter('pathFade') || 0.85; + const pulseCount = this.getParameter('pulseCount') || 3; + + fadeFrame(this.frame, pathFade); + + // Update pulse count if it changed + while (this.pulses.length < pulseCount) { + this.pulses.push(this.spawnPulse(this.pulses.length)); + } + while (this.pulses.length > pulseCount) { + this.pulses.pop(); + } + + this.pulses.forEach((pulse) => { + this.updatePulse(pulse, 0.016); // Assume 60 FPS + this.renderPulse(pulse); + }); + + return this.frame; + } + + getMetadata() { + return { + name: 'Circuit Pulse', + description: 'Animated circuit board with pulsing paths', + parameters: { + pathFade: { type: 'range', min: 0.7, max: 0.95, step: 0.05, default: 0.85 }, + pulseLength: { type: 'range', min: 3, max: 12, step: 1, default: 6 }, + pulseSpeed: { type: 'range', min: 2.0, max: 8.0, step: 0.5, default: 5.0 }, + pulseCount: { type: 'range', min: 1, max: 6, step: 1, default: 3 }, + }, + width: this.width, + height: this.height, + }; + } +} + +module.exports = CircuitPulsePreset; diff --git a/presets/fade-green-blue-preset.js b/presets/fade-green-blue-preset.js new file mode 100644 index 0000000..c19f786 --- /dev/null +++ b/presets/fade-green-blue-preset.js @@ -0,0 +1,51 @@ +// Fade Green Blue preset for LEDLab + +const BasePreset = require('./base-preset'); +const { createFrame, frameToPayload } = require('./frame-utils'); + +class FadeGreenBluePreset extends BasePreset { + constructor(width = 16, height = 16) { + super(width, height); + this.tick = 0; + this.defaultParameters = { + speed: 0.5, // cycles per second + brightness: 1.0, + }; + } + + renderFrame() { + const frame = createFrame(this.width, this.height); + const timeSeconds = (this.tick * 0.016); // Assume 60 FPS + const phase = timeSeconds * this.getParameter('speed') * Math.PI * 2; + const blend = (Math.sin(phase) + 1) * 0.5; // 0..1 + + const brightness = this.getParameter('brightness') || 1.0; + const green = Math.round(255 * (1 - blend) * brightness); + const blue = Math.round(255 * blend * brightness); + + const gHex = green.toString(16).padStart(2, '0'); + const bHex = blue.toString(16).padStart(2, '0'); + + for (let i = 0; i < frame.length; i++) { + frame[i] = '00' + gHex + bHex; + } + + this.tick++; + return frame; + } + + getMetadata() { + return { + name: 'Fade Green Blue', + description: 'Smooth fade between green and blue colors', + parameters: { + speed: { type: 'range', min: 0.1, max: 2.0, step: 0.1, default: 0.5 }, + brightness: { type: 'range', min: 0.1, max: 1.0, step: 0.1, default: 1.0 }, + }, + width: this.width, + height: this.height, + }; + } +} + +module.exports = FadeGreenBluePreset; diff --git a/presets/frame-utils.js b/presets/frame-utils.js new file mode 100644 index 0000000..a78d499 --- /dev/null +++ b/presets/frame-utils.js @@ -0,0 +1,133 @@ +// Frame utilities for LEDLab presets + +const SERPENTINE_WIRING = true; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +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 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 samplePalette(paletteStops, 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 toIndex(col, row, width, serpentine = SERPENTINE_WIRING) { + if (!serpentine || row % 2 === 0) { + return row * width + col; + } + return row * width + (width - 1 - col); +} + +function createFrame(width, height, fill = '000000') { + return new Array(width * height).fill(fill); +} + +function frameToPayload(frame) { + return 'RAW:' + frame.join(''); +} + +function fadeFrame(frame, factor) { + for (let index = 0; index < frame.length; ++index) { + const hex = frame[index]; + const r = parseInt(hex.slice(0, 2), 16) * factor; + const g = parseInt(hex.slice(2, 4), 16) * factor; + const b = parseInt(hex.slice(4, 6), 16) * factor; + frame[index] = toHex(r) + toHex(g) + toHex(b); + } +} + +function addRgbToFrame(frame, index, rgb) { + if (index < 0 || index >= frame.length) { + return; + } + + const current = hexToRgb(frame[index]); + const updated = { + r: clamp(current.r + rgb.r, 0, 255), + g: clamp(current.g + rgb.g, 0, 255), + b: clamp(current.b + rgb.b, 0, 255), + }; + frame[index] = rgbToHex(updated); +} + +function addHexColor(frame, index, hexColor, intensity = 1) { + if (intensity <= 0) { + return; + } + + const base = hexToRgb(hexColor); + addRgbToFrame(frame, index, { + r: base.r * intensity, + g: base.g * intensity, + b: base.b * intensity, + }); +} + +// Color wheel function for rainbow effects +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]; +} + +module.exports = { + clamp, + hexToRgb, + rgbToHex, + lerpRgb, + samplePalette, + toIndex, + createFrame, + frameToPayload, + fadeFrame, + addRgbToFrame, + addHexColor, + wheel, +}; diff --git a/presets/lava-lamp-preset.js b/presets/lava-lamp-preset.js new file mode 100644 index 0000000..cc128d4 --- /dev/null +++ b/presets/lava-lamp-preset.js @@ -0,0 +1,146 @@ +// Lava Lamp preset for LEDLab + +const BasePreset = require('./base-preset'); +const { createFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp } = require('./frame-utils'); + +class LavaLampPreset extends BasePreset { + constructor(width = 16, height = 16) { + super(width, height); + this.blobs = []; + this.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') }, + ]; + this.defaultParameters = { + blobCount: 6, + blobSpeed: 0.18, + minBlobRadius: 0.18, + maxBlobRadius: 0.38, + intensity: 1.0, + }; + } + + init() { + super.init(); + this.blobs = this.createBlobs(this.getParameter('blobCount') || 6); + } + + createBlobs(count) { + const blobList = []; + const maxAxis = Math.max(this.width, this.height); + const minBlobRadius = Math.max(3, maxAxis * (this.getParameter('minBlobRadius') || 0.18)); + const maxBlobRadius = Math.max(minBlobRadius + 1, maxAxis * (this.getParameter('maxBlobRadius') || 0.38)); + + for (let index = 0; index < count; ++index) { + const angle = Math.random() * Math.PI * 2; + const speed = (this.getParameter('blobSpeed') || 0.18) * (0.6 + Math.random() * 0.8); + blobList.push({ + x: Math.random() * Math.max(1, this.width - 1), + y: Math.random() * Math.max(1, this.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: (this.getParameter('intensity') || 1.0) * (0.8 + Math.random() * 0.7), + phase: Math.random() * Math.PI * 2, + phaseVelocity: 0.6 + Math.random() * 0.6, + }); + } + return blobList; + } + + updateBlobs(deltaSeconds) { + const maxX = Math.max(0, this.width - 1); + const maxY = Math.max(0, this.height - 1); + + this.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; + }); + } + + renderFrame() { + this.updateBlobs(0.016); // Assume 60 FPS + + const frame = createFrame(this.width, this.height); + + for (let row = 0; row < this.height; ++row) { + for (let col = 0; col < this.width; ++col) { + const energy = this.calculateEnergyAt(col, row); + const color = samplePalette(this.paletteStops, energy); + frame[toIndex(col, row, this.width)] = color; + } + } + + return frame; + } + + calculateEnergyAt(col, row) { + let energy = 0; + + this.blobs.forEach((blob) => { + const radius = this.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 / this.blobs.length, 0, 1); + } + + getBlobRadius(blob) { + const oscillation = (Math.sin(blob.phase) + 1) * 0.5; + return blob.minRadius + (blob.maxRadius - blob.minRadius) * oscillation; + } + + setParameter(name, value) { + super.setParameter(name, value); + + // Recreate blobs if blob-related parameters change + if (name === 'blobCount' || name === 'blobSpeed' || name === 'minBlobRadius' || name === 'maxBlobRadius' || name === 'intensity') { + this.blobs = this.createBlobs(this.getParameter('blobCount') || 6); + } + } + + getMetadata() { + return { + name: 'Lava Lamp', + description: 'Floating blobs creating organic color gradients', + parameters: { + blobCount: { type: 'range', min: 3, max: 12, step: 1, default: 6 }, + blobSpeed: { type: 'range', min: 0.1, max: 0.5, step: 0.05, default: 0.18 }, + minBlobRadius: { type: 'range', min: 0.1, max: 0.3, step: 0.02, default: 0.18 }, + maxBlobRadius: { type: 'range', min: 0.2, max: 0.5, step: 0.02, default: 0.38 }, + intensity: { type: 'range', min: 0.5, max: 2.0, step: 0.1, default: 1.0 }, + }, + width: this.width, + height: this.height, + }; + } +} + +module.exports = LavaLampPreset; diff --git a/presets/meteor-rain-preset.js b/presets/meteor-rain-preset.js new file mode 100644 index 0000000..485d0d4 --- /dev/null +++ b/presets/meteor-rain-preset.js @@ -0,0 +1,108 @@ +// Meteor Rain preset for LEDLab + +const BasePreset = require('./base-preset'); +const { createFrame, fadeFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp } = require('./frame-utils'); + +class MeteorRainPreset extends BasePreset { + constructor(width = 16, height = 16) { + super(width, height); + this.meteors = []; + this.paletteStops = [ + { stop: 0.0, color: hexToRgb('0a0126') }, + { stop: 0.3, color: hexToRgb('123d8b') }, + { stop: 0.7, color: hexToRgb('21c7d9') }, + { stop: 1.0, color: hexToRgb('f7ffff') }, + ]; + this.defaultParameters = { + meteorCount: 12, + minSpeed: 4, + maxSpeed: 10, + trailDecay: 0.76, + }; + } + + init() { + super.init(); + this.meteors = this.createMeteors(this.getParameter('meteorCount') || 12, this.width, this.height); + } + + createMeteors(count, matrixWidth, matrixHeight) { + const meteorList = []; + for (let index = 0; index < count; ++index) { + meteorList.push(this.spawnMeteor(matrixWidth, matrixHeight)); + } + return meteorList; + } + + spawnMeteor(matrixWidth, matrixHeight) { + const angle = (Math.PI / 4) * (0.6 + Math.random() * 0.8); + const speed = (this.getParameter('minSpeed') || 4) + Math.random() * ((this.getParameter('maxSpeed') || 10) - (this.getParameter('minSpeed') || 4)); + return { + x: Math.random() * matrixWidth, + y: -Math.random() * matrixHeight, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + }; + } + + drawMeteor(meteor) { + const col = Math.round(meteor.x); + const row = Math.round(meteor.y); + if (col < 0 || col >= this.width || row < 0 || row >= this.height) { + return; + } + + const energy = clamp(1.2 - Math.random() * 0.2, 0, 1); + this.frame[toIndex(col, row, this.width)] = samplePalette(this.paletteStops, energy); + } + + updateMeteors(deltaSeconds) { + this.meteors.forEach((meteor, index) => { + meteor.x += meteor.vx * deltaSeconds; + meteor.y += meteor.vy * deltaSeconds; + + this.drawMeteor(meteor); + + if (meteor.x > this.width + 1 || meteor.y > this.height + 1) { + this.meteors[index] = this.spawnMeteor(this.width, this.height); + } + }); + } + + renderFrame() { + this.frame = createFrame(this.width, this.height); + const trailDecay = this.getParameter('trailDecay') || 0.76; + const meteorCount = this.getParameter('meteorCount') || 12; + + fadeFrame(this.frame, trailDecay); + + // Update meteor count if it changed + while (this.meteors.length < meteorCount) { + this.meteors.push(this.spawnMeteor(this.width, this.height)); + } + while (this.meteors.length > meteorCount) { + this.meteors.pop(); + } + + this.updateMeteors(0.016); // Assume 60 FPS + + return this.frame; + } + + getMetadata() { + return { + name: 'Meteor Rain', + description: 'Falling meteors with trailing effects', + parameters: { + meteorCount: { type: 'range', min: 5, max: 20, step: 1, default: 12 }, + minSpeed: { type: 'range', min: 2, max: 8, step: 1, default: 4 }, + maxSpeed: { type: 'range', min: 6, max: 15, step: 1, default: 10 }, + trailDecay: { type: 'range', min: 0.6, max: 0.9, step: 0.02, default: 0.76 }, + }, + width: this.width, + height: this.height, + }; + } +} + +module.exports = MeteorRainPreset; diff --git a/presets/nebula-drift-preset.js b/presets/nebula-drift-preset.js new file mode 100644 index 0000000..f9b694f --- /dev/null +++ b/presets/nebula-drift-preset.js @@ -0,0 +1,81 @@ +// Nebula Drift preset for LEDLab + +const BasePreset = require('./base-preset'); +const { createFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp } = require('./frame-utils'); + +class NebulaDriftPreset extends BasePreset { + constructor(width = 16, height = 16) { + super(width, height); + this.timeSeconds = 0; + this.paletteStops = [ + { stop: 0.0, color: hexToRgb('100406') }, + { stop: 0.25, color: hexToRgb('2e0f1f') }, + { stop: 0.5, color: hexToRgb('6a1731') }, + { stop: 0.7, color: hexToRgb('b63b32') }, + { stop: 0.85, color: hexToRgb('f48b2a') }, + { stop: 1.0, color: hexToRgb('ffe9b0') }, + ]; + this.defaultParameters = { + primarySpeed: 0.15, + secondarySpeed: 0.32, + waveScale: 0.75, + brightness: 1.0, + }; + } + + layeredWave(u, v, speed, offset) { + return Math.sin((u * 3 + v * 2) * Math.PI * this.getParameter('waveScale') + this.timeSeconds * speed + offset); + } + + renderFrame() { + this.timeSeconds += 0.016; // Assume 60 FPS + + const frame = createFrame(this.width, this.height); + const brightness = this.getParameter('brightness') || 1.0; + + for (let row = 0; row < this.height; ++row) { + const v = row / Math.max(1, this.height - 1); + for (let col = 0; col < this.width; ++col) { + const u = col / Math.max(1, this.width - 1); + const primary = this.layeredWave(u, v, this.getParameter('primarySpeed') || 0.15, 0); + const secondary = this.layeredWave(v, u, this.getParameter('secondarySpeed') || 0.32, Math.PI / 4); + const tertiary = Math.sin((u + v) * Math.PI * 1.5 + this.timeSeconds * 0.18); + + const combined = 0.45 * primary + 0.35 * secondary + 0.2 * tertiary; + const envelope = Math.sin((u * v) * Math.PI * 2 + this.timeSeconds * 0.1) * 0.25 + 0.75; + const value = clamp((combined * 0.5 + 0.5) * envelope, 0, 1); + + let color = samplePalette(this.paletteStops, value); + + // Apply brightness + if (brightness < 1.0) { + const rgb = hexToRgb(color); + color = Math.round(rgb.r * brightness).toString(16).padStart(2, '0') + + Math.round(rgb.g * brightness).toString(16).padStart(2, '0') + + Math.round(rgb.b * brightness).toString(16).padStart(2, '0'); + } + + frame[toIndex(col, row, this.width)] = color; + } + } + + return frame; + } + + getMetadata() { + return { + name: 'Nebula Drift', + description: 'Organic drifting nebula with layered wave patterns', + parameters: { + primarySpeed: { type: 'range', min: 0.05, max: 0.3, step: 0.05, default: 0.15 }, + secondarySpeed: { type: 'range', min: 0.2, max: 0.5, step: 0.02, default: 0.32 }, + waveScale: { type: 'range', min: 0.5, max: 1.0, step: 0.05, default: 0.75 }, + brightness: { type: 'range', min: 0.3, max: 1.0, step: 0.1, default: 1.0 }, + }, + width: this.width, + height: this.height, + }; + } +} + +module.exports = NebulaDriftPreset; diff --git a/presets/ocean-glimmer-preset.js b/presets/ocean-glimmer-preset.js new file mode 100644 index 0000000..1e00e7f --- /dev/null +++ b/presets/ocean-glimmer-preset.js @@ -0,0 +1,79 @@ +// Ocean Glimmer preset for LEDLab + +const BasePreset = require('./base-preset'); +const { createFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp } = require('./frame-utils'); + +class OceanGlimmerPreset extends BasePreset { + constructor(width = 16, height = 16) { + super(width, height); + this.timeSeconds = 0; + this.paletteStops = [ + { stop: 0.0, color: hexToRgb('031521') }, + { stop: 0.35, color: hexToRgb('024f6d') }, + { stop: 0.65, color: hexToRgb('13a4a1') }, + { stop: 0.85, color: hexToRgb('67dcd0') }, + { stop: 1.0, color: hexToRgb('fcdba4') }, + ]; + this.defaultParameters = { + shimmer: 0.08, + waveSpeed1: 1.2, + waveSpeed2: 0.9, + waveSpeed3: 0.5, + brightness: 1.0, + }; + } + + renderFrame() { + this.timeSeconds += 0.016; // Assume 60 FPS + + const frame = createFrame(this.width, this.height); + const shimmer = this.getParameter('shimmer') || 0.08; + const brightness = this.getParameter('brightness') || 1.0; + + for (let row = 0; row < this.height; ++row) { + const v = row / Math.max(1, this.height - 1); + for (let col = 0; col < this.width; ++col) { + const u = col / Math.max(1, this.width - 1); + const base = + 0.33 + + 0.26 * Math.sin(u * Math.PI * 2 + this.timeSeconds * (this.getParameter('waveSpeed1') || 1.2)) + + 0.26 * Math.sin(v * Math.PI * 2 - this.timeSeconds * (this.getParameter('waveSpeed2') || 0.9)) + + 0.26 * Math.sin((u + v) * Math.PI * 2 + this.timeSeconds * (this.getParameter('waveSpeed3') || 0.5)); + const noise = (Math.random() - 0.5) * shimmer; + const value = clamp(base + noise, 0, 1); + + let color = samplePalette(this.paletteStops, value); + + // Apply brightness + if (brightness < 1.0) { + const rgb = hexToRgb(color); + color = Math.round(rgb.r * brightness).toString(16).padStart(2, '0') + + Math.round(rgb.g * brightness).toString(16).padStart(2, '0') + + Math.round(rgb.b * brightness).toString(16).padStart(2, '0'); + } + + frame[toIndex(col, row, this.width)] = color; + } + } + + return frame; + } + + getMetadata() { + return { + name: 'Ocean Glimmer', + description: 'Ocean-like waves with shimmer effects', + parameters: { + shimmer: { type: 'range', min: 0.02, max: 0.15, step: 0.01, default: 0.08 }, + waveSpeed1: { type: 'range', min: 0.5, max: 2.0, step: 0.1, default: 1.2 }, + waveSpeed2: { type: 'range', min: 0.5, max: 2.0, step: 0.1, default: 0.9 }, + waveSpeed3: { type: 'range', min: 0.2, max: 1.0, step: 0.1, default: 0.5 }, + brightness: { type: 'range', min: 0.3, max: 1.0, step: 0.1, default: 1.0 }, + }, + width: this.width, + height: this.height, + }; + } +} + +module.exports = OceanGlimmerPreset; diff --git a/presets/preset-registry.js b/presets/preset-registry.js new file mode 100644 index 0000000..43527ae --- /dev/null +++ b/presets/preset-registry.js @@ -0,0 +1,74 @@ +// Preset registry for LEDLab + +const RainbowPreset = require('./rainbow-preset'); +const AuroraCurtainsPreset = require('./aurora-curtains-preset'); +const BouncingBallPreset = require('./bouncing-ball-preset'); +const CircuitPulsePreset = require('./circuit-pulse-preset'); +const FadeGreenBluePreset = require('./fade-green-blue-preset'); +const LavaLampPreset = require('./lava-lamp-preset'); +const MeteorRainPreset = require('./meteor-rain-preset'); +const NebulaDriftPreset = require('./nebula-drift-preset'); +const OceanGlimmerPreset = require('./ocean-glimmer-preset'); +const SpiralBloomPreset = require('./spiral-bloom-preset'); +const VoxelFirefliesPreset = require('./voxel-fireflies-preset'); +const WormholeTunnelPreset = require('./wormhole-tunnel-preset'); + +class PresetRegistry { + constructor() { + this.presets = new Map(); + this.registerDefaults(); + } + + registerDefaults() { + this.register('rainbow', RainbowPreset); + this.register('aurora-curtains', AuroraCurtainsPreset); + this.register('bouncing-ball', BouncingBallPreset); + this.register('circuit-pulse', CircuitPulsePreset); + this.register('fade-green-blue', FadeGreenBluePreset); + this.register('lava-lamp', LavaLampPreset); + this.register('meteor-rain', MeteorRainPreset); + this.register('nebula-drift', NebulaDriftPreset); + this.register('ocean-glimmer', OceanGlimmerPreset); + this.register('spiral-bloom', SpiralBloomPreset); + this.register('voxel-fireflies', VoxelFirefliesPreset); + this.register('wormhole-tunnel', WormholeTunnelPreset); + } + + register(name, presetClass) { + this.presets.set(name, presetClass); + } + + createPreset(name, width = 16, height = 16) { + const presetClass = this.presets.get(name); + if (!presetClass) { + throw new Error(`Preset '${name}' not found`); + } + + return new presetClass(width, height); + } + + getPresetNames() { + return Array.from(this.presets.keys()); + } + + getPresetMetadata(name) { + try { + const preset = this.createPreset(name); + return preset.getMetadata(); + } catch (error) { + console.error(`Error getting metadata for preset '${name}':`, error); + return null; + } + } + + getAllPresetMetadata() { + const metadata = {}; + for (const name of this.getPresetNames()) { + metadata[name] = this.getPresetMetadata(name); + } + return metadata; + } +} + +// Export the constructor class +module.exports = PresetRegistry; diff --git a/presets/rainbow-preset.js b/presets/rainbow-preset.js new file mode 100644 index 0000000..f487fcb --- /dev/null +++ b/presets/rainbow-preset.js @@ -0,0 +1,53 @@ +// Rainbow preset for LEDLab + +const BasePreset = require('./base-preset'); +const { createFrame, wheel } = require('./frame-utils'); + +class RainbowPreset extends BasePreset { + constructor(width = 16, height = 16) { + super(width, height); + this.defaultParameters = { + speed: 1, + brightness: 1.0, + offset: 0, + }; + } + + renderFrame() { + const frame = createFrame(this.width, this.height); + const speed = this.getParameter('speed') || 1; + const brightness = this.getParameter('brightness') || 1.0; + const offset = (this.getParameter('offset') || 0) + this.frameCount * speed; + + for (let i = 0; i < frame.length; i++) { + const colorIndex = (i * 256 / frame.length + offset) & 255; + const [r, g, b] = wheel(colorIndex); + + // Apply brightness + const adjustedR = Math.round(r * brightness); + const adjustedG = Math.round(g * brightness); + const adjustedB = Math.round(b * brightness); + + frame[i] = adjustedR.toString(16).padStart(2, '0') + + adjustedG.toString(16).padStart(2, '0') + + adjustedB.toString(16).padStart(2, '0'); + } + + return frame; + } + + getMetadata() { + return { + name: 'Rainbow', + description: 'Classic rainbow pattern that cycles through colors', + parameters: { + speed: { type: 'range', min: 0.1, max: 5.0, step: 0.1, default: 1.0 }, + brightness: { type: 'range', min: 0.1, max: 1.0, step: 0.1, default: 1.0 }, + }, + width: this.width, + height: this.height, + }; + } +} + +module.exports = RainbowPreset; diff --git a/presets/spiral-bloom-preset.js b/presets/spiral-bloom-preset.js new file mode 100644 index 0000000..7f37689 --- /dev/null +++ b/presets/spiral-bloom-preset.js @@ -0,0 +1,81 @@ +// Spiral Bloom preset for LEDLab + +const BasePreset = require('./base-preset'); +const { createFrame, frameToPayload, hexToRgb, samplePalette, toIndex } = require('./frame-utils'); + +class SpiralBloomPreset extends BasePreset { + constructor(width = 16, height = 16) { + super(width, height); + this.rotation = 0; + this.hueShift = 0; + this.paletteStops = [ + { stop: 0.0, color: hexToRgb('051923') }, + { stop: 0.2, color: hexToRgb('0c4057') }, + { stop: 0.45, color: hexToRgb('1d7a70') }, + { stop: 0.7, color: hexToRgb('39b15f') }, + { stop: 0.88, color: hexToRgb('9dd54c') }, + { stop: 1.0, color: hexToRgb('f7f5bc') }, + ]; + this.defaultParameters = { + rotationSpeed: 0.7, + hueSpeed: 0.2, + spiralArms: 5, + brightness: 1.0, + }; + } + + renderFrame() { + this.rotation += 0.016 * (this.getParameter('rotationSpeed') || 0.7); + this.hueShift += 0.016 * (this.getParameter('hueSpeed') || 0.2); + + const frame = createFrame(this.width, this.height); + const brightness = this.getParameter('brightness') || 1.0; + const spiralArms = this.getParameter('spiralArms') || 5; + + const cx = (this.width - 1) / 2; + const cy = (this.height - 1) / 2; + const radiusNorm = Math.hypot(cx, cy) || 1; + + for (let row = 0; row < this.height; ++row) { + for (let col = 0; col < this.width; ++col) { + const dx = col - cx; + const dy = row - cy; + const radius = Math.hypot(dx, dy) / radiusNorm; + const angle = Math.atan2(dy, dx); + const arm = 0.5 + 0.5 * Math.sin(spiralArms * (angle + this.rotation) + this.hueShift * Math.PI * 2); + const value = Math.min(1, radius * 0.8 + arm * 0.4); + + let color = samplePalette(this.paletteStops, value); + + // Apply brightness + if (brightness < 1.0) { + const rgb = hexToRgb(color); + color = Math.round(rgb.r * brightness).toString(16).padStart(2, '0') + + Math.round(rgb.g * brightness).toString(16).padStart(2, '0') + + Math.round(rgb.b * brightness).toString(16).padStart(2, '0'); + } + + frame[toIndex(col, row, this.width)] = color; + } + } + + return frame; + } + + getMetadata() { + return { + name: 'Spiral Bloom', + description: 'Rotating spiral patterns blooming outward', + parameters: { + rotationSpeed: { type: 'range', min: 0.3, max: 1.5, step: 0.1, default: 0.7 }, + hueSpeed: { type: 'range', min: 0.1, max: 0.5, step: 0.05, default: 0.2 }, + spiralArms: { type: 'range', min: 3, max: 8, step: 1, default: 5 }, + brightness: { type: 'range', min: 0.3, max: 1.0, step: 0.1, default: 1.0 }, + }, + width: this.width, + height: this.height, + }; + } +} + +module.exports = SpiralBloomPreset; diff --git a/presets/voxel-fireflies-preset.js b/presets/voxel-fireflies-preset.js new file mode 100644 index 0000000..5eba08e --- /dev/null +++ b/presets/voxel-fireflies-preset.js @@ -0,0 +1,141 @@ +// Voxel Fireflies preset for LEDLab + +const BasePreset = require('./base-preset'); +const { createFrame, fadeFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp, addHexColor } = require('./frame-utils'); + +class VoxelFirefliesPreset extends BasePreset { + constructor(width = 16, height = 16) { + super(width, height); + this.fireflies = []; + this.paletteStops = [ + { stop: 0.0, color: hexToRgb('02030a') }, + { stop: 0.2, color: hexToRgb('031c2d') }, + { stop: 0.4, color: hexToRgb('053d4a') }, + { stop: 0.6, color: hexToRgb('107b68') }, + { stop: 0.8, color: hexToRgb('14c491') }, + { stop: 1.0, color: hexToRgb('f2ffd2') }, + ]; + this.defaultParameters = { + fireflyCount: 18, + hoverSpeed: 0.6, + glowSpeed: 1.8, + trailDecay: 0.8, + brightness: 1.0, + }; + } + + init() { + super.init(); + this.fireflies = this.createFireflies(this.getParameter('fireflyCount') || 18, this.width, this.height); + } + + createFireflies(count, matrixWidth, matrixHeight) { + const list = []; + for (let index = 0; index < count; ++index) { + list.push(this.spawnFirefly(matrixWidth, matrixHeight)); + } + return list; + } + + spawnFirefly(matrixWidth, matrixHeight) { + return { + x: Math.random() * (matrixWidth - 1), + y: Math.random() * (matrixHeight - 1), + targetX: Math.random() * (matrixWidth - 1), + targetY: Math.random() * (matrixHeight - 1), + glowPhase: Math.random() * Math.PI * 2, + dwell: 1 + Math.random() * 2, + }; + } + + updateFirefly(firefly, deltaSeconds) { + const dx = firefly.targetX - firefly.x; + const dy = firefly.targetY - firefly.y; + const distance = Math.hypot(dx, dy); + + if (distance < 0.2) { + firefly.dwell -= deltaSeconds; + if (firefly.dwell <= 0) { + firefly.targetX = Math.random() * (this.width - 1); + firefly.targetY = Math.random() * (this.height - 1); + firefly.dwell = 1 + Math.random() * 2; + } + } else { + const speed = (this.getParameter('hoverSpeed') || 0.6) * (0.8 + Math.random() * 0.4); + firefly.x += (dx / distance) * speed * deltaSeconds; + firefly.y += (dy / distance) * speed * deltaSeconds; + } + + firefly.glowPhase += deltaSeconds * (this.getParameter('glowSpeed') || 1.8) * (0.7 + Math.random() * 0.6); + } + + drawFirefly(firefly) { + const baseGlow = (Math.sin(firefly.glowPhase) + 1) * 0.5; + const col = Math.round(firefly.x); + const row = Math.round(firefly.y); + + for (let dy = -1; dy <= 1; ++dy) { + for (let dx = -1; dx <= 1; ++dx) { + const sampleX = col + dx; + const sampleY = row + dy; + if (sampleX < 0 || sampleX >= this.width || sampleY < 0 || sampleY >= this.height) { + continue; + } + + const distance = Math.hypot(dx, dy); + const falloff = clamp(1 - distance * 0.7, 0, 1); + const intensity = baseGlow * falloff; + if (intensity <= 0) { + continue; + } + + this.frame[toIndex(sampleX, sampleY, this.width)] = samplePalette(this.paletteStops, intensity); + if (distance === 0) { + addHexColor(this.frame, toIndex(sampleX, sampleY, this.width), 'ffd966', intensity * 1.6); + } + } + } + } + + renderFrame() { + this.frame = createFrame(this.width, this.height); + const trailDecay = this.getParameter('trailDecay') || 0.8; + const fireflyCount = this.getParameter('fireflyCount') || 18; + const brightness = this.getParameter('brightness') || 1.0; + + fadeFrame(this.frame, trailDecay); + + // Update firefly count if it changed + while (this.fireflies.length < fireflyCount) { + this.fireflies.push(this.spawnFirefly(this.width, this.height)); + } + while (this.fireflies.length > fireflyCount) { + this.fireflies.pop(); + } + + this.fireflies.forEach((firefly) => { + this.updateFirefly(firefly, 0.016); // Assume 60 FPS + this.drawFirefly(firefly); + }); + + return this.frame; + } + + getMetadata() { + return { + name: 'Voxel Fireflies', + description: 'Glowing fireflies that hover and move around', + parameters: { + fireflyCount: { type: 'range', min: 8, max: 30, step: 2, default: 18 }, + hoverSpeed: { type: 'range', min: 0.3, max: 1.2, step: 0.1, default: 0.6 }, + glowSpeed: { type: 'range', min: 1.0, max: 3.0, step: 0.2, default: 1.8 }, + trailDecay: { type: 'range', min: 0.7, max: 0.95, step: 0.05, default: 0.8 }, + brightness: { type: 'range', min: 0.5, max: 1.5, step: 0.1, default: 1.0 }, + }, + width: this.width, + height: this.height, + }; + } +} + +module.exports = VoxelFirefliesPreset; diff --git a/presets/wormhole-tunnel-preset.js b/presets/wormhole-tunnel-preset.js new file mode 100644 index 0000000..5463db2 --- /dev/null +++ b/presets/wormhole-tunnel-preset.js @@ -0,0 +1,90 @@ +// Wormhole Tunnel preset for LEDLab + +const BasePreset = require('./base-preset'); +const { createFrame, frameToPayload, hexToRgb, samplePalette, toIndex, clamp } = require('./frame-utils'); + +class WormholeTunnelPreset extends BasePreset { + constructor(width = 16, height = 16) { + super(width, height); + this.timeSeconds = 0; + this.paletteStops = [ + { stop: 0.0, color: hexToRgb('010005') }, + { stop: 0.2, color: hexToRgb('07204f') }, + { stop: 0.45, color: hexToRgb('124aa0') }, + { stop: 0.7, color: hexToRgb('36a5ff') }, + { stop: 0.87, color: hexToRgb('99e6ff') }, + { stop: 1.0, color: hexToRgb('f1fbff') }, + ]; + this.defaultParameters = { + ringDensity: 8, + ringSpeed: 1.4, + ringSharpness: 7.5, + twistIntensity: 2.2, + twistSpeed: 0.9, + coreExponent: 1.6, + brightness: 1.0, + }; + } + + renderFrame() { + this.timeSeconds += 0.016; // Assume 60 FPS + + const frame = createFrame(this.width, this.height); + const brightness = this.getParameter('brightness') || 1.0; + + const cx = (this.width - 1) / 2; + const cy = (this.height - 1) / 2; + const radiusNorm = Math.hypot(cx, cy) || 1; + + for (let row = 0; row < this.height; ++row) { + for (let col = 0; col < this.width; ++col) { + const dx = col - cx; + const dy = row - cy; + const radius = Math.hypot(dx, dy) / radiusNorm; + const angle = Math.atan2(dy, dx); + + const radialPhase = radius * (this.getParameter('ringDensity') || 8) - this.timeSeconds * (this.getParameter('ringSpeed') || 1.4); + const ring = Math.exp(-Math.pow(Math.sin(radialPhase * Math.PI), 2) * (this.getParameter('ringSharpness') || 7.5)); + + const twist = Math.sin(angle * (this.getParameter('twistIntensity') || 2.2) + this.timeSeconds * (this.getParameter('twistSpeed') || 0.9)) * 0.35 + 0.65; + const depth = Math.pow(clamp(1 - radius, 0, 1), this.getParameter('coreExponent') || 1.6); + + const value = clamp(ring * 0.6 + depth * 0.3 + twist * 0.1, 0, 1); + + let color = samplePalette(this.paletteStops, value); + + // Apply brightness + if (brightness < 1.0) { + const rgb = hexToRgb(color); + color = Math.round(rgb.r * brightness).toString(16).padStart(2, '0') + + Math.round(rgb.g * brightness).toString(16).padStart(2, '0') + + Math.round(rgb.b * brightness).toString(16).padStart(2, '0'); + } + + frame[toIndex(col, row, this.width)] = color; + } + } + + return frame; + } + + getMetadata() { + return { + name: 'Wormhole Tunnel', + description: 'Hypnotic tunnel effect with rotating rings', + parameters: { + ringDensity: { type: 'range', min: 4, max: 12, step: 1, default: 8 }, + ringSpeed: { type: 'range', min: 0.5, max: 2.5, step: 0.1, default: 1.4 }, + ringSharpness: { type: 'range', min: 3, max: 12, step: 0.5, default: 7.5 }, + twistIntensity: { type: 'range', min: 1.0, max: 4.0, step: 0.2, default: 2.2 }, + twistSpeed: { type: 'range', min: 0.3, max: 1.5, step: 0.1, default: 0.9 }, + coreExponent: { type: 'range', min: 1.0, max: 2.5, step: 0.1, default: 1.6 }, + brightness: { type: 'range', min: 0.3, max: 1.0, step: 0.1, default: 1.0 }, + }, + width: this.width, + height: this.height, + }; + } +} + +module.exports = WormholeTunnelPreset; diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..6ad48fb --- /dev/null +++ b/public/index.html @@ -0,0 +1,100 @@ + + + + + + SPORE LEDLab + + + +
+
+

SPORE LEDLab

+ +
+ +
+ +
+
+

Matrix Display

+
+ 16x16 | + Stopped +
+
+
+ +
+
+ + +
+ +
+

SPORE Nodes

+
+ +
+
+
Discovering nodes...
+
+
+ + +
+

Animation Presets

+ +
+ +
+
+ + +
+
+ + +
+

Matrix Configuration

+
+
+ + +
+
+ + +
+ +
+
+ + +
+

Manual Control

+
+ + +
+
+
+
+
+ + + + + + + + + + + diff --git a/public/scripts/constants.js b/public/scripts/constants.js new file mode 100644 index 0000000..862d21a --- /dev/null +++ b/public/scripts/constants.js @@ -0,0 +1,51 @@ +(function(){ + const TIMING = { + NAV_COOLDOWN_MS: 300, + VIEW_FADE_OUT_MS: 150, + VIEW_FADE_IN_MS: 200, + VIEW_FADE_DELAY_MS: 50, + AUTO_REFRESH_MS: 30000, + PRIMARY_NODE_REFRESH_MS: 10000, + LOAD_GUARD_MS: 10000, + UDP_DISCOVERY_INTERVAL_MS: 5000, + WEBSOCKET_PING_INTERVAL_MS: 30000, + FRAME_UPDATE_INTERVAL_MS: 50 + }; + + const SELECTORS = { + NAV_TAB: '.nav-tab', + VIEW_CONTENT: '.view-content', + MATRIX_CANVAS: '.matrix-canvas', + PRESET_SELECT: '.preset-select', + NODE_LIST: '.node-list', + CONTROL_PANEL: '.control-panel' + }; + + const CLASSES = { + NODE_CONNECTED: 'node-connected', + NODE_DISCONNECTED: 'node-disconnected', + NODE_SELECTED: 'node-selected', + PRESET_ACTIVE: 'preset-active', + STREAMING_ACTIVE: 'streaming-active' + }; + + const DEFAULTS = { + MATRIX_WIDTH: 16, + MATRIX_HEIGHT: 16, + UDP_PORT: 4210, + WEBSOCKET_PORT: 8080, + BROADCAST_ADDRESS: '255.255.255.255' + }; + + const MESSAGES = { + NODE_DISCOVERED: 'node:discovered', + NODE_LOST: 'node:lost', + STREAM_START: 'stream:start', + STREAM_STOP: 'stream:stop', + STREAM_UPDATE: 'stream:update', + PRESET_CHANGED: 'preset:changed', + MATRIX_UPDATED: 'matrix:updated' + }; + + window.CONSTANTS = window.CONSTANTS || { TIMING, SELECTORS, CLASSES, DEFAULTS, MESSAGES }; +})(); diff --git a/public/scripts/framework.js b/public/scripts/framework.js new file mode 100644 index 0000000..9d815ed --- /dev/null +++ b/public/scripts/framework.js @@ -0,0 +1,759 @@ +// SPORE LEDLab Framework - Component-based architecture with pub/sub system + +// Lightweight logger with level gating +const logger = { + debug: (...args) => { try { if (window && window.DEBUG) { console.debug(...args); } } catch (_) { /* no-op */ } }, + info: (...args) => console.info(...args), + warn: (...args) => console.warn(...args), + error: (...args) => console.error(...args), +}; +if (typeof window !== 'undefined') { + window.logger = window.logger || logger; +} + +// Event Bus for pub/sub communication +class EventBus { + constructor() { + this.events = new Map(); + } + + // Subscribe to an event + subscribe(event, callback) { + if (!this.events.has(event)) { + this.events.set(event, []); + } + this.events.get(event).push(callback); + + // Return unsubscribe function + return () => { + const callbacks = this.events.get(event); + if (callbacks) { + const index = callbacks.indexOf(callback); + if (index > -1) { + callbacks.splice(index, 1); + } + } + }; + } + + // Publish an event + publish(event, data) { + if (this.events.has(event)) { + this.events.get(event).forEach(callback => { + try { + callback(data); + } catch (error) { + console.error(`Error in event callback for ${event}:`, error); + } + }); + } + } + + // Unsubscribe from an event + unsubscribe(event, callback) { + if (this.events.has(event)) { + const callbacks = this.events.get(event); + const index = callbacks.indexOf(callback); + if (index > -1) { + callbacks.splice(index, 1); + } + } + } + + // Clear all events + clear() { + this.events.clear(); + } +} + +// Base ViewModel class with enhanced state management +class ViewModel { + constructor() { + this._data = {}; + this._listeners = new Map(); + this._eventBus = null; + this._uiState = new Map(); // Store UI state like active tabs, expanded cards, etc. + this._previousData = {}; // Store previous data for change detection + } + + // Set the event bus for this view model + setEventBus(eventBus) { + this._eventBus = eventBus; + } + + // Get data property + get(property) { + return this._data[property]; + } + + // Set data property and notify listeners + set(property, value) { + logger.debug(`ViewModel: Setting property '${property}' to:`, value); + + // Check if the value has actually changed + const hasChanged = this._data[property] !== value; + + if (hasChanged) { + // Store previous value for change detection + this._previousData[property] = this._data[property]; + + // Update the data + this._data[property] = value; + + logger.debug(`ViewModel: Property '${property}' changed, notifying listeners...`); + this._notifyListeners(property, value, this._previousData[property]); + } else { + logger.debug(`ViewModel: Property '${property}' unchanged, skipping notification`); + } + } + + // Set multiple properties at once with change detection + setMultiple(properties) { + const changedProperties = {}; + + // Determine changes and update previousData snapshot per key + Object.keys(properties).forEach(key => { + const newValue = properties[key]; + const oldValue = this._data[key]; + if (oldValue !== newValue) { + this._previousData[key] = oldValue; + changedProperties[key] = newValue; + } + }); + + // Apply all properties + Object.keys(properties).forEach(key => { + this._data[key] = properties[key]; + }); + + // Notify listeners only for changed properties with accurate previous values + Object.keys(changedProperties).forEach(key => { + this._notifyListeners(key, this._data[key], this._previousData[key]); + }); + + if (Object.keys(changedProperties).length > 0) { + logger.debug(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties)); + } + } + + // Subscribe to property changes + subscribe(property, callback) { + if (!this._listeners.has(property)) { + this._listeners.set(property, []); + } + this._listeners.get(property).push(callback); + + // Return unsubscribe function + return () => { + const callbacks = this._listeners.get(property); + if (callbacks) { + const index = callbacks.indexOf(callback); + if (index > -1) { + callbacks.splice(index, 1); + } + } + }; + } + + // Notify listeners of property changes + _notifyListeners(property, value, previousValue) { + logger.debug(`ViewModel: _notifyListeners called for property '${property}'`); + if (this._listeners.has(property)) { + const callbacks = this._listeners.get(property); + logger.debug(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`); + callbacks.forEach((callback, index) => { + try { + logger.debug(`ViewModel: Calling listener ${index} for property '${property}'`); + callback(value, previousValue); + } catch (error) { + console.error(`Error in property listener for ${property}:`, error); + } + }); + } else { + logger.debug(`ViewModel: No listeners found for property '${property}'`); + } + } + + // Publish event to event bus + publish(event, data) { + if (this._eventBus) { + this._eventBus.publish(event, data); + } + } + + // Get all data + getAll() { + return { ...this._data }; + } + + // Clear all data + clear() { + this._data = {}; + this._listeners.clear(); + } + + // UI State Management Methods + setUIState(key, value) { + this._uiState.set(key, value); + } + + getUIState(key) { + return this._uiState.get(key); + } + + getAllUIState() { + return new Map(this._uiState); + } + + clearUIState(key) { + if (key) { + this._uiState.delete(key); + } else { + this._uiState.clear(); + } + } + + // Check if a property has changed + hasChanged(property) { + return this._data[property] !== this._previousData[property]; + } + + // Get previous value of a property + getPrevious(property) { + return this._previousData[property]; + } + + // Batch update with change detection + batchUpdate(updates, options = {}) { + const { notifyChanges = true } = options; + + // Track which keys actually change and what the previous values were + const changedKeys = []; + Object.keys(updates).forEach(key => { + const newValue = updates[key]; + const oldValue = this._data[key]; + if (oldValue !== newValue) { + this._previousData[key] = oldValue; + this._data[key] = newValue; + changedKeys.push(key); + } else { + // Still apply to ensure consistency if needed + this._data[key] = newValue; + } + }); + + // Notify listeners for changed keys + if (notifyChanges) { + changedKeys.forEach(key => { + this._notifyListeners(key, this._data[key], this._previousData[key]); + }); + } + } +} + +// Base Component class +class Component { + constructor(container, viewModel, eventBus) { + this.container = container; + this.viewModel = viewModel; + this.eventBus = eventBus; + this.isMounted = false; + this.unsubscribers = []; + this.uiState = new Map(); // Local UI state for this component + + // Set event bus on view model + if (this.viewModel) { + this.viewModel.setEventBus(eventBus); + } + + // Bind methods + this.render = this.render.bind(this); + this.mount = this.mount.bind(this); + this.unmount = this.unmount.bind(this); + this.updatePartial = this.updatePartial.bind(this); + } + + // Mount the component + mount() { + if (this.isMounted) return; + + logger.debug(`${this.constructor.name}: Starting mount...`); + this.isMounted = true; + this.setupEventListeners(); + this.setupViewModelListeners(); + this.render(); + + logger.debug(`${this.constructor.name}: Mounted successfully`); + } + + // Unmount the component + unmount() { + if (!this.isMounted) return; + + this.isMounted = false; + this.cleanupEventListeners(); + this.cleanupViewModelListeners(); + + logger.debug(`${this.constructor.name} unmounted`); + } + + // Setup event listeners (override in subclasses) + setupEventListeners() { + // Override in subclasses + } + + // Setup view model listeners (override in subclasses) + setupViewModelListeners() { + // Override in subclasses + } + + // Cleanup event listeners (override in subclasses) + cleanupEventListeners() { + // Override in subclasses + } + + // Cleanup view model listeners (override in subclasses) + cleanupViewModelListeners() { + // Override in subclasses + } + + // Render the component (override in subclasses) + render() { + // Override in subclasses + } + + // Partial update method for efficient data updates + updatePartial(property, newValue, previousValue) { + // Override in subclasses to implement partial updates + logger.debug(`${this.constructor.name}: Partial update for '${property}':`, { newValue, previousValue }); + } + + // UI State Management Methods + setUIState(key, value) { + this.uiState.set(key, value); + // Also store in view model for persistence across refreshes + if (this.viewModel) { + this.viewModel.setUIState(key, value); + } + } + + getUIState(key) { + // First try local state, then view model state + return this.uiState.get(key) || (this.viewModel ? this.viewModel.getUIState(key) : null); + } + + getAllUIState() { + const localState = new Map(this.uiState); + const viewModelState = this.viewModel ? this.viewModel.getAllUIState() : new Map(); + + // Merge states, with local state taking precedence + const mergedState = new Map(viewModelState); + localState.forEach((value, key) => mergedState.set(key, value)); + + return mergedState; + } + + clearUIState(key) { + if (key) { + this.uiState.delete(key); + if (this.viewModel) { + this.viewModel.clearUIState(key); + } + } else { + this.uiState.clear(); + if (this.viewModel) { + this.viewModel.clearUIState(); + } + } + } + + // Helper method to add event listener and track for cleanup + addEventListener(element, event, handler) { + element.addEventListener(event, handler); + this.unsubscribers.push(() => { + element.removeEventListener(event, handler); + }); + } + + // Helper method to subscribe to event bus and track for cleanup + subscribeToEvent(event, handler) { + const unsubscribe = this.eventBus.subscribe(event, handler); + this.unsubscribers.push(unsubscribe); + } + + // Helper method to subscribe to view model property and track for cleanup + subscribeToProperty(property, handler) { + if (this.viewModel) { + const unsubscribe = this.viewModel.subscribe(property, (newValue, previousValue) => { + // Call handler with both new and previous values for change detection + handler(newValue, previousValue); + }); + this.unsubscribers.push(unsubscribe); + } + } + + // Helper method to find element within component container + findElement(selector) { + return this.container.querySelector(selector); + } + + // Helper method to find all elements within component container + findAllElements(selector) { + return this.container.querySelectorAll(selector); + } + + // Helper method to set innerHTML safely + setHTML(selector, html) { + logger.debug(`${this.constructor.name}: setHTML called with selector '${selector}', html length: ${html.length}`); + + let element; + if (selector === '') { + // Empty selector means set HTML on the component's container itself + element = this.container; + logger.debug(`${this.constructor.name}: Using component container for empty selector`); + } else { + // Find element within the component's container + element = this.findElement(selector); + } + + if (element) { + logger.debug(`${this.constructor.name}: Element found, setting innerHTML`); + element.innerHTML = html; + logger.debug(`${this.constructor.name}: innerHTML set successfully`); + } else { + console.error(`${this.constructor.name}: Element not found for selector '${selector}'`); + } + } + + // Helper method to set text content safely + setText(selector, text) { + const element = this.findElement(selector); + if (element) { + element.textContent = text; + } + } + + // Helper method to add/remove CSS classes + setClass(selector, className, add = true) { + const element = this.findElement(selector); + if (element) { + if (add) { + element.classList.add(className); + } else { + element.classList.remove(className); + } + } + } + + // Helper method to set CSS styles + setStyle(selector, property, value) { + const element = this.findElement(selector); + if (element) { + element.style[property] = value; + } + } + + // Helper method to show/hide elements + setVisible(selector, visible) { + const element = this.findElement(selector); + if (element) { + element.style.display = visible ? '' : 'none'; + } + } + + // Helper method to enable/disable elements + setEnabled(selector, enabled) { + const element = this.findElement(selector); + if (element) { + element.disabled = !enabled; + } + } + + // Reusable render helpers + renderLoading(customHtml) { + const html = customHtml || ` +
+
Loading...
+
+ `; + this.setHTML('', html); + } + + renderError(message) { + const safe = this.escapeHtml(String(message || 'An error occurred')); + const html = ` +
+ Error:
+ ${safe} +
+ `; + this.setHTML('', html); + } + + renderEmpty(customHtml) { + const html = customHtml || ` +
+
No data
+
+ `; + this.setHTML('', html); + } + + // Basic HTML escaping for dynamic values + escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} + +// Application class to manage components and routing +class App { + constructor() { + this.eventBus = new EventBus(); + this.components = new Map(); + this.currentView = null; + this.routes = new Map(); + this.navigationInProgress = false; + this.navigationQueue = []; + this.lastNavigationTime = 0; + this.navigationCooldown = (window.CONSTANTS && window.CONSTANTS.TIMING.NAV_COOLDOWN_MS) || 300; + + // Component cache to keep components alive + this.componentCache = new Map(); + this.cachedViews = new Set(); + } + + // Register a route + registerRoute(name, componentClass, containerId, viewModel = null) { + this.routes.set(name, { componentClass, containerId, viewModel }); + } + + // Navigate to a route + navigateTo(routeName) { + // Check cooldown period + const now = Date.now(); + if (now - this.lastNavigationTime < this.navigationCooldown) { + logger.debug(`App: Navigation cooldown active, skipping route '${routeName}'`); + return; + } + + // If navigation is already in progress, queue this request + if (this.navigationInProgress) { + logger.debug(`App: Navigation in progress, queuing route '${routeName}'`); + if (!this.navigationQueue.includes(routeName)) { + this.navigationQueue.push(routeName); + } + return; + } + + // If trying to navigate to the same route, do nothing + if (this.currentView && this.currentView.routeName === routeName) { + logger.debug(`App: Already on route '${routeName}', skipping navigation`); + return; + } + + this.lastNavigationTime = now; + this.performNavigation(routeName); + } + + // Perform the actual navigation + async performNavigation(routeName) { + this.navigationInProgress = true; + + try { + logger.debug(`App: Navigating to route '${routeName}'`); + const route = this.routes.get(routeName); + if (!route) { + console.error(`Route '${routeName}' not found`); + return; + } + + logger.debug(`App: Route found, component: ${route.componentClass.name}, container: ${route.containerId}`); + + // Get or create component from cache + let component = this.componentCache.get(routeName); + if (!component) { + logger.debug(`App: Component not in cache, creating new instance for '${routeName}'`); + const container = document.getElementById(route.containerId); + if (!container) { + console.error(`Container '${route.containerId}' not found`); + return; + } + + component = new route.componentClass(container, route.viewModel, this.eventBus); + component.routeName = routeName; + component.isCached = true; + this.componentCache.set(routeName, component); + } + + // Hide current view smoothly + if (this.currentView) { + logger.debug('App: Hiding current view'); + await this.hideCurrentView(); + } + + // Show new view + logger.debug(`App: Showing new view '${routeName}'`); + await this.showView(routeName, component); + + // Update navigation state + this.updateNavigation(routeName); + + // Set as current view + this.currentView = component; + + // Mark view as cached for future use + this.cachedViews.add(routeName); + + logger.debug(`App: Navigation to '${routeName}' completed`); + + } catch (error) { + console.error('App: Navigation failed:', error); + } finally { + this.navigationInProgress = false; + + // Process any queued navigation requests + if (this.navigationQueue.length > 0) { + const nextRoute = this.navigationQueue.shift(); + logger.debug(`App: Processing queued navigation to '${nextRoute}'`); + setTimeout(() => this.navigateTo(nextRoute), 100); + } + } + } + + // Hide current view smoothly + async hideCurrentView() { + if (!this.currentView) return; + + // If component is mounted, pause it instead of unmounting + if (this.currentView.isMounted) { + logger.debug('App: Pausing current view instead of unmounting'); + this.currentView.pause(); + } + + // Fade out the container + if (this.currentView.container) { + this.currentView.container.style.opacity = '0'; + this.currentView.container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150}ms ease-out`; + } + + // Wait for fade out to complete + await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150)); + } + + // Show view smoothly + async showView(routeName, component) { + const container = component.container; + + // Ensure component is mounted (but not necessarily active); lazy-create now if needed + if (!component) { + const route = this.routes.get(routeName); + const container = document.getElementById(route.containerId); + component = new route.componentClass(container, route.viewModel, this.eventBus); + component.routeName = routeName; + component.isCached = true; + this.componentCache.set(routeName, component); + } + if (!component.isMounted) { + logger.debug(`App: Mounting component for '${routeName}'`); + component.mount(); + } else { + logger.debug(`App: Resuming component for '${routeName}'`); + component.resume(); + } + + // Fade in the container + container.style.opacity = '0'; + container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_IN_MS) || 200}ms ease-in`; + + // Small delay to ensure smooth transition + await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_DELAY_MS) || 50)); + + // Fade in + container.style.opacity = '1'; + } + + // Update navigation state + updateNavigation(activeRoute) { + // Remove active class from all nav tabs + document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => { + tab.classList.remove('active'); + }); + + // Add active class to current route tab + const activeTab = document.querySelector(`[data-view="${activeRoute}"]`); + if (activeTab) { + activeTab.classList.add('active'); + } + + // Hide all view contents with smooth transition + document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.VIEW_CONTENT) || '.view-content').forEach(view => { + view.classList.remove('active'); + view.style.opacity = '0'; + view.style.transition = 'opacity 0.15s ease-out'; + }); + + // Show current view content with smooth transition + const activeView = document.getElementById(`${activeRoute}-view`); + if (activeView) { + activeView.classList.add('active'); + // Small delay to ensure smooth transition + setTimeout(() => { + activeView.style.opacity = '1'; + activeView.style.transition = 'opacity 0.2s ease-in'; + }, 50); + } + } + + // Register a component + registerComponent(name, component) { + this.components.set(name, component); + } + + // Get a component by name + getComponent(name) { + return this.components.get(name); + } + + // Get the event bus + getEventBus() { + return this.eventBus; + } + + // Initialize the application + init() { + logger.debug('SPORE LEDLab Framework initialized'); + } + + // Setup navigation + setupNavigation() { + document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => { + tab.addEventListener('click', () => { + const routeName = tab.dataset.view; + this.navigateTo(routeName); + }); + }); + } + + // Clean up cached components (call when app is shutting down) + cleanup() { + logger.debug('App: Cleaning up cached components...'); + + this.componentCache.forEach((component, routeName) => { + if (component.isMounted) { + logger.debug(`App: Unmounting cached component '${routeName}'`); + component.unmount(); + } + }); + + this.componentCache.clear(); + this.cachedViews.clear(); + } +} + +// Global app instance (disabled for LEDLab single-page app) +// window.app = new App(); diff --git a/public/scripts/ledlab-app.js b/public/scripts/ledlab-app.js new file mode 100644 index 0000000..ac7f61a --- /dev/null +++ b/public/scripts/ledlab-app.js @@ -0,0 +1,245 @@ +// LEDLab Main Application + +class LEDLabApp { + constructor() { + this.viewModel = new ViewModel(); + this.eventBus = new EventBus(); + this.ws = null; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 5; + this.reconnectDelay = 1000; + + this.matrixDisplay = null; + this.presetControls = null; + this.nodeDiscovery = null; + + this.init(); + } + + init() { + // Set up event bus on view model + this.viewModel.setEventBus(this.eventBus); + + // Initialize components + this.initComponents(); + + // Set up WebSocket connection + this.connectWebSocket(); + + // Set up global event listeners + this.setupGlobalEventListeners(); + + console.log('LEDLab app initialized'); + } + + initComponents() { + // Initialize Matrix Display component + const matrixContainer = document.querySelector('.matrix-section'); + if (matrixContainer) { + this.matrixDisplay = new MatrixDisplay(matrixContainer, this.viewModel, this.eventBus); + this.matrixDisplay.mount(); + } + + // Initialize Preset Controls component + const controlsContainer = document.querySelector('.control-section'); + if (controlsContainer) { + this.presetControls = new PresetControls(controlsContainer, this.viewModel, this.eventBus); + this.presetControls.mount(); + } + + // Initialize Node Discovery component + const nodeContainer = document.querySelector('#node-list').parentElement; + if (nodeContainer) { + this.nodeDiscovery = new NodeDiscovery(nodeContainer, this.viewModel, this.eventBus); + this.nodeDiscovery.mount(); + } + } + + connectWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}`; + + try { + this.ws = new WebSocket(wsUrl); + this.setupWebSocketEventHandlers(); + } catch (error) { + console.error('Failed to create WebSocket connection:', error); + this.scheduleReconnect(); + } + } + + setupWebSocketEventHandlers() { + if (!this.ws) return; + + this.ws.onopen = () => { + console.log('WebSocket connected'); + this.reconnectAttempts = 0; + + // Send any queued messages + this.flushMessageQueue(); + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + this.handleWebSocketMessage(data); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + this.ws.onclose = (event) => { + console.log('WebSocket disconnected:', event.code, event.reason); + + if (event.code !== 1000) { // Not a normal closure + this.scheduleReconnect(); + } + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + }; + } + + handleWebSocketMessage(data) { + // Publish event to the event bus for components to handle + this.eventBus.publish(data.type, data); + } + + scheduleReconnect() { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('Max reconnection attempts reached'); + return; + } + + this.reconnectAttempts++; + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); // Exponential backoff + + console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`); + + setTimeout(() => { + this.connectWebSocket(); + }, delay); + } + + sendWebSocketMessage(data) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(data)); + } else { + console.warn('WebSocket not connected, queuing message'); + // Queue message for when connection is restored + if (!this.messageQueue) { + this.messageQueue = []; + } + this.messageQueue.push(data); + } + } + + flushMessageQueue() { + if (this.messageQueue && this.messageQueue.length > 0) { + console.log(`Flushing ${this.messageQueue.length} queued messages`); + this.messageQueue.forEach(data => this.sendWebSocketMessage(data)); + this.messageQueue = []; + } + } + + setupGlobalEventListeners() { + // Listen for messages from components that need to be sent to server + this.eventBus.subscribe('startPreset', (data) => { + this.sendWebSocketMessage({ + type: 'startPreset', + ...data + }); + }); + + this.eventBus.subscribe('stopStreaming', (data) => { + this.sendWebSocketMessage({ + type: 'stopStreaming', + ...data + }); + }); + + this.eventBus.subscribe('updatePresetParameter', (data) => { + this.sendWebSocketMessage({ + type: 'updatePresetParameter', + ...data + }); + }); + + this.eventBus.subscribe('setMatrixSize', (data) => { + this.sendWebSocketMessage({ + type: 'setMatrixSize', + ...data + }); + }); + + this.eventBus.subscribe('sendToNode', (data) => { + this.sendWebSocketMessage({ + type: 'sendToNode', + ...data + }); + }); + + this.eventBus.subscribe('broadcastToAll', (data) => { + this.sendWebSocketMessage({ + type: 'broadcastToAll', + ...data + }); + }); + + this.eventBus.subscribe('selectNode', (data) => { + this.sendWebSocketMessage({ + type: 'selectNode', + ...data + }); + }); + + this.eventBus.subscribe('selectBroadcast', (data) => { + this.sendWebSocketMessage({ + type: 'selectBroadcast', + ...data + }); + }); + + // Handle theme changes + window.addEventListener('themeChanged', (event) => { + console.log('Theme changed to:', event.detail.theme); + // Update any theme-specific UI elements if needed + }); + } + + // Public API methods for external use + startPreset(presetName, width, height) { + this.viewModel.publish('startPreset', { presetName, width, height }); + } + + stopStreaming() { + this.viewModel.publish('stopStreaming', {}); + } + + updatePresetParameter(parameter, value) { + this.viewModel.publish('updatePresetParameter', { parameter, value }); + } + + setMatrixSize(width, height) { + this.viewModel.publish('setMatrixSize', { width, height }); + } + + sendToNode(nodeIp, message) { + this.viewModel.publish('sendToNode', { nodeIp, message }); + } + + broadcastToAll(message) { + this.viewModel.publish('broadcastToAll', { message }); + } +} + +// Initialize the app when DOM is loaded +document.addEventListener('DOMContentLoaded', function() { + window.ledlabApp = new LEDLabApp(); +}); + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = LEDLabApp; +} diff --git a/public/scripts/matrix-display.js b/public/scripts/matrix-display.js new file mode 100644 index 0000000..4a0b432 --- /dev/null +++ b/public/scripts/matrix-display.js @@ -0,0 +1,203 @@ +// Matrix Display Component + +class MatrixDisplay extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + this.canvas = null; + this.ctx = null; + this.pixelSize = 20; + this.matrixWidth = 16; + this.matrixHeight = 16; + this.frameData = null; + this.animationId = null; + } + + mount() { + super.mount(); + this.setupCanvas(); + this.setupEventListeners(); + this.setupViewModelListeners(); + } + + setupCanvas() { + this.canvas = this.findElement('#matrix-canvas'); + if (!this.canvas) { + console.error('Matrix canvas element not found'); + return; + } + + this.ctx = this.canvas.getContext('2d'); + this.updateCanvasSize(); + } + + updateCanvasSize() { + if (!this.canvas || !this.ctx) return; + + const container = this.canvas.parentElement; + const containerWidth = container.clientWidth - 32; // Account for padding + const containerHeight = container.clientHeight - 32; + + // Calculate pixel size to fit the matrix in the container + const maxPixelWidth = Math.floor(containerWidth / this.matrixWidth); + const maxPixelHeight = Math.floor(containerHeight / this.matrixHeight); + this.pixelSize = Math.min(maxPixelWidth, maxPixelHeight, 40); // Cap at 40px + + this.canvas.width = this.matrixWidth * this.pixelSize; + this.canvas.height = this.matrixHeight * this.pixelSize; + + // Center the canvas + const canvasContainer = this.canvas.parentElement; + canvasContainer.style.display = 'flex'; + canvasContainer.style.alignItems = 'center'; + canvasContainer.style.justifyContent = 'center'; + } + + setupEventListeners() { + // Handle window resize + this.addEventListener(window, 'resize', () => { + this.updateCanvasSize(); + this.renderFrame(); + }); + } + + setupViewModelListeners() { + this.subscribeToEvent('frame', (data) => { + this.frameData = data.data; + this.renderFrame(); + }); + + this.subscribeToEvent('matrixSizeChanged', (data) => { + this.matrixWidth = data.size.width; + this.matrixHeight = data.size.height; + this.updateCanvasSize(); + this.updateMatrixSize(data.size.width, data.size.height); + this.renderFrame(); + }); + + this.subscribeToEvent('streamingStarted', (data) => { + this.updateStreamingStatus('streaming'); + }); + + this.subscribeToEvent('streamingStopped', () => { + this.updateStreamingStatus('stopped'); + }); + + this.subscribeToEvent('status', (data) => { + // Update UI to reflect current server state + this.updateStreamingStatus(data.data.streaming ? 'streaming' : 'stopped'); + if (data.data.matrixSize) { + this.matrixWidth = data.data.matrixSize.width; + this.matrixHeight = data.data.matrixSize.height; + this.updateCanvasSize(); + this.updateMatrixSize(data.data.matrixSize.width, data.data.matrixSize.height); + } + }); + } + + updateStreamingStatus(status) { + const statusElement = this.findElement('#streaming-status'); + if (statusElement) { + statusElement.className = `status-indicator status-${status}`; + statusElement.textContent = status === 'streaming' ? 'Streaming' : 'Stopped'; + } + } + + renderFrame() { + if (!this.ctx || !this.canvas) return; + + // Clear canvas + this.ctx.fillStyle = 'var(--matrix-bg)'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + // Render pixels if we have frame data + if (this.frameData && this.frameData.startsWith('RAW:')) { + const pixelData = this.frameData.substring(4); // Remove 'RAW:' prefix + + // Render pixels in serpentine order to match hardware layout + for (let row = 0; row < this.matrixHeight; row++) { + for (let col = 0; col < this.matrixWidth; col++) { + // Calculate serpentine index manually + const hardwareIndex = (row % 2 === 0) ? + (row * this.matrixWidth + col) : + (row * this.matrixWidth + (this.matrixWidth - 1 - col)); + const pixelStart = hardwareIndex * 6; + + if (pixelStart + 5 < pixelData.length) { + const hexColor = pixelData.substring(pixelStart, pixelStart + 6); + this.renderPixel(col, row, hexColor); + } + } + } + } + } + + renderPixel(col, row, hexColor) { + const x = col * this.pixelSize; + const y = row * this.pixelSize; + + // Convert hex to RGB + const r = parseInt(hexColor.substring(0, 2), 16); + const g = parseInt(hexColor.substring(2, 4), 16); + const b = parseInt(hexColor.substring(4, 6), 16); + + // Skip rendering if pixel is black (optimization) + if (r === 0 && g === 0 && b === 0) { + return; + } + + // Draw pixel with a subtle border for better visibility + this.ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; + this.ctx.fillRect(x + 1, y + 1, this.pixelSize - 2, this.pixelSize - 2); + + // Add a subtle glow effect for brighter pixels + if (r > 128 || g > 128 || b > 128) { + this.ctx.shadowColor = `rgb(${r}, ${g}, ${b})`; + this.ctx.shadowBlur = 2; + this.ctx.fillRect(x + 1, y + 1, this.pixelSize - 2, this.pixelSize - 2); + this.ctx.shadowBlur = 0; + } + } + + // Method to render a test pattern + renderTestPattern() { + this.frameData = 'RAW:'; + for (let row = 0; row < this.matrixHeight; row++) { + for (let col = 0; col < this.matrixWidth; col++) { + // Calculate serpentine index manually + const hardwareIndex = (row % 2 === 0) ? + (row * this.matrixWidth + col) : + (row * this.matrixWidth + (this.matrixWidth - 1 - col)); + // Create a checkerboard pattern + if ((row + col) % 2 === 0) { + this.frameData += '00ff00'; // Green + } else { + this.frameData += '000000'; // Black + } + } + } + + this.renderFrame(); + } + + // Method to clear the matrix + clearMatrix() { + if (this.ctx && this.canvas) { + this.ctx.fillStyle = 'var(--matrix-bg)'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + this.frameData = null; + } + + // Update matrix size display + updateMatrixSize(width, height) { + const sizeElement = this.findElement('#matrix-size'); + if (sizeElement) { + sizeElement.textContent = `${width}x${height}`; + } + } +} + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = MatrixDisplay; +} diff --git a/public/scripts/node-discovery.js b/public/scripts/node-discovery.js new file mode 100644 index 0000000..8c7bd81 --- /dev/null +++ b/public/scripts/node-discovery.js @@ -0,0 +1,178 @@ +// Node Discovery Component + +class NodeDiscovery extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + this.nodes = []; + this.currentTarget = null; + } + + mount() { + super.mount(); + this.setupEventListeners(); + this.setupViewModelListeners(); + this.loadNodes(); + this.startPeriodicRefresh(); + } + + setupEventListeners() { + // Broadcast button + const broadcastBtn = this.findElement('#broadcast-btn'); + if (broadcastBtn) { + this.addEventListener(broadcastBtn, 'click', () => { + this.selectBroadcastTarget(); + }); + } + } + + setupViewModelListeners() { + this.subscribeToEvent('nodeDiscovered', (data) => { + this.addOrUpdateNode(data.node); + }); + + this.subscribeToEvent('nodeLost', (data) => { + this.removeNode(data.node.ip); + }); + + this.subscribeToEvent('status', (data) => { + // Update UI to reflect current server state + if (data.data.nodes) { + this.nodes = data.data.nodes; + this.currentTarget = data.data.currentTarget; + this.renderNodeList(); + } + }); + } + + async loadNodes() { + try { + const response = await fetch('/api/nodes'); + const data = await response.json(); + + this.nodes = data.nodes || []; + this.renderNodeList(); + + } catch (error) { + console.error('Error loading nodes:', error); + this.showError('Failed to load nodes'); + } + } + + startPeriodicRefresh() { + // Refresh node list every 5 seconds + setInterval(() => { + this.loadNodes(); + }, 5000); + } + + addOrUpdateNode(node) { + const existingIndex = this.nodes.findIndex(n => n.ip === node.ip); + + if (existingIndex >= 0) { + // Update existing node + this.nodes[existingIndex] = { ...node, lastSeen: Date.now() }; + } else { + // Add new node + this.nodes.push({ ...node, lastSeen: Date.now() }); + } + + this.renderNodeList(); + } + + removeNode(nodeIp) { + this.nodes = this.nodes.filter(node => node.ip !== nodeIp); + this.renderNodeList(); + } + + renderNodeList() { + const nodeListContainer = this.findElement('#node-list'); + if (!nodeListContainer) return; + + if (this.nodes.length === 0) { + nodeListContainer.innerHTML = '
No nodes discovered
'; + return; + } + + const html = this.nodes.map(node => ` +
+
+
+
${this.escapeHtml(node.ip === 'broadcast' ? 'Broadcast' : node.ip)}
+
${node.status} • Port ${node.port}
+
+
+ `).join(''); + + nodeListContainer.innerHTML = html; + + // Add click handlers for node selection + this.nodes.forEach(node => { + const nodeElement = nodeListContainer.querySelector(`[data-ip="${node.ip}"]`); + if (nodeElement) { + this.addEventListener(nodeElement, 'click', () => { + this.selectNode(node.ip); + }); + } + }); + } + + selectNode(nodeIp) { + this.currentTarget = nodeIp; + this.viewModel.publish('selectNode', { nodeIp }); + + // Update visual selection + const nodeListContainer = this.findElement('#node-list'); + if (nodeListContainer) { + nodeListContainer.querySelectorAll('.node-item').forEach(item => { + item.classList.remove('selected'); + }); + const selectedNode = nodeListContainer.querySelector(`[data-ip="${nodeIp}"]`); + if (selectedNode) { + selectedNode.classList.add('selected'); + } + } + } + + selectBroadcast() { + this.currentTarget = 'broadcast'; + this.viewModel.publish('selectBroadcast', {}); + + // Update visual selection + const nodeListContainer = this.findElement('#node-list'); + if (nodeListContainer) { + nodeListContainer.querySelectorAll('.node-item').forEach(item => { + item.classList.remove('selected'); + }); + const broadcastNode = nodeListContainer.querySelector(`[data-ip="broadcast"]`); + if (broadcastNode) { + broadcastNode.classList.add('selected'); + } + } + } + + showError(message) { + const nodeListContainer = this.findElement('#node-list'); + if (nodeListContainer) { + nodeListContainer.innerHTML = `
${this.escapeHtml(message)}
`; + } + } + + // Public method to select broadcast (called from outside) + selectBroadcastTarget() { + this.selectBroadcast(); + } + + escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = NodeDiscovery; +} diff --git a/public/scripts/preset-controls.js b/public/scripts/preset-controls.js new file mode 100644 index 0000000..7e6cae6 --- /dev/null +++ b/public/scripts/preset-controls.js @@ -0,0 +1,372 @@ +// Preset Controls Component + +class PresetControls extends Component { + constructor(container, viewModel, eventBus) { + super(container, viewModel, eventBus); + this.presets = {}; + this.currentPreset = null; + this.presetControls = new Map(); + } + + mount() { + super.mount(); + this.setupEventListeners(); + this.setupViewModelListeners(); + this.loadPresets(); + } + + setupEventListeners() { + // Preset selection + const presetSelect = this.findElement('#preset-select'); + if (presetSelect) { + this.addEventListener(presetSelect, 'change', (e) => { + this.selectPreset(e.target.value); + }); + } + + // Apply matrix config button + const applyMatrixBtn = this.findElement('#apply-matrix-btn'); + if (applyMatrixBtn) { + this.addEventListener(applyMatrixBtn, 'click', () => { + this.applyMatrixConfig(); + }); + } + + // Start/Stop buttons + const startBtn = this.findElement('#start-btn'); + if (startBtn) { + this.addEventListener(startBtn, 'click', () => { + this.startStreaming(); + }); + } + + const stopBtn = this.findElement('#stop-btn'); + if (stopBtn) { + this.addEventListener(stopBtn, 'click', () => { + this.stopStreaming(); + }); + } + + // Test and clear buttons + const sendTestBtn = this.findElement('#send-test-btn'); + if (sendTestBtn) { + this.addEventListener(sendTestBtn, 'click', () => { + this.sendTestFrame(); + }); + } + + const clearMatrixBtn = this.findElement('#clear-matrix-btn'); + if (clearMatrixBtn) { + this.addEventListener(clearMatrixBtn, 'click', () => { + this.clearMatrix(); + }); + } + } + + setupViewModelListeners() { + this.subscribeToEvent('streamingStarted', (data) => { + this.updateStreamingState(true, data.preset); + }); + + this.subscribeToEvent('streamingStopped', () => { + this.updateStreamingState(false); + }); + + this.subscribeToEvent('presetParameterUpdated', (data) => { + this.updatePresetParameter(data.parameter, data.value); + }); + + + this.subscribeToEvent('status', (data) => { + // Update UI to reflect current server state + const isStreaming = data.data.streaming; + const currentPreset = data.data.currentPreset; + const presetParameters = data.data.presetParameters; + + this.updateStreamingState(isStreaming, currentPreset ? { name: currentPreset } : null); + + if (currentPreset) { + this.selectPreset(currentPreset.toLowerCase().replace('-preset', '')); + } + + if (presetParameters && this.currentPreset) { + // Update parameter controls with current values + Object.entries(presetParameters).forEach(([param, value]) => { + const control = this.presetControls.get(param); + if (control) { + if (control.type === 'range') { + control.value = value; + const valueDisplay = control.parentElement.querySelector('.preset-value'); + if (valueDisplay) { + valueDisplay.textContent = parseFloat(value).toFixed(2); + } + } else if (control.type === 'color') { + control.value = this.hexToColorValue(value); + } else { + control.value = value; + } + } + }); + } + }); + } + + async loadPresets() { + try { + const response = await fetch('/api/presets'); + const data = await response.json(); + + this.presets = data.presets; + this.populatePresetSelect(); + + } catch (error) { + console.error('Error loading presets:', error); + } + } + + populatePresetSelect() { + const presetSelect = this.findElement('#preset-select'); + if (!presetSelect) return; + + // Clear existing options (except the first one) + while (presetSelect.children.length > 1) { + presetSelect.removeChild(presetSelect.lastChild); + } + + // Add preset options + Object.entries(this.presets).forEach(([name, metadata]) => { + const option = document.createElement('option'); + option.value = name; + option.textContent = metadata.name; + presetSelect.appendChild(option); + }); + } + + selectPreset(presetName) { + if (!presetName || !this.presets[presetName]) { + this.currentPreset = null; + this.clearPresetControls(); + return; + } + + this.currentPreset = this.presets[presetName]; + this.createPresetControls(); + } + + createPresetControls() { + const controlsContainer = this.findElement('#preset-controls'); + if (!controlsContainer) return; + + // Clear existing controls + controlsContainer.innerHTML = ''; + + if (!this.currentPreset || !this.currentPreset.parameters) { + return; + } + + // Create controls for each parameter + Object.entries(this.currentPreset.parameters).forEach(([paramName, paramConfig]) => { + const controlDiv = document.createElement('div'); + controlDiv.className = 'preset-control'; + + const label = document.createElement('label'); + label.className = 'preset-label'; + label.textContent = this.formatParameterName(paramName); + controlDiv.appendChild(label); + + const input = this.createParameterInput(paramName, paramConfig); + controlDiv.appendChild(input); + + controlsContainer.appendChild(controlDiv); + this.presetControls.set(paramName, input); + }); + } + + createParameterInput(paramName, paramConfig) { + const { type, min, max, step, default: defaultValue } = paramConfig; + + switch (type) { + case 'range': + const sliderInput = document.createElement('input'); + sliderInput.type = 'range'; + sliderInput.className = 'preset-slider'; + sliderInput.min = min; + sliderInput.max = max; + sliderInput.step = step || 0.1; + sliderInput.value = defaultValue; + + // Add value display + const valueDisplay = document.createElement('span'); + valueDisplay.className = 'preset-value'; + valueDisplay.textContent = defaultValue; + + sliderInput.addEventListener('input', (e) => { + valueDisplay.textContent = parseFloat(e.target.value).toFixed(2); + this.updatePresetParameter(paramName, parseFloat(e.target.value)); + }); + + const container = document.createElement('div'); + container.style.display = 'flex'; + container.style.alignItems = 'center'; + container.style.gap = '0.5rem'; + container.appendChild(sliderInput); + container.appendChild(valueDisplay); + + return container; + + case 'color': + const colorInput = document.createElement('input'); + colorInput.type = 'color'; + colorInput.className = 'preset-input'; + colorInput.value = this.hexToColorValue(defaultValue); + + colorInput.addEventListener('input', (e) => { + const hexValue = this.colorValueToHex(e.target.value); + this.updatePresetParameter(paramName, hexValue); + }); + + return colorInput; + + default: + const textInput = document.createElement('input'); + textInput.type = 'text'; + textInput.className = 'preset-input'; + textInput.value = defaultValue; + + textInput.addEventListener('input', (e) => { + this.updatePresetParameter(paramName, e.target.value); + }); + + return textInput; + } + } + + updatePresetParameter(parameter, value) { + // Send parameter update to server + this.viewModel.publish('updatePresetParameter', { + parameter, + value + }); + } + + clearPresetControls() { + const controlsContainer = this.findElement('#preset-controls'); + if (controlsContainer) { + controlsContainer.innerHTML = ''; + } + this.presetControls.clear(); + } + + formatParameterName(name) { + return name + .split(/(?=[A-Z])/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + hexToColorValue(hex) { + // Convert hex (rrggbb) to color value (#rrggbb) + if (hex.startsWith('#')) return hex; + return `#${hex}`; + } + + colorValueToHex(colorValue) { + // Convert color value (#rrggbb) to hex (rrggbb) + return colorValue.replace('#', ''); + } + + startStreaming() { + const presetSelect = this.findElement('#preset-select'); + if (!presetSelect || !presetSelect.value) { + alert('Please select a preset first'); + return; + } + + const width = parseInt(this.findElement('#matrix-width')?.value) || 16; + const height = parseInt(this.findElement('#matrix-height')?.value) || 16; + + this.viewModel.publish('startPreset', { + presetName: presetSelect.value, + width, + height + }); + } + + stopStreaming() { + this.viewModel.publish('stopStreaming', {}); + } + + sendTestFrame() { + // Create a test frame with a simple pattern in serpentine order + const width = parseInt(this.findElement('#matrix-width')?.value) || 16; + const height = parseInt(this.findElement('#matrix-height')?.value) || 16; + + let frameData = 'RAW:'; + for (let row = 0; row < height; row++) { + for (let col = 0; col < width; col++) { + // Calculate serpentine index manually + const hardwareIndex = (row % 2 === 0) ? + (row * width + col) : + (row * width + (width - 1 - col)); + // Create a checkerboard pattern + if ((row + col) % 2 === 0) { + frameData += '00ff00'; // Green + } else { + frameData += '000000'; // Black + } + } + } + + this.viewModel.publish('broadcastToAll', { + message: frameData + }); + } + + clearMatrix() { + // Send a frame with all black pixels in serpentine order + const width = parseInt(this.findElement('#matrix-width')?.value) || 16; + const height = parseInt(this.findElement('#matrix-height')?.value) || 16; + + let frameData = 'RAW:'; + for (let row = 0; row < height; row++) { + for (let col = 0; col < width; col++) { + // Calculate serpentine index manually + const hardwareIndex = (row % 2 === 0) ? + (row * width + col) : + (row * width + (width - 1 - col)); + frameData += '000000'; + } + } + + this.viewModel.publish('broadcastToAll', { + message: frameData + }); + } + + applyMatrixConfig() { + const width = parseInt(this.findElement('#matrix-width')?.value); + const height = parseInt(this.findElement('#matrix-height')?.value); + + if (width && height) { + this.viewModel.publish('setMatrixSize', { width, height }); + } + } + + updateStreamingState(isStreaming, preset) { + const startBtn = this.findElement('#start-btn'); + const stopBtn = this.findElement('#stop-btn'); + + if (isStreaming) { + startBtn.disabled = true; + stopBtn.disabled = false; + } else { + startBtn.disabled = false; + stopBtn.disabled = true; + } + } +} + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = PresetControls; +} diff --git a/public/scripts/theme-manager.js b/public/scripts/theme-manager.js new file mode 100644 index 0000000..575bbd3 --- /dev/null +++ b/public/scripts/theme-manager.js @@ -0,0 +1,120 @@ +// Theme Manager - Handles theme switching and persistence + +class ThemeManager { + constructor() { + this.currentTheme = this.getStoredTheme() || 'dark'; + this.themeToggle = document.getElementById('theme-toggle'); + this.init(); + } + + init() { + // Apply stored theme on page load + this.applyTheme(this.currentTheme); + + // Set up event listener for theme toggle + if (this.themeToggle) { + this.themeToggle.addEventListener('click', () => this.toggleTheme()); + } + + // Listen for system theme changes + if (window.matchMedia) { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + mediaQuery.addListener((e) => { + if (this.getStoredTheme() === 'system') { + this.applyTheme(e.matches ? 'dark' : 'light'); + } + }); + } + } + + getStoredTheme() { + try { + return localStorage.getItem('spore-ledlab-theme'); + } catch (e) { + console.warn('Could not access localStorage for theme preference'); + return 'dark'; + } + } + + setStoredTheme(theme) { + try { + localStorage.setItem('spore-ledlab-theme', theme); + } catch (e) { + console.warn('Could not save theme preference to localStorage'); + } + } + + applyTheme(theme) { + // Update data attribute on html element + document.documentElement.setAttribute('data-theme', theme); + + // Update theme toggle icon + this.updateThemeIcon(theme); + + // Store the theme preference + this.setStoredTheme(theme); + + this.currentTheme = theme; + + // Dispatch custom event for other components + window.dispatchEvent(new CustomEvent('themeChanged', { + detail: { theme: theme } + })); + } + + updateThemeIcon(theme) { + if (!this.themeToggle) return; + + const svg = this.themeToggle.querySelector('svg'); + if (!svg) return; + + // Update the SVG content based on theme + if (theme === 'light') { + // Sun icon for light theme + svg.innerHTML = ` + + + `; + } else { + // Moon icon for dark theme + svg.innerHTML = ` + + `; + } + } + + toggleTheme() { + const newTheme = this.currentTheme === 'dark' ? 'light' : 'dark'; + this.applyTheme(newTheme); + + // Add a subtle animation to the toggle button + if (this.themeToggle) { + this.themeToggle.style.transform = 'scale(0.9)'; + setTimeout(() => { + this.themeToggle.style.transform = 'scale(1)'; + }, 150); + } + } + + // Method to get current theme (useful for other components) + getCurrentTheme() { + return this.currentTheme; + } + + // Method to set theme programmatically + setTheme(theme) { + if (['dark', 'light'].includes(theme)) { + this.applyTheme(theme); + } + } +} + +// Initialize theme manager when DOM is loaded +document.addEventListener('DOMContentLoaded', function() { + window.themeManager = new ThemeManager(); +}); + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = ThemeManager; +} diff --git a/public/styles/main.css b/public/styles/main.css new file mode 100644 index 0000000..a141a0a --- /dev/null +++ b/public/styles/main.css @@ -0,0 +1,562 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + /* Dark theme colors */ + --bg-primary: #0f0f0f; + --bg-secondary: #1a1a1a; + --bg-tertiary: #2d2d2d; + --text-primary: #ffffff; + --text-secondary: #b3b3b3; + --text-tertiary: #888888; + --accent-primary: #4ade80; + --accent-secondary: #22d3ee; + --accent-warning: #fbbf24; + --accent-error: #f87171; + --border-primary: #333333; + --border-secondary: #444444; + --shadow-primary: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --backdrop-blur: blur(12px); + + /* LEDLab specific colors */ + --matrix-bg: #000000; + --pixel-on: #4ade80; + --pixel-off: #1a1a1a; + --pixel-dim: #22c55e; + --control-bg: #1e1e1e; + --preset-active: #4ade80; + --node-connected: #22c55e; + --node-disconnected: #ef4444; +} + +[data-theme="light"] { + --bg-primary: #ffffff; + --bg-secondary: #f8fafc; + --bg-tertiary: #e2e8f0; + --text-primary: #1e293b; + --text-secondary: #64748b; + --text-tertiary: #94a3b8; + --accent-primary: #16a34a; + --accent-secondary: #0891b2; + --accent-warning: #d97706; + --accent-error: #dc2626; + --border-primary: #e2e8f0; + --border-secondary: #cbd5e1; + --shadow-primary: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + --backdrop-blur: blur(12px); + + /* Light theme LEDLab colors */ + --matrix-bg: #f8fafc; + --pixel-on: #16a34a; + --pixel-off: #f1f5f9; + --pixel-dim: #22c55e; + --control-bg: #f8fafc; + --preset-active: #16a34a; + --node-connected: #16a34a; + --node-disconnected: #dc2626; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: var(--bg-primary); + height: 100vh; + padding: 1rem; + color: var(--text-primary); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.container { + max-width: none; + width: 100%; + margin: 0 auto; + display: flex; + flex-direction: column; + flex: 1; + padding: 0 2rem; + max-height: calc(100vh - 2rem); + overflow: hidden; +} + +/* LEDLab specific styles */ +.ledlab-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-primary); +} + +.ledlab-title { + font-size: 2rem; + font-weight: 700; + color: var(--accent-primary); +} + +.theme-toggle { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + padding: 0.5rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.theme-toggle:hover { + background: var(--bg-tertiary); + transform: scale(1.05); +} + +.theme-toggle svg { + width: 20px; + height: 20px; + fill: currentColor; +} + +/* Main content layout */ +.ledlab-main { + display: flex; + flex: 1; + gap: 2rem; + overflow: hidden; +} + +/* Matrix display section */ +.matrix-section { + flex: 1; + background: var(--bg-secondary); + border-radius: 16px; + border: 1px solid var(--border-primary); + padding: 1.5rem; + display: flex; + flex-direction: column; +} + +.matrix-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.matrix-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); +} + +.matrix-info { + font-size: 0.875rem; + color: var(--text-secondary); + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; +} + +.matrix-container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: var(--matrix-bg); + border-radius: 12px; + border: 1px solid var(--border-secondary); + position: relative; + overflow: hidden; +} + +.matrix-canvas { + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + image-rendering: crisp-edges; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +/* Control panel section */ +.control-section { + width: 320px; + background: var(--bg-secondary); + border-radius: 16px; + border: 1px solid var(--border-primary); + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + overflow-y: auto; +} + +.control-group { + background: var(--control-bg); + border-radius: 12px; + padding: 1rem; + border: 1px solid var(--border-secondary); +} + +.control-group-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.control-group-title::before { + content: ''; + width: 4px; + height: 16px; + background: var(--accent-primary); + border-radius: 2px; +} + +/* Node list */ +.node-controls { + margin-bottom: 1rem; +} + +.node-list { + max-height: 200px; + overflow-y: auto; +} + +.node-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.node-item:hover { + background: var(--bg-tertiary); +} + +.node-item.connected { + border-left: 3px solid var(--node-connected); +} + +.node-item.disconnected { + border-left: 3px solid var(--node-disconnected); +} + +.node-item.selected { + background: rgba(74, 222, 128, 0.1); + border-left: 3px solid var(--accent-primary); +} + +.node-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--node-disconnected); +} + +.node-indicator.connected { + background: var(--node-connected); +} + +.node-info { + flex: 1; + font-size: 0.875rem; +} + +.node-ip { + font-weight: 600; + color: var(--text-primary); + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; +} + +.node-status { + font-size: 0.75rem; + color: var(--text-secondary); +} + +/* Preset controls */ +.preset-select { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 8px; + padding: 0.5rem; + color: var(--text-primary); + font-size: 0.875rem; +} + +.preset-controls { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.preset-control { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.preset-label { + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 500; +} + +.preset-input { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 6px; + padding: 0.5rem; + color: var(--text-primary); + font-size: 0.875rem; +} + +.preset-input:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 2px rgba(74, 222, 128, 0.2); +} + +.preset-slider { + width: 100%; + height: 4px; + background: var(--bg-tertiary); + border-radius: 2px; + outline: none; + -webkit-appearance: none; +} + +.preset-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + background: var(--accent-primary); + border-radius: 50%; + cursor: pointer; +} + +.preset-slider::-moz-range-thumb { + width: 16px; + height: 16px; + background: var(--accent-primary); + border-radius: 50%; + cursor: pointer; + border: none; +} + +/* Buttons */ +.btn { + background: var(--accent-primary); + color: white; + border: none; + border-radius: 8px; + padding: 0.75rem 1rem; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.btn:hover { + background: var(--accent-secondary); + transform: translateY(-1px); +} + +.btn:active { + transform: translateY(0); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-primary); +} + +.btn-secondary:hover { + background: var(--bg-primary); +} + +.btn-small { + padding: 0.5rem 0.75rem; + font-size: 0.75rem; +} + +/* Matrix configuration */ +.matrix-config { + display: flex; + gap: 1rem; + align-items: end; +} + +.matrix-input { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.matrix-input label { + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 500; +} + +.matrix-input input { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 6px; + padding: 0.5rem; + color: var(--text-primary); + font-size: 0.875rem; + width: 80px; +} + +.matrix-input input:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 2px rgba(74, 222, 128, 0.2); +} + + +/* Status indicators */ +.status-indicator { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-weight: 500; +} + +.status-connected { + background: rgba(34, 197, 94, 0.1); + color: var(--node-connected); + border: 1px solid rgba(34, 197, 94, 0.2); +} + +.status-disconnected { + background: rgba(239, 68, 68, 0.1); + color: var(--node-disconnected); + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.status-streaming { + background: rgba(74, 222, 128, 0.1); + color: var(--accent-primary); + border: 1px solid rgba(74, 222, 128, 0.2); +} + +/* Loading and error states */ +.loading { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + color: var(--text-secondary); + font-size: 1.125rem; +} + +.error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 8px; + padding: 1rem; + color: var(--accent-error); + text-align: center; +} + +.empty-state { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + color: var(--text-secondary); + font-style: italic; +} + +/* Scrollbar styling */ +.control-section::-webkit-scrollbar { + width: 6px; +} + +.control-section::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: 3px; +} + +.control-section::-webkit-scrollbar-thumb { + background: var(--border-primary); + border-radius: 3px; +} + +.control-section::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .ledlab-main { + flex-direction: column; + gap: 1rem; + } + + .control-section { + width: 100%; + max-height: 300px; + } + + .matrix-section { + min-height: 400px; + } +} + +/* Animation keyframes */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.animate-fade-in { + animation: fadeIn 0.3s ease-out; +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..4abb710 --- /dev/null +++ b/server/index.js @@ -0,0 +1,483 @@ +// LEDLab Server - Main server file + +const express = require('express'); +const http = require('http'); +const WebSocket = require('ws'); +const path = require('path'); + +// Import services +const UdpDiscovery = require('./udp-discovery'); +const PresetRegistry = require('../presets/preset-registry'); + +class LEDLabServer { + constructor(options = {}) { + this.port = options.port || 8080; + this.udpPort = options.udpPort || 4210; + this.matrixWidth = options.matrixWidth || 16; + this.matrixHeight = options.matrixHeight || 16; + + this.app = express(); + this.server = http.createServer(this.app); + this.wss = new WebSocket.Server({ server: this.server }); + + this.udpDiscovery = new UdpDiscovery(this.udpPort); + this.presetRegistry = new PresetRegistry(); + + this.currentPreset = null; + this.currentPresetName = null; + this.streamingInterval = null; + this.connectedClients = new Set(); + + // Per-node configurations and current target + this.nodeConfigurations = new Map(); // ip -> {presetName, parameters, matrixSize} + this.currentTarget = null; // 'broadcast' or specific IP + + this.setupExpress(); + this.setupWebSocket(); + this.setupUdpDiscovery(); + this.setupPresetManager(); + } + + setupExpress() { + // Serve static files + this.app.use(express.static(path.join(__dirname, '../public'))); + + // API routes + this.app.get('/api/nodes', (req, res) => { + const nodes = this.udpDiscovery.getNodes(); + res.json({ nodes }); + }); + + this.app.get('/api/presets', (req, res) => { + const presets = this.presetRegistry.getAllPresetMetadata(); + res.json({ presets }); + }); + + this.app.get('/api/status', (req, res) => { + res.json({ + streaming: this.currentPreset !== null, + currentPreset: this.currentPreset ? this.currentPreset.getMetadata().name : null, + matrixSize: { width: this.matrixWidth, height: this.matrixHeight }, + nodeCount: this.udpDiscovery.getNodeCount(), + currentTarget: this.currentTarget, + }); + }); + + // Matrix configuration endpoint + this.app.post('/api/matrix/config', express.json(), (req, res) => { + const { width, height } = req.body; + + if (width && height && width > 0 && height > 0) { + this.matrixWidth = width; + this.matrixHeight = height; + + // Restart current preset with new dimensions if one is active + if (this.currentPreset) { + this.stopStreaming(); + this.startPreset(this.currentPreset.constructor.name.toLowerCase().replace('-preset', ''), width, height); + } + + this.broadcastToClients({ + type: 'matrixConfig', + config: { width, height } + }); + + // Save updated configuration for current target + if (this.currentTarget && this.currentTarget !== 'broadcast') { + this.saveCurrentConfiguration(this.currentTarget); + } + + res.json({ success: true, config: { width, height } }); + } else { + res.status(400).json({ error: 'Invalid matrix dimensions' }); + } + }); + } + + setupWebSocket() { + this.wss.on('connection', (ws) => { + console.log('WebSocket client connected'); + this.connectedClients.add(ws); + + ws.on('message', (message) => { + try { + const data = JSON.parse(message.toString()); + this.handleWebSocketMessage(ws, data); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }); + + ws.on('close', () => { + console.log('WebSocket client disconnected'); + this.connectedClients.delete(ws); + }); + + // Send current status to new client + const currentState = { + streaming: this.currentPreset !== null, + currentPreset: this.currentPreset ? this.currentPreset.getMetadata().name : null, + matrixSize: { width: this.matrixWidth, height: this.matrixHeight }, + nodes: this.udpDiscovery.getNodes(), + presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null, + currentTarget: this.currentTarget, + }; + + this.sendToClient(ws, { + type: 'status', + data: currentState + }); + }); + } + + handleWebSocketMessage(ws, data) { + switch (data.type) { + case 'startPreset': + this.startPreset(data.presetName, data.width, data.height); + break; + + case 'stopStreaming': + this.stopStreaming(); + break; + + case 'updatePresetParameter': + this.updatePresetParameter(data.parameter, data.value); + break; + + case 'setMatrixSize': + this.setMatrixSize(data.width, data.height); + break; + + case 'selectNode': + this.selectNode(data.nodeIp); + break; + + case 'selectBroadcast': + this.selectBroadcast(); + break; + + case 'sendToNode': + this.sendToSpecificNode(data.nodeIp, data.message); + break; + + case 'broadcastToAll': + this.broadcastToAllNodes(data.message); + break; + + default: + console.warn('Unknown WebSocket message type:', data.type); + } + } + + setupUdpDiscovery() { + this.udpDiscovery.on('nodeDiscovered', (node) => { + console.log('Node discovered:', node.ip); + + this.broadcastToClients({ + type: 'nodeDiscovered', + node + }); + }); + + this.udpDiscovery.on('nodeLost', (node) => { + console.log('Node lost:', node.ip); + + this.broadcastToClients({ + type: 'nodeLost', + node + }); + }); + + this.udpDiscovery.start(); + } + + setupPresetManager() { + // Start with no active preset + this.currentPreset = null; + } + + startPreset(presetName, width = this.matrixWidth, height = this.matrixHeight) { + try { + // Stop current streaming if active + if (this.currentPreset) { + this.stopStreaming(); + } + + // Create new preset instance + this.currentPreset = this.presetRegistry.createPreset(presetName, width, height); + this.currentPresetName = presetName; // Store the registry key + this.currentPreset.start(); + + console.log(`Started preset: ${presetName} (${width}x${height})`); + + // Start streaming interval + this.streamingInterval = setInterval(() => { + this.streamFrame(); + }, 50); // 20 FPS + + // Notify clients + this.broadcastToClients({ + type: 'streamingStarted', + preset: this.currentPreset.getMetadata() + }); + + // Also send updated state to keep all clients in sync + this.broadcastCurrentState(); + + } catch (error) { + console.error('Error starting preset:', error); + this.sendToClient(ws, { + type: 'error', + message: `Failed to start preset: ${error.message}` + }); + } + } + + stopStreaming() { + if (this.streamingInterval) { + clearInterval(this.streamingInterval); + this.streamingInterval = null; + } + + if (this.currentPreset) { + this.currentPreset.stop(); + this.currentPreset = null; + this.currentPresetName = null; + } + + console.log('Streaming stopped'); + + this.broadcastToClients({ + type: 'streamingStopped' + }); + + // Save current configuration for the current target if it exists + if (this.currentTarget && this.currentTarget !== 'broadcast') { + this.saveCurrentConfiguration(this.currentTarget); + } + + // Also send updated state to keep all clients in sync + this.broadcastCurrentState(); + } + + updatePresetParameter(parameter, value) { + if (this.currentPreset) { + this.currentPreset.setParameter(parameter, value); + + this.broadcastToClients({ + type: 'presetParameterUpdated', + parameter, + value + }); + + // Save updated configuration for current target + if (this.currentTarget && this.currentTarget !== 'broadcast') { + this.saveCurrentConfiguration(this.currentTarget); + } + + // Also send updated state to keep all clients in sync + this.broadcastCurrentState(); + } + } + + setMatrixSize(width, height) { + this.matrixWidth = width; + this.matrixHeight = height; + + if (this.currentPreset) { + this.stopStreaming(); + this.startPreset(this.currentPreset.constructor.name.toLowerCase().replace('-preset', ''), width, height); + } + + this.broadcastToClients({ + type: 'matrixSizeChanged', + size: { width, height } + }); + + // Save updated configuration for current target + if (this.currentTarget && this.currentTarget !== 'broadcast') { + this.saveCurrentConfiguration(this.currentTarget); + } + } + + + streamFrame() { + if (!this.currentPreset) { + return; + } + + const frameData = this.currentPreset.generateFrame(); + if (frameData) { + // Send to specific target or broadcast + if (this.currentTarget === 'broadcast') { + this.udpDiscovery.broadcastToAll(frameData); + } else if (this.currentTarget) { + this.udpDiscovery.sendToNode(this.currentTarget, frameData); + } + + // Send frame data to WebSocket clients for preview + this.broadcastToClients({ + type: 'frame', + data: frameData, + timestamp: Date.now() + }); + } + } + + sendToSpecificNode(nodeIp, message) { + return this.udpDiscovery.sendToNode(nodeIp, message); + } + + broadcastToAllNodes(message) { + return this.udpDiscovery.broadcastToAll(message); + } + + broadcastCurrentState() { + const currentState = { + streaming: this.currentPreset !== null, + currentPreset: this.currentPreset ? this.currentPreset.getMetadata().name : null, + matrixSize: { width: this.matrixWidth, height: this.matrixHeight }, + nodes: this.udpDiscovery.getNodes(), + presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null, + currentTarget: this.currentTarget, + }; + + this.broadcastToClients({ + type: 'status', + data: currentState + }); + } + + // Node selection and configuration management + selectNode(nodeIp) { + this.currentTarget = nodeIp; + + // Load configuration for this node if it exists, otherwise use current settings + const nodeConfig = this.nodeConfigurations.get(nodeIp); + if (nodeConfig) { + this.loadNodeConfiguration(nodeConfig); + } else { + // Save current configuration for this node + this.saveCurrentConfiguration(nodeIp); + } + + this.broadcastCurrentState(); + } + + selectBroadcast() { + this.currentTarget = 'broadcast'; + this.broadcastCurrentState(); + } + + saveCurrentConfiguration(nodeIp) { + if (this.currentPreset && this.currentPresetName) { + this.nodeConfigurations.set(nodeIp, { + presetName: this.currentPresetName, // Use registry key, not display name + parameters: this.currentPreset.getParameters(), + matrixSize: { width: this.matrixWidth, height: this.matrixHeight } + }); + } + } + + loadNodeConfiguration(config) { + // Stop current streaming + this.stopStreaming(); + + // Load the node's configuration + this.matrixWidth = config.matrixSize.width; + this.matrixHeight = config.matrixSize.height; + + // Start the preset with saved parameters + this.startPreset(config.presetName, config.matrixSize.width, config.matrixSize.height); + + // Set the parameters after preset is created + if (this.currentPreset) { + Object.entries(config.parameters).forEach(([param, value]) => { + this.currentPreset.setParameter(param, value); + }); + } + } + + broadcastToClients(message) { + const messageStr = JSON.stringify(message); + this.connectedClients.forEach(ws => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(messageStr); + } + }); + } + + sendToClient(ws, message) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(message)); + } + } + + startServer() { + this.server.listen(this.port, () => { + console.log(`LEDLab server running on port ${this.port}`); + console.log(`UDP discovery on port ${this.udpPort}`); + console.log(`Matrix size: ${this.matrixWidth}x${this.matrixHeight}`); + }); + } + + stopServer() { + console.log('Stopping LEDLab server...'); + + // Stop streaming first + this.stopStreaming(); + + // Stop UDP discovery + this.udpDiscovery.stop(); + + // Close all WebSocket connections immediately + this.wss.close(); + + // Close the server + this.server.close((err) => { + if (err) { + console.error('Error closing server:', err); + } + console.log('LEDLab server stopped'); + process.exit(0); + }); + + // Force exit after 2 seconds if server doesn't close cleanly + setTimeout(() => { + console.log('Forcing server shutdown...'); + process.exit(1); + }, 2000); + } +} + +// Global flag to ensure shutdown handlers are only registered once +let shutdownHandlersRegistered = false; + +// Start server if this file is run directly +if (require.main === module) { + const server = new LEDLabServer({ + port: process.env.PORT || 8080, + udpPort: process.env.UDP_PORT || 4210, + matrixWidth: parseInt(process.env.MATRIX_WIDTH) || 16, + matrixHeight: parseInt(process.env.MATRIX_HEIGHT) || 16, + }); + + // Graceful shutdown (only register once) + if (!shutdownHandlersRegistered) { + shutdownHandlersRegistered = true; + + process.on('SIGINT', () => { + console.log('\nShutting down LEDLab server...'); + server.stopServer(); + }); + + process.on('SIGTERM', () => { + console.log('\nShutting down LEDLab server...'); + server.stopServer(); + }); + } + + server.startServer(); +} + +module.exports = LEDLabServer; diff --git a/server/udp-discovery.js b/server/udp-discovery.js new file mode 100644 index 0000000..0c60eee --- /dev/null +++ b/server/udp-discovery.js @@ -0,0 +1,213 @@ +// UDP Discovery service for SPORE nodes + +const dgram = require('dgram'); +const EventEmitter = require('events'); +const os = require('os'); + +class UdpDiscovery extends EventEmitter { + constructor(port = 4210) { + super(); + this.port = port; + this.socket = null; + this.nodes = new Map(); // ip -> { lastSeen, status } + this.discoveryInterval = null; + this.isRunning = false; + + // Get local network interfaces to filter out local server + this.localInterfaces = this.getLocalInterfaces(); + } + + getLocalInterfaces() { + const interfaces = os.networkInterfaces(); + const localIPs = new Set(); + + Object.values(interfaces).forEach(iface => { + iface.forEach(addr => { + if (addr.family === 'IPv4' && !addr.internal) { + localIPs.add(addr.address); + } + }); + }); + + return localIPs; + } + + start() { + if (this.isRunning) { + return; + } + + this.socket = dgram.createSocket('udp4'); + this.isRunning = true; + + this.socket.on('message', (msg, rinfo) => { + this.handleMessage(msg, rinfo); + }); + + this.socket.on('error', (err) => { + console.error('UDP Discovery socket error:', err); + this.emit('error', err); + }); + + this.socket.bind(this.port, () => { + console.log(`UDP Discovery listening on port ${this.port}`); + // Enable broadcast after binding + this.socket.setBroadcast(true); + this.emit('started'); + }); + + // Start periodic discovery broadcast + this.startDiscoveryBroadcast(); + } + + stop() { + if (!this.isRunning) { + return; + } + + this.isRunning = false; + + if (this.discoveryInterval) { + clearInterval(this.discoveryInterval); + this.discoveryInterval = null; + } + + if (this.socket) { + this.socket.close(); + this.socket = null; + } + + this.nodes.clear(); + console.log('UDP Discovery stopped'); + this.emit('stopped'); + } + + handleMessage(msg, rinfo) { + const message = msg.toString('utf8'); + const nodeIp = rinfo.address; + + // Skip local server IPs + if (this.localInterfaces.has(nodeIp)) { + return; + } + + // Update node last seen time + this.nodes.set(nodeIp, { + lastSeen: Date.now(), + status: 'connected', + address: nodeIp, + port: rinfo.port + }); + + // Emit node discovered/updated event + this.emit('nodeDiscovered', { + ip: nodeIp, + port: rinfo.port, + status: 'connected' + }); + + // Clean up stale nodes periodically + this.cleanupStaleNodes(); + } + + startDiscoveryBroadcast() { + // Broadcast discovery message every 5 seconds + this.discoveryInterval = setInterval(() => { + this.broadcastDiscovery(); + }, 5000); + + // Send initial broadcast + this.broadcastDiscovery(); + } + + broadcastDiscovery() { + if (!this.socket) { + return; + } + + const discoveryMessage = 'SPORE_DISCOVERY'; + const message = Buffer.from(discoveryMessage, 'utf8'); + + // Broadcast to all nodes on the network (broadcast already enabled in bind callback) + + this.socket.send(message, 0, message.length, this.port, '255.255.255.255', (err) => { + if (err) { + console.error('Error broadcasting discovery message:', err); + } else { + console.log('Discovery message broadcasted'); + } + }); + } + + cleanupStaleNodes() { + const now = Date.now(); + const staleThreshold = 10000; // 10 seconds + + for (const [ip, node] of this.nodes.entries()) { + if (now - node.lastSeen > staleThreshold) { + this.nodes.delete(ip); + this.emit('nodeLost', { ip, status: 'disconnected' }); + } + } + } + + getNodes() { + const nodes = Array.from(this.nodes.entries()).map(([ip, node]) => ({ + ip, + ...node + })); + + // Add broadcast option + nodes.unshift({ + ip: 'broadcast', + status: 'broadcast', + address: '255.255.255.255', + port: this.port, + isBroadcast: true + }); + + return nodes; + } + + getNodeCount() { + return this.nodes.size; + } + + sendToNode(nodeIp, message) { + if (!this.socket) { + return false; + } + + const buffer = Buffer.from(message, 'utf8'); + this.socket.send(buffer, 0, buffer.length, this.port, nodeIp, (err) => { + if (err) { + console.error(`Error sending to node ${nodeIp}:`, err); + return false; + } + return true; + }); + + return true; + } + + broadcastToAll(message) { + if (!this.socket) { + return false; + } + + const buffer = Buffer.from(message, 'utf8'); + this.socket.setBroadcast(true); + + this.socket.send(buffer, 0, buffer.length, this.port, '255.255.255.255', (err) => { + if (err) { + console.error('Error broadcasting message:', err); + return false; + } + return true; + }); + + return true; + } +} + +module.exports = UdpDiscovery;