Initial commit: add stepper controller firmware

This commit is contained in:
toptah
2026-09-14 19:44:52 +02:00
commit f2d60303ee
19 changed files with 794 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.pio/
.vscode/
*.bin
secrets.json
config.json
+15
View File
@@ -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.
+10
View File
@@ -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
+23
View File
@@ -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.
+42
View File
@@ -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 = <IP>` 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.
+26
View File
@@ -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.
+6
View File
@@ -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 }
}
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <Arduino.h>
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_;
};
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <Arduino.h>
#include <AccelStepper.h>
#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;
};
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <Arduino.h>
#include <ESP8266WiFi.h>
#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;
};
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <Arduino.h>
#include <ESP8266WebServer.h>
#include <ArduinoOTA.h>
#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;
};
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <Arduino.h>
#include <ESP8266WebServer.h>
#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_;
};
+11
View File
@@ -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
+86
View File
@@ -0,0 +1,86 @@
#include "config/ConfigManager.h"
#include <ArduinoJson.h>
#include <LittleFS.h>
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_; }
+45
View File
@@ -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(); }
+30
View File
@@ -0,0 +1,30 @@
#include "ota/OtaManager.h"
#include <Updater.h>
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;
}
}
}
+121
View File
@@ -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<float>(stepsPerRevolution_));
}
+150
View File
@@ -0,0 +1,150 @@
#include "web/WebServerManager.h"
#include <ArduinoJson.h>
#include <Updater.h>
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("<!doctype html><html lang='de'><meta name='viewport' content='width=device-width,initial-scale=1'><title>Konfiguration</title><style>body{font-family:system-ui,sans-serif;max-width:640px;margin:auto;padding:16px;background:#eef1f2}section{background:#fff;padding:16px;margin:12px 0;border-radius:8px}label{display:block;margin:10px 0}input{width:100%;box-sizing:border-box;padding:9px}button{padding:11px;margin-top:12px;background:#176b73;color:#fff;border:0;border-radius:6px}</style><h1>Konfiguration</h1><form id='form'><section><h2>Geraet</h2><label>Name<input name='name' value='");
page += config.device.name;
page += F("'></label><label>Hostname<input name='hostname' value='");
page += config.device.hostname;
page += F("'></label></section><section><h2>Motor</h2><label>Schritte/Umdrehung<input name='steps' type='number' value='");
page += String(config.motor.stepsPerRevolution);
page += F("'></label><label>Max. Geschwindigkeit<input name='speed' type='number' step='1' value='");
page += String(config.motor.maxSpeed);
page += F("'></label><label>Beschleunigung<input name='acceleration' type='number' step='1' value='");
page += String(config.motor.acceleration);
page += F("'></label></section><section><h2>WLAN</h2><label>SSID<input name='ssid' value='");
page += config.wifi.ssid;
page += F("'></label><label>Passwort<input name='password' type='password' placeholder='unveraendert lassen'></label><label>AP-Name<input name='apName' value='");
page += config.wifi.apName;
page += F("'></label><label>AP-Passwort<input name='apPassword' type='password' placeholder='unveraendert lassen'></label></section><button>Speichern und neu starten</button></form><p><a href='/'>Zur Steuerung</a></p><script>form.onsubmit=async e=>{e.preventDefault();let f=new FormData(form);let r=await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({device:{name:f.get('name'),hostname:f.get('hostname')},motor:{stepsPerRevolution:+f.get('steps'),maxSpeed:+f.get('speed'),acceleration:+f.get('acceleration')},wifi:{ssid:f.get('ssid'),password:f.get('password'),apName:f.get('apName'),apPassword:f.get('apPassword')}})});alert(await r.text())}</script></html>");
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<float>(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(<!doctype html><html lang="de"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PS2014 Controller</title><style>body{font-family:system-ui,sans-serif;max-width:760px;margin:auto;padding:16px;background:#eef1f2;color:#172326}section{background:white;padding:16px;margin:12px 0;border-radius:8px}button{padding:12px;margin:4px;border:0;border-radius:6px;background:#176b73;color:#fff;font-weight:600}button.stop{background:#a33b3b}.grid{display:grid;grid-template-columns:repeat(4,1fr)}dt{font-weight:bold;display:inline}dd{display:inline;margin:0 16px 0 4px}</style><h1>PS2014 Controller</h1><section><dl><dt>Status</dt><dd id="state">...</dd><dt>Position</dt><dd id="position">...</dd><dt>Netz</dt><dd id="network">...</dd></dl></section><section><button onclick="cmd('left')">Dauerlauf links</button><button onclick="cmd('right')">Dauerlauf rechts</button><button class="stop" onclick="cmd('stop')">Stop</button><div class="grid"><button onclick="cmd('left-1')">-1 U</button><button onclick="cmd('left-5')">-5 U</button><button onclick="cmd('left-10')">-10 U</button><button onclick="cmd('left-20')">-20 U</button><button onclick="cmd('right-1')">+1 U</button><button onclick="cmd('right-5')">+5 U</button><button onclick="cmd('right-10')">+10 U</button><button onclick="cmd('right-20')">+20 U</button></div></section><section><button onclick="location.href='/config'">Konfiguration</button><button onclick="cmd('emergency')">Not-Aus</button></section><script>async function cmd(command){await fetch('/api/command',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'command='+encodeURIComponent(command)});status()}async function status(){let s=await fetch('/api/status').then(r=>r.json());state.textContent=s.state;position.textContent=s.positionSteps+' Schritte ('+s.positionRotations.toFixed(2)+' U)';network.textContent=s.mode+' '+s.ip}status();setInterval(status,500)</script></html>)HTML";
}
bool WebServerManager::parseFloatArg(const String& name, float& value) const {
if (!server_.hasArg(name)) return false;
value = server_.arg(name).toFloat();
return true;
}
+54
View File
@@ -0,0 +1,54 @@
#include <Arduino.h>
#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();
}