Project SLOW-SWEEP

Platform   —   ComXim MTxM_V6.01 Motorized Turntable
Objective  —   Modify stock turntable to rotate at 1 revolution per 20–30 minutes
Period     —   June 23–29, 2026
Author     —   J Garman
Product   —   ComXim 15.8″ Heavy Duty Motorized Turntable — amazon.com/dp/B0DB1WK3CW

1. Project Overview

The ComXim 15.8-inch motorized display turntable is a commercially available product designed for product photography and retail display. Its stock speed range spans 0.5–1.28 min/rev (30–77 seconds per revolution). The goal of Project SLOW-SWEEP is to achieve rotation at 1 revolution per 20–30 minutes — a 15–60× slowdown — with bidirectional capability: a full CW sweep followed by a full CCW return.

The project proceeded in three phases. Phase 1 (complete): non-destructive investigation via UART interception, IR reverse-engineering, encoder feedback, and software closed-loop control. Phase 1.5 (June 29): UART exploration of all four STC UART headers and motor drive hardware assessment. All UART ports found to carry only a fixed 1Hz keepalive — no useful telemetry. Single L298N under heatsink confirmed driving both motors in parallel. Phase 2 (Tuesday): bypass the ComXim mainboard entirely and drive both stepper motors in parallel from a single L298N H-bridge board controlled by the ESP32-S3 — mirroring the ComXim’s own architecture.

77s
Min stock speed/rev
25 min
Target speed/rev
0.15°
Encoder resolution
93.4%
Phase 1 step accuracy

2. Hardware Inventory

ComponentPartKey Specifications
TurntableComXim 15.8″ Heavy DutyMainboard: MTxM_V6.01, dated 2025.02.28. Website: comxim.com
Turntable MCUSTC 8H8K64U8051-core, LQFP-32, 64KB flash, closed firmware. Handles IR decode, motor sequencing, speed ramping, buzzer.
Drive motors2× 50BYJ46-2015V bipolar stepper, 1:33 internal planetary gear ratio. Both motors drive a common ring gear in sync.
ControllerESP32-S3-WROOM-1 N16R816MB flash, 8MB PSRAM, dual USB-C. /dev/ttyACM0. IR on GPIO16, encoder A/B on GPIO5/6, UART sniffer GPIO17/18.
IR TransmitterATOM S3 (M5Stack)Accepts NEC codes over serial, transmits as 38kHz IR. /dev/ttyACM1. Software-controlled remote substitute.
EncoderLPD3806-600BM-G5-24C600 PPR quadrature. 4× decode = 2400 counts/rev = 0.15°/count. 5–24V supply.
MicrophonePlantronics Blackwire 5220USB headset, ALSA hw:3,0, mono, 44100Hz. Used for beep detection to confirm IR command receipt.
Motor driver (Phase 2)HiLetgo L298N ×4 packDual H-bridge, 2A continuous per channel, up to 46V motor supply. One unit drives both steppers with all coils wired in parallel — identical to ComXim architecture.

3. Chronology

June 23, 2026 Disassembly & Hardware Survey

Getting Inside

The ComXim has no visible exterior screws. The bottom panel has circular foam pads. Removing them revealed screw covers but no fasteners — the foam pads were destroyed in this search. Access is via a large rectangular window milled into the top panel.

June 23: Foam pads removed reveal no fasteners underneath. The ComXim user manual is visible at right.
June 23: Foam pads removed reveal no fasteners underneath. The ComXim user manual is visible at right.
June 23: Top-down view of the milled access window. The ComXim MTxM_V6.01 mainboard is visible inside the cavity with motor wiring harnesses. The toothed ring gear belt is visible around the inner circumference.
June 23: Top-down view of the milled access window. The ComXim MTxM_V6.01 mainboard is visible inside the cavity with motor wiring harnesses. The toothed ring gear belt is visible around the inner circumference.

Drive System

Through the access window, two 50BYJ46-20 bipolar stepper motors are visible mounted above the cavity, driving a large toothed ring gear belt via pinion gears. Both motors are identical: 15V, 1:33 internal planetary gear reduction, bipolar (4-wire) configuration.

June 23: Both 50BYJ46-20 stepper motors sitting above the access window with their pinion gears engaging the ring gear below.
June 23: Both 50BYJ46-20 stepper motors sitting above the access window with their pinion gears engaging the ring gear below.
June 23: Front motor label reads: 50BYJ46-20 / 15V 1:33 / MC: MC02010020 / SC: VT00000128 / 20250828. Bipolar stepper, 15V, 1:33 internal planetary gear reduction.
June 23: Front motor label reads: 50BYJ46-20 / 15V 1:33 / MC: MC02010020 / SC: VT00000128 / 20250828. Bipolar stepper, 15V, 1:33 internal planetary gear reduction.

ComXim MTxM_V6.01 Control Board

The mainboard sits flat inside the cavity. Key components identified through photography:

June 23: ComXim MTxM_V6.01 board left side. Board date: 2025.02.28. STC 8H8K64U MCU center, buzzer top-left, multiple JST motor connectors.
June 23: ComXim MTxM_V6.01 board left side. Board date: 2025.02.28. STC 8H8K64U MCU center, buzzer top-left, multiple JST motor connectors.
June 23: Board right side. Large aluminum heatsink covers the motor driver IC. UART4 test pads (Vc, Gd, Rx, Tx) visible upper-right.
June 23: Board right side. Large aluminum heatsink covers the motor driver IC. UART4 test pads (Vc, Gd, Rx, Tx) visible upper-right.
June 23: Close-up of the UART4 header. Four pads labeled Vc, Gd, Rx, Tx. Yellow tape applied during wiring. This was the target of the serial interception attempt — which produced no traffic.
June 23: Close-up of the UART4 header. Four pads labeled Vc, Gd, Rx, Tx. Yellow tape applied during wiring. This was the target of the serial interception attempt — which produced no traffic.
June 23: STC 8H8K64U microcontroller in LQFP-32 package. Markings: 512A54533400 / 451-LQFP32 / 8H8K64U / STC. All turntable logic — IR decode, motor sequencing, speed, beep — runs on this chip.
June 23: STC 8H8K64U microcontroller in LQFP-32 package. Markings: 512A54533400 / 451-LQFP32 / 8H8K64U / STC. All turntable logic — IR decode, motor sequencing, speed, beep — runs on this chip.
June 23: Full board view. Board silkscreen: MTxM_V6.01 / 2025.02.28. Website: http://www.comxim.com. ~100x55mm.
June 23: Full board view. Board silkscreen: MTxM_V6.01 / 2025.02.28. Website: http://www.comxim.com. ~100x55mm.
June 24, 2026 UART Interception Dead End

The Serial Strategy

The initial plan assumed the ComXim firmware uses an ASCII or binary serial protocol — similar to other motorized camera or display products — on the UART4 test pads labeled on the mainboard. The ESP32-S3 was wired with GPIO18 (RX) to the ComXim Tx pad and GPIO17 (TX) to the ComXim Rx pad, sharing ground. The Vc pad was deliberately left unconnected to avoid exposing the ESP32’s 3.3V logic to an unknown voltage rail.

June 24: ESP32-S3-WROOM-1 (N16R8) development board positioned next to the ComXim board during UART tap wiring.
June 24: ESP32-S3-WROOM-1 (N16R8) development board positioned next to the ComXim board during UART tap wiring.
June 24: The wired-up UART tap. Jumpers from ESP32 GPIO17/18 to the ComXim UART4 Rx/Tx pads. Result: no traffic observed at any baud rate.
June 24: The wired-up UART tap. Jumpers from ESP32 GPIO17/18 to the ComXim UART4 Rx/Tx pads. Result: no traffic observed at any baud rate.

