# PROJECT SLOW-SWEEP: Complete Engineering Report
**Platform:** ComXim MTxM_V6.01 Motorized Turntable  
**Objective:** Modify a stock commercial turntable to rotate at 1 revolution per 20–30 minutes  
**Period:** June 23–26, 2026  
**Author:** J Garman  
**Development Partner:** Claude Code (Anthropic claude-sonnet-4-6)

---

## 1. Project Objective

The goal of Project SLOW-SWEEP is to modify a stock ComXim 15.8-inch heavy-duty motorized display turntable to achieve ultra-slow, highly repeatable, bidirectional rotation at approximately one full revolution every 20 to 30 minutes (1,200–1,800 seconds per revolution). The intended use case is a slow photographic or display sweep — a full CW rotation followed by a full CCW return, with precise control over angular position throughout.

The primary constraint was to achieve this without permanently damaging the turntable in a way that couldn't be reversed if needed, and without requiring exotic parts unavailable on short notice. The secondary constraint was that the solution had to be controllable from a laptop or Raspberry Pi over USB serial.

---

## 2. Hardware Inventory

### 2.1 The Turntable
- **Product:** ComXim 15.8-inch Heavy Duty Motorized Display Turntable
- **Amazon listing:** https://www.amazon.com/dp/B0DB1WK3CW?
- **Mainboard:** MTxM_V6.01, manufactured 2025.02.28 (Shenzhen ComXim Technology Co., Ltd.)
- **Website printed on board:** http://www.comxim.com
- **Stock remote:** NEC IR protocol, 32-bit frames, address 0xDF00
- **Rated load:** Heavy duty (exact rating not published)
- **Stock speed range:** Approximately 0.5 min/rev (max) to 1.28 min/rev (min via stock remote)

### 2.2 Drive System
- **Motors:** 2× **50BYJ46-20** bipolar stepper motors
- **Motor voltage:** 15V
- **Internal gear ratio:** 1:33 (printed on motor label)
- **Coupling:** Both motors drive a large toothed ring gear/belt system visible through the milled access window in the top panel
- **Configuration:** Both motors run in sync, wired in parallel from one motor driver

### 2.3 Mainboard Microcontroller
- **Part:** STC **8H8K64U**, LQFP-32 package (8051-core architecture)
- **Markings visible on die:** "512A54533400 / 451-LQFP32 / 8H8K64U / STC"
- **Function:** All IR decoding, motor sequencing, speed ramping, beep control, and direction logic runs on this chip
- **Firmware:** Closed, proprietary — no public SDK or command set documentation found

### 2.4 ESP32 Development Board (Primary Controller)
- **Module:** ESP32-S3-WROOM-1, N16R8 variant (16MB Flash, 8MB PSRAM)
- **Development board:** Dual USB-C, black PCB with WS2812 RGB LED and two buttons (BOOT, RST)
- **Serial port:** /dev/ttyACM0
- **Pin assignments (final firmware):**
  - GPIO16 — IR decoder input (38kHz NEC demodulator output)
  - GPIO5  — Encoder channel A
  - GPIO6  — Encoder channel B
  - GPIO17 — UART sniffer RX (wired to ComXim Tx pad)
  - GPIO18 — UART sniffer TX (wired to ComXim Rx pad)
- **Firmware:** PlatformIO / Arduino framework, `src/main.cpp`
- **Role:** Real-time sensor hub — decodes IR, tracks encoder, outputs structured serial lines to laptop

### 2.5 ATOM S3 IR Transmitter
- **Device:** M5Stack ATOM S3 (or compatible) with IR LED
- **Serial port:** /dev/ttyACM1
- **Role:** Accepts NEC command codes over serial and transmits them as 38kHz IR frames, acting as a software-controlled substitute for the physical remote
- **Key capability:** Allows the laptop to send any IR command programmatically without a human pressing buttons

### 2.6 Rotary Encoder
- **Part:** LPD3806-600BM-G5-24C
- **Resolution:** 600 pulses per revolution (PPR), quadrature (A+B) = 2,400 counts per revolution with 4× decode
- **Angular resolution:** 360° ÷ 2400 = **0.15° per count**
- **Supply voltage:** 5–24V (powered from laptop USB 5V rail, confirmed working)
- **Wiring (printed on encoder label):**
  - RED  → Vcc
  - BLK  → 0V (GND)
  - GRN  → Channel A
  - WTI  → Channel B
  - GND  → Shield ground (G)
- **Mounting:** Shaft coupled to the turntable ring gear via a friction or mechanical coupler, positioned in the interior cavity
- **Note on voltage:** The "not 5v_questionmark.jpg" photo documented a moment of uncertainty about whether the encoder Vcc line was 5V-tolerant. It is — the LPD3806-600BM-G5-24C accepts 5–24V Vcc and outputs open-collector or push-pull signals compatible with the ESP32-S3 3.3V logic when pull-up resistors are used correctly.

### 2.7 Microphone (for beep detection)
- **Initial attempt:** Laptop internal microphone (Dell Precision M4800), ALSA device hw:1,0
- **Codec:** ALC3226 (Realtek). Front Mic Boost register was zeroed, requiring `amixer -c 1 set 'Front Mic Boost' 3` to enable gain. Even after this fix, placement away from the turntable made reliable detection marginal.
- **Final solution:** Plantronics Blackwire 5220 USB headset, ALSA device hw:3,0
- **Characteristics:** Mono only (CHANNELS: 1), supports 8000–48000Hz sample rate. Used at 44100Hz in software.
- **Role:** Detecting the audible beep tones the ComXim emits to confirm command receipt — 1 beep = command acknowledged/start, 2 beeps = mid-move interrupt/stop, 3 beeps = boot

---

## 3. Day-by-Day Chronology

---

### June 23, 2026 — Disassembly and Hardware Survey

#### 3.1 Getting Inside the Turntable

The first physical challenge was gaining access to the electronics. The ComXim turntable has no visible screws from the outside. The bottom is a smooth beige plastic shell with several circular foam pads. Removing the foam pads revealed... nothing. No screws underneath the pads.

**Photo: "No screws under foam pads.jpg"**  
This photo documents the broken foam pads and the intact circular screw cover beneath them, with the ComXim user manual visible in the frame for reference. The foam pads were destroyed in the process of determining there were no fasteners under them. The actual disassembly route turned out to be through the top panel.

