gopro_wireless.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894
  1. # gopro_wireless.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 Wireless GoPros."""
  4. from __future__ import annotations
  5. import asyncio
  6. import enum
  7. import logging
  8. import queue
  9. import traceback
  10. from collections import defaultdict
  11. from copy import deepcopy
  12. from pathlib import Path
  13. from types import TracebackType
  14. from typing import Any, Callable, Final
  15. import requests
  16. from tinydb import TinyDB
  17. import open_gopro.features
  18. from open_gopro.api import (
  19. BleCommands,
  20. BleSettings,
  21. BleStatuses,
  22. HttpCommands,
  23. HttpSettings,
  24. WirelessApi,
  25. )
  26. from open_gopro.domain.communicator_interface import (
  27. BleMessage,
  28. GoProBle,
  29. GoProWirelessInterface,
  30. HttpMessage,
  31. Message,
  32. MessageRules,
  33. )
  34. from open_gopro.domain.exceptions import (
  35. ConnectFailed,
  36. ConnectionTerminated,
  37. GoProNotOpened,
  38. InterfaceConfigFailure,
  39. InvalidOpenGoProVersion,
  40. ResponseTimeout,
  41. )
  42. # These are imported this way for monkeypatching in pytest
  43. from open_gopro.domain.gopro_observable import GoProObservable
  44. from open_gopro.gopro_base import (
  45. GoProBase,
  46. GoProMessageInterface,
  47. enforce_message_rules,
  48. )
  49. from open_gopro.models import GoProResp
  50. from open_gopro.models.constants import GoProUUID, StatusId
  51. from open_gopro.models.constants.settings import SettingId
  52. from open_gopro.models.types import ProtobufId, ResponseType, UpdateCb, UpdateType
  53. from open_gopro.network.ble import BleakWrapperController, BleUUID
  54. from open_gopro.network.ble.controller import BLEController
  55. from open_gopro.network.wifi import WifiCli
  56. from open_gopro.network.wifi.controller import WifiController
  57. from open_gopro.network.wifi.requests_session import create_less_strict_requests_session
  58. from open_gopro.parsers.response import BleRespBuilder
  59. from open_gopro.util import SnapshotQueue, get_current_dst_aware_time, pretty_print
  60. from open_gopro.util.logger import Logger
  61. logger = logging.getLogger(__name__)
  62. class _ReadyLock:
  63. """Camera ready state lock manager"""
  64. class _LockOwner(enum.Enum):
  65. """Current owner of the communication lock"""
  66. RULE_ENFORCER = enum.auto()
  67. STATE_MANAGER = enum.auto()
  68. def __init__(self) -> None:
  69. self.lock = asyncio.Lock()
  70. self.owner: _ReadyLock._LockOwner | None = None
  71. async def __aenter__(self) -> _ReadyLock:
  72. """Acquire lock with clear ownership tracking"""
  73. await self.lock.acquire()
  74. return self
  75. async def __aexit__(self, exc_type: BaseException, exc_val: Any, exc_tb: TracebackType) -> None:
  76. """Release lock and clear ownership"""
  77. if self.lock.locked():
  78. self.lock.release()
  79. self.owner = None
  80. async def acquire(self, owner: _LockOwner) -> None:
  81. """Acquire lock with specified owner
  82. Args:
  83. owner (_LockOwner): Owner attempting to acquire lock
  84. """
  85. logger.trace(f"{owner.name} acquiring lock") # type: ignore
  86. await self.lock.acquire()
  87. self.owner = owner
  88. logger.trace(f"{owner.name} acquired lock") # type: ignore
  89. def release(self) -> None:
  90. """Release lock if locked"""
  91. if self.lock.locked():
  92. logger.trace(f"{self.owner.name} releasing lock") # type: ignore
  93. self.lock.release()
  94. self.owner = None
  95. class WirelessGoPro(GoProBase[WirelessApi], GoProWirelessInterface):
  96. """The top-level BLE and Wifi interface to a Wireless GoPro device.
  97. See the `Open GoPro SDK <https://gopro.github.io/OpenGoPro/python_sdk>`_ for complete documentation.
  98. This will handle, for BLE:
  99. - discovering target GoPro device
  100. - establishing the connection
  101. - discovering GATT characteristics
  102. - enabling notifications
  103. - discovering Open GoPro version
  104. - setting the date, time, timezone, and DST
  105. - transferring data
  106. This will handle, for Wifi:
  107. - finding SSID and password
  108. - establishing Wifi connection
  109. - transferring data
  110. This will handle, for COHN:
  111. - connecting to Access Point and provisioning COHN
  112. - maintaining the COHN credential database
  113. - appending COHN headers to HTTP requests
  114. It will also do some state management, etc:
  115. - ensuring camera is ready / not encoding before transferring data
  116. - sending keep alive signal periodically
  117. - tracking COHN state
  118. If no target arg is passed in, the first discovered BLE GoPro device will be connected to.
  119. It can be used via context manager:
  120. >>> async with WirelessGoPro() as gopro:
  121. >>> # Send some messages now
  122. Or without:
  123. >>> gopro = WirelessGoPro()
  124. >>> await gopro.open()
  125. >>> # Send some messages now
  126. Attributes:
  127. WRITE_TIMEOUT (Final[int]): BLE Write Timeout in seconds. Not configurable.
  128. Args:
  129. target (str | None): The trailing digits of the target GoPro's serial number to search for.
  130. Defaults to None which will connect to the first discovered GoPro.
  131. host_wifi_interface (str | None): used to specify the wifi interface the local machine will use to connect
  132. to the GoPro. Defaults to None in which case the first discovered interface will be used. This is only
  133. needed if you have multiple wifi interfaces on your machine.
  134. host_sudo_password (str | None): User password for sudo. Defaults to None in which case you will
  135. be prompted if a password is needed which should only happen on Nix systems. This is only needed for
  136. Nix systems where the user does not have passwordless sudo access to the wifi interface.
  137. cohn_db (Path): Path to COHN Database. Defaults to Path("cohn_db.json").
  138. interfaces (set[WirelessGoPro.Interface] | None): Wireless interfaces for which to attempt to
  139. establish communication channels. Defaults to None in which case both BLE and WiFi will be used.
  140. **kwargs (Any): additional parameters for internal use / testing
  141. Raises:
  142. ValueError: Invalid combination of arguments.
  143. InterfaceConfigFailure: In order to communicate via Wifi, there must be an available
  144. Wifi Interface. By default during initialization, the Wifi driver will attempt to automatically
  145. discover such an interface. If it does not find any, it will raise this exception. Note that
  146. the interface can also be specified manually with the 'wifi_interface' argument.
  147. """
  148. WRITE_TIMEOUT: Final[int] = 5
  149. class Interface(enum.Enum):
  150. """GoPro Wireless Interface selection"""
  151. BLE = enum.auto() #: Bluetooth Low Energy
  152. WIFI_AP = enum.auto() #: Wifi Access Point
  153. COHN = enum.auto() #: Camera on the Home Network (WIFI_STA mode).
  154. def __init__(
  155. self,
  156. target: str | None = None,
  157. host_wifi_interface: str | None = None,
  158. host_sudo_password: str | None = None,
  159. cohn_db: Path = Path("cohn_db.json"),
  160. interfaces: set[WirelessGoPro.Interface] | None = None,
  161. **kwargs: Any,
  162. ) -> None:
  163. GoProBase.__init__(self, **kwargs)
  164. # Store initialization information
  165. interfaces = interfaces or {WirelessGoPro.Interface.BLE, WirelessGoPro.Interface.WIFI_AP}
  166. self._should_enable_wifi = WirelessGoPro.Interface.WIFI_AP in interfaces
  167. self._should_enable_ble = WirelessGoPro.Interface.BLE in interfaces
  168. self._should_enable_cohn = WirelessGoPro.Interface.COHN in interfaces
  169. self._cohn_credentials = kwargs.get("cohn_credentials")
  170. self._is_cohn_configured = False
  171. # Valid parameter selections
  172. if self._should_enable_wifi and self._should_enable_cohn:
  173. raise ValueError("Can not have simultaneous COHN and Wifi Access Point connections")
  174. if self._should_enable_wifi and not self._should_enable_ble:
  175. raise ValueError("Can not have Wifi Access Point connection without BLE")
  176. self._identifier = target
  177. ble_adapter: type[BLEController] = kwargs.get("ble_adapter", BleakWrapperController)
  178. wifi_adapter: type[WifiController] = kwargs.get("wifi_adapter", WifiCli)
  179. # Set up API delegate
  180. self._wireless_api = WirelessApi(self)
  181. self._keep_alive_interval: int = kwargs.get("keep_alive_interval", 3)
  182. try:
  183. # Initialize GoPro Communication Client
  184. GoProWirelessInterface.__init__(
  185. self,
  186. ble_controller=ble_adapter(self._handle_exception),
  187. wifi_controller=wifi_adapter(host_wifi_interface, password=host_sudo_password),
  188. disconnected_cb=self._disconnect_handler,
  189. notification_cb=self._notification_handler,
  190. target=target,
  191. )
  192. except InterfaceConfigFailure as e:
  193. logger.error(
  194. "Could not find a suitable Wifi Interface. If there is an available Wifi interface, try passing it manually with the 'wifi_interface' argument."
  195. )
  196. raise e
  197. # Feature delegates
  198. self.cohn = open_gopro.features.CohnFeature(TinyDB(cohn_db, indent=4))
  199. self.access_point = open_gopro.features.AccessPointFeature()
  200. self.streaming = open_gopro.features.StreamFeature()
  201. # Builders for currently accumulating synchronous responses, indexed by GoProUUID. This assumes there
  202. # can only be one active response per BleUUID
  203. self._active_builders: dict[BleUUID, BleRespBuilder] = {}
  204. # Responses that we are waiting for.
  205. self._sync_resp_wait_q: SnapshotQueue[ResponseType] = SnapshotQueue()
  206. # Synchronous response that has been parsed and are ready for their sender to receive as the response.
  207. self._sync_resp_ready_q: SnapshotQueue[GoProResp] = SnapshotQueue()
  208. self._listeners: dict[UpdateType | GoProBle._CompositeRegisterType, set[UpdateCb]] = defaultdict(set)
  209. # To be set up when opening in async context
  210. self._loop: asyncio.AbstractEventLoop
  211. self._open = False
  212. self._is_ble_connected = False
  213. self._ble_disconnect_event: asyncio.Event
  214. if self._should_maintain_state:
  215. self._status_tasks: list[asyncio.Task] = []
  216. self._state_acquire_lock_tasks: list[asyncio.Task] = []
  217. self._ready_lock: _ReadyLock
  218. self._keep_alive_task: asyncio.Task
  219. self._encoding: bool
  220. self._busy: bool
  221. self._encoding_started: asyncio.Event
  222. @property
  223. def identifier(self) -> str:
  224. """Get a unique identifier for this instance.
  225. The identifier is the last 4 digits of the camera. That is, the same string that is used to
  226. scan for the camera for BLE.
  227. Raises:
  228. GoProNotOpened: Client is not opened yet so no identifier is available
  229. Returns:
  230. str: last 4 digits if available, else None
  231. """
  232. if not self._identifier:
  233. raise GoProNotOpened("Identifier not yet set")
  234. return self._identifier
  235. @property
  236. def is_ble_connected(self) -> bool:
  237. """Are we connected via BLE to the GoPro device?
  238. Returns:
  239. bool: True if yes, False if no
  240. """
  241. # We can't rely on the BLE Client since it can be connected but not ready
  242. return self._is_ble_connected
  243. @property
  244. def is_http_connected(self) -> bool:
  245. """Are we connected via HTTP to the GoPro device?
  246. That is, are we connected to the camera's access point or via COHN?
  247. Returns:
  248. bool: True if yes, False if no
  249. """
  250. return self._is_cohn_configured or self._wifi.is_connected
  251. @property
  252. def ble_command(self) -> BleCommands:
  253. """Used to call the BLE commands
  254. Returns:
  255. BleCommands: the commands
  256. """
  257. return self._api.ble_command
  258. @property
  259. def ble_setting(self) -> BleSettings:
  260. """Used to access the BLE settings
  261. Returns:
  262. BleSettings: the settings
  263. """
  264. return self._api.ble_setting
  265. @property
  266. def ble_status(self) -> BleStatuses:
  267. """Used to access the BLE statuses
  268. Returns:
  269. BleStatuses: the statuses
  270. """
  271. return self._api.ble_status
  272. @property
  273. def http_command(self) -> HttpCommands:
  274. """Used to access the Wifi commands
  275. Returns:
  276. HttpCommands: the commands
  277. """
  278. return self._api.http_command
  279. @property
  280. def http_setting(self) -> HttpSettings:
  281. """Used to access the Wifi settings
  282. Returns:
  283. HttpSettings: the settings
  284. """
  285. return self._api.http_setting
  286. async def open(self, timeout: int = 15, retries: int = 5) -> None:
  287. """Perform all initialization commands for ble and wifi
  288. For BLE: scan and find device, establish connection, discover characteristics, configure queries
  289. start maintenance, and get Open GoPro version..
  290. For Wifi: discover SSID and password, enable and connect. Or disable if not using.
  291. Raises:
  292. Exception: Any exceptions during opening are propagated through
  293. InvalidOpenGoProVersion: Only 2.0 is supported
  294. InterfaceConfigFailure: Requested connection(s) failed to establish
  295. Args:
  296. timeout (int): How long to wait for each connection before timing out. Defaults to 10.
  297. retries (int): How many connection attempts before considering connection failed. Defaults to 5.
  298. """
  299. # Set up concurrency
  300. self._loop = asyncio.get_running_loop()
  301. self._ble_disconnect_event = asyncio.Event()
  302. # If we are to perform BLE housekeeping
  303. if self._should_maintain_state:
  304. self._ready_lock = _ReadyLock()
  305. self._keep_alive_task = asyncio.create_task(self._periodic_keep_alive())
  306. self._encoding = True
  307. self._busy = True
  308. self._encoding_started = asyncio.Event()
  309. await self.cohn.open(gopro=self, loop=self._loop, cohn_credentials=self._cohn_credentials)
  310. await self.access_point.open(self._loop, self)
  311. await self.streaming.open(self._loop, self)
  312. RETRIES = 5
  313. for retry in range(RETRIES):
  314. try:
  315. if self._should_enable_ble:
  316. await self._open_ble(timeout, retries)
  317. # TODO need to handle sending these if BLE does not exist
  318. await self.ble_command.set_third_party_client_info()
  319. # Set current dst-aware time. Don't assert on success since some old cameras don't support this command.
  320. dt, tz_offset, is_dst = get_current_dst_aware_time()
  321. await self.ble_command.set_date_time_tz_dst(date_time=dt, tz_offset=tz_offset, is_dst=is_dst)
  322. # Find and configure API version
  323. version = (await self.ble_command.get_open_gopro_api_version()).data
  324. if version != self.version:
  325. raise InvalidOpenGoProVersion(version)
  326. logger.info(f"Using Open GoPro API version {version}")
  327. await self.cohn.wait_until_ready()
  328. # Establish Wifi / COHN connection if desired
  329. if self._should_enable_wifi:
  330. await self._open_wifi(timeout, retries)
  331. elif self._should_enable_cohn:
  332. # TODO DNS scan?
  333. if await self.cohn.is_configured:
  334. self._is_cohn_configured = True
  335. else:
  336. logger.warning("COHN needs to be configured.")
  337. # We need at least one connection to continue
  338. if not self.is_ble_connected and not self.is_http_connected:
  339. raise InterfaceConfigFailure("No connections were established.")
  340. if not self.is_ble_connected and self._should_maintain_state:
  341. logger.warning("Can not maintain state without BLE")
  342. self._should_maintain_state = False
  343. self._open = True
  344. return
  345. except Exception as e: # pylint: disable=broad-exception-caught
  346. logger.error(f"Error while opening: {repr(e)}")
  347. traceback.print_exc()
  348. await self.close()
  349. if retry >= RETRIES - 1:
  350. raise e
  351. async def close(self) -> None:
  352. """Safely stop the GoPro instance.
  353. This will disconnect BLE and WiFI if applicable.
  354. If not using the context manager, it is mandatory to call this before exiting the program in order to
  355. prevent reconnection issues because the OS has never disconnected from the previous session.
  356. """
  357. try:
  358. await self._close_wifi()
  359. await self._close_ble()
  360. for feature in [self.cohn, self.access_point, self.streaming]:
  361. await feature.close()
  362. except AttributeError:
  363. # This is possible if the GoPro was never opened
  364. pass
  365. except Exception as e: # pylint: disable=broad-exception-caught
  366. logger.warning(f"Error while closing features: {repr(e)}")
  367. self._open = False
  368. def register_update(self, callback: UpdateCb, update: UpdateType) -> None:
  369. """Register for callbacks when an update occurs
  370. Args:
  371. callback (UpdateCb): callback to be notified in
  372. update (UpdateType): update to register for
  373. """
  374. return self._register_update(callback, update)
  375. def _register_update(self, callback: UpdateCb, update: GoProBle._CompositeRegisterType | UpdateType) -> None:
  376. """Common register method for both public UpdateType and "protected" internal register type
  377. Args:
  378. callback (UpdateCb): callback to register
  379. update (GoProBle._CompositeRegisterType | UpdateType): update type to register for
  380. """
  381. self._listeners[update].add(callback)
  382. def unregister_update(self, callback: UpdateCb, update: UpdateType | None = None) -> None:
  383. """Unregister for asynchronous update(s)
  384. Args:
  385. callback (UpdateCb): callback to stop receiving update(s) on
  386. update (UpdateType | None): updates to unsubscribe for. Defaults to None (all
  387. updates that use this callback will be unsubscribed).
  388. """
  389. return self._unregister_update(callback, update)
  390. def _unregister_update(
  391. self, callback: UpdateCb, update: GoProBle._CompositeRegisterType | UpdateType | None = None
  392. ) -> None:
  393. """Common unregister method for both public UpdateType and "protected" internal register type
  394. Args:
  395. callback (UpdateCb): callback to unregister
  396. update (GoProBle._CompositeRegisterType | UpdateType | None): Update type to unregister for. Defaults to
  397. None which will unregister the callback for all update types.
  398. """
  399. if update:
  400. try:
  401. self._listeners.get(update, set()).remove(callback)
  402. except KeyError:
  403. # This is possible if, for example, the register occurred with register and the unregister is now an
  404. # individual setting / status
  405. return
  406. else:
  407. # If update was not specified, remove all uses of callback
  408. for key in dict(self._listeners).keys():
  409. try:
  410. self._listeners[key].remove(callback)
  411. except KeyError:
  412. continue
  413. @property
  414. def is_open(self) -> bool:
  415. """Is this client ready for communication?
  416. Returns:
  417. bool: True if yes, False if no
  418. """
  419. return self._open
  420. @property
  421. async def is_ready(self) -> bool:
  422. """Is gopro ready to receive commands
  423. Returns:
  424. bool: yes if ready, no otherwise
  425. """
  426. return not (self._busy or self._encoding)
  427. ##########################################################################################################
  428. #### Abstracted commands
  429. @GoProBase._ensure_opened((GoProMessageInterface.BLE,))
  430. async def keep_alive(self) -> bool:
  431. """Send a heartbeat to prevent the BLE connection from dropping.
  432. This is sent automatically by the GoPro instance if its `maintain_ble` argument is not False.
  433. Returns:
  434. bool: True if it succeeded,. False otherwise
  435. """
  436. return (await self.ble_setting.led.set(66)).ok # type: ignore
  437. ##########################################################################################################
  438. # End Public API
  439. ##########################################################################################################
  440. async def _enforce_message_rules(
  441. self, wrapped: Callable, message: Message, rules: MessageRules = MessageRules(), **kwargs: Any
  442. ) -> GoProResp:
  443. """Enforce rules around message sending"""
  444. if self._should_maintain_state and self.is_open and not rules.is_fastpass(**kwargs):
  445. logger.trace("Rule enforcer acquiring lock") # type: ignore
  446. async with self._ready_lock as lock:
  447. lock.owner = _ReadyLock._LockOwner.RULE_ENFORCER
  448. logger.trace("Rule enforcer acquired lock") # type: ignore
  449. response = await wrapped(message, **kwargs)
  450. logger.trace("Rule enforcer released lock") # type: ignore
  451. else:
  452. response = await wrapped(message, **kwargs)
  453. # Handle post-response actions
  454. if self._should_maintain_state and rules.should_wait_for_encoding_start(**kwargs):
  455. await self._encoding_started.wait()
  456. self._encoding_started.clear()
  457. return response
  458. async def _notify_listeners(self, update: UpdateType, value: Any) -> None:
  459. """Notify all registered listeners of this update
  460. Args:
  461. update (UpdateType): update to notify
  462. value (Any): value to notify
  463. """
  464. listeners: set[UpdateCb] = set()
  465. # check individual updates
  466. for listener in self._listeners.get(update, []):
  467. listeners.add(listener)
  468. # Now check our internal composite updates
  469. match update:
  470. case StatusId():
  471. for listener in self._listeners.get(GoProBle._CompositeRegisterType.ALL_STATUSES, []):
  472. listeners.add(listener)
  473. case SettingId():
  474. for listener in self._listeners.get(GoProBle._CompositeRegisterType.ALL_SETTINGS, []):
  475. listeners.add(listener)
  476. for listener in listeners:
  477. await listener(update, value)
  478. async def _periodic_keep_alive(self) -> None:
  479. """Task to periodically send the keep alive message via BLE."""
  480. while True:
  481. if self.is_ble_connected:
  482. if not await self.keep_alive():
  483. logger.error("Failed to send keep alive")
  484. await asyncio.sleep(self._keep_alive_interval)
  485. async def _open_ble(self, timeout: int = 10, retries: int = 5) -> None:
  486. """Connect the instance to a device via BLE.
  487. Raises:
  488. InterfaceConfigFailure: failed to get identifier from BLE client
  489. Args:
  490. timeout (int): Time in seconds before considering establishment failed. Defaults to 10 seconds.
  491. retries (int): How many tries to reconnect after failures. Defaults to 5.
  492. """
  493. # Establish connection, pair, etc.
  494. await self._ble.open(timeout, retries)
  495. self._is_ble_connected = True
  496. await self.ble_command.set_pairing_complete()
  497. if not self._ble.identifier:
  498. raise InterfaceConfigFailure("Failed to get identifier from BLE client")
  499. self._identifier = self._ble.identifier[-4:]
  500. # Start state maintenance
  501. if self._should_maintain_state:
  502. logger.trace("State manager initially acquiring lock") # type: ignore
  503. await self._ready_lock.acquire(_ReadyLock._LockOwner.STATE_MANAGER)
  504. logger.trace("State manager initially acquired lock") # type: ignore
  505. self._ble_disconnect_event.clear()
  506. async def _handle_encoding(observable: GoProObservable) -> None:
  507. async for encoding_status in observable.observe(debug_id=StatusId.ENCODING.name):
  508. asyncio.create_task(self._update_internal_state(StatusId.ENCODING, encoding_status))
  509. async def _handle_busy(observable: GoProObservable) -> None:
  510. async for busy_status in observable.observe(debug_id=StatusId.BUSY.name):
  511. asyncio.create_task(self._update_internal_state(StatusId.BUSY, busy_status))
  512. self._status_tasks.append(
  513. asyncio.create_task(_handle_encoding((await self.ble_status.encoding.get_value_observable()).unwrap()))
  514. )
  515. self._status_tasks.append(
  516. asyncio.create_task(_handle_busy((await self.ble_status.busy.get_value_observable()).unwrap()))
  517. )
  518. logger.info("BLE is ready!")
  519. async def _update_internal_state(self, update: UpdateType, value: int) -> None:
  520. """Update internal state based on camera status changes"""
  521. # Clean up pending tasks
  522. logger.trace(f"Received internal state update {update}: {value}") # type: ignore
  523. for task in self._state_acquire_lock_tasks:
  524. task.cancel()
  525. self._state_acquire_lock_tasks.clear()
  526. # Update state variables
  527. previous_ready = await self.is_ready
  528. encoding_started = False
  529. if update == StatusId.ENCODING:
  530. encoding_started = not self._encoding and bool(value)
  531. self._encoding = bool(value)
  532. elif update == StatusId.BUSY:
  533. self._busy = bool(value)
  534. current_ready = await self.is_ready
  535. logger.trace(f"Current state: {self._encoding=}, {self._busy=}, {current_ready=}") # type: ignore
  536. # Handle lock state transitions based on camera readiness
  537. if self._ready_lock.owner == _ReadyLock._LockOwner.STATE_MANAGER:
  538. if current_ready and not previous_ready:
  539. # Camera became ready, release lock
  540. self._ready_lock.release()
  541. elif not current_ready and self._ready_lock.owner != _ReadyLock._LockOwner.STATE_MANAGER:
  542. # Camera became busy, acquire lock
  543. try:
  544. task = asyncio.create_task(self._ready_lock.acquire(_ReadyLock._LockOwner.STATE_MANAGER))
  545. self._state_acquire_lock_tasks.append(task)
  546. await task
  547. except asyncio.CancelledError:
  548. pass
  549. # Notify encoding started if applicable
  550. if encoding_started and self.is_open:
  551. self._encoding_started.set()
  552. async def _route_response(self, response: GoProResp) -> None:
  553. """After parsing response, route it to any stakeholders (such as registered listeners)
  554. Args:
  555. response (GoProResp): parsed response to route
  556. """
  557. original_response = deepcopy(response)
  558. # We only support queries for either one ID or all ID's. If this is an individual query, extract the value
  559. # for cleaner response data
  560. if response._is_query and not response._is_push and len(response.data) == 1:
  561. response.data = list(response.data.values())[0]
  562. # Check if this is the awaited synchronous response (id matches). Note! these have to come in order.
  563. if ((head := await self._sync_resp_wait_q.peek_front()) == response.identifier) or (
  564. # There is a special case for an unsupported protobuf error response. It does not contain the feature ID so we
  565. # have to match it by Feature ID / UUID. Feature ID's are not used across UUID's so we will only check for
  566. # matching Feature ID(s)
  567. isinstance(head, ProtobufId)
  568. and ProtobufId(head.feature_id, None)
  569. == response.identifier # Feature IDs match and response identifier does not contain Action ID
  570. ):
  571. logger.info(Logger.build_log_rx_str(original_response, asynchronous=False))
  572. # Dequeue it and put this on the ready queue
  573. await self._sync_resp_wait_q.get()
  574. await self._sync_resp_ready_q.put(response)
  575. # If this wasn't the awaited synchronous response...
  576. else:
  577. logger.info(Logger.build_log_rx_str(original_response, asynchronous=True))
  578. if response._is_push:
  579. for update_id, value in response.data.items():
  580. await self._notify_listeners(update_id, value)
  581. elif isinstance(response.identifier, ProtobufId):
  582. await self._notify_listeners(response.identifier, response.data)
  583. def _notification_handler(self, handle: int, data: bytearray) -> None:
  584. """Receive notifications from the BLE controller.
  585. Args:
  586. handle (int): Attribute handle that notification was received on.
  587. data (bytearray): Bytestream that was received.
  588. """
  589. async def _async_notification_handler() -> None:
  590. # Responses we don't care about. For now, just the BLE-spec defined battery characteristic
  591. if (uuid := self._ble.gatt_db.handle2uuid(handle)) == GoProUUID.BATT_LEVEL:
  592. return
  593. logger.debug(f'Received response on BleUUID [{uuid}]: {data.hex(":")}')
  594. # Add to response dict if not already there
  595. if uuid not in self._active_builders:
  596. builder = BleRespBuilder()
  597. builder.set_uuid(uuid)
  598. self._active_builders[uuid] = builder
  599. # Accumulate the packet
  600. self._active_builders[uuid].accumulate(data)
  601. if (builder := self._active_builders[uuid]).is_finished_accumulating:
  602. # Clear active response from response dict
  603. del self._active_builders[uuid]
  604. await self._route_response(builder.build())
  605. asyncio.run_coroutine_threadsafe(_async_notification_handler(), self._loop)
  606. async def _close_ble(self) -> None:
  607. """Terminate BLE connection if it is connected"""
  608. if self._should_maintain_state:
  609. for task in [*self._status_tasks, *self._state_acquire_lock_tasks, self._keep_alive_task]:
  610. task.cancel()
  611. try:
  612. await task
  613. except asyncio.CancelledError:
  614. pass # This exception is expected when cancelling a task
  615. if self.is_ble_connected and self._ble is not None:
  616. await self._ble.close()
  617. await self._ble_disconnect_event.wait()
  618. def _disconnect_handler(self, _: Any) -> None:
  619. """Disconnect callback from BLE controller
  620. Raises:
  621. ConnectionTerminated: We entered this callback in an unexpected state.
  622. """
  623. self._is_ble_connected = False
  624. if self._ble_disconnect_event.is_set():
  625. raise ConnectionTerminated("BLE connection terminated unexpectedly.")
  626. self._ble_disconnect_event.set()
  627. @GoProBase._ensure_opened((GoProMessageInterface.BLE,))
  628. @enforce_message_rules
  629. async def _send_ble_message(
  630. self, message: BleMessage, rules: MessageRules = MessageRules(), **kwargs: Any
  631. ) -> GoProResp:
  632. # Store information on the response we are expecting
  633. await self._sync_resp_wait_q.put(message._identifier)
  634. logger.info(Logger.build_log_tx_str(pretty_print(message._as_dict(**kwargs))))
  635. # Fragment data and write it
  636. for packet in self._fragment(message._build_data(**kwargs)):
  637. logger.debug(f"Writing to [{message._uuid.name}] UUID: {packet.hex(':')}")
  638. await self._ble.write(message._uuid, packet)
  639. # Wait to be notified that response was received
  640. try:
  641. response = await asyncio.wait_for(self._sync_resp_ready_q.get(), WirelessGoPro.WRITE_TIMEOUT)
  642. except asyncio.TimeoutError as e:
  643. logger.error(
  644. f"Response timeout of {WirelessGoPro.WRITE_TIMEOUT} seconds when sending {message._identifier}!"
  645. )
  646. raise ResponseTimeout(WirelessGoPro.WRITE_TIMEOUT) from e
  647. except queue.Empty as e:
  648. logger.error(
  649. f"Response timeout of {WirelessGoPro.WRITE_TIMEOUT} seconds when sending {message._identifier}!"
  650. )
  651. raise ResponseTimeout(WirelessGoPro.WRITE_TIMEOUT) from e
  652. # Check status
  653. if not response.ok:
  654. logger.warning(f"Received non-success status: {response.status}")
  655. return response
  656. @GoProBase._ensure_opened((GoProMessageInterface.BLE,))
  657. @enforce_message_rules
  658. async def _read_ble_characteristic(
  659. self, message: BleMessage, rules: MessageRules = MessageRules(), **kwargs: Any
  660. ) -> GoProResp:
  661. received_data = await self._ble.read(message._uuid)
  662. logger.debug(f"Reading from {message._uuid.name}")
  663. builder = BleRespBuilder()
  664. builder.set_uuid(message._uuid)
  665. builder.set_packet(received_data)
  666. return builder.build()
  667. def _handle_cohn(self, message: HttpMessage) -> HttpMessage:
  668. """Prepend COHN headers if COHN is provisioned
  669. Args:
  670. message (HttpMessage): HTTP message to append headers to
  671. Returns:
  672. HttpMessage: potentially modified HTTP message
  673. """
  674. try:
  675. if self._should_enable_cohn and self.cohn.credentials:
  676. message._headers["Authorization"] = self.cohn.credentials.auth_token
  677. message._certificate = self.cohn.credentials.certificate_as_path
  678. return message
  679. except GoProNotOpened:
  680. return message
  681. async def _get_json(self, message: HttpMessage, *args: Any, **kwargs: Any) -> GoProResp:
  682. message = self._handle_cohn(message)
  683. return await super()._get_json(*args, message=message, **kwargs)
  684. async def _get_stream(self, message: HttpMessage, *args: Any, **kwargs: Any) -> GoProResp:
  685. message = self._handle_cohn(message)
  686. return await super()._get_stream(*args, message=message, **kwargs)
  687. async def _put_json(self, message: HttpMessage, *args: Any, **kwargs: Any) -> GoProResp:
  688. message = self._handle_cohn(message)
  689. return await super()._put_json(*args, message=message, **kwargs)
  690. @GoProBase._ensure_opened((GoProMessageInterface.BLE,))
  691. async def _open_wifi(self, timeout: int = 30, retries: int = 5) -> None:
  692. """Connect to a GoPro device via Wifi.
  693. Args:
  694. timeout (int): Time before considering establishment failed. Defaults to 10 seconds.
  695. retries (int): How many tries to reconnect after failures. Defaults to 5.
  696. Raises:
  697. ConnectFailed: Was not able to establish the Wifi Connection
  698. """
  699. logger.info("Discovering Wifi AP info and enabling via BLE")
  700. password = (await self.ble_command.get_wifi_password()).data
  701. ssid = (await self.ble_command.get_wifi_ssid()).data
  702. for retry in range(1, retries):
  703. try:
  704. assert (await self.ble_command.enable_wifi_ap(enable=True)).ok
  705. async def _wait_for_camera_wifi_ready() -> None:
  706. logger.debug("Waiting for camera wifi ready status")
  707. while not (await self.ble_status.ap_mode.get_value()).data:
  708. await asyncio.sleep(0.200)
  709. await asyncio.wait_for(_wait_for_camera_wifi_ready(), 5)
  710. await self._wifi.open(ssid, password, timeout, 1)
  711. break
  712. except ConnectFailed:
  713. logger.warning(f"Wifi connection failed. Retrying #{retry}")
  714. # In case camera Wifi is in strange disable, reset it
  715. assert (await self.ble_command.enable_wifi_ap(enable=False)).ok
  716. else:
  717. raise ConnectFailed("Wifi Connection failed", timeout, retries)
  718. async def _close_wifi(self) -> None:
  719. """Terminate the Wifi connection."""
  720. if hasattr(self, "_wifi"): # Corner case where instantiation fails before superclass is initialized
  721. await self._wifi.close()
  722. @property
  723. def ip_address(self) -> str: # noqa: D102
  724. return self.cohn.credentials.ip_address if self._should_enable_cohn and self.cohn.credentials else "10.5.5.9"
  725. @property
  726. def _base_url(self) -> str:
  727. return (
  728. f"https://{self.ip_address}/"
  729. if self._should_enable_cohn and self.cohn.credentials
  730. else f"http://{self.ip_address}:8080/"
  731. )
  732. @property
  733. def _requests_session(self) -> requests.Session:
  734. return (
  735. create_less_strict_requests_session(self.cohn.credentials.certificate_as_path)
  736. if self._should_enable_cohn and self.cohn.credentials
  737. else requests.Session()
  738. )
  739. @property
  740. def _api(self) -> WirelessApi:
  741. return self._wireless_api