From f2d60303ee56b720ffeb0e9c330994d91bfd1764 Mon Sep 17 00:00:00 2001 From: toptah Date: Mon, 14 Sep 2026 19:44:52 +0200 Subject: [PATCH] Initial commit: add stepper controller firmware --- .gitignore | 5 + ARCHITECTURE.md | 15 +++ CHANGELOG.md | 10 ++ HARDWARE.md | 23 +++++ README.md | 42 +++++++++ TESTING.md | 26 ++++++ config.example.json | 6 ++ include/config/ConfigManager.h | 50 ++++++++++ include/motor/StepperController.h | 49 ++++++++++ include/network/NetworkManager.h | 20 ++++ include/ota/OtaManager.h | 17 ++++ include/web/WebServerManager.h | 34 +++++++ platformio.ini | 11 +++ src/ConfigManager.cpp | 86 +++++++++++++++++ src/NetworkManager.cpp | 45 +++++++++ src/OtaManager.cpp | 30 ++++++ src/StepperController.cpp | 121 ++++++++++++++++++++++++ src/WebServerManager.cpp | 150 ++++++++++++++++++++++++++++++ src/main.cpp | 54 +++++++++++ 19 files changed, 794 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 CHANGELOG.md create mode 100644 HARDWARE.md create mode 100644 README.md create mode 100644 TESTING.md create mode 100644 config.example.json create mode 100644 include/config/ConfigManager.h create mode 100644 include/motor/StepperController.h create mode 100644 include/network/NetworkManager.h create mode 100644 include/ota/OtaManager.h create mode 100644 include/web/WebServerManager.h create mode 100644 platformio.ini create mode 100644 src/ConfigManager.cpp create mode 100644 src/NetworkManager.cpp create mode 100644 src/OtaManager.cpp create mode 100644 src/StepperController.cpp create mode 100644 src/WebServerManager.cpp create mode 100644 src/main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e728ca6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio/ +.vscode/ +*.bin +secrets.json +config.json diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2245061 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,15 @@ +# Architektur V1 + +`main.cpp` besitzt nur den nicht-blockierenden Scheduler: Motor, Netzwerk, Web und OTA werden in jedem Loop-Durchlauf aktualisiert. + +- `ConfigManager`: LittleFS und JSON-Konfiguration, Defaults, Factory Reset +- `StepperController`: einzige Abstraktion fuer AccelStepper und Motorzustand +- `NetworkManager`: STA-Verbindung mit Timeout oder lokaler AP +- `WebServerManager`: Status, Befehle, Konfiguration und Firmware-Upload +- `OtaManager`: ArduinoOTA und Update-Sicherheitsstopp + +Web-Kommandos rufen ausschliesslich `StepperController` auf. MQTT kann spaeter als weiterer Adapter dieselbe API verwenden. V1 fuehrt relative Schritte und die Anzeige einer relativen Position; absolute Positionierung und Homing sind V2. + +## Zustandsmodell + +`IDLE`, `MOVING`, `CONTINUOUS`, `LIMIT_LEFT`, `LIMIT_RIGHT`, `ERROR`. Ein Richtungsbefehl wird bei ausgeloestem Limit vor dem Start abgewiesen. Wird ein Limit waehrend der Fahrt erkannt, werden Ausgaenge deaktiviert und der Motor in den Limit-Zustand gesetzt. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a7e9a87 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## Unreleased - V1 baseline + +- PlatformIO-Projekt fuer Wemos D1 mini angelegt +- Nicht-blockierende Stepper-Steuerung mit AccelStepper +- WLAN-STA und AP-Fallback mit LittleFS-Konfiguration +- Lokale Websteuerung und Status-API +- ArduinoOTA/Web-Update-Grundlage mit Motorstopp +- Pinmapping und V1-Testplan dokumentiert diff --git a/HARDWARE.md b/HARDWARE.md new file mode 100644 index 0000000..992c91d --- /dev/null +++ b/HARDWARE.md @@ -0,0 +1,23 @@ +# Hardware und Pinmapping + +## Motor + +| Funktion | D1-mini | GPIO | Hinweis | +|---|---:|---:|---| +| ULN2003 IN1 | D1 | GPIO5 | unkritischer GPIO | +| ULN2003 IN2 | D2 | GPIO4 | unkritischer GPIO | +| ULN2003 IN3 | D5 | GPIO14 | unkritischer GPIO | +| ULN2003 IN4 | D6 | GPIO12 | unkritischer GPIO | + +Der Code nutzt `HALF4WIRE` und die Sequenz IN1, IN3, IN2, IN4, wie sie fuer gaengige 28BYJ-48/ULN2003-Aufbauten benoetigt wird. Bei umgekehrter Mechanik wird die Richtung in der Konfiguration invertiert. + +## Optionale Endschalter + +- Links: D7 / GPIO13, unkritisch beim Boot +- Rechts: D4 / GPIO2, Bootstrapping-Pin; nur verwenden, wenn der Schalter beim Reset HIGH bleibt + +Standard ist `INPUT_PULLUP` mit Schalter gegen GND und `activeHigh = false`. Endschalter sind in V1 konfigurierbar, aber standardmaessig deaktiviert. + +## Versorgung + +Den Motor ueber eine geeignete externe 5-V-Quelle versorgen. Masse von Netzteil, ULN2003 und D1 mini verbinden. Den Motor nicht aus dem 3.3-V-Pin speisen und die USB-Versorgung nicht als Motorversorgung einplanen. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0579916 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# PS2014 Controller V1 + +Firmware fuer einen Wemos D1 mini (ESP8266) und einen 28BYJ-48 mit ULN2003 zur Steuerung der IKEA PS 2014 Lampe. + +## V1-Umfang + +- Nicht-blockierende relative Motorbewegungen mit AccelStepper +- Links, rechts, Stop, Not-Aus und 1/5/10 Umdrehungen +- WLAN mit persistenten Zugangsdaten und AP-Fallback +- Lokale Weboberflaeche in WLAN- und AP-Betrieb +- LittleFS-Konfiguration ohne Secrets im Repository +- ArduinoOTA und Web-Upload-Grundlage +- Optionale Endschalter-Richtungssperre + +MQTT, Home Assistant, WLED, Homing und absolute Positionierung sind bewusst spaeteren Versionen vorbehalten. + +## Hardware + +- Wemos D1 mini / ESP8266 +- 28BYJ-48, externe 5-V-Versorgung +- ULN2003-Treiber, gemeinsame Masse mit dem ESP8266 + +Details und Boot-Pin-Hinweise stehen in [HARDWARE.md](HARDWARE.md). + +## Build und Upload + +1. PlatformIO in VS Code installieren. +2. Projekt oeffnen. +3. `PlatformIO: Build` ausfuehren. +4. Seriell mit 115200 Baud verbinden. +5. Erstes Flash per USB: `PlatformIO: Upload`. +6. Spaeter OTA mit `upload_protocol = espota` und `upload_port = ` ergaenzen. + +Die lokale Umgebung dieses Projekts hatte beim Anlegen kein `pio`-Kommando im PATH; der Build muss daher in der PlatformIO-Erweiterung oder einer PlatformIO-Shell ausgefuehrt werden. + +## Erster Start + +Ohne gespeicherte SSID startet der Controller den AP `PS2014-Setup` mit dem Passwort `ps2014v1` unter `192.168.4.1`. Die Motorsteuerung bleibt dort verfuegbar. Die persistente Datei liegt nur im LittleFS des Controllers und wird nicht ins Git-Repository geschrieben. + +## Sicherheit und Grenzen + +Vor dem ersten mechanischen Test Motor frei laufen lassen und die Drehrichtung pruefen. Die Startwerte fuer Geschwindigkeit und Beschleunigung sind konservativ, aber keine mechanische Freigabe. Endschalter sind standardmaessig deaktiviert. D4/GPIO2 darf wegen des ESP8266-Bootstraps nicht beliebig beschaltet werden. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..dc643c6 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,26 @@ +# Testplan V1 + +## Software + +- [ ] PlatformIO Build fuer `d1_mini` +- [ ] Defaults werden in LittleFS angelegt +- [ ] WLAN ohne SSID startet AP +- [ ] WLAN-Timeout startet AP +- [ ] Statusseite ist im AP erreichbar +- [ ] 1, 5, 10 und 20 Umdrehungen berechnen 4096, 20480, 40960 und 81920 Schritte +- [ ] Stop und Not-Aus waehrend der Fahrt +- [ ] Dauerlauf links/rechts +- [ ] ungultige JSON-Konfiguration wird abgewiesen +- [ ] Factory Reset loescht WLAN-Daten durch Defaults + +## Hardware + +1. Ohne Mechanik: Motorlauf und Reihenfolge pruefen. +2. Richtung mit der Lampe pruefen. +3. 1, 5, 10 und spaeter 20 Umdrehungen vermessen. +4. Stop waehrend Bewegung testen. +5. Dauerlauf, Temperatur, Schrittverluste und mechanische Blockaden pruefen. +6. WLAN-Verlust und AP-Fallback testen. +7. OTA erst nach sicherem kontrolliertem Motorstopp testen. + +Messwerte und Abweichungen hier mit Datum, Versorgungsspannung, Geschwindigkeit und Beschleunigung ergaenzen. diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..a4fd063 --- /dev/null +++ b/config.example.json @@ -0,0 +1,6 @@ +{ + "device": { "name": "PS2014 Wohnzimmer", "hostname": "ps2014-wohnzimmer" }, + "motor": { "stepsPerRevolution": 4096, "maxSpeed": 500, "acceleration": 180, "reverseDirection": false, "holdPosition": false }, + "wifi": { "ssid": "", "password": "", "apName": "PS2014-Setup", "apPassword": "ps2014v1" }, + "limits": { "enabled": false, "leftPin": 13, "rightPin": 2, "activeHigh": false } +} diff --git a/include/config/ConfigManager.h b/include/config/ConfigManager.h new file mode 100644 index 0000000..727703c --- /dev/null +++ b/include/config/ConfigManager.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +struct DeviceConfig { + String name; + String hostname; +}; + +struct MotorConfig { + long stepsPerRevolution; + float maxSpeed; + float acceleration; + bool reverseDirection; + bool holdPosition; +}; + +struct WifiConfig { + String ssid; + String password; + String apName; + String apPassword; +}; + +struct LimitConfig { + bool enabled; + int leftPin; + int rightPin; + bool activeHigh; +}; + +struct AppConfig { + DeviceConfig device; + MotorConfig motor; + WifiConfig wifi; + LimitConfig limits; +}; + +class ConfigManager { + public: + bool begin(); + bool load(); + bool save(); + void resetToDefaults(); + const AppConfig& get() const; + AppConfig& mutableConfig(); + + private: + AppConfig config_; +}; diff --git a/include/motor/StepperController.h b/include/motor/StepperController.h new file mode 100644 index 0000000..95d3de1 --- /dev/null +++ b/include/motor/StepperController.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include "config/ConfigManager.h" + +enum class MotorState { + Idle, + Moving, + Continuous, + LimitLeft, + LimitRight, + Error +}; + +class StepperController { + public: + StepperController(); + void begin(const MotorConfig& config); + void update(); + void moveLeftRotations(float rotations); + void moveRightRotations(float rotations); + void continuousLeft(); + void continuousRight(); + void stop(); + void emergencyStop(); + void setSpeed(float maxSpeed); + void setAcceleration(float acceleration); + void setLimits(bool enabled, bool leftTriggered, bool rightTriggered); + bool isMoving() const; + long position(); + MotorState state() const; + const char* stateName() const; + + private: + bool directionAllowed(int direction) const; + void applyLimitState(); + long rotationsToSteps(float rotations) const; + + AccelStepper stepper_; + long stepsPerRevolution_ = 4096; + float maxSpeed_ = 500.0f; + bool reverseDirection_ = false; + bool holdPosition_ = false; + bool limitsEnabled_ = false; + bool leftLimit_ = false; + bool rightLimit_ = false; + MotorState state_ = MotorState::Idle; +}; diff --git a/include/network/NetworkManager.h b/include/network/NetworkManager.h new file mode 100644 index 0000000..236017f --- /dev/null +++ b/include/network/NetworkManager.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include "config/ConfigManager.h" + +class NetworkManager { + public: + bool begin(const WifiConfig& config, const String& hostname); + void update(); + void startAccessPoint(const WifiConfig& config, const String& hostname); + bool isAccessPoint() const; + bool isConnected() const; + String modeName() const; + String ipAddress() const; + + private: + bool accessPoint_ = false; + unsigned long lastReconnectAttempt_ = 0; +}; diff --git a/include/ota/OtaManager.h b/include/ota/OtaManager.h new file mode 100644 index 0000000..8c63cc9 --- /dev/null +++ b/include/ota/OtaManager.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include +#include "motor/StepperController.h" + +class OtaManager { + public: + void begin(const String& hostname, StepperController& motor); + void update(); + void handleUpload(ESP8266WebServer& server, HTTPUpload& upload, StepperController& motor); + bool isUpdating() const; + + private: + bool updating_ = false; +}; diff --git a/include/web/WebServerManager.h b/include/web/WebServerManager.h new file mode 100644 index 0000000..69ac9e7 --- /dev/null +++ b/include/web/WebServerManager.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include "config/ConfigManager.h" +#include "motor/StepperController.h" +#include "network/NetworkManager.h" +#include "ota/OtaManager.h" + +class WebServerManager { + public: + WebServerManager(ConfigManager& config, StepperController& motor, NetworkManager& network, OtaManager& ota); + void begin(); + void update(); + + private: + void registerRoutes(); + void handleRoot(); + void handleConfigPage(); + void handleStatus(); + void handleCommand(); + void handleConfigGet(); + void handleConfigPost(); + void handleFactoryReset(); + void handleNotFound(); + String renderPage() const; + bool parseFloatArg(const String& name, float& value) const; + + ESP8266WebServer server_{80}; + ConfigManager& config_; + StepperController& motor_; + NetworkManager& network_; + OtaManager& ota_; +}; diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..37e771b --- /dev/null +++ b/platformio.ini @@ -0,0 +1,11 @@ +[env:d1_mini] +platform = espressif8266 +board = d1_mini +framework = arduino +monitor_speed = 115200 +upload_speed = 921600 +lib_deps = + waspinator/AccelStepper@^1.64 + bblanchon/ArduinoJson@^7.4.2 +build_flags = + -D PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY diff --git a/src/ConfigManager.cpp b/src/ConfigManager.cpp new file mode 100644 index 0000000..296c716 --- /dev/null +++ b/src/ConfigManager.cpp @@ -0,0 +1,86 @@ +#include "config/ConfigManager.h" +#include +#include + +namespace { +constexpr char kConfigPath[] = "/config.json"; +} + +bool ConfigManager::begin() { + if (!LittleFS.begin()) { + return false; + } + if (!load()) { + resetToDefaults(); + return save(); + } + return true; +} + +bool ConfigManager::load() { + if (!LittleFS.exists(kConfigPath)) { + return false; + } + File file = LittleFS.open(kConfigPath, "r"); + if (!file) { + return false; + } + JsonDocument document; + const DeserializationError error = deserializeJson(document, file); + file.close(); + if (error) { + return false; + } + config_.device.name = document["device"]["name"] | "PS2014 Controller"; + config_.device.hostname = document["device"]["hostname"] | "ps2014-controller"; + config_.motor.stepsPerRevolution = document["motor"]["stepsPerRevolution"] | 4096L; + config_.motor.maxSpeed = document["motor"]["maxSpeed"] | 500.0f; + config_.motor.acceleration = document["motor"]["acceleration"] | 180.0f; + config_.motor.reverseDirection = document["motor"]["reverseDirection"] | false; + config_.motor.holdPosition = document["motor"]["holdPosition"] | false; + config_.wifi.ssid = document["wifi"]["ssid"] | ""; + config_.wifi.password = document["wifi"]["password"] | ""; + config_.wifi.apName = document["wifi"]["apName"] | "PS2014-Setup"; + config_.wifi.apPassword = document["wifi"]["apPassword"] | "ps2014v1"; + config_.limits.enabled = document["limits"]["enabled"] | false; + config_.limits.leftPin = document["limits"]["leftPin"] | 13; + config_.limits.rightPin = document["limits"]["rightPin"] | 2; + config_.limits.activeHigh = document["limits"]["activeHigh"] | false; + return config_.motor.stepsPerRevolution > 0 && config_.motor.maxSpeed > 0 && config_.motor.acceleration > 0; +} + +bool ConfigManager::save() { + File file = LittleFS.open(kConfigPath, "w"); + if (!file) { + return false; + } + JsonDocument document; + document["device"]["name"] = config_.device.name; + document["device"]["hostname"] = config_.device.hostname; + document["motor"]["stepsPerRevolution"] = config_.motor.stepsPerRevolution; + document["motor"]["maxSpeed"] = config_.motor.maxSpeed; + document["motor"]["acceleration"] = config_.motor.acceleration; + document["motor"]["reverseDirection"] = config_.motor.reverseDirection; + document["motor"]["holdPosition"] = config_.motor.holdPosition; + document["wifi"]["ssid"] = config_.wifi.ssid; + document["wifi"]["password"] = config_.wifi.password; + document["wifi"]["apName"] = config_.wifi.apName; + document["wifi"]["apPassword"] = config_.wifi.apPassword; + document["limits"]["enabled"] = config_.limits.enabled; + document["limits"]["leftPin"] = config_.limits.leftPin; + document["limits"]["rightPin"] = config_.limits.rightPin; + document["limits"]["activeHigh"] = config_.limits.activeHigh; + const bool ok = serializeJsonPretty(document, file) > 0; + file.close(); + return ok; +} + +void ConfigManager::resetToDefaults() { + config_.device = {"PS2014 Controller", "ps2014-controller"}; + config_.motor = {4096, 500.0f, 180.0f, false, false}; + config_.wifi = {"", "", "PS2014-Setup", "ps2014v1"}; + config_.limits = {false, 13, 2, false}; +} + +const AppConfig& ConfigManager::get() const { return config_; } +AppConfig& ConfigManager::mutableConfig() { return config_; } diff --git a/src/NetworkManager.cpp b/src/NetworkManager.cpp new file mode 100644 index 0000000..ecf6071 --- /dev/null +++ b/src/NetworkManager.cpp @@ -0,0 +1,45 @@ +#include "network/NetworkManager.h" + +bool NetworkManager::begin(const WifiConfig& config, const String& hostname) { + WiFi.hostname(hostname); + WiFi.mode(WIFI_STA); + if (config.ssid.isEmpty()) { + startAccessPoint(config, hostname); + return false; + } + WiFi.begin(config.ssid.c_str(), config.password.c_str()); + const unsigned long started = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - started < 12000UL) { + delay(50); + yield(); + } + if (WiFi.status() == WL_CONNECTED) { + accessPoint_ = false; + return true; + } + startAccessPoint(config, hostname); + return false; +} + +void NetworkManager::startAccessPoint(const WifiConfig& config, const String& hostname) { + WiFi.disconnect(); + WiFi.mode(WIFI_AP); + String ssid = config.apName.isEmpty() ? String("PS2014-Setup-") + String(ESP.getChipId(), HEX) : config.apName; + String password = config.apPassword.length() >= 8 ? config.apPassword : "ps2014v1"; + WiFi.softAP(ssid.c_str(), password.c_str()); + accessPoint_ = true; + (void)hostname; +} + +void NetworkManager::update() { + if (accessPoint_ || WiFi.status() == WL_CONNECTED) return; + if (millis() - lastReconnectAttempt_ > 10000UL) { + lastReconnectAttempt_ = millis(); + WiFi.reconnect(); + } +} + +bool NetworkManager::isAccessPoint() const { return accessPoint_; } +bool NetworkManager::isConnected() const { return accessPoint_ || WiFi.status() == WL_CONNECTED; } +String NetworkManager::modeName() const { return accessPoint_ ? "AP" : "WIFI"; } +String NetworkManager::ipAddress() const { return accessPoint_ ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); } diff --git a/src/OtaManager.cpp b/src/OtaManager.cpp new file mode 100644 index 0000000..d3ee311 --- /dev/null +++ b/src/OtaManager.cpp @@ -0,0 +1,30 @@ +#include "ota/OtaManager.h" +#include + +void OtaManager::begin(const String& hostname, StepperController& motor) { + ArduinoOTA.setHostname(hostname.c_str()); + ArduinoOTA.onStart([this, &motor]() { updating_ = true; motor.emergencyStop(); }); + ArduinoOTA.onEnd([this]() { updating_ = false; }); + ArduinoOTA.onError([this](ota_error_t) { updating_ = false; }); + ArduinoOTA.begin(); +} + +void OtaManager::update() { ArduinoOTA.handle(); } +bool OtaManager::isUpdating() const { return updating_; } + +void OtaManager::handleUpload(ESP8266WebServer& server, HTTPUpload& upload, StepperController& motor) { + if (upload.status == UPLOAD_FILE_START) { + motor.emergencyStop(); + updating_ = true; + if (!Update.begin(upload.totalSize)) Update.printError(Serial); + } else if (upload.status == UPLOAD_FILE_WRITE) { + if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) Update.printError(Serial); + } else if (upload.status == UPLOAD_FILE_END) { + if (Update.end(true)) { + updating_ = false; + } else { + Update.printError(Serial); + updating_ = false; + } + } +} diff --git a/src/StepperController.cpp b/src/StepperController.cpp new file mode 100644 index 0000000..0226f21 --- /dev/null +++ b/src/StepperController.cpp @@ -0,0 +1,121 @@ +#include "motor/StepperController.h" + +namespace { +constexpr uint8_t kMotorIn1 = D1; +constexpr uint8_t kMotorIn2 = D2; +constexpr uint8_t kMotorIn3 = D5; +constexpr uint8_t kMotorIn4 = D6; +} + +StepperController::StepperController() + : stepper_(AccelStepper::HALF4WIRE, kMotorIn1, kMotorIn3, kMotorIn2, kMotorIn4) {} + +void StepperController::begin(const MotorConfig& config) { + stepsPerRevolution_ = config.stepsPerRevolution; + reverseDirection_ = config.reverseDirection; + holdPosition_ = config.holdPosition; + setSpeed(config.maxSpeed); + setAcceleration(config.acceleration); + stepper_.setCurrentPosition(0); +} + +void StepperController::update() { + if (state_ == MotorState::Continuous) { + stepper_.runSpeed(); + } else if (state_ == MotorState::Moving) { + if (!stepper_.run()) { + state_ = MotorState::Idle; + if (!holdPosition_) { + stepper_.disableOutputs(); + } + } + } + applyLimitState(); +} + +void StepperController::moveLeftRotations(float rotations) { + if (directionAllowed(-1)) { + const long steps = rotationsToSteps(rotations) * (reverseDirection_ ? 1 : -1); + stepper_.move(steps); + state_ = MotorState::Moving; + } +} + +void StepperController::moveRightRotations(float rotations) { + if (directionAllowed(1)) { + const long steps = rotationsToSteps(rotations) * (reverseDirection_ ? -1 : 1); + stepper_.move(steps); + state_ = MotorState::Moving; + } +} + +void StepperController::continuousLeft() { + if (directionAllowed(-1)) { + stepper_.setSpeed((reverseDirection_ ? 1 : -1) * maxSpeed_); + state_ = MotorState::Continuous; + } +} + +void StepperController::continuousRight() { + if (directionAllowed(1)) { + stepper_.setSpeed((reverseDirection_ ? -1 : 1) * maxSpeed_); + state_ = MotorState::Continuous; + } +} + +void StepperController::stop() { + stepper_.stop(); + state_ = MotorState::Moving; +} + +void StepperController::emergencyStop() { + stepper_.setCurrentPosition(stepper_.currentPosition()); + stepper_.stop(); + stepper_.disableOutputs(); + state_ = MotorState::Idle; +} + +void StepperController::setSpeed(float maxSpeed) { + maxSpeed_ = maxSpeed; + stepper_.setMaxSpeed(maxSpeed); +} +void StepperController::setAcceleration(float acceleration) { stepper_.setAcceleration(acceleration); } + +void StepperController::setLimits(bool enabled, bool leftTriggered, bool rightTriggered) { + limitsEnabled_ = enabled; + leftLimit_ = leftTriggered; + rightLimit_ = rightTriggered; +} + +bool StepperController::isMoving() const { return state_ == MotorState::Moving || state_ == MotorState::Continuous; } +long StepperController::position() { return stepper_.currentPosition(); } +MotorState StepperController::state() const { return state_; } + +const char* StepperController::stateName() const { + switch (state_) { + case MotorState::Moving: return "MOVING"; + case MotorState::Continuous: return "CONTINUOUS"; + case MotorState::LimitLeft: return "LIMIT_LEFT"; + case MotorState::LimitRight: return "LIMIT_RIGHT"; + case MotorState::Error: return "ERROR"; + default: return "IDLE"; + } +} + +bool StepperController::directionAllowed(int direction) const { + return !limitsEnabled_ || (direction < 0 ? !leftLimit_ : !rightLimit_); +} + +void StepperController::applyLimitState() { + if (!limitsEnabled_ || !isMoving()) return; + const long distance = stepper_.distanceToGo(); + if ((distance < 0 && leftLimit_) || (distance > 0 && rightLimit_)) { + emergencyStop(); + state_ = distance < 0 ? MotorState::LimitLeft : MotorState::LimitRight; + } +} + +long StepperController::rotationsToSteps(float rotations) const { + if (rotations < 0) rotations = -rotations; + return lround(rotations * static_cast(stepsPerRevolution_)); +} diff --git a/src/WebServerManager.cpp b/src/WebServerManager.cpp new file mode 100644 index 0000000..c91f6db --- /dev/null +++ b/src/WebServerManager.cpp @@ -0,0 +1,150 @@ +#include "web/WebServerManager.h" +#include +#include + +WebServerManager::WebServerManager(ConfigManager& config, StepperController& motor, NetworkManager& network, OtaManager& ota) + : config_(config), motor_(motor), network_(network), ota_(ota) {} + +void WebServerManager::begin() { + registerRoutes(); + server_.begin(); +} + +void WebServerManager::update() { server_.handleClient(); } + +void WebServerManager::registerRoutes() { + server_.on("/", HTTP_GET, [this]() { handleRoot(); }); + server_.on("/config", HTTP_GET, [this]() { handleConfigPage(); }); + server_.on("/api/status", HTTP_GET, [this]() { handleStatus(); }); + server_.on("/api/command", HTTP_POST, [this]() { handleCommand(); }); + server_.on("/api/config", HTTP_GET, [this]() { handleConfigGet(); }); + server_.on("/api/config", HTTP_POST, [this]() { handleConfigPost(); }); + server_.on("/api/factory-reset", HTTP_POST, [this]() { handleFactoryReset(); }); + server_.on("/update", HTTP_POST, [this]() { server_.send(200, "text/plain", "Update complete"); delay(100); ESP.restart(); }, + [this]() { ota_.handleUpload(server_, server_.upload(), motor_); }); + server_.onNotFound([this]() { handleNotFound(); }); +} + +void WebServerManager::handleRoot() { server_.send(200, "text/html", renderPage()); } + +void WebServerManager::handleConfigPage() { + const AppConfig& config = config_.get(); + String page = F("Konfiguration

