From 0ea00808861a0932e650ee6b63d3bb56f5c63fd0 Mon Sep 17 00:00:00 2001 From: Stani Date: Sun, 25 Oct 2026 20:26:33 +0100 Subject: [PATCH] =?UTF-8?q?6=20akser,=20StallGuard=20og=20basic=20web=20in?= =?UTF-8?q?terface=20med=20ledningsdiagrammer=20tilf=C3=B8jet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + CLAUDE.md | 24 ++ platformio.ini | 14 + src/Axis.cpp | 307 ++++++++++++++ src/Axis.h | 111 +++++ src/Commands.cpp | 155 +++++++ src/Commands.h | 26 ++ src/Machine.cpp | 246 ++++++++++++ src/Machine.h | 59 +++ src/config.h | 89 ++++ src/main.cpp | 63 +++ web/requirements.txt | 3 + web/server.py | 193 +++++++++ web/static/index.html | 913 ++++++++++++++++++++++++++++++++++++++++++ 14 files changed, 2207 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 platformio.ini create mode 100644 src/Axis.cpp create mode 100644 src/Axis.h create mode 100644 src/Commands.cpp create mode 100644 src/Commands.h create mode 100644 src/Machine.cpp create mode 100644 src/Machine.h create mode 100644 src/config.h create mode 100644 src/main.cpp create mode 100644 web/requirements.txt create mode 100644 web/server.py create mode 100644 web/static/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9aac70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.pio/ +.vscode/ +web/.venv/ +__pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..023e08b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,24 @@ +# Stepper-test (ESP32 + TMC2209) + +Første del af et DIY 5–6-akset robotarm-projekt (senere ROS2 + MoveIt2). +Målet nu: få steppere til at køre pålideligt fra en ESP32 via TMC2209 (UART). + +## Hardware +- Board: ESP32 DevKit (esp32dev), Arduino-framework via PlatformIO +- Driver: TMC2209, UART på Serial2 (RX=GPIO16, TX=GPIO17 via 1 kΩ), adresse 0 +- Pins: STEP=25, DIR=26, EN=27 (aktiv lav) +- R_SENSE = 0.11 Ω, 16 mikroskridt, motorstrøm starter på 600 mA +- Ingen encodere endnu (open-loop) – design så de kan tilføjes senere + +## Kommandoer +- Byg: `pio run` +- Upload: `pio run -t upload` +- Seriel monitor: `pio device monitor` (115200 baud) + +## Regler +- Brug FastAccelStepper til step-generering og TMCStepper til driver-konfiguration. +- IMPORTANT: Hæv aldrig motorstrøm, hastighed eller acceleration uden at spørge mig først. +- Koden skal kunne bygges med `pio run` før du siger, at du er færdig. +- Jeg uploader og tester selv på hardwaren – fortæl mig præcis, hvad jeg skal teste og forvente. +- Forklar kort ændringer i embedded-kode, så jeg forstår dem. +- Kommentarer og svar på dansk. \ No newline at end of file diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..fe93a31 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,14 @@ +; Stepper-test: ESP32 + TMC2209 (UART) + +[env:esp32dev] +platform = espressif32@7.1.3 +board = esp32dev +framework = arduino + +monitor_speed = 115200 +upload_port = /dev/ttyUSB0 +monitor_port = /dev/ttyUSB0 + +lib_deps = + gin66/FastAccelStepper@2.0.0 + teemuatlut/TMCStepper@0.7.3 diff --git a/src/Axis.cpp b/src/Axis.cpp new file mode 100644 index 0000000..406fa76 --- /dev/null +++ b/src/Axis.cpp @@ -0,0 +1,307 @@ +#include "Axis.h" + +Axis::Axis(uint8_t index, const AxisConfig& cfg, FastAccelStepperEngine& engine, HardwareSerial& bus) + : _index(index), + _cfg(cfg), + _engine(engine), + _bus(bus), + _driver(&bus, R_SENSE, cfg.address), + _speedHz(SPEED_HZ_DEFAULT), _accel(ACCEL_DEFAULT), _currentMa(CURRENT_MA_DEFAULT), + _sgManual(SG_THRESHOLD_DEFAULT) {} + +bool Axis::begin() { + _driver.begin(); + initDriver(); + applySgThreshold(); // beregn auto-tærsklen med det samme, også hvis driveren ikke er fundet endnu + + // ---- Step-generering ---- + _stepper = _engine.stepperConnectToPin(_cfg.stepPin); + if (!_stepper) return false; + _stepper->setDirectionPin(_cfg.dirPin); + _stepper->setAutoEnable(false); // EN er fælles og styres af Machine + _stepper->setSpeedInHz(_speedHz); + _stepper->setAcceleration(_accel); + return true; +} + +// Skriver hele konfigurationen til TMC2209. Kaldes ved opstart og igen hver gang +// driveren (gen)opdages – fx når VM tændes efter ESP32, for så har driveren mistet alt. +// En manglende driver koster ~14 ms (TMCStepper venter på svar), så vi læser kun ét register. +bool Axis::initDriver() { + _tmcVersion = _driver.version(); + _uartOk = (_tmcVersion == 0x21); + if (!_uartOk) return false; + + _driver.pdn_disable(true); // PDN_UART-benet bruges til UART, ikke standby + _driver.I_scale_analog(false); // strøm sættes via register, ikke VREF-potmeter + _driver.mstep_reg_select(true); // mikroskridt fra register, ikke MS1/MS2 (de er adresse) + _driver.toff(0); // udgang slukket indtil 'en' (driveren er disabled efter init) + _driver.blank_time(24); + _driver.rms_current(_currentMa); + _driver.microsteps(MICROSTEPS); + _driver.en_spreadCycle(!_stealth); + _driver.pwm_autoscale(true); + _driver.TPWMTHRS(0); // bliv i StealthChop ved alle hastigheder (StallGuard4 kræver det) + _driver.semin(0); // CoolStep slået fra indtil videre + _driver.GSTAT(0b111); // nulstil "reset"-flaget, så et senere strømudfald kan ses + updateSgSpeedWindow(); + applySgThreshold(); + return true; +} + +// TCOOLTHRS bestemmer den laveste hastighed, hvor driveren melder stall på DIAG. +// TSTEP = tid mellem 1/256-mikroskridt i clock-perioder = FCLK / (steps/s * 256/MICROSTEPS). +// StallGuard er aktiv når TSTEP <= TCOOLTHRS, dvs. over SG_MIN_SPEED_FRACTION af sat hastighed. +void Axis::updateSgSpeedWindow() { + if (!_uartOk) return; + float vMin = _speedHz * SG_MIN_SPEED_FRACTION; + uint32_t t = (uint32_t)(TMC_FCLK / (vMin * (256.0f / MICROSTEPS))); + _driver.TCOOLTHRS(min(t, 0xFFFFF)); +} + +// Beregner den effektive StallGuard-tærskel og skriver den til driveren. +// Auto: lineær kurve gennem aksens to kalibreringspunkter, plus brugerens finjustering. +// Manuel: den faste værdi fra 'sgt'. +// Driveren får kun tærsklen når stall-stop er slået til – ellers 0, så en akse uden +// stall-stop aldrig trækker den fælles DIAG-linje og stopper de andre akser. +void Axis::applySgThreshold() { + int32_t thr = _sgManual; + if (_sgAuto) { + float slope = float(_cfg.sgSgtHi - _cfg.sgSgtLo) / float(_cfg.sgSpeedHi - _cfg.sgSpeedLo); + thr = lroundf(_cfg.sgSgtLo + slope * (float(_speedHz) - float(_cfg.sgSpeedLo))) + _sgOffset; + } + _sgThreshold = constrain(thr, 0, 255); + if (_uartOk) _driver.SGTHRS(_stallStop ? _sgThreshold : 0); +} + +Axis::DriverEvent Axis::checkDriver() { + if (!_uartOk) { + // Prøv igen – driveren svarer først når VM er tændt. + return initDriver() ? DriverEvent::Found : DriverEvent::None; + } + // Mistet forbindelse, eller har driveren været nede (reset-flag i GSTAT)? + bool lost = _driver.version() != 0x21; + bool wasReset = !lost && (_driver.GSTAT() & 0x01); + if (!lost && !wasReset) return DriverEvent::None; + + // Konfigurationen i driveren er væk/ukendt -> stop sikkert og kræv nyt 'en'. + disable(); + _uartOk = false; + if (wasReset) initDriver(); + return DriverEvent::Lost; +} + +// CRC8 fra TMC2209-databladet (polynomium x^8 + x^2 + x + 1). +static uint8_t tmcCrc(const uint8_t* data, size_t len) { + uint8_t crc = 0; + for (size_t i = 0; i < len; i++) { + uint8_t b = data[i]; + for (int j = 0; j < 8; j++) { + crc = ((crc >> 7) ^ (b & 0x01)) ? (crc << 1) ^ 0x07 : (crc << 1); + b >>= 1; + } + } + return crc; +} + +String Axis::uartDiag() { + // Sender en læse-forespørgsel på IOIN (0x06) til hver adresse og tæller modtagne bytes. + // TX og RX deler bussen, så de første 4 bytes er altid vores eget ekko; svaret er 8 bytes mere. + String out = String("ok uartdiag bus ") + char('A' + _cfg.bus); + int echoes = 0, replies = 0; + for (uint8_t addr = 0; addr < 4; addr++) { + uint8_t req[4] = {0x05, addr, 0x06, 0}; + req[3] = tmcCrc(req, 3); + while (_bus.available()) _bus.read(); + _bus.write(req, 4); + _bus.flush(); + delay(15); + uint8_t buf[16]; + int n = 0; + while (_bus.available() && n < (int)sizeof(buf)) buf[n++] = _bus.read(); + + bool echo = n >= 4 && memcmp(buf, req, 4) == 0; + bool reply = n >= 12 && tmcCrc(buf + 4, 7) == buf[11]; + echoes += echo; + replies += reply; + out += " | adr" + String(addr) + ": " + String(n) + " bytes"; + if (reply) out += " SVAR (ver 0x" + String(buf[7], HEX) + ")"; + else if (echo) out += " kun ekko"; + else if (n == 0) out += " intet"; + } + if (replies) out += " => " + String(replies) + " driver(e) svarer"; + else if (echoes) out += " => ekko ok, men ingen TMC svarer: tjek VIO, GND, PDN_UART-benet og at VM er tændt"; + else out += " => intet ekko: TX/RX når ikke frem til hinanden (tjek pins, 1 kΩ og samlingspunktet)"; + return out; +} + +const char* Axis::enable() { + if (!_uartOk) return "ingen driver fundet på denne akse"; + _driver.toff(4); // driver-udgang til (0 = slukket) + _enabled = true; + return nullptr; +} + +void Axis::disable() { + if (_stepper && _stepper->isRunning()) _stepper->forceStop(); + if (_uartOk) _driver.toff(0); + _enabled = false; + if (_homing) endHome(); +} + +// Fælles for alle bevægelser: nulstil stall-tilstand fra sidste gang. +void Axis::startMove() { + _stalled = false; +} + +const char* Axis::move(int32_t steps) { + if (!_enabled) return "akse ikke enabled"; + startMove(); + return _stepper->move(steps) == MOVE_OK ? nullptr : "move afvist"; +} + +const char* Axis::moveTo(int32_t target) { + if (!_enabled) return "akse ikke enabled"; + startMove(); + return _stepper->moveTo(target) == MOVE_OK ? nullptr : "goto afvist"; +} + +void Axis::stop() { + if (_stepper) _stepper->stopMove(); +} + +// forceStopAndNewPosition() afbryder step-køen med det samme (forceStop() ville køre de +// allerede planlagte ~20 ms færdig) og sætter positionen i samme ombæring. +void Axis::halt(bool zeroPosition) { + if (!_stepper) return; + _stepper->forceStopAndNewPosition(zeroPosition ? 0 : _stepper->getCurrentPosition()); +} + +bool Axis::isRunning() const { return _stepper && _stepper->isRunning(); } +int32_t Axis::getPosition() const { return _stepper ? _stepper->getCurrentPosition() : 0; } +int32_t Axis::getTarget() const { return _stepper ? _stepper->targetPos() : 0; } + +const char* Axis::setZero() { + if (isRunning()) return "kan ikke nulstille under bevægelse"; + _stepper->setCurrentPosition(0); + return nullptr; +} + +uint32_t Axis::setSpeed(uint32_t hz) { + _speedHz = constrain(hz, 1u, SPEED_HZ_MAX); + _stepper->setSpeedInHz(_speedHz); + _stepper->applySpeedAcceleration(); // gælder også en igangværende bevægelse + updateSgSpeedWindow(); + applySgThreshold(); // auto-tærsklen følger den nye hastighed + return _speedHz; +} + +uint32_t Axis::setAccel(uint32_t a) { + _accel = constrain(a, 1u, ACCEL_MAX); + _stepper->setAcceleration(_accel); + _stepper->applySpeedAcceleration(); + return _accel; +} + +uint16_t Axis::setCurrent(uint16_t mA) { + _currentMa = constrain(mA, (uint16_t)50, CURRENT_MA_MAX); + if (_uartOk) _driver.rms_current(_currentMa); + return _currentMa; +} + +void Axis::setStealthChop(bool on) { + _stealth = on; + if (!on) setStallStop(false); // StallGuard4 virker ikke i SpreadCycle + if (_uartOk) _driver.en_spreadCycle(!on); +} + +// ---------- StallGuard ---------- + +void Axis::setSgThreshold(uint8_t thr) { + _sgManual = thr; + _sgAuto = false; + applySgThreshold(); +} + +void Axis::setSgAuto(bool on) { + _sgAuto = on; + applySgThreshold(); +} + +int16_t Axis::setSgOffset(int16_t off) { + _sgOffset = constrain(off, (int16_t)-SG_OFFSET_MAX, SG_OFFSET_MAX); + applySgThreshold(); + return _sgOffset; +} + +const char* Axis::setStallStop(bool on) { + if (on && !_stealth) return "StallGuard kræver StealthChop (mode stealth)"; + _stallStop = on; + applySgThreshold(); + return nullptr; +} + +uint16_t Axis::sgResult() { + return _uartOk ? _driver.SG_RESULT() : 0; +} + +bool Axis::isStallCandidate() const { + if (!_stallStop || !isRunning()) return false; + // Under accel/decel er StallGuard upålidelig – samme grænse som TCOOLTHRS i driveren. + int32_t vNow = abs(_stepper->getCurrentSpeedInMilliHz()) / 1000; + return vNow >= _speedHz * SG_MIN_SPEED_FRACTION; +} + +const char* Axis::startHome(int dir) { + if (!_enabled) return "akse ikke enabled"; + if (!_stealth) return "StallGuard kræver StealthChop (mode stealth)"; + if (_sgThreshold == 0) return "StallGuard-tærsklen er 0 (sgauto on eller sgt )"; + if (isRunning()) return "aksen kører allerede"; + _stallStopBeforeHome = _stallStop; + setStallStop(true); + startMove(); + if (_stepper->move((int32_t)dir * HOME_MAX_REVS * STEPS_PER_REV) != MOVE_OK) { + setStallStop(_stallStopBeforeHome); + return "home afvist"; + } + _homing = true; + return nullptr; +} + +void Axis::endHome() { + _homing = false; + setStallStop(_stallStopBeforeHome); +} + +String Axis::statusJson() { + // DRV_STATUS læses i ét UART-kald (~3 ms); step-generering kører i hardware og påvirkes ikke. + bool ot = false, otpw = false, s2g = false, ol = false; + uint16_t csActual = 0, sg = 0; + if (_uartOk) { + uint32_t ds = _driver.DRV_STATUS(); + otpw = ds & (1u << 0); + ot = ds & (1u << 1); + s2g = ds & (0b11u << 2); + ol = ds & (0b11u << 6); + csActual = (ds >> 16) & 0x1F; + sg = _driver.SG_RESULT(); + } + + char buf[512]; + snprintf(buf, sizeof(buf), + "{\"i\":%u,\"bus\":%u,\"adr\":%u,\"pos\":%ld,\"target\":%ld,\"running\":%s,\"enabled\":%s," + "\"speed\":%lu,\"accel\":%lu,\"ma\":%u,\"mode\":\"%s\",\"spr\":%ld," + "\"sg\":%u,\"sgt\":%u,\"sgAuto\":%s,\"sgOff\":%d,\"stallStop\":%s,\"stalled\":%s,\"homing\":%s," + "\"tmc\":{\"uart\":%s,\"ver\":%u,\"ot\":%s,\"otpw\":%s,\"s2g\":%s,\"ol\":%s,\"cs\":%u}}", + _index, _cfg.bus, _cfg.address, + (long)getPosition(), (long)getTarget(), + isRunning() ? "true" : "false", _enabled ? "true" : "false", + (unsigned long)_speedHz, (unsigned long)_accel, _currentMa, + _stealth ? "stealth" : "spread", (long)STEPS_PER_REV, + sg, _sgThreshold, _sgAuto ? "true" : "false", _sgOffset, + _stallStop ? "true" : "false", _stalled ? "true" : "false", _homing ? "true" : "false", + _uartOk ? "true" : "false", _tmcVersion, + ot ? "true" : "false", otpw ? "true" : "false", + s2g ? "true" : "false", ol ? "true" : "false", csActual); + return String(buf); +} diff --git a/src/Axis.h b/src/Axis.h new file mode 100644 index 0000000..0b63dff --- /dev/null +++ b/src/Axis.h @@ -0,0 +1,111 @@ +#pragma once +#include +#include +#include +#include "config.h" + +// Én akse = én stepper + én TMC2209 på en delt UART-bus. +// EN-pinnen er fælles for alle drivere (styres af Machine); den enkelte akse slås +// til/fra med driverens toff-register over UART. +// Positionen kommer i dag fra step-tælleren (open-loop). Når der kommer encodere, +// kan getPosition() læse dem i stedet uden at resten af koden ændres. +class Axis { +public: + Axis(uint8_t index, const AxisConfig& cfg, FastAccelStepperEngine& engine, HardwareSerial& bus); + + // Returnerer false hvis step-generatoren ikke kunne oprettes. + bool begin(); + + uint8_t index() const { return _index; } + const AxisConfig& config() const { return _cfg; } + + enum class DriverEvent { None, Found, Lost }; + // Kaldes periodisk: genfinder driveren (fx når VM tændes efter ESP32) og + // opdager hvis den forsvinder eller har været nulstillet (strømudfald på VM). + DriverEvent checkDriver(); + + // Fejlfinding af UART-bussen: ekko-test + svar fra adresse 0–3. + String uartDiag(); + + bool uartOk() const { return _uartOk; } + uint8_t tmcVersion() const { return _tmcVersion; } + + // Bevægelse – returnerer nullptr ved succes, ellers en fejltekst. + const char* move(int32_t steps); + const char* moveTo(int32_t target); + void stop(); // blød stop med deceleration + void halt(bool zeroPosition); // øjeblikkeligt stop uden flere steps (stall/kollision) + + const char* enable(); // driver-udgang til (toff) – Machine sætter fælles EN + void disable(); + bool isEnabled() const { return _enabled; } + + bool isRunning() const; + int32_t getPosition() const; + int32_t getTarget() const; + const char* setZero(); + + // Værdierne clampes til maks fra config.h; returnerer den faktisk satte værdi. + uint32_t setSpeed(uint32_t hz); + uint32_t setAccel(uint32_t stepsPerS2); + uint16_t setCurrent(uint16_t mA); + void setStealthChop(bool on); + + uint32_t speed() const { return _speedHz; } + uint32_t accel() const { return _accel; } + uint16_t current() const { return _currentMa; } + bool stealthChop() const { return _stealth; } + + // ---- StallGuard ---- + void setSgThreshold(uint8_t thr); // fast manuel tærskel – slår auto fra + void setSgAuto(bool on); // tærskel følger hastigheden (kalibreringskurve) + int16_t setSgOffset(int16_t off); // finjustering ± oven på kurven (kun i auto) + uint8_t sgThreshold() const { return _sgThreshold; } // den effektive værdi + bool sgAuto() const { return _sgAuto; } + int16_t sgOffset() const { return _sgOffset; } + const char* setStallStop(bool on); // kræver StealthChop + bool stallStop() const { return _stallStop; } + uint16_t sgResult(); // læser SG_RESULT over UART (høj = lav belastning) + + // Kan denne akse lige nu have udløst DIAG? (stall-stop til, kører over min.-hastighed) + bool isStallCandidate() const; + void markStalled() { _stalled = true; } + bool stalled() const { return _stalled; } + + const char* startHome(int dir); // kør mod mekanisk stop til stall + bool isHoming() const { return _homing; } + void endHome(); // afslut homing (gendanner stall-stop-indstilling) + + // Én linje JSON med aksens status (inkl. TMC-fejlflag og StallGuard). + String statusJson(); + +private: + bool initDriver(); + void updateSgSpeedWindow(); + void applySgThreshold(); + void startMove(); + + uint8_t _index; + AxisConfig _cfg; + FastAccelStepperEngine& _engine; + HardwareSerial& _bus; + TMC2209Stepper _driver; + FastAccelStepper* _stepper = nullptr; + + bool _uartOk = false; + uint8_t _tmcVersion = 0; + bool _enabled = false; + bool _stealth = true; + uint32_t _speedHz; + uint32_t _accel; + uint16_t _currentMa; + + uint8_t _sgManual; + uint8_t _sgThreshold = 0; // effektiv værdi (auto-kurve eller manuel) + bool _sgAuto = true; + int16_t _sgOffset = 0; + bool _stallStop = false; + bool _stalled = false; // låst indtil næste bevægelse + bool _homing = false; + bool _stallStopBeforeHome = false; +}; diff --git a/src/Commands.cpp b/src/Commands.cpp new file mode 100644 index 0000000..97f53a2 --- /dev/null +++ b/src/Commands.cpp @@ -0,0 +1,155 @@ +#include "Commands.h" +#include "config.h" + +// Parser et heltal; returnerer false hvis teksten ikke er et gyldigt tal. +static bool parseLong(const String& s, long& out) { + if (s.length() == 0) return false; + char* end = nullptr; + out = strtol(s.c_str(), &end, 10); + return *end == '\0'; +} + +static bool parseFloat(const String& s, float& out) { + if (s.length() == 0) return false; + char* end = nullptr; + out = strtof(s.c_str(), &end); + return *end == '\0'; +} + +static bool isDigits(const String& s) { + if (s.length() == 0) return false; + for (size_t i = 0; i < s.length(); i++) { + if (!isdigit((unsigned char)s[i])) return false; + } + return true; +} + +String Commands::handle(const String& raw) { + String line = raw; + line.trim(); + if (line.length() == 0) return ""; + + // ---- Valgfrit akse-index først ---- + int axisIdx = -1; + int sp = line.indexOf(' '); + String first = sp < 0 ? line : line.substring(0, sp); + if (isDigits(first)) { + axisIdx = first.toInt(); + if (axisIdx >= NUM_AXES) return "err ukendt akse " + first + " (0-" + String(NUM_AXES - 1) + ")"; + line = sp < 0 ? String() : line.substring(sp + 1); + line.trim(); + if (line.length() == 0) return "err mangler kommando efter akse-index"; + sp = line.indexOf(' '); + } + + String cmd = sp < 0 ? line : line.substring(0, sp); + String arg = sp < 0 ? String() : line.substring(sp + 1); + cmd.toLowerCase(); + arg.trim(); + + // ---- Fælles kommandoer ---- + if (cmd == "hb") { + _hbArmed = true; + return ""; + } + if (cmd == "help") { + return "ok kommandoer: [akse] . Fælles: status estop, og uden akse: en dis stop (alle). " + "Pr. akse: en dis stop move goto rev speed accel " + "zero current mode stealth|spread uartdiag " + "sgt <0-255> sgauto on|off sgoff <+-n> sg on|off sgr home +|-"; + } + if (cmd == "status") { + String out = "G " + _m.globalJson(); + for (uint8_t i = 0; i < NUM_AXES; i++) out += "\nS " + String(i) + " " + _m.axis(i).statusJson(); + return out; + } + if (cmd == "estop") { _m.estop(); return "ok estop (alle drivere disabled)"; } + + if (axisIdx < 0) { + if (cmd == "en") { + const char* err = _m.enableAll(); + return err ? String("err ") + err : String("ok alle fundne akser enabled"); + } + if (cmd == "dis") { _m.disableAll(); return "ok alle akser disabled"; } + if (cmd == "stop") { _m.stopAll(); return "ok stop alle"; } + } + + // ---- Akse-kommandoer (uden index: akse 0) ---- + uint8_t ai = axisIdx < 0 ? 0 : axisIdx; + Axis& a = _m.axis(ai); + _m.requestStatus(ai); // ny S-linje efter kommandoen, så UI'et altid er opdateret + String pre = "a" + String(ai) + " "; + auto okOrErr = [&](const char* err, const String& okText) { + return err ? "err " + pre + err : "ok " + pre + okText; + }; + + if (cmd == "en") return okOrErr(_m.enableAxis(ai), "enabled"); + if (cmd == "dis") { _m.disableAxis(ai); return "ok " + pre + "disabled"; } + if (cmd == "stop") { a.stop(); return "ok " + pre + "stop"; } + if (cmd == "zero") return okOrErr(a.setZero(), "pos=0"); + if (cmd == "uartdiag") return a.uartDiag(); + + long n; + float f; + if (cmd == "move") { + if (!parseLong(arg, n)) return "err brug: [akse] move "; + return okOrErr(a.move(n), "move " + String(n)); + } + if (cmd == "goto") { + if (!parseLong(arg, n)) return "err brug: [akse] goto "; + return okOrErr(a.moveTo(n), "goto " + String(n)); + } + if (cmd == "rev") { + if (!parseFloat(arg, f)) return "err brug: [akse] rev "; + long steps = lroundf(f * STEPS_PER_REV); + return okOrErr(a.move(steps), "move " + String(steps)); + } + if (cmd == "speed") { + if (!parseLong(arg, n) || n <= 0) return "err brug: [akse] speed "; + a.setSpeed(n); + return "ok " + pre + "speed=" + String(a.speed()) + " sgt=" + String(a.sgThreshold()); + } + if (cmd == "accel") { + if (!parseLong(arg, n) || n <= 0) return "err brug: [akse] accel "; + return "ok " + pre + "accel=" + String(a.setAccel(n)); + } + if (cmd == "current") { + if (!parseLong(arg, n) || n <= 0) return "err brug: [akse] current "; + return "ok " + pre + "current=" + String(a.setCurrent(n > 65535 ? 65535 : n)); + } + if (cmd == "sgt") { + if (!parseLong(arg, n) || n < 0 || n > 255) return "err brug: [akse] sgt <0-255>"; + a.setSgThreshold(n); + return "ok " + pre + "sgt=" + String(n) + " manuel (stall når sg <= " + String(2 * n) + ")"; + } + if (cmd == "sgauto") { + arg.toLowerCase(); + if (arg != "on" && arg != "off") return "err brug: [akse] sgauto on|off"; + a.setSgAuto(arg == "on"); + return "ok " + pre + "sgauto=" + arg + " sgt=" + String(a.sgThreshold()); + } + if (cmd == "sgoff") { + if (!parseLong(arg, n)) return "err brug: [akse] sgoff <-100..100>"; + int16_t off = a.setSgOffset(constrain(n, -1000L, 1000L)); + return "ok " + pre + "sgoff=" + String(off) + " sgt=" + String(a.sgThreshold()); + } + if (cmd == "sg") { + arg.toLowerCase(); + if (arg == "on") return okOrErr(a.setStallStop(true), "stall-stop til"); + if (arg == "off") { a.setStallStop(false); return "ok " + pre + "stall-stop fra"; } + return "err brug: [akse] sg on|off"; + } + if (cmd == "sgr") return "ok " + pre + "sg=" + String(a.sgResult()); + if (cmd == "home") { + if (arg == "+") return okOrErr(_m.home(ai, 1), "homing +"); + if (arg == "-") return okOrErr(_m.home(ai, -1), "homing -"); + return "err brug: [akse] home +|-"; + } + if (cmd == "mode") { + arg.toLowerCase(); + if (arg == "stealth") { a.setStealthChop(true); return "ok " + pre + "mode=stealth"; } + if (arg == "spread") { a.setStealthChop(false); return "ok " + pre + "mode=spread"; } + return "err brug: [akse] mode stealth|spread"; + } + return "err ukendt kommando '" + cmd + "' (prøv help)"; +} diff --git a/src/Commands.h b/src/Commands.h new file mode 100644 index 0000000..22eaa6c --- /dev/null +++ b/src/Commands.h @@ -0,0 +1,26 @@ +#pragma once +#include +#include "Machine.h" + +// Tekstprotokol (én kommando pr. linje): +// kommando: "[akse] [argument]" – fx "1 move 3200". Uden akse-index: +// akse-kommandoer går til akse 0, mens en/dis/stop gælder alle akser. +// svar: "ok ..." / "err ..." – præcis én linje pr. kommando (undtagen status) +// status: "G {json}" – fælles tilstand (EN, DIAG, grænser) +// "S {json}" – én akse; løbende mens den kører +// "status" svarer med én G-linje + én S-linje pr. akse +// log: "# ..." – kun til mennesker, ignoreres af PC-programmet +// "hb" er undtagelsen: heartbeat fra PC'en, aktiverer watchdog, giver intet svar. +class Commands { +public: + explicit Commands(Machine& machine) : _m(machine) {} + + // Returnerer svarlinjen/-linjerne (tom streng = intet svar). + String handle(const String& line); + + bool heartbeatArmed() const { return _hbArmed; } + +private: + Machine& _m; + bool _hbArmed = false; +}; diff --git a/src/Machine.cpp b/src/Machine.cpp new file mode 100644 index 0000000..ecdf60b --- /dev/null +++ b/src/Machine.cpp @@ -0,0 +1,246 @@ +#include "Machine.h" + +// DIAG-interrupt (kun DiodeOr): sætter kun et flag – selve stoppet sker i poll(), fordi vi skal +// finde ud af, hvilken akse der stallede, og det kræver UART-læsninger (ikke tilladt i en ISR). +static volatile bool s_diagFlag = false; +static void IRAM_ATTR onDiagRising() { s_diagFlag = true; } + +void Machine::begin() { + // Alle drivere holdes frakoblet (EN høj) mens de konfigureres. + pinMode(PIN_EN, OUTPUT); + digitalWrite(PIN_EN, HIGH); + + // GPIO39 er kun indgang og har ingen intern pull. DiodeOr: ekstern 10 kΩ pull-down holder den + // lav. ResistorAdc: de lave DIAG-udgange holder selv linjen nede gennem modstandene. + pinMode(PIN_DIAG, INPUT); + if (DIAG_MODE == DiagMode::DiodeOr) { + attachInterrupt(digitalPinToInterrupt(PIN_DIAG), onDiagRising, RISING); + } else { + analogSetPinAttenuation(PIN_DIAG, ADC_11db); // måleområde ~0–3,1 V + } + + Serial2.begin(TMC_BAUD, SERIAL_8N1, PIN_BUS_A_RX, PIN_BUS_A_TX); + Serial1.begin(TMC_BAUD, SERIAL_8N1, PIN_BUS_B_RX, PIN_BUS_B_TX); + delay(50); + + _engine.init(); + for (uint8_t i = 0; i < NUM_AXES; i++) { + HardwareSerial& bus = AXES[i].bus == 0 ? Serial2 : Serial1; + _axes[i] = new Axis(i, AXES[i], _engine, bus); + _axes[i]->begin(); + } +} + +bool Machine::diagActive() { + if (DIAG_MODE == DiagMode::DiodeOr) return digitalRead(PIN_DIAG); + _diagMv = analogReadMilliVolts(PIN_DIAG); // kalibreret måling, ~20 µs + return _diagMv > (int32_t)DIAG_ADC_THRESHOLD_MV; +} + +void Machine::updateMasterEnable() { + bool any = false; + for (uint8_t i = 0; i < NUM_AXES; i++) any |= _axes[i]->isEnabled(); + _master = any; + digitalWrite(PIN_EN, any ? LOW : HIGH); +} + +const char* Machine::enableAxis(uint8_t i) { + const char* err = _axes[i]->enable(); + if (!err) updateMasterEnable(); + return err; +} + +void Machine::disableAxis(uint8_t i) { + _axes[i]->disable(); + updateMasterEnable(); + requestStatus(i); +} + +const char* Machine::enableAll() { + uint8_t n = 0; + for (uint8_t i = 0; i < NUM_AXES; i++) { + if (_axes[i]->uartOk() && !_axes[i]->enable()) n++; + } + updateMasterEnable(); + requestAllStatus(); + return n ? nullptr : "ingen drivere fundet"; +} + +void Machine::disableAll() { + for (uint8_t i = 0; i < NUM_AXES; i++) _axes[i]->disable(); + updateMasterEnable(); + requestAllStatus(); +} + +void Machine::stopAll() { + for (uint8_t i = 0; i < NUM_AXES; i++) _axes[i]->stop(); +} + +void Machine::estop() { + digitalWrite(PIN_EN, HIGH); // hardware-sluk først, så intet afhænger af UART + for (uint8_t i = 0; i < NUM_AXES; i++) _axes[i]->disable(); + _master = false; + requestAllStatus(); +} + +const char* Machine::home(uint8_t i, int dir) { + for (uint8_t j = 0; j < NUM_AXES; j++) { + if (_axes[j]->isHoming()) return "en anden akse er i gang med homing"; + } + return _axes[i]->startHome(dir); +} + +bool Machine::anyRunning() { + for (uint8_t i = 0; i < NUM_AXES; i++) { + if (_axes[i]->isRunning()) return true; + } + return false; +} + +void Machine::requestAllStatus() { + for (uint8_t i = 0; i < NUM_AXES; i++) _statusPending[i] = true; + _globalPending = true; +} + +String Machine::globalJson() { + int8_t homing = -1; + for (uint8_t i = 0; i < NUM_AXES; i++) { + if (_axes[i]->isHoming()) homing = i; + } + bool diag = diagActive(); + char buf[240]; + snprintf(buf, sizeof(buf), + "{\"axes\":%u,\"master\":%s,\"diag\":%s,\"diagMode\":\"%s\",\"diagMv\":%ld,\"homing\":%d," + "\"max\":{\"speed\":%lu,\"accel\":%lu,\"ma\":%u}}", + NUM_AXES, _master ? "true" : "false", diag ? "true" : "false", + DIAG_MODE == DiagMode::DiodeOr ? "diode" : "adc", (long)_diagMv, homing, + (unsigned long)SPEED_HZ_MAX, (unsigned long)ACCEL_MAX, CURRENT_MA_MAX); + return String(buf); +} + +void Machine::poll(Print& out) { + pollDiag(out); // først: stall skal stoppes så hurtigt som muligt + pollHoming(out); + pollDrivers(out); + pollStatus(out); +} + +// DIAG er fælles for alle akser (diode-OR). Ved et stall stoppes ALLE kørende akser – +// en kollision et sted i armen skal stoppe hele armen. Derefter findes synderen. +void Machine::pollDiag(Print& out) { + bool flag = s_diagFlag; // kun sat i DiodeOr (fanger korte pulser mellem to loops) + s_diagFlag = false; + if (!flag && !diagActive()) return; + + uint8_t cand[NUM_AXES]; + uint8_t n = 0; + int8_t homingCand = -1; + for (uint8_t i = 0; i < NUM_AXES; i++) { + if (_axes[i]->isStallCandidate()) { + cand[n++] = i; + if (_axes[i]->isHoming()) homingCand = i; + } + } + // Ingen akse kan have udløst et stall (fx under accel, eller stall-stop er fra) -> ignorér. + if (n == 0) return; + + // Stop alle kørende akser med det samme. En homing-akse, der stallede, får position 0. + for (uint8_t i = 0; i < NUM_AXES; i++) { + if (_axes[i]->isRunning()) _axes[i]->halt(i == homingCand); + } + + // Find synderen: homing-aksen hvis den var kandidat, ellers den eneste kandidat, + // ellers den med lavest SG i forhold til sin tærskel (aflæst efter stop). + int8_t culprit = homingCand; + if (culprit < 0) { + culprit = cand[0]; + if (n > 1) { + float best = 1e9f; + for (uint8_t k = 0; k < n; k++) { + Axis& a = *_axes[cand[k]]; + float ratio = float(a.sgResult()) / float(max(1, 2 * a.sgThreshold())); + if (ratio < best) { best = ratio; culprit = cand[k]; } + } + } + } + + Axis& c = *_axes[culprit]; + c.markStalled(); + if (c.isHoming()) { + c.endHome(); + out.printf("# HOME ok a%d - stall fundet, position = 0\n", culprit); + } else { + out.printf("# STALL a%d ved pos %ld (sg=%u) - alle akser stoppet\n", + culprit, (long)c.getPosition(), c.sgResult()); + } + requestAllStatus(); +} + +// En homing, der slutter uden stall (eller stoppes udefra), er fejlet. +void Machine::pollHoming(Print& out) { + for (uint8_t i = 0; i < NUM_AXES; i++) { + Axis& a = *_axes[i]; + if (a.isHoming() && !a.isRunning()) { + a.endHome(); + out.printf("# HOME fejl a%d - intet stall inden for grænsen (eller stoppet)\n", i); + requestStatus(i); + } + } +} + +// Én akse tjekkes ad gangen, spredt over DRIVER_CHECK_INTERVAL_MS. En manglende driver +// koster ~14 ms pr. forsøg, så det må ikke ske for alle akser i samme loop. +void Machine::pollDrivers(Print& out) { + uint32_t now = millis(); + if (now - _lastDriverCheckMs < DRIVER_CHECK_INTERVAL_MS / NUM_AXES) return; + _lastDriverCheckMs = now; + + uint8_t i = _nextDriverCheck; + _nextDriverCheck = (i + 1) % NUM_AXES; + Axis::DriverEvent ev = _axes[i]->checkDriver(); + if (ev == Axis::DriverEvent::None) return; + if (ev == Axis::DriverEvent::Found) { + out.printf("# a%d UART OK - driver fundet og konfigureret\n", i); + } else { + out.printf("# a%d driver mistet eller nulstillet (VM?) - aksen er disabled, send 'en' igen\n", i); + updateMasterEnable(); + } + requestStatus(i); + _globalPending = true; +} + +// Højst én status-linje pr. loop (hver koster et par UART-læsninger), og lavere rate pr. akse +// når flere kører, så 115200 baud til PC'en ikke bliver flaskehals. +void Machine::pollStatus(Print& out) { + uint32_t now = millis(); + uint8_t running = 0; + for (uint8_t i = 0; i < NUM_AXES; i++) { + bool r = _axes[i]->isRunning(); + if (_wasRunning[i] && !r) _statusPending[i] = true; // én sidste status når den stopper + _wasRunning[i] = r; + running += r; + } + + bool diag = diagActive(); + if (_globalPending || diag != _lastDiagLevel || _master != _lastMaster) { + _globalPending = false; + _lastDiagLevel = diag; + _lastMaster = _master; + out.print("G "); + out.println(globalJson()); + return; + } + + uint32_t interval = max(STATUS_INTERVAL_MS, running * STATUS_MS_PER_RUNNING_AXIS); + for (uint8_t k = 0; k < NUM_AXES; k++) { + uint8_t i = (_nextStatus + k) % NUM_AXES; + bool due = _statusPending[i] || (_wasRunning[i] && now - _lastStatusMs[i] >= interval); + if (!due) continue; + _statusPending[i] = false; + _lastStatusMs[i] = now; + _nextStatus = (i + 1) % NUM_AXES; + out.printf("S %u ", i); + out.println(_axes[i]->statusJson()); + return; + } +} diff --git a/src/Machine.h b/src/Machine.h new file mode 100644 index 0000000..3c52da9 --- /dev/null +++ b/src/Machine.h @@ -0,0 +1,59 @@ +#pragma once +#include +#include +#include "config.h" +#include "Axis.h" + +// Hele maskinen: alle akser + de fælles ressourcer (EN-pin, DIAG-linje, UART-busser) +// og den løbende overvågning (stall, homing, driver-tjek, live-status). +class Machine { +public: + explicit Machine(FastAccelStepperEngine& engine) : _engine(engine) {} + + void begin(); + + uint8_t count() const { return NUM_AXES; } + Axis& axis(uint8_t i) { return *_axes[i]; } + + // Fælles EN er aktiv, så længe mindst én akse er enabled. + const char* enableAxis(uint8_t i); + void disableAxis(uint8_t i); + const char* enableAll(); // alle akser med fundet driver + void disableAll(); + void stopAll(); // blød stop af alle akser + void estop(); // EN høj med det samme + alle akser disabled + const char* home(uint8_t i, int dir); // kun én akse ad gangen + + bool masterEnabled() const { return _master; } + bool anyRunning(); + + String globalJson(); + void requestStatus(uint8_t i) { _statusPending[i] = true; } + void requestAllStatus(); + + // Kaldes hver loop. Skriver log- og status-linjer til out. + void poll(Print& out); + +private: + void pollDiag(Print& out); + void pollHoming(Print& out); + void pollDrivers(Print& out); + void pollStatus(Print& out); + void updateMasterEnable(); + bool diagActive(); // er den fælles DIAG-linje aktiv (afhænger af DIAG_MODE)? + + FastAccelStepperEngine& _engine; + Axis* _axes[NUM_AXES] = {}; + bool _master = false; + + uint8_t _nextDriverCheck = 0; + uint32_t _lastDriverCheckMs = 0; + uint8_t _nextStatus = 0; + uint32_t _lastStatusMs[NUM_AXES] = {}; + bool _wasRunning[NUM_AXES] = {}; + bool _statusPending[NUM_AXES] = {}; + bool _globalPending = true; + bool _lastDiagLevel = false; + bool _lastMaster = false; + int32_t _diagMv = -1; // seneste ADC-måling af DIAG-linjen (kun ResistorAdc) +}; diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..c5e3e83 --- /dev/null +++ b/src/config.h @@ -0,0 +1,89 @@ +#pragma once +#include + +// ---------- Akser ---------- +// Alle drivere deler EN, DIAG (via diode-OR) og en af to UART-busser. +// Hver akse har sin egen STEP/DIR og en UART-adresse 0–3 (MS1 = bit 0, MS2 = bit 1). +constexpr uint8_t NUM_AXES = 6; + +struct AxisConfig { + uint8_t stepPin; + uint8_t dirPin; + uint8_t bus; // 0 = bus A (Serial2), 1 = bus B (Serial1) + uint8_t address; // TMC2209 UART-adresse 0–3 + // StallGuard-kalibrering: sgt ved to hastigheder (steps/s), lineært imellem. + uint32_t sgSpeedLo; + uint8_t sgSgtLo; + uint32_t sgSpeedHi; + uint8_t sgSgtHi; +}; + +// Pins valgt så GPIO21/22 er fri til I²C (encodere senere). Boot-støjende pins (5, 14, 15) +// bruges kun til DIR, aldrig STEP, så der ikke kommer falske steps ved opstart. +constexpr AxisConfig AXES[NUM_AXES] = { + // STEP DIR bus adr StallGuard: lav hast., sgt, høj hast., sgt + { 25, 26, 0, 0, 800, 35, 3200, 90 }, // målt 2026-10-23 (42-motor, 600 mA) + { 33, 14, 0, 1, 800, 35, 3200, 90 }, // ikke kalibreret – kopi af akse 0 + { 23, 18, 0, 2, 800, 35, 3200, 90 }, // ikke kalibreret + { 19, 13, 0, 3, 800, 35, 3200, 90 }, // ikke kalibreret + { 32, 5, 1, 0, 800, 35, 3200, 90 }, // ikke kalibreret + { 16, 15, 1, 1, 800, 35, 3200, 90 }, // ikke kalibreret +}; + +// ---------- Fælles pins ---------- +constexpr uint8_t PIN_EN = 27; // fælles enable til alle drivere, aktiv lav +constexpr uint8_t PIN_DIAG = 39; // fælles DIAG-linje fra alle drivere (kun indgang, ingen intern pull) + +// Hvordan DIAG-udgangene er samlet på GPIO39: +// - DiodeOr: Schottky-diode pr. driver (anode på DIAG, katode på linjen) + 10 kΩ pull-down +// til GND. Rent digitalt signal (~3 V ved stall) -> interrupt. Mest pålidelig. +// - ResistorAdc: ens modstand pr. driver (4,7–10 kΩ anbefales, 1 kΩ virker) fra DIAG til linjen, +// ingen pull-down. Linjen bliver 3,3 V × (antal høje)/(antal drivere), altså for lav +// til digital læsning – den måles i stedet med ADC'en. +enum class DiagMode { DiodeOr, ResistorAdc }; +constexpr DiagMode DIAG_MODE = DiagMode::ResistorAdc; // skift til DiodeOr når dioderne er monteret +constexpr uint32_t DIAG_ADC_THRESHOLD_MV = 250; // én høj DIAG ud af 6 drivere giver ~550 mV + +// UART-busser (TX via 1 kΩ til bussen, RX direkte på bussen) +constexpr uint8_t PIN_BUS_A_TX = 17; +constexpr uint8_t PIN_BUS_A_RX = 35; // kun-indgang-pin +constexpr uint8_t PIN_BUS_B_TX = 4; +constexpr uint8_t PIN_BUS_B_RX = 34; // kun-indgang-pin +constexpr uint32_t TMC_BAUD = 115200; + +// Reserveret til encodere (I²C) – bruges ikke endnu. +constexpr uint8_t PIN_I2C_SDA = 21; +constexpr uint8_t PIN_I2C_SCL = 22; + +// ---------- Driver ---------- +constexpr float R_SENSE = 0.11f; // Ohm (BTT TMC2209 V1.3) +constexpr uint16_t MICROSTEPS = 16; +constexpr uint16_t FULL_STEPS_PER_REV = 200; // 1,8° motor +constexpr int32_t STEPS_PER_REV = (int32_t)FULL_STEPS_PER_REV * MICROSTEPS; // 3200 + +// ---------- Grænser ---------- +// VIGTIGT: Maks-værdierne hæves kun efter aftale. Kommandoer kan kun sænke under dem. +constexpr uint16_t CURRENT_MA_DEFAULT = 600; +constexpr uint16_t CURRENT_MA_MAX = 600; + +constexpr uint32_t SPEED_HZ_DEFAULT = 800; // steps/s (= 0,25 omdr./s) +constexpr uint32_t SPEED_HZ_MAX = 3200; // = 1 omdr./s – hævet efter aftale til StallGuard-test + +constexpr uint32_t ACCEL_DEFAULT = 2000; // steps/s² +constexpr uint32_t ACCEL_MAX = 2000; + +// ---------- StallGuard ---------- +// Stall når SG_RESULT <= 2 * SGTHRS. Tærsklen følger som standard kalibreringskurven pr. akse (auto). +constexpr int16_t SG_OFFSET_MAX = 100; // finjustering ± oven på kurven +constexpr uint8_t SG_THRESHOLD_DEFAULT = 0; // manuel tærskel (når auto slås fra med 'sgt ') +// StallGuard er upålidelig ved lav fart (accel/decel) – kun aktiv over denne andel af sat hastighed. +constexpr float SG_MIN_SPEED_FRACTION = 0.5f; +constexpr uint8_t HOME_MAX_REVS = 2; // homing giver op efter så mange omdrejninger +constexpr uint32_t TMC_FCLK = 12000000; // intern clock i TMC2209 + +// ---------- Seriel / sikkerhed ---------- +constexpr uint32_t PC_BAUD = 115200; +constexpr uint32_t STATUS_INTERVAL_MS = 100; // live-status pr. kørende akse (mindst) +constexpr uint32_t STATUS_MS_PER_RUNNING_AXIS = 75; // sænk raten når flere kører (115200 baud-budget) +constexpr uint32_t WATCHDOG_TIMEOUT_MS = 2000; // blød stop hvis PC'en tier (når "hb" er aktiveret) +constexpr uint32_t DRIVER_CHECK_INTERVAL_MS = 1000; // hver akse tjekkes så ofte (spredt ud) diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..d38ca64 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,63 @@ +#include +#include +#include "config.h" +#include "Machine.h" +#include "Commands.h" + +FastAccelStepperEngine engine; +Machine machine(engine); +Commands commands(machine); + +static String rxLine; +static uint32_t lastRxMs = 0; + +void setup() { + Serial.begin(PC_BAUD); + delay(200); + + machine.begin(); + + Serial.println(); + Serial.printf("# Stepper-test klar - %u akser\n", NUM_AXES); + uint8_t found = 0; + for (uint8_t i = 0; i < NUM_AXES; i++) { + Axis& a = machine.axis(i); + found += a.uartOk(); + Serial.printf("# a%u (bus %c, adr %u, STEP %u, DIR %u): %s\n", i, 'A' + a.config().bus, + a.config().address, a.config().stepPin, a.config().dirPin, + a.uartOk() ? "TMC2209 fundet" : "ingen driver"); + } + if (!found) Serial.println("# Ingen drivere fundet - er VM (12/24 V) tændt? Prøver igen løbende."); + Serial.printf("# DIAG: %s på GPIO%u\n", + DIAG_MODE == DiagMode::DiodeOr ? "diode-OR (digital)" : "modstande + ADC", PIN_DIAG); + Serial.println("# Skriv 'help' for kommandoer"); + Serial.println(commands.handle("status")); +} + +void loop() { + // ---- Læs kommandoer fra PC'en (ikke-blokerende) ---- + while (Serial.available()) { + char c = (char)Serial.read(); + lastRxMs = millis(); + if (c == '\n' || c == '\r') { + if (rxLine.length()) { + String reply = commands.handle(rxLine); + if (reply.length()) Serial.println(reply); + rxLine = ""; + } + } else if (rxLine.length() < 80) { + rxLine += c; + } + } + + // ---- Stall, homing, driver-tjek og live-status ---- + machine.poll(Serial); + + // ---- Watchdog: kun aktiv når PC-programmet sender "hb" ---- + uint32_t now = millis(); + if (commands.heartbeatArmed() && now - lastRxMs > WATCHDOG_TIMEOUT_MS && machine.anyRunning()) { + machine.stopAll(); + Serial.println("# watchdog: ingen heartbeat fra PC - blød stop af alle akser"); + lastRxMs = now; // undgå at gentage beskeden hver loop + } +} diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..a4fe80f --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,3 @@ +fastapi +uvicorn[standard] +pyserial diff --git a/web/server.py b/web/server.py new file mode 100644 index 0000000..d59633f --- /dev/null +++ b/web/server.py @@ -0,0 +1,193 @@ +"""Web-interface til stepper-test. Kører på PC'en og taler med ESP32 over USB-serial. + +Start: web/.venv/bin/python web/server.py [--port /dev/ttyUSB0] +Åbn: http://127.0.0.1:8000 +""" + +import argparse +import asyncio +import json +import threading +import time +from contextlib import asynccontextmanager +from pathlib import Path + +import serial +import uvicorn +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse + +STATIC_DIR = Path(__file__).parent / "static" +HEARTBEAT_S = 0.5 # firmwarens watchdog stopper efter 2 s uden data + + +class SerialLink: + """Én seriel forbindelse til ESP32. Genforbinder automatisk hvis kablet ryger.""" + + def __init__(self, port: str, baud: int, on_message): + self.port = port + self.baud = baud + self.on_message = on_message # kaldes fra læsetråden med en dict + self.ser: serial.Serial | None = None + self.lock = threading.Lock() + self.connected = False + self.error = "" + + def start(self): + threading.Thread(target=self._run, daemon=True).start() + + def _set_state(self, connected: bool, error: str = ""): + if connected != self.connected or error != self.error: + self.connected, self.error = connected, error + self.on_message({"type": "conn", "connected": connected, "port": self.port, "error": error}) + + def _open(self) -> serial.Serial: + ser = serial.Serial() + ser.port, ser.baudrate, ser.timeout = self.port, self.baud, 0.2 + # Undgå at DTR/RTS resetter ESP32 når porten åbnes (virker på de fleste boards). + ser.dtr = False + ser.rts = False + ser.open() + return ser + + def _run(self): + while True: + try: + ser = self._open() + except (serial.SerialException, OSError) as e: + self._set_state(False, str(e)) + time.sleep(2) + continue + with self.lock: + self.ser = ser + self._set_state(True) + # Reset ved hver forbindelse, så ESP32 altid starter i kendt tilstand (driver disabled). + # Firmwaren sender selv boot-log og en status-linje. + self.reset() + try: + while True: + raw = ser.readline() + if raw: + text = raw.decode("utf-8", errors="replace").rstrip("\r\n") + if text: + self.on_message({"type": "line", "text": text}) + except (serial.SerialException, OSError) as e: + with self.lock: + self.ser = None + try: + ser.close() + except Exception: + pass + self._set_state(False, str(e)) + time.sleep(1) + + def reset(self) -> bool: + """Genstart ESP32 via auto-reset-kredsløbet: RTS trækker EN lav, DTR=False holder IO0 høj.""" + with self.lock: + if not self.ser: + return False + try: + self.ser.dtr = False + self.ser.rts = True + time.sleep(0.15) + self.ser.rts = False + return True + except (serial.SerialException, OSError): + return False + + def write(self, line: str) -> bool: + with self.lock: + if not self.ser: + return False + try: + self.ser.write((line.strip() + "\n").encode()) + return True + except (serial.SerialException, OSError): + return False + + +clients: set[WebSocket] = set() +loop: asyncio.AbstractEventLoop | None = None +link: SerialLink | None = None + + +async def broadcast(msg: dict): + data = json.dumps(msg) + for ws in list(clients): + try: + await ws.send_text(data) + except Exception: + clients.discard(ws) + + +def on_serial_message(msg: dict): + # Kaldes fra læsetråden – flyt over i asyncio-loopet. + if loop: + asyncio.run_coroutine_threadsafe(broadcast(msg), loop) + + +async def heartbeat(): + # Kun heartbeat mens en browser er forbundet. Lukkes fanen, stopper firmwaren selv. + while True: + await asyncio.sleep(HEARTBEAT_S) + if clients and link: + link.write("hb") + + +@asynccontextmanager +async def lifespan(_app): + global loop + loop = asyncio.get_running_loop() + link.start() + hb = asyncio.create_task(heartbeat()) + yield + hb.cancel() + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/") +async def index(): + return FileResponse(STATIC_DIR / "index.html") + + +@app.websocket("/ws") +async def ws_endpoint(ws: WebSocket): + await ws.accept() + clients.add(ws) + await ws.send_text(json.dumps( + {"type": "conn", "connected": link.connected, "port": link.port, "error": link.error})) + try: + while True: + cmd = (await ws.receive_text()).strip() + if cmd == "!reset": + ok = await asyncio.to_thread(link.reset) + await ws.send_text(json.dumps( + {"type": "line", "text": "# ESP32 resettes" if ok else "err ikke forbundet til ESP32"})) + elif cmd and not link.write(cmd): + await ws.send_text(json.dumps({"type": "line", "text": "err ikke forbundet til ESP32"})) + except WebSocketDisconnect: + pass + finally: + clients.discard(ws) + if not clients: + link.write("stop") # sidste fane lukket → blød stop med det samme + + +def main(): + global link + p = argparse.ArgumentParser(description="Stepper-test web-interface") + p.add_argument("--port", default="/dev/ttyUSB0", help="seriel port til ESP32") + p.add_argument("--baud", type=int, default=115200) + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--http-port", type=int, default=8000) + args = p.parse_args() + + link = SerialLink(args.port, args.baud, on_serial_message) + print(f"Åbn http://{args.host}:{args.http_port} (ESP32 på {args.port})") + uvicorn.run(app, host=args.host, port=args.http_port, log_level="warning") + + +if __name__ == "__main__": + main() diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..9c090e7 --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,913 @@ + + + + + +Stepper-test + + + + + +
+ +
+
+

