"""Common code for gate APIs.""" from __future__ import annotations import dataclasses import json from dataclasses import dataclass from datetime import datetime from enum import Enum from typing import Any, TypeVar, Union, cast from collections.abc import Callable from xml.etree.ElementTree import Element # nosec from typing import Final from .const import NONE_INT GenericType = TypeVar("GenericType") class InvalidDoorException(Exception): """Exception when door is invalid.""" def __init__(self, door_id: int) -> None: super().__init__(f"Door with id {door_id} not found.") self.door_id: Final = door_id class TagException(Exception): """General exception for tags.""" def __init__(self, tag: str, message: str) -> None: super().__init__(message) self.tag: Final = tag class TagNotFoundException(TagException): """Thrown when encountering an unexpected type.""" def __init__(self, tag: str) -> None: """Initialize.""" super().__init__(tag, f"Did not find element tag '{tag}'.") class TextEmptyException(TagException): """Thrown when encountering an unexpected type.""" def __init__(self, tag: str) -> None: """Initialize.""" super().__init__(tag, f"Text was empty for tag '{tag}'.") class UnexpectedTypeException(Exception): """Thrown when encountering an unexpected type.""" def __init__(self, value: Any, expected: type[GenericType]): """Initialize.""" super().__init__( f'Expected of "{value}" to be "{expected}" but was "{type(value)}."' ) self.value: Final = value self.expected: Final = expected def enforce_type(value: Any, expected: type[GenericType]) -> GenericType: """Enforce a data type.""" if not isinstance(value, expected): raise UnexpectedTypeException(value, expected) return value def value_or_none( value: Any, convert_fn: Callable[[Any], GenericType] ) -> GenericType | None: """Convert a value given a specific conversion function.""" if value is None: return None try: return convert_fn(value) except Exception: # pylint: disable=broad-except return None def enum_or_raise(value: str | int | None, enum: type[Enum]) -> Enum: """Return Enum or raise exception.""" if value is None: raise UnexpectedTypeException(value, enum) return enum(value) def str_or_raise(value: Any) -> str: """Return string or raise exception.""" return enforce_type(str_or_none(value), str) def str_or_none(value: Any) -> str | None: """Return str or None.""" return value_or_none(value, str) def int_or_raise(value: Any) -> int: """Return int or raise exception.""" return enforce_type(int_or_none(value), int) def int_or_none(value: Any) -> int | None: """Return int or None.""" return value_or_none(value, int) def float_or_none(value: Any) -> float | None: """Return float or None.""" return value_or_none(value, float) class EnhancedJSONEncoder(json.JSONEncoder): """JSON encoder.""" # pylint: disable=method-hidden def default(self, o) -> Any: # type: ignore """Encode the object.""" if dataclasses.is_dataclass(o): return dataclasses.asdict(o) if isinstance(o, Enum): return o.value return super().default(o) class ApiError(Exception): """Generic API error.""" def __init__(self, code: int, message: str) -> None: super().__init__(f"Code: {code} - {message}") self._code: Final = code self._message: Final = message @property def code(self) -> int: """Get the error code.""" return self._code @property def message(self) -> str: """Get the error message.""" return self._message class CredentialsNotSetException(ApiError): """Credentials not set exception.""" class CredentialsIncorrectException(ApiError): """Credentials incorrect exception.""" class InvalidOptionException(ApiError): """Invalid option exception.""" class InvalidApiCodeException(ApiError): """Invalid api code exception.""" class DoorNotSetException(ApiError): """Door not set exception.""" ExceptionGenerator: Final = Callable[[int, str], ApiError] class DoorStatus(Enum): """Door status.""" CLOSED = "closed" OPENED = "opened" UNDEFINED = "undefined" class TransitionDoorStatus(Enum): """Presumed door status.""" OPENING = "opening" CLOSING = "closing" AllDoorStatus: Final = Union[DoorStatus, TransitionDoorStatus] CLOSE_DOOR_STATUSES: Final = frozenset( (DoorStatus.CLOSED, TransitionDoorStatus.CLOSING) ) OPEN_DOOR_STATUSES: Final = frozenset((DoorStatus.OPENED, TransitionDoorStatus.OPENING)) class DoorMode(Enum): """Door mode.""" GARAGE = "garage" PULSE = "pulse" ON_OFF = "onoff" class RequestOption(Enum): """Request option.""" INFO = "info" ACTIVATE = "activate" @dataclass(frozen=True) # pylint: disable=too-many-instance-attributes class AbstractDoor: """Door object.""" door_id: int permission: bool name: str | None mode: DoorMode gate: bool status: DoorStatus sensor: bool camera: bool events: int | None sensorid: str | None temperature: float | None voltage: int | None @dataclass(frozen=True) class GogoGate2Door(AbstractDoor): """Door object.""" @dataclass(frozen=True) class ISmartGateDoor(AbstractDoor): """Door object.""" enabled: bool apicode: str customimage: bool @dataclass(frozen=True) class Outputs: """Outputs object.""" output1: bool output2: bool output3: bool @dataclass(frozen=True) class Network: """Network object.""" ip: str # pylint: disable=invalid-name @dataclass(frozen=True) class Wifi: """Wifi object.""" SSID: str | None # pylint: disable=invalid-name linkquality: str | None signal: str | None @dataclass(frozen=True) class AbstractActivateResponse: """Response from activate calls.""" result: bool @dataclass(frozen=True) class GogoGate2ActivateResponse(AbstractActivateResponse): """GogoGate2 response from activate calls.""" @dataclass(frozen=True) class ISmartGateActivateResponse(AbstractActivateResponse): """iSmartGate response from activate calls.""" @dataclass(frozen=True) # pylint: disable=too-many-instance-attributes class AbstractInfoResponse: """Response from info calls.""" user: str model: str apiversion: str remoteaccessenabled: bool remoteaccess: str firmwareversion: str door1: AbstractDoor door2: AbstractDoor door3: AbstractDoor network: Network wifi: Wifi @dataclass(frozen=True) # pylint: disable=too-many-instance-attributes class GogoGate2InfoResponse(AbstractInfoResponse): """Response from gogogate2 api calls.""" gogogatename: str apicode: str door1: GogoGate2Door door2: GogoGate2Door door3: GogoGate2Door outputs: Outputs @dataclass(frozen=True) class ISmartGateInfoResponse(AbstractInfoResponse): """Response from iSmartGate info calls.""" pin: int lang: str ismartgatename: str newfirmware: bool door1: ISmartGateDoor door2: ISmartGateDoor door3: ISmartGateDoor @dataclass(frozen=True) class CachedTransitionDoorStatus: """Cached persistent door status.""" door_id: int activated: datetime transition_status: TransitionDoorStatus target_status: DoorStatus def element_or_none(element: Element | None, tag: str) -> Element | None: """Get element from xml element.""" return None if element is None else element.find(tag) def element_or_raise(element: Element | None, tag: str) -> Element: """Get element from xml element.""" found_element: Final = element_or_none(element, tag) if found_element is None: raise TagNotFoundException(tag) return found_element def element_text_or_empty(element: Element | None, tag: str) -> str: """Get element value as text or an empty string.""" return element_text_or_default(element, tag, "") def element_text_or_default(element: Element | None, tag: str, default: str) -> str: """Get element value as text or a default value.""" val: Final = element_text_or_none(element, tag) if val is None: return default return cast(str, val) def element_text_or_none(element: Element | None, tag: str) -> str | None: """Get element text from xml element.""" found_element: Final = element_or_none(element, tag) return ( None if found_element is None else None if found_element.text is None else found_element.text.strip() ) def element_text_or_raise(element: Element | None, tag: str) -> str: """Get element text from xml element.""" found_element: Final = element_or_raise(element, tag) if found_element.text is None: raise TextEmptyException(tag) return found_element.text.strip() def element_int_or_raise(element: Element | None, tag: str) -> int: """Get element int from xml element.""" found_element: Final = element_or_raise(element, tag) if found_element.text is None: raise TextEmptyException(tag) return int_or_raise(found_element.text.strip()) def element_to_api_error(element: Element) -> ApiError: """Get api error from xml element.""" return ApiError( code=int_or_raise(element_text_or_raise(element, "errorcode")), message=element_text_or_raise(element, "errormsg"), ) def wifi_or_raise(element: Element) -> Wifi: """Get wifi from xml element.""" return Wifi( SSID=element_text_or_none(element, "SSID"), linkquality=element_text_or_none(element, "linkquality"), signal=element_text_or_none(element, "signal"), ) def network_or_raise(element: Element) -> Network: """Get network from xml element.""" return Network( ip=element_text_or_raise(element, "ip"), ) def outputs_or_raise(element: Element) -> Outputs: """Get outputs from xml element.""" return Outputs( output1=element_text_or_raise(element, "output1").lower() == "on", output2=element_text_or_raise(element, "output2").lower() == "on", output3=element_text_or_raise(element, "output3").lower() == "on", ) def gogogate2_door_or_raise(door_id: int, element: Element) -> GogoGate2Door: """Get door from xml element.""" temp: Final = float_or_none(element_text_or_none(element, "temperature")) voltage: Final = int_or_none(element_text_or_none(element, "voltage")) return GogoGate2Door( door_id=door_id, permission=element_text_or_raise(element, "permission").lower() == "yes", name=element_text_or_none(element, "name"), gate=False, mode=cast( DoorMode, enum_or_raise(element_text_or_raise(element, "mode"), DoorMode) ), status=cast( DoorStatus, enum_or_raise(element_text_or_raise(element, "status"), DoorStatus), ), sensor=element_text_or_raise(element, "sensor").lower() == "yes", sensorid=element_text_or_none(element, "sensorid"), camera=element_text_or_raise(element, "camera").lower() == "yes", events=int_or_none(element_text_or_none(element, "events")), temperature=None if temp is None else None if temp <= NONE_INT else temp, voltage=None if voltage is None else None if voltage <= NONE_INT else voltage, ) def ismartgate_door_or_raise(door_id: int, element: Element) -> ISmartGateDoor: """Get door from xml element.""" temp: Final = float_or_none(element_text_or_none(element, "temperature")) voltage: Final = int_or_none(element_text_or_none(element, "voltage")) return ISmartGateDoor( door_id=door_id, enabled=element_text_or_empty(element, "enabled").lower() == "yes", apicode=element_text_or_empty(element, "apicode"), customimage=element_text_or_empty(element, "customimage").lower() == "yes", permission=element_text_or_raise(element, "permission").lower() == "yes", name=element_text_or_none(element, "name"), gate=element_text_or_raise(element, "gate").lower() == "yes", mode=cast( DoorMode, enum_or_raise(element_text_or_raise(element, "mode"), DoorMode) ), status=cast( DoorStatus, enum_or_raise(element_text_or_raise(element, "status"), DoorStatus), ), sensor=element_text_or_raise(element, "sensor").lower() == "yes", sensorid=element_text_or_none(element, "sensorid"), camera=element_text_or_raise(element, "camera").lower() == "yes", events=int_or_none(element_text_or_none(element, "events")), temperature=None if temp is None else None if temp <= NONE_INT else temp, voltage=None if voltage is None else None if voltage <= NONE_INT else voltage, ) def ismartgate_door_or_empty(door_id: int, element: Element) -> ISmartGateDoor: """Get door form xml element or return empty if door is not supported. (ex: smartgate mini only supports one door) """ # Check for necessary element, if it does not exist then door is not supported. if element_text_or_none(element, "apicode") is None: # Return empty door return ISmartGateDoor( door_id=door_id, enabled=False, apicode="0", customimage=False, permission=True, name=None, gate=False, mode=DoorMode.GARAGE, status=DoorStatus.UNDEFINED, sensorid="0", camera=False, temperature=None, voltage=None, sensor=False, events=None, ) # Return Supported Door return ismartgate_door_or_raise(door_id, element) def element_to_gogogate2_info_response(element: Element) -> GogoGate2InfoResponse: """Get response from xml element.""" return GogoGate2InfoResponse( user=element_text_or_raise(element, "user"), gogogatename=element_text_or_raise(element, "gogogatename"), model=element_text_or_raise(element, "model"), apiversion=element_text_or_raise(element, "apiversion"), remoteaccessenabled=element_text_or_raise(element, "remoteaccessenabled") == "1", remoteaccess=element_text_or_raise(element, "remoteaccess"), firmwareversion=element_text_or_raise(element, "firmwareversion"), apicode=element_text_or_raise(element, "apicode"), door1=gogogate2_door_or_raise(1, element_or_raise(element, "door1")), door2=gogogate2_door_or_raise(2, element_or_raise(element, "door2")), door3=gogogate2_door_or_raise(3, element_or_raise(element, "door3")), outputs=outputs_or_raise(element_or_raise(element, "outputs")), network=network_or_raise(element_or_raise(element, "network")), wifi=wifi_or_raise(element_or_raise(element, "wifi")), ) def element_to_ismartgate_info_response(element: Element) -> ISmartGateInfoResponse: """Get response from xml element.""" return ISmartGateInfoResponse( user=element_text_or_raise(element, "user"), pin=element_int_or_raise(element, "pin"), lang=element_text_or_raise(element, "lang"), ismartgatename=element_text_or_raise(element, "ismartgatename"), model=element_text_or_raise(element, "model"), apiversion=element_text_or_raise(element, "apiversion"), remoteaccessenabled=element_text_or_raise( element, "remoteaccessenabled" ).lower() == "yes", remoteaccess=element_text_or_raise(element, "remoteaccess"), firmwareversion=element_text_or_raise(element, "firmwareversion"), newfirmware=element_text_or_raise(element, "newfirmware").lower() == "yes", door1=ismartgate_door_or_raise(1, element_or_raise(element, "door1")), door2=ismartgate_door_or_empty(2, element_or_raise(element, "door2")), door3=ismartgate_door_or_empty(3, element_or_raise(element, "door3")), network=network_or_raise(element_or_raise(element, "network")), wifi=wifi_or_raise(element_or_raise(element, "wifi")), ) def element_to_gogogate2_activate_response( element: Element, ) -> GogoGate2ActivateResponse: """Get response from xml element.""" return GogoGate2ActivateResponse( result=element_text_or_raise(element, "result").lower() == "ok" ) def element_to_ismartgate_activate_response( element: Element, ) -> ISmartGateActivateResponse: """Get response from xml element.""" return ISmartGateActivateResponse( result=element_text_or_raise(element, "result").lower() == "ok" ) def get_door_by_id(door_id: int, response: AbstractInfoResponse) -> AbstractDoor | None: """Get a door from a gogogate2 response.""" return next( iter([door for door in get_doors(response) if door.door_id == door_id]), None ) def get_doors(response: AbstractInfoResponse) -> tuple[AbstractDoor, ...]: """Get a tuple of doors from a response.""" return (response.door1, response.door2, response.door3) def get_configured_door_by_id( door_id: int, response: AbstractInfoResponse ) -> AbstractDoor | None: """Get a door from a gogogate2 response.""" return next( iter( [door for door in get_configured_doors(response) if door.door_id == door_id] ), None, ) def get_configured_doors(response: AbstractInfoResponse) -> tuple[AbstractDoor, ...]: """Get a tuple of configured doors from a response.""" return tuple(door for door in get_doors(response) if door.name)