# Copyright 2025 Allen Synthesis
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Hardware-specific definitions & implementations for Europi

Originally all of this was included as part of europi.py, but was moved
here to make it easier to access e.g. button status readers independently
of all of the other EuroPi initialization that occurs inside the `europi`
module.

Normally this module should not be included directly; it's imported
automatically as part of `from europi import *`.
"""

from machine import ADC
from machine import I2C
from machine import PWM
from machine import Pin
from machine import freq
from machine import mem32
import time

from europi_config import load_europi_config, CPU_FREQS
from experimental.experimental_config import load_experimental_config

# Load the configuration objects so we can initialize
# the hardware according to user preferences
europi_config = load_europi_config()
experimental_config = load_experimental_config()

# Analogue voltage read range.
MIN_INPUT_VOLTAGE = 0
MAX_INPUT_VOLTAGE = europi_config.MAX_INPUT_VOLTAGE
GATE_VOLTAGE = europi_config.GATE_VOLTAGE
DEFAULT_SAMPLES = 32

# Output voltage range
MIN_OUTPUT_VOLTAGE = 0
MAX_OUTPUT_VOLTAGE = europi_config.MAX_OUTPUT_VOLTAGE

PWM_FREQ = 100_000

# Digital input and output binary values.
HIGH = 1
LOW = 0

# Pin assignments
PIN_DIN = 22
PIN_AIN = 26
PIN_K1 = 27
PIN_K2 = 28
PIN_B1 = 4
PIN_B2 = 5
PIN_CV1 = 21
PIN_CV2 = 20
PIN_CV3 = 16
PIN_CV4 = 17
PIN_CV5 = 18
PIN_CV6 = 19
PIN_USB_CONNECTED = 24
PIN_TEMPERATURE = 4

# Standard max int consts.
MAX_UINT16 = 65535

# How many CV outputs are there?
# On EuroPi this 6, but future versions (e.g. EuroPi X may have more)
NUM_CVS = 6

# Import the calibration values
# These are generated by tools/calibrate.py, but do not exist by default
try:
    from calibration_values import INPUT_CALIBRATION_VALUES, OUTPUT_CALIBRATION_VALUES
except ImportError:
    # Note: run calibrate.py to get a more precise calibration.
    # Default calibration values are close-enough to reasonable performance, but aren't great
    INPUT_CALIBRATION_VALUES = [384, 44634]
    OUTPUT_CALIBRATION_VALUES = [
        0,
        6300,
        12575,
        19150,
        25375,
        31625,
        38150,
        44225,
        50525,
        56950,
        63475,
    ]
# Legacy calibration using only CV1; apply the same calibration values to each output
if type(OUTPUT_CALIBRATION_VALUES[0]) is int:
    cv1_values = OUTPUT_CALIBRATION_VALUES
    OUTPUT_CALIBRATION_VALUES = []
    for i in range(NUM_CVS):
        OUTPUT_CALIBRATION_VALUES.append(cv1_values)


def clamp(value: int | float, low: int | float, high: int | float):
    """
    Returns a value that is no lower than 'low' and no higher than 'high'.

    :param value:  The value to clamp
    :param low:  The inclusive lower bound
    :param high:  The inclusive upper bound
    """
    return max(min(value, high), low)


class AnalogueReader:
    """A base class for common analogue read methods.

    This class in inherited by classes like Knob and AnalogueInput and does
    not need to be used by user scripts.

    :param pin:  The pin ID the ADC is connected to
    :param samples:  The number of ADC samples to read. More samples results in a more
        accurate reading, but may slow down the program
    :param deadzone:  An optional deadzone to restrict noise at the high and low ends
    """

    def __init__(self, pin, samples=DEFAULT_SAMPLES, deadzone=0.0):
        self.pin_id = pin
        self.pin = ADC(Pin(pin))
        self.set_samples(samples)
        self.set_deadzone(deadzone)

    def _sample_adc(self, samples=None):
        # Over-samples the ADC and returns the average.
        value = 0
        for _ in range(samples or self._samples):
            value += self.pin.read_u16()
        return round(value / (samples or self._samples))

    def set_samples(self, samples):
        """Override the default number of sample reads with the given value."""
        if not isinstance(samples, int):
            raise ValueError(f"set_samples expects an int value, got: {samples}")
        self._samples = samples

    def set_deadzone(self, deadzone):
        """Override the default deadzone with the given value."""
        if not isinstance(deadzone, float):
            raise ValueError(f"set_deadzone expects an float value, got: {deadzone}")
        self._deadzone = deadzone

    def percent(self, samples=None, deadzone=None):
        """Return the percentage of the component's current relative range."""
        dz = self._deadzone
        if deadzone is not None:
            dz = deadzone
        value = self._sample_adc(samples) / MAX_UINT16
        value = value * (1.0 + 2.0 * dz) - dz
        return clamp(value, 0.0, 1.0)

    def range(self, steps=100, samples=None, deadzone=None):
        """Return a value (upper bound excluded) chosen by the current voltage value."""
        if not isinstance(steps, int):
            raise ValueError(f"range expects an int value, got: {steps}")
        percent = self.percent(samples, deadzone)
        if int(percent) == 1:
            return steps - 1
        return int(percent * steps)

    def choice(self, values, samples=None, deadzone=None):
        """Return a value from a list chosen by the current voltage value."""
        if not isinstance(values, list):
            raise ValueError(f"choice expects a list, got: {values}")
        percent = self.percent(samples, deadzone)
        if percent == 1.0:
            return values[-1]
        return values[int(percent * len(values))]