Styring

+
+
+
+
+
+ + +
+
+ + + + +
+
+ +
+

Oversigt – klik på en akse for at vælge den

+
+ + + +
AkseBus/adrDriverPositionOmdrHastighedSG / sgtFlag
+
+
+ +
+

Status – akse 0

+
–
+
 
+
+ Mål– + Kører– + Driver– + Hastighed– + Accel– + Strøm– + Mode– + TMC– +
+
+ +
+

Driver og jog – akse 0

+
+ + + + +
+
+ + + + +
+
+ steps + +
+
+ steps + +
+
+
+ +
+

Indstillinger – akse 0

+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+ Maks-værdier sættes i firmwaren (config.h) og gælder alle akser. +
+ +
+

Log

+
+
+ + + +
+
+
+
+ + +
+
+

StallGuard – belastning og stall-detektion

+
+
+
+
+
+
+ SG_RESULT – akse 0 +
–
+ høj = let belastning, lav = tung belastning +
+
–
+
+ +
+ SG_RESULT (live mens aksen kører) + Stall-grænse = 2 × sgt +
+
+ +
+

Tærskel og stall-stop – akse 0

+
+ Effektiv sgt: – + – +
+
Stall når SG ≤ 2 × sgt
+
+ +
+
+ + + + 0 + + + +
+
Auto bruger aksens kalibreringspunkter i config.h (akse 0: 800 → 35, 3200 → 90). Falske stop: −. Intet stop: +.
+
+ + + + +
+
Manuel slår auto fra. Bruges til at kalibrere nye akser.
+
+ + + +
+

