SLC_REPLY_START = 61 SUCCESS = 0 class RequestPacket(Packet):
    __log = logging.getLogger(f"{__module__}.{__qualname__}")
    _message_type = None
    _address_type = None
    _timeout = b"\x0a\x00"  # 10
    _encap_command = None
    response_class = ResponsePacket
    type_ = None
    VERBOSE_DEBUG = False
    no_response = False

    def __init__(self):
        super().__init__()
        self.message = b""
        self._msg_setup = False
        self._msg = []  # message data
        self._added = []
        self.error = None

    def add(self, *value: bytes):
        self._added.extend(value)
        return self

    def _setup_message(self):
        self._msg_setup = True

    def build_message(self):
        if not self._msg_setup:
            self._setup_message()
            self._msg += self._added
        self.message = b"".join(self._msg)
        return self.message

    def build_request(
        self, target_cid: bytes, session_id: int, context: bytes, option: int, **kwargs
    ) -> bytes:
        msg = self.build_message()
        common = self._build_common_packet_format(msg, addr_data=target_cid)
        header = self._build_header(
            self._encap_command, len(common), session_id, context, option
        )
        return header + common

    @staticmethod
    def _build_header(command, length, session_id, context, option) -> bytes:
        """Build the encapsulate message header

        The header is 24 bytes fixed length, and includes the command and the length of the optional data portion.

         :return: the header
        """
        try:
            return b"".join(
                [
                    command,
                    UINT.encode(length),  # Length UINT
                    UDINT.encode(session_id),  # Session Handle UDINT
                    b"\x00\x00\x00\x00",  # Status UDINT
                    context,  # Sender Context 8 bytes
                    UDINT.encode(option),  # Option UDINT
                ]
            )

        except Exception as err:
            raise CommError("Failed to build request header") from err

    def _build_common_packet_format(self, message, addr_data=None) -> bytes:
        addr_data = (
            b"\x00\x00"
            if addr_data is None
            else UINT.encode(len(addr_data)) + addr_data
        )

        return b"".join(
            [
                b"\x00\x00\x00\x00",  # Interface Handle: shall be 0 for CIP
                self._timeout,
                b"\x02\x00",  # Item count: should be at list 2 (Address and Data)
                self._address_type,
                addr_data,
                self._message_type,
                UINT.encode(len(message)),
                message,
            ]
        )

    def __repr__(self):
        return f"{self.__class__.__name__}(message={_r(self._msg)})"

    __str__ = __repr__ class ResponsePacket(Packet):
    __log = logging.getLogger(f"{__module__}.{__qualname__}")

    def __init__(self, request: "RequestPacket", raw_data: bytes = None):
        super().__init__()
        self.request = request
        self.raw = raw_data
        self._error = None
        self.service = None
        self.service_status = None
        self.data = None
        self.command = None
        self.command_status = None

        self._is_valid = False

        if raw_data is not None:
            self._parse_reply()
        else:
            self._error = "No response data received"

    def __bool__(self):
        return self.is_valid()

    @property
    def error(self) -> Optional[str]:
        if self.is_valid():
            return None
        if self._error is not None:
            return self._error
        if self.command_status not in (None, SUCCESS):
            return self.command_extended_status()
        if self.service_status not in (None, SUCCESS):
            return self.service_extended_status()
        return "Unknown Error"

    def is_valid(self) -> bool:
        return all(
            (
                self._error is None,
                self.command is not None,
                self.command_status == SUCCESS,
            )
        )

    def _parse_reply(self):
        try:
            self.command = self.raw[:2]
            self.command_status = DINT.decode(
                self.raw[8:12]
            )  # encapsulation status check
        except Exception as err:
            self.__log.exception("Failed to parse reply")
            self._error = f"Failed to parse reply - {err}"

    def command_extended_status(self) -> str:
        return "Unknown Error"

    def service_extended_status(self) -> str:
        return "Unknown Error"

    def __repr__(self):
        service = self.service or None
        return f"{self.__class__.__name__}(service={service!r}, command={self.command!r}, error={self.error!r})"

    __str__ = __repr__ class SendUnitDataRequestPacket(RequestPacket):
    __log = logging.getLogger(f"{__module__}.{__qualname__}")
    _message_type = DataItem.connected
    _address_type = AddressItem.connection
    response_class = SendUnitDataResponsePacket
    _encap_command = EncapsulationCommands.send_unit_data

    def __init__(self, sequence: cycle):
        super().__init__()
        self._sequence = next(sequence) if isinstance(sequence, Generator) else sequence

    def _setup_message(self):
        super()._setup_message()
        self._msg.append(UINT.encode(self._sequence))

    def build_request(
        self, target_cid: bytes, session_id: int, context: bytes, option: int, **kwargs
    ):

        return super().build_request(target_cid, session_id, context, option, **kwargs) class SendUnitDataResponsePacket(ResponsePacket):
    __log = logging.getLogger(f"{__module__}.{__qualname__}")

    def __init__(self, request: "SendUnitDataRequestPacket", raw_data: bytes = None):
        super().__init__(request, raw_data)

    def _parse_reply(self):
        try:
            super()._parse_reply()
            self.service = Services.get(Services.from_reply(self.raw[46:47]))
            self.service_status = USINT.decode(self.raw[48:49])
            self.data = self.raw[50:]
        except Exception as err:
            self.__log.exception("Failed to parse reply")
            self._error = f"Failed to parse reply - {err}"

    def is_valid(self) -> bool:
        valid = self.service_status == SUCCESS or (
            self.service_status == INSUFFICIENT_PACKETS
            and self.service in MULTI_PACKET_SERVICES
        )
        return all((super().is_valid(), valid))

    def command_extended_status(self) -> str:
        status = get_service_status(self.command_status)
        ext_status = get_extended_status(self.raw, 48)
        if ext_status:
            return f"{status} - {ext_status}"

        return status

    def service_extended_status(self) -> str:
        status = get_service_status(self.service_status)
        ext_status = get_extended_status(self.raw, 48)
        if ext_status:
            return f"{status} - {ext_status}"

        return status class CIPDriver:
    """
    A base CIP driver for the SLCDriver and LogixDriver classes.  Implements common CIP services like
    (un)registering sessions, forward open/close, generic messaging, etc.
    """

    __log = logging.getLogger(f"{__module__}.{__qualname__}")
    _auto_slot_cip_path = False

    def __init__(self, path: str, *args, **kwargs):
        """
        :param path: CIP path to intended target

            The path may contain 3 forms:

            - IP Address Only (``10.20.30.100``) - Use for a ControlLogix PLC is in slot 0 or if connecting to a CompactLogix or Micro800 PLC.
            - IP Address/Slot (``10.20.30.100/1``) - (ControlLogix) if PLC is not in slot 0
            - CIP Routing Path (``1.2.3.4/backplane/2/enet/6.7.8.9/backplane/0``) - Use for more complex routing.

            .. note::

                Both the IP Address and IP Address/Slot options are shortcuts, they will be replaced with the
                CIP path automatically.  The ``enet`` / ``backplane`` (or ``bp``) segments are symbols for the CIP routing
                port numbers and will be replaced with the correct value.

        """

        self._sequence: cycle = cycle(65535, start=1)
        self._sock: Optional[Socket] = None
        self._session: int = 0
        self._connection_opened: bool = False
        self._target_cid: Optional[bytes] = None
        self._target_is_connected: bool = False
        self._info: Dict[str, Any] = {}
        self._cip_path = path
        ip, _path = parse_connection_path(path, self._auto_slot_cip_path)

        self._cfg = {
            "context": b"_pycomm_",
            "protocol version": b"\x01\x00",
            "rpi": 5000,
            "port": 44818,
            "timeout": 10,
            "ip address": ip,
            "cip_path": _path,
            "option": 0,
            "cid": b"\x27\x04\x19\x71",
            "csn": b"\x27\x04",
            "vid": b"\x09\x10",
            "vsn": b"\x09\x10\x19\x71",
            "extended forward open": True,
            "connection_size": 4000,
        }

    def __enter__(self):
        self.open()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        try:
            self.close()
        except CommError:
            self.__log.exception("Error closing connection.")
            return False
        else:
            if not exc_type:
                return True
            self.__log.exception("Unhandled Client Error", exc_info=(exc_type, exc_val, exc_tb))
            return False

    def __repr__(self):
        return f"{self.__class__.__name__}(path={self._cip_path})"

    def __str__(self):
        _rev = self._info.get("revision", {"major": -1, "minor": -1})
        return f"Device: {self._info.get('product_type', 'None')}, Revision: {_rev['major']}.{_rev['minor']}"

    @property
    def connected(self) -> bool:
        """
        Read-Only Property to check whether or not a connection is open.

        :return: True if a connection is open, False otherwise
        """
        return self._connection_opened

    @property
    def connection_size(self):
        """CIP connection size, ``4000`` if using Extended Forward Open else ``500``"""
        return self._cfg["connection_size"]

    @classmethod
    def list_identity(cls, path) -> Optional[str]:
        """
        Uses the ListIdentity service to identify the target

        :return: device identity if reply contains valid response else None
        """
        plc = cls(path)
        plc.open()
        identity = plc._list_identity()
        plc.close()
        return identity

    @classmethod
    def discover(cls) -> List[Dict[str, Any]]:
        """
        Discovers available devices on the current network(s).
        Returns a list of the discovered devices Identity Object (as ``dict``).
        """
        cls.__log.info("Discovering devices...")
        ip_addrs = [
            sockaddr[0]
            for family, _, _, _, sockaddr in socket.getaddrinfo(socket.gethostname(), None)
            if family == socket.AddressFamily.AF_INET
        ]

        driver = CIPDriver("0.0.0.0")  # dummy driver for creating the list_identity request
        request = ListIdentityRequestPacket()
        message = request.build_request(None, driver._session, b"\x00" * 8, 0)
        devices = []

        for ip in ip_addrs:
            cls.__log.debug(f"Broadcasting discover for IP: %s", ip)
            devices += cls._broadcast_discover(ip, message, request)

        if not devices:
            cls.__log.debug(
                "No devices found so far, attempting broadcast without binding to an IP."
            )
            devices += cls._broadcast_discover(None, message, request)

        if devices:
            cls.__log.info(f"Discovered %d device(s): %r", len(devices), devices)
        else:
            cls.__log.info("No Ethernet/IP devices discovered")

        return devices

    @classmethod
    def _broadcast_discover(cls, ip, message, request):
        devices = []
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            sock.settimeout(1)
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
            if ip:
                sock.bind((ip, 0))

            sock.sendto(message, ("255.255.255.255", 44818))

            while True:
                try:
                    resp = sock.recv(4096)
                    response = request.response_class(request, resp)
                    if response:
                        devices.append(response.identity)
                except Exception:
                    break
        except Exception:
            cls.__log.exception("Error broadcasting discover request")
        finally:
            return devices

    def _list_identity(self):
        request = ListIdentityRequestPacket()
        response = self.send(request)
        return response.identity

    def get_module_info(self, slot: int) -> dict:
        """
        Get the Identity object for a given slot in the rack of the current connection
        """
        try:
            response = self.generic_message(
                service=Services.get_attributes_all,
                class_code=ClassCode.identity_object,
                instance=b"\x01",
                connected=False,
                unconnected_send=True,
                route_path=PADDED_EPATH.encode(
                    (
                        *self._cfg["cip_path"][:-1],
                        PortSegment("bp", slot),
                    ),
                    length=True,
                    pad_length=True,
                ),
                name="get_module_info",
            )

            if response:
                return ModuleIdentityObject.decode(response.value)
            else:
                raise ResponseError(f"generic_message did not return valid data - {response.error}")

        except Exception as err:
            raise ResponseError("error getting module info") from err

    def open(self):
        """
        Creates a new Ethernet/IP socket connection to target device and registers a CIP session.

        :return: True if successful, False otherwise
        """
        # handle the socket layer
        if self._connection_opened:
            return True
        try:
            if self._sock is None:
                self._sock = Socket()
            self.__log.debug(f'Opening connection to {self._cfg["ip address"]}')
            self._sock.connect(self._cfg["ip address"], self._cfg["port"])
            self._connection_opened = True
            self._cfg["cid"] = urandom(4)
            self._cfg["vsn"] = urandom(4)
            if self._register_session() is None:
                self.__log.error("Session not registered")
                return False
            return True
        except Exception as err:
            raise CommError("failed to open a connection") from err

    def _register_session(self) -> Optional[int]:
        """
        Registers a new CIP session with the target.

        :return: the session id if session registered successfully, else None
        """
        if self._session:
            return self._session

        request = RegisterSessionRequestPacket(self._cfg["protocol version"])

        response = self.send(request)
        if response:
            self._session = response.session
            self.__log.info("Session=%d has been registered.", response.session)
            return self._session

        self.__log.error("Failed to register session: %s", response.error)
        return None

    def _forward_open(self):
        """
        Opens a new connection with the target PLC using the *Forward Open* or *Extended Forward Open* service.

        :return: True if connection is open or was successfully opened, False otherwise
        """

        if self._target_is_connected:
            return True

        if self._session == 0:
            raise CommError("A session must be registered before a Forward Open")

        init_net_params = 0b_0100_0010_0000_0000  # CIP Vol 1 - 3-5.5.1.1

        if self._cfg["extended forward open"]:
            net_params = UDINT.encode((self.connection_size & 0xFFFF) | init_net_params << 16)
        else:
            net_params = UINT.encode((self.connection_size & 0x01FF) | init_net_params)

        route_path = PADDED_EPATH.encode(self._cfg["cip_path"] + MSG_ROUTER_PATH, length=True)
        service = (
            ConnectionManagerServices.forward_open
            if not self._cfg["extended forward open"]
            else ConnectionManagerServices.large_forward_open
        )

        forward_open_msg = [
            PRIORITY,
            TIMEOUT_TICKS,
            b"\x00\x00\x00\x00",  # O->T produced connection ID, not needed for us so leave blank
            self._cfg["cid"],
            self._cfg["csn"],
            self._cfg["vid"],
            self._cfg["vsn"],
            TIMEOUT_MULTIPLIER,
            b"\x00\x00\x00",  # reserved
            b"\x01\x40\x20\x00",  # O->T RPI in microseconds, RPIs are not important for us so fixed value is fine
            net_params,
            b"\x01\x40\x20\x00",  # T->O RPI
            net_params,
            TRANSPORT_CLASS,
        ]

        response = self.generic_message(
            service=service,
            class_code=ClassCode.connection_manager,
            instance=ConnectionManagerInstances.open_request,
            request_data=b"".join(forward_open_msg),
            route_path=route_path,
            connected=False,
            name="forward_open",
        )

        if response:
            self._target_cid = response.value[:4]
            self._target_is_connected = True
            self.__log.info(
                f"{'Extended ' if self._cfg['extended forward open'] else ''}Forward Open succeeded. Target CID={self._target_cid}"
            )
            return True
        self.__log.error(f"forward_open failed - {response.error}")
        return False

    def close(self):
        """
        Closes the current connection and un-registers the session.
        """
        errs = []
        try:
            if self._target_is_connected:
                self._forward_close()
            if self._session != 0:
                self._un_register_session()
        except Exception as err:
            errs.append(err)
            self.__log.exception("Error closing connection with device")

        try:
            if self._sock:
                self._sock.close()
        except Exception as err:
            errs.append(err)
            self.__log.exception("Error closing socket connection")

        self._sock = None
        self._target_is_connected = False
        self._session = 0
        self._connection_opened = False

        if errs:
            raise CommError(" - ".join(str(e) for e in errs))

    def _un_register_session(self):
        """
        Un-registers the current session with the target.
        """
        request = UnRegisterSessionRequestPacket()
        self.send(request)
        self._session = None
        self.__log.info("Session Unregistered")

    def _forward_close(self):
        """CIP implementation of the forward close message

        Each connection opened with the forward open message need to be closed.
        Refer to ODVA documentation Volume 1 3-5.5.3

        :return: False if any error in the replayed message
        """

        if self._session == 0:
            raise CommError("A session must be registered before a Forward Open")

        route_path = PADDED_EPATH.encode(
            self._cfg["cip_path"] + MSG_ROUTER_PATH, length=True, pad_length=True
        )

        forward_close_msg = [
            PRIORITY,
            TIMEOUT_TICKS,
            self._cfg["csn"],
            self._cfg["vid"],
            self._cfg["vsn"],
        ]

        response = self.generic_message(
            service=ConnectionManagerServices.forward_close,
            class_code=ClassCode.connection_manager,
            instance=ConnectionManagerInstances.open_request,
            connected=False,
            route_path=route_path,
            request_data=b"".join(forward_close_msg),
            name="forward_close",
        )
        if response:
            self._target_is_connected = False
            self.__log.info("Forward Close succeeded.")
            return True

        self.__log.error("forward_close failed: %s", response.error)
        return False

    def generic_message(
        self,
        service: Union[int, bytes],
        class_code: Union[int, bytes],
        instance: Union[int, bytes],
        attribute: Union[int, bytes] = b"",
        request_data: Any = b"",
        data_type: Optional[Union[Type[DataType], DataType]] = None,
        name: str = "generic",
        connected: bool = True,
        unconnected_send: bool = False,
        route_path: Union[bool, Sequence[CIPSegment], bytes, str] = True,
        **kwargs,
    ) -> Tag:
        """
        Perform a generic CIP message.  Similar to how MSG instructions work in Logix.

        :param service: service code for the request (single byte)
        :param class_code: request object class ID
        :param instance: ID for an instance of the class
                         If set with 0, request class attributes.
        :param attribute: (optional) attribute ID for the service/class/instance
        :param request_data: (optional) any additional data required for the request.
        :param data_type: a ``DataType`` class that will be used to decode the response, None to return just bytes
        :param name:  return ``Tag.tag`` value, arbitrary but can be used for tracking returned Tags
        :param connected: ``True`` if service required a CIP connection (forward open), ``False`` to use UCMM
        :param unconnected_send: (Unconnected Only) wrap service in an UnconnectedSend service
        :param route_path: (Unconnected Only) ``True`` to use current connection route to destination, ``False`` to ignore,
                           Or provide a path string, list of segments to be encoded as a PADDED_EPATH, or
                           an already encoded path.
        :return: a Tag with the result of the request. (Tag.value for writes will be the request_data)
        """

        if connected:
            with_forward_open(lambda _: None)(self)

        _kwargs = {
            "service": service,
            "class_code": class_code,
            "instance": instance,
            "attribute": attribute,
            "request_data": request_data,
            "data_type": data_type,
        }

        if connected:
            _kwargs["sequence"] = self._sequence
        else:
            if route_path is True:
                _kwargs["route_path"] = PADDED_EPATH.encode(
                    self._cfg["cip_path"], length=True, pad_length=True
                )
            elif isinstance(route_path, str):
                _kwargs["route_path"] = PADDED_EPATH.encode(
                    parse_cip_route(route_path), length=True, pad_length=True
                )
            elif isinstance(route_path, bytes):
                _kwargs["route_path"] = route_path
            elif route_path:
                _kwargs["route_path"] = PADDED_EPATH.encode(
                    route_path, length=True, pad_length=True
                )

            _kwargs["unconnected_send"] = unconnected_send

        req_class = GenericConnectedRequestPacket if connected else GenericUnconnectedRequestPacket
        request = req_class(**_kwargs)

        self.__log.info("Sending generic message: %s", name)
        response = self.send(request)
        if not response:
            self.__log.error("Generic message %r failed: %s", name, response.error)
        else:
            self.__log.info("Generic message %r completed", name)

        if kwargs.get("return_response_packet"):
            return Tag(name, response, data_type, error=response.error)

        return Tag(name, response.value, data_type, error=response.error)

    def send(self, request: RequestPacket) -> ResponsePacket:
        if not request.error:
            request_kwargs = {
                "target_cid": self._target_cid,
                "session_id": self._session,
                "context": self._cfg["context"],
                "option": self._cfg["option"],
                "sequence": self._sequence,
            }

            self._send(request.build_request(**request_kwargs))
            self.__log.debug(f"Sent: %r", request)
            reply = None if request.no_response else self._receive()
        else:
            reply = None

        response = request.response_class(request, reply)
        self.__log.debug(f"Received: %r", response)
        return response

    def _send(self, message):
        try:
            self.__log.verbose(">>> SEND >>> \n%s", PacketLazyFormatter(message))
            self._sock.send(message)
        except Exception as err:
            raise CommError("failed to send message") from err

    def _receive(self):
        try:
            reply = self._sock.receive()
        except Exception as err:
            raise CommError("failed to receive reply") from err
        else:
            self.__log.verbose("<<< RECEIVE <<< \n%s", PacketLazyFormatter(reply))
            return reply class ResponseError(PycommError):
    """
    For exceptions raised during handling for responses to requests
    """ class RequestError(PycommError):
    """
    For exceptions raised due to issues building requests or processing of user supplied data
    """ class SLCDriver(CIPDriver):
    """
    An Ethernet/IP Client driver for reading and writing of data files in SLC or MicroLogix PLCs.
    """

    __log = logging.getLogger(f"{__module__}.{__qualname__}")
    _auto_slot_cip_path = True

    def __init__(self, path, *args, **kwargs):
        super().__init__(path, *args, large_packets=False, **kwargs)

    def _msg_start(self):
        """
        No idea what this part is, but it starts all messages
        """
        return b"".join(
            (
                b"\x4b",
                b"\x02",
                b"\x20",  # 8-bit class
                PCCC_PATH,
                b"\x07",
                self._cfg["vid"],
                self._cfg["vsn"],
            )
        )

    @with_forward_open
    def read(self, *addresses: str) -> ReadWriteReturnType:
        """
        Reads data file addresses. To read multiple words add the word count to the address using curly braces,
        e.g. ``N120:10{10}``.

        Does not track request/response size like the CLXDriver.

        :param addresses: one or many data file addresses to read
        :return: a single or list of ``Tag`` objects
        """
        results = [self._read_tag(tag) for tag in addresses]

        if len(results) == 1:
            return results[0]

        return results

    def _read_tag(self, tag) -> Tag:
        _tag = parse_tag(tag)
        if _tag is None:
            raise RequestError(f"Error parsing the tag passed to read() - {tag}")

        message_request = [
            self._msg_start(),
            # page 83 of eip manual
            SLC_CMD_CODE,  # request command code
            b"\x00",  # status code
            UINT.encode(next(self._sequence)),  # transaction identifier
            SLC_FNC_READ,  # function code
            USINT.encode(PCCC_DATA_SIZE[_tag["file_type"]] * _tag["element_count"]),  # byte size
            USINT.encode(int(_tag["file_number"])),
            PCCC_DATA_TYPE[_tag["file_type"]],
            USINT.encode(int(_tag["element_number"])),
            USINT.encode(int(_tag.get("pos_number", 0))),  # sub-element number
        ]

        request = SendUnitDataRequestPacket(self._sequence)
        request.add(b"".join(message_request))
        response = self.send(request)
        self.__log.debug(f"SLC read_tag({tag})")

        status = request_status(response.raw)
        if status is not None:
            return Tag(_tag["tag"], None, _tag["file_type"], status)

        try:
            return _parse_read_reply(_tag, response.raw[SLC_REPLY_START:])
        except ResponseError as err:
            self.__log.exception(f'Failed to parse read reply for {_tag["tag"]}')
            return Tag(_tag["tag"], None, _tag["file_type"], str(err))

    @with_forward_open
    def write(self, *address_values: Tuple[str, TagValueType]) -> ReadWriteReturnType:
        """
        Write values to data file addresses.  To write to multiple words in a file use curly braces in the address
        to indicate the number of words, then set the value to a list of values to write e.g. ``('N120:10{10}', [1, 2, ...])``.

        Does not track request/response size like the CLXDriver.


        :param address_values: one or many 2-element tuples of (address, value)
        :return: a single or list of ``Tag`` objects
        """
        results = [self._write_tag(tag, value) for tag, value in address_values]

        if len(results) == 1:
            return results[0]

        return results

    def _write_tag(self, tag: str, value: TagValueType) -> Tag:
        """write tag from a connected plc
        Possible combination can be passed to this method:
            c.write_tag('N7:0', [-30, 32767, -32767])
            c.write_tag('N7:0', 21)
            c.read_tag('N7:0', 10)
        It is not possible to write status bit
        :return: None is returned in case of error
        """
        _tag = parse_tag(tag)
        if _tag is None:
            raise RequestError(f"Error parsing the tag passed to write() - {tag}")

        _tag["data_size"] = PCCC_DATA_SIZE[_tag["file_type"]]

        message_request = [
            self._msg_start(),
            SLC_CMD_CODE,
            b"\x00",
            UINT.encode(next(self._sequence)),
            SLC_FNC_WRITE,
            USINT.encode(_tag["data_size"] * _tag["element_count"]),
            USINT.encode(int(_tag["file_number"])),
            PCCC_DATA_TYPE[_tag["file_type"]],
            USINT.encode(int(_tag["element_number"])),
            USINT.encode(int(_tag.get("pos_number", 0))),
            writeable_value(_tag, value),
        ]
        request = SendUnitDataRequestPacket(self._sequence)
        request.add(b"".join(message_request))
        response = self.send(request)

        status = request_status(response.raw)
        if status is not None:
            return Tag(_tag["tag"], None, _tag["file_type"], status)

        return Tag(_tag["tag"], value, _tag["file_type"], None)

    @with_forward_open
    def get_processor_type(self):

        msg_request = (
            self._msg_start(),
            # diagnostic status - CMD 06, FNC 03 - pg93 DF1 manual (1770-rm516)
            b"\x06",  # CMD
            b"\x00",  # status code
            UINT.encode(next(self._sequence)),  # transaction identifier
            b"\x03",  # FNC
        )

        request = SendUnitDataRequestPacket(self._sequence)
        request.add(b"".join(msg_request))
        response = self.send(request)
        if response:
            try:
                typ = response.raw[SLC_REPLY_START:][5:16].decode("utf-8").strip()
            except Exception as err:
                self.__log.exception(f"failed getting processor type: {err}")
                typ = None
            finally:
                return typ
        else:
            self.__log.error(
                f"failed to get processor type: {request_status(response.raw)}",
            )
            return None

    @with_forward_open
    def get_file_directory(self):
        plc_type = self.get_processor_type()

        if plc_type is not None:
            sys0_info = _get_sys0_info(plc_type)
            # file_type, element = _get_file_and_element_for_plc_type(plc_type)
            sys0_info["size"] = self._get_file_directory_size(sys0_info)

            if sys0_info["size"] is not None:
                data = self._read_whole_file_directory(sys0_info)
                return _parse_file0(sys0_info, data)
            else:
                raise ResponseError("Failed to read file directory size")
        else:
            raise ResponseError("Failed to read processor type")

    def _get_file_directory_size(self, sys0_info):
        msg_request = [
            self._msg_start(),
            SLC_CMD_CODE,  # request command code
            b"\x00",  # status code
            UINT.encode(next(self._sequence)),  # transaction identifier
            b"\xa1",  # function code, from RSLinx capture
            sys0_info["size_len"],  # size
            b"\x00",  # file number
            sys0_info["file_type"],
            sys0_info["size_element"],
        ]

        request = SendUnitDataRequestPacket(self._sequence)
        request.add(b"".join(msg_request))
        response = self.send(request)
        status = request_status(response.raw)
        if status is None:
            try:
                size = UINT.decode(response.raw[SLC_REPLY_START:]) - sys0_info.get("size_const", 0)
                self.__log.debug(f"SYS 0 file size: {size}")
            except Exception as err:
                self.__log.exception("failed to parse size of File 0")
                size = None
            finally:
                return size
        else:
            self.__log.error(
                f"failed to read size of File 0: {status}",
            )
            return None

    def _read_whole_file_directory(self, sys0_info):
        file0_data = b""
        offset = 0
        file0_size = sys0_info["size"]
        file_type = sys0_info["file_type"]

        while len(file0_data) < file0_size:
            bytes_remaining = file0_size - len(file0_data)
            size = 0x50 if bytes_remaining > 0x50 else bytes_remaining

            msg_request = [
                self._msg_start(),
                # page 83 of eip manual
                SLC_CMD_CODE,  # request command code
                b"\x00",  # status code
                UINT.encode(next(self._sequence)),  # transaction identifier
                b"\xa1",
                # SLC_FNC_READ,  # function code
                USINT.encode(size),  # size
                b"\x00",  # file number
                file_type,
            ]

            msg_request += (
                [USINT.encode(offset)] if offset < 256 else [b"\xFF", UINT.encode(offset)]
            )

            request = SendUnitDataRequestPacket(self._sequence)
            request.add(b"".join(msg_request))
            response = self.send(request)
            status = request_status(response.raw)
            if status is None:
                data = response.raw[SLC_REPLY_START:]
                offset += len(data) // 2
                file0_data += data
            else:
                msg = f"Error reading File 0 contents: {status}"
                self.__log.error(msg)
                raise ResponseError(msg)

        return file0_data def _parse_read_reply(tag, data) -> Tag:
    try:
        bit_read = tag.get("address_field", 0) == 3
        bit_position = int(tag.get("sub_element") or 0)
        data_size = PCCC_DATA_SIZE[tag["file_type"]]
        unpack_func = PCCCDataTypes[tag["file_type"]].decode
        if bit_read:
            new_value = 0
            if tag["file_type"] in {"T", "C"}:
                if bit_position == PCCC_CT["PRE"]:
                    return Tag(
                        tag["tag"],
                        unpack_func(data[new_value + 2 : new_value + 2 + data_size]),
                        tag["file_type"],
                        None,
                    )

                elif bit_position == PCCC_CT["ACC"]:
                    return Tag(
                        tag["tag"],
                        unpack_func(data[new_value + 4 : new_value + 4 + data_size]),
                        tag["file_type"],
                        None,
                    )

            tag_value = unpack_func(data[new_value : new_value + data_size])
            return Tag(tag["tag"], get_bit(tag_value, bit_position), tag["file_type"], None)

        else:
            values_list = [
                unpack_func(data[i : i + data_size]) for i in range(0, len(data), data_size)
            ]
            if len(values_list) > 1:
                return Tag(tag["tag"], values_list, tag["file_type"], None)
            else:
                return Tag(tag["tag"], values_list[0], tag["file_type"], None)
    except Exception as err:
        raise ResponseError("Failed parsing tag read reply") from err class Tag(NamedTuple):
    tag: str  #: tag name of tag read/written or request name (generic message)
    value: Any  #: value read/written, may be ``None`` on error
    type: Optional[str] = None  #: data type of tag
    error: Optional[str] = None  #: error message if unsuccessful, else ``None``

    def __bool__(self):
        """
        ``True`` if both ``value`` is not ``None`` and ``error`` is ``None``, ``False`` otherwise
        """
        return self.value is not None and self.error is None

    def __str__(self):
        return f"{self.tag}, {_r(self.value)}, {self.type}, {self.error}"

    def __repr__(self):
        return f"{self.__class__.__name__}(tag={self.tag!r}, value={self.value!r}, type={self.type!r}, error={self.error!r})"
