async def controller_error_middleware(request: web.Request, handler) -> web.Response: try: return await handler(request) except web.HTTPError: # pragma: no cover raise except Exception as ex: app = request.app message = strex(ex) debug = app['config']['debug'] LOGGER.error(f'[{request.url}] => {message}', exc_info=debug) response = {'error': message} if debug: response['traceback'] = traceback.format_tb(ex.__traceback__) if isinstance(ex, BrewbloxException): http_error = ex.http_error else: # pragma: no cover http_error = web.HTTPInternalServerError raise http_error(text=json.dumps(response), content_type='application/json')
async def connect_subprocess(proc: subprocess.Popen, address: str) -> ConnectionResult: host = SUBPROCESS_HOST port = SUBPROCESS_PORT message = None # We just started a subprocess # Give it some time to get started and respond to the port for _ in range(SUBPROCESS_CONNECT_RETRY_COUNT): await asyncio.sleep(SUBPROCESS_CONNECT_INTERVAL_S) if proc.poll() is not None: raise ChildProcessError( f'Subprocess exited with return code {proc.returncode}') try: reader, writer = await asyncio.open_connection(host, port) return ConnectionResult(host=host, port=port, address=address, process=proc, reader=reader, writer=writer) except OSError as ex: message = strex(ex) LOGGER.debug(f'Subprocess connection error: {message}') # Kill off leftovers with suppress(Exception): proc.terminate() raise ConnectionError(message)
async def _broadcast(self): LOGGER.info(f'Starting {self}') try: api = ObjectApi(self.app) spark_status: status.SparkStatus = status.get_status(self.app) except Exception as ex: LOGGER.error(strex(ex), exc_info=True) raise ex while True: try: await spark_status.synchronized.wait() await asyncio.sleep(PUBLISH_INTERVAL_S) if not self._queues: self._current = None continue self._current = await api.all() coros = [q.put(self._current) for q in self._queues] await asyncio.wait_for( asyncio.gather(*coros, return_exceptions=True), PUBLISH_INTERVAL_S) except asyncio.CancelledError: break except Exception as ex: self._current = None warnings.warn(f'{self} encountered an error: {strex(ex)}')
async def decode( self, obj_type: ObjType_, values: Optional[Encoded_] = ..., opts: Optional[CodecOpts] = None ) -> Union[ObjType_, Tuple[ObjType_, Decoded_]]: """ Decodes given data to a Python-compatible type. Does not guarantee perfect symmetry with `encode()`, only symmetric compatibility. `encode()` can correctly interpret the return values of `decode()`, and vice versa. Args: obj_type (ObjType_): The unique identifier of the codec type. This determines how `values` are decoded. values (Optional[Encoded_]): Encoded representation of the message. If not set, only decoded object type will be returned. opts (Optional[CodecOpts]): Additional options that are passed to the transcoder. Returns: ObjType_: If `values` is not set, only decoded object type is returned. Tuple[ObjType_, Decoded_]: Python-compatible values of both object type and values. """ if not isinstance(values, (bytes, list, type(...))): raise TypeError( f'Unable to decode [{type(values).__name__}] values') if opts is not None and not isinstance(opts, CodecOpts): raise TypeError(f'Invalid codec opts: {opts}') type_name = obj_type no_content = values == ... try: opts = opts or CodecOpts() trc = Transcoder.get(obj_type, self._mod) type_name = trc.type_str() return type_name if no_content else (type_name, trc.decode(values, opts)) except asyncio.CancelledError: # pragma: no cover raise except Exception as ex: msg = strex(ex) LOGGER.debug(msg, exc_info=True) return 'UnknownType' if no_content else ('ErrorObject', { 'error': msg, 'type': type_name })
async def decode( self, identifier: Identifier_, data: Optional[Union[str, bytes]], opts: Optional[DecodeOpts] = None ) -> tuple[Identifier_, Optional[dict]]: """ Decodes given data to a Python-compatible type. Does not guarantee perfect symmetry with `encode()`, only symmetric compatibility. `encode()` can correctly interpret the return values of `decode()`, and vice versa. Args: identifier (Identifier_): The unique identifier of the codec type. This determines how `values` are decoded. data (Optional[Union[str, bytes]]): Base-64 representation of the message bytes. A byte string is acceptable. opts (Optional[DecodeOpts]): Additional options that are passed to the transcoder. Returns: tuple[Identifier_, Optional[dict]]: Decoded identifier, and decoded data. Data will be None if it was None in args. """ if data is not None and not isinstance(data, (str, bytes)): raise TypeError(f'Unable to decode [{type(data).__name__}]') if opts is not None and not isinstance(opts, DecodeOpts): raise TypeError(f'Invalid codec opts: {opts}') decoded_identifier = identifier try: opts = opts or DecodeOpts() trc = Transcoder.get(identifier, self._proto_proc) decoded_identifier = (trc.type_str(), trc.subtype_str()) if data is None: return (decoded_identifier, None) else: data = data if isinstance(data, str) else data.decode() data = b''.join((b64decode(subs) for subs in data.split(','))) return (decoded_identifier, trc.decode(data, opts)) except Exception as ex: msg = strex(ex) LOGGER.debug(msg, exc_info=True) if data is None: return (('UnknownType', None), None) else: return (('ErrorObject', None), { 'error': msg, 'identifier': decoded_identifier })
async def prompt_handshake(): while True: try: await asyncio.sleep(PING_INTERVAL_S) LOGGER.info('prompting handshake...') await self.commander.version() except Exception as ex: LOGGER.error(strex(ex)) pass
async def get(self) -> web.WebSocketResponse: """ Open a WebSocket to stream values from the database as they are added. When the socket is open, it supports commands for ranges and metrics. Each command starts a separate stream, but all streams share the same socket. Streams are identified by a command-defined ID. Tags: TimeSeries """ self.ws = web.WebSocketResponse() streams = {} try: await self.ws.prepare(self.request) socket_closer.add(self.app, self.ws) async for msg in self.ws: # pragma: no branch try: cmd = TimeSeriesStreamCommand.parse_raw(msg.data) existing: asyncio.Task = streams.pop(cmd.id, None) existing and existing.cancel() if cmd.command == 'ranges': query = TimeSeriesRangesQuery(**cmd.query) streams[cmd.id] = asyncio.create_task( self._stream_ranges(cmd.id, query)) elif cmd.command == 'metrics': query = TimeSeriesMetricsQuery(**cmd.query) streams[cmd.id] = asyncio.create_task( self._stream_metrics(cmd.id, query)) elif cmd.command == 'stop': pass # We already removed any pre-existing task from streams # Pydantic validates commands # This path should never be reached else: # pragma: no cover raise NotImplementedError('Unknown command') except Exception as ex: LOGGER.error(f'Stream read error {strex(ex)}') await self.ws.send_json({ 'error': strex(ex), 'message': msg, }) finally: socket_closer.discard(self.app, self.ws) # Coverage complains about next line -> exit not being covered for task in streams.values(): # pragma: no cover task.cancel() return self.ws
def error_response(request: web.Request, ex: Exception, status: int) -> web.Response: app = request.app message = strex(ex) debug = app['config']['debug'] LOGGER.error(f'[{request.url}] => {message}', exc_info=debug) response = {'error': message} if debug: response['traceback'] = traceback.format_tb(ex.__traceback__) return web.json_response(response, status=status)
async def check_remote(app: web.Application): if app['config']['volatile']: return num_attempts = 0 while True: try: await http.session(app).get(f'{STORE_URL}/ping') return except Exception as ex: LOGGER.error(strex(ex)) num_attempts += 1 if num_attempts % 10 == 0: LOGGER.info(f'Waiting for datastore... ({strex(ex)})') await asyncio.sleep(RETRY_INTERVAL_S)
async def flash(self) -> dict: # pragma: no cover sender = ymodem.FileSender(self._notify) cmder = commander.fget(self.app) address = service_status.desc(self.app).device_address self._notify( f'Started updating {self.name}@{address} to version {self.version} ({self.date})' ) try: if not service_status.desc(self.app).is_connected: self._notify('Controller is not connected. Aborting update.') raise exceptions.NotConnected() if self.simulation: raise NotImplementedError( 'Firmware updates not available for simulation controllers' ) self._notify('Sending update command to controller') service_status.set_updating(self.app) await asyncio.sleep(FLUSH_PERIOD_S ) # Wait for in-progress commands to finish await cmder.execute(commands.FirmwareUpdateCommand.from_args()) self._notify('Shutting down normal communication') await cmder.shutdown(self.app) self._notify('Waiting for normal connection to close') await asyncio.wait_for(service_status.wait_disconnected(self.app), STATE_TIMEOUT_S) self._notify(f'Connecting to {address}') conn = await self._connect(address) with conn.autoclose(): await asyncio.wait_for(sender.transfer(conn), TRANSFER_TIMEOUT_S) except Exception as ex: self._notify(f'Failed to update firmware: {strex(ex)}') raise exceptions.FirmwareUpdateFailed(strex(ex)) finally: self._notify('Scheduling service reboot') await shutdown_soon(self.app, UPDATE_SHUTDOWN_DELAY_S) self._notify('Firmware updated!') return {'address': address, 'version': self.version}
async def run(self): try: await service_status.wait_autoconnecting(self.app) service_status.set_connected(self.app, self._address) self.update_ticks() await self.welcome() while True: await asyncio.sleep(3600) except Exception as ex: # pragma: no cover LOGGER.error(strex(ex)) raise ex finally: service_status.set_disconnected(self.app)
async def encode( self, obj_type: ObjType_, values: Optional[Decoded_] = ..., opts: Optional[CodecOpts] = None ) -> Union[ObjType_, Tuple[ObjType_, Encoded_]]: """ Encode given data to a serializable type. Does not guarantee perfect symmetry with `decode()`, only symmetric compatibility. `decode()` can correctly interpret the return values of `encode()`, and vice versa. Args: obj_type (ObjType_): The unique identifier of the codec type. This determines how `values` are encoded. values (Optional(Decoded_)): Decoded representation of the message. If not set, only encoded object type will be returned. opts (Optional[CodecOpts]): Additional options that are passed to the transcoder. Returns: ObjType_: If `values` is not set, only encoded object type is returned. Tuple[ObjType_, Encoded_]: Serializable values of both object type and values. """ if not isinstance(values, (dict, type(...))): raise TypeError( f'Unable to encode [{type(values).__name__}] values') if opts is not None and not isinstance(opts, CodecOpts): raise TypeError(f'Invalid codec opts: {opts}') try: opts = opts or CodecOpts() trc = Transcoder.get(obj_type, self._mod) return trc.type_int() if values is ... \ else (trc.type_int(), trc.encode(deepcopy(values), opts)) except Exception as ex: msg = strex(ex) LOGGER.debug(msg, exc_info=True) raise exceptions.EncodeException(msg)
async def connect_serial(address) -> Connection: LOGGER.info(f'Creating bridge for {address}') proc = subprocess.Popen([ '/usr/bin/socat', 'tcp-listen:8332,reuseaddr,fork', f'file:{address},raw,echo=0,b{YMODEM_TRANSFER_BAUD_RATE}' ]) last_err = None for _ in range(5): try: await asyncio.sleep(1) return await connect_tcp('localhost:8332', proc) except OSError as ex: last_err = strex(ex) LOGGER.debug(f'Subprocess connection error: {last_err}') raise ConnectionError(last_err)
async def encode( self, identifier: Identifier_, data: Optional[dict], ) -> tuple[Identifier_, Optional[str]]: """ Encode given data to a serializable type. Does not guarantee perfect symmetry with `decode()`, only symmetric compatibility. `decode()` can correctly interpret the return values of `encode()`, and vice versa. Args: identifier (Identifier_): The fully qualified identifier of the codec type. This determines how `data` is encoded. data (Optional(dict)): Decoded representation of the message. If not set, only encoded object type will be returned. Returns: tuple[Identifier, Optional[str]]: Numeric identifier, and encoded data. Data will be None if it was None in args. """ if data is not None and not isinstance(data, dict): raise TypeError(f'Unable to encode [{type(data).__name__}]') try: trc = Transcoder.get(identifier, self._proto_proc) encoded_identifier = (trc.type_int(), trc.subtype_int()) if data is None: return (encoded_identifier, None) else: return (encoded_identifier, b64encode(trc.encode(deepcopy(data))).decode()) except Exception as ex: msg = strex(ex) LOGGER.debug(msg, exc_info=True) raise exceptions.EncodeException(msg)
async def flash(self) -> dict: # pragma: no cover ota = ymodem.OtaClient(self._notify) cmder = commander.fget(self.app) status_desc = service_status.desc(self.app) address = status_desc.device_address platform = status_desc.device_info.platform self._notify( f'Started updating {self.name}@{address} to version {self.version} ({self.date})' ) try: if not status_desc.is_connected: self._notify('Controller is not connected. Aborting update.') raise exceptions.NotConnected() if self.simulation: raise NotImplementedError( 'Firmware updates not available for simulation controllers' ) self._notify('Preparing update') service_status.set_updating(self.app) await asyncio.sleep(FLUSH_PERIOD_S ) # Wait for in-progress commands to finish if platform != 'esp32': # pragma: no cover self._notify('Sending update command to controller') await cmder.execute(commands.FirmwareUpdateCommand.from_args()) self._notify('Waiting for normal connection to close') await cmder.shutdown(self.app) await asyncio.wait_for(service_status.wait_disconnected(self.app), STATE_TIMEOUT_S) if platform == 'esp32': # pragma: no cover # ESP connections will always be a TCP address host, _ = address.split(':') self._notify(f'Sending update prompt to {host}') self._notify( 'The Spark will now download and apply the new firmware') self._notify('The update is done when the service reconnects') fw_url = ESP_URL_FMT.format(**self.app['ini']) await http.session(self.app ).post(f'http://{host}:80/firmware_update', data=fw_url) else: self._notify(f'Connecting to {address}') conn = await ymodem.connect(address) with conn.autoclose(): await asyncio.wait_for( ota.send(conn, f'firmware/brewblox-{platform}.bin'), TRANSFER_TIMEOUT_S) self._notify('Update done!') except Exception as ex: self._notify(f'Failed to update firmware: {strex(ex)}') raise exceptions.FirmwareUpdateFailed(strex(ex)) finally: self._notify('Restarting service...') await shutdown_soon(self.app, UPDATE_SHUTDOWN_DELAY_S) return {'address': address, 'version': self.version}
async def write(self, request_b64: Union[str, bytes]): # pragma: no cover try: self.update_ticks() _, dict_content = await self._codec.decode( (codec.REQUEST_TYPE, None), request_b64, ) request = EncodedRequest(**dict_content) payload = request.payload if payload: (in_blockType, _), in_content = await self._codec.decode( (payload.blockType, payload.subtype), payload.content, ) else: in_blockType = 0 in_content = None response = EncodedResponse(msgId=request.msgId, error=ErrorCode.OK, payload=[]) if self.next_error: error = self.next_error.pop(0) if error is None: return # No response at all else: response.error = error elif request.opcode in [ Opcode.NONE, Opcode.VERSION, ]: await self.welcome() elif request.opcode in [Opcode.BLOCK_READ, Opcode.STORAGE_READ]: block = self._blocks.get(payload.blockId) if not block: response.error = ErrorCode.INVALID_BLOCK_ID else: (blockType, subtype), content = await self._codec.encode( (block.type, None), block.data, ) response.payload = [ EncodedPayload(blockId=block.nid, blockType=blockType, subtype=subtype, content=content) ] elif request.opcode in [ Opcode.BLOCK_READ_ALL, Opcode.STORAGE_READ_ALL, ]: for block in self._blocks.values(): (blockType, subtype), content = await self._codec.encode( (block.type, None), block.data, ) response.payload.append( EncodedPayload( blockId=block.nid, blockType=blockType, subtype=subtype, content=content, )) elif request.opcode == Opcode.BLOCK_WRITE: block = self._blocks.get(payload.blockId) if not block: response.error = ErrorCode.INVALID_BLOCK_ID elif not in_content: response.error = ErrorCode.INVALID_BLOCK elif in_blockType != block.type: response.error = ErrorCode.INVALID_BLOCK_TYPE else: block.data = in_content (blockType, subtype), data = await self._codec.encode( (block.type, None), block.data, ) response.payload = [ EncodedPayload(blockId=block.nid, blockType=blockType, subtype=subtype, data=data) ] elif request.opcode == Opcode.BLOCK_CREATE: nid = payload.blockId block = self._blocks.get(nid) if block: response.error = ErrorCode.BLOCK_NOT_CREATABLE elif nid > 0 and nid < const.USER_NID_START: response.error = ErrorCode.BLOCK_NOT_CREATABLE elif not in_content: response.error = ErrorCode.INVALID_BLOCK else: nid = nid or next(self._id_counter) block = FirmwareBlock( nid=nid, type=in_blockType, data=in_content, ) self._blocks[nid] = block (blockType, subtype), content = await self._codec.encode( (block.type, None), block.data, ) response.payload = [ EncodedPayload( blockId=block.nid, blockType=blockType, subtype=subtype, content=content, ) ] elif request.opcode == Opcode.BLOCK_DELETE: nid = payload.blockId block = self._blocks.get(nid) if not block: response.error = ErrorCode.INVALID_BLOCK_ID elif nid < const.USER_NID_START: response.error = ErrorCode.BLOCK_NOT_DELETABLE else: del self._blocks[nid] elif request.opcode == Opcode.BLOCK_DISCOVER: # Always return spark pins when discovering blocks block = self._blocks[const.SPARK_PINS_NID] (blockType, subtype), content = await self._codec.encode( (block.type, None), block.data, ) response.payload = [ EncodedPayload( blockId=block.nid, blockType=blockType, subtype=subtype, content=content, ) ] elif request.opcode == Opcode.REBOOT: self._start_time = datetime.now() self.update_ticks() elif request.opcode == Opcode.CLEAR_BLOCKS: self._blocks = default_blocks() self.update_ticks() elif request.opcode == Opcode.CLEAR_WIFI: self._blocks[const.WIFI_SETTINGS_NID].data.clear() elif request.opcode == Opcode.FACTORY_RESET: self._blocks = default_blocks() self.update_ticks() elif request.opcode == Opcode.FIRMWARE_UPDATE: pass else: response.error = ErrorCode.INVALID_OPCODE _, response_b64 = await self._codec.encode( (codec.RESPONSE_TYPE, None), response.dict()) await self._on_data(response_b64) except Exception as ex: LOGGER.error(strex(ex)) raise ex
async def controller_error_middleware(request: web.Request, handler: web.RequestHandler) -> web.Response: try: return await handler(request) except Exception as ex: LOGGER.error(f'REST error: {strex(ex)}', exc_info=request.app['config']['debug']) return web.json_response({'error': strex(ex)}, status=500)