Homing (sensorløs)

+
+ + + kører højst 2 omdr. mod et mekanisk stop +
+
+ +
+

Kør – akse 0

+
+ + +
+
+ + + + + steps/s · nu – +
+
+ + + + +
+
+ +
+

Sådan tuner du

+
    +
  1. Auto er standard: sgt følger hastigheden. Tjek at den røde linje ligger under den frie kurve ved alle tre hastigheder.
  2. +
  3. Slå stall-stop til. Fri kørsel må ikke stoppe; bremser du, skal den stoppe med det samme.
  4. +
  5. Falske stop: finjustér −. Intet stop: finjustér +.
  6. +
  7. Et stall på én akse stopper alle akser (fælles DIAG-linje). Loggen viser, hvilken akse der stallede.
  8. +
  9. Ny akse/motor: brug Manuel. Find sgt ved 800 og ved 3200, og indsæt dem som aksens kalibreringspunkter i config.h.
  10. +
+ StallGuard virker kun i StealthChop og kun over halvdelen af den indstillede hastighed (ikke under op- og nedbremsning). +
+
+
+ + +
+

Ledningsdiagram – ESP32 + TMC2209 + steppere

+
+
+
+ DIAG-linjen: + + + firmwaren: – +
+ Modstande + ADC virker uden dioder, men har mindre støjmargin. Diode-OR er mest pålidelig. + Skift i firmwaren med DIAG_MODE i config.h – og kobl om, så det passer. +
+
+

