├── ndsweb ├── src │ ├── app │ │ ├── app.component.scss │ │ ├── components │ │ │ ├── view-features │ │ │ │ ├── view-features.component.scss │ │ │ │ ├── view-features.component.html │ │ │ │ └── view-features.component.ts │ │ │ ├── index.ts │ │ │ ├── reorder-columns │ │ │ │ ├── reorder-columns.component.scss │ │ │ │ ├── reorder-columns.component.html │ │ │ │ └── reorder-columns.component.ts │ │ │ └── monitor │ │ │ │ └── monitor.component.scss │ │ ├── app.component.html │ │ ├── services │ │ │ ├── index.ts │ │ │ └── monitor.service.ts │ │ ├── state │ │ │ ├── index.ts │ │ │ └── monitor.state.ts │ │ ├── containers │ │ │ ├── index.ts │ │ │ └── monitor │ │ │ │ ├── monitor-container.component.scss │ │ │ │ ├── monitor-container.component.html │ │ │ │ └── monitor-container.component.ts │ │ ├── models.ts │ │ ├── pipes │ │ │ ├── index.ts │ │ │ ├── format-delta.pipe.ts │ │ │ └── format-size.pipe.ts │ │ ├── actions │ │ │ ├── index.ts │ │ │ └── monitor.actions.ts │ │ ├── tokens.ts │ │ ├── app.routes.ts │ │ ├── app.component.ts │ │ ├── app.config.ts │ │ └── dialogs │ │ │ ├── view-features-dialog.component.ts │ │ │ ├── reorder-columns-dialog.component.ts │ │ │ └── confirm-dialog.component.ts │ ├── test-setup.ts │ ├── environments │ │ ├── environment.ts │ │ └── environment.prod.ts │ ├── styles.scss │ ├── main.ts │ ├── index.html │ └── themes.scss ├── .prettierrc ├── public │ └── favicon.ico ├── readme-compile.png ├── jest.preset.js ├── .prettierignore ├── .vscode │ └── extensions.json ├── tsconfig.editor.json ├── tsconfig.app.json ├── tsconfig.spec.json ├── .gitignore ├── jest.config.ts ├── eslint.config.js ├── tsconfig.json ├── nx.json ├── package.json ├── project.json └── README.md ├── media └── mp4 │ ├── rickroll.mp4 │ ├── triangle.mp4 │ └── goldendollars.mp4 ├── .gitignore ├── secrets.h.example ├── tests ├── ntest.sh ├── ntest.sht └── Makefile ├── .vscode ├── tasks.json ├── c_cpp_properties.json ├── launch.json └── settings.json ├── monitor ├── Makefile ├── main.cpp └── monitor.h ├── ledeffectbase.h ├── effects ├── misceffects.h ├── colorwaveeffect.h ├── bouncingballeffect.h ├── starfield.h ├── paletteeffect.h ├── fireworkseffect.h └── videoeffect.h ├── Makefile ├── .github └── workflows │ └── CI.yml ├── global.h ├── main.cpp ├── palette.h ├── canvas.h ├── schedule.h ├── sample_config.json ├── basegraphics.h ├── README.md ├── utilities.h ├── ledfeature.h ├── interfaces.h └── effectsmanager.h /ndsweb/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ndsweb/src/app/components/view-features/view-features.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ndsweb/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ndsweb/src/app/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './monitor.service'; 2 | -------------------------------------------------------------------------------- /ndsweb/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 4 4 | } 5 | -------------------------------------------------------------------------------- /ndsweb/src/app/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './monitor/monitor.component'; -------------------------------------------------------------------------------- /ndsweb/src/app/state/index.ts: -------------------------------------------------------------------------------- 1 | export { MonitorState } from './monitor.state'; 2 | -------------------------------------------------------------------------------- /ndsweb/src/app/containers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './monitor/monitor-container.component' -------------------------------------------------------------------------------- /media/mp4/rickroll.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PlummersSoftwareLLC/NDSCPP/HEAD/media/mp4/rickroll.mp4 -------------------------------------------------------------------------------- /media/mp4/triangle.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PlummersSoftwareLLC/NDSCPP/HEAD/media/mp4/triangle.mp4 -------------------------------------------------------------------------------- /ndsweb/src/app/models.ts: -------------------------------------------------------------------------------- 1 | export interface Column { 2 | key: string; 3 | value: string; 4 | } 5 | -------------------------------------------------------------------------------- /ndsweb/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PlummersSoftwareLLC/NDSCPP/HEAD/ndsweb/public/favicon.ico -------------------------------------------------------------------------------- /ndsweb/readme-compile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PlummersSoftwareLLC/NDSCPP/HEAD/ndsweb/readme-compile.png -------------------------------------------------------------------------------- /ndsweb/src/app/pipes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './format-size.pipe'; 2 | export * from './format-delta.pipe'; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | secrets.h 2 | config.led 3 | .deps 4 | *.o 5 | *.tmp 6 | ndscpp 7 | monitor/ledmon 8 | tests/tests 9 | cpr -------------------------------------------------------------------------------- /media/mp4/goldendollars.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PlummersSoftwareLLC/NDSCPP/HEAD/media/mp4/goldendollars.mp4 -------------------------------------------------------------------------------- /ndsweb/src/app/actions/index.ts: -------------------------------------------------------------------------------- 1 | import * as MonitorActions from './monitor.actions'; 2 | export { MonitorActions }; -------------------------------------------------------------------------------- /ndsweb/src/app/components/reorder-columns/reorder-columns.component.scss: -------------------------------------------------------------------------------- 1 | .reorder { 2 | cursor: grab; 3 | } 4 | -------------------------------------------------------------------------------- /ndsweb/jest.preset.js: -------------------------------------------------------------------------------- 1 | const nxPreset = require('@nx/jest/preset').default; 2 | 3 | module.exports = { ...nxPreset }; 4 | -------------------------------------------------------------------------------- /ndsweb/src/app/tokens.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from "@angular/core"; 2 | 3 | export const APP_SERVER_URL = new InjectionToken('AppServerUrl'); -------------------------------------------------------------------------------- /secrets.h.example: -------------------------------------------------------------------------------- 1 | // NOTE: Copy this file to secrets.h and enter your actual values there, NOT here! 2 | 3 | #define API_KEY "Your AV API key" 4 | -------------------------------------------------------------------------------- /ndsweb/.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | /dist 3 | /coverage 4 | /.nx/cache 5 | /.nx/workspace-data 6 | .angular 7 | -------------------------------------------------------------------------------- /ndsweb/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; 2 | 3 | setupZoneTestEnv({ 4 | errorOnUnknownElements: true, 5 | errorOnUnknownProperties: true, 6 | }); 7 | -------------------------------------------------------------------------------- /ndsweb/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "nrwl.angular-console", 4 | "esbenp.prettier-vscode", 5 | "dbaeumer.vscode-eslint", 6 | "firsttris.vscode-jest-runner" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /ndsweb/tsconfig.editor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.ts"], 4 | "compilerOptions": {}, 5 | "exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"] 6 | } 7 | -------------------------------------------------------------------------------- /ndsweb/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | 4 | buildVersion: '1.0.0', 5 | buildDate: `${new Date().toISOString()}`, 6 | buildCommit: 'local', 7 | }; 8 | -------------------------------------------------------------------------------- /ndsweb/src/app/containers/monitor/monitor-container.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .error { 6 | padding: 10px; 7 | 8 | .mat-icon { 9 | vertical-align: bottom; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ndsweb/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | 4 | buildVersion: '{{ BUILD_VERSION }}', 5 | buildDate: '{{ BUILD_DATE }}', 6 | buildCommit: '{{ BUILD_COMMIT }}' 7 | }; 8 | -------------------------------------------------------------------------------- /ndsweb/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | @import 'themes'; 3 | 4 | html, body { height: 100%; } 5 | body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } 6 | -------------------------------------------------------------------------------- /tests/ntest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Loop to repeatedly run ./tests 4 | while ./tests; do 5 | echo "Test succeeded, running again..." 6 | done 7 | 8 | # Exit the loop when ./tests fails 9 | echo "Test failed, exiting." 10 | 11 | -------------------------------------------------------------------------------- /tests/ntest.sht: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Loop to repeatedly run ./tests 4 | while ./tests; do 5 | echo "Test succeeded, running again..." 6 | done 7 | 8 | # Exit the loop when ./tests fails 9 | echo "Test failed, exiting." 10 | 11 | -------------------------------------------------------------------------------- /ndsweb/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist/out-tsc", 5 | "types": [] 6 | }, 7 | "files": ["src/main.ts"], 8 | "include": ["src/**/*.d.ts"], 9 | "exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /ndsweb/src/app/components/reorder-columns/reorder-columns.component.html: -------------------------------------------------------------------------------- 1 | 2 | @for (column of columns(); track column.value) { 3 | 4 | drag_handle 5 | {{ column.key }} 6 | 7 | } 8 | 9 | -------------------------------------------------------------------------------- /ndsweb/src/app/components/view-features/view-features.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ feature.friendlyName }} 4 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ndsweb/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist/out-tsc", 5 | "module": "commonjs", 6 | "target": "es2016", 7 | "types": ["jest", "node"] 8 | }, 9 | "files": ["src/test-setup.ts"], 10 | "include": [ 11 | "jest.config.ts", 12 | "src/**/*.test.ts", 13 | "src/**/*.spec.ts", 14 | "src/**/*.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Build ndscpp", 6 | "type": "shell", 7 | "command": "make", 8 | "args": ["all"], 9 | "group": { 10 | "kind": "build", 11 | "isDefault": true 12 | }, 13 | "problemMatcher": [], 14 | "detail": "Build the ndscpp program using make." 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /ndsweb/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { appConfig } from './app/app.config'; 3 | import { AppComponent } from './app/app.component'; 4 | import { environment } from './environments/environment'; 5 | import { enableProdMode } from '@angular/core'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | bootstrapApplication(AppComponent, appConfig).catch((err) => 12 | console.error(err) 13 | ); 14 | -------------------------------------------------------------------------------- /.vscode/c_cpp_properties.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Your Platform", 5 | "includePath": [ 6 | "${workspaceFolder}/**", 7 | "/usr/local/include", 8 | "/usr/local/include/nlohmann" 9 | ], 10 | "macFrameworkPath": [ 11 | "/System/Library/Frameworks", 12 | "/Library/Frameworks" 13 | ], 14 | "intelliSenseMode": "macos-clang-x64", 15 | "compilerPath": "/usr/bin/clang" 16 | } 17 | ], 18 | "version": 4 19 | } -------------------------------------------------------------------------------- /ndsweb/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | dist 5 | tmp 6 | out-tsc 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | 41 | .nx/cache 42 | .nx/workspace-data 43 | 44 | .angular 45 | -------------------------------------------------------------------------------- /ndsweb/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | monitor-web 6 | 7 | 8 | 9 | 13 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug ndscpp", 6 | "type": "cppdbg", 7 | "request": "launch", 8 | "program": "${workspaceFolder}/ndscpp", // Path to your compiled binary 9 | "args": [], // Add program arguments here if needed 10 | "stopAtEntry": false, 11 | "cwd": "${workspaceFolder}", 12 | "environment": [], 13 | "externalConsole": false, 14 | "MIMode": "lldb", 15 | "preLaunchTask": "Build ndscpp", // Run the build task before debugging 16 | "setupCommands": [ 17 | { 18 | "description": "Enable pretty-printing for gdb", 19 | "text": "-enable-pretty-printing", 20 | "ignoreFailures": true 21 | } 22 | ] 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /ndsweb/src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActivatedRouteSnapshot, 3 | Route, 4 | RouterStateSnapshot, 5 | } from '@angular/router'; 6 | 7 | import { MonitorContainerComponent } from './containers'; 8 | import { Store } from '@ngxs/store'; 9 | import { MonitorActions } from './actions'; 10 | import { inject } from '@angular/core'; 11 | 12 | export const appRoutes: Route[] = [ 13 | { 14 | path: '', 15 | component: MonitorContainerComponent, 16 | resolve: { 17 | data: ( 18 | _route: ActivatedRouteSnapshot, 19 | _state: RouterStateSnapshot, 20 | store: Store = inject(Store) 21 | ) => { 22 | store.dispatch(new MonitorActions.LoadCanvases()); 23 | 24 | }, 25 | }, 26 | }, 27 | { path: '**', redirectTo: '' }, 28 | ]; 29 | -------------------------------------------------------------------------------- /ndsweb/src/app/containers/monitor/monitor-container.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | NightDriver Web UI 4 | 5 | 6 | 14 |
15 | warning Connection error with server. 16 | {{ error.statusText }} ({{ error.status }}) 17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /ndsweb/jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | displayName: 'monitor-web', 3 | preset: './jest.preset.js', 4 | setupFilesAfterEnv: ['/src/test-setup.ts'], 5 | coverageDirectory: './coverage/monitor-web', 6 | transform: { 7 | '^.+\\.(ts|mjs|js|html)$': [ 8 | 'jest-preset-angular', 9 | { 10 | tsconfig: '/tsconfig.spec.json', 11 | stringifyContentPathRegex: '\\.(html|svg)$', 12 | }, 13 | ], 14 | }, 15 | transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], 16 | snapshotSerializers: [ 17 | 'jest-preset-angular/build/serializers/no-ng-attributes', 18 | 'jest-preset-angular/build/serializers/ng-snapshot', 19 | 'jest-preset-angular/build/serializers/html-comment', 20 | ], 21 | testMatch: [ 22 | '/src/**/__tests__/**/*.[jt]s?(x)', 23 | '/src/**/*(*.)@(spec|test).[jt]s?(x)', 24 | ], 25 | }; 26 | -------------------------------------------------------------------------------- /ndsweb/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { VERSION as ngVersion } from '@angular/common'; 2 | import { Component } from '@angular/core'; 3 | import { VERSION as ngMaterialVersion } from '@angular/material/core'; 4 | import { RouterModule } from '@angular/router'; 5 | 6 | import { environment } from 'src/environments/environment'; 7 | 8 | @Component({ 9 | imports: [RouterModule], 10 | selector: 'app-root', 11 | templateUrl: './app.component.html', 12 | styleUrl: './app.component.scss', 13 | }) 14 | export class AppComponent { 15 | constructor() { 16 | console.log('@angular/core', ngVersion.full); 17 | console.log('@angular/material', ngMaterialVersion.full); 18 | console.log( 19 | `Build ${environment.buildVersion}-${environment.buildCommit}` 20 | ); 21 | console.log(`Build Date`, new Date(environment.buildDate as string)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ndsweb/src/app/pipes/format-delta.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'formatDelta', 5 | }) 6 | export class FormatDeltaPipe implements PipeTransform { 7 | transform(value: string | number, threshold = 5, width = 21): string { 8 | let time: number = value as number; 9 | 10 | if (typeof value === 'string') { 11 | time = parseFloat(value); 12 | } 13 | 14 | if (Math.abs(time) > 100) { 15 | return 'Unset'; 16 | } 17 | 18 | if (width % 2 == 0) { 19 | width++; 20 | } 21 | 22 | // Normalize value to -1..1 range based on threshold 23 | const normalized = Math.min(1.0, Math.max(-1.0, time / threshold)); 24 | 25 | const center = ~~(width / 2); 26 | const pos = ~~(center + normalized * center); 27 | 28 | let meter = ''; 29 | for (let i = 0; i < width; i++) { 30 | meter += i == pos ? '|' : '-'; 31 | } 32 | return `${time.toFixed(1)}s ${meter}`; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ndsweb/src/app/pipes/format-size.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | const BYTES_IN_GIGABYTE = 1073741824; 4 | const BYTES_IN_MEGABYTE = 1048576; 5 | const BYTES_IN_KILOBYTE = 1024; 6 | 7 | @Pipe({ 8 | name: 'formatSize', 9 | }) 10 | export class FormatSizePipe implements PipeTransform { 11 | transform(value: string | number): string { 12 | let bytes: number = value as number; 13 | 14 | if (typeof value === 'string') { 15 | bytes = parseInt(value); 16 | } 17 | 18 | const gigabytes = bytes / BYTES_IN_GIGABYTE; 19 | const megabytes = bytes / BYTES_IN_MEGABYTE; 20 | const kilobytes = bytes / BYTES_IN_KILOBYTE; 21 | 22 | if (gigabytes > 1) { 23 | return `${gigabytes.toFixed(2)} GB`; 24 | } 25 | 26 | if (megabytes > 1) { 27 | return `${megabytes.toFixed(2)} MB`; 28 | } 29 | 30 | if (kilobytes > 1) { 31 | return `${kilobytes.toFixed(2)} KB`; 32 | } 33 | 34 | return `${bytes} B`; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ndsweb/eslint.config.js: -------------------------------------------------------------------------------- 1 | const nx = require('@nx/eslint-plugin'); 2 | 3 | module.exports = [ 4 | ...nx.configs['flat/base'], 5 | ...nx.configs['flat/typescript'], 6 | ...nx.configs['flat/javascript'], 7 | { 8 | ignores: ['**/dist'], 9 | }, 10 | { 11 | files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], 12 | // Override or add rules here 13 | rules: {}, 14 | }, 15 | ...nx.configs['flat/angular'], 16 | ...nx.configs['flat/angular-template'], 17 | { 18 | files: ['**/*.ts'], 19 | rules: { 20 | '@angular-eslint/directive-selector': [ 21 | 'error', 22 | { 23 | type: 'attribute', 24 | prefix: 'app', 25 | style: 'camelCase', 26 | }, 27 | ], 28 | '@angular-eslint/component-selector': [ 29 | 'error', 30 | { 31 | type: 'element', 32 | prefix: 'app', 33 | style: 'kebab-case', 34 | }, 35 | ], 36 | }, 37 | }, 38 | { 39 | files: ['**/*.html'], 40 | // Override or add rules here 41 | rules: {}, 42 | }, 43 | ]; 44 | -------------------------------------------------------------------------------- /monitor/Makefile: -------------------------------------------------------------------------------- 1 | # Compiler settings 2 | CXX = g++ 3 | CXXFLAGS = -std=c++20 -Wall -Wextra -Werror -O2 4 | INCLUDES = -I. 5 | LDFLAGS = 6 | 7 | # Libraries needed 8 | LIBS = -lcurl -lfmt -lncursesw 9 | 10 | # Binary name 11 | TARGET = ledmon 12 | 13 | # Source files 14 | SOURCES = main.cpp content.cpp 15 | 16 | # Object files 17 | OBJECTS = $(SOURCES:.cpp=.o) 18 | 19 | # Detect platform 20 | UNAME_S := $(shell uname -s) 21 | ifeq ($(UNAME_S), Darwin) 22 | INCLUDES += -I$(shell brew --prefix)/include/ -I$(shell brew --prefix ncurses)/include/ 23 | LDFLAGS += -L$(shell brew --prefix)/lib/ -L$(shell brew --prefix ncurses)/lib/ 24 | endif 25 | 26 | # Default target 27 | all: $(TARGET) 28 | 29 | # Link the target binary 30 | $(TARGET): $(OBJECTS) 31 | @echo "Linking $@..." 32 | @$(CXX) $(LDFLAGS) $(OBJECTS) -o $(TARGET) $(LIBS) 33 | 34 | # Compile source files 35 | %.o: %.cpp 36 | @echo "Compiling $<..." 37 | @$(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@ 38 | 39 | # Clean build files 40 | clean: 41 | @echo "Cleaning build files..." 42 | @rm -f $(OBJECTS) $(TARGET) 43 | 44 | .PHONY: all clean 45 | -------------------------------------------------------------------------------- /ledeffectbase.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | using namespace std; 3 | 4 | // LEDEffectBase 5 | // 6 | // A helper class that implements the ILEDEffect interface. 7 | 8 | #include "interfaces.h" 9 | #include "schedule.h" 10 | 11 | class LEDEffectBase : public ILEDEffect 12 | { 13 | protected: 14 | string _name; 15 | shared_ptr _ptrSchedule = nullptr; 16 | 17 | public: 18 | LEDEffectBase(const string& name) : _name(name) {} 19 | 20 | virtual ~LEDEffectBase() = default; 21 | 22 | const string& Name() const override { return _name; } 23 | 24 | // Default implementation for Start does nothing 25 | void Start(ICanvas& canvas) override 26 | { 27 | } 28 | 29 | // Default implementation for Update does nothing 30 | void Update(ICanvas& canvas, milliseconds deltaTime) override 31 | { 32 | } 33 | 34 | void SetSchedule(const shared_ptr schedule) override 35 | { 36 | _ptrSchedule = schedule; 37 | } 38 | 39 | const shared_ptr GetSchedule() const override 40 | { 41 | return _ptrSchedule; 42 | } 43 | }; 44 | 45 | -------------------------------------------------------------------------------- /ndsweb/src/app/components/reorder-columns/reorder-columns.component.ts: -------------------------------------------------------------------------------- 1 | import { CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop'; 2 | import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; 3 | import { MatIcon } from '@angular/material/icon'; 4 | import { MatList, MatListItem, MatListItemIcon } from '@angular/material/list'; 5 | 6 | import { Column } from '../../models'; 7 | 8 | @Component({ 9 | selector: 'app-reorder-columns', 10 | templateUrl: './reorder-columns.component.html', 11 | styleUrls: ['./reorder-columns.component.scss'], 12 | changeDetection: ChangeDetectionStrategy.OnPush, 13 | standalone: true, 14 | imports: [ 15 | MatList, 16 | MatListItem, 17 | MatListItemIcon, 18 | MatIcon, 19 | CdkDrag, 20 | CdkDragHandle, 21 | CdkDropList 22 | ] 23 | }) 24 | export class ReorderColumnsComponent { 25 | 26 | columns = input(); 27 | columnsChange = output(); 28 | 29 | drop(event: CdkDragDrop) { 30 | const columns = this.columns() as Column[]; 31 | moveItemInArray(columns, event.previousIndex, event.currentIndex); 32 | this.columnsChange.emit(columns); 33 | } 34 | } -------------------------------------------------------------------------------- /tests/Makefile: -------------------------------------------------------------------------------- 1 | # Compiler settings 2 | CXX = clang++ 3 | CXXFLAGS = -std=c++20 -Wall -Wextra -Werror -O2 4 | INCLUDES = -I. 5 | LDFLAGS = 6 | 7 | # Libraries needed 8 | LIBS = -lpthread -lcurl -lcpr -lgtest_main -lgtest 9 | 10 | # Binary name 11 | TARGET = tests 12 | 13 | # Source files 14 | SOURCES = tests.cpp 15 | 16 | # Object files 17 | OBJECTS = $(SOURCES:.cpp=.o) 18 | 19 | # Detect platform 20 | UNAME_S := $(shell uname -s) 21 | ifeq ($(UNAME_S), Darwin) 22 | # macOS-specific settings using Homebrew 23 | INCLUDES += -I$(shell brew --prefix)/include/ 24 | LDFLAGS += -L$(shell brew --prefix)/lib/ 25 | endif 26 | 27 | # Default target 28 | all: $(TARGET) 29 | 30 | # Link the target binary 31 | $(TARGET): $(OBJECTS) 32 | @echo "Linking $@..." 33 | @$(CXX) $(LDFLAGS) $(LIBS) -o $(TARGET) $(OBJECTS) 34 | 35 | # Compile source files 36 | %.o: %.cpp 37 | @echo "Compiling $<..." 38 | @$(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@ 39 | 40 | # Clean build files 41 | clean: 42 | @echo "Cleaning build files..." 43 | @rm -f $(OBJECTS) $(TARGET) 44 | 45 | # Run the tests 46 | test: $(TARGET) 47 | @echo "Running tests..." 48 | @./$(TARGET) 49 | 50 | # Install dependencies on macOS 51 | install-deps-mac: 52 | @echo "Installing dependencies via Homebrew..." 53 | @brew install googletest cpr 54 | 55 | .PHONY: all clean test install-deps-mac -------------------------------------------------------------------------------- /ndsweb/src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { provideHttpClient } from '@angular/common/http'; 3 | import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; 4 | import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; 5 | import { provideRouter } from '@angular/router'; 6 | 7 | import { provideStore } from '@ngxs/store'; 8 | 9 | import { appRoutes } from './app.routes'; 10 | import { MonitorService } from './services'; 11 | import { MonitorState } from './state'; 12 | import { APP_SERVER_URL } from './tokens'; 13 | import { provideToastr } from 'ngx-toastr'; 14 | 15 | export const appConfig: ApplicationConfig = { 16 | providers: [ 17 | CommonModule, 18 | provideHttpClient(), 19 | provideAnimationsAsync(), 20 | provideZoneChangeDetection({ eventCoalescing: true }), 21 | provideToastr({ 22 | countDuplicates: true, 23 | preventDuplicates: true, 24 | resetTimeoutOnDuplicate: true, 25 | maxOpened: 5, 26 | }), 27 | provideRouter(appRoutes), 28 | provideStore([MonitorState], { 29 | developmentMode: true, 30 | }), 31 | 32 | MonitorService, 33 | 34 | { provide: APP_SERVER_URL, useValue: 'http://localhost:7777/api' }, 35 | ], 36 | }; 37 | -------------------------------------------------------------------------------- /ndsweb/src/app/components/view-features/view-features.component.ts: -------------------------------------------------------------------------------- 1 | import { NgFor } from '@angular/common'; 2 | import { 3 | ChangeDetectionStrategy, 4 | Component, 5 | input, 6 | output, 7 | } from '@angular/core'; 8 | import { MatIconButton } from '@angular/material/button'; 9 | import { MatIcon } from '@angular/material/icon'; 10 | import { 11 | MatList, 12 | MatListItem, 13 | MatListItemMeta, 14 | MatListItemTitle, 15 | } from '@angular/material/list'; 16 | 17 | import { Canvas, Feature } from 'src/app/services'; 18 | 19 | @Component({ 20 | selector: 'app-view-features', 21 | templateUrl: `./view-features.component.html`, 22 | styleUrl: `./view-features.component.scss`, 23 | changeDetection: ChangeDetectionStrategy.OnPush, 24 | standalone: true, 25 | imports: [ 26 | MatList, 27 | MatListItem, 28 | NgFor, 29 | MatIcon, 30 | MatIconButton, 31 | MatListItemMeta, 32 | MatListItemTitle, 33 | ], 34 | }) 35 | export class ViewFeaturesComponent { 36 | get features() { 37 | return this.canvas()?.features; 38 | } 39 | 40 | canvas = input(); 41 | 42 | deleteFeature = output<{ canvas: Canvas; feature: Feature }>(); 43 | 44 | onDeleteFeature(feature: Feature) { 45 | const canvas = this.canvas() as Canvas; 46 | this.deleteFeature.emit({ canvas, feature }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /effects/misceffects.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | using namespace std; 4 | using namespace std::chrono; 5 | 6 | #include "../interfaces.h" 7 | #include "../ledeffectbase.h" 8 | #include "../pixeltypes.h" 9 | #include 10 | #include 11 | #include 12 | 13 | class SolidColorFill : public LEDEffectBase 14 | { 15 | private: 16 | CRGB _color; 17 | 18 | public: 19 | SolidColorFill(const string& name, const CRGB& color) 20 | : LEDEffectBase(name), _color(color) 21 | { 22 | } 23 | 24 | void Start(ICanvas& canvas) override 25 | { 26 | } 27 | 28 | void Update(ICanvas& canvas, milliseconds deltaTime) override 29 | { 30 | canvas.Graphics().Clear(_color); 31 | } 32 | 33 | friend inline void to_json(nlohmann::json& j, const SolidColorFill & effect); 34 | friend inline void from_json(const nlohmann::json& j, shared_ptr& effect); 35 | }; 36 | 37 | inline void to_json(nlohmann::json& j, const SolidColorFill & effect) 38 | { 39 | j = { 40 | {"name", effect.Name()}, 41 | {"color", effect._color} // Assumes `to_json` for CRGB is already defined 42 | }; 43 | } 44 | 45 | inline void from_json(const nlohmann::json& j, shared_ptr& effect) 46 | { 47 | effect = make_shared( 48 | j.at("name").get(), 49 | j.at("color").get() // Assumes `from_json` for CRGB is already defined 50 | ); 51 | } -------------------------------------------------------------------------------- /ndsweb/src/themes.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | @include mat.core(); 4 | 5 | html { 6 | height: 100%; 7 | 8 | @include mat.theme( 9 | ( 10 | color: ( 11 | primary: mat.$cyan-palette, 12 | tertiary: mat.$orange-palette, 13 | ), 14 | typography: Roboto, 15 | density: 0, 16 | ) 17 | ); 18 | } 19 | 20 | body { 21 | height: 100%; 22 | color: var(--mat-sys-on-surface); 23 | background: var(--mat-sys-surface); 24 | color-scheme: light; 25 | 26 | &.dark-mode { 27 | color-scheme: dark; 28 | } 29 | } 30 | 31 | $mat-confirm: #00b61e; 32 | $mat-warn: #ba1a1a; 33 | $mat-white: #fff; 34 | 35 | $error-theme: mat.define-theme( 36 | ( 37 | color: ( 38 | theme-type: light, 39 | primary: mat.$red-palette, 40 | ), 41 | ) 42 | ); 43 | 44 | mat-icon.warn { 45 | @include mat.icon-color($error-theme, $color-variant: error); 46 | } 47 | 48 | button.warn { 49 | @include mat.button-overrides( 50 | ( 51 | protected-container-color: $mat-warn, 52 | protected-label-text-color: $mat-white, 53 | ) 54 | ); 55 | } 56 | 57 | button.confirm { 58 | @include mat.button-overrides( 59 | ( 60 | protected-container-color: $mat-confirm, 61 | protected-label-text-color: $mat-white, 62 | ) 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /ndsweb/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "sourceMap": true, 5 | "declaration": false, 6 | "moduleResolution": "node", 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "importHelpers": true, 10 | "target": "es2022", 11 | "module": "esnext", 12 | "lib": ["es2020", "dom"], 13 | "skipLibCheck": true, 14 | "skipDefaultLibCheck": true, 15 | "baseUrl": ".", 16 | "paths": {}, 17 | "forceConsistentCasingInFileNames": true, 18 | "strict": true, 19 | "noImplicitOverride": true, 20 | "noPropertyAccessFromIndexSignature": true, 21 | "noImplicitReturns": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "useDefineForClassFields": false 24 | }, 25 | "files": [], 26 | "include": [], 27 | "references": [ 28 | { 29 | "path": "./tsconfig.editor.json" 30 | }, 31 | { 32 | "path": "./tsconfig.app.json" 33 | }, 34 | { 35 | "path": "./tsconfig.spec.json" 36 | } 37 | ], 38 | "compileOnSave": false, 39 | "exclude": ["node_modules", "tmp"], 40 | "angularCompilerOptions": { 41 | "enableI18nLegacyMessageIdFormat": false, 42 | "strictInjectionParameters": true, 43 | "strictInputAccessModifiers": true, 44 | "strictTemplates": true 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL:=/bin/bash 2 | 3 | CC=clang++ 4 | CFLAGS=-std=c++20 -g3 -O3 -Ieffects 5 | LDFLAGS= 6 | LIBS=-lpthread -lz -lavformat -lavcodec -lavutil -lswscale -lswresample -lfmt 7 | 8 | DEPFLAGS=-MT $@ -MMD -MP -MF $(DEPDIR)/$*.d 9 | 10 | SOURCES=main.cpp 11 | EXECUTABLE=ndscpp 12 | CONFIG_FILES=config.led secrets.h 13 | DEPDIR=.deps 14 | OBJECTS:=$(SOURCES:.cpp=.o) 15 | DEPFILES:=$(SOURCES:%.cpp=$(DEPDIR)/%.d) 16 | 17 | define helptext = 18 | Makefile for ndscpp 19 | 20 | Usage: 21 | all Build the ndscpp application (default) 22 | clean Remove all build artifacts 23 | help Show this help text 24 | 25 | Examples: 26 | $$ make all 27 | 28 | endef 29 | 30 | # Detect platform 31 | UNAME_S := $(shell uname -s) 32 | ifeq ($(UNAME_S), Darwin) 33 | CFLAGS += -I$(shell brew --prefix)/include/ 34 | LDFLAGS += -L$(shell brew --prefix)/lib/ 35 | endif 36 | 37 | all: $(EXECUTABLE) 38 | 39 | help:; @ $(info $(helptext)) : 40 | 41 | $(EXECUTABLE): $(OBJECTS) 42 | @echo Linking $@... 43 | @$(CC) $(LDFLAGS) $^ -o $@ $(LIBS) 44 | 45 | %.o: %.cpp $(CONFIG_FILES) $(DEPDIR)/%.d | $(DEPDIR) 46 | @echo Compiling $<... 47 | @$(CC) $(CFLAGS) $(DEPFLAGS) -c $< -o $@ 48 | 49 | $(CONFIG_FILES): % : %.example 50 | @if [ ! -f $@ ]; then \ 51 | echo "Copying $< to $@..."; \ 52 | cp $< $@; \ 53 | fi 54 | 55 | $(DEPDIR): ; @mkdir -p $@ 56 | 57 | $(DEPFILES): 58 | 59 | clean: 60 | @echo Cleaning build files... 61 | @rm -f $(OBJECTS) $(EXECUTABLE) $(DEPFILES) 62 | 63 | .PHONY: all clean help 64 | 65 | include $(wildcard $(DEPFILES)) 66 | -------------------------------------------------------------------------------- /ndsweb/src/app/dialogs/view-features-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { MatButton } from '@angular/material/button'; 3 | import { MatDialogActions, MatDialogClose, MatDialogContent, MatDialogTitle } from '@angular/material/dialog'; 4 | 5 | import { Store } from '@ngxs/store'; 6 | 7 | import { MonitorActions } from '../actions'; 8 | import { ViewFeaturesComponent } from '../components/view-features/view-features.component'; 9 | import { Canvas, Feature } from '../services'; 10 | import { MonitorState } from '../state'; 11 | 12 | @Component({ 13 | template: ` 14 |

Canvas Features

15 | 16 | 17 | 21 | 22 | 23 | 26 | 27 | `, 28 | imports: [ 29 | MatButton, 30 | MatDialogClose, 31 | MatDialogContent, 32 | MatDialogActions, 33 | MatDialogTitle, 34 | ViewFeaturesComponent, 35 | ], 36 | standalone: true, 37 | }) 38 | export class ViewFeaturesDialogComponent { 39 | store = inject(Store); 40 | canvas = this.store.selectSignal(MonitorState.getSelectedCanvas()); 41 | 42 | onDeleteFeature({ canvas, feature }: { canvas: Canvas; feature: Feature }) { 43 | this.store.dispatch(new MonitorActions.DeleteFeature(canvas, feature)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /ndsweb/nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/nx/schemas/nx-schema.json", 3 | "defaultBase": "master", 4 | "namedInputs": { 5 | "default": ["{projectRoot}/**/*", "sharedGlobals"], 6 | "production": [ 7 | "default", 8 | "!{projectRoot}/.eslintrc.json", 9 | "!{projectRoot}/eslint.config.js", 10 | "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", 11 | "!{projectRoot}/tsconfig.spec.json", 12 | "!{projectRoot}/jest.config.[jt]s", 13 | "!{projectRoot}/src/test-setup.[jt]s", 14 | "!{projectRoot}/test-setup.[jt]s" 15 | ], 16 | "sharedGlobals": [] 17 | }, 18 | "targetDefaults": { 19 | "@angular-devkit/build-angular:browser": { 20 | "cache": true, 21 | "dependsOn": ["^build"], 22 | "inputs": ["production", "^production"] 23 | }, 24 | "@nx/eslint:lint": { 25 | "cache": true, 26 | "inputs": [ 27 | "default", 28 | "{workspaceRoot}/.eslintrc.json", 29 | "{workspaceRoot}/.eslintignore", 30 | "{workspaceRoot}/eslint.config.js" 31 | ] 32 | }, 33 | "@nx/jest:jest": { 34 | "cache": true, 35 | "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"], 36 | "options": { 37 | "passWithNoTests": true 38 | }, 39 | "configurations": { 40 | "ci": { 41 | "ci": true, 42 | "codeCoverage": true 43 | } 44 | } 45 | } 46 | }, 47 | "generators": { 48 | "@nx/angular:application": { 49 | "e2eTestRunner": "none", 50 | "linter": "eslint", 51 | "style": "scss", 52 | "unitTestRunner": "jest" 53 | } 54 | }, 55 | "defaultProject": "monitor-web" 56 | } 57 | -------------------------------------------------------------------------------- /ndsweb/src/app/containers/monitor/monitor-container.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 3 | import { MatCardModule } from '@angular/material/card'; 4 | import { MatIcon } from '@angular/material/icon'; 5 | 6 | import { Store } from '@ngxs/store'; 7 | 8 | import { MonitorActions } from '../../actions'; 9 | import { MonitorComponent } from '../../components'; 10 | import { Canvas } from '../../services'; 11 | import { MonitorState } from '../../state'; 12 | 13 | @Component({ 14 | templateUrl: './monitor-container.component.html', 15 | styleUrls: ['./monitor-container.component.scss'], 16 | changeDetection: ChangeDetectionStrategy.OnPush, 17 | imports: [MonitorComponent, MatCardModule, MatIcon, CommonModule], 18 | standalone: true, 19 | }) 20 | export class MonitorContainerComponent { 21 | canvases = this.store.selectSignal(MonitorState.getCanvases()); 22 | connectionError = this.store.selectSignal(MonitorState.connectionError()); 23 | 24 | constructor(private store: Store) {} 25 | 26 | onAutoRefresh(value: boolean) { 27 | this.store.dispatch(new MonitorActions.UpdateAutoRefresh(value)); 28 | } 29 | 30 | onStartCanvases(canvases: Canvas[]) { 31 | this.store.dispatch(new MonitorActions.StartCanvases(canvases)); 32 | } 33 | 34 | onStopCanvases(canvases: Canvas[]) { 35 | this.store.dispatch(new MonitorActions.StopCanvases(canvases)); 36 | } 37 | 38 | onDeleteCanvas(canvas: Canvas) { 39 | this.store.dispatch(new MonitorActions.ConfirmDeleteCanvas(canvas)); 40 | } 41 | 42 | onViewFeatures(canvas: Canvas) { 43 | this.store.dispatch(new MonitorActions.ViewFeatures(canvas)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: [push, pull_request, workflow_dispatch] 7 | 8 | jobs: 9 | build: 10 | 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest, macos-latest] 14 | fail-fast: false 15 | 16 | runs-on: ${{ matrix.os }} 17 | 18 | defaults: 19 | run: 20 | shell: bash 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v2 25 | 26 | - name: Set up dependencies (Linux) 27 | if: matrix.os == 'ubuntu-latest' 28 | run: | 29 | sudo apt-get update 30 | sudo apt-get install -y libasio-dev zlib1g-dev libavformat-dev libavcodec-dev libavutil-dev libswscale-dev libswresample-dev libcurl4-gnutls-dev libspdlog-dev libgtest-dev 31 | git clone https://github.com/libcpr/cpr.git 32 | cd cpr && mkdir build && cd build 33 | cmake .. -DCPR_USE_SYSTEM_CURL=ON -DBUILD_SHARED_LIBS=ON -DCMAKE_CXX_STANDARD=20 34 | cmake --build . --parallel 35 | sudo cmake --install . 36 | 37 | - name: Set up dependencies (macOS) 38 | if: matrix.os == 'macos-latest' 39 | run: brew install asio ffmpeg spdlog googletest cpr ncurses 40 | 41 | - name: Build project 42 | run: | 43 | make all 44 | make -C monitor 45 | make -C tests 46 | 47 | - name: Run tests 48 | # For the API tests, we start ndscpp in the background and then run the tests. 49 | # Killing ndscpp is a bit rude and technically unnecessary, but we've been 50 | # raised to clean up after ourselves, so that's what we do. 51 | run: | 52 | ./ndscpp > /dev/null 2>&1 & 53 | sleep 1 54 | LD_LIBRARY_PATH=${LD_LIBRARY_PATH}:/usr/local/lib ./tests/tests 55 | sudo killall -9 ndscpp 56 | -------------------------------------------------------------------------------- /ndsweb/src/app/dialogs/reorder-columns-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { MatButton } from '@angular/material/button'; 3 | import { MAT_DIALOG_DATA, MatDialogActions, MatDialogClose, MatDialogContent, MatDialogRef, MatDialogTitle } from '@angular/material/dialog'; 4 | import { MatIcon } from '@angular/material/icon'; 5 | 6 | import { ReorderColumnsComponent } from '../components/reorder-columns/reorder-columns.component'; 7 | import { Column } from '../models'; 8 | 9 | @Component({ 10 | template: ` 11 |

Reorder Columns

12 | 13 | 14 |

15 | Drag and drop by the drag_handle icon to 16 | reorder columns. 17 |

18 | 19 |
20 | 21 | 22 | 25 | 26 | `, 27 | styles: [ 28 | ` 29 | .instructions mat-icon { 30 | vertical-align: middle; 31 | } 32 | `, 33 | ], 34 | imports: [ 35 | MatButton, 36 | MatDialogClose, 37 | MatDialogContent, 38 | MatDialogActions, 39 | MatDialogTitle, 40 | MatIcon, 41 | ReorderColumnsComponent, 42 | ], 43 | standalone: true, 44 | }) 45 | export class ReorderColumnsDialogComponent { 46 | columns: Column[] = inject(MAT_DIALOG_DATA).columns as Column[]; 47 | dialogRef = inject(MatDialogRef); 48 | 49 | onSave() { 50 | this.dialogRef.close(this.columns); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /ndsweb/src/app/actions/monitor.actions.ts: -------------------------------------------------------------------------------- 1 | import { Canvas, Feature } from '../services'; 2 | 3 | const ns = '[Monitor]'; 4 | 5 | export class UpdateAutoRefresh { 6 | static readonly type = `${ns} Update Auto Refresh`; 7 | constructor(public value: boolean) {} 8 | } 9 | 10 | export class LoadCanvases { 11 | static readonly type = `${ns} Load Canvases`; 12 | } 13 | 14 | export class LoadCanvasesFailure { 15 | static readonly type = `${ns} Load Canvases Failure`; 16 | constructor(public error: any) {} 17 | } 18 | 19 | export class StartCanvases { 20 | static readonly type = `${ns} Start Canvases`; 21 | constructor(public canvases: Canvas[]) {} 22 | } 23 | 24 | export class StartCanvasesFailure { 25 | static readonly type = `${ns} Start Canvases Failure`; 26 | constructor(public error: Error) {} 27 | } 28 | 29 | export class StopCanvases { 30 | static readonly type = `${ns} Stop Canvases`; 31 | constructor(public canvases: Canvas[]) {} 32 | } 33 | 34 | export class StopCanvasesFailure { 35 | static readonly type = `${ns} Stop Canvases Failure`; 36 | constructor(public error: Error) {} 37 | } 38 | 39 | export class DeleteCanvas { 40 | static readonly type = `${ns} Delete Canvas`; 41 | constructor(public canvas: Canvas) {} 42 | } 43 | 44 | export class DeleteCanvasFailure { 45 | static readonly type = `${ns} Delete Canvas Failure`; 46 | constructor(public error: Error) {} 47 | } 48 | 49 | export class DeleteFeature { 50 | static readonly type = `${ns} Delete Feature`; 51 | constructor(public canvas: Canvas, public feature: Feature) {} 52 | } 53 | 54 | export class DeleteFeatureFailure { 55 | static readonly type = `${ns} Delete Feature Failure`; 56 | constructor(public error: Error) {} 57 | } 58 | 59 | export class ConfirmDeleteCanvas { 60 | static readonly type = `${ns} Confirm Delete Canvas`; 61 | constructor(public canvas: Canvas) {} 62 | } 63 | 64 | export class ConfirmDeleteFeature { 65 | static readonly type = `${ns} Confirm Delete Feature`; 66 | constructor(public model: { canvas: Canvas; feature: Feature }) {} 67 | } 68 | 69 | export class ViewFeatures { 70 | static readonly type = `${ns} View Features`; 71 | constructor(public canvas: Canvas) {} 72 | } 73 | -------------------------------------------------------------------------------- /global.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | using namespace std; 3 | using namespace chrono; 4 | 5 | // Global Definitions 6 | // 7 | // This file contains global definitions and includes that are used throughout the project. 8 | 9 | #include 10 | #include 11 | #include // For colored console output 12 | #include "utilities.h" 13 | #include "secrets.h" 14 | 15 | extern shared_ptr logger; 16 | 17 | // arraysize 18 | // 19 | // Number of elements in a static array 20 | 21 | #define arraysize(x) (sizeof(x)/sizeof(x[0])) 22 | 23 | // str_snprintf 24 | // 25 | // A safe version of snprintf that returns a string 26 | 27 | inline string str_snprintf(const char *fmt, size_t len, ...) 28 | { 29 | string str(len, '\0'); // Create a string filled with null characters of 'len' length 30 | va_list args; 31 | 32 | va_start(args, len); 33 | int out_length = vsnprintf(&str[0], len + 1, fmt, args); // Write into the string's buffer directly 34 | va_end(args); 35 | 36 | // Resize the string to the actual output length, which vsnprintf returns 37 | if (out_length >= 0) 38 | { 39 | // vsnprintf returns the number of characters that would have been written if n had been sufficiently large 40 | // not counting the terminating null character. 41 | if (static_cast(out_length) > len) 42 | { 43 | // The given length was not sufficient, resize and try again 44 | str.resize(out_length); // Make sure the buffer can hold all data 45 | va_start(args, len); 46 | vsnprintf(&str[0], out_length + 1, fmt, args); // Write again with the correct size 47 | va_end(args); 48 | } 49 | else 50 | { 51 | // The output fit into the buffer, resize to the actual length used 52 | str.resize(out_length); 53 | } 54 | } 55 | else 56 | { 57 | // If vsnprintf returns an error, clear the string 58 | str.clear(); 59 | } 60 | 61 | return str; 62 | } 63 | 64 | // 65 | // Helpful global functions 66 | // 67 | 68 | inline double millis() 69 | { 70 | return (double)clock() / CLOCKS_PER_SEC * 1000; 71 | } 72 | 73 | inline void delay(int ms) 74 | { 75 | this_thread::sleep_for((milliseconds)ms); 76 | } 77 | -------------------------------------------------------------------------------- /ndsweb/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@monitor-web/source", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "start": "nx serve", 7 | "build": "nx build", 8 | "test": "ng add @angular/material" 9 | }, 10 | "private": true, 11 | "dependencies": { 12 | "@angular/animations": "~19.0.0", 13 | "@angular/cdk": "^19.0.2", 14 | "@angular/common": "~19.2.16", 15 | "@angular/compiler": "~19.0.0", 16 | "@angular/core": "~19.0.0", 17 | "@angular/forms": "~19.0.0", 18 | "@angular/material": "~19.0.2", 19 | "@angular/platform-browser": "~19.0.0", 20 | "@angular/platform-browser-dynamic": "~19.0.0", 21 | "@angular/router": "~19.0.0", 22 | "@ngxs/store": "^19.0.0", 23 | "ngx-toastr": "^19.0.0", 24 | "rxjs": "~7.8.0", 25 | "zone.js": "~0.15.0" 26 | }, 27 | "devDependencies": { 28 | "@angular-devkit/build-angular": "~20.3.4", 29 | "@angular-devkit/core": "~19.0.0", 30 | "@angular-devkit/schematics": "~19.0.0", 31 | "@angular/cli": "~19.0.0", 32 | "@angular/compiler-cli": "~19.0.0", 33 | "@angular/language-service": "~19.0.0", 34 | "@angular/material": "^19.0.2", 35 | "@eslint/js": "^9.8.0", 36 | "@nx/angular": "22.0.3", 37 | "@nx/eslint": "20.2.1", 38 | "@nx/eslint-plugin": "20.2.1", 39 | "@nx/jest": "20.2.1", 40 | "@nx/js": "20.2.1", 41 | "@nx/web": "20.2.1", 42 | "@nx/workspace": "20.2.1", 43 | "@schematics/angular": "~19.0.0", 44 | "@swc-node/register": "~1.9.1", 45 | "@swc/core": "~1.5.7", 46 | "@swc/helpers": "~0.5.11", 47 | "@types/jest": "^29.5.12", 48 | "@types/node": "18.16.9", 49 | "@typescript-eslint/utils": "^8.13.0", 50 | "angular-eslint": "^19.0.0", 51 | "eslint": "^9.8.0", 52 | "eslint-config-prettier": "^9.0.0", 53 | "jest": "^29.7.0", 54 | "jest-environment-jsdom": "^29.7.0", 55 | "jest-preset-angular": "~14.4.0", 56 | "nx": "20.2.1", 57 | "prettier": "^2.6.2", 58 | "ts-jest": "^29.1.0", 59 | "ts-node": "10.9.1", 60 | "tslib": "^2.3.0", 61 | "typescript": "~5.6.2", 62 | "typescript-eslint": "^8.13.0" 63 | }, 64 | "overrides": { 65 | "glob": "10.4.2" 66 | }, 67 | "nx": { 68 | "includedScripts": [] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /effects/colorwaveeffect.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | using namespace std; 3 | 4 | #include "../interfaces.h" 5 | #include "../ledeffectbase.h" 6 | #include "../pixeltypes.h" 7 | 8 | class ColorWaveEffect : public LEDEffectBase 9 | { 10 | private: 11 | double _hue; // Current hue for the wave 12 | double _speed; // Speed of hue change 13 | double _waveFrequency; // Frequency of the wave pattern 14 | 15 | public: 16 | ColorWaveEffect(const string& name, double speed = 0.5, double waveFrequency = 10.0) 17 | : LEDEffectBase(name), _hue(0.0), _speed(speed), _waveFrequency(waveFrequency) 18 | { 19 | } 20 | 21 | void Start(ICanvas& canvas) override 22 | { 23 | // Reset the hue at the start 24 | _hue = 0.0; 25 | } 26 | 27 | void Update(ICanvas& canvas, milliseconds deltaTime) override 28 | { 29 | // Increment the hue based on speed and elapsed time 30 | _hue += _speed * deltaTime.count() / 1000.0; 31 | if (_hue >= 1.0) _hue -= 1.0; // Wrap around hue to stay in [0, 1) 32 | 33 | auto& graphics = canvas.Graphics(); 34 | int width = graphics.Width(); 35 | int height = graphics.Height(); 36 | 37 | // Draw the wave 38 | for (int y = 0; y < height; ++y) 39 | { 40 | for (int x = 0; x < width; ++x) 41 | { 42 | // Calculate the hue based on position and wave frequency 43 | double localHue = _hue + (x / static_cast(width) * _waveFrequency); 44 | if (localHue > 1.0) localHue -= 1.0; // Wrap around hue 45 | 46 | // Convert the hue to RGB and draw the pixel 47 | graphics.SetPixel(x, y, CRGB::HSV2RGB(localHue * 360.0)); 48 | } 49 | } 50 | } 51 | 52 | friend inline void to_json(nlohmann::json& j, const ColorWaveEffect & effect); 53 | friend inline void from_json(const nlohmann::json& j, shared_ptr& effect); 54 | }; 55 | 56 | inline void to_json(nlohmann::json& j, const ColorWaveEffect & effect) 57 | { 58 | j = { 59 | {"name", effect.Name()}, 60 | {"speed", effect._speed}, 61 | {"waveFrequency", effect._waveFrequency} 62 | }; 63 | } 64 | 65 | inline void from_json(const nlohmann::json& j, shared_ptr& effect) 66 | { 67 | effect = make_shared( 68 | j.at("name").get(), 69 | j.value("speed", 0.5), 70 | j.value("waveFrequency", 10.0) 71 | ); 72 | } 73 | 74 | -------------------------------------------------------------------------------- /ndsweb/src/app/dialogs/confirm-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { NgClass, NgIf } from '@angular/common'; 2 | import { Component, inject } from '@angular/core'; 3 | import { MatButton } from '@angular/material/button'; 4 | import { 5 | MAT_DIALOG_DATA, 6 | MatDialogActions, 7 | MatDialogClose, 8 | MatDialogContent, 9 | MatDialogRef, 10 | MatDialogTitle, 11 | } from '@angular/material/dialog'; 12 | import { MatIcon } from '@angular/material/icon'; 13 | 14 | @Component({ 15 | template: ` 16 |

{{ title }}

17 | 18 | 19 |

20 | {{ message }} 21 |

22 |
23 | 24 | 28 | 38 | 39 | `, 40 | styles: [``], 41 | imports: [ 42 | MatButton, 43 | MatDialogClose, 44 | MatDialogContent, 45 | MatDialogActions, 46 | MatDialogTitle, 47 | MatIcon, 48 | NgClass, 49 | NgIf, 50 | ], 51 | standalone: true, 52 | }) 53 | export class ConfirmDialogComponent { 54 | get title() { 55 | return this.data.title || 'Confirm Action'; 56 | } 57 | 58 | get message() { 59 | return this.data.message || 'Are you certain you want to do this?'; 60 | } 61 | 62 | get cancelText() { 63 | return this.data.cancelText || 'Back'; 64 | } 65 | 66 | get cancelIcon() { 67 | return this.data.cancelIcon || null; 68 | } 69 | 70 | get cancelClass() { 71 | return this.data.cancelClass || null; 72 | } 73 | 74 | get confirmText() { 75 | return this.data.confirmText || 'Confirm'; 76 | } 77 | 78 | get confirmIcon() { 79 | return this.data.confirmIcon || null; 80 | } 81 | 82 | get confirmClass() { 83 | return this.data.confirmClass || null; 84 | } 85 | 86 | data = inject(MAT_DIALOG_DATA); 87 | dialogRef = inject(MatDialogRef); 88 | } 89 | -------------------------------------------------------------------------------- /ndsweb/src/app/components/monitor/monitor.component.scss: -------------------------------------------------------------------------------- 1 | table { 2 | box-shadow: var(--mat-sys-level2); 3 | 4 | th, 5 | td { 6 | max-width: 100%; 7 | white-space: nowrap; 8 | padding: 0 8px; 9 | 10 | &.empty { 11 | text-align: center; 12 | padding: 20px; 13 | } 14 | } 15 | 16 | th.mat-column-select .select { 17 | display: flex; 18 | align-items: center; 19 | } 20 | } 21 | 22 | .filters { 23 | padding-top: 10px; 24 | gap: 10px; 25 | display: flex; 26 | align-items: baseline; 27 | 28 | .spacer { 29 | flex: 1 1; 30 | } 31 | } 32 | 33 | .good { 34 | color: #00b900; 35 | } 36 | 37 | .warning { 38 | color: #ffcc00; 39 | } 40 | 41 | .danger { 42 | color: #ff0000; 43 | } 44 | 45 | .delta { 46 | white-space: nowrap; 47 | } 48 | 49 | progress { 50 | border: 1px solid #969696; 51 | width: 100%; 52 | height: 30px; 53 | } 54 | progress::-webkit-progress-bar { 55 | background-color: transparent; 56 | } 57 | progress.good::-webkit-progress-value { 58 | background-color: #00b900; 59 | } 60 | progress.warning::-webkit-progress-value { 61 | background-color: #ffcc00; 62 | } 63 | progress.danger::-webkit-progress-value { 64 | background-color: #ff0000; 65 | } 66 | 67 | .mat-column-select { 68 | width: 96px; 69 | } 70 | .mat-column-canvasName { 71 | width: 100px; 72 | } 73 | .mat-column-featureName { 74 | width: 100px; 75 | } 76 | .mat-column-host { 77 | width: 140px; 78 | } 79 | .mat-column-size { 80 | width: 70px; 81 | } 82 | .mat-column-reconnectCount { 83 | width: 35px; 84 | } 85 | .mat-column-fps { 86 | width: 60px; 87 | } 88 | .mat-column-queueDepth { 89 | position: relative; 90 | 91 | .display-value { 92 | position: absolute; 93 | top: 15px; 94 | margin-left: auto; 95 | margin-right: auto; 96 | left: 0; 97 | right: 0; 98 | text-align: center; 99 | } 100 | } 101 | .mat-column-buffer { 102 | width: 80px; 103 | } 104 | .mat-column-signal { 105 | width: 60px; 106 | } 107 | .mat-column-dataRate { 108 | width: 70px; 109 | } 110 | .mat-column-delta { 111 | width: 120px; 112 | } 113 | .mat-column-flash { 114 | width: 50px; 115 | } 116 | .mat-column-status { 117 | width: 70px; 118 | text-transform: uppercase; 119 | 120 | .connected { 121 | color: #00b900; 122 | } 123 | 124 | .disconnected { 125 | color: #ff0000; 126 | } 127 | } 128 | .mat-column-effect { 129 | width: 160px; 130 | } 131 | .mat-column-actions { 132 | text-align: right; 133 | width: 40px; 134 | } 135 | -------------------------------------------------------------------------------- /monitor/main.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include // for getopt 9 | #include // for exit 10 | #include "monitor.h" 11 | 12 | using json = nlohmann::json; 13 | 14 | size_t WriteCallback(void *contents, size_t size, size_t nmemb, void *userp) 15 | { 16 | ((std::string *)userp)->append((char *)contents, size * nmemb); 17 | return size * nmemb; 18 | } 19 | 20 | void print_usage(const char *program_name) 21 | { 22 | fprintf(stderr, "Usage: %s [-s hostname] [-p port] [-f fps]\n", program_name); 23 | fprintf(stderr, "Options:\n"); 24 | fprintf(stderr, " -s Specify the hostname to connect to (default: localhost)\n"); 25 | fprintf(stderr, " -p Specify the port to connect to (default: 7777)\n"); 26 | fprintf(stderr, " -f Specify refresh rate in frames per second (default: 10)\n"); 27 | } 28 | 29 | int main(int argc, char *argv[]) 30 | { 31 | std::string hostname = "localhost"; // default hostname 32 | int port = 7777; // default port 33 | double fps = 10.0; // default refresh rate 34 | int opt; 35 | 36 | // Parse command line options 37 | while ((opt = getopt(argc, argv, "s:p:f:h")) != -1) 38 | { 39 | switch (opt) 40 | { 41 | case 's': 42 | hostname = optarg; 43 | break; 44 | case 'p': 45 | try 46 | { 47 | port = std::stoi(optarg); 48 | if (port <= 0 || port > 65535) 49 | { 50 | fprintf(stderr, "Error: Port must be between 1 and 65535\n"); 51 | exit(1); 52 | } 53 | } 54 | catch (const std::exception &e) 55 | { 56 | fprintf(stderr, "Error: Invalid port number\n"); 57 | exit(1); 58 | } 59 | break; 60 | case 'f': 61 | try 62 | { 63 | fps = std::stod(optarg); 64 | if (fps <= 0.0) 65 | { 66 | fprintf(stderr, "Error: FPS must be greater than 0\n"); 67 | exit(1); 68 | } 69 | } 70 | catch (const std::exception &e) 71 | { 72 | fprintf(stderr, "Error: Invalid FPS value\n"); 73 | exit(1); 74 | } 75 | break; 76 | case 'h': 77 | print_usage(argv[0]); 78 | exit(0); 79 | default: 80 | print_usage(argv[0]); 81 | exit(1); 82 | } 83 | } 84 | 85 | curl_global_init(CURL_GLOBAL_ALL); 86 | 87 | Monitor monitor(hostname, port, fps); 88 | monitor.run(); 89 | 90 | curl_global_cleanup(); 91 | return 0; 92 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "C_Cpp.default.includePath": [ 3 | "/opt/homebrew/include/", 4 | "effects" 5 | ], 6 | "files.associations": { 7 | "__bit_reference": "cpp", 8 | "__hash_table": "cpp", 9 | "__locale": "cpp", 10 | "__node_handle": "cpp", 11 | "__split_buffer": "cpp", 12 | "__threading_support": "cpp", 13 | "__tree": "cpp", 14 | "__verbose_abort": "cpp", 15 | "any": "cpp", 16 | "array": "cpp", 17 | "bitset": "cpp", 18 | "cctype": "cpp", 19 | "charconv": "cpp", 20 | "clocale": "cpp", 21 | "cmath": "cpp", 22 | "complex": "cpp", 23 | "condition_variable": "cpp", 24 | "csignal": "cpp", 25 | "cstdarg": "cpp", 26 | "cstddef": "cpp", 27 | "cstdint": "cpp", 28 | "cstdio": "cpp", 29 | "cstdlib": "cpp", 30 | "cstring": "cpp", 31 | "ctime": "cpp", 32 | "cwchar": "cpp", 33 | "cwctype": "cpp", 34 | "deque": "cpp", 35 | "execution": "cpp", 36 | "memory": "cpp", 37 | "forward_list": "cpp", 38 | "initializer_list": "cpp", 39 | "iomanip": "cpp", 40 | "ios": "cpp", 41 | "iosfwd": "cpp", 42 | "iostream": "cpp", 43 | "istream": "cpp", 44 | "limits": "cpp", 45 | "locale": "cpp", 46 | "map": "cpp", 47 | "mutex": "cpp", 48 | "new": "cpp", 49 | "optional": "cpp", 50 | "ostream": "cpp", 51 | "print": "cpp", 52 | "queue": "cpp", 53 | "ratio": "cpp", 54 | "set": "cpp", 55 | "span": "cpp", 56 | "sstream": "cpp", 57 | "stack": "cpp", 58 | "stdexcept": "cpp", 59 | "streambuf": "cpp", 60 | "string": "cpp", 61 | "string_view": "cpp", 62 | "tuple": "cpp", 63 | "typeinfo": "cpp", 64 | "unordered_map": "cpp", 65 | "valarray": "cpp", 66 | "variant": "cpp", 67 | "vector": "cpp", 68 | "algorithm": "cpp", 69 | "atomic": "cpp", 70 | "bit": "cpp", 71 | "*.tcc": "cpp", 72 | "chrono": "cpp", 73 | "compare": "cpp", 74 | "concepts": "cpp", 75 | "functional": "cpp", 76 | "iterator": "cpp", 77 | "memory_resource": "cpp", 78 | "system_error": "cpp", 79 | "type_traits": "cpp", 80 | "utility": "cpp", 81 | "format": "cpp", 82 | "stop_token": "cpp", 83 | "thread": "cpp", 84 | "__config": "cpp", 85 | "semaphore": "cpp", 86 | "exception": "cpp", 87 | "numeric": "cpp", 88 | "random": "cpp", 89 | "unordered_set": "cpp", 90 | "fstream": "cpp", 91 | "codecvt": "cpp", 92 | "future": "cpp", 93 | "list": "cpp", 94 | "numbers": "cpp", 95 | "shared_mutex": "cpp", 96 | "source_location": "cpp", 97 | "typeindex": "cpp" 98 | }, 99 | "C_Cpp.default.cppStandard": "c++20", 100 | "cSpell.words": [ 101 | "spdlog" 102 | ] 103 | } -------------------------------------------------------------------------------- /ndsweb/src/app/services/monitor.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { inject, Injectable } from '@angular/core'; 3 | import { APP_SERVER_URL } from '../tokens'; 4 | 5 | @Injectable() 6 | export class MonitorService { 7 | serverUrl = inject(APP_SERVER_URL); 8 | http = inject(HttpClient); 9 | 10 | getCanvases() { 11 | return this.http.get(`${this.serverUrl}/canvases`); 12 | } 13 | 14 | deleteCanvas(canvasId: number) { 15 | return this.http.delete(`${this.serverUrl}/canvases/${canvasId}`) 16 | } 17 | 18 | deleteFeature(canvasId: number, featureId: number) { 19 | return this.http.delete(`${this.serverUrl}/canvases/${canvasId}/features/${featureId}`) 20 | } 21 | 22 | startCanvases(canvases: Canvas[]) { 23 | const canvasIds = canvases.map((c) => c.id); 24 | return this.http.post(`${this.serverUrl}/canvases/start`, { 25 | canvasIds, 26 | }); 27 | } 28 | 29 | stopCanvases(canvases: Canvas[]) { 30 | const canvasIds = canvases.map((c) => c.id); 31 | return this.http.post(`${this.serverUrl}/canvases/stop`, { 32 | canvasIds, 33 | }); 34 | } 35 | } 36 | 37 | export interface Canvas { 38 | currentEffectName: string; 39 | effectsManager: { 40 | currentEffectIndex: number; 41 | effects: Effect[]; 42 | fps: number; 43 | type: string; 44 | }; 45 | features: Feature[]; 46 | height: number; 47 | id: number; 48 | name: string; 49 | width: number; 50 | } 51 | 52 | export interface Feature { 53 | bytesPerSecond: number; 54 | channel: number; 55 | clientBufferCount: number; 56 | friendlyName: string; 57 | height: number; 58 | hostName: string; 59 | id: number; 60 | isConnected: boolean; 61 | lastClientResponse?: { 62 | brightness: number; 63 | bufferPos: number; 64 | bufferSize: number; 65 | currentClock: number; 66 | flashVersion?: number | string; 67 | fpsDrawing: number; 68 | newestPacket: number; 69 | oldestPacket: number; 70 | responseSize: number; 71 | sequenceNumber: number; 72 | watts: number; 73 | wifiSignal: number; 74 | }; 75 | offsetX: number; 76 | offsetY: number; 77 | port: number; 78 | queueDepth: number; 79 | queueMaxSize: number; 80 | reconnectCount: number; 81 | redGreenSwap: boolean; 82 | reversed: boolean; 83 | timeOffset: number; 84 | type: string; 85 | width: number; 86 | } 87 | 88 | interface Effect { 89 | maxSpeed: number; 90 | name: string; 91 | newParticleProbability: number; 92 | particleFadeTime: number; 93 | particleHoldTime: number; 94 | particleIgnition: number; 95 | particlePreignitionTime: number; 96 | particleSize: number; 97 | type: string; 98 | 99 | brightness: number; 100 | density: number; 101 | dotSize: number; 102 | everyNthDot: number; 103 | ledColorPerSecond: number; 104 | ledScrollSpeed: number; 105 | mirrored: boolean; 106 | palette: { 107 | blend: boolean; 108 | colors: { 109 | b: number; 110 | g: number; 111 | r: number; 112 | }[]; 113 | }; 114 | rampedColor: false; 115 | } 116 | -------------------------------------------------------------------------------- /main.cpp: -------------------------------------------------------------------------------- 1 | // Main.cpp 2 | // 3 | // This file is the main entry point for the NDSCPP LED Matrix Server application. 4 | // It creates a Canvas, adds a GreenFillEffect to it, and then enters a loop where it 5 | // renders the effect to the canvas, compresses the data, and sends it to the LED 6 | // matrix via a SocketChannel. The program will continue to run until it receives 7 | // a SIGINT signal (Ctrl-C). 8 | 9 | // Main.cpp 10 | // 11 | // This file is the main entry point for the NDSCPP LED Matrix Server application. 12 | // It creates a number of Canvases and adds an effect to each, after which they enter 13 | // a loop where it renders the effect to the canvas, compresses the data, and sends 14 | // it to the relevant LED controller via a SocketChannel. The program will continue 15 | // to run until it receives a SIGINT signal (Ctrl-C). 16 | 17 | #include 18 | #include 19 | #include 20 | #include 21 | #include 22 | #include 23 | 24 | using namespace std; 25 | using namespace chrono; 26 | 27 | #include "crow_all.h" 28 | #include "global.h" 29 | #include "canvas.h" 30 | #include "interfaces.h" 31 | #include "socketchannel.h" 32 | #include "ledfeature.h" 33 | #include "webserver.h" 34 | #include "controller.h" 35 | #include "schedule.h" 36 | 37 | 38 | atomic Canvas::_nextId{0}; // Initialize the static member variable for canvas.h 39 | atomic LEDFeature::_nextId{0}; // Initialize the static member variable for ledfeature.h 40 | atomic SocketChannel::_nextId{0}; // Initialize the static member variable for socketchannel.h 41 | 42 | shared_ptr logger = spdlog::stdout_color_mt("console"); 43 | 44 | // Main program entry point. Runs the webServer and starts up the LED processing. 45 | // When SIGINT is received, exits gracefully. 46 | 47 | int main(int argc, char *argv[]) 48 | { 49 | logger->set_level(spdlog::level::info); 50 | 51 | uint16_t port = 7777; 52 | string filename = "config.led"; 53 | 54 | // Parse command-line options 55 | int opt; 56 | while ((opt = getopt(argc, argv, "p:c:")) != -1) 57 | { 58 | switch (opt) 59 | { 60 | case 'p': 61 | { 62 | int parsedPort = atoi(optarg); 63 | if (parsedPort < 1 || parsedPort > 65535) 64 | { 65 | logger->error("Error: Port number must be between 1 and 65535, but was {}", parsedPort); 66 | return EXIT_FAILURE; 67 | } 68 | port = static_cast(parsedPort); 69 | break; 70 | } 71 | case 'c': 72 | filename = optarg; 73 | break; 74 | default: 75 | cerr << "Usage: " << argv[0] << " [-p ] [-c ]" << endl; 76 | return EXIT_FAILURE; 77 | } 78 | } 79 | 80 | // Load the canvases from the configuration file or use hard-coded table defaults 81 | // depending on USE_DEMO_DATA being defined or not. 82 | 83 | #define USE_DEMO_DATA 0 84 | 85 | #if USE_DEMO_DATA 86 | unique_ptr ptrController = make_unique(port); 87 | ptrController->LoadSampleCanvases(); 88 | #else 89 | unique_ptr ptrController = Controller::CreateFromFile(filename); 90 | #endif 91 | 92 | ptrController->SetPort(port); 93 | ptrController->Connect(); 94 | ptrController->Start(true); // Consider if effect managers want to run 95 | 96 | // Start the web server 97 | crow::logger::setLogLevel(crow::LogLevel::WARNING); 98 | WebServer webServer(*ptrController.get(), filename); 99 | webServer.Start(); 100 | 101 | cout << "Shutting down..." << endl; 102 | 103 | // Shut down rendering and communications 104 | ptrController->Stop(); 105 | ptrController->Disconnect(); 106 | 107 | ptrController = nullptr; 108 | 109 | cout << "Shut down complete." << endl; 110 | 111 | return EXIT_SUCCESS; 112 | } 113 | -------------------------------------------------------------------------------- /palette.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | using namespace std; 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include "json.hpp" 10 | #include "pixeltypes.h" 11 | 12 | // A pallete is a set of colors that can be queried with a floating point indexer to 13 | // get blends throughout the palette's range. The palette can be set to blend or not 14 | // blend between colors. 15 | // 16 | // The palette can be queried with a floating point index, and will return a color 17 | // of that index and fraction from the set of original colors. It wraps, so you can 18 | // as for index 11.4 on an 8 color palette and it will return the color at index 3.4 19 | 20 | class Palette 21 | { 22 | protected: 23 | vector _colorEntries; 24 | 25 | public: 26 | bool _bBlend = true; 27 | 28 | static const vector Rainbow; 29 | static const vector RainbowStripes; 30 | static const vector ChristmasLights; 31 | 32 | explicit Palette(const vector & colors, bool bBlend = true) 33 | : _colorEntries(colors), _bBlend(bBlend) 34 | { 35 | } 36 | 37 | // Add copy/move operations 38 | Palette(const Palette& other) 39 | : _colorEntries(other._colorEntries) 40 | , _bBlend(other._bBlend) 41 | { 42 | } 43 | 44 | Palette& operator=(const Palette& other) 45 | { 46 | _colorEntries = other._colorEntries; 47 | _bBlend = other._bBlend; 48 | return *this; 49 | } 50 | 51 | // Optional but good to have 52 | Palette(Palette&& other) noexcept = default; 53 | Palette& operator=(Palette&& other) noexcept = default; 54 | 55 | size_t originalSize() const 56 | { 57 | return _colorEntries.size(); 58 | } 59 | 60 | const vector & getColors() const 61 | { 62 | return _colorEntries; 63 | } 64 | 65 | virtual CRGB getColor(double d) const 66 | { 67 | auto N = _colorEntries.size(); 68 | 69 | // Normalize d to [0, 1) 70 | d -= floor(d); 71 | if (d < 0) d += 1.0; 72 | 73 | if (!_bBlend) 74 | { 75 | return _colorEntries[static_cast(d * N) % N]; 76 | } 77 | 78 | // Calculate position in palette 79 | const double indexD = d * N; 80 | const size_t index = static_cast(indexD); 81 | const double fraction = indexD - index; 82 | 83 | // Get colors with wrapped index 84 | const CRGB& color1 = _colorEntries[index]; 85 | const CRGB& color2 = _colorEntries[(index + 1) % N]; 86 | return color1.blendWith(color2, fraction); 87 | } 88 | 89 | // Fast path for single-precision float, pre-normalized [0,1) input 90 | virtual CRGB getColorFast(float d) const 91 | { 92 | auto N = _colorEntries.size(); 93 | if (!_bBlend) 94 | { 95 | return _colorEntries[static_cast(d * N) % N]; 96 | } 97 | 98 | const float indexF = d * N; 99 | const size_t index = static_cast(indexF); 100 | const float fraction = indexF - index; 101 | 102 | return _colorEntries[index].blendWith(_colorEntries[(index + 1) % N], fraction); 103 | } 104 | }; 105 | 106 | inline void to_json(nlohmann::json& j, const Palette & palette) 107 | { 108 | auto colorsJson = nlohmann::json::array(); 109 | for (const auto& color : palette.getColors()) 110 | colorsJson.push_back(color); // Uses CRGB serializer 111 | 112 | j = 113 | { 114 | {"colors", colorsJson}, 115 | {"blend", palette._bBlend} 116 | }; 117 | } 118 | 119 | inline void from_json(const nlohmann::json& j, unique_ptr& palette) 120 | { 121 | // Deserialize the "colors" array 122 | vector colors; 123 | for (const auto& colorJson : j.at("colors")) 124 | colors.push_back(colorJson.get()); // Use CRGB's from_json function 125 | 126 | // Deserialize the "blend" flag, defaulting to true if not present 127 | bool blend = j.value("blend", true); 128 | 129 | // Create new Palette 130 | palette = make_unique(colors, blend); 131 | } 132 | -------------------------------------------------------------------------------- /ndsweb/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "monitor-web", 3 | "$schema": "node_modules/nx/schemas/project-schema.json", 4 | "includedScripts": [], 5 | "projectType": "application", 6 | "prefix": "app", 7 | "sourceRoot": "./src", 8 | "tags": [], 9 | "targets": { 10 | "build": { 11 | "executor": "@angular-devkit/build-angular:browser", 12 | "outputs": ["{options.outputPath}"], 13 | "options": { 14 | "outputPath": "dist/monitor-web", 15 | "index": "./src/index.html", 16 | "main": "./src/main.ts", 17 | "polyfills": ["zone.js"], 18 | "tsConfig": "tsconfig.app.json", 19 | "inlineStyleLanguage": "scss", 20 | "assets": [ 21 | { 22 | "glob": "**/*", 23 | "input": "public" 24 | } 25 | ], 26 | "styles": [ 27 | "./src/styles.scss", 28 | "node_modules/ngx-toastr/toastr.css" 29 | ], 30 | "scripts": [] 31 | }, 32 | "configurations": { 33 | "production": { 34 | "budgets": [ 35 | { 36 | "type": "initial", 37 | "maximumWarning": "500kb", 38 | "maximumError": "1mb" 39 | }, 40 | { 41 | "type": "anyComponentStyle", 42 | "maximumWarning": "4kb", 43 | "maximumError": "8kb" 44 | } 45 | ], 46 | "fileReplacements": [ 47 | { 48 | "replace": "src/environments/environment.ts", 49 | "with": "src/environments/environment.prod.ts" 50 | } 51 | ], 52 | "outputHashing": "all", 53 | "optimization": true, 54 | "namedChunks": true, 55 | "extractLicenses": true, 56 | "vendorChunk": true, 57 | "aot": true, 58 | "sourceMap": true 59 | }, 60 | "development": { 61 | "buildOptimizer": false, 62 | "optimization": false, 63 | "vendorChunk": true, 64 | "extractLicenses": false, 65 | "sourceMap": true, 66 | "namedChunks": true 67 | } 68 | }, 69 | "defaultConfiguration": "production" 70 | }, 71 | "serve": { 72 | "executor": "@angular-devkit/build-angular:dev-server", 73 | "configurations": { 74 | "production": { 75 | "buildTarget": "monitor-web:build:production" 76 | }, 77 | "development": { 78 | "buildTarget": "monitor-web:build:development" 79 | } 80 | }, 81 | "defaultConfiguration": "development" 82 | }, 83 | "extract-i18n": { 84 | "executor": "@angular-devkit/build-angular:extract-i18n", 85 | "options": { 86 | "buildTarget": "monitor-web:build" 87 | } 88 | }, 89 | "lint": { 90 | "executor": "@nx/eslint:lint", 91 | "options": { 92 | "lintFilePatterns": ["./src"] 93 | } 94 | }, 95 | "test": { 96 | "executor": "@nx/jest:jest", 97 | "outputs": ["{workspaceRoot}/coverage/{projectName}"], 98 | "options": { 99 | "jestConfig": "jest.config.ts" 100 | } 101 | }, 102 | "serve-static": { 103 | "executor": "@nx/web:file-server", 104 | "options": { 105 | "buildTarget": "monitor-web:build", 106 | "spa": true 107 | } 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /effects/bouncingballeffect.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | using namespace std; 4 | using namespace std::chrono; 5 | 6 | #include "../interfaces.h" 7 | #include "../ledeffectbase.h" 8 | #include "../pixeltypes.h" 9 | #include 10 | #include 11 | #include 12 | 13 | class BouncingBallEffect : public LEDEffectBase 14 | { 15 | private: 16 | size_t _ballCount; 17 | size_t _ballSize; 18 | bool _mirrored; 19 | bool _erase; 20 | 21 | vector _clockTimeSinceLastBounce; 22 | vector _timeSinceLastBounce; 23 | vector _height; 24 | vector _impactVelocity; 25 | vector _dampening; 26 | vector _colors; 27 | 28 | static constexpr float Gravity = -0.25f; 29 | static constexpr float StartHeight = 1.0f; 30 | static constexpr float ImpactVelocityStart = Utilities::constexpr_sqrt(-2.0f * Gravity * StartHeight); 31 | static constexpr auto BallColors = to_array( 32 | { 33 | CRGB::Green, CRGB::Red, CRGB::Blue, CRGB::Orange, CRGB::Purple, CRGB::Yellow, CRGB::Indigo 34 | }); 35 | 36 | public: 37 | BouncingBallEffect(const string& name, size_t ballCount = 5, size_t ballSize = 1, bool mirrored = true, bool erase = true) 38 | : LEDEffectBase(name), _ballCount(ballCount), _ballSize(ballSize), _mirrored(mirrored), _erase(erase) 39 | { 40 | } 41 | 42 | void Start(ICanvas& canvas) override 43 | { 44 | size_t length = canvas.Graphics().Width(); // Assuming 1D for simplicity; adapt for 2D if needed 45 | 46 | _clockTimeSinceLastBounce.resize(_ballCount); 47 | _timeSinceLastBounce.resize(_ballCount); 48 | _height.resize(_ballCount); 49 | _impactVelocity.resize(_ballCount); 50 | _dampening.resize(_ballCount); 51 | _colors.resize(_ballCount); 52 | 53 | for (size_t i = 0; i < _ballCount; ++i) 54 | { 55 | _height[i] = StartHeight; 56 | _impactVelocity[i] = ImpactVelocityStart; 57 | _clockTimeSinceLastBounce[i] = duration_cast(system_clock::now().time_since_epoch()).count(); 58 | _dampening[i] = 1.0f - static_cast(i) / powf(static_cast(_ballCount), 2.0f); 59 | _timeSinceLastBounce[i] = 0; 60 | _colors[i] = BallColors[i % BallColors.size()]; 61 | } 62 | } 63 | 64 | void Update(ICanvas& canvas, milliseconds deltaTime) override 65 | { 66 | auto& graphics = canvas.Graphics(); 67 | size_t length = graphics.Width(); 68 | 69 | // Erase the canvas 70 | if (_erase) 71 | { 72 | graphics.Clear(CRGB::Black); 73 | } 74 | else 75 | { 76 | for (size_t j = 0; j < length; ++j) 77 | if (rand() % 10 > 5) 78 | graphics.FadePixelToBlackBy(j, 0, 50); 79 | } 80 | 81 | // Draw each ball 82 | for (size_t i = 0; i < _ballCount; ++i) 83 | { 84 | _timeSinceLastBounce[i] += deltaTime.count() / 1000.0; // Convert to seconds 85 | _height[i] = 0.5f * Gravity * powf(_timeSinceLastBounce[i], 2.0f) + _impactVelocity[i] * _timeSinceLastBounce[i]; 86 | 87 | if (_height[i] < 0) 88 | { 89 | _height[i] = 0; 90 | _impactVelocity[i] *= _dampening[i]; 91 | _timeSinceLastBounce[i] = 0; 92 | 93 | if (_impactVelocity[i] < 0.5f * ImpactVelocityStart) 94 | { 95 | _impactVelocity[i] = ImpactVelocityStart; 96 | } 97 | } 98 | 99 | float position = _height[i] * (length - 1) / StartHeight; 100 | graphics.SetPixel(position, 0, _colors[i]); 101 | 102 | if (_mirrored) 103 | { 104 | graphics.SetPixel(length - 1 - position, 0, _colors[i]); 105 | } 106 | } 107 | } 108 | 109 | friend inline void to_json(nlohmann::json& j, const BouncingBallEffect& effect); 110 | friend inline void from_json(const nlohmann::json& j, shared_ptr& effect); 111 | }; 112 | 113 | inline void to_json(nlohmann::json& j, const BouncingBallEffect& effect) 114 | { 115 | j = { 116 | {"name", effect.Name()}, 117 | {"ballCount", effect._ballCount}, 118 | {"ballSize", effect._ballSize}, 119 | {"mirrored", effect._mirrored}, 120 | {"erase", effect._erase} 121 | }; 122 | } 123 | 124 | inline void from_json(const nlohmann::json& j, shared_ptr& effect) 125 | { 126 | effect = make_shared( 127 | j.at("name").get(), 128 | j.at("ballCount").get(), 129 | j.at("ballSize").get(), 130 | j.at("mirrored").get(), 131 | j.at("erase").get() 132 | ); 133 | } 134 | -------------------------------------------------------------------------------- /canvas.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | using namespace std; 3 | 4 | // Canvas 5 | // 6 | // Represents the larger drawing surface that is made up from one or more LEDFeatures 7 | 8 | #include "json.hpp" 9 | #include "interfaces.h" 10 | #include "basegraphics.h" 11 | #include "ledfeature.h" 12 | #include "effectsmanager.h" 13 | #include 14 | #include 15 | 16 | class Canvas : public ICanvas 17 | { 18 | static atomic _nextId; 19 | uint32_t _id; 20 | BaseGraphics _graphics; 21 | EffectsManager _effects; 22 | string _name; 23 | vector> _features; 24 | mutable mutex _featuresMutex; 25 | 26 | public: 27 | Canvas(string name, uint32_t width, uint32_t height, uint16_t fps = 30) : 28 | _id(NextId()), 29 | _graphics(width, height), 30 | _effects(fps), 31 | _name(name) 32 | { 33 | } 34 | 35 | static uint32_t NextId() 36 | { 37 | return ++_nextId; 38 | } 39 | 40 | string Name() const override 41 | { 42 | return _name; 43 | } 44 | 45 | uint32_t Id() const override 46 | { 47 | return _id; 48 | } 49 | 50 | uint32_t SetId(uint32_t id) override 51 | { 52 | _id = id; 53 | return _id; 54 | } 55 | 56 | ILEDGraphics & Graphics() override 57 | { 58 | lock_guard lock(_featuresMutex); 59 | return _graphics; 60 | } 61 | 62 | const ILEDGraphics& Graphics() const override 63 | { 64 | lock_guard lock(_featuresMutex); 65 | return _graphics; 66 | } 67 | 68 | IEffectsManager & Effects() override 69 | { 70 | lock_guard lock(_featuresMutex); 71 | return _effects; 72 | } 73 | 74 | const IEffectsManager & Effects() const override 75 | { 76 | lock_guard lock(_featuresMutex); 77 | return _effects; 78 | } 79 | 80 | vector> Features() override 81 | { 82 | lock_guard lock(_featuresMutex); 83 | return _features; 84 | } 85 | 86 | const vector> Features() const override 87 | { 88 | lock_guard lock(_featuresMutex); 89 | return _features; 90 | } 91 | 92 | uint32_t AddFeature(shared_ptr feature) override 93 | { 94 | lock_guard lock(_featuresMutex); 95 | if (!feature) 96 | throw invalid_argument("Cannot add a null feature."); 97 | 98 | feature->SetCanvas(this); 99 | uint32_t id = feature->Id(); 100 | _features.push_back(feature); 101 | return id; 102 | } 103 | 104 | bool RemoveFeatureById(uint16_t featureId) override 105 | { 106 | lock_guard lock(_featuresMutex); 107 | for (size_t i = 0; i < _features.size(); ++i) 108 | { 109 | if (_features[i]->Id() == featureId) 110 | { 111 | _features[i]->Socket()->Stop(); 112 | _features.erase(_features.begin() + i); 113 | return true; 114 | } 115 | } 116 | return false; 117 | } 118 | 119 | friend void to_json(nlohmann::json& j, const ICanvas & canvas); 120 | friend void from_json(const nlohmann::json& j, shared_ptr& canvas); 121 | }; 122 | 123 | // ICanvas --> JSON 124 | 125 | inline void to_json(nlohmann::json& j, const ICanvas& canvas) 126 | { 127 | // Serialize the features 128 | vector jsonFeatures; 129 | for (const auto& feature : canvas.Features()) 130 | jsonFeatures.push_back(*feature); // Dereference the shared pointer 131 | 132 | j = { 133 | {"name", canvas.Name()}, 134 | {"id", canvas.Id()}, 135 | {"width", canvas.Graphics().Width()}, 136 | {"height", canvas.Graphics().Height()}, 137 | {"currentEffectName", canvas.Effects().CurrentEffectName()}, 138 | {"features", jsonFeatures}, // Serialized feature data 139 | {"effectsManager", canvas.Effects()} // EffectsManager must have a `to_json` 140 | }; 141 | } 142 | 143 | inline void to_json(nlohmann::json& j, const shared_ptr& canvasPtr) 144 | { 145 | j = canvasPtr ? nlohmann::json(*canvasPtr) : nullptr; 146 | } 147 | 148 | // ICanvas <-- JSON 149 | 150 | inline void from_json(const nlohmann::json& j, shared_ptr & canvas) 151 | { 152 | // Create canvas with required fields. 153 | canvas = make_shared( 154 | j.at("name").get(), 155 | j.at("width").get(), 156 | j.at("height").get() 157 | ); 158 | 159 | // Features() 160 | for (const auto& featureJson : j.value("features", nlohmann::json::array())) 161 | canvas->AddFeature(featureJson.get>()); 162 | 163 | // Validate and deserialize EffectsManager 164 | if (j.contains("effectsManager")) 165 | from_json(j.at("effectsManager"), canvas->Effects()); 166 | } 167 | -------------------------------------------------------------------------------- /schedule.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | using namespace std; 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | 12 | #include "global.h" 13 | #include "interfaces.h" 14 | 15 | class Schedule : public ISchedule 16 | { 17 | public: 18 | 19 | Schedule() = default; 20 | ~Schedule() = default; 21 | 22 | // Setters 23 | void SetDaysOfWeek(uint8_t days) override { daysOfWeek = days; } 24 | void SetStartTime(const string& time) override { startTime = time; } 25 | void SetStopTime(const string& time) override { stopTime = time; } 26 | void SetStartDate(const string& date) override { startDate = date; } 27 | void SetStopDate(const string& date) override { stopDate = date; } 28 | 29 | // Getters 30 | optional GetDaysOfWeek() const override { return daysOfWeek; } 31 | optional GetStartTime() const override { return startTime; } 32 | optional GetStopTime() const override { return stopTime; } 33 | optional GetStartDate() const override { return startDate; } 34 | optional GetStopDate() const override { return stopDate; } 35 | 36 | 37 | // Methods to manipulate individual days. 38 | // If the daysOfWeek is not yet set, it initializes it to 0. 39 | void AddDay(DayOfWeek day) override 40 | { 41 | if (!daysOfWeek) 42 | daysOfWeek = 0; 43 | 44 | daysOfWeek = *daysOfWeek | day; 45 | } 46 | 47 | void RemoveDay(DayOfWeek day) override 48 | { 49 | if (daysOfWeek) 50 | daysOfWeek = *daysOfWeek & ~day; 51 | } 52 | 53 | bool HasDay(DayOfWeek day) const override 54 | { 55 | return daysOfWeek && ((*daysOfWeek & day) != 0); 56 | } 57 | 58 | // Determines if the schedule is currently active. 59 | // Optional fields are considered a match if not present. 60 | // For daysOfWeek, if not set, the schedule matches all days. 61 | 62 | bool IsActive() const override 63 | { 64 | // Get current time in system local time 65 | auto now = system_clock::now(); 66 | time_t now_c = chrono::system_clock::to_time_t(now); 67 | tm *localTime = localtime(&now_c); 68 | 69 | // Check day-of-week: if daysOfWeek is set, today's bit must be on. 70 | // Note: localTime->tm_wday: Sunday == 0, Monday == 1, etc. 71 | if (daysOfWeek) { 72 | uint8_t todayBit = 1 << localTime->tm_wday; 73 | if (!(*daysOfWeek & todayBit)) 74 | return false; 75 | } 76 | 77 | // Format current date and time as strings in "YYYY-MM-DD" and "HH:MM:SS" formats. 78 | ostringstream dateStream, timeStream; 79 | dateStream << put_time(localTime, "%Y-%m-%d"); 80 | timeStream << put_time(localTime, "%H:%M:%S"); 81 | string currentDate = dateStream.str(); 82 | string currentTime = timeStream.str(); 83 | 84 | // Check start and stop dates if set. 85 | if (startDate && currentDate < *startDate) 86 | return false; 87 | 88 | if (stopDate && currentDate > *stopDate) 89 | return false; 90 | 91 | // Check start and stop times if set. 92 | if (startTime && currentTime < *startTime) 93 | return false; 94 | 95 | if (stopTime && currentTime > *stopTime) 96 | return false; 97 | 98 | return true; 99 | } 100 | 101 | private: 102 | optional daysOfWeek; // Bitmask for days of week 103 | optional startTime; // Format: "HH:MM:SS" 104 | optional stopTime; // Format: "HH:MM:SS" 105 | optional startDate; // Format: "YYYY-MM-DD" 106 | optional stopDate; // Format: "YYYY-MM-DD" 107 | }; 108 | 109 | 110 | // Global to_json and from_json functions for Schedule. 111 | // Only properties that are set will be serialized. 112 | inline void to_json(nlohmann::json &j, const ISchedule &s) 113 | { 114 | if (s.GetDaysOfWeek()) 115 | j["daysOfWeek"] = *s.GetDaysOfWeek(); 116 | 117 | if (s.GetStartTime()) 118 | j["startTime"] = *s.GetStartTime(); 119 | 120 | if (s.GetStopTime()) 121 | j["stopTime"] = *s.GetStopTime(); 122 | 123 | if (s.GetStartDate()) 124 | j["startDate"] = *s.GetStartDate(); 125 | 126 | if (s.GetStopDate()) 127 | j["stopDate"] = *s.GetStopDate(); 128 | } 129 | 130 | inline void from_json(const nlohmann::json &j, shared_ptr &s) 131 | { 132 | s = make_shared(); 133 | 134 | if (j.contains("daysOfWeek")) 135 | s->SetDaysOfWeek(j.at("daysOfWeek").get()); 136 | 137 | if (j.contains("startTime")) 138 | s->SetStartTime(j.at("startTime").get()); 139 | 140 | if (j.contains("stopTime")) 141 | s->SetStopTime(j.at("stopTime").get()); 142 | 143 | if (j.contains("startDate")) 144 | s->SetStartDate(j.at("startDate").get()); 145 | 146 | if (j.contains("stopDate")) 147 | s->SetStopDate(j.at("stopDate").get()); 148 | } 149 | -------------------------------------------------------------------------------- /effects/starfield.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | using namespace std; 4 | using namespace std::chrono; 5 | 6 | #include "../interfaces.h" 7 | #include "../ledeffectbase.h" 8 | #include "../pixeltypes.h" 9 | #include 10 | #include 11 | #include 12 | 13 | class StarfieldEffect : public LEDEffectBase 14 | { 15 | private: 16 | struct Star 17 | { 18 | double x, y; // Current position 19 | double dx, dy; // Velocity components 20 | uint8_t brightness; // Star brightness 21 | CRGB color; // Star color (random or white) 22 | }; 23 | 24 | vector _stars; // Active stars 25 | int _starCount; // Number of stars 26 | mt19937 _rng; // Random number generator 27 | uniform_real_distribution _speedDist; // Speed distribution 28 | uniform_real_distribution _directionDist; // Direction distribution 29 | uniform_int_distribution _brightnessDist; // Brightness distribution 30 | uniform_int_distribution _colorChanceDist; // Determines if a star gets a random color 31 | uniform_int_distribution _colorComponentDist; // Random component for saturated colors 32 | 33 | int _centerX, _centerY; // Center of the canvas 34 | 35 | public: 36 | StarfieldEffect(const string& name, int starCount = 100) 37 | : LEDEffectBase(name), _starCount(starCount), _rng(random_device{}()), 38 | _speedDist(5.0, 20.0), // Increased speed for hyperspace effect 39 | _directionDist(0, 2 * M_PI), // Full 360° angular range 40 | _brightnessDist(28, 255), _colorChanceDist(0, 1), 41 | _colorComponentDist(0, 255), _centerX(0), _centerY(0) 42 | { 43 | } 44 | 45 | void Start(ICanvas& canvas) override 46 | { 47 | _centerX = canvas.Graphics().Width() / 2; 48 | _centerY = canvas.Graphics().Height() / 2; 49 | 50 | _stars.clear(); 51 | for (int i = 0; i < _starCount; ++i) 52 | { 53 | _stars.push_back(CreateRandomStar()); 54 | } 55 | canvas.Graphics().Clear(CRGB::Black); 56 | } 57 | 58 | void Update(ICanvas& canvas, milliseconds deltaTime) override 59 | { 60 | auto& graphics = canvas.Graphics(); 61 | graphics.FadeFrameBy(32); 62 | 63 | double timeFactor = deltaTime.count() / 1000.0; // Convert delta time to seconds 64 | 65 | for (auto& star : _stars) 66 | { 67 | // Update position based on velocity and time 68 | const auto xScale = (double) (graphics.Width() / graphics.Height()) / 2.0; 69 | star.x += star.dx * timeFactor * xScale; 70 | star.y += star.dy * timeFactor; 71 | 72 | // If the star is out of bounds, respawn it 73 | if (star.x < 0 || star.x >= graphics.Width() || star.y < 0 || star.y >= graphics.Height()) 74 | { 75 | star = CreateRandomStar(); 76 | } 77 | 78 | // Draw the star 79 | int ix = static_cast(star.x); 80 | int iy = static_cast(star.y); 81 | graphics.SetPixel(ix, iy, CRGB(star.color.r, star.color.g, star.color.b)); 82 | } 83 | } 84 | 85 | private: 86 | Star CreateRandomStar() 87 | { 88 | // Generate a random speed and direction 89 | double speed = _speedDist(_rng); 90 | double angle = _directionDist(_rng); 91 | 92 | // Compute velocity components 93 | double dx = speed * cos(angle); 94 | double dy = speed * sin(angle); 95 | 96 | // Determine color: 50% chance for random saturated color, otherwise white 97 | CRGB color; 98 | if (_colorChanceDist(_rng) == 0) 99 | { 100 | uint8_t red = _colorComponentDist(_rng); 101 | uint8_t green = _colorComponentDist(_rng); 102 | uint8_t blue = _colorComponentDist(_rng); 103 | 104 | // Make sure one component is maxed for a fully saturated color 105 | int maxComponent = max({red, green, blue}); 106 | if (maxComponent == red) 107 | red = 255; 108 | else if (maxComponent == green) 109 | green = 255; 110 | else 111 | blue = 255; 112 | 113 | color = CRGB(red, green, blue); 114 | } 115 | else 116 | { 117 | // White color 118 | color = CRGB(255, 255, 255); 119 | } 120 | 121 | return Star{ 122 | static_cast(_centerX), 123 | static_cast(_centerY), 124 | dx, 125 | dy, 126 | _brightnessDist(_rng), 127 | color 128 | }; 129 | } 130 | 131 | friend inline void to_json(nlohmann::json& j, const StarfieldEffect & effect); 132 | friend inline void from_json(const nlohmann::json& j, shared_ptr& effect); 133 | }; 134 | 135 | inline void to_json(nlohmann::json& j, const StarfieldEffect & effect) 136 | { 137 | j = { 138 | {"name", effect.Name()}, 139 | {"starCount", effect._starCount} 140 | }; 141 | } 142 | 143 | inline void from_json(const nlohmann::json& j, shared_ptr& effect) 144 | { 145 | effect = make_shared( 146 | j.at("name").get(), 147 | j.value("starCount", 100) 148 | ); 149 | } -------------------------------------------------------------------------------- /effects/paletteeffect.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // PaletteEffect 4 | // 5 | // A versatile effect that scrolls a palette of colors across the canvas. The effect can be configured 6 | // with a variety of parameters to control the speed, density, and appearance of the scrolling colors. 7 | // 8 | // The palette effect advances one pixel at a time and one color at a time through the supplied palette. 9 | // The speed of the color change and the speed of the pixel movement can be controlled independently. 10 | // If left at a density of 1, you get one color per pixel. At 0.5, you get a new color every two pixels, etc. 11 | 12 | using namespace std; 13 | using namespace std::chrono; 14 | 15 | #include "../ledeffectbase.h" 16 | #include "../pixeltypes.h" 17 | #include "../palette.h" 18 | 19 | class PaletteEffect : public LEDEffectBase 20 | { 21 | private: 22 | double _iPixel = 0; 23 | double _iColor; 24 | 25 | public: 26 | Palette _Palette; 27 | double _LEDColorPerSecond = 3.0; 28 | double _LEDScrollSpeed = 0.0; 29 | double _Density = 1.0; 30 | double _EveryNthDot = 1.0; 31 | uint32_t _DotSize = 1; 32 | bool _RampedColor = false; 33 | double _Brightness = 1.0; 34 | bool _Mirrored = false; 35 | 36 | // New constructor taking std::vector directly 37 | PaletteEffect(const string & name, 38 | const vector & colors, 39 | double ledColorPerSecond = 3.0, 40 | double ledScrollSpeed = 0.0, 41 | double density = 1.0, 42 | double everyNthDot = 1.0, 43 | uint32_t dotSize = 1, 44 | bool rampedColor = false, 45 | double brightness = 1.0, 46 | bool mirrored = false, 47 | bool bBlend = true) 48 | : LEDEffectBase(name), 49 | _Palette(colors, bBlend), 50 | _iColor(0), 51 | _LEDColorPerSecond(ledColorPerSecond), 52 | _LEDScrollSpeed(ledScrollSpeed), 53 | _Density(density), 54 | _EveryNthDot(everyNthDot), 55 | _DotSize(dotSize), 56 | _RampedColor(rampedColor), 57 | _Brightness(brightness), 58 | _Mirrored(mirrored) 59 | { 60 | } 61 | 62 | void Update(ICanvas& canvas, milliseconds deltaTime) override 63 | { 64 | auto& graphics = canvas.Graphics(); 65 | const auto width = graphics.Width(); 66 | const auto height = graphics.Height(); 67 | const auto dotcount = width * height; 68 | 69 | graphics.Clear(CRGB::Black); 70 | 71 | // Pre-calculate constants 72 | const double secondsElapsed = deltaTime.count() / 1000.0; 73 | const double cPixelsToScroll = secondsElapsed * _LEDScrollSpeed; 74 | const double cColorsToScroll = secondsElapsed * _LEDColorPerSecond; 75 | const uint32_t cLength = (_Mirrored ? dotcount / 2 : dotcount); 76 | const double cCenter = dotcount / 2.0; 77 | const double colorIncrement = _Density / _Palette.originalSize(); 78 | const double fadeFactor = 1.0 - _Brightness; 79 | 80 | // Update state variables 81 | _iPixel = fmod(_iPixel + cPixelsToScroll, dotcount); 82 | _iColor = fmod(_iColor + (cColorsToScroll * _Density), 1.0); 83 | 84 | // Draw the scrolling color "dots" 85 | 86 | double iColor = _iColor; 87 | for (double i = 0; i < cLength; i += _EveryNthDot) 88 | { 89 | double iPixel = fmod(i + _iPixel, cLength); 90 | CRGB c = _Palette.getColor(iColor).fadeToBlackBy(fadeFactor); 91 | 92 | graphics.SetPixelsF(iPixel + (_Mirrored ? cCenter : 0), _DotSize, c); 93 | if (_Mirrored) 94 | graphics.SetPixelsF(cCenter - iPixel, _DotSize, c); 95 | 96 | iColor = fmod(iColor + colorIncrement, 1.0); 97 | } 98 | 99 | // Handle pixel 0 flicker prevention 100 | if (dotcount > 1) { 101 | graphics.SetPixel(0, 0, graphics.GetPixel(1, 0)); 102 | } 103 | } 104 | 105 | friend inline void to_json(nlohmann::json& j, const PaletteEffect & effect); 106 | friend inline void from_json(const nlohmann::json& j, shared_ptr& effect); 107 | }; 108 | 109 | inline void to_json(nlohmann::json& j, const PaletteEffect & effect) 110 | { 111 | j = { 112 | {"name", effect.Name()}, 113 | {"palette", effect._Palette}, 114 | {"ledColorPerSecond", effect._LEDColorPerSecond}, 115 | {"ledScrollSpeed", effect._LEDScrollSpeed}, 116 | {"density", effect._Density}, 117 | {"everyNthDot", effect._EveryNthDot}, 118 | {"dotSize", effect._DotSize}, 119 | {"rampedColor", effect._RampedColor}, 120 | {"brightness", effect._Brightness}, 121 | {"mirrored", effect._Mirrored} 122 | }; 123 | } 124 | 125 | inline void from_json(const nlohmann::json& j, shared_ptr& effect) 126 | { 127 | effect = make_shared( 128 | j.at("name").get(), 129 | j.at("palette").at("colors").get>(), 130 | j.at("ledColorPerSecond").get(), 131 | j.at("ledScrollSpeed").get(), 132 | j.at("density").get(), 133 | j.at("everyNthDot").get(), 134 | j.at("dotSize").get(), 135 | j.at("rampedColor").get(), 136 | j.at("brightness").get(), 137 | j.at("mirrored").get(), 138 | j.at("palette").at("blend").get() 139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /ndsweb/README.md: -------------------------------------------------------------------------------- 1 | # NDSWeb 2 | 3 | 4 | 5 | ✨ Your new, shiny [Nx workspace](https://nx.dev) is ready ✨. 6 | 7 | [Learn more about this workspace setup and its capabilities](https://nx.dev/getting-started/tutorials/angular-standalone-tutorial?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or run `npx nx graph` to visually explore what was created. Now, let's get you up to speed! 8 | 9 | ## Requirements 10 | Node LTS (Long Term Support) 11 | 12 | This app was initially built on v22.11.0 (LTS), but the current Node LTS version should suffice. 13 | 14 | You can download and install Node from https://nodejs.org/ 15 | 16 | ## Setup 17 | Navigate to the directory and install the required node packages by running the following command: 18 | ```` 19 | npm install 20 | ```` 21 | 22 | ## Run tasks 23 | 24 | To run the dev server for your app, use: 25 | 26 | ```sh 27 | npx nx serve monitor-web 28 | ``` 29 | 30 | After the application builds successfully, you should see an output similar to this: 31 | 32 | ![Successful compile](readme-compile.png) 33 | 34 | You can then navigate in your browser to http://localhost:4200 to view the application 35 | 36 | To create a production bundle: 37 | 38 | ```sh 39 | npx nx build monitor-web 40 | ``` 41 | 42 | To see all available targets to run for a project, run: 43 | 44 | ```sh 45 | npx nx show project monitor-web 46 | ``` 47 | 48 | These targets are either [inferred automatically](https://nx.dev/concepts/inferred-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or defined in the `project.json` or `package.json` files. 49 | 50 | [More about running tasks in the docs »](https://nx.dev/features/run-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) 51 | 52 | ## Add new projects 53 | 54 | While you could add new projects to your workspace manually, you might want to leverage [Nx plugins](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) and their [code generation](https://nx.dev/features/generate-code?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) feature. 55 | 56 | Use the plugin's generator to create new projects. 57 | 58 | To generate a new application, use: 59 | 60 | ```sh 61 | npx nx g @nx/angular:app demo 62 | ``` 63 | 64 | To generate a new library, use: 65 | 66 | ```sh 67 | npx nx g @nx/angular:lib mylib 68 | ``` 69 | 70 | You can use `npx nx list` to get a list of installed plugins. Then, run `npx nx list ` to learn about more specific capabilities of a particular plugin. Alternatively, [install Nx Console](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) to browse plugins and generators in your IDE. 71 | 72 | [Learn more about Nx plugins »](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) | [Browse the plugin registry »](https://nx.dev/plugin-registry?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) 73 | 74 | ## Set up CI! 75 | 76 | ### Step 1 77 | 78 | To connect to Nx Cloud, run the following command: 79 | 80 | ```sh 81 | npx nx connect 82 | ``` 83 | 84 | Connecting to Nx Cloud ensures a [fast and scalable CI](https://nx.dev/ci/intro/why-nx-cloud?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) pipeline. It includes features such as: 85 | 86 | - [Remote caching](https://nx.dev/ci/features/remote-cache?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) 87 | - [Task distribution across multiple machines](https://nx.dev/ci/features/distribute-task-execution?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) 88 | - [Automated e2e test splitting](https://nx.dev/ci/features/split-e2e-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) 89 | - [Task flakiness detection and rerunning](https://nx.dev/ci/features/flaky-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) 90 | 91 | ### Step 2 92 | 93 | Use the following command to configure a CI workflow for your workspace: 94 | 95 | ```sh 96 | npx nx g ci-workflow 97 | ``` 98 | 99 | [Learn more about Nx on CI](https://nx.dev/ci/intro/ci-with-nx#ready-get-started-with-your-provider?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) 100 | 101 | ## Install Nx Console 102 | 103 | Nx Console is an editor extension that enriches your developer experience. It lets you run tasks, generate code, and improves code autocompletion in your IDE. It is available for VSCode and IntelliJ. 104 | 105 | [Install Nx Console »](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) 106 | 107 | ## Useful links 108 | 109 | Learn more: 110 | 111 | - [Learn more about this workspace setup](https://nx.dev/getting-started/tutorials/angular-standalone-tutorial?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) 112 | - [Learn about Nx on CI](https://nx.dev/ci/intro/ci-with-nx?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) 113 | - [Releasing Packages with Nx release](https://nx.dev/features/manage-releases?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) 114 | - [What are Nx plugins?](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) 115 | 116 | And join the Nx community: 117 | - [Discord](https://go.nx.dev/community) 118 | - [Follow us on X](https://twitter.com/nxdevtools) or [LinkedIn](https://www.linkedin.com/company/nrwl) 119 | - [Our Youtube channel](https://www.youtube.com/@nxdevtools) 120 | - [Our blog](https://nx.dev/blog?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) 121 | -------------------------------------------------------------------------------- /effects/fireworkseffect.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | using namespace std; 4 | using namespace std::chrono; 5 | 6 | #include "../interfaces.h" 7 | #include "../ledeffectbase.h" 8 | #include "../pixeltypes.h" 9 | #include "../utilities.h" 10 | #include 11 | #include 12 | #include 13 | #include 14 | 15 | class FireworksEffect : public LEDEffectBase 16 | { 17 | private: 18 | struct Particle 19 | { 20 | CRGB _starColor; 21 | double _birthTime; 22 | double _lastUpdate; 23 | double _velocity; 24 | double _position; 25 | 26 | Particle(const CRGB &starColor, double pos, double maxSpeed, mt19937 &rng) 27 | : _starColor(starColor), 28 | _position(pos), 29 | _velocity(Utilities::RandomDouble(-maxSpeed, maxSpeed)), 30 | _birthTime(GetCurrentTime()), 31 | _lastUpdate(GetCurrentTime()) 32 | { 33 | } 34 | 35 | double Age() const 36 | { 37 | return GetCurrentTime() - _birthTime; 38 | } 39 | 40 | void Update(double deltaTime) 41 | { 42 | _position += _velocity * deltaTime; 43 | _lastUpdate = GetCurrentTime(); 44 | _velocity -= 2 * _velocity * deltaTime; 45 | _starColor.fadeToBlackBy(Utilities::RandomDouble(0.0, 0.1)); 46 | } 47 | 48 | private: 49 | static double GetCurrentTime() 50 | { 51 | return static_cast(chrono::duration_cast( 52 | chrono::steady_clock::now().time_since_epoch()) 53 | .count()) / 54 | 1000.0; 55 | } 56 | }; 57 | 58 | vector _palette; 59 | queue _particles; 60 | mt19937 _rng; 61 | 62 | double _maxSpeed = 175.0; 63 | double _newParticleProbability = 1.0; 64 | double _particlePreignitionTime = 0.0; 65 | double _particleIgnition = 0.2; 66 | double _particleHoldTime = 0.0; 67 | double _particleFadeTime = 2.0; 68 | double _particleSize = 1.0; 69 | 70 | public: 71 | FireworksEffect(const string &name) : LEDEffectBase(name), _rng(random_device{}()) 72 | { 73 | } 74 | 75 | FireworksEffect(const string &name, 76 | double maxSpeed, 77 | double newParticleProbability, 78 | double particlePreignitionTime, 79 | double particleIgnition, 80 | double particleHoldTime, 81 | double particleFadeTime, 82 | double particleSize) 83 | : LEDEffectBase(name), 84 | _maxSpeed(maxSpeed), 85 | _newParticleProbability(newParticleProbability), 86 | _particlePreignitionTime(particlePreignitionTime), 87 | _particleIgnition(particleIgnition), 88 | _particleHoldTime(particleHoldTime), 89 | _particleFadeTime(particleFadeTime), 90 | _particleSize(particleSize), 91 | _rng(random_device{}()) 92 | { 93 | } 94 | 95 | void Update(ICanvas &canvas, milliseconds deltaTime) override 96 | { 97 | const auto ledCount = canvas.Graphics().Width() * canvas.Graphics().Height(); 98 | 99 | for (int i = 0; i < max(5, static_cast(ledCount / 50)); ++i) 100 | { 101 | if (Utilities::RandomDouble(0.0, 1.0) < _newParticleProbability * 0.005) 102 | { 103 | double startPos = Utilities::RandomDouble(0.0, static_cast(canvas.Graphics().Width())); 104 | CRGB color = CHSV(Utilities::RandomInt(0, 255), 255, 255); 105 | int particleCount = Utilities::RandomInt(10, 50); 106 | double multiplier = Utilities::RandomDouble(1.0, 3.0); 107 | 108 | for (int j = 0; j < particleCount; ++j) 109 | { 110 | _particles.emplace(color, startPos, _maxSpeed * multiplier, _rng); 111 | } 112 | } 113 | } 114 | 115 | while (_particles.size() > ledCount) 116 | { 117 | _particles.pop(); 118 | } 119 | 120 | // canvas.Graphics().Clear(CRGB::Black); 121 | canvas.Graphics().FadeFrameBy(64); 122 | 123 | auto particleIter = _particles.front(); 124 | while (!_particles.empty() && particleIter.Age() > _particleHoldTime + _particleIgnition + _particleFadeTime) 125 | { 126 | _particles.pop(); 127 | particleIter = _particles.front(); 128 | } 129 | 130 | queue newParticles; 131 | while (!_particles.empty()) 132 | { 133 | Particle particle = _particles.front(); 134 | _particles.pop(); 135 | 136 | particle.Update(deltaTime.count() / 1000.0); 137 | CRGB color = particle._starColor; 138 | 139 | double fade = 0.0; 140 | if (particle.Age() < _particleIgnition + _particlePreignitionTime) 141 | { 142 | color = CRGB::White; 143 | } 144 | else 145 | { 146 | double age = particle.Age(); 147 | if (age > _particleHoldTime + _particleIgnition) 148 | { 149 | fade = (age - _particleHoldTime - _particleIgnition) / _particleFadeTime; 150 | } 151 | color.fadeToBlackBy(fade); 152 | } 153 | 154 | _particleSize = max(1.0, (1.0 - fade) * (ledCount / 500.0)); 155 | canvas.Graphics().SetPixelsF(particle._position, _particleSize, color); 156 | 157 | newParticles.push(particle); 158 | } 159 | _particles.swap(newParticles); 160 | } 161 | 162 | friend inline void to_json(nlohmann::json &j, const FireworksEffect &effect); 163 | friend inline void from_json(const nlohmann::json &j, shared_ptr &effect); 164 | 165 | }; 166 | 167 | inline void to_json(nlohmann::json &j, const FireworksEffect &effect) 168 | { 169 | j ={ 170 | {"name", effect.Name()}, 171 | {"maxSpeed", effect._maxSpeed}, 172 | {"newParticleProbability", effect._newParticleProbability}, 173 | {"particlePreignitionTime", effect._particlePreignitionTime}, 174 | {"particleIgnition", effect._particleIgnition}, 175 | {"particleHoldTime", effect._particleHoldTime}, 176 | {"particleFadeTime", effect._particleFadeTime}, 177 | {"particleSize", effect._particleSize} 178 | }; 179 | } 180 | 181 | inline void from_json(const nlohmann::json &j, shared_ptr &effect) 182 | { 183 | effect = make_shared( 184 | j.at("name").get(), 185 | j.value("maxSpeed", 175.0), 186 | j.value("newParticleProbability", 1.0), 187 | j.value("particlePreignitionTime", 0.0), 188 | j.value("particleIgnition", 0.2), 189 | j.value("particleHoldTime", 0.0), 190 | j.value("particleFadeTime", 2.0), 191 | j.value("particleSize", 1.0)); 192 | } 193 | 194 | -------------------------------------------------------------------------------- /sample_config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "features": [ 4 | { 5 | "channel": 0, 6 | "friendlyName": "Mesmerizer", 7 | "height": 32, 8 | "hostName": "192.168.8.161", 9 | "offsetX": 0, 10 | "offsetY": 0, 11 | "redGreenSwap": false, 12 | "reversed": false, 13 | "stats": { 14 | "brightness": 0.0, 15 | "bufferPos": 15, 16 | "bufferSize": 180, 17 | "currentClock": 1732594789.404718, 18 | "flashVersion": 40, 19 | "fpsDrawing": 36, 20 | "newestPacket": 4.59520697593689, 21 | "oldestPacket": 4.595242977142334, 22 | "responseSize": 72, 23 | "sequence": 322181, 24 | "watts": 0, 25 | "wifiSignal": -40.0 26 | }, 27 | "width": 64 28 | } 29 | ], 30 | "height": 32, 31 | "id": 0, 32 | "width": 64 33 | }, 34 | { 35 | "features": [ 36 | { 37 | "channel": 0, 38 | "friendlyName": "Banner", 39 | "height": 32, 40 | "hostName": "192.168.1.98", 41 | "offsetX": 0, 42 | "offsetY": 0, 43 | "redGreenSwap": false, 44 | "reversed": false, 45 | "stats": { 46 | "brightness": 100.0, 47 | "bufferPos": 56, 48 | "bufferSize": 500, 49 | "currentClock": 1732594789.557855, 50 | "flashVersion": 0, 51 | "fpsDrawing": 24, 52 | "newestPacket": 9.936142109176636, 53 | "oldestPacket": 7.721737109176636, 54 | "responseSize": 72, 55 | "sequence": 272064, 56 | "watts": 0, 57 | "wifiSignal": 99.0 58 | }, 59 | "width": 512 60 | } 61 | ], 62 | "height": 32, 63 | "id": 1, 64 | "width": 512 65 | }, 66 | { 67 | "features": [ 68 | { 69 | "channel": 0, 70 | "friendlyName": "Window1", 71 | "height": 1, 72 | "hostName": "192.168.8.8", 73 | "offsetX": 0, 74 | "offsetY": 0, 75 | "redGreenSwap": false, 76 | "reversed": false, 77 | "width": 100 78 | } 79 | ], 80 | "height": 1, 81 | "id": 2, 82 | "width": 100 83 | }, 84 | { 85 | "features": [ 86 | { 87 | "channel": 0, 88 | "friendlyName": "Window2", 89 | "height": 1, 90 | "hostName": "192.168.8.9", 91 | "offsetX": 0, 92 | "offsetY": 0, 93 | "redGreenSwap": false, 94 | "reversed": false, 95 | "width": 100 96 | } 97 | ], 98 | "height": 1, 99 | "id": 3, 100 | "width": 100 101 | }, 102 | { 103 | "features": [ 104 | { 105 | "channel": 0, 106 | "friendlyName": "Window3", 107 | "height": 1, 108 | "hostName": "192.168.8.10", 109 | "offsetX": 0, 110 | "offsetY": 0, 111 | "redGreenSwap": false, 112 | "reversed": false, 113 | "width": 100 114 | } 115 | ], 116 | "height": 1, 117 | "id": 4, 118 | "width": 100 119 | }, 120 | { 121 | "features": [ 122 | { 123 | "channel": 0, 124 | "friendlyName": "Cupboard1", 125 | "height": 1, 126 | "hostName": "192.168.8.12", 127 | "offsetX": 0, 128 | "offsetY": 0, 129 | "redGreenSwap": false, 130 | "reversed": false, 131 | "width": 500 132 | }, 133 | { 134 | "channel": 0, 135 | "friendlyName": "Cupboard2", 136 | "height": 1, 137 | "hostName": "192.168.8.29", 138 | "offsetX": 500, 139 | "offsetY": 0, 140 | "redGreenSwap": false, 141 | "reversed": false, 142 | "width": 600 143 | }, 144 | { 145 | "channel": 0, 146 | "friendlyName": "Cupboard3", 147 | "height": 1, 148 | "hostName": "192.168.8.30", 149 | "offsetX": 1100, 150 | "offsetY": 0, 151 | "redGreenSwap": false, 152 | "reversed": false, 153 | "width": 144 154 | }, 155 | { 156 | "channel": 0, 157 | "friendlyName": "Cupboard4", 158 | "height": 1, 159 | "hostName": "192.168.8.15", 160 | "offsetX": 1244, 161 | "offsetY": 0, 162 | "redGreenSwap": false, 163 | "reversed": false, 164 | "width": 144 165 | } 166 | ], 167 | "height": 1, 168 | "id": 5, 169 | "width": 1388 170 | }, 171 | { 172 | "features": [ 173 | { 174 | "channel": 0, 175 | "friendlyName": "CBWEST", 176 | "height": 1, 177 | "hostName": "192.168.8.36", 178 | "offsetX": 0, 179 | "offsetY": 0, 180 | "redGreenSwap": false, 181 | "reversed": false, 182 | "width": 1151 183 | }, 184 | { 185 | "channel": 0, 186 | "friendlyName": "CBEAST1", 187 | "height": 1, 188 | "hostName": "192.168.8.5", 189 | "offsetX": 1151, 190 | "offsetY": 0, 191 | "redGreenSwap": false, 192 | "reversed": true, 193 | "width": 775 194 | }, 195 | { 196 | "channel": 0, 197 | "friendlyName": "CBEAST2", 198 | "height": 1, 199 | "hostName": "192.168.8.37", 200 | "offsetX": 1926, 201 | "offsetY": 0, 202 | "redGreenSwap": false, 203 | "reversed": false, 204 | "width": 926 205 | }, 206 | { 207 | "channel": 0, 208 | "friendlyName": "CBEAST3", 209 | "height": 1, 210 | "hostName": "192.168.8.31", 211 | "offsetX": 2852, 212 | "offsetY": 0, 213 | "redGreenSwap": false, 214 | "reversed": false, 215 | "width": 1129 216 | } 217 | ], 218 | "height": 1, 219 | "id": 6, 220 | "width": 3981 221 | } 222 | ] -------------------------------------------------------------------------------- /effects/videoeffect.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | using namespace std; 3 | using namespace std::chrono; 4 | 5 | #include "../interfaces.h" 6 | #include "../ledeffectbase.h" 7 | #include "../pixeltypes.h" 8 | #include 9 | #include 10 | #include 11 | 12 | extern "C" 13 | { 14 | #include 15 | #include 16 | #include 17 | } 18 | 19 | class MP4PlaybackEffect : public LEDEffectBase 20 | { 21 | private: 22 | string _filePath; 23 | AVFormatContext* _formatCtx = nullptr; 24 | AVCodecContext* _codecCtx = nullptr; 25 | AVFrame* _frame = nullptr; 26 | AVPacket* _packet = nullptr; 27 | SwsContext* _swsCtx = nullptr; 28 | int _videoStreamIndex = -1; 29 | atomic _initialized = false; 30 | mutable recursive_mutex _ffmpegMutex; // recursive_mutex to allow multiple locks by the same thread 31 | 32 | bool AfterInitError(const string& message) 33 | { 34 | logger->error(message); 35 | CleanupFFmpeg(); 36 | return false; 37 | } 38 | 39 | bool InitializeFFmpeg() 40 | { 41 | lock_guard lock(_ffmpegMutex); 42 | 43 | if (_initialized) 44 | return true; 45 | 46 | // Open the input file 47 | if (avformat_open_input(&_formatCtx, _filePath.c_str(), nullptr, nullptr) != 0) 48 | return AfterInitError("Failed to open video file: " + _filePath); 49 | 50 | // Retrieve stream information 51 | if (avformat_find_stream_info(_formatCtx, nullptr) < 0) 52 | return AfterInitError("Failed to retrieve stream info."); 53 | 54 | // Find the video stream 55 | for (unsigned i = 0; i < _formatCtx->nb_streams; i++) 56 | { 57 | if (_formatCtx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) 58 | { 59 | _videoStreamIndex = i; 60 | break; 61 | } 62 | } 63 | 64 | if (_videoStreamIndex == -1) 65 | return AfterInitError("No video stream found."); 66 | 67 | // Find the decoder for the video stream 68 | const AVCodec* codec = avcodec_find_decoder(_formatCtx->streams[_videoStreamIndex]->codecpar->codec_id); 69 | if (!codec) 70 | return AfterInitError("Codec not found."); 71 | 72 | // Allocate the codec context 73 | _codecCtx = avcodec_alloc_context3(codec); 74 | if (!_codecCtx) 75 | return AfterInitError("Failed to allocate codec context."); 76 | 77 | if (avcodec_parameters_to_context(_codecCtx, _formatCtx->streams[_videoStreamIndex]->codecpar) < 0) 78 | return AfterInitError("Failed to copy codec parameters to context."); 79 | 80 | // Open the codec 81 | if (avcodec_open2(_codecCtx, codec, nullptr) < 0) 82 | return AfterInitError("Failed to open codec."); 83 | 84 | // Allocate frames and packets 85 | _frame = av_frame_alloc(); 86 | _packet = av_packet_alloc(); 87 | 88 | _initialized = true; 89 | return true; 90 | } 91 | 92 | void CleanupFFmpeg() 93 | { 94 | lock_guard lock(_ffmpegMutex); 95 | 96 | if (_swsCtx) 97 | sws_freeContext(_swsCtx); 98 | 99 | if (_frame) 100 | av_frame_free(&_frame); 101 | 102 | if (_packet) 103 | av_packet_free(&_packet); 104 | 105 | if (_codecCtx) 106 | avcodec_free_context(&_codecCtx); 107 | 108 | if (_formatCtx) 109 | avformat_close_input(&_formatCtx); 110 | 111 | _initialized = false; 112 | } 113 | 114 | public: 115 | 116 | MP4PlaybackEffect(const string& name, const string& filePath) 117 | : LEDEffectBase(name), _filePath(filePath) {} 118 | 119 | ~MP4PlaybackEffect() 120 | { 121 | CleanupFFmpeg(); 122 | } 123 | 124 | void Start(ICanvas& canvas) override 125 | { 126 | if (!InitializeFFmpeg()) 127 | { 128 | logger->error("Failed to initialize FFmpeg for MP4 playback."); 129 | return; 130 | } 131 | 132 | auto& graphics = canvas.Graphics(); 133 | int canvasWidth = graphics.Width(); 134 | int canvasHeight = graphics.Height(); 135 | 136 | if (_swsCtx) 137 | sws_freeContext(_swsCtx); 138 | 139 | _swsCtx = sws_getContext( 140 | _codecCtx->width, _codecCtx->height, _codecCtx->pix_fmt, 141 | canvasWidth, canvasHeight, AV_PIX_FMT_RGB24, 142 | SWS_BILINEAR, nullptr, nullptr, nullptr); 143 | } 144 | 145 | void Update(ICanvas& canvas, milliseconds deltaTime) override 146 | { 147 | if (!_initialized) 148 | return; 149 | 150 | while (av_read_frame(_formatCtx, _packet) >= 0) 151 | { 152 | if (_packet->stream_index == _videoStreamIndex) 153 | { 154 | if (avcodec_send_packet(_codecCtx, _packet) == 0) 155 | { 156 | while (avcodec_receive_frame(_codecCtx, _frame) == 0) 157 | { 158 | auto& graphics = canvas.Graphics(); 159 | int canvasWidth = graphics.Width(); 160 | int canvasHeight = graphics.Height(); 161 | 162 | // Buffer for RGB24 pixels 163 | vector rgbBuffer(canvasWidth * canvasHeight * 3); 164 | 165 | uint8_t* dstData[1] = { rgbBuffer.data() }; 166 | int dstLinesize[1] = { 3 * canvasWidth }; 167 | 168 | sws_scale(_swsCtx, _frame->data, _frame->linesize, 0, _codecCtx->height, dstData, dstLinesize); 169 | 170 | // Render to canvas 171 | for (int y = 0; y < canvasHeight; ++y) 172 | { 173 | for (int x = 0; x < canvasWidth; ++x) 174 | { 175 | int idx = (y * canvasWidth + x) * 3; 176 | graphics.SetPixel(x, y, CRGB(rgbBuffer[idx], rgbBuffer[idx + 1], rgbBuffer[idx + 2])); 177 | } 178 | } 179 | 180 | av_packet_unref(_packet); 181 | return; // Process one frame per update 182 | } 183 | } 184 | } 185 | av_packet_unref(_packet); 186 | } 187 | 188 | // Loop the video by restarting 189 | av_seek_frame(_formatCtx, _videoStreamIndex, 0, AVSEEK_FLAG_BACKWARD); 190 | } 191 | 192 | friend inline void to_json(nlohmann::json& j, const MP4PlaybackEffect & effect); 193 | friend inline void from_json(const nlohmann::json& j, shared_ptr& effect); 194 | }; 195 | 196 | inline void to_json(nlohmann::json& j, const MP4PlaybackEffect & effect) 197 | { 198 | j = { 199 | {"name", effect.Name()}, 200 | {"filePath", effect._filePath} 201 | }; 202 | } 203 | 204 | inline void from_json(const nlohmann::json& j, shared_ptr& effect) 205 | { 206 | effect = make_shared( 207 | j.at("name").get(), 208 | j.at("filePath").get() 209 | ); 210 | } -------------------------------------------------------------------------------- /monitor/monitor.h: -------------------------------------------------------------------------------- 1 | #define _XOPEN_SOURCE_EXTENDED 1 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include <../json.hpp> 12 | 13 | using json = nlohmann::json; 14 | using namespace std; 15 | using namespace std::chrono; 16 | 17 | // Updated columns with new information 18 | extern const std::vector> COLUMNS; 19 | 20 | // Screen layout constants 21 | constexpr int HEADER_HEIGHT = 3; 22 | constexpr int FOOTER_HEIGHT = 2; 23 | 24 | 25 | extern size_t WriteCallback(void *contents, size_t size, size_t nmemb, void *userp); 26 | 27 | inline std::string buildMeter(double value, double threshold, int width = 21) 28 | { 29 | // Width should be odd to have a center point 30 | if (width % 2 == 0) 31 | width++; 32 | 33 | // Normalize value to -1..1 range based on threshold 34 | double normalized = std::min(1.0, std::max(-1.0, value / threshold)); 35 | 36 | // Calculate position (center = width/2) 37 | int center = width / 2; 38 | int pos = center + static_cast(normalized * center); 39 | 40 | std::string meter; 41 | for (int i = 0; i < width; i++) 42 | { 43 | meter += (i == pos) ? "|" : "-"; 44 | } 45 | return meter; 46 | } 47 | 48 | inline std::wstring buildProgressBar(double value, double maximum, int width = 10) 49 | { 50 | static const std::array blocks = { 51 | L' ', L'▏', L'▎', L'▍', L'▌', L'▋', L'▊', L'▉', L'█' 52 | }; 53 | 54 | double percentage = std::min(1.0, std::max(0.0, value / maximum)); 55 | double exactBlocks = percentage * width; 56 | int fullBlocks = static_cast(exactBlocks); 57 | int remainder = static_cast((exactBlocks - fullBlocks) * 8); 58 | 59 | std::wstring bar; 60 | bar.reserve(width); // wstring::reserve() reserves characters, not bytes 61 | bar.append(fullBlocks, blocks[8]); 62 | 63 | if (fullBlocks < width) { 64 | bar += blocks[remainder]; 65 | bar.append(width - fullBlocks - 1, blocks[0]); 66 | } 67 | 68 | return bar; 69 | } 70 | 71 | 72 | 73 | // Helper to make HTTP requests 74 | inline std::string httpGet(const std::string &url) 75 | { 76 | CURL *curl = curl_easy_init(); 77 | std::string response; 78 | 79 | if (curl) 80 | { 81 | curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); 82 | curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); 83 | curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); 84 | 85 | CURLcode res = curl_easy_perform(curl); 86 | if (res != CURLE_OK) 87 | response = "Error: " + std::string(curl_easy_strerror(res)); 88 | 89 | curl_easy_cleanup(curl); 90 | } 91 | return response; 92 | } 93 | 94 | // Format helpers 95 | inline std::string formatBytes(double bytes) 96 | { 97 | const char *units[] = {"B/s", "KB/s", "MB/s", "GB/s"}; 98 | int unit = 0; 99 | while (bytes >= 1024 && unit < 3) 100 | { 101 | bytes /= 1024; 102 | unit++; 103 | } 104 | std::ostringstream oss; 105 | oss << std::fixed << std::setprecision(0) << bytes << units[unit]; // Removed decimal places 106 | return oss.str(); 107 | } 108 | 109 | inline std::string formatWifiSignal(double signal) 110 | { 111 | std::ostringstream oss; 112 | if (signal >= 100) 113 | oss << " LAN"; 114 | else 115 | oss << ((int)signal) << "dBm"; // Added abs() and removed decimal places 116 | return oss.str(); 117 | } 118 | 119 | inline std::string formatTimeDelta(double delta) 120 | { 121 | std::string meter = buildMeter(delta, 3.0, 5); 122 | std::ostringstream oss; 123 | if (abs(delta) > 100) 124 | oss << "Unset"; 125 | else 126 | oss << std::fixed << std::setprecision(1) << delta << "s " << meter; 127 | return oss.str(); 128 | } 129 | 130 | class Monitor 131 | { 132 | WINDOW *headerWin; 133 | WINDOW *contentWin; 134 | WINDOW *footerWin; 135 | int contentHeight; 136 | int scrollOffset = 0; 137 | std::string baseUrl; 138 | double _fps; 139 | 140 | public: 141 | Monitor(const std::string& hostname = "localhost", int port = 7777, double fps = 10.0) 142 | : baseUrl(std::string("http://") + hostname + ":" + std::to_string(port)), 143 | _fps(fps) 144 | { 145 | setlocale(LC_ALL, ""); 146 | initscr(); 147 | start_color(); 148 | cbreak(); 149 | noecho(); 150 | keypad(stdscr, TRUE); 151 | curs_set(0); 152 | refresh(); 153 | 154 | init_pair(1, COLOR_GREEN, COLOR_BLACK); // Connected 155 | init_pair(2, COLOR_RED, COLOR_BLACK); // Disconnected 156 | init_pair(3, COLOR_YELLOW, COLOR_BLACK); // Headers 157 | init_pair(4, COLOR_CYAN, COLOR_BLACK); // Highlights 158 | init_pair(5, COLOR_RED, COLOR_BLACK); // High delta 159 | init_pair(6, COLOR_YELLOW, COLOR_BLACK); // Warning level 160 | 161 | int maxY, maxX; 162 | getmaxyx(stdscr, maxY, maxX); 163 | contentHeight = maxY - HEADER_HEIGHT - FOOTER_HEIGHT; 164 | 165 | headerWin = newwin(HEADER_HEIGHT, maxX, 0, 0); 166 | contentWin = newwin(contentHeight, maxX, HEADER_HEIGHT, 0); 167 | footerWin = newwin(FOOTER_HEIGHT, maxX, maxY - FOOTER_HEIGHT, 0); 168 | 169 | scrollok(contentWin, TRUE); 170 | keypad(contentWin, TRUE); 171 | } 172 | 173 | ~Monitor() 174 | { 175 | delwin(headerWin); 176 | delwin(contentWin); 177 | delwin(footerWin); 178 | endwin(); 179 | } 180 | 181 | void drawHeader() 182 | { 183 | werase(headerWin); 184 | box(headerWin, 0, 0); 185 | mvwaddstr(headerWin, 0, 2, " NightDriver Monitor "); 186 | 187 | int x = 1; 188 | wattron(headerWin, COLOR_PAIR(3)); 189 | for (const auto &col : COLUMNS) 190 | { 191 | mvwprintw(headerWin, 1, x, "%-*s", col.second, col.first.c_str()); 192 | x += col.second + 1; 193 | } 194 | wattroff(headerWin, COLOR_PAIR(3)); 195 | 196 | wrefresh(headerWin); 197 | } 198 | 199 | void drawContent(); 200 | 201 | void drawFooter() 202 | { 203 | werase(footerWin); 204 | box(footerWin, 0, 0); 205 | mvwaddstr(footerWin, 0, 2, " Controls "); 206 | mvwaddwstr(footerWin, 1, 2, L"Q:Quit ↑/↓:Scroll R:Refresh"); 207 | wrefresh(footerWin); 208 | } 209 | 210 | void run() 211 | { 212 | // Make content window non-blocking 213 | nodelay(contentWin, TRUE); 214 | 215 | bool running = true; 216 | auto lastUpdate = steady_clock::now(); 217 | 218 | while (running) 219 | { 220 | auto now = steady_clock::now(); 221 | auto elapsed = duration_cast(now - lastUpdate); 222 | 223 | // Update display every 100ms 224 | if (elapsed.count() >= 100) { 225 | drawHeader(); 226 | drawContent(); 227 | drawFooter(); 228 | lastUpdate = now; 229 | } 230 | 231 | int ch = wgetch(contentWin); 232 | if (ch != ERR) { // Only process if we actually got input 233 | switch (ch) 234 | { 235 | case 'q': 236 | case 'Q': 237 | running = false; 238 | break; 239 | case KEY_UP: 240 | if (scrollOffset > 0) 241 | scrollOffset--; 242 | break; 243 | case KEY_DOWN: 244 | scrollOffset++; 245 | break; 246 | case 'r': 247 | case 'R': 248 | lastUpdate = steady_clock::time_point::min(); // Force immediate refresh 249 | break; 250 | } 251 | } 252 | 253 | // Small sleep to prevent CPU spinning 254 | std::this_thread::sleep_for(milliseconds(_fps > 0 ? static_cast(1000.0 / _fps) : 100)); 255 | } 256 | } 257 | }; -------------------------------------------------------------------------------- /basegraphics.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | using namespace std; 3 | 4 | // BaseGraphics 5 | // 6 | // A class that can do all the basic drawing functions (pixel, line, circle, etc) of the ILEDGraphics 7 | // interface to its own internal buffer of pixels. It manipulates the buffer only through SetPixel 8 | 9 | #include 10 | #include 11 | 12 | #include "pixeltypes.h" // Assuming this defines the CRGB structure 13 | #include "interfaces.h" 14 | 15 | class BaseGraphics : public ILEDGraphics 16 | { 17 | protected: 18 | uint32_t _width; 19 | uint32_t _height; 20 | vector _pixels; 21 | 22 | virtual inline __attribute__((always_inline)) uint32_t _index(uint32_t x, uint32_t y) const 23 | { 24 | return y * _width + x; 25 | } 26 | 27 | constexpr inline bool _isInBounds(uint32_t x, uint32_t y) const 28 | { 29 | return (x < _width && y < _height); 30 | } 31 | 32 | public: 33 | explicit BaseGraphics(uint32_t width, uint32_t height) 34 | { 35 | if (width == 0 || height == 0) 36 | throw invalid_argument("Width and height must be greater than 0"); 37 | if (static_cast(width) * height > numeric_limits::max() / sizeof(CRGB)) 38 | throw overflow_error("Requested dimensions too large"); 39 | 40 | _width = width; 41 | _height = height; 42 | _pixels.resize(width * height); 43 | } 44 | 45 | // ICanvas methods 46 | uint32_t Width() const override { return _width; } 47 | uint32_t Height() const override { return _height; } 48 | 49 | void FadePixelToBlackBy(uint32_t x, uint32_t y, float amount) override 50 | { 51 | if (_isInBounds(x, y)) 52 | { 53 | CRGB& pixel = _pixels[_index(x, y)]; 54 | pixel.nscale8(amount * 255); 55 | } 56 | } 57 | 58 | const vector& GetPixels() const override 59 | { 60 | return _pixels; 61 | } 62 | 63 | void SetPixel(uint32_t x, uint32_t y, const CRGB& color) override 64 | { 65 | if (_isInBounds(x, y)) 66 | _pixels[_index(x, y)] = color; 67 | } 68 | 69 | CRGB GetPixel(uint32_t x, uint32_t y) const override 70 | { 71 | if (_isInBounds(x, y)) 72 | return _pixels[_index(x, y)]; 73 | return CRGB(0, 0, 0); // Default to black for out-of-bounds 74 | } 75 | 76 | void Clear(const CRGB& color = CRGB::Black) override 77 | { 78 | FillRectangle(0, 0, _width, _height, color); 79 | } 80 | 81 | void FillRectangle(uint32_t x, uint32_t y, uint32_t width, uint32_t height, const CRGB& color) override 82 | { 83 | if (x >= _width || y >= _height) return; 84 | width = min(width, _width - x); 85 | height = min(height, _height - y); 86 | 87 | if (color == CRGB::Black) { 88 | for (uint32_t j = y; j < y + height; ++j) 89 | memset(&_pixels[_index(x, j)], 0, width * sizeof(CRGB)); 90 | } else { 91 | for (uint32_t j = y; j < y + height; ++j) 92 | fill(&_pixels[_index(x, j)], &_pixels[_index(x + width, j)], color); 93 | } 94 | } 95 | 96 | void DrawLine(uint32_t x1, uint32_t y1, uint32_t x2, uint32_t y2, const CRGB& color) override 97 | { 98 | int32_t dx = abs((int32_t)x2 - (int32_t)x1), dy = abs((int32_t)y2 - (int32_t)y1); 99 | int32_t sx = (x1 < x2) ? 1 : -1, sy = (y1 < y2) ? 1 : -1; 100 | int32_t err = dx - dy; 101 | 102 | while (true) 103 | { 104 | SetPixel(x1, y1, color); 105 | if (x1 == x2 && y1 == y2) break; 106 | int e2 = 2 * err; 107 | if (e2 > -dy) 108 | { 109 | err -= dy; 110 | x1 += sx; 111 | } 112 | if (e2 < dx) 113 | { 114 | err += dx; 115 | y1 += sy; 116 | } 117 | } 118 | } 119 | 120 | void DrawCircle(uint32_t x, uint32_t y, uint32_t radius, const CRGB& color) override 121 | { 122 | uint32_t cx = 0, cy = radius; 123 | int32_t d = 1 - radius; 124 | 125 | while (cy >= cx) 126 | { 127 | SetPixel(x + cx, y + cy, color); 128 | SetPixel(x - cx, y + cy, color); 129 | SetPixel(x + cx, y - cy, color); 130 | SetPixel(x - cx, y - cy, color); 131 | SetPixel(x + cy, y + cx, color); 132 | SetPixel(x - cy, y + cx, color); 133 | SetPixel(x + cy, y - cx, color); 134 | SetPixel(x - cy, y - cx, color); 135 | 136 | ++cx; 137 | if (d < 0) 138 | { 139 | d += 2 * cx + 1; 140 | } 141 | else 142 | { 143 | --cy; 144 | d += 2 * (cx - cy) + 1; 145 | } 146 | } 147 | } 148 | 149 | void FillCircle(uint32_t x, uint32_t y, uint32_t radius, const CRGB& color) override 150 | { 151 | // Fill within the bounding box, but only those pixels within radius of the center 152 | 153 | for (uint32_t cy = -radius; cy <= radius; ++cy) 154 | for (uint32_t cx = -radius; cx <= radius; ++cx) 155 | if (cx * cx + cy * cy <= radius * radius) 156 | SetPixel(x + cx, y + cy, color); 157 | } 158 | 159 | void DrawRectangle(uint32_t x, uint32_t y, uint32_t width, uint32_t height, const CRGB& color) override 160 | { 161 | DrawLine(x, y, x + width - 1, y, color); // Top 162 | DrawLine(x, y, x, y + height - 1, color); // Left 163 | DrawLine(x + width - 1, y, x + width - 1, y + height - 1, color); // Right 164 | DrawLine(x, y + height - 1, x + width - 1, y + height - 1, color); // Bottom 165 | } 166 | 167 | void FadeFrameBy(uint8_t dimAmount) override 168 | { 169 | const uint8_t scale = 255 - dimAmount; 170 | for_each(_pixels.begin(), _pixels.end(), 171 | [scale](CRGB& p) { 172 | p.r = (p.r * scale) >> 8; 173 | p.g = (p.g * scale) >> 8; 174 | p.b = (p.b * scale) >> 8; 175 | }); 176 | } 177 | 178 | void SetPixelsF(float fPos, float count, CRGB c, bool bMerge = false) override 179 | { 180 | // Early exit for empty ranges or out-of-bounds start positions 181 | if (count <= 0 || fPos >= _pixels.size() || fPos + count <= 0) 182 | return; 183 | 184 | // Pre-calculate common values 185 | const size_t arraySize = _pixels.size(); 186 | const size_t startIdx = max(0UL, static_cast(floor(fPos))); 187 | const size_t endIdx = min(arraySize, static_cast(ceil(fPos + count))); 188 | const float frac1 = fPos - floor(fPos); 189 | const uint8_t fade1 = static_cast((max(frac1, 1.0f - count)) * 255); 190 | float remainingCount = count - (1.0f - frac1); 191 | const float lastFrac = remainingCount - floor(remainingCount); 192 | const uint8_t fade2 = static_cast((1.0f - lastFrac) * 255); 193 | 194 | if (!bMerge) 195 | { 196 | // Non-merging implementation 197 | 198 | if (startIdx < arraySize) 199 | { 200 | CRGB c1 = c; 201 | c1.fadeToBlackBy(fade1); 202 | _pixels[startIdx] = c1; 203 | } 204 | 205 | // Middle pixels - use pointer arithmetic for speed 206 | CRGB *pixel = &_pixels[startIdx + 1]; 207 | const CRGB *end = &_pixels[endIdx - 1]; 208 | while (pixel < end) 209 | *pixel++ = c; 210 | 211 | // Last pixel if needed 212 | if (lastFrac > 0 && endIdx - 1 < arraySize) 213 | { 214 | CRGB c2 = c; 215 | c2.fadeToBlackBy(fade2); 216 | _pixels[endIdx - 1] = c2; 217 | } 218 | } 219 | else 220 | { 221 | // Merging implementation 222 | // First pixel 223 | if (startIdx < arraySize) 224 | { 225 | CRGB c1 = c; 226 | c1.fadeToBlackBy(fade1); 227 | _pixels[startIdx] += c1; 228 | } 229 | 230 | // Middle pixels - use pointer arithmetic for speed 231 | CRGB *pixel = &_pixels[startIdx + 1]; 232 | const CRGB *end = &_pixels[endIdx - 1]; 233 | while (pixel < end) 234 | *pixel++ += c; 235 | 236 | // Last pixel if needed 237 | if (lastFrac > 0 && endIdx - 1 < arraySize) 238 | { 239 | CRGB c2 = c; 240 | c2.fadeToBlackBy(fade2); 241 | _pixels[endIdx - 1] += c2; 242 | } 243 | } 244 | } 245 | }; 246 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NightDriver Server - Overview 2 | 3 | ## What it is 4 | 5 | It delivers WiFI packets of color data to ESP32 chips that show that color data on LED strips or matrices connected to them. Here's an example: 6 | 7 | My house has a long run of 8000 LEDs, like Christmas lights. Since each ESP32 running NightDriverStrip can only refresh about 1000 LEDs at 30fps, I have broken the run into 8 individual ESP32s, each connected to 1000 LEDs. They could each run the same effect, but it would be hard to sync, and effects could not span across strips. 8 | 9 | NightDriverServer instead composes the drawing on a larger Canvas object, in this case 8000 pixels wide. Each ESP32 is represented as an LEDFeature object, 1000 pixels wide. The first is at offset 0 in the canvas, the next at 1000, then 2000, and so on. 10 | 11 | 30 times per second, NDSCPP renders the scene to the 8000-pixel canvas. Worker threads then split that up into eight separate chunks of 1000 pixels and send each as a packet to the appropriate LED strip, and they all act in concert as one long strip. 12 | 13 | Each NightDriverStrip has a socket available on port 49152 to receive frames of color data, normally at up to 60fps. The strips buffer a few seconds' worth of frames internally and display them perfectly synced by SNTP time, so an effect frame that is supposed to appear all at once across the strips still does so despite delays in Wi-Fi and so on. 14 | 15 | It allows you to build a much larger scene from many little ESP32 installs and control it via WiFi. 16 | 17 | Imagine you had a restaurant with 10 tables. Each table has an LED candle with 16 LEDs. There are two ways to configure this. If you want each candle to do the same thing, you would make a Canvas that is 16 pixels long and then place 10 LEDFeatures in it, all at offset 0. 18 | 19 | If you wanted each candle to render differently, you could make a Canvas that is 160 pixels long and offset the LEDFeatures at 0, 10, 20, 30, and so on. Then your drawing effect could draw to individual candles. 20 | 21 | Alternatively, you could also define 10 Canvases of 16 pixels, and each Canvas has one LEDFeature of 10 pixels each, and each candle in that case can have its own effect. 22 | 23 | ----- 24 | 25 | ![Class diagram](https://github.com/user-attachments/assets/820e37e6-8e1b-4b4b-ab10-6a2f27a34d7f) 26 | 27 | NightDriver Server is a C++ project designed to manage LED displays by organizing them into canvases, applying effects, and transmitting data to remote LED controllers. The code is modular, and leverages interface to separate concerns, making it extensible and straightforward to maintain. 28 | 29 | Key concepts for programmers: 30 | 31 | - **Canvas**: Represents a 2D grid of LEDs where drawing operations and effects are applied. A canvas can contain multiple LED features. 32 | - **LEDFeature**: A specific section of the canvas, associated with a remote LED controller. Each feature defines properties such as dimensions, offsets, and communication parameters. 33 | - **SocketChannel**: Manages the network connection to a remote LED controller, transmitting the pixel data for a feature. 34 | - **Effects**: Visual animations or patterns applied to a canvas, implemented using the `ILEDEffect` interface. 35 | - **Utilities**: Contains helper functions for tasks like byte manipulation, data compression, and color conversion. 36 | 37 | ### Getting Started 38 | 39 | 1. **Define Features**: Create LED features, specifying dimensions, offsets, and the associated socket connections. 40 | 2. **Configure a Canvas**: Combine one or more features into a canvas to organize the drawing surface. 41 | 3. **Apply Effects**: Use the `EffectsManager` to apply visual effects to the canvas. 42 | 4. **Transmit Data**: Socket channels handle sending the rendered canvas data to the remote LED controllers. 43 | 44 | The project includes a REST API via the `WebServer` class to control and configure canvases dynamically. For detailed interface descriptions and class diagrams, refer to the sections below. 45 | 46 | This repository is designed for programmers familiar with modern C++ (C++20 and later) and concepts like interfaces, threading, and network communication. Jump into the code, and start by exploring the interfaces and their implementing classes to understand the system's structure. 47 | 48 | This project uses clang++ and make, and is dependent on the libraries for asio (because Crow uses it), pthreads, z, avformat, avcodec, avutil, swscale, swresample and spdlog. For the "ledmon" monitor application in the monitor directory, the ncurses and curl libraries are required. 49 | 50 | On the Mac, you'll have to install asio, ffmpeg, ncurses and spdlog using Homebrew; the other required libraries are usually already installed: 51 | 52 | ```shell 53 | brew install asio ffmpeg ncurses spdlog 54 | ``` 55 | 56 | On Ubuntu, dev versions for all libraries except ncurses and pthreads have to be installed (ncurses already gets pulled in by llvm/clang): 57 | 58 | ```shell 59 | sudo apt install libasio-dev zlib1g-dev libavformat-dev libavcodec-dev libavutil-dev libswscale-dev libswresample-dev libcurl4-gnutls-dev libspdlog-dev 60 | ``` 61 | 62 | ### Using the test suite 63 | 64 | This project comes with a number of API tests in the `tests` directory, that are implemented using GoogleTest and C++ Requests (cpr). 65 | 66 | On the Mac, you can install the required packages using: 67 | 68 | ```shell 69 | brew install googletest cpr 70 | ``` 71 | 72 | For Ubuntu, unfortunately a distribution package for cpr is not available at the time of writing. The commands to build and install are as follows: 73 | 74 | ```shell 75 | sudo apt update 76 | sudo apt install libgtest-dev cmake 77 | git clone https://github.com/libcpr/cpr.git 78 | cd cpr && mkdir build && cd build 79 | cmake .. -DCPR_USE_SYSTEM_CURL=ON -DBUILD_SHARED_LIBS=ON -DCMAKE_CXX_STANDARD=20 80 | cmake --build . --parallel 81 | sudo cmake --install . 82 | cd ../.. 83 | ``` 84 | 85 | The `cpr` directory has been included in .gitignore, so these steps will not pollute your git branch. 86 | 87 | After installing prerequisites, the tests can be built using `make -C tests` and executed by running `LD_LIBRARY_PATH=${LD_LIBRARY_PATH}:/usr/local/lib ./tests/tests`. 88 | 89 | ## Interfaces Overview 90 | 91 | ### ISocketChannel 92 | 93 | Defines a communication protocol for managing socket connections and sending data to a server. 94 | Provides methods for enqueuing frames, retrieving connection status, and tracking performance metrics. 95 | 96 | ### ICanvas 97 | 98 | Represents a 2D drawing surface that manages LED features and provides rendering capabilities. 99 | Can contain multiple `ILEDFeature` instances, with features mapped to specific regions of the canvas. 100 | 101 | ### ILEDGraphics 102 | 103 | Provides drawing primitives for 1D and 2D LED features, such as lines, rectangles, gradients, and circles. 104 | Exposes APIs for pixel manipulation and advanced rendering techniques. 105 | 106 | ### ILEDEffect 107 | 108 | Defines lifecycle hooks (`Start` and `Update`) for applying visual effects on LED canvases. 109 | Encourages modular effect design, allowing dynamic assignment and switching of effects. 110 | 111 | ### IEffectManager 112 | 113 | Each canvas has an EffectsManager that does the actual drawing of effects to it, and that EffectsManager 114 | manages a set of ILEDEffect objects. 115 | 116 | ### ILEDFeature 117 | 118 | Represents a 2D collection of LEDs with positioning, rendering, and configuration capabilities. 119 | Provides APIs for interacting with its parent canvas and retrieving its assigned color data. 120 | 121 | ## Classes Overview 122 | 123 | ### SocketChannel 124 | 125 | Implements `ISocketChannel` to manage socket connections and transmit LED frame data. 126 | Includes support for data compression and efficient queuing of frames. 127 | Tracks connection state and throughput metrics. 128 | 129 | ### Canvas 130 | 131 | Implements `ICanvas` and `ILEDGraphics`, representing a 2D drawing surface with support for multiple LED features. 132 | Features advanced rendering capabilities, including drawing primitives, gradients, and solid fills. 133 | Serves as the primary interface for rendering effects to assigned LED features. 134 | 135 | ### LEDFeature 136 | 137 | Implements `ILEDFeature` to represent a logical set of LEDs within a canvas. 138 | Handles retrieving pixel data from its assigned region of the parent canvas for transmission over a socket. 139 | Includes attributes such as offset, dimensions, and channel assignment. 140 | 141 | ### EffectsManager 142 | 143 | Manages a collection of effects and controls the currently active effect. 144 | Applies the active effect to an `ICanvas` instance during rendering. 145 | Provides utilities for switching between effects (`NextEffect` and `PreviousEffect`). 146 | 147 | ### WebServer 148 | 149 | Hosts a REST API for interacting with and controlling LED canvases and their features. 150 | Supports dynamic management of features, canvases, and effects via HTTP endpoints. 151 | 152 | ### Utilities 153 | 154 | Provides static helper functions for byte manipulation, color conversion, and data combination tasks. 155 | Includes compression utilities (using zlib), endian-safe conversions, and drawing utilities for LED data. 156 | 157 | ### CRGB 158 | 159 | Represents a 24-bit RGB color, including utility methods for HSV-to-RGB conversion and brightness adjustment. 160 | Forms the base unit of color manipulation across the system. 161 | -------------------------------------------------------------------------------- /utilities.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | using namespace std; 3 | 4 | // Utilities 5 | // 6 | // This class provides a number of utility functions that are used by the various 7 | // classes in the project. Most of them relate to managing the data that is sent 8 | // to the ESP32. The ESP32 expects a specific format for the data that is sent to 9 | // it, and this class provides functions to convert the data into that format. 10 | 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include 18 | #include 19 | #include "pixeltypes.h" 20 | 21 | class Utilities 22 | { 23 | public: 24 | 25 | static constexpr float constexpr_sqrt(float x, float epsilon = 1e-5f) 26 | { 27 | float guess = x / 2.0f; 28 | float result = (guess + x / guess) / 2.0f; 29 | 30 | while ((result - guess) > epsilon || (result - guess) < -epsilon) 31 | { 32 | guess = result; 33 | result = (guess + x / guess) / 2.0f; 34 | } 35 | 36 | return result; 37 | } 38 | 39 | static double RandomDouble(double min, double max) 40 | { 41 | static mt19937 rng(random_device{}()); 42 | uniform_real_distribution dist(min, max); 43 | return dist(rng); 44 | } 45 | 46 | static int RandomInt(int min, int max) 47 | { 48 | static mt19937 rng(random_device{}()); 49 | uniform_int_distribution dist(min, max); 50 | return dist(rng); 51 | } 52 | 53 | static vector ConvertPixelsToByteArray(const vector &pixels, bool reversed, bool redGreenSwap) 54 | { 55 | vector byteArray(pixels.size() * 3); // Allocate space upfront 56 | 57 | // This code makes all kinds of assumptions that CRGB is three RGB bytes, so let's assert that fact 58 | static_assert(sizeof(CRGB) == 3); 59 | 60 | size_t index = 0; 61 | 62 | if (reversed) 63 | { 64 | for (auto it = pixels.rbegin(); it != pixels.rend(); ++it) 65 | { 66 | if (redGreenSwap) 67 | { 68 | byteArray[index++] = it->g; 69 | byteArray[index++] = it->r; 70 | byteArray[index++] = it->b; 71 | } 72 | else 73 | { 74 | byteArray[index++] = it->r; 75 | byteArray[index++] = it->g; 76 | byteArray[index++] = it->b; 77 | } 78 | } 79 | } 80 | else 81 | { 82 | for (const auto &pixel : pixels) 83 | { 84 | if (redGreenSwap) 85 | { 86 | byteArray[index++] = pixel.g; 87 | byteArray[index++] = pixel.r; 88 | byteArray[index++] = pixel.b; 89 | } 90 | else 91 | { 92 | byteArray[index++] = pixel.r; 93 | byteArray[index++] = pixel.g; 94 | byteArray[index++] = pixel.b; 95 | } 96 | } 97 | } 98 | 99 | return byteArray; 100 | } 101 | 102 | // The following XXXXToBytes functions produce a bytestream in the little-endian 103 | // that the original ESP32 code expects 104 | 105 | // Converts a uint16_t to an array of bytes in little-endian order 106 | static constexpr array WORDToBytes(uint16_t value) 107 | { 108 | if constexpr (endian::native == endian::little) 109 | { 110 | return { 111 | static_cast(value & 0xFF), 112 | static_cast((value >> 8) & 0xFF)}; 113 | } 114 | else 115 | { 116 | return { 117 | static_cast(__builtin_bswap16(value) & 0xFF), 118 | static_cast((__builtin_bswap16(value) >> 8) & 0xFF)}; 119 | } 120 | if constexpr (endian::native == endian::little) 121 | { 122 | return { 123 | static_cast(value & 0xFF), 124 | static_cast((value >> 8) & 0xFF)}; 125 | } 126 | else 127 | { 128 | return { 129 | static_cast(__builtin_bswap16(value) & 0xFF), 130 | static_cast((__builtin_bswap16(value) >> 8) & 0xFF)}; 131 | } 132 | } 133 | 134 | // Converts a uint32_t to an array of bytes in little-endian order 135 | static constexpr array DWORDToBytes(uint32_t value) 136 | { 137 | if constexpr (endian::native == endian::little) 138 | { 139 | return { 140 | static_cast(value & 0xFF), 141 | static_cast((value >> 8) & 0xFF), 142 | static_cast((value >> 16) & 0xFF), 143 | static_cast((value >> 24) & 0xFF)}; 144 | } 145 | else 146 | { 147 | uint32_t swapped = __builtin_bswap32(value); 148 | return { 149 | static_cast(swapped & 0xFF), 150 | static_cast((swapped >> 8) & 0xFF), 151 | static_cast((swapped >> 16) & 0xFF), 152 | static_cast((swapped >> 24) & 0xFF)}; 153 | } 154 | } 155 | 156 | // Converts a uint64_t to an array of bytes in little-endian order 157 | static constexpr array ULONGToBytes(uint64_t value) 158 | { 159 | if constexpr (endian::native == endian::little) 160 | { 161 | return { 162 | static_cast(value & 0xFF), 163 | static_cast((value >> 8) & 0xFF), 164 | static_cast((value >> 16) & 0xFF), 165 | static_cast((value >> 24) & 0xFF), 166 | static_cast((value >> 32) & 0xFF), 167 | static_cast((value >> 40) & 0xFF), 168 | static_cast((value >> 48) & 0xFF), 169 | static_cast((value >> 56) & 0xFF)}; 170 | } 171 | else 172 | { 173 | uint64_t swapped = __builtin_bswap64(value); 174 | return { 175 | static_cast(swapped & 0xFF), 176 | static_cast((swapped >> 8) & 0xFF), 177 | static_cast((swapped >> 16) & 0xFF), 178 | static_cast((swapped >> 24) & 0xFF), 179 | static_cast((swapped >> 32) & 0xFF), 180 | static_cast((swapped >> 40) & 0xFF), 181 | static_cast((swapped >> 48) & 0xFF), 182 | static_cast((swapped >> 56) & 0xFF)}; 183 | } 184 | } 185 | 186 | // Combines multiple byte arrays into one. My masterpiece for the day :-) 187 | 188 | template 189 | static vector CombineByteArrays(Arrays &&...arrays) 190 | { 191 | vector combined; 192 | 193 | // Calculate the total size of the combined array using a fold expression 194 | size_t totalSize = (arrays.size() + ...); 195 | combined.reserve(totalSize); 196 | 197 | // Append each array to the combined vector using a comma fold expression 198 | (combined.insert( 199 | combined.end(), 200 | make_move_iterator(arrays.begin()), 201 | make_move_iterator(arrays.end())), 202 | ...); 203 | 204 | return combined; 205 | } 206 | 207 | static vector Compress(const vector &data) 208 | { 209 | // Allocate initial buffer size 210 | constexpr size_t bufferIncrement = 1024; 211 | vector compressedData(bufferIncrement); 212 | 213 | // Initialize zlib stream 214 | z_stream stream{}; 215 | stream.zalloc = Z_NULL; 216 | stream.zfree = Z_NULL; 217 | stream.opaque = Z_NULL; 218 | 219 | // Set input data 220 | stream.next_in = const_cast(data.data()); 221 | stream.avail_in = static_cast(data.size()); 222 | 223 | // Initialize deflate process with optimal compression level 224 | if (deflateInit(&stream, Z_BEST_SPEED) != Z_OK) 225 | { 226 | throw runtime_error("Failed to initialize zlib compression"); 227 | } 228 | 229 | // Compress the data 230 | int result; 231 | do 232 | { // Ensure the output buffer is large enough 233 | if (stream.total_out >= compressedData.size()) 234 | compressedData.resize(compressedData.size() + bufferIncrement); 235 | 236 | // Set the output buffer 237 | stream.next_out = compressedData.data() + stream.total_out; 238 | stream.avail_out = static_cast(compressedData.size() - stream.total_out); 239 | 240 | // Perform the compression 241 | result = deflate(&stream, Z_FINISH); 242 | if (result == Z_STREAM_ERROR) 243 | { 244 | deflateEnd(&stream); 245 | throw runtime_error("Error during zlib compression"); 246 | } 247 | } while (result != Z_STREAM_END); 248 | 249 | // Finalize and clean up 250 | deflateEnd(&stream); 251 | 252 | // Resize the vector to the actual size of the compressed data 253 | compressedData.resize(stream.total_out); 254 | 255 | return compressedData; 256 | } 257 | }; 258 | 259 | -------------------------------------------------------------------------------- /ledfeature.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | using namespace std; 3 | using namespace chrono; 4 | 5 | #include "json.hpp" 6 | 7 | 8 | // LEDFeature 9 | // 10 | // Represents one rectangular section of the canvas and is responsible for producing the 11 | // color data frames for that section of the canvas. The LEDFeature is associated with a 12 | // specific Canvas object, and it retrieves the pixel data from the Canvas to produce the 13 | // data frame. The LEDFeature is also responsible for producing the data frame in the 14 | // format that the ESP32 expects. 15 | 16 | #include "interfaces.h" 17 | #include "utilities.h" 18 | #include "socketchannel.h" 19 | 20 | class LEDFeature : public ILEDFeature 21 | { 22 | const ICanvas * _canvas = nullptr; // Associated canvas 23 | uint32_t _width; 24 | uint32_t _height; 25 | uint32_t _offsetX; 26 | uint32_t _offsetY; 27 | bool _reversed; 28 | uint8_t _channel; 29 | bool _redGreenSwap; 30 | uint32_t _clientBufferCount; 31 | shared_ptr _ptrSocketChannel; 32 | static atomic _nextId; 33 | uint32_t _id; 34 | 35 | public: 36 | LEDFeature(const string & hostName, 37 | const string & friendlyName, 38 | uint16_t port, 39 | uint32_t width, 40 | uint32_t height = 1, 41 | uint32_t offsetX = 0, 42 | uint32_t offsetY = 0, 43 | bool reversed = false, 44 | uint8_t channel = 0, 45 | bool redGreenSwap = false, 46 | uint32_t clientBufferCount = 8) 47 | : _width(width), 48 | _height(height), 49 | _offsetX(offsetX), 50 | _offsetY(offsetY), 51 | _reversed(reversed), 52 | _channel(channel), 53 | _redGreenSwap(redGreenSwap), 54 | _clientBufferCount(clientBufferCount), 55 | _id(_nextId++) 56 | { 57 | _ptrSocketChannel = make_shared(hostName, friendlyName, port); 58 | } 59 | 60 | uint32_t Id() const override 61 | { 62 | return _id; 63 | } 64 | 65 | // Accessor methods 66 | uint32_t Width() const override { return _width; } 67 | uint32_t Height() const override { return _height; } 68 | uint32_t OffsetX() const override { return _offsetX; } 69 | uint32_t OffsetY() const override { return _offsetY; } 70 | bool Reversed() const override { return _reversed; } 71 | uint8_t Channel() const override { return _channel; } 72 | bool RedGreenSwap() const override { return _redGreenSwap; } 73 | uint32_t ClientBufferCount() const override { return _clientBufferCount; } 74 | 75 | void SetCanvas(const ICanvas * canvas) override 76 | { 77 | if (_canvas) 78 | throw runtime_error("Canvas is already set for this LEDFeature."); 79 | 80 | _canvas = canvas; 81 | } 82 | 83 | double TimeOffset () const override 84 | { 85 | constexpr auto kBufferFillRatio = 0.80; 86 | return(_clientBufferCount * kBufferFillRatio) / _canvas->Effects().GetFPS(); 87 | } 88 | 89 | virtual shared_ptr Socket() override 90 | { 91 | return _ptrSocketChannel; 92 | } 93 | 94 | virtual const shared_ptr Socket() const override 95 | { 96 | return _ptrSocketChannel; 97 | } 98 | 99 | vector GetPixelData() const override 100 | { 101 | static_assert(sizeof(CRGB) == 3, "CRGB must be 3 bytes in size for this code to work."); 102 | 103 | if (!_canvas) 104 | throw runtime_error("LEDFeature must be associated with a canvas to retrieve pixel data."); 105 | 106 | const auto& graphics = _canvas->Graphics(); 107 | 108 | // Fast path for full canvas. We assume this is the default case and optimize for it by telling the compiler to expect it. 109 | if (__builtin_expect(_width == graphics.Width() && _height == graphics.Height() && _offsetX == 0 && _offsetY == 0, 1)) 110 | return Utilities::ConvertPixelsToByteArray(graphics.GetPixels(), _reversed, _redGreenSwap); 111 | 112 | // Pre-calculate the final buffer size (3 bytes per pixel) 113 | vector result(_width * _height * sizeof(CRGB)); 114 | 115 | // Direct byte manipulation instead of intermediate CRGB vector 116 | for (uint32_t y = 0; y < _height; ++y) 117 | { 118 | for (uint32_t x = 0; x < _width; ++x) 119 | { 120 | uint32_t canvasX = x + _offsetX; 121 | uint32_t canvasY = y + _offsetY; 122 | 123 | // Calculate output position directly in bytes 124 | uint32_t byteIndex = (y * _width + x) * sizeof(CRGB); 125 | 126 | if (canvasX < graphics.Width() && canvasY < graphics.Height()) 127 | { 128 | const CRGB& pixel = graphics.GetPixel(canvasX, canvasY); 129 | if (_redGreenSwap) 130 | { 131 | result[byteIndex] = pixel.g; 132 | result[byteIndex + 1] = pixel.r; 133 | result[byteIndex + 2] = pixel.b; 134 | } 135 | else 136 | { 137 | result[byteIndex] = pixel.r; 138 | result[byteIndex + 1] = pixel.g; 139 | result[byteIndex + 2] = pixel.b; 140 | } 141 | } 142 | else 143 | { 144 | // Magenta for out of bounds (0xFF, 0x00, 0xFF) 145 | result[byteIndex] = 0xFF; 146 | result[byteIndex + 1] = 0x00; 147 | result[byteIndex + 2] = 0xFF; 148 | } 149 | } 150 | } 151 | 152 | if (_reversed) 153 | { 154 | // In-place reversal of RGB groups 155 | const size_t numPixels = result.size() / 3; 156 | for (size_t i = 0; i < numPixels / 2; ++i) { 157 | size_t front = i * 3; 158 | size_t back = (numPixels - 1 - i) * 3; 159 | swap(result[front], result[back]); 160 | swap(result[front + 1], result[back + 1]); 161 | swap(result[front + 2], result[back + 2]); 162 | } 163 | } 164 | 165 | return result; 166 | } 167 | 168 | vector GetDataFrame() const override 169 | { 170 | // Calculate epoch time 171 | auto now = system_clock::now(); 172 | auto epoch = duration_cast(now.time_since_epoch()).count(); 173 | uint64_t seconds = epoch / 1'000'000 + TimeOffset(); 174 | uint64_t microseconds = epoch % 1'000'000; 175 | 176 | auto pixelData = GetPixelData(); 177 | 178 | return Utilities::CombineByteArrays(Utilities::WORDToBytes(3), 179 | Utilities::WORDToBytes(_channel), 180 | Utilities::DWORDToBytes(_width * _height), 181 | Utilities::ULONGToBytes(seconds), 182 | Utilities::ULONGToBytes(microseconds), 183 | std::move(pixelData)); 184 | } 185 | }; 186 | 187 | inline void to_json(nlohmann::json& j, const ILEDFeature & feature) 188 | { 189 | j = { 190 | {"id", feature.Id()}, 191 | {"hostName", feature.Socket()->HostName()}, 192 | {"friendlyName", feature.Socket()->FriendlyName()}, 193 | {"port", feature.Socket()->Port()}, 194 | {"width", feature.Width()}, 195 | {"height", feature.Height()}, 196 | {"offsetX", feature.OffsetX()}, 197 | {"offsetY", feature.OffsetY()}, 198 | {"reversed", feature.Reversed()}, 199 | {"channel", feature.Channel()}, 200 | {"redGreenSwap", feature.RedGreenSwap()}, 201 | {"clientBufferCount", feature.ClientBufferCount()}, 202 | {"timeOffset", feature.TimeOffset()}, 203 | {"bytesPerSecond", feature.Socket()->GetLastBytesPerSecond()}, 204 | {"isConnected", feature.Socket()->IsConnected()}, 205 | {"queueDepth", feature.Socket()->GetCurrentQueueDepth()}, 206 | {"queueMaxSize", feature.Socket()->GetQueueMaxSize()}, 207 | {"reconnectCount", feature.Socket()->GetReconnectCount()} 208 | }; 209 | 210 | const auto &response = feature.Socket()->LastClientResponse(); 211 | if (response.size == sizeof(ClientResponse)) 212 | j["lastClientResponse"] = response; 213 | } 214 | 215 | inline void from_json(const nlohmann::json& j, shared_ptr & feature) 216 | { 217 | // Use `at` for all fields since they are mandatory 218 | feature = make_shared( 219 | j.at("hostName").get(), 220 | j.at("friendlyName").get(), 221 | j.at("port").get(), 222 | j.at("width").get(), 223 | j.at("height").get(), 224 | j.at("offsetX").get(), 225 | j.at("offsetY").get(), 226 | j.at("reversed").get(), 227 | j.at("channel").get(), 228 | j.at("redGreenSwap").get(), 229 | j.at("clientBufferCount").get() 230 | ); 231 | } 232 | 233 | -------------------------------------------------------------------------------- /interfaces.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | // Interfaces 4 | // 5 | // This file contains the interfaces for the various classes in the project. The interfaces 6 | // are used to define the methods that must be implemented by the classes that use them. This 7 | // allows the classes to be decoupled from each other, and allows for easier testing and 8 | // maintenance of the code. It also presumably makes it easier in the future to interop 9 | // with other languages, etc. 10 | 11 | #include "pixeltypes.h" 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include "json.hpp" 17 | 18 | using namespace std; 19 | using namespace chrono; 20 | 21 | // ILEDGraphics 22 | // 23 | // Represents a 2D drawing surface that can be used to render pixel data. Provides methods for 24 | // setting and getting pixel values, drawing shapes, and clearing the surface. 25 | 26 | class ILEDGraphics 27 | { 28 | public: 29 | virtual ~ILEDGraphics() = default; 30 | 31 | virtual const vector & GetPixels() const = 0; 32 | virtual uint32_t Width() const = 0; 33 | virtual uint32_t Height() const = 0; 34 | virtual void SetPixel(uint32_t x, uint32_t y, const CRGB& color) = 0; 35 | virtual void SetPixelsF(float fPos, float count, CRGB c, bool bMerge = false) = 0; 36 | virtual void FadePixelToBlackBy(uint32_t x, uint32_t y, float amount) = 0; 37 | virtual CRGB GetPixel(uint32_t x, uint32_t y) const = 0; 38 | virtual void Clear(const CRGB& color) = 0; 39 | virtual void FadeFrameBy(uint8_t dimAmount) = 0; 40 | virtual void FillRectangle(uint32_t x, uint32_t y, uint32_t width, uint32_t height, const CRGB& color) = 0; 41 | virtual void DrawLine(uint32_t x1, uint32_t y1, uint32_t x2, uint32_t y2, const CRGB& color) = 0; 42 | virtual void DrawCircle(uint32_t x, uint32_t y, uint32_t radius, const CRGB& color) = 0; 43 | virtual void FillCircle(uint32_t x, uint32_t y, uint32_t radius, const CRGB& color) = 0; 44 | virtual void DrawRectangle(uint32_t x, uint32_t y, uint32_t width, uint32_t height, const CRGB& color) = 0; 45 | }; 46 | 47 | // ISchedule Interface 48 | // 49 | // Represents a class that determine whether the effect involved can/should be run at the current time 50 | 51 | class ISchedule 52 | { 53 | public: 54 | 55 | // Days of week as a bitmask using powers of 2. 56 | 57 | enum DayOfWeek : uint8_t 58 | { 59 | Sunday = 0x01, // 1 60 | Monday = 0x02, // 2 61 | Tuesday = 0x04, // 4 62 | Wednesday = 0x08, // 8 63 | Thursday = 0x10, // 16 64 | Friday = 0x20, // 32 65 | Saturday = 0x40 // 64 66 | }; 67 | 68 | virtual ~ISchedule() = default; 69 | 70 | // Setters for optional schedule properties. 71 | virtual void SetDaysOfWeek(uint8_t days) = 0; 72 | virtual void SetStartTime(const string& time) = 0; // Format: "HH:MM:SS" 73 | virtual void SetStopTime(const string& time) = 0; // Format: "HH:MM:SS" 74 | virtual void SetStartDate(const string& date) = 0; // Format: "YYYY-MM-DD" 75 | virtual void SetStopDate(const string& date) = 0; // Format: "YYYY-MM-DD" 76 | 77 | // Getters for schedule properties. 78 | virtual optional GetDaysOfWeek() const = 0; 79 | virtual optional GetStartTime() const = 0; 80 | virtual optional GetStopTime() const = 0; 81 | virtual optional GetStartDate() const = 0; 82 | virtual optional GetStopDate() const = 0; 83 | 84 | // Methods to manipulate individual days. 85 | virtual void AddDay(DayOfWeek day) = 0; 86 | virtual void RemoveDay(DayOfWeek day) = 0; 87 | virtual bool HasDay(DayOfWeek day) const = 0; 88 | 89 | // Determines if the schedule is currently active. 90 | // Optional fields (days, start/stop times, start/stop dates) are considered a match if not present. 91 | virtual bool IsActive() const = 0; 92 | }; 93 | 94 | struct ClientResponse; 95 | class ICanvas; 96 | 97 | // ILEDEffect 98 | // 99 | // Defines lifecycle hooks (`Start` and `Update`) for applying visual effects on LED canvases. 100 | 101 | class ILEDEffect 102 | { 103 | public: 104 | virtual ~ILEDEffect() = default; 105 | 106 | // Get the name of the effect 107 | virtual const string& Name() const = 0; 108 | 109 | // Called when the effect starts 110 | virtual void Start(ICanvas& canvas) = 0; 111 | 112 | // Called to update the effect, given a canvas and timestamp 113 | virtual void Update(ICanvas& canvas, milliseconds deltaTime) = 0; 114 | 115 | virtual void SetSchedule(const shared_ptr pSchedule) = 0; 116 | virtual const shared_ptr GetSchedule() const = 0; 117 | }; 118 | 119 | // IEffectsManager 120 | // 121 | // Manages a collection of LED effects, allowing for cycling through effects, starting and stopping them, 122 | // and updating the current effect. Provides methods for adding, removing, and clearing effects. 123 | 124 | class IEffectsManager 125 | { 126 | public: 127 | virtual ~IEffectsManager() = default; 128 | 129 | virtual void AddEffect(shared_ptr effect) = 0; 130 | virtual void RemoveEffect(shared_ptr & effect) = 0; 131 | virtual void StartCurrentEffect(ICanvas& canvas) = 0; 132 | virtual void SetCurrentEffect(size_t index, ICanvas& canvas) = 0; 133 | virtual size_t GetCurrentEffect() const = 0; 134 | virtual size_t EffectCount() const = 0; 135 | virtual vector> Effects() const = 0; 136 | virtual void UpdateCurrentEffect(ICanvas& canvas, milliseconds millisDelta) = 0; 137 | virtual void NextEffect() = 0; 138 | virtual void PreviousEffect() = 0; 139 | virtual string CurrentEffectName() const = 0; 140 | virtual void ClearEffects() = 0; 141 | virtual bool WantsToRun() const = 0; 142 | virtual void WantToRun(bool wantsToRun) = 0; 143 | virtual bool IsRunning() const = 0; 144 | virtual void Start(ICanvas& canvas) = 0; 145 | virtual void Stop() = 0; 146 | virtual void SetFPS(uint16_t fps) = 0; 147 | virtual uint16_t GetFPS() const = 0; 148 | virtual void SetEffects(vector> effects) = 0; 149 | virtual void SetCurrentEffectIndex(int index) = 0; 150 | }; 151 | 152 | // ISocketChannel 153 | // 154 | // Defines a communication protocol for managing socket connections and sending data to a server. 155 | // Provides methods for enqueuing frames, retrieving connection status, and tracking performance metrics. 156 | 157 | class ISocketChannel 158 | { 159 | public: 160 | virtual ~ISocketChannel() = default; 161 | 162 | // Accessors for channel details 163 | virtual const string& HostName() const = 0; 164 | virtual const string& FriendlyName() const = 0; 165 | virtual uint16_t Port() const = 0; 166 | 167 | virtual uint32_t Id() const = 0; 168 | 169 | // Data transfer methods 170 | virtual bool EnqueueFrame(vector&& frameData) = 0; 171 | virtual vector CompressFrame(const vector& data) = 0; 172 | 173 | // Connection status 174 | virtual bool IsConnected() const = 0; 175 | virtual uint64_t GetLastBytesPerSecond() const = 0; 176 | virtual ClientResponse LastClientResponse() const = 0; 177 | virtual uint32_t GetReconnectCount() const = 0; 178 | virtual size_t GetCurrentQueueDepth() const = 0; 179 | virtual size_t GetQueueMaxSize() const = 0; 180 | 181 | // Start and stop operations 182 | virtual void Start() = 0; 183 | virtual void Stop() = 0; 184 | }; 185 | 186 | // ILEDFeature 187 | // 188 | // Represents a 2D collection of LEDs with positioning, rendering, and configuration capabilities. 189 | // Provides APIs for interacting with its parent canvas and retrieving its assigned color data. 190 | 191 | class ILEDFeature 192 | { 193 | public: 194 | virtual ~ILEDFeature() = default; 195 | 196 | virtual uint32_t Id() const = 0; 197 | 198 | // Accessor methods 199 | virtual uint32_t Width() const = 0; 200 | virtual uint32_t Height() const = 0; 201 | virtual uint32_t OffsetX() const = 0; 202 | virtual uint32_t OffsetY() const = 0; 203 | virtual bool Reversed() const = 0; 204 | virtual uint8_t Channel() const = 0; 205 | virtual bool RedGreenSwap() const = 0; 206 | virtual uint32_t ClientBufferCount() const = 0; 207 | virtual double TimeOffset () const = 0; 208 | 209 | // Canvas association 210 | virtual void SetCanvas(const ICanvas * canvas) = 0; 211 | 212 | // Data retrieval 213 | virtual vector GetPixelData() const = 0; 214 | virtual vector GetDataFrame() const = 0; 215 | 216 | virtual shared_ptr Socket() = 0; 217 | virtual const shared_ptr Socket() const = 0; 218 | 219 | }; 220 | 221 | // ICanvas 222 | // 223 | // Represents a 2D drawing surface that manages LED features and provides rendering capabilities. 224 | // Can contain multiple `ILEDFeature` instances, with features mapped to specific regions of the canvas 225 | 226 | class ICanvas 227 | { 228 | public: 229 | virtual ~ICanvas() = default; 230 | 231 | virtual uint32_t Id() const = 0; 232 | virtual uint32_t SetId(uint32_t id) = 0; 233 | virtual string Name() const = 0; 234 | virtual uint32_t AddFeature(shared_ptr feature) = 0; 235 | virtual bool RemoveFeatureById(uint16_t featureId) = 0; 236 | 237 | virtual vector> Features() = 0; 238 | virtual const vector> Features() const = 0; 239 | 240 | 241 | virtual ILEDGraphics & Graphics() = 0; 242 | virtual const ILEDGraphics& Graphics() const = 0; 243 | 244 | virtual IEffectsManager & Effects() = 0; 245 | virtual const IEffectsManager & Effects() const = 0; 246 | }; 247 | 248 | class IController 249 | { 250 | public: 251 | virtual ~IController() = default; 252 | 253 | virtual void Connect() = 0; 254 | virtual void Disconnect() = 0; 255 | virtual void Start(bool respectWantsToRun = false) = 0; 256 | virtual void Stop() = 0; 257 | 258 | virtual void WriteToFile(const string& filePath) const = 0; 259 | 260 | virtual uint16_t GetPort() const = 0; 261 | virtual void SetPort(uint16_t port) = 0; 262 | 263 | virtual vector> Canvases() const = 0; 264 | virtual uint32_t AddCanvas(shared_ptr ptrCanvas) = 0; 265 | virtual bool DeleteCanvasById(uint32_t id) = 0; 266 | virtual bool UpdateCanvas(shared_ptr ptrCanvas) = 0; 267 | virtual bool AddFeatureToCanvas(uint16_t canvasId, shared_ptr feature) = 0; 268 | virtual void RemoveFeatureFromCanvas(uint16_t canvasId, uint16_t featureId) = 0; 269 | virtual shared_ptr GetCanvasById(uint16_t id) const = 0; 270 | virtual const shared_ptr GetSocketById(uint16_t id) const = 0; 271 | virtual vector> GetSockets() const = 0; 272 | }; -------------------------------------------------------------------------------- /ndsweb/src/app/state/monitor.state.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { inject, Injectable } from '@angular/core'; 3 | 4 | import { catchError, finalize, switchMap, take, tap, timer } from 'rxjs'; 5 | 6 | import { 7 | Action, 8 | createSelector, 9 | State, 10 | StateContext, 11 | Store, 12 | } from '@ngxs/store'; 13 | 14 | import { MonitorActions } from '../actions'; 15 | import { Canvas, MonitorService } from '../services/monitor.service'; 16 | import { ToastrService } from 'ngx-toastr'; 17 | import { MatDialog } from '@angular/material/dialog'; 18 | import { ConfirmDialogComponent } from '../dialogs/confirm-dialog.component'; 19 | import { ViewFeaturesDialogComponent } from '../dialogs/view-features-dialog.component'; 20 | 21 | const REFRESH_INTERVAL_IN_MS = 80; 22 | 23 | export interface StateModel { 24 | autoRefresh: boolean; 25 | isLoading: boolean; 26 | canvases: Canvas[]; 27 | connectionError: HttpErrorResponse | null; 28 | selectedCanvas: Canvas | null; 29 | updateSelectedCanvas: boolean; 30 | } 31 | 32 | @State({ 33 | name: 'monitor', 34 | defaults: { 35 | autoRefresh: true, 36 | isLoading: false, 37 | connectionError: null, 38 | canvases: [], 39 | selectedCanvas: null, 40 | updateSelectedCanvas: false, 41 | }, 42 | }) 43 | @Injectable() 44 | export class MonitorState { 45 | static getCanvases() { 46 | return createSelector( 47 | [MonitorState], 48 | (state: StateModel) => state.canvases 49 | ); 50 | } 51 | 52 | static autoRefresh() { 53 | return createSelector( 54 | [MonitorState], 55 | (state: StateModel) => state.autoRefresh 56 | ); 57 | } 58 | 59 | static hasConnectionError() { 60 | return createSelector( 61 | [MonitorState], 62 | (state: StateModel) => state.connectionError !== null 63 | ); 64 | } 65 | 66 | static connectionError() { 67 | return createSelector( 68 | [MonitorState], 69 | (state: StateModel) => state.connectionError 70 | ); 71 | } 72 | 73 | static getSelectedCanvas() { 74 | return createSelector( 75 | [MonitorState], 76 | (state: StateModel) => state.selectedCanvas 77 | ); 78 | } 79 | 80 | monitorService = inject(MonitorService); 81 | store = inject(Store); 82 | toastr = inject(ToastrService); 83 | dialog = inject(MatDialog); 84 | 85 | @Action(MonitorActions.UpdateAutoRefresh) 86 | updateAutoRefresh( 87 | { patchState }: StateContext, 88 | { value }: MonitorActions.UpdateAutoRefresh 89 | ) { 90 | patchState({ autoRefresh: value }); 91 | 92 | if (value) { 93 | this.store.dispatch(new MonitorActions.LoadCanvases()); 94 | } 95 | } 96 | 97 | @Action(MonitorActions.LoadCanvases) 98 | loadCanvases( 99 | { patchState, getState, dispatch }: StateContext, 100 | action: MonitorActions.LoadCanvases 101 | ) { 102 | const { isLoading } = getState(); 103 | 104 | if (isLoading) { 105 | return; 106 | } 107 | 108 | patchState({ isLoading: true }); 109 | 110 | return timer(REFRESH_INTERVAL_IN_MS).pipe( 111 | take(1), 112 | switchMap(() => this.monitorService.getCanvases()), 113 | tap((canvases) => { 114 | patchState({ canvases, connectionError: null }); 115 | 116 | const { updateSelectedCanvas, selectedCanvas: current } = 117 | getState(); 118 | 119 | if (updateSelectedCanvas && current) { 120 | const selectedCanvas = canvases.find( 121 | (c) => c.id === current.id 122 | ); 123 | 124 | patchState({ selectedCanvas, updateSelectedCanvas: false }); 125 | } 126 | }), 127 | catchError((error) => 128 | dispatch(new MonitorActions.LoadCanvasesFailure(error)) 129 | ), 130 | finalize(() => { 131 | patchState({ isLoading: false }); 132 | const { autoRefresh } = getState(); 133 | 134 | if (autoRefresh) { 135 | // Instead of firing off actions on a timer, we check if the user has toggled auto-refresh 136 | // and if so, we dispatch a new action to load the controller data. 137 | // This is a more reactive approach, and allows the user to control the refresh rate 138 | // Also prevents multiple requests from being fired off to the server if the request 139 | // takes longer than the timer interval 140 | dispatch(new MonitorActions.LoadCanvases()); 141 | } 142 | }) 143 | ); 144 | } 145 | 146 | @Action(MonitorActions.ViewFeatures) 147 | viewFeatures( 148 | { patchState }: StateContext, 149 | { canvas }: MonitorActions.ViewFeatures 150 | ) { 151 | patchState({ selectedCanvas: canvas }); 152 | 153 | return this.dialog 154 | .open(ViewFeaturesDialogComponent) 155 | .afterClosed() 156 | .pipe(tap(() => patchState({ selectedCanvas: null }))); 157 | } 158 | 159 | @Action(MonitorActions.StartCanvases) 160 | startCanvases( 161 | { dispatch }: StateContext, 162 | { canvases }: MonitorActions.StartCanvases 163 | ) { 164 | if (canvases.length === 0) { 165 | return; 166 | } 167 | 168 | return this.monitorService 169 | .startCanvases(canvases) 170 | .pipe( 171 | catchError((error) => 172 | dispatch(new MonitorActions.StartCanvasesFailure(error)) 173 | ) 174 | ); 175 | } 176 | 177 | @Action(MonitorActions.StopCanvases) 178 | stopCanvases( 179 | { dispatch }: StateContext, 180 | { canvases }: MonitorActions.StopCanvases 181 | ) { 182 | if (canvases.length === 0) { 183 | return; 184 | } 185 | 186 | return this.monitorService 187 | .stopCanvases(canvases) 188 | .pipe( 189 | catchError((error) => 190 | dispatch(new MonitorActions.StopCanvasesFailure(error)) 191 | ) 192 | ); 193 | } 194 | 195 | @Action(MonitorActions.DeleteCanvas) 196 | deleteCanvas( 197 | { dispatch }: StateContext, 198 | { canvas }: MonitorActions.DeleteCanvas 199 | ) { 200 | return this.monitorService.deleteCanvas(canvas.id).pipe( 201 | tap(() => { 202 | this.toastr.success('Canvas deleted successfully'); 203 | }), 204 | catchError((error) => 205 | dispatch(new MonitorActions.DeleteCanvasFailure(error)) 206 | ) 207 | ); 208 | } 209 | 210 | @Action(MonitorActions.DeleteFeature) 211 | deleteFeature( 212 | { dispatch, patchState }: StateContext, 213 | { canvas, feature }: MonitorActions.DeleteFeature 214 | ) { 215 | return this.monitorService.deleteFeature(canvas.id, feature.id).pipe( 216 | tap(() => { 217 | this.toastr.success('Feature deleted successfully'); 218 | 219 | patchState({ updateSelectedCanvas: true }); 220 | }), 221 | catchError((error) => 222 | dispatch(new MonitorActions.DeleteFeatureFailure(error)) 223 | ) 224 | ); 225 | } 226 | 227 | @Action(MonitorActions.ConfirmDeleteCanvas) 228 | confirmDeleteCanvas( 229 | { dispatch }: StateContext, 230 | { canvas }: MonitorActions.ConfirmDeleteCanvas 231 | ) { 232 | return this.dialog 233 | .open(ConfirmDialogComponent, { 234 | disableClose: true, 235 | data: { 236 | title: 'Confirm Delete', 237 | message: `Are you certain you want to delete this canvas? This will remove (${canvas.features.length}) features.`, 238 | cancelText: 'No', 239 | confirmText: 'Yes', 240 | confirmIcon: 'delete', 241 | confirmClass: 'warn', 242 | }, 243 | }) 244 | .afterClosed() 245 | .pipe( 246 | tap((result) => { 247 | if (result) { 248 | dispatch(new MonitorActions.DeleteCanvas(canvas)); 249 | } 250 | }) 251 | ); 252 | } 253 | 254 | @Action(MonitorActions.ConfirmDeleteFeature) 255 | confirmDeleteFeature( 256 | { dispatch }: StateContext, 257 | { model }: MonitorActions.ConfirmDeleteFeature 258 | ) { 259 | return this.dialog 260 | .open(ConfirmDialogComponent, { 261 | disableClose: true, 262 | data: { 263 | title: 'Confirm Delete', 264 | message: 'Are you certain you want to delete this feature?', 265 | cancelText: 'No', 266 | confirmText: 'Yes', 267 | confirmIcon: 'delete', 268 | confirmClass: 'warn', 269 | }, 270 | }) 271 | .afterClosed() 272 | .pipe( 273 | tap((result) => { 274 | if (result) { 275 | dispatch( 276 | new MonitorActions.DeleteFeature( 277 | model.canvas, 278 | model.feature 279 | ) 280 | ); 281 | } 282 | }) 283 | ); 284 | } 285 | 286 | @Action([ 287 | MonitorActions.LoadCanvasesFailure, 288 | MonitorActions.DeleteCanvasFailure, 289 | MonitorActions.DeleteFeatureFailure, 290 | MonitorActions.StartCanvasesFailure, 291 | MonitorActions.StopCanvasesFailure, 292 | ]) 293 | handleError({ patchState }: StateContext, action: ErrorType) { 294 | switch (true) { 295 | case action instanceof MonitorActions.LoadCanvasesFailure: 296 | this.toastr.error( 297 | action.error.message, 298 | 'Error loading canvases', 299 | { disableTimeOut: true } 300 | ); 301 | break; 302 | case action instanceof MonitorActions.StartCanvasesFailure: 303 | this.toastr.error( 304 | action.error.message, 305 | 'Error activating canvas', 306 | { disableTimeOut: true } 307 | ); 308 | break; 309 | case action instanceof MonitorActions.StopCanvasesFailure: 310 | this.toastr.error( 311 | action.error.message, 312 | 'Error deactivating canvas', 313 | { disableTimeOut: true } 314 | ); 315 | break; 316 | case action instanceof MonitorActions.DeleteCanvasFailure: 317 | this.toastr.error( 318 | action.error.message, 319 | 'Error deleting canvas', 320 | { disableTimeOut: true } 321 | ); 322 | break; 323 | case action instanceof MonitorActions.DeleteFeatureFailure: 324 | this.toastr.error( 325 | action.error.message, 326 | 'Error deleting feature', 327 | { disableTimeOut: true } 328 | ); 329 | break; 330 | } 331 | } 332 | } 333 | 334 | type ErrorType = 335 | | MonitorActions.LoadCanvasesFailure 336 | | MonitorActions.DeleteCanvasFailure 337 | | MonitorActions.DeleteFeatureFailure 338 | | MonitorActions.StartCanvasesFailure 339 | | MonitorActions.StopCanvasesFailure; 340 | -------------------------------------------------------------------------------- /effectsmanager.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | using namespace std; 3 | using namespace chrono; 4 | 5 | #include "effects/colorwaveeffect.h" 6 | #include "effects/fireworkseffect.h" 7 | #include "effects/misceffects.h" 8 | #include "effects/paletteeffect.h" 9 | #include "effects/starfield.h" 10 | #include "effects/videoeffect.h" 11 | #include "effects/bouncingballeffect.h" 12 | 13 | // EffectsManager 14 | // 15 | // Manages a collection of ILEDEffect objects. The EffectsManager is responsible for 16 | // starting and stopping the effects, and for switching between them. The EffectsManager 17 | // can also be used to clear all effects. 18 | 19 | #include "interfaces.h" 20 | #include 21 | #include 22 | 23 | class EffectsManager : public IEffectsManager 24 | { 25 | uint16_t _fps; 26 | int _currentEffectIndex; // Index of the current effect 27 | atomic _running; 28 | bool _wantsToRun; 29 | mutable mutex _effectsMutex; // Add mutex as member 30 | vector> _effects; 31 | thread _workerThread; 32 | 33 | public: 34 | EffectsManager(uint16_t fps) : _fps(fps), _currentEffectIndex(-1), _wantsToRun(true), _running(false) // No effect selected initially 35 | { 36 | } 37 | 38 | ~EffectsManager() 39 | { 40 | Stop(); // Ensure the worker thread is stopped when the manager is destroyed 41 | } 42 | 43 | void SetFPS(uint16_t fps) override 44 | { 45 | _fps = fps; 46 | } 47 | 48 | uint16_t GetFPS() const override 49 | { 50 | return _fps; 51 | } 52 | 53 | size_t GetCurrentEffect() const override 54 | { 55 | return _currentEffectIndex; 56 | } 57 | 58 | size_t EffectCount() const override 59 | { 60 | return _effects.size(); 61 | } 62 | 63 | vector> Effects() const override 64 | { 65 | lock_guard lock(_effectsMutex); 66 | return _effects; 67 | } 68 | 69 | // Add an effect to the manager 70 | void AddEffect(shared_ptr effect) override 71 | { 72 | lock_guard lock(_effectsMutex); 73 | 74 | if (!effect) 75 | throw invalid_argument("Cannot add a null effect."); 76 | _effects.push_back(effect); 77 | 78 | // Automatically set the first effect as current if none is selected 79 | if (_currentEffectIndex == -1) 80 | _currentEffectIndex = 0; 81 | } 82 | 83 | // Remove an effect from the manager 84 | void RemoveEffect(shared_ptr &effect) override 85 | { 86 | lock_guard lock(_effectsMutex); 87 | 88 | if (!effect) 89 | throw invalid_argument("Cannot remove a null effect."); 90 | 91 | auto it = remove(_effects.begin(), _effects.end(), effect); 92 | if (it != _effects.end()) 93 | { 94 | auto index = distance(_effects.begin(), it); 95 | _effects.erase(it); 96 | 97 | // Adjust the current effect index 98 | if (index <= _currentEffectIndex) 99 | _currentEffectIndex = (_currentEffectIndex > 0) ? _currentEffectIndex - 1 : -1; 100 | 101 | // If no effects remain, reset the current index 102 | if (_effects.empty()) 103 | _currentEffectIndex = -1; 104 | } 105 | } 106 | 107 | // Start the current effect 108 | void StartCurrentEffect(ICanvas &canvas) override 109 | { 110 | if (_running && IsEffectSelected()) 111 | _effects[_currentEffectIndex]->Start(canvas); 112 | } 113 | 114 | void SetCurrentEffect(size_t index, ICanvas &canvas) override 115 | { 116 | if (index >= _effects.size()) 117 | throw out_of_range("Effect index out of range."); 118 | 119 | _currentEffectIndex = index; 120 | 121 | StartCurrentEffect(canvas); 122 | } 123 | 124 | // Update the current effect and render it to the canvas 125 | void UpdateCurrentEffect(ICanvas &canvas, milliseconds millisDelta) override 126 | { 127 | if (_running && IsEffectSelected()) 128 | _effects[_currentEffectIndex]->Update(canvas, millisDelta); 129 | } 130 | 131 | // Switch to the next effect 132 | void NextEffect() override 133 | { 134 | if (!_effects.empty()) 135 | _currentEffectIndex = (_currentEffectIndex + 1) % _effects.size(); 136 | } 137 | 138 | // Switch to the previous effect 139 | void PreviousEffect() override 140 | { 141 | if (!_effects.empty()) 142 | _currentEffectIndex = (_currentEffectIndex == 0) ? _effects.size() - 1 : _currentEffectIndex - 1; 143 | } 144 | 145 | // Get the name of the current effect 146 | string CurrentEffectName() const override 147 | { 148 | if (IsEffectSelected()) 149 | return _effects[_currentEffectIndex]->Name(); 150 | return "No Effect Selected"; 151 | } 152 | 153 | // Clear all effects 154 | 155 | void ClearEffects() override 156 | { 157 | lock_guard lock(_effectsMutex); 158 | _effects.clear(); 159 | _currentEffectIndex = -1; 160 | } 161 | 162 | 163 | bool WantsToRun() const override 164 | { 165 | return _wantsToRun; 166 | } 167 | 168 | void WantToRun(bool wantsToRun) override 169 | { 170 | _wantsToRun = wantsToRun; 171 | } 172 | 173 | bool IsRunning() const override 174 | { 175 | return _running; 176 | } 177 | 178 | // Start the worker thread to update effects 179 | 180 | void Start(ICanvas &canvas) override 181 | { 182 | logger->debug("Starting effects manager with {} effects at {} FPS", _effects.size(), _fps); 183 | 184 | if (_running.exchange(true)) 185 | return; // Already running 186 | 187 | _workerThread = thread([this, &canvas]() 188 | { 189 | auto frameDuration = 1000ms / _fps; // Target duration per frame 190 | auto nextFrameTime = steady_clock::now(); 191 | constexpr auto bUseCompression = true; 192 | 193 | // Starting the canvas should start the effect at least one time, as many effects 194 | // have one-time setup in their Start() method 195 | 196 | StartCurrentEffect(canvas); 197 | 198 | while (_running) 199 | { 200 | { 201 | lock_guard lock(_effectsMutex); 202 | 203 | // Update the effects and enqueue frames 204 | UpdateCurrentEffect(canvas, frameDuration); 205 | for (const auto &feature : canvas.Features()) 206 | { 207 | auto frame = feature->GetDataFrame(); 208 | if (bUseCompression) 209 | { 210 | auto compressedFrame = feature->Socket()->CompressFrame(frame); 211 | feature->Socket()->EnqueueFrame(std::move(compressedFrame)); 212 | } 213 | else 214 | { 215 | feature->Socket()->EnqueueFrame(std::move(frame)); 216 | } 217 | } 218 | } 219 | 220 | // We wait here while periodically checking _running 221 | 222 | auto now = steady_clock::now(); 223 | while (now < nextFrameTime && _running) { 224 | this_thread::sleep_for(min(steady_clock::duration(10ms), nextFrameTime - now)); 225 | now = steady_clock::now(); // Update 'now' to avoid an infinite loop 226 | } 227 | 228 | // Set the next frame target 229 | nextFrameTime += frameDuration; 230 | } }); 231 | } 232 | 233 | // Stop the worker thread 234 | void Stop() override 235 | { 236 | logger->debug("Stopping effects manager"); 237 | if (!_running.exchange(false)) 238 | return; // Not running 239 | 240 | if (_workerThread.joinable()) 241 | _workerThread.join(); 242 | } 243 | 244 | void SetEffects(vector> effects) override 245 | { 246 | lock_guard lock(_effectsMutex); 247 | _effects = std::move(effects); 248 | } 249 | 250 | void SetCurrentEffectIndex(int index) override 251 | { 252 | _currentEffectIndex = index; 253 | } 254 | 255 | private: 256 | bool IsEffectSelected() const 257 | { 258 | return _currentEffectIndex >= 0 && _currentEffectIndex < static_cast(_effects.size()); 259 | } 260 | }; 261 | 262 | // Define type aliases for effect (de)serialization functions for legibility reasons 263 | using EffectSerializer = function; 264 | using EffectDeserializer = function(const nlohmann::json &)>; 265 | 266 | // Factory function to create a pair of effect (de)serialization functions for a given type 267 | template 268 | pair> jsonPair() 269 | { 270 | EffectSerializer serializer = [](nlohmann::json &j, const ILEDEffect &effect) 271 | { 272 | to_json(j, dynamic_cast(effect)); 273 | }; 274 | 275 | EffectDeserializer deserializer = [](const nlohmann::json &j) 276 | { 277 | return j.get>(); 278 | }; 279 | 280 | return make_pair(typeid(T).name(), make_pair(serializer, deserializer)); 281 | } 282 | 283 | // Map with effect (de)serialization functions 284 | 285 | static const map> to_from_json_map = 286 | { 287 | jsonPair(), 288 | jsonPair(), 289 | jsonPair(), 290 | jsonPair(), 291 | jsonPair(), 292 | jsonPair(), 293 | jsonPair() 294 | }; 295 | 296 | // Dynamically serialize an effect to JSON based on its actual type 297 | 298 | inline void to_json(nlohmann::json &j, const ILEDEffect &effect) 299 | { 300 | string type = typeid(effect).name(); 301 | auto it = to_from_json_map.find(type); 302 | if (it == to_from_json_map.end()) 303 | { 304 | logger->error("Unknown effect type for serialization: {}", type); 305 | throw runtime_error("Unknown effect type for serialization: " + type); 306 | } 307 | it->second.first(j, effect); 308 | j["type"] = type; 309 | 310 | // Serialize schedule if we have one 311 | if (effect.GetSchedule()) 312 | j["schedule"] = *effect.GetSchedule(); 313 | } 314 | 315 | // Dynamically deserialize an effect from JSON based on its indicated type 316 | // and return it on the unique pointer out reference 317 | 318 | // ILEDEffect <-- JSON 319 | 320 | inline void from_json(const nlohmann::json &j, shared_ptr & effect) 321 | { 322 | auto it = to_from_json_map.find(j["type"]); 323 | if (it == to_from_json_map.end()) 324 | { 325 | logger->error("Unknown effect type for deserialization: {}, replacing with magenta fill", j["type"].get()); 326 | effect = make_shared("Unknown Effect Type", CRGB::Magenta); 327 | return; 328 | } 329 | 330 | effect = it->second.second(j); 331 | 332 | // Deserialize schedule if present 333 | if (j.contains("schedule")) { 334 | auto schedule = j["schedule"].get>(); 335 | effect->SetSchedule(schedule); 336 | } 337 | } 338 | 339 | // IEffectsManager <-- JSON 340 | 341 | inline void to_json(nlohmann::json &j, const IEffectsManager &manager) 342 | { 343 | j = 344 | { 345 | {"fps", manager.GetFPS()}, 346 | {"currentEffectIndex", manager.GetCurrentEffect()}, 347 | {"running", manager.IsRunning()} 348 | }; 349 | 350 | for (const auto &effect : manager.Effects()) 351 | j["effects"].push_back(*effect); 352 | }; 353 | 354 | // IEffectManager --> JSON 355 | 356 | inline void from_json(const nlohmann::json &j, IEffectsManager &manager) 357 | { 358 | manager.SetFPS(j.at("fps").get()); 359 | manager.SetEffects(j.at("effects").get>>()); 360 | manager.SetCurrentEffectIndex(j.at("currentEffectIndex").get()); 361 | 362 | // We deserialize the running state to a running *preference*. Directly starting the manager after 363 | // deserialization could create problems, and without having the canvas we can't start it anyway. 364 | if (j.contains("running")) 365 | manager.WantToRun(j.at("running").get()); 366 | } 367 | --------------------------------------------------------------------------------