From 42e069455ec9c960a7fbad44e5b8387ba09760d4 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Sat, 6 Jul 2024 12:43:34 -0700 Subject: [PATCH 01/30] transition through power stress states and capture data meshtastic-py3.12kevinh@kdesktop:~/development/meshtastic/meshtastic-python$ cd /home/kevinh/development/meshtastic/meshtastic-python ; /usr/bin/env /home/kevinh/.cache/pypoetry/virtualenvs/meshtastic-l6tP90xw-py3.12/bin/python /home/kevinh/.vscode/extensions/ms-python.debugpy-2024.6.0-linux-x64/bundled/libs/debugpy/adapter/../../debugpy/launcher 52521 -- -m meshtastic --slog --power-ppk2-meter --power-stress --power-voltage 3.3 INFO file:ppk2.py __init__ line:52 Connected to Power Profiler Kit II (PPK2) INFO file:__main__.py create_power_meter line:1022 Setting power supply to 3.3 volts Connected to radio INFO file:slog.py __init__ line:183 Writing slogs to /home/kevinh/.local/share/meshtastic/slogs/20240706-123803 INFO file:stress.py syncPowerStress line:68 Sending power stress command PRINT_INFO INFO file:stress.py run line:105 Running power stress test 48 for 5.0 seconds INFO file:stress.py syncPowerStress line:68 Sending power stress command LED_ON INFO file:stress.py run line:105 Running power stress test 80 for 5.0 seconds INFO file:stress.py syncPowerStress line:68 Sending power stress command BT_OFF INFO file:stress.py run line:105 Running power stress test 81 for 5.0 seconds INFO file:stress.py syncPowerStress line:68 Sending power stress command BT_ON INFO file:stress.py run line:105 Running power stress test 34 for 5.0 seconds INFO file:stress.py syncPowerStress line:68 Sending power stress command CPU_FULLON INFO file:stress.py run line:105 Running power stress test 32 for 5.0 seconds INFO file:stress.py syncPowerStress line:68 Sending power stress command CPU_IDLE INFO file:stress.py run line:105 Running power stress test 33 for 5.0 seconds INFO file:stress.py syncPowerStress line:68 Sending power stress command CPU_DEEPSLEEP INFO file:stress.py run line:108 Power stress test complete. INFO file:slog.py close line:201 Closing slogs in /home/kevinh/.local/share/meshtastic/slogs/20240706-123803 WARNING file:arrow.py close line:67 Discarding empty file: /home/kevinh/.local/share/meshtastic/slogs/20240706-123803/slog.arrow INFO file:arrow.py close line:70 Compressing log data into /home/kevinh/.local/share/meshtastic/slogs/20240706-123803/power.feather meshtastic-py3.12kevinh@kdesktop:~/development/meshtastic/meshtastic-python$ --- .vscode/launch.json | 2 +- .vscode/settings.json | 1 + meshtastic/__main__.py | 6 +++- meshtastic/powermon/stress.py | 60 +++++++++++++++++++++++++---------- 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 9b4dca42..3b82225b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -204,7 +204,7 @@ "request": "launch", "module": "meshtastic", "justMyCode": false, - "args": ["--slog-out", "default", "--power-ppk2-meter", "--power-stress", "--power-voltage", "3.3", "--seriallog"] + "args": ["--slog", "--power-ppk2-meter", "--power-stress", "--power-voltage", "3.3"] }, { "name": "meshtastic test", diff --git a/.vscode/settings.json b/.vscode/settings.json index 8fbd0a0e..db434be9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "cSpell.words": [ "bitmask", "boardid", + "DEEPSLEEP", "Meshtastic", "milliwatt", "portnums", diff --git a/meshtastic/__main__.py b/meshtastic/__main__.py index 87d23cb5..3873e44d 100644 --- a/meshtastic/__main__.py +++ b/meshtastic/__main__.py @@ -852,14 +852,16 @@ def setSimpleConfig(modem_preset): qr = pyqrcode.create(url) print(qr.terminal()) + log_set: Optional[LogSet] = None # we need to keep a reference to the logset so it doesn't get GCed early if args.slog or args.power_stress: # Setup loggers global meter # pylint: disable=global-variable-not-assigned - LogSet(interface, args.slog if args.slog != 'default' else None, meter) + log_set = LogSet(interface, args.slog if args.slog != 'default' else None, meter) if args.power_stress: stress = PowerStress(interface) stress.run() + closeNow = True # exit immediately after stress test if args.listen: closeNow = False @@ -895,6 +897,8 @@ def setSimpleConfig(modem_preset): # if the user didn't ask for serial debugging output, we might want to exit after we've done our operation if (not args.seriallog) and closeNow: interface.close() # after running command then exit + if log_set: + log_set.close() except Exception as ex: print(f"Aborting due to: {ex}") diff --git a/meshtastic/powermon/stress.py b/meshtastic/powermon/stress.py index 665e58be..2a07c636 100644 --- a/meshtastic/powermon/stress.py +++ b/meshtastic/powermon/stress.py @@ -3,7 +3,7 @@ import logging import time -from ..protobuf import ( portnums_pb2, powermon_pb2 ) +from ..protobuf import portnums_pb2, powermon_pb2 def onPowerStressResponse(packet, interface): @@ -53,6 +53,32 @@ def sendPowerStress( onResponseAckPermitted=True, ) + def syncPowerStress( + self, + cmd: powermon_pb2.PowerStressMessage.Opcode.ValueType, + num_seconds: float = 0.0, + ): + """Send a power stress command and wait for the ack.""" + gotAck = False + + def onResponse(packet: dict): # pylint: disable=unused-argument + nonlocal gotAck + gotAck = True + + logging.info(f"Sending power stress command {powermon_pb2.PowerStressMessage.Opcode.Name(cmd)}") + self.sendPowerStress(cmd, onResponse=onResponse, num_seconds=num_seconds) + + if num_seconds == 0.0: + # Wait for the response and then continue + while not gotAck: + time.sleep(0.1) + else: + # we wait a little bit longer than the time the UUT would be waiting (to make sure all of its messages are handled first) + time.sleep(num_seconds + 0.2) # completely block our thread for the duration of the test + if not gotAck: + logging.error("Did not receive ack for power stress command!") + + class PowerStress: """Walk the UUT through a set of power states so we can capture repeatable power consumption measurements.""" @@ -63,19 +89,21 @@ def __init__(self, iface): def run(self): """Run the power stress test.""" # Send the power stress command - gotAck = False - - def onResponse(packet: dict): # pylint: disable=unused-argument - nonlocal gotAck - gotAck = True - - logging.info("Starting power stress test, attempting to contact UUT...") - self.client.sendPowerStress( - powermon_pb2.PowerStressMessage.PRINT_INFO, onResponse=onResponse - ) - - # Wait for the response - while not gotAck: - time.sleep(0.1) - logging.info("Power stress test complete.") + self.client.syncPowerStress(powermon_pb2.PowerStressMessage.PRINT_INFO) + + num_seconds = 5.0 + states = [ + powermon_pb2.PowerStressMessage.LED_ON, + powermon_pb2.PowerStressMessage.BT_OFF, + powermon_pb2.PowerStressMessage.BT_ON, + powermon_pb2.PowerStressMessage.CPU_FULLON, + powermon_pb2.PowerStressMessage.CPU_IDLE, + powermon_pb2.PowerStressMessage.CPU_DEEPSLEEP, + ] + for s in states: + s_name = powermon_pb2.PowerStressMessage.Opcode.Name(s) + logging.info(f"Running power stress test {s_name} for {num_seconds} seconds") + self.client.syncPowerStress(s, num_seconds) + + logging.info("Power stress test complete.") \ No newline at end of file From 4c02114b750cab795ef4bfbab55cc22b4cbbddaa Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Sat, 6 Jul 2024 13:43:19 -0700 Subject: [PATCH 02/30] fix null pointer if closing an interface which was already shutting down --- meshtastic/serial_interface.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/meshtastic/serial_interface.py b/meshtastic/serial_interface.py index 9a8307d0..13b1f6e6 100644 --- a/meshtastic/serial_interface.py +++ b/meshtastic/serial_interface.py @@ -67,9 +67,10 @@ def __init__(self, devPath: Optional[str]=None, debugOut=None, noProto=False, co def close(self): """Close a connection to the device""" - self.stream.flush() - time.sleep(0.1) - self.stream.flush() - time.sleep(0.1) + if self.stream: # Stream can be null if we were already closed + self.stream.flush() # FIXME: why are there these two flushes with 100ms sleeps? This shouldn't be necessary + time.sleep(0.1) + self.stream.flush() + time.sleep(0.1) logging.debug("Closing Serial stream") StreamInterface.close(self) From 462d9a83dfa26baec0b76d96876b44916ddc53c1 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Sat, 6 Jul 2024 15:07:13 -0700 Subject: [PATCH 03/30] Automatically extract and store all known structured-logs --- .vscode/launch.json | 2 +- meshtastic/__main__.py | 6 ++-- meshtastic/powermon/stress.py | 4 ++- meshtastic/slog/arrow.py | 13 ++++++-- meshtastic/slog/slog.py | 58 +++++++++++++++++++++++++---------- 5 files changed, 60 insertions(+), 23 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 3b82225b..aac37db2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -204,7 +204,7 @@ "request": "launch", "module": "meshtastic", "justMyCode": false, - "args": ["--slog", "--power-ppk2-meter", "--power-stress", "--power-voltage", "3.3"] + "args": ["--slog", "--power-ppk2-meter", "--power-stress", "--power-voltage", "3.3", "--seriallog"] }, { "name": "meshtastic test", diff --git a/meshtastic/__main__.py b/meshtastic/__main__.py index 3873e44d..1bcfd56c 100644 --- a/meshtastic/__main__.py +++ b/meshtastic/__main__.py @@ -897,8 +897,10 @@ def setSimpleConfig(modem_preset): # if the user didn't ask for serial debugging output, we might want to exit after we've done our operation if (not args.seriallog) and closeNow: interface.close() # after running command then exit - if log_set: - log_set.close() + + # Close any structured logs after we've done all of our API operations + if log_set: + log_set.close() except Exception as ex: print(f"Aborting due to: {ex}") diff --git a/meshtastic/powermon/stress.py b/meshtastic/powermon/stress.py index 2a07c636..4e82508e 100644 --- a/meshtastic/powermon/stress.py +++ b/meshtastic/powermon/stress.py @@ -95,11 +95,13 @@ def run(self): num_seconds = 5.0 states = [ powermon_pb2.PowerStressMessage.LED_ON, + powermon_pb2.PowerStressMessage.LED_OFF, powermon_pb2.PowerStressMessage.BT_OFF, powermon_pb2.PowerStressMessage.BT_ON, powermon_pb2.PowerStressMessage.CPU_FULLON, powermon_pb2.PowerStressMessage.CPU_IDLE, - powermon_pb2.PowerStressMessage.CPU_DEEPSLEEP, + # FIXME - can't test deepsleep yet because the ttyACM device disappears. Fix the python code to retry connections + # powermon_pb2.PowerStressMessage.CPU_DEEPSLEEP, ] for s in states: s_name = powermon_pb2.PowerStressMessage.Opcode.Name(s) diff --git a/meshtastic/slog/arrow.py b/meshtastic/slog/arrow.py index 08337044..be908cea 100644 --- a/meshtastic/slog/arrow.py +++ b/meshtastic/slog/arrow.py @@ -29,13 +29,22 @@ def close(self): self.writer.close() self.sink.close() + def set_schema(self, schema: pa.Schema): + """Set the schema for the file. + Only needed for datasets where we can't learn it from the first record written. + + schema (pa.Schema): The schema to use. + """ + assert self.schema is None + self.schema = schema + self.writer = pa.ipc.new_stream(self.sink, schema) + def _write(self): """Write the new rows to the file.""" if len(self.new_rows) > 0: if self.schema is None: # only need to look at the first row to learn the schema - self.schema = pa.Table.from_pylist([self.new_rows[0]]).schema - self.writer = pa.ipc.new_stream(self.sink, self.schema) + self.set_schema(pa.Table.from_pylist([self.new_rows[0]]).schema) self.writer.write_batch(pa.RecordBatch.from_pylist(self.new_rows)) self.new_rows = [] diff --git a/meshtastic/slog/slog.py b/meshtastic/slog/slog.py index d5c19980..91c63506 100644 --- a/meshtastic/slog/slog.py +++ b/meshtastic/slog/slog.py @@ -9,10 +9,12 @@ import time from dataclasses import dataclass from datetime import datetime +from functools import reduce from typing import Optional import parse # type: ignore[import-untyped] import platformdirs +import pyarrow as pa from pubsub import pub # type: ignore[import-untyped] from meshtastic.mesh_interface import MeshInterface @@ -26,15 +28,29 @@ class LogDef: """Log definition.""" code: str # i.e. PM or B or whatever... see meshtastic slog documentation + fields: list[tuple[str, pa.DataType]] # A list of field names and their arrow types format: parse.Parser # A format string that can be used to parse the arguments - def __init__(self, code: str, fmt: str) -> None: + def __init__(self, code: str, fields: list[tuple[str, pa.DataType]]) -> None: """Initialize the LogDef object. code (str): The code. format (str): The format. + """ self.code = code + self.fields = fields + + fmt = "" + for idx, f in enumerate(fields): + if idx != 0: + fmt += "," + + # make the format string + suffix = ( + "" if f[1] == pa.string() else ":d" + ) # treat as a string or an int (the only types we have so far) + fmt += "{" + f[0] + suffix + "}" self.format = parse.compile(fmt) @@ -42,8 +58,9 @@ def __init__(self, code: str, fmt: str) -> None: log_defs = { d.code: d for d in [ - LogDef("B", "{boardid:d},{version}"), - LogDef("PM", "{bitmask:d},{reason}"), + LogDef("B", [("board_id", pa.uint32()), ("sw_version", pa.string())]), + LogDef("PM", [("pm_mask", pa.uint64()), ("pm_reason", pa.string())]), + LogDef("PS", [("ps_state", pa.uint64())]), ] } log_regex = re.compile(".*S:([0-9A-Za-z]+):(.*)") @@ -99,7 +116,15 @@ def __init__(self, client: MeshInterface, dir_path: str) -> None: client (MeshInterface): The MeshInterface object to monitor. """ self.client = client + + # Setup the arrow writer (and its schema) self.writer = FeatherWriter(f"{dir_path}/slog") + all_fields = reduce( + (lambda x, y: x + y), map(lambda x: x.fields, log_defs.values()) + ) + + self.writer.set_schema(pa.schema(all_fields)) + self.raw_file: Optional[ io.TextIOWrapper ] = open( # pylint: disable=consider-using-with @@ -131,21 +156,20 @@ def _onLogMessage(self, line: str) -> None: src = m.group(1) args = m.group(2) args += " " # append a space so that if the last arg is an empty str it will still be accepted as a match - logging.debug(f"SLog {src}, reason: {args}") - if src != "PM": - logging.warning(f"Not yet handling structured log {src} (FIXME)") - else: - d = log_defs.get(src) - if d: - r = d.format.parse(args) # get the values with the correct types - if r: - di = r.named - di["time"] = datetime.now() - self.writer.add_row(di) - else: - logging.warning(f"Failed to parse slog {line} with {d.format}") + logging.debug(f"SLog {src}, args: {args}") + + d = log_defs.get(src) + if d: + r = d.format.parse(args) # get the values with the correct types + if r: + di = r.named + di["time"] = datetime.now() + self.writer.add_row(di) else: - logging.warning(f"Unknown Structured Log: {line}") + logging.warning(f"Failed to parse slog {line} with {d.format}") + else: + logging.warning(f"Unknown Structured Log: {line}") + if self.raw_file: self.raw_file.write(line + "\n") # Write the raw log From 1e447cb52aa6a9bbd3cd97839d627316cce40d89 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Sat, 6 Jul 2024 15:26:15 -0700 Subject: [PATCH 04/30] also store raw log messages in the slog file. --- .vscode/launch.json | 2 +- meshtastic/slog/slog.py | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index aac37db2..85e35baf 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -204,7 +204,7 @@ "request": "launch", "module": "meshtastic", "justMyCode": false, - "args": ["--slog", "--power-ppk2-meter", "--power-stress", "--power-voltage", "3.3", "--seriallog"] + "args": ["--slog", "--power-ppk2-meter", "--power-stress", "--power-voltage", "3.3", "--seriallog", "--ble"] }, { "name": "meshtastic test", diff --git a/meshtastic/slog/slog.py b/meshtastic/slog/slog.py index 91c63506..16660553 100644 --- a/meshtastic/slog/slog.py +++ b/meshtastic/slog/slog.py @@ -110,7 +110,7 @@ class StructuredLogger: """Sniffs device logs for structured log messages, extracts those into apache arrow format. Also writes the raw log messages to raw.txt""" - def __init__(self, client: MeshInterface, dir_path: str) -> None: + def __init__(self, client: MeshInterface, dir_path: str, include_raw=True) -> None: """Initialize the StructuredLogger object. client (MeshInterface): The MeshInterface object to monitor. @@ -123,6 +123,10 @@ def __init__(self, client: MeshInterface, dir_path: str) -> None: (lambda x, y: x + y), map(lambda x: x.fields, log_defs.values()) ) + self.include_raw = include_raw + if self.include_raw: + all_fields.append(("raw", pa.string())) + self.writer.set_schema(pa.schema(all_fields)) self.raw_file: Optional[ @@ -151,6 +155,9 @@ def _onLogMessage(self, line: str) -> None: line (str): the line of log output """ + + di = {} # the dictionary of the fields we found to log + m = log_regex.match(line) if m: src = m.group(1) @@ -163,13 +170,18 @@ def _onLogMessage(self, line: str) -> None: r = d.format.parse(args) # get the values with the correct types if r: di = r.named - di["time"] = datetime.now() - self.writer.add_row(di) else: logging.warning(f"Failed to parse slog {line} with {d.format}") else: logging.warning(f"Unknown Structured Log: {line}") + # Store our structured log record + if di or self.include_raw: + di["time"] = datetime.now() + if self.include_raw: + di["raw"] = line + self.writer.add_row(di) + if self.raw_file: self.raw_file.write(line + "\n") # Write the raw log From fb191092fbca893fa7245db28dfe6913a168e733 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Sat, 6 Jul 2024 16:27:20 -0700 Subject: [PATCH 05/30] gracefully shutdown when BLE device connect fails --- meshtastic/ble_interface.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/meshtastic/ble_interface.py b/meshtastic/ble_interface.py index 038cee53..7c8aee68 100644 --- a/meshtastic/ble_interface.py +++ b/meshtastic/ble_interface.py @@ -53,9 +53,10 @@ def __init__( self._receiveThread.start() logging.debug("Threads running") + self.client: Optional[BLEClient] = None try: logging.debug(f"BLE connecting to: {address if address else 'any'}") - self.client: Optional[BLEClient] = self.connect(address) + self.client = self.connect(address) logging.debug("BLE connected") except BLEInterface.BLEError as e: self.close() @@ -207,7 +208,6 @@ def _sendToRadioImpl(self, toRadio): self.should_read = True def close(self): - atexit.unregister(self._exit_handler) try: MeshInterface.close(self) except Exception as e: @@ -219,6 +219,7 @@ def close(self): self._receiveThread = None if self.client: + atexit.unregister(self._exit_handler) self.client.disconnect() self.client.close() self.client = None From ecbda74bd63662434308e90a24fd09ba7758ea2e Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Sat, 6 Jul 2024 16:41:33 -0700 Subject: [PATCH 06/30] make PPK2 power supply monitor work in supply-mode --- .vscode/launch.json | 2 +- meshtastic/__main__.py | 18 ++++++++++++++---- meshtastic/powermon/ppk2.py | 6 ++++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 85e35baf..b3d3934d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -204,7 +204,7 @@ "request": "launch", "module": "meshtastic", "justMyCode": false, - "args": ["--slog", "--power-ppk2-meter", "--power-stress", "--power-voltage", "3.3", "--seriallog", "--ble"] + "args": ["--slog", "--power-ppk2-supply", "--power-stress", "--power-voltage", "3.3", "--seriallog", "--ble"] }, { "name": "meshtastic test", diff --git a/meshtastic/__main__.py b/meshtastic/__main__.py index 1bcfd56c..d9e3517c 100644 --- a/meshtastic/__main__.py +++ b/meshtastic/__main__.py @@ -1009,24 +1009,34 @@ def create_power_meter(): global meter # pylint: disable=global-statement args = mt_config.args + + # If the user specified a voltage, make sure it is valid + v = 0.0 + if args.power_voltage: + v = float(args.power_voltage) + if v < 0.8 or v > 5.0: + meshtastic.util.our_exit("Voltage must be between 0.8 and 5.0") + if args.power_riden: meter = RidenPowerSupply(args.power_riden) elif args.power_ppk2_supply or args.power_ppk2_meter: meter = PPK2PowerSupply() + assert v > 0, "Voltage must be specified for PPK2" + meter.v = v # PPK2 requires setting voltage before selecting supply mode meter.setIsSupply(args.power_ppk2_supply) elif args.power_sim: meter = SimPowerSupply() - if meter and args.power_voltage: - v = float(args.power_voltage) - if v < 0.5 or v >5.0: - meshtastic.util.our_exit("Voltage must be between 1.0 and 5.0") + if meter and v: logging.info(f"Setting power supply to {v} volts") meter.v = v meter.powerOn() if args.power_wait: input("Powered on, press enter to continue...") + else: + logging.info("Powered-on, waiting for device to boot") + time.sleep(5) def common(): """Shared code for all of our command line wrappers.""" diff --git a/meshtastic/powermon/ppk2.py b/meshtastic/powermon/ppk2.py index de400b97..1f4d45d5 100644 --- a/meshtastic/powermon/ppk2.py +++ b/meshtastic/powermon/ppk2.py @@ -118,9 +118,11 @@ def close(self) -> None: self.measurement_thread.join() # wait for our thread to finish super().close() - def setIsSupply(self, s: bool): + def setIsSupply(self, is_supply: bool): """If in supply mode we will provide power ourself, otherwise we are just an amp meter.""" + assert self.v > 0.8 # We must set a valid voltage before calling this method + self.r.set_source_voltage( int(self.v * 1000) ) # set source voltage in mV BEFORE setting source mode @@ -130,7 +132,7 @@ def setIsSupply(self, s: bool): self.r.start_measuring() # send command to ppk2 if ( - not s + not is_supply ): # min power outpuf of PPK2. If less than this assume we want just meter mode. self.r.use_ampere_meter() else: From 72e0f2a92b947e56adc21420ef2e857aec83f299 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Sun, 7 Jul 2024 13:47:02 -0700 Subject: [PATCH 07/30] Don't silently ingnore malformed protobufs (the \0 in the device side was at fault) --- meshtastic/ble_interface.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/meshtastic/ble_interface.py b/meshtastic/ble_interface.py index 7c8aee68..8d7f47ee 100644 --- a/meshtastic/ble_interface.py +++ b/meshtastic/ble_interface.py @@ -8,16 +8,14 @@ from threading import Thread from typing import List, Optional +import google.protobuf from bleak import BleakClient, BleakScanner, BLEDevice from bleak.exc import BleakDBusError, BleakError -import google.protobuf - from meshtastic.mesh_interface import MeshInterface -from .protobuf import ( - mesh_pb2, -) +from .protobuf import mesh_pb2 + SERVICE_UUID = "6ba1b218-15a8-461f-9fa8-5dcae273eafd" TORADIO_UUID = "f75c76d2-129e-4dad-a1dd-7866124401e7" FROMRADIO_UUID = "2c55e69e-4993-11ed-b878-0242ac120002" @@ -63,7 +61,9 @@ def __init__( raise e if self.client.has_characteristic(LEGACY_LOGRADIO_UUID): - self.client.start_notify(LEGACY_LOGRADIO_UUID, self.legacy_log_radio_handler) + self.client.start_notify( + LEGACY_LOGRADIO_UUID, self.legacy_log_radio_handler + ) if self.client.has_characteristic(LOGRADIO_UUID): self.client.start_notify(LOGRADIO_UUID, self.log_radio_handler) @@ -94,11 +94,15 @@ async def log_radio_handler(self, _, b): # pylint: disable=C0116 log_record = mesh_pb2.LogRecord() try: log_record.ParseFromString(bytes(b)) - except google.protobuf.message.DecodeError: - return - message = f'[{log_record.source}] {log_record.message}' if log_record.source else log_record.message - self._handleLogLine(message) + message = ( + f"[{log_record.source}] {log_record.message}" + if log_record.source + else log_record.message + ) + self._handleLogLine(message) + except google.protobuf.message.DecodeError: + logging.warning("Malformed LogRecord received. Skipping.") async def legacy_log_radio_handler(self, _, b): # pylint: disable=C0116 log_radio = b.decode("utf-8").replace("\n", "") @@ -215,7 +219,9 @@ def close(self): if self._want_receive: self.want_receive = False # Tell the thread we want it to stop - self._receiveThread.join(timeout=2) # If bleak is hung, don't wait for the thread to exit (it is critical we disconnect) + self._receiveThread.join( + timeout=2 + ) # If bleak is hung, don't wait for the thread to exit (it is critical we disconnect) self._receiveThread = None if self.client: From 84b4188211217ce0e21d537b2aa58288237dccde Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Sun, 7 Jul 2024 13:47:19 -0700 Subject: [PATCH 08/30] Gracefully cope with exceptions during power-stress test --- meshtastic/powermon/stress.py | 54 +++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/meshtastic/powermon/stress.py b/meshtastic/powermon/stress.py index 4e82508e..b416d8a3 100644 --- a/meshtastic/powermon/stress.py +++ b/meshtastic/powermon/stress.py @@ -65,7 +65,9 @@ def onResponse(packet: dict): # pylint: disable=unused-argument nonlocal gotAck gotAck = True - logging.info(f"Sending power stress command {powermon_pb2.PowerStressMessage.Opcode.Name(cmd)}") + logging.info( + f"Sending power stress command {powermon_pb2.PowerStressMessage.Opcode.Name(cmd)}" + ) self.sendPowerStress(cmd, onResponse=onResponse, num_seconds=num_seconds) if num_seconds == 0.0: @@ -74,12 +76,13 @@ def onResponse(packet: dict): # pylint: disable=unused-argument time.sleep(0.1) else: # we wait a little bit longer than the time the UUT would be waiting (to make sure all of its messages are handled first) - time.sleep(num_seconds + 0.2) # completely block our thread for the duration of the test + time.sleep( + num_seconds + 0.2 + ) # completely block our thread for the duration of the test if not gotAck: logging.error("Did not receive ack for power stress command!") - class PowerStress: """Walk the UUT through a set of power states so we can capture repeatable power consumption measurements.""" @@ -88,24 +91,27 @@ def __init__(self, iface): def run(self): """Run the power stress test.""" - # Send the power stress command - - self.client.syncPowerStress(powermon_pb2.PowerStressMessage.PRINT_INFO) - - num_seconds = 5.0 - states = [ - powermon_pb2.PowerStressMessage.LED_ON, - powermon_pb2.PowerStressMessage.LED_OFF, - powermon_pb2.PowerStressMessage.BT_OFF, - powermon_pb2.PowerStressMessage.BT_ON, - powermon_pb2.PowerStressMessage.CPU_FULLON, - powermon_pb2.PowerStressMessage.CPU_IDLE, - # FIXME - can't test deepsleep yet because the ttyACM device disappears. Fix the python code to retry connections - # powermon_pb2.PowerStressMessage.CPU_DEEPSLEEP, - ] - for s in states: - s_name = powermon_pb2.PowerStressMessage.Opcode.Name(s) - logging.info(f"Running power stress test {s_name} for {num_seconds} seconds") - self.client.syncPowerStress(s, num_seconds) - - logging.info("Power stress test complete.") \ No newline at end of file + try: + self.client.syncPowerStress(powermon_pb2.PowerStressMessage.PRINT_INFO) + + num_seconds = 5.0 + states = [ + powermon_pb2.PowerStressMessage.LED_ON, + powermon_pb2.PowerStressMessage.LED_OFF, + powermon_pb2.PowerStressMessage.BT_OFF, + powermon_pb2.PowerStressMessage.BT_ON, + powermon_pb2.PowerStressMessage.CPU_FULLON, + powermon_pb2.PowerStressMessage.CPU_IDLE, + # FIXME - can't test deepsleep yet because the ttyACM device disappears. Fix the python code to retry connections + # powermon_pb2.PowerStressMessage.CPU_DEEPSLEEP, + ] + for s in states: + s_name = powermon_pb2.PowerStressMessage.Opcode.Name(s) + logging.info( + f"Running power stress test {s_name} for {num_seconds} seconds" + ) + self.client.syncPowerStress(s, num_seconds) + + logging.info("Power stress test complete.") + except KeyboardInterrupt as e: + logging.warning(f"Power stress interrupted: {e}") From d35423a8161f60b87e9cf333e4d2adc79f30e185 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Sun, 7 Jul 2024 14:57:44 -0700 Subject: [PATCH 09/30] strip \n if it was incorrectly added by the device + # Devices should _not_ be including a newline at the end of each log-line str (especially when + # encapsulated as a LogRecord). But to cope with old device loads, we check for that and fix it here: + if line.endswith("\n"): + line = line[:-1] Also: auto reformatting per our trunk formatting rules. --- meshtastic/mesh_interface.py | 227 +++++++++++++++++++++++------------ 1 file changed, 151 insertions(+), 76 deletions(-) diff --git a/meshtastic/mesh_interface.py b/meshtastic/mesh_interface.py index a7c8b740..42df8aef 100644 --- a/meshtastic/mesh_interface.py +++ b/meshtastic/mesh_interface.py @@ -10,21 +10,14 @@ import time from datetime import datetime from decimal import Decimal - from typing import Any, Callable, Dict, List, Optional, Union import google.protobuf.json_format -from pubsub import pub # type: ignore[import-untyped] -from tabulate import tabulate import print_color # type: ignore[import-untyped] +from pubsub import pub # type: ignore[import-untyped] +from tabulate import tabulate import meshtastic.node - -from meshtastic.protobuf import ( - mesh_pb2, - portnums_pb2, - telemetry_pb2, -) from meshtastic import ( BROADCAST_ADDR, BROADCAST_NUM, @@ -32,18 +25,20 @@ NODELESS_WANT_CONFIG_ID, ResponseHandler, protocols, - publishingThread + publishingThread, ) +from meshtastic.protobuf import mesh_pb2, portnums_pb2, telemetry_pb2 from meshtastic.util import ( Acknowledgment, Timeout, convert_mac_addr, + message_to_json, our_exit, remove_keys_from_dict, stripnl, - message_to_json, ) + def _timeago(delta_secs: int) -> str: """Convert a number of seconds in the past into a short, friendly string e.g. "now", "30 sec ago", "1 hour ago" @@ -67,7 +62,7 @@ def _timeago(delta_secs: int) -> str: return "now" -class MeshInterface: # pylint: disable=R0902 +class MeshInterface: # pylint: disable=R0902 """Interface class for meshtastic devices Properties: @@ -79,11 +74,14 @@ class MeshInterface: # pylint: disable=R0902 class MeshInterfaceError(Exception): """An exception class for general mesh interface errors""" + def __init__(self, message): self.message = message super().__init__(self.message) - def __init__(self, debugOut=None, noProto: bool=False, noNodes: bool=False) -> None: + def __init__( + self, debugOut=None, noProto: bool = False, noNodes: bool = False + ) -> None: """Constructor Keyword Arguments: @@ -93,13 +91,21 @@ def __init__(self, debugOut=None, noProto: bool=False, noNodes: bool=False) -> N on startup, just other configuration information. """ self.debugOut = debugOut - self.nodes: Optional[Dict[str,Dict]] = None # FIXME + self.nodes: Optional[Dict[str, Dict]] = None # FIXME self.isConnected: threading.Event = threading.Event() self.noProto: bool = noProto - self.localNode: meshtastic.node.Node = meshtastic.node.Node(self, -1) # We fixup nodenum later - self.myInfo: Optional[mesh_pb2.MyNodeInfo] = None # We don't have device info yet - self.metadata: Optional[mesh_pb2.DeviceMetadata] = None # We don't have device metadata yet - self.responseHandlers: Dict[int,ResponseHandler] = {} # A map from request ID to the handler + self.localNode: meshtastic.node.Node = meshtastic.node.Node( + self, -1 + ) # We fixup nodenum later + self.myInfo: Optional[ + mesh_pb2.MyNodeInfo + ] = None # We don't have device info yet + self.metadata: Optional[ + mesh_pb2.DeviceMetadata + ] = None # We don't have device metadata yet + self.responseHandlers: Dict[ + int, ResponseHandler + ] = {} # A map from request ID to the handler self.failure = ( None # If we've encountered a fatal exception it will be kept here ) @@ -162,6 +168,12 @@ def _printLogLine(line, interface): def _handleLogLine(self, line: str) -> None: """Handle a line of log output from the device.""" + + # Devices should _not_ be including a newline at the end of each log-line str (especially when + # encapsulated as a LogRecord). But to cope with old device loads, we check for that and fix it here: + if line.endswith("\n"): + line = line[:-1] + pub.sendMessage("meshtastic.log.line", line=line, interface=self) def _handleLogRecord(self, record: mesh_pb2.LogRecord) -> None: @@ -201,7 +213,9 @@ def showInfo(self, file=sys.stdout) -> str: # pylint: disable=W0613 print(infos) return infos - def showNodes(self, includeSelf: bool=True, file=sys.stdout) -> str: # pylint: disable=W0613 + def showNodes( + self, includeSelf: bool = True, file=sys.stdout + ) -> str: # pylint: disable=W0613 """Show table summary of nodes in mesh""" def formatFloat(value, precision=2, unit="") -> Optional[str]: @@ -232,7 +246,11 @@ def getTimeAgo(ts) -> Optional[str]: continue presumptive_id = f"!{node['num']:08x}" - row = {"N": 0, "User": f"Meshtastic {presumptive_id[-4:]}", "ID": presumptive_id} + row = { + "N": 0, + "User": f"Meshtastic {presumptive_id[-4:]}", + "ID": presumptive_id, + } user = node.get("user") if user: @@ -241,7 +259,7 @@ def getTimeAgo(ts) -> Optional[str]: "User": user.get("longName", "N/A"), "AKA": user.get("shortName", "N/A"), "ID": user["id"], - "Hardware": user.get("hwModel", "UNSET") + "Hardware": user.get("hwModel", "UNSET"), } ) @@ -295,7 +313,9 @@ def getTimeAgo(ts) -> Optional[str]: print(table) return table - def getNode(self, nodeId: str, requestChannels: bool=True) -> meshtastic.node.Node: + def getNode( + self, nodeId: str, requestChannels: bool = True + ) -> meshtastic.node.Node: """Return a node object which contains device settings and channel info""" if nodeId in (LOCAL_ADDR, BROADCAST_ADDR): return self.localNode @@ -312,11 +332,11 @@ def getNode(self, nodeId: str, requestChannels: bool=True) -> meshtastic.node.No def sendText( self, text: str, - destinationId: Union[int, str]=BROADCAST_ADDR, - wantAck: bool=False, - wantResponse: bool=False, - onResponse: Optional[Callable[[dict], Any]]=None, - channelIndex: int=0, + destinationId: Union[int, str] = BROADCAST_ADDR, + wantAck: bool = False, + wantResponse: bool = False, + onResponse: Optional[Callable[[dict], Any]] = None, + channelIndex: int = 0, ): """Send a utf8 string to some other node, if the node has a display it will also be shown on the device. @@ -351,13 +371,13 @@ def sendText( def sendData( self, data, - destinationId: Union[int, str]=BROADCAST_ADDR, - portNum: portnums_pb2.PortNum.ValueType=portnums_pb2.PortNum.PRIVATE_APP, - wantAck: bool=False, - wantResponse: bool=False, - onResponse: Optional[Callable[[dict], Any]]=None, - onResponseAckPermitted: bool=False, - channelIndex: int=0, + destinationId: Union[int, str] = BROADCAST_ADDR, + portNum: portnums_pb2.PortNum.ValueType = portnums_pb2.PortNum.PRIVATE_APP, + wantAck: bool = False, + wantResponse: bool = False, + onResponse: Optional[Callable[[dict], Any]] = None, + onResponseAckPermitted: bool = False, + channelIndex: int = 0, ): """Send a data packet to some other node @@ -411,20 +431,22 @@ def sendData( if onResponse is not None: logging.debug(f"Setting a response handler for requestId {meshPacket.id}") - self._addResponseHandler(meshPacket.id, onResponse, ackPermitted=onResponseAckPermitted) + self._addResponseHandler( + meshPacket.id, onResponse, ackPermitted=onResponseAckPermitted + ) p = self._sendPacket(meshPacket, destinationId, wantAck=wantAck) return p def sendPosition( self, - latitude: float=0.0, - longitude: float=0.0, - altitude: int=0, - timeSec: int=0, - destinationId: Union[int, str]=BROADCAST_ADDR, - wantAck: bool=False, - wantResponse: bool=False, - channelIndex: int=0, + latitude: float = 0.0, + longitude: float = 0.0, + altitude: int = 0, + timeSec: int = 0, + destinationId: Union[int, str] = BROADCAST_ADDR, + wantAck: bool = False, + wantResponse: bool = False, + channelIndex: int = 0, ): """ Send a position packet to some other node (normally a broadcast) @@ -475,20 +497,22 @@ def sendPosition( def onResponsePosition(self, p): """on response for position""" - if p["decoded"]["portnum"] == 'POSITION_APP': + if p["decoded"]["portnum"] == "POSITION_APP": self._acknowledgment.receivedPosition = True position = mesh_pb2.Position() position.ParseFromString(p["decoded"]["payload"]) ret = "Position received: " if position.latitude_i != 0 and position.longitude_i != 0: - ret += f"({position.latitude_i * 10**-7}, {position.longitude_i * 10**-7})" + ret += ( + f"({position.latitude_i * 10**-7}, {position.longitude_i * 10**-7})" + ) else: ret += "(unknown)" if position.altitude != 0: ret += f" {position.altitude}m" - if position.precision_bits not in [0,32]: + if position.precision_bits not in [0, 32]: ret += f" precision:{position.precision_bits}" elif position.precision_bits == 32: ret += " full precision" @@ -497,11 +521,15 @@ def onResponsePosition(self, p): print(ret) - elif p["decoded"]["portnum"] == 'ROUTING_APP': - if p["decoded"]["routing"]["errorReason"] == 'NO_RESPONSE': - our_exit("No response from node. At least firmware 2.1.22 is required on the destination node.") + elif p["decoded"]["portnum"] == "ROUTING_APP": + if p["decoded"]["routing"]["errorReason"] == "NO_RESPONSE": + our_exit( + "No response from node. At least firmware 2.1.22 is required on the destination node." + ) - def sendTraceRoute(self, dest: Union[int, str], hopLimit: int, channelIndex: int=0): + def sendTraceRoute( + self, dest: Union[int, str], hopLimit: int, channelIndex: int = 0 + ): """Send the trace route""" r = mesh_pb2.RouteDiscovery() self.sendData( @@ -532,12 +560,19 @@ def onResponseTraceRoute(self, p: dict): self._acknowledgment.receivedTraceRoute = True - def sendTelemetry(self, destinationId: Union[int,str]=BROADCAST_ADDR, wantResponse: bool=False, channelIndex: int=0): + def sendTelemetry( + self, + destinationId: Union[int, str] = BROADCAST_ADDR, + wantResponse: bool = False, + channelIndex: int = 0, + ): """Send telemetry and optionally ask for a response""" r = telemetry_pb2.Telemetry() if self.nodes is not None: - node = next(n for n in self.nodes.values() if n["num"] == self.localNode.nodeNum) + node = next( + n for n in self.nodes.values() if n["num"] == self.localNode.nodeNum + ) if node is not None: metrics = node.get("deviceMetrics") if metrics: @@ -572,7 +607,7 @@ def sendTelemetry(self, destinationId: Union[int,str]=BROADCAST_ADDR, wantRespon def onResponseTelemetry(self, p: dict): """on response for telemetry""" - if p["decoded"]["portnum"] == 'TELEMETRY_APP': + if p["decoded"]["portnum"] == "TELEMETRY_APP": self._acknowledgment.receivedTelemetry = True telemetry = telemetry_pb2.Telemetry() telemetry.ParseFromString(p["decoded"]["payload"]) @@ -587,16 +622,32 @@ def onResponseTelemetry(self, p: dict): f"Total channel utilization: {telemetry.device_metrics.channel_utilization:.2f}%" ) if telemetry.device_metrics.air_util_tx is not None: - print(f"Transmit air utilization: {telemetry.device_metrics.air_util_tx:.2f}%") + print( + f"Transmit air utilization: {telemetry.device_metrics.air_util_tx:.2f}%" + ) - elif p["decoded"]["portnum"] == 'ROUTING_APP': - if p["decoded"]["routing"]["errorReason"] == 'NO_RESPONSE': - our_exit("No response from node. At least firmware 2.1.22 is required on the destination node.") + elif p["decoded"]["portnum"] == "ROUTING_APP": + if p["decoded"]["routing"]["errorReason"] == "NO_RESPONSE": + our_exit( + "No response from node. At least firmware 2.1.22 is required on the destination node." + ) - def _addResponseHandler(self, requestId: int, callback: Callable[[dict], Any], ackPermitted: bool=False): - self.responseHandlers[requestId] = ResponseHandler(callback=callback, ackPermitted=ackPermitted) + def _addResponseHandler( + self, + requestId: int, + callback: Callable[[dict], Any], + ackPermitted: bool = False, + ): + self.responseHandlers[requestId] = ResponseHandler( + callback=callback, ackPermitted=ackPermitted + ) - def _sendPacket(self, meshPacket: mesh_pb2.MeshPacket, destinationId: Union[int,str]=BROADCAST_ADDR, wantAck: bool=False): + def _sendPacket( + self, + meshPacket: mesh_pb2.MeshPacket, + destinationId: Union[int, str] = BROADCAST_ADDR, + wantAck: bool = False, + ): """Send a MeshPacket to the specified node (or if unspecified, broadcast). You probably don't want this - use sendData instead. @@ -663,13 +714,17 @@ def waitForConfig(self): and self.localNode.waitForConfig() ) if not success: - raise MeshInterface.MeshInterfaceError("Timed out waiting for interface config") + raise MeshInterface.MeshInterfaceError( + "Timed out waiting for interface config" + ) def waitForAckNak(self): """Wait for the ack/nak""" success = self._timeout.waitForAckNak(self._acknowledgment) if not success: - raise MeshInterface.MeshInterfaceError("Timed out waiting for an acknowledgment") + raise MeshInterface.MeshInterfaceError( + "Timed out waiting for an acknowledgment" + ) def waitForTraceRoute(self, waitFactor): """Wait for trace route""" @@ -722,7 +777,9 @@ def _waitConnected(self, timeout=30.0): and raise an exception""" if not self.noProto: if not self.isConnected.wait(timeout): # timeout after x seconds - raise MeshInterface.MeshInterfaceError("Timed out waiting for connection completion") + raise MeshInterface.MeshInterfaceError( + "Timed out waiting for connection completion" + ) # If we failed while connecting, raise the connection to the client if self.failure: @@ -731,7 +788,9 @@ def _waitConnected(self, timeout=30.0): def _generatePacketId(self) -> int: """Get a new unique packet ID""" if self.currentPacketId is None: - raise MeshInterface.MeshInterfaceError("Not connected yet, can not generate packet") + raise MeshInterface.MeshInterfaceError( + "Not connected yet, can not generate packet" + ) else: self.currentPacketId = (self.currentPacketId + 1) & 0xFFFFFFFF return self.currentPacketId @@ -778,7 +837,9 @@ def _startConfig(self): self.myInfo = None self.nodes = {} # nodes keyed by ID self.nodesByNum = {} # nodes keyed by nodenum - self._localChannels = [] # empty until we start getting channels pushed from the device (during config) + self._localChannels = ( + [] + ) # empty until we start getting channels pushed from the device (during config) startConfig = mesh_pb2.ToRadio() if self.configId is None or not self.noNodes: @@ -927,7 +988,7 @@ def _handleFromRadio(self, fromRadioBytes): logging.debug("Node without position") # no longer necessary since we're mutating directly in nodesByNum via _getOrCreateByNum - #self.nodesByNum[node["num"]] = node + # self.nodesByNum[node["num"]] = node if "user" in node: # Some nodes might not have user/ids assigned yet if "id" in node["user"]: self.nodes[node["user"]["id"]] = node @@ -953,14 +1014,18 @@ def _handleFromRadio(self, fromRadioBytes): elif fromRadio.HasField("mqttClientProxyMessage"): publishingThread.queueWork( lambda: pub.sendMessage( - "meshtastic.mqttclientproxymessage", proxymessage=fromRadio.mqttClientProxyMessage, interface=self + "meshtastic.mqttclientproxymessage", + proxymessage=fromRadio.mqttClientProxyMessage, + interface=self, ) ) elif fromRadio.HasField("xmodemPacket"): publishingThread.queueWork( lambda: pub.sendMessage( - "meshtastic.xmodempacket", packet=fromRadio.xmodemPacket, interface=self + "meshtastic.xmodempacket", + packet=fromRadio.xmodemPacket, + interface=self, ) ) @@ -1067,7 +1132,7 @@ def _nodeNumToId(self, num: int) -> Optional[str]: return BROADCAST_ADDR try: - return self.nodesByNum[num]["user"]["id"] #type: ignore[index] + return self.nodesByNum[num]["user"]["id"] # type: ignore[index] except: logging.debug(f"Node {num} not found for fromId") return None @@ -1075,7 +1140,9 @@ def _nodeNumToId(self, num: int) -> Optional[str]: def _getOrCreateByNum(self, nodeNum): """Given a nodenum find the NodeInfo in the DB (or create if necessary)""" if nodeNum == BROADCAST_NUM: - raise MeshInterface.MeshInterfaceError("Can not create/find nodenum by the broadcast num") + raise MeshInterface.MeshInterfaceError( + "Can not create/find nodenum by the broadcast num" + ) if nodeNum in self.nodesByNum: return self.nodesByNum[nodeNum] @@ -1087,9 +1154,9 @@ def _getOrCreateByNum(self, nodeNum): "id": presumptive_id, "longName": f"Meshtastic {presumptive_id[-4:]}", "shortName": f"{presumptive_id[-4:]}", - "hwModel": "UNSET" - } - } # Create a minimal node db entry + "hwModel": "UNSET", + }, + } # Create a minimal node db entry self.nodesByNum[nodeNum] = n return n @@ -1198,13 +1265,21 @@ def _handlePacketFromRadio(self, meshPacket, hack=False): # or the handler is set as ackPermitted, but send NAKs and # other, data-containing responses to the handlers routing = decoded.get("routing") - isAck = routing is not None and ("errorReason" not in routing or routing["errorReason"] == "NONE") + isAck = routing is not None and ( + "errorReason" not in routing or routing["errorReason"] == "NONE" + ) # we keep the responseHandler in dict until we actually call it handler = self.responseHandlers.get(requestId, None) if handler is not None: - if (not isAck) or handler.callback.__name__ == "onAckNak" or handler.ackPermitted: + if ( + (not isAck) + or handler.callback.__name__ == "onAckNak" + or handler.ackPermitted + ): handler = self.responseHandlers.pop(requestId, None) - logging.debug(f"Calling response handler for requestId {requestId}") + logging.debug( + f"Calling response handler for requestId {requestId}" + ) handler.callback(asDict) logging.debug(f"Publishing {topic}: packet={stripnl(asDict)} ") From a6c3e5cba808ab60693fac85dfa7ea13ef7cafc1 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Sun, 7 Jul 2024 14:58:30 -0700 Subject: [PATCH 10/30] properly parse all structured log messages --- meshtastic/slog/slog.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/meshtastic/slog/slog.py b/meshtastic/slog/slog.py index 16660553..7cbea092 100644 --- a/meshtastic/slog/slog.py +++ b/meshtastic/slog/slog.py @@ -51,7 +51,9 @@ def __init__(self, code: str, fields: list[tuple[str, pa.DataType]]) -> None: "" if f[1] == pa.string() else ":d" ) # treat as a string or an int (the only types we have so far) fmt += "{" + f[0] + suffix + "}" - self.format = parse.compile(fmt) + self.format = parse.compile( + fmt + ) # We include a catchall matcher at the end - to ignore stuff we don't understand """A dictionary mapping from logdef code to logdef""" @@ -60,7 +62,7 @@ def __init__(self, code: str, fields: list[tuple[str, pa.DataType]]) -> None: for d in [ LogDef("B", [("board_id", pa.uint32()), ("sw_version", pa.string())]), LogDef("PM", [("pm_mask", pa.uint64()), ("pm_reason", pa.string())]), - LogDef("PS", [("ps_state", pa.uint64())]), + LogDef("PS", [("ps_state", pa.uint32())]), ] } log_regex = re.compile(".*S:([0-9A-Za-z]+):(.*)") @@ -139,11 +141,14 @@ def __init__(self, client: MeshInterface, dir_path: str, include_raw=True) -> No def listen_glue(line, interface): # pylint: disable=unused-argument self._onLogMessage(line) - self.listener = pub.subscribe(listen_glue, TOPIC_MESHTASTIC_LOG_LINE) + self._listen_glue = ( + listen_glue # we must save this so it doesn't get garbage collected + ) + self._listener = pub.subscribe(listen_glue, TOPIC_MESHTASTIC_LOG_LINE) def close(self) -> None: """Stop logging.""" - pub.unsubscribe(self.listener, TOPIC_MESHTASTIC_LOG_LINE) + pub.unsubscribe(self._listener, TOPIC_MESHTASTIC_LOG_LINE) self.writer.close() f = self.raw_file self.raw_file = None # mark that we are shutting down @@ -162,14 +167,25 @@ def _onLogMessage(self, line: str) -> None: if m: src = m.group(1) args = m.group(2) - args += " " # append a space so that if the last arg is an empty str it will still be accepted as a match logging.debug(f"SLog {src}, args: {args}") d = log_defs.get(src) if d: + last_field = d.fields[-1] + last_is_str = last_field[1] == pa.string() + if last_is_str: + args += " " + # append a space so that if the last arg is an empty str + # it will still be accepted as a match for a str + r = d.format.parse(args) # get the values with the correct types if r: di = r.named + if last_is_str: + di[last_field[0]] = di[last_field[0]].strip() # remove the trailing space we added + if di[last_field[0]] == "": + # If the last field is an empty string, remove it + del di[last_field[0]] else: logging.warning(f"Failed to parse slog {line} with {d.format}") else: From 9297732806664da369d0c06701969a44d03d5e56 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Sun, 7 Jul 2024 14:59:11 -0700 Subject: [PATCH 11/30] fix possible race with thread shutdown. somehow receiveThread can be null --- meshtastic/ble_interface.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/meshtastic/ble_interface.py b/meshtastic/ble_interface.py index 8d7f47ee..ff4b5ddd 100644 --- a/meshtastic/ble_interface.py +++ b/meshtastic/ble_interface.py @@ -196,7 +196,7 @@ def _receiveFromRadioImpl(self): def _sendToRadioImpl(self, toRadio): b = toRadio.SerializeToString() - if b: + if b and self.client: # we silently ignore writes while we are shutting down logging.debug(f"TORADIO write: {b.hex()}") try: self.client.write_gatt_char( @@ -219,10 +219,11 @@ def close(self): if self._want_receive: self.want_receive = False # Tell the thread we want it to stop - self._receiveThread.join( - timeout=2 - ) # If bleak is hung, don't wait for the thread to exit (it is critical we disconnect) - self._receiveThread = None + if self._receiveThread: + self._receiveThread.join( + timeout=2 + ) # If bleak is hung, don't wait for the thread to exit (it is critical we disconnect) + self._receiveThread = None if self.client: atexit.unregister(self._exit_handler) From 8c63f4dec64334d0ef1b5b964aeb283025b293df Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Sun, 7 Jul 2024 15:17:26 -0700 Subject: [PATCH 12/30] always write using correct schema for the file --- meshtastic/slog/arrow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/meshtastic/slog/arrow.py b/meshtastic/slog/arrow.py index be908cea..d656bea7 100644 --- a/meshtastic/slog/arrow.py +++ b/meshtastic/slog/arrow.py @@ -46,7 +46,9 @@ def _write(self): # only need to look at the first row to learn the schema self.set_schema(pa.Table.from_pylist([self.new_rows[0]]).schema) - self.writer.write_batch(pa.RecordBatch.from_pylist(self.new_rows)) + self.writer.write_batch( + pa.RecordBatch.from_pylist(self.new_rows, schema=self.schema) + ) self.new_rows = [] def add_row(self, row_dict: dict): From 043530afcac7f135927fda2e8efd2595602e5990 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Mon, 8 Jul 2024 09:17:52 -0700 Subject: [PATCH 13/30] fix linter warnings --- meshtastic/mesh_interface.py | 2 +- meshtastic/slog/arrow.py | 5 +++-- meshtastic/slog/slog.py | 9 +++++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/meshtastic/mesh_interface.py b/meshtastic/mesh_interface.py index a51309d8..89709daa 100644 --- a/meshtastic/mesh_interface.py +++ b/meshtastic/mesh_interface.py @@ -214,7 +214,7 @@ def showInfo(self, file=sys.stdout) -> str: # pylint: disable=W0613 return infos def showNodes( - self, includeSelf: bool = True, file=sys.stdout + self, includeSelf: bool = True ) -> str: # pylint: disable=W0613 """Show table summary of nodes in mesh""" diff --git a/meshtastic/slog/arrow.py b/meshtastic/slog/arrow.py index d656bea7..0a53fe88 100644 --- a/meshtastic/slog/arrow.py +++ b/meshtastic/slog/arrow.py @@ -2,6 +2,7 @@ import logging import os +from typing import Optional import pyarrow as pa from pyarrow import feather @@ -19,8 +20,8 @@ def __init__(self, file_name: str): """ self.sink = pa.OSFile(file_name, "wb") # type: ignore self.new_rows: list[dict] = [] - self.schema: pa.Schema | None = None # haven't yet learned the schema - self.writer: pa.RecordBatchFileWriter | None = None + self.schema: Optional[pa.Schema] = None # haven't yet learned the schema + self.writer: Optional[pa.RecordBatchStreamWriter] = None def close(self): """Close the stream and writes the file as needed.""" diff --git a/meshtastic/slog/slog.py b/meshtastic/slog/slog.py index 7cbea092..64527633 100644 --- a/meshtastic/slog/slog.py +++ b/meshtastic/slog/slog.py @@ -129,7 +129,10 @@ def __init__(self, client: MeshInterface, dir_path: str, include_raw=True) -> No if self.include_raw: all_fields.append(("raw", pa.string())) - self.writer.set_schema(pa.schema(all_fields)) + # pass in our name->type tuples a pa.fields + self.writer.set_schema( + pa.schema(map(lambda x: pa.field(x[0], x[1]), all_fields)) + ) self.raw_file: Optional[ io.TextIOWrapper @@ -182,7 +185,9 @@ def _onLogMessage(self, line: str) -> None: if r: di = r.named if last_is_str: - di[last_field[0]] = di[last_field[0]].strip() # remove the trailing space we added + di[last_field[0]] = di[ + last_field[0] + ].strip() # remove the trailing space we added if di[last_field[0]] == "": # If the last field is an empty string, remove it del di[last_field[0]] From 0bc608d8cf035e8096588454243fc0b30639af26 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Wed, 10 Jul 2024 16:43:07 -0700 Subject: [PATCH 14/30] fix analysis imports to import less --- poetry.lock | 586 +++++++++++++++++++++++++------------------------ pyproject.toml | 7 - 2 files changed, 298 insertions(+), 295 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4c4b23d4..e67c9f68 100644 --- a/poetry.lock +++ b/poetry.lock @@ -932,53 +932,53 @@ dotenv = ["python-dotenv"] [[package]] name = "fonttools" -version = "4.53.0" +version = "4.53.1" description = "Tools to manipulate font files" optional = false python-versions = ">=3.8" files = [ - {file = "fonttools-4.53.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:52a6e0a7a0bf611c19bc8ec8f7592bdae79c8296c70eb05917fd831354699b20"}, - {file = "fonttools-4.53.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:099634631b9dd271d4a835d2b2a9e042ccc94ecdf7e2dd9f7f34f7daf333358d"}, - {file = "fonttools-4.53.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e40013572bfb843d6794a3ce076c29ef4efd15937ab833f520117f8eccc84fd6"}, - {file = "fonttools-4.53.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715b41c3e231f7334cbe79dfc698213dcb7211520ec7a3bc2ba20c8515e8a3b5"}, - {file = "fonttools-4.53.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74ae2441731a05b44d5988d3ac2cf784d3ee0a535dbed257cbfff4be8bb49eb9"}, - {file = "fonttools-4.53.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:95db0c6581a54b47c30860d013977b8a14febc206c8b5ff562f9fe32738a8aca"}, - {file = "fonttools-4.53.0-cp310-cp310-win32.whl", hash = "sha256:9cd7a6beec6495d1dffb1033d50a3f82dfece23e9eb3c20cd3c2444d27514068"}, - {file = "fonttools-4.53.0-cp310-cp310-win_amd64.whl", hash = "sha256:daaef7390e632283051e3cf3e16aff2b68b247e99aea916f64e578c0449c9c68"}, - {file = "fonttools-4.53.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a209d2e624ba492df4f3bfad5996d1f76f03069c6133c60cd04f9a9e715595ec"}, - {file = "fonttools-4.53.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f520d9ac5b938e6494f58a25c77564beca7d0199ecf726e1bd3d56872c59749"}, - {file = "fonttools-4.53.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eceef49f457253000e6a2d0f7bd08ff4e9fe96ec4ffce2dbcb32e34d9c1b8161"}, - {file = "fonttools-4.53.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1f3e34373aa16045484b4d9d352d4c6b5f9f77ac77a178252ccbc851e8b2ee"}, - {file = "fonttools-4.53.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:28d072169fe8275fb1a0d35e3233f6df36a7e8474e56cb790a7258ad822b6fd6"}, - {file = "fonttools-4.53.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a2a6ba400d386e904fd05db81f73bee0008af37799a7586deaa4aef8cd5971e"}, - {file = "fonttools-4.53.0-cp311-cp311-win32.whl", hash = "sha256:bb7273789f69b565d88e97e9e1da602b4ee7ba733caf35a6c2affd4334d4f005"}, - {file = "fonttools-4.53.0-cp311-cp311-win_amd64.whl", hash = "sha256:9fe9096a60113e1d755e9e6bda15ef7e03391ee0554d22829aa506cdf946f796"}, - {file = "fonttools-4.53.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d8f191a17369bd53a5557a5ee4bab91d5330ca3aefcdf17fab9a497b0e7cff7a"}, - {file = "fonttools-4.53.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93156dd7f90ae0a1b0e8871032a07ef3178f553f0c70c386025a808f3a63b1f4"}, - {file = "fonttools-4.53.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bff98816cb144fb7b85e4b5ba3888a33b56ecef075b0e95b95bcd0a5fbf20f06"}, - {file = "fonttools-4.53.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:973d030180eca8255b1bce6ffc09ef38a05dcec0e8320cc9b7bcaa65346f341d"}, - {file = "fonttools-4.53.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4ee5a24e281fbd8261c6ab29faa7fd9a87a12e8c0eed485b705236c65999109"}, - {file = "fonttools-4.53.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5bc124fae781a4422f61b98d1d7faa47985f663a64770b78f13d2c072410c2"}, - {file = "fonttools-4.53.0-cp312-cp312-win32.whl", hash = "sha256:a239afa1126b6a619130909c8404070e2b473dd2b7fc4aacacd2e763f8597fea"}, - {file = "fonttools-4.53.0-cp312-cp312-win_amd64.whl", hash = "sha256:45b4afb069039f0366a43a5d454bc54eea942bfb66b3fc3e9a2c07ef4d617380"}, - {file = "fonttools-4.53.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:93bc9e5aaa06ff928d751dc6be889ff3e7d2aa393ab873bc7f6396a99f6fbb12"}, - {file = "fonttools-4.53.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2367d47816cc9783a28645bc1dac07f8ffc93e0f015e8c9fc674a5b76a6da6e4"}, - {file = "fonttools-4.53.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:907fa0b662dd8fc1d7c661b90782ce81afb510fc4b7aa6ae7304d6c094b27bce"}, - {file = "fonttools-4.53.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e0ad3c6ea4bd6a289d958a1eb922767233f00982cf0fe42b177657c86c80a8f"}, - {file = "fonttools-4.53.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:73121a9b7ff93ada888aaee3985a88495489cc027894458cb1a736660bdfb206"}, - {file = "fonttools-4.53.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ee595d7ba9bba130b2bec555a40aafa60c26ce68ed0cf509983e0f12d88674fd"}, - {file = "fonttools-4.53.0-cp38-cp38-win32.whl", hash = "sha256:fca66d9ff2ac89b03f5aa17e0b21a97c21f3491c46b583bb131eb32c7bab33af"}, - {file = "fonttools-4.53.0-cp38-cp38-win_amd64.whl", hash = "sha256:31f0e3147375002aae30696dd1dc596636abbd22fca09d2e730ecde0baad1d6b"}, - {file = "fonttools-4.53.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7d6166192dcd925c78a91d599b48960e0a46fe565391c79fe6de481ac44d20ac"}, - {file = "fonttools-4.53.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef50ec31649fbc3acf6afd261ed89d09eb909b97cc289d80476166df8438524d"}, - {file = "fonttools-4.53.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f193f060391a455920d61684a70017ef5284ccbe6023bb056e15e5ac3de11d1"}, - {file = "fonttools-4.53.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba9f09ff17f947392a855e3455a846f9855f6cf6bec33e9a427d3c1d254c712f"}, - {file = "fonttools-4.53.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0c555e039d268445172b909b1b6bdcba42ada1cf4a60e367d68702e3f87e5f64"}, - {file = "fonttools-4.53.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a4788036201c908079e89ae3f5399b33bf45b9ea4514913f4dbbe4fac08efe0"}, - {file = "fonttools-4.53.0-cp39-cp39-win32.whl", hash = "sha256:d1a24f51a3305362b94681120c508758a88f207fa0a681c16b5a4172e9e6c7a9"}, - {file = "fonttools-4.53.0-cp39-cp39-win_amd64.whl", hash = "sha256:1e677bfb2b4bd0e5e99e0f7283e65e47a9814b0486cb64a41adf9ef110e078f2"}, - {file = "fonttools-4.53.0-py3-none-any.whl", hash = "sha256:6b4f04b1fbc01a3569d63359f2227c89ab294550de277fd09d8fca6185669fa4"}, - {file = "fonttools-4.53.0.tar.gz", hash = "sha256:c93ed66d32de1559b6fc348838c7572d5c0ac1e4a258e76763a5caddd8944002"}, + {file = "fonttools-4.53.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0679a30b59d74b6242909945429dbddb08496935b82f91ea9bf6ad240ec23397"}, + {file = "fonttools-4.53.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8bf06b94694251861ba7fdeea15c8ec0967f84c3d4143ae9daf42bbc7717fe3"}, + {file = "fonttools-4.53.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b96cd370a61f4d083c9c0053bf634279b094308d52fdc2dd9a22d8372fdd590d"}, + {file = "fonttools-4.53.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1c7c5aa18dd3b17995898b4a9b5929d69ef6ae2af5b96d585ff4005033d82f0"}, + {file = "fonttools-4.53.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e013aae589c1c12505da64a7d8d023e584987e51e62006e1bb30d72f26522c41"}, + {file = "fonttools-4.53.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9efd176f874cb6402e607e4cc9b4a9cd584d82fc34a4b0c811970b32ba62501f"}, + {file = "fonttools-4.53.1-cp310-cp310-win32.whl", hash = "sha256:c8696544c964500aa9439efb6761947393b70b17ef4e82d73277413f291260a4"}, + {file = "fonttools-4.53.1-cp310-cp310-win_amd64.whl", hash = "sha256:8959a59de5af6d2bec27489e98ef25a397cfa1774b375d5787509c06659b3671"}, + {file = "fonttools-4.53.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da33440b1413bad53a8674393c5d29ce64d8c1a15ef8a77c642ffd900d07bfe1"}, + {file = "fonttools-4.53.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ff7e5e9bad94e3a70c5cd2fa27f20b9bb9385e10cddab567b85ce5d306ea923"}, + {file = "fonttools-4.53.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6e7170d675d12eac12ad1a981d90f118c06cf680b42a2d74c6c931e54b50719"}, + {file = "fonttools-4.53.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bee32ea8765e859670c4447b0817514ca79054463b6b79784b08a8df3a4d78e3"}, + {file = "fonttools-4.53.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6e08f572625a1ee682115223eabebc4c6a2035a6917eac6f60350aba297ccadb"}, + {file = "fonttools-4.53.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b21952c092ffd827504de7e66b62aba26fdb5f9d1e435c52477e6486e9d128b2"}, + {file = "fonttools-4.53.1-cp311-cp311-win32.whl", hash = "sha256:9dfdae43b7996af46ff9da520998a32b105c7f098aeea06b2226b30e74fbba88"}, + {file = "fonttools-4.53.1-cp311-cp311-win_amd64.whl", hash = "sha256:d4d0096cb1ac7a77b3b41cd78c9b6bc4a400550e21dc7a92f2b5ab53ed74eb02"}, + {file = "fonttools-4.53.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d92d3c2a1b39631a6131c2fa25b5406855f97969b068e7e08413325bc0afba58"}, + {file = "fonttools-4.53.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3b3c8ebafbee8d9002bd8f1195d09ed2bd9ff134ddec37ee8f6a6375e6a4f0e8"}, + {file = "fonttools-4.53.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f029c095ad66c425b0ee85553d0dc326d45d7059dbc227330fc29b43e8ba60"}, + {file = "fonttools-4.53.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f5e6c3510b79ea27bb1ebfcc67048cde9ec67afa87c7dd7efa5c700491ac7f"}, + {file = "fonttools-4.53.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f677ce218976496a587ab17140da141557beb91d2a5c1a14212c994093f2eae2"}, + {file = "fonttools-4.53.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9e6ceba2a01b448e36754983d376064730690401da1dd104ddb543519470a15f"}, + {file = "fonttools-4.53.1-cp312-cp312-win32.whl", hash = "sha256:791b31ebbc05197d7aa096bbc7bd76d591f05905d2fd908bf103af4488e60670"}, + {file = "fonttools-4.53.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ed170b5e17da0264b9f6fae86073be3db15fa1bd74061c8331022bca6d09bab"}, + {file = "fonttools-4.53.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c818c058404eb2bba05e728d38049438afd649e3c409796723dfc17cd3f08749"}, + {file = "fonttools-4.53.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:651390c3b26b0c7d1f4407cad281ee7a5a85a31a110cbac5269de72a51551ba2"}, + {file = "fonttools-4.53.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e54f1bba2f655924c1138bbc7fa91abd61f45c68bd65ab5ed985942712864bbb"}, + {file = "fonttools-4.53.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9cd19cf4fe0595ebdd1d4915882b9440c3a6d30b008f3cc7587c1da7b95be5f"}, + {file = "fonttools-4.53.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2af40ae9cdcb204fc1d8f26b190aa16534fcd4f0df756268df674a270eab575d"}, + {file = "fonttools-4.53.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:35250099b0cfb32d799fb5d6c651220a642fe2e3c7d2560490e6f1d3f9ae9169"}, + {file = "fonttools-4.53.1-cp38-cp38-win32.whl", hash = "sha256:f08df60fbd8d289152079a65da4e66a447efc1d5d5a4d3f299cdd39e3b2e4a7d"}, + {file = "fonttools-4.53.1-cp38-cp38-win_amd64.whl", hash = "sha256:7b6b35e52ddc8fb0db562133894e6ef5b4e54e1283dff606fda3eed938c36fc8"}, + {file = "fonttools-4.53.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:75a157d8d26c06e64ace9df037ee93a4938a4606a38cb7ffaf6635e60e253b7a"}, + {file = "fonttools-4.53.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4824c198f714ab5559c5be10fd1adf876712aa7989882a4ec887bf1ef3e00e31"}, + {file = "fonttools-4.53.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:becc5d7cb89c7b7afa8321b6bb3dbee0eec2b57855c90b3e9bf5fb816671fa7c"}, + {file = "fonttools-4.53.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84ec3fb43befb54be490147b4a922b5314e16372a643004f182babee9f9c3407"}, + {file = "fonttools-4.53.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:73379d3ffdeecb376640cd8ed03e9d2d0e568c9d1a4e9b16504a834ebadc2dfb"}, + {file = "fonttools-4.53.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:02569e9a810f9d11f4ae82c391ebc6fb5730d95a0657d24d754ed7763fb2d122"}, + {file = "fonttools-4.53.1-cp39-cp39-win32.whl", hash = "sha256:aae7bd54187e8bf7fd69f8ab87b2885253d3575163ad4d669a262fe97f0136cb"}, + {file = "fonttools-4.53.1-cp39-cp39-win_amd64.whl", hash = "sha256:e5b708073ea3d684235648786f5f6153a48dc8762cdfe5563c57e80787c29fbb"}, + {file = "fonttools-4.53.1-py3-none-any.whl", hash = "sha256:f1f8758a2ad110bd6432203a344269f445a2907dc24ef6bccfd0ac4e14e0d71d"}, + {file = "fonttools-4.53.1.tar.gz", hash = "sha256:e128778a8e9bc11159ce5447f76766cefbd876f44bd79aff030287254e4752c4"}, ] [package.extras] @@ -1064,13 +1064,13 @@ socks = ["socksio (==1.*)"] [[package]] name = "hypothesis" -version = "6.104.2" +version = "6.105.1" description = "A library for property-based testing" optional = false python-versions = ">=3.8" files = [ - {file = "hypothesis-6.104.2-py3-none-any.whl", hash = "sha256:8b52b7e2462e552c75b819495d5cb6251a2b840accc79cf2ce52588004c915d9"}, - {file = "hypothesis-6.104.2.tar.gz", hash = "sha256:6f2a1489bc8fe1c87ffd202707319b66ec46b2bc11faf6e0161e957b8b9b1eab"}, + {file = "hypothesis-6.105.1-py3-none-any.whl", hash = "sha256:d8993472a7bccfc20172e02ba8636bb0067add92d1fa05e95a40bab02c1e8305"}, + {file = "hypothesis-6.105.1.tar.gz", hash = "sha256:d4eedb42193da9507623f4fe27fd38f715ec19ad30ad7c30e3b25594c0b491dc"}, ] [package.dependencies] @@ -1079,10 +1079,10 @@ exceptiongroup = {version = ">=1.0.0", markers = "python_version < \"3.11\""} sortedcontainers = ">=2.1.0,<3.0.0" [package.extras] -all = ["backports.zoneinfo (>=0.2.1)", "black (>=19.10b0)", "click (>=7.0)", "crosshair-tool (>=0.0.55)", "django (>=3.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.4)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.17.3)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2024.1)"] +all = ["backports.zoneinfo (>=0.2.1)", "black (>=19.10b0)", "click (>=7.0)", "crosshair-tool (>=0.0.59)", "django (>=3.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.7)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.17.3)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2024.1)"] cli = ["black (>=19.10b0)", "click (>=7.0)", "rich (>=9.0.0)"] codemods = ["libcst (>=0.3.16)"] -crosshair = ["crosshair-tool (>=0.0.55)", "hypothesis-crosshair (>=0.0.4)"] +crosshair = ["crosshair-tool (>=0.0.59)", "hypothesis-crosshair (>=0.0.7)"] dateutil = ["python-dateutil (>=1.4)"] django = ["django (>=3.2)"] dpcontracts = ["dpcontracts (>=0.4)"] @@ -1156,13 +1156,13 @@ files = [ [[package]] name = "ipykernel" -version = "6.29.4" +version = "6.29.5" description = "IPython Kernel for Jupyter" optional = false python-versions = ">=3.8" files = [ - {file = "ipykernel-6.29.4-py3-none-any.whl", hash = "sha256:1181e653d95c6808039c509ef8e67c4126b3b3af7781496c7cbfb5ed938a27da"}, - {file = "ipykernel-6.29.4.tar.gz", hash = "sha256:3d44070060f9475ac2092b760123fadf105d2e2493c24848b6691a7c4f42af5c"}, + {file = "ipykernel-6.29.5-py3-none-any.whl", hash = "sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5"}, + {file = "ipykernel-6.29.5.tar.gz", hash = "sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215"}, ] [package.dependencies] @@ -1378,13 +1378,13 @@ files = [ [[package]] name = "jsonschema" -version = "4.22.0" +version = "4.23.0" description = "An implementation of JSON Schema validation for Python" optional = false python-versions = ">=3.8" files = [ - {file = "jsonschema-4.22.0-py3-none-any.whl", hash = "sha256:ff4cfd6b1367a40e7bc6411caec72effadd3db0bbe5017de188f2d6108335802"}, - {file = "jsonschema-4.22.0.tar.gz", hash = "sha256:5b22d434a45935119af990552c862e5d6d564e8f6601206b305a61fdf661a2b7"}, + {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, + {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, ] [package.dependencies] @@ -1399,11 +1399,11 @@ rfc3339-validator = {version = "*", optional = true, markers = "extra == \"forma rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""} rpds-py = ">=0.7.1" uri-template = {version = "*", optional = true, markers = "extra == \"format-nongpl\""} -webcolors = {version = ">=1.11", optional = true, markers = "extra == \"format-nongpl\""} +webcolors = {version = ">=24.6.0", optional = true, markers = "extra == \"format-nongpl\""} [package.extras] format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] -format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] [[package]] name = "jsonschema-specifications" @@ -1875,40 +1875,40 @@ files = [ [[package]] name = "matplotlib" -version = "3.9.0" +version = "3.9.1" description = "Python plotting package" optional = false python-versions = ">=3.9" files = [ - {file = "matplotlib-3.9.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2bcee1dffaf60fe7656183ac2190bd630842ff87b3153afb3e384d966b57fe56"}, - {file = "matplotlib-3.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3f988bafb0fa39d1074ddd5bacd958c853e11def40800c5824556eb630f94d3b"}, - {file = "matplotlib-3.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe428e191ea016bb278758c8ee82a8129c51d81d8c4bc0846c09e7e8e9057241"}, - {file = "matplotlib-3.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaf3978060a106fab40c328778b148f590e27f6fa3cd15a19d6892575bce387d"}, - {file = "matplotlib-3.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2e7f03e5cbbfacdd48c8ea394d365d91ee8f3cae7e6ec611409927b5ed997ee4"}, - {file = "matplotlib-3.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:13beb4840317d45ffd4183a778685e215939be7b08616f431c7795276e067463"}, - {file = "matplotlib-3.9.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:063af8587fceeac13b0936c42a2b6c732c2ab1c98d38abc3337e430e1ff75e38"}, - {file = "matplotlib-3.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9a2fa6d899e17ddca6d6526cf6e7ba677738bf2a6a9590d702c277204a7c6152"}, - {file = "matplotlib-3.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:550cdda3adbd596078cca7d13ed50b77879104e2e46392dcd7c75259d8f00e85"}, - {file = "matplotlib-3.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76cce0f31b351e3551d1f3779420cf8f6ec0d4a8cf9c0237a3b549fd28eb4abb"}, - {file = "matplotlib-3.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c53aeb514ccbbcbab55a27f912d79ea30ab21ee0531ee2c09f13800efb272674"}, - {file = "matplotlib-3.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:a5be985db2596d761cdf0c2eaf52396f26e6a64ab46bd8cd810c48972349d1be"}, - {file = "matplotlib-3.9.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:c79f3a585f1368da6049318bdf1f85568d8d04b2e89fc24b7e02cc9b62017382"}, - {file = "matplotlib-3.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bdd1ecbe268eb3e7653e04f451635f0fb0f77f07fd070242b44c076c9106da84"}, - {file = "matplotlib-3.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d38e85a1a6d732f645f1403ce5e6727fd9418cd4574521d5803d3d94911038e5"}, - {file = "matplotlib-3.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a490715b3b9984fa609116481b22178348c1a220a4499cda79132000a79b4db"}, - {file = "matplotlib-3.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8146ce83cbc5dc71c223a74a1996d446cd35cfb6a04b683e1446b7e6c73603b7"}, - {file = "matplotlib-3.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:d91a4ffc587bacf5c4ce4ecfe4bcd23a4b675e76315f2866e588686cc97fccdf"}, - {file = "matplotlib-3.9.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:616fabf4981a3b3c5a15cd95eba359c8489c4e20e03717aea42866d8d0465956"}, - {file = "matplotlib-3.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cd53c79fd02f1c1808d2cfc87dd3cf4dbc63c5244a58ee7944497107469c8d8a"}, - {file = "matplotlib-3.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06a478f0d67636554fa78558cfbcd7b9dba85b51f5c3b5a0c9be49010cf5f321"}, - {file = "matplotlib-3.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81c40af649d19c85f8073e25e5806926986806fa6d54be506fbf02aef47d5a89"}, - {file = "matplotlib-3.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:52146fc3bd7813cc784562cb93a15788be0b2875c4655e2cc6ea646bfa30344b"}, - {file = "matplotlib-3.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:0fc51eaa5262553868461c083d9adadb11a6017315f3a757fc45ec6ec5f02888"}, - {file = "matplotlib-3.9.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:bd4f2831168afac55b881db82a7730992aa41c4f007f1913465fb182d6fb20c0"}, - {file = "matplotlib-3.9.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:290d304e59be2b33ef5c2d768d0237f5bd132986bdcc66f80bc9bcc300066a03"}, - {file = "matplotlib-3.9.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ff2e239c26be4f24bfa45860c20ffccd118d270c5b5d081fa4ea409b5469fcd"}, - {file = "matplotlib-3.9.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:af4001b7cae70f7eaacfb063db605280058246de590fa7874f00f62259f2df7e"}, - {file = "matplotlib-3.9.0.tar.gz", hash = "sha256:e6d29ea6c19e34b30fb7d88b7081f869a03014f66fe06d62cc77d5a6ea88ed7a"}, + {file = "matplotlib-3.9.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7ccd6270066feb9a9d8e0705aa027f1ff39f354c72a87efe8fa07632f30fc6bb"}, + {file = "matplotlib-3.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:591d3a88903a30a6d23b040c1e44d1afdd0d778758d07110eb7596f811f31842"}, + {file = "matplotlib-3.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd2a59ff4b83d33bca3b5ec58203cc65985367812cb8c257f3e101632be86d92"}, + {file = "matplotlib-3.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc001516ffcf1a221beb51198b194d9230199d6842c540108e4ce109ac05cc0"}, + {file = "matplotlib-3.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83c6a792f1465d174c86d06f3ae85a8fe36e6f5964633ae8106312ec0921fdf5"}, + {file = "matplotlib-3.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:421851f4f57350bcf0811edd754a708d2275533e84f52f6760b740766c6747a7"}, + {file = "matplotlib-3.9.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b3fce58971b465e01b5c538f9d44915640c20ec5ff31346e963c9e1cd66fa812"}, + {file = "matplotlib-3.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a973c53ad0668c53e0ed76b27d2eeeae8799836fd0d0caaa4ecc66bf4e6676c0"}, + {file = "matplotlib-3.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd5acf8f3ef43f7532c2f230249720f5dc5dd40ecafaf1c60ac8200d46d7eb"}, + {file = "matplotlib-3.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab38a4f3772523179b2f772103d8030215b318fef6360cb40558f585bf3d017f"}, + {file = "matplotlib-3.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2315837485ca6188a4b632c5199900e28d33b481eb083663f6a44cfc8987ded3"}, + {file = "matplotlib-3.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:a0c977c5c382f6696caf0bd277ef4f936da7e2aa202ff66cad5f0ac1428ee15b"}, + {file = "matplotlib-3.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:565d572efea2b94f264dd86ef27919515aa6d629252a169b42ce5f570db7f37b"}, + {file = "matplotlib-3.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d397fd8ccc64af2ec0af1f0efc3bacd745ebfb9d507f3f552e8adb689ed730a"}, + {file = "matplotlib-3.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26040c8f5121cd1ad712abffcd4b5222a8aec3a0fe40bc8542c94331deb8780d"}, + {file = "matplotlib-3.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d12cb1837cffaac087ad6b44399d5e22b78c729de3cdae4629e252067b705e2b"}, + {file = "matplotlib-3.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0e835c6988edc3d2d08794f73c323cc62483e13df0194719ecb0723b564e0b5c"}, + {file = "matplotlib-3.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:44a21d922f78ce40435cb35b43dd7d573cf2a30138d5c4b709d19f00e3907fd7"}, + {file = "matplotlib-3.9.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:0c584210c755ae921283d21d01f03a49ef46d1afa184134dd0f95b0202ee6f03"}, + {file = "matplotlib-3.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11fed08f34fa682c2b792942f8902e7aefeed400da71f9e5816bea40a7ce28fe"}, + {file = "matplotlib-3.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0000354e32efcfd86bda75729716b92f5c2edd5b947200be9881f0a671565c33"}, + {file = "matplotlib-3.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4db17fea0ae3aceb8e9ac69c7e3051bae0b3d083bfec932240f9bf5d0197a049"}, + {file = "matplotlib-3.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:208cbce658b72bf6a8e675058fbbf59f67814057ae78165d8a2f87c45b48d0ff"}, + {file = "matplotlib-3.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:dc23f48ab630474264276be156d0d7710ac6c5a09648ccdf49fef9200d8cbe80"}, + {file = "matplotlib-3.9.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3fda72d4d472e2ccd1be0e9ccb6bf0d2eaf635e7f8f51d737ed7e465ac020cb3"}, + {file = "matplotlib-3.9.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:84b3ba8429935a444f1fdc80ed930babbe06725bcf09fbeb5c8757a2cd74af04"}, + {file = "matplotlib-3.9.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b918770bf3e07845408716e5bbda17eadfc3fcbd9307dc67f37d6cf834bb3d98"}, + {file = "matplotlib-3.9.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:f1f2e5d29e9435c97ad4c36fb6668e89aee13d48c75893e25cef064675038ac9"}, + {file = "matplotlib-3.9.1.tar.gz", hash = "sha256:de06b19b8db95dd33d0dc17c926c7c9ebed9f572074b6fac4f65068a6814d010"}, ] [package.dependencies] @@ -2313,84 +2313,95 @@ ptyprocess = ">=0.5" [[package]] name = "pillow" -version = "10.3.0" +version = "10.4.0" description = "Python Imaging Library (Fork)" optional = false python-versions = ">=3.8" files = [ - {file = "pillow-10.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:90b9e29824800e90c84e4022dd5cc16eb2d9605ee13f05d47641eb183cd73d45"}, - {file = "pillow-10.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a2c405445c79c3f5a124573a051062300936b0281fee57637e706453e452746c"}, - {file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78618cdbccaa74d3f88d0ad6cb8ac3007f1a6fa5c6f19af64b55ca170bfa1edf"}, - {file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:261ddb7ca91fcf71757979534fb4c128448b5b4c55cb6152d280312062f69599"}, - {file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ce49c67f4ea0609933d01c0731b34b8695a7a748d6c8d186f95e7d085d2fe475"}, - {file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b14f16f94cbc61215115b9b1236f9c18403c15dd3c52cf629072afa9d54c1cbf"}, - {file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d33891be6df59d93df4d846640f0e46f1a807339f09e79a8040bc887bdcd7ed3"}, - {file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b50811d664d392f02f7761621303eba9d1b056fb1868c8cdf4231279645c25f5"}, - {file = "pillow-10.3.0-cp310-cp310-win32.whl", hash = "sha256:ca2870d5d10d8726a27396d3ca4cf7976cec0f3cb706debe88e3a5bd4610f7d2"}, - {file = "pillow-10.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f0d0591a0aeaefdaf9a5e545e7485f89910c977087e7de2b6c388aec32011e9f"}, - {file = "pillow-10.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:ccce24b7ad89adb5a1e34a6ba96ac2530046763912806ad4c247356a8f33a67b"}, - {file = "pillow-10.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795"}, - {file = "pillow-10.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57"}, - {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27"}, - {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994"}, - {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451"}, - {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd"}, - {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad"}, - {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c"}, - {file = "pillow-10.3.0-cp311-cp311-win32.whl", hash = "sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09"}, - {file = "pillow-10.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d"}, - {file = "pillow-10.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f"}, - {file = "pillow-10.3.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:e46f38133e5a060d46bd630faa4d9fa0202377495df1f068a8299fd78c84de84"}, - {file = "pillow-10.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:50b8eae8f7334ec826d6eeffaeeb00e36b5e24aa0b9df322c247539714c6df19"}, - {file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d3bea1c75f8c53ee4d505c3e67d8c158ad4df0d83170605b50b64025917f338"}, - {file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19aeb96d43902f0a783946a0a87dbdad5c84c936025b8419da0a0cd7724356b1"}, - {file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:74d28c17412d9caa1066f7a31df8403ec23d5268ba46cd0ad2c50fb82ae40462"}, - {file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ff61bfd9253c3915e6d41c651d5f962da23eda633cf02262990094a18a55371a"}, - {file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d886f5d353333b4771d21267c7ecc75b710f1a73d72d03ca06df49b09015a9ef"}, - {file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b5ec25d8b17217d635f8935dbc1b9aa5907962fae29dff220f2659487891cd3"}, - {file = "pillow-10.3.0-cp312-cp312-win32.whl", hash = "sha256:51243f1ed5161b9945011a7360e997729776f6e5d7005ba0c6879267d4c5139d"}, - {file = "pillow-10.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:412444afb8c4c7a6cc11a47dade32982439925537e483be7c0ae0cf96c4f6a0b"}, - {file = "pillow-10.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:798232c92e7665fe82ac085f9d8e8ca98826f8e27859d9a96b41d519ecd2e49a"}, - {file = "pillow-10.3.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:4eaa22f0d22b1a7e93ff0a596d57fdede2e550aecffb5a1ef1106aaece48e96b"}, - {file = "pillow-10.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cd5e14fbf22a87321b24c88669aad3a51ec052eb145315b3da3b7e3cc105b9a2"}, - {file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1530e8f3a4b965eb6a7785cf17a426c779333eb62c9a7d1bbcf3ffd5bf77a4aa"}, - {file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d512aafa1d32efa014fa041d38868fda85028e3f930a96f85d49c7d8ddc0383"}, - {file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:339894035d0ede518b16073bdc2feef4c991ee991a29774b33e515f1d308e08d"}, - {file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:aa7e402ce11f0885305bfb6afb3434b3cd8f53b563ac065452d9d5654c7b86fd"}, - {file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0ea2a783a2bdf2a561808fe4a7a12e9aa3799b701ba305de596bc48b8bdfce9d"}, - {file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c78e1b00a87ce43bb37642c0812315b411e856a905d58d597750eb79802aaaa3"}, - {file = "pillow-10.3.0-cp38-cp38-win32.whl", hash = "sha256:72d622d262e463dfb7595202d229f5f3ab4b852289a1cd09650362db23b9eb0b"}, - {file = "pillow-10.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:2034f6759a722da3a3dbd91a81148cf884e91d1b747992ca288ab88c1de15999"}, - {file = "pillow-10.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2ed854e716a89b1afcedea551cd85f2eb2a807613752ab997b9974aaa0d56936"}, - {file = "pillow-10.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dc1a390a82755a8c26c9964d457d4c9cbec5405896cba94cf51f36ea0d855002"}, - {file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4203efca580f0dd6f882ca211f923168548f7ba334c189e9eab1178ab840bf60"}, - {file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3102045a10945173d38336f6e71a8dc71bcaeed55c3123ad4af82c52807b9375"}, - {file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6fb1b30043271ec92dc65f6d9f0b7a830c210b8a96423074b15c7bc999975f57"}, - {file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:1dfc94946bc60ea375cc39cff0b8da6c7e5f8fcdc1d946beb8da5c216156ddd8"}, - {file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b09b86b27a064c9624d0a6c54da01c1beaf5b6cadfa609cf63789b1d08a797b9"}, - {file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d3b2348a78bc939b4fed6552abfd2e7988e0f81443ef3911a4b8498ca084f6eb"}, - {file = "pillow-10.3.0-cp39-cp39-win32.whl", hash = "sha256:45ebc7b45406febf07fef35d856f0293a92e7417ae7933207e90bf9090b70572"}, - {file = "pillow-10.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:0ba26351b137ca4e0db0342d5d00d2e355eb29372c05afd544ebf47c0956ffeb"}, - {file = "pillow-10.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:50fd3f6b26e3441ae07b7c979309638b72abc1a25da31a81a7fbd9495713ef4f"}, - {file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6b02471b72526ab8a18c39cb7967b72d194ec53c1fd0a70b050565a0f366d355"}, - {file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8ab74c06ffdab957d7670c2a5a6e1a70181cd10b727cd788c4dd9005b6a8acd9"}, - {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:048eeade4c33fdf7e08da40ef402e748df113fd0b4584e32c4af74fe78baaeb2"}, - {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2ec1e921fd07c7cda7962bad283acc2f2a9ccc1b971ee4b216b75fad6f0463"}, - {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c8e73e99da7db1b4cad7f8d682cf6abad7844da39834c288fbfa394a47bbced"}, - {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:16563993329b79513f59142a6b02055e10514c1a8e86dca8b48a893e33cf91e3"}, - {file = "pillow-10.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:dd78700f5788ae180b5ee8902c6aea5a5726bac7c364b202b4b3e3ba2d293170"}, - {file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:aff76a55a8aa8364d25400a210a65ff59d0168e0b4285ba6bf2bd83cf675ba32"}, - {file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b7bc2176354defba3edc2b9a777744462da2f8e921fbaf61e52acb95bafa9828"}, - {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:793b4e24db2e8742ca6423d3fde8396db336698c55cd34b660663ee9e45ed37f"}, - {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d93480005693d247f8346bc8ee28c72a2191bdf1f6b5db469c096c0c867ac015"}, - {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83341b89884e2b2e55886e8fbbf37c3fa5efd6c8907124aeb72f285ae5696e5"}, - {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1a1d1915db1a4fdb2754b9de292642a39a7fb28f1736699527bb649484fb966a"}, - {file = "pillow-10.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a0eaa93d054751ee9964afa21c06247779b90440ca41d184aeb5d410f20ff591"}, - {file = "pillow-10.3.0.tar.gz", hash = "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d"}, -] - -[package.extras] -docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"] + {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, + {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, + {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, + {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, + {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, + {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, + {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, + {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, + {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, + {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, + {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, + {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, + {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, + {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, + {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, + {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, + {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, + {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, + {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, + {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, + {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, + {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, + {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, + {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, + {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, + {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, + {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, + {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, + {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, + {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, + {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, + {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, + {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, + {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, + {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, + {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, + {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, + {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, + {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, + {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, + {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, + {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] fpx = ["olefile"] mic = ["olefile"] tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] @@ -2666,23 +2677,23 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyinstaller" -version = "6.8.0" +version = "6.9.0" description = "PyInstaller bundles a Python application and all its dependencies into a single package." optional = false python-versions = "<3.13,>=3.8" files = [ - {file = "pyinstaller-6.8.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:5ff6bc2784c1026f8e2f04aa3760cbed41408e108a9d4cf1dd52ee8351a3f6e1"}, - {file = "pyinstaller-6.8.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:39ac424d2ee2457d2ab11a5091436e75a0cccae207d460d180aa1fcbbafdd528"}, - {file = "pyinstaller-6.8.0-py3-none-manylinux2014_i686.whl", hash = "sha256:355832a3acc7de90a255ecacd4b9f9e166a547a79c8905d49f14e3a75c1acdb9"}, - {file = "pyinstaller-6.8.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:6303c7a009f47e6a96ef65aed49f41e36ece8d079b9193ca92fe807403e5fe80"}, - {file = "pyinstaller-6.8.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2b71509468c811968c0b5decb5bbe85b6292ea52d7b1f26313d2aabb673fa9a5"}, - {file = "pyinstaller-6.8.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ff31c5b99e05a4384bbe2071df67ec8b2b347640a375eae9b40218be2f1754c6"}, - {file = "pyinstaller-6.8.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:000c36b13fe4cd8d0d8c2bc855b1ddcf39867b5adf389e6b5ca45b25fa3e619d"}, - {file = "pyinstaller-6.8.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:fe0af018d7d5077180e3144ada89a4da5df8d07716eb7e9482834a56dc57a4e8"}, - {file = "pyinstaller-6.8.0-py3-none-win32.whl", hash = "sha256:d257f6645c7334cbd66f38a4fac62c3ad614cc46302b2b5d9f8cc48c563bce0e"}, - {file = "pyinstaller-6.8.0-py3-none-win_amd64.whl", hash = "sha256:81cccfa9b16699b457f4788c5cc119b50f3cd4d0db924955f15c33f2ad27a50d"}, - {file = "pyinstaller-6.8.0-py3-none-win_arm64.whl", hash = "sha256:1c3060a263758cf7f0144ab4c016097b20451b2469d468763414665db1bb743d"}, - {file = "pyinstaller-6.8.0.tar.gz", hash = "sha256:3f4b6520f4423fe19bcc2fd63ab7238851ae2bdcbc98f25bc5d2f97cc62012e9"}, + {file = "pyinstaller-6.9.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:5ced2e83acf222b936ea94abc5a5cc96588705654b39138af8fb321d9cf2b954"}, + {file = "pyinstaller-6.9.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:f18a3d551834ef8fb7830d48d4cc1527004d0e6b51ded7181e78374ad6111846"}, + {file = "pyinstaller-6.9.0-py3-none-manylinux2014_i686.whl", hash = "sha256:f2fc568de3d6d2a176716a3fc9f20da06d351e8bea5ddd10ecb5659fce3a05b0"}, + {file = "pyinstaller-6.9.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:a0f378f64ad0655d11ade9fde7877e7573fd3d5066231608ce7dfa9040faecdd"}, + {file = "pyinstaller-6.9.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:7bf0c13c5a8560c89540746ae742f4f4b82290e95a6b478374d9f34959fe25d6"}, + {file = "pyinstaller-6.9.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:da994aba14c5686db88796684de265a8665733b4df09b939f7ebdf097d18df72"}, + {file = "pyinstaller-6.9.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:4e3e50743c091a06e6d01c59bdd6d03967b453ee5384a9e790759be4129db4a4"}, + {file = "pyinstaller-6.9.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b041be2fe78da47a269604d62c940d68c62f9a3913bdf64af4123f7689d47099"}, + {file = "pyinstaller-6.9.0-py3-none-win32.whl", hash = "sha256:2bf4de17a1c63c0b797b38e13bfb4d03b5ee7c0a68e28b915a7eaacf6b76087f"}, + {file = "pyinstaller-6.9.0-py3-none-win_amd64.whl", hash = "sha256:43709c70b1da8441a730327a8ed362bfcfdc3d42c1bf89f3e2b0a163cc4e7d33"}, + {file = "pyinstaller-6.9.0-py3-none-win_arm64.whl", hash = "sha256:f15c1ef11ed5ceb32447dfbdab687017d6adbef7fc32aa359d584369bfe56eda"}, + {file = "pyinstaller-6.9.0.tar.gz", hash = "sha256:f4a75c552facc2e2a370f1e422b971b5e5cdb4058ff38cea0235aa21fc0b378f"}, ] [package.dependencies] @@ -2691,7 +2702,7 @@ importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} packaging = ">=22.0" pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""} -pyinstaller-hooks-contrib = ">=2024.6" +pyinstaller-hooks-contrib = ">=2024.7" pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} setuptools = ">=42.0.0" @@ -3021,7 +3032,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -3253,110 +3263,110 @@ resolved_reference = "27fd58f069a089676dcaaea2ccb8dc8d24e4c6d9" [[package]] name = "rpds-py" -version = "0.18.1" +version = "0.19.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.8" files = [ - {file = "rpds_py-0.18.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:d31dea506d718693b6b2cffc0648a8929bdc51c70a311b2770f09611caa10d53"}, - {file = "rpds_py-0.18.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:732672fbc449bab754e0b15356c077cc31566df874964d4801ab14f71951ea80"}, - {file = "rpds_py-0.18.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a98a1f0552b5f227a3d6422dbd61bc6f30db170939bd87ed14f3c339aa6c7c9"}, - {file = "rpds_py-0.18.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f1944ce16401aad1e3f7d312247b3d5de7981f634dc9dfe90da72b87d37887d"}, - {file = "rpds_py-0.18.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38e14fb4e370885c4ecd734f093a2225ee52dc384b86fa55fe3f74638b2cfb09"}, - {file = "rpds_py-0.18.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08d74b184f9ab6289b87b19fe6a6d1a97fbfea84b8a3e745e87a5de3029bf944"}, - {file = "rpds_py-0.18.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d70129cef4a8d979caa37e7fe957202e7eee8ea02c5e16455bc9808a59c6b2f0"}, - {file = "rpds_py-0.18.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce0bb20e3a11bd04461324a6a798af34d503f8d6f1aa3d2aa8901ceaf039176d"}, - {file = "rpds_py-0.18.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81c5196a790032e0fc2464c0b4ab95f8610f96f1f2fa3d4deacce6a79852da60"}, - {file = "rpds_py-0.18.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f3027be483868c99b4985fda802a57a67fdf30c5d9a50338d9db646d590198da"}, - {file = "rpds_py-0.18.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d44607f98caa2961bab4fa3c4309724b185b464cdc3ba6f3d7340bac3ec97cc1"}, - {file = "rpds_py-0.18.1-cp310-none-win32.whl", hash = "sha256:c273e795e7a0f1fddd46e1e3cb8be15634c29ae8ff31c196debb620e1edb9333"}, - {file = "rpds_py-0.18.1-cp310-none-win_amd64.whl", hash = "sha256:8352f48d511de5f973e4f2f9412736d7dea76c69faa6d36bcf885b50c758ab9a"}, - {file = "rpds_py-0.18.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6b5ff7e1d63a8281654b5e2896d7f08799378e594f09cf3674e832ecaf396ce8"}, - {file = "rpds_py-0.18.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8927638a4d4137a289e41d0fd631551e89fa346d6dbcfc31ad627557d03ceb6d"}, - {file = "rpds_py-0.18.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:154bf5c93d79558b44e5b50cc354aa0459e518e83677791e6adb0b039b7aa6a7"}, - {file = "rpds_py-0.18.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07f2139741e5deb2c5154a7b9629bc5aa48c766b643c1a6750d16f865a82c5fc"}, - {file = "rpds_py-0.18.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c7672e9fba7425f79019db9945b16e308ed8bc89348c23d955c8c0540da0a07"}, - {file = "rpds_py-0.18.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:489bdfe1abd0406eba6b3bb4fdc87c7fa40f1031de073d0cfb744634cc8fa261"}, - {file = "rpds_py-0.18.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c20f05e8e3d4fc76875fc9cb8cf24b90a63f5a1b4c5b9273f0e8225e169b100"}, - {file = "rpds_py-0.18.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:967342e045564cef76dfcf1edb700b1e20838d83b1aa02ab313e6a497cf923b8"}, - {file = "rpds_py-0.18.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2cc7c1a47f3a63282ab0f422d90ddac4aa3034e39fc66a559ab93041e6505da7"}, - {file = "rpds_py-0.18.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f7afbfee1157e0f9376c00bb232e80a60e59ed716e3211a80cb8506550671e6e"}, - {file = "rpds_py-0.18.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9e6934d70dc50f9f8ea47081ceafdec09245fd9f6032669c3b45705dea096b88"}, - {file = "rpds_py-0.18.1-cp311-none-win32.whl", hash = "sha256:c69882964516dc143083d3795cb508e806b09fc3800fd0d4cddc1df6c36e76bb"}, - {file = "rpds_py-0.18.1-cp311-none-win_amd64.whl", hash = "sha256:70a838f7754483bcdc830444952fd89645569e7452e3226de4a613a4c1793fb2"}, - {file = "rpds_py-0.18.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3dd3cd86e1db5aadd334e011eba4e29d37a104b403e8ca24dcd6703c68ca55b3"}, - {file = "rpds_py-0.18.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05f3d615099bd9b13ecf2fc9cf2d839ad3f20239c678f461c753e93755d629ee"}, - {file = "rpds_py-0.18.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35b2b771b13eee8729a5049c976197ff58a27a3829c018a04341bcf1ae409b2b"}, - {file = "rpds_py-0.18.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee17cd26b97d537af8f33635ef38be873073d516fd425e80559f4585a7b90c43"}, - {file = "rpds_py-0.18.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b646bf655b135ccf4522ed43d6902af37d3f5dbcf0da66c769a2b3938b9d8184"}, - {file = "rpds_py-0.18.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19ba472b9606c36716062c023afa2484d1e4220548751bda14f725a7de17b4f6"}, - {file = "rpds_py-0.18.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e30ac5e329098903262dc5bdd7e2086e0256aa762cc8b744f9e7bf2a427d3f8"}, - {file = "rpds_py-0.18.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d58ad6317d188c43750cb76e9deacf6051d0f884d87dc6518e0280438648a9ac"}, - {file = "rpds_py-0.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e1735502458621921cee039c47318cb90b51d532c2766593be6207eec53e5c4c"}, - {file = "rpds_py-0.18.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f5bab211605d91db0e2995a17b5c6ee5edec1270e46223e513eaa20da20076ac"}, - {file = "rpds_py-0.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2fc24a329a717f9e2448f8cd1f960f9dac4e45b6224d60734edeb67499bab03a"}, - {file = "rpds_py-0.18.1-cp312-none-win32.whl", hash = "sha256:1805d5901779662d599d0e2e4159d8a82c0b05faa86ef9222bf974572286b2b6"}, - {file = "rpds_py-0.18.1-cp312-none-win_amd64.whl", hash = "sha256:720edcb916df872d80f80a1cc5ea9058300b97721efda8651efcd938a9c70a72"}, - {file = "rpds_py-0.18.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:c827576e2fa017a081346dce87d532a5310241648eb3700af9a571a6e9fc7e74"}, - {file = "rpds_py-0.18.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:aa3679e751408d75a0b4d8d26d6647b6d9326f5e35c00a7ccd82b78ef64f65f8"}, - {file = "rpds_py-0.18.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0abeee75434e2ee2d142d650d1e54ac1f8b01e6e6abdde8ffd6eeac6e9c38e20"}, - {file = "rpds_py-0.18.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed402d6153c5d519a0faf1bb69898e97fb31613b49da27a84a13935ea9164dfc"}, - {file = "rpds_py-0.18.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:338dee44b0cef8b70fd2ef54b4e09bb1b97fc6c3a58fea5db6cc083fd9fc2724"}, - {file = "rpds_py-0.18.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7750569d9526199c5b97e5a9f8d96a13300950d910cf04a861d96f4273d5b104"}, - {file = "rpds_py-0.18.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:607345bd5912aacc0c5a63d45a1f73fef29e697884f7e861094e443187c02be5"}, - {file = "rpds_py-0.18.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:207c82978115baa1fd8d706d720b4a4d2b0913df1c78c85ba73fe6c5804505f0"}, - {file = "rpds_py-0.18.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6d1e42d2735d437e7e80bab4d78eb2e459af48c0a46e686ea35f690b93db792d"}, - {file = "rpds_py-0.18.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5463c47c08630007dc0fe99fb480ea4f34a89712410592380425a9b4e1611d8e"}, - {file = "rpds_py-0.18.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:06d218939e1bf2ca50e6b0ec700ffe755e5216a8230ab3e87c059ebb4ea06afc"}, - {file = "rpds_py-0.18.1-cp38-none-win32.whl", hash = "sha256:312fe69b4fe1ffbe76520a7676b1e5ac06ddf7826d764cc10265c3b53f96dbe9"}, - {file = "rpds_py-0.18.1-cp38-none-win_amd64.whl", hash = "sha256:9437ca26784120a279f3137ee080b0e717012c42921eb07861b412340f85bae2"}, - {file = "rpds_py-0.18.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:19e515b78c3fc1039dd7da0a33c28c3154458f947f4dc198d3c72db2b6b5dc93"}, - {file = "rpds_py-0.18.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a7b28c5b066bca9a4eb4e2f2663012debe680f097979d880657f00e1c30875a0"}, - {file = "rpds_py-0.18.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:673fdbbf668dd958eff750e500495ef3f611e2ecc209464f661bc82e9838991e"}, - {file = "rpds_py-0.18.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d960de62227635d2e61068f42a6cb6aae91a7fe00fca0e3aeed17667c8a34611"}, - {file = "rpds_py-0.18.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:352a88dc7892f1da66b6027af06a2e7e5d53fe05924cc2cfc56495b586a10b72"}, - {file = "rpds_py-0.18.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e0ee01ad8260184db21468a6e1c37afa0529acc12c3a697ee498d3c2c4dcaf3"}, - {file = "rpds_py-0.18.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4c39ad2f512b4041343ea3c7894339e4ca7839ac38ca83d68a832fc8b3748ab"}, - {file = "rpds_py-0.18.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aaa71ee43a703c321906813bb252f69524f02aa05bf4eec85f0c41d5d62d0f4c"}, - {file = "rpds_py-0.18.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6cd8098517c64a85e790657e7b1e509b9fe07487fd358e19431cb120f7d96338"}, - {file = "rpds_py-0.18.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4adec039b8e2928983f885c53b7cc4cda8965b62b6596501a0308d2703f8af1b"}, - {file = "rpds_py-0.18.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:32b7daaa3e9389db3695964ce8e566e3413b0c43e3394c05e4b243a4cd7bef26"}, - {file = "rpds_py-0.18.1-cp39-none-win32.whl", hash = "sha256:2625f03b105328729f9450c8badda34d5243231eef6535f80064d57035738360"}, - {file = "rpds_py-0.18.1-cp39-none-win_amd64.whl", hash = "sha256:bf18932d0003c8c4d51a39f244231986ab23ee057d235a12b2684ea26a353590"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cbfbea39ba64f5e53ae2915de36f130588bba71245b418060ec3330ebf85678e"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:a3d456ff2a6a4d2adcdf3c1c960a36f4fd2fec6e3b4902a42a384d17cf4e7a65"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7700936ef9d006b7ef605dc53aa364da2de5a3aa65516a1f3ce73bf82ecfc7ae"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:51584acc5916212e1bf45edd17f3a6b05fe0cbb40482d25e619f824dccb679de"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:942695a206a58d2575033ff1e42b12b2aece98d6003c6bc739fbf33d1773b12f"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b906b5f58892813e5ba5c6056d6a5ad08f358ba49f046d910ad992196ea61397"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6f8e3fecca256fefc91bb6765a693d96692459d7d4c644660a9fff32e517843"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7732770412bab81c5a9f6d20aeb60ae943a9b36dcd990d876a773526468e7163"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:bd1105b50ede37461c1d51b9698c4f4be6e13e69a908ab7751e3807985fc0346"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:618916f5535784960f3ecf8111581f4ad31d347c3de66d02e728de460a46303c"}, - {file = "rpds_py-0.18.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:17c6d2155e2423f7e79e3bb18151c686d40db42d8645e7977442170c360194d4"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:6c4c4c3f878df21faf5fac86eda32671c27889e13570645a9eea0a1abdd50922"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:fab6ce90574645a0d6c58890e9bcaac8d94dff54fb51c69e5522a7358b80ab64"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:531796fb842b53f2695e94dc338929e9f9dbf473b64710c28af5a160b2a8927d"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:740884bc62a5e2bbb31e584f5d23b32320fd75d79f916f15a788d527a5e83644"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:998125738de0158f088aef3cb264a34251908dd2e5d9966774fdab7402edfab7"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2be6e9dd4111d5b31ba3b74d17da54a8319d8168890fbaea4b9e5c3de630ae5"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0cee71bc618cd93716f3c1bf56653740d2d13ddbd47673efa8bf41435a60daa"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c3caec4ec5cd1d18e5dd6ae5194d24ed12785212a90b37f5f7f06b8bedd7139"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:27bba383e8c5231cd559affe169ca0b96ec78d39909ffd817f28b166d7ddd4d8"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-musllinux_1_2_i686.whl", hash = "sha256:a888e8bdb45916234b99da2d859566f1e8a1d2275a801bb8e4a9644e3c7e7909"}, - {file = "rpds_py-0.18.1-pp38-pypy38_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6031b25fb1b06327b43d841f33842b383beba399884f8228a6bb3df3088485ff"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48c2faaa8adfacefcbfdb5f2e2e7bdad081e5ace8d182e5f4ade971f128e6bb3"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:d85164315bd68c0806768dc6bb0429c6f95c354f87485ee3593c4f6b14def2bd"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6afd80f6c79893cfc0574956f78a0add8c76e3696f2d6a15bca2c66c415cf2d4"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa242ac1ff583e4ec7771141606aafc92b361cd90a05c30d93e343a0c2d82a89"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21be4770ff4e08698e1e8e0bce06edb6ea0626e7c8f560bc08222880aca6a6f"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c45a639e93a0c5d4b788b2613bd637468edd62f8f95ebc6fcc303d58ab3f0a8"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:910e71711d1055b2768181efa0a17537b2622afeb0424116619817007f8a2b10"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b9bb1f182a97880f6078283b3505a707057c42bf55d8fca604f70dedfdc0772a"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:1d54f74f40b1f7aaa595a02ff42ef38ca654b1469bef7d52867da474243cc633"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:8d2e182c9ee01135e11e9676e9a62dfad791a7a467738f06726872374a83db49"}, - {file = "rpds_py-0.18.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:636a15acc588f70fda1661234761f9ed9ad79ebed3f2125d44be0862708b666e"}, - {file = "rpds_py-0.18.1.tar.gz", hash = "sha256:dc48b479d540770c811fbd1eb9ba2bb66951863e448efec2e2c102625328e92f"}, + {file = "rpds_py-0.19.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:fb37bd599f031f1a6fb9e58ec62864ccf3ad549cf14bac527dbfa97123edcca4"}, + {file = "rpds_py-0.19.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3384d278df99ec2c6acf701d067147320b864ef6727405d6470838476e44d9e8"}, + {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e54548e0be3ac117595408fd4ca0ac9278fde89829b0b518be92863b17ff67a2"}, + {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8eb488ef928cdbc05a27245e52de73c0d7c72a34240ef4d9893fdf65a8c1a955"}, + {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5da93debdfe27b2bfc69eefb592e1831d957b9535e0943a0ee8b97996de21b5"}, + {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:79e205c70afddd41f6ee79a8656aec738492a550247a7af697d5bd1aee14f766"}, + {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:959179efb3e4a27610e8d54d667c02a9feaa86bbabaf63efa7faa4dfa780d4f1"}, + {file = "rpds_py-0.19.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a6e605bb9edcf010f54f8b6a590dd23a4b40a8cb141255eec2a03db249bc915b"}, + {file = "rpds_py-0.19.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9133d75dc119a61d1a0ded38fb9ba40a00ef41697cc07adb6ae098c875195a3f"}, + {file = "rpds_py-0.19.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd36b712d35e757e28bf2f40a71e8f8a2d43c8b026d881aa0c617b450d6865c9"}, + {file = "rpds_py-0.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354f3a91718489912f2e0fc331c24eaaf6a4565c080e00fbedb6015857c00582"}, + {file = "rpds_py-0.19.0-cp310-none-win32.whl", hash = "sha256:ebcbf356bf5c51afc3290e491d3722b26aaf5b6af3c1c7f6a1b757828a46e336"}, + {file = "rpds_py-0.19.0-cp310-none-win_amd64.whl", hash = "sha256:75a6076289b2df6c8ecb9d13ff79ae0cad1d5fb40af377a5021016d58cd691ec"}, + {file = "rpds_py-0.19.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6d45080095e585f8c5097897313def60caa2046da202cdb17a01f147fb263b81"}, + {file = "rpds_py-0.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5c9581019c96f865483d031691a5ff1cc455feb4d84fc6920a5ffc48a794d8a"}, + {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1540d807364c84516417115c38f0119dfec5ea5c0dd9a25332dea60b1d26fc4d"}, + {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e65489222b410f79711dc3d2d5003d2757e30874096b2008d50329ea4d0f88c"}, + {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9da6f400eeb8c36f72ef6646ea530d6d175a4f77ff2ed8dfd6352842274c1d8b"}, + {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37f46bb11858717e0efa7893c0f7055c43b44c103e40e69442db5061cb26ed34"}, + {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:071d4adc734de562bd11d43bd134330fb6249769b2f66b9310dab7460f4bf714"}, + {file = "rpds_py-0.19.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9625367c8955e4319049113ea4f8fee0c6c1145192d57946c6ffcd8fe8bf48dd"}, + {file = "rpds_py-0.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e19509145275d46bc4d1e16af0b57a12d227c8253655a46bbd5ec317e941279d"}, + {file = "rpds_py-0.19.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d438e4c020d8c39961deaf58f6913b1bf8832d9b6f62ec35bd93e97807e9cbc"}, + {file = "rpds_py-0.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90bf55d9d139e5d127193170f38c584ed3c79e16638890d2e36f23aa1630b952"}, + {file = "rpds_py-0.19.0-cp311-none-win32.whl", hash = "sha256:8d6ad132b1bc13d05ffe5b85e7a01a3998bf3a6302ba594b28d61b8c2cf13aaf"}, + {file = "rpds_py-0.19.0-cp311-none-win_amd64.whl", hash = "sha256:7ec72df7354e6b7f6eb2a17fa6901350018c3a9ad78e48d7b2b54d0412539a67"}, + {file = "rpds_py-0.19.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:5095a7c838a8647c32aa37c3a460d2c48debff7fc26e1136aee60100a8cd8f68"}, + {file = "rpds_py-0.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f2f78ef14077e08856e788fa482107aa602636c16c25bdf59c22ea525a785e9"}, + {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7cc6cb44f8636fbf4a934ca72f3e786ba3c9f9ba4f4d74611e7da80684e48d2"}, + {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf902878b4af334a09de7a45badbff0389e7cf8dc2e4dcf5f07125d0b7c2656d"}, + {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:688aa6b8aa724db1596514751ffb767766e02e5c4a87486ab36b8e1ebc1aedac"}, + {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57dbc9167d48e355e2569346b5aa4077f29bf86389c924df25c0a8b9124461fb"}, + {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4cf5a9497874822341c2ebe0d5850fed392034caadc0bad134ab6822c0925b"}, + {file = "rpds_py-0.19.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a790d235b9d39c70a466200d506bb33a98e2ee374a9b4eec7a8ac64c2c261fa"}, + {file = "rpds_py-0.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d16089dfa58719c98a1c06f2daceba6d8e3fb9b5d7931af4a990a3c486241cb"}, + {file = "rpds_py-0.19.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bc9128e74fe94650367fe23f37074f121b9f796cabbd2f928f13e9661837296d"}, + {file = "rpds_py-0.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c8f77e661ffd96ff104bebf7d0f3255b02aa5d5b28326f5408d6284c4a8b3248"}, + {file = "rpds_py-0.19.0-cp312-none-win32.whl", hash = "sha256:5f83689a38e76969327e9b682be5521d87a0c9e5a2e187d2bc6be4765f0d4600"}, + {file = "rpds_py-0.19.0-cp312-none-win_amd64.whl", hash = "sha256:06925c50f86da0596b9c3c64c3837b2481337b83ef3519e5db2701df695453a4"}, + {file = "rpds_py-0.19.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:52e466bea6f8f3a44b1234570244b1cff45150f59a4acae3fcc5fd700c2993ca"}, + {file = "rpds_py-0.19.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e21cc693045fda7f745c790cb687958161ce172ffe3c5719ca1764e752237d16"}, + {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b31f059878eb1f5da8b2fd82480cc18bed8dcd7fb8fe68370e2e6285fa86da6"}, + {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dd46f309e953927dd018567d6a9e2fb84783963650171f6c5fe7e5c41fd5666"}, + {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34a01a4490e170376cd79258b7f755fa13b1a6c3667e872c8e35051ae857a92b"}, + {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcf426a8c38eb57f7bf28932e68425ba86def6e756a5b8cb4731d8e62e4e0223"}, + {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f68eea5df6347d3f1378ce992d86b2af16ad7ff4dcb4a19ccdc23dea901b87fb"}, + {file = "rpds_py-0.19.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dab8d921b55a28287733263c0e4c7db11b3ee22aee158a4de09f13c93283c62d"}, + {file = "rpds_py-0.19.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6fe87efd7f47266dfc42fe76dae89060038f1d9cb911f89ae7e5084148d1cc08"}, + {file = "rpds_py-0.19.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:535d4b52524a961d220875688159277f0e9eeeda0ac45e766092bfb54437543f"}, + {file = "rpds_py-0.19.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:8b1a94b8afc154fbe36978a511a1f155f9bd97664e4f1f7a374d72e180ceb0ae"}, + {file = "rpds_py-0.19.0-cp38-none-win32.whl", hash = "sha256:7c98298a15d6b90c8f6e3caa6457f4f022423caa5fa1a1ca7a5e9e512bdb77a4"}, + {file = "rpds_py-0.19.0-cp38-none-win_amd64.whl", hash = "sha256:b0da31853ab6e58a11db3205729133ce0df26e6804e93079dee095be3d681dc1"}, + {file = "rpds_py-0.19.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:5039e3cef7b3e7a060de468a4a60a60a1f31786da94c6cb054e7a3c75906111c"}, + {file = "rpds_py-0.19.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab1932ca6cb8c7499a4d87cb21ccc0d3326f172cfb6a64021a889b591bb3045c"}, + {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2afd2164a1e85226fcb6a1da77a5c8896c18bfe08e82e8ceced5181c42d2179"}, + {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1c30841f5040de47a0046c243fc1b44ddc87d1b12435a43b8edff7e7cb1e0d0"}, + {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f757f359f30ec7dcebca662a6bd46d1098f8b9fb1fcd661a9e13f2e8ce343ba1"}, + {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15e65395a59d2e0e96caf8ee5389ffb4604e980479c32742936ddd7ade914b22"}, + {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb0f6eb3a320f24b94d177e62f4074ff438f2ad9d27e75a46221904ef21a7b05"}, + {file = "rpds_py-0.19.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b228e693a2559888790936e20f5f88b6e9f8162c681830eda303bad7517b4d5a"}, + {file = "rpds_py-0.19.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2575efaa5d949c9f4e2cdbe7d805d02122c16065bfb8d95c129372d65a291a0b"}, + {file = "rpds_py-0.19.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5c872814b77a4e84afa293a1bee08c14daed1068b2bb1cc312edbf020bbbca2b"}, + {file = "rpds_py-0.19.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:850720e1b383df199b8433a20e02b25b72f0fded28bc03c5bd79e2ce7ef050be"}, + {file = "rpds_py-0.19.0-cp39-none-win32.whl", hash = "sha256:ce84a7efa5af9f54c0aa7692c45861c1667080814286cacb9958c07fc50294fb"}, + {file = "rpds_py-0.19.0-cp39-none-win_amd64.whl", hash = "sha256:1c26da90b8d06227d7769f34915913911222d24ce08c0ab2d60b354e2d9c7aff"}, + {file = "rpds_py-0.19.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:75969cf900d7be665ccb1622a9aba225cf386bbc9c3bcfeeab9f62b5048f4a07"}, + {file = "rpds_py-0.19.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8445f23f13339da640d1be8e44e5baf4af97e396882ebbf1692aecd67f67c479"}, + {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5a7c1062ef8aea3eda149f08120f10795835fc1c8bc6ad948fb9652a113ca55"}, + {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:462b0c18fbb48fdbf980914a02ee38c423a25fcc4cf40f66bacc95a2d2d73bc8"}, + {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3208f9aea18991ac7f2b39721e947bbd752a1abbe79ad90d9b6a84a74d44409b"}, + {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3444fe52b82f122d8a99bf66777aed6b858d392b12f4c317da19f8234db4533"}, + {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88cb4bac7185a9f0168d38c01d7a00addece9822a52870eee26b8d5b61409213"}, + {file = "rpds_py-0.19.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b130bd4163c93798a6b9bb96be64a7c43e1cec81126ffa7ffaa106e1fc5cef5"}, + {file = "rpds_py-0.19.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:a707b158b4410aefb6b054715545bbb21aaa5d5d0080217290131c49c2124a6e"}, + {file = "rpds_py-0.19.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:dc9ac4659456bde7c567107556ab065801622396b435a3ff213daef27b495388"}, + {file = "rpds_py-0.19.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:81ea573aa46d3b6b3d890cd3c0ad82105985e6058a4baed03cf92518081eec8c"}, + {file = "rpds_py-0.19.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f148c3f47f7f29a79c38cc5d020edcb5ca780020fab94dbc21f9af95c463581"}, + {file = "rpds_py-0.19.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0906357f90784a66e89ae3eadc2654f36c580a7d65cf63e6a616e4aec3a81be"}, + {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f629ecc2db6a4736b5ba95a8347b0089240d69ad14ac364f557d52ad68cf94b0"}, + {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6feacd1d178c30e5bc37184526e56740342fd2aa6371a28367bad7908d454fc"}, + {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae8b6068ee374fdfab63689be0963333aa83b0815ead5d8648389a8ded593378"}, + {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78d57546bad81e0da13263e4c9ce30e96dcbe720dbff5ada08d2600a3502e526"}, + {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b6683a37338818646af718c9ca2a07f89787551057fae57c4ec0446dc6224b"}, + {file = "rpds_py-0.19.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e8481b946792415adc07410420d6fc65a352b45d347b78fec45d8f8f0d7496f0"}, + {file = "rpds_py-0.19.0-pp38-pypy38_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:bec35eb20792ea64c3c57891bc3ca0bedb2884fbac2c8249d9b731447ecde4fa"}, + {file = "rpds_py-0.19.0-pp38-pypy38_pp73-musllinux_1_2_i686.whl", hash = "sha256:aa5476c3e3a402c37779e95f7b4048db2cb5b0ed0b9d006983965e93f40fe05a"}, + {file = "rpds_py-0.19.0-pp38-pypy38_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:19d02c45f2507b489fd4df7b827940f1420480b3e2e471e952af4d44a1ea8e34"}, + {file = "rpds_py-0.19.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a3e2fd14c5d49ee1da322672375963f19f32b3d5953f0615b175ff7b9d38daed"}, + {file = "rpds_py-0.19.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:93a91c2640645303e874eada51f4f33351b84b351a689d470f8108d0e0694210"}, + {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5b9fc03bf76a94065299d4a2ecd8dfbae4ae8e2e8098bbfa6ab6413ca267709"}, + {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a4b07cdf3f84310c08c1de2c12ddadbb7a77568bcb16e95489f9c81074322ed"}, + {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba0ed0dc6763d8bd6e5de5cf0d746d28e706a10b615ea382ac0ab17bb7388633"}, + {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:474bc83233abdcf2124ed3f66230a1c8435896046caa4b0b5ab6013c640803cc"}, + {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329c719d31362355a96b435f4653e3b4b061fcc9eba9f91dd40804ca637d914e"}, + {file = "rpds_py-0.19.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef9101f3f7b59043a34f1dccbb385ca760467590951952d6701df0da9893ca0c"}, + {file = "rpds_py-0.19.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:0121803b0f424ee2109d6e1f27db45b166ebaa4b32ff47d6aa225642636cd834"}, + {file = "rpds_py-0.19.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:8344127403dea42f5970adccf6c5957a71a47f522171fafaf4c6ddb41b61703a"}, + {file = "rpds_py-0.19.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:443cec402ddd650bb2b885113e1dcedb22b1175c6be223b14246a714b61cd521"}, + {file = "rpds_py-0.19.0.tar.gz", hash = "sha256:4fdc9afadbeb393b4bbbad75481e0ea78e4469f2e1d713a90811700830b553a9"}, ] [[package]] @@ -3377,18 +3387,18 @@ win32 = ["pywin32"] [[package]] name = "setuptools" -version = "70.1.1" +version = "70.3.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-70.1.1-py3-none-any.whl", hash = "sha256:a58a8fde0541dab0419750bcc521fbdf8585f6e5cb41909df3a472ef7b81ca95"}, - {file = "setuptools-70.1.1.tar.gz", hash = "sha256:937a48c7cdb7a21eb53cd7f9b59e525503aa8abaf3584c730dc5f7a5bec3a650"}, + {file = "setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc"}, + {file = "setuptools-70.3.0.tar.gz", hash = "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "six" @@ -3469,13 +3479,13 @@ widechars = ["wcwidth"] [[package]] name = "tenacity" -version = "8.4.2" +version = "8.5.0" description = "Retry code until it succeeds" optional = false python-versions = ">=3.8" files = [ - {file = "tenacity-8.4.2-py3-none-any.whl", hash = "sha256:9e6f7cf7da729125c7437222f8a522279751cdfbe6b67bfe64f75d3a348661b2"}, - {file = "tenacity-8.4.2.tar.gz", hash = "sha256:cd80a53a79336edba8489e767f729e4f391c896956b57140b5d7511a64bbd3ef"}, + {file = "tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687"}, + {file = "tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78"}, ] [package.extras] @@ -3534,13 +3544,13 @@ files = [ [[package]] name = "tomlkit" -version = "0.12.5" +version = "0.13.0" description = "Style preserving TOML library" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "tomlkit-0.12.5-py3-none-any.whl", hash = "sha256:af914f5a9c59ed9d0762c7b64d3b5d5df007448eb9cd2edc8a46b1eafead172f"}, - {file = "tomlkit-0.12.5.tar.gz", hash = "sha256:eef34fba39834d4d6b73c9ba7f3e4d1c417a4e56f89a7e96e090dd0d24b8fb3c"}, + {file = "tomlkit-0.13.0-py3-none-any.whl", hash = "sha256:7075d3042d03b80f603482d69bf0c8f345c2b30e41699fd8883227f89972b264"}, + {file = "tomlkit-0.13.0.tar.gz", hash = "sha256:08ad192699734149f5b97b45f1f18dad7eb1b6d16bc72ad0c2335772650d7b72"}, ] [[package]] @@ -4009,4 +4019,4 @@ tunnel = [] [metadata] lock-version = "2.0" python-versions = "^3.9,<3.13" -content-hash = "a6032933510dcce0d89660fb1548219dee51e3373a65cf4addcec1f2b93fbceb" +content-hash = "a8752ba6272252f0fbf8de7b6635dbbbfee7587a9851e4a2050ec0e4f080a6b4" diff --git a/pyproject.toml b/pyproject.toml index d089606f..52f3a7f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,13 +52,6 @@ optional = true [tool.poetry.group.analysis.dependencies] jupyterlab = "^4.2.2" -mypy = "^1.10.0" -mypy-protobuf = "^3.6.0" -types-protobuf = "^5.26.0.20240422" -types-tabulate = "^0.9.0.20240106" -types-requests = "^2.31.0.20240406" -types-setuptools = "^69.5.0.20240423" -types-pyyaml = "^6.0.12.20240311" matplotlib = "^3.9.0" ipympl = "^0.9.4" ipywidgets = "^8.1.3" From d0db5cae135fe3079708abdbf9c7b4b05b888ce6 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Wed, 10 Jul 2024 16:44:27 -0700 Subject: [PATCH 15/30] Store much higher (time) res power readings any time we've just fetched new readings. This allows for better plotting/analysis but still keeping runtime polling low. --- .vscode/launch.json | 2 +- meshtastic/powermon/ppk2.py | 82 ++++++++++++++++++++++--------------- 2 files changed, 49 insertions(+), 35 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index b3d3934d..5db77373 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -204,7 +204,7 @@ "request": "launch", "module": "meshtastic", "justMyCode": false, - "args": ["--slog", "--power-ppk2-supply", "--power-stress", "--power-voltage", "3.3", "--seriallog", "--ble"] + "args": ["--slog", "--power-ppk2-supply", "--power-stress", "--power-voltage", "3.3", "--ble"] }, { "name": "meshtastic test", diff --git a/meshtastic/powermon/ppk2.py b/meshtastic/powermon/ppk2.py index 1f4d45d5..6a706559 100644 --- a/meshtastic/powermon/ppk2.py +++ b/meshtastic/powermon/ppk2.py @@ -9,6 +9,7 @@ from .power_supply import PowerError, PowerSupply + class PPK2PowerSupply(PowerSupply): """Interface for talking with the NRF PPK2 high-resolution micro-power supply. Power Profiler Kit II is what you should google to find it for purchase. @@ -35,12 +36,18 @@ def __init__(self, portName: Optional[str] = None): self.current_min = 0 self.current_sum = 0 self.current_num_samples = 0 + self.current_average = 0 # for tracking avera data read length (to determine if we are sleeping efficiently in measurement_loop) self.total_data_len = 0 self.num_data_reads = 0 self.max_data_len = 0 + # Normally we just sleep with a timeout on this condition (polling the power measurement data repeatedly) + # but any time our measurements have been fully consumed (via reset_measurements) we notify() this condition + # to trigger a new reading ASAP. + self.want_measurement = threading.Condition() + self.r = r = ppk2_api.PPK2_API( portName ) # serial port will be different for you @@ -55,52 +62,57 @@ def __init__(self, portName: Optional[str] = None): def measurement_loop(self): """Endless measurement loop will run in a thread.""" while self.measuring: - # always reads 4096 bytes, even if there is no new samples - or possibly the python single thread (because of global interpreter lock) - # is always behind and thefore we are inherently dropping samples semi randomly!!! - read_data = self.r.get_data() - if read_data != b"": - samples, _ = self.r.get_samples(read_data) - - # update invariants - if len(samples) > 0: - if self.current_num_samples == 0: - self.current_min = samples[ - 0 - ] # we need at least one sample to get an initial min - self.current_max = max(self.current_max, max(samples)) - self.current_min = min(self.current_min, min(samples)) - self.current_sum += sum(samples) - self.current_num_samples += len(samples) - # logging.debug(f"PPK2 data_len={len(read_data)}, sample_len={len(samples)}") - - self.num_data_reads += 1 - self.total_data_len += len(read_data) - self.max_data_len = max(self.max_data_len, len(read_data)) - - time.sleep(0.01) # FIXME figure out correct sleep duration + with self.want_measurement: + self.want_measurement.wait(0.0001 if self.num_data_reads == 0 else 0.01) + # normally we poll using this timeout, but sometimes + # reset_measurement() will notify us to read immediately + + # always reads 4096 bytes, even if there is no new samples - or possibly the python single thread (because of global interpreter lock) + # is always behind and thefore we are inherently dropping samples semi randomly!!! + read_data = self.r.get_data() + if read_data != b"": + samples, _ = self.r.get_samples(read_data) + + # update invariants + if len(samples) > 0: + if ( + self.current_num_samples == 0 + ): # First set of new reads, reset min/max + self.current_max = 0 + self.current_min = samples[ + 0 + ] # we need at least one sample to get an initial min + self.current_max = max(self.current_max, max(samples)) + self.current_min = min(self.current_min, min(samples)) + self.current_sum += sum(samples) + self.current_num_samples += len(samples) + # logging.debug(f"PPK2 data_len={len(read_data)}, sample_len={len(samples)}") + + self.num_data_reads += 1 + self.total_data_len += len(read_data) + self.max_data_len = max(self.max_data_len, len(read_data)) def get_min_current_mA(self): - """Returns max current in mA (since last call to this method).""" + """Return the min current in mA.""" return self.current_min / 1000 def get_max_current_mA(self): - """Returns max current in mA (since last call to this method).""" + """Return the max current in mA.""" return self.current_max / 1000 def get_average_current_mA(self): - """Returns average current in mA (since last call to this method).""" - if self.current_num_samples == 0: - return 0 - else: - return ( - self.current_sum / self.current_num_samples / 1000 - ) # measurements are in microamperes, divide by 1000 + """Return the average current in mA.""" + if self.current_num_samples != 0: + # If we have new samples, calculate a new average + self.current_average = self.current_sum / self.current_num_samples + + # Even if we don't have new samples, return the last calculated average + # measurements are in microamperes, divide by 1000 + return self.current_average / 1000 def reset_measurements(self): """Reset current measurements.""" # Use the last reading as the new only reading (to ensure we always have a valid current reading) - self.current_max = 0 - self.current_min = 0 self.current_sum = 0 self.current_num_samples = 0 @@ -110,6 +122,8 @@ def reset_measurements(self): self.num_data_reads = 0 self.total_data_len = 0 self.max_data_len = 0 + with self.want_measurement: + self.want_measurement.notify() # notify the measurement loop to read immediately def close(self) -> None: """Close the power meter.""" From 628a4cb9be64364590f24983e71ca4814418f560 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Wed, 10 Jul 2024 16:44:56 -0700 Subject: [PATCH 16/30] Always use IDENTICAL timestamps so the power and slog reports can match --- meshtastic/slog/slog.py | 48 ++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/meshtastic/slog/slog.py b/meshtastic/slog/slog.py index 64527633..7b7dd062 100644 --- a/meshtastic/slog/slog.py +++ b/meshtastic/slog/slog.py @@ -82,17 +82,23 @@ def __init__(self, pMeter: PowerMeter, file_path: str, interval=0.2) -> None: ) self.thread.start() + def store_current_reading(self, now: datetime | None = None) -> None: + """Store current power measurement.""" + if now is None: + now = datetime.now() + d = { + "time": now, + "average_mW": self.pMeter.get_average_current_mA(), + "max_mW": self.pMeter.get_max_current_mA(), + "min_mW": self.pMeter.get_min_current_mA(), + } + self.pMeter.reset_measurements() + self.writer.add_row(d) + def _logging_thread(self) -> None: """Background thread for logging the current watts reading.""" while self.is_logging: - d = { - "time": datetime.now(), - "average_mW": self.pMeter.get_average_current_mA(), - "max_mW": self.pMeter.get_max_current_mA(), - "min_mW": self.pMeter.get_min_current_mA(), - } - self.pMeter.reset_measurements() - self.writer.add_row(d) + self.store_current_reading() time.sleep(self.interval) def close(self) -> None: @@ -112,12 +118,19 @@ class StructuredLogger: """Sniffs device logs for structured log messages, extracts those into apache arrow format. Also writes the raw log messages to raw.txt""" - def __init__(self, client: MeshInterface, dir_path: str, include_raw=True) -> None: + def __init__( + self, + client: MeshInterface, + dir_path: str, + power_logger: PowerLogger | None = None, + include_raw=True, + ) -> None: """Initialize the StructuredLogger object. client (MeshInterface): The MeshInterface object to monitor. """ self.client = client + self.power_logger = power_logger # Setup the arrow writer (and its schema) self.writer = FeatherWriter(f"{dir_path}/slog") @@ -129,6 +142,9 @@ def __init__(self, client: MeshInterface, dir_path: str, include_raw=True) -> No if self.include_raw: all_fields.append(("raw", pa.string())) + # Use timestamp as the first column + all_fields.insert(0, ("time", pa.timestamp("us"))) + # pass in our name->type tuples a pa.fields self.writer.set_schema( pa.schema(map(lambda x: pa.field(x[0], x[1]), all_fields)) @@ -198,11 +214,16 @@ def _onLogMessage(self, line: str) -> None: # Store our structured log record if di or self.include_raw: - di["time"] = datetime.now() + now = datetime.now() + di["time"] = now if self.include_raw: di["raw"] = line self.writer.add_row(di) + # If we have a sibling power logger, make sure we have a power measurement with the EXACT same timestamp + if self.power_logger: + self.power_logger.store_current_reading(now) + if self.raw_file: self.raw_file.write(line + "\n") # Write the raw log @@ -239,15 +260,16 @@ def __init__( logging.info(f"Writing slogs to {dir_name}") - self.slog_logger: Optional[StructuredLogger] = StructuredLogger( - client, self.dir_name - ) self.power_logger: Optional[PowerLogger] = ( None if not power_meter else PowerLogger(power_meter, f"{self.dir_name}/power") ) + self.slog_logger: Optional[StructuredLogger] = StructuredLogger( + client, self.dir_name, power_logger=self.power_logger + ) + # Store a lambda so we can find it again to unregister self.atexit_handler = lambda: self.close() # pylint: disable=unnecessary-lambda From 7e007e7e2486e54ec24946eaab507d88dd75f98e Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Thu, 11 Jul 2024 11:48:53 -0700 Subject: [PATCH 17/30] make ArrowWriter thread safe --- meshtastic/slog/arrow.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/meshtastic/slog/arrow.py b/meshtastic/slog/arrow.py index 0a53fe88..4bb2e787 100644 --- a/meshtastic/slog/arrow.py +++ b/meshtastic/slog/arrow.py @@ -1,6 +1,7 @@ """Utilities for Apache Arrow serialization.""" import logging +import threading import os from typing import Optional @@ -22,13 +23,15 @@ def __init__(self, file_name: str): self.new_rows: list[dict] = [] self.schema: Optional[pa.Schema] = None # haven't yet learned the schema self.writer: Optional[pa.RecordBatchStreamWriter] = None + self._lock = threading.Condition() # Ensure only one thread writes at a time def close(self): """Close the stream and writes the file as needed.""" - self._write() - if self.writer: - self.writer.close() - self.sink.close() + with self._lock: + self._write() + if self.writer: + self.writer.close() + self.sink.close() def set_schema(self, schema: pa.Schema): """Set the schema for the file. @@ -36,9 +39,10 @@ def set_schema(self, schema: pa.Schema): schema (pa.Schema): The schema to use. """ - assert self.schema is None - self.schema = schema - self.writer = pa.ipc.new_stream(self.sink, schema) + with self._lock: + assert self.schema is None + self.schema = schema + self.writer = pa.ipc.new_stream(self.sink, schema) def _write(self): """Write the new rows to the file.""" @@ -56,9 +60,10 @@ def add_row(self, row_dict: dict): """Add a row to the arrow file. We will automatically learn the schema from the first row. But all rows must use that schema. """ - self.new_rows.append(row_dict) - if len(self.new_rows) >= chunk_size: - self._write() + with self._lock: + self.new_rows.append(row_dict) + if len(self.new_rows) >= chunk_size: + self._write() class FeatherWriter(ArrowWriter): From 3c76e19c33d793c85501c9eb9609f33f6ce8339e Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Thu, 11 Jul 2024 11:49:12 -0700 Subject: [PATCH 18/30] poll for power readings much more rapidly - traces now look great --- meshtastic/powermon/ppk2.py | 2 +- meshtastic/slog/slog.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/meshtastic/powermon/ppk2.py b/meshtastic/powermon/ppk2.py index 6a706559..a2edb0f6 100644 --- a/meshtastic/powermon/ppk2.py +++ b/meshtastic/powermon/ppk2.py @@ -63,7 +63,7 @@ def measurement_loop(self): """Endless measurement loop will run in a thread.""" while self.measuring: with self.want_measurement: - self.want_measurement.wait(0.0001 if self.num_data_reads == 0 else 0.01) + self.want_measurement.wait(0.0001 if self.num_data_reads == 0 else 0.001) # normally we poll using this timeout, but sometimes # reset_measurement() will notify us to read immediately diff --git a/meshtastic/slog/slog.py b/meshtastic/slog/slog.py index 7b7dd062..449446e3 100644 --- a/meshtastic/slog/slog.py +++ b/meshtastic/slog/slog.py @@ -71,7 +71,7 @@ def __init__(self, code: str, fields: list[tuple[str, pa.DataType]]) -> None: class PowerLogger: """Logs current watts reading periodically using PowerMeter and ArrowWriter.""" - def __init__(self, pMeter: PowerMeter, file_path: str, interval=0.2) -> None: + def __init__(self, pMeter: PowerMeter, file_path: str, interval=0.01) -> None: """Initialize the PowerLogger object.""" self.pMeter = pMeter self.writer = FeatherWriter(file_path) From b464e90368f9b6140128256dd279113461d78062 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Thu, 11 Jul 2024 12:19:16 -0700 Subject: [PATCH 19/30] make ppk2 power meter threadsafe --- meshtastic/powermon/ppk2.py | 48 ++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/meshtastic/powermon/ppk2.py b/meshtastic/powermon/ppk2.py index a2edb0f6..2eda4a4a 100644 --- a/meshtastic/powermon/ppk2.py +++ b/meshtastic/powermon/ppk2.py @@ -46,7 +46,10 @@ def __init__(self, portName: Optional[str] = None): # Normally we just sleep with a timeout on this condition (polling the power measurement data repeatedly) # but any time our measurements have been fully consumed (via reset_measurements) we notify() this condition # to trigger a new reading ASAP. - self.want_measurement = threading.Condition() + self._want_measurement = threading.Condition() + + # To guard against a brief window while updating measured values + self._result_lock = threading.Condition() self.r = r = ppk2_api.PPK2_API( portName @@ -62,8 +65,10 @@ def __init__(self, portName: Optional[str] = None): def measurement_loop(self): """Endless measurement loop will run in a thread.""" while self.measuring: - with self.want_measurement: - self.want_measurement.wait(0.0001 if self.num_data_reads == 0 else 0.001) + with self._want_measurement: + self._want_measurement.wait( + 0.0001 if self.num_data_reads == 0 else 0.001 + ) # normally we poll using this timeout, but sometimes # reset_measurement() will notify us to read immediately @@ -75,17 +80,20 @@ def measurement_loop(self): # update invariants if len(samples) > 0: - if ( - self.current_num_samples == 0 - ): # First set of new reads, reset min/max + if self.current_num_samples == 0: + # First set of new reads, reset min/max self.current_max = 0 - self.current_min = samples[ - 0 - ] # we need at least one sample to get an initial min + self.current_min = samples[0] + # we need at least one sample to get an initial min + + # The following operations could be expensive, so do outside of the lock + # FIXME - change all these lists into numpy arrays to use lots less CPU self.current_max = max(self.current_max, max(samples)) self.current_min = min(self.current_min, min(samples)) - self.current_sum += sum(samples) - self.current_num_samples += len(samples) + latest_sum = sum(samples) + with self._result_lock: + self.current_sum += latest_sum + self.current_num_samples += len(samples) # logging.debug(f"PPK2 data_len={len(read_data)}, sample_len={len(samples)}") self.num_data_reads += 1 @@ -102,13 +110,14 @@ def get_max_current_mA(self): def get_average_current_mA(self): """Return the average current in mA.""" - if self.current_num_samples != 0: - # If we have new samples, calculate a new average - self.current_average = self.current_sum / self.current_num_samples + with self._result_lock: + if self.current_num_samples != 0: + # If we have new samples, calculate a new average + self.current_average = self.current_sum / self.current_num_samples - # Even if we don't have new samples, return the last calculated average - # measurements are in microamperes, divide by 1000 - return self.current_average / 1000 + # Even if we don't have new samples, return the last calculated average + # measurements are in microamperes, divide by 1000 + return self.current_average / 1000 def reset_measurements(self): """Reset current measurements.""" @@ -122,8 +131,9 @@ def reset_measurements(self): self.num_data_reads = 0 self.total_data_len = 0 self.max_data_len = 0 - with self.want_measurement: - self.want_measurement.notify() # notify the measurement loop to read immediately + + with self._want_measurement: + self._want_measurement.notify() # notify the measurement loop to read immediately def close(self) -> None: """Close the power meter.""" From 4dbf9b94e99d73ab704bfdc84bc1a23f2b8408f0 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Thu, 11 Jul 2024 12:27:16 -0700 Subject: [PATCH 20/30] do a new power measurement every 2ms(ish) --- meshtastic/slog/slog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshtastic/slog/slog.py b/meshtastic/slog/slog.py index 449446e3..f7054709 100644 --- a/meshtastic/slog/slog.py +++ b/meshtastic/slog/slog.py @@ -71,7 +71,7 @@ def __init__(self, code: str, fields: list[tuple[str, pa.DataType]]) -> None: class PowerLogger: """Logs current watts reading periodically using PowerMeter and ArrowWriter.""" - def __init__(self, pMeter: PowerMeter, file_path: str, interval=0.01) -> None: + def __init__(self, pMeter: PowerMeter, file_path: str, interval=0.002) -> None: """Initialize the PowerLogger object.""" self.pMeter = pMeter self.writer = FeatherWriter(file_path) From 39e03dbad83e8c2a60e9c9e6cb486750e8ccc8b7 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Thu, 11 Jul 2024 16:39:05 -0700 Subject: [PATCH 21/30] add beginnings of analysis viewer (and fix poetry extras usage for tunnel) --- .vscode/launch.json | 8 ++ meshtastic/analysis/__main__.py | 137 +++++++++++++++++++++++++++++++ poetry.lock | 141 ++++++++++++++++---------------- pyproject.toml | 6 +- 4 files changed, 220 insertions(+), 72 deletions(-) create mode 100644 meshtastic/analysis/__main__.py diff --git a/.vscode/launch.json b/.vscode/launch.json index 5db77373..e034f14c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -36,6 +36,14 @@ "justMyCode": true, "args": ["--tunnel", "--debug"] }, + { + "name": "meshtastic analysis", + "type": "debugpy", + "request": "launch", + "module": "meshtastic.analysis", + "justMyCode": true, + "args": [""] + }, { "name": "meshtastic set chan", "type": "debugpy", diff --git a/meshtastic/analysis/__main__.py b/meshtastic/analysis/__main__.py new file mode 100644 index 00000000..1fe8ef7a --- /dev/null +++ b/meshtastic/analysis/__main__.py @@ -0,0 +1,137 @@ +"""Post-run analysis tools for meshtastic.""" + +import logging +import numpy as np +import pandas as pd +import plotly.express as px +import plotly.graph_objects as go +import pyarrow as pa +import pyarrow.feather as feather +from dash import Dash, Input, Output, callback, dash_table, dcc, html + +from .. import mesh_pb2, powermon_pb2 + +# per https://arrow.apache.org/docs/python/pandas.html#reducing-memory-use-in-table-to-pandas +# use this to get nullable int fields treated as ints rather than floats in pandas +dtype_mapping = { + pa.int8(): pd.Int8Dtype(), + pa.int16(): pd.Int16Dtype(), + pa.int32(): pd.Int32Dtype(), + pa.int64(): pd.Int64Dtype(), + pa.uint8(): pd.UInt8Dtype(), + pa.uint16(): pd.UInt16Dtype(), + pa.uint32(): pd.UInt32Dtype(), + pa.uint64(): pd.UInt64Dtype(), + pa.bool_(): pd.BooleanDtype(), + pa.float32(): pd.Float32Dtype(), + pa.float64(): pd.Float64Dtype(), + pa.string(): pd.StringDtype(), +} + +# sdir = '/home/kevinh/.local/share/meshtastic/slogs/20240626-152804' +sdir = "/home/kevinh/.local/share/meshtastic/slogs/latest" +dpwr = feather.read_table(f"{sdir}/power.feather").to_pandas( + types_mapper=dtype_mapping.get +) +dslog = feather.read_table(f"{sdir}/slog.feather").to_pandas( + types_mapper=dtype_mapping.get +) + + +def get_board_info(): + """Get the board information from the slog dataframe. + + tuple: A tuple containing the board ID and software version. + """ + board_info = dslog[dslog["sw_version"].notnull()] + sw_version = board_info.iloc[0]["sw_version"] + board_id = mesh_pb2.HardwareModel.Name(board_info.iloc[0]["board_id"]) + return (board_id, sw_version) + + +pmon_events = dslog[dslog["pm_mask"].notnull()] + + +pm_masks = pd.Series(pmon_events["pm_mask"]).to_numpy() + +# possible to do this with pandas rolling windows if I was smarter? +pm_changes = [(pm_masks[i - 1] ^ x if i != 0 else x) for i, x in enumerate(pm_masks)] +pm_raises = [(pm_masks[i] & x) for i, x in enumerate(pm_changes)] +pm_falls = [(~pm_masks[i] & x if i != 0 else 0) for i, x in enumerate(pm_changes)] + + +def to_pmon_names(arr) -> list[str]: + """Convert the power monitor state numbers to their corresponding names. + """ + + def to_pmon_name(n): + try: + s = powermon_pb2.PowerMon.State.Name(int(n)) + return s if s != "None" else None + except ValueError: + return None + + return [to_pmon_name(x) for x in arr] + + +pd.options.mode.copy_on_write = True +pmon_events["pm_raises"] = to_pmon_names(pm_raises) +pmon_events["pm_falls"] = to_pmon_names(pm_falls) + +pmon_raises = pmon_events[pmon_events["pm_raises"].notnull()] + + +def create_dash(): + """Create a Dash application for visualizing power consumption data.""" + app = Dash() + + def set_legend(f, name): + f["data"][0]["showlegend"] = True + f["data"][0]["name"] = name + return f + + df = dpwr + avg_pwr_lines = px.line(df, x="time", y="average_mW").update_traces( + line_color="red" + ) + set_legend(avg_pwr_lines, "avg power") + max_pwr_points = px.scatter(df, x="time", y="max_mW").update_traces( + marker_color="blue" + ) + set_legend(max_pwr_points, "max power") + min_pwr_points = px.scatter(df, x="time", y="min_mW").update_traces( + marker_color="green" + ) + set_legend(min_pwr_points, "min power") + + pmon = pmon_raises + fake_y = np.full(len(pmon), 10.0) + pmon_points = px.scatter(pmon, x="time", y=fake_y, text="pm_raises") + + # fig = avg_pwr_lines + # fig.add_trace(max_pwr_points) + # don't show minpower because not that interesting: min_pwr_points.data + fig = go.Figure(data=max_pwr_points.data + avg_pwr_lines.data + pmon_points.data) + + fig.update_layout(legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01)) + + # App layout + app.layout = [ + html.Div(children="Early Meshtastic power analysis tool testing..."), + # dash_table.DataTable(data=df.to_dict('records'), page_size=10), + dcc.Graph(figure=fig), + ] + + return app + + +def main(): + """Entry point of the script.""" + app = create_dash() + port = 8051 + logging.info(f"Running Dash visualization webapp on port {port} (publicly accessible)") + app.run_server(debug=True, host='0.0.0.0', port=port) + + +if __name__ == "__main__": + main() diff --git a/poetry.lock b/poetry.lock index e67c9f68..c49ad98c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -122,13 +122,13 @@ test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock [[package]] name = "astroid" -version = "3.2.2" +version = "3.2.3" description = "An abstract syntax tree for Python with inference support." optional = false python-versions = ">=3.8.0" files = [ - {file = "astroid-3.2.2-py3-none-any.whl", hash = "sha256:e8a0083b4bb28fcffb6207a3bfc9e5d0a68be951dd7e336d5dcf639c682388c0"}, - {file = "astroid-3.2.2.tar.gz", hash = "sha256:8ead48e31b92b2e217b6c9733a21afafe479d52d6e164dd25fb1a770c7c3cf94"}, + {file = "astroid-3.2.3-py3-none-any.whl", hash = "sha256:3eae9ea67c11c858cdd2c91337d2e816bd019ac897ca07d7b346ac10105fceb3"}, + {file = "astroid-3.2.3.tar.gz", hash = "sha256:7099b5a60985529d8d46858befa103b82d0d05a5a5e8b816b5303ed96075e1d9"}, ] [package.dependencies] @@ -315,7 +315,7 @@ files = [ name = "blinker" version = "1.8.2" description = "Fast, simple object-to-object and broadcast signaling" -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "blinker-1.8.2-py3-none-any.whl", hash = "sha256:1779309f71bf239144b9399d06ae925637cf6634cf6bd131104184531bf67c01"}, @@ -603,63 +603,63 @@ test-no-images = ["pytest", "pytest-cov", "pytest-xdist", "wurlitzer"] [[package]] name = "coverage" -version = "7.5.4" +version = "7.6.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cfb5a4f556bb51aba274588200a46e4dd6b505fb1a5f8c5ae408222eb416f99"}, - {file = "coverage-7.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2174e7c23e0a454ffe12267a10732c273243b4f2d50d07544a91198f05c48f47"}, - {file = "coverage-7.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2214ee920787d85db1b6a0bd9da5f8503ccc8fcd5814d90796c2f2493a2f4d2e"}, - {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1137f46adb28e3813dec8c01fefadcb8c614f33576f672962e323b5128d9a68d"}, - {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b385d49609f8e9efc885790a5a0e89f2e3ae042cdf12958b6034cc442de428d3"}, - {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4a474f799456e0eb46d78ab07303286a84a3140e9700b9e154cfebc8f527016"}, - {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5cd64adedf3be66f8ccee418473c2916492d53cbafbfcff851cbec5a8454b136"}, - {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e564c2cf45d2f44a9da56f4e3a26b2236504a496eb4cb0ca7221cd4cc7a9aca9"}, - {file = "coverage-7.5.4-cp310-cp310-win32.whl", hash = "sha256:7076b4b3a5f6d2b5d7f1185fde25b1e54eb66e647a1dfef0e2c2bfaf9b4c88c8"}, - {file = "coverage-7.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:018a12985185038a5b2bcafab04ab833a9a0f2c59995b3cec07e10074c78635f"}, - {file = "coverage-7.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db14f552ac38f10758ad14dd7b983dbab424e731588d300c7db25b6f89e335b5"}, - {file = "coverage-7.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3257fdd8e574805f27bb5342b77bc65578e98cbc004a92232106344053f319ba"}, - {file = "coverage-7.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a6612c99081d8d6134005b1354191e103ec9705d7ba2754e848211ac8cacc6b"}, - {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45d3cbd94159c468b9b8c5a556e3f6b81a8d1af2a92b77320e887c3e7a5d080"}, - {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed550e7442f278af76d9d65af48069f1fb84c9f745ae249c1a183c1e9d1b025c"}, - {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a892be37ca35eb5019ec85402c3371b0f7cda5ab5056023a7f13da0961e60da"}, - {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8192794d120167e2a64721d88dbd688584675e86e15d0569599257566dec9bf0"}, - {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:820bc841faa502e727a48311948e0461132a9c8baa42f6b2b84a29ced24cc078"}, - {file = "coverage-7.5.4-cp311-cp311-win32.whl", hash = "sha256:6aae5cce399a0f065da65c7bb1e8abd5c7a3043da9dceb429ebe1b289bc07806"}, - {file = "coverage-7.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2e344d6adc8ef81c5a233d3a57b3c7d5181f40e79e05e1c143da143ccb6377d"}, - {file = "coverage-7.5.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:54317c2b806354cbb2dc7ac27e2b93f97096912cc16b18289c5d4e44fc663233"}, - {file = "coverage-7.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:042183de01f8b6d531e10c197f7f0315a61e8d805ab29c5f7b51a01d62782747"}, - {file = "coverage-7.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bb74ed465d5fb204b2ec41d79bcd28afccf817de721e8a807d5141c3426638"}, - {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3d45ff86efb129c599a3b287ae2e44c1e281ae0f9a9bad0edc202179bcc3a2e"}, - {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5013ed890dc917cef2c9f765c4c6a8ae9df983cd60dbb635df8ed9f4ebc9f555"}, - {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1014fbf665fef86cdfd6cb5b7371496ce35e4d2a00cda501cf9f5b9e6fced69f"}, - {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3684bc2ff328f935981847082ba4fdc950d58906a40eafa93510d1b54c08a66c"}, - {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:581ea96f92bf71a5ec0974001f900db495488434a6928a2ca7f01eee20c23805"}, - {file = "coverage-7.5.4-cp312-cp312-win32.whl", hash = "sha256:73ca8fbc5bc622e54627314c1a6f1dfdd8db69788f3443e752c215f29fa87a0b"}, - {file = "coverage-7.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:cef4649ec906ea7ea5e9e796e68b987f83fa9a718514fe147f538cfeda76d7a7"}, - {file = "coverage-7.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdd31315fc20868c194130de9ee6bfd99755cc9565edff98ecc12585b90be882"}, - {file = "coverage-7.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:02ff6e898197cc1e9fa375581382b72498eb2e6d5fc0b53f03e496cfee3fac6d"}, - {file = "coverage-7.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d05c16cf4b4c2fc880cb12ba4c9b526e9e5d5bb1d81313d4d732a5b9fe2b9d53"}, - {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5986ee7ea0795a4095ac4d113cbb3448601efca7f158ec7f7087a6c705304e4"}, - {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df54843b88901fdc2f598ac06737f03d71168fd1175728054c8f5a2739ac3e4"}, - {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ab73b35e8d109bffbda9a3e91c64e29fe26e03e49addf5b43d85fc426dde11f9"}, - {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:aea072a941b033813f5e4814541fc265a5c12ed9720daef11ca516aeacd3bd7f"}, - {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16852febd96acd953b0d55fc842ce2dac1710f26729b31c80b940b9afcd9896f"}, - {file = "coverage-7.5.4-cp38-cp38-win32.whl", hash = "sha256:8f894208794b164e6bd4bba61fc98bf6b06be4d390cf2daacfa6eca0a6d2bb4f"}, - {file = "coverage-7.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:e2afe743289273209c992075a5a4913e8d007d569a406ffed0bd080ea02b0633"}, - {file = "coverage-7.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95c3a8cb0463ba9f77383d0fa8c9194cf91f64445a63fc26fb2327e1e1eb088"}, - {file = "coverage-7.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d7564cc09dd91b5a6001754a5b3c6ecc4aba6323baf33a12bd751036c998be4"}, - {file = "coverage-7.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44da56a2589b684813f86d07597fdf8a9c6ce77f58976727329272f5a01f99f7"}, - {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e16f3d6b491c48c5ae726308e6ab1e18ee830b4cdd6913f2d7f77354b33f91c8"}, - {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbc5958cb471e5a5af41b0ddaea96a37e74ed289535e8deca404811f6cb0bc3d"}, - {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a04e990a2a41740b02d6182b498ee9796cf60eefe40cf859b016650147908029"}, - {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ddbd2f9713a79e8e7242d7c51f1929611e991d855f414ca9996c20e44a895f7c"}, - {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b1ccf5e728ccf83acd313c89f07c22d70d6c375a9c6f339233dcf792094bcbf7"}, - {file = "coverage-7.5.4-cp39-cp39-win32.whl", hash = "sha256:56b4eafa21c6c175b3ede004ca12c653a88b6f922494b023aeb1e836df953ace"}, - {file = "coverage-7.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:65e528e2e921ba8fd67d9055e6b9f9e34b21ebd6768ae1c1723f4ea6ace1234d"}, - {file = "coverage-7.5.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:79b356f3dd5b26f3ad23b35c75dbdaf1f9e2450b6bcefc6d0825ea0aa3f86ca5"}, - {file = "coverage-7.5.4.tar.gz", hash = "sha256:a44963520b069e12789d0faea4e9fdb1e410cdc4aab89d94f7f55cbb7fef0353"}, + {file = "coverage-7.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd"}, + {file = "coverage-7.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb"}, + {file = "coverage-7.6.0-cp310-cp310-win32.whl", hash = "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c"}, + {file = "coverage-7.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169"}, + {file = "coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933"}, + {file = "coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63"}, + {file = "coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713"}, + {file = "coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1"}, + {file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"}, + {file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"}, + {file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"}, + {file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"}, + {file = "coverage-7.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b"}, + {file = "coverage-7.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b"}, + {file = "coverage-7.6.0-cp38-cp38-win32.whl", hash = "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428"}, + {file = "coverage-7.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8"}, + {file = "coverage-7.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c"}, + {file = "coverage-7.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd"}, + {file = "coverage-7.6.0-cp39-cp39-win32.whl", hash = "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2"}, + {file = "coverage-7.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca"}, + {file = "coverage-7.6.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6"}, + {file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"}, ] [package.dependencies] @@ -687,7 +687,7 @@ tests = ["pytest", "pytest-cov", "pytest-xdist"] name = "dash" version = "2.17.1" description = "A Python framework for building reactive web-apps. Developed by Plotly." -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "dash-2.17.1-py3-none-any.whl", hash = "sha256:3eefc9ac67003f93a06bc3e500cae0a6787c48e6c81f6f61514239ae2da414e4"}, @@ -720,7 +720,7 @@ testing = ["beautifulsoup4 (>=4.8.2)", "cryptography", "dash-testing-stub (>=0.0 name = "dash-core-components" version = "2.0.0" description = "Core component suite for Dash" -optional = false +optional = true python-versions = "*" files = [ {file = "dash_core_components-2.0.0-py3-none-any.whl", hash = "sha256:52b8e8cce13b18d0802ee3acbc5e888cb1248a04968f962d63d070400af2e346"}, @@ -731,7 +731,7 @@ files = [ name = "dash-html-components" version = "2.0.0" description = "Vanilla HTML components for Dash" -optional = false +optional = true python-versions = "*" files = [ {file = "dash_html_components-2.0.0-py3-none-any.whl", hash = "sha256:b42cc903713c9706af03b3f2548bda4be7307a7cf89b7d6eae3da872717d1b63"}, @@ -742,7 +742,7 @@ files = [ name = "dash-table" version = "5.0.0" description = "Dash table" -optional = false +optional = true python-versions = "*" files = [ {file = "dash_table-5.0.0-py3-none-any.whl", hash = "sha256:19036fa352bb1c11baf38068ec62d172f0515f73ca3276c79dee49b95ddc16c9"}, @@ -911,7 +911,7 @@ devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benc name = "flask" version = "3.0.3" description = "A simple framework for building complex web applications." -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "flask-3.0.3-py3-none-any.whl", hash = "sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3"}, @@ -1311,7 +1311,7 @@ colors = ["colorama (>=0.4.6)"] name = "itsdangerous" version = "2.2.0" description = "Safely pass data to untrusted environments and back." -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, @@ -2428,7 +2428,7 @@ type = ["mypy (>=1.8)"] name = "plotly" version = "5.22.0" description = "An open-source, interactive data visualization library for Python" -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "plotly-5.22.0-py3-none-any.whl", hash = "sha256:68fc1901f098daeb233cc3dd44ec9dc31fb3ca4f4e53189344199c43496ed006"}, @@ -2886,7 +2886,7 @@ cp2110 = ["hidapi"] name = "pytap2" version = "2.3.0" description = "Object-oriented wrapper around the Linux Tun/Tap device" -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "pytap2-2.3.0-py3-none-any.whl", hash = "sha256:a1edc287cf25c61f8fa8415fb6b61e50ac119ef5cd758ce15f2105d2c69f24ef"}, @@ -3206,7 +3206,7 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "retrying" version = "1.3.4" description = "Retrying" -optional = false +optional = true python-versions = "*" files = [ {file = "retrying-1.3.4-py3-none-any.whl", hash = "sha256:8cc4d43cb8e1125e0ff3344e9de678fefd85db3b750b81b2240dc0183af37b35"}, @@ -3481,7 +3481,7 @@ widechars = ["wcwidth"] name = "tenacity" version = "8.5.0" description = "Retry code until it succeeds" -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687"}, @@ -3756,7 +3756,7 @@ test = ["websockets"] name = "werkzeug" version = "3.0.3" description = "The comprehensive WSGI web application library." -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "werkzeug-3.0.3-py3-none-any.whl", hash = "sha256:fc9645dc43e03e4d630d23143a04a7f947a9a3b5727cd535fdfe155a17cc48c8"}, @@ -4014,9 +4014,10 @@ doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linke test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [extras] -tunnel = [] +analysis = ["dash"] +tunnel = ["pytap2"] [metadata] lock-version = "2.0" python-versions = "^3.9,<3.13" -content-hash = "a8752ba6272252f0fbf8de7b6635dbbbfee7587a9851e4a2050ec0e4f080a6b4" +content-hash = "ebe9b93c7e4215f86530f11c1375eff6c955a438310f189cc4e52511dcb29aab" diff --git a/pyproject.toml b/pyproject.toml index 52f3a7f4..6b08d23e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,8 @@ ppk2-api = "^0.9.2" pyarrow = "^16.1.0" platformdirs = "^4.2.2" print-color = "^0.4.6" +dash = { version = "^2.17.1", optional = true } +pytap2 = { version = "^2.3.0", optional = true } [tool.poetry.group.dev.dependencies] hypothesis = "^6.103.2" @@ -35,7 +37,6 @@ pytest-cov = "^5.0.0" pdoc3 = "^0.10.0" autopep8 = "^2.1.0" pylint = "^3.2.3" -pytap2 = "^2.3.0" pyinstaller = "^6.8.0" mypy = "^1.10.0" mypy-protobuf = "^3.6.0" @@ -56,14 +57,15 @@ matplotlib = "^3.9.0" ipympl = "^0.9.4" ipywidgets = "^8.1.3" jupyterlab-widgets = "^3.0.11" -dash = "^2.17.1" [tool.poetry.extras] tunnel = ["pytap2"] +analysis = [ "dash" ] [tool.poetry.scripts] meshtastic = "meshtastic.__main__:main" mesh-tunnel = "meshtastic.__main__:tunnelMain [tunnel]" +mesh-analysis = "meshtastic.analysis.__main__:main [analysis]" # "Poe the poet" (optional) provides an easy way of running non python tools inside the poetry virtualenv # if you would like to use it run "pipx install poe" From 66f83835d9eac189f8674859cd1efa8e82f96e0e Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Thu, 11 Jul 2024 16:56:01 -0700 Subject: [PATCH 22/30] use bootstrap for layout --- meshtastic/analysis/__main__.py | 5 ++++- poetry.lock | 21 +++++++++++++++++++-- pyproject.toml | 7 ++++--- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/meshtastic/analysis/__main__.py b/meshtastic/analysis/__main__.py index 1fe8ef7a..7d080b5a 100644 --- a/meshtastic/analysis/__main__.py +++ b/meshtastic/analysis/__main__.py @@ -8,6 +8,7 @@ import pyarrow as pa import pyarrow.feather as feather from dash import Dash, Input, Output, callback, dash_table, dcc, html +import dash_bootstrap_components as dbc from .. import mesh_pb2, powermon_pb2 @@ -83,7 +84,9 @@ def to_pmon_name(n): def create_dash(): """Create a Dash application for visualizing power consumption data.""" - app = Dash() + app = Dash( + external_stylesheets=[dbc.themes.BOOTSTRAP] + ) def set_legend(f, name): f["data"][0]["showlegend"] = True diff --git a/poetry.lock b/poetry.lock index c49ad98c..4995f5a0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -716,6 +716,23 @@ dev = ["PyYAML (>=5.4.1)", "coloredlogs (>=15.0.1)", "fire (>=0.4.0)"] diskcache = ["diskcache (>=5.2.1)", "multiprocess (>=0.70.12)", "psutil (>=5.8.0)"] testing = ["beautifulsoup4 (>=4.8.2)", "cryptography", "dash-testing-stub (>=0.0.2)", "lxml (>=4.6.2)", "multiprocess (>=0.70.12)", "percy (>=2.0.2)", "psutil (>=5.8.0)", "pytest (>=6.0.2)", "requests[security] (>=2.21.0)", "selenium (>=3.141.0,<=4.2.0)", "waitress (>=1.4.4)"] +[[package]] +name = "dash-bootstrap-components" +version = "1.6.0" +description = "Bootstrap themed components for use in Plotly Dash" +optional = true +python-versions = "<4,>=3.8" +files = [ + {file = "dash_bootstrap_components-1.6.0-py3-none-any.whl", hash = "sha256:97f0f47b38363f18863e1b247462229266ce12e1e171cfb34d3c9898e6e5cd1e"}, + {file = "dash_bootstrap_components-1.6.0.tar.gz", hash = "sha256:960a1ec9397574792f49a8241024fa3cecde0f5930c971a3fc81f016cbeb1095"}, +] + +[package.dependencies] +dash = ">=2.0.0" + +[package.extras] +pandas = ["numpy", "pandas"] + [[package]] name = "dash-core-components" version = "2.0.0" @@ -4014,10 +4031,10 @@ doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linke test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [extras] -analysis = ["dash"] +analysis = ["dash", "dash-bootstrap-components"] tunnel = ["pytap2"] [metadata] lock-version = "2.0" python-versions = "^3.9,<3.13" -content-hash = "ebe9b93c7e4215f86530f11c1375eff6c955a438310f189cc4e52511dcb29aab" +content-hash = "c8a40e0cc2ceeef6297713527b8b878e29b763a3343e0bdfb456995f9303c09e" diff --git a/pyproject.toml b/pyproject.toml index 6b08d23e..05cf219b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = "GPL-3.0-only" readme = "README.md" [tool.poetry.dependencies] -python = "^3.9,<3.13" # 3.9 is needed for pandas, bleak requires a max of 3.13 for some reason +python = "^3.9,<3.13" # 3.9 is needed for pandas, bleak requires a max of 3.13 for some reason pyserial = "^3.5" protobuf = ">=5.26.0" dotmap = "^1.3.30" @@ -29,6 +29,7 @@ platformdirs = "^4.2.2" print-color = "^0.4.6" dash = { version = "^2.17.1", optional = true } pytap2 = { version = "^2.3.0", optional = true } +dash-bootstrap-components = { version = "^1.6.0", optional = true } [tool.poetry.group.dev.dependencies] hypothesis = "^6.103.2" @@ -47,7 +48,7 @@ types-setuptools = "^69.5.0.20240423" types-pyyaml = "^6.0.12.20240311" pyarrow-stubs = "^10.0.1.7" -# If you are doing power analysis you probably want these extra devtools +# If you are doing power analysis you might want these extra devtools [tool.poetry.group.analysis] optional = true @@ -60,7 +61,7 @@ jupyterlab-widgets = "^3.0.11" [tool.poetry.extras] tunnel = ["pytap2"] -analysis = [ "dash" ] +analysis = ["dash", "dash-bootstrap-components"] [tool.poetry.scripts] meshtastic = "meshtastic.__main__:main" From c8eb202c15230d2a4d057b20ea418bb20fa620e7 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Wed, 31 Jul 2024 13:40:29 -0700 Subject: [PATCH 23/30] cleanup and document analysis stuff --- meshtastic/analysis/__main__.py | 122 +++++++++++++++++++------------- 1 file changed, 72 insertions(+), 50 deletions(-) diff --git a/meshtastic/analysis/__main__.py b/meshtastic/analysis/__main__.py index 7d080b5a..b15b8937 100644 --- a/meshtastic/analysis/__main__.py +++ b/meshtastic/analysis/__main__.py @@ -29,91 +29,116 @@ pa.string(): pd.StringDtype(), } -# sdir = '/home/kevinh/.local/share/meshtastic/slogs/20240626-152804' -sdir = "/home/kevinh/.local/share/meshtastic/slogs/latest" -dpwr = feather.read_table(f"{sdir}/power.feather").to_pandas( - types_mapper=dtype_mapping.get -) -dslog = feather.read_table(f"{sdir}/slog.feather").to_pandas( - types_mapper=dtype_mapping.get -) +# Configure panda options +pd.options.mode.copy_on_write = True +def to_pmon_names(arr) -> list[str]: + """Convert the power monitor state numbers to their corresponding names. -def get_board_info(): - """Get the board information from the slog dataframe. + arr (list): List of power monitor state numbers. - tuple: A tuple containing the board ID and software version. + Returns the List of corresponding power monitor state names. """ - board_info = dslog[dslog["sw_version"].notnull()] - sw_version = board_info.iloc[0]["sw_version"] - board_id = mesh_pb2.HardwareModel.Name(board_info.iloc[0]["board_id"]) - return (board_id, sw_version) + def to_pmon_name(n): + try: + s = powermon_pb2.PowerMon.State.Name(int(n)) + return s if s != "None" else None + except ValueError: + return None + return [to_pmon_name(x) for x in arr] -pmon_events = dslog[dslog["pm_mask"].notnull()] +def read_pandas(filepath: str) -> pd.DataFrame: + """Read a feather file and convert it to a pandas DataFrame. + filepath (str): Path to the feather file. -pm_masks = pd.Series(pmon_events["pm_mask"]).to_numpy() + Returns the pandas DataFrame. + """ + return feather.read_table(filepath).to_pandas(types_mapper=dtype_mapping.get) -# possible to do this with pandas rolling windows if I was smarter? -pm_changes = [(pm_masks[i - 1] ^ x if i != 0 else x) for i, x in enumerate(pm_masks)] -pm_raises = [(pm_masks[i] & x) for i, x in enumerate(pm_changes)] -pm_falls = [(~pm_masks[i] & x if i != 0 else 0) for i, x in enumerate(pm_changes)] +def get_pmon_raises(dslog: pd.DataFrame) -> pd.DataFrame: + """Get the power monitor raises from the slog DataFrame. + dslog (pd.DataFrame): The slog DataFrame. -def to_pmon_names(arr) -> list[str]: - """Convert the power monitor state numbers to their corresponding names. + Returns the DataFrame containing the power monitor raises. """ + pmon_events = dslog[dslog["pm_mask"].notnull()] - def to_pmon_name(n): - try: - s = powermon_pb2.PowerMon.State.Name(int(n)) - return s if s != "None" else None - except ValueError: - return None + pm_masks = pd.Series(pmon_events["pm_mask"]).to_numpy() - return [to_pmon_name(x) for x in arr] + # possible to do this with pandas rolling windows if I was smarter? + pm_changes = [(pm_masks[i - 1] ^ x if i != 0 else x) for i, x in enumerate(pm_masks)] + pm_raises = [(pm_masks[i] & x) for i, x in enumerate(pm_changes)] + pm_falls = [(~pm_masks[i] & x if i != 0 else 0) for i, x in enumerate(pm_changes)] + pmon_events["pm_raises"] = to_pmon_names(pm_raises) + pmon_events["pm_falls"] = to_pmon_names(pm_falls) -pd.options.mode.copy_on_write = True -pmon_events["pm_raises"] = to_pmon_names(pm_raises) -pmon_events["pm_falls"] = to_pmon_names(pm_falls) + pmon_raises = pmon_events[pmon_events["pm_raises"].notnull()][["time", "pm_raises"]] + pmon_falls = pmon_events[pmon_events["pm_falls"].notnull()] + + def get_endtime(row): + """Find the corresponding fall event.""" + following = pmon_falls[(pmon_falls["pm_falls"] == row["pm_raises"]) & + (pmon_falls["time"] > row["time"])] + return following.iloc[0] if not following.empty else None + + # HMM - setting end_time doesn't work yet - leave off for now + # pmon_raises['end_time'] = pmon_raises.apply(get_endtime, axis=1) + + return pmon_raises + +def get_board_info(dslog: pd.DataFrame) -> tuple: + """Get the board information from the slog DataFrame. + + dslog (pd.DataFrame): The slog DataFrame. + + Returns a tuple containing the board ID and software version. + """ + board_info = dslog[dslog["sw_version"].notnull()] + sw_version = board_info.iloc[0]["sw_version"] + board_id = mesh_pb2.HardwareModel.Name(board_info.iloc[0]["board_id"]) + return (board_id, sw_version) -pmon_raises = pmon_events[pmon_events["pm_raises"].notnull()] +def create_dash(slog_path: str) -> Dash: + """Create a Dash application for visualizing power consumption data. + slog_path (str): Path to the slog directory. -def create_dash(): - """Create a Dash application for visualizing power consumption data.""" + Returns the Dash application. + """ app = Dash( external_stylesheets=[dbc.themes.BOOTSTRAP] ) + dpwr = read_pandas(f"{slog_path}/power.feather") + dslog = read_pandas(f"{slog_path}/slog.feather") + + pmon_raises = get_pmon_raises(dslog) + def set_legend(f, name): f["data"][0]["showlegend"] = True f["data"][0]["name"] = name return f - df = dpwr - avg_pwr_lines = px.line(df, x="time", y="average_mW").update_traces( + avg_pwr_lines = px.line(dpwr, x="time", y="average_mW").update_traces( line_color="red" ) set_legend(avg_pwr_lines, "avg power") - max_pwr_points = px.scatter(df, x="time", y="max_mW").update_traces( + max_pwr_points = px.scatter(dpwr, x="time", y="max_mW").update_traces( marker_color="blue" ) set_legend(max_pwr_points, "max power") - min_pwr_points = px.scatter(df, x="time", y="min_mW").update_traces( + min_pwr_points = px.scatter(dpwr, x="time", y="min_mW").update_traces( marker_color="green" ) set_legend(min_pwr_points, "min power") - pmon = pmon_raises - fake_y = np.full(len(pmon), 10.0) - pmon_points = px.scatter(pmon, x="time", y=fake_y, text="pm_raises") + fake_y = np.full(len(pmon_raises), 10.0) + pmon_points = px.scatter(pmon_raises, x="time", y=fake_y, text="pm_raises") - # fig = avg_pwr_lines - # fig.add_trace(max_pwr_points) - # don't show minpower because not that interesting: min_pwr_points.data fig = go.Figure(data=max_pwr_points.data + avg_pwr_lines.data + pmon_points.data) fig.update_layout(legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01)) @@ -121,20 +146,17 @@ def set_legend(f, name): # App layout app.layout = [ html.Div(children="Early Meshtastic power analysis tool testing..."), - # dash_table.DataTable(data=df.to_dict('records'), page_size=10), dcc.Graph(figure=fig), ] return app - def main(): """Entry point of the script.""" - app = create_dash() + app = create_dash(slog_path="/home/kevinh/.local/share/meshtastic/slogs/latest") port = 8051 logging.info(f"Running Dash visualization webapp on port {port} (publicly accessible)") app.run_server(debug=True, host='0.0.0.0', port=port) - if __name__ == "__main__": main() From a4715171e47035cca01d9aacc69bb51dfe4c1d99 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Wed, 31 Jul 2024 14:41:47 -0700 Subject: [PATCH 24/30] Add basic arg parsing to the meshtastic analysis stuff --- .vscode/launch.json | 4 +- meshtastic/analysis/__main__.py | 88 ++++++++++++++++++++++----------- meshtastic/slog/__init__.py | 2 +- meshtastic/slog/slog.py | 23 ++++++--- 4 files changed, 78 insertions(+), 39 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index e034f14c..c1790600 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -41,8 +41,8 @@ "type": "debugpy", "request": "launch", "module": "meshtastic.analysis", - "justMyCode": true, - "args": [""] + "justMyCode": false, + "args": [] }, { "name": "meshtastic set chan", diff --git a/meshtastic/analysis/__main__.py b/meshtastic/analysis/__main__.py index b15b8937..2fcc7bf0 100644 --- a/meshtastic/analysis/__main__.py +++ b/meshtastic/analysis/__main__.py @@ -1,6 +1,9 @@ """Post-run analysis tools for meshtastic.""" +import argparse import logging + +import dash_bootstrap_components as dbc import numpy as np import pandas as pd import plotly.express as px @@ -8,30 +11,14 @@ import pyarrow as pa import pyarrow.feather as feather from dash import Dash, Input, Output, callback, dash_table, dcc, html -import dash_bootstrap_components as dbc from .. import mesh_pb2, powermon_pb2 - -# per https://arrow.apache.org/docs/python/pandas.html#reducing-memory-use-in-table-to-pandas -# use this to get nullable int fields treated as ints rather than floats in pandas -dtype_mapping = { - pa.int8(): pd.Int8Dtype(), - pa.int16(): pd.Int16Dtype(), - pa.int32(): pd.Int32Dtype(), - pa.int64(): pd.Int64Dtype(), - pa.uint8(): pd.UInt8Dtype(), - pa.uint16(): pd.UInt16Dtype(), - pa.uint32(): pd.UInt32Dtype(), - pa.uint64(): pd.UInt64Dtype(), - pa.bool_(): pd.BooleanDtype(), - pa.float32(): pd.Float32Dtype(), - pa.float64(): pd.Float64Dtype(), - pa.string(): pd.StringDtype(), -} +from ..slog import root_dir # Configure panda options pd.options.mode.copy_on_write = True + def to_pmon_names(arr) -> list[str]: """Convert the power monitor state numbers to their corresponding names. @@ -39,6 +26,7 @@ def to_pmon_names(arr) -> list[str]: Returns the List of corresponding power monitor state names. """ + def to_pmon_name(n): try: s = powermon_pb2.PowerMon.State.Name(int(n)) @@ -48,6 +36,7 @@ def to_pmon_name(n): return [to_pmon_name(x) for x in arr] + def read_pandas(filepath: str) -> pd.DataFrame: """Read a feather file and convert it to a pandas DataFrame. @@ -55,8 +44,25 @@ def read_pandas(filepath: str) -> pd.DataFrame: Returns the pandas DataFrame. """ + # per https://arrow.apache.org/docs/python/pandas.html#reducing-memory-use-in-table-to-pandas + # use this to get nullable int fields treated as ints rather than floats in pandas + dtype_mapping = { + pa.int8(): pd.Int8Dtype(), + pa.int16(): pd.Int16Dtype(), + pa.int32(): pd.Int32Dtype(), + pa.int64(): pd.Int64Dtype(), + pa.uint8(): pd.UInt8Dtype(), + pa.uint16(): pd.UInt16Dtype(), + pa.uint32(): pd.UInt32Dtype(), + pa.uint64(): pd.UInt64Dtype(), + pa.bool_(): pd.BooleanDtype(), + pa.float32(): pd.Float32Dtype(), + pa.float64(): pd.Float64Dtype(), + pa.string(): pd.StringDtype(), + } return feather.read_table(filepath).to_pandas(types_mapper=dtype_mapping.get) + def get_pmon_raises(dslog: pd.DataFrame) -> pd.DataFrame: """Get the power monitor raises from the slog DataFrame. @@ -69,7 +75,9 @@ def get_pmon_raises(dslog: pd.DataFrame) -> pd.DataFrame: pm_masks = pd.Series(pmon_events["pm_mask"]).to_numpy() # possible to do this with pandas rolling windows if I was smarter? - pm_changes = [(pm_masks[i - 1] ^ x if i != 0 else x) for i, x in enumerate(pm_masks)] + pm_changes = [ + (pm_masks[i - 1] ^ x if i != 0 else x) for i, x in enumerate(pm_masks) + ] pm_raises = [(pm_masks[i] & x) for i, x in enumerate(pm_changes)] pm_falls = [(~pm_masks[i] & x if i != 0 else 0) for i, x in enumerate(pm_changes)] @@ -81,8 +89,10 @@ def get_pmon_raises(dslog: pd.DataFrame) -> pd.DataFrame: def get_endtime(row): """Find the corresponding fall event.""" - following = pmon_falls[(pmon_falls["pm_falls"] == row["pm_raises"]) & - (pmon_falls["time"] > row["time"])] + following = pmon_falls[ + (pmon_falls["pm_falls"] == row["pm_raises"]) + & (pmon_falls["time"] > row["time"]) + ] return following.iloc[0] if not following.empty else None # HMM - setting end_time doesn't work yet - leave off for now @@ -90,6 +100,7 @@ def get_endtime(row): return pmon_raises + def get_board_info(dslog: pd.DataFrame) -> tuple: """Get the board information from the slog DataFrame. @@ -102,6 +113,18 @@ def get_board_info(dslog: pd.DataFrame) -> tuple: board_id = mesh_pb2.HardwareModel.Name(board_info.iloc[0]["board_id"]) return (board_id, sw_version) + +def create_argparser() -> argparse.ArgumentParser: + """Create the argument parser for the script.""" + parser = argparse.ArgumentParser(description="Meshtastic power analysis tools") + group = parser + group.add_argument( + "--slog", + help="Specify the structured-logs directory (defaults to latest log directory)", + ) + return parser + + def create_dash(slog_path: str) -> Dash: """Create a Dash application for visualizing power consumption data. @@ -109,12 +132,15 @@ def create_dash(slog_path: str) -> Dash: Returns the Dash application. """ - app = Dash( - external_stylesheets=[dbc.themes.BOOTSTRAP] - ) + app = Dash(external_stylesheets=[dbc.themes.BOOTSTRAP]) - dpwr = read_pandas(f"{slog_path}/power.feather") - dslog = read_pandas(f"{slog_path}/slog.feather") + parser = create_argparser() + args = parser.parse_args() + if not args.slog: + args.slog = f"{root_dir()}/latest" + + dpwr = read_pandas(f"{args.slog}/power.feather") + dslog = read_pandas(f"{args.slog}/slog.feather") pmon_raises = get_pmon_raises(dslog) @@ -145,18 +171,22 @@ def set_legend(f, name): # App layout app.layout = [ - html.Div(children="Early Meshtastic power analysis tool testing..."), + html.Div(children="Meshtastic power analysis tool testing..."), dcc.Graph(figure=fig), ] return app + def main(): """Entry point of the script.""" app = create_dash(slog_path="/home/kevinh/.local/share/meshtastic/slogs/latest") port = 8051 - logging.info(f"Running Dash visualization webapp on port {port} (publicly accessible)") - app.run_server(debug=True, host='0.0.0.0', port=port) + logging.info( + f"Running Dash visualization webapp on port {port} (publicly accessible)" + ) + app.run_server(debug=True, host="0.0.0.0", port=port) + if __name__ == "__main__": main() diff --git a/meshtastic/slog/__init__.py b/meshtastic/slog/__init__.py index acd5d211..5216dedd 100644 --- a/meshtastic/slog/__init__.py +++ b/meshtastic/slog/__init__.py @@ -1,3 +1,3 @@ """Structured logging framework (see dev docs for more info).""" -from .slog import LogSet +from .slog import LogSet, root_dir diff --git a/meshtastic/slog/slog.py b/meshtastic/slog/slog.py index f7054709..326bac67 100644 --- a/meshtastic/slog/slog.py +++ b/meshtastic/slog/slog.py @@ -23,6 +23,17 @@ from .arrow import FeatherWriter +def root_dir() -> str: + """Return the root directory for slog files.""" + + app_name = "meshtastic" + app_author = "meshtastic" + app_dir = platformdirs.user_data_dir(app_name, app_author) + dir_name = f"{app_dir}/slogs" + os.makedirs(dir_name, exist_ok=True) + return dir_name + + @dataclass(init=False) class LogDef: """Log definition.""" @@ -244,17 +255,15 @@ def __init__( """ if not dir_name: - app_name = "meshtastic" - app_author = "meshtastic" - app_dir = platformdirs.user_data_dir(app_name, app_author) - dir_name = f"{app_dir}/slogs/{datetime.now().strftime('%Y%m%d-%H%M%S')}" + app_dir = root_dir() + dir_name = f"{app_dir}/{datetime.now().strftime('%Y%m%d-%H%M%S')}" os.makedirs(dir_name, exist_ok=True) # Also make a 'latest' directory that always points to the most recent logs # symlink might fail on some platforms, if it does fail silently - if os.path.exists(f"{app_dir}/slogs/latest"): - os.unlink(f"{app_dir}/slogs/latest") - os.symlink(dir_name, f"{app_dir}/slogs/latest", target_is_directory=True) + if os.path.exists(f"{app_dir}/latest"): + os.unlink(f"{app_dir}/latest") + os.symlink(dir_name, f"{app_dir}/latest", target_is_directory=True) self.dir_name = dir_name From 4906f79be5a737ee22d4bf20a15075489d4daa56 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Wed, 31 Jul 2024 15:19:16 -0700 Subject: [PATCH 25/30] fix linter warnings (and alas: reformat __main__.py) main.py's only real change is log_set: Optional[LogSet] = None # type: ignore[annotation-unchecked] Everything else is the automated reformatting to match our trunk formatting rules. --- meshtastic/__main__.py | 188 ++++++++++++++++++++++---------- meshtastic/analysis/__main__.py | 41 ++++--- 2 files changed, 155 insertions(+), 74 deletions(-) diff --git a/meshtastic/__main__.py b/meshtastic/__main__.py index d9e3517c..b7b86a26 100644 --- a/meshtastic/__main__.py +++ b/meshtastic/__main__.py @@ -13,24 +13,30 @@ import time from typing import Optional -import pyqrcode # type: ignore[import-untyped] +import pyqrcode # type: ignore[import-untyped] import yaml from google.protobuf.json_format import MessageToDict -from pubsub import pub # type: ignore[import-untyped] +from pubsub import pub # type: ignore[import-untyped] import meshtastic.test import meshtastic.util -from meshtastic import mt_config -from meshtastic.protobuf import channel_pb2, config_pb2, portnums_pb2 -from meshtastic import remote_hardware, BROADCAST_ADDR -from meshtastic.version import get_active_version +from meshtastic import BROADCAST_ADDR, mt_config, remote_hardware from meshtastic.ble_interface import BLEInterface from meshtastic.mesh_interface import MeshInterface -from meshtastic.powermon import RidenPowerSupply, PPK2PowerSupply, SimPowerSupply, PowerStress, PowerMeter +from meshtastic.powermon import ( + PowerMeter, + PowerStress, + PPK2PowerSupply, + RidenPowerSupply, + SimPowerSupply, +) +from meshtastic.protobuf import channel_pb2, config_pb2, portnums_pb2 from meshtastic.slog import LogSet +from meshtastic.version import get_active_version meter: Optional[PowerMeter] = None + def onReceive(packet, interface): """Callback invoked when a packet arrives""" args = mt_config.args @@ -66,11 +72,13 @@ def onConnection(interface, topic=pub.AUTO_TOPIC): # pylint: disable=W0613 """Callback invoked when we connect/disconnect from a radio""" print(f"Connection changed: {topic.getName()}") + def checkChannel(interface: MeshInterface, channelIndex: int) -> bool: """Given an interface and channel index, return True if that channel is non-disabled on the local node""" ch = interface.localNode.getChannelByChannelIndex(channelIndex) logging.debug(f"ch:{ch}") - return (ch and ch.role != channel_pb2.Channel.Role.DISABLED) + return ch and ch.role != channel_pb2.Channel.Role.DISABLED + def getPref(node, comp_name): """Get a channel or preferences value""" @@ -146,6 +154,7 @@ def splitCompoundName(comp_name): name.append(comp_name) return name + def traverseConfig(config_root, config, interface_config): """Iterate through current config level preferences and either traverse deeper if preference is a dict or set preference""" snake_name = meshtastic.util.camel_to_snake(config_root) @@ -154,14 +163,11 @@ def traverseConfig(config_root, config, interface_config): if isinstance(config[pref], dict): traverseConfig(pref_name, config[pref], interface_config) else: - setPref( - interface_config, - pref_name, - str(config[pref]) - ) + setPref(interface_config, pref_name, str(config[pref])) return True + def setPref(config, comp_name, valStr) -> bool: """Set a channel or preferences value""" @@ -275,7 +281,9 @@ def onConnected(interface): interface.localNode.removeFixedPosition() elif args.setlat or args.setlon or args.setalt: if args.dest != BROADCAST_ADDR: - print("Setting latitude, longitude, and altitude of remote nodes is not supported.") + print( + "Setting latitude, longitude, and altitude of remote nodes is not supported." + ) return closeNow = True @@ -303,10 +311,17 @@ def onConnected(interface): interface.localNode.setFixedPosition(lat, lon, alt) elif not args.no_time: # We normally provide a current time to the mesh when we connect - if interface.localNode.nodeNum in interface.nodesByNum and "position" in interface.nodesByNum[interface.localNode.nodeNum]: + if ( + interface.localNode.nodeNum in interface.nodesByNum + and "position" in interface.nodesByNum[interface.localNode.nodeNum] + ): # send the same position the node already knows, just to update time position = interface.nodesByNum[interface.localNode.nodeNum]["position"] - interface.sendPosition(position.get("latitude", 0.0), position.get("longitude", 0.0), position.get("altitude", 0.0)) + interface.sendPosition( + position.get("latitude", 0.0), + position.get("longitude", 0.0), + position.get("altitude", 0.0), + ) else: interface.sendPosition() @@ -454,7 +469,9 @@ def onConnected(interface): dest = str(args.traceroute) channelIndex = mt_config.channel_index or 0 if checkChannel(interface, channelIndex): - print(f"Sending traceroute request to {dest} on channelIndex:{channelIndex} (this could take a while)") + print( + f"Sending traceroute request to {dest} on channelIndex:{channelIndex} (this could take a while)" + ) interface.sendTraceRoute(dest, hopLimit, channelIndex=channelIndex) if args.request_telemetry: @@ -463,8 +480,14 @@ def onConnected(interface): else: channelIndex = mt_config.channel_index or 0 if checkChannel(interface, channelIndex): - print(f"Sending telemetry request to {args.dest} on channelIndex:{channelIndex} (this could take a while)") - interface.sendTelemetry(destinationId=args.dest, wantResponse=True, channelIndex=channelIndex) + print( + f"Sending telemetry request to {args.dest} on channelIndex:{channelIndex} (this could take a while)" + ) + interface.sendTelemetry( + destinationId=args.dest, + wantResponse=True, + channelIndex=channelIndex, + ) if args.request_position: if args.dest == BROADCAST_ADDR: @@ -472,8 +495,14 @@ def onConnected(interface): else: channelIndex = mt_config.channel_index or 0 if checkChannel(interface, channelIndex): - print(f"Sending position request to {args.dest} on channelIndex:{channelIndex} (this could take a while)") - interface.sendPosition(destinationId=args.dest, wantResponse=True, channelIndex=channelIndex) + print( + f"Sending position request to {args.dest} on channelIndex:{channelIndex} (this could take a while)" + ) + interface.sendPosition( + destinationId=args.dest, + wantResponse=True, + channelIndex=channelIndex, + ) if args.gpio_wrb or args.gpio_rd or args.gpio_watch: if args.dest == BROADCAST_ADDR: @@ -615,7 +644,9 @@ def onConnected(interface): if "config" in configuration: localConfig = interface.getNode(args.dest).localConfig for section in configuration["config"]: - traverseConfig(section, configuration["config"][section], localConfig) + traverseConfig( + section, configuration["config"][section], localConfig + ) interface.getNode(args.dest).writeConfig( meshtastic.util.camel_to_snake(section) ) @@ -623,7 +654,11 @@ def onConnected(interface): if "module_config" in configuration: moduleConfig = interface.getNode(args.dest).moduleConfig for section in configuration["module_config"]: - traverseConfig(section, configuration["module_config"][section], moduleConfig) + traverseConfig( + section, + configuration["module_config"][section], + moduleConfig, + ) interface.getNode(args.dest).writeConfig( meshtastic.util.camel_to_snake(section) ) @@ -676,7 +711,9 @@ def onConnected(interface): print(f"Writing modified channels to device") n.writeChannel(ch.index) if channelIndex is None: - print(f"Setting newly-added channel's {ch.index} as '--ch-index' for further modifications") + print( + f"Setting newly-added channel's {ch.index} as '--ch-index' for further modifications" + ) mt_config.channel_index = ch.index if args.ch_del: @@ -762,7 +799,7 @@ def setSimpleConfig(modem_preset): else: found = setPref(ch.settings, pref[0], pref[1]) if not found: - category_settings = ['module_settings'] + category_settings = ["module_settings"] print( f"{ch.settings.__class__.__name__} does not have an attribute {pref[0]}." ) @@ -772,7 +809,9 @@ def setSimpleConfig(modem_preset): print(f"{field.name}") else: print(f"{field.name}:") - config = ch.settings.DESCRIPTOR.fields_by_name.get(field.name) + config = ch.settings.DESCRIPTOR.fields_by_name.get( + field.name + ) names = [] for sub_field in config.message_type.fields: tmp_name = f"{field.name}.{sub_field.name}" @@ -852,16 +891,20 @@ def setSimpleConfig(modem_preset): qr = pyqrcode.create(url) print(qr.terminal()) - log_set: Optional[LogSet] = None # we need to keep a reference to the logset so it doesn't get GCed early + log_set: Optional[LogSet] = None # type: ignore[annotation-unchecked] + # we need to keep a reference to the logset so it doesn't get GCed early + if args.slog or args.power_stress: # Setup loggers global meter # pylint: disable=global-variable-not-assigned - log_set = LogSet(interface, args.slog if args.slog != 'default' else None, meter) + log_set = LogSet( + interface, args.slog if args.slog != "default" else None, meter + ) if args.power_stress: stress = PowerStress(interface) stress.run() - closeNow = True # exit immediately after stress test + closeNow = True # exit immediately after stress test if args.listen: closeNow = False @@ -891,7 +934,7 @@ def setSimpleConfig(modem_preset): interface.getNode(args.dest, False).iface.waitForAckNak() if args.wait_to_disconnect: - print(f"Waiting {args.wait_to_disconnect} seconds before disconnecting" ) + print(f"Waiting {args.wait_to_disconnect} seconds before disconnecting") time.sleep(int(args.wait_to_disconnect)) # if the user didn't ask for serial debugging output, we might want to exit after we've done our operation @@ -1004,6 +1047,7 @@ def export_config(interface): print(config) return config + def create_power_meter(): """Setup the power meter.""" @@ -1038,6 +1082,7 @@ def create_power_meter(): logging.info("Powered-on, waiting for device to boot") time.sleep(5) + def common(): """Shared code for all of our command line wrappers.""" logfile = None @@ -1104,20 +1149,29 @@ def common(): print(f"Found: name='{x.name}' address='{x.address}'") meshtastic.util.our_exit("BLE scan finished", 0) elif args.ble: - client = BLEInterface(args.ble if args.ble != "any" else None, debugOut=logfile, noProto=args.noproto, noNodes=args.no_nodes) + client = BLEInterface( + args.ble if args.ble != "any" else None, + debugOut=logfile, + noProto=args.noproto, + noNodes=args.no_nodes, + ) elif args.host: try: client = meshtastic.tcp_interface.TCPInterface( - args.host, debugOut=logfile, noProto=args.noproto, noNodes=args.no_nodes + args.host, + debugOut=logfile, + noProto=args.noproto, + noNodes=args.no_nodes, ) except Exception as ex: - meshtastic.util.our_exit( - f"Error connecting to {args.host}:{ex}", 1 - ) + meshtastic.util.our_exit(f"Error connecting to {args.host}:{ex}", 1) else: try: client = meshtastic.serial_interface.SerialInterface( - args.port, debugOut=logfile, noProto=args.noproto, noNodes=args.no_nodes + args.port, + debugOut=logfile, + noProto=args.noproto, + noNodes=args.no_nodes, ) except PermissionError as ex: username = os.getlogin() @@ -1132,7 +1186,10 @@ def common(): if client.devPath is None: try: client = meshtastic.tcp_interface.TCPInterface( - "localhost", debugOut=logfile, noProto=args.noproto, noNodes=args.no_nodes + "localhost", + debugOut=logfile, + noProto=args.noproto, + noNodes=args.no_nodes, ) except Exception as ex: meshtastic.util.our_exit( @@ -1144,7 +1201,10 @@ def common(): have_tunnel = platform.system() == "Linux" if ( - args.noproto or args.reply or (have_tunnel and args.tunnel) or args.listen + args.noproto + or args.reply + or (have_tunnel and args.tunnel) + or args.listen ): # loop until someone presses ctrlc try: while True: @@ -1155,13 +1215,19 @@ def common(): # don't call exit, background threads might be running still # sys.exit(0) + def addConnectionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: """Add connection specifiation arguments""" - outer = parser.add_argument_group('Connection', 'Optional arguments that specify how to connect to a Meshtastic device.') + outer = parser.add_argument_group( + "Connection", + "Optional arguments that specify how to connect to a Meshtastic device.", + ) group = outer.add_mutually_exclusive_group() group.add_argument( - "--port", "--serial", "-s", + "--port", + "--serial", + "-s", help="The port of the device to connect to using serial, e.g. /dev/ttyUSB0. (defaults to trying to detect a port)", nargs="?", const=None, @@ -1169,19 +1235,22 @@ def addConnectionArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentParse ) group.add_argument( - "--host", "--tcp", "-t", + "--host", + "--tcp", + "-t", help="Connect to a device using TCP, optionally passing hostname or IP address to use. (defaults to '%(const)s')", nargs="?", default=None, - const="localhost" + const="localhost", ) group.add_argument( - "--ble", "-b", + "--ble", + "-b", help="Connect to a BLE device, optionally specifying a device name (defaults to '%(const)s')", nargs="?", default=None, - const="any" + const="any", ) return parser @@ -1193,9 +1262,11 @@ def initParser(): args = mt_config.args # The "Help" group includes the help option and other informational stuff about the CLI itself - outerHelpGroup = parser.add_argument_group('Help') + outerHelpGroup = parser.add_argument_group("Help") helpGroup = outerHelpGroup.add_mutually_exclusive_group() - helpGroup.add_argument("-h", "--help", action="help", help="show this help message and exit") + helpGroup.add_argument( + "-h", "--help", action="help", help="show this help message and exit" + ) the_version = get_active_version() helpGroup.add_argument("--version", action="version", version=f"{the_version}") @@ -1232,9 +1303,9 @@ def initParser(): group.add_argument( "--seriallog", help="Log device serial output to either 'none' or a filename to append to. Defaults to 'stdout' if no filename specified.", - nargs='?', + nargs="?", const="stdout", - default=None + default=None, ) group.add_argument( @@ -1490,7 +1561,7 @@ def initParser(): group.add_argument( "--remove-node", - help="Tell the destination node to remove a specific node from its DB, by node number or ID" + help="Tell the destination node to remove a specific node from its DB, by node number or ID", ) group.add_argument( "--reset-nodedb", @@ -1555,7 +1626,9 @@ def initParser(): action="store_true", ) - power_group = parser.add_argument_group('Power Testing', 'Options for power testing/logging.') + power_group = parser.add_argument_group( + "Power Testing", "Options for power testing/logging." + ) power_supply_group = power_group.add_mutually_exclusive_group() @@ -1604,7 +1677,7 @@ def initParser(): help="Store structured-logs (slogs) for this run, optionally you can specifiy a destination directory", nargs="?", default=None, - const="default" + const="default", ) group.add_argument( @@ -1633,7 +1706,9 @@ def initParser(): action="store_true", ) - remoteHardwareArgs = parser.add_argument_group('Remote Hardware', 'Arguments related to the Remote Hardware module') + remoteHardwareArgs = parser.add_argument_group( + "Remote Hardware", "Arguments related to the Remote Hardware module" + ) remoteHardwareArgs.add_argument( "--gpio-wrb", nargs=2, help="Set a particular GPIO # to 1 or 0", action="append" @@ -1647,10 +1722,11 @@ def initParser(): "--gpio-watch", help="Start watching a GPIO mask for changes (ex: '0x10')" ) - have_tunnel = platform.system() == "Linux" if have_tunnel: - tunnelArgs = parser.add_argument_group('Tunnel', 'Arguments related to establishing a tunnel device over the mesh.') + tunnelArgs = parser.add_argument_group( + "Tunnel", "Arguments related to establishing a tunnel device over the mesh." + ) tunnelArgs.add_argument( "--tunnel", action="store_true", @@ -1665,7 +1741,6 @@ def initParser(): parser.set_defaults(deprecated=None) - args = parser.parse_args() mt_config.args = args mt_config.parser = parser @@ -1676,7 +1751,8 @@ def main(): parser = argparse.ArgumentParser( add_help=False, epilog="If no connection arguments are specified, we search for a compatible serial device, " - "and if none is found, then attempt a TCP connection to localhost.") + "and if none is found, then attempt a TCP connection to localhost.", + ) mt_config.parser = parser initParser() common() diff --git a/meshtastic/analysis/__main__.py b/meshtastic/analysis/__main__.py index 2fcc7bf0..993f04b0 100644 --- a/meshtastic/analysis/__main__.py +++ b/meshtastic/analysis/__main__.py @@ -2,15 +2,16 @@ import argparse import logging +from typing import cast -import dash_bootstrap_components as dbc +import dash_bootstrap_components as dbc # type: ignore[import-untyped] import numpy as np import pandas as pd -import plotly.express as px -import plotly.graph_objects as go +import plotly.express as px # type: ignore[import-untyped] +import plotly.graph_objects as go # type: ignore[import-untyped] import pyarrow as pa -import pyarrow.feather as feather -from dash import Dash, Input, Output, callback, dash_table, dcc, html +from dash import Dash, dcc, html # type: ignore[import-untyped] +from pyarrow import feather from .. import mesh_pb2, powermon_pb2 from ..slog import root_dir @@ -60,7 +61,8 @@ def read_pandas(filepath: str) -> pd.DataFrame: pa.float64(): pd.Float64Dtype(), pa.string(): pd.StringDtype(), } - return feather.read_table(filepath).to_pandas(types_mapper=dtype_mapping.get) + + return cast(pd.DataFrame, feather.read_table(filepath).to_pandas(types_mapper=dtype_mapping.get)) # type: ignore[arg-type] def get_pmon_raises(dslog: pd.DataFrame) -> pd.DataFrame: @@ -87,6 +89,7 @@ def get_pmon_raises(dslog: pd.DataFrame) -> pd.DataFrame: pmon_raises = pmon_events[pmon_events["pm_raises"].notnull()][["time", "pm_raises"]] pmon_falls = pmon_events[pmon_events["pm_falls"].notnull()] + # pylint: disable=unused-variable def get_endtime(row): """Find the corresponding fall event.""" following = pmon_falls[ @@ -134,13 +137,8 @@ def create_dash(slog_path: str) -> Dash: """ app = Dash(external_stylesheets=[dbc.themes.BOOTSTRAP]) - parser = create_argparser() - args = parser.parse_args() - if not args.slog: - args.slog = f"{root_dir()}/latest" - - dpwr = read_pandas(f"{args.slog}/power.feather") - dslog = read_pandas(f"{args.slog}/slog.feather") + dpwr = read_pandas(f"{slog_path}/power.feather") + dslog = read_pandas(f"{slog_path}/slog.feather") pmon_raises = get_pmon_raises(dslog) @@ -167,7 +165,9 @@ def set_legend(f, name): fig = go.Figure(data=max_pwr_points.data + avg_pwr_lines.data + pmon_points.data) - fig.update_layout(legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01)) + fig.update_layout( + legend={"yanchor": "top", "y": 0.99, "xanchor": "left", "x": 0.01} + ) # App layout app.layout = [ @@ -180,11 +180,16 @@ def set_legend(f, name): def main(): """Entry point of the script.""" - app = create_dash(slog_path="/home/kevinh/.local/share/meshtastic/slogs/latest") + + parser = create_argparser() + args = parser.parse_args() + if not args.slog: + args.slog = f"{root_dir()}/latest" + + app = create_dash(slog_path=args.slog) port = 8051 - logging.info( - f"Running Dash visualization webapp on port {port} (publicly accessible)" - ) + logging.info(f"Running Dash visualization of {args.slog} (publicly accessible)") + app.run_server(debug=True, host="0.0.0.0", port=port) From bf71e09091f557ea93cfa27187ebf49ac1287c95 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Wed, 31 Jul 2024 15:46:37 -0700 Subject: [PATCH 26/30] get test coverage on powermon and slog stuff --- meshtastic/tests/test_mesh_interface.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/meshtastic/tests/test_mesh_interface.py b/meshtastic/tests/test_mesh_interface.py index 2f8dc831..f637c62d 100644 --- a/meshtastic/tests/test_mesh_interface.py +++ b/meshtastic/tests/test_mesh_interface.py @@ -11,6 +11,8 @@ from .. import BROADCAST_ADDR, LOCAL_ADDR from ..mesh_interface import MeshInterface, _timeago from ..node import Node +from ..slog import LogSet +from ..powermon import SimPowerSupply # TODO # from ..config import Config @@ -47,11 +49,15 @@ def test_MeshInterface(capsys): iface.localNode.localConfig.lora.CopyFrom(config_pb2.Config.LoRaConfig()) + # Also get some coverage of the structured logging/power meter stuff by turning it on as well + log_set = LogSet(iface, None, SimPowerSupply()) + iface.showInfo() iface.localNode.showInfo() iface.showNodes() iface.sendText("hello") iface.close() + log_set.close() out, err = capsys.readouterr() assert re.search(r"Owner: None \(None\)", out, re.MULTILINE) assert re.search(r"Nodes", out, re.MULTILINE) From de29bf34ef3eaaf822db854d0c968c8fd5836f0e Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Wed, 31 Jul 2024 16:03:22 -0700 Subject: [PATCH 27/30] install all extras when running poetry inside of CI --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 543db54c..9ad4f3fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: pip3 install poetry - name: Install meshtastic from local run: | - poetry install + poetry install --all-extras poetry run meshtastic --version - name: Run pylint run: poetry run pylint meshtastic examples/ --ignore-patterns ".*_pb2.pyi?$" @@ -68,5 +68,5 @@ jobs: run: | python -m pip install --upgrade pip pip3 install poetry - poetry install + poetry install --all-extras poetry run meshtastic --version From dfa3d46a34f0d8b9a886c8176ea9cdec22f3b347 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Wed, 31 Jul 2024 16:46:09 -0700 Subject: [PATCH 28/30] add pandas as an optional dependancy (for analytics only) --- .github/workflows/ci.yml | 4 +- poetry.lock | 130 ++++++++++++++++++++++++++++++++++++++- pyproject.toml | 4 +- 3 files changed, 132 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ad4f3fb..c01798bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: pip3 install poetry - name: Install meshtastic from local run: | - poetry install --all-extras + poetry install --all-extras --with dev poetry run meshtastic --version - name: Run pylint run: poetry run pylint meshtastic examples/ --ignore-patterns ".*_pb2.pyi?$" @@ -68,5 +68,5 @@ jobs: run: | python -m pip install --upgrade pip pip3 install poetry - poetry install --all-extras + poetry install poetry run meshtastic --version diff --git a/poetry.lock b/poetry.lock index 4995f5a0..63c32674 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2251,6 +2251,97 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "pandas" +version = "2.2.2" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = true +python-versions = ">=3.9" +files = [ + {file = "pandas-2.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:90c6fca2acf139569e74e8781709dccb6fe25940488755716d1d354d6bc58bce"}, + {file = "pandas-2.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7adfc142dac335d8c1e0dcbd37eb8617eac386596eb9e1a1b77791cf2498238"}, + {file = "pandas-2.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4abfe0be0d7221be4f12552995e58723c7422c80a659da13ca382697de830c08"}, + {file = "pandas-2.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8635c16bf3d99040fdf3ca3db669a7250ddf49c55dc4aa8fe0ae0fa8d6dcc1f0"}, + {file = "pandas-2.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:40ae1dffb3967a52203105a077415a86044a2bea011b5f321c6aa64b379a3f51"}, + {file = "pandas-2.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e5a0b00e1e56a842f922e7fae8ae4077aee4af0acb5ae3622bd4b4c30aedf99"}, + {file = "pandas-2.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:ddf818e4e6c7c6f4f7c8a12709696d193976b591cc7dc50588d3d1a6b5dc8772"}, + {file = "pandas-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:696039430f7a562b74fa45f540aca068ea85fa34c244d0deee539cb6d70aa288"}, + {file = "pandas-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e90497254aacacbc4ea6ae5e7a8cd75629d6ad2b30025a4a8b09aa4faf55151"}, + {file = "pandas-2.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58b84b91b0b9f4bafac2a0ac55002280c094dfc6402402332c0913a59654ab2b"}, + {file = "pandas-2.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2123dc9ad6a814bcdea0f099885276b31b24f7edf40f6cdbc0912672e22eee"}, + {file = "pandas-2.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2925720037f06e89af896c70bca73459d7e6a4be96f9de79e2d440bd499fe0db"}, + {file = "pandas-2.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0cace394b6ea70c01ca1595f839cf193df35d1575986e484ad35c4aeae7266c1"}, + {file = "pandas-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:873d13d177501a28b2756375d59816c365e42ed8417b41665f346289adc68d24"}, + {file = "pandas-2.2.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9dfde2a0ddef507a631dc9dc4af6a9489d5e2e740e226ad426a05cabfbd7c8ef"}, + {file = "pandas-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e9b79011ff7a0f4b1d6da6a61aa1aa604fb312d6647de5bad20013682d1429ce"}, + {file = "pandas-2.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cb51fe389360f3b5a4d57dbd2848a5f033350336ca3b340d1c53a1fad33bcad"}, + {file = "pandas-2.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eee3a87076c0756de40b05c5e9a6069c035ba43e8dd71c379e68cab2c20f16ad"}, + {file = "pandas-2.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3e374f59e440d4ab45ca2fffde54b81ac3834cf5ae2cdfa69c90bc03bde04d76"}, + {file = "pandas-2.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:43498c0bdb43d55cb162cdc8c06fac328ccb5d2eabe3cadeb3529ae6f0517c32"}, + {file = "pandas-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:d187d355ecec3629624fccb01d104da7d7f391db0311145817525281e2804d23"}, + {file = "pandas-2.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0ca6377b8fca51815f382bd0b697a0814c8bda55115678cbc94c30aacbb6eff2"}, + {file = "pandas-2.2.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9057e6aa78a584bc93a13f0a9bf7e753a5e9770a30b4d758b8d5f2a62a9433cd"}, + {file = "pandas-2.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:001910ad31abc7bf06f49dcc903755d2f7f3a9186c0c040b827e522e9cef0863"}, + {file = "pandas-2.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66b479b0bd07204e37583c191535505410daa8df638fd8e75ae1b383851fe921"}, + {file = "pandas-2.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a77e9d1c386196879aa5eb712e77461aaee433e54c68cf253053a73b7e49c33a"}, + {file = "pandas-2.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92fd6b027924a7e178ac202cfbe25e53368db90d56872d20ffae94b96c7acc57"}, + {file = "pandas-2.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:640cef9aa381b60e296db324337a554aeeb883ead99dc8f6c18e81a93942f5f4"}, + {file = "pandas-2.2.2.tar.gz", hash = "sha256:9e79019aba43cb4fda9e4d983f8e88ca0373adbb697ae9c6c43093218de28b54"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.22.4", markers = "python_version < \"3.11\""}, + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, +] +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.7" + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] +aws = ["s3fs (>=2022.11.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] +compression = ["zstandard (>=0.19.0)"] +computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] +feather = ["pyarrow (>=10.0.1)"] +fss = ["fsspec (>=2022.11.0)"] +gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] +hdf5 = ["tables (>=3.8.0)"] +html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] +mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=10.0.1)"] +performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] +plot = ["matplotlib (>=3.6.3)"] +postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] +pyarrow = ["pyarrow (>=10.0.1)"] +spss = ["pyreadstat (>=1.2.0)"] +sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.9.2)"] + +[[package]] +name = "pandas-stubs" +version = "2.2.2.240603" +description = "Type annotations for pandas" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pandas_stubs-2.2.2.240603-py3-none-any.whl", hash = "sha256:e08ce7f602a4da2bff5a67475ba881c39f2a4d4f7fccc1cba57c6f35a379c6c0"}, + {file = "pandas_stubs-2.2.2.240603.tar.gz", hash = "sha256:2dcc86e8fa6ea41535a4561c1f08b3942ba5267b464eff2e99caeee66f9e4cd1"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.23.5", markers = "python_version >= \"3.9\" and python_version < \"3.12\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\" and python_version < \"3.13\""}, +] +types-pytz = ">=2022.1.1" + [[package]] name = "pandocfilters" version = "1.5.1" @@ -2759,8 +2850,8 @@ astroid = ">=3.2.2,<=3.3.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.2", markers = "python_version < \"3.11\""}, - {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, + {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, ] isort = ">=4.2.5,<5.13.0 || >5.13.0,<6" mccabe = ">=0.6,<0.8" @@ -2975,6 +3066,17 @@ files = [ {file = "python_json_logger-2.0.7-py3-none-any.whl", hash = "sha256:f380b826a991ebbe3de4d897aeec42760035ac760345e57b812938dc8b35e2bd"}, ] +[[package]] +name = "pytz" +version = "2024.1" +description = "World timezone definitions, modern and historical" +optional = true +python-versions = "*" +files = [ + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, +] + [[package]] name = "pywin32" version = "306" @@ -3627,6 +3729,17 @@ files = [ {file = "types_python_dateutil-2.9.0.20240316-py3-none-any.whl", hash = "sha256:6b8cb66d960771ce5ff974e9dd45e38facb81718cc1e208b10b1baccbfdbee3b"}, ] +[[package]] +name = "types-pytz" +version = "2024.1.0.20240417" +description = "Typing stubs for pytz" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-pytz-2024.1.0.20240417.tar.gz", hash = "sha256:6810c8a1f68f21fdf0f4f374a432487c77645a0ac0b31de4bf4690cf21ad3981"}, + {file = "types_pytz-2024.1.0.20240417-py3-none-any.whl", hash = "sha256:8335d443310e2db7b74e007414e74c4f53b67452c0cb0d228ca359ccfba59659"}, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20240311" @@ -3685,6 +3798,17 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "tzdata" +version = "2024.1" +description = "Provider of IANA time zone data" +optional = true +python-versions = ">=2" +files = [ + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, +] + [[package]] name = "uri-template" version = "1.3.0" @@ -4031,10 +4155,10 @@ doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linke test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [extras] -analysis = ["dash", "dash-bootstrap-components"] +analysis = ["dash", "dash-bootstrap-components", "pandas"] tunnel = ["pytap2"] [metadata] lock-version = "2.0" python-versions = "^3.9,<3.13" -content-hash = "c8a40e0cc2ceeef6297713527b8b878e29b763a3343e0bdfb456995f9303c09e" +content-hash = "c696888a3c3c82fb8908d8ddf8c917abba741c00b6f1e21ee0a489dd1fbff5da" diff --git a/pyproject.toml b/pyproject.toml index 05cf219b..3f87d86a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ print-color = "^0.4.6" dash = { version = "^2.17.1", optional = true } pytap2 = { version = "^2.3.0", optional = true } dash-bootstrap-components = { version = "^1.6.0", optional = true } +pandas = { version = "^2.2.2", optional = true } [tool.poetry.group.dev.dependencies] hypothesis = "^6.103.2" @@ -47,6 +48,7 @@ types-requests = "^2.31.0.20240406" types-setuptools = "^69.5.0.20240423" types-pyyaml = "^6.0.12.20240311" pyarrow-stubs = "^10.0.1.7" +pandas-stubs = "^2.2.2.240603" # If you are doing power analysis you might want these extra devtools [tool.poetry.group.analysis] @@ -61,7 +63,7 @@ jupyterlab-widgets = "^3.0.11" [tool.poetry.extras] tunnel = ["pytap2"] -analysis = ["dash", "dash-bootstrap-components"] +analysis = ["dash", "dash-bootstrap-components", "pandas", "pandas-stubs"] [tool.poetry.scripts] meshtastic = "meshtastic.__main__:main" From b0e1d961fd8dc60bcdb49ebbdda3c8f73dfd9ce9 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Thu, 1 Aug 2024 09:50:41 -0700 Subject: [PATCH 29/30] add vscode config for auto running python tests --- .vscode/settings.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index db434be9..d2e2409a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,5 +13,10 @@ "Vids" ], "python.pythonPath": "/usr/bin/python3", - "flake8.enabled" : false // we are using trunk for formatting/linting rules, don't yell at us about line length + "flake8.enabled": false, + "python.testing.pytestArgs": [ + "meshtastic/tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true // we are using trunk for formatting/linting rules, don't yell at us about line length } \ No newline at end of file From 8096d102765032954344d7acbcb8ec088e6b6394 Mon Sep 17 00:00:00 2001 From: Kevin Hester Date: Thu, 1 Aug 2024 09:51:27 -0700 Subject: [PATCH 30/30] Do code coverage testing on analysis (using stored device data) --- meshtastic/analysis/__main__.py | 11 +- .../tests/slog-test-input/power.feather | Bin 0 -> 22258 bytes meshtastic/tests/slog-test-input/raw.txt | 349 ++++++++++++++++++ meshtastic/tests/slog-test-input/slog.feather | Bin 0 -> 6282 bytes meshtastic/tests/test_analysis.py | 25 ++ 5 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 meshtastic/tests/slog-test-input/power.feather create mode 100644 meshtastic/tests/slog-test-input/raw.txt create mode 100644 meshtastic/tests/slog-test-input/slog.feather create mode 100644 meshtastic/tests/test_analysis.py diff --git a/meshtastic/analysis/__main__.py b/meshtastic/analysis/__main__.py index 993f04b0..c4d79e94 100644 --- a/meshtastic/analysis/__main__.py +++ b/meshtastic/analysis/__main__.py @@ -125,6 +125,12 @@ def create_argparser() -> argparse.ArgumentParser: "--slog", help="Specify the structured-logs directory (defaults to latest log directory)", ) + group.add_argument( + "--no-server", + action="store_true", + help="Exit immediately, without running the visualization web server", + ) + return parser @@ -190,7 +196,10 @@ def main(): port = 8051 logging.info(f"Running Dash visualization of {args.slog} (publicly accessible)") - app.run_server(debug=True, host="0.0.0.0", port=port) + if not args.no_server: + app.run_server(debug=True, host="0.0.0.0", port=port) + else: + logging.info("Exiting without running visualization server") if __name__ == "__main__": diff --git a/meshtastic/tests/slog-test-input/power.feather b/meshtastic/tests/slog-test-input/power.feather new file mode 100644 index 0000000000000000000000000000000000000000..f5f14536992a512dd2ef895413d692236a40bb66 GIT binary patch literal 22258 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 literal 0 HcmV?d00001 diff --git a/meshtastic/tests/test_analysis.py b/meshtastic/tests/test_analysis.py new file mode 100644 index 00000000..ea8d6aee --- /dev/null +++ b/meshtastic/tests/test_analysis.py @@ -0,0 +1,25 @@ +"""Test analysis processing.""" + +import logging +import os +import sys + +import pytest + +from meshtastic.analysis.__main__ import main + + +@pytest.mark.unit +def test_analysis(caplog): + """Test analysis processing""" + + cur_dir = os.path.dirname(os.path.abspath(__file__)) + slog_input_dir = os.path.join(cur_dir, "slog-test-input") + + sys.argv = ["fakescriptname", "--no-server", "--slog", slog_input_dir] + + with caplog.at_level(logging.DEBUG): + logging.getLogger().propagate = True # Let our testing framework see our logs + main() + + assert "Exiting without running visualization server" in caplog.text