Én akse i detaljer (akse 0)

+ + + + ESP32 DevKit + 3V3 + GND + GPIO25 + GPIO26 + GPIO27 + GPIO17 (TX A) + GPIO35 (RX A) + GPIO39 (DIAG) + + USB-C + → PC (strøm + seriel) + + + + TMC2209 (BTT V1.3) + akse 0 · adresse 0 + VIO + GND + STEP + DIR + EN + PDN_UART + MS1 + MS2 + DIAG + VM + GND + A1 + A2 + B1 + B2 + + + + 3,3 V + + GND + + STEP + + DIR + + EN (fælles, aktiv lav) + + + + + 1 kΩ + + + + UART-bus A + + + + + + MS1 + MS2 → GND (adr 0) + + + + + + + 4,7–10 kΩ (1 kΩ virker) · ingen pull-down + + + + + + + + + + 10 kΩ + Schottky (BAT54 / 1N5817) + + + + + Strømforsyning + 12–24 V DC + + + − + + + + + + + ≥100 µF + + + + Stepper + 42-motor (1,8°) + + + spole A + + + spole B + +
+ Forsyning + GND + STEP / DIR / EN + UART + Motor + DIAG +
+
+ +
+

Flere drivere på samme bus (bus A, akse 0–3) – DIAG via modstandeDIAG via diode-OR