**Photo: "Milled Access Window Top Down.jpg"**  
The turntable top panel has a large rectangular access window, approximately 200×120mm, that appears to have been milled (or router-cut) into the plastic top surface. Through this opening, the ComXim MTxM_V6.01 mainboard is visible lying flat in the turntable cavity, with the motor wiring harnesses (red/black power, yellow/black motor leads) routing outward. The large toothed ring gear that the motors drive is visible as a circular belt of blue-tipped teeth around the inner circumference.

#### 3.2 Stepper Motor Identification

**Photo: "Milled Access Window Steppers.jpg"**  
With the access window open, both stepper motors are visible sitting on top of the turntable platform above the cavity. Each motor has a small white plastic pinion gear on its output shaft that meshes with the ring gear below. Motor lead colors visible: red, black, yellow, and an additional wire per motor.

**Photo: "Dual Stepper Motors.jpg"**  
Close-up of the front motor with label clearly readable:
```
50BYJ46-20
15V  1:33
MC: MC02010020
SC: VT00000128
20250828
```
Model **50BYJ46-20**, 15V, 1:33 internal planetary gear ratio, bipolar configuration (confirmed by user). The manufacture date code 20250828 indicates August 2025 production. Two identical units. Both drive the ring gear in sync.

#### 3.3 Control Board Photography

**Photo: "Control Board Left Side.jpg"**  
The board held in hand, showing the left side. Visible: board silkscreen "MTxM_V6.01 / 2025.02.28", the STC microcontroller in the center (LQFP-32, labeled V6.01 area), buzzer (round black cylinder, top left), multiple white JST-style connectors for motor and sensor wiring, and the crystal oscillator (XTAL1). Several Pitch2.54 and Pitch5.08 connector footprints are labeled.

**Photo: "Control Board Right Side.jpg"**  
Shows the right side of the board in-situ. The large aluminum heatsink dominates the right third of the board — this covers what is almost certainly the motor driver IC (likely an L298N or equivalent full-bridge). The UART4 test point header is visible in the upper right with silk-screen labels: Vc, Gd, Rx, Tx. Also visible: the BT_Shutter connector, WIFI header area, and Hc08 pads. Website "http://www.comxim.com" printed along the bottom edge.

**Photo: "Control Board UART.jpg"**  
Tight close-up of the UART4 header area. The four pads are clearly silk-screened: **Vc** (voltage/power), **Gd** (ground), **Rx** (receive), **Tx** (transmit). These are 0.1mm pitch test points, not connectors. This was the primary target for the serial interception strategy. Yellow tape visible — this was applied during wiring work.

**Photo: "Control Board STC Chip.jpg"**  
Macro photograph of the STC 8H8K64U microcontroller. Package marking: "512A54533400 / 451-LQFP32 / 8H8K64U / STC". This is an 8051-architecture single-chip microcontroller with 64KB flash, hardware UART, PWM outputs, and ADC. It is the sole decision-making chip on the board — it handles the IR receiver input, drives the motor sequences, manages speed ramping, and controls the buzzer.

**Photo: "Control Board Whole Thing.jpg"**  
Full board in hand, showing complete layout: buzzer top-left, STC MCU center, UART2/UART4 headers upper-right, RGB LED connector, heatsink+motor driver right half, multiple motor wiring connectors bottom and side. The board is compact — approximately 100×55mm. The heatsink is riveted directly to the board over the motor driver area.

---

### June 24, 2026 — UART Interception Attempt (Dead End)

#### 3.4 The Serial Strategy

The initial plan (documented in plan.txt) was to intercept the UART4 bus between the STC chip and whatever peripheral it communicated with. The hypothesis was that the stock firmware used an ASCII or binary serial protocol to receive commands — similar to how some motorized camera sliders or pan-tilt heads accept serial strings. If we could identify and inject into this bus, we could issue slow-speed movement commands directly without touching the motor hardware.

The plan called for wiring the ESP32-S3 UART1 (GPIO17=RX, GPIO18=TX) to the ComXim's Rx and Tx test pads, with a shared ground — but deliberately NOT connecting the Vc pad to avoid exposing the ESP32's 3.3V inputs to an unknown higher voltage rail.

#### 3.5 ESP32 and ComXim Wiring

**Photo: "ESP32 pins.jpg"**  
The ESP32-S3-WROOM-1 development board positioned next to the ComXim mainboard. Jumper wires are visible connecting to the ESP32 GPIO headers. The dual USB-C ports on the bottom of the dev board are visible — one for power/programming, one for native USB. The ComXim board is partially visible to the right with its buzzer and connectors.

**Photo: "ComXim UART pins.jpg"**  
The ESP32 board and ComXim UART4 header area both in frame, showing the physical wiring tap in progress. Yellow tape holds wires routed from the UART4 pads. The green/white/red wires from motor connectors are visible routing off to the right. This was the working setup during the serial sniffing phase.

#### 3.6 UART Dead End

With the ESP32 monitoring GPIO17/18 at 115200 baud 8N1, **no traffic was observed on the UART4 pads under any operating condition** — not during boot, not during remote button presses, not during motor movement, not during direction changes.

Several baud rates were tried (9600, 19200, 38400, 57600, 115200). The UART4 pads appear to be an unpopulated debug or programming interface that is not active in the shipped firmware. The STC chip communicates with the IR receiver and motor driver directly via GPIO and internal PWM — not via UART.

The ASCII command injection plan (documented in plan.txt with example strings like `CT+TURNSINGLE;`) was abandoned. There is no serial command set to intercept. The ComXim is entirely IR-remote driven with no documented external control interface.

**This was the first significant negative result of the project.** It cost roughly half a day of investigation but produced a firm conclusion: the only available control interface on the stock hardware is the 38kHz NEC IR remote.

#### 3.7 IR Receiver Investigation

With the UART path closed, attention shifted to the IR receiver. The ComXim has an onboard IR receiver module connected to the STC chip's interrupt input.

**Photo: "IR unplugged.jpg"**  
Shows the left side of the ComXim mainboard with the IR receiver connector unplugged. The board connector labeled "IR遥控" (IR remote control, in Chinese) and "INT1" is visible. An empty 3-pin JST connector is shown disconnected. This photo was taken to document the connector pinout before reconnecting with monitoring wires.

