diff --git a/.cursor/rules/cleancode.mdc b/.cursor/rules/cleancode.mdc new file mode 100644 index 0000000..8b41201 --- /dev/null +++ b/.cursor/rules/cleancode.mdc @@ -0,0 +1,56 @@ +--- +description: Guidelines for writing clean, maintainable, and human-readable code. Apply these rules when writing or reviewing code to ensure consistency and quality. +globs: +alwaysApply: true +--- +# Clean Code Guidelines + +## Constants Over Magic Numbers +- Replace hard-coded values with named constants +- Use descriptive constant names that explain the value's purpose +- Keep constants at the top of the file or in a dedicated constants file + +## Meaningful Names +- Variables, functions, and classes should reveal their purpose +- Names should explain why something exists and how it's used +- Avoid abbreviations unless they're universally understood + +## Smart Comments +- Don't comment on what the code does - make the code self-documenting +- Use comments to explain why something is done a certain way +- Document APIs, complex algorithms, and non-obvious side effects + +## Single Responsibility +- Each function should do exactly one thing +- Functions should be small and focused +- If a function needs a comment to explain what it does, it should be split + +## DRY (Don't Repeat Yourself) +- Extract repeated code into reusable functions +- Share common logic through proper abstraction +- Maintain single sources of truth + +## Clean Structure +- Keep related code together +- Organize code in a logical hierarchy +- Use consistent file and folder naming conventions + +## Encapsulation +- Hide implementation details +- Expose clear interfaces +- Move nested conditionals into well-named functions + +## Code Quality Maintenance +- Refactor continuously +- Fix technical debt early +- Leave code cleaner than you found it + +## Testing +- Write tests before fixing bugs +- Keep tests readable and maintainable +- Test edge cases and error conditions + +## Version Control +- Write clear commit messages +- Make small, focused commits +- Use meaningful branch names \ No newline at end of file diff --git a/.cursor/rules/gitflow.mdc b/.cursor/rules/gitflow.mdc new file mode 100644 index 0000000..d52c71b --- /dev/null +++ b/.cursor/rules/gitflow.mdc @@ -0,0 +1,111 @@ +--- +description: Gitflow Workflow Rules. These rules should be applied when performing git operations. +--- +# Gitflow Workflow Rules + +## Main Branches + +### main (or master) +- Contains production-ready code +- Never commit directly to main +- Only accepts merges from: + - hotfix/* branches + - release/* branches +- Must be tagged with version number after each merge + +### develop +- Main development branch +- Contains latest delivered development changes +- Source branch for feature branches +- Never commit directly to develop + +## Supporting Branches + +### feature/* +- Branch from: develop +- Merge back into: develop +- Naming convention: feature/[issue-id]-descriptive-name +- Example: feature/123-user-authentication +- Must be up-to-date with develop before creating PR +- Delete after merge + +### release/* +- Branch from: develop +- Merge back into: + - main + - develop +- Naming convention: release/vX.Y.Z +- Example: release/v1.2.0 +- Only bug fixes, documentation, and release-oriented tasks +- No new features +- Delete after merge + +### hotfix/* +- Branch from: main +- Merge back into: + - main + - develop +- Naming convention: hotfix/vX.Y.Z +- Example: hotfix/v1.2.1 +- Only for urgent production fixes +- Delete after merge + +## Commit Messages + +- Format: `type(scope): description` +- Types: + - feat: New feature + - fix: Bug fix + - docs: Documentation changes + - style: Formatting, missing semicolons, etc. + - refactor: Code refactoring + - test: Adding tests + - chore: Maintenance tasks + +## Version Control + +### Semantic Versioning +- MAJOR version for incompatible API changes +- MINOR version for backwards-compatible functionality +- PATCH version for backwards-compatible bug fixes + +## Pull Request Rules + +1. All changes must go through Pull Requests +2. Required approvals: minimum 1 +3. CI checks must pass +4. No direct commits to protected branches (main, develop) +5. Branch must be up to date before merging +6. Delete branch after merge + +## Branch Protection Rules + +### main & develop +- Require pull request reviews +- Require status checks to pass +- Require branches to be up to date +- Include administrators in restrictions +- No force pushes +- No deletions + +## Release Process + +1. Create release branch from develop +2. Bump version numbers +3. Fix any release-specific issues +4. Create PR to main +5. After merge to main: + - Tag release + - Merge back to develop + - Delete release branch + +## Hotfix Process + +1. Create hotfix branch from main +2. Fix the issue +3. Bump patch version +4. Create PR to main +5. After merge to main: + - Tag release + - Merge back to develop + - Delete hotfix branch \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index aad9fff..1b8300f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "dgram": "^1.0.1", + "axios": "^1.6.0", "express": "^4.18.2", "ws": "^8.14.2" } @@ -33,6 +33,23 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -95,6 +112,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -140,6 +169,15 @@ "ms": "2.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -159,13 +197,6 @@ "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", @@ -225,6 +256,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "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", @@ -304,6 +350,42 @@ "node": ">= 0.8" } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -392,6 +474,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "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", @@ -583,6 +680,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", diff --git a/server/index.js b/server/index.js index ebb49cd..6b2e865 100644 --- a/server/index.js +++ b/server/index.js @@ -462,6 +462,7 @@ class LEDLabServer { const frameData = stream.preset.generateFrame(); if (frameData) { // Send to specific node + // frameData format: "RAW:FF0000FF0000..." (RAW prefix + hex pixel data) this.udpDiscovery.sendToNode(nodeIp, frameData); // Send frame data to WebSocket clients for preview diff --git a/server/udp-discovery.js b/server/udp-discovery.js index 0fd56fc..f930c21 100644 --- a/server/udp-discovery.js +++ b/server/udp-discovery.js @@ -91,50 +91,110 @@ class UdpDiscovery extends EventEmitter { return; } - // Update node last seen time + // Handle different message types + if (message.startsWith('cluster/heartbeat:')) { + // Extract hostname from heartbeat: "cluster/heartbeat:hostname" + const hostname = message.substring('cluster/heartbeat:'.length); + this.handleHeartbeat(hostname, nodeIp, rinfo.port); + } else if (message.startsWith('node/update:')) { + // Extract hostname and JSON from update: "node/update:hostname:{json}" + const parts = message.substring('node/update:'.length).split(':'); + if (parts.length >= 2) { + const hostname = parts[0]; + const jsonStr = parts.slice(1).join(':'); // Rejoin in case JSON contains colons + this.handleNodeUpdate(hostname, jsonStr, nodeIp, rinfo.port); + } + } + } + + handleHeartbeat(hostname, nodeIp, port) { + console.log(`Heartbeat from ${hostname} @ ${nodeIp}`); + + // Update or add node + const existingNode = this.nodes.get(nodeIp); this.nodes.set(nodeIp, { lastSeen: Date.now(), status: 'connected', address: nodeIp, - port: rinfo.port + port: port, + hostname: hostname }); - // Emit node discovered/updated event - this.emit('nodeDiscovered', { - ip: nodeIp, - port: rinfo.port, - status: 'connected' - }); + // Only emit if this is a new node or if we need to update + if (!existingNode || existingNode.hostname !== hostname) { + this.emit('nodeDiscovered', { + ip: nodeIp, + hostname: hostname, + port: port, + status: 'connected' + }); + } // Clean up stale nodes periodically this.cleanupStaleNodes(); } - startDiscoveryBroadcast() { - // Broadcast discovery message every 5 seconds - this.discoveryInterval = setInterval(() => { - this.broadcastDiscovery(); - }, 5000); + handleNodeUpdate(hostname, jsonStr, nodeIp, port) { + console.log(`Node update from ${hostname} @ ${nodeIp}`); - // Send initial broadcast - this.broadcastDiscovery(); + // Try to parse JSON to extract additional info + let nodeInfo = {}; + try { + nodeInfo = JSON.parse(jsonStr); + } catch (e) { + console.warn(`Failed to parse node update JSON: ${e.message}`); + } + + // Update node with hostname and any additional info + const existingNode = this.nodes.get(nodeIp); + this.nodes.set(nodeIp, { + lastSeen: Date.now(), + status: 'connected', + address: nodeIp, + port: port, + hostname: hostname || nodeInfo.hostname || existingNode?.hostname, + ...nodeInfo + }); + + // Emit update event + this.emit('nodeDiscovered', { + ip: nodeIp, + hostname: hostname || nodeInfo.hostname || existingNode?.hostname, + port: port, + status: 'connected' + }); } - broadcastDiscovery() { + startDiscoveryBroadcast() { + // With the new protocol, SPORE nodes automatically broadcast heartbeats + // LEDLab passively listens for these heartbeats, so we don't need to broadcast. + // However, we can optionally send a heartbeat to prompt nodes to respond faster. + // For now, we just listen for incoming heartbeats from nodes. + + // Optional: send initial heartbeat to prompt nodes to announce themselves + this.broadcastHeartbeat(); + + // Send periodic heartbeats to prompt node announcements (every 10 seconds) + this.discoveryInterval = setInterval(() => { + this.broadcastHeartbeat(); + }, 10000); + } + + broadcastHeartbeat() { if (!this.socket) { return; } - const discoveryMessage = 'SPORE_DISCOVERY'; + // Send heartbeat using the new protocol format: "cluster/heartbeat:hostname" + const hostname = 'ledlab-client'; + const discoveryMessage = `cluster/heartbeat:${hostname}`; 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); + console.error('Error broadcasting heartbeat:', err); } else { - console.log('Discovery message broadcasted'); + console.log('Discovery heartbeat broadcasted'); } }); } @@ -154,6 +214,7 @@ class UdpDiscovery extends EventEmitter { getNodes() { const nodes = Array.from(this.nodes.entries()).map(([ip, node]) => ({ ip, + hostname: node.hostname || ip, ...node }));