+ + + ESP32 + GPIO17 (TX A) + GPIO35 (RX A) + GPIO39 (DIAG) + + + + + 1 kΩ + + + + UART-bus A (én ledning til alle PDN_UART) + + + + fælles DIAG-linje + + + + + + 10 kΩ pull-down + + ingen pull-down – de lave DIAG-udgange holder linjen nede + + + + +

Bus B (GPIO4 TX via 1 kΩ, GPIO34 RX) til akse 4–5 kobles på samme måde, og deres DIAG går til den samme DIAG-linje via hver sin + modstand (samme værdi som de andre)diode. + EN (GPIO27), VIO (3,3 V) og GND går til alle drivere.

+
+ +
+

Alle akser

+
+ + + + + + + + +
AkseSTEPDIRBusAdresseMS1MS2
0GPIO25GPIO26A0GNDGND
1GPIO33GPIO14A13,3 VGND
2GPIO23GPIO18A2GND3,3 V
3GPIO19GPIO13A33,3 V3,3 V
4GPIO32GPIO5B0GNDGND
5GPIO16GPIO15B13,3 VGND
+
+

Fælles

+
+ + + + + + + + + +
SignalESP32Note
EN (alle drivere)GPIO27Lav = drivere aktive. Den enkelte akse slås til/fra over UART.
Bus A TX / RXGPIO17 / GPIO35TX via 1 kΩ, RX direkte på bussen. Akse 0–3.
Bus B TX / RXGPIO4 / GPIO34TX via 1 kΩ, RX direkte på bussen. Akse 4–5.
DIAG (alle drivere)GPIO39 + Modstande + ADC (DIAG_MODE = ResistorAdc): én modstand pr. driver fra DIAG til linjen, alle med samme værdi (4,7–10 kΩ anbefales, 1 kΩ virker). Ingen pull-down. Linjen er 0 V uden stall og mindst ~0,55 V ved stall (6 drivere), så den måles med ADC'en.
+ Diode-OR (DIAG_MODE = DiodeOr): én Schottky-diode pr. driver (BAT54/1N5817/1N5819/BAT85; anode på DIAG, katode på linjen) + 10 kΩ fra GPIO39 til GND. Pull-down'en er påkrævet, fordi GPIO39 ikke har en intern. 1N4148 virker, men giver kun ~2,7 V (lille margin).
+ Forbind aldrig flere DIAG direkte uden modstand eller diode.
I²C (encodere senere)GPIO21 / GPIO22Reserveret – må ikke bruges til andet.
VIO3V3Til alle drivere. 3,3 V, ikke 5 V.
GNDGNDFælles GND mellem ESP32, drivere og strømforsyning er påkrævet.
+
+

