# Written by FACTS Engineering
# Copyright (c) 2023 FACTS Engineering, LLC
# Licensed under the MIT license.
"""
`P1AM`
================================================================================

Library to interface with Productivity1000 modules.
"""

import time
import struct
import board
import busio
from digitalio import DigitalInOut, Direction, Pull
from adafruit_bus_device.spi_device import SPIDevice
from micropython import const
from P1AM.constants import *

__version__ = "1.0.3"
__repo__ = "https://github.com/facts-engineering/CircuitPython_P1AM.git"

def change_bit(original, bit, state):
    """Change value of specific bit position"""
    if state:
        return original | (1 << bit)

    return original & ~(1 << bit)


def unsigned_int_to_float(bits):
    """Convert raw bytes to float representation"""
    packed = struct.pack(">L", bits)
    return struct.unpack(">f", packed)[0]


def unsigned_int_to_signed(bits):
    """Convert unsigned int value to signed int"""
    packed = struct.pack(">I", bits)
    return struct.unpack(">i", packed)[0]


def signed_int_to_unsigned(bits):
    """Convert signed int value to unsigned int"""
    packed = struct.pack(">i", bits)
    return struct.unpack(">I", packed)[0]


def write_block(data, length: int, offset: int, block_type: int):
    """Write a block of data to the Base Controller. Not typically called directly"""
    write_block_hdr = const(0x62)
    data_len = [length >> 8, length & 0xFF]
    data_offset = [offset >> 8, offset & 0xFF]

    if block_type == DISCRETE_OUT:
        byte_order = "little"
    else:
        byte_order = "big"

    data_buf[:length] = data.to_bytes(length, byte_order)
    msg = (
        bytearray([write_block_hdr, block_type] + data_len + data_offset)
        + data_buf[:length]
    )
    # print([hex(i) for i in list(msg)])
    with BC_SPI as _p1:
        _p1.write(msg)
    _data_sync()


def read_block(length: int, offset: int, block_type: int):
    """Read a block of data from the Base Controller. Not typically called directly"""
    read_block_hdr = const(0x52)
    data_len = [length >> 8, length & 0xFF]
    data_offset = [offset >> 8, offset & 0xFF]

    if block_type in (DISCRETE_OUT, DISCRETE_IN, STATUS_IN):
        byte_order = "little"
    else:
        byte_order = "big"

    msg = bytearray([read_block_hdr, block_type] + data_len + data_offset)
    with BC_SPI as _p1:
        _p1.write(msg)
    if _spi_timeout():
        with BC_SPI as _p1:
            time.sleep(0.00001)
            _p1.readinto(data_buf, start=0, end=length)
        _data_sync()
        data = int.from_bytes(data_buf[0:length], byte_order)
        return data
    
    raise RuntimeError(
        "Check External Supply Connection\nNo Base Controller Activity"
    )


def _data_sync(timeout=0.2):
    """Wait for Base Controller to update data"""
    start = time.monotonic()
    power_lost = 0
    while not ACK.value:
        if time.monotonic() - start > timeout:
            power_lost += 1
            break

    start = time.monotonic()
    while ACK.value:
        if time.monotonic() - start > timeout:
            break

    start = time.monotonic()
    while not ACK.value:
        if time.monotonic() - start > timeout:
            power_lost += 1
            break

    if power_lost == 2:
        raise RuntimeError(
            "Check External Supply Connection\nNo Base Controller Activity"
        )


def _spi_timeout(timeout=0.200):
    """Wait for Base Controller to be ready for next transaction"""
    start = time.monotonic()
    while not ACK.value:
        if (time.monotonic() - start) > timeout:
            print("timed out")
            return False
    return True


