# 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

Component | Part | Key Specifications
--- | --- | ---
Turntable | ComXim 15.8″ Heavy Duty | Mainboard: MTxM_V6.01, dated 2025.02.28. Website: comxim.com
Turntable MCU | STC 8H8K64U | 8051-core, LQFP-32, 64KB flash, closed firmware. Handles IR decode, motor sequencing, speed ramping, buzzer.
Drive motors | 2× 50BYJ46-20 | 15V bipolar stepper, 1:33 internal planetary gear ratio. Both motors drive a common ring gear in sync.
Controller | ESP32-S3-WROOM-1 N16R8 | 16MB flash, 8MB PSRAM, dual USB-C. /dev/ttyACM0. IR on GPIO16, encoder A/B on GPIO5/6, UART sniffer GPIO17/18.
IR Transmitter | ATOM S3 (M5Stack) | Accepts NEC codes over serial, transmits as 38kHz IR. /dev/ttyACM1. Software-controlled remote substitute.
Encoder | LPD3806-600BM-G5-24C | 600 PPR quadrature. 4× decode = 2400 counts/rev = 0.15°/count. 5–24V supply.
Microphone | Plantronics Blackwire 5220 | USB headset, ALSA hw:3,0, mono, 44100Hz. Used for beep detection to confirm IR command receipt.
Motor driver (Phase 2) | HiLetgo L298N ×4 pack | Dual 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: 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: 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:

- STC 8H8K64U — 8051-core MCU, LQFP-32. All turntable logic runs here: IR decoding, motor sequencing, speed ramping, direction control, buzzer patterns.
- Motor driver — Under a large aluminum heatsink on the right third of the board. Likely an L298N or equivalent.
- UART4 test pads — Labeled Vc, Gd, Rx, Tx. These were the first investigation target.
- Buzzer — Round piezo cylinder, top-left. Produces command-acknowledgment beeps.
- IR receiver connector — Labeled IR进控 / INT1. Three-pin JST on the left side.

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: 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: 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: 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: 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: 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: 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:

- IR Decoder (GPIO16): ISR captures every edge timing. After 12ms silence, decodes NEC 32-bit frame. Output: `[IR] -> NEC decoded: bits=32 value=0x????????`
- Quadrature Encoder (GPIO5/6): ISR-driven lookup table decoder, fires every state change. Output: `[ENC] counts=NNNN deg=NNN.NN`
- Heartbeat: Output: `[ALIVE] millis=NNN enc=NNN A=N B=N` once per second

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

Code | Function | Code | Function
--- | --- | --- | ---
`0x40` | step | `0x45` | continuous
`0x41` | ccw | `0x02` | 45°
`0x42` | startstop | `0x07` | 180°
`0x43` | slowdown | `0x0A` | speedup
`0x44` | cw | `0x11` | swing
`0x1B` | 90° | `0x08, 0x09` | MYSTERY — avoid

### ComXim Beep Vocabulary

Count | Meaning
--- | ---
1 beep | Command acknowledged — motor started, or direction set
2 beeps | Move interrupted — startstop received while motor was mid-move (our stop signal)
0 beeps | Move completed naturally (reached end of preset angle)
3 beeps | Boot / 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 2 — `read_enc_live()` blocked forever if motor never started. Fix: 1-beep confirmation with 3 retries + 60s absolute timeout.
3. TypeError on call — `mic` 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 Code | Angle Measured | Direction | Duration | Notes
--- | --- | --- | --- | ---
`0x01` | 102.75° | CCW | 8.0s |
`0x03` | 15.0° | CCW | 1.0s |
`0x04` | 7.35° | CCW | 0.0s |
`0x05` | 10.35° | CCW | 0.0s |
`0x06` | 28.5° | CCW | 2.0s |
`0x08` | 0° | none | — | MYSTERY — beep but no movement. Avoid.
`0x09` | 0° | none | — | MYSTERY — possible remote lock. Avoid.
`0x0C` | 168.75° | CCW | 14.0s | ≈180° preset
`0x0D` | 108.6° | CCW | 8.0s | ≈90° preset
`0x12` | 11.55° | CCW | 0.0s |
`0x15` | 6.9° | CCW | 0.0s | Smallest confirmed step at MAX speed
`0x18` | 81.0° | CCW | 6.0s | ≈90° preset
`0x19` | 16.95° | CCW | 1.0s |
`0x1A` | 45.3° | CCW | 3.0s | ≈45° preset
`0x1C` | 44.55° | CCW | 4.0s | ≈45° preset
`0x1D` | 44.1° | CCW | 4.0s | ≈45° preset
`0x20` | 45.9° | CCW | 4.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

Finding | Impact | Status
--- | --- | ---
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 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 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.

Port | Signal Found | Detail | Verdict
--- | --- | --- | ---
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:

Function | Full NEC Value | Command Byte
--- | --- | ---
CCW Direction | `0xBE41DF00` | `0x41`
CW Direction | `0xBB44DF00` | `0x44`
Stop / Start | `0xBD42DF00` | `0x42`

### 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 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 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. 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?
--- | --- | --- | --- | ---
1 | White | Red | _____ Ω |
2 | White | Black | _____ Ω |
3 | White | Yellow | _____ Ω |
4 | Red | Black | _____ Ω |
5 | Red | Yellow | _____ Ω |
6 | Black | Yellow | _____ Ω |

#### Motor 2

# | Wire A | Wire B | Reading | Coil?
--- | --- | --- | --- | ---
1 | White | Red | _____ Ω |
2 | White | Black | _____ Ω |
3 | White | Yellow | _____ Ω |
4 | Red | Black | _____ Ω |
5 | Red | Yellow | _____ Ω |
6 | Black | Yellow | _____ Ω |

#### 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

- ComXim MTxM_V6.01 mainboard — disconnected, removed from cavity
- ATOM S3 IR transmitter — no longer needed
- All NEC IR protocol logic in firmware and Python scripts
- Plantronics Blackwire 5220 — beep detection no longer needed

### What Is Kept

- ESP32-S3-WROOM-1 N16R8 — primary controller, now generates step pulses
- LPD3806-600BM-G5-24C encoder — unchanged wiring, provides position verification
- 15V power supply — barrel jack repurposed directly to L298N motor supply input

### 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 / revolution | 7,200 | 14,400
Degrees / step | 0.050° | 0.025°
Steps / degree | 20 | 40
Encoder counts / rev | 2,400 | 2,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
--- | --- | --- | --- | ---
2 | CCW | −8 | −1.2° | 04:56
3 | CW | −2,403 | −360.45° | 04:56
4 | CCW | −8 | −1.2° | 04:56
5 | CW | −2,404 | −360.60° | 04:56
6 | CCW | −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:

- Live encoder position (counts + degrees)
- Current pass direction and burst/cooldown status
- Pause / Resume / Stop buttons (WebSocket command → firmware)
- Step rate and burst size adjustable from browser
- Optional: DS18B20 1-wire temperature sensor on any spare GPIO for live L298N heatsink temp readout (<$3, single wire + pull-up)

#### 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 stream | None (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 filter | 1 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 DAC | MCP4725 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++

- Dress all signal wires, strain-relief USB, secure ESP32 and L298N boards
- Label motor connectors and power wires
- Verify encoder cable routing doesn’t foul rotation
- Confirm gear cavity can be re-closed with new wiring routed

### 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.