async def request(self, method, target, headers=None, body=None): """ Sends a HTTP request to the current transport and returns an awaitable that can be used to wait for the response. This will automatically set the header. :param method: A HTTP method, like 'GET' or 'POST' :param target: A URI to call the method on :param headers: a list of (header, value) tuples (optional) :param body: The body of the request (optional) """ if not self.protocol: raise AccessoryDisconnectedError( "Connection lost before request could be sent" ) buffer = [] buffer.append(f"{method.upper()} {target} HTTP/1.1") # WARNING: It is vital that a Host: header is present or some devices # will reject the request. buffer.append(f"Host: {self.host}") if headers: for (header, value) in headers: buffer.append(f"{header}: {value}") buffer.append("") buffer.append("") # WARNING: We use \r\n explicitly. \n is not enough for some. request_bytes = "\r\n".join(buffer).encode("utf-8") if body: request_bytes += body # WARNING: It is vital that each request is sent in one call # Some devices are sensitive to unecrypted HTTP requests made in # multiple packets. # https://github.com/jlusiardi/homekit_python/issues/12 # https://github.com/jlusiardi/homekit_python/issues/16 async with self._concurrency_limit: if not self.protocol: raise AccessoryDisconnectedError("Tried to send while not connected") logger.debug("%s: raw request: %r", self.host, request_bytes) resp = await self.protocol.send_bytes(request_bytes) if resp.code >= 400 and resp.code <= 499: logger.debug(f"Got HTTP error {resp.code} for {method} against {target}") raise HttpErrorResponse( f"Got HTTP error {resp.code} for {method} against {target}", response=resp, ) logger.debug("%s: raw response: %r", self.host, resp.body) return resp
async def put_json(self, target, body): response = await self.put( target, serialize_json(body), content_type=HttpContentTypes.JSON, ) if response.code == 204: return {} try: decoded = response.body.decode("utf-8") except UnicodeDecodeError: self.transport.close() raise AccessoryDisconnectedError( "Session closed after receiving non-utf8 response" ) try: parsed = hkjson.loads(decoded) except json.JSONDecodeError: self.transport.close() raise AccessoryDisconnectedError( "Session closed after receiving malformed response from device" ) return parsed
async def _ensure_connected(self): try: await asyncio.wait_for(self.connection.ensure_connection(), 10) except asyncio.TimeoutError: raise AccessoryDisconnectedError( "Timeout while waiting for connection to device" ) if not self.connection.is_connected: raise AccessoryDisconnectedError( "Ensure connection returned but still not connected" )
async def post_json(self, target, body): response = await self.post( target, serialize_json(body), content_type=HttpContentTypes.JSON, ) if response.code != 204: # FIXME: ... pass decoded = response.body.decode("utf-8") if not decoded: # FIXME: Verify this is correct return {} try: parsed = hkjson.loads(decoded) except json.JSONDecodeError: self.transport.close() raise AccessoryDisconnectedError( "Session closed after receiving malformed response from device" ) return parsed
async def send_bytes(self, payload): if self.transport.is_closing(): # FIXME: It would be nice to try and wait for the reconnect in future. # In that case we need to make sure we do it at a layer above send_bytes otherwise # we might encrypt payloads with the last sessions keys then wait for a new connection # to send them - and on that connection the keys would be different. # Also need to make sure that the new connection has chance to pair-verify before # queued writes can happy. raise AccessoryDisconnectedError("Transport is closed") self.transport.write(payload) # We return a future so that our caller can block on a reply # We can send many requests and dispatch the results in order # Should mean we don't need locking around request/reply cycles result = asyncio.Future() self.result_cbs.append(result) try: return await asyncio.wait_for(result, 30) except asyncio.TimeoutError: self.transport.write_eof() self.transport.close() raise AccessoryDisconnectedError("Timeout while waiting for response")
async def _ensure_connected(self, *, _timeout: float = 10): try: try: await asyncio.wait_for(self.connection.ensure_connection(), _timeout) except asyncio.TimeoutError: raise AccessoryDisconnectedError( "Timeout while waiting for connection to device") assert ( self.connection.is_connected ), "must be connected or receive exception after waiting for connection attempt" except: # noqa: E722 # Re-raise with cleanup is unproblematic # Ensure that no `_reconnect` task is left dangling in the background # # Previously, these would accumulate if the target device was # temporarily unavailable and cause a SYN-storm to hit the device # when it became available again. await self.connection.close() raise
def close(self): # If the connection is closed then any pending callbacks will never # fire, so set them to an error state. while self.result_cbs: result = self.result_cbs.pop(0) result.set_exception(AccessoryDisconnectedError("Connection closed"))