Adresse: MS1 er bit 0 og MS2 er bit 1. "3,3 V" betyder forbundet til VIO (3,3 V) – aldrig til VM. + Modul: brug BigTreeTech TMC2209 V1.3 (eller et andet modul med dokumenteret UART). De lilla AliExpress-moduler har ikke PDN_UART ført ud og virker kun i standalone-mode. + Benavne varierer mellem moduler, og pinplaceringen på ESP32 varierer også: find benene efter teksten på boardet.

+
+ +
+

Før strømmen tændes

+
    +
  • Tilslut USB først, tænd derefter for motorstrømmen. Sluk motorstrømmen, før USB tages ud.
  • +
  • Tag aldrig motorstikket ud eller i, mens der er strøm på VM. Det kan ødelægge driveren.
  • +
  • Hold motorkabler væk fra USB-kablet. Støj kan få pc'en til at miste forbindelsen til ESP32.
  • +
  • TMC2209 svarer kun på UART, når VM er tændt. Firmwaren leder efter alle drivere løbende, så når VM tændes, står der # aN UART OK - driver fundet i loggen for hver driver.
  • +
  • Finder den ikke en driver: kør UART-diagnose på aksen. Den viser, hvilke adresser der svarer på bussen. Svarer driveren på en anden adresse end forventet, sidder MS1/MS2 forkert.
  • +
  • Slukkes VM, mens en akse er enabled, disabler firmwaren den. Send en igen, når VM er tilbage.
  • +
+
+
+
+
+ + + +