From 685f21f4c7fb7e9449a559bbc01958d4a53c2b73 Mon Sep 17 00:00:00 2001 From: John Harrington Date: Thu, 4 Dec 2025 09:32:10 +0000 Subject: [PATCH 1/6] feat: Publish python package and docs site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all files to new folder structure feat: combine echo readers chore: stylefix feat: Add CLI commands for desktop/web chore: Typing and ruff Readd deleted assets feat: Add tests chore: stylefixes chore: stylefixes again feat: Add github actions for python CICD feat: Add bump-my-version Bump version: 0.1.0 → 0.1.1 feat: Use hatch-vcs Bump version: 0.1.1 → 0.1.2 chore: Reset version docs: Adjust for using install package fix: bump-my-version command syntax chore: use @patch in tests fix(tests): mock asyncio.open_connection temp: Publish from this branch to test feat: Output methods can set their own interval fix: add id-token write permission to pypi-publish temp: Trigger on workflow change chore: Separate workflows to use version update temp: Trigger python CI feat: simplify workflow setup Bump version: 0.1.0 → 0.1.1 fix: Don't double-fetch tags feat: Simplify, publish on release fix: Rename openecho package to open_echo feat: Run test/lint/typecheck before publishing release feat: Set up jekyll, add contributor docs fix: Remove double publish step feat: Adjust num_samples and sample_time in app settings docs: Update for new settings docs: Tidy up, exclude unnecessary files from build feat: Add workflow to publish docs temp: publish pages from this branch fix: Only publish docs when doc files change chore: Remove unused minima gem docs: Adjust wording fix: Run bundle install on docs docs: Allow manual deploy docs: Adjust wording fix: Install ruby chore: Remove lockfiles chore: ignore lockfiles docs: Adjust wording fix: Plain jekyll build not pages build docs: Adjust baseurl and use relative links docs: Adjust wording docs: Update links docs: Remove relative links fix: Build docs with right baseurl feat: Trigger docs update on release docs: Update paths docs: publish on commit to main docs: Remove build folder Update .github/workflows/docs.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Update documentation/getting_started/index.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Use relative paths for images Initial plan Fix broadcast_json to handle per-connection send failures Co-authored-by: JohnCHarrington <1857365+JohnCHarrington@users.noreply.github.com> Initial plan Fix type hints for depth_callback and data_callback in EchoReader Co-authored-by: JohnCHarrington <1857365+JohnCHarrington@users.noreply.github.com> Initial plan Fix EchoReader to retry with backoff on exceptions instead of blocking Co-authored-by: JohnCHarrington <1857365+JohnCHarrington@users.noreply.github.com> Update .github/workflows/pypi-publish.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .githooks/pre-commit | 34 + .github/workflows/docs.yml | 45 ++ .github/workflows/pypi-publish.yml | 37 ++ .github/workflows/python-ci.yml | 41 ++ .gitignore | 5 + Gemfile | 33 + README.md | 25 +- TUSS4470_shield_002/requirements.txt | 5 - TUSS4470_shield_002/web/app.py | 110 ---- TUSS4470_shield_002/web/echo.py | 235 ------- TUSS4470_shield_002/web/pyproject.toml | 19 - TUSS4470_shield_002/web/requirements.txt | 83 --- TUSS4470_shield_002/web/uv.lock | 537 ---------------- _config.yml | 87 +++ documentation/contributing.md | 79 +++ .../getting_started/TUSS4470_firmware.md | 18 +- .../getting_started/TUSS4470_hardware.md | 27 +- .../getting_started/desktop_interface.md | 36 +- documentation/getting_started/index.md | 15 + .../getting_started/web_interface.md | 33 +- .../lucky_fishfinder}/images/back.JPG | Bin .../lucky_fishfinder}/images/echo_capture.jpg | Bin .../images/fishfinder_pins.JPG | Bin .../lucky_fishfinder}/images/front.JPG | Bin .../lucky_fishfinder/lucky_fishfinder.md | 10 +- pyproject.toml | 73 +++ .../src/open_echo}/UART_UDP_relay.py | 147 ++--- .../open_echo/assets}/static/js-colormaps.js | 0 .../src/open_echo/assets}/static/style.css | 0 .../open_echo/assets}/templates/config.html | 0 .../open_echo/assets}/templates/frontend.html | 0 .../assets}/templates/spectrogram.js | 0 python/src/open_echo/cli.py | 29 + .../src/open_echo}/depth_output.py | 69 ++- .../src/open_echo/desktop.py | 585 +++++++++--------- python/src/open_echo/echo.py | 191 ++++++ python/src/open_echo/py.typed | 0 .../web => python/src/open_echo}/settings.py | 18 +- python/src/open_echo/web.py | 209 +++++++ python/tests/test_depth_output.py | 443 +++++++++++++ python/tests/test_echo.py | 414 +++++++++++++ python/tests/test_settings.py | 98 +++ reverse_engineering/images/.DS_Store | Bin 6148 -> 0 bytes reverse_engineering/live_waterfall.py | 5 +- 44 files changed, 2350 insertions(+), 1445 deletions(-) create mode 100755 .githooks/pre-commit create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/pypi-publish.yml create mode 100644 .github/workflows/python-ci.yml create mode 100644 Gemfile delete mode 100644 TUSS4470_shield_002/requirements.txt delete mode 100644 TUSS4470_shield_002/web/app.py delete mode 100644 TUSS4470_shield_002/web/echo.py delete mode 100644 TUSS4470_shield_002/web/pyproject.toml delete mode 100644 TUSS4470_shield_002/web/requirements.txt delete mode 100644 TUSS4470_shield_002/web/uv.lock create mode 100644 _config.yml create mode 100644 documentation/contributing.md rename TUSS4470_shield_002/getting_started_TUSS4470_firmware.md => documentation/getting_started/TUSS4470_firmware.md (86%) rename TUSS4470_shield_002/README.md => documentation/getting_started/TUSS4470_hardware.md (77%) rename TUSS4470_shield_002/getting_started_interface.md => documentation/getting_started/desktop_interface.md (69%) create mode 100644 documentation/getting_started/index.md rename TUSS4470_shield_002/getting_started_web_interface.md => documentation/getting_started/web_interface.md (65%) rename {reverse_engineering => documentation/lucky_fishfinder}/images/back.JPG (100%) rename {reverse_engineering => documentation/lucky_fishfinder}/images/echo_capture.jpg (100%) rename {reverse_engineering => documentation/lucky_fishfinder}/images/fishfinder_pins.JPG (100%) rename {reverse_engineering => documentation/lucky_fishfinder}/images/front.JPG (100%) rename reverse_engineering/README.md => documentation/lucky_fishfinder/lucky_fishfinder.md (83%) create mode 100644 pyproject.toml rename {TUSS4470_shield_002 => python/src/open_echo}/UART_UDP_relay.py (74%) rename {TUSS4470_shield_002/web => python/src/open_echo/assets}/static/js-colormaps.js (100%) rename {TUSS4470_shield_002/web => python/src/open_echo/assets}/static/style.css (100%) rename {TUSS4470_shield_002/web => python/src/open_echo/assets}/templates/config.html (100%) rename {TUSS4470_shield_002/web => python/src/open_echo/assets}/templates/frontend.html (100%) rename {TUSS4470_shield_002/web => python/src/open_echo/assets}/templates/spectrogram.js (100%) create mode 100644 python/src/open_echo/cli.py rename {TUSS4470_shield_002/web => python/src/open_echo}/depth_output.py (84%) rename TUSS4470_shield_002/echo_interface.py => python/src/open_echo/desktop.py (66%) create mode 100644 python/src/open_echo/echo.py create mode 100644 python/src/open_echo/py.typed rename {TUSS4470_shield_002/web => python/src/open_echo}/settings.py (87%) create mode 100644 python/src/open_echo/web.py create mode 100644 python/tests/test_depth_output.py create mode 100644 python/tests/test_echo.py create mode 100644 python/tests/test_settings.py delete mode 100644 reverse_engineering/images/.DS_Store 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..6df129d --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,45 @@ +name: Publish Docs to GitHub Pages + +on: + push: + branches: [main] + paths: + - 'documentation/**' + - '_config.yml' + - 'Gemfile' + - 'README.md' + + +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..7fcfee2 --- /dev/null +++ b/.github/workflows/pypi-publish.yml @@ -0,0 +1,37 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +jobs: + test-lint-typecheck: + uses: ./.github/workflows/python-ci.yml + build-and-publish: + needs: test-lint-typecheck + 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 fd419c0..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 77% rename from TUSS4470_shield_002/README.md rename to documentation/getting_started/TUSS4470_hardware.md index 1782b09..2f6feae 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..99c5f93 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..8445579 --- /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](TUSS4470_hardware.md). + +This repository provides two software components to support the TUSS4470 Arduino Shield: +- [Arduino Firmware](TUSS4470_firmware.md) - Runs on an Arduino UNO board with the TUSS4470 shield and handles signal generation, echo capture, and communication. +- [Open Echo Interface](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 83% rename from reverse_engineering/README.md rename to documentation/lucky_fishfinder/lucky_fishfinder.md index e921abd..598ad17 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/UART_UDP_relay.py b/python/src/open_echo/UART_UDP_relay.py similarity index 74% rename from TUSS4470_shield_002/UART_UDP_relay.py rename to python/src/open_echo/UART_UDP_relay.py index 3c5f6a3..c68d022 100644 --- a/TUSS4470_shield_002/UART_UDP_relay.py +++ b/python/src/open_echo/UART_UDP_relay.py @@ -1,118 +1,124 @@ +import argparse +import socket + import serial import serial.tools.list_ports -import socket -import argparse START_BYTE = 0xAA -def list_uart_ports(): - """List all available UART/serial ports.""" - ports = serial.tools.list_ports.comports() - if not ports: - print("No serial ports found.") - return - print("Available UART ports:") - for port in ports: - print(f" {port.device} - {port.description}") - - -def read_raw_packet(ser, payload_size, verbose=False): - """ - Reads and returns a FULL raw packet: - b'\\xAA' + payload + checksum - """ - while True: - header = ser.read(1) - if header != bytes([START_BYTE]): - continue - - payload = ser.read(payload_size) - checksum = ser.read(1) - - if len(payload) != payload_size or len(checksum) != 1: - if verbose: - print("⚠️ Incomplete packet") - continue - - # Verify checksum (XOR of payload bytes) - calc_checksum = 0 - for b in payload: - calc_checksum ^= b - - if calc_checksum != checksum[0]: - if verbose: - print("⚠️ Checksum mismatch (UART)") - continue - - if verbose: - print("📦 Packet received (checksum OK)") - - return header + payload + checksum - - -def main(): - parser = argparse.ArgumentParser( - description="UART → UDP transparent relay" - ) - +def configure_relay_parser(parser): parser.add_argument( "-p", "--uart-port", - help="UART device (e.g. COM3 or /dev/ttyUSB0)" + help="UART device (e.g. COM3 or /dev/ttyUSB0)", ) parser.add_argument( "-b", "--baud-rate", type=int, default=250000, - help="UART baud rate (default: 250000)" + help="UART baud rate (default: 250000)", ) parser.add_argument( "-n", "--samples", type=int, default=1800, - help="Number of samples per packet (default: 1800)" + help="Number of samples per packet (default: 1800)", ) parser.add_argument( "--udp-ip", default="127.0.0.1", - help="UDP target IP (default: 127.0.0.1)" + help="UDP target IP (default: 127.0.0.1)", ) parser.add_argument( "--udp-port", type=int, default=5005, - help="UDP target port (default: 5005)" + help="UDP target port (default: 5005)", ) parser.add_argument( "--broadcast", action="store_true", - help="Enable UDP broadcast (255.255.255.255)" + help="Enable UDP broadcast (255.255.255.255)", ) parser.add_argument( "--list-uart", action="store_true", - help="List all available UART/serial ports and exit" + help="List all available UART/serial ports and exit", ) verbosity = parser.add_mutually_exclusive_group() verbosity.add_argument( "--quiet", action="store_true", - help="Suppress all non-error output" + help="Suppress all non-error output", ) verbosity.add_argument( "--verbose", action="store_true", - help="Enable verbose packet diagnostics" + help="Enable verbose packet diagnostics", ) - args = parser.parse_args() + return parser + + +def list_uart_ports(): + """List all available UART/serial ports.""" + ports = serial.tools.list_ports.comports() + if not ports: + print("No serial ports found.") + return + print("Available UART ports:") + for port in ports: + print(f" {port.device} - {port.description}") + + +def read_raw_packet(ser, payload_size, verbose=False): + """ + Reads and returns a FULL raw packet: + b'\\xAA' + payload + checksum + """ + while True: + header = ser.read(1) + if header != bytes([START_BYTE]): + continue + + payload = ser.read(payload_size) + checksum = ser.read(1) + + if len(payload) != payload_size or len(checksum) != 1: + if verbose: + print("Incomplete packet") + continue + + # Verify checksum (XOR of payload bytes) + calc_checksum = 0 + for b in payload: + calc_checksum ^= b + + if calc_checksum != checksum[0]: + if verbose: + print("Checksum mismatch (UART)") + continue + + if verbose: + print("Packet received (checksum OK)") + + return header + payload + checksum + + +def run_relay(args=None): + parser = None + if args is None or isinstance(args, list): + parser = configure_relay_parser( + argparse.ArgumentParser(description="UART → UDP transparent relay") + ) + args = parser.parse_args(args) # ===== Handle list-uart and exit ===== if args.list_uart: @@ -121,8 +127,9 @@ def main(): # Require UART port if not listing if not args.uart_port: - print("❌ Error: UART port must be specified with -p / --uart-port") - parser.print_help() + print("Error: UART port must be specified with -p / --uart-port") + if parser is not None: + parser.print_help() return payload_size = 6 + 2 * args.samples @@ -151,7 +158,7 @@ def main(): try: with serial.Serial(args.uart_port, args.baud_rate, timeout=1) as ser: if not args.quiet: - print("✅ UART connected, relaying packets...\n") + print("UART connected, relaying packets...\n") while True: packet = read_raw_packet( @@ -162,13 +169,9 @@ def main(): udp_sock.sendto(packet, (udp_ip, args.udp_port)) except serial.SerialException as e: - print(f"❌ UART error: {e}") + print(f"UART error: {e}") except KeyboardInterrupt: if not args.quiet: - print("\n🛑 Relay stopped by user") + print("\nRelay stopped by user") finally: - udp_sock.close() - - -if __name__ == "__main__": - main() + udp_sock.close() \ No newline at end of file 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..1558c9f --- /dev/null +++ b/python/src/open_echo/cli.py @@ -0,0 +1,29 @@ +from argparse import ArgumentParser + +from open_echo.desktop import run_desktop +from open_echo.UART_UDP_relay import configure_relay_parser, run_relay +from open_echo.web import run_web + + +def main(): + parser = ArgumentParser( + description="Command-line interface for the open_echo package." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + desktop_parser = subparsers.add_parser("desktop", help="Run desktop interface") + desktop_parser.set_defaults(handler=lambda _: run_desktop()) + + web_parser = subparsers.add_parser("web", help="Run web interface") + web_parser.set_defaults(handler=lambda _: run_web()) + + relay_parser = subparsers.add_parser("relay", help="Run UART to UDP relay") + configure_relay_parser(relay_parser) + relay_parser.set_defaults(handler=run_relay) + + args = parser.parse_args() + args.handler(args) + + +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 66% rename from TUSS4470_shield_002/echo_interface.py rename to python/src/open_echo/desktop.py index a9321b9..f5cdbe8 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) @@ -599,12 +510,12 @@ def __init__(self): self.send_button.clicked.connect(self.send_hex_value) hex_row.addWidget(self.send_button) - # ➕ Settings button + # Settings button self.settings_button = QPushButton("Settings") self.settings_button.clicked.connect(self.open_settings) hex_row.addWidget(self.settings_button) - # ➕ Quit button + # Quit button self.quit_button = QPushButton("Quit") self.quit_button.clicked.connect(self.close) hex_row.addWidget(self.quit_button) @@ -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..e17275e --- /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..c3f0c4c --- /dev/null +++ b/python/src/open_echo/web.py @@ -0,0 +1,209 @@ +import asyncio +import logging +from collections.abc import Callable, Coroutine +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +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 list(self.active_connections): + try: + await connection.send_json(data) + except Exception as e: + log.warning(f"WebSocket send failed, disconnecting client: {e}") + await self.disconnect(connection) + + +class EchoReader: + def __init__( + self, + data_callback: Callable[[dict], Coroutine[Any, Any, None]], + depth_callback: Callable[[float], Coroutine[Any, Any, None]], + 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.""" + retry_delay = 1 # seconds; doubles on each consecutive failure up to 30s max + 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(): + log.info("Restart event set, breaking loop") + break + retry_delay = 1 # reset backoff on clean exit + await self._restart_event.wait() + except Exception as e: + log.error(f"Error in EchoReader: {e}", exc_info=e) + log.info(f"Retrying in {retry_delay}s...") + await asyncio.sleep(retry_delay) + retry_delay = min(retry_delay * 2, 30) + + +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 958f64d3e7e61122cf061619416512f1d0df883a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%TB{E5S)b`g1A)T!UZW`5Q#qsRS|*{5+49f%e!i-mfmycWB5dvvCShPToFRM zl0CcY-L*4P`~YC1&Fl~u0?62eCZ$K@?j71n!2_a&8U@a9j+4`QJ?$y<7hPI=h6)o* z@rW<$ujX~dF%fgbR=ftGCf_xq?@;5CIKdK6*kDC(fd^W*`u@*+d+y}#;Rg3uvx0De z88cU`wG7t!hgH0Es_Za(#WgHAd+)mKpp!w<#Tjr0oB?OR8PJ(6$#x@p?+iEt&cFu) z+8=T@VVSUr7`6^};R!(0H981&U73}WCoB^-5jjG!m`aSPmYx_E)9KGsT$!+m7}H_t z@nLD^_s5Hc*;zji;jl8I_s)Pb&}LvennRuc$NXh_ANlPRx10fI;GZ#IgYm_9#E(j6 z>$lI-S(~$6u}KuyNTE<~T>@xmAK4FsaXgpKxH4fAQB~v*=|sN>1VX%X27ZBoH+kAe Aw*UYD 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 From 76f1679fd0f570ab1d9dafb84d91431b89e9ca28 Mon Sep 17 00:00:00 2001 From: John Harrington Date: Thu, 19 Feb 2026 16:04:53 +0000 Subject: [PATCH 2/6] Integrate UDP relay code --- python/src/open_echo/UART_UDP_relay.py | 24 +++++++----------- python/src/open_echo/depth_output.py | 2 +- python/src/open_echo/echo.py | 35 +++++++++++++++++++------- 3 files changed, 36 insertions(+), 25 deletions(-) diff --git a/python/src/open_echo/UART_UDP_relay.py b/python/src/open_echo/UART_UDP_relay.py index c68d022..8542886 100644 --- a/python/src/open_echo/UART_UDP_relay.py +++ b/python/src/open_echo/UART_UDP_relay.py @@ -3,8 +3,7 @@ import serial import serial.tools.list_ports - -START_BYTE = 0xAA +from open_echo.echo import START_BYTE, compute_checksum, payload_size def configure_relay_parser(parser): @@ -78,30 +77,25 @@ def list_uart_ports(): print(f" {port.device} - {port.description}") -def read_raw_packet(ser, payload_size, verbose=False): +def read_raw_packet(ser, num_payload_bytes, verbose=False): """ Reads and returns a FULL raw packet: b'\\xAA' + payload + checksum """ while True: header = ser.read(1) - if header != bytes([START_BYTE]): + if not header or header[0] != START_BYTE: continue - payload = ser.read(payload_size) + payload = ser.read(num_payload_bytes) checksum = ser.read(1) - if len(payload) != payload_size or len(checksum) != 1: + if len(payload) != num_payload_bytes or len(checksum) != 1: if verbose: print("Incomplete packet") continue - # Verify checksum (XOR of payload bytes) - calc_checksum = 0 - for b in payload: - calc_checksum ^= b - - if calc_checksum != checksum[0]: + if compute_checksum(payload) != checksum[0]: if verbose: print("Checksum mismatch (UART)") continue @@ -132,7 +126,7 @@ def run_relay(args=None): parser.print_help() return - payload_size = 6 + 2 * args.samples + pld_size = payload_size(args.samples) udp_ip = "255.255.255.255" if args.broadcast else args.udp_ip # ===== Startup banner ===== @@ -143,7 +137,7 @@ def run_relay(args=None): print(f" UART port : {args.uart_port}") print(f" Baud rate : {args.baud_rate}") print(f" Samples : {args.samples}") - print(f" Payload size : {payload_size} bytes") + print(f" Payload size : {pld_size} bytes") print(f" UDP target IP : {udp_ip}") print(f" UDP target port: {args.udp_port}") print(f" Broadcast mode : {'ON' if args.broadcast else 'OFF'}") @@ -163,7 +157,7 @@ def run_relay(args=None): while True: packet = read_raw_packet( ser, - payload_size, + pld_size, verbose=args.verbose and not args.quiet ) udp_sock.sendto(packet, (udp_ip, args.udp_port)) diff --git a/python/src/open_echo/depth_output.py b/python/src/open_echo/depth_output.py index c1dc134..511fa55 100644 --- a/python/src/open_echo/depth_output.py +++ b/python/src/open_echo/depth_output.py @@ -43,7 +43,7 @@ async def output(self) -> Any: 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.output_interval ) ): output_class.last_output_time = asyncio.get_event_loop().time() diff --git a/python/src/open_echo/echo.py b/python/src/open_echo/echo.py index e17275e..c85b7da 100644 --- a/python/src/open_echo/echo.py +++ b/python/src/open_echo/echo.py @@ -13,6 +13,27 @@ if TYPE_CHECKING: from open_echo.settings import Settings +START_BYTE = 0xAA +PAYLOAD_HEADER_SIZE = 6 + + +def compute_checksum(payload: bytes) -> int: + """XOR checksum over all bytes in payload.""" + chk = 0 + for b in payload: + chk ^= b + return chk + + +def payload_size(num_samples: int) -> int: + """Total payload size: 6-byte header + 1 byte per sample.""" + return PAYLOAD_HEADER_SIZE + num_samples + + +def packet_size(num_samples: int) -> int: + """Full packet size: start byte + payload + checksum byte.""" + return 1 + payload_size(num_samples) + 1 + class EchoReadError(ValueError): pass @@ -31,15 +52,11 @@ class EchoPacket: @classmethod def unpack(cls, payload: bytes, checksum: bytes, num_samples: int) -> "EchoPacket": - if len(payload) != 6 + num_samples or len(checksum) != 1: + if len(payload) != payload_size(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") + if compute_checksum(payload) != checksum[0]: raise ChecksumMismatchError("Checksum mismatch") # Unpack payload @@ -117,7 +134,7 @@ async def read(self) -> EchoPacket: while True: header = await self.reader.readexactly(1) - if header != b"\xaa": + if header[0] != START_BYTE: continue # Wait for the start byte payload = await self.reader.readexactly( @@ -136,7 +153,7 @@ def __init__(self, outer): def datagram_received(self, data: bytes, addr): for b in data: if not self.outer._buf: - if b == 0xAA: + if b == START_BYTE: self.outer._buf.append(b) else: continue @@ -162,7 +179,7 @@ def __init__(self, settings: "Settings"): self._transport = None self._queue: asyncio.Queue = asyncio.Queue() self._buf = bytearray() - self.packet_size = 1 + 6 + self.settings.num_samples + 1 + self.packet_size = packet_size(self.settings.num_samples) self.host = getattr(settings, "udp_host", "0.0.0.0") self.port = getattr(settings, "udp_port", 9999) From 87795aeb4c9b5909286991d12de1301ceba7eb1c Mon Sep 17 00:00:00 2001 From: John Harrington Date: Thu, 19 Feb 2026 16:10:12 +0000 Subject: [PATCH 3/6] Improve logging --- python/src/open_echo/echo.py | 27 +++++++++++++++++++-------- python/tests/test_echo.py | 16 +++------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/python/src/open_echo/echo.py b/python/src/open_echo/echo.py index c85b7da..367ff95 100644 --- a/python/src/open_echo/echo.py +++ b/python/src/open_echo/echo.py @@ -1,4 +1,5 @@ import asyncio +import logging import struct from abc import ABC, abstractmethod from collections.abc import AsyncGenerator @@ -10,6 +11,8 @@ import serial.tools.list_ports import serial_asyncio_fast as aserial +log = logging.getLogger(__name__) + if TYPE_CHECKING: from open_echo.settings import Settings @@ -56,7 +59,9 @@ def unpack(cls, payload: bytes, checksum: bytes, num_samples: int) -> "EchoPacke raise EchoReadError("Invalid payload or checksum length") # Verify checksum - if compute_checksum(payload) != checksum[0]: + calc = compute_checksum(payload) + if calc != checksum[0]: + log.warning("Checksum mismatch: expected 0x%02X, got 0x%02X", checksum[0], calc) raise ChecksumMismatchError("Checksum mismatch") # Unpack payload @@ -74,7 +79,7 @@ def unpack(cls, payload: bytes, checksum: bytes, num_samples: int) -> "EchoPacke class AsyncReader(ABC): def __init__(self, settings: "Settings"): - print("AsyncReader initialized") + log.debug("AsyncReader initialized") self.settings = settings async def __aenter__(self) -> "AsyncReader": @@ -106,7 +111,7 @@ async def __aiter__(self) -> AsyncGenerator[EchoPacket, None]: class SerialReader(AsyncReader): def __init__(self, settings: "Settings"): - print("SerialReader initialized") + log.debug("SerialReader initialized") super().__init__(settings) self.reader: asyncio.StreamReader | None = None self.writer: asyncio.StreamWriter | None = None @@ -138,11 +143,15 @@ async def read(self) -> EchoPacket: continue # Wait for the start byte payload = await self.reader.readexactly( - 6 + self.settings.num_samples - ) # Read payload + payload_size(self.settings.num_samples) + ) checksum = await self.reader.readexactly(1) - return EchoPacket.unpack(payload, checksum, self.settings.num_samples) + try: + return EchoPacket.unpack(payload, checksum, self.settings.num_samples) + except ChecksumMismatchError: + log.warning("Serial: checksum mismatch, dropping packet") + continue class UDPReader(AsyncReader): @@ -171,6 +180,8 @@ def datagram_received(self, data: bytes, addr): payload, checksum, self.outer.settings.num_samples ) self.outer._queue.put_nowait(result) + except ChecksumMismatchError: + log.warning("UDP: checksum mismatch, dropping packet") finally: self.outer._buf.clear() @@ -184,14 +195,14 @@ def __init__(self, settings: "Settings"): self.port = getattr(settings, "udp_port", 9999) async def open(self): - print("Starting UDP listener...") + 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 - print(f"UDP listener bound to {self.host}:{self.port}") + log.info("UDP listener bound to %s:%d", self.host, self.port) async def close(self): if self._transport: diff --git a/python/tests/test_echo.py b/python/tests/test_echo.py index 31fbb62..7bbc710 100644 --- a/python/tests/test_echo.py +++ b/python/tests/test_echo.py @@ -235,9 +235,8 @@ async def test_udpreader_protocol_checksum_mismatch_clears_buffer_and_does_not_e ) 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)) + # Corrupted packet should be silently dropped, not raise + 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): @@ -279,16 +278,7 @@ async def read(self): @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() - +async def test_udpreader_open_and_close(): settings = DummySettings( num_samples=8, udp_host="127.0.0.1", udp_port=0 ) # use ephemeral port From 7c6bfa2d040e45349274f87d175522beae083d95 Mon Sep 17 00:00:00 2001 From: John Harrington Date: Fri, 20 Feb 2026 11:37:39 +0000 Subject: [PATCH 4/6] Refactor desktop app to run background web process --- python/src/open_echo/cli.py | 9 +- python/src/open_echo/desktop.py | 1278 +++++++++++++++---------------- python/src/open_echo/web.py | 19 + python/tests/test_cli.py | 52 ++ python/tests/test_desktop.py | 191 +++++ python/tests/test_web.py | 277 +++++++ 6 files changed, 1178 insertions(+), 648 deletions(-) create mode 100644 python/tests/test_cli.py create mode 100644 python/tests/test_desktop.py create mode 100644 python/tests/test_web.py diff --git a/python/src/open_echo/cli.py b/python/src/open_echo/cli.py index 1558c9f..8e32e9b 100644 --- a/python/src/open_echo/cli.py +++ b/python/src/open_echo/cli.py @@ -12,7 +12,14 @@ def main(): subparsers = parser.add_subparsers(dest="command", required=True) desktop_parser = subparsers.add_parser("desktop", help="Run desktop interface") - desktop_parser.set_defaults(handler=lambda _: run_desktop()) + desktop_parser.add_argument( + "--server-url", + default="http://localhost:8000", + help="URL of the web server (default: http://localhost:8000)", + ) + desktop_parser.set_defaults( + handler=lambda args: run_desktop(server_url=args.server_url) + ) web_parser = subparsers.add_parser("web", help="Run web interface") web_parser.set_defaults(handler=lambda _: run_web()) diff --git a/python/src/open_echo/desktop.py b/python/src/open_echo/desktop.py index f5cdbe8..5883af0 100644 --- a/python/src/open_echo/desktop.py +++ b/python/src/open_echo/desktop.py @@ -1,18 +1,15 @@ -# Async integration +# Desktop client — connects to the web server's WebSocket for echo data. +# If no web server is running, spawns one as a subprocess. import asyncio -import socket +import json +import logging +import subprocess import sys -import time +import threading import numpy as np import pyqtgraph as pg import qdarktheme -import serial -import serial.tools.list_ports -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 ( @@ -23,109 +20,286 @@ QLabel, QLineEdit, QMainWindow, + QMessageBox, QPushButton, QVBoxLayout, QWidget, ) from qasync import QEventLoop -# Serial Configuration -BAUD_RATE = 250000 -# Default values; overridden by WaterfallApp instance settings -NUM_SAMPLES = 1800 # (X-axis) +log = logging.getLogger(__name__) MAX_ROWS = 300 # Number of time steps (Y-axis) Y_LABEL_DISTANCE = 50 # distance between labels in cm +DEFAULT_LEVELS = (0, 256) -SPEED_OF_SOUND = 1440 # default sound speed meters/second in water -# SPEED_OF_SOUND = 343 # default sound speed meters/second in water +# Default server URL +DEFAULT_SERVER_URL = "http://localhost:8000" -# SAMPLE_TIME = 52.226e-6 # 13.2 microseconds on Atmega328 max sample speed plus 50 microseconds delay in sampling loop -# 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 = 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 -# SAMPLE_TIME = 1.290e-6 # 13.2 microseconds on RP2040 max sample speed without additional delay -DEFAULT_LEVELS = (0, 256) # Expected data range +# --------------------------------------------------------------------------- +# WebSocket client — receives echo data from the web server +# --------------------------------------------------------------------------- + + +class WebSocketClient(QObject): + """WebSocket client running in a background thread with its own event loop. + + qasync's event loop doesn't support TCP ``create_connection``, so we run + the websockets client in a dedicated thread and bridge data back to Qt + via pyqtSignal. + """ + + packet_received = pyqtSignal(dict) + connection_changed = pyqtSignal(str) # "connected", "reconnecting" + + def __init__(self, server_url: str = DEFAULT_SERVER_URL): + super().__init__() + self.server_url = server_url.rstrip("/") + self._ws_url = ( + self.server_url.replace("http://", "ws://").replace( + "https://", "wss://" + ) + + "/ws" + ) + self._thread: threading.Thread | None = None + self._stop_event = threading.Event() + + def start(self): + self._stop_event.clear() + self._thread = threading.Thread( + target=self._thread_main, daemon=True, name="ws-client" + ) + self._thread.start() + + def stop(self): + self._stop_event.set() + if self._thread: + self._thread.join(timeout=5) + self._thread = None + + def _thread_main(self): + """Entry point for the background thread — runs its own asyncio loop.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(self._run()) + finally: + loop.close() + + async def _run(self): + import websockets + + retry_delay = 1.0 + while not self._stop_event.is_set(): + try: + self.connection_changed.emit("reconnecting") + async with websockets.connect(self._ws_url) as ws: + self.connection_changed.emit("connected") + retry_delay = 1.0 + async for raw in ws: + if self._stop_event.is_set(): + break + try: + data = json.loads(raw) + self.packet_received.emit(data) + except json.JSONDecodeError: + log.warning("Invalid JSON from WebSocket") + except asyncio.CancelledError: + break + except Exception as e: + log.warning( + "WebSocket error: %s — retrying in %.0fs", e, retry_delay + ) + self.connection_changed.emit("reconnecting") + # Use stop_event.wait so we can exit promptly + if self._stop_event.wait(timeout=retry_delay): + break + retry_delay = min(retry_delay * 2, 30.0) + + +# --------------------------------------------------------------------------- +# Web server lifecycle — auto-detect or spawn +# --------------------------------------------------------------------------- + + +class WebServerManager: + """Checks for an existing web server and spawns one if needed.""" + + def __init__(self, server_url: str = DEFAULT_SERVER_URL): + self.server_url = server_url.rstrip("/") + self._process: subprocess.Popen | None = None + self._owned = False # True if we spawned the server + + async def ensure_running(self) -> bool: + """Return True once the server is reachable. Spawns if needed.""" + if await asyncio.get_event_loop().run_in_executor( + None, self._is_reachable + ): + log.info("Web server already running at %s", self.server_url) + return True + + log.info("No web server found — spawning one...") + self._spawn() + # Wait for it to become reachable + for _ in range(60): # up to ~30 s + await asyncio.sleep(0.5) + if await asyncio.get_event_loop().run_in_executor( + None, self._is_reachable + ): + log.info("Web server is now reachable") + return True + + log.error("Web server failed to start within timeout") + return False + + def _is_reachable(self) -> bool: + import urllib.request + + try: + resp = urllib.request.urlopen( + f"{self.server_url}/api/settings", timeout=2 + ) + return resp.status == 200 + except Exception: + return False + + def _spawn(self): + self._process = subprocess.Popen( + [ + sys.executable, + "-m", + "uvicorn", + "open_echo.web:app", + "--host", + "0.0.0.0", + "--port", + "8000", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + self._owned = True + + def shutdown(self): + if self._owned and self._process: + log.info("Shutting down spawned web server (pid %d)", self._process.pid) + self._process.terminate() + try: + self._process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait(timeout=2) + self._process = None + -# Module-level derived values are kept for defaults only; instance values are used in UI -SAMPLE_RESOLUTION = (SPEED_OF_SOUND * SAMPLE_TIME * 100) / 2 -PACKET_SIZE = 1 + 6 + NUM_SAMPLES + 1 -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) -} +# --------------------------------------------------------------------------- +# HTTP helpers — synchronous (run via executor from qasync loop) +# --------------------------------------------------------------------------- -def generate_dbt_sentence(depth_cm): - depth_m = depth_cm / 100.0 - depth_ft = depth_m * 3.28084 - depth_fathoms = depth_m * 0.546807 +def _fetch_settings_sync(server_url: str) -> dict | None: + import json + import urllib.request - # Format the DBT sentence without checksum - sentence_body = f"DBT,{depth_ft:.1f},f,{depth_m:.1f},M,{depth_fathoms:.1f},F" + try: + resp = urllib.request.urlopen( + f"{server_url}/api/settings", timeout=5 + ) + return json.loads(resp.read()) + except Exception as e: + log.error("Failed to fetch settings: %s", e) + return None - # Compute checksum - checksum = 0 - for char in sentence_body: - checksum ^= ord(char) - nmea_sentence = f"${sentence_body}*{checksum:02X}" - return nmea_sentence +def _push_settings_sync(server_url: str, settings_dict: dict) -> dict | None: + import json + import urllib.request + try: + data = json.dumps(settings_dict).encode("utf-8") + req = urllib.request.Request( + f"{server_url}/api/settings", + data=data, + headers={"Content-Type": "application/json"}, + method="PUT", + ) + resp = urllib.request.urlopen(req, timeout=5) + return json.loads(resp.read()) + except Exception as e: + log.error("Failed to push settings: %s", e) + return None -def get_serial_ports(): - """Retrieve a list of available serial ports.""" - return [port.device for port in serial.tools.list_ports.comports()][::-1] +def _fetch_serial_ports_sync(server_url: str) -> list[str]: + import json + import urllib.request -def get_local_ip(): try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - ip = s.getsockname()[0] - s.close() - return ip - except Exception: - return "127.0.0.1" + resp = urllib.request.urlopen( + f"{server_url}/api/serial-ports", timeout=5 + ) + return json.loads(resp.read()) + except Exception as e: + log.error("Failed to fetch serial ports: %s", e) + return [] + + +async def fetch_settings(server_url: str) -> dict | None: + return await asyncio.get_event_loop().run_in_executor( + None, _fetch_settings_sync, server_url + ) + + +async def push_settings(server_url: str, settings_dict: dict) -> dict | None: + return await asyncio.get_event_loop().run_in_executor( + None, _push_settings_sync, server_url, settings_dict + ) + + +async def fetch_serial_ports(server_url: str) -> list[str]: + return await asyncio.get_event_loop().run_in_executor( + None, _fetch_serial_ports_sync, server_url + ) + + +# --------------------------------------------------------------------------- +# Settings Dialog — local display + proxied echo settings +# --------------------------------------------------------------------------- class SettingsDialog(QWidget): + settings_applied = pyqtSignal() + def __init__( self, parent=None, current_gradient="cyclic", - current_speed=343, - current_num_samples=NUM_SAMPLES, - current_sample_time_us=SAMPLE_TIME * 1e6, - nmea_enabled=False, - nmea_port=10110, - nmea_address="127.0.0.1", + server_url: str = DEFAULT_SERVER_URL, ): super().__init__(parent) - self.setWindowTitle("Chart Settings") - self.setFixedSize(340, 640) - self.main_app = parent + self.server_url = server_url + self.setWindowTitle("Settings") + self.setFixedSize(380, 750) + + # Populated asynchronously after show() + self._server_settings: dict = {} - # Outer layout for centering outer_layout = QVBoxLayout(self) - outer_layout.setAlignment(Qt.AlignCenter) + outer_layout.setAlignment(Qt.AlignCenter) # type: ignore[attr-defined] - # === Card container === card = QWidget() + card.setObjectName("Card") card_layout = QVBoxLayout(card) card_layout.setContentsMargins(20, 20, 20, 20) - card_layout.setSpacing(15) + card_layout.setSpacing(12) + + # === Display Settings (local) === + display_header = QLabel("Display") + display_header.setStyleSheet("font-weight: bold; font-size: 15px;") + card_layout.addWidget(display_header) - # --- Color Map --- card_layout.addWidget(QLabel("Color Map:")) self.gradient_dropdown = QComboBox() self.gradient_dropdown.addItems( @@ -147,241 +321,279 @@ def __init__( self.gradient_dropdown.setCurrentText(current_gradient) card_layout.addWidget(self.gradient_dropdown) - # --- Speed of Sound --- - card_layout.addWidget(QLabel("Speed of Sound:")) - self.speed_dropdown = QComboBox() - self.speed_dropdown.addItems(["343m/s (Air)", "1440m/s (Water)"]) - self.speed_dropdown.setCurrentIndex(1 if current_speed == 1440 else 0) - card_layout.addWidget(self.speed_dropdown) - - # --- Sampling Parameters --- - sampling_section = QVBoxLayout() - sampling_section.setSpacing(8) - - sampling_label = QLabel("Sampling Parameters:") - sampling_label.setStyleSheet("font-weight: bold;") - sampling_section.addWidget(sampling_label) - - # Number of Samples - ns_row = QHBoxLayout() - ns_label = QLabel("Num. Samples:") - ns_label.setMinimumWidth(100) - self.num_samples_input = QLineEdit() - self.num_samples_input.setPlaceholderText("e.g. 1800") - self.num_samples_input.setText(str(current_num_samples)) - self.num_samples_input.setMaximumWidth(200) - ns_row.addWidget(ns_label) - ns_row.addWidget(self.num_samples_input) - ns_row.addStretch() - sampling_section.addLayout(ns_row) - - # Sample Time (microseconds) - st_row = QHBoxLayout() - st_label = QLabel("Sample Time (µs):") - st_label.setMinimumWidth(100) - self.sample_time_input = QLineEdit() - self.sample_time_input.setPlaceholderText("e.g. 13.2") - # Accept display in microseconds for user convenience - self.sample_time_input.setText(f"{current_sample_time_us:.6f}") - self.sample_time_input.setMaximumWidth(200) - st_row.addWidget(st_label) - st_row.addWidget(self.sample_time_input) - st_row.addStretch() - sampling_section.addLayout(st_row) - - card_layout.addLayout(sampling_section) - - # --- NMEA Output Section --- - nmea_section = QVBoxLayout() - nmea_section.setSpacing(8) - - # Section title - nmea_label = QLabel("NMEA TCP Output:") - nmea_label.setStyleSheet("font-weight: bold;") - nmea_section.addWidget(nmea_label) - - # Enable checkbox - self.nmea_enable_checkbox = QCheckBox("Enable NMEA Output") - self.nmea_enable_checkbox.setStyleSheet( - "QCheckBox:hover { text-decoration: none; }" - ) - nmea_section.addWidget(self.nmea_enable_checkbox) - - # Address display row - addr_row = QHBoxLayout() - addr_label = QLabel("Address:") - addr_label.setMinimumWidth(60) - - self.addr_display = QLabel(nmea_address) - self.addr_display.setStyleSheet("color: #cccccc; padding: 2px;") - self.addr_display.setTextInteractionFlags( - Qt.TextSelectableByMouse - ) # Allow text copy - - copy_button = QPushButton("Copy") - copy_button.setFixedHeight(22) - copy_button.setStyleSheet("font-size: 11px; padding: 2px 6px;") - copy_button.clicked.connect( - lambda: QApplication.clipboard().setText(nmea_address) - ) - - addr_row.addWidget(addr_label) - addr_row.addWidget(self.addr_display) - addr_row.addWidget(copy_button) - addr_row.addStretch() - nmea_section.addLayout(addr_row) - - # Port input with label to the left - port_row = QHBoxLayout() - - # --- Large Depth Display Option --- self.large_depth_checkbox = QCheckBox("Show Depth Display") self.large_depth_checkbox.setChecked( getattr(parent, "large_depth_visible", True) ) card_layout.addWidget(self.large_depth_checkbox) - port_label = QLabel("Port:") - port_label.setMinimumWidth(40) - - self.port_input = QLineEdit() - self.port_input.setPlaceholderText("TCP Port (default: 10110)") - self.port_input.setText(str(nmea_port)) - self.port_input.setMaximumWidth(200) - - port_row.addWidget(port_label) - port_row.addWidget(self.port_input) - port_row.addStretch() - nmea_section.addLayout(port_row) + # === Echo Settings (proxied to web server) === + echo_header = QLabel("Echo Sounder") + echo_header.setStyleSheet("font-weight: bold; font-size: 15px;") + card_layout.addWidget(echo_header) + + # Connection type + card_layout.addWidget(QLabel("Connection:")) + self.connection_type_dropdown = QComboBox() + self.connection_type_dropdown.addItems(["SERIAL", "UDP"]) + self.connection_type_dropdown.currentTextChanged.connect( + self._on_connection_type_changed + ) + card_layout.addWidget(self.connection_type_dropdown) - # Connect AFTER both widgets are created - self.nmea_enable_checkbox.toggled.connect(self.port_input.setEnabled) + # Serial port + self.serial_port_label = QLabel("Serial Port:") + card_layout.addWidget(self.serial_port_label) + self.serial_port_dropdown = QComboBox() + card_layout.addWidget(self.serial_port_dropdown) - # Apply initial state (pass nmea_enabled into the constructor!) - self.nmea_enable_checkbox.setChecked(nmea_enabled) - self.port_input.setEnabled(nmea_enabled) + # UDP port + self.udp_port_label = QLabel("UDP Port:") + card_layout.addWidget(self.udp_port_label) + self.udp_port_input = QLineEdit() + self.udp_port_input.setText("9999") + card_layout.addWidget(self.udp_port_input) - # Add to card layout - card_layout.addLayout(nmea_section) + # Medium + card_layout.addWidget(QLabel("Medium:")) + self.medium_dropdown = QComboBox() + self.medium_dropdown.addItems(["water", "air"]) + card_layout.addWidget(self.medium_dropdown) - # --- Buttons --- + # Num samples + ns_row = QHBoxLayout() + ns_row.addWidget(QLabel("Num Samples:")) + self.num_samples_input = QLineEdit() + self.num_samples_input.setText("1800") + self.num_samples_input.setMaximumWidth(120) + ns_row.addWidget(self.num_samples_input) + ns_row.addStretch() + card_layout.addLayout(ns_row) + + # === Depth Output === + depth_header = QLabel("Depth Output") + depth_header.setStyleSheet("font-weight: bold; font-size: 15px;") + card_layout.addWidget(depth_header) + + td_row = QHBoxLayout() + td_row.addWidget(QLabel("Transducer depth (m):")) + self.transducer_depth_input = QLineEdit("0.0") + self.transducer_depth_input.setMaximumWidth(80) + td_row.addWidget(self.transducer_depth_input) + td_row.addStretch() + card_layout.addLayout(td_row) + + draft_row = QHBoxLayout() + draft_row.addWidget(QLabel("Draft (m):")) + self.draft_input = QLineEdit("0.0") + self.draft_input.setMaximumWidth(80) + draft_row.addWidget(self.draft_input) + draft_row.addStretch() + card_layout.addLayout(draft_row) + + # SignalK + self.signalk_enable = QCheckBox("SignalK output") + card_layout.addWidget(self.signalk_enable) + sk_row = QHBoxLayout() + sk_row.addWidget(QLabel("SignalK address:")) + self.signalk_address_input = QLineEdit("localhost:3000") + sk_row.addWidget(self.signalk_address_input) + card_layout.addLayout(sk_row) + + # NMEA + self.nmea_enable = QCheckBox("NMEA0183 output") + card_layout.addWidget(self.nmea_enable) + nmea_row = QHBoxLayout() + nmea_row.addWidget(QLabel("NMEA address:")) + self.nmea_address_input = QLineEdit("localhost:10110") + nmea_row.addWidget(self.nmea_address_input) + card_layout.addLayout(nmea_row) + + # === Buttons === button_layout = QHBoxLayout() apply_button = QPushButton("Apply") - apply_button.clicked.connect(self.apply_settings) + apply_button.clicked.connect(self._apply) cancel_button = QPushButton("Cancel") - cancel_button.clicked.connect(self.close) + cancel_button.clicked.connect(self.close) # type: ignore[arg-type] button_layout.addWidget(apply_button) button_layout.addWidget(cancel_button) card_layout.addLayout(button_layout) - # Add card to outer layout outer_layout.addWidget(card) - # --- Styling --- self.setStyleSheet( """ - QDialog { - background-color: #1e1e1e; - } - QWidget#Card { - background-color: #2b2b2b; - border-radius: 12px; - padding: 15px; - } - QLabel { - color: #ffffff; - font-size: 14px; - } - QComboBox { - background-color: #3c3c3c; - color: white; - padding: 4px; - border-radius: 4px; - } - QPushButton { - background-color: #444444; - border: 1px solid #666; - padding: 5px 10px; - border-radius: 6px; - } - QPushButton:hover { - background-color: #555; - } - """ + QWidget#Card { + background-color: #2b2b2b; + border-radius: 12px; + padding: 15px; + } + QLabel { color: #ffffff; font-size: 14px; } + QComboBox { + background-color: #3c3c3c; color: white; + padding: 4px; border-radius: 4px; + } + QLineEdit { + background-color: #3c3c3c; color: white; + padding: 4px; border-radius: 4px; + } + QPushButton { + background-color: #444444; border: 1px solid #666; + padding: 5px 10px; border-radius: 6px; + } + QPushButton:hover { background-color: #555; } + """ ) - # Set object name so stylesheet applies to card - card.setObjectName("Card") - self.setLayout(outer_layout) + self._on_connection_type_changed( + self.connection_type_dropdown.currentText() + ) + + # --- async population --- + + def showEvent(self, event): + super().showEvent(event) + asyncio.ensure_future(self._load_from_server()) - def apply_settings(self): - selected_gradient = self.gradient_dropdown.currentText() - selected_speed = 343 if self.speed_dropdown.currentIndex() == 0 else 1440 - nmea_enabled = self.nmea_enable_checkbox.isChecked() - nmea_port = ( - int(self.port_input.text()) if self.port_input.text().isdigit() else 10110 + async def _load_from_server(self): + """Fetch current settings + serial ports from web server.""" + settings, ports = await asyncio.gather( + fetch_settings(self.server_url), + fetch_serial_ports(self.server_url), ) - # Parse sampling params + if settings is None: + QMessageBox.warning(self, "Error", "Could not reach web server") + return + + self._server_settings = settings + + # Populate widgets from server state + ct = settings.get("connection_type") + if ct: + idx = self.connection_type_dropdown.findText(ct.upper()) + if idx >= 0: + self.connection_type_dropdown.setCurrentIndex(idx) + + self.serial_port_dropdown.clear() + self.serial_port_dropdown.addItems(ports) + sp = settings.get("serial_port", "") + idx = self.serial_port_dropdown.findText(sp) + if idx >= 0: + self.serial_port_dropdown.setCurrentIndex(idx) + + self.udp_port_input.setText(str(settings.get("udp_port", 9999))) + self.num_samples_input.setText(str(settings.get("num_samples", 1800))) + + medium = settings.get("medium", "water") + idx = self.medium_dropdown.findText(medium) + if idx >= 0: + self.medium_dropdown.setCurrentIndex(idx) + + self.transducer_depth_input.setText( + str(settings.get("transducer_depth", 0.0)) + ) + self.draft_input.setText(str(settings.get("draft", 0.0))) + + self.signalk_enable.setChecked(settings.get("signalk_enable", False)) + self.signalk_address_input.setText( + settings.get("signalk_address", "localhost:3000") + ) + + self.nmea_enable.setChecked(settings.get("nmea_enable", False)) + self.nmea_address_input.setText( + settings.get("nmea_address", "localhost:10110") + ) + + def _on_connection_type_changed(self, text): + is_serial = text.upper() == "SERIAL" + self.serial_port_label.setVisible(is_serial) + self.serial_port_dropdown.setVisible(is_serial) + self.udp_port_label.setVisible(not is_serial) + self.udp_port_input.setVisible(not is_serial) + + # --- apply --- + + def _apply(self): + # Local display settings — applied immediately + if self.main_app: + self.main_app.set_gradient(self.gradient_dropdown.currentText()) + self.main_app.set_large_depth_display( + self.large_depth_checkbox.isChecked() + ) + + # Echo settings → push to server + echo_settings = dict(self._server_settings) + echo_settings["connection_type"] = ( + self.connection_type_dropdown.currentText().upper() + ) + echo_settings["serial_port"] = self.serial_port_dropdown.currentText() try: - ns_value = int(self.num_samples_input.text()) - except Exception: - ns_value = None + echo_settings["udp_port"] = int(self.udp_port_input.text()) + except ValueError: + echo_settings["udp_port"] = 9999 try: - st_us_value = float(self.sample_time_input.text()) - except Exception: - st_us_value = None + echo_settings["num_samples"] = int(self.num_samples_input.text()) + except ValueError: + echo_settings["num_samples"] = 1800 + echo_settings["medium"] = self.medium_dropdown.currentText() + try: + echo_settings["transducer_depth"] = float( + self.transducer_depth_input.text() + ) + except ValueError: + echo_settings["transducer_depth"] = 0.0 + try: + echo_settings["draft"] = float(self.draft_input.text()) + except ValueError: + echo_settings["draft"] = 0.0 + echo_settings["signalk_enable"] = self.signalk_enable.isChecked() + echo_settings["signalk_address"] = self.signalk_address_input.text() + echo_settings["nmea_enable"] = self.nmea_enable.isChecked() + echo_settings["nmea_address"] = self.nmea_address_input.text() + + asyncio.ensure_future(self._push_and_close(echo_settings)) + + async def _push_and_close(self, echo_settings: dict): + result = await push_settings(self.server_url, echo_settings) + if result is None: + QMessageBox.warning( + self, + "Error", + "Failed to update settings on web server", + ) + else: + self.settings_applied.emit() + self.close() - if self.main_app: - self.main_app.set_gradient(selected_gradient) - self.main_app.set_sound_speed(selected_speed) - self.main_app.configure_nmea_output(enabled=nmea_enabled, port=nmea_port) - self.main_app.set_large_depth_display(self.large_depth_checkbox.isChecked()) - # Apply sampling settings if valid - if ns_value and ns_value > 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() +# --------------------------------------------------------------------------- +# Main application window +# --------------------------------------------------------------------------- class WaterfallApp(QMainWindow): - def __init__(self): + def __init__(self, server_url: str = DEFAULT_SERVER_URL): super().__init__() - 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.server_url = server_url - self.nmea_enabled = False - self.nmea_port = 10110 - self.nmea_socket = None - self.nmea_output_enabled = False + self.current_gradient = "cyclic" + self.large_depth_visible = True - 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 + # Sampling state — initialised from first WebSocket message + self.num_samples = 1800 + self.resolution = 0.9768 # cm per row (water default) self.setWindowTitle("Open Echo Interface") - self.setGeometry(0, 0, 480, 800) # Portrait mode for Raspberry Pi screen + self.setGeometry(0, 0, 480, 800) self._recompute_sampling_derived() self.data = np.zeros((MAX_ROWS, self.num_samples)) - # Disable window translucency - self.setAttribute(Qt.WA_TranslucentBackground, False) - - # Force opaque window flag - self.setWindowFlags(self.windowFlags() & ~Qt.FramelessWindowHint) - - # Set solid background color explicitly via palette + # Solid background + self.setAttribute(Qt.WA_TranslucentBackground, False) # type: ignore[attr-defined] + self.setWindowFlags(self.windowFlags() & ~Qt.FramelessWindowHint) # type: ignore[attr-defined] palette = self.palette() palette.setColor(QPalette.Window, QColor("#2b2b2b")) self.setPalette(palette) @@ -399,66 +611,37 @@ def __init__(self): self.imageitem = pg.ImageItem(axisOrder="row-major") self.waterfall.addItem(self.imageitem) self.waterfall.setMouseEnabled(x=False, y=False) - self.waterfall.setMinimumHeight(400) # Slightly more vertical space + self.waterfall.setMinimumHeight(400) self.waterfall.invertY(True) main_layout.addWidget(self.waterfall) 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.depth_line = pg.InfiniteLine( + angle=0, pen=pg.mkPen("r", width=2) + ) self.waterfall.addItem(self.depth_line) - # Mirror Y-axis ticks to the right side right_axis = self.waterfall.getAxis("right") right_axis.setTicks([inverted_depth_labels]) right_axis.setStyle(showValues=True) - # dd horizontal lines - 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) + self._depth_lines: list[pg.InfiniteLine] = [] + self._add_grid_lines() - # === Colorbar BELOW the plot to save width === + # === Colorbar (hidden but still drives LUT) === self.colorbar = pg.HistogramLUTWidget() self.colorbar.setImageItem(self.imageitem) self.colorbar.item.gradient.loadPreset("cyclic") - # self.colorbar.setMaximumHeight(80) self.imageitem.setLevels(DEFAULT_LEVELS) - # main_layout.addWidget(self.colorbar) - - # === Controls (Vertical) === + # === Controls === controls_layout = QVBoxLayout() - # Serial row - serial_row = QHBoxLayout() - - # === UDP Connection Row === - udp_row = QHBoxLayout() - - udp_row.addWidget(QLabel("UDP Port:")) - self.udp_port_input = QLineEdit() - self.udp_port_input.setText("5005") - self.udp_port_input.setMaximumWidth(100) - udp_row.addWidget(self.udp_port_input) - - self.udp_connect_button = QPushButton("Connect UDP") - self.udp_connect_button.clicked.connect(self.toggle_udp_connection) - udp_row.addWidget(self.udp_connect_button) - - controls_layout.addLayout(udp_row) - - # === Large Depth Display === + # Large Depth Display self.large_depth_label = QLabel("--- m") - self.large_depth_label.setAlignment(Qt.AlignCenter) + self.large_depth_label.setAlignment(Qt.AlignCenter) # type: ignore[attr-defined] self.large_depth_label.setStyleSheet( """ QLabel { @@ -468,235 +651,130 @@ def __init__(self): } """ ) - self.large_depth_label.setVisible(True) # hidden by default - serial_row.addWidget(self.large_depth_label) - - serial_row.addWidget(QLabel("Port:")) - self.serial_dropdown = QComboBox() - ports = get_serial_ports() - self.serial_dropdown.addItems(ports) - self.serial_dropdown.setMinimumWidth(150) - serial_row.addWidget(self.serial_dropdown) - - self.connect_button = QPushButton("Connect") - self.connect_button.clicked.connect( - self.toggle_serial_connection - ) # Connects to toggle handler - serial_row.addWidget(self.connect_button) - - controls_layout.addLayout(serial_row) + self.large_depth_label.setVisible(True) + controls_layout.addWidget(self.large_depth_label) # Info labels info_layout = QHBoxLayout() self.depth_label = QLabel("Depth: --- cm") self.temperature_label = QLabel("Temperature: --- °C") self.drive_voltage_label = QLabel("vDRV: --- V") - info_layout.addWidget(self.depth_label) info_layout.addWidget(self.temperature_label) info_layout.addWidget(self.drive_voltage_label) - info_container = QWidget() info_container.setLayout(info_layout) - controls_layout.addWidget(info_container) # No grid args! + controls_layout.addWidget(info_container) + + # Bottom row: status + buttons + bottom_row = QHBoxLayout() - # Hex input - hex_row = QHBoxLayout() - self.hex_input = QLineEdit() - self.hex_input.setPlaceholderText("0x1F") - hex_row.addWidget(self.hex_input) + self.status_label = QLabel("Starting...") + self.status_label.setStyleSheet("color: #aaa; font-size: 12px;") + bottom_row.addWidget(self.status_label) - self.send_button = QPushButton("Send") - self.send_button.clicked.connect(self.send_hex_value) - hex_row.addWidget(self.send_button) + bottom_row.addStretch() - # Settings button self.settings_button = QPushButton("Settings") self.settings_button.clicked.connect(self.open_settings) - hex_row.addWidget(self.settings_button) + bottom_row.addWidget(self.settings_button) + + self.web_button = QPushButton("Open Web UI") + self.web_button.clicked.connect(self._open_web_ui) + bottom_row.addWidget(self.web_button) - # Quit button self.quit_button = QPushButton("Quit") - self.quit_button.clicked.connect(self.close) - hex_row.addWidget(self.quit_button) + self.quit_button.clicked.connect(self.close) # type: ignore[arg-type] + bottom_row.addWidget(self.quit_button) - controls_layout.addLayout(hex_row) + controls_layout.addLayout(bottom_row) controls_container = QWidget() controls_container.setLayout(controls_layout) main_layout.addWidget(controls_container) - # 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()) - settings = Settings( - connection_type=ConnectionTypeEnum.UDP, - udp_port=udp_port, - num_samples=self.num_samples, + # === WebSocket client === + self._ws_client = WebSocketClient(server_url) + self._ws_client.packet_received.connect(self._on_ws_packet) + self._ws_client.connection_changed.connect(self._on_connection_changed) + + # === Web server manager === + self._server_manager = WebServerManager(server_url) + + # --- Lifecycle --- + + async def start_connection(self): + """Ensure web server is running, then start WebSocket client.""" + self._update_status("Starting server...") + ok = await self._server_manager.ensure_running() + if not ok: + self._update_status("Server failed to start") + QMessageBox.critical( + self, + "Error", + "Could not start or connect to the web server.\n" + "Try running 'openecho web' manually.", ) - 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 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 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 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): - self.large_depth_visible = enabled - self.large_depth_label.setVisible(enabled) - - 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: - self.nmea_client_socket.close() - except Exception: - pass - self.nmea_client_socket = None - - if hasattr(self, "nmea_server_socket") and self.nmea_server_socket: - try: - self.nmea_server_socket.close() - except Exception: - pass - self.nmea_server_socket = None - - if enabled: - try: - self.nmea_server_socket = socket.socket( - socket.AF_INET, socket.SOCK_STREAM - ) - self.nmea_server_socket.setsockopt( - socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 - ) - self.nmea_server_socket.bind(("0.0.0.0", port)) - self.nmea_server_socket.listen(1) - print(f"Waiting for TCP NMEA connection on port {port}...") - self.nmea_client_socket, _ = self.nmea_server_socket.accept() - print(f"NMEA client connected on port {port}") - except Exception as e: - print(f"Failed to set up NMEA output: {e}") - self.nmea_output_enabled = False - - def generate_dbt_sentence(self, depth_cm): - depth_m = depth_cm / 100.0 - depth_ft = depth_m * 3.28084 - depth_fathoms = depth_m * 0.546807 - - sentence_body = f"DBT,{depth_ft:.1f},f,{depth_m:.1f},M,{depth_fathoms:.1f},F" - checksum = 0 - for char in sentence_body: - checksum ^= ord(char) + return - return f"${sentence_body}*{checksum:02X}\r\n" + # Check if this is the first run (no settings configured yet) + settings = await fetch_settings(self.server_url) + if settings and settings.get("serial_port") == "init": + self._update_status("Waiting for configuration...") + self.open_settings(on_first_run=True) + return - def set_gradient(self, gradient_name): - self.current_gradient = gradient_name - self.colorbar.item.gradient.loadPreset(gradient_name) + self._ws_client.start() - def set_sound_speed(self, speed): - global SPEED_OF_SOUND - SPEED_OF_SOUND = speed - self.current_speed = speed - self._recompute_sampling_derived() - self._refresh_axes_and_grid() + def closeEvent(self, event): + self._ws_client.stop() + self._server_manager.shutdown() + event.accept() - def key_press_event(self, event): - print("key pressed") + def keyPressEvent(self, event): if event.key() == ord("Q"): - print("Quit triggered from keyboard.") self.close() - elif event.key() == ord("C"): - print("Connect triggered from keyboard.") - self.connect_button.click() else: super().keyPressEvent(event) - def connect_serial(self): - selected_port = self.serial_dropdown.currentText() - try: - 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._reader_task and self._reader_task_type == ConnectionTypeEnum.SERIAL: - self.disconnect_serial() - self.connect_button.setText("Connect") - else: - self.connect_serial() - if ( - self._reader_task - and self._reader_task_type == ConnectionTypeEnum.SERIAL - ): - self.connect_button.setText("Disconnect") + # --- WebSocket data handling --- - def disconnect_serial(self): - 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") + def _on_ws_packet(self, data: dict): + spectrogram = data.get("spectrogram") + if spectrogram is None: + return + + spectrogram = np.array(spectrogram, dtype=np.uint8) + + # Dynamic num_samples handling + if len(spectrogram) != self.num_samples: + self.num_samples = len(spectrogram) + self.data = np.zeros((MAX_ROWS, self.num_samples)) + self._recompute_sampling_derived() + self._refresh_axes_and_grid() + + # Update resolution if it changed + new_resolution = data.get("resolution", self.resolution) + if abs(new_resolution - self.resolution) > 0.001: + self.resolution = new_resolution + self._recompute_sampling_derived() + self._refresh_axes_and_grid() + + depth_m = data.get("measured_depth", 0.0) + temperature = data.get("temperature", 0.0) + drive_voltage = data.get("drive_voltage", 0.0) + + # Convert depth_m back to index for the depth line position + depth_index = ( + depth_m / (self.resolution / 100) if self.resolution > 0 else 0 + ) - def waterfall_plot_callback( - self, spectrogram, depth_index, temperature, drive_voltage + self._waterfall_plot_callback( + spectrogram, depth_index, depth_m, temperature, drive_voltage + ) + + def _waterfall_plot_callback( + self, spectrogram, depth_index, depth_m, temperature, drive_voltage ): self.data = np.roll(self.data, -1, axis=0) self.data[-1, :] = spectrogram @@ -706,123 +784,65 @@ def waterfall_plot_callback( mean = np.mean(self.data) self.imageitem.setLevels((mean - 2 * sigma, mean + 2 * sigma)) - depth_cm = depth_index * self.sample_resolution - self.depth_label.setText(f"Depth: {depth_cm:.1f} cm | Index: {depth_index:.0f}") + depth_cm = depth_m * 100 + 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") self.depth_line.setPos(depth_index) - # Update big depth label (in meters, 1 decimal) if self.large_depth_label.isVisible(): - self.large_depth_label.setText(f"{depth_cm / 100:.1f} m") + self.large_depth_label.setText(f"{depth_m:.1f} m") - if hasattr(self, "nmea_output_enabled") and self.nmea_output_enabled: - now = time.time() + # --- Connection status --- - # Check if it's time to send again - if ( - not hasattr(self, "_last_nmea_sent") - or (now - self._last_nmea_sent) >= 1.0 - ): - print("Sending NMEA data") - try: - depth_cm = depth_index * self.sample_resolution - depth_m = depth_cm / 100 - depth_ft = depth_m * 3.28084 - depth_fathoms = depth_m * 0.546807 - - def calculate_checksum(sentence): - checksum = 0 - for char in sentence: - checksum ^= ord(char) - return f"*{checksum:02X}" - - nmea_sentence = ( - f"DBT,{depth_ft:.1f},f,{depth_m:.1f},M,{depth_fathoms:.1f},F" - ) - full_sentence = ( - f"${nmea_sentence}{calculate_checksum(nmea_sentence)}\r\n" - ) - - self.nmea_client_socket.sendall(full_sentence.encode("ascii")) - - # Update timestamp - self._last_nmea_sent = now - - except Exception as e: - print(f"NMEA send failed: {e}") - - def send_hex_value(self): - hex_value = self.hex_input.text().strip() - print(hex_value) - - if hex_value.startswith("0x") and len(hex_value) > 2: - try: - if self.serial_thread and self.serial_thread.isRunning(): - with serial.Serial( - self.serial_dropdown.currentText(), BAUD_RATE - ) as ser: - ser.write(hex_value.encode()) - print(f"Sent: {hex_value}") - except ValueError: - print("Invalid hex format.") - else: - print("Invalid hex value. Please enter a valid hex string (e.g., 0x1F)") - - 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 _on_connection_changed(self, status: str): + status_map = { + "connected": "Server: connected", + "reconnecting": "Server: reconnecting...", + "starting": "Server: starting...", + } + self._update_status(status_map.get(status, status)) - def _start_reader(self, settings: Settings): - # Generic starter for any AsyncReader subclass - if self._reader_task: - self._stop_reader() + def _update_status(self, text: str): + self.status_label.setText(text) - 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}") + # --- Display settings --- - self._reader_task = asyncio.create_task(_run()) + def set_gradient(self, gradient_name): + self.current_gradient = gradient_name + self.colorbar.item.gradient.loadPreset(gradient_name) - def _stop_reader(self): - if self._reader_task: - try: - self._reader_task.cancel() - except Exception: - pass - self._reader_task = None + def set_large_depth_display(self, enabled: bool): + self.large_depth_visible = enabled + self.large_depth_label.setVisible(enabled) - def open_settings(self): - device_ip = get_local_ip() + # --- Settings dialog --- + def open_settings(self, on_first_run=False): self.settings_dialog = SettingsDialog( 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, + server_url=self.server_url, ) + if on_first_run: + # Start the WebSocket client once the user applies settings + self.settings_dialog.settings_applied.connect(self._ws_client.start) self.settings_dialog.show() + def _open_web_ui(self): + from PyQt5.QtCore import QUrl + from PyQt5.QtGui import QDesktopServices + + QDesktopServices.openUrl(QUrl(self.server_url)) + + # --- Sampling / axes --- + 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.sample_resolution = self.resolution # cm per row + if self.sample_resolution <= 0: + self.sample_resolution = 0.9768 # safe fallback self.max_depth = int(self.num_samples * self.sample_resolution) self.depth_labels = { int(i / self.sample_resolution): f"{i / 100}" @@ -833,17 +853,10 @@ 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]) + self._remove_grid_lines() + self._add_grid_lines() - # 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 + def _add_grid_lines(self): for i in range(0, int(self.max_depth), Y_LABEL_DISTANCE): row_index = int(i / self.sample_resolution) hline = pg.InfiniteLine( @@ -854,62 +867,33 @@ def _refresh_axes_and_grid(self): 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: - self.current_gradient = gradient_name - self.colorbar.item.gradient.loadPreset(gradient_name) - print(f"Gradient changed to: {gradient_name}") - except Exception as e: - print(f"Failed to apply gradient '{gradient_name}': {e}") + def _remove_grid_lines(self): + for ln in self._depth_lines: + try: + self.waterfall.removeItem(ln) + except Exception: + pass + self._depth_lines.clear() -def get_current_gradient(self): - try: - return self.colorbar.item.gradient.currentPreset - except Exception: - return "cyclic" # Fallback +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- -def run_desktop(): +def run_desktop(server_url: str = DEFAULT_SERVER_URL): app = QApplication(sys.argv) qdarktheme.setup_theme("dark") - # Run Qt and asyncio together loop = QEventLoop(app) asyncio.set_event_loop(loop) - window = WaterfallApp() + window = WaterfallApp(server_url=server_url) window.show() + # Kick off the async server check + WebSocket connection + asyncio.ensure_future(window.start_connection()) + with loop: loop.run_forever() diff --git a/python/src/open_echo/web.py b/python/src/open_echo/web.py index c3f0c4c..b68d183 100644 --- a/python/src/open_echo/web.py +++ b/python/src/open_echo/web.py @@ -199,6 +199,25 @@ async def config_post(request: Request, new_settings: Settings = Form(...)): # return RedirectResponse("/", status_code=303) +# --- JSON API for desktop client --- + + +@app.get("/api/settings") +async def api_get_settings(): + return app.state.settings.model_dump() + + +@app.put("/api/settings") +async def api_put_settings(new_settings: Settings): + await update_settings(new_settings) + return app.state.settings.model_dump() + + +@app.get("/api/serial-ports") +async def api_serial_ports(): + return SerialReader.get_serial_ports() + + def run_web(): import uvicorn diff --git a/python/tests/test_cli.py b/python/tests/test_cli.py new file mode 100644 index 0000000..cce7e4d --- /dev/null +++ b/python/tests/test_cli.py @@ -0,0 +1,52 @@ +"""Tests for the CLI argument parser.""" +import sys +from unittest.mock import patch + +import pytest + + +class TestCLIParser: + def test_desktop_subcommand_default_server_url(self): + with patch.object(sys, "argv", ["openecho", "desktop"]), patch( + "open_echo.cli.run_desktop" + ) as mock_run: + from open_echo.cli import main + + main() + mock_run.assert_called_once_with( + server_url="http://localhost:8000" + ) + + def test_desktop_subcommand_custom_server_url(self): + with patch.object( + sys, "argv", ["openecho", "desktop", "--server-url", "http://myhost:9000"] + ), patch("open_echo.cli.run_desktop") as mock_run: + from open_echo.cli import main + + main() + mock_run.assert_called_once_with( + server_url="http://myhost:9000" + ) + + def test_web_subcommand(self): + with patch.object(sys, "argv", ["openecho", "web"]), patch( + "open_echo.cli.run_web" + ) as mock_run: + from open_echo.cli import main + + main() + mock_run.assert_called_once() + + def test_missing_subcommand_exits(self): + with patch.object(sys, "argv", ["openecho"]), pytest.raises(SystemExit): + from open_echo.cli import main + + main() + + def test_invalid_subcommand_exits(self): + with patch.object(sys, "argv", ["openecho", "nonexistent"]), pytest.raises( + SystemExit + ): + from open_echo.cli import main + + main() diff --git a/python/tests/test_desktop.py b/python/tests/test_desktop.py new file mode 100644 index 0000000..9485408 --- /dev/null +++ b/python/tests/test_desktop.py @@ -0,0 +1,191 @@ +"""Tests for desktop.py helper functions and classes.""" +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from unittest.mock import MagicMock + +import pytest +from open_echo.desktop import ( + WebServerManager, + WebSocketClient, + _fetch_serial_ports_sync, + _fetch_settings_sync, + _push_settings_sync, +) + +# --------------------------------------------------------------------------- +# Tiny HTTP server used to test the sync HTTP helpers and WebServerManager +# --------------------------------------------------------------------------- + + +class _MockHandler(BaseHTTPRequestHandler): + """Minimal HTTP handler for testing sync helpers.""" + + # Class-level response config — set before each test + response_body: bytes = b"{}" + response_code: int = 200 + + def do_GET(self): + self.send_response(self.response_code) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(self.response_body) + + def do_PUT(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + # Echo back the received JSON + self.send_response(self.response_code) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args): + pass # suppress log spam + + +@pytest.fixture +def mock_server(): + """Start a local HTTP server on an ephemeral port for tests.""" + server = HTTPServer(("127.0.0.1", 0), _MockHandler) + port = server.server_address[1] + url = f"http://127.0.0.1:{port}" + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield url, server + server.shutdown() + + +# --------------------------------------------------------------------------- +# Sync HTTP helpers +# --------------------------------------------------------------------------- + + +class TestFetchSettingsSync: + def test_returns_dict_on_success(self, mock_server): + url, server = mock_server + _MockHandler.response_body = json.dumps( + {"num_samples": 1800, "serial_port": "init"} + ).encode() + _MockHandler.response_code = 200 + + result = _fetch_settings_sync(url) + assert result is not None + assert result["num_samples"] == 1800 + + def test_returns_none_on_connection_error(self): + result = _fetch_settings_sync("http://127.0.0.1:1") + assert result is None + + +class TestPushSettingsSync: + def test_returns_echoed_dict_on_success(self, mock_server): + url, server = mock_server + _MockHandler.response_code = 200 + + payload = {"num_samples": 900, "serial_port": "/dev/tty0"} + result = _push_settings_sync(url, payload) + assert result is not None + assert result["num_samples"] == 900 + + def test_returns_none_on_connection_error(self): + result = _push_settings_sync("http://127.0.0.1:1", {"a": 1}) + assert result is None + + +class TestFetchSerialPortsSync: + def test_returns_list_on_success(self, mock_server): + url, server = mock_server + _MockHandler.response_body = json.dumps( + ["/dev/ttyUSB0", "/dev/ttyACM0"] + ).encode() + _MockHandler.response_code = 200 + + result = _fetch_serial_ports_sync(url) + assert result == ["/dev/ttyUSB0", "/dev/ttyACM0"] + + def test_returns_empty_list_on_error(self): + result = _fetch_serial_ports_sync("http://127.0.0.1:1") + assert result == [] + + +# --------------------------------------------------------------------------- +# WebServerManager +# --------------------------------------------------------------------------- + + +class TestWebServerManager: + def test_is_reachable_returns_true_when_server_up(self, mock_server): + url, server = mock_server + _MockHandler.response_code = 200 + _MockHandler.response_body = b'{"serial_port":"init"}' + + mgr = WebServerManager(url) + assert mgr._is_reachable() is True + + def test_is_reachable_returns_false_when_server_down(self): + mgr = WebServerManager("http://127.0.0.1:1") + assert mgr._is_reachable() is False + + def test_shutdown_noop_when_not_owned(self): + mgr = WebServerManager() + mgr._owned = False + mgr._process = MagicMock() + mgr.shutdown() + mgr._process.terminate.assert_not_called() + + def test_shutdown_terminates_owned_process(self): + mgr = WebServerManager() + mgr._owned = True + mock_proc = MagicMock() + mock_proc.pid = 12345 + mock_proc.wait.return_value = None + mgr._process = mock_proc + + mgr.shutdown() + + mock_proc.terminate.assert_called_once() + assert mgr._process is None + + @pytest.mark.asyncio + async def test_ensure_running_returns_true_when_already_up(self, mock_server): + url, server = mock_server + _MockHandler.response_code = 200 + _MockHandler.response_body = b'{"serial_port":"init"}' + + mgr = WebServerManager(url) + result = await mgr.ensure_running() + assert result is True + assert mgr._owned is False + + +# --------------------------------------------------------------------------- +# WebSocketClient +# --------------------------------------------------------------------------- + + +class TestWebSocketClient: + def test_url_construction(self): + ws = WebSocketClient("http://localhost:8000") + assert ws._ws_url == "ws://localhost:8000/ws" + + def test_url_construction_https(self): + ws = WebSocketClient("https://example.com:443") + assert ws._ws_url == "wss://example.com:443/ws" + + def test_url_strips_trailing_slash(self): + ws = WebSocketClient("http://localhost:8000/") + assert ws._ws_url == "ws://localhost:8000/ws" + + def test_stop_sets_event_and_clears_thread(self): + ws = WebSocketClient() + ws._stop_event = threading.Event() + mock_thread = MagicMock() + mock_thread.join.return_value = None + ws._thread = mock_thread + + ws.stop() + + assert ws._stop_event.is_set() + mock_thread.join.assert_called_once() + assert ws._thread is None diff --git a/python/tests/test_web.py b/python/tests/test_web.py new file mode 100644 index 0000000..264b077 --- /dev/null +++ b/python/tests/test_web.py @@ -0,0 +1,277 @@ +"""Tests for the web server FastAPI endpoints.""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient +from open_echo.settings import Settings +from open_echo.web import ( + ConnectionManager, + EchoReader, + app, + echo_reader, + output_manager, +) + + +@pytest.fixture +def client(): + """Provide a TestClient with lifespan mocked (no real background tasks).""" + with ( + patch.object(echo_reader, "__enter__", return_value=echo_reader), + patch.object(echo_reader, "__exit__", return_value=False), + patch.object(output_manager, "__enter__", return_value=output_manager), + patch.object(output_manager, "__exit__", return_value=False), + patch.object( + output_manager, "update_settings", new_callable=AsyncMock + ), + patch.object(echo_reader, "update_settings"), + patch("open_echo.web.Settings.load", return_value=Settings()), + patch("open_echo.web.Settings.save"), + TestClient(app, raise_server_exceptions=True) as c, + ): + yield c + + +@pytest.fixture(autouse=True) +def _reset_app_state(): + """Reset app.state.settings between tests.""" + app.state.settings = Settings() + yield + app.state.settings = Settings() + + +# --------------------------------------------------------------------------- +# JSON API: GET /api/settings +# --------------------------------------------------------------------------- + + +class TestApiGetSettings: + def test_returns_200_with_default_settings(self, client): + resp = client.get("/api/settings") + assert resp.status_code == 200 + data = resp.json() + assert data["num_samples"] == 1800 + assert data["serial_port"] == "init" + assert data["medium"] == "water" + + def test_reflects_updated_state(self, client): + app.state.settings = Settings(num_samples=500, serial_port="/dev/ttyUSB0") + resp = client.get("/api/settings") + data = resp.json() + assert data["num_samples"] == 500 + assert data["serial_port"] == "/dev/ttyUSB0" + + +# --------------------------------------------------------------------------- +# JSON API: PUT /api/settings +# --------------------------------------------------------------------------- + + +class TestApiPutSettings: + def test_updates_settings_and_returns_new_state(self, client): + resp = client.put( + "/api/settings", + json={ + "connection_type": "UDP", + "udp_port": 7777, + "num_samples": 900, + "serial_port": "test", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["udp_port"] == 7777 + assert data["num_samples"] == 900 + assert data["connection_type"] == "UDP" + + def test_merges_with_existing_settings(self, client): + # Set initial state + app.state.settings = Settings( + serial_port="/dev/ttyUSB0", num_samples=1000 + ) + # PUT only changes num_samples + resp = client.put( + "/api/settings", + json={ + "connection_type": "SERIAL", + "num_samples": 2000, + "serial_port": "/dev/ttyUSB0", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["num_samples"] == 2000 + assert data["serial_port"] == "/dev/ttyUSB0" + + def test_rejects_invalid_connection_type(self, client): + resp = client.put( + "/api/settings", + json={"connection_type": "BLUETOOTH"}, + ) + assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- +# JSON API: GET /api/serial-ports +# --------------------------------------------------------------------------- + + +class TestApiSerialPorts: + @patch("open_echo.web.SerialReader.get_serial_ports") + def test_returns_list(self, mock_ports, client): + mock_ports.return_value = ["/dev/ttyUSB0", "/dev/ttyACM0"] + resp = client.get("/api/serial-ports") + assert resp.status_code == 200 + assert resp.json() == ["/dev/ttyUSB0", "/dev/ttyACM0"] + + @patch("open_echo.web.SerialReader.get_serial_ports") + def test_returns_empty_list_when_no_ports(self, mock_ports, client): + mock_ports.return_value = [] + resp = client.get("/api/serial-ports") + assert resp.status_code == 200 + assert resp.json() == [] + + +# --------------------------------------------------------------------------- +# GET / — redirects to config when serial_port == "init" +# --------------------------------------------------------------------------- + + +class TestHomeRoute: + def test_redirects_to_config_when_unconfigured(self, client): + app.state.settings = Settings(serial_port="init") + resp = client.get("/", follow_redirects=False) + assert resp.status_code == 303 + assert resp.headers["location"] == "/config" + + +# --------------------------------------------------------------------------- +# ConnectionManager +# --------------------------------------------------------------------------- + + +class TestConnectionManager: + @pytest.mark.asyncio + async def test_connect_and_disconnect(self): + cm = ConnectionManager() + ws = AsyncMock() + await cm.connect(ws) + assert ws in cm.active_connections + await cm.disconnect(ws) + assert ws not in cm.active_connections + + @pytest.mark.asyncio + async def test_disconnect_nonexistent_is_noop(self): + cm = ConnectionManager() + ws = AsyncMock() + await cm.disconnect(ws) # should not raise + + @pytest.mark.asyncio + async def test_broadcast_json_sends_to_all(self): + cm = ConnectionManager() + ws1 = AsyncMock() + ws2 = AsyncMock() + await cm.connect(ws1) + await cm.connect(ws2) + + data = {"depth": 1.5} + await cm.broadcast_json(data) + + ws1.send_json.assert_awaited_once_with(data) + ws2.send_json.assert_awaited_once_with(data) + + @pytest.mark.asyncio + async def test_broadcast_json_removes_failed_client(self): + cm = ConnectionManager() + good_ws = AsyncMock() + bad_ws = AsyncMock() + bad_ws.send_json.side_effect = RuntimeError("broken pipe") + + await cm.connect(good_ws) + await cm.connect(bad_ws) + + await cm.broadcast_json({"test": True}) + + assert good_ws in cm.active_connections + assert bad_ws not in cm.active_connections + + +# --------------------------------------------------------------------------- +# EchoReader +# --------------------------------------------------------------------------- + + +class TestEchoReader: + def test_update_settings_sets_restart_event(self): + data_cb = AsyncMock() + depth_cb = AsyncMock() + er = EchoReader(data_callback=data_cb, depth_callback=depth_cb) + + new_settings = Settings(num_samples=500, serial_port="test") + er.update_settings(new_settings) + + assert er.settings == new_settings + assert er._restart_event.is_set() + + @pytest.mark.asyncio + async def test_process_echo_calls_callbacks(self): + import numpy as np + from open_echo.echo import EchoPacket + + data_cb = AsyncMock() + depth_cb = AsyncMock() + er = EchoReader(data_callback=data_cb, depth_callback=depth_cb) + er.settings = Settings(medium="water") + + samples = np.array([100, 150, 200], dtype=np.uint8) + pkt = EchoPacket( + samples=samples, depth_index=5, temperature=22.5, drive_voltage=48.0 + ) + + await er.process_echo(pkt) + + data_cb.assert_awaited_once() + depth_cb.assert_awaited_once() + + call_data = data_cb.call_args[0][0] + assert call_data["spectrogram"] == [100, 150, 200] + assert call_data["temperature"] == 22.5 + assert call_data["drive_voltage"] == 48.0 + assert "measured_depth" in call_data + assert "resolution" in call_data + + @pytest.mark.asyncio + async def test_process_echo_handles_data_callback_error(self): + data_cb = AsyncMock(side_effect=RuntimeError("boom")) + depth_cb = AsyncMock() + er = EchoReader(data_callback=data_cb, depth_callback=depth_cb) + er.settings = Settings(medium="water") + + import numpy as np + from open_echo.echo import EchoPacket + + pkt = EchoPacket( + samples=np.zeros(3, dtype=np.uint8), + depth_index=1, + temperature=20.0, + drive_voltage=5.0, + ) + # Should not raise — errors are logged + await er.process_echo(pkt) + # depth callback should still be called + depth_cb.assert_awaited_once() + + def test_context_manager_creates_and_cancels_task(self): + """Test __enter__ creates task and __exit__ cancels it.""" + data_cb = AsyncMock() + depth_cb = AsyncMock() + er = EchoReader(data_callback=data_cb, depth_callback=depth_cb) + + mock_task = MagicMock() + with patch("asyncio.create_task", return_value=mock_task): + er.__enter__() + assert er._task is mock_task + + er.__exit__(None, None, None) + assert er._task is None + mock_task.cancel.assert_called_once() From 2819d15fcfce47bb4d2a7b92937e3c644389a993 Mon Sep 17 00:00:00 2001 From: John Harrington Date: Fri, 20 Feb 2026 14:37:44 +0000 Subject: [PATCH 5/6] Remove a few more prints --- README.md | 2 +- .../getting_started/TUSS4470_hardware.md | 2 +- python/src/open_echo/depth_output.py | 21 +++++++++++-------- reverse_engineering/live_waterfall.py | 7 +++++-- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f764a20..1e35621 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Raw Data Waterfall chart in the Python Desktop software: 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 +They can also be bought as a complete and tested set directly from Elecrow: https://www.elecrow.com/open-echo-tuss4470-development-shield.html 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 diff --git a/documentation/getting_started/TUSS4470_hardware.md b/documentation/getting_started/TUSS4470_hardware.md index 2f6feae..1660eff 100644 --- a/documentation/getting_started/TUSS4470_hardware.md +++ b/documentation/getting_started/TUSS4470_hardware.md @@ -22,7 +22,7 @@ Use the Python software to see the echoes. ### Ordering 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 +They can also be bought as a complete and tested set directly from Elecrow: https://www.elecrow.com/open-echo-tuss4470-development-shield.html 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 diff --git a/python/src/open_echo/depth_output.py b/python/src/open_echo/depth_output.py index 511fa55..fbaab95 100644 --- a/python/src/open_echo/depth_output.py +++ b/python/src/open_echo/depth_output.py @@ -1,5 +1,6 @@ import asyncio import json +import logging from abc import ABC, abstractmethod from typing import Any @@ -7,6 +8,8 @@ from httpx import AsyncClient from open_echo.settings import NMEAOffset, Settings +logger = logging.getLogger(__name__) + class OutputManager: def __init__(self, settings: Settings | None = None): @@ -32,14 +35,14 @@ 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] - print(f"Output classes: {self._output_classes}") + logger.info("Output classes: %s", self._output_classes) for output_class in self._output_classes: await output_class.start() async def output(self) -> Any: for output_class in self._output_classes: - if output_class.current_value is not None or ( + if output_class.current_value is not None and ( output_class.last_output_time is None or ( (asyncio.get_event_loop().time() - output_class.last_output_time) @@ -132,7 +135,7 @@ async def get_token(self): access_request_uri, json={ "clientId": "f6b20288-5ecf-4daa-9a13-1594bc145abe", - "description": "open_echo Depth Sounder", + "description": "Open Echo Depth Sounder", }, ) access_request.raise_for_status() @@ -173,10 +176,10 @@ async def stop(self): async def output(self): if self._ws is None: try: - print("Reconnecting to SignalK server...") + logger.info("Reconnecting to SignalK server...") await self.start() except Exception as e: - print(f"SignalK connection error: {e}") + logger.exception("SignalK connection error: %s", e) return try: # Format as SignalK delta message for depth @@ -208,10 +211,10 @@ async def output(self): delta = {"updates": [{"values": values}]} - print("Send signalk delta: %s", delta) + logger.debug("Send signalk delta: %s", delta) await self._ws.send(json.dumps(delta)) except Exception as e: - print(f"SignalK send error: {e}") + logger.exception("SignalK send error: %s", e) # Attempt reconnect next time if self._ws: await self.stop() @@ -249,7 +252,7 @@ async def output(self): try: await self.start() except Exception as e: - print(f"NMEA0183 TCP connection error: {e}") + logger.exception("NMEA0183 TCP connection error: %s", e) return try: # Send DBT and DPT sentences, ending with CRLF (NMEA standard) @@ -291,7 +294,7 @@ def calculate_checksum(sentence): await self._writer.drain() except Exception as e: - print(f"NMEA0183 TCP send error: {e}") + logger.exception("NMEA0183 TCP send error: %s", e) # Attempt reconnect next time await self.stop() diff --git a/reverse_engineering/live_waterfall.py b/reverse_engineering/live_waterfall.py index 85c5af8..fb6d749 100644 --- a/reverse_engineering/live_waterfall.py +++ b/reverse_engineering/live_waterfall.py @@ -3,6 +3,9 @@ import matplotlib.pyplot as plt import numpy as np import serial +import logging + +logger = logging.getLogger(__name__) # Serial port configuration serial_port = "/dev/tty.usbserial-1120" # Updated to the specified serial port @@ -69,7 +72,7 @@ def parse_data(line): values = [int(x) for x in parts] return values except Exception as e: - print(f"Error parsing line: {line} - {e}") + logger.exception("Error parsing line: %s - %s", line, e) return None @@ -101,7 +104,7 @@ def parse_data(line): plt.pause(0.1) except KeyboardInterrupt: - print("Exiting...") + logger.info("Exiting...") break # Close serial connection From ed9276f3a2f089d9d52bb9947e821473e02f4b1b Mon Sep 17 00:00:00 2001 From: John Harrington Date: Mon, 23 Feb 2026 09:42:03 +0000 Subject: [PATCH 6/6] Add simulator --- python/src/open_echo/UART_UDP_relay.py | 15 +- python/src/open_echo/cli.py | 23 +++ python/src/open_echo/depth_output.py | 1 + python/src/open_echo/desktop.py | 56 ++---- python/src/open_echo/echo.py | 25 ++- python/src/open_echo/simulate.py | 80 ++++++++ python/tests/test_cli.py | 39 ++-- python/tests/test_desktop.py | 1 + python/tests/test_simulate.py | 248 +++++++++++++++++++++++++ python/tests/test_web.py | 9 +- 10 files changed, 422 insertions(+), 75 deletions(-) create mode 100644 python/src/open_echo/simulate.py create mode 100644 python/tests/test_simulate.py diff --git a/python/src/open_echo/UART_UDP_relay.py b/python/src/open_echo/UART_UDP_relay.py index 8542886..3db7668 100644 --- a/python/src/open_echo/UART_UDP_relay.py +++ b/python/src/open_echo/UART_UDP_relay.py @@ -8,19 +8,22 @@ def configure_relay_parser(parser): parser.add_argument( - "-p", "--uart-port", + "-p", + "--uart-port", help="UART device (e.g. COM3 or /dev/ttyUSB0)", ) parser.add_argument( - "-b", "--baud-rate", + "-b", + "--baud-rate", type=int, default=250000, help="UART baud rate (default: 250000)", ) parser.add_argument( - "-n", "--samples", + "-n", + "--samples", type=int, default=1800, help="Number of samples per packet (default: 1800)", @@ -156,9 +159,7 @@ def run_relay(args=None): while True: packet = read_raw_packet( - ser, - pld_size, - verbose=args.verbose and not args.quiet + ser, pld_size, verbose=args.verbose and not args.quiet ) udp_sock.sendto(packet, (udp_ip, args.udp_port)) @@ -168,4 +169,4 @@ def run_relay(args=None): if not args.quiet: print("\nRelay stopped by user") finally: - udp_sock.close() \ No newline at end of file + udp_sock.close() diff --git a/python/src/open_echo/cli.py b/python/src/open_echo/cli.py index 8e32e9b..3bff9f4 100644 --- a/python/src/open_echo/cli.py +++ b/python/src/open_echo/cli.py @@ -1,6 +1,7 @@ from argparse import ArgumentParser from open_echo.desktop import run_desktop +from open_echo.simulate import run_simulate from open_echo.UART_UDP_relay import configure_relay_parser, run_relay from open_echo.web import run_web @@ -28,6 +29,28 @@ def main(): configure_relay_parser(relay_parser) relay_parser.set_defaults(handler=run_relay) + simulate_parser = subparsers.add_parser( + "simulate", help="Generate simulated echo packets (UDP or serial PTY)" + ) + simulate_parser.add_argument( + "--host", default="127.0.0.1", help="UDP host to send to (default: 127.0.0.1)" + ) + simulate_parser.add_argument( + "--port", type=int, default=9999, help="UDP port to send to (default: 9999)" + ) + simulate_parser.add_argument( + "--rate", type=float, default=1.0, help="Packets per second (default: 1.0)" + ) + simulate_parser.add_argument( + "--num-samples", type=int, default=1800, help="Number of samples per packet" + ) + simulate_parser.add_argument( + "--randomize", + action="store_true", + help="Randomize depth index and spike amplitude", + ) + simulate_parser.set_defaults(handler=lambda args: run_simulate(args)) + args = parser.parse_args() args.handler(args) diff --git a/python/src/open_echo/depth_output.py b/python/src/open_echo/depth_output.py index fbaab95..b3b58d6 100644 --- a/python/src/open_echo/depth_output.py +++ b/python/src/open_echo/depth_output.py @@ -59,6 +59,7 @@ async def _run(self): continue await self.output() + await asyncio.sleep(0.1) def __enter__(self): self._task = asyncio.create_task(self._run()) diff --git a/python/src/open_echo/desktop.py b/python/src/open_echo/desktop.py index 5883af0..c6ba73a 100644 --- a/python/src/open_echo/desktop.py +++ b/python/src/open_echo/desktop.py @@ -57,9 +57,7 @@ def __init__(self, server_url: str = DEFAULT_SERVER_URL): super().__init__() self.server_url = server_url.rstrip("/") self._ws_url = ( - self.server_url.replace("http://", "ws://").replace( - "https://", "wss://" - ) + self.server_url.replace("http://", "ws://").replace("https://", "wss://") + "/ws" ) self._thread: threading.Thread | None = None @@ -108,9 +106,7 @@ async def _run(self): except asyncio.CancelledError: break except Exception as e: - log.warning( - "WebSocket error: %s — retrying in %.0fs", e, retry_delay - ) + log.warning("WebSocket error: %s — retrying in %.0fs", e, retry_delay) self.connection_changed.emit("reconnecting") # Use stop_event.wait so we can exit promptly if self._stop_event.wait(timeout=retry_delay): @@ -133,9 +129,7 @@ def __init__(self, server_url: str = DEFAULT_SERVER_URL): async def ensure_running(self) -> bool: """Return True once the server is reachable. Spawns if needed.""" - if await asyncio.get_event_loop().run_in_executor( - None, self._is_reachable - ): + if await asyncio.get_event_loop().run_in_executor(None, self._is_reachable): log.info("Web server already running at %s", self.server_url) return True @@ -144,9 +138,7 @@ async def ensure_running(self) -> bool: # Wait for it to become reachable for _ in range(60): # up to ~30 s await asyncio.sleep(0.5) - if await asyncio.get_event_loop().run_in_executor( - None, self._is_reachable - ): + if await asyncio.get_event_loop().run_in_executor(None, self._is_reachable): log.info("Web server is now reachable") return True @@ -157,9 +149,7 @@ def _is_reachable(self) -> bool: import urllib.request try: - resp = urllib.request.urlopen( - f"{self.server_url}/api/settings", timeout=2 - ) + resp = urllib.request.urlopen(f"{self.server_url}/api/settings", timeout=2) return resp.status == 200 except Exception: return False @@ -203,9 +193,7 @@ def _fetch_settings_sync(server_url: str) -> dict | None: import urllib.request try: - resp = urllib.request.urlopen( - f"{server_url}/api/settings", timeout=5 - ) + resp = urllib.request.urlopen(f"{server_url}/api/settings", timeout=5) return json.loads(resp.read()) except Exception as e: log.error("Failed to fetch settings: %s", e) @@ -236,9 +224,7 @@ def _fetch_serial_ports_sync(server_url: str) -> list[str]: import urllib.request try: - resp = urllib.request.urlopen( - f"{server_url}/api/serial-ports", timeout=5 - ) + resp = urllib.request.urlopen(f"{server_url}/api/serial-ports", timeout=5) return json.loads(resp.read()) except Exception as e: log.error("Failed to fetch serial ports: %s", e) @@ -446,9 +432,7 @@ def __init__( ) self.setLayout(outer_layout) - self._on_connection_type_changed( - self.connection_type_dropdown.currentText() - ) + self._on_connection_type_changed(self.connection_type_dropdown.currentText()) # --- async population --- @@ -491,9 +475,7 @@ async def _load_from_server(self): if idx >= 0: self.medium_dropdown.setCurrentIndex(idx) - self.transducer_depth_input.setText( - str(settings.get("transducer_depth", 0.0)) - ) + self.transducer_depth_input.setText(str(settings.get("transducer_depth", 0.0))) self.draft_input.setText(str(settings.get("draft", 0.0))) self.signalk_enable.setChecked(settings.get("signalk_enable", False)) @@ -502,9 +484,7 @@ async def _load_from_server(self): ) self.nmea_enable.setChecked(settings.get("nmea_enable", False)) - self.nmea_address_input.setText( - settings.get("nmea_address", "localhost:10110") - ) + self.nmea_address_input.setText(settings.get("nmea_address", "localhost:10110")) def _on_connection_type_changed(self, text): is_serial = text.upper() == "SERIAL" @@ -519,9 +499,7 @@ def _apply(self): # Local display settings — applied immediately if self.main_app: self.main_app.set_gradient(self.gradient_dropdown.currentText()) - self.main_app.set_large_depth_display( - self.large_depth_checkbox.isChecked() - ) + self.main_app.set_large_depth_display(self.large_depth_checkbox.isChecked()) # Echo settings → push to server echo_settings = dict(self._server_settings) @@ -618,9 +596,7 @@ def __init__(self, server_url: str = DEFAULT_SERVER_URL): 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.depth_line = pg.InfiniteLine(angle=0, pen=pg.mkPen("r", width=2)) self.waterfall.addItem(self.depth_line) right_axis = self.waterfall.getAxis("right") @@ -765,9 +741,7 @@ def _on_ws_packet(self, data: dict): drive_voltage = data.get("drive_voltage", 0.0) # Convert depth_m back to index for the depth line position - depth_index = ( - depth_m / (self.resolution / 100) if self.resolution > 0 else 0 - ) + depth_index = depth_m / (self.resolution / 100) if self.resolution > 0 else 0 self._waterfall_plot_callback( spectrogram, depth_index, depth_m, temperature, drive_voltage @@ -785,9 +759,7 @@ def _waterfall_plot_callback( self.imageitem.setLevels((mean - 2 * sigma, mean + 2 * sigma)) depth_cm = depth_m * 100 - self.depth_label.setText( - f"Depth: {depth_cm:.1f} cm | Index: {depth_index:.0f}" - ) + 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") self.depth_line.setPos(depth_index) diff --git a/python/src/open_echo/echo.py b/python/src/open_echo/echo.py index 367ff95..6fe1767 100644 --- a/python/src/open_echo/echo.py +++ b/python/src/open_echo/echo.py @@ -61,7 +61,9 @@ def unpack(cls, payload: bytes, checksum: bytes, num_samples: int) -> "EchoPacke # Verify checksum calc = compute_checksum(payload) if calc != checksum[0]: - log.warning("Checksum mismatch: expected 0x%02X, got 0x%02X", checksum[0], calc) + log.warning( + "Checksum mismatch: expected 0x%02X, got 0x%02X", checksum[0], calc + ) raise ChecksumMismatchError("Checksum mismatch") # Unpack payload @@ -118,8 +120,25 @@ def __init__(self, settings: "Settings"): @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] + """Retrieve a list of available serial ports. + + Also includes the simulated PTY device if the simulator is running. + """ + import os + + ports = [port.device for port in serial.tools.list_ports.comports()][::-1] + + # Check for a running simulator PTY + pty_marker = os.path.join(os.path.expanduser("~"), ".openecho_simulate_pty") + try: + with open(pty_marker) as f: + pty_path = f.read().strip() + if pty_path and os.path.exists(pty_path) and pty_path not in ports: + ports.insert(0, pty_path) + except FileNotFoundError: + pass + + return ports async def open(self): self.reader, self.writer = await aserial.open_serial_connection( diff --git a/python/src/open_echo/simulate.py b/python/src/open_echo/simulate.py new file mode 100644 index 0000000..7e396ec --- /dev/null +++ b/python/src/open_echo/simulate.py @@ -0,0 +1,80 @@ +import argparse +import asyncio +import random +import struct + +from open_echo.echo import START_BYTE, compute_checksum + + +def build_packet( + num_samples: int, + depth_idx: int, + temperature: float, + drive_voltage: float, + samples: bytes, +) -> bytes: + """Build a valid echo packet from the given parameters.""" + temp_scaled = int(temperature * 100) + vdrv_scaled = int(drive_voltage * 100) + header = struct.pack(" bytes: + """Create a single simulated echo packet with background noise and a depth spike.""" + depth_idx = int(num_samples // 4 + (num_samples // 8) * (1 + (i % 5) / 5)) + if randomize: + depth_idx = random.randint(0, num_samples - 1) + + # Light background noise to resemble a real echo waveform + samples = bytearray(random.randint(0, 15) for _ in range(num_samples)) + samples[depth_idx] = random.randint(120, 255) if randomize else 220 + + return build_packet( + num_samples, + depth_idx, + temperature=20.0, + drive_voltage=3.3, + samples=bytes(samples), + ) + + +async def _udp_send( + host: str, port: int, rate: float, num_samples: int, randomize: bool +): + loop = asyncio.get_running_loop() + transport, _ = await loop.create_datagram_endpoint( + lambda: asyncio.DatagramProtocol(), remote_addr=(host, port) + ) + + try: + i = 0 + while True: + packet = _generate_packet(i, num_samples, randomize) + transport.sendto(packet) + await asyncio.sleep(1.0 / rate) + i += 1 + finally: + transport.close() + + +def run_simulate(args: argparse.Namespace | None = None): + """Entry point for CLI. `args` should provide attributes: host, port, rate, num_samples, randomize""" + host = getattr(args, "host", "127.0.0.1") + port = int(getattr(args, "port", 9999)) + rate = float(getattr(args, "rate", 1.0)) + num_samples = int(getattr(args, "num_samples", 1800)) + randomize = bool(getattr(args, "randomize", False)) + + try: + print( + f"Simulating UDP packets to {host}:{port} @ {rate} pkt/s (num_samples={num_samples})" + ) + asyncio.run(_udp_send(host, port, rate, num_samples, randomize)) + except KeyboardInterrupt: + print("Simulation stopped") + return diff --git a/python/tests/test_cli.py b/python/tests/test_cli.py index cce7e4d..c8cd5f0 100644 --- a/python/tests/test_cli.py +++ b/python/tests/test_cli.py @@ -1,4 +1,5 @@ """Tests for the CLI argument parser.""" + import sys from unittest.mock import patch @@ -7,31 +8,34 @@ class TestCLIParser: def test_desktop_subcommand_default_server_url(self): - with patch.object(sys, "argv", ["openecho", "desktop"]), patch( - "open_echo.cli.run_desktop" - ) as mock_run: + with ( + patch.object(sys, "argv", ["openecho", "desktop"]), + patch("open_echo.cli.run_desktop") as mock_run, + ): from open_echo.cli import main main() - mock_run.assert_called_once_with( - server_url="http://localhost:8000" - ) + mock_run.assert_called_once_with(server_url="http://localhost:8000") def test_desktop_subcommand_custom_server_url(self): - with patch.object( - sys, "argv", ["openecho", "desktop", "--server-url", "http://myhost:9000"] - ), patch("open_echo.cli.run_desktop") as mock_run: + with ( + patch.object( + sys, + "argv", + ["openecho", "desktop", "--server-url", "http://myhost:9000"], + ), + patch("open_echo.cli.run_desktop") as mock_run, + ): from open_echo.cli import main main() - mock_run.assert_called_once_with( - server_url="http://myhost:9000" - ) + mock_run.assert_called_once_with(server_url="http://myhost:9000") def test_web_subcommand(self): - with patch.object(sys, "argv", ["openecho", "web"]), patch( - "open_echo.cli.run_web" - ) as mock_run: + with ( + patch.object(sys, "argv", ["openecho", "web"]), + patch("open_echo.cli.run_web") as mock_run, + ): from open_echo.cli import main main() @@ -44,8 +48,9 @@ def test_missing_subcommand_exits(self): main() def test_invalid_subcommand_exits(self): - with patch.object(sys, "argv", ["openecho", "nonexistent"]), pytest.raises( - SystemExit + with ( + patch.object(sys, "argv", ["openecho", "nonexistent"]), + pytest.raises(SystemExit), ): from open_echo.cli import main diff --git a/python/tests/test_desktop.py b/python/tests/test_desktop.py index 9485408..a83972b 100644 --- a/python/tests/test_desktop.py +++ b/python/tests/test_desktop.py @@ -1,4 +1,5 @@ """Tests for desktop.py helper functions and classes.""" + import json import threading from http.server import BaseHTTPRequestHandler, HTTPServer diff --git a/python/tests/test_simulate.py b/python/tests/test_simulate.py new file mode 100644 index 0000000..584e3fa --- /dev/null +++ b/python/tests/test_simulate.py @@ -0,0 +1,248 @@ +import asyncio + +import numpy as np +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st +from open_echo.echo import ( + START_BYTE, + EchoPacket, + compute_checksum, + packet_size, +) +from open_echo.simulate import ( + _generate_packet, + _udp_send, + build_packet, + run_simulate, +) + +# --------------------------------------------------------------------------- +# Strategies +# --------------------------------------------------------------------------- + +reasonable_num_samples = st.integers(min_value=1, max_value=5000) +depth_idx_strategy = st.integers(min_value=0, max_value=65535) +temperature_strategy = st.floats( + min_value=-100.0, max_value=300.0, allow_nan=False, allow_infinity=False +) +drive_voltage_strategy = st.floats( + min_value=0.0, max_value=600.0, allow_nan=False, allow_infinity=False +) + + +# --------------------------------------------------------------------------- +# build_packet – property-based +# --------------------------------------------------------------------------- + + +@given( + num_samples=reasonable_num_samples, + depth_idx=st.integers(min_value=0, max_value=4999), + temperature=temperature_strategy, + drive_voltage=drive_voltage_strategy, +) +def test_build_packet_starts_with_start_byte( + num_samples, depth_idx, temperature, drive_voltage +): + depth_idx = min(depth_idx, num_samples - 1) + samples = bytes(num_samples) + pkt = build_packet(num_samples, depth_idx, temperature, drive_voltage, samples) + assert pkt[0] == START_BYTE + + +@given( + num_samples=reasonable_num_samples, + depth_idx=st.integers(min_value=0, max_value=4999), + temperature=temperature_strategy, + drive_voltage=drive_voltage_strategy, +) +def test_build_packet_has_correct_length( + num_samples, depth_idx, temperature, drive_voltage +): + depth_idx = min(depth_idx, num_samples - 1) + samples = bytes(num_samples) + pkt = build_packet(num_samples, depth_idx, temperature, drive_voltage, samples) + assert len(pkt) == packet_size(num_samples) + + +@given( + num_samples=reasonable_num_samples, + depth_idx=st.integers(min_value=0, max_value=4999), + temperature=temperature_strategy, + drive_voltage=drive_voltage_strategy, +) +def test_build_packet_checksum_is_valid( + num_samples, depth_idx, temperature, drive_voltage +): + """The checksum byte must equal XOR of the entire payload.""" + depth_idx = min(depth_idx, num_samples - 1) + samples = bytes(num_samples) + pkt = build_packet(num_samples, depth_idx, temperature, drive_voltage, samples) + payload = pkt[1:-1] + expected_chk = compute_checksum(payload) + assert pkt[-1] == expected_chk + + +@given( + num_samples=reasonable_num_samples, + depth_idx=st.integers(min_value=0, max_value=4999), + temperature=temperature_strategy, + drive_voltage=drive_voltage_strategy, +) +def test_build_packet_round_trips_through_unpack( + num_samples, depth_idx, temperature, drive_voltage +): + """build_packet output must be decodable by EchoPacket.unpack.""" + depth_idx = min(depth_idx, num_samples - 1) + samples = bytes([i % 256 for i in range(num_samples)]) + pkt = build_packet(num_samples, depth_idx, temperature, drive_voltage, samples) + + payload = pkt[1:-1] + checksum = pkt[-1:] + decoded = EchoPacket.unpack(payload, checksum, num_samples) + + assert decoded.depth_index == min(depth_idx, num_samples) + assert decoded.samples.size == num_samples + np.testing.assert_array_equal( + decoded.samples, np.frombuffer(samples, dtype=np.uint8) + ) + assert decoded.temperature == pytest.approx(int(temperature * 100) / 100.0) + assert decoded.drive_voltage == pytest.approx(int(drive_voltage * 100) / 100.0) + + +# --------------------------------------------------------------------------- +# build_packet – edge cases / error handling +# --------------------------------------------------------------------------- + + +def test_build_packet_rejects_wrong_sample_length(): + with pytest.raises(ValueError, match="samples length must equal num_samples"): + build_packet(10, 0, 20.0, 3.3, bytes(5)) + + +def test_build_packet_zero_samples(): + """Edge case: a packet with exactly 0 samples should still build correctly.""" + pkt = build_packet(0, 0, 20.0, 3.3, b"") + assert pkt[0] == START_BYTE + assert len(pkt) == packet_size(0) + + +# --------------------------------------------------------------------------- +# _generate_packet – property-based +# --------------------------------------------------------------------------- + + +@given( + i=st.integers(min_value=0, max_value=10000), + num_samples=st.integers(min_value=10, max_value=3000), + randomize=st.booleans(), +) +@settings(max_examples=200) +def test_generate_packet_produces_valid_decodable_packets(i, num_samples, randomize): + """Every generated packet must be decodable by EchoPacket.unpack.""" + pkt = _generate_packet(i, num_samples, randomize) + assert pkt[0] == START_BYTE + assert len(pkt) == packet_size(num_samples) + + payload = pkt[1:-1] + checksum = pkt[-1:] + decoded = EchoPacket.unpack(payload, checksum, num_samples) + assert decoded.samples.size == num_samples + assert 0 <= decoded.depth_index <= num_samples + + +@given( + i=st.integers(min_value=0, max_value=100), + num_samples=st.integers(min_value=10, max_value=3000), +) +def test_generate_packet_deterministic_has_spike_at_220(i, num_samples): + """When randomize=False the spike sample at depth_idx must be 220.""" + pkt = _generate_packet(i, num_samples, randomize=False) + payload = pkt[1:-1] + checksum = pkt[-1:] + decoded = EchoPacket.unpack(payload, checksum, num_samples) + assert decoded.samples[decoded.depth_index] == 220 + + +@given( + i=st.integers(min_value=0, max_value=100), + num_samples=st.integers(min_value=10, max_value=3000), +) +def test_generate_packet_has_background_noise(i, num_samples): + """Samples should contain background noise (not all zeros except spike).""" + pkt = _generate_packet(i, num_samples, randomize=False) + payload = pkt[1:-1] + checksum = pkt[-1:] + decoded = EchoPacket.unpack(payload, checksum, num_samples) + + # At least some non-spike samples should be > 0 (noise) + # With num_samples >= 10 and values 0-15, probability of all-zero is negligible + non_spike = np.delete(decoded.samples, decoded.depth_index) + # Allow for the rare case where all noise is 0 (possible but astronomically unlikely for num_samples>=10) + # We just assert the max background value is within noise range + assert non_spike.max() <= 15 + + +# --------------------------------------------------------------------------- +# _udp_send – integration (receive a few packets from localhost) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_udp_send_delivers_valid_packets(): + """Start a UDP listener, run _udp_send briefly, and verify received packets.""" + num_samples = 32 + expected_pkt_size = packet_size(num_samples) + received: list[bytes] = [] + + class Receiver(asyncio.DatagramProtocol): + def datagram_received(self, data, addr): + received.append(data) + + loop = asyncio.get_running_loop() + transport, _ = await loop.create_datagram_endpoint( + Receiver, local_addr=("127.0.0.1", 0) + ) + _, port = transport.get_extra_info("sockname") + + # Run sender as a task, cancel after we get enough packets + sender_task = asyncio.create_task( + _udp_send("127.0.0.1", port, rate=200, num_samples=num_samples, randomize=False) + ) + + # Wait until we have at least 3 packets (with a timeout) + for _ in range(100): + if len(received) >= 3: + break + await asyncio.sleep(0.02) + + sender_task.cancel() + with pytest.raises(asyncio.CancelledError): + await sender_task + transport.close() + + assert len(received) >= 3 + for pkt_bytes in received[:3]: + assert len(pkt_bytes) == expected_pkt_size + assert pkt_bytes[0] == START_BYTE + decoded = EchoPacket.unpack(pkt_bytes[1:-1], pkt_bytes[-1:], num_samples) + assert decoded.samples.size == num_samples + + +# --------------------------------------------------------------------------- +# run_simulate – CLI plumbing +# --------------------------------------------------------------------------- + + +def test_run_simulate_defaults_without_args(capsys): + """run_simulate(None) should fall back to defaults and start UDP (we just verify it doesn't crash on init).""" + # We can't let it actually run forever, so we patch asyncio.run to capture the coroutine + import unittest.mock as mock + + with mock.patch("open_echo.simulate.asyncio") as mock_asyncio: + mock_asyncio.run = mock.MagicMock(side_effect=KeyboardInterrupt) + run_simulate(None) + + captured = capsys.readouterr() + assert "Simulating UDP" in captured.out or "Simulation stopped" in captured.out diff --git a/python/tests/test_web.py b/python/tests/test_web.py index 264b077..061eb6f 100644 --- a/python/tests/test_web.py +++ b/python/tests/test_web.py @@ -1,4 +1,5 @@ """Tests for the web server FastAPI endpoints.""" + from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -21,9 +22,7 @@ def client(): patch.object(echo_reader, "__exit__", return_value=False), patch.object(output_manager, "__enter__", return_value=output_manager), patch.object(output_manager, "__exit__", return_value=False), - patch.object( - output_manager, "update_settings", new_callable=AsyncMock - ), + patch.object(output_manager, "update_settings", new_callable=AsyncMock), patch.object(echo_reader, "update_settings"), patch("open_echo.web.Settings.load", return_value=Settings()), patch("open_echo.web.Settings.save"), @@ -86,9 +85,7 @@ def test_updates_settings_and_returns_new_state(self, client): def test_merges_with_existing_settings(self, client): # Set initial state - app.state.settings = Settings( - serial_port="/dev/ttyUSB0", num_samples=1000 - ) + app.state.settings = Settings(serial_port="/dev/ttyUSB0", num_samples=1000) # PUT only changes num_samples resp = client.put( "/api/settings",