Result: Zero Traffic at All Baud Rates

Monitored at 9600, 19200, 38400, 57600, and 115200 baud. No bytes observed under any operating condition — not at boot, not during button presses, not during motor movement. The UART4 pads are an unpopulated programming/debug interface, inactive in the shipped firmware. The STC chip controls everything via GPIO and internal PWM. There is no serial command set.

The ASCII injection plan was abandoned. The project pivoted to IR protocol reverse engineering.

IR Receiver Tap

With serial ruled out, the IR receiver connector was tapped with monitoring wires, allowing the ESP32 to see the same demodulated NEC signal that the STC chip receives without interrupting normal remote operation. The ATOM S3 on /dev/ttyACM1 was confirmed capable of transmitting arbitrary NEC frames via serial commands, acting as a fully software-controlled remote.

June 24: IR receiver connector (labeled IR remote control / INT1) unplugged from the ComXim board to inspect pinout before adding monitoring wires.
June 24: IR receiver connector (labeled IR remote control / INT1) unplugged from the ComXim board to inspect pinout before adding monitoring wires.
June 24: Three colored tap wires (red, blue, gray) inserted into the IR receiver connector, allowing the ESP32 to monitor the same demodulated NEC signal the STC chip receives.
June 24: Three colored tap wires (red, blue, gray) inserted into the IR receiver connector, allowing the ESP32 to monitor the same demodulated NEC signal the STC chip receives.
June 25, 2026 Encoder Integration & Firmware (Phase 1 Complete)

LPD3806-600BM-G5-24C Encoder

A LPD3806-600BM-G5-24C rotary encoder was mechanically coupled to the turntable ring gear. With 600 PPR and 4× quadrature decode in firmware, this gives 2400 counts per revolution = 0.15° per count. Wiring: RED=Vcc (5V from USB), BLK=GND, GRN=Channel A (GPIO5), WTI=Channel B (GPIO6), GND=shield ground.

June 25: LPD3806-600BM-G5-24C rotary encoder. 600 PPR, quadrature. 4x decode = 2400 counts/rev = 0.15 deg/count.
June 25: LPD3806-600BM-G5-24C rotary encoder. 600 PPR, quadrature. 4x decode = 2400 counts/rev = 0.15 deg/count.
June 25: Encoder wiring label. RED=Vcc, BLK=0V, GRN=A, WTI=B, GND=Shield.
June 25: Encoder wiring label. RED=Vcc, BLK=0V, GRN=A, WTI=B, GND=Shield.
June 25: Encoder with 5 dupont headers crimped, ready for ESP32 GPIO connection.
June 25: Encoder with 5 dupont headers crimped, ready for ESP32 GPIO connection.
June 25: ESP32-S3 dev board with red annotation arrow pointing to pin 14 (labeled 5Vn GND). Investigating whether this pin could supply regulated 5V to the encoder. Encoder was ultimately powered from laptop USB 5V rail.
June 25: ESP32-S3 dev board with red annotation arrow pointing to pin 14 (labeled 5Vn GND). Investigating whether this pin could supply regulated 5V to the encoder. Encoder was ultimately powered from laptop USB 5V rail.

Firmware Architecture (src/main.cpp)

Three parallel tasks run on the ESP32:

Git commit 8c495d5: “Initial commit: Project SLOW-SWEEP Phase 1 complete” — June 25, 2026.

June 26, 2026 Software Development, IR Mapping & Closed-Loop Sweep

NEC Protocol Reverse Engineering

ComXim remote uses NEC protocol, address 0xDF00. 32-bit frame layout:

Bits 31-24: ~command (inverted)
Bits 23-16:  command byte  <-- correct extraction field
Bits 15-8:   address high (0xDF)
Bits  7-0:   address low  (0x00)

Bug: IR Command Always Decoded as 0x00

Initial parser: int(val, 16) & 0xFF — always returned address low byte (0x00). Every command appeared unknown. Fix: (int(val, 16) >> 16) & 0xFF extracts bits 23–16 (the actual command byte).

Known Command Map

CodeFunctionCodeFunction
0x40step0x45continuous
0x41ccw0x0245°
0x42startstop0x07180°
0x43slowdown0x0Aspeedup
0x44cw0x11swing
0x1B90°0x08, 0x09MYSTERY — avoid

ComXim Beep Vocabulary

CountMeaning
1 beepCommand acknowledged — motor started, or direction set
2 beepsMove interrupted — startstop received while motor was mid-move (our stop signal)
0 beepsMove completed naturally (reached end of preset angle)
3 beepsBoot / power-on sequence

Speed Range & Critical Findings

30.0s
Max speed / rev
77.1s
Min speed / rev
25 min
Target (unbridgeable gap)

Critical Finding: Speed Gap Cannot Be Closed via IR

Minimum stock speed (1.28 min/rev) is 15–20× too fast. No IR command can make the ComXim run slower than its firmware floor. Additionally, all step-angle presets scale linearly with motor speed — the same 6° step command produces ~90° at default speed. Native step commands are unusable for precision slow motion.

Backlash: CW→CCW reversal showed a 242.25° deadband. Mitigation: always sweep a full CW rotation then full CCW — never reversing mid-sweep.

Precision test: 180°×2 CW + 180°×2 CCW returned within 1.35° of home mark. Excellent mechanical accuracy.

Closed-Loop Control Strategy

Run continuous mode at minimum speed → watch encoder stream → send stop when encoder count reaches (target − 6 counts overshoot compensation) → wait out remaining interval. This achieves any arbitrarily slow rotation speed by controlling the dwell time between steps.

Bugs Fixed in slow_sweep.py

  1. Step 1 +38° overshoot — Table still coasting from prior test. Fix: wait_stopped() at top of each step.
  2. Hang after step 2read_enc_live() blocked forever if motor never started. Fix: 1-beep confirmation with 3 retries + 60s absolute timeout.
  3. TypeError on callmic added to function signature but call site not updated. Fixed.
  4. Beep threshold too high — 8× ambient unreliable. Lowered to max(ambient × 1.3, 50).
  5. Mic wrong device/mode — Laptop mic unreliable. Switched to USB headset hw:3,0, changed -c 2 to -c 1.

4. IR Scan Results

All measurements at maximum speed (25× speedup). Same commands produce approximately 90° at default speed. Scan run June 26, 15:46–15:55.

NEC CodeAngle MeasuredDirectionDurationNotes
0x01102.75°CCW8.0s
0x0315.0°CCW1.0s
0x047.35°CCW0.0s
0x0510.35°CCW0.0s
0x0628.5°CCW2.0s
0x08noneMYSTERY — beep but no movement. Avoid.
0x09noneMYSTERY — possible remote lock. Avoid.
0x0C168.75°CCW14.0s≈180° preset
0x0D108.6°CCW8.0s≈90° preset
0x1211.55°CCW0.0s
0x156.9°CCW0.0sSmallest confirmed step at MAX speed
0x1881.0°CCW6.0s≈90° preset
0x1916.95°CCW1.0s
0x1A45.3°CCW3.0s≈45° preset
0x1C44.55°CCW4.0s≈45° preset
0x1D44.1°CCW4.0s≈45° preset
0x2045.9°CCW4.0s≈45° preset

5. Full Sweep Performance

Run June 26, 17:51–19:01+. 151 steps, 6° target, 25s interval, CCW, minimum motor speed.