class AnalogueInput(AnalogueReader):
    """A class for handling the reading of analogue control voltage.

    The analogue input allows you to 'read' CV from anywhere between 0 and 12V.

    It is protected for the entire Eurorack range, so don't worry about
    plugging in a bipolar source, it will simply be clipped to 0-12V.

    The functions all take an optional parameter of ``samples``, which will
    oversample the ADC and then take an average, which will take more time per
    reading, but will give you a statistically more accurate result. The
    default is 32, provides a balance of performance vs accuracy, but if you
    want to process at the maximum speed you can use as little as 1, and the
    processor won't bog down until you get way up into the thousands if you
    wan't incredibly accurate (but quite slow) readings.

    The percent function takes an optional parameter ``deadzone``. However this
    parameter is ignored and just present to be compatible with the percent
    function of the AnalogueReader and Knob classes

    :param pin:  The pin ID the ADC is connected to
    :param min_voltage:  The minimum allowed input voltage
    :param max_voltage:  The maximum allowed input voltage
    :param deadzone:  An optional deadzone to restrict noise at the high and low ends
    """

    def __init__(self, pin, min_voltage=MIN_INPUT_VOLTAGE, max_voltage=MAX_INPUT_VOLTAGE):
        super().__init__(pin)
        self.MIN_VOLTAGE = min_voltage
        self.MAX_VOLTAGE = max_voltage
        self._gradients = []
        for index, value in enumerate(INPUT_CALIBRATION_VALUES[:-1]):
            try:
                self._gradients.append(1 / (INPUT_CALIBRATION_VALUES[index + 1] - value))
            except ZeroDivisionError:
                raise Exception(
                    "The input calibration process did not complete properly. Please complete again with rack power turned on"
                )
        self._gradients.append(self._gradients[-1])

    def percent(self, samples=None, deadzone=None):
        """Current voltage as a relative percentage of the component's range."""
        # Determine the percent value from the max calibration value.
        reading = self._sample_adc(samples) - INPUT_CALIBRATION_VALUES[0]
        max_value = max(
            reading,
            INPUT_CALIBRATION_VALUES[-1] - INPUT_CALIBRATION_VALUES[0],
        )
        return max(reading / max_value, 0.0)

    def read_voltage(self, samples=None):
        raw_reading = self._sample_adc(samples)
        reading = raw_reading - INPUT_CALIBRATION_VALUES[0]
        max_value = max(
            reading,
            INPUT_CALIBRATION_VALUES[-1] - INPUT_CALIBRATION_VALUES[0],
        )
        percent = max(reading / max_value, 0.0)
        # low precision vs. high precision
        if len(self._gradients) == 2:
            cv = 10 * max(
                reading / (INPUT_CALIBRATION_VALUES[-1] - INPUT_CALIBRATION_VALUES[0]),
                0.0,
            )
        else:
            index = int(percent * (len(INPUT_CALIBRATION_VALUES) - 1))
            cv = index + (self._gradients[index] * (raw_reading - INPUT_CALIBRATION_VALUES[index]))
        return clamp(cv, self.MIN_VOLTAGE, self.MAX_VOLTAGE)


