gopro_base.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. # gopro.py/Open GoPro, Version 2.0 (C) Copyright 2021 GoPro, Inc. (http://gopro.com/OpenGoPro).
  2. # This copyright was auto-generated on Wed, Sep 1, 2021 5:05:47 PM
  3. """Implements top level interface to GoPro module."""
  4. from __future__ import annotations
  5. import asyncio
  6. import enum
  7. import json
  8. import logging
  9. import threading
  10. import traceback
  11. from abc import abstractmethod
  12. from typing import Any, Awaitable, Callable, Final, Generic, TypeVar
  13. import requests
  14. import wrapt
  15. from open_gopro.api import (
  16. BleCommands,
  17. BleSettings,
  18. BleStatuses,
  19. HttpCommands,
  20. HttpSettings,
  21. WiredApi,
  22. WirelessApi,
  23. )
  24. from open_gopro.domain.communicator_interface import (
  25. GoProHttp,
  26. HttpMessage,
  27. Message,
  28. MessageRules,
  29. )
  30. from open_gopro.domain.exceptions import GoProNotOpened, ResponseTimeout
  31. from open_gopro.models import GoProResp
  32. from open_gopro.models.constants import ErrorCode
  33. from open_gopro.models.types import JsonDict
  34. from open_gopro.parsers.response import RequestsHttpRespBuilderDirector
  35. from open_gopro.util import pretty_print
  36. from open_gopro.util.logger import Logger
  37. logger = logging.getLogger(__name__)
  38. GoPro = TypeVar("GoPro", bound="GoProBase")
  39. ApiType = TypeVar("ApiType", WiredApi, WirelessApi)
  40. MessageMethodType = Callable[[Any, bool], Awaitable[GoProResp]]
  41. class GoProMessageInterface(enum.Enum):
  42. """Enum to identify wireless interface"""
  43. HTTP = enum.auto()
  44. BLE = enum.auto()
  45. @wrapt.decorator
  46. def catch_thread_exception(wrapped: Callable, instance: GoProBase, args: Any, kwargs: Any) -> Callable | None:
  47. """Catch any exceptions from this method and pass them to the exception handler identifier by thread name
  48. Args:
  49. wrapped (Callable): method that this is wrapping
  50. instance (GoProBase): instance owner of method
  51. args (Any): positional args
  52. kwargs (Any): keyword args
  53. Returns:
  54. Callable | None: forwarded return of wrapped method or None if exception occurs
  55. """
  56. try:
  57. return wrapped(*args, **kwargs)
  58. except Exception as e: # pylint: disable=broad-exception-caught
  59. instance._handle_exception(threading.current_thread().name, {"exception": e})
  60. return None
  61. def ensure_opened(interface: tuple[GoProMessageInterface]) -> Callable:
  62. """Raise exception if relevant interface is not currently opened
  63. Args:
  64. interface (tuple[GoProMessageInterface]): wireless interface to verify
  65. Returns:
  66. Callable: Direct pass-through of callable after verification
  67. """
  68. @wrapt.decorator
  69. def wrapper(wrapped: Callable, instance: GoProBase, args: Any, kwargs: Any) -> Callable:
  70. if GoProMessageInterface.BLE in interface and not instance.is_ble_connected:
  71. raise GoProNotOpened("BLE not connected")
  72. if GoProMessageInterface.HTTP in interface and not instance.is_http_connected:
  73. raise GoProNotOpened("HTTP interface not connected")
  74. return wrapped(*args, **kwargs)
  75. return wrapper
  76. @wrapt.decorator
  77. async def enforce_message_rules(wrapped: MessageMethodType, instance: GoProBase, args: Any, kwargs: Any) -> GoProResp:
  78. """Decorator proxy to call the GoProBase's _enforce_message_rules method.
  79. Args:
  80. wrapped (MessageMethodType): Operation to enforce
  81. instance (GoProBase): GoProBase instance to use
  82. args (Any): positional arguments to wrapped
  83. kwargs (Any): keyword arguments to wrapped
  84. Returns:
  85. GoProResp: common response object
  86. """
  87. return await instance._enforce_message_rules(wrapped, *args, **kwargs)
  88. class GoProBase(GoProHttp, Generic[ApiType]):
  89. """The base class for communicating with all GoPro Clients"""
  90. HTTP_TIMEOUT: Final = 5
  91. HTTP_GET_RETRIES: Final = 5
  92. def __init__(self, **kwargs: Any) -> None:
  93. self._should_maintain_state = kwargs.get("maintain_state", True)
  94. self._exception_cb = kwargs.get("exception_cb", None)
  95. async def __aenter__(self: GoPro) -> GoPro:
  96. await self.open()
  97. return self
  98. async def __aexit__(self, *_: Any) -> None:
  99. await self.close()
  100. @abstractmethod
  101. async def open(self, timeout: int = 10, retries: int = 5) -> None:
  102. """Connect to the GoPro Client and prepare it for communication
  103. Args:
  104. timeout (int): time before considering connection a failure. Defaults to 10.
  105. retries (int): number of connection retries. Defaults to 5.
  106. """
  107. @abstractmethod
  108. async def close(self) -> None:
  109. """Gracefully close the GoPro Client connection"""
  110. @property
  111. @abstractmethod
  112. async def is_ready(self) -> bool:
  113. """Is gopro ready to receive commands
  114. Returns:
  115. bool: yes if ready, no otherwise
  116. """
  117. @property
  118. @abstractmethod
  119. def _requests_session(self) -> requests.Session:
  120. """The requests session used to communicate with the GoPro Client
  121. Returns:
  122. requests.Session: requests session
  123. """
  124. @property
  125. @abstractmethod
  126. def identifier(self) -> str:
  127. """Unique identifier for the connected GoPro Client
  128. Returns:
  129. str: identifier
  130. """
  131. @property
  132. def version(self) -> str:
  133. """The API version that the connected camera supports
  134. Only 2.0 is currently supported
  135. Returns:
  136. str: supported version
  137. """
  138. return self._api.version
  139. @property
  140. @abstractmethod
  141. def http_command(self) -> HttpCommands:
  142. """Used to access the Wifi commands
  143. Returns:
  144. HttpCommands: the commands
  145. """
  146. @property
  147. @abstractmethod
  148. def http_setting(self) -> HttpSettings:
  149. """Used to access the Wifi settings
  150. Returns:
  151. HttpSettings: the settings
  152. """
  153. @property
  154. @abstractmethod
  155. def ble_command(self) -> BleCommands:
  156. """Used to call the BLE commands
  157. Returns:
  158. BleCommands: the commands
  159. """
  160. @property
  161. @abstractmethod
  162. def ble_setting(self) -> BleSettings:
  163. """Used to access the BLE settings
  164. Returns:
  165. BleSettings: the settings
  166. """
  167. @property
  168. @abstractmethod
  169. def ble_status(self) -> BleStatuses:
  170. """Used to access the BLE statuses
  171. Returns:
  172. BleStatuses: the statuses
  173. """
  174. @property
  175. @abstractmethod
  176. def is_open(self) -> bool:
  177. """Is this client ready for communication?
  178. Returns:
  179. bool: True if yes, False if no
  180. """
  181. @property
  182. @abstractmethod
  183. def is_ble_connected(self) -> bool:
  184. """Are we connected via BLE to the GoPro device?
  185. Returns:
  186. bool: True if yes, False if no
  187. """
  188. @property
  189. @abstractmethod
  190. def is_http_connected(self) -> bool:
  191. """Are we connected via HTTP to the GoPro device?
  192. Returns:
  193. bool: True if yes, False if no
  194. """
  195. ##########################################################################################################
  196. # End Public API
  197. ##########################################################################################################
  198. @abstractmethod
  199. async def _enforce_message_rules(
  200. self, wrapped: Callable, message: Message, rules: MessageRules, **kwargs: Any
  201. ) -> GoProResp:
  202. """Rule Enforcer. Called by enforce_message_rules decorator.
  203. Args:
  204. wrapped (Callable): operation to enforce
  205. message (Message): message passed to operation
  206. rules (MessageRules): rules to enforce
  207. **kwargs (Any) : arguments passed to operation
  208. Returns:
  209. GoProResp: Operation response
  210. """
  211. def _handle_exception(self, source: Any, context: JsonDict) -> None:
  212. """Gather exceptions from module threads and send through callback if registered.
  213. Note that this function signature matches asyncio's exception callback requirement.
  214. Args:
  215. source (Any): Where did the exception come from?
  216. context (JsonDict): Access exception via context["exception"]
  217. """
  218. # context["message"] will always be there; but context["exception"] may not
  219. if exception := context.get("exception", False):
  220. logger.error(f"Received exception {exception} from {source}")
  221. logger.error(traceback.format_exc())
  222. if self._exception_cb:
  223. self._exception_cb(exception)
  224. else:
  225. logger.error(f"Caught unknown message: {context['message']} from {source}")
  226. class _InternalState(enum.IntFlag):
  227. """State used to manage whether the GoPro instance is ready or not."""
  228. READY = 0
  229. ENCODING = 1 << 0
  230. SYSTEM_BUSY = 1 << 1
  231. @property
  232. @abstractmethod
  233. def ip_address(self) -> str:
  234. """The IP address of the GoPro device
  235. Raises:
  236. GoProNotOpened: The GoPro IP address is not yet available
  237. Returns:
  238. str: IP address
  239. """
  240. @property
  241. @abstractmethod
  242. def _base_url(self) -> str:
  243. """Build the base endpoint for USB commands
  244. Returns:
  245. str: base endpoint with URL from serial number
  246. """
  247. @property
  248. @abstractmethod
  249. def _api(self) -> ApiType:
  250. """Unique identifier for the connected GoPro Client
  251. Returns:
  252. ApiType: identifier
  253. """
  254. @staticmethod
  255. def _ensure_opened(interface: tuple[GoProMessageInterface]) -> Callable:
  256. """Raise exception if relevant interface is not currently opened
  257. Args:
  258. interface (tuple[GoProMessageInterface]): wireless interface to verify
  259. Returns:
  260. Callable: Direct pass-through of callable after verification
  261. """
  262. return ensure_opened(interface)
  263. @staticmethod
  264. def _catch_thread_exception(*args: Any, **kwargs: Any) -> Callable | None:
  265. """Catch any exceptions from this method and pass them to the exception handler identifier by thread name
  266. Args:
  267. *args (Any): positional args
  268. **kwargs (Any): keyword args
  269. Returns:
  270. Callable | None: forwarded return of wrapped method or None if exception occurs
  271. """
  272. return catch_thread_exception(*args, **kwargs)
  273. def _build_http_request_args(self, message: HttpMessage) -> dict[str, Any]:
  274. """Helper method to build request kwargs from message
  275. Args:
  276. message (HttpMessage): message to build args from
  277. Returns:
  278. dict[str, Any]: built args
  279. """
  280. # Dynamically build get kwargs
  281. request_args: dict[str, Any] = {}
  282. if message._headers:
  283. request_args["headers"] = message._headers
  284. if message._certificate:
  285. request_args["verify"] = str(message._certificate)
  286. return request_args
  287. @enforce_message_rules
  288. async def _get_json(
  289. self,
  290. message: HttpMessage,
  291. *,
  292. timeout: int = HTTP_TIMEOUT,
  293. rules: MessageRules = MessageRules(),
  294. **kwargs: Any,
  295. ) -> GoProResp:
  296. url = self._base_url + message.build_url(**kwargs)
  297. logger.debug(f"Sending: {url}")
  298. logger.info(Logger.build_log_tx_str(pretty_print(message._as_dict(**kwargs))))
  299. for retry in range(1, GoProBase.HTTP_GET_RETRIES + 1):
  300. try:
  301. http_response = self._requests_session.get(
  302. url,
  303. timeout=timeout,
  304. **self._build_http_request_args(message),
  305. )
  306. logger.trace(f"received raw json: {json.dumps(http_response.json() if http_response.text else {}, indent=4)}") # type: ignore
  307. if not http_response.ok:
  308. logger.warning(f"Received non-success status {http_response.status_code}: {http_response.reason}")
  309. response = RequestsHttpRespBuilderDirector(http_response, message._parser)()
  310. break
  311. except requests.exceptions.ConnectionError as e:
  312. # This appears to only occur after initial connection after pairing
  313. logger.warning(repr(e))
  314. # Back off before retrying. TODO This appears to be needed on MacOS
  315. await asyncio.sleep(2)
  316. except Exception as e: # pylint: disable=broad-exception-caught
  317. logger.error(f"Unexpected error: {repr(e)}")
  318. logger.warning(f"Retrying #{retry} to send the command...")
  319. else:
  320. raise ResponseTimeout(GoProBase.HTTP_GET_RETRIES)
  321. logger.info(Logger.build_log_rx_str(pretty_print(response._as_dict())))
  322. return response
  323. @enforce_message_rules
  324. async def _get_stream(
  325. self,
  326. message: HttpMessage,
  327. *,
  328. timeout: int = HTTP_TIMEOUT,
  329. rules: MessageRules = MessageRules(),
  330. **kwargs: Any,
  331. ) -> GoProResp:
  332. url = self._base_url + message.build_url(path=kwargs["camera_file"])
  333. logger.debug(f"Sending: {url}")
  334. with self._requests_session.get(
  335. url,
  336. stream=True,
  337. timeout=timeout,
  338. **self._build_http_request_args(message),
  339. ) as request:
  340. request.raise_for_status()
  341. file = kwargs["local_file"]
  342. with open(file, "wb") as f:
  343. logger.debug(f"receiving stream to {file}...")
  344. for chunk in request.iter_content(chunk_size=8192):
  345. f.write(chunk)
  346. return GoProResp(protocol=GoProResp.Protocol.HTTP, status=ErrorCode.SUCCESS, data=file, identifier=url)
  347. @enforce_message_rules
  348. async def _put_json(
  349. self,
  350. message: HttpMessage,
  351. *,
  352. timeout: int = HTTP_TIMEOUT,
  353. rules: MessageRules = MessageRules(),
  354. **kwargs: Any,
  355. ) -> GoProResp:
  356. url = self._base_url + message.build_url(**kwargs)
  357. body = message.build_body(**kwargs)
  358. logger.debug(f"Sending: {url} with body: {json.dumps(body, indent=4)}")
  359. for retry in range(1, GoProBase.HTTP_GET_RETRIES + 1):
  360. try:
  361. http_response = self._requests_session.put(
  362. url,
  363. timeout=timeout,
  364. json=body,
  365. **self._build_http_request_args(message),
  366. )
  367. logger.trace(f"received raw json: {json.dumps(http_response.json() if http_response.text else {}, indent=4)}") # type: ignore
  368. if not http_response.ok:
  369. logger.warning(f"Received non-success status {http_response.status_code}: {http_response.reason}")
  370. response = RequestsHttpRespBuilderDirector(http_response, message._parser)()
  371. break
  372. except requests.exceptions.ConnectionError as e:
  373. # This appears to only occur after initial connection after pairing
  374. logger.warning(repr(e))
  375. # Back off before retrying. TODO This appears to be needed on MacOS
  376. await asyncio.sleep(2)
  377. except Exception as e: # pylint: disable=broad-exception-caught
  378. logger.error(f"Unexpected error: {repr(e)}")
  379. logger.warning(f"Retrying #{retry} to send the command...")
  380. else:
  381. raise ResponseTimeout(GoProBase.HTTP_GET_RETRIES)
  382. return response