151
Total steps
3.66
Revolutions
141
Normal steps (93.4%)
5.92°
Mean step (target 6.0°)
0.55°
Std deviation
10
Overruns (6.6%)

Overrun Pattern: All 47.9–52.3°

Ten steps overran to approximately 49–52° (not random — nearly identical magnitudes). Most likely cause: IR stop command not received by turntable on those steps; motor ran approximately 10 extra seconds. Recovery was immediate on the next step.

Beep Pattern During Operation (Expected)

Every step: 1 beep then 2 beeps. This is correct by design: 1 beep = "continuous" acknowledged (motor starts); 2 beeps = "startstop" while moving (motor interrupted at target). The 6.6% overrun rate is the motivation for Phase 2 direct motor control.

6. Key Negative Results

FindingImpactStatus
UART4 pads silent — no serial protocol exists in shipped firmware Half-day investigation, ASCII injection plan abandoned entirely Dead end
Stock minimum speed (1.28 min/rev) is 15–20× too fast for target Cannot reach 20–30 min/rev via IR commands alone Worked around (Phase 1)
Step angle scales linearly with motor speed 6° step command produces ~90° at default speed; native steps unusable for slow motion Worked around via continuous+stop
Laptop internal mic (ALC3226) unreliable Beep detection required codec gain fix, still marginal at distance Solved: Plantronics USB headset hw:3,0
Commands 0x08, 0x09: beep but no visible movement Unknown modal function — possible remote lock command Avoided permanently
IR stop reliability: 6.6% failure rate (10/151 steps overran to ~50°) Unacceptable for production use; not random — systematic IR reception failures Root cause: drives Phase 2 L298N plan

7. Source Code

src/main.cpp — ESP32-S3 Firmware

IR decoder (ISR edge capture → NEC decode on GPIO16), quadrature encoder (lookup-table ISR on GPIO5/6), UART sniffer passthrough (GPIO17/18), 1Hz heartbeat. Built with PlatformIO / Arduino framework.

#include <Arduino.h>

// GPIO17/18 avoid the octal-PSRAM pins (35-37) and the native USB-Serial/JTAG
// pins (19/20) reserved on this N16R8 module.
#define COM_RX_PIN 18  // ESP32 RX1 <- ComXim Tx
#define COM_TX_PIN 17  // ESP32 TX1 -> ComXim Rx
#define BAUDRATE 115200

// IR receiver OUT line (gray wire), confirmed via press/release test.
// Tapped through a 10k series resistor.
#define IR_PIN 16

// LPD3806-600BM quadrature encoder: 600 PPR, open-collector NPN outputs.
// Pulled up to 3.3V via external 10k resistors. 4x decode = 2400 counts/rev.
#define ENC_A_PIN 5
#define ENC_B_PIN 6
#define ENC_COUNTS_PER_REV 2400

volatile int32_t encPosition = 0;
volatile uint8_t encPrevState = 0;

// Lookup table: index = (prevAB << 2) | currAB, value = direction step
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 currState = (a << 1) | b;
  encPosition += ENC_TABLE[(encPrevState << 2) | currState];
  encPrevState = currState;
}
#define IR_BUF_SIZE 256

volatile uint32_t irTimes[IR_BUF_SIZE];
volatile uint8_t irLevels[IR_BUF_SIZE];
volatile uint16_t irHead = 0;
volatile uint16_t irTail = 0;

void IRAM_ATTR irISR() {
  uint16_t next = (irHead + 1) % IR_BUF_SIZE;
  if (next != irTail) {
    irTimes[irHead] = micros();
    irLevels[irHead] = digitalRead(IR_PIN);
    irHead = next;
  }
}

void setup() {
  Serial.begin(BAUDRATE);
  while (!Serial) { ; }

  Serial1.begin(BAUDRATE, SERIAL_8N1, COM_RX_PIN, COM_TX_PIN);

  pinMode(IR_PIN, INPUT);
  attachInterrupt(digitalPinToInterrupt(IR_PIN), irISR, CHANGE);

  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);

  Serial.println();
  Serial.println("==============================================");
  Serial.println("PROJECT SLOW-SWEEP: IR DECODE + ENCODER");
  Serial.println("==============================================");
  Serial.printf("IR on GPIO%d, Encoder A=GPIO%d B=GPIO%d\n", IR_PIN, ENC_A_PIN, ENC_B_PIN);
}

#define MAX_EDGES 140
uint32_t edgeTimes[MAX_EDGES];
uint8_t edgeLevels[MAX_EDGES];
int edgeCount = 0;
uint32_t lastEdgeMicros = 0;
bool inFrame = false;

void decodeAndPrint() {
  if (edgeCount < 3) return;

  Serial.print("[IR FRAME] edges=");
  Serial.print(edgeCount);
  Serial.print("  ");
  for (int i = 0; i + 1 < edgeCount && i < 70; i++) {
    uint32_t dur = edgeTimes[i + 1] - edgeTimes[i];
    Serial.print(edgeLevels[i] == 0 ? "M" : "S");
    Serial.print(dur);
    Serial.print(' ');
  }
  Serial.println();

  // Attempt NEC-style decode: leading mark ~9000us, then space ~4500us (new)
  // or ~2250us (repeat), then 32 bits of mark(~562us)+space(562=0 / 1687=1).
  uint32_t mark0 = edgeTimes[1] - edgeTimes[0];
  uint32_t space0 = edgeTimes[2] - edgeTimes[1];

  if (mark0 > 8000 && mark0 < 10000) {
    if (space0 > 1800 && space0 < 2700) {
      Serial.println("[IR] -> NEC REPEAT");
      return;
    }
    if (space0 > 3800 && space0 < 5200) {
      uint32_t value = 0;
      int bits = 0;
      int idx = 2;  // index of bit0's mark-start edge
      while (idx + 2 < edgeCount && bits < 32) {
        uint32_t bitSpace = edgeTimes[idx + 2] - edgeTimes[idx + 1];
        uint8_t bit = (bitSpace > 1000) ? 1 : 0;
        value |= ((uint32_t)bit << bits);
        bits++;
        idx += 2;
      }
      Serial.printf("[IR] -> NEC decoded: bits=%d  value=0x%08lX\n", bits, (unsigned long)value);
      return;
    }
  }
  Serial.println("[IR] -> (raw frame, did not match NEC leader timing)");
}

void loop() {
  // ComXim Mainboard -> Laptop CLI
  if (Serial1.available()) {
    Serial.print("[COMXIM]: ");
    while (Serial1.available()) {
      Serial.print((char)Serial1.read());
    }
  }

  while (irTail != irHead) {
    uint32_t t = irTimes[irTail];
    uint8_t lvl = irLevels[irTail];
    irTail = (irTail + 1) % IR_BUF_SIZE;

    if (!inFrame) {
      edgeCount = 0;
      inFrame = true;
    }
    if (edgeCount < MAX_EDGES) {
      edgeTimes[edgeCount] = t;
      edgeLevels[edgeCount] = lvl;
      edgeCount++;
    }
    lastEdgeMicros = t;
  }

  if (inFrame && (micros() - lastEdgeMicros > 12000)) {
    decodeAndPrint();
    inFrame = false;
    edgeCount = 0;
  }

  // Report encoder position whenever it changes
  static int32_t lastReportedPos = 0;
  int32_t pos = encPosition;
  if (pos != lastReportedPos) {
    float degrees = (float)pos * 360.0f / ENC_COUNTS_PER_REV;
    Serial.printf("[ENC] counts=%ld  deg=%.2f\n", (long)pos, degrees);
    lastReportedPos = pos;
  }

  static uint32_t lastHeartbeat = 0;
  if (millis() - lastHeartbeat > 1000) {
    lastHeartbeat = millis();
    int a = digitalRead(ENC_A_PIN);
    int b = digitalRead(ENC_B_PIN);
    Serial.printf("[ALIVE] millis=%lu  enc=%ld  A=%d B=%d\n", (unsigned long)lastHeartbeat, (long)encPosition, a, b);
  }

  // Laptop CLI -> ComXim Mainboard
  if (Serial.available()) {
    String cmd = Serial.readStringUntil('\n');
    cmd.trim();
    if (cmd.length() > 0) {
      if (!cmd.endsWith(";")) {
        cmd += ";";
      }
      Serial.print("[INJECTING]: ");
      Serial.println(cmd);
      Serial1.print(cmd);
    }
  }
}

