From 0fcebc04590a666ccbea082ebddc2591046315ca Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sat, 4 Oct 2025 13:54:06 +0200 Subject: [PATCH] feat: multimatrix example --- examples/multimatrix/MultiMatrixService.cpp | 228 ++++++++++++++++++++ examples/multimatrix/MultiMatrixService.h | 50 +++++ examples/multimatrix/README.md | 19 ++ examples/multimatrix/data/public/index.html | 138 ++++++++++++ examples/multimatrix/main.cpp | 54 +++++ include/spore/core/NodeContext.h | 2 +- platformio.ini | 25 ++- test/pixelstream/tetris/server.js | 2 +- 8 files changed, 515 insertions(+), 3 deletions(-) create mode 100644 examples/multimatrix/MultiMatrixService.cpp create mode 100644 examples/multimatrix/MultiMatrixService.h create mode 100644 examples/multimatrix/README.md create mode 100644 examples/multimatrix/data/public/index.html create mode 100644 examples/multimatrix/main.cpp diff --git a/examples/multimatrix/MultiMatrixService.cpp b/examples/multimatrix/MultiMatrixService.cpp new file mode 100644 index 0000000..726b5db --- /dev/null +++ b/examples/multimatrix/MultiMatrixService.cpp @@ -0,0 +1,228 @@ +#include "MultiMatrixService.h" +#include "spore/core/ApiServer.h" +#include "spore/util/Logging.h" +#include +#include + +namespace { +constexpr uint8_t DEFAULT_VOLUME = 15; +constexpr char API_STATUS_ENDPOINT[] = "/api/audio/status"; +constexpr char API_CONTROL_ENDPOINT[] = "/api/audio"; +constexpr char EVENT_TOPIC[] = "audio/player"; +} + +MultiMatrixService::MultiMatrixService(NodeContext& ctx, TaskManager& taskManager, uint8_t rxPin, uint8_t txPin, uint8_t potentiometerPin) + : m_ctx(ctx), + m_taskManager(taskManager), + m_serial(std::make_unique(rxPin, txPin)), + m_potentiometerPin(potentiometerPin), + m_volume(DEFAULT_VOLUME), + m_playerReady(false) { + pinMode(m_potentiometerPin, INPUT); + m_serial->begin(9600); + + // DFPlayer Mini requires time to initialize after power-on + delay(1000); + + if (m_player.begin(*m_serial)) { + m_playerReady = true; + m_player.setTimeOut(500); + m_player.EQ(DFPLAYER_EQ_NORMAL); + m_player.volume(m_volume); + LOG_INFO("MultiMatrixService", "DFPlayer initialized successfully"); + publishEvent("ready"); + } else { + LOG_ERROR("MultiMatrixService", "Failed to initialize DFPlayer"); + } + registerTasks(); +} + +void MultiMatrixService::registerEndpoints(ApiServer& api) { + api.addEndpoint(API_STATUS_ENDPOINT, HTTP_GET, + [this](AsyncWebServerRequest* request) { handleStatusRequest(request); }, + std::vector{}); + + api.addEndpoint(API_CONTROL_ENDPOINT, HTTP_POST, + [this](AsyncWebServerRequest* request) { handleControlRequest(request); }, + std::vector{ + ParamSpec{String("action"), true, String("body"), String("string"), + {String("play"), String("stop"), String("pause"), String("resume"), String("next"), String("previous"), String("volume")}}, + ParamSpec{String("volume"), false, String("body"), String("numberRange"), {}, String("15")} + }); +} + +bool MultiMatrixService::isReady() const { + return m_playerReady; +} + +uint8_t MultiMatrixService::getVolume() const { + return m_volume; +} + +void MultiMatrixService::play() { + if (!m_playerReady) { + return; + } + m_player.play(); + publishEvent("play"); + LOG_INFO("MultiMatrixService", "Playback started"); +} + +void MultiMatrixService::stop() { + if (!m_playerReady) { + return; + } + m_player.stop(); + publishEvent("stop"); + LOG_INFO("MultiMatrixService", "Playback stopped"); +} + +void MultiMatrixService::pause() { + if (!m_playerReady) { + return; + } + m_player.pause(); + publishEvent("pause"); + LOG_INFO("MultiMatrixService", "Playback paused"); +} + +void MultiMatrixService::resume() { + if (!m_playerReady) { + return; + } + m_player.start(); + publishEvent("resume"); + LOG_INFO("MultiMatrixService", "Playback resumed"); +} + +void MultiMatrixService::next() { + if (!m_playerReady) { + return; + } + m_player.next(); + publishEvent("next"); + LOG_INFO("MultiMatrixService", "Next track"); +} + +void MultiMatrixService::previous() { + if (!m_playerReady) { + return; + } + m_player.previous(); + publishEvent("previous"); + LOG_INFO("MultiMatrixService", "Previous track"); +} + +void MultiMatrixService::setVolume(uint8_t volume) { + if (!m_playerReady) { + return; + } + const uint8_t clampedVolume = std::min(volume, MAX_VOLUME); + if (clampedVolume == m_volume) { + return; + } + applyVolume(clampedVolume); +} + +void MultiMatrixService::registerTasks() { + m_taskManager.registerTask("multimatrix_potentiometer", POTENTIOMETER_SAMPLE_INTERVAL_MS, + [this]() { pollPotentiometer(); }); +} + +void MultiMatrixService::pollPotentiometer() { + if (!m_playerReady) { + return; + } + + const uint16_t rawValue = analogRead(static_cast(m_potentiometerPin)); + const uint8_t targetVolume = calculateVolumeFromPotentiometer(rawValue); + + // Debug: fire event on significant change + if (targetVolume > m_volume + POT_VOLUME_EPSILON || targetVolume + POT_VOLUME_EPSILON < m_volume) { + StaticJsonDocument<128> debugDoc; + debugDoc["action"] = "pot_debug"; + debugDoc["raw"] = rawValue; + debugDoc["target"] = targetVolume; + debugDoc["current"] = m_volume; + String debugPayload; + serializeJson(debugDoc, debugPayload); + m_ctx.fire(EVENT_TOPIC, &debugPayload); + + applyVolume(targetVolume); + } +} + +uint8_t MultiMatrixService::calculateVolumeFromPotentiometer(uint16_t rawValue) const { + // Clamp raw value to prevent underflow (analogRead can return 1024) + const uint16_t clampedValue = std::min(rawValue, 1023U); + // Invert: all down (0) = max volume, all up (1023) = min volume + const uint16_t invertedValue = 1023U - clampedValue; + const uint8_t scaledVolume = static_cast((static_cast(invertedValue) * MAX_VOLUME) / 1023U); + return std::min(scaledVolume, MAX_VOLUME); +} + +void MultiMatrixService::applyVolume(uint8_t targetVolume) { + m_volume = targetVolume; + m_player.volume(m_volume); + publishEvent("volume"); + LOG_INFO("MultiMatrixService", String("Volume set to ") + String(m_volume)); +} + +void MultiMatrixService::handleStatusRequest(AsyncWebServerRequest* request) { + StaticJsonDocument<128> doc; + doc["ready"] = m_playerReady; + doc["volume"] = static_cast(m_volume); + + String json; + serializeJson(doc, json); + request->send(200, "application/json", json); +} + +void MultiMatrixService::handleControlRequest(AsyncWebServerRequest* request) { + String action = request->hasParam("action", true) ? request->getParam("action", true)->value() : ""; + bool ok = true; + + if (action.equalsIgnoreCase("play")) { + play(); + } else if (action.equalsIgnoreCase("stop")) { + stop(); + } else if (action.equalsIgnoreCase("pause")) { + pause(); + } else if (action.equalsIgnoreCase("resume")) { + resume(); + } else if (action.equalsIgnoreCase("next")) { + next(); + } else if (action.equalsIgnoreCase("previous")) { + previous(); + } else if (action.equalsIgnoreCase("volume")) { + if (request->hasParam("volume", true)) { + int volumeValue = request->getParam("volume", true)->value().toInt(); + setVolume(static_cast(std::max(0, std::min(static_cast(MAX_VOLUME), volumeValue)))); + } else { + ok = false; + } + } else { + ok = false; + } + + StaticJsonDocument<192> resp; + resp["success"] = ok; + resp["ready"] = m_playerReady; + resp["volume"] = static_cast(m_volume); + if (!ok) { + resp["message"] = "Invalid action"; + } + + String json; + serializeJson(resp, json); + request->send(ok ? 200 : 400, "application/json", json); +} + +void MultiMatrixService::publishEvent(const char* action) { + StaticJsonDocument<128> doc; + doc["action"] = action; + doc["volume"] = static_cast(m_volume); + String payload; + serializeJson(doc, payload); + m_ctx.fire(EVENT_TOPIC, &payload); +} diff --git a/examples/multimatrix/MultiMatrixService.h b/examples/multimatrix/MultiMatrixService.h new file mode 100644 index 0000000..188d465 --- /dev/null +++ b/examples/multimatrix/MultiMatrixService.h @@ -0,0 +1,50 @@ +#pragma once +#include +#include +#include +#include +#include +#include "spore/Service.h" +#include "spore/core/TaskManager.h" +#include "spore/core/NodeContext.h" + +class ApiServer; +class AsyncWebServerRequest; +class MultiMatrixService : public Service { +public: + MultiMatrixService(NodeContext& ctx, TaskManager& taskManager, uint8_t rxPin, uint8_t txPin, uint8_t potentiometerPin); + void registerEndpoints(ApiServer& api) override; + const char* getName() const override { return "MultiMatrixAudio"; } + + bool isReady() const; + uint8_t getVolume() const; + + void play(); + void stop(); + void pause(); + void resume(); + void next(); + void previous(); + void setVolume(uint8_t volume); + +private: + static constexpr uint16_t POTENTIOMETER_SAMPLE_INTERVAL_MS = 200; + static constexpr uint8_t MAX_VOLUME = 30; + static constexpr uint8_t POT_VOLUME_EPSILON = 2; + + void registerTasks(); + void pollPotentiometer(); + void handleStatusRequest(AsyncWebServerRequest* request); + void handleControlRequest(AsyncWebServerRequest* request); + uint8_t calculateVolumeFromPotentiometer(uint16_t rawValue) const; + void applyVolume(uint8_t targetVolume); + void publishEvent(const char* action); + + NodeContext& m_ctx; + TaskManager& m_taskManager; + std::unique_ptr m_serial; + DFRobotDFPlayerMini m_player; + uint8_t m_potentiometerPin; + uint8_t m_volume; + bool m_playerReady; +}; diff --git a/examples/multimatrix/README.md b/examples/multimatrix/README.md new file mode 100644 index 0000000..6a00f40 --- /dev/null +++ b/examples/multimatrix/README.md @@ -0,0 +1,19 @@ +# Multi-Matrix + +This example combines different capabilities: +- Spore base stack +- use PixelStreamController in Matrix mode 16x16 +- DFRobotDFPlayerMini audio playback +- analog potentiometer to controll audio volume +- API and web interface to control the audio player (start, stop, pause, next / previous track, volume) + +## MCU +- Wemos D1 Mini + +## Pin Configuration +``` +#define MP3PLAYER_PIN_RX D3 +#define MP3PLAYER_PIN_TX D4 +#define MATRIX_PIN D2 +#define POTI_PIN A0 +``` diff --git a/examples/multimatrix/data/public/index.html b/examples/multimatrix/data/public/index.html new file mode 100644 index 0000000..c535e31 --- /dev/null +++ b/examples/multimatrix/data/public/index.html @@ -0,0 +1,138 @@ + + + + + + Multi-Matrix Audio Control + + + +
+