Konfiguration

Geraet

Motor

WLAN

Zur Steuerung

"); + server_.send(200, "text/html", page); +} + +void WebServerManager::handleStatus() { + JsonDocument document; + const AppConfig& config = config_.get(); + document["name"] = config.device.name; + document["mode"] = network_.modeName(); + document["ip"] = network_.ipAddress(); + document["state"] = motor_.stateName(); + document["positionSteps"] = motor_.position(); + document["positionRotations"] = static_cast(motor_.position()) / config.motor.stepsPerRevolution; + document["moving"] = motor_.isMoving(); + String output; + serializeJson(document, output); + server_.send(200, "application/json", output); +} + +void WebServerManager::handleCommand() { + const String command = server_.arg("command"); + if (command == "left") motor_.continuousLeft(); + else if (command == "right") motor_.continuousRight(); + else if (command == "stop") motor_.stop(); + else if (command == "emergency") motor_.emergencyStop(); + else if (command.startsWith("left-")) motor_.moveLeftRotations(command.substring(5).toFloat()); + else if (command.startsWith("right-")) motor_.moveRightRotations(command.substring(6).toFloat()); + else { + server_.send(400, "application/json", "{\"error\":\"invalid command\"}"); + return; + } + server_.send(200, "application/json", "{\"ok\":true}"); +} + +void WebServerManager::handleConfigGet() { + const AppConfig& config = config_.get(); + JsonDocument document; + document["device"]["name"] = config.device.name; + document["device"]["hostname"] = config.device.hostname; + document["motor"]["stepsPerRevolution"] = config.motor.stepsPerRevolution; + document["motor"]["maxSpeed"] = config.motor.maxSpeed; + document["motor"]["acceleration"] = config.motor.acceleration; + document["motor"]["reverseDirection"] = config.motor.reverseDirection; + document["motor"]["holdPosition"] = config.motor.holdPosition; + document["wifi"]["ssid"] = config.wifi.ssid; + document["wifi"]["apName"] = config.wifi.apName; + document["limits"]["enabled"] = config.limits.enabled; + String output; + serializeJson(document, output); + server_.send(200, "application/json", output); +} + +void WebServerManager::handleConfigPost() { + JsonDocument document; + if (deserializeJson(document, server_.arg("plain"))) { + server_.send(400, "application/json", "{\"error\":\"invalid json\"}"); + return; + } + AppConfig& config = config_.mutableConfig(); + config.device.name = document["device"]["name"] | config.device.name; + config.device.hostname = document["device"]["hostname"] | config.device.hostname; + config.motor.stepsPerRevolution = document["motor"]["stepsPerRevolution"] | config.motor.stepsPerRevolution; + config.motor.maxSpeed = document["motor"]["maxSpeed"] | config.motor.maxSpeed; + config.motor.acceleration = document["motor"]["acceleration"] | config.motor.acceleration; + config.motor.reverseDirection = document["motor"]["reverseDirection"] | config.motor.reverseDirection; + config.motor.holdPosition = document["motor"]["holdPosition"] | config.motor.holdPosition; + config.wifi.ssid = document["wifi"]["ssid"] | config.wifi.ssid; + const String submittedPassword = document["wifi"]["password"] | ""; + if (!submittedPassword.isEmpty()) { + config.wifi.password = submittedPassword; + } + config.wifi.apName = document["wifi"]["apName"] | config.wifi.apName; + const String submittedApPassword = document["wifi"]["apPassword"] | ""; + if (!submittedApPassword.isEmpty()) { + config.wifi.apPassword = submittedApPassword; + } + config.wifi.apName = document["wifi"]["apName"] | config.wifi.apName; + config.limits.enabled = document["limits"]["enabled"] | config.limits.enabled; + if (config.motor.stepsPerRevolution <= 0 || config.motor.maxSpeed <= 0 || config.motor.acceleration <= 0 || !config_.save()) { + server_.send(400, "application/json", "{\"error\":\"configuration rejected\"}"); + return; + } + server_.send(200, "application/json", "{\"ok\":true,\"restartRequired\":true}"); +} + +void WebServerManager::handleFactoryReset() { + motor_.emergencyStop(); + config_.resetToDefaults(); + config_.save(); + server_.send(200, "text/plain", "Factory reset complete. Rebooting."); + delay(100); + ESP.restart(); +} + +void WebServerManager::handleNotFound() { server_.send(404, "text/plain", "Not found"); } + +String WebServerManager::renderPage() const { + return R"HTML(PS2014 Controller

PS2014 Controller

Status
...
Position
...
Netz
...
)HTML"; +} + +bool WebServerManager::parseFloatArg(const String& name, float& value) const { + if (!server_.hasArg(name)) return false; + value = server_.arg(name).toFloat(); + return true; +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..d25857c --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,54 @@ +#include +#include "config/ConfigManager.h" +#include "motor/StepperController.h" +#include "network/NetworkManager.h" +#include "ota/OtaManager.h" +#include "web/WebServerManager.h" + +ConfigManager configManager; +StepperController motor; +NetworkManager network; +OtaManager ota; +WebServerManager web(configManager, motor, network, ota); + +bool readLimit(int pin, bool activeHigh) { + const bool level = digitalRead(pin) == HIGH; + return activeHigh ? level : !level; +} + +void setup() { + Serial.begin(115200); + Serial.println(); + Serial.println(F("[INFO] PS2014 Controller V1 starting")); + + if (!configManager.begin()) { + Serial.println(F("[ERROR] Config filesystem unavailable")); + } + const AppConfig& config = configManager.get(); + if (config.limits.enabled) { + pinMode(config.limits.leftPin, config.limits.activeHigh ? INPUT : INPUT_PULLUP); + pinMode(config.limits.rightPin, config.limits.activeHigh ? INPUT : INPUT_PULLUP); + } + motor.begin(config.motor); + network.begin(config.wifi, config.device.hostname); + ota.begin(config.device.hostname, motor); + web.begin(); + + Serial.print(F("[INFO] Mode: ")); + Serial.println(network.modeName()); + Serial.print(F("[INFO] IP: ")); + Serial.println(network.ipAddress()); +} + +void loop() { + const AppConfig& config = configManager.get(); + if (config.limits.enabled) { + motor.setLimits(true, readLimit(config.limits.leftPin, config.limits.activeHigh), + readLimit(config.limits.rightPin, config.limits.activeHigh)); + } + motor.update(); + network.update(); + web.update(); + ota.update(); + yield(); +}