**Photo: "IR plugged in three colored wires.jpg"**  
The same connector area, now with three colored jumper wires (red, blue, gray/white) inserted into the IR receiver connector or tapping its signals. This shows the tap point used to monitor IR signal traffic — allowing the ESP32 to see the same demodulated IR data stream that the STC chip receives, without interrupting the STC's ability to respond to commands.

The key insight from this phase: if the ESP32 could decode the NEC frames, and the ATOM S3 could re-transmit arbitrary NEC frames, then the laptop could fully control the turntable without any hardware modification to the drive system.

---

### June 25, 2026 — Firmware Development and Encoder Integration

#### 3.8 ESP32 Firmware (src/main.cpp)

The ESP32-S3 firmware was developed and committed on June 25, 2026 (git commit 8c495d5: "Initial commit: Project SLOW-SWEEP Phase 1 complete"). The firmware runs three parallel tasks:

**IR Decoder** (GPIO16): Monitors the demodulated IR signal from a 38kHz receiver module. Captures edge timings, reconstructs NEC 32-bit frames, and outputs them to serial:
```
[IR] -> NEC decoded: bits=32  value=0x????????
```

**Encoder Counter** (GPIO5=A, GPIO6=B): Hardware interrupt-driven quadrature decoder. Fires on every state change, giving 4× resolution:
```
[ENC] counts=NNNN  deg=NNN.NN
```
With 600 PPR and 4× decode = 2400 counts/revolution, each count = 0.15°.

**Heartbeat** (UART output, 1Hz): Reports encoder position and A/B pin states every second regardless of motion:
```
[ALIVE] millis=NNN  enc=NNN  A=N B=N
```

**UART Sniffer** (GPIO17/18): Still present in firmware but confirmed silent — the ComXim does not use these pads.

#### 3.9 Encoder Physical Installation

**Photo: "Encoder Model Number.jpg"**  
The LPD3806-600BM-G5-24C held in hand showing the model label: "ROTARY ENCODER / TYPE:LPD3806-600BM-G5-24C / ORDER J733 QTY 1 / S531C19P03105164". This is a 38mm diameter industrial quadrature encoder with a 6mm shaft.

**Photo: "Encoder Connections.jpg"**  
The encoder rotated to show the wire label side: "ENCODER CONNECTIONS / RED Vcc / BLK 0V / GRN A / WTI B / GND G". Five wires total — power (RED+BLK), quadrature outputs (GRN=A, WTI=B), and shield ground (GND). The shaft is visible at top.

**Photo: "Encoder Wires.jpg"**  
The encoder held with 5 dupont pin headers crimped to the wire ends, ready for breadboard or direct GPIO connection to the ESP32. The same connection label is visible. The encoder shaft couples to the turntable ring gear.

#### 3.10 Voltage Rail Investigation

**Photo: "not 5v_questionmark.jpg"**  
Close-up of the ESP32-S3 development board showing a red annotation arrow pointing to GPIO pin 14, which is labeled "5Vn GND" on the PCB silkscreen. This photo was taken during investigation of whether any of the ESP32 dev board pins could supply regulated 5V to power the encoder (which requires 5–24V Vcc). The LPD3806 is nominally a 5–24V device. Ultimately the encoder was powered from the laptop USB 5V rail directly, sidestepping the question.

---

### June 26, 2026 — Software Development, IR Mapping, and Sweep Execution

The entire software stack — monitor.py, focused_scan.py, slow_sweep.py — was written and iterated on this day. This was the most intensive session of the project.

#### 3.11 NEC Protocol Reverse Engineering

The first task was mapping the ComXim remote's IR commands to their NEC codes. The ESP32 firmware outputs the raw 32-bit NEC value. The NEC 32-bit frame format is:

```
Bits 31-24: ~command (inverted command byte)
Bits 23-16: command byte
Bits 15-8:  address high byte
Bits 7-0:   address low byte
```

For the ComXim remote, address = 0xDF00. So a command byte of 0x42 produces value: `0xBD42DF00`.

**Critical bug discovered:** The initial IR parser extracted the command as `int(val, 16) & 0xFF`, which always produced 0x00 — the address low byte, not the command byte. The fix was `(int(val, 16) >> 16) & 0xFF`, which correctly extracts bits 23-16. This bug caused all IR frames to appear as "unknown command 0x00" until discovered.

**Final known command map:**
```
0x40 → step
0x41 → ccw
0x42 → startstop
0x43 → slowdown
0x44 → cw
0x45 → continuous
0x02 → 45deg
0x07 → 180deg
0x0A → speedup
0x11 → swing
0x1B → 90deg
```

#### 3.12 ComXim Beep Vocabulary

Through testing, the ComXim buzzer was found to use a consistent beep vocabulary:
- **1 beep:** Command acknowledged — motor started (response to startstop, continuous, direction commands)
- **2 beeps:** Move interrupted — startstop pressed while motor was mid-move
- **0 beeps:** Move completed naturally (motor reached end of a preset angle move on its own)
- **3 beeps:** Boot/power-on sequence

This beep system became the confirmation mechanism for all closed-loop control.

#### 3.13 ATOM S3 as Programmatic Remote

A key discovery mid-session: the ATOM S3 device on /dev/ttyACM1 accepts NEC command strings over serial and transmits them as IR pulses. This meant the laptop could issue any remote button press without human interaction. The `send_ir()` function in all scripts:
```python
def send_ir(atom, cmd, delay=0.15):
    atom.write(f"{cmd}\n".encode())
    time.sleep(delay)
```

This eliminated the need for anyone to be physically present at the turntable during automated tests.

#### 3.14 Beep Detection — Microphone Struggles

The beep detection system required a microphone to hear the ComXim buzzer. Several obstacles were encountered:

**Obstacle 1 — Laptop internal mic too quiet.** The Dell Precision M4800's internal microphone (hw:1,0, ALC3226 codec) was initially tried. The codec's "Front Mic Boost" register was at zero, requiring `amixer -c 1 set 'Front Mic Boost' 3` to enable gain. Even with boost, the mic picked up beeps at RMS ~1203, but the initial threshold was set to 8× ambient = 1018 — then raised further — making detection unreliable. The mic was also stereo (2 channels) while later tests needed mono.

**Obstacle 2 — arecord exclusive access.** Multiple instances of arecord fighting over the device caused silent failures. Fixed with `pkill arecord` before any new capture.

