#include #include #include #include #define BAUDRATE 115200 #define ENC_A_PIN 5 #define ENC_B_PIN 6 #define ENC_COUNTS_PER_REV 2400 #define IN1 1 // Coil A+ (White) #define IN2 2 // Coil A- (Red) #define IN3 3 // Coil B+ (Black) #define IN4 4 // Coil B- (Yellow) #define STEPS_PER_REV 14400 // half-step static const char* AP_SSID = "SlowSweep"; static const char* AP_PASS = ""; // ── Half-step sequence ──────────────────────────────────────────────────────── static const uint8_t STEP_TABLE[8][4] = { {1,0,0,0}, {1,0,1,0}, {0,0,1,0}, {0,1,1,0}, {0,1,0,0}, {0,1,0,1}, {0,0,0,1}, {1,0,0,1}, }; // ── Encoder ────────────────────────────────────────────────────────────────── volatile int32_t encPosition = 0; volatile uint8_t encPrevState = 0; static const int8_t ENC_TABLE[16] = { 0,+1,-1, 0, -1, 0, 0,+1, +1, 0, 0,-1, 0,-1,+1, 0 }; void IRAM_ATTR encISR() { uint8_t a = digitalRead(ENC_A_PIN); uint8_t b = digitalRead(ENC_B_PIN); uint8_t s = (a << 1) | b; encPosition += ENC_TABLE[(encPrevState << 2) | s]; encPrevState = s; } // ── Motor primitives ────────────────────────────────────────────────────────── int stepIndex = 0; void applyStep() { digitalWrite(IN1, STEP_TABLE[stepIndex][0]); digitalWrite(IN2, STEP_TABLE[stepIndex][1]); digitalWrite(IN3, STEP_TABLE[stepIndex][2]); digitalWrite(IN4, STEP_TABLE[stepIndex][3]); } void releaseCoils() { digitalWrite(IN1,0); digitalWrite(IN2,0); digitalWrite(IN3,0); digitalWrite(IN4,0); } // ── Motor state machine ─────────────────────────────────────────────────────── // Burst-pause: step BURST_STEPS quickly, release coils, cool, repeat. // sweepMode: auto-reverse direction every STEPS_PER_REV steps. enum Phase { IDLE, BURSTING, COOLING }; Phase phase = IDLE; int motorDir = 1; // 1=CW, -1=CCW int minsPerRev = 5; // speed preset: 5 / 10 / 20 / 30 bool motorEnabled = false; bool sweepMode = false; // auto-reverse CW↔CCW int stepsInPass = 0; // counts 0→STEPS_PER_REV then resets #define BURST_STEPS 240 // steps per burst (6° per window) #define BURST_STEP_MS_MIN 5 // fastest allowed step rate // Window = minsPerRev * 60000ms / (STEPS_PER_REV/BURST_STEPS) // = minsPerRev * 60000 * 240 / 14400 // = minsPerRev * 1000 ms // Burst = ~1/6 of window so motion is visibly ~5 s at 30 min/rev. static int stepDelayMs() { int ms = (minsPerRev * 1000) / (6 * BURST_STEPS); return max(BURST_STEP_MS_MIN, ms); } // 5 min/rev → stepDelay=5ms, burst=1.2s, cool= 3.8s // 10 min/rev → stepDelay=6ms, burst=1.4s, cool= 8.6s // 20 min/rev → stepDelay=13ms,burst=3.1s, cool=16.9s // 30 min/rev → stepDelay=20ms,burst=4.8s, cool=25.2s static int coolMs() { int windowMs = minsPerRev * 1000; int burstMs = BURST_STEPS * stepDelayMs(); return max(200, windowMs - burstMs); } static int burstLeft = 0; static uint32_t lastStepMs = 0; static uint32_t coolEndMs = 0; static void beginBurst() { burstLeft = BURST_STEPS; lastStepMs = millis(); phase = BURSTING; } static void resetHome() { encPosition = 0; stepsInPass = 0; motorDir = 1; // always restart CW from home } // ── WebSocket / HTTP ────────────────────────────────────────────────────────── AsyncWebServer server(80); AsyncWebSocket ws("/ws"); DNSServer dnsServer; static void broadcastState() { if (!ws.count()) return; // pdeg: 0-360 showing table position in current pass. // CW pass: 0°→360°. CCW pass: 360°→0°. float pdeg = (float)stepsInPass * 360.0f / STEPS_PER_REV; if (motorDir == -1) pdeg = 360.0f - pdeg; const char* st = (phase==BURSTING) ? "run" : (phase==COOLING) ? "cool" : "idle"; char buf[128]; snprintf(buf, sizeof(buf), "{\"enc\":%ld,\"pdeg\":%.1f,\"state\":\"%s\",\"dir\":%d,\"mpr\":%d}", (long)encPosition, pdeg, st, motorDir, minsPerRev); ws.textAll(buf); } static void handleMsg(const char* d) { if (strstr(d, "\"start\"")) { sweepMode = true; motorEnabled = true; stepsInPass = 0; motorDir = 1; if (phase == IDLE) beginBurst(); } else if (strstr(d, "\"stop\"")) { sweepMode = false; motorEnabled = false; } else if (strstr(d, "\"zero\"")) { resetHome(); } else if (strstr(d, "\"nudge\"")) { // 1° = STEPS_PER_REV/360 = 40 half-steps. Blocks ~200ms — fine on async server. const char* p = strstr(d, "\"v\":"); if (p) { int dir = atoi(p + 4); // 1=CW, -1=CCW if (dir != 0) { sweepMode = false; motorEnabled = false; phase = IDLE; releaseCoils(); int cnt = STEPS_PER_REV / 360; // 40 steps for (int i = 0; i < cnt; i++) { stepIndex = (stepIndex + dir + 8) % 8; applyStep(); delay(5); } releaseCoils(); } } } else if (strstr(d, "\"speed\"")) { const char* p = strstr(d, "\"v\":"); if (p) { int v = atoi(p + 4); if (v==5||v==10||v==20||v==30) minsPerRev = v; } // Backward-compat manual direction commands (serial scripts / debug) } else if (strstr(d, "\"cw\"")) { sweepMode=false; motorDir=1; motorEnabled=true; if (phase==IDLE) beginBurst(); } else if (strstr(d, "\"ccw\"")) { sweepMode=false; motorDir=-1; motorEnabled=true; if (phase==IDLE) beginBurst(); } } void onWs(AsyncWebSocket*, AsyncWebSocketClient*, AwsEventType type, void* arg, uint8_t* data, size_t len) { if (type != WS_EVT_DATA) return; AwsFrameInfo* fi = (AwsFrameInfo*)arg; if (fi->final && fi->index == 0 && fi->len == len && fi->opcode == WS_TEXT) { data[len] = 0; handleMsg((char*)data); } } // ── Dashboard HTML ──────────────────────────────────────────────────────────── static const char HTML[] PROGMEM = R"html( SlowSweep

