commit f2d60303ee56b720ffeb0e9c330994d91bfd1764 Author: toptah Date: Mon Sep 14 19:44:52 2026 +0200 Initial commit: add stepper controller firmware 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(); +}