**Obstacle 3 — Monitor log doubling.** monitor.py was both writing to a log file internally AND being redirected with `>> monitor.log`, doubling every line. Fixed by running with `> /dev/null`.

**Solution — Plantronics Blackwire 5220 USB headset.** Plugging a USB headset into the laptop gave a clean, close-mounted microphone on hw:3,0. The headset mic is mono only (CHANNELS: 1), so all arecord calls were changed from `-c 2` to `-c 1` and the chunk byte size calculation was fixed accordingly. Threshold was reduced to `max(ambient * 1.3, 50)`, giving ~322 threshold versus ~248 ambient. Beep detection became reliable and consistent.

#### 3.15 Focused Scan (focused_scan.py) — June 26, 15:46

A systematic automated scan of all IR command codes was run, measuring the encoder response to each. Key findings from `focused_scan.log`:

**Phase 1 — Speed Range:**
- **Min speed** (25× slowdown presses): 360° in **77.1 seconds** = 1.28 min/rev (4.67°/sec)
- **Max speed** (25× speedup presses): 360° in **30.0 seconds** = 0.50 min/rev (12°/sec)
- Stock speed range is 0.5–1.28 min/rev — far too fast for the project goal of 20–30 min/rev

**Phase 2 — Backlash Measurement:**
Control test (CW→CW, no reversal): two consecutive 180° moves gave -171.0° and -170.1° — consistent, 178.5° average (0.83% error, symmetric so errors cancel over full rotation).
Backlash test (CW→CCW reversal): net displacement 242.25° instead of expected 0°. This suggests significant deadband on direction reversal due to gear lash. This was factored into later strategy decisions — the approach of running always-CW then always-CCW (never reversing mid-sequence) avoids this backlash entirely.

**Phase 3 — Command Investigation:**
Selected results at max speed:
```
0x15 → 6.9°  (the smallest confirmed step command)
0x04 → 7.35°
0x05 → 10.35°
0x03 → 15.0°
0x1A → 45.3°  (≈45°)
0x0C → 168.75° (≈180°)
0x08 → no response (mystery — possibly mode-setting)
0x09 → no response (mystery — possibly remote lock)
```

**Critical finding on step angle:** The step angle for all preset commands scales linearly with motor speed. At max speed (25× speedup), the "step" command (0x40) gives ~6°. At default speed, the same command gives ~90°. The ComXim firmware appears to use a fixed time-based step, not a fixed encoder count — so slower speed = larger angular step for the same command. This made the native step commands useless for precision slow-sweep at slow speeds.

#### 3.16 Precision Test — 180°×2 CW + 180°×2 CCW

Before committing to the slow sweep approach, a manual precision test was run using the physical remote:
- 180° CW (preset), 180° CW again
- Switch to CCW, 180° CCW, 180° CCW again
- Return to home mark

