diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..76580b8 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[pre-commit] Running ruff (fix), pytest, and mypy..." + +# Only run if Python files changed; otherwise skip quickly +changed_py=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.py$' || true) +if [[ -z "$changed_py" ]]; then + echo "[pre-commit] No Python changes detected. Skipping checks." + exit 0 +fi + +if ! command -v uv >/dev/null 2>&1; then + echo "[pre-commit] 'uv' not found. Install from https://github.com/astral-sh/uv" + exit 1 +fi + +# Lint and auto-fix Python code +echo "[pre-commit] Ruff: check & fix" +uvx ruff check --fix + +# Re-stage any auto-fixed files so the commit includes updates +git add -A + +# Run tests +echo "[pre-commit] Pytest" +uv run pytest -q python/ + +# Type checking +echo "[pre-commit] Mypy" +uv run mypy + +echo "[pre-commit] All checks passed. Proceeding with commit." +exit 0 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..995c695 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,42 @@ +name: Publish Docs to GitHub Pages + +on: + push: + branches: [main] + paths: + - 'documentation/**' + + +permissions: + contents: read + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Pages + uses: actions/configure-pages@v5 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' # Not needed with a .ruby-version, .tool-versions or mise.toml + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - name: Build with Jekyll + run: bundle exec jekyll build + env: + JEKYLL_ENV: production + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + deploy: + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} \ No newline at end of file diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml new file mode 100644 index 0000000..4b80ee5 --- /dev/null +++ b/.github/workflows/pypi-publish.yml @@ -0,0 +1,36 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +jobs: + test-lint-typecheck: + uses: ./.github/workflows/python-ci.yml + build-and-publish: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Setup uv + uses: astral-sh/setup-uv@v3 + + - name: Build package (sdist and wheel) + run: uv build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_TOKEN }} + # Uncomment to be verbose or skip existing versions + # verbose: true + # skip-existing: true diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..c0e5bad --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,41 @@ +name: Python CI + +on: + workflow_call: + push: + branches: ["**"] + paths: + - '**/*.py' + - 'pyproject.toml' + pull_request: + branches: ["**"] + paths: + - '**/*.py' + - 'pyproject.toml' + +jobs: + test-lint-typecheck: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Setup uv + uses: astral-sh/setup-uv@v3 + + - name: Sync dependencies (including dev) + run: uv sync --all-extras --dev + + - name: Run tests (pytest) + run: uv run pytest + + - name: Lint (ruff) + run: uvx ruff check + + - name: Type check (mypy) + run: uv run mypy \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0057c27..0de93e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ __pycache__/ .* !.gitignore +!.githooks +!.github +__about__.py +_site +*.lock \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..6e4c7a1 --- /dev/null +++ b/Gemfile @@ -0,0 +1,33 @@ +source "https://rubygems.org" +# Hello! This is where you manage which Jekyll version is used to run. +# When you want to use a different version, change it below, save the +# file and run `bundle install`. Run Jekyll with `bundle exec`, like so: +# +# bundle exec jekyll serve +# +# This will help ensure the proper Jekyll version is running. +# Happy Jekylling! +gem "jekyll", "~> 4.4.1" +# This is the default theme for new Jekyll sites. You may change this to anything you like. +gem "just-the-docs" +# If you have any plugins, put them here! +group :jekyll_plugins do + gem "jekyll-feed", "~> 0.12" + gem "jekyll-readme-index" + gem "jekyll-gfm-admonitions" + gem "jekyll-relative-links" +end + +# Windows and JRuby does not include zoneinfo files, so bundle the tzinfo-data gem +# and associated library. +platforms :mingw, :x64_mingw, :mswin, :jruby do + gem "tzinfo", ">= 1", "< 3" + gem "tzinfo-data" +end + +# Performance-booster for watching directories on Windows +gem "wdm", "~> 0.1", :platforms => [:mingw, :x64_mingw, :mswin] + +# Lock `http_parser.rb` gem to `v0.6.x` on JRuby builds since newer versions of the gem +# do not have a Java counterpart. +gem "http_parser.rb", "~> 0.6.0", :platforms => [:jruby] diff --git a/README.md b/README.md index a49906e..f764a20 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,36 @@ +--- +layout: home +title: Open Echo +nav_exclude: true +--- + + Open Echo Cover ## Universal Open-Source SONAR Controller and Development Stack An ongoing open-source hardware and software project for building sonar systems for testing, boating, bathymetry, and research. -The most commonly used hardware is the [TUSS4470 Arduino Shield](TUSS4470_shield_002/), which stacks on top of an Arduino Uno to drive the TUSS4470 ultrasonic driver. -The board can run the [RAW Data Firmware](TUSS4470_shield_002/getting_started_TUSS4470_firmware.md) to operate a wide variety of ultrasonic transducers, covering frequencies from 40 kHz up to 1000 kHz in different media such as air or water. +The most commonly used hardware is the [TUSS4470 Arduino Shield](documentation/getting_started/TUSS4470_hardware.md), which stacks on top of an Arduino Uno to drive the TUSS4470 ultrasonic driver. +The board can run the [RAW Data Firmware](documentation/getting_started/desktop_interface.md) to operate a wide variety of ultrasonic transducers, covering frequencies from 40 kHz up to 1000 kHz in different media such as air or water. -The [NMEA Output Firmware](TUSS4470_shield_002/arduino/NMEA_DBT_OUT/NMEA_DBT_OUT.ino) can read depth data from commercially available in-water ultrasonic transducers (e.g., on boats) and output NMEA0183-compatible data to a computer or a UART-connected device such as a Pixhawk or other controllers. +The [NMEA Output Firmware](https://github.com/neumi/open_echo/tree/main/TUSS4470_shield_002/arduino/NMEA_DBT_OUT/NMEA_DBT_OUT.ino) can read depth data from commercially available in-water ultrasonic transducers (e.g., on boats) and output NMEA0183-compatible data to a computer or a UART-connected device such as a Pixhawk or other controllers. Open Echo has been tested on multiple ultrasonic transducers and is compatible with all of themβ€”from car parking sensors to Lowrance Tripleshot side-scan transducers. -The [Python Interface Software](TUSS4470_shield_002/getting_started_interface.md) connects to Open Echo boards running the [RAW Data Firmware](TUSS4470_shield_002/getting_started_TUSS4470_firmware.md). It can display raw echo data, change configurations, output a TCP depth data stream, and more. +The [Python Interface Software](documentation/getting_started/desktop_interface.md) connects to Open Echo boards running the [RAW Data Firmware](documentation/getting_started/TUSS4470_firmware.md). It can display raw echo data, change configurations, output a TCP depth data stream, and more. -Check the [Getting Started Guide](TUSS4470_shield_002/README.md)! +Check the [Getting Started Guide](documentation/getting_started/index.md)! If something is unclear or you find a bug, please open an issue. Raw Data Waterfall chart in the Python Desktop software: -Open Echo Interface Software +Open Echo Interface Software ## Getting the Hardware -If you need the hardware, you can order it using the [Hardware Files](TUSS4470_shield_002/TUSS4470_shield_hardware/TUSS4470_shield) from a board + SMT house ([JLC recommended](https://jlcpcb.com/?from=Neumi)). +If you need the hardware, you can order it using the [Hardware Files](https://github.com/neumi/open_echo/tree/main/TUSS4470_shield_002/TUSS4470_shield_hardware/TUSS4470_shield) from a board + SMT house ([JLC recommended](https://jlcpcb.com/?from=Neumi)). They can also be bought as a complete and tested set direclty from Elecrow: https://www.elecrow.com/open-echo-tuss4470-development-shield.html @@ -36,12 +43,12 @@ All profits go directly toward supporting and advancing the Open Echo project! [TUSS4470 Arduino Shield](TUSS4470_shield_002/): PCB overview TUSS4470 -### This project is currently in development. The [TUSS4470 Development Shield](TUSS4470_shield_002/) is ready for external use! +### This project is currently in development. The [TUSS4470 Development Shield](documentation/getting_started/TUSS4470_hardware.md) is ready for external use! Development is ongoing! Check the documentation and Discord channel for the latest updates. Want to stay updated or participate? Join the [Discord](https://discord.com/invite/rerCyqAcrw)! -Check the [Getting Started Guide](TUSS4470_shield_002/README.md). +Check the [Getting Started Guide](documentation/getting_started/). ## Vision An accessible Open Source SONAR stack for development, research and real use: diff --git a/TUSS4470_shield_002/requirements.txt b/TUSS4470_shield_002/requirements.txt deleted file mode 100644 index e28f244..0000000 --- a/TUSS4470_shield_002/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -numpy -pyserial==3.4 -PyQt5 -pyqtgraph -pyqtdarktheme diff --git a/TUSS4470_shield_002/web/app.py b/TUSS4470_shield_002/web/app.py deleted file mode 100644 index 21416d9..0000000 --- a/TUSS4470_shield_002/web/app.py +++ /dev/null @@ -1,110 +0,0 @@ -from contextlib import asynccontextmanager -from depth_output import OutputManager -from settings import Settings -from echo import EchoReader, SerialReader -import logging -from fastapi import FastAPI, WebSocket, Request, Form -from fastapi.responses import RedirectResponse -from fastapi.templating import Jinja2Templates -from fastapi.staticfiles import StaticFiles - -log = logging.getLogger("uvicorn") - - -class ConnectionManager: - def __init__(self): - self.active_connections: list[WebSocket] = [] - - async def connect(self, websocket: WebSocket): - await websocket.accept() - self.active_connections.append(websocket) - log.info(f"WebSocket connected: {websocket.client}") - - async def disconnect(self, websocket: WebSocket): - if websocket in self.active_connections: - self.active_connections.remove(websocket) - - async def broadcast_json(self, data): - for connection in self.active_connections: - await connection.send_json(data) - - -connection_manager = ConnectionManager() -output_manager = OutputManager() -echo_reader = EchoReader( - data_callback=connection_manager.broadcast_json, - depth_callback=output_manager.update, -) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - try: - await update_settings(Settings.load()) - except Exception as e: - log.error(f"Failed to load settings: {e}") - - with output_manager, echo_reader: - yield - - -app = FastAPI(lifespan=lifespan) -app.state.settings = Settings() -templates = Jinja2Templates(directory="templates") - -app.mount("/static", StaticFiles(directory="static"), name="static") - -async def update_settings(new_settings: Settings): - settings = Settings.model_validate( - { - **app.state.settings.model_dump(exclude_none=True, exclude_unset=True, exclude_defaults=True), - **new_settings.model_dump(exclude_none=True, exclude_unset=True, exclude_defaults=True), - } - ) - - echo_reader.update_settings(settings) - await output_manager.update_settings(settings) - app.state.settings = settings - - app.state.settings.save() - - - -@app.websocket("/ws") -async def websocket_endpoint(websocket: WebSocket): - await connection_manager.connect(websocket) - try: - while True: - await websocket.receive_text() # Just here to keep the connection alive - except Exception as e: - log.error(f"WebSocket closed: {e}") - finally: - await connection_manager.disconnect(websocket) - - -@app.get("/") -async def home(request: Request): - if app.state.settings.serial_port == "init": - return RedirectResponse("/config", status_code=303) - - return templates.TemplateResponse( - "frontend.html", {"request": request, "settings": app.state.settings} - ) - - -@app.get("/config") -async def config(request: Request): - return templates.TemplateResponse( - "config.html", - { - "request": request, - "settings": app.state.settings, - "ports": SerialReader.get_serial_ports(), - }, - ) - - -@app.post("/config") -async def config_post(request: Request, new_settings: Settings = Form(...)): - await update_settings(new_settings) - return RedirectResponse("/", status_code=303) diff --git a/TUSS4470_shield_002/web/echo.py b/TUSS4470_shield_002/web/echo.py deleted file mode 100644 index a9faded..0000000 --- a/TUSS4470_shield_002/web/echo.py +++ /dev/null @@ -1,235 +0,0 @@ -from abc import ABC, abstractmethod -import asyncio -from enum import Enum -from typing import Callable, Coroutine -import numpy as np -import serial.tools.list_ports -import struct -import logging -import serial_asyncio_fast as aserial - - -log = logging.getLogger("uvicorn") - - -class Reader(ABC): - def __init__(self, settings): - self.settings = settings - - @abstractmethod - async def open(self): - pass - - @abstractmethod - async def close(self): - pass - - @abstractmethod - async def read(self): - pass - - def unpack(self, payload: bytes, checksum: bytes) -> tuple[np.ndarray, float, float, float]: - if len(payload) != 6 + self.settings.num_samples or len(checksum) != 1: - raise ValueError("Invalid payload or checksum length") - - # Verify checksum - calc_checksum = 0 - for byte in payload: - calc_checksum ^= byte - if calc_checksum != checksum[0]: - log.warning("⚠️ Checksum mismatch") - # raise ValueError("Checksum mismatch") - - # Unpack payload - depth, temp_scaled, vDrv_scaled = struct.unpack("= self.outer.packet_size: - # Full packet - payload = self.outer._buf[1:1 + 6 + self.outer.settings.num_samples] - checksum = self.outer._buf[-1:] - try: - result = self.outer.unpack(payload, checksum) - self.outer._queue.put_nowait(result) - except ValueError: - pass - finally: - self.outer._buf.clear() - - def __init__(self, settings): - super().__init__(settings) - self._transport = None - self._queue: asyncio.Queue = asyncio.Queue() - self._buf = bytearray() - self.packet_size = 1 + 6 + self.settings.num_samples + 1 - self.host = getattr(settings, "udp_host", "0.0.0.0") - self.port = getattr(settings, "udp_port", 9999) - - async def open(self): - log.info("Starting UDP listener...") - loop = asyncio.get_running_loop() - transport, protocol = await loop.create_datagram_endpoint( - lambda: UDPReader._PacketProtocol(self), - local_addr=(self.host, self.port), - ) - self._transport = transport - log.info(f"πŸ“‘ UDP listener bound to {self.host}:{self.port}") - - async def close(self): - if self._transport: - self._transport.close() - self._transport = None - - async def read(self): - # Wait for next valid parsed packet - return await self._queue.get() - - -class EchoReader: - def __init__( - self, - data_callback: Callable[[dict], Coroutine], - depth_callback: Callable[[dict], Coroutine], - settings = None, - ): - self.settings = settings - self._restart_event = asyncio.Event() - self.data_callback = data_callback - self.depth_callback = depth_callback - self._task: asyncio.Task | None = None - - def update_settings(self, new_settings): - log.info("EchoReader updating settings...") - self.settings = new_settings - self._restart_event.set() # Signal restart - - def __enter__(self): - self._task = asyncio.create_task(self.run_forever()) - return self - - def __exit__(self, exc_type, exc_value, traceback): - if self._task: - self._task.cancel() - self._task = None - - if exc_type is not None: - log.error(f"Error in EchoReader: {exc_value}") - - async def aread_echo(self, reader: Reader): - result = await reader.read() - if result: - values, depth_index, temperature, drive_voltage = result - - resolution = self.settings.resolution - depth = depth_index * (resolution / 100) # Convert to meters - try: - data = { - "spectrogram": values.tolist(), - "measured_depth": depth, - "temperature": temperature, - "drive_voltage": drive_voltage, - "resolution": resolution, - } - await self.data_callback(data) - except Exception as e: - log.error(f"❌ Error sending data: {e}", exc_info=e) - - try: - self.depth_callback(depth) - except Exception as e: - log.error(f"❌ Error sending depth: {e}", exc_info=e) - - await asyncio.sleep(0.1) # Allow time for other tasks - - async def run_forever(self): - """Continuously read serial data and emit processed arrays. Supports live settings update and restart.""" - while True: - if self.settings is None: - log.warning("Settings not initialized, waiting...") - await asyncio.sleep(1) - continue - - log.info("EchoReader starting...") - self._restart_event.clear() - try: - reader = self.settings.connection_type.value(self.settings) - await reader.open() - log.info(f"Opening connection: {self.settings.connection_type.name}") - while not self._restart_event.is_set(): - await self.aread_echo(reader) - except Exception as e: - log.error(f"❌ Error in EchoReader: {e}", exc_info=e) - finally: - await reader.close() - - await self._restart_event.wait() - - -class ConnectionTypeEnum(Enum): - SERIAL = SerialReader - UDP = UDPReader diff --git a/TUSS4470_shield_002/web/pyproject.toml b/TUSS4470_shield_002/web/pyproject.toml deleted file mode 100644 index 6cc27f2..0000000 --- a/TUSS4470_shield_002/web/pyproject.toml +++ /dev/null @@ -1,19 +0,0 @@ -[project] -name = "open-echo-web" -version = "0.1.0" -description = "Web interface for OpenEcho" -readme = "README.md" -requires-python = ">=3.11" -dependencies = [ - "fastapi>=0.116.1", - "httpx>=0.28.1", - "jinja2>=3.1.6", - "numpy>=2.3.2", - "pydantic-settings>=2.10.1", - "pyserial<3.5", - "pyserial-asyncio>=0.6", - "pyserial-asyncio-fast>=0.16", - "python-multipart>=0.0.20", - "uvicorn>=0.35.0", - "websockets>=15.0.1", -] diff --git a/TUSS4470_shield_002/web/requirements.txt b/TUSS4470_shield_002/web/requirements.txt deleted file mode 100644 index 5d8d1e8..0000000 --- a/TUSS4470_shield_002/web/requirements.txt +++ /dev/null @@ -1,83 +0,0 @@ -# This file was autogenerated by uv via the following command: -# uv export --no-hashes --format requirements-txt -annotated-types==0.7.0 - # via pydantic -anyio==4.10.0 - # via - # httpx - # starlette -certifi==2025.8.3 - # via - # httpcore - # httpx -click==8.2.1 - # via uvicorn -colorama==0.4.6 ; sys_platform == 'win32' - # via click -fastapi==0.116.1 - # via open-echo-web -h11==0.16.0 - # via - # httpcore - # uvicorn -httpcore==1.0.9 - # via httpx -httpx==0.28.1 - # via open-echo-web -idna==3.10 - # via - # anyio - # httpx -jinja2==3.1.6 - # via open-echo-web -markupsafe==3.0.2 - # via jinja2 -numpy==2.3.2 - # via - # open-echo-web - # scipy -pydantic==2.11.7 - # via - # fastapi - # pydantic-settings -pydantic-core==2.33.2 - # via pydantic -pydantic-settings==2.10.1 - # via open-echo-web -pyserial==3.4 - # via - # open-echo-web - # pyserial-asyncio - # pyserial-asyncio-fast -pyserial-asyncio==0.6 - # via open-echo-web -pyserial-asyncio-fast==0.16 - # via open-echo-web -python-dotenv==1.1.1 - # via pydantic-settings -python-multipart==0.0.20 - # via open-echo-web -scipy==1.16.1 - # via open-echo-web -sniffio==1.3.1 - # via anyio -starlette==0.47.3 - # via fastapi -typing-extensions==4.14.1 - # via - # anyio - # fastapi - # pydantic - # pydantic-core - # starlette - # typing-inspection -typing-inspection==0.4.1 - # via - # pydantic - # pydantic-settings -uvicorn==0.35.0 - # via open-echo-web -uvloop==0.21.0 - # via open-echo-web -websockets==15.0.1 - # via open-echo-web diff --git a/TUSS4470_shield_002/web/uv.lock b/TUSS4470_shield_002/web/uv.lock deleted file mode 100644 index bee9a1f..0000000 --- a/TUSS4470_shield_002/web/uv.lock +++ /dev/null @@ -1,537 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version < '3.14'", -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, -] - -[[package]] -name = "certifi" -version = "2025.11.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, -] - -[[package]] -name = "click" -version = "8.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "fastapi" -version = "0.116.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, - { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, - { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, - { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, - { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, -] - -[[package]] -name = "numpy" -version = "2.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306, upload-time = "2025-07-24T21:32:07.553Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/26/1320083986108998bd487e2931eed2aeedf914b6e8905431487543ec911d/numpy-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:852ae5bed3478b92f093e30f785c98e0cb62fa0a939ed057c31716e18a7a22b9", size = 21259016, upload-time = "2025-07-24T20:24:35.214Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2b/792b341463fa93fc7e55abbdbe87dac316c5b8cb5e94fb7a59fb6fa0cda5/numpy-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a0e27186e781a69959d0230dd9909b5e26024f8da10683bd6344baea1885168", size = 14451158, upload-time = "2025-07-24T20:24:58.397Z" }, - { url = "https://files.pythonhosted.org/packages/b7/13/e792d7209261afb0c9f4759ffef6135b35c77c6349a151f488f531d13595/numpy-2.3.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f0a1a8476ad77a228e41619af2fa9505cf69df928e9aaa165746584ea17fed2b", size = 5379817, upload-time = "2025-07-24T20:25:07.746Z" }, - { url = "https://files.pythonhosted.org/packages/49/ce/055274fcba4107c022b2113a213c7287346563f48d62e8d2a5176ad93217/numpy-2.3.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cbc95b3813920145032412f7e33d12080f11dc776262df1712e1638207dde9e8", size = 6913606, upload-time = "2025-07-24T20:25:18.84Z" }, - { url = "https://files.pythonhosted.org/packages/17/f2/e4d72e6bc5ff01e2ab613dc198d560714971900c03674b41947e38606502/numpy-2.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75018be4980a7324edc5930fe39aa391d5734531b1926968605416ff58c332d", size = 14589652, upload-time = "2025-07-24T20:25:40.356Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b0/fbeee3000a51ebf7222016e2939b5c5ecf8000a19555d04a18f1e02521b8/numpy-2.3.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20b8200721840f5621b7bd03f8dcd78de33ec522fc40dc2641aa09537df010c3", size = 16938816, upload-time = "2025-07-24T20:26:05.721Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ec/2f6c45c3484cc159621ea8fc000ac5a86f1575f090cac78ac27193ce82cd/numpy-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f91e5c028504660d606340a084db4b216567ded1056ea2b4be4f9d10b67197f", size = 16370512, upload-time = "2025-07-24T20:26:30.545Z" }, - { url = "https://files.pythonhosted.org/packages/b5/01/dd67cf511850bd7aefd6347aaae0956ed415abea741ae107834aae7d6d4e/numpy-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fb1752a3bb9a3ad2d6b090b88a9a0ae1cd6f004ef95f75825e2f382c183b2097", size = 18884947, upload-time = "2025-07-24T20:26:58.24Z" }, - { url = "https://files.pythonhosted.org/packages/a7/17/2cf60fd3e6a61d006778735edf67a222787a8c1a7842aed43ef96d777446/numpy-2.3.2-cp311-cp311-win32.whl", hash = "sha256:4ae6863868aaee2f57503c7a5052b3a2807cf7a3914475e637a0ecd366ced220", size = 6599494, upload-time = "2025-07-24T20:27:09.786Z" }, - { url = "https://files.pythonhosted.org/packages/d5/03/0eade211c504bda872a594f045f98ddcc6caef2b7c63610946845e304d3f/numpy-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:240259d6564f1c65424bcd10f435145a7644a65a6811cfc3201c4a429ba79170", size = 13087889, upload-time = "2025-07-24T20:27:29.558Z" }, - { url = "https://files.pythonhosted.org/packages/13/32/2c7979d39dafb2a25087e12310fc7f3b9d3c7d960df4f4bc97955ae0ce1d/numpy-2.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:4209f874d45f921bde2cff1ffcd8a3695f545ad2ffbef6d3d3c6768162efab89", size = 10459560, upload-time = "2025-07-24T20:27:46.803Z" }, - { url = "https://files.pythonhosted.org/packages/00/6d/745dd1c1c5c284d17725e5c802ca4d45cfc6803519d777f087b71c9f4069/numpy-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bc3186bea41fae9d8e90c2b4fb5f0a1f5a690682da79b92574d63f56b529080b", size = 20956420, upload-time = "2025-07-24T20:28:18.002Z" }, - { url = "https://files.pythonhosted.org/packages/bc/96/e7b533ea5740641dd62b07a790af5d9d8fec36000b8e2d0472bd7574105f/numpy-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f4f0215edb189048a3c03bd5b19345bdfa7b45a7a6f72ae5945d2a28272727f", size = 14184660, upload-time = "2025-07-24T20:28:39.522Z" }, - { url = "https://files.pythonhosted.org/packages/2b/53/102c6122db45a62aa20d1b18c9986f67e6b97e0d6fbc1ae13e3e4c84430c/numpy-2.3.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b1224a734cd509f70816455c3cffe13a4f599b1bf7130f913ba0e2c0b2006c0", size = 5113382, upload-time = "2025-07-24T20:28:48.544Z" }, - { url = "https://files.pythonhosted.org/packages/2b/21/376257efcbf63e624250717e82b4fae93d60178f09eb03ed766dbb48ec9c/numpy-2.3.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3dcf02866b977a38ba3ec10215220609ab9667378a9e2150615673f3ffd6c73b", size = 6647258, upload-time = "2025-07-24T20:28:59.104Z" }, - { url = "https://files.pythonhosted.org/packages/91/ba/f4ebf257f08affa464fe6036e13f2bf9d4642a40228781dc1235da81be9f/numpy-2.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:572d5512df5470f50ada8d1972c5f1082d9a0b7aa5944db8084077570cf98370", size = 14281409, upload-time = "2025-07-24T20:40:30.298Z" }, - { url = "https://files.pythonhosted.org/packages/59/ef/f96536f1df42c668cbacb727a8c6da7afc9c05ece6d558927fb1722693e1/numpy-2.3.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8145dd6d10df13c559d1e4314df29695613575183fa2e2d11fac4c208c8a1f73", size = 16641317, upload-time = "2025-07-24T20:40:56.625Z" }, - { url = "https://files.pythonhosted.org/packages/f6/a7/af813a7b4f9a42f498dde8a4c6fcbff8100eed00182cc91dbaf095645f38/numpy-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:103ea7063fa624af04a791c39f97070bf93b96d7af7eb23530cd087dc8dbe9dc", size = 16056262, upload-time = "2025-07-24T20:41:20.797Z" }, - { url = "https://files.pythonhosted.org/packages/8b/5d/41c4ef8404caaa7f05ed1cfb06afe16a25895260eacbd29b4d84dff2920b/numpy-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc927d7f289d14f5e037be917539620603294454130b6de200091e23d27dc9be", size = 18579342, upload-time = "2025-07-24T20:41:50.753Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4f/9950e44c5a11636f4a3af6e825ec23003475cc9a466edb7a759ed3ea63bd/numpy-2.3.2-cp312-cp312-win32.whl", hash = "sha256:d95f59afe7f808c103be692175008bab926b59309ade3e6d25009e9a171f7036", size = 6320610, upload-time = "2025-07-24T20:42:01.551Z" }, - { url = "https://files.pythonhosted.org/packages/7c/2f/244643a5ce54a94f0a9a2ab578189c061e4a87c002e037b0829dd77293b6/numpy-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9e196ade2400c0c737d93465327d1ae7c06c7cb8a1756121ebf54b06ca183c7f", size = 12786292, upload-time = "2025-07-24T20:42:20.738Z" }, - { url = "https://files.pythonhosted.org/packages/54/cd/7b5f49d5d78db7badab22d8323c1b6ae458fbf86c4fdfa194ab3cd4eb39b/numpy-2.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:ee807923782faaf60d0d7331f5e86da7d5e3079e28b291973c545476c2b00d07", size = 10194071, upload-time = "2025-07-24T20:42:36.657Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c0/c6bb172c916b00700ed3bf71cb56175fd1f7dbecebf8353545d0b5519f6c/numpy-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8d9727f5316a256425892b043736d63e89ed15bbfe6556c5ff4d9d4448ff3b3", size = 20949074, upload-time = "2025-07-24T20:43:07.813Z" }, - { url = "https://files.pythonhosted.org/packages/20/4e/c116466d22acaf4573e58421c956c6076dc526e24a6be0903219775d862e/numpy-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:efc81393f25f14d11c9d161e46e6ee348637c0a1e8a54bf9dedc472a3fae993b", size = 14177311, upload-time = "2025-07-24T20:43:29.335Z" }, - { url = "https://files.pythonhosted.org/packages/78/45/d4698c182895af189c463fc91d70805d455a227261d950e4e0f1310c2550/numpy-2.3.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dd937f088a2df683cbb79dda9a772b62a3e5a8a7e76690612c2737f38c6ef1b6", size = 5106022, upload-time = "2025-07-24T20:43:37.999Z" }, - { url = "https://files.pythonhosted.org/packages/9f/76/3e6880fef4420179309dba72a8c11f6166c431cf6dee54c577af8906f914/numpy-2.3.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:11e58218c0c46c80509186e460d79fbdc9ca1eb8d8aee39d8f2dc768eb781089", size = 6640135, upload-time = "2025-07-24T20:43:49.28Z" }, - { url = "https://files.pythonhosted.org/packages/34/fa/87ff7f25b3c4ce9085a62554460b7db686fef1e0207e8977795c7b7d7ba1/numpy-2.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ad4ebcb683a1f99f4f392cc522ee20a18b2bb12a2c1c42c3d48d5a1adc9d3d2", size = 14278147, upload-time = "2025-07-24T20:44:10.328Z" }, - { url = "https://files.pythonhosted.org/packages/1d/0f/571b2c7a3833ae419fe69ff7b479a78d313581785203cc70a8db90121b9a/numpy-2.3.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938065908d1d869c7d75d8ec45f735a034771c6ea07088867f713d1cd3bbbe4f", size = 16635989, upload-time = "2025-07-24T20:44:34.88Z" }, - { url = "https://files.pythonhosted.org/packages/24/5a/84ae8dca9c9a4c592fe11340b36a86ffa9fd3e40513198daf8a97839345c/numpy-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66459dccc65d8ec98cc7df61307b64bf9e08101f9598755d42d8ae65d9a7a6ee", size = 16053052, upload-time = "2025-07-24T20:44:58.872Z" }, - { url = "https://files.pythonhosted.org/packages/57/7c/e5725d99a9133b9813fcf148d3f858df98511686e853169dbaf63aec6097/numpy-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7af9ed2aa9ec5950daf05bb11abc4076a108bd3c7db9aa7251d5f107079b6a6", size = 18577955, upload-time = "2025-07-24T20:45:26.714Z" }, - { url = "https://files.pythonhosted.org/packages/ae/11/7c546fcf42145f29b71e4d6f429e96d8d68e5a7ba1830b2e68d7418f0bbd/numpy-2.3.2-cp313-cp313-win32.whl", hash = "sha256:906a30249315f9c8e17b085cc5f87d3f369b35fedd0051d4a84686967bdbbd0b", size = 6311843, upload-time = "2025-07-24T20:49:24.444Z" }, - { url = "https://files.pythonhosted.org/packages/aa/6f/a428fd1cb7ed39b4280d057720fed5121b0d7754fd2a9768640160f5517b/numpy-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:c63d95dc9d67b676e9108fe0d2182987ccb0f11933c1e8959f42fa0da8d4fa56", size = 12782876, upload-time = "2025-07-24T20:49:43.227Z" }, - { url = "https://files.pythonhosted.org/packages/65/85/4ea455c9040a12595fb6c43f2c217257c7b52dd0ba332c6a6c1d28b289fe/numpy-2.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:b05a89f2fb84d21235f93de47129dd4f11c16f64c87c33f5e284e6a3a54e43f2", size = 10192786, upload-time = "2025-07-24T20:49:59.443Z" }, - { url = "https://files.pythonhosted.org/packages/80/23/8278f40282d10c3f258ec3ff1b103d4994bcad78b0cba9208317f6bb73da/numpy-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e6ecfeddfa83b02318f4d84acf15fbdbf9ded18e46989a15a8b6995dfbf85ab", size = 21047395, upload-time = "2025-07-24T20:45:58.821Z" }, - { url = "https://files.pythonhosted.org/packages/1f/2d/624f2ce4a5df52628b4ccd16a4f9437b37c35f4f8a50d00e962aae6efd7a/numpy-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:508b0eada3eded10a3b55725b40806a4b855961040180028f52580c4729916a2", size = 14300374, upload-time = "2025-07-24T20:46:20.207Z" }, - { url = "https://files.pythonhosted.org/packages/f6/62/ff1e512cdbb829b80a6bd08318a58698867bca0ca2499d101b4af063ee97/numpy-2.3.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:754d6755d9a7588bdc6ac47dc4ee97867271b17cee39cb87aef079574366db0a", size = 5228864, upload-time = "2025-07-24T20:46:30.58Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8e/74bc18078fff03192d4032cfa99d5a5ca937807136d6f5790ce07ca53515/numpy-2.3.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f66e7d2b2d7712410d3bc5684149040ef5f19856f20277cd17ea83e5006286", size = 6737533, upload-time = "2025-07-24T20:46:46.111Z" }, - { url = "https://files.pythonhosted.org/packages/19/ea/0731efe2c9073ccca5698ef6a8c3667c4cf4eea53fcdcd0b50140aba03bc/numpy-2.3.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6ea4e5a65d5a90c7d286ddff2b87f3f4ad61faa3db8dabe936b34c2275b6f8", size = 14352007, upload-time = "2025-07-24T20:47:07.1Z" }, - { url = "https://files.pythonhosted.org/packages/cf/90/36be0865f16dfed20f4bc7f75235b963d5939707d4b591f086777412ff7b/numpy-2.3.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3ef07ec8cbc8fc9e369c8dcd52019510c12da4de81367d8b20bc692aa07573a", size = 16701914, upload-time = "2025-07-24T20:47:32.459Z" }, - { url = "https://files.pythonhosted.org/packages/94/30/06cd055e24cb6c38e5989a9e747042b4e723535758e6153f11afea88c01b/numpy-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:27c9f90e7481275c7800dc9c24b7cc40ace3fdb970ae4d21eaff983a32f70c91", size = 16132708, upload-time = "2025-07-24T20:47:58.129Z" }, - { url = "https://files.pythonhosted.org/packages/9a/14/ecede608ea73e58267fd7cb78f42341b3b37ba576e778a1a06baffbe585c/numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:07b62978075b67eee4065b166d000d457c82a1efe726cce608b9db9dd66a73a5", size = 18651678, upload-time = "2025-07-24T20:48:25.402Z" }, - { url = "https://files.pythonhosted.org/packages/40/f3/2fe6066b8d07c3685509bc24d56386534c008b462a488b7f503ba82b8923/numpy-2.3.2-cp313-cp313t-win32.whl", hash = "sha256:c771cfac34a4f2c0de8e8c97312d07d64fd8f8ed45bc9f5726a7e947270152b5", size = 6441832, upload-time = "2025-07-24T20:48:37.181Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ba/0937d66d05204d8f28630c9c60bc3eda68824abde4cf756c4d6aad03b0c6/numpy-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:72dbebb2dcc8305c431b2836bcc66af967df91be793d63a24e3d9b741374c450", size = 12927049, upload-time = "2025-07-24T20:48:56.24Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ed/13542dd59c104d5e654dfa2ac282c199ba64846a74c2c4bcdbc3a0f75df1/numpy-2.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:72c6df2267e926a6d5286b0a6d556ebe49eae261062059317837fda12ddf0c1a", size = 10262935, upload-time = "2025-07-24T20:49:13.136Z" }, - { url = "https://files.pythonhosted.org/packages/c9/7c/7659048aaf498f7611b783e000c7268fcc4dcf0ce21cd10aad7b2e8f9591/numpy-2.3.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a", size = 20950906, upload-time = "2025-07-24T20:50:30.346Z" }, - { url = "https://files.pythonhosted.org/packages/80/db/984bea9d4ddf7112a04cfdfb22b1050af5757864cfffe8e09e44b7f11a10/numpy-2.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b", size = 14185607, upload-time = "2025-07-24T20:50:51.923Z" }, - { url = "https://files.pythonhosted.org/packages/e4/76/b3d6f414f4eca568f469ac112a3b510938d892bc5a6c190cb883af080b77/numpy-2.3.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125", size = 5114110, upload-time = "2025-07-24T20:51:01.041Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d2/6f5e6826abd6bca52392ed88fe44a4b52aacb60567ac3bc86c67834c3a56/numpy-2.3.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19", size = 6642050, upload-time = "2025-07-24T20:51:11.64Z" }, - { url = "https://files.pythonhosted.org/packages/c4/43/f12b2ade99199e39c73ad182f103f9d9791f48d885c600c8e05927865baf/numpy-2.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f", size = 14296292, upload-time = "2025-07-24T20:51:33.488Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f9/77c07d94bf110a916b17210fac38680ed8734c236bfed9982fd8524a7b47/numpy-2.3.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5", size = 16638913, upload-time = "2025-07-24T20:51:58.517Z" }, - { url = "https://files.pythonhosted.org/packages/9b/d1/9d9f2c8ea399cc05cfff8a7437453bd4e7d894373a93cdc46361bbb49a7d/numpy-2.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58", size = 16071180, upload-time = "2025-07-24T20:52:22.827Z" }, - { url = "https://files.pythonhosted.org/packages/4c/41/82e2c68aff2a0c9bf315e47d61951099fed65d8cb2c8d9dc388cb87e947e/numpy-2.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0", size = 18576809, upload-time = "2025-07-24T20:52:51.015Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/4b4fd3efb0837ed252d0f583c5c35a75121038a8c4e065f2c259be06d2d8/numpy-2.3.2-cp314-cp314-win32.whl", hash = "sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2", size = 6366410, upload-time = "2025-07-24T20:56:44.949Z" }, - { url = "https://files.pythonhosted.org/packages/11/9e/b4c24a6b8467b61aced5c8dc7dcfce23621baa2e17f661edb2444a418040/numpy-2.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b", size = 12918821, upload-time = "2025-07-24T20:57:06.479Z" }, - { url = "https://files.pythonhosted.org/packages/0e/0f/0dc44007c70b1007c1cef86b06986a3812dd7106d8f946c09cfa75782556/numpy-2.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910", size = 10477303, upload-time = "2025-07-24T20:57:22.879Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3e/075752b79140b78ddfc9c0a1634d234cfdbc6f9bbbfa6b7504e445ad7d19/numpy-2.3.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e", size = 21047524, upload-time = "2025-07-24T20:53:22.086Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6d/60e8247564a72426570d0e0ea1151b95ce5bd2f1597bb878a18d32aec855/numpy-2.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45", size = 14300519, upload-time = "2025-07-24T20:53:44.053Z" }, - { url = "https://files.pythonhosted.org/packages/4d/73/d8326c442cd428d47a067070c3ac6cc3b651a6e53613a1668342a12d4479/numpy-2.3.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b", size = 5228972, upload-time = "2025-07-24T20:53:53.81Z" }, - { url = "https://files.pythonhosted.org/packages/34/2e/e71b2d6dad075271e7079db776196829019b90ce3ece5c69639e4f6fdc44/numpy-2.3.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2", size = 6737439, upload-time = "2025-07-24T20:54:04.742Z" }, - { url = "https://files.pythonhosted.org/packages/15/b0/d004bcd56c2c5e0500ffc65385eb6d569ffd3363cb5e593ae742749b2daa/numpy-2.3.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0", size = 14352479, upload-time = "2025-07-24T20:54:25.819Z" }, - { url = "https://files.pythonhosted.org/packages/11/e3/285142fcff8721e0c99b51686426165059874c150ea9ab898e12a492e291/numpy-2.3.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0", size = 16702805, upload-time = "2025-07-24T20:54:50.814Z" }, - { url = "https://files.pythonhosted.org/packages/33/c3/33b56b0e47e604af2c7cd065edca892d180f5899599b76830652875249a3/numpy-2.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2", size = 16133830, upload-time = "2025-07-24T20:55:17.306Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ae/7b1476a1f4d6a48bc669b8deb09939c56dd2a439db1ab03017844374fb67/numpy-2.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf", size = 18652665, upload-time = "2025-07-24T20:55:46.665Z" }, - { url = "https://files.pythonhosted.org/packages/14/ba/5b5c9978c4bb161034148ade2de9db44ec316fab89ce8c400db0e0c81f86/numpy-2.3.2-cp314-cp314t-win32.whl", hash = "sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1", size = 6514777, upload-time = "2025-07-24T20:55:57.66Z" }, - { url = "https://files.pythonhosted.org/packages/eb/46/3dbaf0ae7c17cdc46b9f662c56da2054887b8d9e737c1476f335c83d33db/numpy-2.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b", size = 13111856, upload-time = "2025-07-24T20:56:17.318Z" }, - { url = "https://files.pythonhosted.org/packages/c1/9e/1652778bce745a67b5fe05adde60ed362d38eb17d919a540e813d30f6874/numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631", size = 10544226, upload-time = "2025-07-24T20:56:34.509Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ea/50ebc91d28b275b23b7128ef25c3d08152bc4068f42742867e07a870a42a/numpy-2.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:14a91ebac98813a49bc6aa1a0dfc09513dcec1d97eaf31ca21a87221a1cdcb15", size = 21130338, upload-time = "2025-07-24T20:57:54.37Z" }, - { url = "https://files.pythonhosted.org/packages/9f/57/cdd5eac00dd5f137277355c318a955c0d8fb8aa486020c22afd305f8b88f/numpy-2.3.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:71669b5daae692189540cffc4c439468d35a3f84f0c88b078ecd94337f6cb0ec", size = 14375776, upload-time = "2025-07-24T20:58:16.303Z" }, - { url = "https://files.pythonhosted.org/packages/83/85/27280c7f34fcd305c2209c0cdca4d70775e4859a9eaa92f850087f8dea50/numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:69779198d9caee6e547adb933941ed7520f896fd9656834c300bdf4dd8642712", size = 5304882, upload-time = "2025-07-24T20:58:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/48/b4/6500b24d278e15dd796f43824e69939d00981d37d9779e32499e823aa0aa/numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2c3271cc4097beb5a60f010bcc1cc204b300bb3eafb4399376418a83a1c6373c", size = 6818405, upload-time = "2025-07-24T20:58:37.341Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c9/142c1e03f199d202da8e980c2496213509291b6024fd2735ad28ae7065c7/numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446acd11fe3dc1830568c941d44449fd5cb83068e5c70bd5a470d323d448296", size = 14419651, upload-time = "2025-07-24T20:58:59.048Z" }, - { url = "https://files.pythonhosted.org/packages/8b/95/8023e87cbea31a750a6c00ff9427d65ebc5fef104a136bfa69f76266d614/numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa098a5ab53fa407fded5870865c6275a5cd4101cfdef8d6fafc48286a96e981", size = 16760166, upload-time = "2025-07-24T21:28:56.38Z" }, - { url = "https://files.pythonhosted.org/packages/78/e3/6690b3f85a05506733c7e90b577e4762517404ea78bab2ca3a5cb1aeb78d/numpy-2.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6936aff90dda378c09bea075af0d9c675fe3a977a9d2402f95a87f440f59f619", size = 12977811, upload-time = "2025-07-24T21:29:18.234Z" }, -] - -[[package]] -name = "open-echo-web" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "fastapi" }, - { name = "httpx" }, - { name = "jinja2" }, - { name = "numpy" }, - { name = "pydantic-settings" }, - { name = "pyserial" }, - { name = "pyserial-asyncio" }, - { name = "pyserial-asyncio-fast" }, - { name = "python-multipart" }, - { name = "uvicorn" }, - { name = "websockets" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastapi", specifier = ">=0.116.1" }, - { name = "httpx", specifier = ">=0.28.1" }, - { name = "jinja2", specifier = ">=3.1.6" }, - { name = "numpy", specifier = ">=2.3.2" }, - { name = "pydantic-settings", specifier = ">=2.10.1" }, - { name = "pyserial", specifier = "<3.5" }, - { name = "pyserial-asyncio", specifier = ">=0.6" }, - { name = "pyserial-asyncio-fast", specifier = ">=0.16" }, - { name = "python-multipart", specifier = ">=0.0.20" }, - { name = "uvicorn", specifier = ">=0.35.0" }, - { name = "websockets", specifier = ">=15.0.1" }, -] - -[[package]] -name = "pydantic" -version = "2.11.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.33.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, - { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, - { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, - { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, - { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, - { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, - { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, - { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, - { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, - { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, - { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, - { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, -] - -[[package]] -name = "pydantic-settings" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, -] - -[[package]] -name = "pyserial" -version = "3.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/74/11b04703ec416717b247d789103277269d567db575d2fd88f25d9767fe3d/pyserial-3.4.tar.gz", hash = "sha256:6e2d401fdee0eab996cf734e67773a0143b932772ca8b42451440cfed942c627", size = 151657, upload-time = "2017-07-23T21:10:04.368Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/e4/2a744dd9e3be04a0c0907414e2a01a7c88bb3915cbe3c8cc06e209f59c30/pyserial-3.4-py2.py3-none-any.whl", hash = "sha256:e0770fadba80c31013896c7e6ef703f72e7834965954a78e71a3049488d4d7d8", size = 193717, upload-time = "2017-07-23T21:10:01.982Z" }, -] - -[[package]] -name = "pyserial-asyncio" -version = "0.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyserial" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4a/9a/8477699dcbc1882ea51dcff4d3c25aa3f2063ed8f7d7a849fd8f610506b6/pyserial-asyncio-0.6.tar.gz", hash = "sha256:b6032923e05e9d75ec17a5af9a98429c46d2839adfaf80604d52e0faacd7a32f", size = 31322, upload-time = "2021-09-30T22:29:02.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/24/c820cf15f87f7b164e83710c1852d4f900d9793961579e5ef64189bc0c10/pyserial_asyncio-0.6-py3-none-any.whl", hash = "sha256:de9337922619421b62b9b1a84048634b3ac520e1d690a674ed246a2af7ce1fc5", size = 7594, upload-time = "2021-09-30T22:29:00.12Z" }, -] - -[[package]] -name = "pyserial-asyncio-fast" -version = "0.16" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyserial" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/d1/6c444e0f6b886345a7993d358c6734ccc440521cdca4999601e86f111708/pyserial_asyncio_fast-0.16.tar.gz", hash = "sha256:fd52643380406739d777014b0aea0873d756b542eb62f7556567239cec007115", size = 32696, upload-time = "2025-03-27T02:35:20.624Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/19/f76987bad313bb2dabf21914c1ec7441a1e846f05764f9948f1ccc2640a8/pyserial_asyncio_fast-0.16-py3-none-any.whl", hash = "sha256:88939d94e341a04c0c8bc3c1ed4e874439cb5a1e21ccfb0fd7315a8e45df1687", size = 9729, upload-time = "2025-03-27T02:35:19.062Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, -] - -[[package]] -name = "python-multipart" -version = "0.0.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "starlette" -version = "0.47.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/57/d062573f391d062710d4088fa1369428c38d51460ab6fedff920efef932e/starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8", size = 2583948, upload-time = "2025-07-20T17:31:58.522Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.14.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.35.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, -] - -[[package]] -name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, - { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, - { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, - { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, - { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, - { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, -] diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000..19226ac --- /dev/null +++ b/_config.yml @@ -0,0 +1,87 @@ +# Welcome to Jekyll! +# +# This config file is meant for settings that affect your whole blog, values +# which you are expected to set up once and rarely edit after that. If you find +# yourself editing this file very often, consider using Jekyll's data files +# feature for the data you need to update frequently. +# +# For technical reasons, this file is *NOT* reloaded automatically when you use +# 'bundle exec jekyll serve'. If you change this file, please restart the server process. +# +# If you need help with YAML syntax, here are some quick references for you: +# https://learn-the-web.algonquindesign.ca/topics/markdown-yaml-cheat-sheet/#yaml +# https://learnxinyminutes.com/docs/yaml/ +# +# Site settings +# These are used to personalize your new site. If you look in the HTML files, +# you will see them accessed via {{ site.title }}, {{ site.email }}, and so on. +# You can create any custom variable you would like, and they will be accessible +# in the templates via {{ site.myvariable }}. + +title: Open Echo +description: >- + Universal Open-Source SONAR Controller and Development Stack. + An ongoing open-source hardware and software project for building + sonar systems for testing, boating, bathymetry, and research. +baseurl: "/open_echo" +url: "https://neumi.github.io" +github_username: Neumi + +# Build settings +theme: just-the-docs +plugins: + - jekyll-feed + - jekyll-readme-index + - jekyll-gfm-admonitions +# - jekyll-relative-links + +# relative_links: +# enabled: true +# collections: true + +# Use README.md as the site's index +readme_index: + enabled: true + with_frontmatter: true + +# Just the Docs configuration +just_the_docs: + # Set the navigation to use a side-bar + navigation: true + search_enabled: true + color_scheme: light + heading_anchors: true + +aux_links: + GitHub: + - https://github.com/Neumi/open_echo + +include: + - README.md + - documentation/** + +exclude: + - development/ + - software/ + - Gemfile + - Gemfile.lock + - .githooks + - .github + - python/ + - .gitignore + - '**/*.py' + - pyproject.toml + - uv.lock + - '**/*.ino' + - '**/*.h' + - '**/*.cpp' + - '**/*.json' + - '**/*.yml' + - '**/*.toml' + - '**/*.csv' + - '**/*.step' + - '**/*.kicad*' + - '**/*.zip' + - '**/*.gbr*' + - '**/*.drl' + - '**/*.ipc' \ No newline at end of file diff --git a/documentation/contributing.md b/documentation/contributing.md new file mode 100644 index 0000000..9b27b5a --- /dev/null +++ b/documentation/contributing.md @@ -0,0 +1,79 @@ +--- +layout: default +title: Contributing +has_children: true +nav_order: 2 +--- + + +# Contributing + +This project welcomes contributions from the community. There are 3 main aspects to the project: + +- Hardware, the TUSS4470 shields which can be used to drive transducers +- Firmware, written in arduino IDE and uploaded to various microcontroller boards (which the TUSS4470 shields connect to) +- Software, python code for viewing the outputs from the firmware/hardware (in future also to be used for configuring them!) + +## Firmware + +### Prerequisites +- Git +- Arduino IDE (or VSCode with Arduino Community extension) + +### Getting started +1. Clone the repository +2. Open arduino IDE (or VSCode with Arduino community extension) the sketch for the board you are planning to develop for +3. Select your board - you may need to install the relevant library. +4. Make your changes +5. Upload the sketch! + +## Python Software + +### Prerequisites +- Git +- Python (installed on your system) +- uv (see install instructions: https://docs.astral.sh/uv/) + +### Getting started +1. Clone the repository +2. Set up the environment and install dependencies: + ``` + uv sync + ``` + This will create a virtual environment and install dependencies defined in pyproject.toml. + +### Git hooks +The repository provides optional Git hooks to run typechecks, linting and unit tests: + +``` +git config core.hooksPath .githooks +chmod +x .githooks/* +``` + +If you want to commit without these checks (e.g. when you haven't written unit tests yet!) you can use `git commit --no-verify` + +### Formatting, linting, typecheck and test +- Format: + ``` + uvx ruff format + ``` +- Lint and auto-fix: + ``` + uvx ruff check --fix + ``` +- Typecheck: + ``` + uv run mypy + ``` +- Unit test + ``` + uv run pytest + ``` + +## Contribution workflow +- Fork and create a new branch for your changes. +- Keep commits focused and descriptive. +- Ensure formatting, linting, and tests pass before opening a pull request. +- Submit a PR with a clear summary of changes and any relevant context. + +Thank you for contributing. \ No newline at end of file diff --git a/TUSS4470_shield_002/getting_started_TUSS4470_firmware.md b/documentation/getting_started/TUSS4470_firmware.md similarity index 86% rename from TUSS4470_shield_002/getting_started_TUSS4470_firmware.md rename to documentation/getting_started/TUSS4470_firmware.md index 1b237a0..13ef749 100644 --- a/TUSS4470_shield_002/getting_started_TUSS4470_firmware.md +++ b/documentation/getting_started/TUSS4470_firmware.md @@ -1,15 +1,17 @@ +--- +layout: default +title: TUSS4470 Firmware +parent: Getting Started +nav_order: 2 +--- # Getting Started with Arduino TUSS4470 Firmware -This repository provides two software components to support the TUSS4470 Arduino Shield: -- [Arduino Firmware](arduino/TUSS4470_arduino/TUSS4470_arduino.ino) - Runs on an Arduino UNO board with the TUSS4470 shield and handles signal generation, echo capture, and communication. -- [Open Echo Interface](echo_interface.py) - A desktop application that communicates with the Arduino, displays echo data as a real-time waterfall chart, and will soon allow runtime configuration of the system. - ## Arduino Firmware The Arduino firmware initializes the TUSS4470 device and manages the ultrasonic signal transmission and echo reception cycle. It sends digitized echo data over the serial interface to a host computer for analysis. -Key Features +**Key Features** - SPI communication with TUSS4470 chip - Configurable drive frequency - Adjustable sampling size (defines detection range) @@ -18,18 +20,18 @@ The Arduino firmware initializes the TUSS4470 device and manages the ultrasonic - Binary data transfer to Python software > [!NOTE] -> The firmware is designed to be easily modified. Users are encouraged to experiment with parameters to suit their application needs. +> The firmware is designed to be easily modified. Users are encouraged to experiment with parameters to suit their needs. ## Uploading the Firmware 1. Open the Arduino IDE. 2. Select your Arduino UNO board and the correct COM port. 3. Set the configuration as described below. -4. Load the firmware sketch [TUSS4470_arduino.ino](arduino/TUSS4470_arduino/TUSS4470_arduino.ino). +4. Load the firmware sketch [TUSS4470_arduino.ino](https://github.com/neumi/open_echo/tree/main/TUSS4470_shield_002/arduino/TUSS4470_arduino/TUSS4470_arduino.ino). 5. Upload the sketch to the Arduino UNO. ## βš™οΈ Configuration Parameters -Below are the key parameters used to control the ultrasonic transducer behavior, echo processing, filtering and outputs. `NUM_SAMPLES` must be kept in sync with the [Open Echo Interface](echo_interface.py). Due to RAM limitations on the Arduino UNO R3, it can't exceed ~1800 samples. The Arduino UNO R4 can reach ~12000 samples. +Below are the key parameters used to control the ultrasonic transducer behavior, echo processing, filtering and outputs. `NUM_SAMPLES` must be kept in sync with the [Open Echo Interface](documentation/getting_started/desktop_interface.md). Due to RAM limitations on the Arduino UNO R3, it can't exceed ~1800 samples. The Arduino UNO R4 can reach ~12000 samples. ### πŸ“Š Settings diff --git a/TUSS4470_shield_002/README.md b/documentation/getting_started/TUSS4470_hardware.md similarity index 76% rename from TUSS4470_shield_002/README.md rename to documentation/getting_started/TUSS4470_hardware.md index 1782b09..2e6459b 100644 --- a/TUSS4470_shield_002/README.md +++ b/documentation/getting_started/TUSS4470_hardware.md @@ -1,3 +1,10 @@ +--- +layout: default +title: TUSS4470 Hardware +parent: Getting Started +nav_order: 1 +--- + # Open Echo TUSS4470 Shield Getting Started Guide The TUSS4470 is an ultrasonic driver and receiver IC designed for seamless interaction with ultrasonic transducers. The TUSS4470 Arduino Shield is a development board that enables quick evaluation of the TUSS4470's features using the Arduino UNO platform. @@ -13,15 +20,17 @@ Upload one of the provided example sketches to explore different features of the Use the Python software to see the echoes. ### Ordering -The shield can be easily ordered via [JLCPCB](https://jlcpcb.com/?from=Neumi) or other PCB fabrication services. +If you need the hardware, you can order it using the [Hardware Files](https://github.com/neumi/open_echo/tree/main/TUSS4470_shield_002/TUSS4470_shield_hardware/TUSS4470_shield) from a board + SMT house ([JLC recommended](https://jlcpcb.com/?from=Neumi)). + +They can also be bought as a complete and tested set direclty from Elecrow: https://www.elecrow.com/open-echo-tuss4470-development-shield.html -TUSS4470 schematic +If they’re out of stock, or if you’d prefer to order them within Germany to reduce shipping costs, please send me an email at: openechoes@gmail.com -> [!Note] -> I just got a few new boards, I sell as starter kits. Feel free to DM me on [Discord](https://discord.com/invite/rerCyqAcrw) if you're interested. Or send an email to: openechoes@gmail.com One assmebled board is 50€ + shipping. (10/2025) +All profits go directly toward supporting and advancing the Open Echo project! +If you don't order the boards directly from me or Elecrow, please be aware that I can't provide support. -PCB overview TUSS4470 +PCB overview TUSS4470 ## Electrical Connections @@ -50,7 +59,7 @@ If you need to drive transducers at frequencies other than the provided presets > On board version 002, "Custom" is pre-selected to match the right capacitances together with the 200kHz capacitors for 150kHz. Below is the electrical connection layout for the cINN and cFLT capacitors and jumpers: -TUSS4470 schematic +TUSS4470 schematic ### Power Supply Options The board supports two power input options: @@ -63,7 +72,7 @@ Use this if you require higher voltage (up to 28V max) for more powerful transdu - MT3608 Boost Converter: You can add an MT3608 boost converter to your board to generate a higher vDRV from the USB 5V supply. This solution is reliable and works well in many applications, such as powering marine transducers. Simply take the MT3608 module (included in the starter kits), secure it to the shield with double-sided tape, and connect the three wires as shown: -TUSS4470 schematic +TUSS4470 schematic > [!Tip] > To get started, use a 12V power supply. Many ultrasonic transducers operate reliably at this voltage. @@ -86,14 +95,14 @@ Connect your PZT crystal or preassembled ultrasonic transducer to the "Transduce > For transducer connections exceeding 10 cm in length, use coaxial cable. Connect the cable shield to the transducer ground (GND). The recommended setup is illustrated below: -TUSS4470 Board ready to use +TUSS4470 Board ready to use > [!Important] > Always connect GND/Shield to the TOP pin on the transducer. > Using the wrong pin increases powerline noise and significantly weakens the signal. Below: Comparison of a transducer wired incorrectly (left half) vs. correctly (right half). -Powerline noise on transducer cable shield +Powerline noise on transducer cable shield Next Steps: Proceed to [Getting Started with Arduino TUSS4470 Firmware](getting_started_TUSS4470_firmware.md). diff --git a/TUSS4470_shield_002/getting_started_interface.md b/documentation/getting_started/desktop_interface.md similarity index 69% rename from TUSS4470_shield_002/getting_started_interface.md rename to documentation/getting_started/desktop_interface.md index 9065aba..cb6fd4f 100644 --- a/TUSS4470_shield_002/getting_started_interface.md +++ b/documentation/getting_started/desktop_interface.md @@ -1,6 +1,13 @@ +--- +layout: default +title: Desktop Interface +parent: Getting Started +nav_order: 3 +--- + # Getting Started Open Echo Interface Software -The [***Open Echo Interface***](echo_interface.py) is a cross-platform Python application that interacts with the Arduino + TUSS4470 Shield. +The ***Open Echo Interface*** is a cross-platform Python application that interacts with the Arduino + TUSS4470 Shield. It displays ultrasonic echo data in real-time using a waterfall chart visualization. The application is intended primarily as a testing and development tool, but is stable enough for continuous use -tested for several days on a Raspberry Pi 4 without issues. @@ -17,44 +24,37 @@ The application is intended primarily as a testing and development tool, but is ## Installation & Setup -### 1. Create and activate a virtual environment - -```bash -cd open_echo/TUSS4470_shield_002 -python3 -m venv venv -source venv/bin/activate -``` - -### 2. Install requirements +###Β 1. Install openecho ```bash -pip install -r requirements.txt +pip install open-echo ``` -### 3. Start Open Echo Interface Software +### 2. Start Open Echo Interface Software +Run the following command to start the web server. ```bash -python echo_interface.py +openecho desktop ``` Select the correct COM port, then click Connect or press c on your keyboard. Once connected, the Open Echo board will begin streaming data, which will appear on the right side of the interface. The red horizontal line indicates the currently detected depth, based on the strongest first echo received after the ring-down delay. -Open Echo Interface Software +Open Echo Interface Software ### 4. Change to your own needs -You can change different settings in the first lines of the [**Open Echo Interface**](echo_interface.py) code to customize it to your specific use cases. +You can change different settings in the first lines of the [**Open Echo Interface**](https://github.com/neumi/open_echo/tree/main/python/src/open_echo/desktop.py) code to customize it to your specific use cases. ### πŸ“Š Parameter Settings | Parameter | Description | |------------------|-------------| | `BAUD_RATE` | Must match the baud rate configured in the Arduino firmware. | -| `NUM_SAMPLES` | Must match the `NUM_SAMPLES` value used in the Arduino firmware. | +| `NUM_SAMPLES` | Must match the `NUM_SAMPLES` value used in the Arduino firmware. Can be overriden in the interface settings | | `MAX_ROWS` | Sets the number of historical measurements displayed in the chart before it scrolls. | | `Y_LABEL_DISTANCE`| Defines the vertical axis label spacing, in centimeters. | -| `SPEED_OF_SOUND` | Used to convert sample timing into distance. Set to ~330 for air, ~1450 for water. | -| `SAMPLE_TIME` | Sampling interval in microseconds. For the Arduino UNO with [TUSS4470_arduino.ino](arduino/TUSS4470_arduino/TUSS4470_arduino.ino), this must be set to **13.2 Β΅s**. | +| `SPEED_OF_SOUND` | Used to convert sample timing into distance. Set to ~330 for air, ~1450 for water. Can be overriden in the interface settings| +| `SAMPLE_TIME` | Sampling interval in microseconds. For the Arduino UNO with [TUSS4470_arduino.ino](arduino/TUSS4470_arduino/TUSS4470_arduino.ino), this must be set to **13.2 Β΅s**. Can be overriden in the interface settings| --- diff --git a/documentation/getting_started/index.md b/documentation/getting_started/index.md new file mode 100644 index 0000000..34355ec --- /dev/null +++ b/documentation/getting_started/index.md @@ -0,0 +1,15 @@ +--- +layout: default +title: Getting Started +has_children: true +nav_order: 1 +--- + +Getting started guides for Open Echo. + +First, you will need an [Open Echo TUSS4470 Arduino Shield](documentation/getting_started/TUSS4470_hardware.md). + +This repository provides two software components to support the TUSS4470 Arduino Shield: +- [Arduino Firmware](documentation/getting_started/TUSS4470_firmware.md) - Runs on an Arduino UNO board with the TUSS4470 shield and handles signal generation, echo capture, and communication. +- [Open Echo Interface](documentation/getting_started/desktop_interface.md) - A desktop application that communicates with the Arduino, displays echo data as a real-time waterfall chart, and will soon allow runtime configuration of the system. + diff --git a/TUSS4470_shield_002/getting_started_web_interface.md b/documentation/getting_started/web_interface.md similarity index 65% rename from TUSS4470_shield_002/getting_started_web_interface.md rename to documentation/getting_started/web_interface.md index 246b281..ea602a8 100644 --- a/TUSS4470_shield_002/getting_started_web_interface.md +++ b/documentation/getting_started/web_interface.md @@ -1,6 +1,13 @@ +--- +layout: default +title: Web Interface +parent: Getting Started +nav_order: 4 +--- + # Getting Started Open Echo Interface Software -The [***Open Echo Web Interface***](echo_interface.py) is a cross-platform Python application that interacts with the Arduino + TUSS4470 Shield. +The ***Open Echo Web Interface*** is a cross-platform Python application that interacts with the Arduino + TUSS4470 Shield. It displays ultrasonic echo data in real-time in the browser using a waterfall chart visualization. @@ -19,28 +26,14 @@ It displays ultrasonic echo data in real-time in the browser using a waterfall c ## Installation & Setup -### 1. Create and activate a virtual environment - -```bash -cd open_echo/TUSS4470_shield_002/web -python3 -m venv venv -source venv/bin/activate -``` - -### 2. Install requirements +###Β 1. Install openecho ```bash -pip install -r requirements.txt +pip install open-echo ``` -### 3. Start Open Echo Interface Software +### 2. Start Open Echo Interface Software Run the following command to start the web server. ```bash -python3 -m uvicorn --host=0.0.0.0 --port=8000 app:app +openecho web ``` -Then go to http://localhost:8000. The first connection will be redirected to /config to set up the connection, then you should see your echoes. - - ---- -Want to stay updated, have questions or want to participate? Join my [Discord](https://discord.com/invite/rerCyqAcrw)! - -Or write an issue. Thanks! +Then go to http://localhost:8000. The first connection will be redirected to /config to set up the connection, then you should see your echoes. \ No newline at end of file diff --git a/reverse_engineering/images/back.JPG b/documentation/lucky_fishfinder/images/back.JPG similarity index 100% rename from reverse_engineering/images/back.JPG rename to documentation/lucky_fishfinder/images/back.JPG diff --git a/reverse_engineering/images/echo_capture.jpg b/documentation/lucky_fishfinder/images/echo_capture.jpg similarity index 100% rename from reverse_engineering/images/echo_capture.jpg rename to documentation/lucky_fishfinder/images/echo_capture.jpg diff --git a/reverse_engineering/images/fishfinder_pins.JPG b/documentation/lucky_fishfinder/images/fishfinder_pins.JPG similarity index 100% rename from reverse_engineering/images/fishfinder_pins.JPG rename to documentation/lucky_fishfinder/images/fishfinder_pins.JPG diff --git a/reverse_engineering/images/front.JPG b/documentation/lucky_fishfinder/images/front.JPG similarity index 100% rename from reverse_engineering/images/front.JPG rename to documentation/lucky_fishfinder/images/front.JPG diff --git a/reverse_engineering/README.md b/documentation/lucky_fishfinder/lucky_fishfinder.md similarity index 79% rename from reverse_engineering/README.md rename to documentation/lucky_fishfinder/lucky_fishfinder.md index e921abd..0609ece 100644 --- a/reverse_engineering/README.md +++ b/documentation/lucky_fishfinder/lucky_fishfinder.md @@ -1,3 +1,9 @@ +--- +layout: default +title: Lucky Fishfinder +nav_order: 9 +--- + # open_echo Reverse engineering of the LUCKY fishfinder to learn about ultrasonics projects from a real product. @@ -7,10 +13,10 @@ Using a trigger and analog pin on an Arduino UNO we can read RAW echo data from As of today, there are at least three hardware versions of the LUCKY fishfinder. All of them seem to follow a similar concept, but the pinout is different! LUCKY fishfinder pins: -LUCKY fishfinder pin hack +LUCKY fishfinder pin hack Measured results using LUCKY fishfinder, FastLOGIC (Arduino) and Matplotlib + Python: -LUCKY fishfinder pin hack +LUCKY fishfinder pin hack The chart shows a measurement of reflection time (translated to cm using 1482m/s speed of sound in water) and the past 50 measurements. The LUCKY fish finder takes around 2.3 full measurements per second. The brigter the pixel, the stronger the return signal. The plot shows the sandy ground in the first 1/4 and the rest is the reflection of a metal ladder in the water (horizontal). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3352f4e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,73 @@ +[project] +name = "open_echo" +description = "Python library for OpenEcho boards" +readme = "README.md" +requires-python = ">=3.11" +dynamic = ["version"] +dependencies = [ + "fastapi>=0.116.1", + "httpx>=0.28.1", + "jinja2>=3.1.6", + "numpy>=2.3.2", + "pydantic-settings>=2.10.1", + "pyqt5>=5.15.11", + "pyqtdarktheme>=2.1.0", + "pyqtgraph>=0.14.0", + "qasync>=0.24.0", + "pyserial<3.5", + "pyserial-asyncio>=0.6", + "pyserial-asyncio-fast>=0.16", + "python-multipart>=0.0.20", + "uvicorn>=0.35.0", + "websockets>=15.0.1", +] + +[project.scripts] +openecho = "open_echo.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["python/src/open_echo"] + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "vcs" + +[dependency-groups] +dev = [ + "hypothesis>=6.148.7", + "mypy>=1.19.0", + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", + "pytest-cov>=7.0.0", + "types-pyserial>=3.5.0.20251001", +] + +[tool.mypy] +packages = ["open_echo"] +ignore_missing_imports = true + +[tool.ruff] +include = ["python/**/*.py"] + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", +] +ignore = [ + "E501", #Β line too long + "SIM105" # "with contextlib.suppress(Exception)" +] diff --git a/TUSS4470_shield_002/web/static/js-colormaps.js b/python/src/open_echo/assets/static/js-colormaps.js similarity index 100% rename from TUSS4470_shield_002/web/static/js-colormaps.js rename to python/src/open_echo/assets/static/js-colormaps.js diff --git a/TUSS4470_shield_002/web/static/style.css b/python/src/open_echo/assets/static/style.css similarity index 100% rename from TUSS4470_shield_002/web/static/style.css rename to python/src/open_echo/assets/static/style.css diff --git a/TUSS4470_shield_002/web/templates/config.html b/python/src/open_echo/assets/templates/config.html similarity index 100% rename from TUSS4470_shield_002/web/templates/config.html rename to python/src/open_echo/assets/templates/config.html diff --git a/TUSS4470_shield_002/web/templates/frontend.html b/python/src/open_echo/assets/templates/frontend.html similarity index 100% rename from TUSS4470_shield_002/web/templates/frontend.html rename to python/src/open_echo/assets/templates/frontend.html diff --git a/TUSS4470_shield_002/web/templates/spectrogram.js b/python/src/open_echo/assets/templates/spectrogram.js similarity index 100% rename from TUSS4470_shield_002/web/templates/spectrogram.js rename to python/src/open_echo/assets/templates/spectrogram.js diff --git a/python/src/open_echo/cli.py b/python/src/open_echo/cli.py new file mode 100644 index 0000000..bdb6300 --- /dev/null +++ b/python/src/open_echo/cli.py @@ -0,0 +1,28 @@ +import sys +from argparse import ArgumentParser + +from open_echo.desktop import run_desktop +from open_echo.web import run_web + + +def main(): + parser = ArgumentParser( + description="Command-line interface for the open_echo package." + ) + parser.add_argument( + "command", choices=["desktop", "web"], help="The command to run." + ) + + args = parser.parse_args() + + if args.command == "desktop": + run_desktop() + elif args.command == "web": + run_web() + else: + print("Unknown command. Please use 'desktop' or 'web'.") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/TUSS4470_shield_002/web/depth_output.py b/python/src/open_echo/depth_output.py similarity index 84% rename from TUSS4470_shield_002/web/depth_output.py rename to python/src/open_echo/depth_output.py index 05a7375..c1dc134 100644 --- a/TUSS4470_shield_002/web/depth_output.py +++ b/python/src/open_echo/depth_output.py @@ -1,14 +1,11 @@ -from abc import ABC, abstractmethod import asyncio -import logging -from httpx import AsyncClient -import websockets import json +from abc import ABC, abstractmethod from typing import Any -from settings import NMEAOffset, Settings - -log = logging.getLogger("uvicorn") +import websockets +from httpx import AsyncClient +from open_echo.settings import NMEAOffset, Settings class OutputManager: @@ -35,15 +32,21 @@ async def update_settings(self, new_settings: Settings): if method in output_methods ] self._output_classes = [cls(self.settings) for cls in new_output_classes] - log.info(f"Output classes: {self._output_classes}") + print(f"Output classes: {self._output_classes}") for output_class in self._output_classes: await output_class.start() - async def output(self): - """Override this in subclasses to define output behavior.""" + async def output(self) -> Any: for output_class in self._output_classes: - if output_class._current_value is not None: + if output_class.current_value is not None or ( + output_class.last_output_time is None + or ( + (asyncio.get_event_loop().time() - output_class.last_output_time) + >= (output_class.output_interval * 1000) + ) + ): + output_class.last_output_time = asyncio.get_event_loop().time() await output_class.output() async def _run(self): @@ -53,7 +56,6 @@ async def _run(self): continue await self.output() - await asyncio.sleep(1.0) def __enter__(self): self._task = asyncio.create_task(self._run()) @@ -67,7 +69,9 @@ def __exit__(self, exc_type, exc_value, traceback): class OutputMethod(ABC): def __init__(self, settings: Settings): self.settings = settings - self._current_value = None + self.current_value = None + self.last_output_time: float | None = None + self.output_interval = 1.0 # seconds @abstractmethod async def start(self): @@ -81,7 +85,7 @@ async def stop(self): def update(self, value: Any): """Update the current value.""" - self._current_value = value + self.current_value = value @abstractmethod async def output(self): @@ -124,16 +128,19 @@ async def get_token(self): access_request_uri = f"http://{uri}/signalk/v1/access/requests" async with AsyncClient() as client: - access_request = await client.post(access_request_uri, json={ - "clientId": "f6b20288-5ecf-4daa-9a13-1594bc145abe", - "description": "OpenEcho Depth Sounder" - }) + access_request = await client.post( + access_request_uri, + json={ + "clientId": "f6b20288-5ecf-4daa-9a13-1594bc145abe", + "description": "open_echo Depth Sounder", + }, + ) access_request.raise_for_status() poll_path = access_request.json().get("href") if not poll_path: raise ValueError("Failed to get poll URI from access request") - + poll_uri = f"http://{uri}{poll_path}" # Poll until approved (this is a simple implementation; consider adding timeout/retry) @@ -142,13 +149,15 @@ async def get_token(self): poll_response = await client.get(poll_uri) state = poll_response.json().get("state") await asyncio.sleep(1) - + if state != "COMPLETED": raise ValueError(f"Unknown access request state: {state}") access_request_response = poll_response.json().get("accessRequest") if access_request_response["permission"] != "APPROVED": - raise ValueError(f"SignalK access request not approved: {access_request_response['permission']}") + raise ValueError( + f"SignalK access request not approved: {access_request_response['permission']}" + ) self.settings.signalk_token = access_request_response.get("token") self.settings.save() @@ -156,8 +165,6 @@ async def get_token(self): return self.settings.signalk_token - - async def stop(self): if self._ws: await self._ws.close() @@ -166,14 +173,14 @@ async def stop(self): async def output(self): if self._ws is None: try: - log.info("Reconnecting to SignalK server...") + print("Reconnecting to SignalK server...") await self.start() except Exception as e: - log.error(f"SignalK connection error: {e}") + print(f"SignalK connection error: {e}") return try: # Format as SignalK delta message for depth - depth_m = self._current_value + depth_m = self.current_value values = [{"path": "environment.depth.belowTransducer", "value": depth_m}] # Add water depth and depth below keel if settings are present @@ -201,10 +208,10 @@ async def output(self): delta = {"updates": [{"values": values}]} - log.debug("Send signalk delta: %s", delta) + print("Send signalk delta: %s", delta) await self._ws.send(json.dumps(delta)) except Exception as e: - log.error(f"SignalK send error: {e}") + print(f"SignalK send error: {e}") # Attempt reconnect next time if self._ws: await self.stop() @@ -246,7 +253,7 @@ async def output(self): return try: # Send DBT and DPT sentences, ending with CRLF (NMEA standard) - depth_m = self._current_value + depth_m = self.current_value depth_ft = depth_m * 3.28084 depth_fathoms = depth_m * 0.546807 @@ -257,7 +264,9 @@ def calculate_checksum(sentence): return f"*{checksum:02X}" # DBT: Depth Below Transducer - dbt_sentence = f"SDDBT,{depth_ft:.1f},f,{depth_m:.1f},M,{depth_fathoms:.1f},F" + dbt_sentence = ( + f"SDDBT,{depth_ft:.1f},f,{depth_m:.1f},M,{depth_fathoms:.1f},F" + ) dbt_full = f"${dbt_sentence}{calculate_checksum(dbt_sentence)}\r\n" self._writer.write(dbt_full.encode()) diff --git a/TUSS4470_shield_002/echo_interface.py b/python/src/open_echo/desktop.py similarity index 67% rename from TUSS4470_shield_002/echo_interface.py rename to python/src/open_echo/desktop.py index 7029074..895cc05 100644 --- a/TUSS4470_shield_002/echo_interface.py +++ b/python/src/open_echo/desktop.py @@ -1,34 +1,38 @@ +# Async integration +import asyncio +import socket import sys +import time + import numpy as np +import pyqtgraph as pg +import qdarktheme import serial import serial.tools.list_ports -import struct -import time -import socket +from open_echo.echo import ConnectionTypeEnum + +# Use shared settings/readers +from open_echo.settings import Settings +from PyQt5.QtCore import QObject, Qt, pyqtSignal +from PyQt5.QtGui import QColor, QPalette from PyQt5.QtWidgets import ( QApplication, - QMainWindow, - QVBoxLayout, - QWidget, + QCheckBox, QComboBox, - QPushButton, + QHBoxLayout, QLabel, QLineEdit, + QMainWindow, + QPushButton, + QVBoxLayout, + QWidget, ) -from PyQt5.QtCore import QThread, pyqtSignal -import pyqtgraph as pg -import qdarktheme -from PyQt5.QtWidgets import ( - QHBoxLayout, -) -from PyQt5.QtCore import Qt -from PyQt5.QtGui import QPalette, QColor -from PyQt5.QtWidgets import QVBoxLayout, QLabel, QCheckBox, QLineEdit -from PyQt5.QtWidgets import QApplication +from qasync import QEventLoop # Serial Configuration BAUD_RATE = 250000 -NUM_SAMPLES = 1800 # (X-axis) +# Default values; overridden by WaterfallApp instance settings +NUM_SAMPLES = 1800 # (X-axis) MAX_ROWS = 300 # Number of time steps (Y-axis) Y_LABEL_DISTANCE = 50 # distance between labels in cm @@ -40,7 +44,9 @@ # SAMPLE_TIME = 47.0e-6 # SAMPLE_TIME = 41.666e-6 # 13.2 microseconds on Atmega328 max sample speed plus 40 microseconds delay in sampling loop # SAMPLE_TIME = 22.22e-6 # 13.2 microseconds on Atmega328 max sample speed plus 20 microseconds delay in sampling loop -SAMPLE_TIME = 13.2e-6 # 13.2 microseconds on Atmega328 max sample speed without additional delay +SAMPLE_TIME = ( + 13.2e-6 # 13.2 microseconds on Atmega328 max sample speed without additional delay +) # SAMPLE_TIME = 11.0e-6 # 13.2 microseconds on RP2040 max sample speed with 10 microseconds additional delay per sample # SAMPLE_TIME = 7.682e-6 # 7.682 microseconds on STM32F103 max sample speed # SAMPLE_TIME = 6.0e-6 # 6 microseconds on RP2040 max sample speed with 5 microseconds additional delay per sample @@ -48,43 +54,14 @@ DEFAULT_LEVELS = (0, 256) # Expected data range -SAMPLE_RESOLUTION = (SPEED_OF_SOUND * SAMPLE_TIME * 100) / 2 # cm per row (0.99 cm per row) -PACKET_SIZE = 1 + 6 + NUM_SAMPLES + 1 # header + payload + checksum -MAX_DEPTH = NUM_SAMPLES * SAMPLE_RESOLUTION # Total depth in cm -depth_labels = {int(i / SAMPLE_RESOLUTION): f"{i / 100}" for i in range(0, int(MAX_DEPTH), Y_LABEL_DISTANCE)} - - -def read_packet(ser): - while True: - header = ser.read(1) - if header != b"\xaa": - continue # Wait for the start byte - - payload = ser.read(6 + NUM_SAMPLES) - checksum = ser.read(1) - - if len(payload) != 6 + NUM_SAMPLES or len(checksum) != 1: - continue # Incomplete packet - - # Verify checksum - calc_checksum = 0 - for byte in payload: - calc_checksum ^= byte - if calc_checksum != checksum[0]: - print("⚠️ Checksum mismatch: {} != {}".format(calc_checksum, checksum[0])) - continue - - # Unpack payload (firmware sends little-endian raw struct bytes) - depth, temp_scaled, vDrv_scaled = struct.unpack(" 0: + self.main_app.set_num_samples(ns_value) + if st_us_value and st_us_value > 0: + # convert microseconds to seconds + self.main_app.set_sample_time(st_us_value * 1e-6) self.close() @@ -453,20 +351,29 @@ def apply_settings(self): class WaterfallApp(QMainWindow): def __init__(self): super().__init__() - self.serial_thread = None # βœ… Define it early to avoid AttributeError + self.serial_thread = None # kept for backward-compat, no longer used + + # Single async reader task (generic AsyncReader) + self._reader_task = None + self._reader_task_type: ConnectionTypeEnum | None = None self.nmea_enabled = False self.nmea_port = 10110 self.nmea_socket = None self.nmea_output_enabled = False - self.current_gradient = 'cyclic' # default color scheme + self.current_gradient = "cyclic" # default color scheme self.current_speed = SPEED_OF_SOUND # default sound speed (343) + # User-configurable sampling parameters + self.num_samples = NUM_SAMPLES + self.sample_time = SAMPLE_TIME + self.setWindowTitle("Open Echo Interface") self.setGeometry(0, 0, 480, 800) # Portrait mode for Raspberry Pi screen - self.data = np.zeros((MAX_ROWS, NUM_SAMPLES)) + self._recompute_sampling_derived() + self.data = np.zeros((MAX_ROWS, self.num_samples)) # Disable window translucency self.setAttribute(Qt.WA_TranslucentBackground, False) @@ -497,7 +404,7 @@ def __init__(self): main_layout.addWidget(self.waterfall) - inverted_depth_labels = list(depth_labels.items())[::-1] + inverted_depth_labels = list(self.depth_labels.items())[::-1] self.waterfall.getAxis("left").setTicks([inverted_depth_labels]) self.depth_line = pg.InfiniteLine(angle=0, pen=pg.mkPen("r", width=2)) self.waterfall.addItem(self.depth_line) @@ -508,14 +415,16 @@ def __init__(self): right_axis.setStyle(showValues=True) # dd horizontal lines - for i in range(0, int(MAX_DEPTH), Y_LABEL_DISTANCE): - row_index = int(i / SAMPLE_RESOLUTION) + self._depth_lines = [] + for i in range(0, int(self.max_depth), Y_LABEL_DISTANCE): + row_index = int(i / self.sample_resolution) hline = pg.InfiniteLine( pos=row_index, angle=0, pen=pg.mkPen(color="w", style=pg.QtCore.Qt.DotLine), ) self.waterfall.addItem(hline) + self._depth_lines.append(hline) # === Colorbar BELOW the plot to save width === self.colorbar = pg.HistogramLUTWidget() @@ -550,13 +459,15 @@ def __init__(self): # === Large Depth Display === self.large_depth_label = QLabel("--- m") self.large_depth_label.setAlignment(Qt.AlignCenter) - self.large_depth_label.setStyleSheet(""" + self.large_depth_label.setStyleSheet( + """ QLabel { color: #00ffcc; font-size: 64px; font-weight: bold; } - """) + """ + ) self.large_depth_label.setVisible(True) # hidden by default serial_row.addWidget(self.large_depth_label) @@ -615,33 +526,62 @@ def __init__(self): controls_container.setLayout(controls_layout) main_layout.addWidget(controls_container) - def connect_udp(self): - if hasattr(self, 'udp_thread') and self.udp_thread: - self.udp_thread.stop() - self.udp_thread = None + # Adapter to safely update UI from async packets + + class EchoAdapter(QObject): + packet_signal = pyqtSignal(object) + + def __init__(self, app_ref): + super().__init__() + self._app = app_ref + self.packet_signal.connect(self._on_packet) + def _on_packet(self, pkt): + try: + # EchoPacket fields: spectrogram, depth_index, temperature, drive_voltage + self._app.waterfall_plot_callback( + pkt.samples, + pkt.depth_index, + pkt.temperature, + pkt.drive_voltage, + ) + except Exception as e: + print(f"❌ UI packet handling error: {e}") + + async def emit(self, pkt): + self.packet_signal.emit(pkt) + + self._adapter = EchoAdapter(self) + + def connect_udp(self): try: udp_port = int(self.udp_port_input.text()) - self.udp_thread = UDPReader(port=udp_port) - self.udp_thread.data_received.connect(self.waterfall_plot_callback) - self.udp_thread.start() + settings = Settings( + connection_type=ConnectionTypeEnum.UDP, + udp_port=udp_port, + num_samples=self.num_samples, + ) + self._start_reader(settings) + self._reader_task_type = ConnectionTypeEnum.UDP print(f"βœ… UDP listener started on port {udp_port}") except Exception as e: print(f"❌ Failed to start UDP listener: {e}") def disconnect_udp(self): - if hasattr(self, 'udp_thread') and self.udp_thread: - self.udp_thread.stop() - self.udp_thread = None + if self._reader_task and self._reader_task_type == ConnectionTypeEnum.UDP: + self._stop_reader() + self._reader_task_type = None print("πŸ”Œ UDP listener stopped") + else: + print("⚠️ No active UDP connection to disconnect") def toggle_udp_connection(self): - if hasattr(self, 'udp_thread') and self.udp_thread and self.udp_thread.isRunning(): + if self._reader_task and self._reader_task_type == ConnectionTypeEnum.UDP: self.disconnect_udp() self.udp_connect_button.setText("Connect UDP") else: self.connect_udp() - if hasattr(self, 'udp_thread') and self.udp_thread.isRunning(): + if self._reader_task and self._reader_task_type == ConnectionTypeEnum.UDP: self.udp_connect_button.setText("Disconnect UDP") def set_large_depth_display(self, enabled: bool): @@ -652,6 +592,9 @@ def configure_nmea_output(self, enabled: bool, port: int): self.nmea_output_enabled = enabled self.nmea_port = port + self.nmea_server_socket: socket.socket | None + self.nmea_client_socket: socket.socket | None + # Close previous connections if needed if hasattr(self, "nmea_client_socket") and self.nmea_client_socket: try: @@ -669,8 +612,6 @@ def configure_nmea_output(self, enabled: bool, port: int): if enabled: try: - import socket - self.nmea_server_socket = socket.socket( socket.AF_INET, socket.SOCK_STREAM ) @@ -703,24 +644,13 @@ def set_gradient(self, gradient_name): self.colorbar.item.gradient.loadPreset(gradient_name) def set_sound_speed(self, speed): - global SPEED_OF_SOUND, SAMPLE_RESOLUTION, MAX_DEPTH, depth_labels - + global SPEED_OF_SOUND SPEED_OF_SOUND = speed self.current_speed = speed - SAMPLE_RESOLUTION = (SPEED_OF_SOUND * SAMPLE_TIME * 100) / 2 - print(SAMPLE_RESOLUTION) - MAX_DEPTH = NUM_SAMPLES * SAMPLE_RESOLUTION - depth_labels = { - int(i / SAMPLE_RESOLUTION): f"{i / 100}" - for i in range(0, int(MAX_DEPTH), Y_LABEL_DISTANCE) - } - - # Re-apply Y-axis ticks - inverted_depth_labels = list(depth_labels.items())[::-1] - self.waterfall.getAxis("left").setTicks([inverted_depth_labels]) - self.waterfall.getAxis("right").setTicks([inverted_depth_labels]) + self._recompute_sampling_derived() + self._refresh_axes_and_grid() - def keyPressEvent(self, event): + def key_press_event(self, event): print("key pressed") if event.key() == ord("Q"): print("πŸ›‘ Quit triggered from keyboard.") @@ -732,39 +662,36 @@ def keyPressEvent(self, event): super().keyPressEvent(event) def connect_serial(self): - if self.serial_thread: - self.serial_thread.stop() - self.serial_thread = None - selected_port = self.serial_dropdown.currentText() try: - self.serial_thread = SerialReader(selected_port, BAUD_RATE) - print(f"πŸš€ Using Serial reader on {selected_port}") - - self.serial_thread.data_received.connect(self.waterfall_plot_callback) - self.serial_thread.start() + settings = Settings( + connection_type=ConnectionTypeEnum.SERIAL, + serial_port=selected_port, + num_samples=self.num_samples, + ) + self._start_reader(settings) + self._reader_task_type = ConnectionTypeEnum.SERIAL print(f"βœ… Connected to {selected_port}") except Exception as e: print(f"❌ Connection failed: {e}") def toggle_serial_connection(self): - if self.serial_thread and self.serial_thread.isRunning(): + if self._reader_task and self._reader_task_type == ConnectionTypeEnum.SERIAL: self.disconnect_serial() self.connect_button.setText("Connect") else: self.connect_serial() - if self.serial_thread and self.serial_thread.isRunning(): + if ( + self._reader_task + and self._reader_task_type == ConnectionTypeEnum.SERIAL + ): self.connect_button.setText("Disconnect") def disconnect_serial(self): - if self.serial_thread: - try: - self.serial_thread.stop() - self.serial_thread.wait() # Ensure thread ends before continuing - self.serial_thread = None - print("πŸ”Œ Disconnected from serial device") - except Exception as e: - print(f"❌ Disconnection failed: {e}") + if self._reader_task and self._reader_task_type == ConnectionTypeEnum.SERIAL: + self._stop_reader() + self._reader_task_type = None + print("πŸ”Œ Disconnected from serial device") else: print("⚠️ No active serial connection to disconnect") @@ -779,7 +706,7 @@ def waterfall_plot_callback( mean = np.mean(self.data) self.imageitem.setLevels((mean - 2 * sigma, mean + 2 * sigma)) - depth_cm = depth_index * SAMPLE_RESOLUTION + depth_cm = depth_index * self.sample_resolution self.depth_label.setText(f"Depth: {depth_cm:.1f} cm | Index: {depth_index:.0f}") self.temperature_label.setText(f"Temperature: {temperature:.1f} Β°C") self.drive_voltage_label.setText(f"vDRV: {drive_voltage:.1f} V") @@ -789,7 +716,7 @@ def waterfall_plot_callback( if self.large_depth_label.isVisible(): self.large_depth_label.setText(f"{depth_cm / 100:.1f} m") - if hasattr(self, 'nmea_output_enabled') and self.nmea_output_enabled: + if hasattr(self, "nmea_output_enabled") and self.nmea_output_enabled: now = time.time() # Check if it's time to send again @@ -799,7 +726,7 @@ def waterfall_plot_callback( ): print("Sending NMEA data") try: - depth_cm = depth_index * SAMPLE_RESOLUTION + depth_cm = depth_index * self.sample_resolution depth_m = depth_cm / 100 depth_ft = depth_m * 3.28084 depth_fathoms = depth_m * 0.546807 @@ -842,14 +769,42 @@ def send_hex_value(self): else: print("❌ Invalid hex value. Please enter a valid hex string (e.g., 0x1F)") - def closeEvent(self, event): - if self.serial_thread: - self.serial_thread.stop() - if hasattr(self, 'udp_thread') and self.udp_thread: - self.udp_thread.stop() - + def close_event(self, event): + # Cancel async reader task + if self._reader_task: + try: + self._reader_task.cancel() + except Exception: + pass + self._reader_task = None event.accept() + def _start_reader(self, settings: Settings): + # Generic starter for any AsyncReader subclass + if self._reader_task: + self._stop_reader() + + async def _run(): + reader_cls = settings.connection_type.value + print(f"Starting {reader_cls.__name__}") + try: + reader = reader_cls(settings) + async with reader: + async for pkt in reader: + await self._adapter.emit(pkt) + except Exception as e: + print(f"❌ Reader error: {e}") + + self._reader_task = asyncio.create_task(_run()) + + def _stop_reader(self): + if self._reader_task: + try: + self._reader_task.cancel() + except Exception: + pass + self._reader_task = None + def open_settings(self): device_ip = get_local_ip() @@ -857,12 +812,76 @@ def open_settings(self): parent=self, current_gradient=self.current_gradient, current_speed=self.current_speed, + current_num_samples=self.num_samples, + current_sample_time_us=self.sample_time * 1e6, nmea_enabled=self.nmea_output_enabled, nmea_port=self.nmea_port, nmea_address=device_ip, ) self.settings_dialog.show() + def _recompute_sampling_derived(self): + # Derived values based on current sampling configuration and speed of sound + self.sample_resolution = (SPEED_OF_SOUND * self.sample_time * 100) / 2 + self.max_depth = int(self.num_samples * self.sample_resolution) + self.depth_labels = { + int(i / self.sample_resolution): f"{i / 100}" + for i in range(0, int(self.max_depth), Y_LABEL_DISTANCE) + } + + def _refresh_axes_and_grid(self): + inverted_depth_labels = list(self.depth_labels.items())[::-1] + self.waterfall.getAxis("left").setTicks([inverted_depth_labels]) + self.waterfall.getAxis("right").setTicks([inverted_depth_labels]) + + # Remove old grid lines + if hasattr(self, "_depth_lines"): + for ln in self._depth_lines: + try: + self.waterfall.removeItem(ln) + except Exception: + pass + self._depth_lines = [] + + # Add new grid lines + for i in range(0, int(self.max_depth), Y_LABEL_DISTANCE): + row_index = int(i / self.sample_resolution) + hline = pg.InfiniteLine( + pos=row_index, + angle=0, + pen=pg.mkPen(color="w", style=pg.QtCore.Qt.DotLine), + ) + self.waterfall.addItem(hline) + self._depth_lines.append(hline) + + def set_num_samples(self, n: int): + try: + n = int(n) + except Exception: + return + if n <= 0: + return + if n == self.num_samples: + return + self.num_samples = n + # Resize data buffer + self.data = np.zeros((MAX_ROWS, self.num_samples)) + self._recompute_sampling_derived() + self._refresh_axes_and_grid() + + def set_sample_time(self, seconds: float): + try: + seconds = float(seconds) + except Exception: + return + if seconds <= 0: + return + if abs(seconds - self.sample_time) < 1e-12: + return + self.sample_time = seconds + self._recompute_sampling_derived() + self._refresh_axes_and_grid() + def set_gradient(self, gradient_name): try: @@ -880,14 +899,20 @@ def get_current_gradient(self): return "cyclic" # Fallback -if __name__ == "__main__": +def run_desktop(): app = QApplication(sys.argv) - - # Apply the dark theme qdarktheme.setup_theme("dark") - window = WaterfallApp() - # window.showFullScreen() + # Run Qt and asyncio together + loop = QEventLoop(app) + asyncio.set_event_loop(loop) + + window = WaterfallApp() window.show() - sys.exit(app.exec()) + with loop: + loop.run_forever() + + +if __name__ == "__main__": + run_desktop() diff --git a/python/src/open_echo/echo.py b/python/src/open_echo/echo.py new file mode 100644 index 0000000..26af770 --- /dev/null +++ b/python/src/open_echo/echo.py @@ -0,0 +1,191 @@ +import asyncio +import struct +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING + +import numpy as np +import serial.tools.list_ports +import serial_asyncio_fast as aserial + +if TYPE_CHECKING: + from open_echo.settings import Settings + + +class EchoReadError(ValueError): + pass + + +class ChecksumMismatchError(EchoReadError): + pass + + +@dataclass +class EchoPacket: + samples: np.ndarray + depth_index: int + temperature: float + drive_voltage: float + + @classmethod + def unpack(cls, payload: bytes, checksum: bytes, num_samples: int) -> "EchoPacket": + if len(payload) != 6 + num_samples or len(checksum) != 1: + raise EchoReadError("Invalid payload or checksum length") + + # Verify checksum + calc_checksum = 0 + for byte in payload: + calc_checksum ^= byte + if calc_checksum != checksum[0]: + print("⚠️ Checksum mismatch") + raise ChecksumMismatchError("Checksum mismatch") + + # Unpack payload + depth, temp_scaled, vDrv_scaled = struct.unpack(" "AsyncReader": + await self.open() + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.close() + + @abstractmethod + async def open(self): + pass + + @abstractmethod + async def close(self): + pass + + @abstractmethod + async def read(self) -> EchoPacket: + pass + + async def __aiter__(self) -> AsyncGenerator[EchoPacket, None]: + try: + while True: + yield await self.read() + except asyncio.CancelledError: + return + + +class SerialReader(AsyncReader): + def __init__(self, settings: "Settings"): + print("SerialReader initialized") + super().__init__(settings) + self.reader: asyncio.StreamReader | None = None + self.writer: asyncio.StreamWriter | None = None + + @staticmethod + def get_serial_ports() -> list[str]: + """Retrieve a list of available serial ports.""" + return [port.device for port in serial.tools.list_ports.comports()][::-1] + + async def open(self): + self.reader, self.writer = await aserial.open_serial_connection( + url=self.settings.serial_port, + baudrate=self.settings.baud_rate, + timeout=1, + ) + + async def close(self): + if self.writer: + self.writer.close() + await self.writer.wait_closed() + + async def read(self) -> EchoPacket: + if self.reader is None: + raise RuntimeError("Serial port not opened") + + while True: + header = await self.reader.readexactly(1) + if header != b"\xaa": + continue # Wait for the start byte + + payload = await self.reader.readexactly( + 6 + self.settings.num_samples + ) # Read payload + checksum = await self.reader.readexactly(1) + + return EchoPacket.unpack(payload, checksum, self.settings.num_samples) + + +class UDPReader(AsyncReader): + class _PacketProtocol(asyncio.DatagramProtocol): + def __init__(self, outer): + self.outer = outer + + def datagram_received(self, data: bytes, addr): + for b in data: + if not self.outer._buf: + if b == 0xAA: + self.outer._buf.append(b) + else: + continue + else: + self.outer._buf.append(b) + + if len(self.outer._buf) >= self.outer.packet_size: + # Full packet + payload = self.outer._buf[ + 1 : 1 + 6 + self.outer.settings.num_samples + ] + checksum = self.outer._buf[-1:] + try: + result = EchoPacket.unpack( + payload, checksum, self.outer.settings.num_samples + ) + self.outer._queue.put_nowait(result) + finally: + self.outer._buf.clear() + + def __init__(self, settings: "Settings"): + super().__init__(settings) + self._transport = None + self._queue: asyncio.Queue = asyncio.Queue() + self._buf = bytearray() + self.packet_size = 1 + 6 + self.settings.num_samples + 1 + self.host = getattr(settings, "udp_host", "0.0.0.0") + self.port = getattr(settings, "udp_port", 9999) + + async def open(self): + print("Starting UDP listener...") + loop = asyncio.get_running_loop() + transport, protocol = await loop.create_datagram_endpoint( + lambda: UDPReader._PacketProtocol(self), + local_addr=(self.host, self.port), + ) + self._transport = transport + print(f"πŸ“‘ UDP listener bound to {self.host}:{self.port}") + + async def close(self): + if self._transport: + self._transport.close() + self._transport = None + + async def read(self) -> EchoPacket: + # Wait for next valid parsed packet + return await self._queue.get() + + +class ConnectionTypeEnum(Enum): + SERIAL = SerialReader + UDP = UDPReader diff --git a/python/src/open_echo/py.typed b/python/src/open_echo/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/TUSS4470_shield_002/web/settings.py b/python/src/open_echo/settings.py similarity index 87% rename from TUSS4470_shield_002/web/settings.py rename to python/src/open_echo/settings.py index 4402ca3..1cb2b59 100644 --- a/TUSS4470_shield_002/web/settings.py +++ b/python/src/open_echo/settings.py @@ -1,7 +1,8 @@ from enum import StrEnum from typing import Annotated -from echo import ConnectionTypeEnum -from pydantic import BaseModel, Field, field_validator, PlainSerializer + +from open_echo.echo import ConnectionTypeEnum +from pydantic import BaseModel, Field, PlainSerializer, field_validator class Medium(StrEnum): @@ -22,7 +23,12 @@ class NMEAOffset(StrEnum): class Settings(BaseModel): - connection_type: Annotated[ConnectionTypeEnum, PlainSerializer(lambda v: v.name, return_type=str)] | None = None + connection_type: ( + Annotated[ + ConnectionTypeEnum, PlainSerializer(lambda v: v.name, return_type=str) + ] + | None + ) = None udp_port: int = 9999 serial_port: str = "init" baud_rate: int = 250000 @@ -87,7 +93,7 @@ def save(self, filename=".settings.json"): @classmethod def load(cls, filename=".settings.json"): - with open(filename, "r", encoding="utf-8") as f: + with open(filename, encoding="utf-8") as f: data = f.read() - - return cls.model_validate_json(data) \ No newline at end of file + + return cls.model_validate_json(data) diff --git a/python/src/open_echo/web.py b/python/src/open_echo/web.py new file mode 100644 index 0000000..921fc89 --- /dev/null +++ b/python/src/open_echo/web.py @@ -0,0 +1,200 @@ +import asyncio +import logging +from collections.abc import Callable, Coroutine +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, Form, Request, WebSocket +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from open_echo.depth_output import OutputManager +from open_echo.echo import EchoPacket, SerialReader +from open_echo.settings import Settings + +log = logging.getLogger("uvicorn") + + +class ConnectionManager: + def __init__(self) -> None: + self.active_connections: list[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + log.info(f"WebSocket connected: {websocket.client}") + + async def disconnect(self, websocket: WebSocket) -> None: + if websocket in self.active_connections: + self.active_connections.remove(websocket) + + async def broadcast_json(self, data) -> None: + for connection in self.active_connections: + await connection.send_json(data) + + +class EchoReader: + def __init__( + self, + data_callback: Callable[[dict], Coroutine], + depth_callback: Callable[[dict], Coroutine], + settings=None, + ): + self.settings = settings + self._restart_event = asyncio.Event() + self.data_callback = data_callback + self.depth_callback = depth_callback + self._task: asyncio.Task | None = None + + def update_settings(self, new_settings): + log.info("EchoReader updating settings...") + self.settings = new_settings + self._restart_event.set() # Signal restart + + def __enter__(self): + self._task = asyncio.create_task(self.run_forever()) + return self + + def __exit__(self, exc_type, exc_value, traceback): + if self._task: + self._task.cancel() + self._task = None + + if exc_type is not None: + log.error(f"Error in EchoReader: {exc_value}") + + async def process_echo(self, echo: EchoPacket): + resolution = self.settings.resolution + depth = echo.depth_index * (resolution / 100) # Convert to meters + try: + data = { + "spectrogram": echo.samples.tolist(), + "measured_depth": depth, + "temperature": echo.temperature, + "drive_voltage": echo.drive_voltage, + "resolution": resolution, + } + await self.data_callback(data) + except Exception as e: + log.error(f"❌ Error sending data: {e}", exc_info=e) + + try: + await self.depth_callback(depth) + except Exception as e: + log.error(f"❌ Error sending depth: {e}", exc_info=e) + + async def run_forever(self): + """Continuously read serial data and emit processed arrays. Supports live settings update and restart.""" + while True: + if self.settings is None: + log.warning("Settings not initialized, waiting...") + await asyncio.sleep(1) + continue + + log.info("EchoReader starting...") + self._restart_event.clear() + try: + reader = self.settings.connection_type.value(self.settings) + async with reader: + async for pkt in reader: + await self.process_echo(pkt) + if self._restart_event.is_set(): + print("Restart event set, breaking loop") + break + except Exception as e: + log.error(f"❌ Error in EchoReader: {e}", exc_info=e) + + await self._restart_event.wait() + + +connection_manager = ConnectionManager() +output_manager = OutputManager() +echo_reader = EchoReader( + data_callback=connection_manager.broadcast_json, + depth_callback=output_manager.update, +) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + try: + await update_settings(Settings.load()) + except Exception as e: + log.error(f"Failed to load settings: {e}") + + with output_manager, echo_reader: + yield + + +assets_dir = Path(__file__).parent.resolve() / "assets" + +app = FastAPI(lifespan=lifespan) +app.state.settings = Settings() +templates = Jinja2Templates(directory=assets_dir / "templates") + +app.mount("/static", StaticFiles(directory=assets_dir / "static"), name="static") + + +async def update_settings(new_settings: Settings): + settings = Settings.model_validate( + { + **app.state.settings.model_dump(exclude_none=True, exclude_unset=True), + **new_settings.model_dump(exclude_none=True, exclude_unset=True), + } + ) + + echo_reader.update_settings(settings) + await output_manager.update_settings(settings) + app.state.settings = settings + + app.state.settings.save() + + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await connection_manager.connect(websocket) + try: + while True: + await websocket.receive_text() # Just here to keep the connection alive + except Exception as e: + log.error(f"WebSocket closed: {e}") + finally: + await connection_manager.disconnect(websocket) + + +@app.get("/") +async def home(request: Request): + if app.state.settings.serial_port == "init": + return RedirectResponse("/config", status_code=303) + + return templates.TemplateResponse( + "frontend.html", {"request": request, "settings": app.state.settings} + ) + + +@app.get("/config") +async def config(request: Request): + return templates.TemplateResponse( + "config.html", + { + "request": request, + "settings": app.state.settings, + "ports": SerialReader.get_serial_ports(), + }, + ) + + +@app.post("/config") +async def config_post(request: Request, new_settings: Settings = Form(...)): # noqa: B008 + await update_settings(new_settings) + return RedirectResponse("/", status_code=303) + + +def run_web(): + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) + + +if __name__ == "__main__": + run_web() diff --git a/python/tests/test_depth_output.py b/python/tests/test_depth_output.py new file mode 100644 index 0000000..70596bb --- /dev/null +++ b/python/tests/test_depth_output.py @@ -0,0 +1,443 @@ +import asyncio +import json +from unittest.mock import patch + +import pytest +from open_echo.depth_output import ( + NMEA0183Output, + OutputManager, + SignalKOutput, + output_methods, +) +from open_echo.settings import NMEAOffset, Settings + + +class DummyWS: + def __init__(self): + self.sent = [] + self.closed = False + + async def send(self, data: str): + self.sent.append(json.loads(data)) + + async def close(self): + self.closed = True + + +class DummyWriter: + def __init__(self): + self.buffer = bytearray() + self._closing = False + + def write(self, data: bytes): + self.buffer.extend(data) + + async def drain(self): + return None + + def is_closing(self): + return self._closing + + def close(self): + self._closing = True + + async def wait_closed(self): + return None + + +class DummyReader: + async def read(self, n: int): + return b"" + + +@pytest.mark.asyncio +@patch("websockets.connect") +@patch("asyncio.open_connection") +@patch("open_echo.depth_output.AsyncClient") +async def test_output_manager_update_settings_starts_methods( + MockAsyncClient, mock_open_connection, mock_ws_connect +): + # Monkeypatch websockets.connect + dummy_ws = DummyWS() + + mock_open_connection.return_value = (DummyReader(), DummyWriter()) + + async def mock_connect(uri): + assert uri.startswith("ws://localhost:3000/signalk/v1/stream") + return dummy_ws + + mock_ws_connect.side_effect = mock_connect + + # Monkeypatch httpx.AsyncClient.post/get for token flow + class DummyResponse: + def __init__(self, json_data): + self._json = json_data + + def json(self): + return self._json + + def raise_for_status(self): + return None + + class DummyClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json=None): + assert url == "http://localhost:3000/signalk/v1/access/requests" + return DummyResponse( + {"href": "/signalk/v1/access/requests/abc", "state": "PENDING"} + ) + + async def get(self, url): + # First poll returns PENDING then COMPLETED with APPROVED + if not hasattr(self, "_polled"): + self._polled = True + return DummyResponse({"state": "PENDING"}) + return DummyResponse( + { + "state": "COMPLETED", + "accessRequest": {"permission": "APPROVED", "token": "tok123"}, + } + ) + + # Patch the imported AsyncClient used inside module, not httpx.AsyncClient + MockAsyncClient.return_value = DummyClient() + + # Prepare settings enabling both outputs + s = Settings( + signalk_enable=True, + signalk_address="localhost:3000", + nmea_enable=True, + nmea_address="localhost:10110", + transducer_depth=1.0, + draft=0.5, + ) + + om = OutputManager() + await om.update_settings(s) + + # Two outputs created + assert len(om._output_classes) == 2 + assert any(isinstance(o, SignalKOutput) for o in om._output_classes) + assert any(isinstance(o, NMEA0183Output) for o in om._output_classes) + + # SignalKOutput has token set and connected + sk = [o for o in om._output_classes if isinstance(o, SignalKOutput)][0] + assert s.signalk_token == "tok123" + assert sk._ws is dummy_ws + + +@pytest.mark.asyncio +@patch("open_echo.depth_output.AsyncClient") +@patch("websockets.connect") +async def test_signalk_get_token_waits_when_ongoing(mock_ws_connect, MockAsyncClient): + # Ensure concurrent get_token calls wait for ongoing request + class DummyClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json=None): + return type( + "R", + (), + { + "json": lambda self: {"href": "/h", "state": "PENDING"}, + "raise_for_status": lambda self: None, + }, + )() + + async def get(self, url): + return type( + "G", + (), + { + "json": lambda self: { + "state": "COMPLETED", + "accessRequest": {"permission": "APPROVED", "token": "tokX"}, + } + }, + )() + + MockAsyncClient.return_value = DummyClient() + + # Stub websockets to avoid real connect + async def mock_connect(uri): + return DummyWS() + + mock_ws_connect.side_effect = mock_connect + + s = Settings(signalk_enable=True, signalk_address="localhost:3000") + sk = SignalKOutput(s) + # Set ongoing flag and call get_token twice concurrently + sk._access_request_ongoing = True + + async def unset_ongoing(): + # simulate another task completing access request soon + await asyncio.sleep(0.01) + sk._access_request_ongoing = False + + t = asyncio.create_task(unset_ongoing()) + tok = await sk.get_token() + await t + assert tok == s.signalk_token + + +@pytest.mark.asyncio +@patch("websockets.connect") +async def test_signalk_output_sends_delta(mock_ws_connect): + dummy_ws = DummyWS() + + async def mock_connect(uri): + return dummy_ws + + mock_ws_connect.side_effect = mock_connect + # Bypass token fetch + s = Settings( + signalk_enable=True, + signalk_address="localhost:3000", + transducer_depth=2.0, + draft=0.5, + signalk_token="tok", + ) + sk = SignalKOutput(s) + await sk.start() + + sk.update(3.0) # depth below transducer + await sk.output() + + assert len(dummy_ws.sent) == 1 + values = dummy_ws.sent[0]["updates"][0]["values"] + paths = {v["path"] for v in values} + assert "environment.depth.belowTransducer" in paths + assert "environment.depth.belowSurface" in paths + assert "environment.depth.belowKeel" in paths + + +@pytest.mark.asyncio +@patch("websockets.connect") +async def test_signalk_output_reconnect_on_send_error(mock_ws_connect): + # First connection returns ws that raises on send; second connection returns working ws + class FailingWS(DummyWS): + async def send(self, data: str): + raise RuntimeError("send failure") + + failing_ws = FailingWS() + working_ws = DummyWS() + calls = [] + + async def mock_connect(uri): + calls.append(uri) + # Return failing first, then working + return failing_ws if len(calls) == 1 else working_ws + + mock_ws_connect.side_effect = mock_connect + s = Settings( + signalk_enable=True, signalk_address="localhost:3000", signalk_token="tok" + ) + sk = SignalKOutput(s) + await sk.start() + sk.update(1.0) + # First output should log error and stop the failing ws + await sk.output() + assert failing_ws.closed is True + # Second output should reconnect and send + await sk.output() + assert len(working_ws.sent) == 1 + + +@pytest.mark.asyncio +@patch("asyncio.open_connection") +async def test_nmea0183_output_writes_sentences(mock_open_conn): + dummy_writer = DummyWriter() + dummy_reader = DummyReader() + + async def mock_open_connection(host, port): + assert host == "localhost" and port == 10110 + return dummy_reader, dummy_writer + + mock_open_conn.side_effect = mock_open_connection + s = Settings( + nmea_enable=True, + nmea_address="localhost:10110", + nmea_offset=NMEAOffset.ToKeel, + transducer_depth=1.0, + draft=2.0, + ) + nmea = NMEA0183Output(s) + await nmea.start() + + nmea.update(4.2) + await nmea.output() + + # Expect DBT and DPT sentences written + out = dummy_writer.buffer.decode("ascii") + assert out.count("$SDDBT,") == 1 + assert out.count("$SDDPT,") == 1 + + +@pytest.mark.asyncio +@patch("asyncio.open_connection") +async def test_nmea0183_output_reconnects_when_writer_closing(mock_open_conn): + dummy_writer = DummyWriter() + dummy_writer._closing = True + dummy_reader = DummyReader() + + # Track reconnect calls + reconnects = {"count": 0} + + async def mock_open_connection(host, port): + reconnects["count"] += 1 + return dummy_reader, DummyWriter() # new open writer + + mock_open_conn.side_effect = mock_open_connection + s = Settings( + nmea_enable=True, + nmea_address="localhost:10110", + nmea_offset=NMEAOffset.ToTransducer, + ) + nmea = NMEA0183Output(s) + + # Manually set closing writer + nmea._writer = dummy_writer + nmea.update(2.5) + + await nmea.output() + assert reconnects["count"] >= 1 + + +@pytest.mark.asyncio +async def test_output_manager_context_lifecycle(): + # Avoid running an infinite loop: set settings to None so it sleeps; then exit quickly + om = OutputManager(Settings()) + + # Replace _run to a short coroutine + async def short_run(): + await asyncio.sleep(0) + + om._run = short_run # type: ignore[assignment] + om.__enter__() + assert om._task is not None + om.__exit__(None, None, None) + assert om._task is None + + +@pytest.mark.asyncio +async def test_signalk_start_missing_address_raises(): + s = Settings(signalk_enable=True, signalk_address="") + sk = SignalKOutput(s) + with pytest.raises(ValueError): + await sk.start() + + +@pytest.mark.asyncio +async def test_nmea_start_missing_or_invalid_address_raises(): + s_missing = Settings(nmea_enable=True, nmea_address="") + nmea_missing = NMEA0183Output(s_missing) + with pytest.raises(ValueError): + await nmea_missing.start() + + s_invalid = Settings(nmea_enable=True, nmea_address="localhost") + nmea_invalid = NMEA0183Output(s_invalid) + with pytest.raises(ValueError): + await nmea_invalid.start() + + +@pytest.mark.asyncio +async def test_nmea_stop_handles_wait_closed_exception(): + class ErrWriter(DummyWriter): + async def wait_closed(self): + raise RuntimeError("boom") + + nmea = NMEA0183Output(Settings(nmea_enable=True, nmea_address="localhost:10110")) + nmea._writer = ErrWriter() + nmea._reader = DummyReader() + # Should not raise + await nmea.stop() + + +@pytest.mark.asyncio +@patch("asyncio.open_connection") +async def test_nmea_offset_to_surface_branch(mock_open_conn): + dummy_writer = DummyWriter() + dummy_reader = DummyReader() + + async def mock_open_connection(host, port): + return dummy_reader, dummy_writer + + mock_open_conn.side_effect = mock_open_connection + # ToSurface should use transducer_depth as positive offset + s = Settings( + nmea_enable=True, + nmea_address="localhost:10110", + nmea_offset=NMEAOffset.ToSurface, + transducer_depth=1.5, + draft=2.0, + ) + nmea = NMEA0183Output(s) + await nmea.start() + nmea.update(2.0) + await nmea.output() + out = dummy_writer.buffer.decode("ascii") + # DPT sentence contains depth plus offset and the offset itself + assert "$SDDPT," in out + + +@pytest.mark.asyncio +async def test_output_manager_run_loop_behaviour(): + # Use a concrete OutputMethod that records outputs + class Recorder(NMEA0183Output): + async def start(self): + self.started = True + + async def stop(self): + self.stopped = True + + async def output(self): + self.last_output = self.current_value + + s = Settings(nmea_enable=True, nmea_address="localhost:10110") + om = OutputManager(s) + + # Inject recorder directly + om._output_classes = [Recorder(s)] + om.update(1.23) + + # Run a couple of iterations + async def one_tick(): + await om.output() + + await one_tick() + assert om._output_classes[0].last_output == 1.23 + + +def test_output_methods_registry(): + assert output_methods["signalk"] is SignalKOutput + assert output_methods["nmea0183"] is NMEA0183Output + + +@pytest.mark.asyncio +@patch("websockets.connect") +async def test_signalk_output_only_below_transducer_when_no_offsets(mock_ws_connect): + dummy_ws = DummyWS() + + async def mock_connect(uri): + return dummy_ws + + mock_ws_connect.side_effect = mock_connect + s = Settings( + signalk_enable=True, signalk_address="localhost:3000", signalk_token="tok" + ) + sk = SignalKOutput(s) + await sk.start() + sk.update(3.3) + await sk.output() + values = dummy_ws.sent[0]["updates"][0]["values"] + paths = {v["path"] for v in values} + assert paths == {"environment.depth.belowTransducer"} diff --git a/python/tests/test_echo.py b/python/tests/test_echo.py new file mode 100644 index 0000000..31fbb62 --- /dev/null +++ b/python/tests/test_echo.py @@ -0,0 +1,414 @@ +import asyncio +from dataclasses import dataclass +from unittest.mock import patch + +import numpy as np +import pytest +from hypothesis import given +from hypothesis import strategies as st +from open_echo.echo import ( + ChecksumMismatchError, + ConnectionTypeEnum, + EchoPacket, + EchoReadError, + SerialReader, + UDPReader, +) + + +def make_payload( + num_samples: int, depth: int = 10, temp: float = 12.34, vdrv: float = 48.7 +): + temp_scaled = int(round(temp * 100)) + vdrv_scaled = int(round(vdrv * 100)) + # depth(uint16), temp(int16), vDrv(uint16) + header = ( + (depth & 0xFFFF).to_bytes(2, "little") + + (temp_scaled & 0xFFFF).to_bytes(2, "little", signed=False) + + (vdrv_scaled & 0xFFFF).to_bytes(2, "little") + ) + samples = bytes([i % 256 for i in range(num_samples)]) + payload = header + samples + checksum = bytes([compute_checksum(payload)]) + return payload, checksum + + +def compute_checksum(payload: bytes) -> int: + chk = 0 + for b in payload: + chk ^= b + return chk + + +@given( + num_samples=st.integers(min_value=1, max_value=10000), + depth=st.integers(min_value=0, max_value=100), + temp=st.floats( + allow_nan=False, allow_infinity=False, width=32, min_value=-40.0, max_value=85.0 + ), + vdrv=st.floats( + allow_nan=False, allow_infinity=False, width=32, min_value=0.0, max_value=100.0 + ), +) +def test_echopacket_unpack_property(num_samples, depth, temp, vdrv): + payload, checksum = make_payload(num_samples, depth=depth, temp=temp, vdrv=vdrv) + pkt = EchoPacket.unpack(payload, checksum, num_samples) + assert pkt.samples.size == num_samples + assert 0 <= pkt.depth_index <= num_samples + assert isinstance(pkt.temperature, float) + assert isinstance(pkt.drive_voltage, float) + + +@given( + num_samples=st.integers(min_value=1, max_value=10000), + corrupt_byte=st.integers(min_value=0, max_value=255), +) +def test_echopacket_checksum_mismatch_property(num_samples, corrupt_byte): + payload, checksum = make_payload(num_samples) + # Corrupt checksum deterministically + bad_checksum = ( + bytes([corrupt_byte]) + if corrupt_byte != checksum[0] + else bytes([(checksum[0] ^ 0xFF) & 0xFF]) + ) + with pytest.raises(ChecksumMismatchError): + EchoPacket.unpack(payload, bad_checksum, num_samples) + + +def test_echopacket_unpack_happy_path(): + num_samples = 32 + payload, checksum = make_payload(num_samples, depth=20, temp=23.45, vdrv=50.0) + + pkt = EchoPacket.unpack(payload, checksum, num_samples) + + assert isinstance(pkt.samples, np.ndarray) + assert pkt.samples.dtype == np.uint8 + assert pkt.samples.size == num_samples + # depth is clamped to num_samples + assert pkt.depth_index == min(20, num_samples) + assert pytest.approx(pkt.temperature, 0.001) == 23.45 + assert pytest.approx(pkt.drive_voltage, 0.001) == 50.0 + + +def test_echopacket_depth_clamped_when_exceeds_num_samples(): + num_samples = 16 + payload, checksum = make_payload(num_samples, depth=100, temp=10.0, vdrv=5.0) + pkt = EchoPacket.unpack(payload, checksum, num_samples) + assert pkt.depth_index == num_samples + + +def test_echopacket_unpack_handles_negative_temperature(): + num_samples = 8 + # temp = -5.67 C + payload, checksum = make_payload(num_samples, depth=2, temp=-5.67, vdrv=12.0) + pkt = EchoPacket.unpack(payload, checksum, num_samples) + assert pytest.approx(pkt.temperature, 0.001) == -5.67 + + +def test_echopacket_unpack_invalid_lengths(): + num_samples = 16 + payload, checksum = make_payload(num_samples) + + with pytest.raises(EchoReadError): + EchoPacket.unpack(payload[:-1], checksum, num_samples) # wrong payload length + + with pytest.raises(EchoReadError): + EchoPacket.unpack(payload, b"", num_samples) # wrong checksum length + + +def test_echopacket_unpack_checksum_mismatch(): + num_samples = 8 + payload, checksum = make_payload(num_samples) + bad_checksum = bytes([(checksum[0] ^ 0xFF) & 0xFF]) + + with pytest.raises(ChecksumMismatchError): + EchoPacket.unpack(payload, bad_checksum, num_samples) + + +@patch("serial.tools.list_ports.comports") +def test_serialreader_get_serial_ports_does_not_throw(mock_comports): + # Monkeypatch serial.tools.list_ports.comports to return a dummy list + class DummyPort: + def __init__(self, device): + self.device = device + + def fake_comports(): + return [DummyPort("/dev/tty.usbmodem0"), DummyPort("/dev/tty.usbserial1")] + + mock_comports.side_effect = fake_comports + + ports = SerialReader.get_serial_ports() + assert ports == ["/dev/tty.usbserial1", "/dev/tty.usbmodem0"] + + +@dataclass +class DummySettings: + num_samples: int = 8 + udp_host: str = "127.0.0.1" + udp_port: int = 9999 + serial_port: str = "/dev/tty.usbserial0" + baud_rate: int = 115200 + + +def build_udp_packet( + settings: DummySettings, + start_byte: int = 0xAA, + depth: int = 3, + temp: float = 21.0, + vdrv: float = 48.0, +): + payload, checksum = make_payload( + settings.num_samples, depth=depth, temp=temp, vdrv=vdrv + ) + return bytes([start_byte]) + payload + checksum + + +@pytest.mark.asyncio +async def test_udpreader_protocol_parses_full_packet_and_queues_result(): + settings = DummySettings(num_samples=8) + reader = UDPReader(settings) + # No need to open transport; test protocol behavior directly + proto = UDPReader._PacketProtocol(reader) + + packet = build_udp_packet(settings, start_byte=0xAA, depth=5, temp=22.5, vdrv=49.0) + proto.datagram_received(packet, ("127.0.0.1", 12345)) + + result = await asyncio.wait_for(reader.read(), timeout=0.1) + assert isinstance(result, EchoPacket) + assert result.depth_index == min(5, settings.num_samples) + assert result.samples.size == settings.num_samples + assert pytest.approx(result.temperature, 0.001) == 22.5 + assert pytest.approx(result.drive_voltage, 0.001) == 49.0 + + +@pytest.mark.asyncio +async def test_udpreader_protocol_ignores_wrong_start_byte_and_recovers(): + settings = DummySettings(num_samples=8) + reader = UDPReader(settings) + proto = UDPReader._PacketProtocol(reader) + + bad_start = build_udp_packet( + settings, start_byte=0x00, depth=4, temp=15.0, vdrv=30.0 + ) + good_packet = build_udp_packet( + settings, start_byte=0xAA, depth=4, temp=15.0, vdrv=30.0 + ) + + # Send bad packet: should not enqueue + proto.datagram_received(bad_start, ("127.0.0.1", 1)) + # Then a good packet + proto.datagram_received(good_packet, ("127.0.0.1", 1)) + + result = await asyncio.wait_for(reader.read(), timeout=0.1) + assert isinstance(result, EchoPacket) + assert result.depth_index == 4 + + +@pytest.mark.asyncio +async def test_udpreader_protocol_handles_multiple_packets_in_one_datagram(): + settings = DummySettings(num_samples=6) + reader = UDPReader(settings) + proto = UDPReader._PacketProtocol(reader) + + pkt1 = build_udp_packet(settings, start_byte=0xAA, depth=1, temp=10.0, vdrv=20.0) + pkt2 = build_udp_packet(settings, start_byte=0xAA, depth=5, temp=15.0, vdrv=30.0) + joined = pkt1 + pkt2 + + proto.datagram_received(joined, ("127.0.0.1", 2)) + + r1 = await asyncio.wait_for(reader.read(), timeout=0.1) + r2 = await asyncio.wait_for(reader.read(), timeout=0.1) + + assert r1.depth_index == 1 + assert r2.depth_index == 5 + + +@pytest.mark.asyncio +async def test_udpreader_protocol_checksum_mismatch_clears_buffer_and_does_not_enqueue(): + settings = DummySettings(num_samples=8) + reader = UDPReader(settings) + proto = UDPReader._PacketProtocol(reader) + + # Build a valid payload then corrupt checksum + payload, checksum = make_payload( + settings.num_samples, depth=2, temp=10.0, vdrv=12.0 + ) + bad_checksum = bytes([(checksum[0] ^ 0xFF) & 0xFF]) + packet = bytes([0xAA]) + payload + bad_checksum + # Protocol raises on checksum mismatch; ensure buffer clears and nothing enqueued + with pytest.raises(ChecksumMismatchError): + proto.datagram_received(packet, ("127.0.0.1", 1)) + + # Verify queue is empty by timing out when trying to read + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(reader.read(), timeout=0.05) + + +@pytest.mark.asyncio +async def test_asyncreader_iterator_yields_until_cancelled(): + # Build a minimal stub of AsyncReader that produces N packets then cancels + # Subclass AsyncReader to exercise its __aiter__ implementation directly + from open_echo.echo import AsyncReader + + class ConcreteReader(AsyncReader): + def __init__(self, settings, packets: int): + super().__init__(settings) + self._remaining = packets + + async def open(self): + return None + + async def close(self): + return None + + async def read(self): + if self._remaining <= 0: + raise asyncio.CancelledError() + self._remaining -= 1 + payload, checksum = make_payload(4, depth=2, temp=10.0, vdrv=12.0) + return EchoPacket.unpack(payload, checksum, 4) + + reader = ConcreteReader(DummySettings(), packets=3) + out = [] + async for pkt in reader: + out.append(pkt) + if len(out) >= 3: + break + assert len(out) == 3 + assert all(isinstance(p, EchoPacket) for p in out) + + +@pytest.mark.asyncio +async def test_udpreader_open_and_close_monkeypatched_log(): + # Patch a dummy logger into module to avoid NameError + import open_echo.echo as echo_mod + + class DummyLog: + def info(self, *_args, **_kwargs): + return None + + echo_mod.log = DummyLog() + + settings = DummySettings( + num_samples=8, udp_host="127.0.0.1", udp_port=0 + ) # use ephemeral port + reader = UDPReader(settings) + + # Create a datagram endpoint bound to localhost; then close + await reader.open() + await reader.close() + + +@pytest.mark.asyncio +@patch("serial_asyncio_fast.open_serial_connection") +async def test_serialreader_open_read_close_with_mock(mock_open_serial_connection): + # Mock serial_asyncio_fast.open_serial_connection to return a reader/writer + class DummyStreamReader: + def __init__(self, packets: bytes | None = None): + self._buf = packets or b"" + + async def readexactly(self, n): + # Pop n bytes from buffer + if len(self._buf) < n: + raise asyncio.IncompleteReadError(partial=self._buf, expected=n) + chunk = self._buf[:n] + self._buf = self._buf[n:] + return chunk + + class DummyStreamWriter: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + async def wait_closed(self): + return None + + settings = DummySettings(num_samples=8) + + # Build one valid packet buffer: header(0xAA) + payload + checksum + payload, checksum = make_payload( + settings.num_samples, depth=6, temp=20.0, vdrv=40.0 + ) + packet_bytes = bytes([0xAA]) + payload + checksum + + dummy_reader = DummyStreamReader(packets=packet_bytes) + dummy_writer = DummyStreamWriter() + + async def fake_open_serial_connection(url, baudrate, timeout): + assert url == settings.serial_port + assert baudrate == settings.baud_rate + return dummy_reader, dummy_writer + + mock_open_serial_connection.side_effect = fake_open_serial_connection + + sr = SerialReader(settings) + await sr.open() + pkt = await sr.read() + await sr.close() + + assert isinstance(pkt, EchoPacket) + assert pkt.depth_index == 6 + assert dummy_writer.closed is True + + +@pytest.mark.asyncio +@patch("serial_asyncio_fast.open_serial_connection") +async def test_serialreader_read_skips_until_start_byte(mock_open_serial_connection): + # Build buffer with noise byte then a valid packet + settings = DummySettings(num_samples=4) + + noise = b"\x00" # not 0xAA + payload, checksum = make_payload( + settings.num_samples, depth=2, temp=25.0, vdrv=33.3 + ) + packet_bytes = noise + bytes([0xAA]) + payload + checksum + + class DummyReader: + def __init__(self, buf: bytes): + self._buf = buf + + async def readexactly(self, n): + if len(self._buf) < n: + raise asyncio.IncompleteReadError(partial=self._buf, expected=n) + chunk = self._buf[:n] + self._buf = self._buf[n:] + return chunk + + class DummyWriter: + def __init__(self): + self.closed = False + + def close(self): + self.closed = True + + async def wait_closed(self): + return None + + dummy_reader = DummyReader(packet_bytes) + dummy_writer = DummyWriter() + + async def fake_open_serial_connection(url, baudrate, timeout): + return dummy_reader, dummy_writer + + mock_open_serial_connection.side_effect = fake_open_serial_connection + + sr = SerialReader(settings) + await sr.open() + pkt = await sr.read() + await sr.close() + + assert pkt.depth_index == 2 + + +def test_connection_type_enum_values(): + assert ConnectionTypeEnum.SERIAL.value is SerialReader + assert ConnectionTypeEnum.UDP.value is UDPReader + + +@pytest.mark.asyncio +async def test_serialreader_read_raises_when_not_open(): + sr = SerialReader(DummySettings(num_samples=4)) + with pytest.raises(RuntimeError): + await sr.read() diff --git a/python/tests/test_settings.py b/python/tests/test_settings.py new file mode 100644 index 0000000..8e2b2b4 --- /dev/null +++ b/python/tests/test_settings.py @@ -0,0 +1,98 @@ +import json +import math + +import pytest +from open_echo.echo import ConnectionTypeEnum +from open_echo.settings import Medium, NMEAOffset, Settings + + +def test_connection_type_parsing_from_enum(): + s = Settings(connection_type=ConnectionTypeEnum.UDP) + assert s.connection_type is ConnectionTypeEnum.UDP + + +def test_connection_type_parsing_from_string_name_case_insensitive(): + s = Settings(connection_type="serial") + assert s.connection_type is ConnectionTypeEnum.SERIAL + s2 = Settings(connection_type="UDP") + assert s2.connection_type is ConnectionTypeEnum.UDP + + +def test_connection_type_parsing_invalid_string_raises(): + with pytest.raises(ValueError): + Settings(connection_type="bluetooth") + + +def test_colormap_validation_accepts_allowed_values(): + for cmap in ["viridis", "plasma", "inferno", "magma", "terrain"]: + s = Settings(colormap=cmap) + assert s.colormap == cmap + + +def test_colormap_validation_rejects_unknown_value(): + with pytest.raises(ValueError): + Settings(colormap="rainbow") + + +def test_resolution_water_and_air_calculation(): + # Expected resolution = speed_of_sound * 13.2e-6 * 100 / 2 + s_water = Settings(medium=Medium.WATER) + s_air = Settings(medium=Medium.AIR) + expected_water = 1480 * 13.2e-6 * 100 / 2 + expected_air = 330 * 13.2e-6 * 100 / 2 + assert math.isclose(s_water.resolution, expected_water, rel_tol=1e-9) + assert math.isclose(s_air.resolution, expected_air, rel_tol=1e-9) + + +def test_resolution_unsupported_medium_raises(): + class FakeMedium(str): + pass + + fake = FakeMedium("ice") + # Bypass Pydantic constraint by setting after init + s = Settings() + s.medium = fake # type: ignore[assignment] + with pytest.raises(ValueError): + _ = s.resolution + + +def test_output_methods_flags(): + s = Settings(signalk_enable=False, nmea_enable=False) + assert s.output_methods == [] + s.signalk_enable = True + assert s.output_methods == ["signalk"] + s.nmea_enable = True + assert set(s.output_methods) == {"signalk", "nmea0183"} + + +def test_save_and_load_roundtrip(tmp_path): + s = Settings( + connection_type=ConnectionTypeEnum.SERIAL, + udp_port=8888, + serial_port="/dev/tty.usbserial", + baud_rate=115200, + num_samples=1024, + colormap="plasma", + transducer_depth=1.2, + draft=0.3, + depth_output_enable=True, + medium=Medium.WATER, + signalk_enable=True, + signalk_address="localhost:3000", + nmea_enable=True, + nmea_address="localhost:10110", + nmea_offset=NMEAOffset.ToKeel, + signalk_token="abc123", + ) + + file_path = tmp_path / "settings.json" + # Use real file for roundtrip despite open being patched; set side_effect to default open + s.save(str(file_path)) + + # Ensure file content is valid JSON + data = json.loads(file_path.read_text()) + assert data["connection_type"] == "SERIAL" + + s2 = Settings.load(str(file_path)) + assert s2 == s + assert s2.connection_type is ConnectionTypeEnum.SERIAL diff --git a/reverse_engineering/images/.DS_Store b/reverse_engineering/images/.DS_Store deleted file mode 100644 index 958f64d..0000000 Binary files a/reverse_engineering/images/.DS_Store and /dev/null differ diff --git a/reverse_engineering/live_waterfall.py b/reverse_engineering/live_waterfall.py index 99d2cbc..85c5af8 100644 --- a/reverse_engineering/live_waterfall.py +++ b/reverse_engineering/live_waterfall.py @@ -1,7 +1,8 @@ -import serial +import time + import matplotlib.pyplot as plt import numpy as np -import time +import serial # Serial port configuration serial_port = "/dev/tty.usbserial-1120" # Updated to the specified serial port