from pycomm3.const import SLC_REPLY_START, SUCCESS
from pycomm3.packets import ResponsePacket, SendUnitDataResponsePacket, RequestPacket, SendUnitDataRequestPacket
from pycomm3.cip_driver import CIPDriver
from unittest import mock
from pycomm3.exceptions import ResponseError, RequestError
from pycomm3.slc_driver import SLCDriver, _parse_read_reply
from pycomm3.tag import Tag
import pytest
"""Tests for the SLCDriver

The methods and functions in the slc_driver.py file are extraordinarily
difficult to test.
"""




CONNECT_PATH = '192.168.1.100/1'



def test_slc__read_tag_raises_requesterror_for_none():
    driver = SLCDriver(CONNECT_PATH)

    with mock.patch('pycomm3.slc_driver.parse_tag', return_value=None):
        with pytest.raises(RequestError):
            driver._read_tag(None)


def test_slc__read_tag_returns_tag():
    TEST_PARSED_TAG = {
        'file_type': 'N',
        'file_number': '1',
        'element_number': '1',
        'pos_number': '1',
        'address_field': 2,
        'element_count': 1,
        'tag': 'Dummy Parsed Tag'
    }
    RESPONSE_PACKET = ResponsePacket(RequestPacket(), b'\x00')
    driver = SLCDriver(CONNECT_PATH)

    with mock.patch('pycomm3.slc_driver.parse_tag', return_value=TEST_PARSED_TAG), \
         mock.patch.object(SLCDriver, 'send') as mock_send:
        mock_send.return_value = RESPONSE_PACKET
        assert type(driver._read_tag("Anything at all")) == Tag


