From 6c54bbd16ea0c7d3f60ed0aa4b161d35ea1dfefb Mon Sep 17 00:00:00 2001 From: Ayzen Date: Tue, 23 Sep 2025 18:42:55 +0300 Subject: [PATCH] initial commit --- reference_data_acquisition/bridge_logger.py | 131 ++++ .../socat_serial_sniffer.sh | 82 +++ requirements.txt | 3 + vna_system/api/__init__.py | 1 + vna_system/api/api_config.json | 9 + vna_system/api/endpoints/__init__.py | 1 + vna_system/api/endpoints/health.py | 43 ++ vna_system/api/endpoints/processing.py | 27 + vna_system/api/endpoints/web_ui.py | 57 ++ vna_system/api/main.py | 143 ++++ vna_system/api/websockets/__init__.py | 1 + vna_system/api/websockets/processing.py | 16 + .../api/websockets/websocket_handler.py | 165 +++++ .../S11/str100_stp8800_pnts1000_bw1khz.bin | Bin 0 -> 78742 bytes vna_system/binary_logs/current_log.bin | Bin 0 -> 78742 bytes vna_system/config/config.py | 120 +++ .../core/acquisition/data_acquisition.py | 405 ++++++++++ vna_system/core/acquisition/sweep_buffer.py | 91 +++ vna_system/core/processing/__init__.py | 1 + vna_system/core/processing/base_processor.py | 63 ++ vna_system/core/processing/config.json | 20 + .../core/processing/processors/__init__.py | 1 + .../processing/processors/magnitude_plot.py | 158 ++++ vna_system/core/processing/results_storage.py | 295 ++++++++ vna_system/core/processing/sweep_processor.py | 231 ++++++ vna_system/core/singletons.py | 15 + vna_system/scripts/__init__.py | 1 + vna_system/scripts/start.sh | 92 +++ vna_system/web_ui/static/assets/favicon.svg | 8 + vna_system/web_ui/static/css/charts.css | 242 ++++++ vna_system/web_ui/static/css/components.css | 461 ++++++++++++ vna_system/web_ui/static/css/layout.css | 377 ++++++++++ vna_system/web_ui/static/css/normalize.css | 54 ++ vna_system/web_ui/static/css/variables.css | 134 ++++ vna_system/web_ui/static/js/main.js | 394 ++++++++++ vna_system/web_ui/static/js/modules/charts.js | 693 ++++++++++++++++++ .../web_ui/static/js/modules/notifications.js | 466 ++++++++++++ .../web_ui/static/js/modules/storage.js | 453 ++++++++++++ vna_system/web_ui/static/js/modules/ui.js | 529 +++++++++++++ .../web_ui/static/js/modules/websocket.js | 460 ++++++++++++ vna_system/web_ui/templates/index.html | 139 ++++ 41 files changed, 6582 insertions(+) create mode 100755 reference_data_acquisition/bridge_logger.py create mode 100755 reference_data_acquisition/socat_serial_sniffer.sh create mode 100644 requirements.txt create mode 100644 vna_system/api/__init__.py create mode 100644 vna_system/api/api_config.json create mode 100644 vna_system/api/endpoints/__init__.py create mode 100644 vna_system/api/endpoints/health.py create mode 100644 vna_system/api/endpoints/processing.py create mode 100644 vna_system/api/endpoints/web_ui.py create mode 100644 vna_system/api/main.py create mode 100644 vna_system/api/websockets/__init__.py create mode 100644 vna_system/api/websockets/processing.py create mode 100644 vna_system/api/websockets/websocket_handler.py create mode 100644 vna_system/binary_logs/config_logs/S11/str100_stp8800_pnts1000_bw1khz.bin create mode 100644 vna_system/binary_logs/current_log.bin create mode 100644 vna_system/config/config.py create mode 100644 vna_system/core/acquisition/data_acquisition.py create mode 100644 vna_system/core/acquisition/sweep_buffer.py create mode 100644 vna_system/core/processing/__init__.py create mode 100644 vna_system/core/processing/base_processor.py create mode 100644 vna_system/core/processing/config.json create mode 100644 vna_system/core/processing/processors/__init__.py create mode 100644 vna_system/core/processing/processors/magnitude_plot.py create mode 100644 vna_system/core/processing/results_storage.py create mode 100644 vna_system/core/processing/sweep_processor.py create mode 100644 vna_system/core/singletons.py create mode 100644 vna_system/scripts/__init__.py create mode 100755 vna_system/scripts/start.sh create mode 100644 vna_system/web_ui/static/assets/favicon.svg create mode 100644 vna_system/web_ui/static/css/charts.css create mode 100644 vna_system/web_ui/static/css/components.css create mode 100644 vna_system/web_ui/static/css/layout.css create mode 100644 vna_system/web_ui/static/css/normalize.css create mode 100644 vna_system/web_ui/static/css/variables.css create mode 100644 vna_system/web_ui/static/js/main.js create mode 100644 vna_system/web_ui/static/js/modules/charts.js create mode 100644 vna_system/web_ui/static/js/modules/notifications.js create mode 100644 vna_system/web_ui/static/js/modules/storage.js create mode 100644 vna_system/web_ui/static/js/modules/ui.js create mode 100644 vna_system/web_ui/static/js/modules/websocket.js create mode 100644 vna_system/web_ui/templates/index.html diff --git a/reference_data_acquisition/bridge_logger.py b/reference_data_acquisition/bridge_logger.py new file mode 100755 index 0000000..5370848 --- /dev/null +++ b/reference_data_acquisition/bridge_logger.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +Half-duplex serial bridge & logger. + +- Open two fds: + host_fd: readable/writable endpoint the app uses (VIRT_DEV) + dev_fd: real serial device (REAL_DEV) +- Relay bytes between them. +- Build one log record per *directional burst*: + - When bytes arrive from host: start/continue a TX frame. + - When first byte arrives from device: flush TX (if any), start RX frame. + - And vice versa. +- Binary log format: + MAGIC "VNALOG1\n" + Then repeated records: [1 byte dir][4 bytes len (big-endian)][payload] + dir 0x01 = host→device ('>'), 0x00 = device→host ('<') +""" + +import os, sys, fcntl, select, struct + +MAGIC = b"VNALOG1\n" +DIR_TX = 0x01 # host→device +DIR_RX = 0x00 # device→host + +if len(sys.argv) != 4: + print("usage: bridge_logger.py VIRT_DEV REAL_DEV LOGFILE", file=sys.stderr) + sys.exit(2) +VIRT_DEV, REAL_DEV, LOGFILE = sys.argv[1:4] + +# Open endpoints non-blocking, read+write +host_fd = os.open(VIRT_DEV, os.O_RDWR | os.O_NONBLOCK) +dev_fd = os.open(REAL_DEV, os.O_RDWR | os.O_NONBLOCK) + +# Prepare log (write header if empty) +log_fd = os.open(LOGFILE, os.O_CREAT | os.O_RDWR, 0o644) +if os.lseek(log_fd, 0, os.SEEK_END) == 0: + os.write(log_fd, MAGIC) + +def set_nonblock(fd): + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + if not (fl & os.O_NONBLOCK): + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) +set_nonblock(host_fd); set_nonblock(dev_fd) + +poll = select.poll() +poll.register(host_fd, select.POLLIN) +poll.register(dev_fd, select.POLLIN) + +# Current frame state +cur_dir = None # None / DIR_TX / DIR_RX +buf = bytearray() + +READ_CHUNK = 65536 + +def write_all(fd, data: bytes): + """Non-blocking write-all with POLLOUT assistance.""" + view = memoryview(data) + off = 0 + while off < len(view): + try: + n = os.write(fd, view[off:]) + if n is None: n = 0 + except BlockingIOError: + # Wait for fd to become writable + p = select.poll(); p.register(fd, select.POLLOUT) + p.poll(1000) + continue + off += n + +def flush_record(): + """If a frame is open, append it as one binary record and reset buffer.""" + global cur_dir, buf + if cur_dir is None or not buf: + return + # dir byte + 4-byte big-endian length + payload + rec = bytes([cur_dir]) + struct.pack(">I", len(buf)) + bytes(buf) + # Single write is fine (kernel will split if huge) + os.write(log_fd, rec) + buf.clear() + cur_dir = None + +try: + while True: + events = poll.poll(1000) # 1s tick; framing doesn't depend on idle + if not events: + # If nothing is happening for a while, we could flush a tail, + # but with half-duplex the tail will be flushed on direction switch or exit. + continue + for (fd, ev) in events: + if not (ev & select.POLLIN): + continue + try: + chunk = os.read(fd, READ_CHUNK) + except BlockingIOError: + continue + if not chunk: + # EOF on one side: flush and exit + flush_record() + sys.exit(0) + + if fd is host_fd: + # Incoming from host: forward to device + if cur_dir is None: + cur_dir = DIR_TX + elif cur_dir != DIR_TX: + # Direction switched: flush previous RX frame, start TX + flush_record() + cur_dir = DIR_TX + buf.extend(chunk) + write_all(dev_fd, chunk) + else: + # Incoming from device: forward to host + if cur_dir is None: + cur_dir = DIR_RX + elif cur_dir != DIR_RX: + # Direction switched: flush previous TX frame, start RX + flush_record() + cur_dir = DIR_RX + buf.extend(chunk) + write_all(host_fd, chunk) +except KeyboardInterrupt: + pass +finally: + # Final flush and cleanup + flush_record() + try: os.close(host_fd) + except: pass + try: os.close(dev_fd) + except: pass + try: os.close(log_fd) + except: pass \ No newline at end of file diff --git a/reference_data_acquisition/socat_serial_sniffer.sh b/reference_data_acquisition/socat_serial_sniffer.sh new file mode 100755 index 0000000..38b1ec7 --- /dev/null +++ b/reference_data_acquisition/socat_serial_sniffer.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +set -euo pipefail +IFS=$'\n\t' + +# --- Settings ---------------------------------------------------------------- +LOGFILE="vna_protocol.bin" # will be normalized to absolute path +DEVICE="/dev/ttyACM0" +REAL_DEV="${DEVICE}.real" +VIRT_DEV="/tmp/vna_log" # PTY endpoint for the app (peer created by socat) + +# Normalize LOGFILE and ensure directory exists +if command -v realpath >/dev/null 2>&1; then + LOGFILE="$(realpath -m -- "$LOGFILE")" +else + LOGFILE="$(cd "$(dirname "$LOGFILE")" && pwd -P)/$(basename "$LOGFILE")" +fi +mkdir -p "$(dirname "$LOGFILE")" + +BRIDGE="./bridge_logger.py" + +# --- Helpers ----------------------------------------------------------------- +restore_device() { + [[ -e "$VIRT_DEV" || -L "$VIRT_DEV" ]] && rm -f "$VIRT_DEV" || true + if [[ -e "$DEVICE" && ! -c "$DEVICE" ]]; then rm -f "$DEVICE" || true; fi + if [[ -e "$REAL_DEV" ]]; then sudo mv -f "$REAL_DEV" "$DEVICE" || true; fi + if [[ -e "$REAL_DEV" && -c "$DEVICE" ]]; then rm -f "$REAL_DEV" || true; fi +} + +kill_descendants() { + pkill -TERM -P $$ 2>/dev/null || true + sleep 0.1 + pkill -KILL -P $$ 2>/dev/null || true +} + + +# --- Cleanup ----------------------------------------------------------------- +CLEANED=0 +cleanup() { + (( CLEANED )) && return 0 + CLEANED=1 + set +e + echo; echo "Stopping logger..." + restore_device + kill_descendants + echo "Log saved to: $LOGFILE" + echo "Cleanup complete." +} +trap cleanup EXIT INT TERM HUP QUIT + +echo "== Preparing direction-switch protocol logging ==" +echo "Binary log file: $LOGFILE" + +# Fix leftovers from previous run (if any) +if [[ -e "$REAL_DEV" ]]; then + echo "Found leftover $REAL_DEV — restoring..." + restore_device +fi + +# Safety checks +[[ -e "$DEVICE" ]] || { echo "Error: device $DEVICE not found."; exit 1; } +[[ -c "$DEVICE" ]] || { echo "Error: $DEVICE is not a character device."; exit 1; } + +# Park the real device and create a PTY pair for the application +sudo mv "$DEVICE" "$REAL_DEV" +socat -d -d pty,raw,echo=0,link="$DEVICE",mode=666 \ + pty,raw,echo=0,link="$VIRT_DEV" & +sleep 1 + +# Configure the real serial device (adjust if needed) +stty -F "$REAL_DEV" 115200 cs8 -cstopb -parenb -ixon -ixoff -crtscts raw -echo + +# Initialize the binary log with a magic header (if empty) +if [[ ! -s "$LOGFILE" ]]; then + printf 'VNALOG1\n' > "$LOGFILE" +fi + +# Run the bridge+logger (single process handles both directions + logging) +"$BRIDGE" "$VIRT_DEV" "$REAL_DEV" "$LOGFILE" & + +echo "Logging active. Press Ctrl+C to stop." +wait diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b88a936 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pyserial==3.5 +websockets==15.0.1 +# ??? kaleido==1.1.0 \ No newline at end of file diff --git a/vna_system/api/__init__.py b/vna_system/api/__init__.py new file mode 100644 index 0000000..48f5e3a --- /dev/null +++ b/vna_system/api/__init__.py @@ -0,0 +1 @@ +"""VNA System API module.""" \ No newline at end of file diff --git a/vna_system/api/api_config.json b/vna_system/api/api_config.json new file mode 100644 index 0000000..08206f4 --- /dev/null +++ b/vna_system/api/api_config.json @@ -0,0 +1,9 @@ +{ + "server": { + "host": "0.0.0.0", + "port": 8000 + }, + "logging": { + "level": "INFO" + } +} \ No newline at end of file diff --git a/vna_system/api/endpoints/__init__.py b/vna_system/api/endpoints/__init__.py new file mode 100644 index 0000000..d458bbe --- /dev/null +++ b/vna_system/api/endpoints/__init__.py @@ -0,0 +1 @@ +"""API endpoints module.""" \ No newline at end of file diff --git a/vna_system/api/endpoints/health.py b/vna_system/api/endpoints/health.py new file mode 100644 index 0000000..f7fc5b3 --- /dev/null +++ b/vna_system/api/endpoints/health.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +""" +Health and status endpoints. +""" + +from fastapi import APIRouter + +import vna_system.core.singletons as singletons + + +router = APIRouter(prefix="/api/v1", tags=["health"]) + + +@router.get("/") +async def root(): + """Root endpoint.""" + return {"message": "VNA System API", "version": "1.0.0"} + + +@router.get("/health") +async def health_check(): + """Health check endpoint.""" + return { + "status": "healthy", + "acquisition_running": singletons.vna_data_acquisition_instance.is_running, + "processing_running": singletons.processing_manager.is_running, + "processing_stats": singletons.processing_manager.get_processing_stats() + } + + +@router.get("/device-status") +async def device_status(): + """Get device connection status and details.""" + acquisition = singletons.vna_data_acquisition_instance + device_info = acquisition.get_device_info() + + return { + "device_info": device_info, + "acquisition_running": acquisition.is_running, + "last_sweep": acquisition.sweep_buffer.get_latest_sweep_number(), + "total_sweeps": len(acquisition.sweep_buffer.get_all_sweeps()), + "buffer_stats": acquisition.sweep_buffer.get_stats(), + } \ No newline at end of file diff --git a/vna_system/api/endpoints/processing.py b/vna_system/api/endpoints/processing.py new file mode 100644 index 0000000..7dc08ee --- /dev/null +++ b/vna_system/api/endpoints/processing.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter, HTTPException +import vna_system.core.singletons as singletons + +router = APIRouter(prefix="/api/v1", tags=["processing"]) + + +@router.get("/stats") +async def get_stats(): + """Get processing statistics.""" + return singletons.processing_manager.get_processing_stats() + + +@router.get("/history") +async def get_history(processor_name: str | None = None, limit: int = 10): + """Get processing results history.""" + results = singletons.processing_manager.get_latest_results(processor_name)[-limit:] + return [result.to_dict() for result in results] + + +@router.get("/sweep/{sweep_number}") +async def get_sweep(sweep_number: int, processor_name: str | None = None): + """Get results for specific sweep.""" + results = singletons.processing_manager.get_result_by_sweep(sweep_number, processor_name) + if not results: + raise HTTPException(status_code=404, detail=f"No results found for sweep {sweep_number}") + + return [result.to_dict() for result in results] \ No newline at end of file diff --git a/vna_system/api/endpoints/web_ui.py b/vna_system/api/endpoints/web_ui.py new file mode 100644 index 0000000..de7d931 --- /dev/null +++ b/vna_system/api/endpoints/web_ui.py @@ -0,0 +1,57 @@ +""" +Web UI endpoints for serving the dashboard +""" + +from fastapi import APIRouter +from fastapi.responses import HTMLResponse +from pathlib import Path +import logging + +logger = logging.getLogger(__name__) + +# Create router +router = APIRouter(prefix="", tags=["web-ui"]) + +# Get the web UI directory path +WEB_UI_DIR = Path(__file__).parent.parent.parent / "web_ui" +STATIC_DIR = WEB_UI_DIR / "static" +TEMPLATES_DIR = WEB_UI_DIR / "templates" + +# Static files will be mounted in main.py + +@router.get("/", response_class=HTMLResponse) +async def dashboard(): + """Serve the main dashboard page.""" + try: + index_file = TEMPLATES_DIR / "index.html" + + if not index_file.exists(): + logger.error(f"Dashboard template not found: {index_file}") + return HTMLResponse( + content="

Dashboard Not Available

The web UI template could not be found.

", + status_code=404 + ) + + with open(index_file, 'r', encoding='utf-8') as f: + content = f.read() + + return HTMLResponse(content=content) + + except Exception as e: + logger.error(f"Error serving dashboard: {e}") + return HTMLResponse( + content=f"

Error

Unable to load dashboard: {e}

", + status_code=500 + ) + +@router.get("/health-ui") +async def health_ui(): + """Health check endpoint for web UI.""" + return { + "service": "VNA Web UI", + "status": "healthy", + "web_ui_dir": str(WEB_UI_DIR), + "static_dir_exists": STATIC_DIR.exists(), + "templates_dir_exists": TEMPLATES_DIR.exists(), + "index_exists": (TEMPLATES_DIR / "index.html").exists() + } \ No newline at end of file diff --git a/vna_system/api/main.py b/vna_system/api/main.py new file mode 100644 index 0000000..914f0e7 --- /dev/null +++ b/vna_system/api/main.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import json +import logging +import sys +from contextlib import asynccontextmanager +from typing import Any, Dict + +import uvicorn +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from pathlib import Path + +import vna_system.core.singletons as singletons +from vna_system.core.processing.sweep_processor import SweepProcessingManager +from vna_system.api.websockets.websocket_handler import WebSocketManager +from vna_system.api.endpoints import health, processing, web_ui +from vna_system.api.websockets import processing as ws_processing + + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +# Disable noisy third-party loggers +logging.getLogger('kaleido').setLevel(logging.ERROR) +logging.getLogger('choreographer').setLevel(logging.ERROR) +logging.getLogger('kaleido.kaleido').setLevel(logging.ERROR) +logging.getLogger('choreographer.browsers.chromium').setLevel(logging.ERROR) +logging.getLogger('choreographer.browser_async').setLevel(logging.ERROR) +logging.getLogger('choreographer.utils._tmpfile').setLevel(logging.ERROR) +logging.getLogger('kaleido._kaleido_tab').setLevel(logging.ERROR) + +logger = logging.getLogger(__name__) + + + + +def load_config(config_path: str = "vna_system/api/api_config.json") -> Dict[str, Any]: + """Load API configuration from file.""" + try: + with open(config_path, 'r') as f: + config = json.load(f) + logger.info(f"Loaded API config from {config_path}") + return config + except Exception as e: + logger.error(f"Failed to load config: {e}") + sys.exit(1) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """FastAPI lifespan events.""" + # Startup + logger.info("Starting VNA API Server...") + + try: + # Load config + config = load_config() + + # Set log level + log_level = config.get("logging", {}).get("level", "INFO") + logging.getLogger().setLevel(getattr(logging, log_level)) + + # Start acquisition + logger.info("Starting data acquisition...") + singletons.vna_data_acquisition_instance.start() + + # Connect processing to acquisition + singletons.processing_manager.set_sweep_buffer(singletons.vna_data_acquisition_instance.sweep_buffer) + singletons.processing_manager.start() + logger.info("Sweep processing started") + + logger.info("VNA API Server started successfully") + + yield + + except Exception as e: + logger.error(f"Error during startup: {e}") + raise + + # Shutdown + logger.info("Shutting down VNA API Server...") + + if singletons.processing_manager: + singletons.processing_manager.stop() + logger.info("Processing stopped") + + if singletons.vna_data_acquisition_instance and singletons.vna_data_acquisition_instance.is_running: + singletons.vna_data_acquisition_instance.stop() + logger.info("Acquisition stopped") + + logger.info("VNA API Server shutdown complete") + + +# Create FastAPI app +app = FastAPI( + title="VNA System API", + description="Real-time VNA data acquisition and processing API", + version="1.0.0", + lifespan=lifespan +) + +# Mount static files for web UI +WEB_UI_DIR = Path(__file__).parent.parent / "web_ui" +STATIC_DIR = WEB_UI_DIR / "static" + +if STATIC_DIR.exists(): + app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + logger.info(f"Mounted static files from: {STATIC_DIR}") +else: + logger.warning(f"Static directory not found: {STATIC_DIR}") + +# Include routers +app.include_router(web_ui.router) # Web UI should be first for root path +app.include_router(health.router) +app.include_router(processing.router) +app.include_router(ws_processing.router) + + +def main(): + """Main entry point.""" + config = load_config() + + # Server configuration + server_config = config.get("server", {}) + host = server_config.get("host", "0.0.0.0") + port = server_config.get("port", 8000) + + # Start server + uvicorn.run( + "vna_system.api.main:app", + host=host, + port=port, + log_level="info", + reload=False + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/vna_system/api/websockets/__init__.py b/vna_system/api/websockets/__init__.py new file mode 100644 index 0000000..27a2e69 --- /dev/null +++ b/vna_system/api/websockets/__init__.py @@ -0,0 +1 @@ +"""WebSocket routes module.""" \ No newline at end of file diff --git a/vna_system/api/websockets/processing.py b/vna_system/api/websockets/processing.py new file mode 100644 index 0000000..b7614c2 --- /dev/null +++ b/vna_system/api/websockets/processing.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +""" +WebSocket routes for processing data streaming. +""" + +from fastapi import APIRouter, WebSocket +import vna_system.core.singletons as singletons + +router = APIRouter() + + +@router.websocket("/ws/processing") +async def processing_websocket_endpoint(websocket: WebSocket): + """WebSocket endpoint for real-time processing data streaming.""" + + await singletons.websocket_manager.handle_websocket(websocket) \ No newline at end of file diff --git a/vna_system/api/websockets/websocket_handler.py b/vna_system/api/websockets/websocket_handler.py new file mode 100644 index 0000000..5af18a5 --- /dev/null +++ b/vna_system/api/websockets/websocket_handler.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +WebSocket handler for real-time sweep data processing results using FastAPI. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any, Dict, List + +from fastapi import WebSocket, WebSocketDisconnect + +from vna_system.core.processing.sweep_processor import SweepProcessingManager + + +logger = logging.getLogger(__name__) + + +class WebSocketManager: + """WebSocket manager for streaming processing results to clients.""" + + def __init__(self, processing_manager: SweepProcessingManager) -> None: + self.processing_manager = processing_manager + self.active_connections: List[WebSocket] = [] + + async def connect(self, websocket: WebSocket) -> None: + """Accept a new WebSocket connection.""" + await websocket.accept() + self.active_connections.append(websocket) + logger.info(f"Client connected. Total clients: {len(self.active_connections)}") + + def disconnect(self, websocket: WebSocket) -> None: + """Remove WebSocket connection.""" + if websocket in self.active_connections: + self.active_connections.remove(websocket) + logger.info(f"Client disconnected. Total clients: {len(self.active_connections)}") + + async def send_personal_message(self, message: Dict[str, Any], websocket: WebSocket) -> None: + """Send message to specific websocket.""" + try: + await websocket.send_text(json.dumps(message)) + except Exception as e: + logger.error(f"Error sending message to client: {e}") + + async def broadcast(self, message: Dict[str, Any]) -> None: + """Send message to all connected clients.""" + if not self.active_connections: + return + + disconnected = [] + for connection in self.active_connections: + try: + await connection.send_text(json.dumps(message)) + except Exception as e: + logger.error(f"Error broadcasting to client: {e}") + disconnected.append(connection) + + # Remove failed connections + for connection in disconnected: + self.disconnect(connection) + + async def handle_websocket(self, websocket: WebSocket) -> None: + """Handle WebSocket connection.""" + await self.connect(websocket) + + try: + # Send initial status + await self.send_personal_message({ + "type": "status", + "data": { + "connected": True, + "processing_stats": self.processing_manager.get_processing_stats() + } + }, websocket) + + # Start processing results stream + stream_task = asyncio.create_task(self._stream_results(websocket)) + + try: + # Handle incoming messages + while True: + data = await websocket.receive_text() + try: + message = json.loads(data) + await self._handle_message(websocket, message) + except json.JSONDecodeError: + await self.send_personal_message({ + "type": "error", + "message": "Invalid JSON format" + }, websocket) + except WebSocketDisconnect: + logger.info("WebSocket disconnected") + except Exception as e: + logger.error(f"WebSocket message handling error: {e}") + + except WebSocketDisconnect: + logger.info("WebSocket disconnected") + except Exception as e: + logger.error(f"WebSocket error: {e}") + finally: + stream_task.cancel() + self.disconnect(websocket) + + async def _handle_message(self, websocket: WebSocket, message: Dict[str, Any]) -> None: + """Handle incoming client message.""" + message_type = message.get("type") + + if message_type == "get_stats": + await self.send_personal_message({ + "type": "stats", + "data": self.processing_manager.get_processing_stats() + }, websocket) + + elif message_type == "get_history": + processor_name = message.get("processor_name") + limit = message.get("limit", 10) + + results = self.processing_manager.get_latest_results(processor_name)[-limit:] + await self.send_personal_message({ + "type": "history", + "data": [result.to_dict() for result in results] + }, websocket) + + elif message_type == "get_sweep": + sweep_number = message.get("sweep_number") + processor_name = message.get("processor_name") + + if sweep_number is not None: + results = self.processing_manager.get_result_by_sweep(sweep_number, processor_name) + await self.send_personal_message({ + "type": "sweep_data", + "data": [result.to_dict() for result in results] + }, websocket) + else: + await self.send_personal_message({ + "type": "error", + "message": "sweep_number is required" + }, websocket) + + else: + await self.send_personal_message({ + "type": "error", + "message": f"Unknown message type: {message_type}" + }, websocket) + + async def _stream_results(self, websocket: WebSocket) -> None: + """Stream processing results to websocket.""" + while websocket in self.active_connections: + try: + result = self.processing_manager.get_next_result(timeout=0.1) + if result: + await self.send_personal_message({ + "type": "processing_result", + "data": result.to_dict() + }, websocket) + + await asyncio.sleep(0.05) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Error streaming results: {e}") + await asyncio.sleep(1.0) diff --git a/vna_system/binary_logs/config_logs/S11/str100_stp8800_pnts1000_bw1khz.bin b/vna_system/binary_logs/config_logs/S11/str100_stp8800_pnts1000_bw1khz.bin new file mode 100644 index 0000000000000000000000000000000000000000..ff27d15a2a0cf61251d006746f3f46ead962ee3a GIT binary patch literal 78742 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 Nm^^Y2M&>GC{(qYi8P)&* literal 0 HcmV?d00001 diff --git a/vna_system/binary_logs/current_log.bin b/vna_system/binary_logs/current_log.bin new file mode 100644 index 0000000000000000000000000000000000000000..ff27d15a2a0cf61251d006746f3f46ead962ee3a GIT binary patch literal 78742 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 Nm^^Y2M&>GC{(qYi8P)&* literal 0 HcmV?d00001 diff --git a/vna_system/config/config.py b/vna_system/config/config.py new file mode 100644 index 0000000..5800627 --- /dev/null +++ b/vna_system/config/config.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +Configuration file for VNA data acquisition system +""" + +import glob +import logging +import serial.tools.list_ports + +# Serial communication settings +DEFAULT_BAUD_RATE = 115200 +DEFAULT_PORT = "/dev/ttyACM0" + +# VNA device identification +VNA_VID = 0x0483 # STMicroelectronics +VNA_PID = 0x5740 # STM32 Virtual ComPort +VNA_MANUFACTURER = "STMicroelectronics" +VNA_PRODUCT = "STM32 Virtual ComPort" +RX_TIMEOUT = 5.0 +TX_CHUNK_SIZE = 64 * 1024 + +# Sweep detection and parsing constants +SWEEP_CMD_LEN = 515 +SWEEP_CMD_PREFIX = bytes([0xAA, 0x00, 0xDA]) +MEAS_HEADER_LEN = 21 +MEAS_CMDS_PER_SWEEP = 17 +EXPECTED_POINTS_PER_SWEEP = 1000 + +# Buffer settings +SWEEP_BUFFER_MAX_SIZE = 100 # Maximum number of sweeps to store in circular buffer +SERIAL_BUFFER_SIZE = 512 * 1024 + +# Log file settings +BIN_LOG_FILE_PATH = "./vna_system/binary_logs/current_log.bin" # Symbolic link to the current log file + +# Binary log format constants +MAGIC = b"VNALOG1\n" +DIR_TO_DEV = 0x01 # '>' +DIR_FROM_DEV = 0x00 # '<' + +# File I/O settings +FILE_CHUNK_SIZE = 256 * 1024 +SERIAL_PEEK_SIZE = 32 + +# Timeout settings +SERIAL_IDLE_TIMEOUT = 0.5 +SERIAL_DRAIN_DELAY = 0.05 +SERIAL_DRAIN_CHECK_DELAY = 0.01 +SERIAL_CONNECT_DELAY = 0.5 + + +def find_vna_port(): + """ + Automatically find VNA device port. + + Returns: + str: Port path (e.g., '/dev/ttyACM1') or None if not found + """ + logger = logging.getLogger(__name__) + + # Method 1: Use pyserial port detection by VID/PID + try: + ports = list(serial.tools.list_ports.comports()) + logger.debug(f"Found {len(ports)} serial ports") + + for port in ports: + logger.debug(f"Checking port {port.device}: VID={port.vid:04X} PID={port.pid:04X} " + f"Manufacturer='{port.manufacturer}' Product='{port.description}'") + + # Check by VID/PID + if port.vid == VNA_VID and port.pid == VNA_PID: + logger.info(f"Found VNA device by VID/PID at {port.device}") + return port.device + + # Fallback: Check by manufacturer/product strings + if (port.manufacturer and VNA_MANUFACTURER.lower() in port.manufacturer.lower() and + port.description and VNA_PRODUCT.lower() in port.description.lower()): + logger.info(f"Found VNA device by description at {port.device}") + return port.device + + except Exception as e: + logger.warning(f"Error during VID/PID port detection: {e}") + + # Method 2: Search ttyACM devices (Linux-specific) + try: + acm_ports = glob.glob('/dev/ttyACM*') + logger.debug(f"Found ACM ports: {acm_ports}") + + if acm_ports: + # Sort to get consistent ordering (ttyACM0, ttyACM1, etc.) + acm_ports.sort() + logger.info(f"Using first available ACM port: {acm_ports[0]}") + return acm_ports[0] + + except Exception as e: + logger.warning(f"Error during ACM port detection: {e}") + + # Method 3: Fallback to default + logger.warning(f"VNA device not found, using default port: {DEFAULT_PORT}") + return DEFAULT_PORT + + +def get_vna_port(): + """ + Get VNA port, trying auto-detection first, then falling back to default. + + Returns: + str: Port path to use for VNA connection + """ + logger = logging.getLogger(__name__) + + try: + port = find_vna_port() + if port and port != DEFAULT_PORT: + logger.info(f"Auto-detected VNA port: {port}") + return port + except Exception as e: + logger.error(f"Port detection failed: {e}") + logger.info(f"Using default port: {DEFAULT_PORT}") + return DEFAULT_PORT \ No newline at end of file diff --git a/vna_system/core/acquisition/data_acquisition.py b/vna_system/core/acquisition/data_acquisition.py new file mode 100644 index 0000000..cc49bd8 --- /dev/null +++ b/vna_system/core/acquisition/data_acquisition.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +import io +import logging +import os +import struct +import threading +import time +from typing import BinaryIO, List, Optional, Tuple + +import serial + +# Avoid wildcard imports: make config access explicit and discoverable. +from vna_system.config import config as cfg +from vna_system.core.acquisition.sweep_buffer import SweepBuffer + + +logger = logging.getLogger(__name__) + + +class VNADataAcquisition: + """Main data acquisition class with asynchronous sweep collection.""" + + def __init__(self, port: str = None, baud: int = cfg.DEFAULT_BAUD_RATE) -> None: + self.bin_log_path: str = cfg.BIN_LOG_FILE_PATH + + # Auto-detect port if not specified + if port is None: + self.port: str = cfg.get_vna_port() + logger.info(f"Using auto-detected port: {self.port}") + else: + self.port: str = port + logger.info(f"Using specified port: {self.port}") + + self.baud: int = baud + + self._sweep_buffer = SweepBuffer() + self.ser: Optional[serial.SerialBase] = None + + # Control flags + self._running: bool = False + self._thread: Optional[threading.Thread] = None + self._stop_event: threading.Event = threading.Event() + + # Sweep collection state + self._collecting: bool = False + self._collected_rx_payloads: List[bytes] = [] + self._meas_cmds_in_sweep: int = 0 + + # --------------------------------------------------------------------- # + # Lifecycle + # --------------------------------------------------------------------- # + + def start(self) -> None: + """Start the data acquisition background thread.""" + if self._running: + logger.debug("Acquisition already running; start() call ignored.") + return + + self._running = True + self._stop_event.clear() + self._thread = threading.Thread(target=self._acquisition_loop, daemon=True) + self._thread.start() + logger.info("Acquisition thread started.") + + def stop(self) -> None: + """Stop the data acquisition background thread.""" + if not self._running: + logger.debug("Acquisition not running; stop() call ignored.") + return + + self._running = False + self._stop_event.set() + + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=5.0) + logger.info("Acquisition thread joined.") + + self._close_serial() + + @property + def is_running(self) -> bool: + """Return True if the acquisition thread is alive.""" + return self._running and (self._thread is not None and self._thread.is_alive()) + + @property + def sweep_buffer(self) -> SweepBuffer: + """Return a reference to the sweep buffer.""" + return self._sweep_buffer + + def get_device_info(self) -> dict: + """Get information about the connected VNA device.""" + info = { + 'port': self.port, + 'baud_rate': self.baud, + 'is_connected': self.ser is not None and self.ser.is_open if self.ser else False, + 'is_running': self.is_running, + 'device_detected': False, + 'device_details': None + } + + # Try to get device details from port detection + try: + from serial.tools.list_ports import comports + for port in comports(): + if port.device == self.port: + info['device_detected'] = True + info['device_details'] = { + 'vid': f"0x{port.vid:04X}" if port.vid else None, + 'pid': f"0x{port.pid:04X}" if port.pid else None, + 'manufacturer': port.manufacturer, + 'product': port.description, + 'serial_number': port.serial_number + } + break + except Exception as e: + logger.debug(f"Could not get device details: {e}") + + return info + + # --------------------------------------------------------------------- # + # Serial management + # --------------------------------------------------------------------- # + + def _connect_serial(self) -> bool: + """Establish the serial connection.""" + try: + logger.info(f"Attempting to connect to VNA device at {self.port} @ {self.baud} baud") + + # Check if port exists + import os + if not os.path.exists(self.port): + logger.error(f"Port {self.port} does not exist. Device not connected?") + return False + + # Check permissions + if not os.access(self.port, os.R_OK | os.W_OK): + logger.error(f"No read/write permissions for {self.port}. Try: sudo chmod 666 {self.port}") + logger.error("Or add your user to the dialout group: sudo usermod -a -G dialout $USER") + return False + + self.ser = serial.Serial(self.port, baudrate=self.baud, timeout=0) + time.sleep(cfg.SERIAL_CONNECT_DELAY) + self._drain_serial_input() + logger.info(f"Serial connection established to {self.port}") + return True + except PermissionError as exc: + logger.error(f"Permission denied for {self.port}: {exc}") + logger.error("Try: sudo chmod 666 {self.port} or add user to dialout group") + return False + except serial.SerialException as exc: + logger.error(f"Serial connection failed for {self.port}: {exc}") + return False + except Exception as exc: # noqa: BLE001 (keep broad to preserve behavior) + logger.error(f"Unexpected error connecting to {self.port}: {exc}") + return False + + def _close_serial(self) -> None: + """Close the serial connection if open.""" + if self.ser: + try: + self.ser.close() + logger.debug("Serial connection closed.") + except Exception: # noqa: BLE001 + # Preserve original behavior: swallow any serial close errors. + logger.debug("Ignoring error while closing serial.", exc_info=True) + finally: + self.ser = None + + def _drain_serial_input(self) -> None: + """Drain any pending bytes from the serial input buffer.""" + if not self.ser: + return + + drained = 0 + while True: + bytes_waiting = getattr(self.ser, "in_waiting", 0) + if bytes_waiting <= 0: + break + drained += len(self.ser.read(bytes_waiting)) + time.sleep(cfg.SERIAL_DRAIN_CHECK_DELAY) + if drained: + logger.debug("Drained %d pending byte(s) from serial input.", drained) + + # --------------------------------------------------------------------- # + # Acquisition loop + # --------------------------------------------------------------------- # + + def _acquisition_loop(self) -> None: + """Main acquisition loop executed by the background thread.""" + while self._running and not self._stop_event.is_set(): + try: + if not self._connect_serial(): + time.sleep(1.0) + continue + + with open(self.bin_log_path, "rb") as raw: + buffered = io.BufferedReader(raw, buffer_size=cfg.SERIAL_BUFFER_SIZE) + self._drain_serial_input() + self._process_sweep_data(buffered) + + except Exception as exc: # noqa: BLE001 + logger.error("Acquisition error: %s", exc) + time.sleep(1.0) + finally: + self._close_serial() + + # --------------------------------------------------------------------- # + # Log processing + # --------------------------------------------------------------------- # + + def _process_sweep_data(self, f: BinaryIO) -> None: + """Process the binary log file and collect sweep data one sweep at a time.""" + while self._running and not self._stop_event.is_set(): + try: + # Start from beginning of file for each sweep + f.seek(0) + + # Validate header + header = self._read_exact(f, len(cfg.MAGIC)) + if header != cfg.MAGIC: + raise ValueError("Invalid log format: MAGIC header mismatch.") + + self._reset_sweep_state() + + # Process one complete sweep + sweep_completed = False + while not sweep_completed and self._running and not self._stop_event.is_set(): + # Read record header + dir_byte = f.read(1) + if not dir_byte: + # EOF reached without completing sweep - wait and retry + logger.debug("EOF reached, waiting for more data...") + time.sleep(0.1) + break + + direction = dir_byte[0] + (length,) = struct.unpack(">I", self._read_exact(f, 4)) + + if direction == cfg.DIR_TO_DEV: + # TX path: stream to device and inspect for sweep start + first = self._serial_write_from_file(f, length) + + if not self._collecting and self._is_sweep_start_command(length, first): + self._collecting = True + self._collected_rx_payloads = [] + self._meas_cmds_in_sweep = 0 + logger.info("Starting sweep data collection from device") + + elif direction == cfg.DIR_FROM_DEV: + # RX path: read exact number of bytes from device + rx_bytes = self._serial_read_exact(length, capture=self._collecting) + self._skip_bytes(f, length) # Keep log file pointer in sync + + if self._collecting: + self._collected_rx_payloads.append(rx_bytes) + self._meas_cmds_in_sweep += 1 + + # Check for sweep completion + if self._meas_cmds_in_sweep >= cfg.MEAS_CMDS_PER_SWEEP: + self._finalize_sweep() + sweep_completed = True + + except Exception as exc: # noqa: BLE001 + logger.error("Processing error: %s", exc) + time.sleep(1.0) + + def _finalize_sweep(self) -> None: + """Parse collected payloads into points and push to the buffer.""" + all_points: List[Tuple[float, float]] = [] + for payload in self._collected_rx_payloads: + all_points.extend(self._parse_measurement_data(payload)) + + if all_points: + sweep_number = self._sweep_buffer.add_sweep(all_points) + logger.info(f"Collected sweep #{sweep_number} with {len(all_points)} data points") + if len(all_points) != cfg.EXPECTED_POINTS_PER_SWEEP: + logger.warning( + "Expected %d points, got %d.", + cfg.EXPECTED_POINTS_PER_SWEEP, + len(all_points), + ) + + self._reset_sweep_state() + + def _reset_sweep_state(self) -> None: + """Reset internal state for the next sweep collection.""" + self._collecting = False + self._collected_rx_payloads = [] + self._meas_cmds_in_sweep = 0 + + # --------------------------------------------------------------------- # + # I/O helpers + # --------------------------------------------------------------------- # + + def _read_exact(self, f: BinaryIO, n: int) -> bytes: + """Read exactly *n* bytes from a file-like object or raise EOFError.""" + buf = bytearray() + while len(buf) < n: + chunk = f.read(n - len(buf)) + if not chunk: + raise EOFError(f"Unexpected EOF while reading {n} bytes.") + buf += chunk + return bytes(buf) + + def _skip_bytes(self, f: BinaryIO, n: int) -> None: + """Skip *n* bytes in a file-like object efficiently.""" + if hasattr(f, "seek"): + try: + f.seek(n, os.SEEK_CUR) + return + except (OSError, io.UnsupportedOperation): + # Fall back to manual skipping below. + pass + + remaining = n + while remaining > 0: + chunk = f.read(min(cfg.FILE_CHUNK_SIZE, remaining)) + if not chunk: + raise EOFError(f"Unexpected EOF while skipping {n} bytes.") + remaining -= len(chunk) + + def _serial_write_from_file(self, f: BinaryIO, nbytes: int) -> bytes: + """ + Stream *nbytes* from a file-like object to the serial port. + + Returns the first few bytes (up to cfg.SERIAL_PEEK_SIZE) for command inspection. + """ + first = bytearray() + remaining = nbytes + + while remaining > 0: + to_read = min(cfg.TX_CHUNK_SIZE, remaining) + chunk = f.read(to_read) + if not chunk: + raise EOFError("Log truncated while sending.") + + # Capture a peek for command inspection + needed = max(0, cfg.SERIAL_PEEK_SIZE - len(first)) + if needed: + first.extend(chunk[:needed]) + + # Write to serial + written = 0 + while written < len(chunk): + if not self.ser: + break + n = self.ser.write(chunk[written:]) + if n is None: + n = 0 + written += n + + remaining -= len(chunk) + + return bytes(first) + + def _serial_read_exact(self, nbytes: int, capture: bool = False) -> bytes: + """Read exactly *nbytes* from the serial port; optionally capture and return them.""" + if not self.ser: + return b"" + + deadline = time.monotonic() + cfg.RX_TIMEOUT + total = 0 + out = bytearray() if capture else None + old_timeout = self.ser.timeout + self.ser.timeout = min(cfg.SERIAL_IDLE_TIMEOUT, cfg.RX_TIMEOUT) + + try: + while total < nbytes: + if time.monotonic() >= deadline: + raise TimeoutError(f"Timeout while waiting for {nbytes} bytes.") + chunk = self.ser.read(nbytes - total) + if chunk: + total += len(chunk) + if capture and out is not None: + out.extend(chunk) + return bytes(out) if (capture and out is not None) else b"" + finally: + self.ser.timeout = old_timeout + + # --------------------------------------------------------------------- # + # Parsing & detection + # --------------------------------------------------------------------- # + + def _parse_measurement_data(self, payload: bytes) -> List[Tuple[float, float]]: + """Parse complex measurement samples (float32 pairs) from a payload.""" + if len(payload) <= cfg.MEAS_HEADER_LEN: + return [] + + data = memoryview(payload)[cfg.MEAS_HEADER_LEN:] + out: List[Tuple[float, float]] = [] + n_pairs = len(data) // 8 # 2 × float32 per point + + for i in range(n_pairs): + off = i * 8 + real = struct.unpack_from(" bool: + """Return True if a TX command indicates the start of a sweep.""" + return tx_len == cfg.SWEEP_CMD_LEN and first_bytes.startswith(cfg.SWEEP_CMD_PREFIX) + diff --git a/vna_system/core/acquisition/sweep_buffer.py b/vna_system/core/acquisition/sweep_buffer.py new file mode 100644 index 0000000..9f45ae8 --- /dev/null +++ b/vna_system/core/acquisition/sweep_buffer.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +""" +Sweep data structures and buffer management for VNA data acquisition. + +This module contains classes for storing and managing sweep data in a thread-safe manner. +""" + +import math +import threading +import time +from collections import deque +from dataclasses import dataclass +from typing import List, Tuple, Optional, Dict, Any + +from vna_system.config.config import SWEEP_BUFFER_MAX_SIZE + + +@dataclass +class SweepData: + """Container for a single sweep with metadata""" + sweep_number: int + timestamp: float + points: List[Tuple[float, float]] # Complex pairs (real, imag) + total_points: int + + @property + def magnitude_phase_data(self) -> List[Tuple[float, float, float, float]]: + """Convert to magnitude/phase representation""" + result = [] + for real, imag in self.points: + magnitude = (real * real + imag * imag) ** 0.5 + phase = math.atan2(imag, real) if (real != 0.0 or imag != 0.0) else 0.0 + result.append((real, imag, magnitude, phase)) + return result + + +class SweepBuffer: + """Thread-safe circular buffer for sweep data""" + + def __init__(self, max_size: int = SWEEP_BUFFER_MAX_SIZE, initial_sweep_number: int = 0): + self._buffer = deque(maxlen=max_size) + self._lock = threading.RLock() + self._sweep_counter = initial_sweep_number + + def add_sweep(self, points: List[Tuple[float, float]]) -> int: + """Add a new sweep to the buffer and return its number""" + with self._lock: + self._sweep_counter += 1 + sweep = SweepData( + sweep_number=self._sweep_counter, + timestamp=time.time(), + points=points, + total_points=len(points) + ) + self._buffer.append(sweep) + return self._sweep_counter + + def get_latest_sweep(self) -> Optional[SweepData]: + """Get the most recent sweep""" + with self._lock: + return self._buffer[-1] if self._buffer else None + + def get_sweep_by_number(self, sweep_number: int) -> Optional[SweepData]: + """Get a specific sweep by its number""" + with self._lock: + for sweep in reversed(self._buffer): + if sweep.sweep_number == sweep_number: + return sweep + return None + + def get_all_sweeps(self) -> List[SweepData]: + """Get all sweeps currently in buffer""" + with self._lock: + return list(self._buffer) + + def get_buffer_info(self) -> Dict[str, Any]: + """Get buffer status information""" + with self._lock: + return { + 'current_size': len(self._buffer), + 'max_size': self._buffer.maxlen, + 'total_sweeps_processed': self._sweep_counter, + 'latest_sweep_number': self._buffer[-1].sweep_number if self._buffer else None, + 'oldest_sweep_number': self._buffer[0].sweep_number if self._buffer else None, + 'next_sweep_number': self._sweep_counter + 1 + } + + def set_sweep_counter(self, sweep_number: int) -> None: + """Set the sweep counter to continue from a specific number.""" + with self._lock: + self._sweep_counter = sweep_number \ No newline at end of file diff --git a/vna_system/core/processing/__init__.py b/vna_system/core/processing/__init__.py new file mode 100644 index 0000000..d678fc3 --- /dev/null +++ b/vna_system/core/processing/__init__.py @@ -0,0 +1 @@ +"""Sweep data processing module.""" \ No newline at end of file diff --git a/vna_system/core/processing/base_processor.py b/vna_system/core/processing/base_processor.py new file mode 100644 index 0000000..14900c0 --- /dev/null +++ b/vna_system/core/processing/base_processor.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +Base sweep processor interface and utilities. +""" + +from __future__ import annotations + +import abc +from typing import Any, Dict, List, Optional + +from vna_system.core.acquisition.sweep_buffer import SweepData + + +class BaseSweepProcessor(abc.ABC): + """Abstract base class for sweep data processors.""" + + def __init__(self, config: Dict[str, Any]) -> None: + """Initialize processor with configuration.""" + self.config = config + self.enabled = config.get("enabled", True) + + @property + @abc.abstractmethod + def name(self) -> str: + """Return processor name.""" + pass + + @abc.abstractmethod + def process(self, sweep: SweepData) -> Optional[ProcessingResult]: + """Process a sweep and return results.""" + pass + + def should_process(self, sweep: SweepData) -> bool: + """Check if this processor should process the given sweep.""" + return self.enabled + + +class ProcessingResult: + """Result of sweep processing.""" + + def __init__( + self, + processor_name: str, + sweep_number: int, + data: Dict[str, Any], + plotly_figure: Optional[Dict[str, Any]] = None, + file_path: Optional[str] = None, + ) -> None: + self.processor_name = processor_name + self.sweep_number = sweep_number + self.data = data + self.plotly_figure = plotly_figure + self.file_path = file_path + + def to_dict(self) -> Dict[str, Any]: + """Convert result to dictionary for serialization.""" + return { + "processor_name": self.processor_name, + "sweep_number": self.sweep_number, + "data": self.data, + "plotly_figure": self.plotly_figure, + "file_path": self.file_path, + } \ No newline at end of file diff --git a/vna_system/core/processing/config.json b/vna_system/core/processing/config.json new file mode 100644 index 0000000..3310563 --- /dev/null +++ b/vna_system/core/processing/config.json @@ -0,0 +1,20 @@ +{ + "processors": { + "magnitude_plot": { + "enabled": true, + "output_dir": "./plots/magnitude", + "save_image": true, + "image_format": "png", + "width": 800, + "height": 600 + } + }, + "processing": { + "queue_max_size": 1000 + }, + "storage": { + "dir": "./processing_results", + "max_sweeps": 10000, + "sweeps_per_subdir": 1000 + } +} \ No newline at end of file diff --git a/vna_system/core/processing/processors/__init__.py b/vna_system/core/processing/processors/__init__.py new file mode 100644 index 0000000..101db2b --- /dev/null +++ b/vna_system/core/processing/processors/__init__.py @@ -0,0 +1 @@ +"""Sweep data processors.""" \ No newline at end of file diff --git a/vna_system/core/processing/processors/magnitude_plot.py b/vna_system/core/processing/processors/magnitude_plot.py new file mode 100644 index 0000000..86f4f45 --- /dev/null +++ b/vna_system/core/processing/processors/magnitude_plot.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +Magnitude plot processor for sweep data. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import matplotlib.pyplot as plt +import matplotlib +matplotlib.use('Agg') # Use non-interactive backend +import plotly.graph_objects as go + +from vna_system.core.acquisition.sweep_buffer import SweepData +from vna_system.core.processing.base_processor import BaseSweepProcessor, ProcessingResult + + +class MagnitudePlotProcessor(BaseSweepProcessor): + """Processor that creates magnitude plots from sweep data.""" + + def __init__(self, config: Dict[str, Any]) -> None: + super().__init__(config) + self.output_dir = Path(config.get("output_dir", "./plots")) + self.save_image = config.get("save_image", True) + self.image_format = config.get("image_format", "png") + self.width = config.get("width", 800) + self.height = config.get("height", 600) + + # Create output directory if it doesn't exist + if self.save_image: + self.output_dir.mkdir(parents=True, exist_ok=True) + + @property + def name(self) -> str: + return "magnitude_plot" + + def process(self, sweep: SweepData) -> Optional[ProcessingResult]: + """Process sweep data and create magnitude plot.""" + if not self.should_process(sweep): + return None + + # Extract magnitude data + magnitude_data = self._extract_magnitude_data(sweep) + if not magnitude_data: + return None + + # Create plotly figure for websocket/API consumption + plotly_fig = self._create_plotly_figure(sweep, magnitude_data) + + # Save image if requested (using matplotlib) + file_path = None + if self.save_image: + file_path = self._save_matplotlib_image(sweep, magnitude_data) + + # Prepare result data + result_data = { + "sweep_number": sweep.sweep_number, + "timestamp": sweep.timestamp, + "total_points": sweep.total_points, + "magnitude_stats": self._calculate_magnitude_stats(magnitude_data), + } + + return ProcessingResult( + processor_name=self.name, + sweep_number=sweep.sweep_number, + data=result_data, + plotly_figure=plotly_fig.to_dict(), + file_path=str(file_path) if file_path else None, + ) + + def _extract_magnitude_data(self, sweep: SweepData) -> List[Tuple[float, float]]: + """Extract magnitude data from sweep points.""" + magnitude_data = [] + for i, (real, imag) in enumerate(sweep.points): + magnitude = np.sqrt(real**2 + imag**2) + magnitude_data.append((i, magnitude)) + return magnitude_data + + def _create_plotly_figure(self, sweep: SweepData, magnitude_data: List[Tuple[float, float]]) -> go.Figure: + """Create plotly figure for magnitude plot.""" + indices = [point[0] for point in magnitude_data] + magnitudes = [point[1] for point in magnitude_data] + + fig = go.Figure() + + fig.add_trace(go.Scatter( + x=indices, + y=magnitudes, + mode='lines', + name='Magnitude', + line=dict(color='blue', width=2) + )) + + fig.update_layout( + title=f'Magnitude Plot - Sweep #{sweep.sweep_number}', + xaxis_title='Point Index', + yaxis_title='Magnitude', + width=self.width, + height=self.height, + template='plotly_white', + showlegend=False + ) + + return fig + + def _save_matplotlib_image(self, sweep: SweepData, magnitude_data: List[Tuple[float, float]]) -> Optional[Path]: + """Save plot as image file using matplotlib.""" + try: + filename = f"magnitude_sweep_{sweep.sweep_number:06d}.{self.image_format}" + file_path = self.output_dir / filename + + # Extract data for plotting + indices = [point[0] for point in magnitude_data] + magnitudes = [point[1] for point in magnitude_data] + + # Create matplotlib figure + fig, ax = plt.subplots(figsize=(self.width/100, self.height/100), dpi=100) + ax.plot(indices, magnitudes, 'b-', linewidth=2, label='Magnitude') + + ax.set_title(f'Magnitude Plot - Sweep #{sweep.sweep_number}') + ax.set_xlabel('Point Index') + ax.set_ylabel('Magnitude') + ax.legend() + ax.grid(True, alpha=0.3) + + # Add info text + info_text = f'Timestamp: {sweep.timestamp:.3f}s\nTotal Points: {sweep.total_points}' + ax.text(0.02, 0.98, info_text, transform=ax.transAxes, + verticalalignment='top', bbox=dict(boxstyle='round', facecolor='white', alpha=0.8)) + + # Save figure + plt.savefig(str(file_path), bbox_inches='tight', dpi=100) + plt.close(fig) + + return file_path + except Exception as e: + print(f"Failed to save matplotlib image: {e}") + return None + + def _calculate_magnitude_stats(self, magnitude_data: List[Tuple[float, float]]) -> Dict[str, float]: + """Calculate basic statistics for magnitude data.""" + if not magnitude_data: + return {} + + magnitudes = [point[1] for point in magnitude_data] + magnitudes_array = np.array(magnitudes) + + return { + "min": float(np.min(magnitudes_array)), + "max": float(np.max(magnitudes_array)), + "mean": float(np.mean(magnitudes_array)), + "std": float(np.std(magnitudes_array)), + "median": float(np.median(magnitudes_array)), + } \ No newline at end of file diff --git a/vna_system/core/processing/results_storage.py b/vna_system/core/processing/results_storage.py new file mode 100644 index 0000000..aa125ef --- /dev/null +++ b/vna_system/core/processing/results_storage.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import json +import logging +import threading +from pathlib import Path +from typing import Any, Dict, List, Optional + +from vna_system.core.processing.base_processor import ProcessingResult + + +logger = logging.getLogger(__name__) + + +class ResultsStorage: + """Sweep-based file storage for processing results.""" + + def __init__( + self, + storage_dir: str = "processing_results", + max_sweeps: int = 10000, + sweeps_per_subdir: int = 1000 + ) -> None: + self.storage_dir = Path(storage_dir) + self.max_sweeps = max_sweeps + self.sweeps_per_subdir = sweeps_per_subdir + + # Thread safety + self._lock = threading.RLock() + + # Create storage directory + self.storage_dir.mkdir(parents=True, exist_ok=True) + + # In-memory cache for quick lookup: {sweep_number: {processor_name: file_path, ...}} + self._cache: Dict[int, Dict[str, str]] = {} + self._build_cache() + + def _build_cache(self) -> None: + """Build in-memory cache by scanning storage directory.""" + logger.info("Building results cache...") + self._cache = {} + + # Scan all sweep subdirectories + for subdir in self.storage_dir.iterdir(): + if subdir.is_dir() and subdir.name.startswith("sweeps_"): + # Scan sweep directories within subdirectory + for sweep_dir in subdir.iterdir(): + if sweep_dir.is_dir() and sweep_dir.name.startswith("sweep_"): + try: + sweep_number = int(sweep_dir.name.split("_")[1]) + self._cache[sweep_number] = {} + + # Find all processor files in this sweep + for result_file in sweep_dir.glob("*.json"): + if result_file.name != "metadata.json": + processor_name = result_file.stem + self._cache[sweep_number][processor_name] = str(result_file) + + except (ValueError, IndexError): + logger.warning(f"Invalid sweep directory name: {sweep_dir.name}") + + logger.info(f"Built cache with {len(self._cache)} sweeps") + + def _get_sweep_dir(self, sweep_number: int) -> Path: + """Get directory path for specific sweep.""" + # Group sweeps in subdirectories to avoid too many directories + subdir_index = sweep_number // self.sweeps_per_subdir + subdir_name = f"sweeps_{subdir_index:03d}_{(subdir_index + 1) * self.sweeps_per_subdir - 1:06d}" + + sweep_name = f"sweep_{sweep_number:06d}" + sweep_dir = self.storage_dir / subdir_name / sweep_name + sweep_dir.mkdir(parents=True, exist_ok=True) + return sweep_dir + + def _get_result_file_path(self, sweep_number: int, processor_name: str) -> Path: + """Get file path for specific sweep and processor result.""" + sweep_dir = self._get_sweep_dir(sweep_number) + return sweep_dir / f"{processor_name}.json" + + def _get_metadata_file_path(self, sweep_number: int) -> Path: + """Get metadata file path for specific sweep.""" + sweep_dir = self._get_sweep_dir(sweep_number) + return sweep_dir / "metadata.json" + + def store_result(self, result: ProcessingResult, overwrite: bool = False) -> None: + """Store a processing result.""" + with self._lock: + sweep_number = result.sweep_number + processor_name = result.processor_name + + # Get file paths + result_file = self._get_result_file_path(sweep_number, processor_name) + metadata_file = self._get_metadata_file_path(sweep_number) + + # Skip if result file already exists (don't overwrite unless forced) + if result_file.exists() and not overwrite: # SHOULD NOT HAPPEND + logger.debug(f"Result file already exists, skipping: {result_file}") + # Still update cache for existing file + if sweep_number not in self._cache: + self._cache[sweep_number] = {} + self._cache[sweep_number][processor_name] = str(result_file) + return + + try: + # Store processor result + with open(result_file, 'w') as f: + json.dump(result.to_dict(), f, indent=2) + + # Update/create metadata + metadata = {} + if metadata_file.exists(): + try: + with open(metadata_file, 'r') as f: + metadata = json.load(f) + except Exception as e: + logger.warning(f"Failed to read metadata: {e}") + + # Update metadata + metadata.update({ + "sweep_number": sweep_number, + "timestamp": result.data.get("timestamp", 0), + "total_points": result.data.get("total_points", 0), + "processors": metadata.get("processors", []) + }) + + # Add processor to list if not already there + if processor_name not in metadata["processors"]: + metadata["processors"].append(processor_name) + + # Save metadata + with open(metadata_file, 'w') as f: + json.dump(metadata, f, indent=2) + + # Update cache + if sweep_number not in self._cache: + self._cache[sweep_number] = {} + + self._cache[sweep_number][processor_name] = str(result_file) + + # Cleanup old sweeps if needed + self._cleanup_old_sweeps() + + logger.debug(f"Stored result for sweep {sweep_number}, processor {processor_name}") + + except Exception as e: + logger.error(f"Failed to store result: {e}") + + def _cleanup_old_sweeps(self) -> None: + """Remove old sweeps if we exceed the limit.""" + if len(self._cache) > self.max_sweeps: + # Sort by sweep number and remove oldest + sorted_sweeps = sorted(self._cache.keys()) + sweeps_to_remove = sorted_sweeps[:len(sorted_sweeps) - self.max_sweeps] + + for sweep_number in sweeps_to_remove: + try: + sweep_dir = self._get_sweep_dir(sweep_number) + + # Remove all files in sweep directory + if sweep_dir.exists(): + for file in sweep_dir.iterdir(): + file.unlink() + sweep_dir.rmdir() + logger.debug(f"Removed sweep directory: {sweep_dir}") + + # Remove from cache + del self._cache[sweep_number] + + except Exception as e: + logger.error(f"Failed to remove sweep {sweep_number}: {e}") + + def get_latest_results(self, processor_name: Optional[str] = None, limit: int = 10) -> List[ProcessingResult]: + """Get latest processing results.""" + with self._lock: + results = [] + + # Get latest sweep numbers + sorted_sweeps = sorted(self._cache.keys(), reverse=True) + + for sweep_number in sorted_sweeps[:limit * 2]: # Get more sweeps to find enough results + if len(results) >= limit: + break + + sweep_results = self.get_result_by_sweep(sweep_number, processor_name) + results.extend(sweep_results) + + return results[:limit] + + def get_result_by_sweep(self, sweep_number: int, processor_name: Optional[str] = None) -> List[ProcessingResult]: + """Get processing results for specific sweep number.""" + with self._lock: + results = [] + + if sweep_number not in self._cache: + return results + + sweep_processors = self._cache[sweep_number] + + # Filter by processor name if specified + if processor_name: + if processor_name in sweep_processors: + processors_to_load = [processor_name] + else: + processors_to_load = [] + else: + processors_to_load = list(sweep_processors.keys()) + + # Load results for each processor + for proc_name in processors_to_load: + file_path = Path(sweep_processors[proc_name]) + try: + if file_path.exists(): + with open(file_path, 'r') as f: + result_data = json.load(f) + + result = ProcessingResult( + processor_name=result_data["processor_name"], + sweep_number=result_data["sweep_number"], + data=result_data["data"], + plotly_figure=result_data.get("plotly_figure"), + file_path=result_data.get("file_path") + ) + results.append(result) + + except Exception as e: + logger.error(f"Failed to load result from {file_path}: {e}") + + return results + + def get_sweep_metadata(self, sweep_number: int) -> Optional[Dict[str, Any]]: + """Get metadata for specific sweep.""" + with self._lock: + if sweep_number not in self._cache: + return None + + metadata_file = self._get_metadata_file_path(sweep_number) + try: + if metadata_file.exists(): + with open(metadata_file, 'r') as f: + return json.load(f) + except Exception as e: + logger.error(f"Failed to read metadata for sweep {sweep_number}: {e}") + + return None + + def get_available_sweeps(self, limit: Optional[int] = None) -> List[int]: + """Get list of available sweep numbers.""" + with self._lock: + sorted_sweeps = sorted(self._cache.keys(), reverse=True) + if limit: + return sorted_sweeps[:limit] + return sorted_sweeps + + def get_processors_for_sweep(self, sweep_number: int) -> List[str]: + """Get list of processors that have results for specific sweep.""" + with self._lock: + if sweep_number in self._cache: + return list(self._cache[sweep_number].keys()) + return [] + + def get_storage_stats(self) -> Dict[str, Any]: + """Get storage statistics.""" + with self._lock: + if not self._cache: + return { + "storage_dir": str(self.storage_dir), + "total_sweeps": 0, + "processors": {}, + "oldest_sweep": None, + "newest_sweep": None + } + + # Calculate processor statistics + processor_counts = {} + for sweep_processors in self._cache.values(): + for processor_name in sweep_processors.keys(): + processor_counts[processor_name] = processor_counts.get(processor_name, 0) + 1 + + sorted_sweeps = sorted(self._cache.keys()) + + return { + "storage_dir": str(self.storage_dir), + "total_sweeps": len(self._cache), + "processors": processor_counts, + "oldest_sweep": sorted_sweeps[0] if sorted_sweeps else None, + "newest_sweep": sorted_sweeps[-1] if sorted_sweeps else None, + "storage_structure": "sweep-based" + } + + def get_max_sweep_number(self) -> int: + """Get the maximum sweep number currently stored.""" + with self._lock: + if not self._cache: + return 0 + return max(self._cache.keys()) \ No newline at end of file diff --git a/vna_system/core/processing/sweep_processor.py b/vna_system/core/processing/sweep_processor.py new file mode 100644 index 0000000..026db98 --- /dev/null +++ b/vna_system/core/processing/sweep_processor.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import json +import logging +import queue +import threading +import time +from typing import Any, Dict, List, Optional + +from vna_system.core.acquisition.sweep_buffer import SweepBuffer, SweepData +from vna_system.core.processing.base_processor import BaseSweepProcessor, ProcessingResult +from vna_system.core.processing.processors.magnitude_plot import MagnitudePlotProcessor +from vna_system.core.processing.results_storage import ResultsStorage + + +logger = logging.getLogger(__name__) + + +class SweepProcessingManager: + """Manager for sweep data processing with configurable processors and queue-based results.""" + + def __init__(self, config_path: str = "vna_system/core/processing/config.json") -> None: + self.processors: List[BaseSweepProcessor] = [] + self.config: Dict[str, Any] = {} + self.sweep_buffer: Optional[SweepBuffer] = None + + # Processing control + self._running = False + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._last_processed_sweep = 0 + + # Results queue for websocket consumption + self.results_queue: queue.Queue[ProcessingResult] = queue.Queue(maxsize=10) + + # Load configuration first + self.load_config(config_path) + + # File-based results storage (after config is loaded) + storage_config = self.config.get("storage", {}) + self.results_storage = ResultsStorage( + storage_dir=storage_config.get("dir", "processing_results"), + max_sweeps=storage_config.get("max_sweeps", 10000), + sweeps_per_subdir=storage_config.get("sweeps_per_subdir", 1000) + ) + + + def load_config(self, config_path: str) -> None: + """Load processing configuration from JSON file.""" + try: + with open(config_path, 'r') as f: + self.config = json.load(f) + self._initialize_processors() + logger.info(f"Loaded processing config from {config_path}") + except Exception as e: + logger.error(f"Failed to load config from {config_path}: {e}") + + def _initialize_processors(self) -> None: + """Initialize processors based on configuration.""" + self.processors.clear() + + processor_configs = self.config.get("processors", {}) + + # Initialize magnitude plot processor + if "magnitude_plot" in processor_configs: + magnitude_config = processor_configs["magnitude_plot"] + self.processors.append(MagnitudePlotProcessor(magnitude_config)) + + logger.info(f"Initialized {len(self.processors)} processors") + + + def set_sweep_buffer(self, sweep_buffer: SweepBuffer) -> None: + """Set the sweep buffer to process data from.""" + self.sweep_buffer = sweep_buffer + + # Initialize sweep counter to continue from existing data + try: + max_sweep = self.results_storage.get_max_sweep_number() + if max_sweep > 0: + self.sweep_buffer.set_sweep_counter(max_sweep) + self._last_processed_sweep = max_sweep # Sync our tracking + logger.info(f"Set sweep buffer to continue from sweep #{max_sweep + 1}") + else: + logger.info("Sweep buffer starts from #1") + except Exception as e: + logger.warning(f"Failed to set sweep counter: {e}") + + def start(self) -> None: + """Start the processing thread.""" + if self._running: + logger.debug("Processing already running; start() call ignored.") + return + + if not self.sweep_buffer: + logger.error("Cannot start processing without sweep buffer") + return + + self._running = True + self._stop_event.clear() + self._thread = threading.Thread(target=self._processing_loop, daemon=True) + self._thread.start() + logger.info("Sweep processing started") + + def stop(self) -> None: + """Stop the processing thread.""" + if not self._running: + logger.debug("Processing not running; stop() call ignored.") + return + + self._running = False + self._stop_event.set() + + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=5.0) + logger.info("Processing thread joined") + + @property + def is_running(self) -> bool: + """Check if processing is currently running.""" + return self._running and (self._thread is not None and self._thread.is_alive()) + + def _processing_loop(self) -> None: + """Main processing loop executed by the background thread.""" + while self._running and not self._stop_event.is_set(): + try: + if not self.sweep_buffer: + time.sleep(0.1) + continue + + # Get latest sweep + latest_sweep = self.sweep_buffer.get_latest_sweep() + if not latest_sweep: + time.sleep(0.1) + continue + + # Process new sweeps + if latest_sweep.sweep_number > self._last_processed_sweep: + self._process_sweep(latest_sweep) + self._last_processed_sweep = latest_sweep.sweep_number + + time.sleep(0.05) # Small delay to avoid busy waiting + + except Exception as e: + logger.error(f"Processing loop error: {e}") + time.sleep(1.0) + + def _process_sweep(self, sweep: SweepData) -> None: + """Process a single sweep with all configured processors.""" + logger.debug(f"Processing sweep #{sweep.sweep_number}") + + for processor in self.processors: + try: + result = processor.process(sweep) + if result: + self._store_result(result) + self._queue_result(result) + logger.debug(f"Processed sweep #{sweep.sweep_number} with {processor.name}") + except Exception as e: + logger.error(f"Error in processor {processor.name}: {e}") + + def _store_result(self, result: ProcessingResult) -> None: + """Store processing result in file storage.""" + self.results_storage.store_result(result) + + def _queue_result(self, result: ProcessingResult) -> None: + """Queue processing result for websocket consumption.""" + try: + self.results_queue.put_nowait(result) + except queue.Full: + logger.warning("Results queue is full, dropping oldest result") + try: + self.results_queue.get_nowait() # Remove oldest + self.results_queue.put_nowait(result) # Add new + except queue.Empty: + pass + + def get_next_result(self, timeout: Optional[float] = None) -> Optional[ProcessingResult]: + """Get next processing result from queue. Used by websocket handler.""" + try: + return self.results_queue.get(timeout=timeout) + except queue.Empty: + return None + + def get_pending_results_count(self) -> int: + """Get number of pending results in queue.""" + return self.results_queue.qsize() + + def drain_results_queue(self) -> List[ProcessingResult]: + """Drain all pending results from queue. Used for batch processing.""" + results = [] + while True: + try: + result = self.results_queue.get_nowait() + results.append(result) + except queue.Empty: + break + return results + + def get_latest_results(self, processor_name: Optional[str] = None, limit: int = 10) -> List[ProcessingResult]: + """Get latest processing results from storage, optionally filtered by processor name.""" + return self.results_storage.get_latest_results(processor_name, limit) + + def get_result_by_sweep(self, sweep_number: int, processor_name: Optional[str] = None) -> List[ProcessingResult]: + """Get processing results for a specific sweep number from storage.""" + return self.results_storage.get_result_by_sweep(sweep_number, processor_name) + + def add_processor(self, processor: BaseSweepProcessor) -> None: + """Add a processor manually.""" + self.processors.append(processor) + logger.info(f"Added processor: {processor.name}") + + def remove_processor(self, processor_name: str) -> bool: + """Remove a processor by name.""" + for i, processor in enumerate(self.processors): + if processor.name == processor_name: + del self.processors[i] + logger.info(f"Removed processor: {processor_name}") + return True + return False + + def get_processing_stats(self) -> Dict[str, Any]: + """Get processing statistics.""" + storage_stats = self.results_storage.get_storage_stats() + return { + "is_running": self.is_running, + "processors_count": len(self.processors), + "processor_names": [p.name for p in self.processors], + "last_processed_sweep": self._last_processed_sweep, + "results_queue_size": self.results_queue.qsize(), + "storage_stats": storage_stats, + } \ No newline at end of file diff --git a/vna_system/core/singletons.py b/vna_system/core/singletons.py new file mode 100644 index 0000000..a5393e6 --- /dev/null +++ b/vna_system/core/singletons.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +""" +Global singleton instances for the VNA system. + +This module centralizes all singleton instances to avoid global variables +scattered throughout the codebase. +""" +from vna_system.core.acquisition.data_acquisition import VNADataAcquisition +from vna_system.core.processing.sweep_processor import SweepProcessingManager +from vna_system.api.websockets.websocket_handler import WebSocketManager + +# Global singleton instances +vna_data_acquisition_instance: VNADataAcquisition = VNADataAcquisition() +processing_manager: SweepProcessingManager = SweepProcessingManager() +websocket_manager: WebSocketManager = WebSocketManager(processing_manager) \ No newline at end of file diff --git a/vna_system/scripts/__init__.py b/vna_system/scripts/__init__.py new file mode 100644 index 0000000..55dbf87 --- /dev/null +++ b/vna_system/scripts/__init__.py @@ -0,0 +1 @@ +"""VNA System scripts module.""" \ No newline at end of file diff --git a/vna_system/scripts/start.sh b/vna_system/scripts/start.sh new file mode 100755 index 0000000..097af56 --- /dev/null +++ b/vna_system/scripts/start.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# +# VNA System Startup Script +# +# This script starts the VNA System API server with proper environment setup +# + +set -e # Exit on any error + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Logging functions +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if we're in the right directory +if [ ! -f "$PROJECT_ROOT/vna_system/api/main.py" ]; then + log_error "VNA System main.py not found. Please run this script from the project directory." + exit 1 +fi + +# Change to project root +cd "$PROJECT_ROOT" + +log_info "Starting VNA System from: $PROJECT_ROOT" + +# Check Python version +if ! command -v python3 &> /dev/null; then + log_error "Python 3 is not installed or not in PATH" + exit 1 +fi + +PYTHON_VERSION=$(python3 --version 2>&1 | cut -d' ' -f2) +log_info "Using Python version: $PYTHON_VERSION" + +# Check if virtual environment exists +if [ -d "venv" ] || [ -d ".venv" ]; then + if [ -d "venv" ]; then + VENV_PATH="venv" + else + VENV_PATH=".venv" + fi + + log_info "Found virtual environment at: $VENV_PATH" + log_info "Activating virtual environment..." + + # Activate virtual environment + source "$VENV_PATH/bin/activate" + log_success "Virtual environment activated" +else + log_warning "No virtual environment found. Using system Python." + log_warning "Consider creating a virtual environment: python3 -m venv venv" +fi + +# Check configuration files +if [ ! -f "vna_system/api/api_config.json" ]; then + log_warning "API config not found. Using defaults." +fi + +if [ ! -f "vna_system/core/processing/config.json" ]; then + log_warning "Processing config not found. Some processors may not work." +fi + +# Start the server +log_info "Starting VNA System API server..." +log_info "Press Ctrl+C to stop the server" +echo + +# Run the main application +exec python3 -m vna_system.api.main \ No newline at end of file diff --git a/vna_system/web_ui/static/assets/favicon.svg b/vna_system/web_ui/static/assets/favicon.svg new file mode 100644 index 0000000..94949c4 --- /dev/null +++ b/vna_system/web_ui/static/assets/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/vna_system/web_ui/static/css/charts.css b/vna_system/web_ui/static/css/charts.css new file mode 100644 index 0000000..14c3f7a --- /dev/null +++ b/vna_system/web_ui/static/css/charts.css @@ -0,0 +1,242 @@ +/* Chart-specific styles */ +.plotly-chart { + background-color: transparent !important; +} + +/* Custom Plotly theme overrides */ +.js-plotly-plot .plotly { + background-color: transparent !important; +} + +/* Plotly modebar styling - simplified to ensure visibility */ +.modebar { + background-color: rgba(51, 65, 85, 0.9) !important; + border: 1px solid rgba(71, 85, 105, 0.8) !important; + border-radius: 8px !important; + padding: 4px !important; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3) !important; + z-index: 1000 !important; + position: relative !important; +} + +.modebar-btn { + background-color: transparent !important; + border: none !important; + border-radius: 4px !important; + color: #cbd5e1 !important; + padding: 3px !important; + margin: 1px !important; + width: 24px !important; + height: 24px !important; + cursor: pointer !important; +} + +.modebar-btn:hover { + background-color: rgba(71, 85, 105, 0.8) !important; + color: #f1f5f9 !important; +} + +.modebar-btn.active { + background-color: #3b82f6 !important; + color: white !important; +} + +/* Fullscreen chart styles */ +.chart-card:fullscreen { + width: 100vw !important; + height: 100vh !important; + max-width: none !important; + max-height: none !important; + background-color: var(--bg-primary) !important; + border: none !important; + border-radius: 0 !important; + display: flex !important; + flex-direction: column !important; +} + +.chart-card:fullscreen .chart-card__header { + flex-shrink: 0; + padding: var(--space-3) var(--space-4); +} + +.chart-card:fullscreen .chart-card__content { + flex: 1; + display: flex; + flex-direction: column; + padding: var(--space-2); + min-height: 0; +} + +.chart-card:fullscreen .chart-card__plot { + flex: 1 !important; + width: 100% !important; + height: 100% !important; + min-height: 0 !important; +} + +.chart-card:fullscreen .chart-card__plot .js-plotly-plot { + width: 100% !important; + height: 100% !important; +} + +.chart-card:fullscreen .chart-card__plot .plotly .svg-container { + width: 100% !important; + height: 100% !important; +} + +.chart-card:fullscreen .chart-card__meta { + flex-shrink: 0; + padding: var(--space-2) var(--space-4); +} + +/* Chart loading state */ +.chart-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; + flex-direction: column; + gap: var(--space-4); +} + +.chart-loading__spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border-primary); + border-top: 3px solid var(--color-primary-500); + border-radius: var(--radius-full); + animation: spin 1s linear infinite; +} + +.chart-loading__text { + font-size: var(--font-size-sm); + color: var(--text-tertiary); +} + +/* Chart error state */ +.chart-error { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; + flex-direction: column; + gap: var(--space-3); + text-align: center; + padding: var(--space-6); +} + +.chart-error__icon { + width: 48px; + height: 48px; + color: var(--color-error-500); +} + +.chart-error__title { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.chart-error__message { + font-size: var(--font-size-sm); + color: var(--text-secondary); + max-width: 300px; + line-height: var(--line-height-relaxed); +} + +/* Chart animations */ +@keyframes chartFadeIn { + from { + opacity: 0; + transform: translateY(20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.chart-card--animated { + animation: chartFadeIn 0.5s ease; +} + +/* Plotly theme customization */ +.chart-card .js-plotly-plot { + border-radius: 0; + overflow: hidden; +} + +/* Custom scrollbar for chart containers */ +.chart-card__content::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.chart-card__content::-webkit-scrollbar-track { + background-color: var(--bg-primary); + border-radius: var(--radius-full); +} + +.chart-card__content::-webkit-scrollbar-thumb { + background-color: var(--border-secondary); + border-radius: var(--radius-full); + border: 1px solid var(--bg-primary); +} + +.chart-card__content::-webkit-scrollbar-thumb:hover { + background-color: var(--text-tertiary); +} + +/* Responsive chart adjustments */ +@media (max-width: 768px) { + .chart-card__plot { + height: 320px !important; + min-height: 300px; + } + + .chart-card__content { + min-height: 350px; + } + + .chart-loading, + .chart-error { + min-height: 320px; + } +} + +/* Chart grid responsive behavior */ +@media (max-width: 1200px) { + .charts-grid { + grid-template-columns: 1fr; + } +} + +@media (min-width: 1400px) { + .charts-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 1800px) { + .charts-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +/* Print styles */ +@media print { + .chart-card__header, + .chart-card__meta, + .modebar { + display: none !important; + } + + .chart-card { + break-inside: avoid; + page-break-inside: avoid; + } + + .chart-card__content { + padding: 0; + } +} \ No newline at end of file diff --git a/vna_system/web_ui/static/css/components.css b/vna_system/web_ui/static/css/components.css new file mode 100644 index 0000000..c6fe495 --- /dev/null +++ b/vna_system/web_ui/static/css/components.css @@ -0,0 +1,461 @@ +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-2-5) var(--space-4); + border-radius: var(--radius-lg); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + line-height: 1; + text-decoration: none; + cursor: pointer; + transition: all var(--transition-fast); + border: 1px solid transparent; + white-space: nowrap; +} + +.btn--sm { + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-xs); +} + +.btn--lg { + padding: var(--space-3) var(--space-6); + font-size: var(--font-size-base); +} + +/* Button Variants */ +.btn--primary { + background-color: var(--color-primary-600); + color: white; +} + +.btn--primary:hover { + background-color: var(--color-primary-700); +} + +.btn--bordered { + border: 2px solid currentColor; + box-shadow: var(--shadow-sm); +} + +.btn--primary.btn--bordered { + border-color: var(--color-primary-500); + box-shadow: 0 0 0 1px var(--color-primary-600), var(--shadow-sm); +} + +.btn--primary.btn--bordered:hover { + box-shadow: 0 0 0 1px var(--color-primary-500), var(--shadow-md); + transform: translateY(-1px); +} + +.btn--secondary { + background-color: var(--bg-tertiary); + color: var(--text-primary); + border-color: var(--border-primary); +} + +.btn--secondary:hover { + background-color: var(--bg-surface-hover); +} + +.btn--ghost { + background-color: transparent; + color: var(--text-secondary); +} + +.btn--ghost:hover { + background-color: var(--bg-surface-hover); + color: var(--text-primary); +} + +.btn i { + width: 14px; + height: 14px; +} + +.btn--sm i { + width: 12px; + height: 12px; +} + +.btn--lg i { + width: 16px; + height: 16px; +} + +/* Processor Toggles */ +.processor-toggles { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.processor-toggle { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background-color: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + cursor: pointer; + transition: all var(--transition-fast); +} + +.processor-toggle:hover { + background-color: var(--bg-surface-hover); +} + +.processor-toggle--active { + background-color: var(--color-primary-900); + border-color: var(--color-primary-600); + color: var(--color-primary-300); +} + +.processor-toggle__checkbox { + position: relative; + width: 16px; + height: 16px; + border: 2px solid var(--border-secondary); + border-radius: var(--radius-sm); + transition: all var(--transition-fast); +} + +.processor-toggle--active .processor-toggle__checkbox { + background-color: var(--color-primary-500); + border-color: var(--color-primary-500); +} + +.processor-toggle__checkbox::after { + content: ''; + position: absolute; + top: 1px; + left: 4px; + width: 4px; + height: 8px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + opacity: 0; + transition: opacity var(--transition-fast); +} + +.processor-toggle--active .processor-toggle__checkbox::after { + opacity: 1; +} + +.processor-toggle__label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--text-secondary); + transition: color var(--transition-fast); +} + +.processor-toggle--active .processor-toggle__label { + color: var(--color-primary-300); +} + +.processor-toggle__count { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + background-color: var(--bg-primary); + padding: var(--space-0-5) var(--space-1-5); + border-radius: var(--radius-full); + min-width: 20px; + text-align: center; +} + +/* Display Controls */ +.display-controls { + display: flex; + gap: var(--space-2); +} + +/* Connection Info */ +.connection-info { + display: flex; + gap: var(--space-4); +} + +.info-item { + display: flex; + flex-direction: column; + gap: var(--space-1); + align-items: center; +} + +.info-label { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.info-value { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + font-family: var(--font-mono); +} + +/* Chart Card */ +.chart-card { + background-color: var(--bg-surface); + border: 1px solid var(--border-primary); + border-radius: var(--radius-xl); + overflow: visible; + transition: all var(--transition-fast); +} + +.chart-card:hover { + border-color: var(--border-secondary); + box-shadow: var(--shadow-md); +} + +.chart-card--hidden { + display: none; +} + +.chart-card__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4) var(--space-5); + background-color: var(--bg-tertiary); + border-bottom: 1px solid var(--border-primary); +} + +.chart-card__title { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.chart-card__icon { + width: 18px; + height: 18px; + color: var(--color-primary-500); +} + +.chart-card__actions { + display: flex; + gap: var(--space-1); +} + +.chart-card__action { + padding: var(--space-1-5); + border-radius: var(--radius-default); + color: var(--text-tertiary); + transition: all var(--transition-fast); + opacity: 0.6; +} + +.chart-card__action:hover { + color: var(--text-primary); + background-color: var(--bg-surface-hover); + opacity: 1; +} + +.chart-card:hover .chart-card__action { + opacity: 0.8; +} + +.chart-card__action i { + width: 14px; + height: 14px; +} + +.chart-card__content { + position: relative; + padding: var(--space-4); + min-height: 450px; +} + +.chart-card__plot { + width: 100% !important; + height: 420px !important; + min-height: 400px; + position: relative; +} + +.chart-card__plot .js-plotly-plot { + width: 100% !important; + height: 100% !important; +} + +.chart-card__plot .plotly .svg-container { + width: 100% !important; + height: 100% !important; +} + +.chart-card__meta { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-3) var(--space-5); + background-color: var(--bg-primary); + border-top: 1px solid var(--border-primary); + font-size: var(--font-size-xs); + color: var(--text-tertiary); +} + +.chart-card__timestamp { + font-family: var(--font-mono); +} + +.chart-card__sweep { + font-family: var(--font-mono); + font-weight: var(--font-weight-medium); +} + +/* Notifications */ +.notifications { + position: fixed; + top: var(--space-4); + right: var(--space-4); + z-index: var(--z-toast); + display: flex; + flex-direction: column; + gap: var(--space-2); + pointer-events: none; +} + +.notification { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-4) var(--space-5); + background-color: var(--bg-surface); + border: 1px solid var(--border-primary); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + min-width: 320px; + max-width: 480px; + pointer-events: auto; + animation: slideInRight 0.3s ease; +} + +.notification--success { + border-left: 4px solid var(--color-success-500); +} + +.notification--error { + border-left: 4px solid var(--color-error-500); +} + +.notification--warning { + border-left: 4px solid var(--color-warning-500); +} + +.notification--info { + border-left: 4px solid var(--color-primary-500); +} + +.notification__icon { + width: 20px; + height: 20px; + margin-top: var(--space-0-5); + flex-shrink: 0; +} + +.notification--success .notification__icon { + color: var(--color-success-500); +} + +.notification--error .notification__icon { + color: var(--color-error-500); +} + +.notification--warning .notification__icon { + color: var(--color-warning-500); +} + +.notification--info .notification__icon { + color: var(--color-primary-500); +} + +.notification__content { + flex: 1; + min-width: 0; +} + +.notification__title { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + margin-bottom: var(--space-1); +} + +.notification__message { + font-size: var(--font-size-sm); + color: var(--text-secondary); + line-height: var(--line-height-relaxed); +} + +.notification__close { + padding: var(--space-1); + border-radius: var(--radius-default); + color: var(--text-tertiary); + transition: all var(--transition-fast); + margin: calc(-1 * var(--space-1)) calc(-1 * var(--space-2)) calc(-1 * var(--space-1)) 0; +} + +.notification__close:hover { + color: var(--text-primary); + background-color: var(--bg-surface-hover); +} + +.notification__close i { + width: 16px; + height: 16px; +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(100px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Form Elements (for future use) */ +.form-group { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.form-label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--text-primary); +} + +.form-input { + padding: var(--space-2-5) var(--space-3); + background-color: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + font-size: var(--font-size-sm); + color: var(--text-primary); + transition: all var(--transition-fast); +} + +.form-input:focus { + outline: none; + border-color: var(--color-primary-500); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.form-input::placeholder { + color: var(--text-tertiary); +} \ No newline at end of file diff --git a/vna_system/web_ui/static/css/layout.css b/vna_system/web_ui/static/css/layout.css new file mode 100644 index 0000000..1c25a0d --- /dev/null +++ b/vna_system/web_ui/static/css/layout.css @@ -0,0 +1,377 @@ +/* Base Layout */ +html { + font-size: 16px; +} + +body { + font-family: var(--font-family); + font-size: var(--font-size-base); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-normal); + color: var(--text-primary); + background-color: var(--bg-primary); + overflow-x: hidden; +} + +/* Header */ +.header { + position: sticky; + top: 0; + height: var(--header-height); + background-color: var(--bg-surface); + border-bottom: 1px solid var(--border-primary); + backdrop-filter: blur(8px); + z-index: var(--z-dropdown); +} + +.header__container { + display: flex; + align-items: center; + justify-content: space-between; + height: 100%; + padding: 0 var(--space-6); + max-width: var(--max-content-width); + margin: 0 auto; +} + +.header__brand { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.header__icon { + width: 24px; + height: 24px; + color: var(--color-primary-500); +} + +.header__title { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.header__subtitle { + font-size: var(--font-size-sm); + color: var(--text-tertiary); + padding: var(--space-1) var(--space-2); + background-color: var(--bg-tertiary); + border-radius: var(--radius-default); +} + +.header__status { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-6); +} + +.header__stats { + display: flex; + gap: var(--space-4); +} + +.stat-item { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background-color: var(--bg-tertiary); + border-radius: var(--radius-lg); + border: 1px solid var(--border-primary); +} + +.stat-label { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: var(--font-weight-medium); +} + +.stat-value { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + font-family: var(--font-mono); +} + +.header__nav { + display: flex; + gap: var(--space-1); +} + +/* Navigation */ +.nav-btn { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-lg); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--text-secondary); + background-color: transparent; + transition: all var(--transition-fast); + cursor: pointer; +} + +.nav-btn:hover { + color: var(--text-primary); + background-color: var(--bg-surface-hover); +} + +.nav-btn--active { + color: var(--color-primary-400); + background-color: var(--color-primary-900); +} + +.nav-btn i { + width: 16px; + height: 16px; +} + +/* Status Indicator */ +.status-indicator { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.status-indicator__dot { + width: 8px; + height: 8px; + border-radius: var(--radius-full); + background-color: var(--color-error-500); + animation: pulse 2s infinite; +} + +.status-indicator--connected .status-indicator__dot { + background-color: var(--color-success-500); + animation: none; +} + +.status-indicator--connecting .status-indicator__dot { + background-color: var(--color-warning-500); +} + +.status-indicator__text { + font-size: var(--font-size-sm); + color: var(--text-secondary); + font-weight: var(--font-weight-medium); +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Main Content */ +.main { + min-height: calc(100vh - var(--header-height)); + max-width: var(--max-content-width); + margin: 0 auto; + padding: var(--space-6); +} + +/* Views */ +.view { + display: none; + animation: fadeIn 0.3s ease; +} + +.view--active { + display: block; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Controls Panel */ +.controls-panel { + margin-bottom: var(--space-6); + background-color: var(--bg-surface); + border: 1px solid var(--border-primary); + border-radius: var(--radius-xl); + overflow: hidden; +} + +.controls-panel__container { + display: flex; + gap: var(--space-6); + padding: var(--space-4) var(--space-6); + align-items: center; + flex-wrap: wrap; +} + +.controls-group { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; +} + +.controls-label { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Charts Container */ +.charts-container { + position: relative; + min-height: 60vh; +} + +.charts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(550px, 1fr)); + gap: var(--space-6); + margin-bottom: var(--space-6); +} + +/* Empty State */ +.empty-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 60vh; + text-align: center; +} + +.empty-state--hidden { + display: none; +} + +.empty-state__content { + max-width: 400px; +} + +.empty-state__icon { + width: 64px; + height: 64px; + margin: 0 auto var(--space-6); + color: var(--color-primary-500); + opacity: 0.5; +} + +.empty-state__title { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + margin-bottom: var(--space-2); +} + +.empty-state__description { + font-size: var(--font-size-base); + color: var(--text-secondary); + margin-bottom: var(--space-6); + line-height: var(--line-height-relaxed); +} + +/* Loading Spinner */ +.loading-spinner { + display: flex; + justify-content: center; +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border-primary); + border-top: 3px solid var(--color-primary-500); + border-radius: var(--radius-full); + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Settings & Logs Views */ +.settings-container, +.logs-container { + max-width: 800px; + margin: 0 auto; + text-align: center; + padding: var(--space-12); +} + +.settings-container h2, +.logs-container h2 { + font-size: var(--font-size-3xl); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + margin-bottom: var(--space-4); +} + +.settings-container p, +.logs-container p { + font-size: var(--font-size-lg); + color: var(--text-secondary); +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .charts-grid { + grid-template-columns: 1fr; + } + + .controls-panel__container { + flex-direction: column; + align-items: stretch; + gap: var(--space-4); + } +} + +@media (max-width: 768px) { + .header__container { + padding: 0 var(--space-4); + } + + .header__brand { + flex-direction: column; + align-items: flex-start; + gap: var(--space-1); + } + + .header__nav { + flex-direction: column; + } + + .main { + padding: var(--space-4); + } + + .nav-btn span { + display: none; + } +} + +@media (max-width: 480px) { + .header__status { + display: none; + } + + .charts-grid { + grid-template-columns: 1fr; + gap: var(--space-4); + } +} \ No newline at end of file diff --git a/vna_system/web_ui/static/css/normalize.css b/vna_system/web_ui/static/css/normalize.css new file mode 100644 index 0000000..d724c71 --- /dev/null +++ b/vna_system/web_ui/static/css/normalize.css @@ -0,0 +1,54 @@ +/* Modern CSS Reset */ +*, *::before, *::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +html, body { + height: 100%; +} + +body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +img, picture, video, canvas, svg { + display: block; + max-width: 100%; +} + +input, button, textarea, select { + font: inherit; +} + +p, h1, h2, h3, h4, h5, h6 { + overflow-wrap: break-word; +} + +#root, #__next { + isolation: isolate; +} + +/* Remove default button styles */ +button { + background: none; + border: none; + padding: 0; + cursor: pointer; +} + +/* Remove default list styles */ +ul, ol { + list-style: none; + padding: 0; +} + +/* Remove default link styles */ +a { + color: inherit; + text-decoration: none; +} \ No newline at end of file diff --git a/vna_system/web_ui/static/css/variables.css b/vna_system/web_ui/static/css/variables.css new file mode 100644 index 0000000..e2cedbf --- /dev/null +++ b/vna_system/web_ui/static/css/variables.css @@ -0,0 +1,134 @@ +:root { + /* Colors - Modern dark theme with blue accents */ + --color-primary-50: #eff6ff; + --color-primary-100: #dbeafe; + --color-primary-200: #bfdbfe; + --color-primary-300: #93c5fd; + --color-primary-400: #60a5fa; + --color-primary-500: #3b82f6; + --color-primary-600: #2563eb; + --color-primary-700: #1d4ed8; + --color-primary-800: #1e40af; + --color-primary-900: #1e3a8a; + + --color-gray-50: #f8fafc; + --color-gray-100: #f1f5f9; + --color-gray-200: #e2e8f0; + --color-gray-300: #cbd5e1; + --color-gray-400: #94a3b8; + --color-gray-500: #64748b; + --color-gray-600: #475569; + --color-gray-700: #334155; + --color-gray-800: #1e293b; + --color-gray-900: #0f172a; + + --color-success-400: #4ade80; + --color-success-500: #22c55e; + --color-success-600: #16a34a; + + --color-warning-400: #facc15; + --color-warning-500: #eab308; + --color-warning-600: #ca8a04; + + --color-error-400: #f87171; + --color-error-500: #ef4444; + --color-error-600: #dc2626; + + /* Dark Theme Colors */ + --bg-primary: var(--color-gray-900); + --bg-secondary: var(--color-gray-800); + --bg-tertiary: var(--color-gray-700); + --bg-surface: var(--color-gray-800); + --bg-surface-hover: var(--color-gray-700); + --bg-overlay: rgba(15, 23, 42, 0.95); + + --text-primary: var(--color-gray-50); + --text-secondary: var(--color-gray-300); + --text-tertiary: var(--color-gray-400); + + --border-primary: var(--color-gray-700); + --border-secondary: var(--color-gray-600); + + /* Typography */ + --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + --font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + + --font-size-xs: 0.75rem; /* 12px */ + --font-size-sm: 0.875rem; /* 14px */ + --font-size-base: 1rem; /* 16px */ + --font-size-lg: 1.125rem; /* 18px */ + --font-size-xl: 1.25rem; /* 20px */ + --font-size-2xl: 1.5rem; /* 24px */ + --font-size-3xl: 1.875rem; /* 30px */ + + --font-weight-light: 300; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + --line-height-tight: 1.25; + --line-height-normal: 1.5; + --line-height-relaxed: 1.75; + + /* Spacing */ + --space-px: 1px; + --space-0-5: 0.125rem; + --space-1: 0.25rem; + --space-1-5: 0.375rem; + --space-2: 0.5rem; + --space-2-5: 0.625rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + --space-16: 4rem; + --space-20: 5rem; + --space-24: 6rem; + + /* Border Radius */ + --radius-none: 0px; + --radius-sm: 0.125rem; + --radius-default: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + --radius-2xl: 1rem; + --radius-full: 9999px; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-default: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + --shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + + /* Transitions */ + --transition-fast: 0.15s ease; + --transition-normal: 0.2s ease; + --transition-slow: 0.3s ease; + + /* Z-index */ + --z-dropdown: 10; + --z-modal: 50; + --z-toast: 100; + + /* Layout */ + --header-height: 4rem; + --sidebar-width: 16rem; + --max-content-width: 1400px; + + /* Chart Colors */ + --chart-color-1: var(--color-primary-500); + --chart-color-2: var(--color-success-500); + --chart-color-3: var(--color-warning-500); + --chart-color-4: var(--color-error-500); + --chart-color-5: #8b5cf6; + --chart-color-6: #06b6d4; + --chart-color-7: #f59e0b; + --chart-color-8: #10b981; +} \ No newline at end of file diff --git a/vna_system/web_ui/static/js/main.js b/vna_system/web_ui/static/js/main.js new file mode 100644 index 0000000..1e8996f --- /dev/null +++ b/vna_system/web_ui/static/js/main.js @@ -0,0 +1,394 @@ +/** + * VNA System Web UI - Main Entry Point + * Modern ES6+ JavaScript with modular architecture + */ + +import { WebSocketManager } from './modules/websocket.js'; +import { ChartManager } from './modules/charts.js'; +import { UIManager } from './modules/ui.js'; +import { NotificationManager } from './modules/notifications.js'; +import { StorageManager } from './modules/storage.js'; + +/** + * Main Application Class + * Coordinates all modules and manages application lifecycle + */ +class VNADashboard { + constructor() { + this.isInitialized = false; + this.config = { + websocket: { + url: this.getWebSocketURL(), + reconnectInterval: 3000, + maxReconnectAttempts: 10 + }, + charts: { + theme: 'dark', + updateInterval: 100, + maxDataPoints: 1000, + animation: true + }, + ui: { + autoHideNotifications: true, + notificationTimeout: 5000 + } + }; + + // Initialize managers + this.storage = new StorageManager(); + this.notifications = new NotificationManager(); + this.ui = new UIManager(this.notifications); + this.charts = new ChartManager(this.config.charts, this.notifications); + this.websocket = new WebSocketManager(this.config.websocket, this.notifications); + + // Bind methods + this.handleWebSocketData = this.handleWebSocketData.bind(this); + this.handleVisibilityChange = this.handleVisibilityChange.bind(this); + this.handleBeforeUnload = this.handleBeforeUnload.bind(this); + } + + /** + * Initialize the application + */ + async init() { + try { + console.log('🚀 Initializing VNA Dashboard...'); + + // Initialize Lucide icons + if (typeof lucide !== 'undefined') { + lucide.createIcons(); + } + + // Initialize modules in correct order + await this.initializeModules(); + + // Set up event listeners + this.setupEventListeners(); + + // Connect WebSocket + await this.websocket.connect(); + + this.isInitialized = true; + console.log('✅ VNA Dashboard initialized successfully'); + + // Show welcome notification + this.notifications.show({ + type: 'info', + title: 'Dashboard Ready', + message: 'Connected to VNA System. Waiting for sweep data...' + }); + + } catch (error) { + console.error('❌ Failed to initialize VNA Dashboard:', error); + this.notifications.show({ + type: 'error', + title: 'Initialization Failed', + message: error.message || 'Failed to initialize dashboard' + }); + } + } + + /** + * Initialize all modules + */ + async initializeModules() { + // Initialize UI manager first + await this.ui.init(); + + // Initialize chart manager + await this.charts.init(); + + // Set up UI event handlers + this.setupUIHandlers(); + + // Load and apply user preferences after UI is initialized + const preferences = await this.storage.getPreferences(); + if (preferences) { + // Clean up old invalid processors from preferences + this.cleanupPreferences(preferences); + this.applyPreferences(preferences); + + // Save cleaned preferences back to storage + this.savePreferences(); + } + } + + /** + * Set up event listeners + */ + setupEventListeners() { + // WebSocket events + this.websocket.on('data', this.handleWebSocketData); + this.websocket.on('connected', () => { + this.ui.setConnectionStatus('connected'); + }); + this.websocket.on('disconnected', () => { + this.ui.setConnectionStatus('disconnected'); + }); + this.websocket.on('connecting', () => { + this.ui.setConnectionStatus('connecting'); + }); + + // Browser events + document.addEventListener('visibilitychange', this.handleVisibilityChange); + window.addEventListener('beforeunload', this.handleBeforeUnload); + + // Keyboard shortcuts + document.addEventListener('keydown', this.handleKeyboardShortcuts.bind(this)); + } + + /** + * Set up UI-specific event handlers + */ + setupUIHandlers() { + // Navigation + this.ui.onViewChange((view) => { + console.log(`📱 Switched to view: ${view}`); + }); + + // Processor toggles + this.ui.onProcessorToggle((processor, enabled) => { + console.log(`🔧 Processor ${processor}: ${enabled ? 'enabled' : 'disabled'}`); + this.charts.toggleProcessor(processor, enabled); + this.savePreferences(); + }); + + // Chart clearing removed - users can disable processors individually + + this.ui.onExportData(() => { + console.log('📊 Exporting chart data...'); + this.exportData(); + }); + } + + /** + * Handle WebSocket data + */ + handleWebSocketData(data) { + try { + if (data.type === 'processing_result') { + // Add chart data + this.charts.addData(data.data); + + // Update processor in UI + this.ui.updateProcessorFromData(data.data.processor_name); + + // Update general stats + this.ui.updateStats({ + sweepCount: data.data.sweep_number, + lastUpdate: new Date() + }); + } else if (data.type === 'system_status') { + this.ui.updateSystemStatus(data.data); + } + } catch (error) { + console.error('❌ Error handling WebSocket data:', error); + this.notifications.show({ + type: 'error', + title: 'Data Processing Error', + message: 'Failed to process incoming data' + }); + } + } + + /** + * Handle visibility changes (tab switch, minimize, etc.) + */ + handleVisibilityChange() { + if (document.hidden) { + // Pause expensive operations when hidden + this.charts.pause(); + } else { + // Resume when visible + this.charts.resume(); + } + } + + /** + * Handle before unload (page close/refresh) + */ + handleBeforeUnload() { + this.savePreferences(); + this.websocket.disconnect(); + } + + /** + * Handle keyboard shortcuts + */ + handleKeyboardShortcuts(event) { + // Ctrl/Cmd + E: Export data + if ((event.ctrlKey || event.metaKey) && event.key === 'e') { + event.preventDefault(); + this.ui.triggerExportData(); + } + + // Ctrl/Cmd + Shift + R: Reconnect WebSocket + if ((event.ctrlKey || event.metaKey) && event.key === 'r' && event.shiftKey) { + event.preventDefault(); + this.websocket.reconnect(); + } + } + + /** + * Export data functionality + */ + exportData() { + try { + const data = this.charts.exportData(); + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: 'application/json' + }); + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `vna-data-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + this.notifications.show({ + type: 'success', + title: 'Export Complete', + message: 'Chart data exported successfully' + }); + } catch (error) { + console.error('❌ Export failed:', error); + this.notifications.show({ + type: 'error', + title: 'Export Failed', + message: 'Failed to export chart data' + }); + } + } + + /** + * Clean up old/invalid preferences + */ + cleanupPreferences(preferences) { + // List of invalid/old processor names that should be removed + const invalidProcessors = ['sweep_counter']; + + if (preferences.disabledProcessors && Array.isArray(preferences.disabledProcessors)) { + // Remove invalid processors + preferences.disabledProcessors = preferences.disabledProcessors.filter( + processor => !invalidProcessors.includes(processor) + ); + + if (preferences.disabledProcessors.length === 0) { + delete preferences.disabledProcessors; + } + } + } + + /** + * Apply user preferences + */ + applyPreferences(preferences) { + try { + if (preferences.disabledProcessors && Array.isArray(preferences.disabledProcessors)) { + preferences.disabledProcessors.forEach(processor => { + if (this.ui && typeof this.ui.setProcessorEnabled === 'function') { + this.ui.setProcessorEnabled(processor, false); + } + }); + } + + if (preferences.theme) { + if (this.ui && typeof this.ui.setTheme === 'function') { + this.ui.setTheme(preferences.theme); + } + } + } catch (error) { + console.warn('⚠️ Error applying preferences:', error); + } + } + + /** + * Save current preferences + */ + async savePreferences() { + try { + const preferences = { + disabledProcessors: this.charts.getDisabledProcessors(), + theme: this.ui.getCurrentTheme(), + timestamp: Date.now() + }; + await this.storage.savePreferences(preferences); + } catch (error) { + console.warn('⚠️ Failed to save preferences:', error); + } + } + + /** + * Get WebSocket URL based on current location + */ + getWebSocketURL() { + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = location.host; + return `${protocol}//${host}/ws/processing`; + } + + /** + * Cleanup resources + */ + destroy() { + if (!this.isInitialized) return; + + console.log('🧹 Cleaning up VNA Dashboard...'); + + // Remove event listeners + document.removeEventListener('visibilitychange', this.handleVisibilityChange); + window.removeEventListener('beforeunload', this.handleBeforeUnload); + + // Disconnect WebSocket + this.websocket.disconnect(); + + // Cleanup managers + this.charts.destroy(); + this.ui.destroy(); + this.notifications.destroy(); + + this.isInitialized = false; + console.log('✅ VNA Dashboard cleanup complete'); + } +} + +// Initialize dashboard when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + window.vnaDashboard = new VNADashboard(); + window.vnaDashboard.init(); + }); +} else { + window.vnaDashboard = new VNADashboard(); + window.vnaDashboard.init(); +} + +// Global error handling +window.addEventListener('error', (event) => { + console.error('🚨 Global error:', event.error); +}); + +window.addEventListener('unhandledrejection', (event) => { + console.error('🚨 Unhandled promise rejection:', event.reason); +}); + +// Development helpers +if (typeof process !== 'undefined' && process?.env?.NODE_ENV === 'development') { + window.debug = { + dashboard: () => window.vnaDashboard, + websocket: () => window.vnaDashboard?.websocket, + charts: () => window.vnaDashboard?.charts, + ui: () => window.vnaDashboard?.ui + }; +} else { + // Browser environment debug helpers + window.debug = { + dashboard: () => window.vnaDashboard, + websocket: () => window.vnaDashboard?.websocket, + charts: () => window.vnaDashboard?.charts, + ui: () => window.vnaDashboard?.ui + }; +} \ No newline at end of file diff --git a/vna_system/web_ui/static/js/modules/charts.js b/vna_system/web_ui/static/js/modules/charts.js new file mode 100644 index 0000000..bb874a4 --- /dev/null +++ b/vna_system/web_ui/static/js/modules/charts.js @@ -0,0 +1,693 @@ +/** + * Chart Manager + * Handles Plotly.js chart creation, updates, and management + */ + +export class ChartManager { + constructor(config, notifications) { + this.config = config; + this.notifications = notifications; + + // Chart storage + this.charts = new Map(); // processor_name -> chart instance + this.chartData = new Map(); // processor_name -> data array + this.disabledProcessors = new Set(); + + // UI elements + this.chartsGrid = null; + this.emptyState = null; + + // Update management + this.updateQueue = new Map(); + this.isUpdating = false; + this.isPaused = false; + + // Performance tracking + this.performanceStats = { + chartsCreated: 0, + updatesProcessed: 0, + avgUpdateTime: 0, + lastUpdateTime: null + }; + + // Plotly theme configuration + this.plotlyConfig = { + displayModeBar: true, + modeBarButtonsToRemove: [ + 'select2d', 'lasso2d', 'hoverClosestCartesian', + 'hoverCompareCartesian', 'toggleSpikelines' + ], + displaylogo: false, + responsive: false, // Disable responsive to avoid resize issues + doubleClick: 'reset', + toImageButtonOptions: { + format: 'png', + filename: 'vna_chart', + height: 600, + width: 800, + scale: 1 + } + }; + + this.plotlyLayout = { + plot_bgcolor: 'transparent', + paper_bgcolor: 'transparent', + font: { + family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', + size: 12, + color: '#f1f5f9' // --text-secondary + }, + colorway: [ + '#3b82f6', '#22c55e', '#eab308', '#ef4444', + '#8b5cf6', '#06b6d4', '#f59e0b', '#10b981' + ], + margin: { l: 60, r: 40, t: 40, b: 60 }, + showlegend: false, + xaxis: { + gridcolor: '#334155', + zerolinecolor: '#475569', + color: '#cbd5e1', + fixedrange: false // Allow zoom/pan + }, + yaxis: { + gridcolor: '#334155', + zerolinecolor: '#475569', + color: '#cbd5e1', + fixedrange: false // Allow zoom/pan + }, + autosize: true, + width: null, // Let CSS control width + height: null // Let CSS control height + }; + } + + /** + * Initialize chart manager + */ + async init() { + console.log('📊 Initializing Chart Manager...'); + + // Get DOM elements + this.chartsGrid = document.getElementById('chartsGrid'); + this.emptyState = document.getElementById('emptyState'); + + if (!this.chartsGrid || !this.emptyState) { + throw new Error('Required DOM elements not found'); + } + + // Ensure Plotly is available + if (typeof Plotly === 'undefined') { + throw new Error('Plotly.js not loaded'); + } + + console.log('✅ Chart Manager initialized'); + } + + /** + * Add new data from WebSocket + */ + addData(processingResult) { + try { + const { processor_name, sweep_number, plotly_figure, data } = processingResult; + + if (!processor_name || !plotly_figure) { + console.warn('⚠️ Invalid processing result:', processingResult); + return; + } + + // Check if processor is disabled + if (this.disabledProcessors.has(processor_name)) { + console.log(`⏸️ Skipping disabled processor: ${processor_name}`); + return; + } + + // Store data + if (!this.chartData.has(processor_name)) { + this.chartData.set(processor_name, []); + } + + const dataArray = this.chartData.get(processor_name); + dataArray.push({ + sweep_number, + plotly_figure, + data, + timestamp: new Date() + }); + + // Limit data points for performance + if (dataArray.length > this.config.maxDataPoints) { + dataArray.splice(0, dataArray.length - this.config.maxDataPoints); + } + + // Create or update chart + if (!this.charts.has(processor_name)) { + this.createChart(processor_name); + } + + this.updateChart(processor_name, plotly_figure); + this.hideEmptyState(); + + } catch (error) { + console.error('❌ Error adding chart data:', error); + this.notifications.show({ + type: 'error', + title: 'Chart Error', + message: `Failed to update ${processingResult?.processor_name} chart` + }); + } + } + + /** + * Create new chart + */ + createChart(processorName) { + console.log(`📊 Creating chart for processor: ${processorName}`); + + // Create chart card HTML + const chartCard = this.createChartCard(processorName); + this.chartsGrid.appendChild(chartCard); + + // Get plot container + const plotContainer = chartCard.querySelector('.chart-card__plot'); + + // Create empty chart with proper sizing + const layout = { + ...this.plotlyLayout, + title: { + text: this.formatProcessorName(processorName), + font: { size: 16, color: '#f1f5f9' } + }, + width: plotContainer.clientWidth || 500, + height: plotContainer.clientHeight || 420 + }; + + Plotly.newPlot(plotContainer, [], layout, this.plotlyConfig); + + // Set up resize observer for proper sizing + if (window.ResizeObserver) { + const resizeObserver = new ResizeObserver(() => { + if (plotContainer && plotContainer.clientWidth > 0) { + Plotly.Plots.resize(plotContainer); + } + }); + resizeObserver.observe(plotContainer); + + // Store observer for cleanup + plotContainer._resizeObserver = resizeObserver; + } + + // Store chart reference + this.charts.set(processorName, { + element: chartCard, + plotContainer: plotContainer, + isVisible: true + }); + + this.performanceStats.chartsCreated++; + + // Add animation class + if (this.config.animation) { + setTimeout(() => { + chartCard.classList.add('chart-card--animated'); + }, 50); + } + } + + /** + * Update existing chart + */ + updateChart(processorName, plotlyFigure) { + if (this.isPaused) return; + + const chart = this.charts.get(processorName); + if (!chart || !chart.plotContainer) { + console.warn(`⚠️ Chart not found for processor: ${processorName}`); + return; + } + + try { + const startTime = performance.now(); + + // Queue update to avoid blocking UI + this.queueUpdate(processorName, () => { + // Prepare layout without overriding size + const updateLayout = { + ...this.plotlyLayout, + ...plotlyFigure.layout, + title: { + text: this.formatProcessorName(processorName), + font: { size: 16, color: '#f1f5f9' } + } + }; + + // Don't override existing width/height if they're working + delete updateLayout.width; + delete updateLayout.height; + + // Update plot with new data + Plotly.react( + chart.plotContainer, + plotlyFigure.data, + updateLayout, + this.plotlyConfig + ); + + // Update metadata + this.updateChartMetadata(processorName); + + // Update performance stats + const updateTime = performance.now() - startTime; + this.updatePerformanceStats(updateTime); + }); + + } catch (error) { + console.error(`❌ Error updating chart ${processorName}:`, error); + } + } + + /** + * Queue chart update to avoid blocking UI + */ + queueUpdate(processorName, updateFn) { + this.updateQueue.set(processorName, updateFn); + + if (!this.isUpdating) { + this.processUpdateQueue(); + } + } + + /** + * Process queued updates + */ + async processUpdateQueue() { + if (this.isPaused) return; + + this.isUpdating = true; + + while (this.updateQueue.size > 0 && !this.isPaused) { + const [processorName, updateFn] = this.updateQueue.entries().next().value; + this.updateQueue.delete(processorName); + + try { + await updateFn(); + } catch (error) { + console.error(`❌ Error in queued update for ${processorName}:`, error); + } + + // Yield to browser between updates + await new Promise(resolve => setTimeout(resolve, 0)); + } + + this.isUpdating = false; + } + + /** + * Create chart card HTML + */ + createChartCard(processorName) { + const card = document.createElement('div'); + card.className = 'chart-card'; + card.dataset.processor = processorName; + + card.innerHTML = ` +
+
+ + ${this.formatProcessorName(processorName)} +
+
+ + + +
+
+
+
+
+
+
+ Last update: -- +
+
+ Sweep: -- +
+
+ `; + + // Add event listeners + this.setupChartCardEvents(card, processorName); + + // Initialize Lucide icons + if (typeof lucide !== 'undefined') { + lucide.createIcons({ attrs: { 'stroke-width': 1.5 } }); + } + + return card; + } + + /** + * Set up chart card event listeners + */ + setupChartCardEvents(card, processorName) { + card.addEventListener('click', (event) => { + const action = event.target.closest('[data-action]')?.dataset.action; + if (!action) return; + + event.stopPropagation(); + + switch (action) { + case 'fullscreen': + this.toggleFullscreen(processorName); + break; + case 'download': + this.downloadChart(processorName); + break; + case 'hide': + this.hideChart(processorName); + // Also disable the processor toggle + if (window.vnaDashboard && window.vnaDashboard.ui) { + window.vnaDashboard.ui.setProcessorEnabled(processorName, false); + } + break; + } + }); + } + + /** + * Update chart metadata + */ + updateChartMetadata(processorName) { + const chart = this.charts.get(processorName); + const data = this.chartData.get(processorName); + + if (!chart || !data || data.length === 0) return; + + const latestData = data[data.length - 1]; + const timestampEl = chart.element.querySelector('[data-timestamp]'); + const sweepEl = chart.element.querySelector('[data-sweep]'); + + if (timestampEl) { + timestampEl.textContent = `Last update: ${latestData.timestamp.toLocaleTimeString()}`; + timestampEl.dataset.timestamp = latestData.timestamp.toISOString(); + } + + if (sweepEl) { + sweepEl.textContent = `Sweep: #${latestData.sweep_number}`; + sweepEl.dataset.sweep = latestData.sweep_number; + } + } + + /** + * Format processor name for display + */ + formatProcessorName(processorName) { + return processorName + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + /** + * Toggle processor visibility + */ + toggleProcessor(processorName, enabled) { + if (enabled) { + this.disabledProcessors.delete(processorName); + this.showChart(processorName); + } else { + this.disabledProcessors.add(processorName); + this.hideChart(processorName); + } + } + + /** + * Show chart + */ + showChart(processorName) { + const chart = this.charts.get(processorName); + if (chart) { + chart.element.classList.remove('chart-card--hidden'); + chart.isVisible = true; + + // Trigger resize to ensure proper rendering + setTimeout(() => { + if (chart.plotContainer) { + Plotly.Plots.resize(chart.plotContainer); + } + }, 100); + } + + this.updateEmptyStateVisibility(); + } + + /** + * Hide chart + */ + hideChart(processorName) { + const chart = this.charts.get(processorName); + if (chart) { + chart.element.classList.add('chart-card--hidden'); + chart.isVisible = false; + } + + this.updateEmptyStateVisibility(); + } + + /** + * Remove chart completely + */ + removeChart(processorName) { + const chart = this.charts.get(processorName); + if (chart) { + // Clean up ResizeObserver + if (chart.plotContainer && chart.plotContainer._resizeObserver) { + chart.plotContainer._resizeObserver.disconnect(); + chart.plotContainer._resizeObserver = null; + } + + // Clean up Plotly + if (chart.plotContainer) { + Plotly.purge(chart.plotContainer); + } + + // Remove from DOM + chart.element.remove(); + + // Clean up data + this.charts.delete(processorName); + this.chartData.delete(processorName); + this.disabledProcessors.delete(processorName); + } + + this.updateEmptyStateVisibility(); + } + + /** + * Clear all charts + */ + clearAll() { + console.log('🗑️ Clearing all charts...'); + + for (const [processorName] of this.charts) { + this.removeChart(processorName); + } + + this.charts.clear(); + this.chartData.clear(); + this.updateQueue.clear(); + this.updateEmptyStateVisibility(); + } + + /** + * Download chart as image + */ + downloadChart(processorName) { + const chart = this.charts.get(processorName); + if (!chart || !chart.plotContainer) return; + + try { + Plotly.downloadImage(chart.plotContainer, { + format: 'png', + width: 1200, + height: 800, + filename: `${processorName}-${Date.now()}` + }); + + this.notifications.show({ + type: 'success', + title: 'Download Started', + message: `Chart image download started` + }); + + } catch (error) { + console.error('❌ Chart download failed:', error); + this.notifications.show({ + type: 'error', + title: 'Download Failed', + message: 'Failed to download chart image' + }); + } + } + + /** + * Toggle fullscreen mode + */ + toggleFullscreen(processorName) { + const chart = this.charts.get(processorName); + if (!chart || !chart.element) return; + + if (!document.fullscreenElement) { + chart.element.requestFullscreen().then(() => { + // Resize chart for fullscreen after CSS transitions complete + setTimeout(() => { + if (chart.plotContainer) { + // Get fullscreen dimensions + const rect = chart.plotContainer.getBoundingClientRect(); + + // Update layout for fullscreen + Plotly.relayout(chart.plotContainer, { + width: rect.width, + height: rect.height + }); + + // Force resize + Plotly.Plots.resize(chart.plotContainer); + } + }, 200); + }).catch(console.error); + } else { + document.exitFullscreen().then(() => { + // Resize chart back to normal view + setTimeout(() => { + if (chart.plotContainer) { + Plotly.Plots.resize(chart.plotContainer); + } + }, 100); + }); + } + } + + /** + * Hide empty state + */ + hideEmptyState() { + if (this.emptyState) { + this.emptyState.classList.add('empty-state--hidden'); + } + } + + /** + * Update empty state visibility + */ + updateEmptyStateVisibility() { + if (!this.emptyState) return; + + const hasVisibleCharts = Array.from(this.charts.values()) + .some(chart => chart.isVisible); + + if (hasVisibleCharts) { + this.emptyState.classList.add('empty-state--hidden'); + } else { + this.emptyState.classList.remove('empty-state--hidden'); + } + } + + /** + * Update performance statistics + */ + updatePerformanceStats(updateTime) { + this.performanceStats.updatesProcessed++; + this.performanceStats.lastUpdateTime = new Date(); + + // Calculate rolling average + const totalTime = this.performanceStats.avgUpdateTime * + (this.performanceStats.updatesProcessed - 1) + updateTime; + this.performanceStats.avgUpdateTime = totalTime / this.performanceStats.updatesProcessed; + } + + /** + * Pause updates (for performance when tab is hidden) + */ + pause() { + this.isPaused = true; + console.log('⏸️ Chart updates paused'); + } + + /** + * Resume updates + */ + resume() { + this.isPaused = false; + console.log('▶️ Chart updates resumed'); + + // Process any queued updates + if (this.updateQueue.size > 0) { + this.processUpdateQueue(); + } + } + + /** + * Export all chart data + */ + exportData() { + const exportData = { + timestamp: new Date().toISOString(), + charts: {}, + stats: this.performanceStats + }; + + for (const [processorName, dataArray] of this.chartData) { + exportData.charts[processorName] = dataArray.map(item => ({ + sweep_number: item.sweep_number, + data: item.data, + timestamp: item.timestamp.toISOString() + })); + } + + return exportData; + } + + /** + * Get disabled processors list + */ + getDisabledProcessors() { + return Array.from(this.disabledProcessors); + } + + /** + * Get chart statistics + */ + getStats() { + return { + ...this.performanceStats, + totalCharts: this.charts.size, + visibleCharts: Array.from(this.charts.values()).filter(c => c.isVisible).length, + disabledProcessors: this.disabledProcessors.size, + queuedUpdates: this.updateQueue.size, + isPaused: this.isPaused + }; + } + + /** + * Cleanup + */ + destroy() { + console.log('🧹 Cleaning up Chart Manager...'); + + // Clear all charts (this also cleans up Plotly instances) + this.clearAll(); + + // Clear update queue + this.updateQueue.clear(); + this.isUpdating = false; + this.isPaused = true; + + console.log('✅ Chart Manager cleanup complete'); + } +} \ No newline at end of file diff --git a/vna_system/web_ui/static/js/modules/notifications.js b/vna_system/web_ui/static/js/modules/notifications.js new file mode 100644 index 0000000..baba19c --- /dev/null +++ b/vna_system/web_ui/static/js/modules/notifications.js @@ -0,0 +1,466 @@ +/** + * Notification Manager + * Handles toast notifications and user feedback + */ + +export class NotificationManager { + constructor() { + this.container = null; + this.notifications = new Map(); // id -> notification element + this.nextId = 1; + + // Configuration + this.config = { + maxNotifications: 5, + defaultTimeout: 5000, + animationDuration: 300, + position: 'top-right' // top-right, top-left, bottom-right, bottom-left + }; + + // Notification types configuration + this.typeConfig = { + success: { + icon: 'check-circle', + timeout: 3000, + class: 'notification--success' + }, + error: { + icon: 'alert-circle', + timeout: 7000, + class: 'notification--error' + }, + warning: { + icon: 'alert-triangle', + timeout: 5000, + class: 'notification--warning' + }, + info: { + icon: 'info', + timeout: 4000, + class: 'notification--info' + } + }; + + this.init(); + } + + /** + * Initialize notification system + */ + init() { + // Create or find notification container + this.container = document.getElementById('notifications'); + + if (!this.container) { + this.container = document.createElement('div'); + this.container.id = 'notifications'; + this.container.className = 'notifications'; + document.body.appendChild(this.container); + } + + console.log('📢 Notification Manager initialized'); + } + + /** + * Show a notification + */ + show(options) { + const notification = this.createNotification(options); + this.addNotification(notification); + return notification.id; + } + + /** + * Create notification object + */ + createNotification(options) { + const { + type = 'info', + title, + message, + timeout = null, + persistent = false, + actions = [] + } = options; + + const id = this.nextId++; + const config = this.typeConfig[type] || this.typeConfig.info; + const finalTimeout = persistent ? null : (timeout ?? config.timeout); + + return { + id, + type, + title, + message, + timeout: finalTimeout, + persistent, + actions, + config, + element: null, + timer: null, + createdAt: new Date() + }; + } + + /** + * Add notification to DOM and manage queue + */ + addNotification(notification) { + // Remove oldest notifications if we exceed the limit + this.enforceMaxNotifications(); + + // Create DOM element + notification.element = this.createNotificationElement(notification); + + // Add to container + this.container.appendChild(notification.element); + + // Store notification + this.notifications.set(notification.id, notification); + + // Animate in + setTimeout(() => { + notification.element.classList.add('notification--visible'); + }, 10); + + // Set up auto-dismiss timer + if (notification.timeout) { + notification.timer = setTimeout(() => { + this.dismiss(notification.id); + }, notification.timeout); + } + + // Initialize Lucide icons + if (typeof lucide !== 'undefined') { + lucide.createIcons({ attrs: { 'stroke-width': 1.5 } }); + } + + console.log(`📢 Showing ${notification.type} notification:`, notification.title); + } + + /** + * Create notification DOM element + */ + createNotificationElement(notification) { + const element = document.createElement('div'); + element.className = `notification ${notification.config.class}`; + element.dataset.id = notification.id; + + const iconHtml = ``; + + const titleHtml = notification.title ? + `
${this.escapeHtml(notification.title)}
` : ''; + + const messageHtml = notification.message ? + `
${this.escapeHtml(notification.message)}
` : ''; + + const actionsHtml = notification.actions.length > 0 ? + this.createActionsHtml(notification.actions) : ''; + + const closeHtml = ` + + `; + + element.innerHTML = ` + ${iconHtml} +
+ ${titleHtml} + ${messageHtml} + ${actionsHtml} +
+ ${closeHtml} + `; + + // Add event listeners + this.setupNotificationEvents(element, notification); + + return element; + } + + /** + * Create actions HTML for notification + */ + createActionsHtml(actions) { + if (actions.length === 0) return ''; + + const actionsHtml = actions.map(action => { + const buttonClass = action.primary ? 'btn btn--primary btn--sm' : 'btn btn--ghost btn--sm'; + return ` + + `; + }).join(''); + + return `
${actionsHtml}
`; + } + + /** + * Set up notification event listeners + */ + setupNotificationEvents(element, notification) { + element.addEventListener('click', (event) => { + const action = event.target.closest('[data-action]')?.dataset.action; + + if (action === 'close') { + this.dismiss(notification.id); + } else if (action === 'custom') { + const actionId = event.target.closest('[data-action-id]')?.dataset.actionId; + const actionConfig = notification.actions.find(a => a.id === actionId); + + if (actionConfig && actionConfig.handler) { + try { + actionConfig.handler(notification); + } catch (error) { + console.error('❌ Error in notification action handler:', error); + } + } + + // Auto-dismiss after action unless specified otherwise + if (!actionConfig || actionConfig.dismissOnClick !== false) { + this.dismiss(notification.id); + } + } + }); + + // Pause auto-dismiss on hover + element.addEventListener('mouseenter', () => { + if (notification.timer) { + clearTimeout(notification.timer); + notification.timer = null; + } + }); + + // Resume auto-dismiss on leave (if not persistent) + element.addEventListener('mouseleave', () => { + if (notification.timeout && !notification.persistent && !notification.timer) { + notification.timer = setTimeout(() => { + this.dismiss(notification.id); + }, 1000); // Shorter timeout after hover + } + }); + } + + /** + * Dismiss notification by ID + */ + dismiss(id) { + const notification = this.notifications.get(id); + if (!notification) return; + + console.log(`📢 Dismissing notification: ${id}`); + + // Clear timer + if (notification.timer) { + clearTimeout(notification.timer); + notification.timer = null; + } + + // Animate out + if (notification.element) { + notification.element.classList.add('notification--dismissing'); + + setTimeout(() => { + if (notification.element && notification.element.parentNode) { + notification.element.parentNode.removeChild(notification.element); + } + this.notifications.delete(id); + }, this.config.animationDuration); + } else { + this.notifications.delete(id); + } + } + + /** + * Dismiss all notifications + */ + dismissAll() { + console.log('📢 Dismissing all notifications'); + + for (const id of this.notifications.keys()) { + this.dismiss(id); + } + } + + /** + * Dismiss notifications by type + */ + dismissByType(type) { + console.log(`📢 Dismissing all ${type} notifications`); + + for (const notification of this.notifications.values()) { + if (notification.type === type) { + this.dismiss(notification.id); + } + } + } + + /** + * Update existing notification + */ + update(id, updates) { + const notification = this.notifications.get(id); + if (!notification) return false; + + // Update properties + Object.assign(notification, updates); + + // Update DOM if needed + if (updates.title || updates.message) { + const titleEl = notification.element.querySelector('.notification__title'); + const messageEl = notification.element.querySelector('.notification__message'); + + if (titleEl && updates.title !== undefined) { + titleEl.textContent = updates.title; + } + + if (messageEl && updates.message !== undefined) { + messageEl.textContent = updates.message; + } + } + + // Update timeout + if (updates.timeout !== undefined) { + if (notification.timer) { + clearTimeout(notification.timer); + } + + if (updates.timeout > 0) { + notification.timer = setTimeout(() => { + this.dismiss(id); + }, updates.timeout); + } + } + + return true; + } + + /** + * Enforce maximum number of notifications + */ + enforceMaxNotifications() { + const notifications = Array.from(this.notifications.values()) + .sort((a, b) => a.createdAt - b.createdAt); + + while (notifications.length >= this.config.maxNotifications) { + const oldest = notifications.shift(); + if (oldest && !oldest.persistent) { + this.dismiss(oldest.id); + } + } + } + + /** + * Convenience methods for different notification types + */ + success(title, message, options = {}) { + return this.show({ + type: 'success', + title, + message, + ...options + }); + } + + error(title, message, options = {}) { + return this.show({ + type: 'error', + title, + message, + ...options + }); + } + + warning(title, message, options = {}) { + return this.show({ + type: 'warning', + title, + message, + ...options + }); + } + + info(title, message, options = {}) { + return this.show({ + type: 'info', + title, + message, + ...options + }); + } + + /** + * Get notification statistics + */ + getStats() { + return { + total: this.notifications.size, + byType: this.getNotificationsByType(), + oldestTimestamp: this.getOldestNotificationTime(), + newestTimestamp: this.getNewestNotificationTime() + }; + } + + /** + * Get notifications grouped by type + */ + getNotificationsByType() { + const byType = {}; + + for (const notification of this.notifications.values()) { + byType[notification.type] = (byType[notification.type] || 0) + 1; + } + + return byType; + } + + /** + * Get oldest notification timestamp + */ + getOldestNotificationTime() { + const notifications = Array.from(this.notifications.values()); + if (notifications.length === 0) return null; + + return Math.min(...notifications.map(n => n.createdAt.getTime())); + } + + /** + * Get newest notification timestamp + */ + getNewestNotificationTime() { + const notifications = Array.from(this.notifications.values()); + if (notifications.length === 0) return null; + + return Math.max(...notifications.map(n => n.createdAt.getTime())); + } + + /** + * Escape HTML to prevent XSS + */ + escapeHtml(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + /** + * Cleanup + */ + destroy() { + console.log('🧹 Cleaning up Notification Manager...'); + + this.dismissAll(); + + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + + console.log('✅ Notification Manager cleanup complete'); + } +} \ No newline at end of file diff --git a/vna_system/web_ui/static/js/modules/storage.js b/vna_system/web_ui/static/js/modules/storage.js new file mode 100644 index 0000000..bf6f265 --- /dev/null +++ b/vna_system/web_ui/static/js/modules/storage.js @@ -0,0 +1,453 @@ +/** + * Storage Manager + * Handles localStorage persistence and user preferences + */ + +export class StorageManager { + constructor() { + this.prefix = 'vna_dashboard_'; + this.isAvailable = this.checkAvailability(); + + // Storage keys + this.keys = { + preferences: 'preferences', + chartData: 'chart_data', + session: 'session_data', + debug: 'debug_info' + }; + + console.log(`💾 Storage Manager initialized (${this.isAvailable ? 'available' : 'not available'})`); + } + + /** + * Check if localStorage is available + */ + checkAvailability() { + try { + const test = '__storage_test__'; + localStorage.setItem(test, test); + localStorage.removeItem(test); + return true; + } catch (e) { + console.warn('⚠️ localStorage not available:', e.message); + return false; + } + } + + /** + * Get full storage key with prefix + */ + getKey(key) { + return `${this.prefix}${key}`; + } + + /** + * Save data to localStorage + */ + async save(key, data) { + if (!this.isAvailable) { + console.warn('⚠️ Storage not available, cannot save:', key); + return false; + } + + try { + const serialized = JSON.stringify({ + data, + timestamp: Date.now(), + version: '1.0' + }); + + localStorage.setItem(this.getKey(key), serialized); + console.log(`💾 Saved to storage: ${key}`); + return true; + + } catch (error) { + console.error('❌ Failed to save to storage:', error); + + // Handle quota exceeded error + if (error.name === 'QuotaExceededError') { + console.log('🧹 Storage quota exceeded, cleaning up...'); + this.cleanup(); + + // Try again after cleanup + try { + localStorage.setItem(this.getKey(key), serialized); + console.log(`💾 Saved to storage after cleanup: ${key}`); + return true; + } catch (retryError) { + console.error('❌ Still failed after cleanup:', retryError); + } + } + + return false; + } + } + + /** + * Load data from localStorage + */ + async load(key) { + if (!this.isAvailable) { + console.warn('⚠️ Storage not available, cannot load:', key); + return null; + } + + try { + const serialized = localStorage.getItem(this.getKey(key)); + if (!serialized) { + return null; + } + + const parsed = JSON.parse(serialized); + + // Validate structure + if (!parsed.data || !parsed.timestamp) { + console.warn('⚠️ Invalid storage data format:', key); + this.remove(key); // Clean up invalid data + return null; + } + + console.log(`💾 Loaded from storage: ${key} (${new Date(parsed.timestamp).toLocaleString()})`); + return parsed.data; + + } catch (error) { + console.error('❌ Failed to load from storage:', error); + this.remove(key); // Clean up corrupted data + return null; + } + } + + /** + * Remove data from localStorage + */ + async remove(key) { + if (!this.isAvailable) return false; + + try { + localStorage.removeItem(this.getKey(key)); + console.log(`🗑️ Removed from storage: ${key}`); + return true; + } catch (error) { + console.error('❌ Failed to remove from storage:', error); + return false; + } + } + + /** + * Check if key exists in storage + */ + async exists(key) { + if (!this.isAvailable) return false; + + try { + return localStorage.getItem(this.getKey(key)) !== null; + } catch (error) { + console.error('❌ Failed to check storage existence:', error); + return false; + } + } + + /** + * Save user preferences + */ + async savePreferences(preferences) { + const data = { + ...preferences, + lastUpdated: Date.now() + }; + + return await this.save(this.keys.preferences, data); + } + + /** + * Load user preferences + */ + async getPreferences() { + return await this.load(this.keys.preferences); + } + + /** + * Save chart data for offline viewing + */ + async saveChartData(chartData) { + // Limit size to prevent storage overflow + const maxCharts = 10; + const limitedData = {}; + + const chartNames = Object.keys(chartData).slice(-maxCharts); + chartNames.forEach(name => { + // Keep only last 100 data points per chart + const data = chartData[name]; + if (Array.isArray(data)) { + limitedData[name] = data.slice(-100); + } else { + limitedData[name] = data; + } + }); + + return await this.save(this.keys.chartData, limitedData); + } + + /** + * Load saved chart data + */ + async getChartData() { + return await this.load(this.keys.chartData); + } + + /** + * Save session data (temporary data that expires) + */ + async saveSession(sessionData) { + const data = { + ...sessionData, + expiresAt: Date.now() + (24 * 60 * 60 * 1000) // 24 hours + }; + + return await this.save(this.keys.session, data); + } + + /** + * Load session data (with expiration check) + */ + async getSession() { + const data = await this.load(this.keys.session); + + if (data && data.expiresAt) { + if (Date.now() > data.expiresAt) { + console.log('🕒 Session data expired, removing...'); + await this.remove(this.keys.session); + return null; + } + } + + return data; + } + + /** + * Save debug information + */ + async saveDebugInfo(debugInfo) { + const data = { + userAgent: navigator.userAgent, + timestamp: Date.now(), + url: window.location.href, + ...debugInfo + }; + + return await this.save(this.keys.debug, data); + } + + /** + * Get debug information + */ + async getDebugInfo() { + return await this.load(this.keys.debug); + } + + /** + * Get storage usage statistics + */ + getStorageStats() { + if (!this.isAvailable) { + return { available: false }; + } + + try { + // Calculate used storage + let totalSize = 0; + const itemSizes = {}; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(this.prefix)) { + const value = localStorage.getItem(key); + const size = value ? value.length : 0; + totalSize += size; + itemSizes[key.replace(this.prefix, '')] = size; + } + } + + return { + available: true, + totalSize, + totalSizeFormatted: this.formatBytes(totalSize), + itemSizes, + itemCount: Object.keys(itemSizes).length, + storageQuotaMB: this.estimateStorageQuota() + }; + + } catch (error) { + console.error('❌ Failed to calculate storage stats:', error); + return { available: false, error: error.message }; + } + } + + /** + * Estimate storage quota (very rough estimate) + */ + estimateStorageQuota() { + // Most browsers have 5-10MB localStorage quota + return 5; // MB + } + + /** + * Format bytes to human readable format + */ + formatBytes(bytes) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + /** + * Cleanup old or unnecessary data + */ + async cleanup() { + if (!this.isAvailable) return; + + console.log('🧹 Cleaning up storage...'); + + try { + const keysToRemove = []; + const cutoffTime = Date.now() - (7 * 24 * 60 * 60 * 1000); // 7 days ago + + // Find keys to remove + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(this.prefix)) { + try { + const value = localStorage.getItem(key); + const parsed = JSON.parse(value); + + // Remove old data + if (parsed.timestamp && parsed.timestamp < cutoffTime) { + keysToRemove.push(key); + } + + } catch (parseError) { + // Remove corrupted data + keysToRemove.push(key); + } + } + } + + // Remove identified keys + keysToRemove.forEach(key => { + localStorage.removeItem(key); + console.log(`🗑️ Cleaned up: ${key}`); + }); + + console.log(`🧹 Cleanup complete. Removed ${keysToRemove.length} items.`); + + } catch (error) { + console.error('❌ Cleanup failed:', error); + } + } + + /** + * Clear all application data + */ + async clearAll() { + if (!this.isAvailable) return false; + + console.log('🗑️ Clearing all storage data...'); + + try { + const keysToRemove = []; + + // Find all our keys + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(this.prefix)) { + keysToRemove.push(key); + } + } + + // Remove all keys + keysToRemove.forEach(key => { + localStorage.removeItem(key); + }); + + console.log(`🗑️ Cleared ${keysToRemove.length} items from storage`); + return true; + + } catch (error) { + console.error('❌ Failed to clear storage:', error); + return false; + } + } + + /** + * Export all data for backup + */ + async exportData() { + if (!this.isAvailable) return null; + + try { + const exportData = { + timestamp: Date.now(), + version: '1.0', + data: {} + }; + + // Export all our data + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(this.prefix)) { + const shortKey = key.replace(this.prefix, ''); + const value = localStorage.getItem(key); + exportData.data[shortKey] = JSON.parse(value); + } + } + + console.log('📤 Exported storage data'); + return exportData; + + } catch (error) { + console.error('❌ Failed to export data:', error); + return null; + } + } + + /** + * Import data from backup + */ + async importData(importData) { + if (!this.isAvailable) return false; + + try { + if (!importData.data || typeof importData.data !== 'object') { + throw new Error('Invalid import data format'); + } + + // Import each item + for (const [key, value] of Object.entries(importData.data)) { + const fullKey = this.getKey(key); + localStorage.setItem(fullKey, JSON.stringify(value)); + } + + console.log(`📥 Imported ${Object.keys(importData.data).length} items`); + return true; + + } catch (error) { + console.error('❌ Failed to import data:', error); + return false; + } + } + + /** + * Cleanup on destroy + */ + destroy() { + console.log('🧹 Storage Manager cleanup...'); + // Perform any necessary cleanup + // For now, just run a cleanup to free up space + this.cleanup(); + console.log('✅ Storage Manager cleanup complete'); + } +} \ No newline at end of file diff --git a/vna_system/web_ui/static/js/modules/ui.js b/vna_system/web_ui/static/js/modules/ui.js new file mode 100644 index 0000000..212ea33 --- /dev/null +++ b/vna_system/web_ui/static/js/modules/ui.js @@ -0,0 +1,529 @@ +/** + * UI Manager + * Handles user interface interactions and state management + */ + +export class UIManager { + constructor(notifications) { + this.notifications = notifications; + + // UI Elements + this.elements = { + connectionStatus: null, + processorToggles: null, + sweepCount: null, + dataRate: null, + clearChartsBtn: null, + exportBtn: null, + navButtons: null, + views: null + }; + + // State + this.currentView = 'dashboard'; + this.connectionStatus = 'disconnected'; + this.processors = new Map(); // processor_name -> { enabled, count } + + // Event handlers + this.eventHandlers = { + viewChange: [], + processorToggle: [], + clearCharts: [], + exportData: [] + }; + + // Statistics tracking + this.stats = { + sweepCount: 0, + dataRate: 0, + lastUpdate: null, + rateCalculation: { + samples: [], + windowMs: 10000 // 10 second window + } + }; + } + + /** + * Initialize UI Manager + */ + async init() { + console.log('🎨 Initializing UI Manager...'); + + // Get DOM elements + this.findElements(); + + // Set up event listeners + this.setupEventListeners(); + + // Initialize UI state + this.initializeUIState(); + + // Start update loop + this.startUpdateLoop(); + + console.log('✅ UI Manager initialized'); + } + + /** + * Find and cache DOM elements + */ + findElements() { + this.elements.connectionStatus = document.getElementById('connectionStatus'); + this.elements.processorToggles = document.getElementById('processorToggles'); + this.elements.sweepCount = document.getElementById('sweepCount'); + this.elements.dataRate = document.getElementById('dataRate'); + // clearChartsBtn removed + this.elements.exportBtn = document.getElementById('exportBtn'); + this.elements.navButtons = document.querySelectorAll('.nav-btn[data-view]'); + this.elements.views = document.querySelectorAll('.view'); + + // Validate required elements + const required = [ + 'connectionStatus', 'processorToggles', 'exportBtn' + ]; + + for (const element of required) { + if (!this.elements[element]) { + throw new Error(`Required UI element not found: ${element}`); + } + } + } + + /** + * Set up event listeners + */ + setupEventListeners() { + // Navigation + this.elements.navButtons.forEach(button => { + button.addEventListener('click', (e) => { + const view = e.currentTarget.dataset.view; + this.switchView(view); + }); + }); + + // Note: Clear charts functionality removed - use processor toggles instead + + // Export button + this.elements.exportBtn.addEventListener('click', () => { + this.triggerExportData(); + }); + + // Processor toggles container (event delegation) + this.elements.processorToggles.addEventListener('click', (e) => { + const toggle = e.target.closest('.processor-toggle'); + if (toggle) { + const processor = toggle.dataset.processor; + const isEnabled = toggle.classList.contains('processor-toggle--active'); + this.toggleProcessor(processor, !isEnabled); + } + }); + + // Window resize + window.addEventListener('resize', this.debounce(() => { + this.handleResize(); + }, 300)); + } + + /** + * Initialize UI state + */ + initializeUIState() { + // Set initial connection status + this.setConnectionStatus('disconnected'); + + // Initialize stats display + this.updateStatsDisplay(); + + // Set initial view + this.switchView(this.currentView); + + // Clean up any invalid processors + this.cleanupInvalidProcessors(); + } + + /** + * Clean up invalid/old processors + */ + cleanupInvalidProcessors() { + const invalidProcessors = ['sweep_counter']; + + invalidProcessors.forEach(processor => { + if (this.processors.has(processor)) { + console.log(`🧹 Removing invalid processor: ${processor}`); + this.processors.delete(processor); + } + }); + } + + /** + * Start update loop for real-time UI updates + */ + startUpdateLoop() { + setInterval(() => { + this.updateDataRateDisplay(); + }, 1000); + } + + /** + * Switch between views (Dashboard, Settings, Logs) + */ + switchView(viewName) { + if (this.currentView === viewName) return; + + console.log(`🔄 Switching to view: ${viewName}`); + + // Update navigation buttons + this.elements.navButtons.forEach(button => { + const isActive = button.dataset.view === viewName; + button.classList.toggle('nav-btn--active', isActive); + }); + + // Update views + this.elements.views.forEach(view => { + const isActive = view.id === `${viewName}View`; + view.classList.toggle('view--active', isActive); + }); + + this.currentView = viewName; + this.emitEvent('viewChange', viewName); + } + + /** + * Update connection status + */ + setConnectionStatus(status) { + if (this.connectionStatus === status) return; + + console.log(`🔌 Connection status: ${status}`); + this.connectionStatus = status; + + const statusElement = this.elements.connectionStatus; + const textElement = statusElement.querySelector('.status-indicator__text'); + + // Remove existing status classes + statusElement.classList.remove( + 'status-indicator--connected', + 'status-indicator--connecting', + 'status-indicator--disconnected' + ); + + // Add new status class and update text + switch (status) { + case 'connected': + statusElement.classList.add('status-indicator--connected'); + textElement.textContent = 'Connected'; + break; + case 'connecting': + statusElement.classList.add('status-indicator--connecting'); + textElement.textContent = 'Connecting...'; + break; + case 'disconnected': + default: + statusElement.classList.add('status-indicator--disconnected'); + textElement.textContent = 'Disconnected'; + break; + } + } + + /** + * Update processor toggles + */ + updateProcessorToggles(processors) { + if (!this.elements.processorToggles) return; + + // Clear existing toggles + this.elements.processorToggles.innerHTML = ''; + + // Create toggles for each processor + processors.forEach(processor => { + const toggle = this.createProcessorToggle(processor); + this.elements.processorToggles.appendChild(toggle); + }); + + // Update Lucide icons + if (typeof lucide !== 'undefined') { + lucide.createIcons({ attrs: { 'stroke-width': 1.5 } }); + } + } + + /** + * Create processor toggle element + */ + createProcessorToggle(processor) { + const toggle = document.createElement('div'); + toggle.className = `processor-toggle ${processor.enabled ? 'processor-toggle--active' : ''}`; + toggle.dataset.processor = processor.name; + + toggle.innerHTML = ` +
+
${this.formatProcessorName(processor.name)}
+
${processor.count || 0}
+ `; + + return toggle; + } + + /** + * Toggle processor state + */ + toggleProcessor(processorName, enabled) { + console.log(`🔧 Toggle processor ${processorName}: ${enabled}`); + + // Update processor state + const processor = this.processors.get(processorName) || { count: 0 }; + processor.enabled = enabled; + this.processors.set(processorName, processor); + + // Update UI only if elements exist + if (this.elements.processorToggles) { + const toggle = this.elements.processorToggles.querySelector(`[data-processor="${processorName}"]`); + if (toggle) { + toggle.classList.toggle('processor-toggle--active', enabled); + } else { + // Toggle element doesn't exist yet, refresh toggles + this.refreshProcessorToggles(); + } + } + + // Emit event + this.emitEvent('processorToggle', processorName, enabled); + } + + /** + * Update processor from incoming data + */ + updateProcessorFromData(processorName) { + let processor = this.processors.get(processorName); + + if (!processor) { + // New processor discovered + processor = { enabled: true, count: 0 }; + this.processors.set(processorName, processor); + this.refreshProcessorToggles(); + } + + // Increment count + processor.count++; + + // Update count display + const toggle = this.elements.processorToggles.querySelector(`[data-processor="${processorName}"]`); + if (toggle) { + const countElement = toggle.querySelector('.processor-toggle__count'); + if (countElement) { + countElement.textContent = processor.count; + } + } + } + + /** + * Refresh processor toggles display + */ + refreshProcessorToggles() { + const processors = Array.from(this.processors.entries()).map(([name, data]) => ({ + name, + enabled: data.enabled, + count: data.count + })); + + this.updateProcessorToggles(processors); + } + + /** + * Update statistics display + */ + updateStats(newStats) { + if (newStats.sweepCount !== undefined) { + this.stats.sweepCount = newStats.sweepCount; + } + + if (newStats.lastUpdate) { + this.updateDataRate(newStats.lastUpdate); + } + + this.updateStatsDisplay(); + } + + /** + * Update data rate calculation + */ + updateDataRate(timestamp) { + const now = timestamp.getTime(); + this.stats.rateCalculation.samples.push(now); + + // Remove samples outside the window + const cutoff = now - this.stats.rateCalculation.windowMs; + this.stats.rateCalculation.samples = this.stats.rateCalculation.samples + .filter(time => time > cutoff); + + // Calculate rate (samples per second) + const samplesInWindow = this.stats.rateCalculation.samples.length; + const windowSeconds = this.stats.rateCalculation.windowMs / 1000; + this.stats.dataRate = Math.round(samplesInWindow / windowSeconds * 10) / 10; + } + + /** + * Update statistics display elements + */ + updateStatsDisplay() { + if (this.elements.sweepCount) { + this.elements.sweepCount.textContent = this.stats.sweepCount.toLocaleString(); + } + + this.updateDataRateDisplay(); + } + + /** + * Update data rate display + */ + updateDataRateDisplay() { + if (this.elements.dataRate) { + this.elements.dataRate.textContent = `${this.stats.dataRate}/s`; + } + } + + /** + * Set processor enabled state (external API) + */ + setProcessorEnabled(processorName, enabled) { + if (!processorName) return; + + // If processor doesn't exist yet, store the preference for later + if (!this.processors.has(processorName)) { + const processor = { enabled, count: 0 }; + this.processors.set(processorName, processor); + return; + } + + this.toggleProcessor(processorName, enabled); + } + + /** + * Format processor name for display + */ + formatProcessorName(processorName) { + return processorName + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + // Clear charts functionality removed - use processor toggles + + /** + * Trigger export data action + */ + triggerExportData() { + this.emitEvent('exportData'); + } + + /** + * Handle window resize + */ + handleResize() { + console.log('📱 Window resized'); + // Trigger chart resize if needed + // This is handled by the chart manager + } + + /** + * Update system status (for future use) + */ + updateSystemStatus(statusData) { + console.log('📊 System status update:', statusData); + // Future implementation for system health monitoring + } + + /** + * Set theme (for future use) + */ + setTheme(theme) { + console.log(`🎨 Setting theme: ${theme}`); + document.documentElement.setAttribute('data-theme', theme); + } + + /** + * Get current theme + */ + getCurrentTheme() { + return document.documentElement.getAttribute('data-theme') || 'dark'; + } + + /** + * Event system + */ + onViewChange(callback) { + this.eventHandlers.viewChange.push(callback); + } + + onProcessorToggle(callback) { + this.eventHandlers.processorToggle.push(callback); + } + + onClearCharts(callback) { + this.eventHandlers.clearCharts.push(callback); + } + + onExportData(callback) { + this.eventHandlers.exportData.push(callback); + } + + /** + * Emit event to registered handlers + */ + emitEvent(eventType, ...args) { + if (this.eventHandlers[eventType]) { + this.eventHandlers[eventType].forEach(handler => { + try { + handler(...args); + } catch (error) { + console.error(`❌ Error in ${eventType} handler:`, error); + } + }); + } + } + + /** + * Utility: Debounce function + */ + debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + + /** + * Get UI statistics + */ + getStats() { + return { + currentView: this.currentView, + connectionStatus: this.connectionStatus, + processorsCount: this.processors.size, + ...this.stats + }; + } + + /** + * Cleanup + */ + destroy() { + console.log('🧹 Cleaning up UI Manager...'); + + // Clear event handlers + Object.keys(this.eventHandlers).forEach(key => { + this.eventHandlers[key] = []; + }); + + // Clear processors + this.processors.clear(); + + console.log('✅ UI Manager cleanup complete'); + } +} \ No newline at end of file diff --git a/vna_system/web_ui/static/js/modules/websocket.js b/vna_system/web_ui/static/js/modules/websocket.js new file mode 100644 index 0000000..4920341 --- /dev/null +++ b/vna_system/web_ui/static/js/modules/websocket.js @@ -0,0 +1,460 @@ +/** + * WebSocket Manager + * Handles real-time communication with the VNA backend + */ + +export class WebSocketManager { + constructor(config, notifications) { + this.config = config; + this.notifications = notifications; + this.ws = null; + this.isConnected = false; + this.isConnecting = false; + this.reconnectAttempts = 0; + this.reconnectTimer = null; + this.pingTimer = null; + this.lastPing = null; + this.eventListeners = new Map(); + + // Message queue for when disconnected + this.messageQueue = []; + this.maxQueueSize = 100; + + // Statistics + this.stats = { + messagesReceived: 0, + messagesPerSecond: 0, + lastMessageTime: null, + connectionTime: null, + bytesReceived: 0 + }; + + // Rate limiting for message processing + this.messageRateCounter = []; + this.rateWindowMs = 1000; + } + + /** + * Connect to WebSocket server + */ + async connect() { + if (this.isConnected || this.isConnecting) { + console.log('🔌 WebSocket already connected/connecting'); + return; + } + + try { + this.isConnecting = true; + this.emit('connecting'); + + console.log(`🔌 Connecting to WebSocket: ${this.config.url}`); + + this.ws = new WebSocket(this.config.url); + this.setupWebSocketEvents(); + + // Wait for connection or timeout + await this.waitForConnection(5000); + + } catch (error) { + this.isConnecting = false; + console.error('❌ WebSocket connection failed:', error); + this.handleConnectionError(error); + throw error; + } + } + + /** + * Wait for WebSocket connection with timeout + */ + waitForConnection(timeoutMs) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('WebSocket connection timeout')); + }, timeoutMs); + + const checkConnection = () => { + if (this.isConnected) { + clearTimeout(timeout); + resolve(); + } else if (this.ws?.readyState === WebSocket.CLOSED || + this.ws?.readyState === WebSocket.CLOSING) { + clearTimeout(timeout); + reject(new Error('WebSocket connection failed')); + } + }; + + // Check immediately and then every 100ms + checkConnection(); + const interval = setInterval(() => { + checkConnection(); + if (this.isConnected) { + clearInterval(interval); + } + }, 100); + + // Clean up interval on resolve/reject + const originalResolve = resolve; + const originalReject = reject; + resolve = (...args) => { + clearInterval(interval); + originalResolve(...args); + }; + reject = (...args) => { + clearInterval(interval); + originalReject(...args); + }; + }); + } + + /** + * Set up WebSocket event handlers + */ + setupWebSocketEvents() { + if (!this.ws) return; + + this.ws.onopen = (event) => { + console.log('✅ WebSocket connected'); + this.isConnected = true; + this.isConnecting = false; + this.reconnectAttempts = 0; + this.stats.connectionTime = new Date(); + + this.startPingPong(); + this.processPendingMessages(); + this.emit('connected', event); + + this.notifications.show({ + type: 'success', + title: 'Connected', + message: 'Real-time connection established' + }); + }; + + this.ws.onmessage = (event) => { + try { + this.handleMessage(event.data); + } catch (error) { + console.error('❌ Error processing WebSocket message:', error); + this.notifications.show({ + type: 'error', + title: 'Message Error', + message: 'Failed to process received data' + }); + } + }; + + this.ws.onerror = (error) => { + console.error('❌ WebSocket error:', error); + this.handleConnectionError(error); + }; + + this.ws.onclose = (event) => { + console.log('🔌 WebSocket closed:', event.code, event.reason); + this.handleDisconnection(event); + }; + } + + /** + * Handle incoming messages + */ + handleMessage(data) { + // Update statistics + this.stats.messagesReceived++; + this.stats.lastMessageTime = new Date(); + this.stats.bytesReceived += data.length; + + // Rate limiting check + this.updateMessageRate(); + + try { + let parsedData; + + // Handle different message types + if (typeof data === 'string') { + if (data === 'ping') { + this.handlePing(); + return; + } else if (data === 'pong') { + this.handlePong(); + return; + } else { + parsedData = JSON.parse(data); + } + } else { + // Handle binary data if needed + console.warn('⚠️ Received binary data, not implemented'); + return; + } + + // Emit data event + this.emit('data', parsedData); + + } catch (error) { + console.error('❌ Failed to parse WebSocket message:', error); + console.log('📝 Raw message:', data); + } + } + + /** + * Update message rate statistics + */ + updateMessageRate() { + const now = Date.now(); + this.messageRateCounter.push(now); + + // Remove messages outside the rate window + this.messageRateCounter = this.messageRateCounter.filter( + time => now - time < this.rateWindowMs + ); + + this.stats.messagesPerSecond = this.messageRateCounter.length; + } + + /** + * Handle ping message + */ + handlePing() { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send('pong'); + } + } + + /** + * Handle pong message + */ + handlePong() { + if (this.lastPing) { + const latency = Date.now() - this.lastPing; + console.log(`🏓 WebSocket latency: ${latency}ms`); + this.lastPing = null; + } + } + + /** + * Start ping-pong mechanism + */ + startPingPong() { + this.pingTimer = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.lastPing = Date.now(); + this.ws.send('ping'); + } + }, 30000); // Ping every 30 seconds + } + + /** + * Handle connection errors + */ + handleConnectionError(error) { + console.error('❌ WebSocket connection error:', error); + + if (!this.isConnected) { + this.notifications.show({ + type: 'error', + title: 'Connection Failed', + message: 'Unable to establish real-time connection' + }); + } + } + + /** + * Handle disconnection + */ + handleDisconnection(event) { + const wasConnected = this.isConnected; + this.isConnected = false; + this.isConnecting = false; + + // Clean up timers + if (this.pingTimer) { + clearInterval(this.pingTimer); + this.pingTimer = null; + } + + this.emit('disconnected', event); + + if (wasConnected) { + this.notifications.show({ + type: 'warning', + title: 'Disconnected', + message: 'Real-time connection lost. Attempting to reconnect...' + }); + + // Auto-reconnect + this.scheduleReconnect(); + } + } + + /** + * Schedule automatic reconnection + */ + scheduleReconnect() { + if (this.reconnectTimer) return; + + const delay = Math.min( + this.config.reconnectInterval * Math.pow(2, this.reconnectAttempts), + 30000 // Max 30 seconds + ); + + console.log(`🔄 Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts + 1})`); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.reconnect(); + }, delay); + } + + /** + * Manually trigger reconnection + */ + async reconnect() { + if (this.reconnectAttempts >= this.config.maxReconnectAttempts) { + console.error('❌ Max reconnection attempts reached'); + this.notifications.show({ + type: 'error', + title: 'Connection Failed', + message: 'Unable to reconnect after multiple attempts' + }); + return; + } + + this.reconnectAttempts++; + + // Close existing connection + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + try { + await this.connect(); + } catch (error) { + console.error(`❌ Reconnection attempt ${this.reconnectAttempts} failed:`, error); + this.scheduleReconnect(); + } + } + + /** + * Send message + */ + send(data) { + if (!this.isConnected || !this.ws) { + // Queue message for later + if (this.messageQueue.length < this.maxQueueSize) { + this.messageQueue.push(data); + console.log('📤 Message queued (not connected)'); + } else { + console.warn('⚠️ Message queue full, dropping message'); + } + return false; + } + + try { + const message = typeof data === 'string' ? data : JSON.stringify(data); + this.ws.send(message); + return true; + } catch (error) { + console.error('❌ Failed to send message:', error); + return false; + } + } + + /** + * Process pending messages after reconnection + */ + processPendingMessages() { + if (this.messageQueue.length > 0) { + console.log(`📤 Processing ${this.messageQueue.length} queued messages`); + + const messages = [...this.messageQueue]; + this.messageQueue = []; + + messages.forEach(message => { + this.send(message); + }); + } + } + + /** + * Disconnect WebSocket + */ + disconnect() { + console.log('🔌 Disconnecting WebSocket'); + + // Clear reconnection timer + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + // Clear ping timer + if (this.pingTimer) { + clearInterval(this.pingTimer); + this.pingTimer = null; + } + + // Close WebSocket + if (this.ws) { + this.ws.close(1000, 'Manual disconnect'); + this.ws = null; + } + + this.isConnected = false; + this.isConnecting = false; + this.reconnectAttempts = 0; + } + + /** + * Get connection statistics + */ + getStats() { + return { + ...this.stats, + isConnected: this.isConnected, + isConnecting: this.isConnecting, + reconnectAttempts: this.reconnectAttempts, + queuedMessages: this.messageQueue.length + }; + } + + /** + * Event listener management + */ + on(event, callback) { + if (!this.eventListeners.has(event)) { + this.eventListeners.set(event, []); + } + this.eventListeners.get(event).push(callback); + } + + off(event, callback) { + if (this.eventListeners.has(event)) { + const listeners = this.eventListeners.get(event); + const index = listeners.indexOf(callback); + if (index > -1) { + listeners.splice(index, 1); + } + } + } + + emit(event, data) { + if (this.eventListeners.has(event)) { + this.eventListeners.get(event).forEach(callback => { + try { + callback(data); + } catch (error) { + console.error(`❌ Error in event listener for ${event}:`, error); + } + }); + } + } + + /** + * Cleanup + */ + destroy() { + this.disconnect(); + this.eventListeners.clear(); + this.messageQueue = []; + } +} \ No newline at end of file diff --git a/vna_system/web_ui/templates/index.html b/vna_system/web_ui/templates/index.html new file mode 100644 index 0000000..85f8feb --- /dev/null +++ b/vna_system/web_ui/templates/index.html @@ -0,0 +1,139 @@ + + + + + + + + + VNA System Dashboard + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +

VNA System

+ Real-time Analysis +
+ +
+
+
+ Connecting... +
+ +
+
+ Sweeps: + 0 +
+
+ Rate: + 0/s +
+
+
+ + +
+
+ + +
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+ + +
+
+ +
+ +
+
+ +

No Data Yet

+

+ Waiting for sweep data from the VNA system... +

+
+
+
+
+
+
+
+ + +
+
+

Settings

+

Settings panel will be implemented in future versions.

+
+
+
+ + +
+ + + + + + \ No newline at end of file