class IO_Module:  # pylint: disable=too-many-instance-attributes, too-many-branches, too-many-statements
    """
    Class for modules in P1000 Base. Each module has a collection of channel objects.
    io_module objects are generated by the base class init function

    Iterative references e.g. "my_module[3]" return the channel object. For combo
    modules you must specify type e.g. "my_module.inputs[3]".

    All list object of values for the module can be obtained by calling
    "my_module.values" or "my_module.states". Similarly this can be set for outputs
    e.g. "my_modules.values = [0xFFF, 0x7FF, 0x3FF,0x1FF]"

    Bitmapped methods are available for discrete modules as an alternative to lists when
    interacting with multiple channels

    Module statuses can be read using properties or directly as bytes

    A module can be configured using the configuration tool here:
    https://facts-engineering.github.io/config.html

    """

    def __init__(self, slot, module_id, offsets):

        self.id = module_id
        self.di = mdb[self.id][DISCRETE_IN]
        self.do = mdb[self.id][DISCRETE_OUT]
        self.ai = mdb[self.id][ANALOG_IN]
        self.ao = mdb[self.id][ANALOG_OUT]
        self.st = mdb[self.id][STATUS_IN]
        self.dt = mdb[self.id][DATA_TYPE]  # Data Type/Resolution
        self.slot = slot

        self.last_outputs = None
        self.inputs = []
        self.outputs = []

        # Config modules that need config
        if mdb[self.id][CONFIG_LEN]:
            self.current_config = mdb[self.id][DEFAULT_CONFIG]
            self.configure_module(mdb[self.id][DEFAULT_CONFIG])

        # Check if temperature Module
        self.is_temperature = mdb[self.id][DATA_TYPE] == TEMP_MODULE

        # Check if specialty module
        if mdb[self.id][DATA_TYPE] < PWM_MODULE:
            self.specialty = False
        else:
            self.specialty = True

        if self.st > 0:
            self.st_offset = offsets[STATUS_IN]

        if not self.specialty:
            if self.di > 0 or self.do > 0:  # Is a discrete module

                if self.di > 0:
                    self.di_offset = offsets[DISCRETE_IN]
                    for idx in range(self.di * 8):
                        self.inputs.append(IO_Channel(idx, DISCRETE_IN, self))
                    if USE_1_INDEXING is True:
                        self.inputs.insert(0, None)

                if self.do > 0:
                    self.do_offset = offsets[DISCRETE_OUT]
                    self.last_outputs = 0
                    for idx in range(self.do * 8):
                        self.outputs.append(IO_Channel(idx, DISCRETE_OUT, self))
                    if USE_1_INDEXING is True:
                        self.outputs.insert(0, None)
                    if self.id in odd_length_modules:
                        self.outputs.pop()

            else:  # is an analog module
                if self.ai > 0:
                    self.ai_offset = offsets[ANALOG_IN]
                    for idx in range(self.ai / analog_word_size):
                        self.inputs.append(IO_Channel(idx, ANALOG_IN, self))
                    if USE_1_INDEXING is True:
                        self.inputs.insert(0, None)

                if self.ao > 0:
                    self.ao_offset = offsets[ANALOG_OUT]
                    for idx in range(self.ao / analog_word_size):
                        self.outputs.append(IO_Channel(idx, ANALOG_OUT, self))
                    if USE_1_INDEXING is True:
                        self.outputs.insert(0, None)
                    self.last_outputs = [0] * (self.ao // analog_word_size)
        else:
            if mdb[self.id][DATA_TYPE] == PWM_MODULE:
                self.ao_offset = offsets[ANALOG_OUT]
                self.last_outputs = []
                for idx in range(self.ao / (2 * analog_word_size)):
                    # self._output_values.append([0,0]) # duty cycle, freq
                    self.outputs.append(PWM_Channel(idx, ANALOG_OUT, self))
                self.last_outputs = [0] * (self.ao // analog_word_size)
                if USE_1_INDEXING is True:
                    self.outputs.insert(0, None)

            elif mdb[self.id][DATA_TYPE] == HSC_MODULE:
                self.ao_offset = offsets[ANALOG_OUT]
                self.ai_offset = offsets[ANALOG_IN]
                self.di_offset = offsets[DISCRETE_IN]
                self.di = 1  # preserves offset, but removes unused second DI byte
                self.ao = 0  # remove base/module level accesses
                self.ai = 0
                self.st = 0
                self.last_outputs = [0] * 9  # for underlying compatability
                self.hsc_current_config = list(mdb[self.id][DEFAULT_CONFIG])
                self.update_settings = lambda: self.configure_module(
                    self.hsc_current_config
                )
                self.inputs.append(HSC_Channel(0, self))
                self.inputs.append(HSC_Channel(1, self))
                if USE_1_INDEXING is True:
                    self.inputs.insert(0, None)

    def __str__(self):
        return "Slot {} - {}".format(self.slot, mdb[self.id][P1_NAME])

    def __getitem__(self, address):
        """Returns channel object"""
        if (not self.specialty) and (
            (self.di > 0 and self.do > 0) or (self.ai > 0 and self.ao > 0)
        ):
            raise ValueError(
                f"For combo modules use module.inputs[{address}] or module.outputs[{address}]"
            )
        if self.di or self.ai:
            return self.inputs[address]

        return self.outputs[address]

    def print_state(self):
        """Prints the current reading of all channels for this module"""
        print(self)
        for channel in self.inputs:
            if channel == 0:
                continue
            print(
                "Input Channel {:>2} is reading - {}".format(
                    channel.channel + int(USE_1_INDEXING), channel.value
                )
            )
        for channel in self.outputs:
            if channel == 0:
                continue
            print(
                "Output Channel {:>2} is outputting - {}".format(
                    channel.channel + int(USE_1_INDEXING), channel.value
                )
            )

    def check_range_status(self, range_offset, channel=None):
        """Get the status value(s) for a particluar range. Property methods preferred"""
        if self.st < 12:
            raise ValueError(str(self) + " does not support range status")
        if range_offset == BURNOUT_STATUS and not self.is_temperature:
            raise ValueError(str(self) + " does not support burnout status")

        status_byte = read_block(1, self.st_offset + range_offset, STATUS_IN)
        if channel is None:
            range_list = [bool(status_byte >> idx & 1) for idx in range(self.ai // 4)]
            return range_list

        return bool(status_byte >> channel & 1)

    @property
    def missing24(self):
        """Check if module is missing 24V"""
        if self.st < 4:
            raise ValueError(str(self) + " does not support missing 24V status")
        status_byte = (
            read_block(1, self.st_offset + MISSING24V_STATUS, STATUS_IN) >> 1
        )
        return bool(status_byte & 1)

    @property
    def not_ready(self):
        """Check if temperature module is not ready to be read"""
        if not self.is_temperature:
            raise ValueError(str(self) + " does not support module not ready status")
        status_byte = (
            read_block(1, self.st_offset + MISSING24V_STATUS, STATUS_IN) >> 2
        )
        return bool(status_byte & 1)

    @property
    def over_range(self, channel=None):  # pylint: disable=property-with-parameters
        """Check over range flag(s)"""
        return self.check_range_status(OVER_RANGE_STATUS, channel)

    @property
    def under_range(self, channel=None):  # pylint: disable=property-with-parameters
        """Check under range flag(s)"""
        return self.check_range_status(UNDER_RANGE_STATUS, channel)

    @property
    def burnout(self, channel=None):  # pylint: disable=property-with-parameters
        """Check burnout flag(s)"""
        return self.check_range_status(BURNOUT_STATUS, channel)

    @property
    def values(self):
        """For non-combo modules returns a list of all channels values for a module"""
        if (self.di > 0 and self.do > 0) or (self.ai > 0 and self.ao > 0):
            raise ValueError(
                "For combo modules use module.input_values or module.output_values"
            )

        if self.di > 0 or self.ai > 0:
            return self.input_values
        return self.output_values

    @values.setter
    def values(self, states):
        """Sets the outputs of a module to a given list of values"""
        self.output_values = states

    @property
    def output_values(self):
        """Returns a list of the current output values of a module"""
        if self.do > 0:
            data = self.last_outputs
            return [data >> idx & 1 for idx in range(self.do * 8)]
        if self.ao > 0:
            return self.last_outputs
        raise ValueError(str(self) + " is not an output module")

    @output_values.setter
    def output_values(self, states):
        """Sets the outputs of a module to a given list of values"""
        try:
            iter(states)
        except TypeError:
            print("Values must be iterable")
        else:
            if self.do > 0:
                all_ch_states = [int(val) & 1 for val in states]
                for idx, state in enumerate(all_ch_states):
                    self.last_outputs = change_bit(self.last_outputs, idx, state)
                write_block(self.last_outputs, self.do, self.do_offset, DISCRETE_OUT)

            elif self.ao > 0:
                self.last_outputs[: len(states)] = states
                all_ch_bytes = 0
                for val in self.last_outputs:
                    all_ch_bytes = all_ch_bytes << 32
                    all_ch_bytes += val & 0xFFFFFFFF
                write_block(all_ch_bytes, self.ao, self.ao_offset, ANALOG_OUT)
            else:
                raise ValueError(str(self) + " is not an output module")

    @property
    def input_values(self):
        """Returns a list of the current input values of a module"""
        if self.di > 0:
            data = read_block(self.di, self.di_offset, DISCRETE_IN)
            return [data >> idx & 1 for idx in range(self.di * 8)]
        if self.ai > 0:
            data = read_block(self.ai, self.ai_offset, ANALOG_IN)
            values = [
                data >> (idx * 32) & 0xFFFFFFFF
                for idx in range((self.ai // 4) - 1, -1, -1)
            ]
            if self.is_temperature:
                return [unsigned_int_to_float(reading) for reading in values]
            return [unsigned_int_to_signed(reading) for reading in values]
        raise ValueError(str(self) + " is not an input module")
            
    @property
    def reals(self):
        """For non-combo modules returns a list of all channels values for a module"""
        if (self.di > 0 and self.do > 0) or (self.ai > 0 and self.ao > 0):
            raise ValueError(
                "For combo modules use module.input_reals or module.output_reals"
            )

        if self.di > 0 or self.ai > 0:
            return self.input_reals
        return self.output_reals

    @reals.setter
    def reals(self, states):
        """Sets the outputs of a module to a given list of values"""
        self.output_reals = states
    
    @property
    def output_reals(self):
        """Returns a list of the current output reals of a module"""
        if not len(self.outputs):
            raise ValueError(str(self) + " is not an output module")
        return [channel.real for channel in self.outputs if channel != None]
    
    @output_reals.setter
    def output_reals(self, states):
        """Sets the outputs of a module to a given list of values"""
        try:
            iter(states)
        except TypeError:
            print("Values must be iterable")
        else:
            if not len(self.outputs):
                raise ValueError(str(self) + " is not an output module")
            channels = [channel for channel in self.outputs if channel != None]
            for index, value in enumerate(states):
                channels[index].real = value

    @property
    def input_reals(self):
        """Returns a list of the current input reals of a module"""
        if not len(self.inputs):
            raise ValueError(str(self) + " is not an input module")
        return [channel.real for channel in self.inputs if channel != None]

    def do_bitmapped(self, states):
        """Bitmapped representation of discrete outputs.
        E.g. 0xAA turns on every even output"""
        if self.do > 0:
            mask = 0
            for _ in range(self.do):
                mask = mask << 8
                mask += 0xFF
            self.last_outputs = states & mask
            write_block(self.last_outputs, self.do, self.do_offset, DISCRETE_OUT)
        else:
            raise ValueError(str(self) + " is not a discrete output module")

    def di_bitmapped(self):
        """Bitmapped representation of discrete inputs.
        E.g. 0x55 means every odd input is on"""
        if self.di > 0:
            return read_block(self.di, self.di_offset, DISCRETE_IN)
        raise ValueError(str(self) + " is not a discrete input module")

    def status_bitmapped(self):
        """Returns all status bytes of a module. Prefer using properties"""
        return read_block(self.st, self.st_offset, STATUS_IN)

    def configure_module(self, config_data):
        """Send module configuration data"""
        old = self.current_config
        self.current_config = config_data
        if old != self.current_config:
            for channel in self.inputs:
                if channel != None: channel._update_range()
        config_hdr = const(0x10)
        slot = self.slot + int(not USE_1_INDEXING)
        msg = bytearray((config_hdr, slot) + tuple(config_data))
        with BC_SPI as _p1:
            _p1.write(msg)
        _data_sync()
        _data_sync()
        time.sleep(0.1)


class IO_Channel:
    """
    Class for channel in io_module class. io_module channel objects are generated by the
    io_module class init function

    The current value of the module can be obtained by referencing "my_channel.value" or
    "my_channel.state". Similarly this can be set for outputs e.g. "my_channel.value = True"

    The real-world values of the module can be obtained by referencing "my_channel.real". 
    Similarly this can be set for outputs e.g. "my_channel.real = 5.0" to set a 0-10V output to 5.0V

    Module statuses can be read using properties

    A module can be configured using the configuration tool here:
    https://facts-engineering.github.io/config.html
    """

    def __init__(self, channel: int, ch_type: int, slot: IO_Module):
        self.channel = channel
        self.ch_type = ch_type
        self.module = slot

        self._update_range()

    def __str__(self):
        type_name = ("Discrete Input", "Analog Input", "Discrete Output", "Analog Output")[self.ch_type]
        return f"{type_name} Channel {self.channel + int(USE_1_INDEXING)} in slot {self.module.slot} module {mdb[self.module.id][P1_NAME]}"

    @property
    def over_range(self):
        """Check over range flag"""
        return self.module.check_range_status(OVER_RANGE_STATUS, self.channel)

    @property
    def under_range(self):
        """Check under range flag"""
        return self.module.check_range_status(UNDER_RANGE_STATUS, self.channel)

    @property
    def burnout(self):
        """Check burnout range flag"""
        return self.module.check_range_status(BURNOUT_STATUS, self.channel)

    @property
    def value(self):
        """Returns value for this channel"""
        if self.ch_type == DISCRETE_IN:
            slot_reading = read_block(
                self.module.di, self.module.di_offset, self.ch_type
            )
            ch_reading = (slot_reading >> self.channel) & 1
            return ch_reading

        if self.ch_type == ANALOG_IN:
            data_offset = self.module.ai_offset + self.channel * analog_word_size
            ch_reading = read_block(analog_word_size, data_offset, self.ch_type)
            if self.module.is_temperature:
                return unsigned_int_to_float(ch_reading)
            return unsigned_int_to_signed(ch_reading)

        if self.ch_type == DISCRETE_OUT:
            return (self.module.last_outputs >> self.channel) & 1

        if self.ch_type == ANALOG_OUT:
            return self.module.last_outputs[self.channel]

        raise TypeError("Unsupported channel type")

    @value.setter
    def value(self, state):
        """Set output value of channel. Discretes will accept boolean or int"""
        state = int(state)
        if self.ch_type == DISCRETE_OUT:
            self.module.last_outputs = change_bit(
                self.module.last_outputs, self.channel, state
            )
            data_bytes = self.module.last_outputs
            data_len = self.module.do
            data_offset = self.module.do_offset
        elif self.ch_type == ANALOG_OUT:
            self.module.last_outputs[self.channel] = state
            data_bytes = state
            data_len = analog_word_size
            data_offset = self.module.ao_offset + self.channel * analog_word_size
        else:
            raise TypeError("Cannot assign value: {} is not an output".format(self))
        write_block(data_bytes, data_len, data_offset, self.ch_type)

    @property
    def real(self):
        """Returns converted value for this channel"""
        if self.ch_type == DISCRETE_IN or self.ch_type == DISCRETE_OUT:
            return bool(self.value)
        
        if self.module.is_temperature:
            return self.value

        if self.ch_type == ANALOG_IN or self.ch_type == ANALOG_OUT:
            value = self.range_bottom + ((self.value / (2**self.resolution - 1)) * (self.range_top - self.range_bottom))
            return value

    @real.setter
    def real(self, state):
        """Set converted output value of channel. Discretes will accept boolean or int"""
        if self.ch_type == DISCRETE_OUT:
            self.value = state
        elif self.ch_type == ANALOG_OUT:
            counts = ((state - self.range_bottom) / (self.range_top - self.range_bottom)) * (2**self.resolution - 1)
            self.value = round(counts)
    
    def _update_range(self):
        self.resolution = self.module.dt

        if self.module.id == 0x34605581:    # P1-04AD has four different range configs per channel
            channel_setting = self.module.current_config[5 + (self.channel * 4)]
            self.range_bottom = AD_RANGES[channel_setting][0]
            self.range_top = AD_RANGES[channel_setting][1]
        else:
            if self.ch_type == ANALOG_OUT or self.ch_type == ANALOG_IN:
                module_name = mdb[self.module.id][P1_NAME]
                signal_type = module_name.endswith("-1")   # True for current
                self.range_top = 20.0 if signal_type else 10.0
                if self.ch_type == ANALOG_OUT:
                    # Current out is 4-20mA, Voltage out is 0-10V
                    self.range_bottom = 4.0 if signal_type else 0.0
                if self.ch_type == ANALOG_IN:
                    # Current out is 0-20mA, Voltage out is 0-10V
                    self.range_bottom = 0.0 if signal_type else 0.0
                    if module_name.find("ADL") != -1: self.resolution += 1  # All low-res P1 inputs are 13-bit

class PWM_Channel:
    """
    Class for a PWM channel for a P1-04PWM related io_module class. io_module channel
    objects are generated by the io_module class init function.

    Channel frequency and duty cycle can be set or read via the properities.
    """

    def __init__(self, channel: int, ch_type: int, slot: IO_Module):
        self.channel = channel
        self.module = slot
        self.ch_type = ch_type

    def __str__(self):
        return "Channel {} in slot {} module {}".format(
            self.channel + int(USE_1_INDEXING),
            self.module.slot,
            mdb[self.module.id][P1_NAME],
        )

    @property
    def value(self):
        """Returns a list with the current duty cycle and frequency"""
        return [self.duty_cycle, self.frequency]

    @value.setter
    def value(self, duty_frequency):
        """Sets the current duty cycle and frequency. Pass in an iterable of [duty, freq]"""
        self.duty_cycle = duty_frequency[0]
        self.frequency = duty_frequency[1]

    @property
    def frequency(self):
        """Read the frequency of this channel in Hz"""
        return self.module.last_outputs[self.channel * 2 + PWM_FREQ]

    @frequency.setter
    def frequency(self, freq):
        """Write the frequency of this channel in Hz"""
        if freq > PWM_FREQ_MAX:
            raise ValueError(
                "Frequency {}Hz is higher than max of {}Hz".format(freq, PWM_FREQ_MAX)
            )
        self._write_state(freq, PWM_FREQ)

    @property
    def duty_cycle(self):
        """Read the duty cycle of this channel in %"""
        return self.module.last_outputs[self.channel * 2 + PWM_DUTY] / 100

    @duty_cycle.setter
    def duty_cycle(self, duty):
        """Write the duty cycle of this channel in %"""
        if duty > PWM_DUTY_MAX:
            raise ValueError(
                "Duty Cycle {} is higher than max of {}".format(duty, PWM_DUTY_MAX)
            )
        duty = int(duty * 100)
        self._write_state(duty, PWM_DUTY)

    # Property Aliases
    freq = frequency
    duty = duty_cycle

    def _write_state(self, value, pwm_param):
        self.module.last_outputs[self.channel * 2 + pwm_param] = value
        data_bytes = value
        data_len = analog_word_size
        data_offset = (
            self.module.ao_offset
            + (self.channel * analog_word_size * 2)
            + pwm_param * 4
        )
        write_block(data_bytes, data_len, data_offset, self.ch_type)


class HSC_Channel:
    """
    Class for an HSC channel for a P1-02HSC related io_module class. io_module channel
    objects are generated by the io_module class init function.

    Current position can be read with the position property.
    Configuration settings via their properties and are written out when
    the update_settings is used.
    """

    def __init__(self, channel, slot):
        self.channel = channel
        self.module = slot
        self._set_pos_trig = IO_Channel(0, ANALOG_OUT, self.module)
        if channel == 0:
            self._pos = IO_Channel(0, ANALOG_IN, self.module)
            self._status_flags = IO_Channel(3, ANALOG_IN, self.module)

            self._z_pos = IO_Channel(1, ANALOG_OUT, self.module)
            self._roll_pos = IO_Channel(3, ANALOG_OUT, self.module)
            self._set_pos_cnt = IO_Channel(4, ANALOG_OUT, self.module)
            self._set_pos_bit = 0x1
            self._config_offset = 7

        else:
            self._pos = IO_Channel(1, ANALOG_IN, self.module)
            self._status_flags = IO_Channel(4, ANALOG_IN, self.module)

            self._z_pos = IO_Channel(5, ANALOG_OUT, self.module)
            self._roll_pos = IO_Channel(7, ANALOG_OUT, self.module)
            self._set_pos_cnt = IO_Channel(8, ANALOG_OUT, self.module)
            self._set_pos_bit = 0x10000
            self._config_offset = 11

        self.rollover_position = 0x7FFFFFFF

    def __str__(self):
        return "Channel {} in slot {} module {}".format(
            self.channel + int(USE_1_INDEXING),
            self.module.slot,
            mdb[self.module.id][P1_NAME],
        )

    @property
    def _cfg(self):
        return self.module.hsc_current_config[self._config_offset]

    def _update_cfg(self, bit, value):
        current_value = self._cfg
        self.module.hsc_current_config[self._config_offset] = change_bit(
            current_value, bit, int(value)
        )

    @property
    def position(self):
        """Returns the current position as a 32-bit signed value"""
        return self._pos.value

    @position.setter
    def position(self, counts):
        """Manually set the position of this channel"""
        if self.is_rotary and (counts >= self.rollover_position or counts < 0):
            raise ValueError(
                f"""Invalid set position count: {counts}
Must be within the rotary range of {self.rollover_position}"""
            )
        if abs(counts) > 0x7FFFFFFF:
            raise ValueError(
                f"Setting position to {counts} exceeds the maximum allowed counts"
            )
        if not self.is_rotary:
            counts = signed_int_to_unsigned(counts)

        self._set_pos_cnt.value = counts
        self._set_pos_trig.value = self._set_pos_bit
        self._set_pos_trig.value = 0

    # Alias for similarity with other channel types
    value = position

    @property
    def enable_z_reset(self):
        """Returns if z-reset functionality is enabled"""
        return bool(self._cfg >> 6 & 1)

    @enable_z_reset.setter
    def enable_z_reset(self, value):
        """Set to enable z-reset functionality"""
        self._update_cfg(6, int(value))

    @property
    def z_reset_position(self):
        """Current Z-reset position value"""
        return self._z_pos.value

    @z_reset_position.setter
    def z_reset_position(self, counts):
        """Value to reset position to when z-reset is enabled"""
        if self.is_rotary and (counts >= self.rollover_position or counts < 0):
            raise ValueError(
                f"""Invalid Z reset position count: {counts}
Must be within rotary range {self.rollover_position}"""
            )
        if abs(counts) > 0x7FFFFFFF:
            raise ValueError(
                f"Setting Z reset position to {counts} exceeds the maximum allowed counts"
            )
        if not self.is_rotary:
            counts = signed_int_to_unsigned(counts)

        self._z_pos.value = counts

    @property
    def is_rotary(self):
        """Returns if channel is configured as a rotary encoder"""
        return bool(self._cfg >> 7 & 1)

    @is_rotary.setter
    def is_rotary(self, value):
        """Set channel as a rotary encoder"""
        self._update_cfg(7, int(value))

    @property
    def rollover_position(self):
        """Current rotary rollover position"""
        return self._roll_pos.value

    @rollover_position.setter
    def rollover_position(self, counts):
        """
        Set the rotary rollover position. 
        Note: Must be greater than 0 and within the maximum allowed counts
        Note: rollover is only active when the rotary is enabled.
        """
        if counts <= 0:
            raise ValueError("Zero and negative values are not valid rollover counts")

        if counts > 0x7FFFFFFF:
            raise ValueError(
                f"Setting rollover position to {counts} exceeds the maximum allowed counts"
            )
        self._roll_pos.value = counts

    @property
    def inhibit_on_input(self):
        """Returns setting for inhibiting counting based on input state"""
        inputs = (None, "1z", "3in", "2z", "4in")
        return inputs[self._cfg >> 3 & 0b111]

    @inhibit_on_input.setter
    def inhibit_on_input(self, point):
        """Select which input to inhibit counting on for this channel"""
        valid_inputs = (None, "1z", "3in", "2z", "4in")
        if point.lower() not in valid_inputs:
            raise ValueError(
                f"{point} is not a valid input\n Valid inputs are {valid_inputs}"
            )
        input_value = valid_inputs.index(point.lower())
        self.module.hsc_current_config[self._config_offset] &= 0xC7
        self.module.hsc_current_config[self._config_offset] |= input_value << 3

    @property
    def input_inhibit_active(self):
        """Returns true if channel is actively inhibited by defined input"""
        return bool(self._status_flags.value >> 17 & 1)

    @property
    def alert_new_position(self):
        """
        Returns true if a new rollover position is set.

        The old (currently running) rollover position will be active until the condition is
        corrected by either changing the rollover value to be greater than the current position,
        or by changing the current position to be less than the proposed new rollover position.

        """
        return bool(self._status_flags.value >> 16 & 1)

    @property
    def counting_mode(self):
        """Return the current counting mode of this channel"""
        mode = ("step_direction", "quadrature_4x", "quadrature_1x")
        return mode[self._cfg >> 1 & 0b11]

    @counting_mode.setter
    def counting_mode(self, mode):
        """Set the current counting mode of this channel"""
        valid_modes = ("step_direction", "quadrature_4x", "quadrature_1x")
        if mode.lower() not in valid_modes:
            raise ValueError(
                f"{mode} is not a valid mode\n Valid modes are {valid_modes}"
            )
        mode_value = valid_modes.index(mode.lower())
        self.module.hsc_current_config[self._config_offset] &= 0xF9
        self.module.hsc_current_config[self._config_offset] |= mode_value << 1

    @property
    def positive_polarity(self):
        """Return if the channel is using positive counting polarity"""
        return bool(self._cfg & 1)

    @positive_polarity.setter
    def positive_polarity(self, value):
        """Set if the channel is using positive counting polarity"""
        self._update_cfg(0, int(value))


class Base:
    """
    Class for P1000 Base.

    Iterative references e.g. "my_module[3]" return the io_module object.

    A C style API is available for those wanting their program to look more similar to
    the C P1AM library.

    A module can be configured using the configuration tool here:
    https://facts-engineering.github.io/config.html

    """

    def __init__(
        self,
        *,
        zero_indexing=False,
        cs_pin=None,
        ack_pin=None,
        spi=None,
        en_pin=None
    ):

        global CS, ACK, BC_SPI, USE_1_INDEXING  # pylint: disable=global-statement

        self.timeout = 0.200
        self.debug = True
        self.module_ids = []
        self.module_offsets = None
        self.io_modules = []
        self.p1_spi = spi

        if en_pin is None:
            self._en = DigitalInOut(board.BC_EN)
        else:
            self._en = DigitalInOut(en_pin)
        self._en.direction = Direction.OUTPUT
        self._en.value = True

        USE_1_INDEXING = not zero_indexing  # begin module/channel numbering at 0

        if cs_pin is None:
            CS = DigitalInOut(board.BC_CS)
        else:
            CS = DigitalInOut(cs_pin)
        CS.direction = Direction.OUTPUT
        CS.value = True

        if ack_pin is None:
            ACK = DigitalInOut(board.BC_READY)
        else:
            ACK = DigitalInOut(ack_pin)
        ACK.direction = Direction.INPUT
        ACK.pull = Pull.UP

        if self.p1_spi is None:
            self.p1_spi = busio.SPI(board.BC_SCK, MISO=board.BC_MISO, MOSI=board.BC_MOSI)
            BC_SPI = SPIDevice(self.p1_spi, CS, baudrate=1000000, phase=0, polarity=1)
        else:
            BC_SPI = SPIDevice(self.p1_spi, CS, baudrate=1000000, phase=0, polarity=1)
        self.spi_device = BC_SPI

        time.sleep(0.05)
        self.init()

    def __str__(self):
        modules_string = ""
        for module in self.io_modules:
            if module == 0:
                continue
            modules_string += str(module) + "\n"
        return modules_string

    def __getitem__(self, slot):
        return self.io_modules[slot]

    def _debug_print(self, print_this):
        if self.debug:
            print(print_this)
        else:
            pass

    @property
    def is_active(self):
        """Flag to see if the Base Controller has been initialised and is running"""
        resp = bytearray(1)
        with BC_SPI as _p1:
            _p1.write(b"\x04")  # Active header
        if _spi_timeout():
            with BC_SPI as _p1:
                _p1.readinto(resp, write_value=0xFF)
            _data_sync()
            return True
        return False

    def init(self):  # pylint: disable=too-many-locals, too-many-statements
        """
        Initialises base and generates io_module objects.
        """
        slots = 0
        retry = 0

        # enable base controller
        self._en.value = False
        time.sleep(0.1)
        self._en.value = True
        time.sleep(0.1)

        if _spi_timeout(5) is False:
            raise RuntimeError(
                "Check External Supply Connection\nNo Base Controller Activity"
            )

        while (slots == 0 or slots > 15) and retry < 5:
            while not ACK.value:
                pass
            with self.spi_device as _p1:
                _p1.write(b"\x02")
            time.sleep(0.05)
            with self.spi_device as _p1:
                data_bytes = bytearray(1)
                _p1.readinto(data_bytes, write_value=0xFF)
                slots = data_bytes[0]
                # print(slots)
            retry += 1
        if retry == 5:
            raise RuntimeError("Zero Modules in base")

        self.module_ids = []
        with self.spi_device as _p1:
            id_bytes_len = slots * 4
            base_constants = []
            data_bytes = bytearray(id_bytes_len)
            _p1.readinto(data_bytes)
        with self.spi_device as _p1:
            for i in range(0, id_bytes_len, 4):
                module_id = data_bytes[i : i + 4]
                module_id = int.from_bytes(module_id, "little")
                try:
                    constants = list(mdb[module_id][:P1_NAME])  # get data before name
                    constants[1], constants[2] = (
                        constants[DISCRETE_OUT],
                        constants[ANALOG_IN],
                    )  # correct for positional change
                    base_constants += constants
                    self.module_ids.append(module_id)  # store name in list
                except KeyError:
                    print("Bad ID")
                    print(hex(module_id))
            _p1.write(bytearray(base_constants))

        # calculate offsets
        current_offsets = [0, 0, 0, 0, 0]
        self.module_offsets = []
        for module in range(len(self.module_ids)):
            temp_offsets = [0, 0, 0, 0, 0]
            this_id = self.module_ids[module]
            this_module = mdb[this_id][:CONFIG_LEN]  # get data lengths before config
            for idx, data_len in enumerate(this_module):
                if data_len == 0:
                    temp_offsets[idx] = 0
                else:
                    temp_offsets[idx] = current_offsets[idx]
                    current_offsets[idx] += data_len

            self.module_offsets.append(tuple(temp_offsets))

        self.module_offsets = tuple(self.module_offsets)

        self.io_modules = []
        for idx in range(len(self.module_ids)):
            self.io_modules.append(
                IO_Module(
                    idx + int(USE_1_INDEXING),
                    self.module_ids[idx],
                    self.module_offsets[idx],
                )
            )
            # self._debug_print(self.io_modules[idx])
        if USE_1_INDEXING is True:
            self.io_modules.insert(0, None)
        time.sleep(0.05)
        return self.io_modules

    def deinit(self):
        """Disable base controller and release GPIO and SPI hardware resources"""
        global CS, ACK, BC_SPI  # pylint: disable=global-statement

        self._en.value = False
        time.sleep(0.1)
        self._en.deinit()
        CS.deinit()
        ACK.deinit()
        self.p1_spi.deinit()

        self._en = None
        CS = None
        ACK = None
        BC_SPI = None

    def print_modules(self):
        """Prints the slot number and module name.
        Slot number is relevant to current indexing scheme"""
        for cnt, module in enumerate(self.module_ids, USE_1_INDEXING):
            print("Slot {} - {}".format(cnt, mdb[module][P1_NAME]))
        print()

    def config_watchdog(
        self, milliseconds, mode="TOGGLE"
    ):  # pylint: disable=no-self-use
        """Configures watchdog timeout for base controller
        milliseconds is the time it will take for the base controller to reset
        mode determines whether the Base Controller will quickly reset the CPU
        or hold it in reset until 24V has been cycled.
        """

        config_wd_hdr = const(0x33)

        if mode.upper() == "TOGGLE":
            mode_val = 1
        elif mode.upper() == "HOLD":
            mode_val = 0
        else:
            raise ValueError(f"{mode} is not valid, use TOGGLE or HOLD")

        milliseconds = int(milliseconds) & 0xFFFF
        timeout_low = milliseconds & 0xFF
        timeout_high = milliseconds >> 8
        toggle_time = [0, 100]
        msg = bytearray(
            [config_wd_hdr, timeout_low, timeout_high] + toggle_time + [mode_val]
        )

        with BC_SPI as _p1:
            _p1.write(msg)
        _data_sync()

    def pet_watchdog(self):
        """Manually reset watchdog timer. Any function that reads or writes to the base
        controller will do this automatically, so this provides a manual way of doing so.
        """
        self._wd_control(0x30)

    def start_watchdog(self):
        """Start the watchdog timer"""
        self._wd_control(0x31)

    def stop_watchdog(self):
        """Stop the watchdog timer"""
        self._wd_control(0x32)

    def _wd_control(self, action):  # pylint: disable=no-self-use
        buf = bytearray([action])
        with BC_SPI as _p1:
            _p1.write(buf)
        if _spi_timeout():
            with BC_SPI as _p1:
                _p1.readinto(buf)
            _data_sync()

    def writeDiscrete(self, data, slot, channel=None):  # pylint: disable=invalid-name
        """Writes a value to a discrete output channel. Omitting channel writes a
        bitmapped value to all channels in the module"""
        if self.io_modules[slot].do == 0:
            raise ValueError(
                str(self.io_modules[slot]) + " is not a discrete output module"
            )

        if channel is not None:
            self.io_modules[slot].outputs[channel].value = data
        else:
            self.io_modules[slot].do_bitmapped(data)

    def readDiscrete(self, slot, channel=None):  # pylint: disable=invalid-name
        """Reads a value from a discrete input channel. Omitting channel reads a
        bitmapped value from all channels in the module"""
        if self.io_modules[slot].di == 0:
            raise ValueError(
                str(self.io_modules[slot]) + " is not a discrete input module"
            )
        if channel is not None:
            return self.io_modules[slot].inputs[channel].value
        return self.io_modules[slot].di_bitmapped()

    def writeAnalog(self, data, slot, channel):  # pylint: disable=invalid-name
        """Writes an number of counts to an analog channel"""
        if self.io_modules[slot].ao == 0 or self.io_modules[slot].specialty:
            raise ValueError(
                str(self.io_modules[slot]) + " is not a analog output module"
            )
        self.io_modules[slot].outputs[channel].value = data

    def readAnalog(self, slot, channel):  # pylint: disable=invalid-name
        """Reads a number of counts from an analog channel"""
        if self.io_modules[slot].ai == 0:
            raise ValueError(
                str(self.io_modules[slot]) + " is not a analog input module"
            )
        return self.io_modules[slot].inputs[channel].value

    def readTemperature(self, slot, channel):  # pylint: disable=invalid-name
        """Reads a temperature from a temperature module as a float"""
        if not self.io_modules[slot].is_temperature:
            raise ValueError(
                str(self.io_modules[slot]) + " is not a temperature input module"
            )
        return self.io_modules[slot][channel].value

    def readStatus(self, slot):  # pylint: disable=invalid-name
        """Reads status bytes from a specified module"""
        if self.io_modules[slot].st == 0:
            raise ValueError(str(self.io_modules[slot]) + " has no status bits")
        return self.io_modules[slot].status_bitmapped()

    def rollCall(self, module_names):  # pylint: disable=invalid-name
        """Compares a list of module names with the ones that have signed on. Raises
        an error if there is a mismatch"""
        if not isinstance(module_names, (list, tuple)) or not isinstance(
            module_names[0], str
        ):
            raise TypeError("rollCall requires a tuple or list of module names")

        actual_modules = [
            mdb[self.io_modules[idx].id][P1_NAME]
            for idx in range(int(USE_1_INDEXING), len(self.io_modules))
        ]
        expect_len = len(module_names)
        actual_len = len(actual_modules)
        if expect_len != actual_len:
            if expect_len > actual_len:
                actual_modules.extend(["Empty"] * (expect_len - actual_len))
            else:
                module_names.extend(["Empty"] * (expect_len - actual_len))

        mismatches = []
        check_len = len(actual_modules)
        for idx in range(check_len):  # pylint: disable=consider-using-enumerate
            found = actual_modules[idx]
            expected = module_names[idx]
            if found != expected:
                mismatches.append([found, expected, idx])
        if mismatches:
            err_list = [
                f"Found module {module[0]} does not match {module[1]} in slot {module[2]}"
                for module in mismatches
            ]
            raise ValueError("\n" + "\n".join(err_list))