monitor.py — Live Serial Monitor

Real-time terminal monitor: decodes and logs encoder position, named IR commands, and beep count events. Mic via ALSA arecord, 20ms chunk RMS analysis.

"""
PROJECT SLOW-SWEEP — Live monitor with beep counting
Watches: encoder position, IR frames, and mic beep patterns simultaneously.
Run: python3 monitor.py
Tail: tail -f monitor.log
"""

import serial, time, subprocess, threading, sys
import numpy as np

LOG = "/home/person/Desktop/Turntable_Hacking/monitor.log"
WROOM_PORT = "/dev/ttyACM0"
MIC_DEVICE = "hw:3,0"
MIC_RATE   = 44100

# ── Logging ──────────────────────────────────────────────────────────────────

def log(msg, tag=""):
    ts = time.strftime("%H:%M:%S.") + f"{int(time.time()*10)%10}"
    prefix = f"[{tag}]" if tag else ""
    line = f"[{ts}] {prefix} {msg}"
    print(line, flush=True)
    with open(LOG, "a") as f:
        f.write(line + "\n")

# ── Beep counter ─────────────────────────────────────────────────────────────

class BeepCounter:
    """
    Continuously records audio and counts distinct beeps.
    A beep = RMS above threshold for >40ms.
    A new beep = gap of >120ms silence between spikes.
    Reports beep count whenever a pattern completes (silence after beeps).
    """
    CHUNK_MS    = 20      # analyze in 20ms windows
    MIN_BEEP_MS = 40      # minimum duration to count as a beep
    MIN_GAP_MS  = 120     # silence gap to separate beeps
    REPORT_MS   = 400     # silence after last beep before reporting

    def __init__(self, threshold=500):
        self.threshold = threshold
        self._stop = threading.Event()
        self._thread = None
        self.ambient = 0.0

    def calibrate(self, seconds=2.0):
        """Measure ambient noise floor."""
        log(f"Calibrating mic for {seconds}s...", "MIC")
        proc = subprocess.Popen(
            ['arecord', '-D', MIC_DEVICE, '-f', 'S16_LE',
             '-r', str(MIC_RATE), '-c', '1', '-d', str(int(seconds)+1)],
            stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
        data = proc.stdout.read()
        proc.kill()
        if data:
            samples = np.frombuffer(data, dtype=np.int16).astype(np.float32)
            self.ambient = float(np.sqrt(np.mean(samples**2)))
        self.threshold = max(self.ambient * 1.3, 50)
        log(f"Ambient RMS={self.ambient:.1f}  Beep threshold={self.threshold:.1f}", "MIC")

    def start(self):
        self._stop.clear()
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()

    def stop(self):
        self._stop.set()

    def _run(self):
        chunk_samples = int(MIC_RATE * self.CHUNK_MS / 1000)  # mono
        chunk_bytes = chunk_samples * 2  # 16-bit

        proc = subprocess.Popen(
            ['arecord', '-D', MIC_DEVICE, '-f', 'S16_LE',
             '-r', str(MIC_RATE), '-c', '1'],
            stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)

        beep_count = 0
        in_beep = False
        beep_ms = 0
        gap_ms = 0

        while not self._stop.is_set():
            chunk = proc.stdout.read(chunk_bytes)
            if not chunk:
                break
            samples = np.frombuffer(chunk, dtype=np.int16).astype(np.float32)
            rms = float(np.sqrt(np.mean(samples**2)))
            loud = rms > self.threshold

            if loud:
                if not in_beep:
                    in_beep = True
                    beep_ms = 0
                    gap_ms = 0
                else:
                    beep_ms += self.CHUNK_MS
                    gap_ms = 0
            else:
                if in_beep:
                    gap_ms += self.CHUNK_MS
                    if gap_ms >= self.MIN_GAP_MS:
                        # Beep ended
                        if beep_ms >= self.MIN_BEEP_MS:
                            beep_count += 1
                        in_beep = False
                        beep_ms = 0
                else:
                    # In silence
                    if beep_count > 0:
                        gap_ms += self.CHUNK_MS
                        if gap_ms >= self.REPORT_MS:
                            log(f"BEEP x{beep_count}", "MIC")
                            beep_count = 0
                            gap_ms = 0

        proc.kill()
        if beep_count > 0:
            log(f"BEEP x{beep_count} (final)", "MIC")

# ── Serial monitor ────────────────────────────────────────────────────────────

def serial_monitor(wroom, enc_ref):
    """Read WROOM serial and log IR frames, encoder changes, and alive ticks."""
    last_enc = None
    last_ir_time = 0

    while True:
        try:
            line = wroom.readline().decode(errors="replace").strip()
        except:
            break
        if not line:
            continue

        if "[ENC]" in line:
            # Only log every 10 counts to avoid spam, but always log direction changes
            try:
                counts = int(line.split("counts=")[1].split()[0])
                deg_abs = round((counts - enc_ref) * 0.15, 1)
                if last_enc is None or abs(counts - last_enc) >= 10:
                    log(f"pos={counts} ({deg_abs:+.1f}deg from home)", "ENC")
                    last_enc = counts
            except:
                pass

        elif "[IR]" in line and "NEC decoded" in line:
            try:
                val = line.split("value=")[1].strip()
                cmd = (int(val, 16) >> 16) & 0xFF
                known = {
                    0x40:"step", 0x41:"ccw", 0x42:"startstop",
                    0x43:"slowdown", 0x44:"cw", 0x45:"continuous",
                    0x02:"45deg", 0x07:"180deg", 0x0A:"speedup",
                    0x11:"swing", 0x1B:"90deg",
                }
                name = known.get(cmd, f"0x{cmd:02X}")
                now = time.time()
                if now - last_ir_time > 0.5:  # debounce repeats
                    log(f"cmd={name} (0x{cmd:02X})", "IR")
                    last_ir_time = now
            except:
                pass

        elif "[ALIVE]" in line:
            try:
                enc = int(line.split("enc=")[1].split()[0])
                deg_abs = round((enc - enc_ref) * 0.15, 1)
                log(f"heartbeat enc={enc} ({deg_abs:+.1f}deg from home)", "TICK")
                if last_enc is None:
                    last_enc = enc
            except:
                pass

# ── Main ──────────────────────────────────────────────────────────────────────

log("=== MONITOR START ===")
log("Tail this log: tail -f /home/person/Desktop/Turntable_Hacking/monitor.log")

wroom = serial.Serial(WROOM_PORT, 115200, timeout=1)
time.sleep(2)
wroom.reset_input_buffer()

# Get initial encoder position as home reference
log("Reading home position...", "SETUP")
enc_home = None
deadline = time.time() + 5
while time.time() < deadline:
    line = wroom.readline().decode(errors="replace").strip()
    if "[ALIVE]" in line and "enc=" in line:
        try:
            enc_home = int(line.split("enc=")[1].split()[0])
            break
        except:
            pass
if enc_home is None:
    enc_home = 0
log(f"Home encoder position = {enc_home}", "SETUP")

# Calibrate mic
mic = BeepCounter()
mic.calibrate(seconds=2.0)
mic.start()

log("", "")
log("=== READY — start your test sequence ===")
log("SEQUENCE: reboot → mark home → 180deg x2 CW → switch CCW → 180deg x2 CCW")
log("")

# Run serial monitor (blocks until serial dies)
try:
    serial_monitor(wroom, enc_home)
except KeyboardInterrupt:
    pass
finally:
    mic.stop()
    log("=== MONITOR STOPPED ===")

slow_sweep.py — Closed-Loop Sweep Controller

Runs continuous rotation at minimum stock speed, watches encoder stream, sends stop at target angle, waits out the interval. Runs indefinitely until Ctrl+C. Beep confirmation with 3 retries and 60s absolute timeout guard.

"""
PROJECT SLOW-SWEEP — Closed-loop encoder-controlled rotation.

Runs the turntable in continuous mode at min speed, watches the encoder,
and sends STOP at exactly the target angle. Then waits out the remainder
of the step interval. Achieves ultra-slow rotation (20-30 min/rev) with
encoder-verified precision.

Physics:
  Min speed = 77s/rev = 2400 counts/rev -> 32ms/count
  IR stop latency ~200ms -> ~6 count overshoot at min speed
  We target (desired_counts - OVERSHOOT_COUNTS) to land on the mark.

Usage:
  python3 slow_sweep.py [--minutes-per-rev 25] [--direction ccw]
                        [--step-deg 6.0] [--revolutions N]
"""

import serial, time, threading, subprocess, argparse
import numpy as np

WROOM_PORT       = "/dev/ttyACM0"
ATOM_PORT        = "/dev/ttyACM1"
MIC_DEVICE       = "hw:3,0"
MIC_RATE         = 44100
LOG              = "/home/person/Desktop/Turntable_Hacking/slow_sweep.log"

COUNTS_PER_DEG   = 2400 / 360.0        # 6.667 counts/deg
OVERSHOOT_COUNTS = 6                    # ~200ms IR latency at min speed

# ── Logging ───────────────────────────────────────────────────────────────────

def log(msg):
    ts = time.strftime("%H:%M:%S")
    line = f"[{ts}] {msg}"
    print(line, flush=True)
    with open(LOG, "a") as f:
        f.write(line + "\n")

# ── Beep listener ─────────────────────────────────────────────────────────────

class BeepListener:
    CHUNK_MS    = 20
    MIN_BEEP_MS = 40
    MIN_GAP_MS  = 120
    REPORT_MS   = 400

    def __init__(self):
        self._lock   = threading.Lock()
        self._event  = threading.Event()
        self._counts = []
        self.threshold = 50
        self._stop  = threading.Event()

    def calibrate(self):
        proc = subprocess.Popen(
            ['arecord', '-D', MIC_DEVICE, '-f', 'S16_LE',
             '-r', str(MIC_RATE), '-c', '1', '-d', '2'],
            stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
        data = proc.stdout.read()
        proc.kill()
        if data:
            s = np.frombuffer(data, dtype=np.int16).astype(np.float32)
            ambient = float(np.sqrt(np.mean(s**2)))
        else:
            ambient = 0.0
        self.threshold = max(ambient * 1.3, 50)
        log(f"MIC calibrated: ambient={ambient:.1f}  threshold={self.threshold:.1f}")

    def start(self):
        self._stop.clear()
        threading.Thread(target=self._run, daemon=True).start()

    def stop(self):
        self._stop.set()

    def wait_beep(self, timeout=3.0):
        self._event.clear()
        if self._event.wait(timeout):
            with self._lock:
                return self._counts.pop(0) if self._counts else 1
        return 0

    def _run(self):
        chunk_n    = int(MIC_RATE * self.CHUNK_MS / 1000)
        chunk_bytes = chunk_n * 2
        proc = subprocess.Popen(
            ['arecord', '-D', MIC_DEVICE, '-f', 'S16_LE',
             '-r', str(MIC_RATE), '-c', '1'],
            stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)

        beep_count = 0
        in_beep    = False
        beep_ms    = 0
        gap_ms     = 0

        while not self._stop.is_set():
            chunk = proc.stdout.read(chunk_bytes)
            if not chunk:
                break
            rms  = float(np.sqrt(np.mean(
                np.frombuffer(chunk, dtype=np.int16).astype(np.float32)**2)))
            loud = rms > self.threshold

            if loud:
                if not in_beep:
                    in_beep = True; beep_ms = 0; gap_ms = 0
                else:
                    beep_ms += self.CHUNK_MS; gap_ms = 0
            else:
                if in_beep:
                    gap_ms += self.CHUNK_MS
                    if gap_ms >= self.MIN_GAP_MS:
                        if beep_ms >= self.MIN_BEEP_MS:
                            beep_count += 1
                        in_beep = False; beep_ms = 0
                else:
                    if beep_count > 0:
                        gap_ms += self.CHUNK_MS
                        if gap_ms >= self.REPORT_MS:
                            with self._lock:
                                self._counts.append(beep_count)
                            self._event.set()
                            beep_count = 0; gap_ms = 0
        proc.kill()

# ── Serial helpers ────────────────────────────────────────────────────────────

def send_ir(atom, cmd, delay=0.15):
    atom.write(f"{cmd}\n".encode())
    time.sleep(delay)

def read_enc_alive(wroom, timeout=2.0):
    """Read next ALIVE heartbeat and return encoder count."""
    deadline = time.time() + timeout
    while time.time() < deadline:
        line = wroom.readline().decode(errors="replace").strip()
        if "[ALIVE]" in line and "enc=" in line:
            try:
                return int(line.split("enc=")[1].split()[0])
            except:
                pass
    return None

def read_enc_live(wroom):
    """Read next [ENC] line (fires every encoder count change during motion)."""
    while True:
        line = wroom.readline().decode(errors="replace").strip()
        if "[ENC]" in line and "counts=" in line:
            try:
                return int(line.split("counts=")[1].split()[0])
            except:
                pass

def wait_stopped(wroom, settle_s=1.5, timeout=10.0):
    """Wait until encoder is stable for settle_s seconds."""
    last_enc  = None
    last_move = time.time()
    deadline  = time.time() + timeout
    while time.time() < deadline:
        line = wroom.readline().decode(errors="replace").strip()
        if "[ALIVE]" not in line or "enc=" not in line:
            continue
        try:
            enc = int(line.split("enc=")[1].split()[0])
        except:
            continue
        if last_enc is not None and enc != last_enc:
            last_move = time.time()
        last_enc = enc
        if time.time() - last_move >= settle_s:
            break
    return last_enc

# ── Closed-loop step ──────────────────────────────────────────────────────────

def closed_loop_step(wroom, atom, mic, target_deg, direction_sign, interval_s):
    """
    Run continuous rotation, stop when encoder hits target_deg.
    direction_sign: +1 for CCW (encoder increases), -1 for CW (encoder decreases).
    Returns (actual_deg, overshoot_deg).
    """
    target_counts = int(target_deg * COUNTS_PER_DEG)
    stop_at       = target_counts - OVERSHOOT_COUNTS  # fire stop early

    cycle_start = time.time()

    # Ensure table is fully stopped before starting
    wait_stopped(wroom, settle_s=1.0, timeout=5.0)

    # Flush stale serial data, note start position
    wroom.reset_input_buffer()
    enc_start = read_enc_alive(wroom, timeout=2.0)
    if enc_start is None:
        log("WARNING: could not read start encoder position")
        enc_start = 0

    # Start continuous rotation — retry until 1-beep confirms table started
    started = False
    for attempt in range(3):
        send_ir(atom, "continuous", delay=0.1)
        b = mic.wait_beep(timeout=1.5)
        if b == 1:
            started = True
            break
        # No beep — table may already be in continuous mode, try startstop to reset then retry
        send_ir(atom, "startstop", delay=0.1)
        mic.wait_beep(timeout=1.0)
        time.sleep(0.3)
    if not started:
        return 0.0, 0.0, enc_start  # skip this step, report zero move

    # Watch encoder until stop_at counts reached (with timeout guard)
    deadline = time.time() + 60.0
    while time.time() < deadline:
        enc = read_enc_live(wroom)
        delta = (enc - enc_start) * direction_sign
        if delta >= stop_at:
            send_ir(atom, "startstop", delay=0.0)
            break
    else:
        send_ir(atom, "startstop", delay=0.0)  # safety stop on timeout

    # Let it fully stop
    enc_final = wait_stopped(wroom, settle_s=1.5, timeout=10.0) or enc_start
    actual_counts = abs(enc_final - enc_start)
    actual_deg    = actual_counts / COUNTS_PER_DEG
    overshoot_deg = actual_deg - target_deg

    # Wait out the rest of the interval
    elapsed        = time.time() - cycle_start
    wait_remaining = max(0.0, interval_s - elapsed)
    if wait_remaining > 0:
        time.sleep(wait_remaining)

    return actual_deg, overshoot_deg, enc_final

# ── Main ──────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="SLOW-SWEEP closed-loop pulse")
    parser.add_argument("--minutes-per-rev", type=float, default=25.0)
    parser.add_argument("--direction", choices=["cw", "ccw"], default="ccw")
    parser.add_argument("--step-deg", type=float, default=6.0,
                        help="Degrees per step (default 6.0)")
    parser.add_argument("--revolutions", type=float, default=0,
                        help="Stop after N revolutions (0 = run forever)")
    args = parser.parse_args()

    steps_per_rev  = 360.0 / args.step_deg
    interval_s     = (args.minutes_per_rev * 60.0) / steps_per_rev
    direction_sign = +1 if args.direction == "ccw" else -1
    target_counts  = int(args.step_deg * COUNTS_PER_DEG)

    log("=" * 55)
    log("SLOW-SWEEP (closed-loop encoder control)")
    log(f"  direction      : {args.direction}")
    log(f"  step size      : {args.step_deg}° ({target_counts} counts)")
    log(f"  overshoot comp : {OVERSHOOT_COUNTS} counts")
    log(f"  steps/rev      : {steps_per_rev:.1f}")
    log(f"  interval       : {interval_s:.1f}s/step")
    log(f"  speed target   : {args.minutes_per_rev:.1f} min/rev")
    if args.revolutions:
        log(f"  target         : {args.revolutions} rev ({int(args.revolutions*steps_per_rev)} steps)")
    log("=" * 55)

    wroom = serial.Serial(WROOM_PORT, 115200, timeout=1)
    atom  = serial.Serial(ATOM_PORT,  115200, timeout=1)
    time.sleep(1)
    wroom.reset_input_buffer()

    mic = BeepListener()
    mic.calibrate()
    mic.start()

    enc_home = read_enc_alive(wroom) or 0
    log(f"Home encoder: {enc_home}")

    # Set min speed (25x slowdown -> 77s/rev = slowest)
    log("Setting min speed (25x slowdown)...")
    for _ in range(25):
        send_ir(atom, "0x43", delay=0.05)
    time.sleep(0.5)

    # Set direction
    dir_cmd = args.direction
    send_ir(atom, dir_cmd)
    b = mic.wait_beep(timeout=2.0)
    log(f"Direction set to {args.direction} (beep={b})")
    time.sleep(0.5)

    max_steps  = int(args.revolutions * steps_per_rev) if args.revolutions else 0
    step_count = 0
    total_deg  = 0.0
    enc_pos    = enc_home

    log("Starting sweep — Ctrl+C to stop")
    log(f"{'step':>5}  {'moved':>7}  {'overshoot':>9}  {'total':>10}  {'pos':>8}")

    try:
        while True:
            actual_deg, overshoot_deg, enc_pos = closed_loop_step(
                wroom, atom, mic, args.step_deg, direction_sign, interval_s)

            step_count += 1
            total_deg  += actual_deg
            total_rev   = total_deg / 360.0
            offset_deg  = (enc_pos - enc_home) * 0.15

            log(f"{step_count:>5}  {actual_deg:>6.1f}°  {overshoot_deg:>+8.1f}°  "
                f"{total_deg:>7.0f}° ({total_rev:.2f}rev)  {offset_deg:>+7.1f}°")

            if actual_deg > args.step_deg * 2:
                log(f"WARNING: step {step_count} overran {actual_deg:.1f}° (target {args.step_deg}°) — IR stop not received?")

            if max_steps and step_count >= max_steps:
                log(f"Target reached: {args.revolutions} rev in {step_count} steps")
                break

    except KeyboardInterrupt:
        log("")
        log(f"Stopped after {step_count} steps")
        log(f"Total: {total_deg:.1f}° = {total_deg/360:.2f} rev")

    finally:
        mic.stop()
        atom.close()
        wroom.close()

if __name__ == "__main__":
    main()

8. Phase 1.5 — UART Exploration (June 29)

With the Phase 1 sweep complete, a second investigation day was spent attempting to extract telemetry or firmware access from the ComXim mainboard via its UART headers before committing to the full L298N bypass. Four UART ports were tested, and the STC chip package was verified against the official datasheet.

STC 8H8K64U — Verified Datasheet Specifications

Package confirmed as LQFP32 (32-pin), not the 64-pin variant referenced by some distributor listings. Sources: stcmicro.com product page, features PDF, full reference manual.

  • Flash: 64KB   SRAM: 8.2KB   Operating voltage: 1.9–5.5V
  • UARTs: 4 confirmed — max baud = FOSC/4
  • UART1 P3.0/P3.1 — ISP/bootloader port (only UART used by stcgal)
  • UART3 default pins: P0.0 (RXD3) / P0.1 (TXD3)
  • UART4 default pins: P0.2 (RXD4) / P0.3 (TXD4)
UART3 header annotated
UART3 header with VCC/GND annotated in red. Square via (rightmost in this orientation) = VCC 3.3V. Leftmost = GND. PCB traces from the two middle pins run through protection resistors (R29/R30 bank) directly to the STC LQFP32 chip. VCC confirmed at 3.3V — safe for direct ESP32 GPIO connection with no level shifting.
All UART headers annotated
All UART header locations on the ComXim board. UART3 (3, left, next to STC chip) — factory-populated, tested. UART2 (2, upper right, labeled RxD2/TxD2) — tested. UART4 (bottom right, Pitch2.54) — populated for test, silent. HC-08 Bluetooth header (bottom center, labeled Tx/Rx/GC/DC) — discovered mid-session; initial UART testing sessions were mistakenly performed on this header.

UART Test Results

All ports tested at 115200 baud via GPIO18 on the WROOM (Serial1 RX). VCC left floating. GND shared. Board TX only — no data injected. ComXim power-cycled at the start of each capture.

PortSignal FoundDetailVerdict
UART2 ### @ 1Hz 0x23 0x23 0x23 — fixed 3-byte heartbeat, 1Hz. Unchanged across all motor states. Dead end
UART3 ### @ 1Hz Identical output to UART2. Same firmware signal routed to both ports. Dead end
UART4 Silent Both middle pins tried. No output at any point. Unused by firmware. Unused
UART1 Untested ISP/bootloader on P3.0/P3.1. Header not located on board. Would require stcgal + power-cycle timing. Future option

The ### Heartbeat

The three-byte sequence 0x23 0x23 0x23 (“###”) appears on both UART2 and UART3 at exactly 1Hz. It did not change in response to: speed changes, direction changes, start/stop commands, or motor movement. It is a fixed firmware keepalive with no encoded state — not a useful telemetry source.

Additional IR Codes Captured

IR commands sent during UART testing captured three NEC codes not previously documented:

FunctionFull NEC ValueCommand Byte
CCW Direction0xBE41DF000x41
CW Direction0xBB44DF000x44
Stop / Start0xBD42DF000x42

Motor Drive Hardware Assessment

New photos of the stepper wiring and underside of the ComXim board provided strong confirmation of the internal motor driver architecture.

Motor connector and heatsink
Motor connector above the heatsink. All four wire colors (White, Red, Black, Yellow) exit from a single 4-pin connector into the motor driver section. Both 50BYJ46-20 motors share this connector, indicating the ComXim drives them in parallel from a single H-bridge output.
Side view of heatsink, capacitor and chip
Side view showing: 35V/470µF JerCap bulk capacitor (motor supply smoothing), the large aluminum heatsink, and a visible R500 marking (0.5Ω current-sense resistor — classic L298N topology). The package under the heatsink is a single large power IC requiring forced cooling for continuous operation at this current level.
PCB underside showing thermal pad and motor wire solder points
PCB underside. The large square exposed thermal pad (annotated HS) is the bottom of the POWERSO-20 package — this is the definitive signature of an L298N in POWERSO-20. Four solder points on the right edge are annotated Y/R/B/W (Yellow, Red, Black, White) — the four motor wire outputs.

Confirmed: Single L298N (POWERSO-20) Driving Both Motors in Parallel

Evidence: R500 current-sense resistor, 35V/470µF bulk cap, large POWERSO-20 thermal pad on PCB underside, single 4-wire motor connector carrying all W/R/B/Y leads. The ComXim routes both 50BYJ46-20 motors coil-in-parallel through one L298N — hence the requirement for the large heatsink under continuous operation.

Phase 2 Decision: Single L298N, Both Motors in Parallel

Both 50BYJ46-20 motors share the same internal gear ring — they are mechanically locked and must always move together. Running them from separate drivers would risk stripping the gear profile if any timing difference existed between boards. The correct architecture drives both motors from a single L298N with coils wired in parallel — exactly what the ComXim already does. This simplifies wiring to just 4 ESP32 GPIO pins and is proven to work at this current and voltage.

Current Note

At 15V with ~15Ω coil resistance, each coil draws ~1A. Two motors in parallel doubles this to ~2A per H-bridge output — right at the L298N continuous rating. At slow sweep speeds with discrete steps rather than continuous rotation, average current is well below peak. Monitor the L298N board temperature during first test runs. The HiLetgo board has a small integral heatsink; the ComXim required a large external one for continuous operation.

9. Phase 2 Plan — L298N Direct Motor Drive

Rationale

Phase 1 proved the encoder position feedback works reliably. The weak link is the IR channel: 6.6% step failure rate, limited to the ComXim firmware speed floor, and dependent on the ATOM S3 as auxiliary hardware. Phase 2 eliminates the ComXim mainboard and drives the 50BYJ46-20 bipolar steppers directly from the ESP32-S3 via an L298N H-bridge board.

Why Single L298N + Parallel Coils Works

A bipolar stepper requires two H-bridges (one per winding). One L298N = two H-bridges = one complete bipolar stepper drive. The ComXim drove both motors coil-in-parallel from a single L298N (confirmed by PCB underside thermal pad analysis). The replacement build mirrors this exactly: one L298N board, both motors in parallel, 4 ESP32 GPIO pins. Motor wire colors: White, Red, Black, Yellow. Coil pairs identified by multimeter before wiring.

Wiring Plan

ESP32-S3 Single L298N Board Motors (both in parallel) -------- ------------------ ------------------------- GPIO1 ──────────── IN1 OUT1 ────┬────────── Motor 1 Coil A+ └────────── Motor 2 Coil A+ GPIO2 ──────────── IN2 OUT2 ────┬────────── Motor 1 Coil A- └────────── Motor 2 Coil A- GPIO3 ──────────── IN3 OUT3 ────┬────────── Motor 1 Coil B+ └────────── Motor 2 Coil B+ GPIO4 ──────────── IN4 OUT4 ────┬────────── Motor 1 Coil B- └────────── Motor 2 Coil B- GND ─────────────── GND 15V ─────────────── VS (motor supply) ENA jumper ──────── Tied HIGH (bridge A always enabled) ENB jumper ──────── Tied HIGH (bridge B always enabled) Avoid: GPIO0=boot, GPIO5/6=encoder, GPIO16=IR, GPIO17/18=UART, GPIO19/20=USB, GPIO35-37=PSRAM

Bipolar Motor Coil Identification — Multimeter Readings

Set multimeter to resistance (Ω) mode. With motors disconnected from the ComXim, probe all 6 wire-pair combinations on each motor. Exactly 2 pairs will show a resistance value (the two coils). The other 4 pairs will read OL (open circuit — no connection). Expected coil resistance for the 50BYJ46-20 at 15V: roughly 10–30Ω. Both motors should give identical results (same model).

Motor 1

# Wire A Wire B Reading Coil?
1WhiteRed_____ Ω
2WhiteBlack_____ Ω
3WhiteYellow_____ Ω
4RedBlack_____ Ω
5RedYellow_____ Ω
6BlackYellow_____ Ω

Motor 2

# Wire A Wire B Reading Coil?
1WhiteRed_____ Ω
2WhiteBlack_____ Ω
3WhiteYellow_____ Ω
4RedBlack_____ Ω
5RedYellow_____ Ω
6BlackYellow_____ Ω

Interpreting the Readings

  • The 2 pairs that show resistance = Coil A and Coil B. The other 4 pairs = OL (open, different coil or no connection).
  • Label them: the resistance pair is A+ (one wire) and A− (the other). Same for B. Polarity determines step direction — if the motor runs backwards, swap A+ and A− in the wiring, or invert the step sequence in firmware.
  • Both motors MUST be wired identically (same color → same OUT pin) because the gear ring locks them mechanically. There is no independent operation.
  • Example: White–Red = 18Ω, Black–Yellow = 18Ω, all others OL → Coil A = White/Red, Coil B = Black/Yellow. Wire White & White to OUT1, Red & Red to OUT2, Black & Black to OUT3, Yellow & Yellow to OUT4.

Bipolar Full-Step Sequence

Step  IN1  IN2  IN3  IN4    Energized coils
  1    H    L    H    L      A+ and B+
  2    L    H    H    L      A- and B+
  3    L    H    L    H      A- and B-
  4    H    L    L    H      A+ and B-

Advance forward  = CW rotation
Advance backward = CCW rotation
Speed            = microsecond delay between steps

What Is Retired

What Is Kept

Tuesday Action Items

  1. Identify coil pairs — Before disconnecting ComXim, use the fill-in table above (resistance mode, all 6 color combinations on each motor). Two pairs ~10–30Ω = Coil A and Coil B. Label with tape. Both motors must match.
  2. Wire single L298N board — Both motors in parallel: Coil A(+) of both → OUT1, Coil A(−) of both → OUT2, Coil B(+) of both → OUT3, Coil B(−) of both → OUT4. 15V to VS. GND shared. ENA/ENB jumpers ON.
  3. Wire ESP32 GPIOs — 4 wires only: GPIO1 → IN1, GPIO2 → IN2, GPIO3 → IN3, GPIO4 → IN4. ATOM S3 not needed — leave unplugged.
  4. Add step sequencer firmware — Update src/main.cpp with serial command handler: STEP <n> <dir> advances both motors in sync through the 4-step full-step sequence. Encoder ISR unchanged.
  5. Characterize the system — Run a known step count, read encoder delta, compute actual total gear reduction (motor 1:33 × ring gear ratio).
  6. Write slow_sweep_v2.py — Replace IR send_ir() calls with serial STEP commands. No beep listener needed. Same encoder closed-loop logic.

Expected Outcome

Direct firmware step control gives deterministic, lossless rotation at any speed. The 6.6% IR overrun rate drops to 0%. Any speed from milliseconds-per-step to seconds-per-step is achievable — the 25 min/rev target is trivial. The encoder provides continuous sanity checking that no steps were skipped under load.

10. Phase 2 Results — Thermal Testing & Calibration (June 30)

Full-Step Baseline (Test 1)

Initial wiring complete. Firmware flashed with full-step 4-state table (7200 steps/rev). A sustained rotation test immediately revealed thermal problems: the L298N surface temperature reached 150°F+ and the driver triggered its internal thermal shutdown multiple times, causing audible stall-and-restart pauses mid-rotation. The turntable did not complete a full revolution.

Root Cause: Voltage-Mode Drive at Full Load

Two motors in parallel = ~9Ω effective coil resistance per channel. At 15V: I ≈ 1.67A per channel. With both channels energised simultaneously (full-step), the L298N dissipates roughly 6W continuously (Vce_sat × I × 2 channels). The L298N POWERSO-20 with its stock heatsink cannot sustain this indefinitely — junction temperature exceeds the 150°C thermal cutoff. The ComXim board used the R500 sense resistors for current-mode chopping, which both limits current and enables microstepping — explaining why it ran cooler and smoother.

Half-Step Upgrade (Test 2)

Firmware updated to an 8-state half-step sequence. The single-coil intermediate states draw half the current (power = I²R, so ¼ the heat per state), reducing average dissipation by ~35%. Peak temperature dropped to 118–119°F on a continuous run. Motion was noticeably smoother and quieter than full-step. Still warm enough to be a concern for sustained runs.

Half-Step + Burst-Pause (Test 3 — Final)

Implemented thermal duty-cycle management in software: step 5° quickly (200 steps at 5ms/step ≈ 1 second), then release all coils for 3 seconds, repeat. With coils de-energised the L298N dissipates zero power and cools rapidly. Result: temperature never exceeded 95°F across the full 3× CW/CCW endurance run.

Calibration Results

Parameter Full-Step Half-Step
Steps / revolution7,20014,400
Degrees / step0.050°0.025°
Steps / degree2040
Encoder counts / rev2,4002,400 (unchanged)
Angular accuracy (10° test)±0.25°±0.25°
Peak L298N temp (sustained)150°F+ (thermal shutdown)95°F (burst-pause)

Endurance Test — 3× CW/CCW (30 min, June 30)

6 passes × ~5 min each. Burst-pause profile: 5° burst then 3s cooldown, 72 bursts per revolution. Encoder logged at end of each pass:

Pass Direction Enc Counts Degrees Pass Time
2CCW−8−1.2°04:56
3CW−2,403−360.45°04:56
4CCW−8−1.2°04:56
5CW−2,404−360.60°04:56
6CCW−7−1.05°04:56

Every pass completed in exactly 04:56. CW passes land within 0.6° of −360°; CCW returns within 1.2° of 0 — consistent gear-train backlash on direction reversal, not missed steps. Zero thermal cutouts.

11. Test Videos (June 30)

Three progressive tests recorded as the drive strategy evolved from full-step to half-step burst-pause.

Test 1 — Full-Step, Continuous

First live run. L298N reached 150°F+, thermal cutout triggered multiple times causing audible pauses. Rotation did not complete. Demonstrated need for either thermal management or drive-mode change.

Test 2 — Half-Step, Continuous

Firmware upgraded to 8-state half-step (14,400 steps/rev). Single-coil intermediate states reduced average current. Temperature dropped to ~119°F. Motion smoother and quieter. Still warm enough to trigger concern for sustained runs.

Test 3 — Half-Step + Burst-Pause (Final)

Software thermal management added: 5° burst at 5ms/step (~1s moving), then coils released for 3s. L298N cools to ambient during dwell. 3× CW/CCW endurance run completed — temperature never exceeded 95°F. Encoder confirmed <0.5° accuracy every pass.

12. Phase 3 Goals — July 1

Morning: 30-Minute Endurance Validation

Run a full 30-minute sustained CW/CCW test (6 passes × 5 min) with burst-pause to confirm thermal stability under extended load. Log peak temperature and encoder drift across all passes. This is the gate before MVP wiring cleanup.

WiFi AP + Web Dashboard

The ESP32-S3 serves its own access point — no external router needed. A simple page (AsyncWebServer + WebSocket) gives live readouts and controls from any phone or laptop on the bench:

Temperature Sensor Note

The ESP32-S3 has no built-in temperature sensor accessible for external use. To get heatsink temperature in the dashboard, add a DS18B20 (1-wire digital, ±0.5°C accuracy) or an NTC thermistor (analog, free if salvaged) to a spare GPIO. DS18B20 is preferred — digital, no ADC calibration needed, readable in firmware with a single library call.

Customer Output: Position Voltage / RS-232 Stream

The customer wants a voltage signal proportional to turntable rotation. Options in order of simplicity:

Option Hardware Needed Notes
RS-232 / serial streamNone (already have USB serial)Simplest. Output CSV “angle,counts\n” at 10Hz. Customer reads with any terminal or data logger. Add MAX232 chip for true RS-232 levels if needed.
PWM → RC filter1 resistor + 1 cap (~$0.10)Firmware outputs PWM duty cycle proportional to angle (0° = 0%, 360° = 100%). RC filter smooths to 0–3.3V DC. Cheap but ripple exists.
MCP4725 I²C DACMCP4725 breakout (~$2)12-bit DAC, 0–3.3V clean analog output. 2 wires (SDA/SCL). Professional quality. Discuss with customer what voltage range and scaling they need.

Recommendation: start the customer conversation with the serial stream (zero cost, immediate) and confirm whether they need a true analog voltage, then choose DAC vs PWM+RC based on their noise tolerance.

Wiring Cleanup → MVP++

Driver Upgrade Path (Future)

If continuous-rotation smooth sweeps are needed without burst-pause, replace the L298N with two DRV8825 or TMC2208 breakout boards (one per motor, ~$5–$12 each). These use current-chopping microstepping (up to 1/32 step), run cool at full speed, and require only STEP+DIR signals from the ESP32 — two GPIO pins total instead of four. TMC2208 adds stealthChop for near-silent operation. Wire both boards to the same STEP/DIR lines for guaranteed lockstep.

MVP++ Definition

Turntable rotates at any programmable rate, direction-reversible, with encoder position verification. Web dashboard gives real-time control from a phone with no laptop required. Customer position output available over serial (and optionally as an analog voltage). Wiring clean enough to close the chassis. Thermal management confirmed stable for >30 min.