"""This is a class for SIA Events.""" from __future__ import annotations import copy import logging from abc import ABC, abstractmethod from copy import deepcopy from dataclasses import dataclass, field, asdict, replace from datetime import datetime, timedelta, timezone, tzinfo from typing import Union, Any from Crypto.Cipher import AES from Crypto.Cipher._mode_cbc import CbcMode from .account import SIAAccount from .const import IV, RSP_XDATA from .errors import EventFormatError, NoAccountError from .utils import ( ADM_MAPPING, MAIN_MATCHER, OH_MATCHER, MessageTypes, ResponseType, SIA_CODES, SIACode, SIAXData, XDATA, _get_matcher, ) _LOGGER = logging.getLogger(__name__) @dataclass # type: ignore class BaseEvent(ABC): """Base class for Events.""" # From Main Matcher full_message: str | None = None msg_crc: str | None = None length: str | None = None encrypted: bool | None = None message_type: str | MessageTypes | None = None receiver: str = "R0" line: str = "L0" account: str | None = None sequence: str | None = None # Content to be parsed content: str | None = None encrypted_content: str | None = None # From (Encrypted) Content ti: str | None = None # pylint: disable=invalid-name id: str | None = None # pylint: disable=invalid-name ri: str | None = None # pylint: disable=invalid-name code: str | None = None message: str | None = None x_data: str | None = None timestamp: datetime | str | None = None # From ADM-CID event_qualifier: str | None = None event_type: str | None = None partition: str | None = None # Parsed fields calc_crc: str | None = None extended_data: list[SIAXData] | None = None sia_account: SIAAccount | None = field(repr=False, default=None) sia_code: SIACode | None = field(default=None, repr=False) # Parse flags _content_parsed: bool = field(init=False, default=False, repr=False) _encrypted_content_decrypted: bool = field(init=False, default=False, repr=False) _adm_parsed: bool = field(init=False, default=False, repr=False) _sia_added: bool = field(init=False, default=False, repr=False) _xdata_parsed: bool = field(init=False, default=False, repr=False) @property def valid_message(self) -> bool: """Return True for OH and NAK Events.""" return True # pragma: no cover @property def code_not_found(self) -> bool: """Return True if there is no Code.""" return bool(self.sia_code is None) @property @abstractmethod def response(self) -> ResponseType | None: """Abstract method.""" @property def valid_timestamp(self) -> bool: """Return True for OH and NAK Events.""" return True # pragma: no cover @abstractmethod def create_response(self) -> bytes: """Abstract method.""" def set_sia_code(self) -> None: """Return the SIA Code object, based on the code field.""" if self.code: # pragma: no cover self.sia_code = copy.copy(SIA_CODES.get(self.code)) # pylint: disable=E1101 self._sia_added = True def _get_crypter(self) -> CbcMode | None: """Give back a encrypter/decrypter.""" if not self.sia_account: return None # pragma: no cover if not self.sia_account.key_b: return None # pragma: no cover cypher = AES.new(self.sia_account.key_b, AES.MODE_CBC, IV) if isinstance(cypher, CbcMode): return cypher return None # pragma: no cover @classmethod def from_line( cls, incoming: str, accounts: dict[str, SIAAccount] | None = None ) -> SIAEvent: """Create a Event from a line. Arguments: incoming {str} -- The line to be parsed. accounts {List[SIAAccount]} -- accounts to check against, optional Raises: EventFormatError: If the event is not formatted according to SIA DC09 or ADM-CID. """ line_match = MAIN_MATCHER.match(incoming) if not line_match: oh_event = OH_MATCHER.match(incoming) if oh_event: fields = oh_event.groupdict() return OHEvent( full_message=incoming, msg_crc="", message_type=MessageTypes.OH, length=str(len(incoming)), encrypted=False, receiver=fields["receiver"], line=fields["line"], account=fields["account"], id=fields["id"], ) raise EventFormatError( f"No matches found, event was not a SIA or ADM Spec event, line was: {incoming}" ) main_content = line_match.groupdict() encrypted = bool(main_content["encrypted_flag"]) acc = main_content["account"] sia_account = None if accounts and acc: sia_account = accounts.get(acc, None) or accounts.get("", None) return SIAEvent( full_message=incoming[8:], msg_crc=main_content["crc"], length=main_content["length"], encrypted=encrypted, message_type=main_content["message_type"], sequence=main_content["sequence"], receiver=main_content["receiver"], line=main_content["line"], account=acc, content=main_content["rest"] if not encrypted else None, encrypted_content=main_content["rest"] if encrypted else None, sia_account=sia_account, ) @staticmethod def _get_timestamp(device_timezone: tzinfo | None = None) -> str: """Create a timestamp in the right format.""" if device_timezone: return ( datetime.now() .astimezone(device_timezone) .strftime("_%H:%M:%S,%m-%d-%Y") ) return datetime.now(timezone.utc).strftime("_%H:%M:%S,%m-%d-%Y") @staticmethod def _crc_calc(msg: str | None) -> str | None: """Calculate the CRC of the msg.""" if msg is None: # pragma: no cover return None crc = 0 for letter in str.encode(msg): temp = letter for _ in range(0, 8): temp ^= crc & 1 crc >>= 1 if (temp & 1) != 0: crc ^= 0xA001 temp >>= 1 return ("%x" % crc).upper().zfill(4) def to_dict(self, **kwargs: Any) -> dict[str, Any]: """Create a dict from the dataclass. Kwargs are only there for legacy (after no longer using dataclasses_json), so remove any other arguments from this function. """ event = deepcopy(self) event.sia_account = None if event.timestamp is not None and isinstance(event.timestamp, datetime): event.timestamp = event.timestamp.isoformat() return asdict(event) @classmethod def from_dict(cls, event: dict[str, Any]) -> BaseEvent: """Create a SIA Event from a dict.""" if "_content_parsed" in event: event.pop("_content_parsed") if "_encrypted_content_decrypted" in event: event.pop("_encrypted_content_decrypted") if "_adm_parsed" in event: event.pop("_adm_parsed") if "_sia_added" in event: event.pop("_sia_added") if "_xdata_parsed" in event: event.pop("_xdata_parsed") if "timestamp" in event and event["timestamp"] is not None: event["timestamp"] = datetime.fromisoformat(event["timestamp"]) return cls(**event) @dataclass class SIAEvent(BaseEvent): """Class for SIAEvents.""" def __post_init__(self) -> None: """Run post init logic.""" # Convert the message type to the enum if isinstance(self.message_type, str): # pragma: no cover self.message_type = MessageTypes(self.message_type) # Calculate the CRC of the message. if not self.calc_crc: self.calc_crc = self._crc_calc(self.full_message) # If there is encrypted content and a key, decrypt if self.encrypted_content: if not self.sia_account or not self.sia_account.encrypted: raise NoAccountError("No account present with encrypted message.") if not self._encrypted_content_decrypted: self.decrypt_content() # If there is content (either after decrypting or directly) parse if self.content and not self._content_parsed: self.parse_content() # If it is a ADM-CID message, map the qualifier and type to a code. if ( self.message_type == MessageTypes.ADMCID and self.event_qualifier is not None and self.event_type is not None and not self._adm_parsed ): self.parse_adm() # If there is a code, map it to the full SIA Code spec. if self.code and not self._sia_added: self.set_sia_code() # If there is x_data, parse it. if self.x_data and not self._xdata_parsed: self.parse_extended_data() # pragma: no cover @property def response(self) -> ResponseType | None: """Get the responsetype.""" if not self.valid_message: return None if self.code_not_found: return ResponseType.DUH if not self.sia_account: return ResponseType.NAK # pragma: no cover if not self.valid_timestamp: return ResponseType.NAK if self.extended_data is not None: # pragma: no cover if [x for x in self.extended_data if x.identifier in RSP_XDATA]: return ResponseType.RSP return ResponseType.ACK @property def sia_string(self) -> str: # pragma: no cover """Create a string with the SIA codes and some other fields.""" if self.sia_code: return f"Code: {self.code}, Type: {self.sia_code.type}, \ Description: {self.sia_code.description}" return f"Code: {self.code}" @property def valid_length(self) -> bool: """Check if the length of the message is the same in the message and supplied. Will not throw an error if not correct. """ if self.length is None or self.full_message is None: # pragma: no cover return True return int(self.length) == int( str(len(self.full_message)), 16 ) # pragma: no cover @property def valid_message(self) -> bool: """Check the validity of the message by comparing the sent CRC with the calculated CRC.""" return self.msg_crc == self.calc_crc @property def valid_timestamp(self) -> bool: """Check if the timestamp is within bounds.""" if not self.sia_account: # pragma: no cover return True if self.sia_account.allowed_timeband is None: # pragma: no cover return True if self.timestamp and isinstance(self.timestamp, datetime): current_time = datetime.now(self.sia_account.device_timezone) current_min = current_time - timedelta( seconds=self.sia_account.allowed_timeband[0] ) current_plus = current_time + timedelta( seconds=self.sia_account.allowed_timeband[1] ) return current_min <= self.timestamp <= current_plus return True # pragma: no cover def create_response(self) -> bytes: """Create a response message, based on account, event and response type. Returns: bytes -- Response to send back to sender. """ response_type = self.response x_data = None if response_type is None: return b"\n\r" if not self.sia_account: return f'"{response_type.value}"'.encode("ascii") if ( self.extended_data and [x for x in self.extended_data if x.identifier == "K"] is not None and self.sia_account.key is not None ): x_data = f"[K{self.sia_account.key}]" if response_type == ResponseType.NAK: res = f'"{response_type.value}"0000{self.receiver}{self.line}A0[{self.sia_account.response_qualifier}]{self._get_timestamp(self.sia_account.device_timezone)}' elif not self.encrypted or response_type == ResponseType.DUH: res = f'"{response_type.value}"{self.sequence}{self.receiver}{self.line}#{self.account}[{self.sia_account.response_qualifier}]{x_data if x_data else ""}' else: encrypted_content = self.encrypt_content( f"]{x_data if x_data else ''}{self._get_timestamp(self.sia_account.device_timezone)}" ) res = f'"*{response_type.value}"{self.sequence}{self.receiver}{self.line}#{self.account}[{self.sia_account.response_qualifier}{encrypted_content}' header = ("%04x" % len(res)).upper() new_crc = self._crc_calc(res) return f"\n{new_crc}{header}{res}\r".encode("ascii") def decrypt_content(self) -> None: """Decrypt the content, if encrypted account, otherwise pass back the event.""" if not self.encrypted_content: # pragma: no cover return None decr = self._get_crypter() if not decr: return None # pragma: no cover self.content = decr.decrypt(bytes.fromhex(self.encrypted_content)).decode( "ascii", "ignore" ) self._encrypted_content_decrypted = True def encrypt_content(self, message: str) -> str | None: """Encrypt a string. Arguments: message {str} -- String to encrypt. Returns: str -- Encrypted string, if encrypted account. """ encr = self._get_crypter() if not encr: return None # pragma: no cover fill_size = len(message) + 16 - len(message) % 16 return encr.encrypt(message.zfill(fill_size).encode("ascii")).hex().upper() def parse_adm(self) -> None: """Parse the event qualifier and type for ADM messages.""" if self.event_qualifier and self.event_type: # pragma: no cover if sub_map := ADM_MAPPING.get(self.event_type, None): self.code = sub_map.get(self.event_qualifier, "YN") else: self.code = "YN" # invalid data self._adm_parsed = True def parse_content(self) -> None: """Set internal content field, parse the content and store the right things.""" if ( not self.message_type or self.encrypted is None or not self.content ): # pragma: no cover return matcher = _get_matcher(self.message_type, self.encrypted) matches = matcher.match(self.content) if not matches: raise EventFormatError( f"Parse content: no matches found in {self.content}, using matcher: {matcher}" ) content = matches.groupdict() _LOGGER.debug("Content matches: %s", content) # Parse specific fields per message type if self.message_type == MessageTypes.ADMCID: self.partition = content["partition"] self.event_qualifier = content["event_qualifier"] self.event_type = content["event_type"] else: self.code = content["code"] self.ti = content["ti"] self.id = content["id"] self.message = content["message"] # Parse generic fields if not self.account: self.account = content["account"] self.ri = content["ri"] self.x_data = content["xdata"] if content["timestamp"]: try: timestamp = datetime.strptime(content["timestamp"], "%H:%M:%S,%m-%d-%Y") self.timestamp = timestamp.replace(tzinfo=timezone.utc) except ValueError: _LOGGER.warning( "Timestamp could not be parsed as a timestamp: %s", content["timestamp"], ) if self.message_type == MessageTypes.NULL and self.code_not_found: self.code = "RP" self.ri = "0" self._content_parsed = True def parse_extended_data(self) -> None: """Set extended data.""" if self.x_data is None: # pragma: no cover return x_data_list = self.x_data.split("][") self.extended_data = [] for x_data in x_data_list: # pragma: no cover xdata = XDATA.get(x_data[0], None) if xdata: self.extended_data.append(replace(xdata, value=x_data[1:])) self._xdata_parsed = True def __str__(self) -> str: """Return the event as a string.""" return f"\ Content: {self.content}, \ Zone (ri): {self.ri}, \ Code: {self.code}, \ Message: {self.message if self.message else ''}, \ Account: {self.account}, \ Receiver: {self.receiver}, \ Line: {self.line}, \ Timestamp: {self.timestamp}, \ Length: {self.length}, \ Sequence: {self.sequence}, \ CRC: {self.msg_crc}, \ Calc CRC: {self.calc_crc}, \ Encrypted Content: {self.encrypted_content}, \ Full Message: {self.full_message}." @dataclass class OHEvent(SIAEvent): """Class for OH events.""" code: str = "RP" def __post_init__(self) -> None: """If there is a code, map it to the full SIA Code spec.""" if isinstance(self.message_type, str): # pragma: no cover self.message_type = MessageTypes(self.message_type) if not self._sia_added: # pragma: no cover self.set_sia_code() @property def response(self) -> ResponseType: """Return ACK for OH Events.""" return ResponseType.ACK # pragma: no cover def create_response(self) -> bytes: """Create a response message, based on account, event and response type. Returns: str -- Response to send back to sender. """ return '"ACK"'.encode("ascii") # pragma: no cover @dataclass class NAKEvent(BaseEvent): """Class for NAK Events.""" def __post_init__(self) -> None: """Run post init work.""" if isinstance(self.message_type, str): # pragma: no cover self.message_type = MessageTypes(self.message_type) @property def response(self) -> ResponseType: """Return NAK for NAK Events.""" return ResponseType.NAK def create_response(self) -> bytes: """Create a response message, based on account, event and response type. Returns: str -- Response to send back to sender. """ res = f'"NAK"0000L0R0A0[]{self._get_timestamp()}' header = ("%04x" % len(res)).upper() return f"\n{self._crc_calc(res)}{header}{res}\r".encode("ascii") EventsType = Union[SIAEvent, OHEvent, NAKEvent]