class Knob(AnalogueReader):
    """A class for handling the reading of knob voltage and position.

    Read_position has a default value of 100, meaning if you simply use
    ``kx.read_position()`` you will return a whole number percent style value
    from 0-100.

    There is also the optional parameter of ``samples`` (which must come after the
    normal parameter), the same as the analogue input uses (the knob positions
    are 'read' via an analogue to digital converter). It has a default value
    of 256, but you can use higher or lower depending on if you value speed or
    accuracy more. If you really want to avoid 'noise' which would present as
    a flickering value despite the knob being still, then I'd suggest using
    higher samples (and probably a smaller number to divide the position by).
    The default ``samples`` value can also be set using the ``set_samples()``
    method, which will then be used on all analogue read calls for that
    component.

    An optional ``deadzone`` parameter can be used to place deadzones at both
    positions (all the way left and right) of the knob to make sure the full range
    is available on all builds. The default value is 0.01 (resulting in 1% of the
    travel used as deadzone on each side). There is usually no need to change this.

    Additionally, the ``choice()`` method can be used to select a value from a
    list of values based on the knob's position::

        def clock_division(self):
            return k1.choice([1, 2, 3, 4, 5, 6, 7, 8, 16, 32])

    When the knob is all the way to the left, the return value will be ``1``,
    at 12 o'clock it will return the mid point value of ``5`` and when fully
    clockwise, the last list item of ``32`` will be returned.

    The ADCs used to read the knob position are only 12 bit, which means that
    any read_position value above 4096 (2^12) will not actually be any finer
    resolution, but will instead just go up in steps. For example using 8192
    would only return values which go up in steps of 2.

    :param pin:  The pin ID the ADC is connected to
    :param deadzone:  An optional deadzone to restrict noise at the high and low ends
    """

    def __init__(self, pin, deadzone=0.01):
        super().__init__(pin, deadzone=deadzone)

    def percent(self, samples=None, deadzone=None):
        """Return the knob's position as relative percentage."""
        # Reverse range to provide increasing range.
        return 1.0 - super().percent(samples, deadzone)

    def read_position(self, steps=100, samples=None, deadzone=None):
        """Returns the position as a value between zero and provided integer."""
        return self.range(steps, samples, deadzone)


class DigitalReader:
    """A base class for common digital inputs methods.

    This class in inherited by classes like Button and DigitalInput and does
    not need to be used by user scripts.

    :param pin:  The pin ID we read from
    :param debounce_delay:  The time in ms that we debounce the input
    """

    def __init__(self, pin, debounce_delay=500):
        self.pin = Pin(pin, Pin.IN)
        self.debounce_delay = debounce_delay

        # Default handlers are noop callables.
        self._rising_handler = lambda: None
        self._falling_handler = lambda: None

        # Both high handler
        self._both_handler = lambda: None
        self._other = None

        # IRQ event timestamps
        self.last_rising_ms = 0
        self.last_falling_ms = 0

    def _bounce_wrapper(self, pin):
        """IRQ handler wrapper for falling and rising edge callback functions."""
        if self.value() == HIGH:
            if time.ticks_diff(time.ticks_ms(), self.last_rising_ms) < self.debounce_delay:
                return
            self.last_rising_ms = time.ticks_ms()
            return self._rising_handler()
        else:
            if time.ticks_diff(time.ticks_ms(), self.last_falling_ms) < self.debounce_delay:
                return
            self.last_falling_ms = time.ticks_ms()

            # Check if 'other' pin is set and if 'other' pins is high and if this pin has been high for long enough.
            if (
                self._other
                and self._other.value()
                and time.ticks_diff(self.last_falling_ms, self.last_rising_ms) > 500
            ):
                return self._both_handler()
            return self._falling_handler()

    def value(self):
        """The current binary value, HIGH (1) or LOW (0)."""
        # Both the digital input and buttons are normally high, and 'pulled'
        # low when on, so this is flipped to be more intuitive
        # (high when on, low when off)
        return LOW if self.pin.value() else HIGH

    def handler(self, func):
        """Define the callback function to call when rising edge detected."""
        if not callable(func):
            raise ValueError("Provided handler func is not callable")
        self._rising_handler = func
        self.pin.irq(handler=self._bounce_wrapper)

    def handler_falling(self, func):
        """Define the callback function to call when falling edge detected."""
        if not callable(func):
            raise ValueError("Provided handler func is not callable")
        self._falling_handler = func
        self.pin.irq(handler=self._bounce_wrapper)

    def reset_handler(self):
        self.pin.irq(handler=None)

    def _handler_both(self, other, func):
        """When this and other are high, execute the both func."""
        if not callable(func):
            raise ValueError("Provided handler func is not callable")
        self._other = other
        self._both_handler = func
        self.pin.irq(handler=self._bounce_wrapper)


