Initial commit: add stepper controller firmware
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.pio/
|
||||
.vscode/
|
||||
*.bin
|
||||
secrets.json
|
||||
config.json
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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_;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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_;
|
||||
};
|
||||
@@ -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
|
||||
@@ -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_; }
|
||||
@@ -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(); }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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_));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user