Multi-Matrix Audio

+
+ + + + + + +
+
+ + + 15 +
+
+

Player Status: Unknown

+

Volume: -

+
+
+ + + + diff --git a/examples/multimatrix/main.cpp b/examples/multimatrix/main.cpp new file mode 100644 index 0000000..2732964 --- /dev/null +++ b/examples/multimatrix/main.cpp @@ -0,0 +1,54 @@ +#include +#include +#include "spore/Spore.h" +#include "spore/util/Logging.h" +#include "../pixelstream/PixelStreamController.h" +#include "MultiMatrixService.h" + +namespace { +constexpr uint8_t MATRIX_PIN = D2; +constexpr uint16_t MATRIX_PIXEL_COUNT = 256; +constexpr uint8_t MATRIX_BRIGHTNESS = 40; +constexpr uint16_t MATRIX_WIDTH = 16; +constexpr bool MATRIX_SERPENTINE = false; +constexpr neoPixelType MATRIX_PIXEL_TYPE = NEO_GRB + NEO_KHZ800; +constexpr uint8_t MP3PLAYER_PIN_RX = D3; +constexpr uint8_t MP3PLAYER_PIN_TX = D4; +constexpr uint8_t POTENTIOMETER_PIN = A0; +} + +Spore spore({ + {"app", "multimatrix"}, + {"role", "media"}, + {"matrix", String(MATRIX_PIXEL_COUNT)} +}); + +std::unique_ptr pixelController; +std::shared_ptr audioService; + +void setup() { + spore.setup(); + + PixelStreamConfig config{ + MATRIX_PIN, + MATRIX_PIXEL_COUNT, + MATRIX_BRIGHTNESS, + MATRIX_WIDTH, + MATRIX_SERPENTINE, + MATRIX_PIXEL_TYPE + }; + + pixelController = std::make_unique(spore.getContext(), config); + pixelController->begin(); + + audioService = std::make_shared(spore.getContext(), spore.getTaskManager(), MP3PLAYER_PIN_RX, MP3PLAYER_PIN_TX, POTENTIOMETER_PIN); + spore.addService(audioService); + + spore.begin(); + + LOG_INFO("MultiMatrix", "Setup complete"); +} + +void loop() { + spore.loop(); +} diff --git a/include/spore/core/NodeContext.h b/include/spore/core/NodeContext.h index 822d6aa..49dc455 100644 --- a/include/spore/core/NodeContext.h +++ b/include/spore/core/NodeContext.h @@ -19,7 +19,7 @@ public: IPAddress localIP; NodeInfo self; std::map* memberList; - Config config; + ::Config config; using EventCallback = std::function; std::map> eventRegistry; diff --git a/platformio.ini b/platformio.ini index 6fa4b77..1c96bdd 100644 --- a/platformio.ini +++ b/platformio.ini @@ -130,7 +130,7 @@ upload_speed = 115200 monitor_speed = 115200 board_build.filesystem = littlefs board_build.flash_mode = dout -board_build.ldscript = eagle.flash.1m256.ld +board_build.ldscript = eagle.flash.4m1m.ld lib_deps = ${common.lib_deps} adafruit/Adafruit NeoPixel@^1.15.1 build_flags = -DPIXEL_PIN=TX -DPIXEL_COUNT=256 -DMATRIX_WIDTH=16 @@ -142,3 +142,26 @@ build_src_filter = + + + + +[env:multimatrix] +platform = platformio/espressif8266@^4.2.1 +board = d1_mini +framework = arduino +upload_speed = 115200 +monitor_speed = 115200 +board_build.filesystem = littlefs +board_build.flash_mode = dio +board_build.flash_size = 4M +board_build.ldscript = eagle.flash.4m1m.ld +lib_deps = ${common.lib_deps} + adafruit/Adafruit NeoPixel@^1.15.1 + dfrobot/DFRobotDFPlayerMini@^1.0.6 +build_src_filter = + + + + + + + + + + + + + + + + diff --git a/test/pixelstream/tetris/server.js b/test/pixelstream/tetris/server.js index 6b2526f..f609481 100644 --- a/test/pixelstream/tetris/server.js +++ b/test/pixelstream/tetris/server.js @@ -6,7 +6,7 @@ const { WebSocketServer } = require('ws'); const HTTP_PORT = process.env.PORT || 3000; const UDP_BROADCAST_PORT = Number(process.env.UDP_PORT) || 4210; -const UDP_BROADCAST_ADDR = process.env.UDP_ADDR || '10.0.1.144'; +const UDP_BROADCAST_ADDR = process.env.UDP_ADDR || '255.255.255.255'; const MATRIX_WIDTH = Number(process.env.MATRIX_WIDTH) || 16; const MATRIX_HEIGHT = Number(process.env.MATRIX_HEIGHT) || 16;