def test_slc_get_processor_type_returns_none_if_falsy_response():
    driver = SLCDriver(CONNECT_PATH)
    RESPONSE_PACKET = SendUnitDataResponsePacket(SendUnitDataRequestPacket(driver._sequence), b'\x00')

    with mock.patch.object(SLCDriver, 'send') as mock_send, \
         mock.patch.object(CIPDriver, '_forward_open'):
        mock_send.return_value = RESPONSE_PACKET
        assert driver.get_processor_type() is None


def test_slc_get_processor_type_returns_none_if_parsing_exception():
    driver = SLCDriver(CONNECT_PATH)
    EXPECTED_TYPE = None

    with mock.patch.object(SLCDriver, 'send') as mock_send, \
         mock.patch.object(CIPDriver, '_forward_open'), \
         mock.patch.object(ResponsePacket, '_parse_reply'):
        RESPONSE_PACKET = SendUnitDataResponsePacket(SendUnitDataRequestPacket(driver._sequence), b'\x00')
        RESPONSE_PACKET.command = "Something"
        RESPONSE_PACKET.command_status = SUCCESS
        
        mock_send.return_value = RESPONSE_PACKET
        assert EXPECTED_TYPE == driver.get_processor_type()


def test__parse_read_reply_raises_dataerror_if_exception():
    driver = SLCDriver(CONNECT_PATH)


    with pytest.raises(ResponseError):