from __future__ import annotations import asyncio import logging from dataclasses import dataclass from datetime import timedelta from enum import Enum, unique from typing import Any, Optional, Set, TypeVar, Union from .const import CMD_DELAY_NEXT_PACKET from .packets import ( BIN32_ABS, BIN32_NET, BIN48_ABS, BIN48_NET, BIN48_NET_TIME, MalformedPacketException, Packet, ) LOG = logging.getLogger(__name__) PACKET_HEADER = bytes.fromhex("feff") API_RESPONSE_WAIT_TIME = timedelta(seconds=3) # Time to wait for an API response PACKET_DELAY_CLEAR_TIME = timedelta( seconds=3 ) # Time to wait after a packet delay request so that GEM can finish sending any pending packets T = TypeVar("T") R = TypeVar("R") @dataclass(frozen=True) class PacketProtocolMessage: """Base class for messages sent by a PacketProtocol.""" protocol: PacketProtocol @dataclass(frozen=True) class ConnectionMadeMessage(PacketProtocolMessage): """Message sent when a new connection has been made to a protocol. Sent once shortly after creation of the protocol instance.""" pass @dataclass(frozen=True) class PacketReceivedMessage(PacketProtocolMessage): """Message sent when a packet has been received by the protocol.""" packet: Packet @dataclass(frozen=True) class ConnectionLostMessage(PacketProtocolMessage): """Message sent when a protocol loses its connection. exc is the exception that caused the connection to drop, if any.""" exc: Optional[BaseException] class PacketProtocol(asyncio.Protocol): """Protocol implementation for processing a stream of data packets from a GreenEye Monitor.""" def __init__( self, queue: asyncio.Queue[PacketProtocolMessage], ): """ Create a new protocol instance. Whenever a data packet is received from the GEM, a `Packet` instance will be enqueued to `queue`. """ self._buffer = bytearray() self._queue = queue self._transport: Optional[asyncio.BaseTransport] = None def connection_made(self, transport: asyncio.BaseTransport) -> None: LOG.info("%d: Connection opened", id(self)) assert self._transport is None self._transport = transport self._queue.put_nowait(ConnectionMadeMessage(protocol=self)) def connection_lost(self, exc: Optional[BaseException]) -> None: if exc is not None: LOG.warning("%d: Connection lost due to exception", id(self), exc_info=exc) else: LOG.info("%d: Connection closed", id(self)) self._transport = None self._queue.put_nowait(ConnectionLostMessage(protocol=self, exc=exc)) def data_received(self, data: bytes) -> None: LOG.debug("%d: Received %d bytes", id(self), len(data)) self._buffer.extend(data) try: packet = self._get_packet() while packet is not None: self._queue.put_nowait( PacketReceivedMessage(protocol=self, packet=packet) ) packet = self._get_packet() except Exception: LOG.exception("%d: Exception while attempting to parse a packet.", id(self)) def close(self) -> None: """Closes the underlying transport, if any.""" if self._transport: self._transport.close() self._transport = None def _get_packet(self) -> Optional[Packet]: """ Returns a full packet if available. """ while len(self._buffer) > 0: def skip_malformed_packet(msg: str, *args: Any, **kwargs: Any): LOG.debug( "%d Skipping malformed packet due to " + msg + ". Buffer contents: %s", id(self), *args, self._buffer, ) del self._buffer[0 : len(PACKET_HEADER)] header_index = self._buffer.find(PACKET_HEADER) if header_index == -1: LOG.debug( "%d: No header found. Discarding junk data: %s", id(self), self._buffer, ) self._buffer.clear() continue del self._buffer[0:header_index] if len(self._buffer) < len(PACKET_HEADER) + 1: # Not enough length yet LOG.debug( "%d: Not enough data in buffer yet (%d bytes): %s", id(self), len(self._buffer), self._buffer, ) return None format_code = self._buffer[len(PACKET_HEADER)] if format_code == 8: packet_format = BIN32_ABS elif format_code == 7: packet_format = BIN32_NET elif format_code == 6: packet_format = BIN48_ABS elif format_code == 5: packet_format = BIN48_NET else: skip_malformed_packet("unknown format code 0x%x", format_code) continue if len(self._buffer) < packet_format.size: # Not enough length yet LOG.debug( "%d: Not enough data in buffer yet (%d bytes)", id(self), len(self._buffer), ) return None try: result = None try: result = packet_format.parse(self._buffer) except MalformedPacketException: if packet_format != BIN48_NET: raise if result is None: if len(self._buffer) < BIN48_NET_TIME.size: # Not enough length yet LOG.debug( "%d: Not enough data in buffer yet (%d bytes)", id(self), len(self._buffer), ) return None result = BIN48_NET_TIME.parse(self._buffer) LOG.debug( "%d: Parsed one %s packet.", id(self), result.packet_format.name ) del self._buffer[0 : result.packet_format.size] return result except MalformedPacketException as e: skip_malformed_packet(e.args[0]) self._ensure_transport() def _ensure_transport(self) -> asyncio.BaseTransport: if not self._transport: raise EOFError return self._transport @unique class ProtocolState(Enum): RECEIVING_PACKETS = 1 # Receiving packets from the GEM SENT_PACKET_DELAY_REQUEST = 2 # Sent the packet delay request prior to an API request, waiting for any in-flight packets SENT_API_REQUEST = 3 # Sent an API request, waiting for a response RECEIVED_API_RESPONSE = 4 # Received an API response, waiting for end call class ProtocolStateException(Exception): def __init__( self, actual: ProtocolState, expected: Union[ProtocolState, Set[ProtocolState]], *args: object, ) -> None: self._actual = actual self._expected = expected super().__init__(*args) def __str__(self) -> str: if isinstance(self._expected, set): expected = [s.name for s in self._expected] if len(expected) > 1: expected_str = ", ".join(expected[:-1]) + f", or {expected[-1]}" else: expected_str = expected[0] else: expected_str = self._expected.name return f"Expected state to be {expected_str}; but got {self._actual.name}!" class BidirectionalProtocol(PacketProtocol): """Protocol implementation for bi-directional communication with a GreenEye Monitor.""" def __init__(self, queue: asyncio.Queue[PacketProtocolMessage]): super().__init__(queue) self._state = ProtocolState.RECEIVING_PACKETS self._api_buffer = bytearray() def data_received(self, data: bytes) -> None: if self._state == ProtocolState.SENT_API_REQUEST: LOG.debug("%d: Received %d bytes", id(self), len(data)) self._api_buffer.extend(data) else: super().data_received(data) def begin_api_request(self) -> timedelta: """ Begin the process of sending an API request. Calls WriteTransport.write on the associated transport with bytes that need to be sent. Returns a timedelta. Callers must wait for that amount of time, then call send_api_request with the actual request. """ self._expect_state(ProtocolState.RECEIVING_PACKETS) LOG.debug("%d: Starting API request. Requesting packet delay...", id(self)) self._ensure_write_transport().write( CMD_DELAY_NEXT_PACKET.encode() ) # Delay packets for 15 seconds self._state = ProtocolState.SENT_PACKET_DELAY_REQUEST return PACKET_DELAY_CLEAR_TIME def send_api_request(self, request: str) -> timedelta: """ Send the given API request, after having called begin_api_request. Calls WriteTransport.write on the associated transport with bytes that need to be sent. Returns a timedelta. Callers must wait for that amount of time, then call receive_api_response to receive the response. """ self._expect_state(ProtocolState.SENT_PACKET_DELAY_REQUEST) LOG.debug("%d: Sending API request '%s'...", id(self), request) self._ensure_write_transport().write(request.encode()) self._state = ProtocolState.SENT_API_REQUEST return API_RESPONSE_WAIT_TIME def receive_api_response(self) -> str: """ Returns the bytes that were received in response to a call to send_api_request. Callers must call end_api_request after this call. """ self._expect_state(ProtocolState.SENT_API_REQUEST) response = bytes(self._api_buffer).decode() LOG.debug("%d: Received API response: '%s'", id(self), response) self._state = ProtocolState.RECEIVED_API_RESPONSE return response def end_api_request(self) -> None: """ Ends an API request. Every begin_api_request call must have a matching end_api_request call, even if an error occurred in between. """ self._expect_state( { ProtocolState.RECEIVED_API_RESPONSE, ProtocolState.SENT_PACKET_DELAY_REQUEST, } ) self._api_buffer.clear() LOG.debug("%d: Ended API request", id(self)) self._state = ProtocolState.RECEIVING_PACKETS def _ensure_write_transport(self) -> asyncio.WriteTransport: transport = self._ensure_transport() assert isinstance(transport, asyncio.WriteTransport) return transport def _expect_state(self, expected_state: Union[ProtocolState, Set[ProtocolState]]): if not isinstance(expected_state, set): expected_state = {expected_state} assert len(expected_state) > 0 if not self._state in expected_state: raise ProtocolStateException(actual=self._state, expected=expected_state)