diff --git a/include/deviceconfig.h b/include/deviceconfig.h index 276d4d5ee..ffd15edb9 100644 --- a/include/deviceconfig.h +++ b/include/deviceconfig.h @@ -32,6 +32,7 @@ #include "globals.h" +#include #include #include @@ -124,6 +125,39 @@ class DeviceConfig : public IJSONSerializable { + public: + enum class OutputDriver : uint8_t + { + WS281x, + HUB75 + }; + + struct RuntimeTopology + { + uint16_t width = MATRIX_WIDTH; + uint16_t height = MATRIX_HEIGHT; + bool serpentine = true; + }; + + struct RuntimeOutputs + { + OutputDriver driver = + #if USE_HUB75 + OutputDriver::HUB75; + #else + OutputDriver::WS281x; + #endif + size_t channelCount = NUM_CHANNELS; + std::array outputPins{}; + }; + + struct RuntimeConfig + { + RuntimeTopology topology; + RuntimeOutputs outputs; + }; + + private: // Add variables for additional settings to this list String hostname = cszHostname; String location = cszLocation; @@ -141,12 +175,20 @@ class DeviceConfig : public IJSONSerializable CRGB globalColor = CRGB::Red; bool applyGlobalColors = false; CRGB secondColor = CRGB::Red; + int8_t audioInputPin = AUDIO_INPUT_PIN; + RuntimeTopology runtimeTopology = {}; + RuntimeOutputs runtimeOutputs = {}; std::vector> settingSpecs; std::vector> settingSpecReferences; size_t writerIndex; void SaveToJSON() const; + bool SetTimeZoneInternal(const String& newTimeZone, bool skipWrite); + static std::array GetCompiledWS281xPins(); + static const char* DriverName(OutputDriver driver); + static bool IsHub75Build(); + void LogRuntimeConfig(const char* reason) const; template void SetAndSave(T& target, const T& source) @@ -191,6 +233,13 @@ class DeviceConfig : public IJSONSerializable static constexpr const char * GlobalColorTag = NAME_OF(globalColor); static constexpr const char * ApplyGlobalColorsTag = NAME_OF(applyGlobalColors); static constexpr const char * SecondColorTag = NAME_OF(secondColor); + static constexpr const char * MatrixWidthTag = "matrixWidth"; + static constexpr const char * MatrixHeightTag = "matrixHeight"; + static constexpr const char * MatrixSerpentineTag = "matrixSerpentine"; + static constexpr const char * OutputDriverTag = "outputDriver"; + static constexpr const char * WS281xChannelCountTag = "ws281xChannelCount"; + static constexpr const char * WS281xPinsTag = "ws281xPins"; + static constexpr const char * AudioInputPinTag = "audioInputPin"; DeviceConfig(); @@ -236,6 +285,7 @@ class DeviceConfig : public IJSONSerializable void SetRememberCurrentEffect(bool newRememberCurrentEffect); uint8_t GetBrightness() const { return brightness; } + static ValidateResponse ValidateBrightness(int newBrightness); static ValidateResponse ValidateBrightness(const String& newBrightness); void SetBrightness(int newBrightness); @@ -243,6 +293,7 @@ class DeviceConfig : public IJSONSerializable void SetShowVUMeter(bool newShowVUMeter); int GetPowerLimit() const { return powerLimit; } + static ValidateResponse ValidatePowerLimit(int newPowerLimit); static ValidateResponse ValidatePowerLimit(const String& newPowerLimit); void SetPowerLimit(int newPowerLimit); @@ -257,4 +308,73 @@ class DeviceConfig : public IJSONSerializable void SetColorSettings(const CRGB& globalColor, const CRGB& secondColor); void ApplyColorSettings(std::optional globalColor, std::optional secondColor, bool clearGlobalColor, bool applyGlobalColor); + + static constexpr uint16_t GetCompiledMatrixWidth() { return MATRIX_WIDTH; } + static constexpr uint16_t GetCompiledMatrixHeight() { return MATRIX_HEIGHT; } + static constexpr size_t GetCompiledLEDCount() { return NUM_LEDS; } + static constexpr size_t GetCompiledChannelCount() { return NUM_CHANNELS; } + static constexpr int GetCompiledAudioInputPin() { return AUDIO_INPUT_PIN; } + static std::array GetCompiledPins() { return GetCompiledWS281xPins(); } + static OutputDriver GetCompiledOutputDriver() + { + #if USE_HUB75 + return OutputDriver::HUB75; + #else + return OutputDriver::WS281x; + #endif + } + + RuntimeConfig GetRuntimeConfig() const { return RuntimeConfig{runtimeTopology, runtimeOutputs}; } + const RuntimeTopology& GetTopology() const { return runtimeTopology; } + const RuntimeOutputs& GetOutputs() const { return runtimeOutputs; } + uint16_t GetMatrixWidth() const { return runtimeTopology.width; } + uint16_t GetMatrixHeight() const { return runtimeTopology.height; } + bool IsMatrixSerpentine() const { return runtimeTopology.serpentine; } + size_t GetActiveLEDCount() const { return static_cast(runtimeTopology.width) * runtimeTopology.height; } + int GetAudioInputPin() const { return audioInputPin; } + OutputDriver GetOutputDriver() const { return runtimeOutputs.driver; } + size_t GetChannelCount() const { return runtimeOutputs.channelCount; } + const std::array& GetWS281xPins() const { return runtimeOutputs.outputPins; } + // When runtime output settings still match the compiled WS281x transport, we can keep using the + // long-proven FastLED controller path and reserve the new runtime manager for true pin/count changes. + bool UsesCompiledWS281xTransport() const + { + return runtimeOutputs.driver == GetCompiledOutputDriver() + && runtimeOutputs.channelCount == GetCompiledChannelCount() + && runtimeOutputs.outputPins == GetCompiledPins(); + } + bool SupportsLiveTopology() const { return !IsHub75Build() && runtimeOutputs.driver == OutputDriver::WS281x; } + bool SupportsLiveOutputReconfigure() const { return !IsHub75Build() && runtimeOutputs.driver == OutputDriver::WS281x; } + bool SupportsConfigurableAudioInputPin() const + { + #if ENABLE_AUDIO && !USE_M5 && (USE_I2S_AUDIO || ELECROW) + return true; + #else + return false; + #endif + } + bool SupportsLiveAudioInputReconfigure() const { return false; } + String GetAudioInputModeName() const + { + #if !ENABLE_AUDIO + return "disabled"; + #elif USE_M5 + return "m5_internal"; + #elif USE_I2S_AUDIO || ELECROW + return "i2s"; + #else + return "adc_fixed"; + #endif + } + bool RequiresRecompileForCurrentRuntimeConfig() const { return runtimeOutputs.driver != GetCompiledOutputDriver(); } + String GetCompiledDriverName() const { return DriverName(GetCompiledOutputDriver()); } + String GetRuntimeDriverName() const { return DriverName(runtimeOutputs.driver); } + + ValidateResponse ValidateAudioInputPin(int pin) const; + ValidateResponse ValidateTopology(uint16_t width, uint16_t height, bool serpentine) const; + ValidateResponse ValidateOutputDriver(OutputDriver driver) const; + ValidateResponse ValidateWS281xSettings(size_t channelCount, const std::array& pins) const; + ValidateResponse ValidateRuntimeConfig(const RuntimeConfig& config) const; + bool SetRuntimeConfig(const RuntimeConfig& config, bool skipWrite = false, String* errorMessage = nullptr); + void SetAudioInputPin(int newAudioInputPin); }; diff --git a/include/effectmanager.h b/include/effectmanager.h index 3c10e3db3..304220e6d 100644 --- a/include/effectmanager.h +++ b/include/effectmanager.h @@ -119,6 +119,7 @@ class EffectManager : public IJSONSerializable bool _clearTempEffectWhenExpired = false; std::atomic_bool _newFrameAvailable = false; String _effectSetHashString = ""; + uint32_t _lastBeatSequence = 0; std::vector> _gfx; std::shared_ptr _tempEffect; @@ -126,6 +127,7 @@ class EffectManager : public IJSONSerializable std::vector> _effectEventListeners; void construct(bool clearTempEffect); + void DispatchBeatIfNeeded(); // Implementation is in effects.cpp void LoadJSONEffects(const JsonArrayConst& effectsArray); diff --git a/include/effects/matrix/PatternWeather.h b/include/effects/matrix/PatternWeather.h index cf977d997..51f91c7af 100644 --- a/include/effects/matrix/PatternWeather.h +++ b/include/effects/matrix/PatternWeather.h @@ -100,7 +100,10 @@ extern const uint8_t thunderstorm_night_end[] asm("_binary_assets_bmp_thun static constexpr auto pszDaysOfWeek = to_array( { "SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT" } ); -static std::map, psram_allocator>> weatherIcons = +// This lookup table is tiny and constructed during global initialization, before the system has +// completed its normal startup path. Keep it on the regular heap so boot does not depend on PSRAM. + +static std::map weatherIcons = { { "01d", EmbeddedFile(clearsky_start, clearsky_end) }, { "02d", EmbeddedFile(fewclouds_start, fewclouds_end) }, diff --git a/include/effects/matrix/spectrumeffects.h b/include/effects/matrix/spectrumeffects.h index 2bc3e9345..c37565162 100644 --- a/include/effects/matrix/spectrumeffects.h +++ b/include/effects/matrix/spectrumeffects.h @@ -122,6 +122,54 @@ class InsulatorSpectrumEffect : public EffectWithId, pu class VUMeter { protected: + mutable uint32_t _lastIndicatorBeatSequence = 0; + mutable uint32_t _lastIndicatorNearBeatSequence = 0; + mutable uint32_t _indicatorUntilMs = 0; + mutable CRGB _indicatorColor = CRGB::Black; + + bool UpdateBeatIndicatorState() const + { + const auto & beat = g_Analyzer.LastBeat(); + const auto & nearBeat = g_Analyzer.LastNearBeat(); + const uint32_t now = millis(); + + if (beat.sequence != 0 && beat.sequence != _lastIndicatorBeatSequence) + { + // Every accepted beat should restart the lamp pulse. Do not apply + // additional gating here; the analyzer has already classified it. + _lastIndicatorBeatSequence = beat.sequence; + _indicatorColor = CRGB::Red; + _indicatorUntilMs = now + static_cast(std::clamp(beat.msPerBeat * 0.03f, 20.0f, 36.0f)); + return true; + } + + if (nearBeat.sequence != 0 && nearBeat.sequence != _lastIndicatorNearBeatSequence) + { + _lastIndicatorNearBeatSequence = nearBeat.sequence; + + // Near-miss pulses are shorter and only take effect when a real beat + // did not just restart the indicator above. + _indicatorColor = CRGB::Blue; + _indicatorUntilMs = now + static_cast(std::clamp(nearBeat.msPerBeat * 0.02f, 14.0f, 24.0f)); + return true; + } + + return now <= _indicatorUntilMs; + } + + void DrawBeatIndicator(std::vector> & GFX, int yVU) const + { + if (!UpdateBeatIndicatorState()) + return; + + const int centerLeft = (GFX[0]->width() / 2) - 1; + const int centerRight = centerLeft + 1; + + // Overlay the center pair with a saturated pulse so beat classifier + // state is visible even when the rest of the meter uses the palette. + GFX[0]->setPixel(centerLeft, yVU, _indicatorColor); + GFX[0]->setPixel(centerRight, yVU, _indicatorColor); + } // DrawVUPixels // @@ -186,6 +234,8 @@ class VUMeter for (int i = 0; i < bars; i++) DrawVUPixels(GFX, i, yVU, i > bars ? 255 : 0, pPalette); + + DrawBeatIndicator(GFX, yVU); } }; diff --git a/include/effects/strip/musiceffect.h b/include/effects/strip/musiceffect.h index 7cebb7e89..c7044aa9f 100644 --- a/include/effects/strip/musiceffect.h +++ b/include/effects/strip/musiceffect.h @@ -30,8 +30,6 @@ //--------------------------------------------------------------------------- -#include - #include "effects.h" #include "faneffects.h" #include "values.h" @@ -41,8 +39,8 @@ // BeatEffectBase // // A specialization of LEDStripEffect, adds a HandleBeat function that allows apps to -// draw based on the music beat. The Draw() function does the audio processing and calls -// HandleBeat() whenever. Apps are free to draw in both Draw() and HandleBeat(). +// react to the shared analyzer beat event. Draw() remains free to do its own rendering; +// BeatEffectBase only adapts the central beat callback onto the older HandleBeat API. // // The constructor allows you to specify the sensitivity by where the latch points are/ // For a highly sensitive (defaults), keep them both close to 1.0. For a wider beat @@ -52,9 +50,6 @@ class BeatEffectBase { protected: - - const int _maxSamples = 60; - std::deque _samples; double _lastBeat = 0; float _minRange = 0; float _minElapsed = 0; @@ -79,47 +74,17 @@ class BeatEffectBase } - // BeatEffectBase::Draw - // - // Doesn't actually "draw" anything, but rather it scans the audio VU to detect beats, and when it finds one, - // it calls the virtual "HandleBeat" function. + // Beat handling moved into SoundAnalyzer so all effects see the same beat + // timing. ProcessAudio stays as a compatibility no-op for older effects. + virtual void ProcessAudio() {} - virtual void ProcessAudio() + virtual void OnBeat(const BeatInfo& beat) { - debugV("BeatEffectBase2::Draw"); - double elapsed = SecondsSinceLastBeat(); - - // Access peaks via const reference to avoid copying - const PeakData & peaks = g_Analyzer.Peaks(); - auto basslevel = peaks[0] * 2; // Scale to historical 0-2 range - - debugV("basslevel: %0.2f", basslevel); - _samples.push_back(basslevel); - float minimum = *min_element(_samples.begin(), _samples.end()); - float maximum = *max_element(_samples.begin(), _samples.end()); + const float elapsed = std::max(static_cast(_minElapsed), beat.intervalMs / 1000.0f); + const float span = std::max(beat.strength, _minRange); - //Serial.printf("Samples: %d, max: %0.2f, min: %0.2f, span: %0.2f\n", _samples.size(), maximum, minimum, maximum-minimum); - - if (_samples.size() >= _maxSamples) - _samples.pop_front(); - - if (maximum - minimum > _minRange) - { - if (elapsed < _minElapsed) - { - //Serial.printf("False Beat: elapsed: %0.2f, range: %0.2f, time: %0.2lf\n", elapsed, maximum - minimum, g_AppTime.CurrentTime()); - // False beat too early, clear data but don't reset lastBeat - _samples.clear(); - } - else - { - debugV("Beat: elapsed: %0.2lf, range: %0.2lf\n", elapsed, maximum - minimum); - - HandleBeat(false, elapsed, maximum - minimum); - _lastBeat = g_Values.AppTime.CurrentTime(); - _samples.clear(); - } - } + HandleBeat(beat.major, elapsed, span); + _lastBeat = g_Values.AppTime.CurrentTime(); } }; diff --git a/include/effects/strip/stareffect.h b/include/effects/strip/stareffect.h index 62bf05e8e..f9d73187b 100644 --- a/include/effects/strip/stareffect.h +++ b/include/effects/strip/stareffect.h @@ -33,6 +33,7 @@ #include #include +#include #include "particles.h" #include "random_utils.h" @@ -367,6 +368,7 @@ class StarEffectBase : public EffectWithId> float _blurFactor; float _musicFactor; CRGB _skyColor; + uint32_t _lastMusicBeatSequence = 0; public: @@ -436,6 +438,31 @@ class StarEffectBase : public EffectWithId> virtual void CreateStars() { + #if ENABLE_AUDIO + // MusicStar is now a direct beat-detector validation effect: new stars + // are only born from the shared beat event sequence. + if constexpr (std::is_same_v) + { + const auto& beat = g_Analyzer.LastBeat(); + if (beat.sequence == 0 || beat.sequence == _lastMusicBeatSequence) + return; + + _lastMusicBeatSequence = beat.sequence; + const size_t burstCount = std::clamp( + static_cast(6 + beat.confidence * 12.0f + beat.strength * 6.0f + (beat.major ? 6.0f : 0.0f)), + 6, + 30); + + for (size_t i = 0; i < burstCount && _allParticles.size() < cMaxStars; ++i) + { + StarType newstar(_palette, _blendType, _maxSpeed * std::max(1.0f, _musicFactor), _starSize); + newstar._iPos = (int) random_range(0U, LEDStripEffect::_cLEDs - 1 - starWidth); + _allParticles.push_back(newstar); + } + return; + } + #endif + for (int i = 0; i < cMaxNewStarsPerFrame; i++) { float prob = _newStarProbability / 100.0f; @@ -467,6 +494,11 @@ class StarEffectBase : public EffectWithId> } } + void OnBeat(const BeatInfo& beat) override + { + LEDStripEffect::OnBeat(beat); + } + virtual void Update() { // Any particles that have lived their lifespan can be removed. They should be found at the back of the array diff --git a/include/gfxbase.h b/include/gfxbase.h index 1bfb103d7..b3970a23b 100644 --- a/include/gfxbase.h +++ b/include/gfxbase.h @@ -116,6 +116,8 @@ constexpr static inline uint8_t WU_WEIGHT(uint8_t a, uint8_t b) class Boid; +uint16_t XY(uint16_t x, uint16_t y); + class GFXBase : public Adafruit_GFX { #if USE_NOISE @@ -129,6 +131,7 @@ class GFXBase : public Adafruit_GFX size_t _width; size_t _height; size_t _ledcount; + bool _serpentine = true; // 32 Entries in the 5-bit gamma table static const uint8_t gamma5[32]; @@ -185,13 +188,17 @@ class GFXBase : public Adafruit_GFX virtual ~GFXBase() override; #if USE_NOISE + void EnsureNoise(); + Noise &GetNoise() { + EnsureNoise(); return *_ptrNoise; } const Noise &GetNoise() const { + const_cast(this)->EnsureNoise(); return *_ptrNoise; } #endif @@ -206,6 +213,23 @@ class GFXBase : public Adafruit_GFX return _ledcount; } + virtual size_t GetMatrixWidth() const + { + return _width; + } + + virtual size_t GetMatrixHeight() const + { + return _height; + } + + virtual bool IsSerpentine() const + { + return _serpentine; + } + + virtual void ConfigureTopology(size_t width, size_t height, bool serpentine); + static uint8_t beatcos8(accum88 beats_per_minute, uint8_t lowest = 0, uint8_t highest = 255, uint32_t timebase = 0, uint8_t phase_offset = 0); static uint8_t mapsin8(uint8_t theta, uint8_t lowest = 0, uint8_t highest = 255); static uint8_t mapcos8(uint8_t theta, uint8_t lowest = 0, uint8_t highest = 255); @@ -250,7 +274,7 @@ class GFXBase : public Adafruit_GFX __attribute__((always_inline)) inline virtual uint16_t xy(uint16_t x, uint16_t y) const noexcept { - if (x & 0x01) + if (_serpentine && (x & 0x01)) { // Odd rows run backwards uint8_t reverseY = (_height - 1) - y; @@ -263,18 +287,6 @@ class GFXBase : public Adafruit_GFX } } - // This is an optimization that allows us to use direct math for the XY lookup when using the matrix, where - // it's a very simple layout. Others may need to override this function. Using a #define here allows - // us to avoid an extra virtual function call in the inner loop of the effects. - - #if USE_HUB75 - #define XY(x, y) ((y) * MATRIX_WIDTH + (x)) - #elif HELMET - #define XY(x, y) (x, MATRIX_HEIGHT - 1 - y) // Invert the Y axis for the helmet display - #else - #define XY(x, y) (((x) & 0x01) ? (((x) * MATRIX_HEIGHT) + ((MATRIX_HEIGHT - 1) - (y))) : (((x) * MATRIX_HEIGHT) + (y))) - #endif - // Retrieves the color of a pixel at the specified X and Y coordinates. virtual CRGB getPixel(int16_t x, int16_t y) const; diff --git a/include/globals.h b/include/globals.h index ccc8002b1..d467e4fcb 100644 --- a/include/globals.h +++ b/include/globals.h @@ -693,20 +693,20 @@ extern const int g_aRingSizeTable[]; // The M5 mic is on Pin34, but when I wire up my own microphone module I usually put it on pin 36. #if ENABLE_AUDIO - #ifndef INPUT_PIN + #ifndef AUDIO_INPUT_PIN #if TTGO - #define INPUT_PIN (36) + #define AUDIO_INPUT_PIN (36) #elif ELECROW - #define INPUT_PIN (41) + #define AUDIO_INPUT_PIN (41) #elif USE_M5 - #define INPUT_PIN (34) + #define AUDIO_INPUT_PIN (34) #define IO_PIN (0) #else - #define INPUT_PIN (36) // Audio line input, ADC #1, input line 0 (GPIO pin 36) + #define AUDIO_INPUT_PIN (36) // Audio line input, ADC #1, input line 0 (GPIO pin 36) #endif #endif #else - #define INPUT_PIN 0 + #define AUDIO_INPUT_PIN 0 #endif #ifndef IR_REMOTE_PIN @@ -726,7 +726,7 @@ extern const int g_aRingSizeTable[]; #endif #ifndef I2S_DATA_PIN - #define I2S_DATA_PIN INPUT_PIN + #define I2S_DATA_PIN AUDIO_INPUT_PIN #endif #endif diff --git a/include/jsonserializer.h b/include/jsonserializer.h index d955e8f34..d3a230e02 100644 --- a/include/jsonserializer.h +++ b/include/jsonserializer.h @@ -36,6 +36,7 @@ #include #include #include +#include #include template @@ -96,6 +97,7 @@ class JSONWriter struct WriterEntry; std::vector> writers; + mutable std::mutex writersMutex; std::atomic_ulong latestFlagMs; std::atomic_bool flushRequested; std::atomic_bool haltWrites; diff --git a/include/ledbuffer.h b/include/ledbuffer.h index 55311b553..a7697fd76 100644 --- a/include/ledbuffer.h +++ b/include/ledbuffer.h @@ -76,6 +76,7 @@ class LEDBuffer bool UpdateFromWire(std::unique_ptr & payloadData, size_t payloadLength); void DrawBuffer(); + void Reconfigure(std::shared_ptr pStrand); }; // LEDBufferManager @@ -139,6 +140,8 @@ class LEDBufferManager std::shared_ptr PeekOldestBuffer() const; + void Reconfigure(const std::shared_ptr& pGFX); + // operator[] // // Returns a pointer to the buffer at the specified logical index, or nullptr if empty diff --git a/include/ledstripeffect.h b/include/ledstripeffect.h index 186885175..959ed1c1a 100644 --- a/include/ledstripeffect.h +++ b/include/ledstripeffect.h @@ -40,6 +40,7 @@ #include class GFXBase; +struct BeatInfo; #if HEXAGON class HexagonGFX; @@ -135,6 +136,7 @@ class LEDStripEffect : public IJSONSerializable virtual void Start() {} // Optional method called when time to clean/init the effect virtual void Draw() = 0; // Your effect must implement these + virtual void OnBeat(const BeatInfo&) {} // Optional beat callback for audio-reactive effects GFXBase& g(size_t channel = 0); const GFXBase& g(size_t channel = 0) const; diff --git a/include/socketserver.h b/include/socketserver.h index 7fd99412a..cc719897a 100644 --- a/include/socketserver.h +++ b/include/socketserver.h @@ -102,6 +102,8 @@ class SocketServer void release(); bool begin(); void ResetReadBuffer(); + void SetLEDCount(size_t numLeds) { _numLeds = numLeds; } + size_t GetLEDCount() const { return _numLeds; } // ReadUntilNBytesReceived // diff --git a/include/soundanalyzer.h b/include/soundanalyzer.h index 5936a8fe1..b62d9a529 100644 --- a/include/soundanalyzer.h +++ b/include/soundanalyzer.h @@ -183,6 +183,29 @@ void AudioSerialTaskEntry(void *); // Replace previous PeakData class with a direct alias to std::array using PeakData = std::array; +// BeatInfo +// +// Beat detection is computed once in the analyzer and published as a compact +// event record so effects can react without rescanning the FFT themselves. +struct BeatInfo +{ + uint32_t sequence = 0; + uint32_t timestampMs = 0; + float intervalMs = 0.0f; + float bpm = 0.0f; + float msPerBeat = 0.0f; + float confidence = 0.0f; + float strength = 0.0f; + float bass = 0.0f; + float mid = 0.0f; + float treble = 0.0f; + float flux = 0.0f; + float vu = 0.0f; + float vuRatio = 0.0f; + bool major = false; + bool simulated = false; +}; + // Interface for SoundAnalyzer (audio and non-audio variants) class ISoundAnalyzer { @@ -211,6 +234,8 @@ class ISoundAnalyzer virtual float Peak1Decay(int band) const = 0; virtual float Peak2Decay(int band) const = 0; virtual unsigned long LastPeak1Time(int band) const = 0; + virtual const BeatInfo & LastBeat() const = 0; + virtual const BeatInfo & LastNearBeat() const = 0; // --- Simulation & Testing --- virtual void SetSimulateBeat(bool) = 0; @@ -228,6 +253,7 @@ class ISoundAnalyzer class SoundAnalyzer : public ISoundAnalyzer // Non-audio case stub { PeakData _emptyPeaks; // zero-initialized + BeatInfo _beatInfo{}; public: float VURatio() const override { @@ -289,6 +315,16 @@ class SoundAnalyzer : public ISoundAnalyzer // Non-audio case stub return 0; } + const BeatInfo & LastBeat() const override + { + return _beatInfo; + } + + const BeatInfo & LastNearBeat() const override + { + return _beatInfo; + } + void SetPeakDecayRates(float, float) override { } @@ -423,6 +459,16 @@ class SoundAnalyzerBase : public ISoundAnalyzer return (band >= 0 && band < NUM_BANDS) ? _lastPeak1Time[band] : 0; } + const BeatInfo & LastBeat() const override + { + return _lastBeatInfo; + } + + const BeatInfo & LastNearBeat() const override + { + return _lastNearBeatInfo; + } + void SetSimulateBeat(bool b) override { _simulateBeat = b; @@ -490,6 +536,7 @@ class SoundAnalyzerBase : public ISoundAnalyzer PeakData _vPeaks{}; // Normalized band energies 0..1 PeakData _livePeaks{}; // Attack-limited display peaks per band PeakData _Peaks{}; // cached last normalized peaks + PeakData _beatPeaks{}; // Beat-only peaks derived before display autoscale/attack limiting std::array _bandBinStart{}; std::array _bandBinEnd{}; float _energyMaxEnv = 0.01f; // adaptive envelope for autoscaling (start low for fast adaptation) @@ -508,6 +555,22 @@ class SoundAnalyzerBase : public ISoundAnalyzer float _peak1DecayRate = 1.25f; float _peak2DecayRate = 1.25f; + // Keep beat state next to the analyzer so effects observe one shared pulse. + BeatInfo _lastBeatInfo{}; + BeatInfo _lastNearBeatInfo{}; + PeakData _previousBeatPeaks{}; + float _beatScoreBaseline = 0.0f; + float _beatFluxBaseline = 0.0f; + float _beatBassBaseline = 0.0f; + float _beatScoreDeviation = 0.0f; + float _beatFluxDeviation = 0.0f; + float _beatBassDeviation = 0.0f; + float _previousBeatIntervalMs = 500.0f; + uint32_t _lastBeatDetectedMs = 0; + uint32_t _lastBeatDebugMs = 0; + uint32_t _lastSimulatedBeatIndex = 0; + bool _hasSimulatedBeat = false; + static constexpr int kBandOffset = 2; // number of lowest source bands to skip in layout (skip bins 0,1,2) std::array _vReal{}; std::array _vImaginary{}; @@ -527,6 +590,11 @@ class SoundAnalyzerBase : public ISoundAnalyzer void SampleAudio(); void UpdateVU(float newval); void ComputeBandLayout(); + void ResetFrameState(); + void ResetBeatDetection(); + void UpdateBeatDetection(); + void RecordBeat(uint32_t now, float confidence, float strength, float bass, float mid, float treble, float flux, bool simulated); + void RecordNearBeat(uint32_t now, float score, float strength, float bass, float mid, float treble, float flux); // Energy spectrum processing (implemented inline or in template) // diff --git a/include/systemcontainer.h b/include/systemcontainer.h index 4d54728d7..829a098f1 100644 --- a/include/systemcontainer.h +++ b/include/systemcontainer.h @@ -53,6 +53,7 @@ class Screen; class SocketServer; class WebSocketServer; class CWebServer; +class WS281xOutputManager; namespace nd_network { class NetworkReader; } using nd_network::NetworkReader; @@ -91,6 +92,10 @@ class SystemContainer std::unique_ptr _ptrSocketServer; #endif + #if USE_WS281X + std::unique_ptr _ptrWS281xOutputManager; + #endif + #if WEB_SOCKETS_ANY_ENABLED std::unique_ptr _ptrWebSocketServer; #endif @@ -130,6 +135,8 @@ class SystemContainer // Config objects void SetupConfig(); + bool ApplyRuntimeConfiguration(String* errorMessage = nullptr); + int GetConfiguredAudioInputPin() const; bool HasJSONWriter() const { return !!_ptrJSONWriter; } JSONWriter& GetJSONWriter() const; bool HasDeviceConfig() const { return !!_ptrDeviceConfig; } @@ -153,6 +160,12 @@ class SystemContainer SocketServer& GetSocketServer() const; #endif + #if USE_WS281X + WS281xOutputManager& SetupWS281xOutputManager(); + bool HasWS281xOutputManager() const { return !!_ptrWS281xOutputManager; } + WS281xOutputManager& GetWS281xOutputManager() const; + #endif + #if WEB_SOCKETS_ANY_ENABLED WebSocketServer& SetupWebSocketServer(CWebServer& webServer); bool HasWebSocketServer() const { return !!_ptrWebSocketServer; } diff --git a/include/webserver.h b/include/webserver.h index 1c51bc2af..29ca8d2da 100644 --- a/include/webserver.h +++ b/include/webserver.h @@ -167,7 +167,9 @@ class CWebServer static bool IsPostParamTrue(AsyncWebServerRequest * pRequest, const String & paramName); static const std::vector> & LoadDeviceSettingSpecs(); static void SendSettingSpecsResponse(AsyncWebServerRequest * pRequest, const std::vector> & settingSpecs); - static void SetSettingsIfPresent(AsyncWebServerRequest * pRequest); + static bool ValidateLegacyDeviceSettings(AsyncWebServerRequest * pRequest, String* errorMessage = nullptr); + static bool ValidateUnifiedDeviceSettings(JsonObjectConst device, String* errorMessage = nullptr); + static bool SetSettingsIfPresent(AsyncWebServerRequest * pRequest, String* errorMessage = nullptr); static long GetEffectIndexFromParam(AsyncWebServerRequest * pRequest, bool post = false); static bool CheckAndGetSettingsEffect(AsyncWebServerRequest * pRequest, std::shared_ptr & effect, bool post = false); static void SendEffectSettingsResponse(AsyncWebServerRequest * pRequest, std::shared_ptr & effect); @@ -179,6 +181,9 @@ class CWebServer static void GetSettingSpecs(AsyncWebServerRequest * pRequest); static void GetSettings(AsyncWebServerRequest * pRequest); static void SetSettings(AsyncWebServerRequest * pRequest); + static void GetUnifiedSettings(AsyncWebServerRequest * pRequest); + static void GetUnifiedSettingsSchema(AsyncWebServerRequest * pRequest); + static void SetUnifiedSettings(AsyncWebServerRequest * pRequest, JsonVariantConst json); static void GetEffectSettingSpecs(AsyncWebServerRequest * pRequest); static void GetEffectSettings(AsyncWebServerRequest * pRequest); static void SetEffectSettings(AsyncWebServerRequest * pRequest); diff --git a/include/ws281xgfx.h b/include/ws281xgfx.h index f2e5a2054..a88fffb88 100644 --- a/include/ws281xgfx.h +++ b/include/ws281xgfx.h @@ -35,6 +35,8 @@ #include "gfxbase.h" +class DeviceConfig; + // WS281xGfx // // A derivation of GFXBase that adds LED-strip-specific functionality @@ -42,8 +44,6 @@ class WS281xGFX : public GFXBase { protected: - static void AddLEDs(std::vector>& devices); - public: WS281xGFX(size_t w, size_t h); @@ -51,6 +51,8 @@ class WS281xGFX : public GFXBase ~WS281xGFX() override; static void InitializeHardware(std::vector>& devices); + static void ApplyCompiledTransportConfiguration(const DeviceConfig& deviceConfig, const std::vector>& devices, const char* reason); + void ConfigureTopology(size_t width, size_t height, bool serpentine) override; // PostProcessFrame // diff --git a/include/ws281xoutputmanager.h b/include/ws281xoutputmanager.h new file mode 100644 index 000000000..550206cd0 --- /dev/null +++ b/include/ws281xoutputmanager.h @@ -0,0 +1,65 @@ +#pragma once + +//+-------------------------------------------------------------------------- +// +// File: ws281xoutputmanager.h +// +// NightDriverStrip - (c) 2018 Plummer's Software LLC. All Rights Reserved. +// +// Runtime WS281x output manager. Keeps FastLED for effect math and CRGB buffers, +// but moves GPIO/channel binding into a reconfigurable ESP32 output layer. +// +//--------------------------------------------------------------------------- + +#include "globals.h" + +#if USE_WS281X + +#include +#include +#include + +#include "deviceconfig.h" +#if FASTLED_RMT5 +#include "platforms/esp/32/rmt_5/idf5_rmt.h" +#else +#include "platforms/esp/32/rmt_4/idf4_rmt.h" +#endif + +class GFXBase; + +#if FASTLED_RMT5 +using WS281xRuntimeController = fl::RmtController5; +#else +using WS281xRuntimeController = RmtController; +#endif + +class WS281xOutputManager +{ + struct ChannelState + { + int8_t pin = -1; + size_t ledCount = 0; + bool active = false; + std::unique_ptr controller; + }; + + std::array _channels{}; + size_t _activeChannelCount = 0; + size_t _activeLEDCount = 0; + + bool RecreateChannel(size_t channelIndex, int8_t pin, size_t ledCount, String* errorMessage); + void ReleaseChannel(size_t channelIndex); + + public: + WS281xOutputManager() = default; + ~WS281xOutputManager(); + + bool ApplyConfig(const DeviceConfig& config, const std::vector>& devices, String* errorMessage = nullptr); + void Show(const std::vector>& devices, uint16_t pixelsDrawn, uint8_t brightness); + + size_t GetActiveChannelCount() const { return _activeChannelCount; } + size_t GetActiveLEDCount() const { return _activeLEDCount; } +}; + +#endif diff --git a/platformio.ini b/platformio.ini index 06dfa3882..216508102 100644 --- a/platformio.ini +++ b/platformio.ini @@ -33,7 +33,7 @@ extra_configs = upload_port = monitor_port = build_type = release -upload_speed = 2000000 +upload_speed = 4000000 monitor_speed = 115200 build_flags = -std=gnu++2a -g3 @@ -360,7 +360,7 @@ lib_deps = https://github.com/Xinyuan-LilyGO/TTGO-T-Display [env] platform = platformio/espressif32 @ ^6.12.0 framework = arduino -build_type = release +build_type = debug build_unflags = -std=gnu++11 lib_extra_dirs = ${PROJECT_DIR}/lib monitor_filters = esp32_exception_decoder @@ -434,7 +434,7 @@ build_src_flags = -DPROJECT_NAME="\"Mesmerizer\"" -DMATRIX_HEIGHT=32 -DNUM_BANDS=16 -DIR_REMOTE_PIN=39 - -DINPUT_PIN=36 + -DAUDIO_INPUT_PIN=36 -DTOGGLE_BUTTON_0=0 lib_deps = https://github.com/PlummersSoftwareLLC/SmartMatrix.git @@ -485,16 +485,18 @@ board_build.partitions = config/partitions_custom_noota_v3.csv ; This is the basic DEMO project again, but expanded to work on the M5, which means it can draw to ; the built in LCD. It's made so that you can connect to the small 4-pin connector on the M5, ; so it sends power and ground as well as data on PIN 32. -[env:m5demo] + +[m5demo] extends = dev_m5stick_c build_flags = ${dev_m5stick_c.build_flags} build_src_flags = ${dev_m5stick_c.build_src_flags} -DM5DEMO=1 -DEFFECTS_DEMO=1 + -DUSE_WS281X=1 -DPROJECT_NAME="\"M5Demo\"" -DMATRIX_WIDTH=144*5+38 -DMATRIX_HEIGHT=1 - -DCOLOR_ORDER=EOrder::GRB + -DCOLOR_ORDER=EOrder::RGB -DENABLE_AUDIOSERIAL=0 -DENABLE_WIFI=1 -DINCOMING_WIFI_ENABLED=1 @@ -514,7 +516,7 @@ build_src_flags = -DUSE_MATRIX=1 -DUSE_HUB75=0 -DUSE_WS281X=1 -DNUM_CHANNELS=1 - -DEFFECTS_FULLMATRIX=1 + -DEFFECTS_DEMO=1 -DMATRIX_WIDTH=64 -DMATRIX_HEIGHT=32 -DSHOW_VU_METER=1 @@ -532,7 +534,7 @@ build_src_flags = -DUSE_MATRIX=1 -DENABLE_AUDIO=1 -DCOLORDATA_SERVER_ENABLED=0 -DDEFAULT_EFFECT_INTERVAL=0 - -DLED_PIN0=-1 + -DLED_PIN0=32 -DDEFAULT_INFO_PAGE=2 -DNUM_BANDS=16 @@ -543,14 +545,9 @@ board_build.embed_files = ${embed_matrix_assets.board_build.embed_files} [env:m5plusdemo] extends = dev_m5stick_c_plus2 build_flags = ${dev_m5stick_c_plus2.build_flags} - ${m5demobase.build_flags} + ${m5demo.build_flags} build_src_flags = ${dev_m5stick_c_plus2.build_src_flags} - ${m5demobase.build_src_flags} - -DPROJECT_NAME="\"M5Plus2Demo\"" - -lib_deps = ${dev_m5stick_c_plus2.lib_deps} - ${m5demobase.lib_deps} -board_build.embed_files = ${m5demobase.board_build.embed_files} + ${m5demo.build_src_flags} ; The _v2 and _v3 envs are intended for on-going development efforts by the project maintainers. ; Use them at your own risk. @@ -683,7 +680,7 @@ build_src_flags = ${dev_lilygo_amoled.build_src_flags} -DCOLORDATA_SERVER_ENABLED=0 -DDEFAULT_EFFECT_INTERVAL=60*60*24*5 -DNUM_BANDS=64 - -DINPUT_PIN=41 + -DAUDIO_INPUT_PIN=41 -DUSE_I2S_AUDIO=1 -DI2S_BCLK_PIN=39 -DI2S_WS_PIN=42 @@ -781,6 +778,7 @@ build_flags = ${dev_m5stick_c_plus.build_flags} build_src_flags = ${dev_m5stick_c_plus.build_src_flags} -DSPECTRUM=1 -DEFFECTS_SPECTRUM=1 + -DUSE_WS281X=1 -DPROJECT_NAME="\"Spectrum\"" -DENABLE_AUDIOSERIAL=0 -DENABLE_WIFI=1 @@ -804,12 +802,17 @@ build_src_flags = ${dev_m5stick_c_plus.build_src_flags} -DMATRIX_HEIGHT=16 -DNUM_BANDS=16 -DSHOW_VU_METER=1 + ; Spectrum builds often use an external IR receiver, so keep the shared remote_flags hook + ; alive here. That lets a local custom_*.ini restore the correct GPIO without hardcoding it. + ${remote_flags.build_flags} + ${dev_m5stick_c_plus.build_flags} [env:spectrum2] extends = dev_m5stick_c_plus2 build_flags = ${dev_m5stick_c_plus2.build_flags} build_src_flags = ${dev_m5stick_c_plus2.build_src_flags} -DSPECTRUM=1 + -DUSE_WS281X=1 -DEFFECTS_FULLMATRIX=1 -DUSE_MATRIX=1 -DPROJECT_NAME="\"Spectrum2\"" @@ -834,7 +837,9 @@ build_src_flags = ${dev_m5stick_c_plus2.build_src_flags} -DMATRIX_HEIGHT=16 -DNUM_BANDS=16 -DSHOW_VU_METER=1 - + ; StickC Plus2 only exposes IR transmit on-board; receive usually comes from an external sensor. + ; Preserve the remote_flags override path so each setup can supply its actual receiver GPIO. + ${remote_flags.build_flags} lib_deps = ${base.graphics_deps} ${dev_m5stick_c_plus2.lib_deps} board_build.embed_files = ${m5demobase.board_build.embed_files} @@ -1197,7 +1202,7 @@ build_src_flags = ${dev_esp32.build_src_flags} -DNUM_BANDS=48 -DMAX_SAMPLES=512 -DUSE_I2S_AUDIO=1 - -DINPUT_PIN=33 + -DAUDIO_INPUT_PIN=33 -DI2S_BCLK_PIN=26 -DI2S_WS_PIN=25 -DDEFAULT_EFFECT_INTERVAL=60*60*24 diff --git a/src/audio.cpp b/src/audio.cpp index 6829b3511..3aa906672 100644 --- a/src/audio.cpp +++ b/src/audio.cpp @@ -42,6 +42,7 @@ #if ENABLE_AUDIO #include "nd_network.h" #include "soundanalyzer.h" +#include "systemcontainer.h" #include "time_utils.h" // AudioSamplerTaskEntry @@ -51,8 +52,13 @@ void IRAM_ATTR AudioSamplerTaskEntry(void *) { debugI(">>> Sampler Task Started"); - // Enable microphone input - pinMode(INPUT_PIN, INPUT); + // M5 boards sample through M5.Mic/M5Unified, not the generic AUDIO_INPUT_PIN path. + // Only configure AUDIO_INPUT_PIN for the external mic configurations that actually consume it. + #if !USE_M5 + const auto audioInputPin = g_ptrSystem->GetConfiguredAudioInputPin(); + if (audioInputPin >= 0) + pinMode(audioInputPin, INPUT); + #endif g_Analyzer.InitAudioInput(); @@ -67,7 +73,7 @@ void IRAM_ATTR AudioSamplerTaskEntry(void *) // VURatio with a fadeout static auto lastVU = 0.0f; - constexpr auto VU_DECAY_PER_SECOND = 6.00; + constexpr auto VU_DECAY_PER_SECOND = 9.00; // Get the elapsed time since the last frame. We'll calculate this at the right spot from the first loop onwards static auto frameDurationSeconds = (millis() - lastFrame) / 1000.0; diff --git a/src/deviceconfig.cpp b/src/deviceconfig.cpp index 924752aa3..0a85d26fd 100644 --- a/src/deviceconfig.cpp +++ b/src/deviceconfig.cpp @@ -31,6 +31,9 @@ #include "globals.h" #include +#include +#include +#include #include #include #include @@ -42,6 +45,38 @@ extern const char timezones_start[] asm("_binary_config_timezones_json_start"); +namespace +{ + constexpr const char* kRecompileNeededMessage = "recompile needed"; + + constexpr std::array kCompiledWS281xPins = { + #if NUM_CHANNELS >= 1 + LED_PIN0, + #endif + #if NUM_CHANNELS >= 2 + LED_PIN1, + #endif + #if NUM_CHANNELS >= 3 + LED_PIN2, + #endif + #if NUM_CHANNELS >= 4 + LED_PIN3, + #endif + #if NUM_CHANNELS >= 5 + LED_PIN4, + #endif + #if NUM_CHANNELS >= 6 + LED_PIN5, + #endif + #if NUM_CHANNELS >= 7 + LED_PIN6, + #endif + #if NUM_CHANNELS >= 8 + LED_PIN7, + #endif + }; +} + // DeviceConfig holds, persists and loads device-wide configuration settings. Effect-specific settings should // be managed using overrides of the respective methods in LEDStripEffect (mainly FillSettingSpecs(), // SerializeSettingsToJSON() and SetSetting()). @@ -66,8 +101,58 @@ void DeviceConfig::SaveToJSON() const g_ptrSystem->GetJSONWriter().FlagWriter(writerIndex); } +std::array DeviceConfig::GetCompiledWS281xPins() +{ + return kCompiledWS281xPins; +} + +const char* DeviceConfig::DriverName(OutputDriver driver) +{ + switch (driver) + { + case OutputDriver::HUB75: + return "hub75"; + + case OutputDriver::WS281x: + default: + return "ws281x"; + } +} + +bool DeviceConfig::IsHub75Build() +{ + return GetCompiledOutputDriver() == OutputDriver::HUB75; +} + +void DeviceConfig::LogRuntimeConfig(const char* reason) const +{ + String activePins; + for (size_t i = 0; i < runtimeOutputs.channelCount && i < runtimeOutputs.outputPins.size(); ++i) + { + if (!activePins.isEmpty()) + activePins += ','; + activePins += String(runtimeOutputs.outputPins[i]); + } + + debugI("Runtime config (%s): driver=%s matrix=%ux%u leds=%u serpentine=%d channels=%u audioPin=%d", + reason, + DriverName(runtimeOutputs.driver), + runtimeTopology.width, + runtimeTopology.height, + static_cast(GetActiveLEDCount()), + runtimeTopology.serpentine, + static_cast(runtimeOutputs.channelCount), + audioInputPin); + debugI("Runtime config pins (%s): activeChannels=%s", reason, activePins.c_str()); +} + DeviceConfig::DeviceConfig() { + runtimeTopology.serpentine = !IsHub75Build(); + runtimeOutputs.driver = GetCompiledOutputDriver(); + runtimeOutputs.channelCount = NUM_CHANNELS; + runtimeOutputs.outputPins = GetCompiledWS281xPins(); + writerIndex = g_ptrSystem->GetJSONWriter().RegisterWriter( [this] { assert(SaveToJSONFile(DEVICE_CONFIG_FILE, *this)); } ); @@ -88,6 +173,8 @@ DeviceConfig::DeviceConfig() SaveToJSON(); } + + LogRuntimeConfig("init"); } bool DeviceConfig::SerializeToJSON(JsonObject& jsonObject) @@ -118,6 +205,16 @@ bool DeviceConfig::SerializeToJSON(JsonObject& jsonObject, bool includeSensitive jsonDoc[GlobalColorTag] = globalColor; jsonDoc[ApplyGlobalColorsTag] = applyGlobalColors; jsonDoc[SecondColorTag] = secondColor; + jsonDoc[AudioInputPinTag] = audioInputPin; + jsonDoc[MatrixWidthTag] = runtimeTopology.width; + jsonDoc[MatrixHeightTag] = runtimeTopology.height; + jsonDoc[MatrixSerpentineTag] = runtimeTopology.serpentine; + jsonDoc[OutputDriverTag] = DriverName(runtimeOutputs.driver); + jsonDoc[WS281xChannelCountTag] = runtimeOutputs.channelCount; + + auto ws281xPins = jsonDoc[WS281xPinsTag].to(); + for (auto pin : runtimeOutputs.outputPins) + ws281xPins.add(pin); if (includeSensitive) jsonDoc[OpenWeatherApiKeyTag] = openWeatherApiKey; @@ -148,6 +245,10 @@ bool DeviceConfig::DeserializeFromJSON(const JsonObjectConst& jsonObject, bool s SetIfPresentIn(jsonObject, rememberCurrentEffect, RememberCurrentEffectTag); SetIfPresentIn(jsonObject, powerLimit, PowerLimitTag); SetIfPresentIn(jsonObject, brightness, BrightnessTag); + // Persisted config predates the newer brightness guardrails in some installs, so treat an invalid + // saved brightness as "unset" and fall back to the normal 100% default instead of booting dark. + if (brightness < BRIGHTNESS_MIN || brightness > BRIGHTNESS_MAX) + brightness = BRIGHTNESS_MAX; // Only deserialize showVUMeter if the VU meter is enabled in the build #if SHOW_VU_METER SetIfPresentIn(jsonObject, showVUMeter, ShowVUMeterTag); @@ -155,12 +256,50 @@ bool DeviceConfig::DeserializeFromJSON(const JsonObjectConst& jsonObject, bool s SetIfPresentIn(jsonObject, globalColor, GlobalColorTag); SetIfPresentIn(jsonObject, applyGlobalColors, ApplyGlobalColorsTag); SetIfPresentIn(jsonObject, secondColor, SecondColorTag); + if (jsonObject[AudioInputPinTag].is()) + { + const int persistedAudioInputPin = jsonObject[AudioInputPinTag].as(); + auto [pinValid, _] = ValidateAudioInputPin(persistedAudioInputPin); + audioInputPin = pinValid ? persistedAudioInputPin : GetCompiledAudioInputPin(); + } + + RuntimeConfig updated = GetRuntimeConfig(); + + SetIfPresentIn(jsonObject, updated.topology.width, MatrixWidthTag); + SetIfPresentIn(jsonObject, updated.topology.height, MatrixHeightTag); + SetIfPresentIn(jsonObject, updated.topology.serpentine, MatrixSerpentineTag); + + if (jsonObject[OutputDriverTag].is()) + { + const auto driverName = jsonObject[OutputDriverTag].as(); + if (driverName == DriverName(OutputDriver::HUB75)) + updated.outputs.driver = OutputDriver::HUB75; + else if (driverName == DriverName(OutputDriver::WS281x)) + updated.outputs.driver = OutputDriver::WS281x; + } + + if (jsonObject[WS281xChannelCountTag].is()) + updated.outputs.channelCount = jsonObject[WS281xChannelCountTag].as(); + + if (jsonObject[WS281xPinsTag].is()) + { + auto pinArray = jsonObject[WS281xPinsTag].as(); + for (size_t i = 0; i < updated.outputs.outputPins.size() && i < pinArray.size(); ++i) + { + if (pinArray[i].is()) + updated.outputs.outputPins[i] = pinArray[i].as(); + } + } + + String runtimeConfigError; + if (!SetRuntimeConfig(updated, true, &runtimeConfigError)) + debugW("Ignoring invalid persisted runtime config: %s", runtimeConfigError.c_str()); if (ntpServer.isEmpty()) ntpServer = NTP_SERVER_DEFAULT; if (jsonObject[TimeZoneTag].is()) - return SetTimeZone(jsonObject[TimeZoneTag], true); + return SetTimeZoneInternal(jsonObject[TimeZoneTag], true); if (!skipWrite) SaveToJSON(); @@ -301,6 +440,51 @@ const std::vector>& DeviceConfig::GetSetting "by some effects. Defaults to the previous global color if not explicitly set.", SettingSpec::SettingType::Color ); + settingSpecs.emplace_back( + MatrixWidthTag, + "Matrix width", + "Active matrix width. WS281x builds validate this by total LED capacity, so width * height must stay within the compiled LED budget.", + SettingSpec::SettingType::PositiveBigInteger, + 1, + GetCompiledLEDCount() + ); + settingSpecs.emplace_back( + MatrixHeightTag, + "Matrix height", + "Active matrix height. WS281x builds validate this by total LED capacity, so width * height must stay within the compiled LED budget.", + SettingSpec::SettingType::PositiveBigInteger, + 1, + GetCompiledLEDCount() + ); + settingSpecs.emplace_back( + MatrixSerpentineTag, + "Serpentine layout", + "Controls the logical XY mapping for strip-based matrices. HUB75 ignores this because its panel mapping is build-defined.", + SettingSpec::SettingType::Boolean + ); + auto& audioInputPinSpec = settingSpecs.emplace_back( + AudioInputPinTag, + "Audio input pin", + "External microphone input pin. This is boot-applied today because the audio task still owns the active DMA/I2S handles once sampling starts.", + SettingSpec::SettingType::Integer, + -1, + 48 + ); + audioInputPinSpec.HasValidation = true; + settingSpecs.emplace_back( + OutputDriverTag, + "Output driver", + "Runtime-selected driver. If this differs from the firmware's compiled driver, the API reports recompile required.", + SettingSpec::SettingType::String + ); + settingSpecs.emplace_back( + WS281xChannelCountTag, + "WS281x channel count", + "Number of active strip channels within the compiled maximum. Live updates are limited to WS281x builds.", + SettingSpec::SettingType::PositiveBigInteger, + 1, + GetCompiledChannelCount() + ); settingSpecReferences.insert(settingSpecReferences.end(), settingSpecs.begin(), settingSpecs.end()); } @@ -358,19 +542,22 @@ void DeviceConfig::SetRememberCurrentEffect(bool newRememberCurrentEffect) SetAndSave(rememberCurrentEffect, newRememberCurrentEffect); } -DeviceConfig::ValidateResponse DeviceConfig::ValidateBrightness(const String& newBrightness) +DeviceConfig::ValidateResponse DeviceConfig::ValidateBrightness(int newBrightness) { - auto newNumericBrightness = newBrightness.toInt(); - - if (newNumericBrightness < BRIGHTNESS_MIN) + if (newBrightness < BRIGHTNESS_MIN) return { false, String("brightness is below minimum value of ") + BRIGHTNESS_MIN }; - if (newNumericBrightness > BRIGHTNESS_MAX) + if (newBrightness > BRIGHTNESS_MAX) return { false, String("brightness is above maximum value of ") + BRIGHTNESS_MAX }; return { true, "" }; } +DeviceConfig::ValidateResponse DeviceConfig::ValidateBrightness(const String& newBrightness) +{ + return ValidateBrightness(newBrightness.toInt()); +} + void DeviceConfig::SetBrightness(int newBrightness) { SetAndSave(brightness, uint8_t(std::clamp(newBrightness, BRIGHTNESS_MIN, BRIGHTNESS_MAX))); @@ -386,14 +573,19 @@ void DeviceConfig::SetShowVUMeter(bool newShowVUMeter) #endif } -DeviceConfig::ValidateResponse DeviceConfig::ValidatePowerLimit(const String& newPowerLimit) +DeviceConfig::ValidateResponse DeviceConfig::ValidatePowerLimit(int newPowerLimit) { - if (newPowerLimit.toInt() < POWER_LIMIT_MIN) + if (newPowerLimit < POWER_LIMIT_MIN) return { false, String("powerLimit is below minimum value of ") + POWER_LIMIT_MIN }; return { true, "" }; } +DeviceConfig::ValidateResponse DeviceConfig::ValidatePowerLimit(const String& newPowerLimit) +{ + return ValidatePowerLimit(newPowerLimit.toInt()); +} + void DeviceConfig::SetPowerLimit(int newPowerLimit) { if (newPowerLimit >= POWER_LIMIT_MIN) @@ -420,8 +612,46 @@ void DeviceConfig::SetSecondColor(const CRGB& newSecondColor) SetAndSave(secondColor, newSecondColor); } +DeviceConfig::ValidateResponse DeviceConfig::ValidateAudioInputPin(int pin) const +{ + if (pin < -1) + return { false, "audio input pin must be -1 or a valid GPIO" }; + + if (pin == GetCompiledAudioInputPin()) + return { true, "" }; + + // The settings API now separates "compiled default" from "active value". External I2S mics can + // move their DIN pin at boot, but the M5 onboard mic path and the current ADC path are still fixed. + if (!SupportsConfigurableAudioInputPin()) + return { false, kRecompileNeededMessage }; + + if (pin == -1) + return { true, "" }; + + if (!GPIO_IS_VALID_GPIO(static_cast(pin))) + return { false, "audio input pin must be a valid GPIO" }; + + return { true, "" }; +} + +void DeviceConfig::SetAudioInputPin(int newAudioInputPin) +{ + auto [isValid, _] = ValidateAudioInputPin(newAudioInputPin); + if (!isValid) + return; + + if (audioInputPin == newAudioInputPin) + return; + + SetAndSave(audioInputPin, static_cast(newAudioInputPin)); + LogRuntimeConfig("audio input pin changed"); +} + +// This helper separates "apply the timezone to the running process" from "persist a user edit". +// Startup/config-load needs to set TZ immediately so localtime() is correct, but it must not +// immediately rewrite device.cfg just because we re-applied the already-persisted value. // The timezone JSON file used by this logic is generated using tools/gen-tz-json.py -bool DeviceConfig::SetTimeZone(const String& newTimeZone, bool skipWrite) +bool DeviceConfig::SetTimeZoneInternal(const String& newTimeZone, bool skipWrite) { String quotedTZ = "\n\"" + newTimeZone + '"'; @@ -461,6 +691,11 @@ bool DeviceConfig::SetTimeZone(const String& newTimeZone, bool skipWrite) return true; } +bool DeviceConfig::SetTimeZone(const String& newTimeZone, bool skipWrite) +{ + return SetTimeZoneInternal(newTimeZone, skipWrite); +} + #if ENABLE_WIFI DeviceConfig::ValidateResponse DeviceConfig::ValidateOpenWeatherAPIKey(const String &newOpenWeatherAPIKey) { @@ -558,3 +793,115 @@ void DeviceConfig::ApplyColorSettings(std::optional newGlobalColor, std::o g_ptrSystem->GetEffectManager().ApplyGlobalColor(finalGlobalColor); } } + +DeviceConfig::ValidateResponse DeviceConfig::ValidateTopology(uint16_t width, uint16_t height, bool serpentine) const +{ + if (width == 0 || height == 0) + return { false, "matrix dimensions must be greater than zero" }; + + // The strip path sizes its live buffers from total LED capacity, not the original compile-time aspect ratio. + // That keeps reshaping flexible while still refusing requests that would outgrow the compiled backing store. + if (static_cast(width) * height > GetCompiledLEDCount()) + return { false, kRecompileNeededMessage }; + + if (IsHub75Build()) + { + if (width != GetCompiledMatrixWidth() || height != GetCompiledMatrixHeight()) + return { false, kRecompileNeededMessage }; + + if (serpentine != runtimeTopology.serpentine) + return { false, kRecompileNeededMessage }; + } + + return { true, "" }; +} + +DeviceConfig::ValidateResponse DeviceConfig::ValidateOutputDriver(OutputDriver driver) const +{ + if (driver != GetCompiledOutputDriver()) + return { false, kRecompileNeededMessage }; + + return { true, "" }; +} + +DeviceConfig::ValidateResponse DeviceConfig::ValidateWS281xSettings(size_t channelCount, const std::array& pins) const +{ + if (channelCount == 0) + return { false, "channel count must be greater than zero" }; + + if (channelCount > GetCompiledChannelCount()) + return { false, kRecompileNeededMessage }; + + if (IsHub75Build()) + { + if (channelCount != GetCompiledChannelCount()) + return { false, kRecompileNeededMessage }; + + if (pins != GetCompiledWS281xPins()) + return { false, kRecompileNeededMessage }; + } + + for (size_t i = 0; i < channelCount; ++i) + { + if (pins[i] < 0) + return { false, "active channels require valid GPIO pins" }; + + for (size_t j = i + 1; j < channelCount; ++j) + { + if (pins[i] == pins[j]) + return { false, "WS281x channel pins must be unique" }; + } + } + + return { true, "" }; +} + +DeviceConfig::ValidateResponse DeviceConfig::ValidateRuntimeConfig(const RuntimeConfig& config) const +{ + auto [driverValid, driverMessage] = ValidateOutputDriver(config.outputs.driver); + if (!driverValid) + return { false, driverMessage }; + + auto [topologyValid, topologyMessage] = ValidateTopology(config.topology.width, config.topology.height, config.topology.serpentine); + if (!topologyValid) + return { false, topologyMessage }; + + auto [ws281xValid, ws281xMessage] = ValidateWS281xSettings(config.outputs.channelCount, config.outputs.outputPins); + if (!ws281xValid) + return { false, ws281xMessage }; + + return { true, "" }; +} + +bool DeviceConfig::SetRuntimeConfig(const RuntimeConfig& config, bool skipWrite, String* errorMessage) +{ + auto [isValid, validationMessage] = ValidateRuntimeConfig(config); + if (!isValid) + { + if (errorMessage) + *errorMessage = validationMessage; + return false; + } + + const bool changed = + runtimeTopology.width != config.topology.width + || runtimeTopology.height != config.topology.height + || runtimeTopology.serpentine != config.topology.serpentine + || runtimeOutputs.driver != config.outputs.driver + || runtimeOutputs.channelCount != config.outputs.channelCount + || runtimeOutputs.outputPins != config.outputs.outputPins; + + runtimeTopology = config.topology; + runtimeOutputs = config.outputs; + + if (!skipWrite) + SaveToJSON(); + + if (changed && !skipWrite) + LogRuntimeConfig("runtime config changed"); + + if (errorMessage) + *errorMessage = ""; + + return true; +} diff --git a/src/drawing.cpp b/src/drawing.cpp index 9236a76fd..612776c93 100644 --- a/src/drawing.cpp +++ b/src/drawing.cpp @@ -94,7 +94,7 @@ uint16_t WiFiDraw() { l_usLastWifiDraw = micros(); g_Values.Fader = 255; - debugV("Calling LEDBuffer::Draw from wire with %d/%d pixels.", pixelsDrawn, NUM_LEDS); + debugV("Calling LEDBuffer::Draw from wire with %d/%zu pixels.", pixelsDrawn, pBuffer->_pStrand->GetLEDCount()); pBuffer->DrawBuffer(); // In case we drew some pixels and then drew 0 due a failure, we want to return a positive // number of pixels drawn so the caller knows we did in fact render. @@ -137,8 +137,9 @@ uint16_t LocalDraw() #endif #endif - debugV("LocalDraw claims to have drawn %d pixels", NUM_LEDS); - return NUM_LEDS; + const auto activeLEDCount = g_ptrSystem->GetEffectManager().g().GetLEDCount(); + debugV("LocalDraw claims to have drawn %zu pixels", activeLEDCount); + return activeLEDCount; } else { @@ -226,7 +227,7 @@ void ShowOnboardRGBLED() ledcWrite(2, 255 - c.g); ledcWrite(3, 255 - c.b); #else - int iLed = NUM_LEDS / 2; + int iLed = g_ptrSystem->GetEffectManager().g().GetLEDCount() / 2; const auto& graphics = g_ptrSystem->GetEffectManager().g(); ledcWrite(1, 255 - graphics.leds[iLed].r); // write red component to channel 1, etc. ledcWrite(2, 255 - graphics.leds[iLed].g); diff --git a/src/effectmanager.cpp b/src/effectmanager.cpp index 6a88e1192..a22a08020 100644 --- a/src/effectmanager.cpp +++ b/src/effectmanager.cpp @@ -45,6 +45,7 @@ #include "websocketserver.h" #include "effects/strip/misceffects.h" +#include "effects/strip/musiceffect.h" #if USE_HUB75 #include "hub75gfx.h" #endif @@ -140,9 +141,26 @@ void EffectManager::StartEffect() #endif effect->Start(); + _lastBeatSequence = g_Analyzer.LastBeat().sequence; _effectStartTime = millis(); } +void EffectManager::DispatchBeatIfNeeded() +{ +#if ENABLE_AUDIO + const auto& beat = g_Analyzer.LastBeat(); + if (beat.sequence == 0 || beat.sequence == _lastBeatSequence) + return; + + // Beat callbacks are sequenced here so every active effect sees the same + // detector output, including BeatEffectBase-derived effects via OnBeat(). + auto& currentEffect = GetCurrentEffect(); + currentEffect.OnBeat(beat); + + _lastBeatSequence = beat.sequence; +#endif +} + #ifndef NO_EFFECT_PERSISTENCE #define NO_EFFECT_PERSISTENCE 0 #endif @@ -996,6 +1014,7 @@ void EffectManager::Update() return; CheckEffectTimerExpired(); + DispatchBeatIfNeeded(); if (_tempEffect) _tempEffect->Draw(); diff --git a/src/gfxbase.cpp b/src/gfxbase.cpp index c74edca2c..8a9d1240b 100644 --- a/src/gfxbase.cpp +++ b/src/gfxbase.cpp @@ -1377,34 +1377,53 @@ GFXBase::GFXBase(int w, int h) : Adafruit_GFX(w, h), // Allocate boids for matrix effects (like PatternBounce) when we have matrix dimensions #if MATRIX_HEIGHT > 1 debugV("Allocating boids for matrix effects"); - _boids.reset(psram_allocator().allocate(MATRIX_WIDTH)); + _boids = std::make_unique(_width); assert(_boids); #endif - #if USE_NOISE - debugV("Allocating noise"); - _ptrNoise = std::make_unique(); // Avoid specific PSRAM allocation since highly random access - assert(_ptrNoise); - debugV("Setting up noise"); - NoiseVariablesSetup(); - debugV("Filling noise"); - FillGetNoise(); - #endif - debugV("Setting up palette"); loadPalette(0); ResetOscillators(); } -// Remove the XY macro definition that was set in gfxbase.h. In this file we won't use it beyond this point anyway. -#undef XY +void GFXBase::ConfigureTopology(size_t width, size_t height, bool serpentine) +{ + // The runtime topology work is intentionally routed through GFXBase so effects that already ask g() + // for geometry start honoring live strip layouts without each effect learning about DeviceConfig. + + _width = width; + _height = height; + _ledcount = width * height; + _serpentine = serpentine; + + WIDTH = width; + HEIGHT = height; + + Adafruit_GFX::_width = width; + Adafruit_GFX::_height = height; +} + +#if USE_NOISE +void GFXBase::EnsureNoise() +{ + if (_ptrNoise) + return; + + // Noise is large and only used by a subset of effects. Lazy allocation keeps the boot path leaner + // and still allows runtime topology to stay within the build-time maximum noise backing store. + _ptrNoise = std::make_unique(); + assert(_ptrNoise); + NoiseVariablesSetup(); + FillGetNoise(); +} +#endif // Dirty hack to support FastLED, which calls out of band to get the pixel index for "the" array, without // any indication of which array or who's asking, so we assume the first matrix. If you have trouble with // more than one matrix and some FastLED functions like blur2d, this would be why. -uint16_t XY(uint8_t x, uint8_t y) +uint16_t XY(uint16_t x, uint16_t y) { - static auto& g = g_ptrSystem->GetEffectManager().g(); + auto& g = g_ptrSystem->GetEffectManager().g(); return g.xy(x, y); } diff --git a/src/jsonserializer.cpp b/src/jsonserializer.cpp index e281e818d..080415eff 100644 --- a/src/jsonserializer.cpp +++ b/src/jsonserializer.cpp @@ -246,6 +246,7 @@ size_t JSONWriter::RegisterWriter(const std::function& writer) // Add a writer to the collection. Returns the index of the added writer, for use with FlagWriter() // Add the writer with its flag unset + std::lock_guard lock(writersMutex); writers.push_back(std::make_shared(writer)); return writers.size() - 1; } @@ -255,10 +256,15 @@ void JSONWriter::FlagWriter(size_t index) // Flag a writer for invocation and wake up the task that calls them // Check if we received a valid writer index - if (index >= writers.size()) - return; + std::shared_ptr entry; + { + std::lock_guard lock(writersMutex); + if (index >= writers.size()) + return; + entry = writers[index]; + } - writers[index]->flag.store(true); + entry->flag.store(true); latestFlagMs.store(millis()); g_ptrSystem->GetTaskManager().NotifyJSONWriterThread(); @@ -312,7 +318,14 @@ void IRAM_ATTR JSONWriterTaskEntry(void *) notifyWait = pdMS_TO_TICKS(holdUntil - now); } - for (auto &entryPtr : g_ptrSystem->GetJSONWriter().writers) + std::vector> writersSnapshot; + { + auto& jsonWriter = g_ptrSystem->GetJSONWriter(); + std::lock_guard lock(jsonWriter.writersMutex); + writersSnapshot = jsonWriter.writers; + } + + for (auto &entryPtr : writersSnapshot) { auto& entry = *entryPtr; // Unset flag before we do the actual write. This makes that we don't miss another flag raise if it happens while reading diff --git a/src/ledbuffer.cpp b/src/ledbuffer.cpp index 5eab2cdc4..ee348fe58 100644 --- a/src/ledbuffer.cpp +++ b/src/ledbuffer.cpp @@ -15,7 +15,7 @@ LEDBuffer::LEDBuffer(std::shared_ptr pStrand) : _timeStampMicroseconds(0), _timeStampSeconds(0) { - _leds.reset(psram_allocator().allocate(NUM_LEDS)); + _leds.reset(psram_allocator().allocate(_pStrand->GetLEDCount())); } uint64_t LEDBuffer::Seconds() const { return _timeStampSeconds; } @@ -69,7 +69,7 @@ bool LEDBuffer::UpdateFromWire(std::unique_ptr & payloadData, size_t debugW("Data size mismatch"); return false; } - if (length32 > NUM_LEDS) + if (length32 > _pStrand->GetLEDCount()) { debugW("More data than we have LEDs\n"); return false; @@ -91,6 +91,15 @@ void LEDBuffer::DrawBuffer() _pStrand->fillLeds(_leds); } +void LEDBuffer::Reconfigure(std::shared_ptr pStrand) +{ + _pStrand = std::move(pStrand); + _leds.reset(psram_allocator().allocate(_pStrand->GetLEDCount())); + _pixelCount = 0; + _timeStampMicroseconds = 0; + _timeStampSeconds = 0; +} + // LEDBufferManager // // Manages a circular buffer of LEDBuffer objects. The buffer itself is an array of shared_ptrs to @@ -220,6 +229,18 @@ std::shared_ptr LEDBufferManager::PeekOldestBuffer() const return (*_ppBuffers)[_iLastBuffer]; } +void LEDBufferManager::Reconfigure(const std::shared_ptr& pGFX) +{ + // Runtime topology changes should not leave stale-sized WiFi buffers behind. Resetting the circular + // queue here makes the active transport size match the active graphics context immediately. + for (auto& buffer : *_ppBuffers) + buffer->Reconfigure(pGFX); + + _iNextBuffer = 0; + _iLastBuffer = 0; + _pLastBufferAdded.reset(); +} + // operator[] // // Returns a pointer to the buffer at the specified logical index, or nullptr if empty diff --git a/src/main.cpp b/src/main.cpp index 2e74fae4e..f56343d2f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -467,7 +467,7 @@ void setup() #endif #if INCOMING_WIFI_ENABLED - g_ptrSystem->SetupSocketServer(NetworkPort::IncomingWiFi, NUM_LEDS); // $C000 is free RAM on the C64, fwiw! + g_ptrSystem->SetupSocketServer(NetworkPort::IncomingWiFi, g_ptrSystem->GetDeviceConfig().GetActiveLEDCount()); // $C000 is free RAM on the C64, fwiw! #endif #if ENABLE_WIFI && ENABLE_WEBSERVER @@ -490,9 +490,15 @@ void setup() #if ENABLE_AUDIO { - #if INPUT_PIN - if (INPUT_PIN >= 0) - pinMode(INPUT_PIN, INPUT); + // USE_M5 implies we are using M5Unified which manages the mic pins itself, so we + // skip manual pin setup in that case. For other boards, we set the audio input pin + // according to the current config, which allows the boot-applied pin to match what + // settings report and for settings changes to take effect immediately. + + #if !USE_M5 + const auto audioInputPin = g_ptrSystem->GetConfiguredAudioInputPin(); + if (audioInputPin >= 0) + pinMode(audioInputPin, INPUT); #endif #if TTGO pinMode(37, OUTPUT); // This pin layout allows for mounting a MAX4466 to the backside @@ -524,7 +530,9 @@ void setup() #elif USE_M5 M5.begin(); - // M5 specific setup is now inside M5Screen constructor + // M5Unified boots the panel in portrait. Set landscape before we size the Screen wrapper so + // the screen task and layout code agree on width/height from the start. + M5.Lcd.setRotation(1); g_ptrSystem->SetupHardwareDisplay(M5.Lcd.width(), M5.Lcd.height()); #elif ELECROW diff --git a/src/network.cpp b/src/network.cpp index 6b4054f60..3d7270783 100644 --- a/src/network.cpp +++ b/src/network.cpp @@ -500,10 +500,11 @@ namespace nd_network void DoStatsCommand(const DebugCLI::cli_argv &) { auto &bufferManager = g_ptrSystem->GetBufferManagers()[0]; + const auto& config = g_ptrSystem->GetDeviceConfig(); - DebugCLI::cli_printf("%s:%zux%d %zuK %ddB:%s", + DebugCLI::cli_printf("%s:%zux%zu %zuK %ddB:%s", FLASH_VERSION_NAME, g_ptrSystem->GetDevices().size(), - NUM_LEDS, (size_t)(ESP.getFreeHeap()/1024), abs(WiFi.RSSI()), + config.GetActiveLEDCount(), (size_t)(ESP.getFreeHeap()/1024), abs(WiFi.RSSI()), WiFi.isConnected() ? WiFi.localIP().toString().c_str() : "None"); DebugCLI::cli_printf("BUFR:%02zu/%02zu [%lufps]", (size_t)bufferManager.Depth(), (size_t)bufferManager.BufferCount(), @@ -856,6 +857,7 @@ void IRAM_ATTR ColorDataTaskEntry(void *) auto& graphics = effectManager.g(); auto leds = graphics.leds; + const auto activeLEDCount = graphics.GetLEDCount(); if (frameEventListener.CheckAndClearNewFrameAvailable() && leds != nullptr) { @@ -865,11 +867,11 @@ void IRAM_ATTR ColorDataTaskEntry(void *) // Potentially too large for the stack, so we allocate it on the heap instead std::unique_ptr pPacket = std::make_unique(); pPacket->header = COLOR_DATA_PACKET_HEADER; - pPacket->width = graphics.width(); - pPacket->height = graphics.height(); - memcpy(pPacket->colors, leds, sizeof(CRGB) * NUM_LEDS); + pPacket->width = graphics.GetMatrixWidth(); + pPacket->height = graphics.GetMatrixHeight(); + memcpy(pPacket->colors, leds, sizeof(CRGB) * activeLEDCount); - if (!_viewer.SendPacket(socket, pPacket.get(), sizeof(ColorDataPacket))) + if (!_viewer.SendPacket(socket, pPacket.get(), sizeof(pPacket->header) + sizeof(pPacket->width) + sizeof(pPacket->height) + sizeof(CRGB) * activeLEDCount)) { // If anything goes wrong, we close the socket so it can accept new incoming attempts debugW("Error on color data socket, so closing"); @@ -879,7 +881,7 @@ void IRAM_ATTR ColorDataTaskEntry(void *) } #if COLORDATA_WEB_SOCKET_ENABLED - webSocketServer.SendColorData(leds, NUM_LEDS); + webSocketServer.SendColorData(leds, activeLEDCount); #endif } @@ -894,4 +896,4 @@ void IRAM_ATTR ColorDataTaskEntry(void *) } } #endif // COLORDATA_SERVER_ENABLED -#endif // ENABLE_WIFI \ No newline at end of file +#endif // ENABLE_WIFI diff --git a/src/remotecontrol.cpp b/src/remotecontrol.cpp index 20a990420..a1d2905d5 100644 --- a/src/remotecontrol.cpp +++ b/src/remotecontrol.cpp @@ -218,15 +218,27 @@ static const RemoteColorCode RemoteColorCodes[] = // Native RMT Decoder Implementation (Legacy 4.x API) // --------------------------------------------------------- -#define NEC_DECODE_MARGIN 200 // Tolerance in microseconds +#define NEC_DECODE_MARGIN 300 // Tolerance in microseconds #define RMT_RESOLUTION_HZ 1000000 +namespace +{ + #if USE_WS281X + // Reserve legacy RMT channel 7 for IR receive on WS281x builds. LED output uses channels from 0 upward, + // so the only impossible case is asking for eight LED channels and a remote at the same time. + static_assert(NUM_CHANNELS < 8, "WS281x plus remote exceeds available legacy ESP32 RMT channels"); + #endif + + constexpr int kRemoteRmtChannelIndex = 7; +} + class RemoteControlImpl { public: - RemoteControlImpl(int pin) : _pin(pin), _channel(RMT_CHANNEL_0) {} + RemoteControlImpl(int pin) : _pin(pin), _channel(static_cast(kRemoteRmtChannelIndex)) {} ~RemoteControlImpl() { + detachInterrupt(_pin); if (_begun) { rmt_rx_stop(_channel); rmt_driver_uninstall(_channel); @@ -240,6 +252,8 @@ class RemoteControlImpl config.rx_config.filter_ticks_thresh = 100; // Ignore pulses shorter than 100us config.rx_config.idle_threshold = 20000; // 20ms idle = end of frame + attachInterruptArg(_pin, &RemoteControlImpl::EdgeISR, this, CHANGE); + if (rmt_config(&config) != ESP_OK) return false; if (rmt_driver_install(_channel, 1024, 0) != ESP_OK) return false; if (rmt_get_ringbuf_handle(_channel, &_ringbuf) != ESP_OK) return false; @@ -268,6 +282,11 @@ class RemoteControlImpl RingbufHandle_t _ringbuf = NULL; bool _begun = false; + static void EdgeISR(void* arg) + { + (void)arg; + } + bool match(uint32_t measured, uint32_t target) { return (measured >= (target - NEC_DECODE_MARGIN)) && (measured <= (target + NEC_DECODE_MARGIN)); @@ -297,11 +316,13 @@ class RemoteControlImpl if (symbol_count < 67) return false; uint32_t data = 0; - int bit_idx = 0; - for (int i = 2; i < 66; i += 2) { if (!match(get_time(i), 560)) return false; + // Keep the historical MSB-first assembly here because the existing command tables were + // authored against these numeric decode values. Switching to protocol-pure LSB-first + // assembly changes every key code and breaks matching on working remotes. + data <<= 1; // 560us space = '0', 1690us space = '1' @@ -364,8 +385,6 @@ void RemoteControl::handle() if (result == 0) return; - // debugI("Received IR Remote Code: 0x%08lX %s\n", (unsigned long)result, isRepeat ? "(Repeat)" : ""); - if (isRepeat || result == lastResult) { static uint lastRepeatTime = millis(); diff --git a/src/soundanalyzer.cpp b/src/soundanalyzer.cpp index bd60aee09..6e33662c8 100644 --- a/src/soundanalyzer.cpp +++ b/src/soundanalyzer.cpp @@ -36,6 +36,7 @@ #include #include "soundanalyzer.h" +#include "systemcontainer.h" #include "values.h" #if ENABLE_AUDIO @@ -46,6 +47,43 @@ #include #endif +namespace +{ + int GetConfiguredAudioInputPin() + { + if (g_ptrSystem) + return g_ptrSystem->GetConfiguredAudioInputPin(); + + return AUDIO_INPUT_PIN; + } + + template + float MeanOfHistory(const std::array& values, size_t count) + { + if (count == 0) + return 0.0f; + + const float sum = std::accumulate(values.begin(), values.begin() + count, 0.0f); + return sum / static_cast(count); + } + + template + float StdDevOfHistory(const std::array& values, size_t count, float mean) + { + if (count == 0) + return 0.0f; + + float variance = 0.0f; + for (size_t i = 0; i < count; ++i) + { + const float delta = values[i] - mean; + variance += delta * delta; + } + + return sqrtf(variance / static_cast(count)); + } +} + // SoundAnalyzerBase // // Construct analyzer, allocate buffers (PSRAM-preferred), set initial state. @@ -88,10 +126,53 @@ SoundAnalyzerBase::~SoundAnalyzerBase() // // Reset and clear the FFT buffers void SoundAnalyzerBase::Reset() +{ + debugI("Audio analyzer full reset"); + ResetFrameState(); + _noiseFloor.fill(0.0f); + _rawPrev.fill(0.0f); + _livePeaks.fill(0.0f); + _energyMaxEnv = 0.01f; + _msLastRemoteAudio = 0; + _AudioFPS = 0; + _serialFPS = 0; + _VURatio = 1.0f; + _VURatioFade = 1.0f; + _VU = 0.0f; + _PeakVU = 0.0f; + _MinVU = 0.0f; + _oldVU = 0.0f; + _oldPeakVU = 0.0f; + _oldMinVU = 0.0f; + ResetBeatDetection(); +} + +void SoundAnalyzerBase::ResetFrameState() { _vReal.fill(0.0f); _vImaginary.fill(0.0f); _vPeaks.fill(0.0f); + _Peaks.fill(0.0f); + _beatPeaks.fill(0.0f); +} + +void SoundAnalyzerBase::ResetBeatDetection() +{ + debugV("Beat detector reset"); + _lastBeatInfo = {}; + _lastNearBeatInfo = {}; + _previousBeatPeaks.fill(0.0f); + _beatScoreBaseline = 0.0f; + _beatFluxBaseline = 0.0f; + _beatBassBaseline = 0.0f; + _beatScoreDeviation = 0.0f; + _beatFluxDeviation = 0.0f; + _beatBassDeviation = 0.0f; + _previousBeatIntervalMs = 500.0f; + _lastBeatDetectedMs = 0; + _lastBeatDebugMs = 0; + _lastSimulatedBeatIndex = 0; + _hasSimulatedBeat = false; } // FFT @@ -112,10 +193,10 @@ void SoundAnalyzerBase::FFT() void SoundAnalyzerBase::SampleAudio() { size_t bytesRead = 0; + const auto inputPin = GetConfiguredAudioInputPin(); -#if INPUT_PIN < 0 - return; -#endif + if (inputPin < 0) + return; #if USE_M5 bytesRead = SampleM5(); @@ -210,10 +291,13 @@ float SoundAnalyzerBase::BeatEnhance(float amt) // Entry point for configuring board-specific audio input (M5, I2S Digital, or I2S ADC Analog). void SoundAnalyzerBase::InitAudioInput() { -#if INPUT_PIN < 0 - debugI("Audio: INPUT_PIN < 0, skipping hardware initialization. SimBeat only."); - return; -#endif + const auto inputPin = GetConfiguredAudioInputPin(); + + if (inputPin < 0) + { + debugI("Audio: input pin < 0, skipping hardware initialization. SimBeat only."); + return; + } debugV("Begin InitAudioInput..."); @@ -296,10 +380,177 @@ void SoundAnalyzerBase::SetPeakDataFromRemote(const PeakData &peaks) _msLastRemoteAudio = millis(); _Peaks = peaks; _vPeaks = _Peaks; + _beatPeaks = _Peaks; float sum = std::accumulate(_vPeaks.begin(), _vPeaks.end(), 0.0f); UpdateVU(sum / (float)NUM_BANDS); } +void SoundAnalyzerBase::RecordBeat(uint32_t now, float confidence, float strength, float bass, float mid, float treble, float flux, bool simulated) +{ + const float intervalMs = (_lastBeatDetectedMs == 0) ? _previousBeatIntervalMs : static_cast(now - _lastBeatDetectedMs); + + _lastBeatDetectedMs = now; + _previousBeatIntervalMs = (_lastBeatInfo.sequence == 0) + ? intervalMs + : ((_previousBeatIntervalMs * 0.75f) + (intervalMs * 0.25f)); + + _lastBeatInfo.sequence++; + _lastBeatInfo.timestampMs = now; + _lastBeatInfo.intervalMs = intervalMs; + _lastBeatInfo.msPerBeat = _previousBeatIntervalMs; + _lastBeatInfo.bpm = (_previousBeatIntervalMs > 1.0f) ? (60000.0f / _previousBeatIntervalMs) : 0.0f; + _lastBeatInfo.confidence = confidence; + _lastBeatInfo.strength = strength; + _lastBeatInfo.bass = bass; + _lastBeatInfo.mid = mid; + _lastBeatInfo.treble = treble; + _lastBeatInfo.flux = flux; + _lastBeatInfo.vu = _VU; + _lastBeatInfo.vuRatio = _VURatio; + _lastBeatInfo.major = confidence >= 0.95f || strength >= 1.90f; + _lastBeatInfo.simulated = simulated; +} + +void SoundAnalyzerBase::RecordNearBeat(uint32_t now, float score, float strength, float bass, float mid, float treble, float flux) +{ + _lastNearBeatInfo.sequence++; + _lastNearBeatInfo.timestampMs = now; + _lastNearBeatInfo.intervalMs = 0.0f; + _lastNearBeatInfo.msPerBeat = _previousBeatIntervalMs; + _lastNearBeatInfo.bpm = (_previousBeatIntervalMs > 1.0f) ? (60000.0f / _previousBeatIntervalMs) : 0.0f; + _lastNearBeatInfo.confidence = score; + _lastNearBeatInfo.strength = strength; + _lastNearBeatInfo.bass = bass; + _lastNearBeatInfo.mid = mid; + _lastNearBeatInfo.treble = treble; + _lastNearBeatInfo.flux = flux; + _lastNearBeatInfo.vu = _VU; + _lastNearBeatInfo.vuRatio = _VURatio; + _lastNearBeatInfo.major = false; + _lastNearBeatInfo.simulated = false; +} + +void SoundAnalyzerBase::UpdateBeatDetection() +{ + constexpr float kBaselineAlpha = 0.08f; + constexpr float kDeviationAlpha = 0.12f; + + const size_t bassBands = std::max(1, std::min(NUM_BANDS, 3)); + const size_t midBands = std::max(1, std::min(NUM_BANDS - bassBands, std::max(1, NUM_BANDS / 3))); + + float bass = 0.0f; + float mid = 0.0f; + float treble = 0.0f; + float flux = 0.0f; + float lowFlux = 0.0f; + + for (size_t i = 0; i < NUM_BANDS; ++i) + { + const float band = _beatPeaks[i]; + const float delta = std::max(0.0f, band - _previousBeatPeaks[i]); + + if (i < bassBands) + { + bass += band; + lowFlux += delta; + } + else if (i < bassBands + midBands) + { + mid += band; + } + else + { + treble += band; + } + + flux += delta; + } + + bass /= static_cast(bassBands); + if (midBands > 0) + mid /= static_cast(midBands); + + const size_t trebleBands = (NUM_BANDS > bassBands + midBands) ? (NUM_BANDS - bassBands - midBands) : 0; + if (trebleBands > 0) + treble /= static_cast(trebleBands); + + flux /= static_cast(NUM_BANDS); + lowFlux /= static_cast(bassBands); + const float beatBandGroups = static_cast((bassBands > 0 ? 1 : 0) + (midBands > 0 ? 1 : 0) + (trebleBands > 0 ? 1 : 0)); + const float beatVu = (bass + mid + treble) / std::max(1.0f, beatBandGroups); + + const float score = bass * 0.55f + lowFlux * 1.50f + flux * 1.20f + mid * 0.15f + beatVu * 0.25f; + const float strength = std::clamp((bass * 1.10f) + (lowFlux * 2.10f) + (flux * 1.35f), 0.0f, 2.5f); + const float scoreThreshold = _beatScoreBaseline + std::max(0.03f, _beatScoreDeviation * 1.20f); + const float fluxThreshold = _beatFluxBaseline + std::max(0.012f, _beatFluxDeviation * 1.15f); + const float bassThreshold = _beatBassBaseline + std::max(0.012f, _beatBassDeviation * 1.00f); + + const uint32_t now = millis(); + const float minIntervalMs = std::clamp(_previousBeatIntervalMs * 0.48f, 200.0f, 650.0f); + const bool enoughGap = (_lastBeatDetectedMs == 0) || (static_cast(now - _lastBeatDetectedMs) >= minIntervalMs); + const bool candidate = enoughGap + && score > scoreThreshold + && flux > fluxThreshold + && (bass > bassThreshold || lowFlux > fluxThreshold || (score > (scoreThreshold * 1.08f) && flux > (fluxThreshold * 1.10f))) + && strength > 0.10f + && (bass + lowFlux) > 0.06f; + const bool nearCandidate = enoughGap + && score > (scoreThreshold * 0.75f) + && flux > (fluxThreshold * 0.75f) + && (bass + lowFlux) > 0.05f; + + if (candidate) + { + const float confidence = std::clamp( + ((score - scoreThreshold) * 1.40f) + + ((flux - fluxThreshold) * 1.80f) + + ((bass - bassThreshold) * 1.00f) + + strength * 0.20f, + 0.0f, + 1.5f); + + RecordBeat(now, confidence, strength, bass, mid, treble, flux, false); + debugV("Beat detected: seq=%lu bass=%.3f flux=%.3f lowFlux=%.3f score=%.3f strength=%.3f bpm=%.1f", + (unsigned long)_lastBeatInfo.sequence, + bass, + flux, + lowFlux, + score, + strength, + _lastBeatInfo.bpm); + } + else if (nearCandidate) + { + RecordNearBeat(now, score, strength, bass, mid, treble, flux); + + if (now - _lastBeatDebugMs >= 250) + { + _lastBeatDebugMs = now; + debugI("Beat near-miss: bass=%.3f/%.3f flux=%.3f/%.3f score=%.3f/%.3f lowFlux=%.3f strength=%.3f gap=%d", + bass, + bassThreshold, + flux, + fluxThreshold, + score, + scoreThreshold, + lowFlux, + strength, + enoughGap ? 1 : 0); + } + } + + const float baselineAlpha = candidate ? (kBaselineAlpha * 0.35f) : kBaselineAlpha; + _beatScoreBaseline += (score - _beatScoreBaseline) * baselineAlpha; + _beatFluxBaseline += (flux - _beatFluxBaseline) * baselineAlpha; + _beatBassBaseline += (bass - _beatBassBaseline) * baselineAlpha; + + _beatScoreDeviation += (fabsf(score - _beatScoreBaseline) - _beatScoreDeviation) * kDeviationAlpha; + _beatFluxDeviation += (fabsf(flux - _beatFluxBaseline) - _beatFluxDeviation) * kDeviationAlpha; + _beatBassDeviation += (fabsf(bass - _beatBassBaseline) - _beatBassDeviation) * kDeviationAlpha; + + _previousBeatPeaks = _beatPeaks; +} + #if ENABLE_AUDIO_DEBUG // DumpBandLayout // @@ -355,7 +606,18 @@ void SoundAnalyzerBase::SimulateBeatPass() } _Peaks = simulatedPeaks; + _beatPeaks = simulatedPeaks; UpdateVU(targetVU); + + // Simulated beats should fire exactly once per cycle so effects can be + // tested without depending on heuristic onset detection. + const uint32_t beatIndex = static_cast(currentTime / beatPeriodMillis); + if (isOnBeat && (!_hasSimulatedBeat || beatIndex != _lastSimulatedBeatIndex)) + { + _hasSimulatedBeat = true; + _lastSimulatedBeatIndex = beatIndex; + RecordBeat(currentTime, 1.0f, 2.0f, 0.85f, 0.35f, 0.15f, 0.90f, true); + } } // RunSamplerPass @@ -373,7 +635,7 @@ void SoundAnalyzerBase::RunSamplerPass() if (millis() - _msLastRemoteAudio > AUDIO_PEAK_REMOTE_TIMEOUT) { // Use local microphone - type determined at compile time - Reset(); + ResetFrameState(); SampleAudio(); FFT(); ProcessPeaksEnergy(); @@ -384,6 +646,8 @@ void SoundAnalyzerBase::RunSamplerPass() float sum = std::accumulate(_Peaks.begin(), _Peaks.end(), 0.0f); UpdateVU(sum / NUM_BANDS); } + + UpdateBeatDetection(); } // --- Private Initialization Helpers --- @@ -407,7 +671,8 @@ void SoundAnalyzerBase::InitM5() void SoundAnalyzerBase::InitI2S_Modern() { #if (USE_I2S_AUDIO || ELECROW) && IS_IDF5 - debugI("Audio: Initializing I2S Digital Mic (Modern) on BCLK:%d WS:%d DIN:%d", I2S_BCLK_PIN, I2S_WS_PIN, INPUT_PIN); + const auto inputPin = GetConfiguredAudioInputPin(); + debugI("Audio: Initializing I2S Digital Mic (Modern) on BCLK:%d WS:%d DIN:%d", I2S_BCLK_PIN, I2S_WS_PIN, inputPin); // Digital Microphones (INMP441, etc.) - Standard I2S Mode i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_AUTO, I2S_ROLE_MASTER); ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, NULL, &_rx_handle)); @@ -420,7 +685,7 @@ void SoundAnalyzerBase::InitI2S_Modern() .bclk = I2S_BCLK_PIN, .ws = I2S_WS_PIN, .dout = I2S_GPIO_UNUSED, - .din = INPUT_PIN, + .din = static_cast(inputPin), }, }; @@ -432,7 +697,8 @@ void SoundAnalyzerBase::InitI2S_Modern() void SoundAnalyzerBase::InitI2S_Legacy() { #if (USE_I2S_AUDIO || ELECROW) && !IS_IDF5 - debugI("Audio: Initializing I2S Digital Mic (Legacy) on BCLK:%d WS:%d DIN:%d", I2S_BCLK_PIN, I2S_WS_PIN, INPUT_PIN); + const auto inputPin = GetConfiguredAudioInputPin(); + debugI("Audio: Initializing I2S Digital Mic (Legacy) on BCLK:%d WS:%d DIN:%d", I2S_BCLK_PIN, I2S_WS_PIN, inputPin); const i2s_config_t i2s_config = {.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX), .sample_rate = SAMPLING_FREQUENCY, .bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT, @@ -447,12 +713,12 @@ void SoundAnalyzerBase::InitI2S_Legacy() pinMode(I2S_BCLK_PIN, OUTPUT); pinMode(I2S_WS_PIN, OUTPUT); - pinMode(INPUT_PIN, INPUT); + pinMode(inputPin, INPUT); const i2s_pin_config_t pin_config = {.bck_io_num = I2S_BCLK_PIN, .ws_io_num = I2S_WS_PIN, .data_out_num = I2S_PIN_NO_CHANGE, - .data_in_num = INPUT_PIN}; + .data_in_num = inputPin}; ESP_ERROR_CHECK(i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL)); ESP_ERROR_CHECK(i2s_set_pin(I2S_NUM_0, &pin_config)); @@ -480,7 +746,7 @@ void SoundAnalyzerBase::InitADC_Modern() // Configure pattern: channel, attenuation, etc. adc_digi_pattern_config_t adc_pattern[1] = {0}; adc_pattern[0].atten = ADC_ATTEN_DB_12; // 12dB (formerly 11dB) for full range ~3.3V - adc_pattern[0].channel = ADC_CHANNEL_0; // FIXED for now, ideally map from INPUT_PIN + adc_pattern[0].channel = ADC_CHANNEL_0; // FIXED for now, ideally map from AUDIO_INPUT_PIN adc_pattern[0].unit = ADC_UNIT_1; adc_pattern[0].bit_width = ADC_BITWIDTH_12; @@ -644,6 +910,8 @@ size_t SoundAnalyzerBase::SampleADC_Legacy() template const PeakData & SoundAnalyzer::ProcessPeaksEnergy() { + PeakData rawSignals{}; + // Band offset handled in ComputeBandLayout so index 0 is the lowest VISIBLE band float frameMax = 0.0f; float frameSumRaw = 0.0f; @@ -673,6 +941,7 @@ const PeakData & SoundAnalyzer::ProcessPeaksEnergy() if (end <= start) { _vPeaks[b] = 0.0f; + rawSignals[b] = 0.0f; continue; } @@ -735,13 +1004,13 @@ const PeakData & SoundAnalyzer::ProcessPeaksEnergy() float right = (b < NUM_BANDS - 1) ? _rawPrev[b + 1] : signal; float smoothed = 0.25f * (2.0f * signal + left + right); _rawPrev[b] = smoothed; - _vPeaks[b] = smoothed; + rawSignals[b] = smoothed; if (smoothed > frameMax) { frameMax = smoothed; } #else - _vPeaks[b] = signal; + rawSignals[b] = signal; if (signal > frameMax) { frameMax = signal; @@ -767,6 +1036,7 @@ const PeakData & SoundAnalyzer::ProcessPeaksEnergy() { _vPeaks.fill(0.0f); _Peaks.fill(0.0f); + _beatPeaks.fill(0.0f); UpdateVU(0.0f); return _Peaks; } @@ -779,6 +1049,7 @@ const PeakData & SoundAnalyzer::ProcessPeaksEnergy() { _vPeaks.fill(0.0f); _Peaks.fill(0.0f); + _beatPeaks.fill(0.0f); UpdateVU(0.0f); return _Peaks; } @@ -786,12 +1057,20 @@ const PeakData & SoundAnalyzer::ProcessPeaksEnergy() float envFloor = std::max(_params.energyMinEnv, envFloorRaw); float normDen = std::max(_energyMaxEnv, envFloor); const float invEnv = 1.0f / normDen; + const float beatNoiseRef = std::max(noiseMean, _params.energyMinEnv); float sumNorm = 0.0f; // Now that layout skips the lowest bins, emit all NUM_BANDS directly with no reindexing for (int b = 0; b < NUM_BANDS; b++) { - float vTarget = std::clamp(powf(std::max(0.0f, _vPeaks[b] * invEnv), _params.compressGamma), 0.0f, 1.0f); + // Beat detection runs from the pre-display signal so it is not pushed + // around by the same adaptive envelope and attack limiting used to make + // the spectrum/VU visuals look good. + const float beatReference = std::max({_noiseFloor[b], beatNoiseRef, _params.energyMinEnv}); + const float beatRatio = std::max(0.0f, rawSignals[b]) / (beatReference + 1e-9f); + _beatPeaks[b] = std::clamp(sqrtf(beatRatio), 0.0f, 2.0f); + + float vTarget = std::clamp(powf(std::max(0.0f, rawSignals[b] * invEnv), _params.compressGamma), 0.0f, 1.0f); vTarget = std::clamp(vTarget * kDisplayGain, 0.0f, 1.0f); const float bandFloor = std::max(kBandFloorMin * 1.0f, kBandFloorScale); // Fixed kBandFloorMin usage diff --git a/src/systemcontainer.cpp b/src/systemcontainer.cpp index db88d1b84..8aebc4491 100644 --- a/src/systemcontainer.cpp +++ b/src/systemcontainer.cpp @@ -41,6 +41,10 @@ #include "taskmgr.h" #include "webserver.h" #include "websocketserver.h" +#if USE_WS281X +#include "ws281xgfx.h" +#include "ws281xoutputmanager.h" +#endif // SystemContainer // @@ -145,6 +149,14 @@ SocketServer& SystemContainer::GetSocketServer() const } #endif +#if USE_WS281X +WS281xOutputManager& SystemContainer::GetWS281xOutputManager() const +{ + CheckPointer(!!_ptrWS281xOutputManager, "WS281xOutputManager"); + return *_ptrWS281xOutputManager; +} +#endif + #if WEB_SOCKETS_ANY_ENABLED WebSocketServer& SystemContainer::GetWebSocketServer() const { @@ -194,7 +206,9 @@ SystemContainer::BufferManagerContainer& SystemContainer::SetupBufferManagers() uint32_t memtouse = ESP.getFreeHeap() - RESERVE_MEMORY; #endif - uint32_t memtoalloc = (_ptrDevices->size() * (sizeof(LEDBuffer) + NUM_LEDS * sizeof(CRGB))); + uint32_t memtoalloc = 0; + for (const auto& device : *_ptrDevices) + memtoalloc += sizeof(LEDBuffer) + (device->GetLEDCount() * sizeof(CRGB)); uint32_t cBuffers = memtouse / memtoalloc; if (cBuffers < MIN_BUFFERS) @@ -273,6 +287,67 @@ void SystemContainer::SetupConfig() _ptrDeviceConfig = make_unique_psram(); } +int SystemContainer::GetConfiguredAudioInputPin() const +{ + if (_ptrDeviceConfig) + return _ptrDeviceConfig->GetAudioInputPin(); + + return DeviceConfig::GetCompiledAudioInputPin(); +} + +bool SystemContainer::ApplyRuntimeConfiguration(String* errorMessage) +{ + auto& config = GetDeviceConfig(); + + if (config.RequiresRecompileForCurrentRuntimeConfig()) + { + if (errorMessage) + *errorMessage = "recompile needed"; + return false; + } + + #if USE_WS281X + if (_ptrDevices) + { + // Reconfiguring the already-owned GFX objects keeps the rest of the renderer stable while + // still letting the active WS281x layout, channel count, and pins move inside build limits. + for (auto& device : *_ptrDevices) + device->ConfigureTopology(config.GetMatrixWidth(), config.GetMatrixHeight(), config.IsMatrixSerpentine()); + } + + if (_ptrBufferManagers && _ptrDevices) + { + for (size_t i = 0; i < _ptrBufferManagers->size() && i < _ptrDevices->size(); ++i) + (*_ptrBufferManagers)[i].Reconfigure((*_ptrDevices)[i]); + } + + #if INCOMING_WIFI_ENABLED + if (_ptrSocketServer) + _ptrSocketServer->SetLEDCount(config.GetActiveLEDCount()); + #endif + + // Compiled-pin WS281x transport is still the lowest-risk path for the common case where the user + // only changes topology inside the existing build envelope. Only engage the runtime manager when + // the active output really diverges from the compiled channel/pin layout. + if (_ptrDevices) + { + if (config.UsesCompiledWS281xTransport()) + { + WS281xGFX::ApplyCompiledTransportConfiguration(config, *_ptrDevices, "runtime apply"); + } + else + { + return SetupWS281xOutputManager().ApplyConfig(config, *_ptrDevices, errorMessage); + } + } + #endif + + if (errorMessage) + *errorMessage = ""; + + return true; +} + #if ENABLE_WIFI NetworkReader& SystemContainer::SetupNetworkReader() { @@ -295,7 +370,10 @@ CWebServer& SystemContainer::SetupWebServer() RemoteControl& SystemContainer::SetupRemoteControl() { if (!_ptrRemoteControl) + { + debugI("Remote configured: enabled=1 pin=%d", IR_REMOTE_PIN); _ptrRemoteControl = make_unique_psram(); + } return *_ptrRemoteControl; } #endif @@ -305,10 +383,21 @@ SocketServer& SystemContainer::SetupSocketServer(NetworkPort port, int ledCount) { if (!_ptrSocketServer) _ptrSocketServer = make_unique_psram(port, ledCount); + else + _ptrSocketServer->SetLEDCount(ledCount); return *_ptrSocketServer; } #endif +#if USE_WS281X +WS281xOutputManager& SystemContainer::SetupWS281xOutputManager() +{ + if (!_ptrWS281xOutputManager) + _ptrWS281xOutputManager = make_unique_psram(); + return *_ptrWS281xOutputManager; +} +#endif + #if WEB_SOCKETS_ANY_ENABLED WebSocketServer& SystemContainer::SetupWebSocketServer(CWebServer& webServer) { diff --git a/src/webserver.cpp b/src/webserver.cpp index 0d87ee978..580ebc891 100644 --- a/src/webserver.cpp +++ b/src/webserver.cpp @@ -40,6 +40,7 @@ #include "deviceconfig.h" #include "effectmanager.h" +#include "gfxbase.h" #include "improvserial.h" #include "ledstripeffect.h" #include "soundanalyzer.h" @@ -47,6 +48,136 @@ #include "taskmgr.h" #include "values.h" +namespace +{ + void AppendPins(JsonArray target, const std::array& pins) + { + for (auto pin : pins) + target.add(pin); + } + + void FillUnifiedSettingsJson(JsonObject root) + { + auto& deviceConfig = g_ptrSystem->GetDeviceConfig(); + auto& effectManager = g_ptrSystem->GetEffectManager(); + + auto device = root["device"].to(); + device["hostname"] = deviceConfig.GetHostname(); + device["location"] = deviceConfig.GetLocation(); + device["locationIsZip"] = deviceConfig.IsLocationZip(); + device["countryCode"] = deviceConfig.GetCountryCode(); + device["timeZone"] = deviceConfig.GetTimeZone(); + device["use24HourClock"] = deviceConfig.Use24HourClock(); + device["useCelsius"] = deviceConfig.UseCelsius(); + device["ntpServer"] = deviceConfig.GetNTPServer(); + device["rememberCurrentEffect"] = deviceConfig.RememberCurrentEffect(); + device["powerLimit"] = deviceConfig.GetPowerLimit(); + device["brightness"] = deviceConfig.GetBrightness(); + device["globalColor"] = deviceConfig.GlobalColor(); + device["secondColor"] = deviceConfig.SecondColor(); + device["applyGlobalColors"] = deviceConfig.ApplyGlobalColors(); + #if ENABLE_REMOTE + auto remote = device["remote"].to(); + remote["enabled"] = true; + remote["pin"] = IR_REMOTE_PIN; + #else + auto remote = device["remote"].to(); + remote["enabled"] = false; + remote["pin"] = -1; + #endif + auto audio = device["audio"].to(); + audio["enabled"] = + #if ENABLE_AUDIO + true; + #else + false; + #endif + audio["inputPin"] = deviceConfig.GetAudioInputPin(); + audio["compiledDefaultPin"] = DeviceConfig::GetCompiledAudioInputPin(); + audio["mode"] = deviceConfig.GetAudioInputModeName(); + audio["liveApply"] = deviceConfig.SupportsLiveAudioInputReconfigure(); + audio["requiresReboot"] = !deviceConfig.SupportsLiveAudioInputReconfigure(); + audio["supportsPinOverride"] = deviceConfig.SupportsConfigurableAudioInputPin(); + + auto topology = root["topology"].to(); + topology["width"] = deviceConfig.GetMatrixWidth(); + topology["height"] = deviceConfig.GetMatrixHeight(); + topology["serpentine"] = deviceConfig.IsMatrixSerpentine(); + topology["ledCount"] = deviceConfig.GetActiveLEDCount(); + topology["liveApply"] = deviceConfig.SupportsLiveTopology(); + + auto outputs = root["outputs"].to(); + outputs["driver"] = deviceConfig.GetRuntimeDriverName(); + outputs["compiledDriver"] = deviceConfig.GetCompiledDriverName(); + outputs["liveApply"] = deviceConfig.SupportsLiveOutputReconfigure(); + + auto ws281x = outputs["ws281x"].to(); + ws281x["channelCount"] = deviceConfig.GetChannelCount(); + ws281x["compiledMaxChannels"] = DeviceConfig::GetCompiledChannelCount(); + AppendPins(ws281x["pins"].to(), deviceConfig.GetWS281xPins()); + + auto effects = root["effects"].to(); + effects["effectInterval"] = effectManager.GetInterval(); + } + + void FillUnifiedSettingsSchemaJson(JsonObject root) + { + auto& deviceConfig = g_ptrSystem->GetDeviceConfig(); + + auto topology = root["topology"].to(); + topology["compiledMaxWidth"] = + DeviceConfig::GetCompiledOutputDriver() == DeviceConfig::OutputDriver::HUB75 + ? DeviceConfig::GetCompiledMatrixWidth() + : DeviceConfig::GetCompiledLEDCount(); + topology["compiledMaxHeight"] = + DeviceConfig::GetCompiledOutputDriver() == DeviceConfig::OutputDriver::HUB75 + ? DeviceConfig::GetCompiledMatrixHeight() + : DeviceConfig::GetCompiledLEDCount(); + topology["compiledNominalWidth"] = DeviceConfig::GetCompiledMatrixWidth(); + topology["compiledNominalHeight"] = DeviceConfig::GetCompiledMatrixHeight(); + topology["compiledMaxLEDs"] = DeviceConfig::GetCompiledLEDCount(); + topology["liveApply"] = deviceConfig.SupportsLiveTopology(); + topology["rejectMessage"] = "recompile needed"; + + auto outputs = root["outputs"].to(); + outputs["compiledDriver"] = deviceConfig.GetCompiledDriverName(); + outputs["liveApply"] = deviceConfig.SupportsLiveOutputReconfigure(); + outputs["rejectMessage"] = "recompile needed"; + + auto drivers = outputs["allowedDrivers"].to(); + drivers.add(deviceConfig.GetCompiledDriverName()); + + auto ws281x = outputs["ws281x"].to(); + ws281x["compiledMaxChannels"] = DeviceConfig::GetCompiledChannelCount(); + ws281x["compiledMaxLEDs"] = DeviceConfig::GetCompiledLEDCount(); + AppendPins(ws281x["compiledPins"].to(), DeviceConfig::GetCompiledPins()); + + auto device = root["device"].to(); + auto remote = device["remote"].to(); + remote["enabled"] = + #if ENABLE_REMOTE + true; + #else + false; + #endif + remote["pin"] = IR_REMOTE_PIN; + + auto audio = device["audio"].to(); + audio["enabled"] = + #if ENABLE_AUDIO + true; + #else + false; + #endif + audio["compiledDefaultPin"] = DeviceConfig::GetCompiledAudioInputPin(); + audio["mode"] = deviceConfig.GetAudioInputModeName(); + audio["liveApply"] = deviceConfig.SupportsLiveAudioInputReconfigure(); + audio["requiresReboot"] = !deviceConfig.SupportsLiveAudioInputReconfigure(); + audio["supportsPinOverride"] = deviceConfig.SupportsConfigurableAudioInputPin(); + audio["rejectMessage"] = "recompile needed"; + } +} + // Static member initializers // Maps settings for which a validator is available to the invocation thereof @@ -54,7 +185,8 @@ const std::map CWebServer::settingValidators { { DeviceConfig::OpenWeatherApiKeyTag, [](const String& value) { return g_ptrSystem->GetDeviceConfig().ValidateOpenWeatherAPIKey(value); } }, { DeviceConfig::PowerLimitTag, [](const String& value) { return g_ptrSystem->GetDeviceConfig().ValidatePowerLimit(value); } }, - { DeviceConfig::BrightnessTag, [](const String& value) { return g_ptrSystem->GetDeviceConfig().ValidateBrightness(value); } } + { DeviceConfig::BrightnessTag, [](const String& value) { return g_ptrSystem->GetDeviceConfig().ValidateBrightness(value); } }, + { DeviceConfig::AudioInputPinTag, [](const String& value) { return g_ptrSystem->GetDeviceConfig().ValidateAudioInputPin(value.toInt()); } } }; std::vector> CWebServer::mySettingSpecs = {}; @@ -186,6 +318,16 @@ void CWebServer::begin() _server.on("/settings/specs", HTTP_GET, GetSettingSpecs); _server.on("/settings", HTTP_GET, GetSettings); _server.on("/settings", HTTP_POST, SetSettings); + _server.on("/api/v1/settings/schema",HTTP_GET, GetUnifiedSettingsSchema); + _server.on("/api/v1/settings", HTTP_GET, GetUnifiedSettings); + + auto settingsHandler = new AsyncCallbackJsonWebHandler("/api/v1/settings", + [](AsyncWebServerRequest* pRequest, JsonVariant& json) + { + SetUnifiedSettings(pRequest, json.as()); + }); + settingsHandler->setMethod(HTTP_POST); + _server.addHandler(settingsHandler); _server.on("/reset", HTTP_POST, Reset); @@ -286,23 +428,41 @@ void CWebServer::GetStatistics(AsyncWebServerRequest * pRequest, StatisticsType auto response = new AsyncJsonResponse(); auto& j = response->getRoot(); + const auto& deviceConfig = g_ptrSystem->GetDeviceConfig(); + const auto activeWidth = g_ptrSystem->HasEffectManager() ? g_ptrSystem->GetEffectManager().g().GetMatrixWidth() : deviceConfig.GetMatrixWidth(); + const auto activeHeight = g_ptrSystem->HasEffectManager() ? g_ptrSystem->GetEffectManager().g().GetMatrixHeight() : deviceConfig.GetMatrixHeight(); + const auto activeLEDCount = g_ptrSystem->HasEffectManager() ? g_ptrSystem->GetEffectManager().g().GetLEDCount() : deviceConfig.GetActiveLEDCount(); if ((statsType & StatisticsType::Static) != StatisticsType::None) { - j["MATRIX_WIDTH"] = MATRIX_WIDTH; - j["MATRIX_HEIGHT"] = MATRIX_HEIGHT; - j["FRAMES_SOCKET"] = !!COLORDATA_WEB_SOCKET_ENABLED; - j["EFFECTS_SOCKET"] = !!EFFECTS_WEB_SOCKET_ENABLED; - j["CHIP_MODEL"] = _staticStats.ChipModel; - j["CHIP_CORES"] = _staticStats.ChipCores; - j["CHIP_SPEED"] = _staticStats.CpuFreqMHz; - j["PROG_SIZE"] = _staticStats.SketchSize; - j["CODE_SIZE"] = _staticStats.SketchSize; - j["FLASH_SIZE"] = _staticStats.FlashChipSize; - j["HEAP_SIZE"] = _staticStats.HeapSize; - j["DMA_SIZE"] = _staticStats.DmaHeapSize; - j["PSRAM_SIZE"] = _staticStats.PsramSize; - j["CODE_FREE"] = _staticStats.FreeSketchSpace; + j["MATRIX_WIDTH"] = MATRIX_WIDTH; + j["MATRIX_HEIGHT"] = MATRIX_HEIGHT; + j["CONFIGURED_MATRIX_WIDTH"] = deviceConfig.GetMatrixWidth(); + j["CONFIGURED_MATRIX_HEIGHT"] = deviceConfig.GetMatrixHeight(); + j["CONFIGURED_NUM_LEDS"] = deviceConfig.GetActiveLEDCount(); + j["ACTIVE_MATRIX_WIDTH"] = activeWidth; + j["ACTIVE_MATRIX_HEIGHT"] = activeHeight; + j["ACTIVE_NUM_LEDS"] = activeLEDCount; + j["COMPILED_NUM_LEDS"] = DeviceConfig::GetCompiledLEDCount(); + j["COMPILED_NUM_CHANNELS"] = DeviceConfig::GetCompiledChannelCount(); + j["ACTIVE_NUM_CHANNELS"] = deviceConfig.GetChannelCount(); + j["COMPILED_OUTPUT_DRIVER"] = deviceConfig.GetCompiledDriverName(); + j["ACTIVE_OUTPUT_DRIVER"] = deviceConfig.GetRuntimeDriverName(); + j["COMPILED_AUDIO_INPUT_PIN"] = DeviceConfig::GetCompiledAudioInputPin(); + j["CONFIGURED_AUDIO_INPUT_PIN"] = deviceConfig.GetAudioInputPin(); + j["AUDIO_INPUT_MODE"] = deviceConfig.GetAudioInputModeName(); + j["FRAMES_SOCKET"] = !!COLORDATA_WEB_SOCKET_ENABLED; + j["EFFECTS_SOCKET"] = !!EFFECTS_WEB_SOCKET_ENABLED; + j["CHIP_MODEL"] = _staticStats.ChipModel; + j["CHIP_CORES"] = _staticStats.ChipCores; + j["CHIP_SPEED"] = _staticStats.CpuFreqMHz; + j["PROG_SIZE"] = _staticStats.SketchSize; + j["CODE_SIZE"] = _staticStats.SketchSize; + j["FLASH_SIZE"] = _staticStats.FlashChipSize; + j["HEAP_SIZE"] = _staticStats.HeapSize; + j["DMA_SIZE"] = _staticStats.DmaHeapSize; + j["PSRAM_SIZE"] = _staticStats.PsramSize; + j["CODE_FREE"] = _staticStats.FreeSketchSpace; } if ((statsType & StatisticsType::Dynamic) != StatisticsType::None) @@ -516,13 +676,194 @@ void CWebServer::GetSettings(AsyncWebServerRequest * pRequest) AddCORSHeaderAndSendResponse(pRequest, response); } -// Support function that silently sets whatever settings are included in the request passed. -// Composing a response is left to the invoker! -void CWebServer::SetSettingsIfPresent(AsyncWebServerRequest * pRequest) +void CWebServer::GetUnifiedSettings(AsyncWebServerRequest * pRequest) +{ + auto response = new AsyncJsonResponse(); + auto root = response->getRoot().to(); + FillUnifiedSettingsJson(root); + AddCORSHeaderAndSendResponse(pRequest, response); +} + +void CWebServer::GetUnifiedSettingsSchema(AsyncWebServerRequest * pRequest) +{ + auto response = new AsyncJsonResponse(); + auto root = response->getRoot().to(); + FillUnifiedSettingsSchemaJson(root); + AddCORSHeaderAndSendResponse(pRequest, response); +} + +bool CWebServer::ValidateLegacyDeviceSettings(AsyncWebServerRequest * pRequest, String* errorMessage) +{ + auto& deviceConfig = g_ptrSystem->GetDeviceConfig(); + + // Validate the constrained settings first so the legacy POST path behaves like the unified JSON API: + // either the nontrivial request is coherent as a whole, or we reject it before any setters persist a + // partially applied config. + + if (pRequest->hasParam(DeviceConfig::OpenWeatherApiKeyTag, true, false)) + { + auto [isValid, validationMessage] = + deviceConfig.ValidateOpenWeatherAPIKey(pRequest->getParam(DeviceConfig::OpenWeatherApiKeyTag, true, false)->value()); + if (!isValid) + { + if (errorMessage) + *errorMessage = validationMessage; + return false; + } + } + + if (pRequest->hasParam(DeviceConfig::PowerLimitTag, true, false)) + { + auto [isValid, validationMessage] = + deviceConfig.ValidatePowerLimit(pRequest->getParam(DeviceConfig::PowerLimitTag, true, false)->value().toInt()); + if (!isValid) + { + if (errorMessage) + *errorMessage = validationMessage; + return false; + } + } + + if (pRequest->hasParam(DeviceConfig::BrightnessTag, true, false)) + { + auto [isValid, validationMessage] = + deviceConfig.ValidateBrightness(pRequest->getParam(DeviceConfig::BrightnessTag, true, false)->value().toInt()); + if (!isValid) + { + if (errorMessage) + *errorMessage = validationMessage; + return false; + } + } + + if (pRequest->hasParam(DeviceConfig::AudioInputPinTag, true, false)) + { + auto [isValid, validationMessage] = + deviceConfig.ValidateAudioInputPin(pRequest->getParam(DeviceConfig::AudioInputPinTag, true, false)->value().toInt()); + if (!isValid) + { + if (errorMessage) + *errorMessage = validationMessage; + return false; + } + } + + if (errorMessage) + *errorMessage = ""; + + return true; +} + +bool CWebServer::ValidateUnifiedDeviceSettings(JsonObjectConst device, String* errorMessage) +{ + auto& deviceConfig = g_ptrSystem->GetDeviceConfig(); + + if (device[DeviceConfig::OpenWeatherApiKeyTag].is()) + { + auto [isValid, validationMessage] = deviceConfig.ValidateOpenWeatherAPIKey(device[DeviceConfig::OpenWeatherApiKeyTag].as()); + if (!isValid) + { + if (errorMessage) + *errorMessage = validationMessage; + return false; + } + } + + if (device[DeviceConfig::PowerLimitTag].is()) + { + auto [isValid, validationMessage] = deviceConfig.ValidatePowerLimit(device[DeviceConfig::PowerLimitTag].as()); + if (!isValid) + { + if (errorMessage) + *errorMessage = validationMessage; + return false; + } + } + + if (device[DeviceConfig::BrightnessTag].is()) + { + auto [isValid, validationMessage] = deviceConfig.ValidateBrightness(device[DeviceConfig::BrightnessTag].as()); + if (!isValid) + { + if (errorMessage) + *errorMessage = validationMessage; + return false; + } + } + + std::optional requestedAudioInputPin; + + if (device[DeviceConfig::AudioInputPinTag].is()) + requestedAudioInputPin = device[DeviceConfig::AudioInputPinTag].as(); + + if (device["audio"].is()) + { + auto audio = device["audio"].as(); + if (audio["inputPin"].is()) + { + const int nestedAudioInputPin = audio["inputPin"].as(); + // The API still accepts both the legacy flat key and the structured audio object. Reject + // conflicting requests explicitly so callers do not get a half-legacy, half-modern winner + // picked implicitly by field order. + if (requestedAudioInputPin.has_value() && requestedAudioInputPin.value() != nestedAudioInputPin) + { + if (errorMessage) + *errorMessage = "Malformed request"; + return false; + } + + requestedAudioInputPin = nestedAudioInputPin; + } + } + + if (requestedAudioInputPin.has_value()) + { + auto [isValid, validationMessage] = deviceConfig.ValidateAudioInputPin(requestedAudioInputPin.value()); + if (!isValid) + { + if (errorMessage) + *errorMessage = validationMessage; + return false; + } + } + + if (errorMessage) + *errorMessage = ""; + + return true; +} + +// Support function that applies whatever settings are included in the request passed after validating the +// nontrivial ones up front. Returning false lets the caller report a single coherent bad-request error +// instead of half-applying device config and then discovering a later constraint violation. +bool CWebServer::SetSettingsIfPresent(AsyncWebServerRequest * pRequest, String* errorMessage) { auto& deviceConfig = g_ptrSystem->GetDeviceConfig(); auto& effectManager = g_ptrSystem->GetEffectManager(); + if (!ValidateLegacyDeviceSettings(pRequest, errorMessage)) + return false; + + auto runtimeConfig = deviceConfig.GetRuntimeConfig(); + bool runtimeConfigChanged = false; + + runtimeConfigChanged = PushPostParamIfPresent(pRequest, DeviceConfig::MatrixWidthTag, SET_VALUE(runtimeConfig.topology.width = value)) || runtimeConfigChanged; + runtimeConfigChanged = PushPostParamIfPresent(pRequest, DeviceConfig::MatrixHeightTag, SET_VALUE(runtimeConfig.topology.height = value)) || runtimeConfigChanged; + runtimeConfigChanged = PushPostParamIfPresent(pRequest, DeviceConfig::MatrixSerpentineTag, SET_VALUE(runtimeConfig.topology.serpentine = value)) || runtimeConfigChanged; + runtimeConfigChanged = PushPostParamIfPresent(pRequest, DeviceConfig::WS281xChannelCountTag, SET_VALUE(runtimeConfig.outputs.channelCount = value)) || runtimeConfigChanged; + runtimeConfigChanged = PushPostParamIfPresent(pRequest, DeviceConfig::OutputDriverTag, SET_VALUE(runtimeConfig.outputs.driver = value == "hub75" ? DeviceConfig::OutputDriver::HUB75 : DeviceConfig::OutputDriver::WS281x)) || runtimeConfigChanged; + + if (runtimeConfigChanged) + { + auto [isValid, validationMessage] = deviceConfig.ValidateRuntimeConfig(runtimeConfig); + if (!isValid) + { + if (errorMessage) + *errorMessage = validationMessage; + return false; + } + } + PushPostParamIfPresent(pRequest,"effectInterval", SET_VALUE(effectManager.SetInterval(value))); PushPostParamIfPresent(pRequest, DeviceConfig::HostnameTag, SET_VALUE(deviceConfig.SetHostname(value))); PushPostParamIfPresent(pRequest, DeviceConfig::LocationTag, SET_VALUE(deviceConfig.SetLocation(value))); @@ -536,6 +877,7 @@ void CWebServer::SetSettingsIfPresent(AsyncWebServerRequest * pRequest) PushPostParamIfPresent(pRequest, DeviceConfig::RememberCurrentEffectTag, SET_VALUE(deviceConfig.SetRememberCurrentEffect(value))); PushPostParamIfPresent(pRequest, DeviceConfig::PowerLimitTag, SET_VALUE(deviceConfig.SetPowerLimit(value))); PushPostParamIfPresent(pRequest, DeviceConfig::BrightnessTag, SET_VALUE(deviceConfig.SetBrightness(value))); + PushPostParamIfPresent(pRequest, DeviceConfig::AudioInputPinTag, SET_VALUE(deviceConfig.SetAudioInputPin(value))); #if SHOW_VU_METER PushPostParamIfPresent(pRequest, DeviceConfig::ShowVUMeterTag, SET_VALUE(effectManager.ShowVU(value))); @@ -550,6 +892,23 @@ void CWebServer::SetSettingsIfPresent(AsyncWebServerRequest * pRequest) deviceConfig.ApplyColorSettings(globalColor, secondColor, IsPostParamTrue(pRequest, DeviceConfig::ClearGlobalColorTag), IsPostParamTrue(pRequest, DeviceConfig::ApplyGlobalColorsTag)); + + if (runtimeConfigChanged) + { + String runtimeErrorMessage; + if (!deviceConfig.SetRuntimeConfig(runtimeConfig, false, &runtimeErrorMessage) + || !g_ptrSystem->ApplyRuntimeConfiguration(&runtimeErrorMessage)) + { + if (errorMessage) + *errorMessage = runtimeErrorMessage; + return false; + } + } + + if (errorMessage) + *errorMessage = ""; + + return true; } // Set settings and return resulting config @@ -557,12 +916,131 @@ void CWebServer::SetSettings(AsyncWebServerRequest * pRequest) { debugV("SetSettings"); - SetSettingsIfPresent(pRequest); + String errorMessage; + if (!SetSettingsIfPresent(pRequest, &errorMessage)) + { + AddCORSHeaderAndSendBadRequest(pRequest, errorMessage); + return; + } // We return the current config in response GetSettings(pRequest); } +void CWebServer::SetUnifiedSettings(AsyncWebServerRequest * pRequest, JsonVariantConst json) +{ + if (!json.is()) + { + AddCORSHeaderAndSendBadRequest(pRequest, "Malformed request"); + return; + } + + auto root = json.as(); + auto& deviceConfig = g_ptrSystem->GetDeviceConfig(); + auto& effectManager = g_ptrSystem->GetEffectManager(); + auto runtimeConfig = deviceConfig.GetRuntimeConfig(); + + if (root["topology"].is()) + { + auto topology = root["topology"].as(); + if (topology["width"].is()) runtimeConfig.topology.width = topology["width"].as(); + if (topology["height"].is()) runtimeConfig.topology.height = topology["height"].as(); + if (topology["serpentine"].is()) runtimeConfig.topology.serpentine = topology["serpentine"].as(); + } + + if (root["outputs"].is()) + { + auto outputs = root["outputs"].as(); + if (outputs["driver"].is()) + { + const auto driver = outputs["driver"].as(); + runtimeConfig.outputs.driver = driver == "hub75" ? DeviceConfig::OutputDriver::HUB75 : DeviceConfig::OutputDriver::WS281x; + } + + if (outputs["ws281x"].is()) + { + auto ws281x = outputs["ws281x"].as(); + if (ws281x["channelCount"].is()) runtimeConfig.outputs.channelCount = ws281x["channelCount"].as(); + if (ws281x["pins"].is()) + { + auto pins = ws281x["pins"].as(); + for (size_t i = 0; i < runtimeConfig.outputs.outputPins.size() && i < pins.size(); ++i) + { + if (pins[i].is()) + runtimeConfig.outputs.outputPins[i] = pins[i].as(); + } + } + } + } + + auto [runtimeConfigValid, runtimeConfigMessage] = deviceConfig.ValidateRuntimeConfig(runtimeConfig); + if (!runtimeConfigValid) + { + AddCORSHeaderAndSendBadRequest(pRequest, runtimeConfigMessage); + return; + } + + if (root["device"].is()) + { + auto device = root["device"].as(); + String validationMessage; + if (!ValidateUnifiedDeviceSettings(device, &validationMessage)) + { + AddCORSHeaderAndSendBadRequest(pRequest, validationMessage); + return; + } + + if (device[DeviceConfig::HostnameTag].is()) deviceConfig.SetHostname(device[DeviceConfig::HostnameTag].as()); + if (device[DeviceConfig::LocationTag].is()) deviceConfig.SetLocation(device[DeviceConfig::LocationTag].as()); + if (device[DeviceConfig::LocationIsZipTag].is()) deviceConfig.SetLocationIsZip(device[DeviceConfig::LocationIsZipTag].as()); + if (device[DeviceConfig::CountryCodeTag].is()) deviceConfig.SetCountryCode(device[DeviceConfig::CountryCodeTag].as()); + if (device[DeviceConfig::OpenWeatherApiKeyTag].is()) deviceConfig.SetOpenWeatherAPIKey(device[DeviceConfig::OpenWeatherApiKeyTag].as()); + if (device[DeviceConfig::TimeZoneTag].is()) deviceConfig.SetTimeZone(device[DeviceConfig::TimeZoneTag].as()); + if (device[DeviceConfig::Use24HourClockTag].is()) deviceConfig.Set24HourClock(device[DeviceConfig::Use24HourClockTag].as()); + if (device[DeviceConfig::UseCelsiusTag].is()) deviceConfig.SetUseCelsius(device[DeviceConfig::UseCelsiusTag].as()); + if (device[DeviceConfig::NTPServerTag].is()) deviceConfig.SetNTPServer(device[DeviceConfig::NTPServerTag].as()); + if (device[DeviceConfig::RememberCurrentEffectTag].is()) deviceConfig.SetRememberCurrentEffect(device[DeviceConfig::RememberCurrentEffectTag].as()); + if (device[DeviceConfig::PowerLimitTag].is()) deviceConfig.SetPowerLimit(device[DeviceConfig::PowerLimitTag].as()); + if (device[DeviceConfig::BrightnessTag].is()) deviceConfig.SetBrightness(device[DeviceConfig::BrightnessTag].as()); + if (device[DeviceConfig::AudioInputPinTag].is()) deviceConfig.SetAudioInputPin(device[DeviceConfig::AudioInputPinTag].as()); + if (device["audio"].is()) + { + auto audio = device["audio"].as(); + if (audio["inputPin"].is()) deviceConfig.SetAudioInputPin(audio["inputPin"].as()); + } + + std::optional globalColor = {}; + std::optional secondColor = {}; + if (device[DeviceConfig::GlobalColorTag].is()) globalColor = device[DeviceConfig::GlobalColorTag].as(); + if (device[DeviceConfig::SecondColorTag].is()) secondColor = device[DeviceConfig::SecondColorTag].as(); + deviceConfig.ApplyColorSettings(globalColor, secondColor, + device[DeviceConfig::ClearGlobalColorTag].is() && device[DeviceConfig::ClearGlobalColorTag].as(), + device[DeviceConfig::ApplyGlobalColorsTag].is() && device[DeviceConfig::ApplyGlobalColorsTag].as()); + } + + if (root["effects"].is()) + { + auto effects = root["effects"].as(); + if (effects["effectInterval"].is()) + effectManager.SetInterval(effects["effectInterval"].as()); + } + + String errorMessage; + if (!deviceConfig.SetRuntimeConfig(runtimeConfig, false, &errorMessage)) + { + AddCORSHeaderAndSendBadRequest(pRequest, errorMessage); + return; + } + + if (!g_ptrSystem->ApplyRuntimeConfiguration(&errorMessage)) + { + AddCORSHeaderAndSendBadRequest(pRequest, errorMessage); + return; + } + + GetUnifiedSettings(pRequest); +} + bool CWebServer::CheckAndGetSettingsEffect(AsyncWebServerRequest * pRequest, std::shared_ptr & effect, bool post) { const auto& effectsList = g_ptrSystem->GetEffectManager().EffectsList(); @@ -695,7 +1173,12 @@ void CWebServer::ValidateAndSetSetting(AsyncWebServerRequest * pRequest) } // Process the setting as per usual - SetSettingsIfPresent(pRequest); + String errorMessage; + if (!SetSettingsIfPresent(pRequest, &errorMessage)) + { + AddCORSHeaderAndSendBadRequest(pRequest, errorMessage); + return; + } AddCORSHeaderAndSendOKResponse(pRequest); } diff --git a/src/ws281xgfx.cpp b/src/ws281xgfx.cpp index 230846997..42c483282 100644 --- a/src/ws281xgfx.cpp +++ b/src/ws281xgfx.cpp @@ -30,68 +30,102 @@ #include "globals.h" +#include +#include + #include "deviceconfig.h" #include "effectmanager.h" #include "systemcontainer.h" #include "values.h" #include "ws281xgfx.h" +#if USE_WS281X +#include "ws281xoutputmanager.h" +#endif -void WS281xGFX::AddLEDs(std::vector>& devices) +namespace { - // Macro to add LEDs to a channel - - #if FASTLED_EXPERIMENTAL_ESP32_RGBW_ENABLED - #define ADD_CHANNEL(channel) \ - debugI("Adding %zu LEDs to pin %d from channel %d on FastLED.", devices[channel]->GetLEDCount(), LED_PIN ## channel, channel); \ - FastLED.addLeds(devices[channel]->leds, devices[channel]->GetLEDCount()).setRgbw(Rgbw(kRGBWDefaultColorTemp, FASTLED_EXPERIMENTAL_ESP32_RGBW_MODE )); \ - pinMode(LED_PIN ## channel, OUTPUT) - #else - #define ADD_CHANNEL(channel) \ - debugI("Adding %zu LEDs to pin %d from channel %d on FastLED.", devices[channel]->GetLEDCount(), LED_PIN ## channel, channel); \ - FastLED.addLeds(devices[channel]->leds, devices[channel]->GetLEDCount()); \ - pinMode(LED_PIN ## channel, OUTPUT) - #endif - - debugI("Adding LEDs to FastLED..."); - - // The following "unrolled conditional compile loop" to set up the channels is needed because the LED pin - // is a template parameter to FastLED.addLeds() - - #if NUM_CHANNELS >= 1 && LED_PIN0 >= 0 - ADD_CHANNEL(0); - #endif - - #if NUM_CHANNELS >= 2 && LED_PIN1 >= 0 - ADD_CHANNEL(1); - #endif - - #if NUM_CHANNELS >= 3 && LED_PIN2 >= 0 - ADD_CHANNEL(2); - #endif - - #if NUM_CHANNELS >= 4 && LED_PIN3 >= 0 - ADD_CHANNEL(3); - #endif - - #if NUM_CHANNELS >= 5 && LED_PIN4 >= 0 - ADD_CHANNEL(4); - #endif - - #if NUM_CHANNELS >= 6 && LED_PIN5 >= 0 - ADD_CHANNEL(5); - #endif - - #if NUM_CHANNELS >= 7 && LED_PIN6 >= 0 - ADD_CHANNEL(6); - #endif + void LogWS281xConfiguration(const DeviceConfig& deviceConfig, const std::vector>& devices, bool compiledTransport, const char* reason) + { + debugI("WS281x config (%s): path=%s driver=%s channels=%zu matrix=%ux%u serpentine=%d leds=%zu", + reason ? reason : "update", + compiledTransport ? "compiled" : "runtime", + deviceConfig.GetRuntimeDriverName().c_str(), + deviceConfig.GetChannelCount(), + static_cast(deviceConfig.GetMatrixWidth()), + static_cast(deviceConfig.GetMatrixHeight()), + deviceConfig.IsMatrixSerpentine(), + deviceConfig.GetActiveLEDCount()); + + const auto& configuredPins = deviceConfig.GetWS281xPins(); + for (size_t channel = 0; channel < deviceConfig.GetChannelCount() && channel < devices.size(); ++channel) + { + const auto pin = compiledTransport ? DeviceConfig::GetCompiledPins()[channel] : configuredPins[channel]; + const auto& graphics = *devices[channel]; + debugI("WS281x channel %zu (%s): pin=%d leds=%zu matrix=%ux%u serpentine=%d buffer=%p", + channel, + compiledTransport ? "compiled" : "runtime", + pin, + graphics.GetLEDCount(), + static_cast(graphics.GetMatrixWidth()), + static_cast(graphics.GetMatrixHeight()), + graphics.IsSerpentine(), + graphics.leds); + } + } - #if NUM_CHANNELS >= 8 && LED_PIN7 >= 0 - ADD_CHANNEL(7); - #endif + void RebindCompiledLEDs(const std::vector>& devices) + { + for (size_t channel = 0; channel < devices.size() && channel < static_cast(NUM_CHANNELS) && channel < static_cast(FastLED.count()); ++channel) + FastLED[channel].setLeds(devices[channel]->leds, devices[channel]->GetLEDCount()); + } - #ifdef POWER_LIMIT_MW - set_max_power_in_milliwatts(POWER_LIMIT_MW); // Set brightness limit - #endif + void AddCompiledLEDs(std::vector>& devices) + { + #if FASTLED_EXPERIMENTAL_ESP32_RGBW_ENABLED + #define ADD_CHANNEL(channel) \ + debugI("Adding %zu LEDs to pin %d from channel %d on FastLED.", devices[channel]->GetLEDCount(), LED_PIN ## channel, channel); \ + FastLED.addLeds(devices[channel]->leds, devices[channel]->GetLEDCount()).setRgbw(Rgbw(kRGBWDefaultColorTemp, FASTLED_EXPERIMENTAL_ESP32_RGBW_MODE )); \ + pinMode(LED_PIN ## channel, OUTPUT) + #else + #define ADD_CHANNEL(channel) \ + debugI("Adding %zu LEDs to pin %d from channel %d on FastLED.", devices[channel]->GetLEDCount(), LED_PIN ## channel, channel); \ + FastLED.addLeds(devices[channel]->leds, devices[channel]->GetLEDCount()); \ + pinMode(LED_PIN ## channel, OUTPUT) + #endif + + debugI("Adding LEDs to FastLED..."); + + #if NUM_CHANNELS >= 1 && LED_PIN0 >= 0 + ADD_CHANNEL(0); + #endif + #if NUM_CHANNELS >= 2 && LED_PIN1 >= 0 + ADD_CHANNEL(1); + #endif + #if NUM_CHANNELS >= 3 && LED_PIN2 >= 0 + ADD_CHANNEL(2); + #endif + #if NUM_CHANNELS >= 4 && LED_PIN3 >= 0 + ADD_CHANNEL(3); + #endif + #if NUM_CHANNELS >= 5 && LED_PIN4 >= 0 + ADD_CHANNEL(4); + #endif + #if NUM_CHANNELS >= 6 && LED_PIN5 >= 0 + ADD_CHANNEL(5); + #endif + #if NUM_CHANNELS >= 7 && LED_PIN6 >= 0 + ADD_CHANNEL(6); + #endif + #if NUM_CHANNELS >= 8 && LED_PIN7 >= 0 + ADD_CHANNEL(7); + #endif + + #ifdef POWER_LIMIT_MW + set_max_power_in_milliwatts(POWER_LIMIT_MW); + #endif + + #undef ADD_CHANNEL + } } WS281xGFX::WS281xGFX(size_t w, size_t h) : GFXBase(w, h) @@ -108,6 +142,33 @@ WS281xGFX::~WS281xGFX() leds = nullptr; } +void WS281xGFX::ApplyCompiledTransportConfiguration(const DeviceConfig& deviceConfig, const std::vector>& devices, const char* reason) +{ + RebindCompiledLEDs(devices); + LogWS281xConfiguration(deviceConfig, devices, true, reason); +} + +void WS281xGFX::ConfigureTopology(size_t width, size_t height, bool serpentine) +{ + const auto newLEDCount = width * height; + if (newLEDCount != GetLEDCount()) + { + auto* newPixels = static_cast(calloc(newLEDCount, sizeof(CRGB))); + if (!newPixels) + throw std::runtime_error("Unable to resize LEDs in WS281xGFX"); + + if (leds) + { + memcpy(newPixels, leds, std::min(GetLEDCount(), newLEDCount) * sizeof(CRGB)); + free(leds); + } + + leds = newPixels; + } + + GFXBase::ConfigureTopology(width, height, serpentine); +} + void WS281xGFX::InitializeHardware(std::vector>& devices) { // We don't support more than 8 parallel channels @@ -115,13 +176,32 @@ void WS281xGFX::InitializeHardware(std::vector>& device #error The maximum value of NUM_CHANNELS (number of parallel channels) is 8 #endif + const auto& deviceConfig = g_ptrSystem->GetDeviceConfig(); + for (int i = 0; i < NUM_CHANNELS; i++) { debugW("Allocating WS281xGFX for channel %d", i); - devices.push_back(make_shared_psram(MATRIX_WIDTH, MATRIX_HEIGHT)); + auto device = std::make_shared(deviceConfig.GetMatrixWidth(), deviceConfig.GetMatrixHeight()); + device->ConfigureTopology(deviceConfig.GetMatrixWidth(), deviceConfig.GetMatrixHeight(), deviceConfig.IsMatrixSerpentine()); + devices.push_back(device); } - AddLEDs(devices); + // Keep the compiled FastLED transport alive for the default boot path so existing boards retain the + // same hardware behavior. The runtime manager only takes over when the user truly changes pins/channels. + if (deviceConfig.UsesCompiledWS281xTransport()) + { + AddCompiledLEDs(devices); + ApplyCompiledTransportConfiguration(deviceConfig, devices, "init"); + } + else + { + #if USE_WS281X + auto& outputManager = g_ptrSystem->SetupWS281xOutputManager(); + String errorMessage; + if (!outputManager.ApplyConfig(deviceConfig, devices, &errorMessage)) + throw std::runtime_error(errorMessage.c_str()); + #endif + } } // PostProcessFrame @@ -139,32 +219,48 @@ void WS281xGFX::PostProcessFrame(uint16_t localPixelsDrawn, uint16_t wifiPixelsD return; } - // If there are no LEDs to show, we can just return now - - if (FastLED.count() == 0) + #if USE_WS281X + auto& effectManager = g_ptrSystem->GetEffectManager(); + const auto& deviceConfig = g_ptrSystem->GetDeviceConfig(); + if (deviceConfig.UsesCompiledWS281xTransport()) + { + for (int i = 0; i < NUM_CHANNELS; i++) + { + auto& graphics = effectManager.g(i); + const auto ledCount = graphics.GetLEDCount(); + const auto activePixels = std::min(pixelsDrawn, ledCount); + + fadeLightBy(graphics.leds, activePixels, 255 - deviceConfig.GetBrightness()); + if (activePixels < ledCount) + fill_solid(graphics.leds + activePixels, ledCount - activePixels, CRGB::Black); + } + FastLED.show(g_Values.Fader); + g_Values.FPS = FastLED.getFPS(); + } + else { + if (!g_ptrSystem->HasWS281xOutputManager()) + { + static auto lastDrawTime = millis(); + g_Values.FPS = 1000.0 / max(1UL, millis() - lastDrawTime); + lastDrawTime = millis(); + return; + } + static auto lastDrawTime = millis(); + auto& outputManager = g_ptrSystem->GetWS281xOutputManager(); + const auto targetBrightness = std::min(deviceConfig.GetBrightness(), g_Values.Fader); + outputManager.Show(g_ptrSystem->GetDevices(), pixelsDrawn, targetBrightness); g_Values.FPS = 1000.0 / max(1UL, millis() - lastDrawTime); lastDrawTime = millis(); - return; } - - auto& effectManager = g_ptrSystem->GetEffectManager(); - - for (int i = 0; i < NUM_CHANNELS; i++) - { - FastLED[i].setLeds(effectManager.g(i).leds, pixelsDrawn); - fadeLightBy(FastLED[i].leds(), FastLED[i].size(), 255 - g_ptrSystem->GetDeviceConfig().GetBrightness()); - } - FastLED.show(g_Values.Fader); //Shows the pixels - - g_Values.FPS = FastLED.getFPS(); #ifdef POWER_LIMIT_MW - g_Values.Brite = 100.0 * calculate_max_brightness_for_power_mW(g_ptrSystem->GetDeviceConfig().GetBrightness(), POWER_LIMIT_MW) / 255; + g_Values.Brite = 100.0 * calculate_max_brightness_for_power_mW(deviceConfig.GetBrightness(), POWER_LIMIT_MW) / 255; #else - g_Values.Brite = 100.0 * g_ptrSystem->GetDeviceConfig().GetBrightness() / 255; + g_Values.Brite = 100.0 * deviceConfig.GetBrightness() / 255; #endif g_Values.Watts = calculate_unscaled_power_mW(effectManager.g().leds, pixelsDrawn) / 1000; // 1000 for mw->W + #endif } #if HEXAGON @@ -183,10 +279,15 @@ void HexagonGFX::InitializeHardware(std::vector>& devic for (int i = 0; i < NUM_CHANNELS; i++) { debugW("Allocating HexagonGFX for channel %d", i); - devices.push_back(make_shared_psram(NUM_LEDS)); + devices.push_back(std::make_shared(NUM_LEDS)); } - AddLEDs(devices); + #if USE_WS281X + auto& outputManager = g_ptrSystem->SetupWS281xOutputManager(); + String errorMessage; + if (!outputManager.ApplyConfig(g_ptrSystem->GetDeviceConfig(), devices, &errorMessage)) + throw std::runtime_error(errorMessage.c_str()); + #endif } // filHexRing diff --git a/src/ws281xoutputmanager.cpp b/src/ws281xoutputmanager.cpp new file mode 100644 index 000000000..dde546d09 --- /dev/null +++ b/src/ws281xoutputmanager.cpp @@ -0,0 +1,201 @@ +//+-------------------------------------------------------------------------- +// +// File: ws281xoutputmanager.cpp +// +// NightDriverStrip - (c) 2018 Plummer's Software LLC. All Rights Reserved. +// +// ESP32 runtime WS281x output manager. This is the small indirection layer that +// lets us change pins/channel count/live LED count without changing effect code. +// +//--------------------------------------------------------------------------- + +#include "globals.h" + +#if USE_WS281X + +#include "ws281xoutputmanager.h" + +#include +#include + +#include "gfxbase.h" +#include "pixel_controller.h" + +#ifndef FASTLED_RMT_BUILTIN_DRIVER +#define FASTLED_RMT_BUILTIN_DRIVER false +#endif + +namespace +{ + static_assert(NUM_CHANNELS <= 8, "ESP32 RMT path supports up to 8 WS281x channels"); + + ColorAdjustment MakeIdentityColorAdjustment() + { + ColorAdjustment adjustment{}; + adjustment.premixed = CRGB::White; + #if FASTLED_HD_COLOR_MIXING + adjustment.color = CRGB::White; + adjustment.brightness = 255; + #endif + return adjustment; + } + + void LogRuntimeWS281xConfiguration(const DeviceConfig& config, const std::vector>& devices, const char* reason) + { + debugI("WS281x config (%s): path=runtime driver=%s channels=%zu matrix=%ux%u serpentine=%d leds=%zu", + reason ? reason : "update", + config.GetRuntimeDriverName().c_str(), + config.GetChannelCount(), + static_cast(config.GetMatrixWidth()), + static_cast(config.GetMatrixHeight()), + config.IsMatrixSerpentine(), + config.GetActiveLEDCount()); + + const auto& pins = config.GetWS281xPins(); + for (size_t channel = 0; channel < config.GetChannelCount() && channel < devices.size(); ++channel) + { + const auto& graphics = *devices[channel]; + debugI("WS281x channel %zu (runtime): pin=%d leds=%zu matrix=%ux%u serpentine=%d buffer=%p", + channel, + pins[channel], + graphics.GetLEDCount(), + static_cast(graphics.GetMatrixWidth()), + static_cast(graphics.GetMatrixHeight()), + graphics.IsSerpentine(), + graphics.leds); + } + } +} + +WS281xOutputManager::~WS281xOutputManager() +{ + for (size_t i = 0; i < _channels.size(); ++i) + ReleaseChannel(i); +} + +bool WS281xOutputManager::RecreateChannel(size_t channelIndex, int8_t pin, size_t ledCount, String* errorMessage) +{ + ReleaseChannel(channelIndex); + + auto& state = _channels[channelIndex]; + #if FASTLED_RMT5 + state.controller = std::make_unique( + pin, + FASTLED_WS2812_T1, + FASTLED_WS2812_T2, + FASTLED_WS2812_T3, + WS281xRuntimeController::DMA_AUTO + ); + #else + state.controller = std::make_unique(pin, FASTLED_WS2812_T1, FASTLED_WS2812_T2, FASTLED_WS2812_T3, 8, FASTLED_RMT_BUILTIN_DRIVER); + #endif + if (!state.controller) + { + if (errorMessage) + *errorMessage = "failed to allocate WS281x output"; + ReleaseChannel(channelIndex); + return false; + } + + state.pin = pin; + state.ledCount = ledCount; + state.active = true; + pinMode(pin, OUTPUT); + return true; +} + +void WS281xOutputManager::ReleaseChannel(size_t channelIndex) +{ + auto& state = _channels[channelIndex]; + + state.controller.reset(); + + if (state.pin >= 0) + { + pinMode(state.pin, OUTPUT); + digitalWrite(state.pin, LOW); + } + + state.pin = -1; + state.ledCount = 0; + state.active = false; +} + +bool WS281xOutputManager::ApplyConfig(const DeviceConfig& config, const std::vector>& devices, String* errorMessage) +{ + if (config.GetOutputDriver() != DeviceConfig::OutputDriver::WS281x) + { + if (errorMessage) + *errorMessage = "recompile needed"; + return false; + } + + const size_t channelCount = std::min(config.GetChannelCount(), devices.size()); + const size_t ledCount = config.GetActiveLEDCount(); + const auto& pins = config.GetWS281xPins(); + + // The manager owns the runtime transport objects. Rebuilding them from DeviceConfig keeps GPIO and + // channel changes local here instead of forcing the rest of the renderer through templated FastLED paths. + for (size_t i = 0; i < _channels.size(); ++i) + { + const bool shouldBeActive = i < channelCount; + if (!shouldBeActive) + { + ReleaseChannel(i); + continue; + } + + auto& state = _channels[i]; + if (!state.active || state.pin != pins[i] || state.ledCount != ledCount) + { + if (!RecreateChannel(i, pins[i], ledCount, errorMessage)) + return false; + } + } + + _activeChannelCount = channelCount; + _activeLEDCount = ledCount; + + if (errorMessage) + *errorMessage = ""; + + LogRuntimeWS281xConfiguration(config, devices, "apply"); + + return true; +} + +void WS281xOutputManager::Show(const std::vector>& devices, uint16_t pixelsDrawn, uint8_t brightness) +{ + if (_activeChannelCount == 0 || _activeLEDCount == 0) + return; + + const size_t pixelsToShow = std::min(static_cast(pixelsDrawn), _activeLEDCount); + const uint8_t scale = brightness; + + for (size_t channelIndex = 0; channelIndex < _activeChannelCount && channelIndex < devices.size(); ++channelIndex) + { + auto& state = _channels[channelIndex]; + if (!state.active || !state.controller) + continue; + + const auto& device = devices[channelIndex]; + std::unique_ptr outputPixels = std::make_unique(_activeLEDCount); + for (size_t pixelIndex = 0; pixelIndex < _activeLEDCount; ++pixelIndex) + { + CRGB color = pixelIndex < pixelsToShow ? device->leds[pixelIndex] : CRGB::Black; + nscale8x3_video(color.r, color.g, color.b, scale); + outputPixels[pixelIndex] = color; + } + + PixelController pixels(reinterpret_cast(outputPixels.get()), _activeLEDCount, MakeIdentityColorAdjustment(), DISABLE_DITHER, true, 0); + auto iterator = pixels.as_iterator(Rgbw()); + #if FASTLED_RMT5 + state.controller->loadPixelData(iterator); + state.controller->showPixels(); + #else + state.controller->showPixels(iterator); + #endif + } +} + +#endif diff --git a/tools/test_topology.py b/tools/test_topology.py new file mode 100644 index 000000000..46ecfbd9a --- /dev/null +++ b/tools/test_topology.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 + +""" +Smoke-test WS281x runtime topology against a live NightDriverStrip device. + +This test is intentionally end-to-end: +- read compiled/runtime capability from /api/v1/settings/schema +- apply several valid live topology reshapes +- verify /api/v1/settings and /statistics reflect each change +- verify invalid requests are rejected with the expected error +- restore the original topology before exiting + +Requires: + pip install requests +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from dataclasses import dataclass +from typing import Iterable + +try: + import requests +except ModuleNotFoundError: # pragma: no cover - dependency handling path + requests = None + + +class TestFailure(RuntimeError): + pass + + +@dataclass(frozen=True) +class Shape: + width: int + height: int + + @property + def leds(self) -> int: + return self.width * self.height + + def label(self) -> str: + return f"{self.width}x{self.height} ({self.leds} leds)" + + +class Device: + def __init__(self, host: str, port: int = 80, timeout: float = 5.0): + if requests is None: + fail("This script requires the 'requests' package. Install it with: pip install requests") + self.host = host + self.port = port + self.base_url = f"http://{host}:{port}" + self.timeout = timeout + + def get_json(self, path: str, timeout: float | None = None) -> dict: + response = requests.get(f"{self.base_url}{path}", timeout=self.timeout if timeout is None else timeout) + response.raise_for_status() + return response.json() + + def post_json(self, path: str, payload: dict, timeout: float | None = None) -> requests.Response: + response = requests.post( + f"{self.base_url}{path}", + json=payload, + timeout=self.timeout if timeout is None else timeout, + ) + return response + + def post_form(self, path: str, payload: dict, timeout: float | None = None) -> requests.Response: + response = requests.post( + f"{self.base_url}{path}", + data=payload, + timeout=self.timeout if timeout is None else timeout, + ) + return response + + +def fail(message: str) -> None: + raise TestFailure(message) + + +def expect(condition: bool, message: str) -> None: + if not condition: + fail(message) + + +def unique_shapes(shapes: Iterable[Shape]) -> list[Shape]: + seen: set[tuple[int, int]] = set() + result: list[Shape] = [] + for shape in shapes: + key = (shape.width, shape.height) + if key in seen: + continue + seen.add(key) + result.append(shape) + return result + + +def choose_valid_shapes(schema: dict, current: Shape) -> list[Shape]: + topology = schema["topology"] + max_leds = int(topology["compiledMaxLEDs"]) + nominal_width = int(topology["compiledNominalWidth"]) + nominal_height = int(topology["compiledNominalHeight"]) + nominal_area = nominal_width * nominal_height + + candidates: list[Shape] = [] + + for width, height in ((32, 16), (32, 8), (16, 8), (24, 8), (16, 16), (8, 8)): + if width > 0 and height > 0 and width * height <= max_leds: + candidates.append(Shape(width, height)) + + if current.leds > 1: + reduced_height = max(1, current.height // 2) + candidates.append(Shape(current.width, reduced_height)) + reduced_width = max(1, current.width // 2) + candidates.append(Shape(reduced_width, current.height)) + + # If compiled LED cap is larger than the nominal matrix area, deliberately prove that runtime + # reshape is bounded by NUM_LEDS rather than the original compile-time width/height envelope. + if max_leds > nominal_area: + wider = nominal_width + 2 + if wider <= max_leds: + stretched_height = max(1, max_leds // wider) + if wider * stretched_height <= max_leds: + candidates.append(Shape(wider, stretched_height)) + + taller = nominal_height + 2 + stretched_width = max(1, max_leds // taller) + if stretched_width * taller <= max_leds: + candidates.append(Shape(stretched_width, taller)) + + filtered = [ + shape + for shape in unique_shapes(candidates) + if shape != current and shape.width > 0 and shape.height > 0 and shape.leds <= max_leds + ] + + if not filtered: + fail("Could not derive any valid topology test shapes from the device schema") + + return filtered + + +def wait_for_live_shape(device: Device, shape: Shape, attempts: int = 20, delay_seconds: float = 0.25) -> dict: + last_stats = {} + for _ in range(attempts): + stats = device.get_json("/statistics") + last_stats = stats + if ( + int(stats["CONFIGURED_MATRIX_WIDTH"]) == shape.width + and int(stats["CONFIGURED_MATRIX_HEIGHT"]) == shape.height + and int(stats["CONFIGURED_NUM_LEDS"]) == shape.leds + and int(stats["ACTIVE_MATRIX_WIDTH"]) == shape.width + and int(stats["ACTIVE_MATRIX_HEIGHT"]) == shape.height + and int(stats["ACTIVE_NUM_LEDS"]) == shape.leds + ): + return stats + time.sleep(delay_seconds) + + fail( + "Device did not converge to the expected live topology. " + f"expected={shape.label()} last_stats=" + + json.dumps( + { + "CONFIGURED_MATRIX_WIDTH": last_stats.get("CONFIGURED_MATRIX_WIDTH"), + "CONFIGURED_MATRIX_HEIGHT": last_stats.get("CONFIGURED_MATRIX_HEIGHT"), + "CONFIGURED_NUM_LEDS": last_stats.get("CONFIGURED_NUM_LEDS"), + "ACTIVE_MATRIX_WIDTH": last_stats.get("ACTIVE_MATRIX_WIDTH"), + "ACTIVE_MATRIX_HEIGHT": last_stats.get("ACTIVE_MATRIX_HEIGHT"), + "ACTIVE_NUM_LEDS": last_stats.get("ACTIVE_NUM_LEDS"), + } + ) + ) + + +def restore_shape(device: Device, shape: Shape) -> None: + payload = {"topology": {"width": shape.width, "height": shape.height}} + + try: + response = device.post_json("/api/v1/settings", payload) + if response.status_code == 200: + wait_for_live_shape(device, shape, attempts=40, delay_seconds=0.5) + return + + print( + f"Warning: restore request returned HTTP {response.status_code}: {response.text}", + file=sys.stderr, + ) + except requests.exceptions.ReadTimeout: + print( + "Restore request timed out; polling to see whether the device applied it anyway...", + file=sys.stderr, + ) + try: + wait_for_live_shape(device, shape, attempts=40, delay_seconds=0.5) + return + except TestFailure: + print("Restore was not visible after timeout; retrying once with a longer HTTP timeout...", file=sys.stderr) + except requests.exceptions.RequestException as exc: + print(f"Restore request hit a transport error: {exc}. Retrying once...", file=sys.stderr) + + try: + response = device.post_json("/api/v1/settings", payload, timeout=15.0) + if response.status_code == 200: + wait_for_live_shape(device, shape, attempts=40, delay_seconds=0.5) + return + print( + f"Warning: restore retry returned HTTP {response.status_code}: {response.text}", + file=sys.stderr, + ) + except requests.exceptions.RequestException as exc: + print(f"Warning: restore retry failed: {exc}", file=sys.stderr) + + +def verify_settings(device: Device, shape: Shape) -> None: + settings = device.get_json("/api/v1/settings") + topology = settings["topology"] + expect(int(topology["width"]) == shape.width, f"/api/v1/settings width mismatch for {shape.label()}") + expect(int(topology["height"]) == shape.height, f"/api/v1/settings height mismatch for {shape.label()}") + expect(int(topology["ledCount"]) == shape.leds, f"/api/v1/settings ledCount mismatch for {shape.label()}") + + +def apply_shape(device: Device, shape: Shape) -> None: + response = device.post_json( + "/api/v1/settings", + {"topology": {"width": shape.width, "height": shape.height}}, + ) + if response.status_code != 200: + fail(f"Expected topology {shape.label()} to succeed, got {response.status_code}: {response.text}") + + verify_settings(device, shape) + wait_for_live_shape(device, shape) + + +def verify_invalid_area(device: Device, max_leds: int) -> None: + invalid = Shape(max_leds + 1, 1) + response = device.post_json( + "/api/v1/settings", + {"topology": {"width": invalid.width, "height": invalid.height}}, + ) + expect(response.status_code == 400, f"Expected invalid topology {invalid.label()} to be rejected with HTTP 400") + expect("recompile needed" in response.text.lower(), "Expected invalid-area rejection to mention 'recompile needed'") + + +def verify_driver_mismatch(device: Device, compiled_driver: str) -> None: + invalid_driver = "hub75" if compiled_driver == "ws281x" else "ws281x" + response = device.post_json("/api/v1/settings", {"outputs": {"driver": invalid_driver}}) + expect(response.status_code == 400, "Expected output-driver mismatch to be rejected with HTTP 400") + expect("recompile needed" in response.text.lower(), "Expected driver-mismatch rejection to mention 'recompile needed'") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Smoke-test NightDriverStrip runtime topology on a live WS281x device.") + parser.add_argument("host", help="Device hostname or IP address") + parser.add_argument("--port", type=int, default=80, help="HTTP port (default: 80)") + parser.add_argument( + "--keep", + action="store_true", + help="Leave the last successfully applied topology in place instead of restoring the original topology", + ) + args = parser.parse_args() + + device = Device(args.host, args.port) + + schema = device.get_json("/api/v1/settings/schema") + settings = device.get_json("/api/v1/settings") + statistics = device.get_json("/statistics") + + compiled_driver = str(schema["outputs"]["compiledDriver"]) + expect(compiled_driver == "ws281x", f"This smoke test currently targets WS281x devices, got compiled driver '{compiled_driver}'") + expect(bool(schema["topology"]["liveApply"]) is True, "Expected live topology apply to be enabled for this device") + + original = Shape( + width=int(settings["topology"]["width"]), + height=int(settings["topology"]["height"]), + ) + + compiled_max_leds = int(schema["topology"]["compiledMaxLEDs"]) + compiled_nominal_width = int(schema["topology"]["compiledNominalWidth"]) + compiled_nominal_height = int(schema["topology"]["compiledNominalHeight"]) + compiled_topology = Shape(compiled_nominal_width, compiled_nominal_height) + + print( + "Compiled capacity:", + f"nominal={compiled_nominal_width}x{compiled_nominal_height}", + f"max_leds={compiled_max_leds}", + f"current={original.label()}", + ) + + shapes = choose_valid_shapes(schema, original) + + # Prefer a few compact smoke-test cases rather than exhaustively walking every possible reshape. + shapes = shapes[:4] + print("Testing valid shapes:", ", ".join(shape.label() for shape in shapes)) + + try: + for shape in shapes: + print(f"Applying valid topology: {shape.label()}") + apply_shape(device, shape) + + print("Verifying invalid area rejection") + verify_invalid_area(device, compiled_max_leds) + + print("Verifying driver mismatch rejection") + verify_driver_mismatch(device, compiled_driver) + + # Final consistency check after invalid requests. + last_applied = shapes[-1] if shapes else original + verify_settings(device, last_applied) + wait_for_live_shape(device, last_applied) + + final_stats = device.get_json("/statistics") + print("Smoke test passed") + print( + "Final live stats:", + json.dumps( + { + "CONFIGURED_MATRIX_WIDTH": final_stats.get("CONFIGURED_MATRIX_WIDTH"), + "CONFIGURED_MATRIX_HEIGHT": final_stats.get("CONFIGURED_MATRIX_HEIGHT"), + "ACTIVE_MATRIX_WIDTH": final_stats.get("ACTIVE_MATRIX_WIDTH"), + "ACTIVE_MATRIX_HEIGHT": final_stats.get("ACTIVE_MATRIX_HEIGHT"), + "COMPILED_NUM_LEDS": final_stats.get("COMPILED_NUM_LEDS"), + } + ), + ) + return 0 + finally: + if not args.keep: + print(f"Restoring compiled topology: {compiled_topology.label()}") + restore_shape(device, compiled_topology) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except TestFailure as exc: + print(f"Topology smoke test failed: {exc}", file=sys.stderr) + raise SystemExit(1)