⚡ SlowSweep

---°
connecting…
per full rotation
FINE TUNE
)html"; // ── setup ───────────────────────────────────────────────────────────────────── void setup() { Serial.begin(BAUDRATE); pinMode(IN1, OUTPUT); pinMode(IN2, OUTPUT); pinMode(IN3, OUTPUT); pinMode(IN4, OUTPUT); releaseCoils(); pinMode(ENC_A_PIN, INPUT_PULLUP); pinMode(ENC_B_PIN, INPUT_PULLUP); encPrevState = (digitalRead(ENC_A_PIN) << 1) | digitalRead(ENC_B_PIN); attachInterrupt(digitalPinToInterrupt(ENC_A_PIN), encISR, CHANGE); attachInterrupt(digitalPinToInterrupt(ENC_B_PIN), encISR, CHANGE); WiFi.softAP(AP_SSID, AP_PASS); Serial.printf("\n[WIFI] AP \"%s\" IP: %s\n", AP_SSID, WiFi.softAPIP().toString().c_str()); // Captive portal: answer all DNS queries with our IP so phones detect // "login required" and route traffic through WiFi instead of mobile data. dnsServer.start(53, "*", WiFi.softAPIP()); ws.onEvent(onWs); server.addHandler(&ws); server.on("/", HTTP_GET, [](AsyncWebServerRequest* req) { req->send(200, "text/html", HTML); }); // Catch-all: redirect captive-portal probe URLs (connectivitycheck.*, captive.apple.com, etc.) server.onNotFound([](AsyncWebServerRequest* req) { req->redirect("http://192.168.4.1/"); }); server.begin(); Serial.println("[HTTP] Dashboard: http://192.168.4.1"); Serial.println("[SERIAL] START / STOP / ZERO / CW / CCW / STEP n ms / RELEASE / ENC"); Serial.println("========================================="); } // ── loop ────────────────────────────────────────────────────────────────────── void loop() { uint32_t now = millis(); dnsServer.processNextRequest(); // ── Motor state machine ────────────────────────────────────────────────── switch (phase) { case BURSTING: if (now - lastStepMs >= (uint32_t)stepDelayMs()) { stepIndex = (stepIndex + motorDir + 8) % 8; applyStep(); lastStepMs = now; // Auto-reverse: flip direction every STEPS_PER_REV steps if (sweepMode) { stepsInPass++; if (stepsInPass >= STEPS_PER_REV) { stepsInPass = 0; motorDir = -motorDir; } } if (--burstLeft <= 0) { releaseCoils(); coolEndMs = now + coolMs(); phase = COOLING; } } break; case COOLING: if (now >= coolEndMs) { if (motorEnabled) { beginBurst(); } else { phase = IDLE; } } break; case IDLE: break; } // ── WebSocket ──────────────────────────────────────────────────────────── ws.cleanupClients(); static uint32_t lastBcast = 0; if (now - lastBcast >= 250) { lastBcast = now; broadcastState(); } // ── Serial commands ────────────────────────────────────────────────────── if (Serial.available()) { String scmd = Serial.readStringUntil('\n'); scmd.trim(); scmd.toUpperCase(); if (scmd == "START") { sweepMode=true; motorEnabled=true; stepsInPass=0; motorDir=1; if (phase==IDLE) beginBurst(); Serial.println("[START] sweep mode"); } else if (scmd == "STOP") { sweepMode=false; motorEnabled=false; Serial.println("[STOP] after current burst"); } else if (scmd == "CW") { sweepMode=false; motorDir=1; motorEnabled=true; if (phase==IDLE) beginBurst(); } else if (scmd == "CCW") { sweepMode=false; motorDir=-1; motorEnabled=true; if (phase==IDLE) beginBurst(); } else if (scmd == "RELEASE") { sweepMode=false; motorEnabled=false; phase=IDLE; releaseCoils(); Serial.println("[RELEASE]"); } else if (scmd == "ZERO") { resetHome(); Serial.println("[ZERO] home set"); } else if (scmd == "ENC") { float deg = (float)encPosition * 360.0f / ENC_COUNTS_PER_REV; Serial.printf("[ENC] counts=%ld deg=%.2f\n", (long)encPosition, deg); } else if (scmd.startsWith("STEP")) { int n=0, dms=5; sscanf(scmd.c_str(), "STEP %d %d", &n, &dms); int dir=(n>=0)?1:-1, cnt=abs(n); int32_t before = encPosition; for (int i=0; i= 200) { float deg = pos * 360.0f / ENC_COUNTS_PER_REV; Serial.printf("[ENC] counts=%ld deg=%.2f\n", (long)pos, deg); lastRep = pos; lastEncPrint = now; } static uint32_t lastHB = 0; if (now - lastHB >= 1000) { lastHB = now; Serial.printf("[ALIVE] millis=%lu enc=%ld\n", (unsigned long)now, (long)encPosition); } }