// PG TURNTABLE — 1/8 microstep, smoothed, slow
// - MICROSTEP=8 (A4988: MS1=H, MS2=H, MS3=L)
// - RunSpeed=0.005f (≈ same angular speed as 0.010 at 1/16)
// - Accel floor for smooth ramps, 400kHz I2C, throttled redraws, service stepper during draws
//
// Pins: D3 STEP, D4 DIR, D5 EN(LOW=enable), D10 Camera
// D6 Start/Pause (short) + Stop (long), D7 Shots, D8 Interval, D9 Reset (long)
// I2C: A4 SDA, A5 SCL (SSD1306 @ 0x3C)
#include <Wire.h>
#include <U8g2lib.h>
#include <AccelStepper.h>
#include <math.h>
#include "spectrix_logo.h"
#include "icon_camera.h"
#include "icon_cross.h"
// ===== Pins =====
const uint8_t PIN_STEP = 3;
const uint8_t PIN_DIR = 4;
const uint8_t PIN_EN = 5; // StepStick EN (active LOW)
const uint8_t PIN_CAM = 10; // Opto LED (HIGH = fire)
// Buttons (right column: top→bottom)
const uint8_t PIN_GO_STOP = 6; // Start/Pause short, Stop long
const uint8_t PIN_SHOTS = 7; // cycle 10/24/36
const uint8_t PIN_INT = 8; // interval 1..5 s
const uint8_t PIN_RESET = 9; // long = home
// ===== Motion =====
const long STEPS_PER_REV_MOTOR = 200L; // 1.8°
const uint8_t MICROSTEP = 8; // 1/8-step << MS1=H, MS2=H, MS3=L
const uint16_t GEAR_NUM = 1, GEAR_DEN = 1; // 1:1
// Base caps (absolute); scaled for normal running
const float BASE_MAX_SPEED = 12000.0f; // steps/s
const float BASE_ACCEL = 6000.0f; // steps/s^2
float RunSpeed = 0.003f; // overall running speed scaler (0.003..0.03 smooth range)
float ReturnSpeed = 0.05f; // homing/final-arc scaler (gentle)
// Camera timing
const uint16_t CAM_PULSE_MS = 180;
const uint16_t CAM_SETTLE_MS = 120;
const uint16_t SETTLE_BEFORE_FIRE_MS = 500; // hidden settle
// Button thresholds
const uint16_t LP_STOP_MS = 1200;
const uint16_t LP_RESET_MS = 2000;
// UI options
const uint8_t SHOT_CHOICES[] = {10, 24, 36};
const uint8_t NUM_SHOT_CHOICES = sizeof(SHOT_CHOICES);
const uint8_t MIN_PAUSE_S = 1, MAX_PAUSE_S = 5;
// Hold durations
const uint16_t SPLASH_MS = 3000;
const uint16_t COMPLETE_MS = 3000;
// ===== Display & Stepper =====
U8G2_SSD1306_128X64_NONAME_1_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE); // page buffer; we service stepper inside pages
AccelStepper stepper(AccelStepper::DRIVER, PIN_STEP, PIN_DIR);
// Defaults
uint8_t shotChoiceIdx = 0; // 10 shots
uint8_t pauseSeconds = 3; // 3 s
// ---- Debounced Buttons ----
class DebouncedButton {
public:
void begin(uint8_t p, uint16_t longMs_, uint16_t debounceMs_=25) {
pin = p; longMs = longMs_; debounceMs = debounceMs_;
pinMode(pin, INPUT_PULLUP);
raw = stable = !isActive();
lastChange = millis();
}
void update() {
bool r = isActive(); uint32_t now = millis();
pressedEvt = releasedEvt = false;
if (r != raw) { raw = r; lastChange = now; }
if ((now - lastChange) >= debounceMs && raw != stable) {
stable = raw;
if (stable) { tPressed = now; longLatched = false; pressedEvt = true; }
else { releasedEvt = true; }
}
if (stable && !longLatched && (now - tPressed) >= longMs) longLatched = true;
}
bool shortPress() { return (releasedEvt && !longLatched); }
bool longPress() { return (releasedEvt && longLatched); }
private:
uint8_t pin=0; uint16_t longMs=1000, debounceMs=25;
bool raw=false, stable=false, pressedEvt=false, releasedEvt=false, longLatched=false;
uint32_t lastChange=0, tPressed=0;
inline bool isActive() const { return digitalRead(pin) == LOW; }
};
DebouncedButton bGoStop, bShots, bInt, bReset;
// ----- State machine -----
enum Mode { MENU, SETTLE, FIRE, INTERVAL, MOVE, PAUSED, RETURN_HOME, COMPLETE_MOVE, COMPLETE_MSG } mode = MENU;
uint16_t totalShots = SHOT_CHOICES[shotChoiceIdx];
uint16_t currentShot = 0;
// Exact step distribution (no drift)
long totalStepsPerRev = 0, baseSteps = 0, remainder = 0, remAcc = 0;
long startPos = 0, targetPos = 0, finalTarget = 0;
uint32_t settleEnd=0, intervalEnd=0, bootGuardUntil=0, completeMsgEnd=0;
// UI throttling + blinking header
const uint16_t UI_INTERVAL_IDLE_MS = 100;
const uint16_t UI_INTERVAL_RUN_MS = 400; // fewer redraws while moving
uint32_t nextUI=0;
const uint16_t BLINK_MS = 500; uint32_t nextBlink=0; bool blinkOn=true;
// Helpers to apply motion limits (with accel floor for smoothness)
inline void applyNormalMotion(){
float f = RunSpeed; if (f < 0.003f) f = 0.003f; if (f > 1.0f) f = 1.0f;
float accelFactor = (f < 0.35f) ? 0.35f : f; // floor accel at 35% of base
stepper.setMaxSpeed(BASE_MAX_SPEED * f);
stepper.setAcceleration(BASE_ACCEL * accelFactor);
}
inline void applyReturnMotion(){
float f = ReturnSpeed; if (f < 0.02f) f = 0.02f; if (f > 1.0f) f = 1.0f;
stepper.setMaxSpeed(BASE_MAX_SPEED * f);
stepper.setAcceleration(BASE_ACCEL * f);
}
// ---------- Icons ----------
inline void iconPlay (uint8_t cx,uint8_t cy){ u8g2.drawTriangle(cx-4,cy-5, cx-4,cy+5, cx+5,cy); }
inline void iconPause(uint8_t cx,uint8_t cy){ u8g2.drawBox(cx-5, cy-6, 3, 12); u8g2.drawBox(cx+2, cy-6, 3, 12); }
inline void iconCameraBmp(uint8_t cx, uint8_t cy){
int x = cx - (ICON_CAMERA_WIDTH / 2), y = cy - (ICON_CAMERA_HEIGHT / 2);
u8g2.setDrawColor(1); u8g2.setBitmapMode(1);
u8g2.drawXBMP(x, y, ICON_CAMERA_WIDTH, ICON_CAMERA_HEIGHT, icon_camera_bits);
}
inline void iconCrossBmp(uint8_t cx, uint8_t cy){
int x = cx - (ICON_CROSS_WIDTH / 2), y = cy - (ICON_CROSS_HEIGHT / 2);
u8g2.setDrawColor(1); u8g2.setBitmapMode(1);
u8g2.drawXBMP(x, y, ICON_CROSS_WIDTH, ICON_CROSS_HEIGHT, icon_cross_bits);
}
inline void iconClock(uint8_t cx,uint8_t cy){
u8g2.drawCircle(cx,cy,6); u8g2.drawLine(cx,cy, cx,cy-3); u8g2.drawLine(cx,cy, cx+3,cy);
}
void drawRightIcons(bool runningLike){
const uint8_t cx=120; const uint8_t ys[4]={10,26,42,58};
if(runningLike) iconPause(cx,ys[0]); else iconPlay(cx,ys[0]);
iconCameraBmp(cx,ys[1]); iconClock(cx,ys[2]); iconCrossBmp(cx,ys[3]);
}
// ---------- Big centered text ----------
void drawCenteredBig2(const char* a, const char* b){
u8g2.firstPage(); do {
u8g2.setFont(u8g2_font_10x20_tf);
int ascent=u8g2.getAscent(), descent=u8g2.getDescent(), lh=ascent-descent;
int blockH = lh*2 + 2, topBaseline=(64 - blockH)/2 + ascent;
int w1=u8g2.getStrWidth(a), x1=(128-w1)/2, y1=topBaseline;
int w2=u8g2.getStrWidth(b), x2=(128-w2)/2, y2=topBaseline+lh+2;
u8g2.drawStr(x1,y1,a); u8g2.drawStr(x2,y2,b);
stepper.run(); // keep stepping during draw
} while(u8g2.nextPage());
}
// ---------- Splash ----------
void drawSplashLogo(){
u8g2.firstPage(); do {
int x = (128 - SPECTRIX_LOGO_WIDTH) / 2;
int y = (64 - SPECTRIX_LOGO_HEIGHT) / 2;
u8g2.setDrawColor(1); u8g2.setBitmapMode(1);
u8g2.drawXBMP(x, y, SPECTRIX_LOGO_WIDTH, SPECTRIX_LOGO_HEIGHT, spectrix_logo_bits);
stepper.run(); // service during draw
} while(u8g2.nextPage());
}
// ---------- Menu ----------
void drawMenu(){
u8g2.firstPage(); do {
u8g2.setFont(u8g2_font_6x13_tf);
u8g2.drawStr(0,12,"PG TURNTABLE");
u8g2.drawHLine(0,14,96);
char line[32];
snprintf(line,sizeof(line),"Shots/Rev: %u", SHOT_CHOICES[shotChoiceIdx]); u8g2.drawStr(0,30,line);
snprintf(line,sizeof(line),"Interval: %us", pauseSeconds); u8g2.drawStr(0,46,line);
drawRightIcons(false);
stepper.run();
} while(u8g2.nextPage());
}
// ---------- RUN page ----------
void renderRunPage(bool runningLike){
const bool showHeader = blinkOn;
u8g2.setFont(u8g2_font_6x13_tf);
u8g2.drawStr(0,12, showHeader ? "RUNNING..." : "");
u8g2.drawHLine(0,14,96);
char line[32];
u8g2.setFont(u8g2_font_8x13B_tf);
snprintf(line,sizeof(line),"Shot %u of %u", currentShot, totalShots); u8g2.drawStr(0,34,line);
if (mode == INTERVAL){
long remain = (long)intervalEnd - (long)millis(); if(remain<0) remain=0;
uint16_t sec = (remain + 999)/1000;
snprintf(line,sizeof(line),"Delay %us", sec); u8g2.drawStr(0,54,line);
}
drawRightIcons(runningLike);
}
inline void drawRunNow(bool runningLike){
u8g2.firstPage(); do { renderRunPage(runningLike); stepper.run(); } while(u8g2.nextPage());
}
inline void drawRunThrottled(bool runningLike){
uint16_t iv = runningLike ? UI_INTERVAL_RUN_MS : UI_INTERVAL_IDLE_MS;
uint32_t now=millis(); if(now>=nextUI){ drawRunNow(runningLike); nextUI=now+iv; }
}
// ---------- PAUSED page ----------
void renderPausedPage(){
const bool showHeader = blinkOn;
u8g2.setFont(u8g2_font_8x13B_tf);
if (showHeader) { u8g2.drawStr(0,16,"PAUSED"); }
u8g2.drawHLine(0,18,96);
drawRightIcons(false);
}
inline void drawPausedNow(){ u8g2.firstPage(); do { renderPausedPage(); stepper.run(); } while(u8g2.nextPage()); }
inline void drawPausedThrottled(){ if(millis()>=nextUI){ drawPausedNow(); nextUI=millis()+UI_INTERVAL_IDLE_MS; } }
// ---------- Homing page ----------
void drawHoming(){
u8g2.firstPage(); do {
u8g2.setFont(u8g2_font_6x13_tf);
u8g2.drawStr(0,12,"RETURNING TO START...");
u8g2.drawHLine(0,14,96);
u8g2.drawStr(0,32,"Please wait");
drawRightIcons(true);
stepper.run();
} while(u8g2.nextPage());
}
// ---------- Camera ----------
void cameraFire(){ digitalWrite(PIN_CAM, HIGH); delay(CAM_PULSE_MS); digitalWrite(PIN_CAM, LOW); delay(CAM_SETTLE_MS); }
// ---------- Step calc ----------
void computeExactSteps(){
long motorMicro = STEPS_PER_REV_MOTOR * (long)MICROSTEP; // 200*8 = 1600
long scaled = motorMicro * (long)GEAR_NUM;
totalStepsPerRev = scaled / (long)GEAR_DEN;
baseSteps = totalStepsPerRev / (long)totalShots;
remainder = totalStepsPerRev % (long)totalShots;
remAcc = 0;
}
long nextMoveSteps(){ long m=baseSteps; remAcc += remainder; if(remAcc >= (long)totalShots){ remAcc -= (long)totalShots; m += 1; } return m; }
// ---------- Setup ----------
void setup(){
pinMode(PIN_CAM, OUTPUT); digitalWrite(PIN_CAM, LOW);
bGoStop.begin(PIN_GO_STOP, LP_STOP_MS, 25);
bShots .begin(PIN_SHOTS, 800, 25);
bInt .begin(PIN_INT, 800, 25);
bReset .begin(PIN_RESET, LP_RESET_MS,25);
delay(30);
u8g2.begin(); u8g2.setI2CAddress(0x3C * 2); u8g2.setFlipMode(0);
u8g2.setBusClock(400000UL); // faster I2C
stepper.setEnablePin(PIN_EN);
stepper.setPinsInverted(false,false,true); // invert enable
stepper.enableOutputs();
applyNormalMotion();
drawSplashLogo(); delay(SPLASH_MS);
drawMenu();
bootGuardUntil = millis() + 800;
nextUI = millis();
nextBlink = millis() + BLINK_MS;
}
// ---------- Loop ----------
void loop(){
// Always service stepper
stepper.run();
// Buttons
bGoStop.update(); bShots.update(); bInt.update(); bReset.update();
// Blink timing (RUN states + PAUSED)
bool blinkActive =
(mode==SETTLE) || (mode==FIRE) || (mode==INTERVAL) ||
(mode==MOVE) || (mode==COMPLETE_MOVE) || (mode==PAUSED);
if (blinkActive && (long)(millis() - nextBlink) >= 0){
blinkOn = !blinkOn; nextBlink = millis() + BLINK_MS;
} else if (!blinkActive) { blinkOn = true; }
// RESET (long): hard-stop then home using ReturnSpeed
if (bReset.longPress()){
stepper.stop(); stepper.disableOutputs(); delay(1); stepper.enableOutputs();
applyReturnMotion();
long here = stepper.currentPosition(); stepper.setCurrentPosition(here);
stepper.moveTo(startPos);
drawHoming(); mode = RETURN_HOME;
}
switch(mode){
case MENU: {
if ((long)(millis() - bootGuardUntil) < 0) { drawMenu(); break; }
if (bShots.shortPress()){ shotChoiceIdx = (shotChoiceIdx + 1) % NUM_SHOT_CHOICES; totalShots = SHOT_CHOICES[shotChoiceIdx]; drawMenu(); }
if (bInt.shortPress()){ pauseSeconds = (pauseSeconds < MAX_PAUSE_S) ? (pauseSeconds+1) : MIN_PAUSE_S; drawMenu(); }
if (bGoStop.shortPress()){
totalShots = SHOT_CHOICES[shotChoiceIdx]; computeExactSteps();
currentShot = 1; startPos = stepper.currentPosition(); targetPos = startPos;
finalTarget = startPos + totalStepsPerRev;
settleEnd = millis() + SETTLE_BEFORE_FIRE_MS;
applyNormalMotion();
mode = SETTLE; drawRunNow(true);
}
break;
}
case SETTLE: {
if (bGoStop.shortPress()){ mode = PAUSED; drawPausedNow(); break; }
if (bGoStop.longPress()){ mode = MENU; stepper.stop(); drawMenu(); break; }
if ((long)millis() - (long)settleEnd >= 0) { mode = FIRE; }
drawRunThrottled(true); break;
}
case FIRE: {
drawRunThrottled(true); cameraFire();
intervalEnd = millis() + (uint32_t)pauseSeconds*1000UL; mode = INTERVAL; break;
}
case INTERVAL: {
if (bGoStop.shortPress()){ mode = PAUSED; drawPausedNow(); break; }
if (bGoStop.longPress()){ mode = MENU; stepper.stop(); drawMenu(); break; }
if ((long)millis() - (long)intervalEnd >= 0){
if (currentShot >= totalShots) { applyReturnMotion(); stepper.moveTo(finalTarget); mode = COMPLETE_MOVE; drawRunNow(true); }
else { long stepDelta = nextMoveSteps(); targetPos += stepDelta; applyNormalMotion(); stepper.moveTo(targetPos); mode = MOVE; }
}
drawRunThrottled(true); break;
}
case MOVE: {
if (bGoStop.shortPress()){ mode = PAUSED; drawPausedNow(); break; }
if (bGoStop.longPress()){ mode = MENU; stepper.stop(); drawMenu(); break; }
if (stepper.distanceToGo() == 0){
currentShot++; settleEnd = millis() + SETTLE_BEFORE_FIRE_MS; mode = SETTLE; drawRunNow(true);
} else {
drawRunThrottled(true);
}
break;
}
case COMPLETE_MOVE: {
if (bGoStop.longPress()){ mode = MENU; stepper.stop(); drawMenu(); break; }
if (stepper.distanceToGo() == 0){
drawCenteredBig2("Process","complete");
completeMsgEnd = millis() + COMPLETE_MS; mode = COMPLETE_MSG;
} else {
drawRunThrottled(true);
}
break;
}
case COMPLETE_MSG: {
if ((long)millis() - (long)completeMsgEnd >= 0){
currentShot = 0; remAcc = 0; drawMenu(); mode = MENU;
}
break;
}
case PAUSED: {
if (bGoStop.shortPress()){
if (stepper.distanceToGo() != 0) { mode = MOVE; drawRunNow(true); }
else { intervalEnd = millis() + (uint32_t)pauseSeconds*1000UL; mode = INTERVAL; drawRunNow(true); }
}
if (bGoStop.longPress()){ mode = MENU; stepper.stop(); drawMenu(); }
drawPausedThrottled(); break;
}
case RETURN_HOME: {
if (stepper.distanceToGo() == 0){
applyNormalMotion(); currentShot = 0; remAcc = 0; drawMenu(); mode = MENU;
} else {
drawRunThrottled(true);
}
break;
}
}
}
Start/Pause/Stop
Set shot number
Set delay between moves
Reset and return to zero