class DigitalInput(DigitalReader):
    """A class for handling reading of the digital input.

    The Digital Input jack can detect a HIGH signal when recieving voltage >
    0.8v and will be LOW when below.

    To use the handler method, you simply define whatever you want to happen
    when a button or the digital input is triggered, and then use
    ``x.handler(new_function)``. Do not include the brackets for the function,
    and replace the 'x' in the example with the name of your input, either
    ``b1``, ``b2``, or ``din``.

    Here is another example how you can write digital input handlers to react
    to a clock source and match its trigger duration.::

        @din.handler
        def gate_on():
            # Trigger outputs with a probability set by knobs.
            cv1.value(random() > k1.percent())
            cv2.value(random() > k2.percent())

        @din.handler_falling
        def gate_off():
            # Turn off all triggers on falling clock trigger to match clock.
            cv1.off()
            cv2.off()

    When writing a handler, try to keep the code as minimal as possible.
    Ideally handlers should be used to change state and allow your main loop
    to change behavior based on the altered state. See `tips <https://docs.micropython.org/en/latest/reference/isr_rules.html#tips-and-recommended-practices>`_
    from the MicroPython documentation for more details.

    :param pin:  The pin ID we read from
    :param debounce_delay:  The time in ms that we debounce the input
    """

    def __init__(self, pin, debounce_delay=0):
        super().__init__(pin, debounce_delay)

    def last_triggered(self):
        """Return the ticks_ms of the last trigger.

        If the button has not yet been pressed, the default return value is 0.
        """
        return self.last_rising_ms


class Button(DigitalReader):
    """A class for handling push button behavior.

    Button instances have a method ``last_pressed()``
    (similar to ``DigitalInput.last_triggered()``) which can be used by your
    script to help perform some action or behavior relative to when the button
    was last pressed (or input trigger received). For example, if you want to
    call a function to display a message that a button was pressed, you could
    add the following code to your main script loop::

        # Inside the main loop...
        if b1.last_pressed() > 0 and ticks_diff(ticks_ms(), b1.last_pressed()) < 2000:
            # Call this during the 2000 ms duration after button press.
            display_button_pressed()

    Note, if a button has not yet been pressed, the ``last_pressed()`` default
    return value is 0, so you may want to add the check `if b1.last_pressed() > 0`
    before you check the elapsed duration to ensure the button has been
    pressed. This is also useful when checking if the digital input has been
    triggered with the ``DigitalInput.last_triggered()`` method.

    :param pin:  The pin ID we read from
    :param debounce_delay:  The time in ms that we debounce the input
    """

    def __init__(self, pin, debounce_delay=200):
        super().__init__(pin, debounce_delay)

    def last_pressed(self):
        """Return the ticks_ms of the last button press

        If the button has not yet been pressed, the default return value is 0.
        """
        return self.last_rising_ms


