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..1e35621 100644
--- a/README.md
+++ b/README.md
@@ -1,31 +1,38 @@
+---
+layout: home
+title: Open Echo
+nav_exclude: true
+---
+
+
## 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:
-
+
## 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
+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
@@ -36,12 +43,12 @@ All profits go directly toward supporting and advancing the Open Echo project!
[TUSS4470 Arduino Shield](TUSS4470_shield_002/):
-### 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/echo_interface.py b/TUSS4470_shield_002/echo_interface.py
deleted file mode 100644
index a9321b9..0000000
--- a/TUSS4470_shield_002/echo_interface.py
+++ /dev/null
@@ -1,893 +0,0 @@
-import sys
-import numpy as np
-import serial
-import serial.tools.list_ports
-import struct
-import time
-import socket
-from PyQt5.QtWidgets import (
- QApplication,
- QMainWindow,
- QVBoxLayout,
- QWidget,
- QComboBox,
- QPushButton,
- QLabel,
- QLineEdit,
-)
-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
-
-# Serial Configuration
-BAUD_RATE = 250000
-NUM_SAMPLES = 1800 # (X-axis)
-
-MAX_ROWS = 300 # Number of time steps (Y-axis)
-Y_LABEL_DISTANCE = 50 # distance between labels in cm
-
-SPEED_OF_SOUND = 1440 # default sound speed meters/second in water
-# SPEED_OF_SOUND = 343 # default sound speed meters/second in water
-
-# 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
-
-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("= 1.0
- ):
- print("Sending NMEA data")
- try:
- depth_cm = depth_index * 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 closeEvent(self, event):
- if self.serial_thread:
- self.serial_thread.stop()
- if hasattr(self, 'udp_thread') and self.udp_thread:
- self.udp_thread.stop()
-
- event.accept()
-
- def open_settings(self):
- device_ip = get_local_ip()
-
- self.settings_dialog = SettingsDialog(
- parent=self,
- current_gradient=self.current_gradient,
- current_speed=self.current_speed,
- nmea_enabled=self.nmea_output_enabled,
- nmea_port=self.nmea_port,
- nmea_address=device_ip,
- )
- self.settings_dialog.show()
-
-
-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 get_current_gradient(self):
- try:
- return self.colorbar.item.gradient.currentPreset
- except Exception:
- return "cyclic" # Fallback
-
-
-if __name__ == "__main__":
- app = QApplication(sys.argv)
-
- # Apply the dark theme
- qdarktheme.setup_theme("dark")
- window = WaterfallApp()
-
- # window.showFullScreen()
- window.show()
-
- sys.exit(app.exec())
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..1660eff 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 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
-> [!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.
-
+
## 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:
-
+
### 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:
-
+
> [!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:
-
+
> [!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).
-
+
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.
-
+
### 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:
-
+
Measured results using LUCKY fishfinder, FastLOGIC (Arduino) and Matplotlib + Python:
-
+
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 62%
rename from TUSS4470_shield_002/UART_UDP_relay.py
rename to python/src/open_echo/UART_UDP_relay.py
index 3c5f6a3..3db7668 100644
--- a/TUSS4470_shield_002/UART_UDP_relay.py
+++ b/python/src/open_echo/UART_UDP_relay.py
@@ -1,118 +1,121 @@
-import serial
-import serial.tools.list_ports
-import socket
import argparse
+import socket
-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
-
+import serial
+import serial.tools.list_ports
+from open_echo.echo import START_BYTE, compute_checksum, payload_size
-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)"
+ "-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)"
+ 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)"
+ 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, num_payload_bytes, verbose=False):
+ """
+ Reads and returns a FULL raw packet:
+ b'\\xAA' + payload + checksum
+ """
+ while True:
+ header = ser.read(1)
+ if not header or header[0] != START_BYTE:
+ continue
+
+ payload = ser.read(num_payload_bytes)
+ checksum = ser.read(1)
+
+ if len(payload) != num_payload_bytes or len(checksum) != 1:
+ if verbose:
+ print("Incomplete packet")
+ continue
+
+ if compute_checksum(payload) != 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,11 +124,12 @@ 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
+ pld_size = payload_size(args.samples)
udp_ip = "255.255.255.255" if args.broadcast else args.udp_ip
# ===== Startup banner =====
@@ -136,7 +140,7 @@ def main():
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'}")
@@ -151,24 +155,18 @@ 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(
- ser,
- payload_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))
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()
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..3bff9f4
--- /dev/null
+++ b/python/src/open_echo/cli.py
@@ -0,0 +1,59 @@
+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
+
+
+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.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())
+
+ relay_parser = subparsers.add_parser("relay", help="Run UART to UDP relay")
+ 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)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/TUSS4470_shield_002/web/depth_output.py b/python/src/open_echo/depth_output.py
similarity index 82%
rename from TUSS4470_shield_002/web/depth_output.py
rename to python/src/open_echo/depth_output.py
index 05a7375..b3b58d6 100644
--- a/TUSS4470_shield_002/web/depth_output.py
+++ b/python/src/open_echo/depth_output.py
@@ -1,14 +1,14 @@
-from abc import ABC, abstractmethod
import asyncio
-import logging
-from httpx import AsyncClient
-import websockets
import json
+import logging
+from abc import ABC, abstractmethod
from typing import Any
-from settings import NMEAOffset, Settings
+import websockets
+from httpx import AsyncClient
+from open_echo.settings import NMEAOffset, Settings
-log = logging.getLogger("uvicorn")
+logger = logging.getLogger(__name__)
class OutputManager:
@@ -35,15 +35,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}")
+ logger.info("Output classes: %s", 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 and (
+ output_class.last_output_time is None
+ or (
+ (asyncio.get_event_loop().time() - output_class.last_output_time)
+ >= output_class.output_interval
+ )
+ ):
+ output_class.last_output_time = asyncio.get_event_loop().time()
await output_class.output()
async def _run(self):
@@ -53,7 +59,7 @@ async def _run(self):
continue
await self.output()
- await asyncio.sleep(1.0)
+ await asyncio.sleep(0.1)
def __enter__(self):
self._task = asyncio.create_task(self._run())
@@ -67,7 +73,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 +89,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 +132,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 +153,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 +169,6 @@ async def get_token(self):
return self.settings.signalk_token
-
-
async def stop(self):
if self._ws:
await self._ws.close()
@@ -166,14 +177,14 @@ async def stop(self):
async def output(self):
if self._ws is None:
try:
- log.info("Reconnecting to SignalK server...")
+ logger.info("Reconnecting to SignalK server...")
await self.start()
except Exception as e:
- log.error(f"SignalK connection error: {e}")
+ logger.exception("SignalK connection error: %s", 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 +212,10 @@ async def output(self):
delta = {"updates": [{"values": values}]}
- log.debug("Send signalk delta: %s", delta)
+ logger.debug("Send signalk delta: %s", delta)
await self._ws.send(json.dumps(delta))
except Exception as e:
- log.error(f"SignalK send error: {e}")
+ logger.exception("SignalK send error: %s", e)
# Attempt reconnect next time
if self._ws:
await self.stop()
@@ -242,11 +253,11 @@ 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)
- depth_m = self._current_value
+ depth_m = self.current_value
depth_ft = depth_m * 3.28084
depth_fathoms = depth_m * 0.546807
@@ -257,7 +268,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())
@@ -282,7 +295,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/python/src/open_echo/desktop.py b/python/src/open_echo/desktop.py
new file mode 100644
index 0000000..c6ba73a
--- /dev/null
+++ b/python/src/open_echo/desktop.py
@@ -0,0 +1,874 @@
+# 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 json
+import logging
+import subprocess
+import sys
+import threading
+
+import numpy as np
+import pyqtgraph as pg
+import qdarktheme
+from PyQt5.QtCore import QObject, Qt, pyqtSignal
+from PyQt5.QtGui import QColor, QPalette
+from PyQt5.QtWidgets import (
+ QApplication,
+ QCheckBox,
+ QComboBox,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QMainWindow,
+ QMessageBox,
+ QPushButton,
+ QVBoxLayout,
+ QWidget,
+)
+from qasync import QEventLoop
+
+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)
+
+# Default server URL
+DEFAULT_SERVER_URL = "http://localhost:8000"
+
+
+# ---------------------------------------------------------------------------
+# 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
+
+
+# ---------------------------------------------------------------------------
+# HTTP helpers — synchronous (run via executor from qasync loop)
+# ---------------------------------------------------------------------------
+
+
+def _fetch_settings_sync(server_url: str) -> dict | None:
+ import json
+ import urllib.request
+
+ 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
+
+
+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 _fetch_serial_ports_sync(server_url: str) -> list[str]:
+ import json
+ import urllib.request
+
+ try:
+ 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",
+ server_url: str = DEFAULT_SERVER_URL,
+ ):
+ super().__init__(parent)
+ 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 = QVBoxLayout(self)
+ outer_layout.setAlignment(Qt.AlignCenter) # type: ignore[attr-defined]
+
+ card = QWidget()
+ card.setObjectName("Card")
+ card_layout = QVBoxLayout(card)
+ card_layout.setContentsMargins(20, 20, 20, 20)
+ 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)
+
+ card_layout.addWidget(QLabel("Color Map:"))
+ self.gradient_dropdown = QComboBox()
+ self.gradient_dropdown.addItems(
+ [
+ "viridis",
+ "plasma",
+ "inferno",
+ "magma",
+ "thermal",
+ "flame",
+ "yellowy",
+ "bipolar",
+ "spectrum",
+ "cyclic",
+ "greyclip",
+ "grey",
+ ]
+ )
+ self.gradient_dropdown.setCurrentText(current_gradient)
+ card_layout.addWidget(self.gradient_dropdown)
+
+ 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)
+
+ # === 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)
+
+ # 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)
+
+ # 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)
+
+ # Medium
+ card_layout.addWidget(QLabel("Medium:"))
+ self.medium_dropdown = QComboBox()
+ self.medium_dropdown.addItems(["water", "air"])
+ card_layout.addWidget(self.medium_dropdown)
+
+ # 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)
+ cancel_button = QPushButton("Cancel")
+ 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)
+
+ outer_layout.addWidget(card)
+
+ self.setStyleSheet(
+ """
+ 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; }
+ """
+ )
+
+ 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())
+
+ 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),
+ )
+
+ 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:
+ echo_settings["udp_port"] = int(self.udp_port_input.text())
+ except ValueError:
+ echo_settings["udp_port"] = 9999
+ try:
+ 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()
+
+
+# ---------------------------------------------------------------------------
+# Main application window
+# ---------------------------------------------------------------------------
+
+
+class WaterfallApp(QMainWindow):
+ def __init__(self, server_url: str = DEFAULT_SERVER_URL):
+ super().__init__()
+ self.server_url = server_url
+
+ self.current_gradient = "cyclic"
+ self.large_depth_visible = True
+
+ # 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)
+
+ self._recompute_sampling_derived()
+ self.data = np.zeros((MAX_ROWS, self.num_samples))
+
+ # 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)
+ self.setAutoFillBackground(True)
+
+ central_widget = QWidget()
+ self.setCentralWidget(central_widget)
+ main_layout = QVBoxLayout()
+ main_layout.setContentsMargins(5, 5, 5, 5)
+ main_layout.setSpacing(5)
+ central_widget.setLayout(main_layout)
+
+ # === Waterfall Plot ===
+ self.waterfall = pg.PlotWidget()
+ self.imageitem = pg.ImageItem(axisOrder="row-major")
+ self.waterfall.addItem(self.imageitem)
+ self.waterfall.setMouseEnabled(x=False, y=False)
+ 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.waterfall.addItem(self.depth_line)
+
+ right_axis = self.waterfall.getAxis("right")
+ right_axis.setTicks([inverted_depth_labels])
+ right_axis.setStyle(showValues=True)
+
+ self._depth_lines: list[pg.InfiniteLine] = []
+ self._add_grid_lines()
+
+ # === Colorbar (hidden but still drives LUT) ===
+ self.colorbar = pg.HistogramLUTWidget()
+ self.colorbar.setImageItem(self.imageitem)
+ self.colorbar.item.gradient.loadPreset("cyclic")
+ self.imageitem.setLevels(DEFAULT_LEVELS)
+
+ # === Controls ===
+ controls_layout = QVBoxLayout()
+
+ # Large Depth Display
+ self.large_depth_label = QLabel("--- m")
+ self.large_depth_label.setAlignment(Qt.AlignCenter) # type: ignore[attr-defined]
+ self.large_depth_label.setStyleSheet(
+ """
+ QLabel {
+ color: #00ffcc;
+ font-size: 64px;
+ font-weight: bold;
+ }
+ """
+ )
+ 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)
+
+ # Bottom row: status + buttons
+ bottom_row = QHBoxLayout()
+
+ self.status_label = QLabel("Starting...")
+ self.status_label.setStyleSheet("color: #aaa; font-size: 12px;")
+ bottom_row.addWidget(self.status_label)
+
+ bottom_row.addStretch()
+
+ self.settings_button = QPushButton("Settings")
+ self.settings_button.clicked.connect(self.open_settings)
+ 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)
+
+ self.quit_button = QPushButton("Quit")
+ self.quit_button.clicked.connect(self.close) # type: ignore[arg-type]
+ bottom_row.addWidget(self.quit_button)
+
+ controls_layout.addLayout(bottom_row)
+
+ controls_container = QWidget()
+ controls_container.setLayout(controls_layout)
+ main_layout.addWidget(controls_container)
+
+ # === 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.",
+ )
+ return
+
+ # 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
+
+ self._ws_client.start()
+
+ def closeEvent(self, event):
+ self._ws_client.stop()
+ self._server_manager.shutdown()
+ event.accept()
+
+ def keyPressEvent(self, event):
+ if event.key() == ord("Q"):
+ self.close()
+ else:
+ super().keyPressEvent(event)
+
+ # --- WebSocket data handling ---
+
+ 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
+
+ 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
+ self.imageitem.setImage(self.data.T, autoLevels=False)
+
+ sigma = np.std(self.data)
+ mean = np.mean(self.data)
+ 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.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)
+
+ if self.large_depth_label.isVisible():
+ self.large_depth_label.setText(f"{depth_m:.1f} m")
+
+ # --- Connection status ---
+
+ 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 _update_status(self, text: str):
+ self.status_label.setText(text)
+
+ # --- Display settings ---
+
+ def set_gradient(self, gradient_name):
+ self.current_gradient = gradient_name
+ self.colorbar.item.gradient.loadPreset(gradient_name)
+
+ def set_large_depth_display(self, enabled: bool):
+ self.large_depth_visible = enabled
+ self.large_depth_label.setVisible(enabled)
+
+ # --- Settings dialog ---
+
+ def open_settings(self, on_first_run=False):
+ self.settings_dialog = SettingsDialog(
+ parent=self,
+ current_gradient=self.current_gradient,
+ 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):
+ 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}"
+ 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])
+ self._remove_grid_lines()
+ self._add_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(
+ 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 _remove_grid_lines(self):
+ for ln in self._depth_lines:
+ try:
+ self.waterfall.removeItem(ln)
+ except Exception:
+ pass
+ self._depth_lines.clear()
+
+
+# ---------------------------------------------------------------------------
+# Entry point
+# ---------------------------------------------------------------------------
+
+
+def run_desktop(server_url: str = DEFAULT_SERVER_URL):
+ app = QApplication(sys.argv)
+ qdarktheme.setup_theme("dark")
+
+ loop = QEventLoop(app)
+ asyncio.set_event_loop(loop)
+
+ 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()
+
+
+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..6fe1767
--- /dev/null
+++ b/python/src/open_echo/echo.py
@@ -0,0 +1,238 @@
+import asyncio
+import logging
+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
+
+log = logging.getLogger(__name__)
+
+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
+
+
+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) != payload_size(num_samples) or len(checksum) != 1:
+ raise EchoReadError("Invalid payload or checksum length")
+
+ # Verify checksum
+ 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
+ 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"):
+ log.debug("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.
+
+ 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(
+ 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[0] != START_BYTE:
+ continue # Wait for the start byte
+
+ payload = await self.reader.readexactly(
+ payload_size(self.settings.num_samples)
+ )
+ checksum = await self.reader.readexactly(1)
+
+ try:
+ return EchoPacket.unpack(payload, checksum, self.settings.num_samples)
+ except ChecksumMismatchError:
+ log.warning("Serial: checksum mismatch, dropping packet")
+ continue
+
+
+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 == START_BYTE:
+ 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)
+ except ChecksumMismatchError:
+ log.warning("UDP: checksum mismatch, dropping packet")
+ 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 = packet_size(self.settings.num_samples)
+ 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("UDP listener bound to %s:%d", 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/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/src/open_echo/web.py b/python/src/open_echo/web.py
new file mode 100644
index 0000000..b68d183
--- /dev/null
+++ b/python/src/open_echo/web.py
@@ -0,0 +1,228 @@
+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)
+
+
+# --- 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
+
+ uvicorn.run(app, host="0.0.0.0", port=8000)
+
+
+if __name__ == "__main__":
+ run_web()
diff --git a/python/tests/test_cli.py b/python/tests/test_cli.py
new file mode 100644
index 0000000..c8cd5f0
--- /dev/null
+++ b/python/tests/test_cli.py
@@ -0,0 +1,57 @@
+"""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_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_desktop.py b/python/tests/test_desktop.py
new file mode 100644
index 0000000..a83972b
--- /dev/null
+++ b/python/tests/test_desktop.py
@@ -0,0 +1,192 @@
+"""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_echo.py b/python/tests/test_echo.py
new file mode 100644
index 0000000..7bbc710
--- /dev/null
+++ b/python/tests/test_echo.py
@@ -0,0 +1,404 @@
+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
+ # 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):
+ 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():
+ 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/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
new file mode 100644
index 0000000..061eb6f
--- /dev/null
+++ b/python/tests/test_web.py
@@ -0,0 +1,274 @@
+"""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()
diff --git a/reverse_engineering/images/.DS_Store b/reverse_engineering/images/.DS_Store
deleted file mode 100644
index 958f64d..0000000
Binary files a/reverse_engineering/images/.DS_Store and /dev/null differ
diff --git a/reverse_engineering/live_waterfall.py b/reverse_engineering/live_waterfall.py
index 99d2cbc..fb6d749 100644
--- a/reverse_engineering/live_waterfall.py
+++ b/reverse_engineering/live_waterfall.py
@@ -1,7 +1,11 @@
-import serial
+import time
+
import matplotlib.pyplot as plt
import numpy as np
-import time
+import serial
+import logging
+
+logger = logging.getLogger(__name__)
# Serial port configuration
serial_port = "/dev/tty.usbserial-1120" # Updated to the specified serial port
@@ -68,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
@@ -100,7 +104,7 @@ def parse_data(line):
plt.pause(0.1)
except KeyboardInterrupt:
- print("Exiting...")
+ logger.info("Exiting...")
break
# Close serial connection