**Result:** The lineup marks at the end of 2×CW and 2×CCW were "nearly perfect" (user's words). The encoder confirmed each 180° preset = ~178.5°, which is 1.5° short, but since CW and CCW errors are equal and opposite they cancel perfectly over a full bidirectional sweep. No backlash was detectable within a single direction. Mechanical accuracy: excellent.

#### 3.17 Closed-Loop Encoder Control Strategy

Given that:
1. Stock speeds (0.5–1.28 min/rev) are 20–60× too fast
2. Step angle varies with speed (unusable for precision)
3. The encoder gives real-time position at 0.15°/count

The approach became: run the motor in continuous mode at minimum speed, watch the encoder, and send a stop command when the target encoder count is reached. This is a software closed-loop position controller using IR as the actuator.

**Key constants derived:**
- Min speed: 77s/rev = 32ms/count overshoot at stop signal
- IR latency: ~200ms from stop command sent to motor halted
- Overshoot at min speed: ~6 encoder counts = ~0.9°
- Therefore: fire stop command 6 counts before target (OVERSHOOT_COUNTS = 6)

**Timing for 25 min/rev at 6°/step:**
- Steps per revolution: 360/6 = 60
- Interval per step: 25×60÷60 = 25 seconds
- Move time: ~1–2 seconds
- Wait time: ~23 seconds of idle per step

#### 3.18 slow_sweep.py Development

`slow_sweep.py` was written from scratch as a complete closed-loop sweep controller. Key classes and functions:

**BeepListener class:** Background thread running arecord (mono, 44100Hz, hw:3,0). Processes audio in 20ms chunks, measures RMS, applies a state machine to count distinct beep tones separated by ≥120ms silence gaps. Exposes `wait_beep(timeout)` which blocks until a beep event is detected, returning the count (1 or 2).

**read_enc_live(wroom):** Blocks reading ESP32 serial until an `[ENC]` line appears, returns the encoder count. This fires on every encoder count change during motion — approximately every 32ms at min speed.

**wait_stopped(wroom, settle_s, timeout):** Reads `[ALIVE]` heartbeat lines and waits until the encoder value is stable for `settle_s` seconds. Used to confirm the motor has fully halted before beginning the next step.

**closed_loop_step(wroom, atom, mic, target_deg, direction_sign, interval_s):**
The core function. Sequence:
1. Call `wait_stopped()` to ensure table is stationary (fixes the first-step anomaly seen in earlier runs where the table was still coasting from a prior test)
2. Read start encoder position from `[ALIVE]` heartbeat
3. Send "continuous" via ATOM → wait for 1-beep confirmation (up to 3 retry attempts)
4. If no beep after 3 attempts: skip step, return zeros
5. Watch `[ENC]` stream; compute delta = (enc - enc_start) × direction_sign
6. When delta ≥ (target_counts - OVERSHOOT_COUNTS): send "startstop" immediately
7. 60-second timeout guard: if no target reached, send safety stop anyway
8. Call `wait_stopped()` to confirm halt
9. Sleep remaining interval time
10. Return (actual_deg, overshoot_deg, final_enc_position)

**main():**
- Arguments: `--minutes-per-rev` (default 25), `--direction` (cw/ccw), `--step-deg` (default 6.0), `--revolutions` (0 = forever)
- Sets minimum speed by sending slowdown command 25 times
- Sets direction, waits for beep confirmation
- Loops indefinitely calling `closed_loop_step()`
- Logs each step result to `/home/person/Desktop/Turntable_Hacking/slow_sweep.log`

#### 3.19 Bugs Encountered and Fixed During Development

**Bug 1 — Step 1 always overshot by ~38°:**
The table was still coasting from a prior manual test when step 1 began. The encoder was in motion at t=0. Fixed by adding `wait_stopped()` at the very beginning of each `closed_loop_step()` call before reading enc_start.

**Bug 2 — Script hung after step 2 (the hard one):**
`read_enc_live()` blocks forever waiting for `[ENC]` serial lines. If the "continuous" command was not received by the turntable (IR transmission failure, table in wrong state), no motion occurred, no `[ENC]` lines came, and the function waited indefinitely.

Fixed by adding beep confirmation: after sending "continuous", wait up to 1.5 seconds for a 1-beep response. If no beep, send "startstop" to reset state and retry. Up to 3 retry attempts before skipping the step entirely. The hang was converted into a graceful failure mode.

**Bug 3 — main() call site not updated after signature change:**
`closed_loop_step()` had `mic` added as a parameter but the call in `main()` still passed the old argument count. This would cause a TypeError at runtime. Fixed by updating the call to include `mic`.

**Bug 4 — Beep threshold too high (8× ambient):**
Initial threshold of 1018 was above the actual beep level (~1203 peak but variable). Threshold was progressively reduced — ultimately to `max(ambient * 1.3, 50)` = ~322, giving reliable detection with ~75 RMS of margin above ambient noise.

**Bug 5 — IR command byte extraction always 0x00:**
`int(val, 16) & 0xFF` extracts the address low byte (0x00 for ComXim address 0xDF00). Fixed: `(int(val, 16) >> 16) & 0xFF` extracts the actual command byte from bits 23-16. All IR monitoring was blind to actual commands until this was corrected.

#### 3.20 Full Slow Sweep Execution — June 26, 17:51–19:01+

With all bugs fixed, the full continuous sweep was launched:

```
python3 slow_sweep.py --minutes-per-rev 25 --direction ccw
```

No `--revolutions` limit — runs until manually stopped.

**Run parameters:**
- Direction: CCW
- Step size: 6.0° (40 encoder counts)
- Overshoot compensation: 6 counts
- Steps per revolution: 60
- Interval: 25.0 seconds per step
- Target speed: 25 min/rev (1 revolution per 25 minutes)

**Results over 151 steps (~3.66 revolutions, ~63 minutes):**

Normal steps (≤12°): **141 of 151** (93.4%)
- Mean: 5.92°
- Standard deviation: 0.55°
- Typical range: 5.5°–6.8°

Overruns (>12°): **10 of 151** (6.6%)
- All overruns were in the range 47.9°–52.3° (approximately 49–50°)
- Pattern: not random noise — all overruns are nearly identical in magnitude
- Probable cause: IR stop command not received by turntable on those steps; motor ran ~10 additional seconds before next event (possibly the start of the next cycle attempting "continuous" which may have triggered a stop)
- Recovery: all overruns were followed immediately by normal 5.5° steps on the next cycle

**The beep pattern observed during operation** (user noted this from the physical room):
- Every step sounds like: 1 beep, then 2 beeps
- This is correct and expected: 1 beep = "continuous" acknowledged (motor starts), 2 beeps = "startstop" sent while in motion (motor interrupted mid-move)
- The closed-loop design intentionally uses this interrupt-while-moving pattern

**Watchdog added (post-run, in source):**
After the 10 overruns were identified, a watchdog line was added to `main()`:
```python
if actual_deg > args.step_deg * 2:
    log(f"WARNING: step {step_count} overran {actual_deg:.1f}° ...")
```
This will flag anomalous steps in future runs but was not active during this sweep (code updated in source while process was running in memory).

---

## 4. Assessment: What Worked and What Didn't

### What Worked

- **Encoder as position feedback:** The LPD3806-600BM at 2400 counts/rev gave rock-solid position data. The `[ENC]` serial stream was reliable and low-latency.
- **ATOM S3 as programmatic remote:** Being able to send any IR command from Python in one line was the enabler for all automation. Without this, every test would have required manual button presses.
- **Beep detection as command confirmation:** The 1-beep / 2-beep vocabulary gave a reliable out-of-band confirmation channel for command acknowledgment.
- **Minimum speed continuous + encoder stop:** The fundamental closed-loop approach works. 93% of steps landed within ±0.9° of target.
- **Backlash avoidance:** Running full CW then full CCW (never reversing within a sweep direction) completely avoids the measured 242° backlash deadband. The precision test confirmed sub-degree repeatability over 360°.

### What Didn't Work

- **UART interception:** The UART4 pads are silent in the shipped firmware. There is no serial command set. This consumed roughly half a day.
- **Internal laptop mic for beep detection:** Too far from the source, gain issues with the ALC3226 codec, stereo-only on the ALSA interface. Required a USB headset to solve.
- **Native step commands for slow motion:** The ComXim's preset step angles scale with motor speed. There is no way to issue a "6° step" at slow speed using the remote protocol — the step will be 90° at default speed. The only way to achieve slow small steps is the closed-loop continuous+stop approach.
- **Stock speed range:** 0.5–1.28 min/rev is the full range available via the remote. Target was 20–30 min/rev. The 15–25× gap cannot be bridged through IR commands alone.
- **IR stop reliability (the 6.6% overrun rate):** Ten steps out of 151 failed to stop on time, all overshooting by approximately 50°. The IR transmission path from the ATOM S3 to the ComXim receiver is not 100% reliable — occasional frames are lost. At 93% reliability this is an acceptable demo result, but not sufficient for production use.

---

## 5. Phase 2 Plan: L298N Direct Motor Drive

### 5.1 Rationale

The Phase 1 approach (IR closed-loop) proved that the encoder + encoder-based position control works well. The weak link is the IR channel — it's lossy (6.6% step failure rate), requires the ATOM S3 as a separate device, and is fundamentally limited to the speed range the ComXim firmware provides.

The Phase 2 approach eliminates the ComXim board entirely. The motors are 50BYJ46-20 bipolar steppers — they have no inherent dependency on the ComXim firmware. If we drive their coils directly with a proper H-bridge driver, we own the speed, the direction, and the step sequence outright.

### 5.2 Why L298N

The **HiLetgo 4-pack L298N Motor Driver Controller Board Module** (Dual H-Bridge) is already on hand. The L298N is a classic dual H-bridge IC that can drive two independent DC or bipolar stepper motor windings. Each H-bridge can source/sink up to 2A continuous (3A peak).

For a **bipolar stepper**, each motor requires two H-bridges — one per winding. The L298N has exactly two H-bridges, making it a natural 1:1 match for one bipolar stepper motor.

Since both 50BYJ46-20 motors are identical and must move in perfect sync, both motors can be wired in **parallel** — coil A of motor 1 in parallel with coil A of motor 2, coil B of motor 1 in parallel with coil B of motor 2. This is almost certainly how the ComXim board drives them internally (one L298N or equivalent, both motors in parallel). The combined current draw is doubled but within L298N spec for these motors at 15V.

Result: **one L298N drives both motors simultaneously from four GPIO pins.**

### 5.3 Wiring Plan

```
ESP32-S3                  L298N Board              Motors
---------                 -----------              ------
GPIO_A1 ──────────────── IN1                      Motor 1 coil A+ ──┐
GPIO_A2 ──────────────── IN2                      Motor 2 coil A+ ──┘── OUT1
GPIO_B1 ──────────────── IN3                      Motor 1 coil A- ──┐
GPIO_B2 ──────────────── IN4                      Motor 2 coil A- ──┘── OUT2
3.3V ─────────────────── 5V logic supply          Motor 1 coil B+ ──┐
GND ──────────────────── GND                      Motor 2 coil B+ ──┘── OUT3
                          12V/15V motor supply ─── Motor 1 coil B- ──┐
                          (from existing PSU)      Motor 2 coil B- ──┘── OUT4
ENA tied HIGH (always enabled)
ENB tied HIGH (always enabled)
```

The existing turntable power supply provides 15V. This feeds the L298N motor supply input directly. The L298N logic side runs from the ESP32 3.3V (or 5V — the L298N logic inputs are 5V-compatible but will also accept 3.3V signals in most implementations, or a small level-shifter can be added).

### 5.4 Firmware Changes

The existing `src/main.cpp` encoder decoder stays entirely unchanged — it already works perfectly and provides the position feedback needed. What changes is replacing the IR decoder task with a stepper sequencer.

**Bipolar stepper full-step sequence (4 steps):**
```
Step  IN1  IN2  IN3  IN4
  1    H    L    H    L    (coil A+, coil B+)
  2    L    H    H    L    (coil A-, coil B+)
  3    L    H    L    H    (coil A-, coil B-)
  4    H    L    L    H    (coil A+, coil B-)
```
Advancing through this sequence forward = CW rotation. Reversing the sequence = CCW. Speed = how fast steps are issued (step delay in microseconds or milliseconds). At 15V, 1:33 gear reduction, and the additional ring gear reduction, even a modest step rate of a few hundred steps/second at the motor shaft produces very slow rotation at the turntable surface.

For a target of 25 min/rev at the turntable surface:
- Ring gear ratio needs to be measured (estimated from geometry — likely 10:1 to 20:1 additional reduction)
- Motor steps/rev = motor native steps × 4 (full step) or × 8 (half step) × 33 (gear ratio)
- Total system steps/rev = motor_spr × 33 × ring_ratio
- Required step interval = (25 × 60 seconds) / total_steps_per_rev

For half-stepping (8 micro-positions × 33 gear × estimated 20:1 ring = 5,280 steps/rev at table surface): 25 minutes / 5280 steps = 0.284 seconds per step. Well within comfortable firmware territory.

### 5.5 What to Keep from Phase 1

- **LPD3806-600BM encoder:** Stays exactly as wired. The encoder provides position verification independent of step count — essential for confirming the stepper didn't skip steps under load.
- **ESP32-S3-WROOM-1:** Stays as the primary controller, now generating step pulses instead of monitoring IR.
- **Python logging scripts:** The monitor.py and slow_sweep.py logging infrastructure can be adapted. Instead of listening for IR commands, the Python side just commands step counts and intervals over serial to the ESP32.
- **ATOM S3:** No longer needed. The IR channel is retired entirely.

### 5.6 What Changes

- **ComXim MTxM_V6.01 board:** Removed from the turntable cavity. Its motor driver, buzzer, IR receiver, and STC microcontroller are all bypassed.
- **Motor wiring:** The 4-wire connectors from the two motors are unplugged from the ComXim board and rewired to the L298N outputs.
- **Power supply:** The 15V barrel jack that powered the ComXim now powers the L298N motor supply input. The ComXim's regulated 5V rail (from its onboard 78M05 or equivalent) is no longer used — the ESP32 continues to be powered by laptop USB.

### 5.7 Immediate Next Steps (Starting Monday)

1. **Identify motor coil pairs** with a multimeter: measure resistance between all combinations of the 4 motor wires. Two pairs that each show ~Xohms continuity are the two windings. Note which wires are coil A and coil B for each motor.

2. **Wire L298N:** Connect one L298N board to 4 ESP32 GPIOs (TBD: suggest GPIO8, GPIO9, GPIO10, GPIO11 — free from the encoder and current assignments). Motor wires in parallel as described above. 15V to L298N VMotor. GND shared.

3. **Write stepper firmware:** Add a step sequencer to `src/main.cpp`. Accept serial commands: `STEP <N> <delay_us>` to move N steps at a given rate. The encoder continues to report position simultaneously.

4. **Characterize the system:** Run the motor at a known step rate, measure actual turntable degrees per N steps using the encoder. Calculate the exact ring gear ratio from this data.

5. **Write slow_sweep_v2.py:** Same architecture as slow_sweep.py but replace `send_ir()` calls with `send_serial_step_command()`. Use encoder feedback to verify position after each step burst and detect any step skipping.

6. **Target:** 1 revolution per 25 minutes, bidirectional, ≥99% step reliability, no IR dependency.

---

## 6. File Reference

| File | Purpose |
|------|---------|
| `src/main.cpp` | ESP32-S3 firmware: IR decoder, quadrature encoder, heartbeat, UART sniffer |
| `monitor.py` | Real-time terminal monitor: encoder position, IR command names, beep events |
| `focused_scan.py` | Automated IR command scanner: measures encoder response to each NEC code |
| `slow_sweep.py` | Closed-loop sweep controller: continuous+encoder-stop at 25 min/rev |
| `scan_results.log` | Output of full IR command sweep |
| `focused_scan.log` | Output of focused scan (Phase 1–3) |
| `monitor.log` | Full session serial monitor output |
| `slow_sweep.log` | Step-by-step results of the 151-step full sweep run |
| `platformio.ini` | PlatformIO build configuration for ESP32-S3 |
| `plan.txt` | Original Phase 1 plan document (UART strategy, since superseded) |
| `links.txt` | Amazon product link for the ComXim turntable |

---

## 7. Summary

Project SLOW-SWEEP explored three distinct approaches to slowing a commercial motorized turntable by 20–30×:

1. **Serial interception** (UART4 tap) — Dead end. No traffic on the labeled pads.
2. **IR closed-loop control** (continuous+encoder-stop) — Works at 93% reliability. Limited by IR transmission losses and the stock motor driver's minimum speed of 1.28 min/rev.
3. **Direct motor drive via L298N** — Planned for Phase 2. Eliminates all ComXim firmware constraints. Full speed, direction, and position control from ESP32 firmware.

The encoder-based position system (LPD3806-600BM, 2400 counts/rev, 0.15°/count) performed flawlessly throughout and carries forward into Phase 2 unchanged.

**Project status:** Phase 1 complete. Phase 2 hardware work begins Monday.

---

## 8. Phase 2 Results: L298N Direct Motor Drive

*Completed week of June 30 – July 1, 2026*

### 8.1 Motor Wiring and Half-Step Drive

With the ComXim board bypassed, the two 50BYJ46-20 bipolar steppers were wired in parallel to a single HiLetgo L298N dual H-bridge module. Final GPIO assignments on the ESP32-S3:

| GPIO | L298N | Coil | Wire color |
|------|-------|------|------------|
| 1 | IN1 | A+ | White |
| 2 | IN2 | A- | Red |
| 3 | IN3 | B+ | Black |
| 4 | IN4 | B- | Yellow |
| 5 | — | ENC_A | — |
| 6 | — | ENC_B | — |

Half-step drive was implemented using an 8-entry lookup table (`STEP_TABLE[8][4]`), giving 14,400 half-steps per turntable revolution (confirmed by encoder calibration). The step table gives smoother motion and lower resonance than full-step at the low speeds used here.

### 8.2 Thermal Management — Burst-Pause

Running the L298N at continuous half-step speed caused thermal cutouts. Root cause: L298N voltage-mode drive at ~1.67A per channel × 1.8V Vce_sat × 2 channels ≈ 6W continuous dissipation.

**Tested thermal progression:**

| Mode | Peak temp | Outcome |
|------|-----------|---------|
| Full-step, continuous | 150°F+ | Thermal cutout, shutdown |
| Half-step, continuous | 118°F | No cutout but warm |
| Half-step + burst-pause | **95°F** | **Stable indefinitely** |

**Burst-pause method:** Step 240 half-steps (6°) in a fast burst, then release all coils and wait for the remaining window time. This reduces average duty cycle to ~20% while maintaining precise position control. No additional hardware required.

**Window timing math:**
- Window = `minsPerRev × 1000 ms` (derived from 60000 ms × 240 steps / 14400 steps/rev)
- Burst = `BURST_STEPS × stepDelayMs()` where step delay scales with speed
- Cooldown = Window − Burst, minimum 200 ms

| Preset | Step delay | Burst | Cooldown | Total window |
|--------|-----------|-------|----------|-------------|
| 5 min/rev | 5 ms | 1.2 s | 3.8 s | 5 s |
| 10 min/rev | 6 ms | 1.4 s | 8.6 s | 10 s |
| 20 min/rev | 13 ms | 3.1 s | 16.9 s | 20 s |
| 30 min/rev | 20 ms | 4.8 s | 25.2 s | 30 s |

### 8.3 Encoder Calibration

The encoder was confirmed at 14,400 steps per revolution by driving the motor exactly 14,400 half-steps and reading the encoder. Encoder drift over a full rotation: ≤0.6° (attributable to gear backlash, not missed steps). Zero missed steps under any burst-pause condition.

### 8.4 Timelapse Sweep Validation

A 5-pass timelapse sweep at 5 min/rev (3 cycles CW + CCW) was executed and recorded. The `timelapse_sweep.py` script runs autonomously. All passes completed in exactly 5:00 per rotation. System validated for production use at all four speed presets.

---

## 9. Phase 3: Endurance Test and WiFi Dashboard

*Completed July 2, 2026*

### 9.1 30-Minute Endurance Test — 5 Passes

A 2.5-hour endurance test was run to validate thermal stability and step accuracy at the slowest speed (30 min/rev):

- **Script:** `endurance_sweep.py`
- **Profile:** Trapezoidal accel/decel (10@20ms, 10@15ms, 10@10ms, 10@7ms, 160@5ms, decel reversed)
- **Passes:** CW → CCW → CW → CCW → CW (5 total)
- **Duration:** 2 hours 30 minutes

**Results:**
- Every pass: **exactly 30:00**
- Encoder drift: **≤0.6°** per pass (gear backlash only)
- Missed steps: **zero**
- Thermal cutouts: **zero** (heatsink stable at 95°F throughout)

**Video — Best 30-minute pass:**  
https://youtu.be/VS2JxDMvqqM

### 9.2 WiFi AP Dashboard

A self-contained WiFi dashboard was added to the ESP32-S3 firmware using the AsyncWebServer + AsyncTCP libraries. The ESP32 creates an open access point named **"SlowSweep"** (IP: 192.168.4.1). The full dashboard is served as an embedded HTML page with a WebSocket for live telemetry.

**Libraries (platformio.ini):**
```ini
lib_deps =
    https://github.com/me-no-dev/AsyncTCP.git
    https://github.com/me-no-dev/ESPAsyncWebServer.git
```

**Dashboard features:**
- Large 0.0–360.0° angle readout (green = CW, purple = CCW)
- Large ↻/↺ rotation arrow with CLOCKWISE / COUNTER-CW label
- 4 speed preset buttons: 5 / 10 / 20 / 30 min/rev
- ▶ Start / ■ Stop toggle (auto-reverse CW↔CCW sweep mode)
- ◄ 1° / 1° ► nudge buttons for fine-tuning home position
- ⊙ Zero / Home button (resets step counter and encoder)
- WebSocket broadcasts state JSON every 250 ms
- Auto-reconnects if WebSocket drops (retries every 2 s)
- Connection status dot (green = live, red = dropped)

**WebSocket JSON format:**
```json
{"enc": 2400, "pdeg": 180.0, "state": "run", "dir": 1, "mpr": 30}
```

**Supported WebSocket commands:**
| Command | Action |
|---------|--------|
| `{"cmd":"start"}` | Begin auto-reverse CW↔CCW sweep |
| `{"cmd":"stop"}` | Stop after current burst |
| `{"cmd":"zero"}` | Reset encoder + step counter, set direction CW |
| `{"cmd":"speed","v":30}` | Set speed preset (5/10/20/30) |
| `{"cmd":"nudge","v":1}` | Step exactly 1° CW (40 half-steps, ~200 ms) |
| `{"cmd":"nudge","v":-1}` | Step exactly 1° CCW |

### 9.3 Captive Portal Fix — Mobile Phone Access

When a smartphone connects to SlowSweep, the OS detects no internet access and keeps mobile data active, routing `192.168.4.1` requests over the cellular network instead of WiFi. The fix is a captive portal DNS server: the ESP32 runs `DNSServer` on UDP port 53, answering all DNS queries with 192.168.4.1. The phone's OS detects a "login required" network (like hotel WiFi), forces WiFi routing, and pops up the dashboard automatically.

```cpp
#include <DNSServer.h>
DNSServer dnsServer;
// In setup():
dnsServer.start(53, "*", WiFi.softAPIP());
// In loop():
dnsServer.processNextRequest();
// Catch-all route redirects all captive-portal probe URLs:
server.onNotFound([](AsyncWebServerRequest* req){
    req->redirect("http://192.168.4.1/");
});
```

### 9.4 Bugs Found and Fixed in Firmware

**Bug 1 — All speed presets identical (HTTP 408 root cause):**  
The `coolMs()` function computed `1000.0f / rpm` where `rpm = 1/minsPerRev` — treating minutes as seconds. This gave a result 60× too small, always negative after subtracting burst time, clamping to the 200 ms minimum. All four presets ran at 200 ms cooldown (identical). Fix: replaced with `windowMs = minsPerRev * 1000`, then `coolMs = windowMs - burstMs`.

**Bug 2 — Encoder serial spam causing HTTP 408 timeouts:**  
The encoder change reporter in `loop()` had no rate limit. Floating encoder pins could fire `Serial.printf` on every loop iteration, starving `AsyncWebServer`'s TCP processing. Fix: added 200 ms minimum interval between serial encoder prints.

**Bug 3 — Phones get HTTP 408 (not laptop):**  
Smartphones route requests over mobile data when connected to an AP with no internet, regardless of what IP is typed. Fix: captive portal DNS server as described in 9.3 above.

---

## 10. Customer Output Signal Options

The customer requires a voltage signal proportional to turntable angle for input to a **Campbell Scientific CR1000X** data logger analog input.

**CR1000X analog input specs (§18.5.1):**
- SE input range: ±5 V
- Input impedance: 20 GΩ (no loading concern)
- Resolution at ±5000 mV range: 11.8 µV RMS (19-bit effective)
- C terminals (C1–C8): LVTTL compatible — ESP32-S3 3.3V GPIO works directly without a level converter

**Note:** The ESP32-S3 has **no built-in DAC** (removed from ESP32-S3 vs. original ESP32). True DC analog output requires an RC filter on PWM or an external DAC chip.

**Full option analysis:** [`cr1000x_output_options.txt`](cr1000x_output_options.txt)  
**CR1000X manual:** [`cr1000x-product-manual.pdf`](cr1000x-product-manual.pdf)

**Options summary (ranked easiest to most complex):**

| # | Method | New parts | CR1000X side | Angle resolution |
|---|--------|-----------|--------------|-----------------|
| 1 | **PWM + RC filter** | 10 kΩ + 47 µF (~$0.25) | `VoltSE()` — standard | ~0.09°/step |
| 2 | **Serial UART ASCII** | 1 wire | `SerialOpen()` + string parse | Exact float |
| 3 | **PWM period averaging** | 1 wire | `PeriodAvg()` + math | ~0.04° |
| 4 | **MCP4725 I²C DAC** | $2–4 module | `VoltSE()` — standard | ~0.09°/step, no ripple |
| 5 | **SDI-12 smart sensor** | Wire + level shift | `SDI12Recorder()` | Exact float |

**Recommendation:** Option 1 (PWM + RC) for familiar analog-sensor workflow with minimal parts; Option 2 (Serial) if zero hardware cost is the priority and CR1000X programming is acceptable.

**Wiring note:** Always connect ESP32-S3 GND to a **Signal Ground (±)** terminal on the CR1000X — not Power Ground (G) — as the analog measurement reference.

---

## 11. Updated File Reference

| File | Purpose |
|------|---------|
| `src/main.cpp` | ESP32-S3 firmware: WiFi AP dashboard, burst-pause state machine, captive portal DNS |
| `platformio.ini` | PlatformIO build config — AsyncTCP, ESPAsyncWebServer, DNSServer |
| `endurance_sweep.py` | 30 min/rev, 5-pass endurance test (Python/serial) |
| `timelapse_sweep.py` | 5 min/rev, 3-cycle CW+CCW timelapse sweep |
| `slow_sweep_v2.py` | General sweep: --degrees --rpm --shots --reverse |
| `calibrate_steps.py` | Auto-calibration (confirmed 14400 steps/rev) |
| `cr1000x_output_options.txt` | Analysis of 5 options for ESP32-S3 → CR1000X angle voltage output |
| `cr1000x-product-manual.pdf` | Campbell Scientific CR1000X product manual (analog input specs §18.5.1) |
| `monitor.py` | Real-time terminal monitor (Phase 1 — IR/serial) |
| `focused_scan.py` | Automated IR command scanner (Phase 1) |
| `slow_sweep.py` | Phase 1 closed-loop IR sweep controller |
| `scan_results.log` | Output of full IR command sweep |
| `slow_sweep.log` | Phase 1 step-by-step sweep results (151 steps) |
| `PROJECT_SLOW_SWEEP_REPORT.md` | This document |

---

*Report last updated July 2, 2026. All source files in `/home/person/Desktop/Turntable_Hacking/`.*