class Output:
    """A class for sending digital or analogue voltage to an output jack.

    The outputs are capable of providing 0-10V, which can be achieved using
    the ``cvx.voltage()`` method.

    So that there is no chance of not having the full range, the chosen
    resistor values actually give you a range of about 0-10.5V, which is why
    calibration is important if you want to be able to output precise voltages.

    :param pin:  The GPIO pin we use as a PWM output
    :param min_voltage: The minimum allowed output voltage
    :param max_voltage: The maximum allowed output voltage
    :param gate_voltage:  The voltage we use for gate signals (see ``.on()``, ``.off()``)
    :param calibration_values:  Calibration data for this output
    """

    def __init__(
        self,
        pin,
        min_voltage=MIN_OUTPUT_VOLTAGE,
        max_voltage=MAX_OUTPUT_VOLTAGE,
        gate_voltage=GATE_VOLTAGE,
        calibration_values=OUTPUT_CALIBRATION_VALUES[0],
    ):
        self.pin = PWM(Pin(pin))
        self.pin.freq(PWM_FREQ)
        self.MIN_VOLTAGE = min_voltage
        self.MAX_VOLTAGE = max_voltage
        self.gate_voltage = gate_voltage

        self._calibration_values = calibration_values
        self._duty = 0
        self._gradients = []
        for index, value in enumerate(self._calibration_values[:-1]):
            self._gradients.append(self._calibration_values[index + 1] - value)
        self._gradients.append(self._gradients[-1])

    def _set_duty(self, cycle):
        cycle = int(cycle)
        self.pin.duty_u16(clamp(cycle, 0, MAX_UINT16))
        self._duty = cycle

    def voltage(self, voltage=None):
        """
        Set the output voltage to the provided value within the range of MIN_VOLTAGE to MAX_VOLTAGE

        By default this range is 0-10, but can be overridden via the configuration file

        :param voltage:  The desired volts to send to the output. If None the current voltage is returned
        """
        if voltage is None:
            return self._duty / MAX_UINT16 * self.MAX_VOLTAGE
        voltage = clamp(voltage, self.MIN_VOLTAGE, self.MAX_VOLTAGE)
        index = int(voltage // 1)
        self._set_duty(self._calibration_values[index] + (self._gradients[index] * (voltage % 1)))

    def on(self):
        """
        Set the voltage HIGH according to the gate voltage

        By default this is 5V, but can be overridden via the configuration file
        """
        self.voltage(self.gate_voltage)

    def off(self):
        """Set the voltage LOW at 0 volts."""
        self._set_duty(0)

    def toggle(self):
        """Invert the Output's current state."""
        if self._duty > 500:
            self.off()
        else:
            self.on()

    def value(self, value):
        """
        Sets the output to 0V or 5V based on a binary input, 0 or 1.

        :param value:  HIGH or LOW, according to the desired state.
        """
        if value:  # silently allow booleans too!
            self.on()
        else:
            self.off()


class Thermometer:
    """
    Wrapper for the temperature sensor connected to Pin 4

    Reports the module's current temperature in Celsius.

    If the module's temperature sensor is not working correctly, the temperature will always be reported as None
    """

    # Conversion factor for converting from the raw ADC reading to sensible units
    # See Raspberry Pi Pico datasheet for details
    TEMP_CONV_FACTOR = 3.3 / 65535

    def __init__(self):
        # The Raspberry Pi Pico 2's temperature sensor doesn't work with some older
        # uPython firmwares so do some basic exception handling
        try:
            self.pin = ADC(PIN_TEMPERATURE)
        except:
            self.pin = None

    def read_temperature(self):
        """
        Read the ADC and return the current temperature

        :return:  The current temperature in Celsius, or None if the hardware did not initialze properly
        """
        if self.pin:
            # See the Pico's datasheet for the details of this calculation
            return 27 - ((self.pin.read_u16() * self.TEMP_CONV_FACTOR) - 0.706) / 0.001721
        else:
            return None


class UsbConnection:
    """
    Checks the USB terminal is connected or not

    On the original Pico we can check Pin 24, but on the Pico 2 this does not work. In that case
    check the SIE_STATUS register and check bit 16
    """

    def __init__(self):
        if europi_config.PICO_MODEL == "pico2":
            self.pin = None
        else:
            self.pin = DigitalReader(PIN_USB_CONNECTED)

    def value(self):
        """Return 0 or 1, indicating if the USB connection is disconnected or connected"""
        if self.pin:
            return self.pin.value()
        else:
            # see https://forum.micropython.org/viewtopic.php?t=10814#p59545
            SIE_STATUS = 0x50110000 + 0x50
            BIT_CONNECTED = 1 << 16

            if mem32[SIE_STATUS] & BIT_CONNECTED:
                return 1
            else:
                return 0


# Define all the I/O using the appropriate class and with the pins used
din = DigitalInput(PIN_DIN)
ain = AnalogueInput(PIN_AIN)
k1 = Knob(PIN_K1)
k2 = Knob(PIN_K2)
b1 = Button(PIN_B1)
b2 = Button(PIN_B2)

# Output CVs
cv1 = Output(PIN_CV1, calibration_values=OUTPUT_CALIBRATION_VALUES[0])
cv2 = Output(PIN_CV2, calibration_values=OUTPUT_CALIBRATION_VALUES[1])
cv3 = Output(PIN_CV3, calibration_values=OUTPUT_CALIBRATION_VALUES[2])
cv4 = Output(PIN_CV4, calibration_values=OUTPUT_CALIBRATION_VALUES[3])
cv5 = Output(PIN_CV5, calibration_values=OUTPUT_CALIBRATION_VALUES[4])
cv6 = Output(PIN_CV6, calibration_values=OUTPUT_CALIBRATION_VALUES[5])
cvs = [cv1, cv2, cv3, cv4, cv5, cv6]

# Helper object for reading the onboard temperature sensor
thermometer = Thermometer()

# Helper object to detect if the USB cable is connected or not
usb_connected = UsbConnection()

# External I2C
external_i2c = I2C(
    europi_config.EXTERNAL_I2C_CHANNEL,
    sda=Pin(europi_config.EXTERNAL_I2C_SDA),
    scl=Pin(europi_config.EXTERNAL_I2C_SCL),
    freq=europi_config.EXTERNAL_I2C_FREQUENCY,
    timeout=europi_config.EXTERNAL_I2C_TIMEOUT,
)

# Set the desired clock speed according to the configuration
# By default this will overclock the CPU, but some users may not want to
# e.g. to lower power consumption on a very power-constrained system
freq(CPU_FREQS[europi_config.PICO_MODEL][europi_config.CPU_FREQ])
