"""
gauge_vid6008.py — Automotive stepper gauge driver for ESP32 / MicroPython

Supports two driver modes:

  1. STEP/DIR mode (e.g. VID-6008): 2 pins — DIR and STEP
     Each step requires a pulse on STEP; DIR sets direction.

  2. 4-PHASE mode (e.g. VID28/BKA30D/Switec X25): 4 pins — IN1..IN4
     Driven via two H-bridge pairs (ULN2003 or direct GPIO).
"""

from machine import Pin
import utime


_TOTAL_STEPS = 3780
_OVERRUN_STEPS = 200

_FULL_SEQUENCE = [
    (1, 0, 1, 0),
    (0, 1, 1, 0),
    (0, 1, 0, 1),
    (1, 0, 0, 1),
]


class Gauge:
    """
    Analog-style stepper gauge driver.

    Parameters
    ----------
    pins : tuple[int, ...]
        2 pins for STEP/DIR mode: (DIR, STEP)
        4 pins for 4-phase mode: (IN1, IN2, IN3, IN4)

    mode : str
        "stepdir" for VID-6008 style (2 pins), "4phase" for VID28/X25 (4 pins).

    min_val : float
        Value that corresponds to the physical lower stop.

    max_val : float
        Value that corresponds to the physical upper stop.

    step_us : int
        Delay between steps in microseconds (default 200).
    """

    def __init__(self, pins, mode="4phase", min_val=0, max_val=100, step_us=200):
        self._mode = mode
        self._step_us = step_us
        self._min_val = min_val
        self._max_val = max_val

        if mode == "stepdir":
            if len(pins) != 2:
                raise ValueError("stepdir mode requires 2 pins: (DIR, STEP)")
            self._pin_dir = Pin(pins[0], Pin.OUT)
            self._pin_step = Pin(pins[1], Pin.OUT)
            self._total_steps = _TOTAL_STEPS
            self._phase = 0
            self._last_dir = None  # track last direction to avoid redundant writes

        elif mode == "4phase":
            if len(pins) != 4:
                raise ValueError("4phase mode requires 4 pins: (IN1, IN2, IN3, IN4)")
            self._pins = [Pin(p, Pin.OUT) for p in pins]
            self._total_steps = _TOTAL_STEPS
            self._sequence = _FULL_SEQUENCE
            self._phase = 0
        else:
            raise ValueError("mode must be 'stepdir' or '4phase'")

        self._current_step = 0
        self._zeroed = False
        self._last_dir = None

    def zero(self):
        overrun = _OVERRUN_STEPS
        if self._mode == "stepdir":
            self._pin_dir.value(0)
            utime.sleep_us(10)
            for _ in range(_TOTAL_STEPS + overrun):
                self._pulse_step(500)
        else:
            for _ in range(_TOTAL_STEPS + overrun):
                self._step(-1)
                utime.sleep_ms(2)

        self._current_step = 0
        self._zeroed = True
        self._release()

    def set(self, value, release=True):
        if not self._zeroed:
            raise RuntimeError("Call zero() before set()")

        value = max(self._min_val, min(self._max_val, value))
        target_step = self._val_to_step(value)
        delta = target_step - self._current_step

        if delta == 0:
            return

        if self._mode == "stepdir":
            self._move_steps(delta)
        else:
            for _ in range(abs(delta)):
                self._step(1 if delta > 0 else -1)
                utime.sleep_us(self._step_us)

        self._current_step = target_step

        if release:
            self._release()

    def _move_steps(self, delta):
        direction = 1 if delta > 0 else 0
        self._pin_dir.value(direction)
        utime.sleep_us(10)
        for _ in range(abs(delta)):
            self._pulse_step(self._step_us)

    def _pulse_step(self, delay_us):
        self._pin_step.value(1)
        utime.sleep_us(2)
        self._pin_step.value(0)
        utime.sleep_us(delay_us)

    def steps_toward(self, value, limit=5, deadband=0.5):
        """Return the step delta needed to move toward value, clamped to ±limit.

        deadband: If error is less than this fraction of one step, return 0 to prevent
        micro-corrections due to floating-point rounding. Default 0.5 means
        no movement if error < half a step.
        """
        target_step = self._val_to_step(value)
        delta = target_step - self._current_step
        deadband_steps = deadband
        if abs(delta) < deadband_steps:
            return 0
        return max(-limit, min(limit, delta))

    def get(self):
        return self._step_to_val(self._current_step)

    def release(self):
        self._release()

    def step(self, direction):
        if self._mode == "stepdir":
            if direction != self._last_dir:
                self._pin_dir.value(1 if direction > 0 else 0)
                self._last_dir = direction
                utime.sleep_us(10)
            self._pulse_step(self._step_us)
            self._current_step += direction
        else:
            self._step(direction)

    def _step(self, direction):
        steps_in_seq = len(self._sequence)
        self._phase = (self._phase + direction) % steps_in_seq
        seq = self._sequence[self._phase]
        for pin, state in zip(self._pins, seq):
            pin.value(state)
        self._current_step += direction

    def _release(self):
        if self._mode == "stepdir":
            self._pin_step.value(0)
        else:
            for pin in self._pins:
                pin.value(0)

    def _val_to_step(self, value):
        frac = (value - self._min_val) / (self._max_val - self._min_val)
        return int(round(frac * self._total_steps))

    def _step_to_val(self, step):
        frac = step / self._total_steps
        return self._min_val + frac * (self._max_val - self._min_val)


if __name__ == "__main__":
    g = Gauge(
        pins=(12, 13),
        mode="stepdir",
        min_val=0,
        max_val=100,
        step_us=200,
    )

    print("Zeroing gauge...")
    g.zero()
    print("Zero complete.")

    for target in [25, 50, 75, 100, 50, 0]:
        print("Moving to {}  (currently at {:.1f})".format(target, g.get()))
        g.set(target)
        utime.sleep_ms(500)

    print("Done.")
