From 7d382136c26215f5f11f11601f92df74e3548326 Mon Sep 17 00:00:00 2001 From: Ty Young Date: Tue, 24 Feb 2026 14:38:19 -0700 Subject: [PATCH 1/6] New gui --- BASE_STATION_GUI_DOCUMENTATION.md | 376 ------ GUI_README.md | 173 --- UI.py | 152 --- base_station_gui.py | 1910 ----------------------------- base_station_gui_old.py | 1900 ---------------------------- base_station_main.py | 7 +- constants.py | 1 - gui/BueTable.py | 84 ++ gui/CoordsTable.py | 19 + gui/DialogCancelTests.py | 75 ++ gui/DialogRunTests.py | 106 ++ gui/DistanceTable.py | 32 + gui/LogViewerWidget.py | 341 +++++ gui/MapManager.py | 147 +++ gui/main.py | 157 +++ gui/requirements.txt | 23 + gui/ui/DialogCancelTestsUi.py | 128 ++ gui/ui/DialogRunTestsUi.py | 72 ++ gui/ui/MainWindowUi.py | 299 +++++ gui/ui/dialog_cancel_tests.ui | 107 ++ gui/ui/dialog_run_tests.ui | 184 +++ gui/ui/image.png | Bin 0 -> 220553 bytes gui/ui/main_window.ui | 410 +++++++ gui_test_tkinter.py | 248 ---- launch_gui.sh | 60 - main_ui.py | 276 ----- real_base_station_gui.py | 830 ------------- setup/lake_test_setup.txt | 46 - setup/requirements.txt | 44 +- simplified_test_dialog.py | 285 ----- tker.py | 186 --- 31 files changed, 2205 insertions(+), 6473 deletions(-) delete mode 100644 BASE_STATION_GUI_DOCUMENTATION.md delete mode 100644 GUI_README.md delete mode 100644 UI.py delete mode 100644 base_station_gui.py delete mode 100644 base_station_gui_old.py create mode 100644 gui/BueTable.py create mode 100644 gui/CoordsTable.py create mode 100644 gui/DialogCancelTests.py create mode 100644 gui/DialogRunTests.py create mode 100644 gui/DistanceTable.py create mode 100644 gui/LogViewerWidget.py create mode 100644 gui/MapManager.py create mode 100644 gui/main.py create mode 100644 gui/requirements.txt create mode 100644 gui/ui/DialogCancelTestsUi.py create mode 100644 gui/ui/DialogRunTestsUi.py create mode 100644 gui/ui/MainWindowUi.py create mode 100644 gui/ui/dialog_cancel_tests.ui create mode 100644 gui/ui/dialog_run_tests.ui create mode 100644 gui/ui/image.png create mode 100644 gui/ui/main_window.ui delete mode 100644 gui_test_tkinter.py delete mode 100755 launch_gui.sh delete mode 100644 main_ui.py delete mode 100755 real_base_station_gui.py delete mode 100644 setup/lake_test_setup.txt delete mode 100644 simplified_test_dialog.py delete mode 100644 tker.py diff --git a/BASE_STATION_GUI_DOCUMENTATION.md b/BASE_STATION_GUI_DOCUMENTATION.md deleted file mode 100644 index ee66b20..0000000 --- a/BASE_STATION_GUI_DOCUMENTATION.md +++ /dev/null @@ -1,376 +0,0 @@ -# Base Station GUI Documentation - -## Overview - -This documentation covers the comprehensive Base Station GUI system developed for managing bUE (Base Unit Equipment) connections, testing, monitoring, and control. The system provides a complete graphical interface to replace the original terminal-based interface with enhanced functionality and real-time monitoring capabilities. - -## Table of Contents - -1. [System Architecture](#system-architecture) -2. [File Structure](#file-structure) -3. [Core Features](#core-features) -4. [GUI Components](#gui-components) -5. [Implementation Details](#implementation-details) -6. [Key Improvements Made](#key-improvements-made) -7. [Usage Instructions](#usage-instructions) -8. [Technical Specifications](#technical-specifications) -9. [Troubleshooting](#troubleshooting) - -## System Architecture - -The Base Station GUI system consists of several interconnected components: - -``` -┌─────────────────────────────────────────────┐ -│ Main GUI │ -│ ┌─────────────┐ ┌─────────────┐ ┌────────┐ │ -│ │ bUE List │ │ Map │ │ Tables │ │ -│ │ & Controls │ │ Display │ │ & Data │ │ -│ └─────────────┘ └─────────────┘ └────────┘ │ -│ ┌─────────────────────────────────────────┐ │ -│ │ Messages Display │ │ -│ └─────────────────────────────────────────┘ │ -└─────────────────────────────────────────────┘ - │ - ┌──────▼───────┐ - │ Base Station │ - │ Main │ - └──────┬───────┘ - │ - ┌──────▼───────┐ - │ OTA │ - │ Communication│ - └──────────────┘ -``` - -## File Structure - -### Core Files - -- **`real_base_station_gui.py`** - Main GUI application for production use with actual bUEs -- **`base_station_gui.py`** - Original GUI implementation with all dialog classes -- **`gui_test_tkinter.py`** - Test GUI with mock data for development and demonstration -- **`base_station_main.py`** - Core base station logic and bUE management -- **`constants.py`** - System constants and bUE mappings -- **`ota.py`** - Over-the-air communication handling - -### Configuration Files - -- **`config_base.yaml`** - Base station configuration file -- **`auto_config.yaml`** - Universal configuration for bUE matching - -### Support Files - -- **`launch_gui.sh`** - GUI launcher script with environment setup -- **`test_base_station.py`** - Standalone base station testing utility -- **`GUI_README.md`** - Quick start guide - -## Core Features - -### 1. Real-time bUE Monitoring -- **Connection Status**: Live display of connected bUEs with status indicators -- **Ping Monitoring**: Real-time ping status (Good 🟢, Warning 🟡, Lost 🔴) -- **Test Status**: Current testing state (Testing 🧪, Idle 💤) -- **Automatic Updates**: GUI refreshes every second with current data - -### 2. Interactive Map Display -- **GPS Visualization**: Real-time plotting of bUE locations -- **Custom Markers**: Add, manage, and pair markers with specific bUEs -- **Proximity Detection**: bUEs turn green when within 20 meters of paired markers -- **Dynamic Scaling**: Automatic map bounds calculation with padding - -### 3. Comprehensive Data Tables -- **Coordinates Table**: Live GPS coordinates for all connected bUEs -- **Distance Calculations**: - - bUE-to-bUE distances using great circle calculations - - bUE-to-marker distances for paired custom markers -- **Precision Display**: Distance measurements in meters with 2-decimal precision - -### 4. Advanced Message System -- **Unlimited History**: Removed 10-message cap for complete message logging -- **Auto-scroll**: Automatically scrolls to show newest messages -- **Real-time Updates**: Live display of all bUE communications and test outputs - -### 5. bUE Management Controls -- **Context Menu Operations**: - - Disconnect: Remove bUE from active connections - - Reload: Restart bUE service (systemctl restart) - - Restart: Full system reboot - - Open Log File: View individual bUE logs in integrated viewer -- **Smart Selection**: Click empty space to deselect all items -- **Auto-dismiss Menus**: Context menus close when clicking elsewhere - -### 6. Test Management System -- **Comprehensive Test Dialog**: Configure and run tests on multiple bUEs -- **Script Selection**: Choose from available test scripts -- **Timing Control**: Delay-based test scheduling with automatic time calculation -- **Multi-bUE Support**: Select which bUEs run which tests -- **Test Cancellation**: Cancel running tests with confirmation dialogs - -### 7. Log Management -- **Integrated Log Viewer**: Built-in log file viewing with auto-refresh -- **Individual bUE Logs**: Dedicated log files for each bUE (logs/bue_{id}.log) -- **Base Station Logs**: Centralized base station logging -- **Auto-refresh**: Log viewers update automatically as files change - -## GUI Components - -### Layout Structure -The GUI uses a responsive grid-based layout with four main sections: - -``` -┌─────────────┬─────────────────┬─────────────┐ -│ bUE List │ │ │ -│ & Controls │ Map │ Tables │ -│ │ │ │ -├─────────────┼─────────────────┤ │ -│ Status & │ Messages │ │ -│ Controls │ │ │ -└─────────────┴─────────────────┴─────────────┘ -``` - -### Left Panel - bUE Management -- **Connected bUEs TreeView**: Hierarchical display with columns for ID, Status, and Ping Status -- **Base Station Controls**: Connection status indicator (always shows "🟢 LISTENING FOR bUEs") -- **Test Controls**: Run Test and Cancel Tests buttons -- **Log Controls**: Access to base station logs -- **Map Controls**: Add and manage custom markers - -### Center Top Panel - Interactive Map -- **Canvas Display**: Dynamic coordinate plotting with auto-scaling -- **Legend**: Visual indicators for bUEs (🔵), Markers (📍), and proximity (🟢) -- **Real-time Updates**: Live position tracking and marker display - -### Center Bottom Panel - Messages -- **Scrollable Text Area**: Unlimited message history with auto-scroll -- **Clear Function**: Button to clear message history -- **Real-time Feed**: Live display of all bUE communications - -### Right Panel - Data Tables -- **Coordinates Table**: Live GPS data for all connected bUEs -- **Distance Table**: Calculated distances between bUEs and to paired markers -- **Auto-refresh**: Tables update automatically with new data - -## Implementation Details - -### Threading Architecture -```python -Main GUI Thread -├── Update Loop Thread (1Hz refresh) -├── Base Station Threads -│ ├── Message Queue Handler -│ ├── Ping Queue Handler -│ └── State Machine Tick -└── Dialog Threads (as needed) -``` - -### Smart Update System -The GUI implements intelligent updating to minimize performance impact: - -1. **Selective Updates**: Only rebuilds bUE list when connections change -2. **In-place Updates**: Updates status/ping values without clearing selections -3. **Selection Preservation**: Maintains user selections across updates -4. **Content Change Detection**: Only updates messages when content actually changes - -### Distance Calculation -Uses the Haversine formula for accurate great circle distance calculations: -```python -def calculate_distance(self, lat1, lon1, lat2, lon2): - R = 6371000 # Earth's radius in meters - # Haversine formula implementation - return R * c # Distance in meters -``` - -### Error Handling -Comprehensive error handling throughout: -- **tkinter Errors**: Proper cleanup of context menus and bindings -- **Communication Errors**: Graceful handling of OTA message failures -- **Data Validation**: Coordinate validation and range checking -- **File Operations**: Safe log file access with error recovery - -## Key Improvements Made - -### 1. Message System Enhancement -**Problem**: Original system limited to 10 messages -**Solution**: Removed `maxlen=10` from `deque()` in `base_station_main.py` -**Impact**: Complete message history available for debugging and monitoring - -### 2. Context Menu Auto-dismiss -**Problem**: Context menus stayed open, blocking interface -**Solution**: Implemented click-elsewhere detection with proper binding cleanup -**Impact**: Intuitive menu behavior matching desktop standards - -### 3. Selection Persistence -**Problem**: GUI updates cleared selections every second -**Solution**: Smart update system that preserves selections during refreshes -**Impact**: Right-click operations work reliably without timing issues - -### 4. bUE Reload Functionality -**Problem**: Typo in `bue_main.py` calling non-existent method -**Solution**: Fixed `reload_service_service()` → `reload_service()` -**Impact**: Reload functionality now works correctly alongside restart - -### 5. Proximity Detection -**Problem**: No visual indication when bUEs reach target locations -**Solution**: 20-meter proximity detection with color change (blue → green) -**Impact**: Clear visual feedback for bUE positioning accuracy - -### 6. Distance Table Enhancement -**Problem**: Only showed bUE-to-bUE distances -**Solution**: Added bUE-to-marker distance calculations -**Impact**: Complete distance monitoring for all relevant pairs - -### 7. Selection Management -**Problem**: bUEs remained selected (blue) even when not needed -**Solution**: Click empty space to deselect all items -**Impact**: Clean interface with intuitive selection behavior - -## Usage Instructions - -### Starting the GUI - -1. **Production Use**: - ```bash - python3 real_base_station_gui.py [config_file] - ``` - Default config: `config_base.yaml` - -2. **Testing/Demo**: - ```bash - python3 gui_test_tkinter.py - ``` - Uses mock data for demonstration - -3. **With Launcher Script**: - ```bash - ./launch_gui.sh - ``` - Includes environment setup - -### Basic Operations - -1. **Monitor bUEs**: View connected bUEs in left panel with real-time status -2. **Manage bUEs**: Right-click any bUE for context menu (disconnect, reload, restart, logs) -3. **View Locations**: Monitor bUE positions on interactive map -4. **Add Markers**: Use "Add Custom Marker" to create reference points -5. **Run Tests**: Click "Run Test" to configure and execute test scripts -6. **View Messages**: Monitor all communications in messages panel -7. **Check Distances**: View calculated distances in right panel tables - -### Advanced Features - -1. **Custom Markers**: - - Add markers with GPS coordinates - - Pair markers with specific bUEs - - Visual proximity indication (20m threshold) - -2. **Test Management**: - - Select multiple bUEs for testing - - Choose different scripts per bUE - - Set test start delays - - Monitor test progress and results - -3. **Log Analysis**: - - Individual bUE log files - - Auto-refreshing log viewers - - Centralized base station logging - -## Technical Specifications - -### System Requirements -- **Python**: 3.8+ (tested with 3.12) -- **OS**: Linux (Ubuntu/Debian tested) -- **GUI**: tkinter (standard library) -- **Hardware**: Serial ports for OTA communication - -### Dependencies -```python -# Standard Library -tkinter, threading, time, math, os, sys -datetime, collections, queue - -# Third-party -loguru # Logging -PyYAML # Configuration -geopy # Distance calculations -pyserial # Serial communication -``` - -### Performance Characteristics -- **Update Frequency**: 1 Hz (every second) -- **Memory Usage**: Grows with message history (unlimited) -- **CPU Usage**: Low (< 5% typical) -- **Response Time**: < 100ms for UI operations - -### Configuration Parameters -```yaml -# config_base.yaml -OTA_PORT: "/dev/ttyUSB0" # Serial port for communication -OTA_BAUDRATE: 9600 # Serial communication speed -OTA_ID: 1 # Base station identifier -``` - -### Distance Calculation Accuracy -- **Method**: Haversine formula (great circle distance) -- **Precision**: Sub-meter accuracy for typical ranges -- **Range**: Effective for 0.1m to 1000km distances -- **Proximity Threshold**: 20 meters (configurable) - -## Troubleshooting - -### Common Issues - -1. **"Config file not found"** - - Ensure `config_base.yaml` exists in project directory - - Check file permissions and path - -2. **No bUEs connecting** - - Verify serial port configuration in config file - - Check physical connections and bUE power - - Monitor base station logs for connection attempts - -3. **Context menu errors** - - Fixed in current version with proper error handling - - If issues persist, check tkinter version compatibility - -4. **Map not displaying** - - Requires valid GPS coordinates from connected bUEs - - Check bUE GPS functionality and coordinate validity - -5. **Tests not starting** - - Ensure bUEs are connected and not already testing - - Verify test scripts exist and are executable - - Check OTA communication functionality - -### Debug Information - -Enable debug logging by checking the console output and log files: -- **Base Station Log**: `logs/base_station.log` -- **Individual bUE Logs**: `logs/bue_{id}.log` -- **Console Output**: Real-time debug messages - -### Performance Optimization - -For systems with many bUEs (>10): -1. Consider increasing update interval in `update_loop()` -2. Implement message history limits if memory usage becomes excessive -3. Monitor system resources and adjust accordingly - -## Future Enhancement Opportunities - -1. **Configuration GUI**: Visual configuration editor -2. **Historical Data**: Database storage for long-term analysis -3. **Export Functionality**: Data export in various formats -4. **Advanced Mapping**: Satellite imagery integration -5. **Alert System**: Configurable notifications for events -6. **Multi-base Station**: Support for multiple base stations -7. **Web Interface**: Browser-based remote access - ---- - -**Documentation Version**: 1.0 -**Last Updated**: July 23, 2025 -**System Version**: Production Ready -**Author**: AI Assistant working with Ty Young - -For technical support or feature requests, refer to the project repository and issue tracking system. diff --git a/GUI_README.md b/GUI_README.md deleted file mode 100644 index 9cb41b0..0000000 --- a/GUI_README.md +++ /dev/null @@ -1,173 +0,0 @@ -# Base Station GUI with Interactive Maps - -This script provides a GUI interface for controlling the base station with actual bUEs, featuring both interactive and simple map displays. - -## New Map Features - -### Interactive Map (Default) -- **TkinterMapView Integration** - Uses real satellite/street map tiles -- **Auto-centering** - Map automatically centers on bUE locations -- **Auto-zoom** - Intelligent zoom based on coordinate spread -- **Real-time Updates** - Map updates as bUEs move -- **Map Toggle** - Switch between interactive and simple map modes - -### Installation - -#### Option 1: Automatic Installation (Recommended) -```bash -python3 install_map_view.py -``` - -#### Option 2: Manual Installation -```bash -pip install tkintermapview -``` - -#### Option 3: No Installation Required -The GUI automatically falls back to a simple canvas map if TkinterMapView is not available. - -## Usage - -### Option 1: Using the launcher script (Recommended) -```bash -./launch_gui.sh # Uses config_base.yaml -./launch_gui.sh my_config.yaml # Uses custom config file -``` - -### Option 2: Direct Python execution -```bash -python3 base_station_gui.py # Uses config_base.yaml -python3 base_station_gui.py my_config.yaml # Uses custom config file -``` - -## Features - -### Map Display (Enhanced) -- **Interactive Map** - Real satellite/street map view (default when available) -- **Simple Canvas Map** - Fallback coordinate plot view -- **Map Toggle Button** - Switch between map types on the fly -- **Auto-positioning** - Map centers automatically on bUE locations -- **Smart Zoom** - Zoom level adjusts based on coordinate spread -- **Status Indicators** - Shows which map type is currently active - -### Connection Management -- **Start/Stop Listening** - Control whether the base station accepts new connections -- **Connected bUEs List** - Shows all connected bUEs with status indicators -- **Right-click Context Menu** - Disconnect, reload, restart, or view logs for any bUE - -### Testing -- **Run Tests** - Configure and execute tests on connected bUEs -- **Cancel Tests** - Stop running tests -- **Test Delay** - Set how long to wait before starting tests - -### Monitoring -- **Real-time Map** - Shows bUE locations based on actual GPS coordinates -- **Custom Markers** - Add and manage custom markers on the map -- **Proximity Detection** - bUEs change color when close to paired markers -- **Data Tables** - Live coordinates and distance calculations -- **Message Display** - Real-time message log from base station - -### Log Viewing -- **Base Station Log** - View base station logs within the GUI -- **Individual bUE Logs** - View logs for specific bUEs -- **Auto-refresh** - Logs update automatically as new messages arrive - -## Key Differences from Test GUI - -1. **Real Base Station Integration** - Uses actual `Base_Station_Main` instance -2. **Connection Controls** - Start/stop listening for bUE connections -3. **Error Handling** - Robust error handling for real-world conditions -4. **Configuration Support** - Can use different config files -5. **Log Integration** - Works with actual log files in `logs/` directory - -## Prerequisites - -1. **Configuration File** - Ensure you have a valid config file (e.g., `config_base.yaml`) -2. **Virtual Environment** - The launcher will try to activate `uw_env` if it exists -3. **Log Directory** - Ensure `logs/` directory exists for log file viewing -4. **Required Python Packages** - Same as your existing base station setup - -## Troubleshooting - -### GUI Won't Start -- Check that config file exists and is valid -- Verify all required Python packages are installed -- Check that `base_station_main.py` and `constants.py` are present - -### No bUEs Connecting -**CRITICAL**: The base station must be properly listening for connections. - -1. **Check the Status Indicator**: The GUI should show "🟢 LISTENING FOR bUEs" in green -2. **Verify Config File**: Make sure your `config_base.yaml` has correct network settings -3. **Test Base Station Independently**: - ```bash - python3 test_base_station.py - ``` - This will run a simple test to verify the base station is working without the GUI -4. **Check bUE Configuration**: Ensure your bUEs are configured to connect to the correct base station address/port -5. **Monitor Logs**: Check `logs/base_station.log` for connection attempts and errors -6. **Compare with Working Terminal Interface**: Your terminal `main_ui.py` should work - if it doesn't, the issue is with the base station setup, not the GUI - -### GUI Shows "Not Listening" -- The base station automatically starts listening when initialized -- If it shows "Not Listening", click "Start Listening" button -- Check the base station log for any initialization errors - -### Map Not Showing bUEs -- bUEs must send GPS coordinates to appear on map -- Check that bUEs have valid GPS fixes -- Verify coordinate format matches expected format (latitude, longitude as strings/floats) - -### Interactive Map Issues -- **Installation**: Run `python3 install_map_view.py` to install TkinterMapView -- **Internet Connection**: Interactive map requires internet for map tiles -- **Fallback Available**: GUI automatically uses simple map if interactive map fails -- **Toggle Maps**: Use "Switch to Simple Map" button if interactive map has issues - -### Map Toggle Not Working -- Ensure TkinterMapView is properly installed -- Check console output for error messages -- Try restarting the application -- Use the simple map as fallback if needed - -### Performance Issues -- Interactive map may be slower on older systems -- Switch to simple map for better performance -- Check internet connection speed for map tile loading - -### Logs Not Opening -- Ensure `logs/` directory exists -- Check file permissions on log files -- Verify log file paths match those used by your base station - -## Testing Connection Issues - -### Step 1: Test Base Station Independently -```bash -python3 test_base_station.py -``` -This script mimics your working `main_ui.py` structure and will show: -- If the base station initializes correctly -- If it's listening for connections -- Real-time connection status -- Any bUE connection attempts - -### Step 2: Compare with Terminal Interface -If the test script above doesn't work, but your `main_ui.py` does work, then: -1. Check the differences in configuration -2. Ensure the same config file is being used -3. Verify the same Python environment - -### Step 3: Use the GUI -Once the test script shows bUEs connecting, the GUI should work identically. - -## Integration Notes - -This GUI is designed to work alongside your existing base station infrastructure. It: - -- Uses the same `Base_Station_Main` class as your terminal interface -- Works with the same config files and log files -- Maintains compatibility with existing bUE communication protocols -- Can be used as a drop-in replacement for the terminal interface - -The GUI provides all the functionality you had in `main_ui.py` but with a modern, user-friendly interface. diff --git a/UI.py b/UI.py deleted file mode 100644 index 8c251bc..0000000 --- a/UI.py +++ /dev/null @@ -1,152 +0,0 @@ -from datetime import datetime - -from rich.table import Table -from rich.console import Group -from rich.panel import Panel - -from constants import TIMEOUT, bUEs - - -def bue_status_table(base_station) -> Table: - """Make a styled table of connected bUEs.""" - table = Table(title="📡 Connected bUEs", show_header=True, header_style="bold cyan") - table.add_column("bUE ID", style="green", no_wrap=True, justify="center") - table.add_column("Status", style="yellow", justify="center") - - for bue in base_station.connected_bues: - status = "🧪 Testing" if bue in getattr(base_station, "testing_bues", []) else "💤 Idle" - table.add_row(bUEs[str(bue)], status) - - if not base_station.connected_bues: - table.add_row("[dim]No bUEs connected[/dim]", "[dim]N/A[/dim]") - - return table - - -def bue_ping_table(base_station) -> Table: - """Make a styled table of connected bUEs.""" - table = Table(title="🏓 bUE PINGs", show_header=True, header_style="bold cyan") - table.add_column("bUE ID", style="green", no_wrap=True, justify="center") - table.add_column("Receiving PINGs", style="yellow", justify="center") - - for bue in base_station.connected_bues: - if base_station.bue_timeout_tracker[bue] >= TIMEOUT / 2: - ping_status = "🟢 Good" - elif base_station.bue_timeout_tracker[bue] > 0: - ping_status = "🟡 Warning" - else: - ping_status = "🔴 Lost" - - table.add_row(bUEs[str(bue)], str(ping_status)) - - if not base_station.connected_bues: - table.add_row("[dim]No bUEs connected[/dim]", "[dim]N/A[/dim]") - - return table - - -def bue_coordinates_table(base_station) -> Table: - """Make a styled coordinates table.""" - table = Table(title="🌍 bUE Coordinates", show_header=True, header_style="bold blue") - table.add_column("bUE ID", style="cyan", justify="center") - table.add_column("Coordinates", style="yellow", justify="left") - - for bue in base_station.connected_bues: - if bue in base_station.bue_coordinates: - coords = base_station.bue_coordinates[bue] - table.add_row(bUEs[str(bue)], str(coords)) - - if not base_station.bue_coordinates: - table.add_row("[dim]No coordinates available[/dim]", "[dim]N/A[/dim]") - - return table - - -def bue_distance_table(base_station) -> Table: - """Make a styled distance table.""" - table = Table(title="📏 bUE Distances", show_header=True, header_style="bold blue") - table.add_column("bUE Pair", style="cyan", justify="center") - table.add_column("Distance", style="yellow", justify="left") - - # Use a set to avoid duplicate pairs - processed_pairs = set() - - for bue1 in base_station.connected_bues: - for bue2 in base_station.connected_bues: - if ( - bue1 != bue2 - and bue1 in base_station.bue_coordinates - and bue2 in base_station.bue_coordinates - and (bue1, bue2) not in processed_pairs - and (bue2, bue1) not in processed_pairs - ): - - dist = base_station.get_distance(bue1, bue2) - - try: - dist = base_station.get_distance(bue1, bue2) - if dist is not None: - table.add_row(f"{bUEs[str(bue1)]} ↔ {bUEs[str(bue2)]}", f"{dist:.2f}m") - else: - table.add_row( - f"{bUEs[str(bue1)]} ↔ {bUEs[str(bue2)]}", - "[red]Invalid coordinates[/red]", - ) - except Exception as e: - table.add_row( - f"{bUEs[str(bue1)]} ↔ {bUEs[str(bue2)]}", - f"[red]Error: {str(e)}[/red]", - ) - - # Mark this pair as processed - processed_pairs.add((bue1, bue2)) - - if not base_station.bue_coordinates or len(processed_pairs) == 0: - table.add_row("[dim]No distances available[/dim]", "[dim]N/A[/dim]") - - return table - - -def received_messages_table(base_station) -> Table: - """Make a styled coordinates table.""" - table = Table(title="💌 Received Messages", show_header=True, header_style="bold blue") - table.add_column("Messages", style="cyan", justify="center") - - for message in base_station.stdout_history: - table.add_row(message) - - if not base_station.stdout_history: - table.add_row("[dim]No messages[/dim]") - - return table - - -def create_compact_dashboard(base_station): - """Create a compact dashboard without Layout.""" - # Header - current_time = datetime.now().strftime("%H:%M:%S") - connected_count = len(base_station.connected_bues) - testing_count = len(getattr(base_station, "testing_bues", [])) - - header_text = f"🏢 Base Station Dashboard - {current_time} | Connected: {connected_count} | Testing: {testing_count}" - header = Panel(header_text, style="bold white on blue", padding=(0, 1)) - - connected_table = bue_status_table(base_station) - coordinates_table = bue_coordinates_table(base_station) - distance_table = bue_distance_table(base_station) - ping_table = bue_ping_table(base_station) - received_messages = received_messages_table(base_station) - - from rich.columns import Columns - - tables = Columns( - [ - connected_table, - ping_table, - coordinates_table, - distance_table, - received_messages, - ] - ) - - return Group(header, tables) diff --git a/base_station_gui.py b/base_station_gui.py deleted file mode 100644 index 9c28fb5..0000000 --- a/base_station_gui.py +++ /dev/null @@ -1,1910 +0,0 @@ -""" -base_station_gui.py -Ty Young - -A comprehensive GUI for the base station using tkinter. -This GUI provides all the functionality of main_ui.py but with a graphical interface. - -Features: -- Connected bUEs menu with status indicators -- Right-click context menu for bUE operations (disconnect, reload, restart, open logs) -- Interactive map showing bUE locations -- Custom markers that can be paired with bUEs -- Color-coded proximity indicators (changes when bUEs are within 10-20m of markers) -- Coordinates table -- Distance table between bUEs -- Received messages table -- Base station log file viewer -""" - -import tkinter as tk -from tkinter import ttk, messagebox, filedialog, scrolledtext -import threading -import time -import os -import subprocess -import platform -from datetime import datetime, date, timedelta -from loguru import logger -import math - -# Try to import TkinterMapView, fallback to canvas if not available -try: - import tkintermapview - from PIL import Image, ImageDraw, ImageTk - - MAP_VIEW_AVAILABLE = True - print("TkinterMapView is available - using interactive map") -except ImportError: - MAP_VIEW_AVAILABLE = False - print("TkinterMapView not available - using fallback canvas map") - -from base_station_main import Base_Station_Main - -# from constants import bUEs, TIMEOUT - - -class BaseStationGUI: - def __init__(self, root): - self.root = root - self.root.title("Base Station Control Panel") - self.root.geometry("1400x900") - - # Initialize base station - self.base_station = None - self.update_thread = None - self.running = False - - # Custom markers for the map - self.custom_markers = {} # {marker_id: {'name': str, 'lat': float, 'lon': float, 'paired_bue': int}} - self.marker_counter = 0 - - # Map configuration - self.use_interactive_map = MAP_VIEW_AVAILABLE - self.map_widget = None # Will hold TkinterMapView or canvas - self.map_markers = {} # Track markers on the interactive map - self.last_bue_positions = {} # Track last known bUE positions to detect changes - self.map_auto_positioned = False # Track if we've done initial positioning - - # Setup GUI - self.setup_gui() - - # Start base station - self.start_base_station() - - def create_circle_marker_icon(self, color, size=20): - """Create a custom circular marker icon similar to canvas map""" - if not MAP_VIEW_AVAILABLE: - return None - - try: - # Create image with transparent background - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - - # Draw outer circle (border) - border_color = "darkblue" if color in ["blue", "green"] else "darkred" - draw.ellipse([0, 0, size - 1, size - 1], fill=color, outline=border_color, width=2) - - # Convert to PhotoImage for tkinter - return ImageTk.PhotoImage(img) - except Exception as e: - logger.error(f"Error creating circle marker icon: {e}") - return None - - # Handle window close - self.root.protocol("WM_DELETE_WINDOW", self.on_closing) - - def setup_gui(self): - """Setup the main GUI layout with all panels always visible""" - # Increase window size to accommodate all panels - self.root.geometry("1600x1000") - - # Create main container with grid layout - main_frame = ttk.Frame(self.root) - main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Configure grid weights for responsive resizing - main_frame.grid_columnconfigure(0, weight=1) # Left column - main_frame.grid_columnconfigure(1, weight=2) # Middle column (map) - main_frame.grid_columnconfigure(2, weight=1) # Right column - main_frame.grid_rowconfigure(0, weight=1) # Top row - main_frame.grid_rowconfigure(1, weight=1) # Bottom row - - # Left panel - bUE list and controls - left_frame = ttk.Frame(main_frame) - left_frame.grid(row=0, column=0, rowspan=2, sticky="nsew", padx=(0, 2)) - self.setup_left_panel(left_frame) - - # Middle top panel - Map - map_frame = ttk.LabelFrame(main_frame, text="bUE Location Map") - map_frame.grid(row=0, column=1, sticky="nsew", padx=2) - self.setup_map_view(map_frame) - - # Middle bottom panel - Messages - messages_frame = ttk.LabelFrame(main_frame, text="Messages") - messages_frame.grid(row=1, column=1, sticky="nsew", padx=2, pady=(2, 0)) - self.setup_messages_view(messages_frame) - - # Right panel - Data tables - tables_frame = ttk.Frame(main_frame) - tables_frame.grid(row=0, column=2, rowspan=2, sticky="nsew", padx=(2, 0)) - self.setup_tables_view(tables_frame) - - # Status bar - self.status_var = tk.StringVar() - self.status_var.set("Initializing...") - status_bar = ttk.Label(self.root, textvariable=self.status_var, relief=tk.SUNKEN) - status_bar.pack(side=tk.BOTTOM, fill=tk.X) - - def setup_left_panel(self, parent): - """Setup the left panel with bUE list and controls""" - # bUE List Frame - bue_frame = ttk.LabelFrame(parent, text="Connected bUEs") - bue_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # bUE Treeview - self.bue_tree = ttk.Treeview(bue_frame, columns=("status", "ping"), show="tree headings") - self.bue_tree.heading("#0", text="bUE ID") - self.bue_tree.heading("status", text="State") - self.bue_tree.heading("ping", text="Missed Pings") - - self.bue_tree.column("#0", width=100) - self.bue_tree.column("status", width=100) - self.bue_tree.column("ping", width=100) - - # Scrollbar for treeview - bue_scrollbar = ttk.Scrollbar(bue_frame, orient=tk.VERTICAL, command=self.bue_tree.yview) - self.bue_tree.configure(yscrollcommand=bue_scrollbar.set) - - self.bue_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - bue_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - # Bind right-click context menu (cross-platform) - if platform.system() == "Darwin": # macOS - self.bue_tree.bind("", self.show_bue_context_menu) # macOS right-click - self.bue_tree.bind("", self.show_bue_context_menu) # macOS Ctrl+click alternative - else: # Linux/Windows - self.bue_tree.bind("", self.show_bue_context_menu) # Standard right-click - - # Control buttons frame - control_frame = ttk.LabelFrame(parent, text="Controls") - control_frame.pack(fill=tk.X, padx=5, pady=5) - - # Test button - ttk.Button(control_frame, text="Run Test", command=self.run_test).pack(fill=tk.X, pady=2) - - # Cancel test button - ttk.Button(control_frame, text="Cancel Tests", command=self.cancel_tests).pack(fill=tk.X, pady=2) - - # Open base station log - ttk.Button(control_frame, text="Open Base Station Log", command=self.open_base_log).pack(fill=tk.X, pady=2) - - # Map controls frame - map_control_frame = ttk.LabelFrame(parent, text="Map Controls") - map_control_frame.pack(fill=tk.X, padx=5, pady=5) - - ttk.Button(map_control_frame, text="Add Custom Marker", command=self.add_custom_marker).pack(fill=tk.X, pady=2) - ttk.Button(map_control_frame, text="Manage Markers", command=self.manage_markers).pack(fill=tk.X, pady=2) - - # Map type toggle (only show if both options are available) - if MAP_VIEW_AVAILABLE: - self.map_toggle_btn = ttk.Button(map_control_frame, text="Switch to Simple Map", command=self.toggle_map_type) - self.map_toggle_btn.pack(fill=tk.X, pady=2) - - def setup_map_view(self, parent): - """Setup the map view with bUE locations and custom markers""" - # Create container for map - self.map_container = parent - - # Set up the appropriate map type - if self.use_interactive_map and MAP_VIEW_AVAILABLE: - self.setup_interactive_map() - else: - self.setup_canvas_map() - - # Map info frame (always present) - map_info_frame = ttk.Frame(parent) - map_info_frame.pack(fill=tk.X, padx=5, pady=5) - - ttk.Label(map_info_frame, text="Legend:").pack(side=tk.LEFT) - ttk.Label(map_info_frame, text="🔵 bUE", foreground="blue").pack(side=tk.LEFT, padx=5) - ttk.Label(map_info_frame, text="📍 Marker", foreground="red").pack(side=tk.LEFT, padx=5) - ttk.Label(map_info_frame, text="🟢 Close", foreground="green").pack(side=tk.LEFT, padx=5) - - if self.use_interactive_map and MAP_VIEW_AVAILABLE: - ttk.Label(map_info_frame, text="| Interactive Map Active", foreground="green").pack(side=tk.LEFT, padx=5) - else: - ttk.Label(map_info_frame, text="| Simple Map Active", foreground="orange").pack(side=tk.LEFT, padx=5) - - def setup_interactive_map(self): - """Setup TkinterMapView interactive map""" - try: - # Create the map widget - self.map_widget = tkintermapview.TkinterMapView(self.map_container, width=600, height=400, corner_radius=0) - self.map_widget.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Set a default position (you can change this to your area) - self.map_widget.set_position(40.2518, -111.6493) # Provo, Utah - self.map_widget.set_zoom(10) - - # Clear any existing markers - self.map_markers = {} - - print("Interactive map initialized successfully") - - except Exception as e: - print(f"Failed to setup interactive map: {e}") - logger.error(f"Failed to setup interactive map: {e}") - # Fallback to canvas map - self.use_interactive_map = False - self.setup_canvas_map() - - def setup_canvas_map(self): - """Setup fallback canvas-based map""" - # Create canvas map (original implementation) - self.map_widget = tk.Canvas(self.map_container, bg="lightblue", width=600, height=400) - self.map_widget.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Bind canvas events - self.map_widget.bind("", self.on_map_click) - self.map_widget.bind("", self.on_map_hover) - - def toggle_map_type(self): - """Toggle between interactive map and simple canvas map""" - if not MAP_VIEW_AVAILABLE: - messagebox.showinfo("Map Toggle", "TkinterMapView is not available. Cannot switch map types.") - return - - try: - # Store current state - old_use_interactive = self.use_interactive_map - - # Toggle map type - self.use_interactive_map = not self.use_interactive_map - - # Clear the current map widget - if hasattr(self, "map_widget") and self.map_widget: - self.map_widget.destroy() - - # Create new map - if self.use_interactive_map: - self.setup_interactive_map() - if hasattr(self, "map_toggle_btn"): - self.map_toggle_btn.config(text="Switch to Simple Map") - else: - self.setup_canvas_map() - if hasattr(self, "map_toggle_btn"): - self.map_toggle_btn.config(text="Switch to Interactive Map") - - # Update the map with current data - self.update_map() - - # Update info text - for widget in self.map_container.winfo_children(): - if isinstance(widget, ttk.Frame): - for child in widget.winfo_children(): - if isinstance(child, ttk.Label) and "Map Active" in child.cget("text"): - if self.use_interactive_map: - child.config(text="| Interactive Map Active", foreground="green") - else: - child.config(text="| Simple Map Active", foreground="orange") - - logger.info( - f"Switched from {'Interactive' if old_use_interactive else 'Simple'} to {'Interactive' if self.use_interactive_map else 'Simple'} map" - ) - - except Exception as e: - messagebox.showerror("Map Error", f"Failed to switch map type: {e}") - logger.error(f"Failed to toggle map type: {e}") - - def setup_tables_view(self, parent): - """Setup the tables view with coordinates and distances""" - # Create paned window for tables - tables_paned = ttk.PanedWindow(parent, orient=tk.VERTICAL) - tables_paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Coordinates table - coord_frame = ttk.LabelFrame(tables_paned, text="bUE Coordinates") - tables_paned.add(coord_frame, weight=1) - - self.coord_tree = ttk.Treeview(coord_frame, columns=("latitude", "longitude"), show="tree headings") - self.coord_tree.heading("#0", text="bUE ID") - self.coord_tree.heading("latitude", text="Latitude") - self.coord_tree.heading("longitude", text="Longitude") - self.coord_tree.pack(fill=tk.BOTH, expand=True) - - # Distance table - dist_frame = ttk.LabelFrame(tables_paned, text="bUE Distances") - tables_paned.add(dist_frame, weight=1) - - self.dist_tree = ttk.Treeview(dist_frame, columns=("distance",), show="tree headings") - self.dist_tree.heading("#0", text="bUE Pair") - self.dist_tree.heading("distance", text="Distance (m)") - self.dist_tree.pack(fill=tk.BOTH, expand=True) - - def setup_messages_view(self, parent): - """Setup the messages view""" - # Messages text area - adjust height for horizontal layout - self.messages_text = scrolledtext.ScrolledText(parent, height=12, wrap=tk.WORD) - self.messages_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Control frame for buttons - control_frame = ttk.Frame(parent) - control_frame.pack(fill=tk.X, padx=5, pady=5) - - # Clear messages button - ttk.Button(control_frame, text="Clear Messages", command=self.clear_messages).pack(side=tk.LEFT) - - def start_base_station(self): - """Initialize and start the base station""" - try: - self.base_station = Base_Station_Main("config_base.yaml") - self.base_station.tick_enabled = True - self.running = True - - # Start update thread - self.update_thread = threading.Thread(target=self.update_loop, daemon=True) - self.update_thread.start() - - self.status_var.set("Base Station Running") - logger.info("Base Station GUI started successfully") - - except Exception as e: - messagebox.showerror("Error", f"Failed to start base station: {e}") - logger.error(f"Failed to start base station: {e}") - - def update_loop(self): - """Main update loop for GUI refresh""" - while self.running: - try: - if self.base_station: - self.root.after(0, self.update_display) - time.sleep(1) # Update every second - except Exception as e: - logger.error(f"Error in update loop: {e}") - - def update_display(self): - """Update all GUI elements with current data""" - if not self.base_station: - return - - self.update_bue_list() - self.update_map() - self.update_tables() - self.update_messages() - self.update_status() - - def update_bue_list(self): - """Update the bUE list with current connections and status""" - # Clear existing items - for item in self.bue_tree.get_children(): - self.bue_tree.delete(item) - - # Add connected bUEs - for bue_id in self.base_station.connected_bues: - bue_name = self.base_station.bue_id_to_hostname[bue_id] - - # # Determine status - # if bue_id in getattr(self.base_station, "testing_bues", []): - # status = "🧪 Testing" - # else: - # status = "💤 Idle" - status = self.base_station.bue_id_to_state[bue_id] - - # Determine ping status - timeout_val = self.base_station.bue_missed_ping_counter.get(bue_id, 0) - # if timeout_val >= TIMEOUT / 2: - # ping_status = "🟢 Good" - # elif timeout_val > 0: - # ping_status = "🟡 Warning" - # else: - # ping_status = "🔴 Lost" - - ping_status = timeout_val - - self.bue_tree.insert("", "end", iid=bue_id, text=bue_name, values=(status, ping_status)) - - def update_map(self): - """Update the map with bUE locations and markers""" - if not hasattr(self, "map_widget") or not self.map_widget: - return - - if self.use_interactive_map and MAP_VIEW_AVAILABLE: - self.update_interactive_map() - else: - self.update_canvas_map() - - def update_interactive_map(self): - """Update TkinterMapView with current data""" - if not hasattr(self, "map_widget") or not self.map_widget: - return - - try: - # Clear existing markers - for marker_id, marker_obj in self.map_markers.items(): - try: - marker_obj.delete() - except: - pass - self.map_markers.clear() - - if not self.base_station or not self.base_station.bue_id_to_coords: - return - - # Check if bUE positions have changed significantly - current_positions = {} - position_changed = False - new_bues_detected = False - - # Calculate center point for the map - lats = [] - lons = [] - - # Get bUE coordinates and track changes - for bue_id, coords in self.base_station.bue_id_to_coords.items(): - try: - lat, lon = float(coords[0]), float(coords[1]) - lats.append(lat) - lons.append(lon) - - current_positions[bue_id] = (lat, lon) - - # Check for new bUEs - if bue_id not in self.last_bue_positions: - new_bues_detected = True - # Check for significant position changes (more than ~100 meters) - elif bue_id in self.last_bue_positions: - old_lat, old_lon = self.last_bue_positions[bue_id] - distance_moved = self.calculate_distance(lat, lon, old_lat, old_lon) - if distance_moved > 100: # 100 meters threshold - position_changed = True - - except (ValueError, IndexError): - continue - - # Add custom marker coordinates - for marker in self.custom_markers.values(): - lats.append(marker["lat"]) - lons.append(marker["lon"]) - - # Only auto-center/zoom if: - # 1. This is the first time setting up the map, OR - # 2. New bUEs have been detected, OR - # 3. Existing bUEs have moved significantly - should_auto_position = not self.map_auto_positioned or new_bues_detected or position_changed - - if should_auto_position and lats and lons: - # Set map center to the average of all coordinates - center_lat = sum(lats) / len(lats) - center_lon = sum(lons) / len(lons) - self.map_widget.set_position(center_lat, center_lon) - - # Auto-zoom to fit all markers with extra context - lat_range = max(lats) - min(lats) - lon_range = max(lons) - min(lons) - max_range = max(lat_range, lon_range) - - # Add padding to ensure markers aren't at the edge (25% extra space) - padded_range = max_range * 1.25 - - # Determine zoom level based on coordinate range (less aggressive zooming) - if padded_range > 1: - zoom = 7 # Reduced from 8 - very wide area view - elif padded_range > 0.1: - zoom = 10 # Reduced from 12 - city-level view - elif padded_range > 0.01: - zoom = 12 # Reduced from 15 - neighborhood view - elif padded_range > 0.001: - zoom = 14 # New level - street level with good context - else: - zoom = 15 # Reduced from 17 - close but not too tight - - self.map_widget.set_zoom(zoom) - self.map_auto_positioned = True - - if new_bues_detected: - logger.info("Auto-centered map due to new bUEs") - elif position_changed: - logger.info("Auto-centered map due to significant bUE movement") - - # Update position tracking - self.last_bue_positions = current_positions.copy() - - # Add bUE markers - for bue_id, coords in self.base_station.bue_id_to_coords.items(): - try: - lat, lon = float(coords[0]), float(coords[1]) - bue_name = 55555 - - # Check proximity to custom markers - is_close = False - for marker in self.custom_markers.values(): - if marker.get("paired_bue") == bue_id: - distance = self.calculate_distance(lat, lon, marker["lat"], marker["lon"]) - if distance <= 20: # 20 meters proximity - is_close = True - break - - # Choose marker color based on proximity - marker_color = "green" if is_close else "blue" - - # Create custom circle icon matching canvas map style - circle_icon = self.create_circle_marker_icon(marker_color) - - # Create marker with custom circular icon - if circle_icon: - marker = self.map_widget.set_marker( - lat, lon, text=bue_name, icon=circle_icon, font=("Arial", 10, "bold"), text_color="white" - ) - else: - # Fallback to default marker if icon creation failed - marker = self.map_widget.set_marker( - lat, - lon, - text=bue_name, - marker_color_circle=marker_color, - marker_color_outside="darkblue", - font=("Arial", 10, "bold"), - ) - self.map_markers[f"bue_{bue_id}"] = marker - - except (ValueError, IndexError) as e: - logger.error(f"Error plotting bUE {bue_id} on interactive map: {e}") - - # Add custom markers - for marker_id, marker_data in self.custom_markers.items(): - try: - # Create custom circle icon for custom markers - circle_icon = self.create_circle_marker_icon("red") - - # Create custom marker with circular icon - if circle_icon: - marker = self.map_widget.set_marker( - marker_data["lat"], - marker_data["lon"], - text=marker_data["name"], - icon=circle_icon, - font=("Arial", 10, "bold"), - text_color="white", - ) - else: - # Fallback to default marker if icon creation failed - marker = self.map_widget.set_marker( - marker_data["lat"], - marker_data["lon"], - text=marker_data["name"], - marker_color_circle="red", - marker_color_outside="darkred", - font=("Arial", 10, "bold"), - ) - self.map_markers[f"custom_{marker_id}"] = marker - except Exception as e: - logger.error(f"Error plotting custom marker {marker_id} on interactive map: {e}") - - except Exception as e: - logger.error(f"Error updating interactive map: {e}") - - def update_canvas_map(self): - """Update canvas-based map (original implementation)""" - if not hasattr(self, "map_widget") or not self.map_widget: - return - - # Clear canvas - self.map_widget.delete("all") - - if not self.base_station or not self.base_station.bue_id_to_coords: - self.map_widget.create_text( - 300, - 200, - text="No bUE coordinates available", - font=("Arial", 14), - fill="gray", - ) - return - - # Calculate map bounds - lats = [] - lons = [] - - # Get bUE coordinates - for coords in self.base_station.bue_id_to_coords.values(): - try: - lat, lon = float(coords[0]), float(coords[1]) - lats.append(lat) - lons.append(lon) - except (ValueError, IndexError): - continue - - # Add custom marker coordinates - for marker in self.custom_markers.values(): - lats.append(marker["lat"]) - lons.append(marker["lon"]) - - if not lats or not lons: - self.map_widget.create_text( - 300, - 200, - text="No valid coordinates available", - font=("Arial", 14), - fill="gray", - ) - return - - # Calculate bounds with padding - min_lat, max_lat = min(lats), max(lats) - min_lon, max_lon = min(lons), max(lons) - - # Add padding - lat_padding = (max_lat - min_lat) * 0.1 or 0.001 - lon_padding = (max_lon - min_lon) * 0.1 or 0.001 - - min_lat -= lat_padding - max_lat += lat_padding - min_lon -= lon_padding - max_lon += lon_padding - - # Get canvas dimensions - canvas_width = self.map_widget.winfo_width() or 600 - canvas_height = self.map_widget.winfo_height() or 400 - - # Map coordinate conversion functions - def lat_to_y(lat): - return canvas_height - ((lat - min_lat) / (max_lat - min_lat)) * canvas_height - - def lon_to_x(lon): - return ((lon - min_lon) / (max_lon - min_lon)) * canvas_width - - # Draw bUEs - for bue_id, coords in self.base_station.bue_id_to_coords.items(): - try: - lat, lon = float(coords[0]), float(coords[1]) - x, y = lon_to_x(lon), lat_to_y(lat) - - # Check proximity to custom markers - is_close = False - for marker in self.custom_markers.values(): - if marker.get("paired_bue") == bue_id: - distance = self.calculate_distance(lat, lon, marker["lat"], marker["lon"]) - if distance <= 20: # 20 meters proximity - is_close = True - break - - # Choose color based on proximity - color = "green" if is_close else "blue" - - # Draw bUE circle - radius = 8 - self.map_widget.create_oval( - x - radius, - y - radius, - x + radius, - y + radius, - fill=color, - outline="darkblue", - width=2, - tags=f"bue_{bue_id}", - ) - - # Label - # bue_name = bUEs5.get(str(bue_id), f"bUE {bue_id}") - bue_name = self.base_station.bue_id_to_hostname(int(bue_id)) - self.map_widget.create_text( - x, - y - 15, - text=bue_name, - font=("Arial", 8), - fill="black", - tags=f"bue_{bue_id}", - ) - - except (ValueError, IndexError) as e: - logger.error(f"Error plotting bUE {bue_id}: {e}") - - # Draw custom markers - for marker_id, marker in self.custom_markers.items(): - x, y = lon_to_x(marker["lon"]), lat_to_y(marker["lat"]) - - # Draw marker - radius = 6 - self.map_widget.create_oval( - x - radius, - y - radius, - x + radius, - y + radius, - fill="red", - outline="darkred", - width=2, - tags=f"marker_{marker_id}", - ) - - # Label - self.map_widget.create_text( - x, - y - 15, - text=marker["name"], - font=("Arial", 8), - fill="red", - tags=f"marker_{marker_id}", - ) - - def update_tables(self): - """Update coordinate and distance tables""" - # Update coordinates table - for item in self.coord_tree.get_children(): - self.coord_tree.delete(item) - - if self.base_station: - for bue_id, coords in self.base_station.bue_id_to_coords.items(): - # bue_name = bUEs5.get(str(bue_id), f"bUE {bue_id}") - bue_name = self.base_station.bue_id_to_hostname(int(bue_id)) - try: - lat, lon = coords[0], coords[1] - self.coord_tree.insert("", "end", text=bue_name, values=(lat, lon)) - except (IndexError, ValueError): - self.coord_tree.insert("", "end", text=bue_name, values=("Invalid", "Invalid")) - - # Update distance table - for item in self.dist_tree.get_children(): - self.dist_tree.delete(item) - - if self.base_station and len(self.base_station.connected_bues) > 1: - processed_pairs = set() - for bue1 in self.base_station.connected_bues: - for bue2 in self.base_station.connected_bues: - if ( - bue1 != bue2 - and bue1 in self.base_station.bue_id_to_coords - and bue2 in self.base_station.bue_id_to_coords - and (bue1, bue2) not in processed_pairs - and (bue2, bue1) not in processed_pairs - ): - - distance = self.base_station.get_distance(bue1, bue2) - if distance is not None: - # pair_name = f"{bUEs5.get(str(bue1), str(bue1))} ↔ {bUEs5.get(str(bue2), str(bue2))}" - pair_name = f"{self.base_station.bue_id_to_hostname(int(bue1))} ↔ {self.base_station.bue_id_to_hostname(int(bue1))}" - self.dist_tree.insert("", "end", text=pair_name, values=(f"{distance:.2f}")) - - processed_pairs.add((bue1, bue2)) - - def update_messages(self): - """Update the messages display""" - if self.base_station and hasattr(self.base_station, "stdout_history"): - # Get current content - current_content = self.messages_text.get(1.0, tk.END) - - # Build new content - new_content = "\n".join(self.base_station.stdout_history) - - # Only update if content changed - if new_content.strip() != current_content.strip(): - self.messages_text.delete(1.0, tk.END) - self.messages_text.insert(1.0, new_content) - self.messages_text.see(tk.END) # Scroll to bottom - - def update_status(self): - """Update the status bar""" - if self.base_station: - connected = len(self.base_station.connected_bues) - testing = len(getattr(self.base_station, "testing_bues", [])) - current_time = datetime.now().strftime("%H:%M:%S") - self.status_var.set(f"Time: {current_time} | Connected: {connected} | Testing: {testing}") - - def show_bue_context_menu(self, event): - """Show context menu for bUE operations""" - item = self.bue_tree.selection()[0] if self.bue_tree.selection() else None - if not item: - return - - bue_id = int(item) - - # Create context menu - context_menu = tk.Menu(self.root, tearoff=0) - context_menu.add_command(label="Disconnect", command=lambda: self.disconnect_bue(bue_id)) - context_menu.add_command(label="Reload", command=lambda: self.reload_bue(bue_id)) - context_menu.add_command(label="Restart", command=lambda: self.restart_bue(bue_id)) - context_menu.add_separator() - context_menu.add_command(label="Open Log File", command=lambda: self.open_bue_log(bue_id)) - - # Show menu - try: - context_menu.tk_popup(event.x_root, event.y_root) - finally: - context_menu.grab_release() - - def disconnect_bue(self, bue_id): - """Disconnect a specific bUE""" - if messagebox.askyesno( - "Confirm Disconnect", - # f"Disconnect from {bUEs5.get(str(bue_id), str(bue_id))}?", - f"Disconnect from {self.base_station.bue_id_to_hostname(int(bue_id))}?", - ): - try: - self.base_station.connected_bues.remove(bue_id) - if bue_id in self.base_station.bue_id_to_coords: - del self.base_station.bue_id_to_coords[bue_id] - if bue_id in getattr(self.base_station, "testing_bues", []): - self.base_station.testing_bues.remove(bue_id) - if bue_id in self.base_station.bue_missed_ping_counter: - del self.base_station.bue_missed_ping_counter[bue_id] - logger.info(f"Disconnected from bUE {bue_id}") - except Exception as e: - messagebox.showerror("Error", f"Failed to disconnect: {e}") - - def reload_bue(self, bue_id): - """Reload a specific bUE""" - if messagebox.askyesno("Confirm Reload", f"Reload {self.base_station.bue_id_to_hostname(int(bue_id))}?"): - try: - self.base_station.ota.send_ota_message(bue_id, "RELOAD") - self.disconnect_bue(bue_id) - logger.info(f"Sent reload command to bUE {bue_id}") - except Exception as e: - messagebox.showerror("Error", f"Failed to reload: {e}") - - def restart_bue(self, bue_id): - """Restart a specific bUE""" - if messagebox.askyesno("Confirm Restart", f"Restart {self.base_station.bue_id_to_hostname(int(bue_id))}?"): - try: - self.base_station.ota.send_ota_message(bue_id, "RESTART") - self.disconnect_bue(bue_id) - logger.info(f"Sent restart command to bUE {bue_id}") - except Exception as e: - messagebox.showerror("Error", f"Failed to restart: {e}") - - def open_bue_log(self, bue_id): - """Open the log file for a specific bUE""" - log_path = f"logs/bue_{bue_id}.log" - bue_name = self.base_station.bue_id_to_hostname(int(bue_id)) - LogViewerDialog(self.root, log_path, f"{bue_name} Log") - - def open_base_log(self): - """Open the base station log file""" - log_path = "logs/base_station.log" - LogViewerDialog(self.root, log_path, "Base Station Log") - - def run_test(self): - """Run test dialog and execute tests""" - if not self.base_station or not self.base_station.connected_bues: - messagebox.showwarning("No bUEs", "No bUEs currently connected") - return - - # Create test dialog - TestDialog(self.root, self.base_station) - - def cancel_tests(self): - """Cancel running tests""" - if not hasattr(self.base_station, "testing_bues") or not self.base_station.testing_bues: - messagebox.showinfo("No Tests", "No tests currently running") - return - - # Create cancel dialog - CancelTestDialog(self.root, self.base_station) - - def clear_messages(self): - """Clear the messages display""" - self.messages_text.delete(1.0, tk.END) - if self.base_station and hasattr(self.base_station, "stdout_history"): - self.base_station.stdout_history.clear() - - def add_custom_marker(self): - """Add a custom marker to the map""" - AddMarkerDialog(self.root, self) - - def manage_markers(self): - """Manage existing custom markers""" - ManageMarkersDialog(self.root, self) - - def on_map_click(self, event): - """Handle map click events""" - # Get clicked coordinates (simplified - would need proper coordinate conversion) - pass - - def on_map_hover(self, event): - """Handle map hover events""" - # Show coordinates or object info on hover - pass - - def calculate_distance(self, lat1, lon1, lat2, lon2): - """Calculate distance between two coordinates in meters""" - # Haversine formula - R = 6371000 # Earth's radius in meters - - lat1_rad = math.radians(lat1) - lat2_rad = math.radians(lat2) - delta_lat = math.radians(lat2 - lat1) - delta_lon = math.radians(lon2 - lon1) - - a = math.sin(delta_lat / 2) * math.sin(delta_lat / 2) + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin( - delta_lon / 2 - ) * math.sin(delta_lon / 2) - c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) - - return R * c - - def on_closing(self): - """Handle application closing""" - if messagebox.askokcancel("Quit", "Do you want to quit?"): - self.running = False - if self.base_station: - self.base_station.EXIT = True - if hasattr(self.base_station, "__del__"): - self.base_station.__del__() - self.root.destroy() - - -class TestDialog: - """All-in-one test dialog - everything in a single window""" - - def __init__(self, parent, base_station): - self.parent = parent - self.base_station = base_station - - self.dialog = tk.Toplevel(parent) - self.dialog.title("Test Management") - self.dialog.geometry("700x700") - self.dialog.grab_set() - - # Available test files - self.test_files = [ - "grc/lora_td_ru", - "grc/lora_tu_rd", - "Old/helloworld", - "Old/sf_msg_test", - "Old" "gpstest", - "gpstest2", - "../osu_testing/run_tx", - "../osu_testing/run_rx", - "../osu_testing/run_rx_12_20", - ] - - # Selected bUEs and their configurations - self.selected_bues = [] - self.bue_configs = {} # {bue_id: {'file': str, 'params': str}} - - self.setup_dialog() - - def setup_dialog(self): - """Setup the all-in-one test dialog""" - # Step 1: bUE Selection - selection_frame = ttk.LabelFrame(self.dialog, text="Step 1: Select bUEs for Testing", padding="10") - selection_frame.pack(fill=tk.X, padx=10, pady=5) - - ttk.Label(selection_frame, text="Choose which bUEs will run tests:").pack(anchor=tk.W, pady=(0, 5)) - - # Create checkboxes for connected bUEs - self.bue_vars = {} - checkbox_frame = ttk.Frame(selection_frame) - checkbox_frame.pack(fill=tk.X) - - row = 0 - col = 0 - for i, bue_id in enumerate(self.base_station.connected_bues): - bue_name = self.base_station.bue_id_to_hostname[int(bue_id)] - var = tk.BooleanVar() - self.bue_vars[bue_id] = var - - cb = ttk.Checkbutton( - checkbox_frame, - text=bue_name, - variable=var, - command=self.update_selection, - ) - cb.grid(row=row, column=col, sticky=tk.W, padx=20, pady=2) - - col += 1 - if col > 1: # 2 columns - col = 0 - row += 1 - - # Selection summary - self.selection_label = ttk.Label(selection_frame, text="No bUEs selected", foreground="gray") - self.selection_label.pack(anchor=tk.W, pady=(5, 0)) - - # Step 2: Test Delay - time_frame = ttk.LabelFrame(self.dialog, text="Step 2: Set Test Delay", padding="10") - time_frame.pack(fill=tk.X, padx=10, pady=5) - - # Delay input - delay_controls = ttk.Frame(time_frame) - delay_controls.pack() - - ttk.Label(delay_controls, text="Start test in:").grid(row=0, column=0, padx=5) - self.delay_var = tk.StringVar(value="30") - delay_spin = tk.Spinbox(delay_controls, from_=5, to=300, textvariable=self.delay_var, width=5) - delay_spin.grid(row=0, column=1, padx=5) - ttk.Label(delay_controls, text="seconds").grid(row=0, column=2, padx=5) - - # Calculated start time display - self.start_time_label = ttk.Label(time_frame, text="", foreground="blue", font=("TkDefaultFont", 9)) - self.start_time_label.pack(pady=(10, 0)) - - # Update the calculated time when delay changes - self.delay_var.trace("w", self.update_calculated_time) - - # Start automatic time updates every second - self.start_auto_time_updates() - self.update_calculated_time() # Initial calculation - - # Step 3: Configure Individual bUEs - ALL IN ONE WINDOW - config_frame = ttk.LabelFrame(self.dialog, text="Step 3: Configure Each Selected bUE", padding="10") - config_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) - - # Create a scrollable frame for bUE configurations - canvas = tk.Canvas(config_frame, height=300) - scrollbar = ttk.Scrollbar(config_frame, orient="vertical", command=canvas.yview) - self.scrollable_frame = ttk.Frame(canvas) - - self.scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - - canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - # Initially empty - will be populated when bUEs are selected - self.no_selection_label = ttk.Label( - self.scrollable_frame, - text="Select bUEs above to configure their tests here", - foreground="gray", - ) - self.no_selection_label.pack(pady=50) - - # Buttons - button_frame = ttk.Frame(self.dialog) - button_frame.pack(fill=tk.X, padx=10, pady=10) - - self.run_btn = ttk.Button(button_frame, text="Run Tests", command=self.run_tests, state=tk.DISABLED) - self.run_btn.pack(side=tk.LEFT, padx=5) - - ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack(side=tk.RIGHT, padx=5) - - def update_selection(self): - """Update the selection and show inline configuration""" - self.selected_bues = [bue_id for bue_id, var in self.bue_vars.items() if var.get()] - - if self.selected_bues: - bue_names = [self.base_station.bue_id_to_hostname(int(bid)) for bid in self.selected_bues] - self.selection_label.config(text=f"Selected: {', '.join(bue_names)}", foreground="blue") - - # Show inline configuration for each selected bUE - self.show_inline_configs() - else: - self.selection_label.config(text="No bUEs selected", foreground="gray") - self.clear_inline_configs() - self.run_btn.config(state=tk.DISABLED) - - def start_auto_time_updates(self): - """Start automatic time updates every second""" - self.update_calculated_time() - # Schedule next update in 1000ms (1 second) - self.dialog.after(1000, self.start_auto_time_updates) - - def update_calculated_time(self, *args): - """Update the calculated start time display using current time""" - try: - delay_seconds = int(self.delay_var.get()) - # Always use current time for real-time updates - current_time = datetime.now().replace(microsecond=0) - start_time = current_time + timedelta(seconds=delay_seconds) - - # Format the time nicely - time_str = start_time.strftime("%I:%M:%S %p") - date_str = start_time.strftime("%Y-%m-%d") - - if start_time.date() == current_time.date(): - # Same day - self.start_time_label.config(text=f"Tests will start at: {time_str} (today)") - else: - # Next day - self.start_time_label.config(text=f"Tests will start at: {time_str} on {date_str}") - - except ValueError: - self.start_time_label.config(text="Invalid delay time") - - def show_inline_configs(self): - """Show configuration options for each selected bUE inline""" - # Clear existing config widgets - for widget in self.scrollable_frame.winfo_children(): - widget.destroy() - - self.config_widgets = {} - - for i, bue_id in enumerate(self.selected_bues): - bue_name = self.base_station.bue_id_to_hostname(int(bue_id)) - - # Create a frame for this bUE's configuration - bue_frame = ttk.LabelFrame(self.scrollable_frame, text=f"Configure {bue_name}", padding="10") - bue_frame.pack(fill=tk.X, padx=5, pady=5) - - # Test file selection row - file_frame = ttk.Frame(bue_frame) - file_frame.pack(fill=tk.X, pady=(0, 5)) - - ttk.Label(file_frame, text="Test File:", width=12).pack(side=tk.LEFT) - file_var = tk.StringVar(value=self.test_files[0]) - file_combo = ttk.Combobox( - file_frame, - textvariable=file_var, - values=self.test_files, - state="readonly", - width=20, - ) - file_combo.pack(side=tk.LEFT, padx=(5, 0)) - - # Message field (always visible) - msg_frame = ttk.Frame(bue_frame) - msg_frame.pack(fill=tk.X, pady=(0, 5)) - - ttk.Label(msg_frame, text="Message:", width=12).pack(side=tk.LEFT) - msg_var = tk.StringVar() - msg_entry = ttk.Entry(msg_frame, textvariable=msg_var, width=20) - msg_entry.pack(side=tk.LEFT, padx=(5, 0)) - - # Conditional parameters frame for run_tx/run_rx only - params_frame = ttk.Frame(bue_frame) - - sf = [5, 6, 7, 8, 9, 10, 11, 12] - - # Create all the conditional widgets but don't pack them yet - sf_var = tk.IntVar(value=8) - bw_var = tk.StringVar(value="6000") # Default bandwidth - freq_var = tk.StringVar(value="12000") # Default center frequency - period_var = tk.StringVar(value="3000") # Default period - - # Row 1: Spreading Factor and Bandwidth - row1_frame = ttk.Frame(params_frame) - ttk.Label(row1_frame, text="Spreading Factor:", width=16).pack(side=tk.LEFT) - sf_entry = ttk.Combobox(row1_frame, textvariable=sf_var, values=sf, state="readonly", width=8) - sf_entry.pack(side=tk.LEFT, padx=(5, 15)) - - ttk.Label(row1_frame, text="Bandwidth:", width=12).pack(side=tk.LEFT) - bw_entry = ttk.Entry(row1_frame, textvariable=bw_var, width=10) - bw_entry.pack(side=tk.LEFT, padx=(5, 0)) - - # Row 2: Center Frequency and Period - row2_frame = ttk.Frame(params_frame) - ttk.Label(row2_frame, text="Center Frequency:", width=16).pack(side=tk.LEFT) - freq_entry = ttk.Entry(row2_frame, textvariable=freq_var, width=12) - freq_entry.pack(side=tk.LEFT, padx=(5, 15)) - - ttk.Label(row2_frame, text="Period:", width=12).pack(side=tk.LEFT) - period_entry = ttk.Entry(row2_frame, textvariable=period_var, width=10) - period_entry.pack(side=tk.LEFT, padx=(5, 0)) - - # Validation function for integer-only fields - def validate_integer(char): - return char.isdigit() - - vcmd = (self.dialog.register(validate_integer), "%S") - bw_entry.config(validate="key", validatecommand=vcmd) - freq_entry.config(validate="key", validatecommand=vcmd) - period_entry.config(validate="key", validatecommand=vcmd) - - def update_params_visibility(*args, fvar=file_var, pframe=params_frame, r1frame=row1_frame, r2frame=row2_frame): - selected_file = fvar.get() - if selected_file.endswith("run_tx") or selected_file.endswith("run_rx"): - pframe.pack(fill=tk.X, pady=(5, 0)) - r1frame.pack(fill=tk.X, pady=(0, 5)) - r2frame.pack(fill=tk.X, pady=(0, 5)) - else: - pframe.pack_forget() - - # Bind file selection changes to update visibility - file_var.trace("w", update_params_visibility) - # Initial visibility check - update_params_visibility() - - # Add placeholder functionality - placeholder_text = "No spaces" - msg_entry.insert(0, placeholder_text) - msg_entry.config(foreground="gray", font=("TkDefaultFont", 10, "italic")) - - def on_focus_in(event, entry=msg_entry, var=msg_var, placeholder=placeholder_text): - if var.get() == placeholder: - entry.delete(0, tk.END) - entry.config(foreground="black", font=("TkDefaultFont", 10, "normal")) - - def on_focus_out(event, entry=msg_entry, var=msg_var, placeholder=placeholder_text): - if not var.get(): - entry.insert(0, placeholder) - entry.config(foreground="gray", font=("TkDefaultFont", 10, "italic")) - - msg_entry.bind("", on_focus_in) - msg_entry.bind("", on_focus_out) - - # Store the variables for this bUE - self.config_widgets[bue_id] = { - "file_var": file_var, - "sf_var": sf_var, - "bw_var": bw_var, - "freq_var": freq_var, - "period_var": period_var, - "msg_var": msg_var, - "file_combo": file_combo, - "sf_entry": sf_entry, - "bw_entry": bw_entry, - "freq_entry": freq_entry, - "period_entry": period_entry, - "msg_entry": msg_entry, - } - - # Bind changes to enable run button - file_var.trace("w", self.check_ready_to_run) - sf_var.trace("w", self.check_ready_to_run) - bw_var.trace("w", self.check_ready_to_run) - freq_var.trace("w", self.check_ready_to_run) - period_var.trace("w", self.check_ready_to_run) - msg_var.trace("w", self.check_ready_to_run) - # Enable run button if we have configurations - self.check_ready_to_run() - - def clear_inline_configs(self): - """Clear all configuration widgets""" - for widget in self.scrollable_frame.winfo_children(): - widget.destroy() - - self.no_selection_label = ttk.Label( - self.scrollable_frame, - text="Select bUEs above to configure their tests here", - foreground="gray", - ) - self.no_selection_label.pack(pady=50) - - self.config_widgets = {} - - def check_ready_to_run(self, *args): - """Check if all configurations are ready and enable run button""" - if self.selected_bues and hasattr(self, "config_widgets") and self.config_widgets: - # All selected bUEs have configuration widgets, enable run - self.run_btn.config(state=tk.NORMAL) - else: - self.run_btn.config(state=tk.DISABLED) - - def run_tests(self): - """Execute the configured tests""" - if not self.selected_bues or not hasattr(self, "config_widgets"): - messagebox.showwarning( - "No Configuration", - "Please select and configure at least one bUE for testing", - ) - return - - # Collect configurations from the inline widgets - self.bue_configs = {} - for bue_id in self.selected_bues: - if bue_id in self.config_widgets: - widgets = self.config_widgets[bue_id] - config = { - "file": widgets["file_var"].get(), - "msg": widgets["msg_var"].get(), - } - - # Add run_tx/run_rx specific parameters if applicable - selected_file = widgets["file_var"].get() - if selected_file.endswith("run_tx") or selected_file.endswith("run_rx"): - config.update( - { - "sf": widgets["sf_var"].get(), - "bw": widgets["bw_var"].get(), - "freq": widgets["freq_var"].get(), - "period": widgets["period_var"].get(), - } - ) - - self.bue_configs[bue_id] = config - - # Calculate start time using delay - use CURRENT time for actual execution - try: - delay_seconds = int(self.delay_var.get()) - execution_time = datetime.now().replace(microsecond=0) # Fresh time for execution - start_time = execution_time + timedelta(seconds=delay_seconds) - unix_timestamp = int(start_time.timestamp()) - - # Format the start time for user confirmation - time_str = start_time.strftime("%I:%M:%S %p") - - # Send test commands - for bue_id, config in self.bue_configs.items(): - selected_file = config["file"] # ← FIX: Get file from config, not from outside loop - - if selected_file.endswith("run_tx") or selected_file.endswith("run_rx"): - command = f"TEST:{config['file']},{unix_timestamp},-s {config['sf']} -m {config['msg']} -c {config['freq']} -b {config['bw']} -p {config['period']}" - elif selected_file.startswith("Old"): - command = f"TEST:{config['file']},{unix_timestamp},{config['msg']}" - else: - command = f"TEST:{config['file']},{unix_timestamp}," - - self.base_station.ota.send_ota_message(bue_id, command) - time.sleep(0.1) - logger.info(f"Sent test command to bUE {bue_id}: {command}") - - bue_names = [self.base_station.bue_id_to_hostname(int(bue_id)) for bue_id in self.bue_configs.keys()] - messagebox.showinfo( - "Tests Scheduled", - f"Tests scheduled for: {', '.join(bue_names)}\n\n" - f"Actual start time: {time_str}\n" - f"Delay: {delay_seconds} seconds from when you clicked 'Run'", - ) - self.dialog.destroy() - - except Exception as e: - messagebox.showerror("Error", f"Failed to schedule tests: {e}") - - -# Remove the IndividualBueConfigDialog since we don't need it anymore - - -class ConfigureBueDialog: - """Dialog for configuring a single bUE test""" - - def __init__(self, parent, bue_id, test_files, config_tree): - self.parent = parent - self.bue_id = bue_id - self.test_files = test_files - self.config_tree = config_tree - - self.dialog = tk.Toplevel(parent) - self.dialog.title(f"Configure {self.base_station.bue_id_to_hostname(int(bue_id))}") - self.dialog.geometry("400x200") - self.dialog.grab_set() - - self.setup_dialog() - - def setup_dialog(self): - """Setup the configuration dialog""" - ttk.Label( - self.dialog, - text=f"Configure test for {self.base_station.bue_id_to_hostname(int(self.bue_id))}", - ).pack(pady=10) - - # Test file selection - ttk.Label(self.dialog, text="Test File:").pack(anchor=tk.W, padx=20) - self.file_var = tk.StringVar(value=self.test_files[0]) - file_combo = ttk.Combobox( - self.dialog, - textvariable=self.file_var, - values=self.test_files, - state="readonly", - ) - file_combo.pack(fill=tk.X, padx=20, pady=5) - - # Parameters - ttk.Label(self.dialog, text="Parameters:").pack(anchor=tk.W, padx=20) - self.params_var = tk.StringVar() - ttk.Entry(self.dialog, textvariable=self.params_var).pack(fill=tk.X, padx=20, pady=5) - - # Buttons - button_frame = ttk.Frame(self.dialog) - button_frame.pack(fill=tk.X, padx=20, pady=10) - - ttk.Button(button_frame, text="OK", command=self.save_config).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack(side=tk.RIGHT, padx=5) - - def save_config(self): - """Save the configuration""" - bue_name = self.base_station.bue_id_to_hostname(int(self.bue_id)) - self.config_tree.insert( - "", - "end", - text=bue_name, - values=(self.file_var.get(), self.params_var.get()), - ) - self.dialog.destroy() - - -class CancelTestDialog: - """Dialog for canceling running tests""" - - def __init__(self, parent, base_station): - self.parent = parent - self.base_station = base_station - - self.dialog = tk.Toplevel(parent) - self.dialog.title("Cancel Tests") - self.dialog.geometry("300x200") - self.dialog.grab_set() - - self.setup_dialog() - - def setup_dialog(self): - """Setup the cancel dialog""" - ttk.Label(self.dialog, text="Select tests to cancel:").pack(pady=10) - - self.test_vars = {} - for bue_id in getattr(self.base_station, "testing_bues", []): - bue_name = self.base_station.bue_id_to_hostname(int(bue_id)) - var = tk.BooleanVar() - self.test_vars[bue_id] = var - ttk.Checkbutton(self.dialog, text=bue_name, variable=var).pack(anchor=tk.W, padx=20) - - # Buttons - button_frame = ttk.Frame(self.dialog) - button_frame.pack(fill=tk.X, padx=20, pady=10) - - ttk.Button(button_frame, text="Cancel Selected", command=self.cancel_tests).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="Close", command=self.dialog.destroy).pack(side=tk.RIGHT, padx=5) - - def cancel_tests(self): - """Cancel selected tests""" - canceled = [] - for bue_id, var in self.test_vars.items(): - if var.get(): - try: - for i in range(3): - self.base_station.ota.send_ota_message(bue_id, "CANC") - time.sleep(0.1) - canceled.append(self.base_station.bue_id_to_hostname(int(bue_id))) - logger.info(f"Sent cancel command to bUE {bue_id}") - except Exception as e: - logger.error(f"Failed to cancel test for bUE {bue_id}: {e}") - - if canceled: - messagebox.showinfo("Tests Canceled", f"Canceled tests for: {', '.join(canceled)}") - - self.dialog.destroy() - - -class AddMarkerDialog: - """Dialog for adding custom markers""" - - def __init__(self, parent, main_gui): - self.parent = parent - self.main_gui = main_gui - - self.dialog = tk.Toplevel(parent) - self.dialog.title("Add Custom Marker") - self.dialog.geometry("400x350") - self.dialog.grab_set() - - self.setup_dialog() - - def setup_dialog(self): - """Setup the add marker dialog""" - # Marker name - ttk.Label(self.dialog, text="Marker Name:").pack(anchor=tk.W, padx=20, pady=(20, 5)) - self.name_var = tk.StringVar() - ttk.Entry(self.dialog, textvariable=self.name_var, width=30).pack(fill=tk.X, padx=20, pady=5) - - # Coordinates - ttk.Label(self.dialog, text="Latitude:").pack(anchor=tk.W, padx=20, pady=(10, 5)) - self.lat_var = tk.StringVar() - ttk.Entry(self.dialog, textvariable=self.lat_var, width=30).pack(fill=tk.X, padx=20, pady=5) - - ttk.Label(self.dialog, text="Longitude:").pack(anchor=tk.W, padx=20, pady=(10, 5)) - self.lon_var = tk.StringVar() - ttk.Entry(self.dialog, textvariable=self.lon_var, width=30).pack(fill=tk.X, padx=20, pady=5) - - # Pair with bUE - ttk.Label(self.dialog, text="Pair with bUE (optional):").pack(anchor=tk.W, padx=20, pady=(10, 5)) - try: - bue_options = ["None"] + [ - self.base_station.bue_id_to_hostname(int(bue_id)) for bue_id in self.main_gui.base_station.connected_bues - ] - self.bue_var = tk.StringVar(value="None") - ttk.Combobox( - self.dialog, - textvariable=self.bue_var, - values=bue_options, - state="readonly", - ).pack(fill=tk.X, padx=20, pady=5) - except Exception as e: - print(f"Error creating bUE combobox: {e}") - # Fallback simple combobox - self.bue_var = tk.StringVar(value="None") - ttk.Combobox( - self.dialog, - textvariable=self.bue_var, - values=["None"], - state="readonly", - ).pack(fill=tk.X, padx=20, pady=5) - - # Buttons - button_frame = ttk.Frame(self.dialog) - button_frame.pack(fill=tk.X, padx=20, pady=20) - - # Add some spacing between buttons - ttk.Button(button_frame, text="Add Marker", command=self.add_marker).pack(side=tk.LEFT, padx=(0, 10)) - ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack(side=tk.LEFT, padx=(10, 0)) - - def add_marker(self): - """Add the custom marker""" - try: - name = self.name_var.get().strip() - lat = float(self.lat_var.get()) - lon = float(self.lon_var.get()) - - if not name: - messagebox.showwarning("Invalid Input", "Please enter a marker name") - return - - # Get paired bUE ID - paired_bue = None - bue_selection = self.bue_var.get() - if bue_selection != "None": - for bue_id in self.main_gui.base_station.connected_bues: - if self.base_station.bue_id_to_hostname(int(bue_id)) == bue_selection: - paired_bue = bue_id - break - - # Add marker - marker_id = self.main_gui.marker_counter - self.main_gui.marker_counter += 1 - - self.main_gui.custom_markers[marker_id] = { - "name": name, - "lat": lat, - "lon": lon, - "paired_bue": paired_bue, - } - - # Refresh the map to show the new marker - self.main_gui.update_map() - - messagebox.showinfo("Marker Added", f"Added marker '{name}'") - self.dialog.destroy() - - except ValueError: - messagebox.showerror("Invalid Input", "Please enter valid coordinates") - except Exception as e: - messagebox.showerror("Error", f"Failed to add marker: {e}") - - -class ManageMarkersDialog: - """Dialog for managing custom markers""" - - def __init__(self, parent, main_gui): - self.parent = parent - self.main_gui = main_gui - - self.dialog = tk.Toplevel(parent) - self.dialog.title("Manage Custom Markers") - self.dialog.geometry("600x400") - self.dialog.grab_set() - - self.setup_dialog() - self.refresh_markers() - - def setup_dialog(self): - """Setup the manage markers dialog""" - # Markers list - self.markers_tree = ttk.Treeview(self.dialog, columns=("lat", "lon", "paired_bue"), show="tree headings") - self.markers_tree.heading("#0", text="Marker Name") - self.markers_tree.heading("lat", text="Latitude") - self.markers_tree.heading("lon", text="Longitude") - self.markers_tree.heading("paired_bue", text="Paired bUE") - - self.markers_tree.pack(fill=tk.BOTH, expand=True, padx=20, pady=20) - - # Buttons - button_frame = ttk.Frame(self.dialog) - button_frame.pack(fill=tk.X, padx=20, pady=10) - - ttk.Button(button_frame, text="Delete Selected", command=self.delete_marker).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="Edit Selected", command=self.edit_marker).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="Close", command=self.dialog.destroy).pack(side=tk.RIGHT, padx=5) - - def refresh_markers(self): - """Refresh the markers list""" - for item in self.markers_tree.get_children(): - self.markers_tree.delete(item) - - for marker_id, marker in self.main_gui.custom_markers.items(): - paired_bue_name = "None" - if marker.get("paired_bue"): - paired_bue_name = self.base_station.bue_id_to_hostname(int(paired_bue_name)) - - self.markers_tree.insert( - "", - "end", - iid=marker_id, - text=marker["name"], - values=(marker["lat"], marker["lon"], paired_bue_name), - ) - - def delete_marker(self): - """Delete selected marker""" - selection = self.markers_tree.selection() - if not selection: - messagebox.showwarning("No Selection", "Please select a marker to delete") - return - - marker_id = int(selection[0]) - marker_name = self.main_gui.custom_markers[marker_id]["name"] - - if messagebox.askyesno("Confirm Delete", f"Delete marker '{marker_name}'?"): - del self.main_gui.custom_markers[marker_id] - self.refresh_markers() - # Refresh the map to remove the deleted marker - self.main_gui.update_map() - - def edit_marker(self): - """Edit selected marker""" - selection = self.markers_tree.selection() - if not selection: - messagebox.showwarning("No Selection", "Please select a marker to edit") - return - - marker_id = int(selection[0]) - # Could implement edit dialog similar to AddMarkerDialog - messagebox.showinfo("Edit Marker", "Edit functionality not yet implemented") - - -class LogViewerDialog: - """Dialog for viewing log files within the GUI""" - - def __init__(self, parent, log_path, title): - self.parent = parent - self.log_path = log_path - - self.dialog = tk.Toplevel(parent) - self.dialog.title(title) - self.dialog.geometry("900x600") - self.dialog.grab_set() - - # Make dialog resizable - self.dialog.resizable(True, True) - - self.setup_dialog() - self.load_log_content() - - # Auto-refresh thread for live log viewing - self.auto_refresh = True - self.refresh_thread = threading.Thread(target=self.auto_refresh_loop, daemon=True) - self.refresh_thread.start() - - # Handle dialog close - self.dialog.protocol("WM_DELETE_WINDOW", self.on_closing) - - def setup_dialog(self): - """Setup the log viewer dialog""" - # Top frame with controls - control_frame = ttk.Frame(self.dialog) - control_frame.pack(fill=tk.X, padx=10, pady=5) - - # File info - self.file_info_var = tk.StringVar() - ttk.Label(control_frame, textvariable=self.file_info_var).pack(side=tk.LEFT) - - # Buttons - button_frame = ttk.Frame(control_frame) - button_frame.pack(side=tk.RIGHT) - - ttk.Button(button_frame, text="Refresh", command=self.refresh_log).pack(side=tk.LEFT, padx=2) - ttk.Button(button_frame, text="Clear", command=self.clear_log).pack(side=tk.LEFT, padx=2) - ttk.Button(button_frame, text="Save As...", command=self.save_log).pack(side=tk.LEFT, padx=2) - - # Auto-refresh toggle - self.auto_refresh_var = tk.BooleanVar(value=True) - ttk.Checkbutton( - button_frame, - text="Auto-refresh", - variable=self.auto_refresh_var, - command=self.toggle_auto_refresh, - ).pack(side=tk.LEFT, padx=5) - - # Search frame - search_frame = ttk.Frame(self.dialog) - search_frame.pack(fill=tk.X, padx=10, pady=2) - - ttk.Label(search_frame, text="Search:").pack(side=tk.LEFT) - self.search_var = tk.StringVar() - self.search_entry = ttk.Entry(search_frame, textvariable=self.search_var) - self.search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5) - self.search_entry.bind("", self.search_log) - self.search_entry.bind("", self.search_as_type) - - ttk.Button(search_frame, text="Find", command=self.search_log).pack(side=tk.LEFT, padx=2) - ttk.Button(search_frame, text="Clear Search", command=self.clear_search).pack(side=tk.LEFT, padx=2) - - # Log content area with scrollbars - content_frame = ttk.Frame(self.dialog) - content_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) - - # Text widget with scrollbars - self.log_text = scrolledtext.ScrolledText( - content_frame, - wrap=tk.WORD, - font=("Consolas", 10), # Monospace font for logs - state=tk.DISABLED, - ) - self.log_text.pack(fill=tk.BOTH, expand=True) - - # Configure text tags for highlighting - self.log_text.tag_configure("error", foreground="red", font=("Consolas", 10, "bold")) - self.log_text.tag_configure("warning", foreground="orange", font=("Consolas", 10, "bold")) - self.log_text.tag_configure("info", foreground="blue") - self.log_text.tag_configure("debug", foreground="gray") - self.log_text.tag_configure("search_highlight", background="yellow") - - # Status bar - self.status_var = tk.StringVar() - status_bar = ttk.Label(self.dialog, textvariable=self.status_var, relief=tk.SUNKEN) - status_bar.pack(side=tk.BOTTOM, fill=tk.X) - - def load_log_content(self): - """Load log file content""" - try: - if os.path.exists(self.log_path): - with open(self.log_path, "r", encoding="utf-8", errors="ignore") as f: - content = f.read() - - # Update text widget - self.log_text.config(state=tk.NORMAL) - self.log_text.delete(1.0, tk.END) - - # Apply syntax highlighting - self.insert_with_highlighting(content) - - self.log_text.config(state=tk.DISABLED) - self.log_text.see(tk.END) # Scroll to bottom - - # Update file info - file_size = os.path.getsize(self.log_path) - line_count = content.count("\n") - self.file_info_var.set(f"File: {self.log_path} | Size: {file_size:,} bytes | Lines: {line_count:,}") - self.status_var.set("Log loaded successfully") - - else: - self.log_text.config(state=tk.NORMAL) - self.log_text.delete(1.0, tk.END) - self.log_text.insert(1.0, f"Log file not found: {self.log_path}") - self.log_text.config(state=tk.DISABLED) - self.file_info_var.set(f"File: {self.log_path} | Status: Not Found") - self.status_var.set("Log file not found") - - except Exception as e: - self.log_text.config(state=tk.NORMAL) - self.log_text.delete(1.0, tk.END) - self.log_text.insert(1.0, f"Error reading log file: {e}") - self.log_text.config(state=tk.DISABLED) - self.status_var.set(f"Error: {e}") - - def insert_with_highlighting(self, content): - """Insert content with syntax highlighting for log levels""" - lines = content.split("\n") - - for line in lines: - line_lower = line.lower() - - # Determine tag based on log level - if "error" in line_lower or "failed" in line_lower or "exception" in line_lower: - tag = "error" - elif "warning" in line_lower or "warn" in line_lower: - tag = "warning" - elif "info" in line_lower: - tag = "info" - elif "debug" in line_lower: - tag = "debug" - else: - tag = None - - if tag: - self.log_text.insert(tk.END, line + "\n", tag) - else: - self.log_text.insert(tk.END, line + "\n") - - def refresh_log(self): - """Manually refresh the log content""" - self.load_log_content() - - def clear_log(self): - """Clear the log display (not the actual file)""" - if messagebox.askyesno( - "Clear Display", - "Clear the log display? (This won't delete the actual log file)", - ): - self.log_text.config(state=tk.NORMAL) - self.log_text.delete(1.0, tk.END) - self.log_text.config(state=tk.DISABLED) - self.status_var.set("Display cleared") - - def save_log(self): - """Save log content to a new file""" - try: - file_path = filedialog.asksaveasfilename( - defaultextension=".log", - filetypes=[ - ("Log files", "*.log"), - ("Text files", "*.txt"), - ("All files", "*.*"), - ], - ) - - if file_path: - content = self.log_text.get(1.0, tk.END) - with open(file_path, "w", encoding="utf-8") as f: - f.write(content) - self.status_var.set(f"Log saved to: {file_path}") - - except Exception as e: - messagebox.showerror("Save Error", f"Failed to save log: {e}") - - def search_log(self, event=None): - """Search for text in the log""" - search_text = self.search_var.get() - if not search_text: - return - - # Clear previous highlights - self.log_text.tag_remove("search_highlight", 1.0, tk.END) - - # Search and highlight - start_pos = 1.0 - matches = 0 - - while True: - pos = self.log_text.search(search_text, start_pos, tk.END, nocase=True) - if not pos: - break - - end_pos = f"{pos}+{len(search_text)}c" - self.log_text.tag_add("search_highlight", pos, end_pos) - start_pos = end_pos - matches += 1 - - if matches > 0: - # Jump to first match - first_match = self.log_text.search(search_text, 1.0, tk.END, nocase=True) - self.log_text.see(first_match) - self.status_var.set(f"Found {matches} matches for '{search_text}'") - else: - self.status_var.set(f"No matches found for '{search_text}'") - - def search_as_type(self, event=None): - """Search as user types (with delay)""" - # Cancel previous search - if hasattr(self, "search_timer"): - self.dialog.after_cancel(self.search_timer) - - # Schedule new search - self.search_timer = self.dialog.after(300, self.search_log) # 300ms delay - - def clear_search(self): - """Clear search highlighting""" - self.search_var.set("") - self.log_text.tag_remove("search_highlight", 1.0, tk.END) - self.status_var.set("Search cleared") - - def toggle_auto_refresh(self): - """Toggle auto-refresh functionality""" - self.auto_refresh = self.auto_refresh_var.get() - if self.auto_refresh: - self.status_var.set("Auto-refresh enabled") - else: - self.status_var.set("Auto-refresh disabled") - - def auto_refresh_loop(self): - """Auto-refresh loop for live log viewing""" - while self.auto_refresh: - try: - if self.auto_refresh_var.get(): - # Check if file has been modified - if os.path.exists(self.log_path): - current_mtime = os.path.getmtime(self.log_path) - if not hasattr(self, "last_mtime") or current_mtime > self.last_mtime: - self.last_mtime = current_mtime - self.dialog.after(0, self.load_log_content) - - time.sleep(2) # Check every 2 seconds - - except Exception as e: - logger.error(f"Auto-refresh error: {e}") - time.sleep(5) # Wait longer on error - - def on_closing(self): - """Handle dialog closing""" - self.auto_refresh = False - self.dialog.destroy() - - -def main(): - """Main function to start the GUI""" - root = tk.Tk() - app = BaseStationGUI(root) - root.mainloop() - - -if __name__ == "__main__": - main() diff --git a/base_station_gui_old.py b/base_station_gui_old.py deleted file mode 100644 index 418f839..0000000 --- a/base_station_gui_old.py +++ /dev/null @@ -1,1900 +0,0 @@ -""" -base_station_gui.py -Ty Young - -A comprehensive GUI for the base station using tkinter. -This GUI provides all the functionality of main_ui.py but with a graphical interface. - -Features: -- Connected bUEs menu with status indicators -- Right-click context menu for bUE operations (disconnect, reload, restart, open logs) -- Interactive map showing bUE locations -- Custom markers that can be paired with bUEs -- Color-coded proximity indicators (changes when bUEs are within 10-20m of markers) -- Coordinates table -- Distance table between bUEs -- Received messages table -- Base station log file viewer -""" - -import tkinter as tk -from tkinter import ttk, messagebox, filedialog, scrolledtext -import threading -import time -import os -import subprocess -import platform -from datetime import datetime, date, timedelta -from loguru import logger -import math - -# Try to import TkinterMapView, fallback to canvas if not available -try: - import tkintermapview - from PIL import Image, ImageDraw, ImageTk - - MAP_VIEW_AVAILABLE = True - print("TkinterMapView is available - using interactive map") -except ImportError: - MAP_VIEW_AVAILABLE = False - print("TkinterMapView not available - using fallback canvas map") - -from base_station_main import Base_Station_Main -from constants import bUEs, TIMEOUT - - -class BaseStationGUI: - def __init__(self, root): - self.root = root - self.root.title("Base Station Control Panel") - self.root.geometry("1400x900") - - # Initialize base station - self.base_station = None - self.update_thread = None - self.running = False - - # Custom markers for the map - self.custom_markers = {} # {marker_id: {'name': str, 'lat': float, 'lon': float, 'paired_bue': int}} - self.marker_counter = 0 - - # Map configuration - self.use_interactive_map = MAP_VIEW_AVAILABLE - self.map_widget = None # Will hold TkinterMapView or canvas - self.map_markers = {} # Track markers on the interactive map - self.last_bue_positions = {} # Track last known bUE positions to detect changes - self.map_auto_positioned = False # Track if we've done initial positioning - - # Setup GUI - self.setup_gui() - - # Start base station - self.start_base_station() - - def create_circle_marker_icon(self, color, size=20): - """Create a custom circular marker icon similar to canvas map""" - if not MAP_VIEW_AVAILABLE: - return None - - try: - # Create image with transparent background - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - - # Draw outer circle (border) - border_color = "darkblue" if color in ["blue", "green"] else "darkred" - draw.ellipse([0, 0, size - 1, size - 1], fill=color, outline=border_color, width=2) - - # Convert to PhotoImage for tkinter - return ImageTk.PhotoImage(img) - except Exception as e: - logger.error(f"Error creating circle marker icon: {e}") - return None - - # Handle window close - self.root.protocol("WM_DELETE_WINDOW", self.on_closing) - - def setup_gui(self): - """Setup the main GUI layout with all panels always visible""" - # Increase window size to accommodate all panels - self.root.geometry("1600x1000") - - # Create main container with grid layout - main_frame = ttk.Frame(self.root) - main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Configure grid weights for responsive resizing - main_frame.grid_columnconfigure(0, weight=1) # Left column - main_frame.grid_columnconfigure(1, weight=2) # Middle column (map) - main_frame.grid_columnconfigure(2, weight=1) # Right column - main_frame.grid_rowconfigure(0, weight=1) # Top row - main_frame.grid_rowconfigure(1, weight=1) # Bottom row - - # Left panel - bUE list and controls - left_frame = ttk.Frame(main_frame) - left_frame.grid(row=0, column=0, rowspan=2, sticky="nsew", padx=(0, 2)) - self.setup_left_panel(left_frame) - - # Middle top panel - Map - map_frame = ttk.LabelFrame(main_frame, text="bUE Location Map") - map_frame.grid(row=0, column=1, sticky="nsew", padx=2) - self.setup_map_view(map_frame) - - # Middle bottom panel - Messages - messages_frame = ttk.LabelFrame(main_frame, text="Messages") - messages_frame.grid(row=1, column=1, sticky="nsew", padx=2, pady=(2, 0)) - self.setup_messages_view(messages_frame) - - # Right panel - Data tables - tables_frame = ttk.Frame(main_frame) - tables_frame.grid(row=0, column=2, rowspan=2, sticky="nsew", padx=(2, 0)) - self.setup_tables_view(tables_frame) - - # Status bar - self.status_var = tk.StringVar() - self.status_var.set("Initializing...") - status_bar = ttk.Label(self.root, textvariable=self.status_var, relief=tk.SUNKEN) - status_bar.pack(side=tk.BOTTOM, fill=tk.X) - - def setup_left_panel(self, parent): - """Setup the left panel with bUE list and controls""" - # bUE List Frame - bue_frame = ttk.LabelFrame(parent, text="Connected bUEs") - bue_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # bUE Treeview - self.bue_tree = ttk.Treeview(bue_frame, columns=("status", "ping"), show="tree headings") - self.bue_tree.heading("#0", text="bUE ID") - self.bue_tree.heading("status", text="Status") - self.bue_tree.heading("ping", text="Ping Status") - - self.bue_tree.column("#0", width=100) - self.bue_tree.column("status", width=100) - self.bue_tree.column("ping", width=100) - - # Scrollbar for treeview - bue_scrollbar = ttk.Scrollbar(bue_frame, orient=tk.VERTICAL, command=self.bue_tree.yview) - self.bue_tree.configure(yscrollcommand=bue_scrollbar.set) - - self.bue_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - bue_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - # Bind right-click context menu (cross-platform) - if platform.system() == "Darwin": # macOS - self.bue_tree.bind("", self.show_bue_context_menu) # macOS right-click - self.bue_tree.bind("", self.show_bue_context_menu) # macOS Ctrl+click alternative - else: # Linux/Windows - self.bue_tree.bind("", self.show_bue_context_menu) # Standard right-click - - # Control buttons frame - control_frame = ttk.LabelFrame(parent, text="Controls") - control_frame.pack(fill=tk.X, padx=5, pady=5) - - # Test button - ttk.Button(control_frame, text="Run Test", command=self.run_test).pack(fill=tk.X, pady=2) - - # Cancel test button - ttk.Button(control_frame, text="Cancel Tests", command=self.cancel_tests).pack(fill=tk.X, pady=2) - - # Open base station log - ttk.Button(control_frame, text="Open Base Station Log", command=self.open_base_log).pack(fill=tk.X, pady=2) - - # Map controls frame - map_control_frame = ttk.LabelFrame(parent, text="Map Controls") - map_control_frame.pack(fill=tk.X, padx=5, pady=5) - - ttk.Button(map_control_frame, text="Add Custom Marker", command=self.add_custom_marker).pack(fill=tk.X, pady=2) - ttk.Button(map_control_frame, text="Manage Markers", command=self.manage_markers).pack(fill=tk.X, pady=2) - - # Map type toggle (only show if both options are available) - if MAP_VIEW_AVAILABLE: - self.map_toggle_btn = ttk.Button(map_control_frame, text="Switch to Simple Map", command=self.toggle_map_type) - self.map_toggle_btn.pack(fill=tk.X, pady=2) - - def setup_map_view(self, parent): - """Setup the map view with bUE locations and custom markers""" - # Create container for map - self.map_container = parent - - # Set up the appropriate map type - if self.use_interactive_map and MAP_VIEW_AVAILABLE: - self.setup_interactive_map() - else: - self.setup_canvas_map() - - # Map info frame (always present) - map_info_frame = ttk.Frame(parent) - map_info_frame.pack(fill=tk.X, padx=5, pady=5) - - ttk.Label(map_info_frame, text="Legend:").pack(side=tk.LEFT) - ttk.Label(map_info_frame, text="🔵 bUE", foreground="blue").pack(side=tk.LEFT, padx=5) - ttk.Label(map_info_frame, text="📍 Marker", foreground="red").pack(side=tk.LEFT, padx=5) - ttk.Label(map_info_frame, text="🟢 Close", foreground="green").pack(side=tk.LEFT, padx=5) - - if self.use_interactive_map and MAP_VIEW_AVAILABLE: - ttk.Label(map_info_frame, text="| Interactive Map Active", foreground="green").pack(side=tk.LEFT, padx=5) - else: - ttk.Label(map_info_frame, text="| Simple Map Active", foreground="orange").pack(side=tk.LEFT, padx=5) - - def setup_interactive_map(self): - """Setup TkinterMapView interactive map""" - try: - # Create the map widget - self.map_widget = tkintermapview.TkinterMapView(self.map_container, width=600, height=400, corner_radius=0) - self.map_widget.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Set a default position (you can change this to your area) - self.map_widget.set_position(40.2518, -111.6493) # Provo, Utah - self.map_widget.set_zoom(10) - - # Clear any existing markers - self.map_markers = {} - - print("Interactive map initialized successfully") - - except Exception as e: - print(f"Failed to setup interactive map: {e}") - logger.error(f"Failed to setup interactive map: {e}") - # Fallback to canvas map - self.use_interactive_map = False - self.setup_canvas_map() - - def setup_canvas_map(self): - """Setup fallback canvas-based map""" - # Create canvas map (original implementation) - self.map_widget = tk.Canvas(self.map_container, bg="lightblue", width=600, height=400) - self.map_widget.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Bind canvas events - self.map_widget.bind("", self.on_map_click) - self.map_widget.bind("", self.on_map_hover) - - def toggle_map_type(self): - """Toggle between interactive map and simple canvas map""" - if not MAP_VIEW_AVAILABLE: - messagebox.showinfo("Map Toggle", "TkinterMapView is not available. Cannot switch map types.") - return - - try: - # Store current state - old_use_interactive = self.use_interactive_map - - # Toggle map type - self.use_interactive_map = not self.use_interactive_map - - # Clear the current map widget - if hasattr(self, "map_widget") and self.map_widget: - self.map_widget.destroy() - - # Create new map - if self.use_interactive_map: - self.setup_interactive_map() - if hasattr(self, "map_toggle_btn"): - self.map_toggle_btn.config(text="Switch to Simple Map") - else: - self.setup_canvas_map() - if hasattr(self, "map_toggle_btn"): - self.map_toggle_btn.config(text="Switch to Interactive Map") - - # Update the map with current data - self.update_map() - - # Update info text - for widget in self.map_container.winfo_children(): - if isinstance(widget, ttk.Frame): - for child in widget.winfo_children(): - if isinstance(child, ttk.Label) and "Map Active" in child.cget("text"): - if self.use_interactive_map: - child.config(text="| Interactive Map Active", foreground="green") - else: - child.config(text="| Simple Map Active", foreground="orange") - - logger.info( - f"Switched from {'Interactive' if old_use_interactive else 'Simple'} to {'Interactive' if self.use_interactive_map else 'Simple'} map" - ) - - except Exception as e: - messagebox.showerror("Map Error", f"Failed to switch map type: {e}") - logger.error(f"Failed to toggle map type: {e}") - - def setup_tables_view(self, parent): - """Setup the tables view with coordinates and distances""" - # Create paned window for tables - tables_paned = ttk.PanedWindow(parent, orient=tk.VERTICAL) - tables_paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Coordinates table - coord_frame = ttk.LabelFrame(tables_paned, text="bUE Coordinates") - tables_paned.add(coord_frame, weight=1) - - self.coord_tree = ttk.Treeview(coord_frame, columns=("latitude", "longitude"), show="tree headings") - self.coord_tree.heading("#0", text="bUE ID") - self.coord_tree.heading("latitude", text="Latitude") - self.coord_tree.heading("longitude", text="Longitude") - self.coord_tree.pack(fill=tk.BOTH, expand=True) - - # Distance table - dist_frame = ttk.LabelFrame(tables_paned, text="bUE Distances") - tables_paned.add(dist_frame, weight=1) - - self.dist_tree = ttk.Treeview(dist_frame, columns=("distance",), show="tree headings") - self.dist_tree.heading("#0", text="bUE Pair") - self.dist_tree.heading("distance", text="Distance (m)") - self.dist_tree.pack(fill=tk.BOTH, expand=True) - - def setup_messages_view(self, parent): - """Setup the messages view""" - # Messages text area - adjust height for horizontal layout - self.messages_text = scrolledtext.ScrolledText(parent, height=12, wrap=tk.WORD) - self.messages_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Control frame for buttons - control_frame = ttk.Frame(parent) - control_frame.pack(fill=tk.X, padx=5, pady=5) - - # Clear messages button - ttk.Button(control_frame, text="Clear Messages", command=self.clear_messages).pack(side=tk.LEFT) - - def start_base_station(self): - """Initialize and start the base station""" - try: - self.base_station = Base_Station_Main("config_base.yaml") - self.base_station.tick_enabled = True - self.running = True - - # Start update thread - self.update_thread = threading.Thread(target=self.update_loop, daemon=True) - self.update_thread.start() - - self.status_var.set("Base Station Running") - logger.info("Base Station GUI started successfully") - - except Exception as e: - messagebox.showerror("Error", f"Failed to start base station: {e}") - logger.error(f"Failed to start base station: {e}") - - def update_loop(self): - """Main update loop for GUI refresh""" - while self.running: - try: - if self.base_station: - self.root.after(0, self.update_display) - time.sleep(1) # Update every second - except Exception as e: - logger.error(f"Error in update loop: {e}") - - def update_display(self): - """Update all GUI elements with current data""" - if not self.base_station: - return - - self.update_bue_list() - self.update_map() - self.update_tables() - self.update_messages() - self.update_status() - - def update_bue_list(self): - """Update the bUE list with current connections and status""" - # Clear existing items - for item in self.bue_tree.get_children(): - self.bue_tree.delete(item) - - # Add connected bUEs - for bue_id in self.base_station.connected_bues: - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - - # Determine status - if bue_id in getattr(self.base_station, "testing_bues", []): - status = "🧪 Testing" - else: - status = "💤 Idle" - - # Determine ping status - timeout_val = self.base_station.bue_timeout_tracker.get(bue_id, 0) - if timeout_val >= TIMEOUT / 2: - ping_status = "🟢 Good" - elif timeout_val > 0: - ping_status = "🟡 Warning" - else: - ping_status = "🔴 Lost" - - self.bue_tree.insert("", "end", iid=bue_id, text=bue_name, values=(status, ping_status)) - - def update_map(self): - """Update the map with bUE locations and markers""" - if not hasattr(self, "map_widget") or not self.map_widget: - return - - if self.use_interactive_map and MAP_VIEW_AVAILABLE: - self.update_interactive_map() - else: - self.update_canvas_map() - - def update_interactive_map(self): - """Update TkinterMapView with current data""" - if not hasattr(self, "map_widget") or not self.map_widget: - return - - try: - # Clear existing markers - for marker_id, marker_obj in self.map_markers.items(): - try: - marker_obj.delete() - except: - pass - self.map_markers.clear() - - if not self.base_station or not self.base_station.bue_coordinates: - return - - # Check if bUE positions have changed significantly - current_positions = {} - position_changed = False - new_bues_detected = False - - # Calculate center point for the map - lats = [] - lons = [] - - # Get bUE coordinates and track changes - for bue_id, coords in self.base_station.bue_coordinates.items(): - try: - lat, lon = float(coords[0]), float(coords[1]) - lats.append(lat) - lons.append(lon) - - current_positions[bue_id] = (lat, lon) - - # Check for new bUEs - if bue_id not in self.last_bue_positions: - new_bues_detected = True - # Check for significant position changes (more than ~100 meters) - elif bue_id in self.last_bue_positions: - old_lat, old_lon = self.last_bue_positions[bue_id] - distance_moved = self.calculate_distance(lat, lon, old_lat, old_lon) - if distance_moved > 100: # 100 meters threshold - position_changed = True - - except (ValueError, IndexError): - continue - - # Add custom marker coordinates - for marker in self.custom_markers.values(): - lats.append(marker["lat"]) - lons.append(marker["lon"]) - - # Only auto-center/zoom if: - # 1. This is the first time setting up the map, OR - # 2. New bUEs have been detected, OR - # 3. Existing bUEs have moved significantly - should_auto_position = not self.map_auto_positioned or new_bues_detected or position_changed - - if should_auto_position and lats and lons: - # Set map center to the average of all coordinates - center_lat = sum(lats) / len(lats) - center_lon = sum(lons) / len(lons) - self.map_widget.set_position(center_lat, center_lon) - - # Auto-zoom to fit all markers with extra context - lat_range = max(lats) - min(lats) - lon_range = max(lons) - min(lons) - max_range = max(lat_range, lon_range) - - # Add padding to ensure markers aren't at the edge (25% extra space) - padded_range = max_range * 1.25 - - # Determine zoom level based on coordinate range (less aggressive zooming) - if padded_range > 1: - zoom = 7 # Reduced from 8 - very wide area view - elif padded_range > 0.1: - zoom = 10 # Reduced from 12 - city-level view - elif padded_range > 0.01: - zoom = 12 # Reduced from 15 - neighborhood view - elif padded_range > 0.001: - zoom = 14 # New level - street level with good context - else: - zoom = 15 # Reduced from 17 - close but not too tight - - self.map_widget.set_zoom(zoom) - self.map_auto_positioned = True - - if new_bues_detected: - logger.info("Auto-centered map due to new bUEs") - elif position_changed: - logger.info("Auto-centered map due to significant bUE movement") - - # Update position tracking - self.last_bue_positions = current_positions.copy() - - # Add bUE markers - for bue_id, coords in self.base_station.bue_coordinates.items(): - try: - lat, lon = float(coords[0]), float(coords[1]) - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - - # Check proximity to custom markers - is_close = False - for marker in self.custom_markers.values(): - if marker.get("paired_bue") == bue_id: - distance = self.calculate_distance(lat, lon, marker["lat"], marker["lon"]) - if distance <= 20: # 20 meters proximity - is_close = True - break - - # Choose marker color based on proximity - marker_color = "green" if is_close else "blue" - - # Create custom circle icon matching canvas map style - circle_icon = self.create_circle_marker_icon(marker_color) - - # Create marker with custom circular icon - if circle_icon: - marker = self.map_widget.set_marker( - lat, lon, text=bue_name, icon=circle_icon, font=("Arial", 10, "bold"), text_color="white" - ) - else: - # Fallback to default marker if icon creation failed - marker = self.map_widget.set_marker( - lat, - lon, - text=bue_name, - marker_color_circle=marker_color, - marker_color_outside="darkblue", - font=("Arial", 10, "bold"), - ) - self.map_markers[f"bue_{bue_id}"] = marker - - except (ValueError, IndexError) as e: - logger.error(f"Error plotting bUE {bue_id} on interactive map: {e}") - - # Add custom markers - for marker_id, marker_data in self.custom_markers.items(): - try: - # Create custom circle icon for custom markers - circle_icon = self.create_circle_marker_icon("red") - - # Create custom marker with circular icon - if circle_icon: - marker = self.map_widget.set_marker( - marker_data["lat"], - marker_data["lon"], - text=marker_data["name"], - icon=circle_icon, - font=("Arial", 10, "bold"), - text_color="white", - ) - else: - # Fallback to default marker if icon creation failed - marker = self.map_widget.set_marker( - marker_data["lat"], - marker_data["lon"], - text=marker_data["name"], - marker_color_circle="red", - marker_color_outside="darkred", - font=("Arial", 10, "bold"), - ) - self.map_markers[f"custom_{marker_id}"] = marker - except Exception as e: - logger.error(f"Error plotting custom marker {marker_id} on interactive map: {e}") - - except Exception as e: - logger.error(f"Error updating interactive map: {e}") - - def update_canvas_map(self): - """Update canvas-based map (original implementation)""" - if not hasattr(self, "map_widget") or not self.map_widget: - return - - # Clear canvas - self.map_widget.delete("all") - - if not self.base_station or not self.base_station.bue_coordinates: - self.map_widget.create_text( - 300, - 200, - text="No bUE coordinates available", - font=("Arial", 14), - fill="gray", - ) - return - - # Calculate map bounds - lats = [] - lons = [] - - # Get bUE coordinates - for coords in self.base_station.bue_coordinates.values(): - try: - lat, lon = float(coords[0]), float(coords[1]) - lats.append(lat) - lons.append(lon) - except (ValueError, IndexError): - continue - - # Add custom marker coordinates - for marker in self.custom_markers.values(): - lats.append(marker["lat"]) - lons.append(marker["lon"]) - - if not lats or not lons: - self.map_widget.create_text( - 300, - 200, - text="No valid coordinates available", - font=("Arial", 14), - fill="gray", - ) - return - - # Calculate bounds with padding - min_lat, max_lat = min(lats), max(lats) - min_lon, max_lon = min(lons), max(lons) - - # Add padding - lat_padding = (max_lat - min_lat) * 0.1 or 0.001 - lon_padding = (max_lon - min_lon) * 0.1 or 0.001 - - min_lat -= lat_padding - max_lat += lat_padding - min_lon -= lon_padding - max_lon += lon_padding - - # Get canvas dimensions - canvas_width = self.map_widget.winfo_width() or 600 - canvas_height = self.map_widget.winfo_height() or 400 - - # Map coordinate conversion functions - def lat_to_y(lat): - return canvas_height - ((lat - min_lat) / (max_lat - min_lat)) * canvas_height - - def lon_to_x(lon): - return ((lon - min_lon) / (max_lon - min_lon)) * canvas_width - - # Draw bUEs - for bue_id, coords in self.base_station.bue_coordinates.items(): - try: - lat, lon = float(coords[0]), float(coords[1]) - x, y = lon_to_x(lon), lat_to_y(lat) - - # Check proximity to custom markers - is_close = False - for marker in self.custom_markers.values(): - if marker.get("paired_bue") == bue_id: - distance = self.calculate_distance(lat, lon, marker["lat"], marker["lon"]) - if distance <= 20: # 20 meters proximity - is_close = True - break - - # Choose color based on proximity - color = "green" if is_close else "blue" - - # Draw bUE circle - radius = 8 - self.map_widget.create_oval( - x - radius, - y - radius, - x + radius, - y + radius, - fill=color, - outline="darkblue", - width=2, - tags=f"bue_{bue_id}", - ) - - # Label - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - self.map_widget.create_text( - x, - y - 15, - text=bue_name, - font=("Arial", 8), - fill="black", - tags=f"bue_{bue_id}", - ) - - except (ValueError, IndexError) as e: - logger.error(f"Error plotting bUE {bue_id}: {e}") - - # Draw custom markers - for marker_id, marker in self.custom_markers.items(): - x, y = lon_to_x(marker["lon"]), lat_to_y(marker["lat"]) - - # Draw marker - radius = 6 - self.map_widget.create_oval( - x - radius, - y - radius, - x + radius, - y + radius, - fill="red", - outline="darkred", - width=2, - tags=f"marker_{marker_id}", - ) - - # Label - self.map_widget.create_text( - x, - y - 15, - text=marker["name"], - font=("Arial", 8), - fill="red", - tags=f"marker_{marker_id}", - ) - - def update_tables(self): - """Update coordinate and distance tables""" - # Update coordinates table - for item in self.coord_tree.get_children(): - self.coord_tree.delete(item) - - if self.base_station: - for bue_id, coords in self.base_station.bue_coordinates.items(): - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - try: - lat, lon = coords[0], coords[1] - self.coord_tree.insert("", "end", text=bue_name, values=(lat, lon)) - except (IndexError, ValueError): - self.coord_tree.insert("", "end", text=bue_name, values=("Invalid", "Invalid")) - - # Update distance table - for item in self.dist_tree.get_children(): - self.dist_tree.delete(item) - - if self.base_station and len(self.base_station.connected_bues) > 1: - processed_pairs = set() - for bue1 in self.base_station.connected_bues: - for bue2 in self.base_station.connected_bues: - if ( - bue1 != bue2 - and bue1 in self.base_station.bue_coordinates - and bue2 in self.base_station.bue_coordinates - and (bue1, bue2) not in processed_pairs - and (bue2, bue1) not in processed_pairs - ): - - distance = self.base_station.get_distance(bue1, bue2) - if distance is not None: - pair_name = f"{bUEs.get(str(bue1), str(bue1))} ↔ {bUEs.get(str(bue2), str(bue2))}" - self.dist_tree.insert("", "end", text=pair_name, values=(f"{distance:.2f}")) - - processed_pairs.add((bue1, bue2)) - - def update_messages(self): - """Update the messages display""" - if self.base_station and hasattr(self.base_station, "stdout_history"): - # Get current content - current_content = self.messages_text.get(1.0, tk.END) - - # Build new content - new_content = "\n".join(self.base_station.stdout_history) - - # Only update if content changed - if new_content.strip() != current_content.strip(): - self.messages_text.delete(1.0, tk.END) - self.messages_text.insert(1.0, new_content) - self.messages_text.see(tk.END) # Scroll to bottom - - def update_status(self): - """Update the status bar""" - if self.base_station: - connected = len(self.base_station.connected_bues) - testing = len(getattr(self.base_station, "testing_bues", [])) - current_time = datetime.now().strftime("%H:%M:%S") - self.status_var.set(f"Time: {current_time} | Connected: {connected} | Testing: {testing}") - - def show_bue_context_menu(self, event): - """Show context menu for bUE operations""" - item = self.bue_tree.selection()[0] if self.bue_tree.selection() else None - if not item: - return - - bue_id = int(item) - - # Create context menu - context_menu = tk.Menu(self.root, tearoff=0) - context_menu.add_command(label="Disconnect", command=lambda: self.disconnect_bue(bue_id)) - context_menu.add_command(label="Reload", command=lambda: self.reload_bue(bue_id)) - context_menu.add_command(label="Restart", command=lambda: self.restart_bue(bue_id)) - context_menu.add_separator() - context_menu.add_command(label="Open Log File", command=lambda: self.open_bue_log(bue_id)) - - # Show menu - try: - context_menu.tk_popup(event.x_root, event.y_root) - finally: - context_menu.grab_release() - - def disconnect_bue(self, bue_id): - """Disconnect a specific bUE""" - if messagebox.askyesno( - "Confirm Disconnect", - f"Disconnect from {bUEs.get(str(bue_id), str(bue_id))}?", - ): - try: - self.base_station.connected_bues.remove(bue_id) - if bue_id in self.base_station.bue_coordinates: - del self.base_station.bue_coordinates[bue_id] - if bue_id in getattr(self.base_station, "testing_bues", []): - self.base_station.testing_bues.remove(bue_id) - if bue_id in self.base_station.bue_timeout_tracker: - del self.base_station.bue_timeout_tracker[bue_id] - logger.info(f"Disconnected from bUE {bue_id}") - except Exception as e: - messagebox.showerror("Error", f"Failed to disconnect: {e}") - - def reload_bue(self, bue_id): - """Reload a specific bUE""" - if messagebox.askyesno("Confirm Reload", f"Reload {bUEs.get(str(bue_id), str(bue_id))}?"): - try: - self.base_station.ota.send_ota_message(bue_id, "RELOAD") - self.disconnect_bue(bue_id) - logger.info(f"Sent reload command to bUE {bue_id}") - except Exception as e: - messagebox.showerror("Error", f"Failed to reload: {e}") - - def restart_bue(self, bue_id): - """Restart a specific bUE""" - if messagebox.askyesno("Confirm Restart", f"Restart {bUEs.get(str(bue_id), str(bue_id))}?"): - try: - self.base_station.ota.send_ota_message(bue_id, "RESTART") - self.disconnect_bue(bue_id) - logger.info(f"Sent restart command to bUE {bue_id}") - except Exception as e: - messagebox.showerror("Error", f"Failed to restart: {e}") - - def open_bue_log(self, bue_id): - """Open the log file for a specific bUE""" - log_path = f"logs/bue_{bue_id}.log" - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - LogViewerDialog(self.root, log_path, f"{bue_name} Log") - - def open_base_log(self): - """Open the base station log file""" - log_path = "logs/base_station.log" - LogViewerDialog(self.root, log_path, "Base Station Log") - - def run_test(self): - """Run test dialog and execute tests""" - if not self.base_station or not self.base_station.connected_bues: - messagebox.showwarning("No bUEs", "No bUEs currently connected") - return - - # Create test dialog - TestDialog(self.root, self.base_station) - - def cancel_tests(self): - """Cancel running tests""" - if not hasattr(self.base_station, "testing_bues") or not self.base_station.testing_bues: - messagebox.showinfo("No Tests", "No tests currently running") - return - - # Create cancel dialog - CancelTestDialog(self.root, self.base_station) - - def clear_messages(self): - """Clear the messages display""" - self.messages_text.delete(1.0, tk.END) - if self.base_station and hasattr(self.base_station, "stdout_history"): - self.base_station.stdout_history.clear() - - def add_custom_marker(self): - """Add a custom marker to the map""" - AddMarkerDialog(self.root, self) - - def manage_markers(self): - """Manage existing custom markers""" - ManageMarkersDialog(self.root, self) - - def on_map_click(self, event): - """Handle map click events""" - # Get clicked coordinates (simplified - would need proper coordinate conversion) - pass - - def on_map_hover(self, event): - """Handle map hover events""" - # Show coordinates or object info on hover - pass - - def calculate_distance(self, lat1, lon1, lat2, lon2): - """Calculate distance between two coordinates in meters""" - # Haversine formula - R = 6371000 # Earth's radius in meters - - lat1_rad = math.radians(lat1) - lat2_rad = math.radians(lat2) - delta_lat = math.radians(lat2 - lat1) - delta_lon = math.radians(lon2 - lon1) - - a = math.sin(delta_lat / 2) * math.sin(delta_lat / 2) + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin( - delta_lon / 2 - ) * math.sin(delta_lon / 2) - c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) - - return R * c - - def on_closing(self): - """Handle application closing""" - if messagebox.askokcancel("Quit", "Do you want to quit?"): - self.running = False - if self.base_station: - self.base_station.EXIT = True - if hasattr(self.base_station, "__del__"): - self.base_station.__del__() - self.root.destroy() - - -class TestDialog: - """All-in-one test dialog - everything in a single window""" - - def __init__(self, parent, base_station): - self.parent = parent - self.base_station = base_station - - self.dialog = tk.Toplevel(parent) - self.dialog.title("Test Management") - self.dialog.geometry("700x700") - self.dialog.grab_set() - - # Available test files - self.test_files = [ - "grc/lora_td_ru", - "grc/lora_tu_rd", - "Old/helloworld", - "Old/sf_msg_test", - "Old" "gpstest", - "gpstest2", - "../osu_testing/run_tx", - "../osu_testing/run_rx", - "../osu_testing/run_rx_12_20", - ] - - # Selected bUEs and their configurations - self.selected_bues = [] - self.bue_configs = {} # {bue_id: {'file': str, 'params': str}} - - self.setup_dialog() - - def setup_dialog(self): - """Setup the all-in-one test dialog""" - # Step 1: bUE Selection - selection_frame = ttk.LabelFrame(self.dialog, text="Step 1: Select bUEs for Testing", padding="10") - selection_frame.pack(fill=tk.X, padx=10, pady=5) - - ttk.Label(selection_frame, text="Choose which bUEs will run tests:").pack(anchor=tk.W, pady=(0, 5)) - - # Create checkboxes for connected bUEs - self.bue_vars = {} - checkbox_frame = ttk.Frame(selection_frame) - checkbox_frame.pack(fill=tk.X) - - row = 0 - col = 0 - for i, bue_id in enumerate(self.base_station.connected_bues): - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - var = tk.BooleanVar() - self.bue_vars[bue_id] = var - - cb = ttk.Checkbutton( - checkbox_frame, - text=bue_name, - variable=var, - command=self.update_selection, - ) - cb.grid(row=row, column=col, sticky=tk.W, padx=20, pady=2) - - col += 1 - if col > 1: # 2 columns - col = 0 - row += 1 - - # Selection summary - self.selection_label = ttk.Label(selection_frame, text="No bUEs selected", foreground="gray") - self.selection_label.pack(anchor=tk.W, pady=(5, 0)) - - # Step 2: Test Delay - time_frame = ttk.LabelFrame(self.dialog, text="Step 2: Set Test Delay", padding="10") - time_frame.pack(fill=tk.X, padx=10, pady=5) - - # Delay input - delay_controls = ttk.Frame(time_frame) - delay_controls.pack() - - ttk.Label(delay_controls, text="Start test in:").grid(row=0, column=0, padx=5) - self.delay_var = tk.StringVar(value="30") - delay_spin = tk.Spinbox(delay_controls, from_=5, to=300, textvariable=self.delay_var, width=5) - delay_spin.grid(row=0, column=1, padx=5) - ttk.Label(delay_controls, text="seconds").grid(row=0, column=2, padx=5) - - # Calculated start time display - self.start_time_label = ttk.Label(time_frame, text="", foreground="blue", font=("TkDefaultFont", 9)) - self.start_time_label.pack(pady=(10, 0)) - - # Update the calculated time when delay changes - self.delay_var.trace("w", self.update_calculated_time) - - # Start automatic time updates every second - self.start_auto_time_updates() - self.update_calculated_time() # Initial calculation - - # Step 3: Configure Individual bUEs - ALL IN ONE WINDOW - config_frame = ttk.LabelFrame(self.dialog, text="Step 3: Configure Each Selected bUE", padding="10") - config_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) - - # Create a scrollable frame for bUE configurations - canvas = tk.Canvas(config_frame, height=300) - scrollbar = ttk.Scrollbar(config_frame, orient="vertical", command=canvas.yview) - self.scrollable_frame = ttk.Frame(canvas) - - self.scrollable_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) - - canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - # Initially empty - will be populated when bUEs are selected - self.no_selection_label = ttk.Label( - self.scrollable_frame, - text="Select bUEs above to configure their tests here", - foreground="gray", - ) - self.no_selection_label.pack(pady=50) - - # Buttons - button_frame = ttk.Frame(self.dialog) - button_frame.pack(fill=tk.X, padx=10, pady=10) - - self.run_btn = ttk.Button(button_frame, text="Run Tests", command=self.run_tests, state=tk.DISABLED) - self.run_btn.pack(side=tk.LEFT, padx=5) - - ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack(side=tk.RIGHT, padx=5) - - def update_selection(self): - """Update the selection and show inline configuration""" - self.selected_bues = [bue_id for bue_id, var in self.bue_vars.items() if var.get()] - - if self.selected_bues: - bue_names = [bUEs.get(str(bid), f"bUE {bid}") for bid in self.selected_bues] - self.selection_label.config(text=f"Selected: {', '.join(bue_names)}", foreground="blue") - - # Show inline configuration for each selected bUE - self.show_inline_configs() - else: - self.selection_label.config(text="No bUEs selected", foreground="gray") - self.clear_inline_configs() - self.run_btn.config(state=tk.DISABLED) - - def start_auto_time_updates(self): - """Start automatic time updates every second""" - self.update_calculated_time() - # Schedule next update in 1000ms (1 second) - self.dialog.after(1000, self.start_auto_time_updates) - - def update_calculated_time(self, *args): - """Update the calculated start time display using current time""" - try: - delay_seconds = int(self.delay_var.get()) - # Always use current time for real-time updates - current_time = datetime.now().replace(microsecond=0) - start_time = current_time + timedelta(seconds=delay_seconds) - - # Format the time nicely - time_str = start_time.strftime("%I:%M:%S %p") - date_str = start_time.strftime("%Y-%m-%d") - - if start_time.date() == current_time.date(): - # Same day - self.start_time_label.config(text=f"Tests will start at: {time_str} (today)") - else: - # Next day - self.start_time_label.config(text=f"Tests will start at: {time_str} on {date_str}") - - except ValueError: - self.start_time_label.config(text="Invalid delay time") - - def show_inline_configs(self): - """Show configuration options for each selected bUE inline""" - # Clear existing config widgets - for widget in self.scrollable_frame.winfo_children(): - widget.destroy() - - self.config_widgets = {} - - for i, bue_id in enumerate(self.selected_bues): - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - - # Create a frame for this bUE's configuration - bue_frame = ttk.LabelFrame(self.scrollable_frame, text=f"Configure {bue_name}", padding="10") - bue_frame.pack(fill=tk.X, padx=5, pady=5) - - # Test file selection row - file_frame = ttk.Frame(bue_frame) - file_frame.pack(fill=tk.X, pady=(0, 5)) - - ttk.Label(file_frame, text="Test File:", width=12).pack(side=tk.LEFT) - file_var = tk.StringVar(value=self.test_files[0]) - file_combo = ttk.Combobox( - file_frame, - textvariable=file_var, - values=self.test_files, - state="readonly", - width=20, - ) - file_combo.pack(side=tk.LEFT, padx=(5, 0)) - - # Message field (always visible) - msg_frame = ttk.Frame(bue_frame) - msg_frame.pack(fill=tk.X, pady=(0, 5)) - - ttk.Label(msg_frame, text="Message:", width=12).pack(side=tk.LEFT) - msg_var = tk.StringVar() - msg_entry = ttk.Entry(msg_frame, textvariable=msg_var, width=20) - msg_entry.pack(side=tk.LEFT, padx=(5, 0)) - - # Conditional parameters frame for run_tx/run_rx only - params_frame = ttk.Frame(bue_frame) - - sf = [5, 6, 7, 8, 9, 10, 11, 12] - - # Create all the conditional widgets but don't pack them yet - sf_var = tk.IntVar(value=8) - bw_var = tk.StringVar(value="6000") # Default bandwidth - freq_var = tk.StringVar(value="12000") # Default center frequency - period_var = tk.StringVar(value="3000") # Default period - - # Row 1: Spreading Factor and Bandwidth - row1_frame = ttk.Frame(params_frame) - ttk.Label(row1_frame, text="Spreading Factor:", width=16).pack(side=tk.LEFT) - sf_entry = ttk.Combobox(row1_frame, textvariable=sf_var, values=sf, state="readonly", width=8) - sf_entry.pack(side=tk.LEFT, padx=(5, 15)) - - ttk.Label(row1_frame, text="Bandwidth:", width=12).pack(side=tk.LEFT) - bw_entry = ttk.Entry(row1_frame, textvariable=bw_var, width=10) - bw_entry.pack(side=tk.LEFT, padx=(5, 0)) - - # Row 2: Center Frequency and Period - row2_frame = ttk.Frame(params_frame) - ttk.Label(row2_frame, text="Center Frequency:", width=16).pack(side=tk.LEFT) - freq_entry = ttk.Entry(row2_frame, textvariable=freq_var, width=12) - freq_entry.pack(side=tk.LEFT, padx=(5, 15)) - - ttk.Label(row2_frame, text="Period:", width=12).pack(side=tk.LEFT) - period_entry = ttk.Entry(row2_frame, textvariable=period_var, width=10) - period_entry.pack(side=tk.LEFT, padx=(5, 0)) - - # Validation function for integer-only fields - def validate_integer(char): - return char.isdigit() - - vcmd = (self.dialog.register(validate_integer), "%S") - bw_entry.config(validate="key", validatecommand=vcmd) - freq_entry.config(validate="key", validatecommand=vcmd) - period_entry.config(validate="key", validatecommand=vcmd) - - def update_params_visibility(*args, fvar=file_var, pframe=params_frame, r1frame=row1_frame, r2frame=row2_frame): - selected_file = fvar.get() - if selected_file.endswith("run_tx") or selected_file.endswith("run_rx"): - pframe.pack(fill=tk.X, pady=(5, 0)) - r1frame.pack(fill=tk.X, pady=(0, 5)) - r2frame.pack(fill=tk.X, pady=(0, 5)) - else: - pframe.pack_forget() - - # Bind file selection changes to update visibility - file_var.trace("w", update_params_visibility) - # Initial visibility check - update_params_visibility() - - # Add placeholder functionality - placeholder_text = "No spaces" - msg_entry.insert(0, placeholder_text) - msg_entry.config(foreground="gray", font=("TkDefaultFont", 10, "italic")) - - def on_focus_in(event, entry=msg_entry, var=msg_var, placeholder=placeholder_text): - if var.get() == placeholder: - entry.delete(0, tk.END) - entry.config(foreground="black", font=("TkDefaultFont", 10, "normal")) - - def on_focus_out(event, entry=msg_entry, var=msg_var, placeholder=placeholder_text): - if not var.get(): - entry.insert(0, placeholder) - entry.config(foreground="gray", font=("TkDefaultFont", 10, "italic")) - - msg_entry.bind("", on_focus_in) - msg_entry.bind("", on_focus_out) - - # Store the variables for this bUE - self.config_widgets[bue_id] = { - "file_var": file_var, - "sf_var": sf_var, - "bw_var": bw_var, - "freq_var": freq_var, - "period_var": period_var, - "msg_var": msg_var, - "file_combo": file_combo, - "sf_entry": sf_entry, - "bw_entry": bw_entry, - "freq_entry": freq_entry, - "period_entry": period_entry, - "msg_entry": msg_entry, - } - - # Bind changes to enable run button - file_var.trace("w", self.check_ready_to_run) - sf_var.trace("w", self.check_ready_to_run) - bw_var.trace("w", self.check_ready_to_run) - freq_var.trace("w", self.check_ready_to_run) - period_var.trace("w", self.check_ready_to_run) - msg_var.trace("w", self.check_ready_to_run) - # Enable run button if we have configurations - self.check_ready_to_run() - - def clear_inline_configs(self): - """Clear all configuration widgets""" - for widget in self.scrollable_frame.winfo_children(): - widget.destroy() - - self.no_selection_label = ttk.Label( - self.scrollable_frame, - text="Select bUEs above to configure their tests here", - foreground="gray", - ) - self.no_selection_label.pack(pady=50) - - self.config_widgets = {} - - def check_ready_to_run(self, *args): - """Check if all configurations are ready and enable run button""" - if self.selected_bues and hasattr(self, "config_widgets") and self.config_widgets: - # All selected bUEs have configuration widgets, enable run - self.run_btn.config(state=tk.NORMAL) - else: - self.run_btn.config(state=tk.DISABLED) - - def run_tests(self): - """Execute the configured tests""" - if not self.selected_bues or not hasattr(self, "config_widgets"): - messagebox.showwarning( - "No Configuration", - "Please select and configure at least one bUE for testing", - ) - return - - # Collect configurations from the inline widgets - self.bue_configs = {} - for bue_id in self.selected_bues: - if bue_id in self.config_widgets: - widgets = self.config_widgets[bue_id] - config = { - "file": widgets["file_var"].get(), - "msg": widgets["msg_var"].get(), - } - - # Add run_tx/run_rx specific parameters if applicable - selected_file = widgets["file_var"].get() - if selected_file.endswith("run_tx") or selected_file.endswith("run_rx"): - config.update( - { - "sf": widgets["sf_var"].get(), - "bw": widgets["bw_var"].get(), - "freq": widgets["freq_var"].get(), - "period": widgets["period_var"].get(), - } - ) - - self.bue_configs[bue_id] = config - - # Calculate start time using delay - use CURRENT time for actual execution - try: - delay_seconds = int(self.delay_var.get()) - execution_time = datetime.now().replace(microsecond=0) # Fresh time for execution - start_time = execution_time + timedelta(seconds=delay_seconds) - unix_timestamp = int(start_time.timestamp()) - - # Format the start time for user confirmation - time_str = start_time.strftime("%I:%M:%S %p") - - # Send test commands - for bue_id, config in self.bue_configs.items(): - selected_file = config["file"] # ← FIX: Get file from config, not from outside loop - - if selected_file.endswith("run_tx") or selected_file.endswith("run_rx"): - command = f"TEST:{config['file']},{unix_timestamp},-s {config['sf']} -m {config['msg']} -c {config['freq']} -b {config['bw']} -p {config['period']}" - else: - command = f"TEST:{config['file']},{unix_timestamp}," - - self.base_station.ota.send_ota_message(bue_id, command) - time.sleep(0.1) - logger.info(f"Sent test command to bUE {bue_id}: {command}") - - bue_names = [bUEs.get(str(bue_id), str(bue_id)) for bue_id in self.bue_configs.keys()] - messagebox.showinfo( - "Tests Scheduled", - f"Tests scheduled for: {', '.join(bue_names)}\n\n" - f"Actual start time: {time_str}\n" - f"Delay: {delay_seconds} seconds from when you clicked 'Run'", - ) - self.dialog.destroy() - - except Exception as e: - messagebox.showerror("Error", f"Failed to schedule tests: {e}") - - -# Remove the IndividualBueConfigDialog since we don't need it anymore - - -class ConfigureBueDialog: - """Dialog for configuring a single bUE test""" - - def __init__(self, parent, bue_id, test_files, config_tree): - self.parent = parent - self.bue_id = bue_id - self.test_files = test_files - self.config_tree = config_tree - - self.dialog = tk.Toplevel(parent) - self.dialog.title(f"Configure {bUEs.get(str(bue_id), f'bUE {bue_id}')}") - self.dialog.geometry("400x200") - self.dialog.grab_set() - - self.setup_dialog() - - def setup_dialog(self): - """Setup the configuration dialog""" - ttk.Label( - self.dialog, - text=f"Configure test for {bUEs.get(str(self.bue_id), f'bUE {self.bue_id}')}", - ).pack(pady=10) - - # Test file selection - ttk.Label(self.dialog, text="Test File:").pack(anchor=tk.W, padx=20) - self.file_var = tk.StringVar(value=self.test_files[0]) - file_combo = ttk.Combobox( - self.dialog, - textvariable=self.file_var, - values=self.test_files, - state="readonly", - ) - file_combo.pack(fill=tk.X, padx=20, pady=5) - - # Parameters - ttk.Label(self.dialog, text="Parameters:").pack(anchor=tk.W, padx=20) - self.params_var = tk.StringVar() - ttk.Entry(self.dialog, textvariable=self.params_var).pack(fill=tk.X, padx=20, pady=5) - - # Buttons - button_frame = ttk.Frame(self.dialog) - button_frame.pack(fill=tk.X, padx=20, pady=10) - - ttk.Button(button_frame, text="OK", command=self.save_config).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack(side=tk.RIGHT, padx=5) - - def save_config(self): - """Save the configuration""" - bue_name = bUEs.get(str(self.bue_id), f"bUE {self.bue_id}") - self.config_tree.insert( - "", - "end", - text=bue_name, - values=(self.file_var.get(), self.params_var.get()), - ) - self.dialog.destroy() - - -class CancelTestDialog: - """Dialog for canceling running tests""" - - def __init__(self, parent, base_station): - self.parent = parent - self.base_station = base_station - - self.dialog = tk.Toplevel(parent) - self.dialog.title("Cancel Tests") - self.dialog.geometry("300x200") - self.dialog.grab_set() - - self.setup_dialog() - - def setup_dialog(self): - """Setup the cancel dialog""" - ttk.Label(self.dialog, text="Select tests to cancel:").pack(pady=10) - - self.test_vars = {} - for bue_id in getattr(self.base_station, "testing_bues", []): - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - var = tk.BooleanVar() - self.test_vars[bue_id] = var - ttk.Checkbutton(self.dialog, text=bue_name, variable=var).pack(anchor=tk.W, padx=20) - - # Buttons - button_frame = ttk.Frame(self.dialog) - button_frame.pack(fill=tk.X, padx=20, pady=10) - - ttk.Button(button_frame, text="Cancel Selected", command=self.cancel_tests).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="Close", command=self.dialog.destroy).pack(side=tk.RIGHT, padx=5) - - def cancel_tests(self): - """Cancel selected tests""" - canceled = [] - for bue_id, var in self.test_vars.items(): - if var.get(): - try: - for i in range(3): - self.base_station.ota.send_ota_message(bue_id, "CANC") - time.sleep(0.1) - canceled.append(bUEs.get(str(bue_id), str(bue_id))) - logger.info(f"Sent cancel command to bUE {bue_id}") - except Exception as e: - logger.error(f"Failed to cancel test for bUE {bue_id}: {e}") - - if canceled: - messagebox.showinfo("Tests Canceled", f"Canceled tests for: {', '.join(canceled)}") - - self.dialog.destroy() - - -class AddMarkerDialog: - """Dialog for adding custom markers""" - - def __init__(self, parent, main_gui): - self.parent = parent - self.main_gui = main_gui - - self.dialog = tk.Toplevel(parent) - self.dialog.title("Add Custom Marker") - self.dialog.geometry("400x350") - self.dialog.grab_set() - - self.setup_dialog() - - def setup_dialog(self): - """Setup the add marker dialog""" - # Marker name - ttk.Label(self.dialog, text="Marker Name:").pack(anchor=tk.W, padx=20, pady=(20, 5)) - self.name_var = tk.StringVar() - ttk.Entry(self.dialog, textvariable=self.name_var, width=30).pack(fill=tk.X, padx=20, pady=5) - - # Coordinates - ttk.Label(self.dialog, text="Latitude:").pack(anchor=tk.W, padx=20, pady=(10, 5)) - self.lat_var = tk.StringVar() - ttk.Entry(self.dialog, textvariable=self.lat_var, width=30).pack(fill=tk.X, padx=20, pady=5) - - ttk.Label(self.dialog, text="Longitude:").pack(anchor=tk.W, padx=20, pady=(10, 5)) - self.lon_var = tk.StringVar() - ttk.Entry(self.dialog, textvariable=self.lon_var, width=30).pack(fill=tk.X, padx=20, pady=5) - - # Pair with bUE - ttk.Label(self.dialog, text="Pair with bUE (optional):").pack(anchor=tk.W, padx=20, pady=(10, 5)) - try: - bue_options = ["None"] + [ - bUEs.get(str(bue_id), f"bUE {bue_id}") for bue_id in self.main_gui.base_station.connected_bues - ] - self.bue_var = tk.StringVar(value="None") - ttk.Combobox( - self.dialog, - textvariable=self.bue_var, - values=bue_options, - state="readonly", - ).pack(fill=tk.X, padx=20, pady=5) - except Exception as e: - print(f"Error creating bUE combobox: {e}") - # Fallback simple combobox - self.bue_var = tk.StringVar(value="None") - ttk.Combobox( - self.dialog, - textvariable=self.bue_var, - values=["None"], - state="readonly", - ).pack(fill=tk.X, padx=20, pady=5) - - # Buttons - button_frame = ttk.Frame(self.dialog) - button_frame.pack(fill=tk.X, padx=20, pady=20) - - # Add some spacing between buttons - ttk.Button(button_frame, text="Add Marker", command=self.add_marker).pack(side=tk.LEFT, padx=(0, 10)) - ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack(side=tk.LEFT, padx=(10, 0)) - - def add_marker(self): - """Add the custom marker""" - try: - name = self.name_var.get().strip() - lat = float(self.lat_var.get()) - lon = float(self.lon_var.get()) - - if not name: - messagebox.showwarning("Invalid Input", "Please enter a marker name") - return - - # Get paired bUE ID - paired_bue = None - bue_selection = self.bue_var.get() - if bue_selection != "None": - for bue_id in self.main_gui.base_station.connected_bues: - if bUEs.get(str(bue_id), f"bUE {bue_id}") == bue_selection: - paired_bue = bue_id - break - - # Add marker - marker_id = self.main_gui.marker_counter - self.main_gui.marker_counter += 1 - - self.main_gui.custom_markers[marker_id] = { - "name": name, - "lat": lat, - "lon": lon, - "paired_bue": paired_bue, - } - - # Refresh the map to show the new marker - self.main_gui.update_map() - - messagebox.showinfo("Marker Added", f"Added marker '{name}'") - self.dialog.destroy() - - except ValueError: - messagebox.showerror("Invalid Input", "Please enter valid coordinates") - except Exception as e: - messagebox.showerror("Error", f"Failed to add marker: {e}") - - -class ManageMarkersDialog: - """Dialog for managing custom markers""" - - def __init__(self, parent, main_gui): - self.parent = parent - self.main_gui = main_gui - - self.dialog = tk.Toplevel(parent) - self.dialog.title("Manage Custom Markers") - self.dialog.geometry("600x400") - self.dialog.grab_set() - - self.setup_dialog() - self.refresh_markers() - - def setup_dialog(self): - """Setup the manage markers dialog""" - # Markers list - self.markers_tree = ttk.Treeview(self.dialog, columns=("lat", "lon", "paired_bue"), show="tree headings") - self.markers_tree.heading("#0", text="Marker Name") - self.markers_tree.heading("lat", text="Latitude") - self.markers_tree.heading("lon", text="Longitude") - self.markers_tree.heading("paired_bue", text="Paired bUE") - - self.markers_tree.pack(fill=tk.BOTH, expand=True, padx=20, pady=20) - - # Buttons - button_frame = ttk.Frame(self.dialog) - button_frame.pack(fill=tk.X, padx=20, pady=10) - - ttk.Button(button_frame, text="Delete Selected", command=self.delete_marker).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="Edit Selected", command=self.edit_marker).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="Close", command=self.dialog.destroy).pack(side=tk.RIGHT, padx=5) - - def refresh_markers(self): - """Refresh the markers list""" - for item in self.markers_tree.get_children(): - self.markers_tree.delete(item) - - for marker_id, marker in self.main_gui.custom_markers.items(): - paired_bue_name = "None" - if marker.get("paired_bue"): - paired_bue_name = bUEs.get(str(marker["paired_bue"]), f"bUE {marker['paired_bue']}") - - self.markers_tree.insert( - "", - "end", - iid=marker_id, - text=marker["name"], - values=(marker["lat"], marker["lon"], paired_bue_name), - ) - - def delete_marker(self): - """Delete selected marker""" - selection = self.markers_tree.selection() - if not selection: - messagebox.showwarning("No Selection", "Please select a marker to delete") - return - - marker_id = int(selection[0]) - marker_name = self.main_gui.custom_markers[marker_id]["name"] - - if messagebox.askyesno("Confirm Delete", f"Delete marker '{marker_name}'?"): - del self.main_gui.custom_markers[marker_id] - self.refresh_markers() - # Refresh the map to remove the deleted marker - self.main_gui.update_map() - - def edit_marker(self): - """Edit selected marker""" - selection = self.markers_tree.selection() - if not selection: - messagebox.showwarning("No Selection", "Please select a marker to edit") - return - - marker_id = int(selection[0]) - # Could implement edit dialog similar to AddMarkerDialog - messagebox.showinfo("Edit Marker", "Edit functionality not yet implemented") - - -class LogViewerDialog: - """Dialog for viewing log files within the GUI""" - - def __init__(self, parent, log_path, title): - self.parent = parent - self.log_path = log_path - - self.dialog = tk.Toplevel(parent) - self.dialog.title(title) - self.dialog.geometry("900x600") - self.dialog.grab_set() - - # Make dialog resizable - self.dialog.resizable(True, True) - - self.setup_dialog() - self.load_log_content() - - # Auto-refresh thread for live log viewing - self.auto_refresh = True - self.refresh_thread = threading.Thread(target=self.auto_refresh_loop, daemon=True) - self.refresh_thread.start() - - # Handle dialog close - self.dialog.protocol("WM_DELETE_WINDOW", self.on_closing) - - def setup_dialog(self): - """Setup the log viewer dialog""" - # Top frame with controls - control_frame = ttk.Frame(self.dialog) - control_frame.pack(fill=tk.X, padx=10, pady=5) - - # File info - self.file_info_var = tk.StringVar() - ttk.Label(control_frame, textvariable=self.file_info_var).pack(side=tk.LEFT) - - # Buttons - button_frame = ttk.Frame(control_frame) - button_frame.pack(side=tk.RIGHT) - - ttk.Button(button_frame, text="Refresh", command=self.refresh_log).pack(side=tk.LEFT, padx=2) - ttk.Button(button_frame, text="Clear", command=self.clear_log).pack(side=tk.LEFT, padx=2) - ttk.Button(button_frame, text="Save As...", command=self.save_log).pack(side=tk.LEFT, padx=2) - - # Auto-refresh toggle - self.auto_refresh_var = tk.BooleanVar(value=True) - ttk.Checkbutton( - button_frame, - text="Auto-refresh", - variable=self.auto_refresh_var, - command=self.toggle_auto_refresh, - ).pack(side=tk.LEFT, padx=5) - - # Search frame - search_frame = ttk.Frame(self.dialog) - search_frame.pack(fill=tk.X, padx=10, pady=2) - - ttk.Label(search_frame, text="Search:").pack(side=tk.LEFT) - self.search_var = tk.StringVar() - self.search_entry = ttk.Entry(search_frame, textvariable=self.search_var) - self.search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5) - self.search_entry.bind("", self.search_log) - self.search_entry.bind("", self.search_as_type) - - ttk.Button(search_frame, text="Find", command=self.search_log).pack(side=tk.LEFT, padx=2) - ttk.Button(search_frame, text="Clear Search", command=self.clear_search).pack(side=tk.LEFT, padx=2) - - # Log content area with scrollbars - content_frame = ttk.Frame(self.dialog) - content_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) - - # Text widget with scrollbars - self.log_text = scrolledtext.ScrolledText( - content_frame, - wrap=tk.WORD, - font=("Consolas", 10), # Monospace font for logs - state=tk.DISABLED, - ) - self.log_text.pack(fill=tk.BOTH, expand=True) - - # Configure text tags for highlighting - self.log_text.tag_configure("error", foreground="red", font=("Consolas", 10, "bold")) - self.log_text.tag_configure("warning", foreground="orange", font=("Consolas", 10, "bold")) - self.log_text.tag_configure("info", foreground="blue") - self.log_text.tag_configure("debug", foreground="gray") - self.log_text.tag_configure("search_highlight", background="yellow") - - # Status bar - self.status_var = tk.StringVar() - status_bar = ttk.Label(self.dialog, textvariable=self.status_var, relief=tk.SUNKEN) - status_bar.pack(side=tk.BOTTOM, fill=tk.X) - - def load_log_content(self): - """Load log file content""" - try: - if os.path.exists(self.log_path): - with open(self.log_path, "r", encoding="utf-8", errors="ignore") as f: - content = f.read() - - # Update text widget - self.log_text.config(state=tk.NORMAL) - self.log_text.delete(1.0, tk.END) - - # Apply syntax highlighting - self.insert_with_highlighting(content) - - self.log_text.config(state=tk.DISABLED) - self.log_text.see(tk.END) # Scroll to bottom - - # Update file info - file_size = os.path.getsize(self.log_path) - line_count = content.count("\n") - self.file_info_var.set(f"File: {self.log_path} | Size: {file_size:,} bytes | Lines: {line_count:,}") - self.status_var.set("Log loaded successfully") - - else: - self.log_text.config(state=tk.NORMAL) - self.log_text.delete(1.0, tk.END) - self.log_text.insert(1.0, f"Log file not found: {self.log_path}") - self.log_text.config(state=tk.DISABLED) - self.file_info_var.set(f"File: {self.log_path} | Status: Not Found") - self.status_var.set("Log file not found") - - except Exception as e: - self.log_text.config(state=tk.NORMAL) - self.log_text.delete(1.0, tk.END) - self.log_text.insert(1.0, f"Error reading log file: {e}") - self.log_text.config(state=tk.DISABLED) - self.status_var.set(f"Error: {e}") - - def insert_with_highlighting(self, content): - """Insert content with syntax highlighting for log levels""" - lines = content.split("\n") - - for line in lines: - line_lower = line.lower() - - # Determine tag based on log level - if "error" in line_lower or "failed" in line_lower or "exception" in line_lower: - tag = "error" - elif "warning" in line_lower or "warn" in line_lower: - tag = "warning" - elif "info" in line_lower: - tag = "info" - elif "debug" in line_lower: - tag = "debug" - else: - tag = None - - if tag: - self.log_text.insert(tk.END, line + "\n", tag) - else: - self.log_text.insert(tk.END, line + "\n") - - def refresh_log(self): - """Manually refresh the log content""" - self.load_log_content() - - def clear_log(self): - """Clear the log display (not the actual file)""" - if messagebox.askyesno( - "Clear Display", - "Clear the log display? (This won't delete the actual log file)", - ): - self.log_text.config(state=tk.NORMAL) - self.log_text.delete(1.0, tk.END) - self.log_text.config(state=tk.DISABLED) - self.status_var.set("Display cleared") - - def save_log(self): - """Save log content to a new file""" - try: - file_path = filedialog.asksaveasfilename( - defaultextension=".log", - filetypes=[ - ("Log files", "*.log"), - ("Text files", "*.txt"), - ("All files", "*.*"), - ], - ) - - if file_path: - content = self.log_text.get(1.0, tk.END) - with open(file_path, "w", encoding="utf-8") as f: - f.write(content) - self.status_var.set(f"Log saved to: {file_path}") - - except Exception as e: - messagebox.showerror("Save Error", f"Failed to save log: {e}") - - def search_log(self, event=None): - """Search for text in the log""" - search_text = self.search_var.get() - if not search_text: - return - - # Clear previous highlights - self.log_text.tag_remove("search_highlight", 1.0, tk.END) - - # Search and highlight - start_pos = 1.0 - matches = 0 - - while True: - pos = self.log_text.search(search_text, start_pos, tk.END, nocase=True) - if not pos: - break - - end_pos = f"{pos}+{len(search_text)}c" - self.log_text.tag_add("search_highlight", pos, end_pos) - start_pos = end_pos - matches += 1 - - if matches > 0: - # Jump to first match - first_match = self.log_text.search(search_text, 1.0, tk.END, nocase=True) - self.log_text.see(first_match) - self.status_var.set(f"Found {matches} matches for '{search_text}'") - else: - self.status_var.set(f"No matches found for '{search_text}'") - - def search_as_type(self, event=None): - """Search as user types (with delay)""" - # Cancel previous search - if hasattr(self, "search_timer"): - self.dialog.after_cancel(self.search_timer) - - # Schedule new search - self.search_timer = self.dialog.after(300, self.search_log) # 300ms delay - - def clear_search(self): - """Clear search highlighting""" - self.search_var.set("") - self.log_text.tag_remove("search_highlight", 1.0, tk.END) - self.status_var.set("Search cleared") - - def toggle_auto_refresh(self): - """Toggle auto-refresh functionality""" - self.auto_refresh = self.auto_refresh_var.get() - if self.auto_refresh: - self.status_var.set("Auto-refresh enabled") - else: - self.status_var.set("Auto-refresh disabled") - - def auto_refresh_loop(self): - """Auto-refresh loop for live log viewing""" - while self.auto_refresh: - try: - if self.auto_refresh_var.get(): - # Check if file has been modified - if os.path.exists(self.log_path): - current_mtime = os.path.getmtime(self.log_path) - if not hasattr(self, "last_mtime") or current_mtime > self.last_mtime: - self.last_mtime = current_mtime - self.dialog.after(0, self.load_log_content) - - time.sleep(2) # Check every 2 seconds - - except Exception as e: - logger.error(f"Auto-refresh error: {e}") - time.sleep(5) # Wait longer on error - - def on_closing(self): - """Handle dialog closing""" - self.auto_refresh = False - self.dialog.destroy() - - -def main(): - """Main function to start the GUI""" - root = tk.Tk() - app = BaseStationGUI(root) - root.mainloop() - - -if __name__ == "__main__": - main() diff --git a/base_station_main.py b/base_station_main.py index 43bfa67..b1ecfce 100644 --- a/base_station_main.py +++ b/base_station_main.py @@ -26,7 +26,8 @@ logger.remove() # Remove default sink # Main log for everything -logger.add("logs/base_station.log", rotation="10 MB") +logger.add("logs/base_station.log", rotation="10 MB", enqueue=True) +logger.add("logs/last_run.log", mode="w", enqueue=True) class Base_Station_Main: @@ -61,7 +62,7 @@ def __init__(self, yaml_str): self.bue_missed_ping_counter: dict[int, int] = {} # Dictionary to hold how many PINGs have been missed self.bue_tout: list[str] = [] # List to hold messages that come with TOUT messages self.bue_id_to_state: dict[int, str] = {} # Dictionary to hold what state each bUE is currently in - self.bue_id_to_coords: dict[int, (int, int)] = {} # Dictionary to hold the coords of each bUE + self.bue_id_to_coords: dict[int, (float, float)] = {} # Dictionary to hold the coords of each bUE self.bue_id_to_last_ping_time: dict[int, int] = {} # Dictionary to hold when a bUE got its last PING # Set up the ota threads @@ -212,7 +213,7 @@ def ota_ping_handler(self, src_id: str, state: str, lat: str, long: str): coords: str = "" if lat != "" and long != "": - self.bue_id_to_coords[int(src_id)] = (int(lat), int(long)) + self.bue_id_to_coords[int(src_id)] = (float(lat), float(long)) coords = f"@ {lat}, {long}" self.ota_outgoing_queue.put((src_id, "PINGR")) diff --git a/constants.py b/constants.py index 0557208..c0762a9 100644 --- a/constants.py +++ b/constants.py @@ -1,6 +1,5 @@ from enum import Enum, auto - class State(Enum): INIT = auto() CONNECT_OTA = auto() diff --git a/gui/BueTable.py b/gui/BueTable.py new file mode 100644 index 0000000..3d8792b --- /dev/null +++ b/gui/BueTable.py @@ -0,0 +1,84 @@ +from PySide6 import QtWidgets +from PySide6.QtCore import Qt + + +class Buetable: + def __init__(self, parent_window): + self.parent = parent_window + + def setup_table(self): + self.parent.tableWidget_bue.setRowCount(0) + self.parent.tableWidget_bue.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + self.parent.tableWidget_bue.setContextMenuPolicy( + Qt.ContextMenuPolicy.CustomContextMenu + ) + self.parent.tableWidget_bue.customContextMenuRequested.connect( + self.show_context_menu + ) + + def populate_table(self): + # Clear the table first to avoid duplicates + self.parent.tableWidget_bue.setRowCount(0) + + for bue_id, hostname in self.parent.base_station.bue_id_to_hostname.items(): + if bue_id not in self.parent.base_station.bue_id_to_state: + continue + + if bue_id not in self.parent.base_station.bue_missed_ping_counter: + continue + + row = self.parent.tableWidget_bue.rowCount() + self.parent.tableWidget_bue.insertRow(row) + + state = self.parent.base_station.bue_id_to_state[bue_id] + missed_pings = self.parent.base_station.bue_missed_ping_counter[bue_id] + + hostname_item = QtWidgets.QTableWidgetItem(hostname) + # Store bue_id as user data (hidden from display) + hostname_item.setData(Qt.ItemDataRole.UserRole, bue_id) + + self.parent.tableWidget_bue.setItem(row, 0, hostname_item) + self.parent.tableWidget_bue.setItem( + row, 1, QtWidgets.QTableWidgetItem(str(state)[6:]) + ) + self.parent.tableWidget_bue.setItem( + row, 2, QtWidgets.QTableWidgetItem(str(missed_pings)) + ) + + def show_context_menu(self, position): + """Show context menu when right-clicking on table.""" + # Check if click was on a valid item + item = self.parent.tableWidget_bue.itemAt(position) + if item is None: + return + + # Get the bue_id from the first column of the current row + row = item.row() + hostname_item = self.parent.tableWidget_bue.item(row, 0) + bue_id = hostname_item.data(Qt.ItemDataRole.UserRole) + hostname = hostname_item.text() + + # Create context menu + context_menu = QtWidgets.QMenu(self.parent.tableWidget_bue) + + # Add actions + restart_action = context_menu.addAction(f"Restart {hostname}") + reboot_action = context_menu.addAction(f"Reboot {hostname}") + context_menu.addSeparator() + ping_action = context_menu.addAction(f"Send Ping to {hostname}") + debug_action = context_menu.addAction(f"Debug {hostname}") + + # Show menu and get selected action + action = context_menu.exec_(self.parent.tableWidget_bue.mapToGlobal(position)) + + # Handle selected action + if action == restart_action: + print(f"restart {bue_id} : {hostname}") + elif action == reboot_action: + print(f"Reboot {bue_id} : {hostname}") + elif action == ping_action: + print(f"Action {bue_id} : {hostname}") + elif action == debug_action: + print(f"Debug {bue_id} : {hostname}") diff --git a/gui/CoordsTable.py b/gui/CoordsTable.py new file mode 100644 index 0000000..191426c --- /dev/null +++ b/gui/CoordsTable.py @@ -0,0 +1,19 @@ +from PySide6 import QtWidgets +from geopy import distance as dist + +class CoordsTable: + def __init__(self, parent_window): + self.parent = parent_window + + def populate_coords_table(self): + # Clear the table first to avoid duplicates + self.parent.tableWidget_coords.setRowCount(0) + + for bue_id, coords in self.parent.base_station.bue_id_to_coords.items(): + hostname = self.parent.base_station.bue_id_to_hostname[bue_id] + lat, long = coords + + row = self.parent.tableWidget_coords.rowCount() + self.parent.tableWidget_coords.insertRow(row) + self.parent.tableWidget_coords.setItem(row, 0, QtWidgets.QTableWidgetItem(f"{hostname}")) + self.parent.tableWidget_coords.setItem(row, 1, QtWidgets.QTableWidgetItem(f"{lat:.4f}, {long:.4f}")) diff --git a/gui/DialogCancelTests.py b/gui/DialogCancelTests.py new file mode 100644 index 0000000..20cfcac --- /dev/null +++ b/gui/DialogCancelTests.py @@ -0,0 +1,75 @@ +import os +from PySide6 import QtWidgets, QtGui +from gui.ui.DialogCancelTestsUi import Ui_dialog_cancel_tests + +class DialogCancelTests: + def __init__(self, parent_window): + self.parent = parent_window + self.dialog_cancel_tests = None + + def open_dialog_cancel_tests(self): + if self.dialog_cancel_tests is None: + self.dialog_cancel_tests = QtWidgets.QDialog() + self.dialog_cancel_tests_ui = Ui_dialog_cancel_tests() + self.dialog_cancel_tests_ui.setupUi(self.dialog_cancel_tests) + + # Set the correct image path + image_path = os.path.join(os.path.dirname(__file__), "ui", "image.png") + if os.path.exists(image_path): + pixmap = QtGui.QPixmap(image_path) + if not pixmap.isNull(): + self.dialog_cancel_tests_ui.label.setPixmap(pixmap) + + # Connect button box signals to close handler + self.dialog_cancel_tests_ui.button_exit.clicked.connect(lambda: self.close_dialog_cancel_tests(send_cancels=False)) + self.dialog_cancel_tests_ui.button_send_cancel.clicked.connect(lambda: self.close_dialog_cancel_tests(send_cancels=True)) + + self.setup_bue_checkboxes() + self.dialog_cancel_tests.show() + + def close_dialog_cancel_tests(self, send_cancels: bool): + """Reset dialog_canel_tests to None when dialog is closed.""" + if send_cancels: + self.send_cancels() + self.dialog_cancel_tests = None + + def setup_bue_checkboxes(self): + """Create checkboxes for each connected BUE in the dialog.""" + # Clear any existing layout first + if self.dialog_cancel_tests_ui.widget_bue_selection.layout(): + QtWidgets.QWidget().setLayout( + self.dialog_cancel_tests_ui.widget_bue_selection.layout() + ) + + # Create and set the layout + layout = QtWidgets.QVBoxLayout() + self.dialog_cancel_tests_ui.widget_bue_selection.setLayout(layout) + + # Dictionary to store checkbox references + self.bue_checkboxes = {} + + # Create a checkbox for each connected BUE + for bue_id in self.parent.base_station.connected_bues: + hostname = self.parent.base_station.bue_id_to_hostname.get(bue_id, f"BUE_{bue_id}") + + checkbox = QtWidgets.QCheckBox(f"{hostname} (ID: {bue_id})") + checkbox.setChecked(True) # Default to checked + + # Store reference to checkbox with bue_id as key + self.bue_checkboxes[bue_id] = checkbox + + # Add to layout + layout.addWidget(checkbox) + + def send_cancels(self): + # Get only selected BUEs from checkboxes + selected_bues = [] + for bue_id, checkbox in self.bue_checkboxes.items(): + if checkbox.isChecked(): + selected_bues.append(bue_id) + + # Send to selected BUEs only + for bue_id in selected_bues: + self.parent.base_station.ota.send_ota_message(bue_id, "CANC") + + print(f"Sent hello world to {len(selected_bues)} selected BUEs") \ No newline at end of file diff --git a/gui/DialogRunTests.py b/gui/DialogRunTests.py new file mode 100644 index 0000000..5330373 --- /dev/null +++ b/gui/DialogRunTests.py @@ -0,0 +1,106 @@ +from PySide6 import QtWidgets +from gui.ui.DialogRunTestsUi import Ui_dialog_run_tests + +from datetime import datetime, timedelta + +class DialogRunTests: + def __init__(self, parent_window): + self.parent = parent_window + self.dialog_run_tests = None + + def open_dialog_run_tests(self): + if self.dialog_run_tests is None: + self.dialog_run_tests = QtWidgets.QDialog() + self.dialog_run_tests_ui = Ui_dialog_run_tests() + self.dialog_run_tests_ui.setupUi(self.dialog_run_tests) + + self.dialog_run_tests_ui.button_hello_world.clicked.connect(self.send_hello_world) + + self.dialog_run_tests_ui.pushButton_init.clicked.connect(lambda: self.send_utw("init")) + self.dialog_run_tests_ui.pushButton_resp.clicked.connect(lambda: self.send_utw("resp")) + + # Connect button box signals to close handler + self.dialog_run_tests_ui.buttonBox.accepted.connect(self.close_dialog_run_tests) + self.dialog_run_tests_ui.buttonBox.rejected.connect(self.close_dialog_run_tests) + + self.setup_bue_checkboxes() + self.dialog_run_tests.show() + + def close_dialog_run_tests(self): + """Reset dialog_run_tests to None when dialog is closed.""" + self.dialog_run_tests = None + + def send_hello_world(self): + execution_time = datetime.now().replace(microsecond=0) + timedelta(seconds=10) + start_time = int(execution_time.timestamp()) + + # Get only selected BUEs from checkboxes + selected_bues = [] + for bue_id, checkbox in self.bue_checkboxes.items(): + if checkbox.isChecked(): + selected_bues.append(bue_id) + + # Send to selected BUEs only + for bue_id in selected_bues: + self.parent.base_station.ota.send_ota_message( + bue_id, + f"TEST:Old/helloworld,{start_time},5 {self.parent.base_station.bue_id_to_hostname[bue_id]}", + ) + + print(f"Sent hello world to {len(selected_bues)} selected BUEs") + + + def send_utw(self, type: str): + execution_time = datetime.now().replace(microsecond=0) + timedelta(seconds=10) + start_time = int(execution_time.timestamp()) + + # Get only selected BUEs from checkboxes + selected_bues = [] + for bue_id, checkbox in self.bue_checkboxes.items(): + if checkbox.isChecked(): + selected_bues.append(bue_id) + + + + # Send to selected BUEs only + for bue_id in selected_bues: + if(type == "init"): + self.parent.base_station.ota.send_ota_message( + bue_id, + f"TEST:/home/admin/two_agent_osu/agent_main,{start_time},-a rtt_init", + ) + elif(type == "resp"): + self.parent.base_station.ota.send_ota_message( + bue_id, + f"TEST:/home/admin/two_agent_osu/agent_main,{start_time},-a rtt_resp", + ) + + print(f"Sent hello world to {len(selected_bues)} selected BUEs") + + def setup_bue_checkboxes(self): + """Create checkboxes for each connected BUE in the dialog.""" + # Clear any existing layout first + if self.dialog_run_tests_ui.widget_bue_selection.layout(): + QtWidgets.QWidget().setLayout( + self.dialog_run_tests_ui.widget_bue_selection.layout() + ) + + # Create and set the layout + layout = QtWidgets.QVBoxLayout() + self.dialog_run_tests_ui.widget_bue_selection.setLayout(layout) + + # Dictionary to store checkbox references + self.bue_checkboxes = {} + + # Create a checkbox for each connected BUE + for bue_id in self.parent.base_station.connected_bues: + hostname = self.parent.base_station.bue_id_to_hostname.get(bue_id, f"BUE_{bue_id}") + + checkbox = QtWidgets.QCheckBox(f"{hostname} (ID: {bue_id})") + checkbox.setChecked(True) # Default to checked + + # Store reference to checkbox with bue_id as key + self.bue_checkboxes[bue_id] = checkbox + + # Add to layout + layout.addWidget(checkbox) \ No newline at end of file diff --git a/gui/DistanceTable.py b/gui/DistanceTable.py new file mode 100644 index 0000000..25ef051 --- /dev/null +++ b/gui/DistanceTable.py @@ -0,0 +1,32 @@ +from PySide6 import QtWidgets +from geopy import distance as dist + +class DistanceTable: + def __init__(self, parent_window): + self.parent = parent_window + + def populate_distance_table(self): + # Clear the table first to avoid duplicates + self.parent.tableWidget_distances.setRowCount(0) + + self.parent.tableWidget_distances.horizontalHeader().setStretchLastSection(False) + self.parent.tableWidget_distances.horizontalHeader().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) + self.parent.tableWidget_distances.horizontalHeader().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # Second column: 100px + + bue_ids = list(self.parent.base_station.bue_id_to_coords.keys()) + + + for i, b1 in enumerate(bue_ids, start=1): + b1_hostname = self.parent.base_station.bue_id_to_hostname[b1] + b1_coords = self.parent.base_station.bue_id_to_coords[b1] + # b1_lat, b1_long = self.parent.base_station.bue_id_to_coords[b1] + for b2 in bue_ids[i:]: + b2_hostname = self.parent.base_station.bue_id_to_hostname[b2] + b2_coords = self.parent.base_station.bue_id_to_coords[b2] + # b2_lat, b2_long = self.parent.base_station.bue_id_to_coords[b2] + distance = dist.great_circle(b1_coords, b2_coords).meters + + row = self.parent.tableWidget_distances.rowCount() + self.parent.tableWidget_distances.insertRow(row) + self.parent.tableWidget_distances.setItem(row, 0, QtWidgets.QTableWidgetItem(f"{b1_hostname} to {b2_hostname}")) + self.parent.tableWidget_distances.setItem(row, 1, QtWidgets.QTableWidgetItem(f"{float(distance):.2f} m")) diff --git a/gui/LogViewerWidget.py b/gui/LogViewerWidget.py new file mode 100644 index 0000000..0cffd56 --- /dev/null +++ b/gui/LogViewerWidget.py @@ -0,0 +1,341 @@ +import os +import sys +from pathlib import Path +from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QScrollBar, QApplication, QMainWindow, QHBoxLayout, QCheckBox +from PySide6.QtCore import QTimer, QFileSystemWatcher, Signal, QObject, Qt, QThread +from PySide6.QtGui import QFont, QTextCursor, QPalette, QColor + + +class LogViewerWidget(QWidget): + """ + A custom PySide6 widget for viewing log files with real-time updates. + + Features: + - Real-time file monitoring and updates + - Scroll position preservation when not at bottom + - Automatic scrolling to bottom for new content (only when already at bottom) + - Proper spacing and parent handling + - Non-blocking file operations + """ + + # Signal emitted when log content changes + logUpdated = Signal(str) + + def __init__(self, parent=None, log_file_path="../logs/last_run.log"): + super().__init__(parent) + + self.log_file_path = log_file_path + self.full_log_path = os.path.abspath(log_file_path) + self.last_file_size = 0 + self.was_at_bottom = True + self.last_modification_time = 0 + self.is_paused = False # Track pause state + + self.setup_ui() + self.setup_file_monitoring() + + def setup_ui(self): + """Set up the user interface with proper spacing and layout.""" + # Ensure widget has an object name so stylesheet can target it + self.setObjectName("LogViewerWidget") + + # Ensure the widget background is filled from palette (fallback) + pal = self.palette() + pal.setColor(QPalette.Window, QColor("#ffffff")) + self.setPalette(pal) + self.setAutoFillBackground(True) + + # Create main layout with appropriate margins + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) # Add padding from edges + layout.setSpacing(4) # Small spacing between controls and text display + + # Create control panel + control_layout = QHBoxLayout() + + # Create pause checkbox + self.pause_checkbox = QCheckBox("Pause updates") + self.pause_checkbox.setToolTip("Pause automatic log updates to read without interruption") + self.pause_checkbox.toggled.connect(self.on_pause_toggled) + # Use the application's standard font for the checkbox (not bold) and make indicator larger + self.pause_checkbox.setFont(QApplication.font()) + self.pause_checkbox.setStyleSheet( + """ + QCheckBox { + color: #000000; + font-size: 10pt; + spacing: 6px; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + } + QCheckBox::indicator:unchecked { + background-color: #ffffff; + border: 1px solid #5e5e5e; + border-radius: 3px; + } + QCheckBox::indicator:checked { + background-color: #0e7490; + border: 1px solid #0e7490; + border-radius: 3px; + } + """ + ) + + control_layout.addWidget(self.pause_checkbox) + control_layout.addStretch() # Push checkbox to the left + + layout.addLayout(control_layout) + + # Create text display widget + self.text_display = QTextEdit(self) + self.text_display.setReadOnly(True) + self.text_display.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap) + + # Set up font for better readability and don't make it bold + font = QFont("Consolas", 9) # Monospace font + if not font.exactMatch(): + font = QFont("Monaco", 9) + if not font.exactMatch(): + font = QFont("Courier New", 9) + # ensure it's not bold so it matches other UI text + font.setBold(False) + self.text_display.setFont(font) + + # Style the text display to match white app background and dark text + self.text_display.setStyleSheet( + """ + QTextEdit { + background-color: #ffffff; + color: #000000; + border: 1px solid #cfcfcf; + selection-background-color: #cfe8ff; + } + """ + ) + + # Connect scroll events to track position + scrollbar = self.text_display.verticalScrollBar() + scrollbar.valueChanged.connect(self.on_scroll_changed) + + layout.addWidget(self.text_display) + + # Load initial content + self.load_initial_content() + + def setup_file_monitoring(self): + """Set up file system monitoring for real-time updates.""" + # Create file system watcher + self.file_watcher = QFileSystemWatcher(self) + + # Ensure the directory exists + log_dir = os.path.dirname(self.full_log_path) + if not os.path.exists(log_dir): + os.makedirs(log_dir, exist_ok=True) + + # Watch the log file and its directory + if os.path.exists(self.full_log_path): + self.file_watcher.addPath(self.full_log_path) + self.file_watcher.addPath(log_dir) + + # Connect file change signals + self.file_watcher.fileChanged.connect(self.on_file_changed) + self.file_watcher.directoryChanged.connect(self.on_directory_changed) + + # Set up a timer for periodic checks (backup monitoring) + self.update_timer = QTimer(self) + self.update_timer.timeout.connect(self.check_file_updates) + self.update_timer.start(1000) # Check every second + + def load_initial_content(self): + """Load the initial content of the log file.""" + try: + if os.path.exists(self.full_log_path): + with open(self.full_log_path, "r", encoding="utf-8", errors="ignore") as file: + content = file.read() + self.text_display.setPlainText(content) + self.last_file_size = len(content.encode("utf-8")) + self.last_modification_time = os.path.getmtime(self.full_log_path) + + # Scroll to bottom initially + self.scroll_to_bottom() + except Exception as e: + self.text_display.setPlainText(f"Error loading log file: {str(e)}") + + def on_scroll_changed(self, value): + """Track whether user is at the bottom of the log.""" + scrollbar = self.text_display.verticalScrollBar() + # Consider "at bottom" if within 10 pixels of the bottom + self.was_at_bottom = value >= scrollbar.maximum() - 10 + + def on_pause_toggled(self, checked): + """Handle pause checkbox toggle.""" + self.is_paused = checked + if checked: + # When pausing, stop the timer + if hasattr(self, "update_timer"): + self.update_timer.stop() + else: + # When resuming, restart the timer and do an immediate update + if hasattr(self, "update_timer"): + self.update_timer.start(1000) + self.check_file_updates() # Immediate check for any missed updates + + def on_file_changed(self, path): + """Handle file change events.""" + if path == self.full_log_path and not self.is_paused: + self.update_log_content() + + def on_directory_changed(self, path): + """Handle directory change events (in case file is recreated).""" + if not self.is_paused and os.path.exists(self.full_log_path): + # Re-add the file to watcher if it was recreated + if self.full_log_path not in self.file_watcher.files(): + self.file_watcher.addPath(self.full_log_path) + self.update_log_content() + + def check_file_updates(self): + """Periodic check for file updates (backup method).""" + if self.is_paused: + return + + try: + if os.path.exists(self.full_log_path): + current_mod_time = os.path.getmtime(self.full_log_path) + if current_mod_time > self.last_modification_time: + self.update_log_content() + except Exception: + pass # Silently handle any file system errors + + def update_log_content(self): + """Update the log content while preserving scroll position.""" + if self.is_paused: + return + + try: + if not os.path.exists(self.full_log_path): + return + + # Get current file size and modification time + current_mod_time = os.path.getmtime(self.full_log_path) + + # Only update if file was actually modified + if current_mod_time <= self.last_modification_time: + return + + with open(self.full_log_path, "r", encoding="utf-8", errors="ignore") as file: + new_content = file.read() + + current_content = self.text_display.toPlainText() + + # Only update if content actually changed + if new_content != current_content: + # Store scroll position before update + scrollbar = self.text_display.verticalScrollBar() + old_scroll_value = scrollbar.value() + + # Update content + self.text_display.setPlainText(new_content) + + # Restore scroll position or scroll to bottom + if self.was_at_bottom: + # User was at bottom, so scroll to new bottom + self.scroll_to_bottom() + else: + # User was scrolled up, preserve their position + scrollbar.setValue(old_scroll_value) + + # Update tracking variables + self.last_file_size = len(new_content.encode("utf-8")) + self.last_modification_time = current_mod_time + + # Emit signal for any listeners + self.logUpdated.emit(new_content) + + except Exception as e: + # Handle errors gracefully - maybe show in status or just ignore + pass + + def scroll_to_bottom(self): + """Scroll the text display to the bottom.""" + scrollbar = self.text_display.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + + def clear_log(self): + """Clear the log display.""" + self.text_display.clear() + + def get_log_content(self): + """Get the current log content.""" + return self.text_display.toPlainText() + + def set_log_file_path(self, new_path): + """Change the log file being monitored.""" + # Remove old file from watcher + if self.full_log_path in self.file_watcher.files(): + self.file_watcher.removePath(self.full_log_path) + + # Update path and add to watcher + self.log_file_path = new_path + self.full_log_path = os.path.abspath(new_path) + + if os.path.exists(self.full_log_path): + self.file_watcher.addPath(self.full_log_path) + + # Load new content + self.load_initial_content() + + def closeEvent(self, event): + """Clean up when widget is closed.""" + if hasattr(self, "update_timer"): + self.update_timer.stop() + if hasattr(self, "file_watcher"): + self.file_watcher.deleteLater() + super().closeEvent(event) + + +# Example usage and testing +if __name__ == "__main__": + import tempfile + import threading + import time + + app = QApplication(sys.argv) + + # Create a test window + window = QMainWindow() + window.setWindowTitle("Log Viewer Widget Test") + window.setGeometry(100, 100, 800, 600) + + # Create the log viewer widget + log_viewer = LogViewerWidget(window, "logs/last_run.log") + window.setCentralWidget(log_viewer) + + window.show() + + # Create a test function to write to the log file periodically + def write_test_logs(): + log_dir = "logs" + if not os.path.exists(log_dir): + os.makedirs(log_dir) + + log_file = "logs/last_run.log" + counter = 1 + + while True: + try: + with open(log_file, "a", encoding="utf-8") as f: + f.write(f"Test log entry {counter} - {time.strftime('%Y-%m-%d %H:%M:%S')}\n") + f.flush() + counter += 1 + time.sleep(2) # Write new log every 2 seconds + except Exception as e: + print(f"Error writing to log: {e}") + break + + # Start background thread to write test logs + log_thread = threading.Thread(target=write_test_logs, daemon=True) + log_thread.start() + + sys.exit(app.exec()) diff --git a/gui/MapManager.py b/gui/MapManager.py new file mode 100644 index 0000000..f3e0ffe --- /dev/null +++ b/gui/MapManager.py @@ -0,0 +1,147 @@ +import math +import pyqtgraph as pg +from qgmap import QGoogleMap, QtWidgets +from qgmap.presets import base64DataUrl + +class MapManager: + def __init__(self, parent_window): + self.parent = parent_window + self.gmap_enabled = False + self.satmap = None + self.graphmap = None + self.gmap_auto_fitted = False + + def initialize_map(self): + if self.parent.frame_map.layout() is None: + layout = QtWidgets.QVBoxLayout(self.parent.frame_map) + layout.setContentsMargins(0, 0, 0, 0) + self.parent.frame_map.setLayout(layout) + + self.setup_graph_map() + + def setup_gmap(self): + self.satmap = QGoogleMap(None) + self.satmap.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Expanding, + ) + + self.parent.frame_map.layout().addWidget(self.satmap) + self.satmap.waitUntilReady() + + def setup_graph_map(self): + self.graphmap = pg.PlotWidget() + self.graphmap.setLabel('left', 'Latitude') + self.graphmap.setLabel('bottom', 'Longitude') + self.graphmap.setTitle('GPS System Locations') + self.graphmap.showGrid(x=True, y=True) + self.graphmap.setAspectLocked(True, ratio=1.0) + + self.parent.frame_map.layout().addWidget(self.graphmap) + + def swap_map_type(self): + self.gmap_enabled = not self.gmap_enabled + + if self.gmap_enabled: + if self.graphmap is not None: + self.parent.frame_map.layout().removeWidget(self.graphmap) + self.graphmap.setParent(None) + self.graphmap.deleteLater() + self.graphmap = None + + self.setup_gmap() + else: + if self.satmap is not None: + self.parent.frame_map.layout().removeWidget(self.satmap) + self.satmap.setParent(None) + self.satmap.deleteLater() + self.satmap = None + + self.setup_graph_map() + + self.populate_map() + + def fit_markers_to_view(self, coords_list): + if not coords_list: + return + + self.gmap_auto_fitted = True + + lats = [coord[0] for coord in coords_list] + lons = [coord[1] for coord in coords_list] + + # Calculate center + center_lat = (max(lats) + min(lats)) / 2 + center_lon = (max(lons) + min(lons)) / 2 + + # Calculate zoom + lat_diff = max(lats) - min(lats) + lon_diff = max(lons) - min(lons) + max_diff = max(lat_diff, lon_diff) + + if max_diff == 0: + zoom = 16 + else: + zoom = int(math.log2(360 / max_diff)) - 1 + zoom = max(1, min(zoom, 18)) + + # Set zoom first, then center (order matters!) + self.satmap.setZoom(zoom) + self.satmap.centerAt(center_lat, center_lon) + + def populate_map(self): + if self.gmap_enabled and self.satmap is not None: + for bue_id, coords in self.parent.base_station.bue_id_to_coords.items(): + self.satmap.addMarker(f"{bue_id}", *coords, + icon=self.customPin('green', self.parent.base_station.bue_id_to_hostname[bue_id]), + draggable=0, + ) + + # Auto-fit bounds to show all markers + if not self.gmap_auto_fitted and self.parent.base_station.bue_id_to_coords : + coords_list = list(self.parent.base_station.bue_id_to_coords.values()) + if coords_list: + self.fit_markers_to_view(coords_list) + else: + if self.graphmap is not None: + self.graphmap.clear() + # Extract coordinates for plotting + lats = [] + lons = [] + labels = [] + + for bue_id, coords in self.parent.base_station.bue_id_to_coords.items(): + lat, lon = coords + lats.append(lat) + lons.append(lon) + hostname = self.parent.base_station.bue_id_to_hostname[bue_id] + labels.append(f"{hostname}") + + if lats and lons: + # Plot scatter points + scatter = self.graphmap.plot(lons, lats, pen=None, symbol='o', + symbolBrush='green', symbolSize=10) + + # Add text labels for each point + for i, (lon, lat, label) in enumerate(zip(lons, lats, labels)): + text = pg.TextItem(label, color='green', anchor=(0.5, 1)) + text.setPos(lon, lat) + self.graphmap.addItem(text) + + def customPin(self, color, name, path=None): + # Create a circle with text above it + svg = f''' + + {name} + + + + + ''' + + url = base64DataUrl(svg) + return dict( + iconUrl=url, + iconAnchor=[30, 25], # Anchor point at center of circle + iconSize=[60, 40] # Size of the entire icon + ) \ No newline at end of file diff --git a/gui/main.py b/gui/main.py new file mode 100644 index 0000000..4a242b2 --- /dev/null +++ b/gui/main.py @@ -0,0 +1,157 @@ +from PySide6 import QtUiTools, QtWidgets +from PySide6.QtCore import QTimer +import sys +import os + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from base_station_main import Base_Station_Main +from MapManager import MapManager + +from gui.ui.MainWindowUi import Ui_MainWindow + +from DialogRunTests import DialogRunTests +from gui.DialogCancelTests import DialogCancelTests + +from DistanceTable import DistanceTable +from CoordsTable import CoordsTable +from LogViewerWidget import LogViewerWidget +from BueTable import Buetable + +class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): + def __init__(self): + super().__init__() + self.setupUi(self) + self.base_station = Base_Station_Main("config_base.yaml") + self.loader = QtUiTools.QUiLoader() + + self.map_manager = MapManager(self) + self.distance_table = DistanceTable(self) + self.coords_table = CoordsTable(self) + self.bue_table = Buetable(self) + + log_viewer = LogViewerWidget(self.frame_base_station_log, "logs/last_run.log") + layout = QtWidgets.QVBoxLayout() + self.frame_base_station_log.setLayout(layout) + layout.addWidget(log_viewer) + + self.dialog_run_tests = DialogRunTests(self) + self.dialog_cancel_tests = DialogCancelTests(self) + + self.button_run_tests.clicked.connect(self.dialog_run_tests.open_dialog_run_tests) + self.button_switch_map_type.clicked.connect(self.map_manager.swap_map_type) + self.button_cancel_tests.clicked.connect(self.dialog_cancel_tests.open_dialog_cancel_tests) + self.button_clear_messages.clicked.connect(lambda: self.base_station.bue_tout.clear()) + + self.bue_table.setup_table() + + # Track previous state to detect changes + self.prev_bue_state = {} + self.prev_missed_pings = {} + self.prev_bue_tout = {} + self.prev_bue_id_to_coords = {} + + self.bue_checkboxes = {} + + self.map_manager.initialize_map() + self.bue_table.populate_table() + self.distance_table.populate_distance_table() + self.coords_table.populate_coords_table() + self.setup_timer() + + + + def setup_bue_checkboxes(self): + """Create checkboxes for each connected BUE in the dialog.""" + # Clear any existing layout first + if self.dialog_run_tests_ui.widget_bue_selection.layout(): + QtWidgets.QWidget().setLayout( + self.dialog_run_tests.widget_bue_selection.layout() + ) + + # Create and set the layout + layout = QtWidgets.QVBoxLayout() + self.dialog_run_tests_ui.widget_bue_selection.setLayout(layout) + + # Dictionary to store checkbox references + self.bue_checkboxes = {} + + # Create a checkbox for each connected BUE + for bue_id in self.base_station.connected_bues: + hostname = self.base_station.bue_id_to_hostname.get(bue_id, f"BUE_{bue_id}") + + checkbox = QtWidgets.QCheckBox(f"{hostname} (ID: {bue_id})") + checkbox.setChecked(True) # Default to checked + + # Store reference to checkbox with bue_id as key + self.bue_checkboxes[bue_id] = checkbox + + # Add to layout (this is the key part!) + layout.addWidget(checkbox) + + + def setup_timer(self): + """Set up a timer to refresh the table every second.""" + self.timer = QTimer(self) + self.timer.timeout.connect(self.check_for_changes) + self.timer.start(1000) # 1000 milliseconds = 1 second + + def check_for_changes(self): + """Check if there are changes in state or missed pings, and update table if needed.""" + current_state = self.base_station.bue_id_to_state.copy() + current_missed_pings = self.base_station.bue_missed_ping_counter.copy() + current_bue_tout = self.base_station.bue_tout.copy() + current_bue_id_to_coord = self.base_station.bue_id_to_coords.copy() + + # Check if there are any changes + state_changed = current_state != self.prev_bue_state + pings_changed = current_missed_pings != self.prev_missed_pings + messages_changed = current_bue_tout != self.prev_bue_tout + coords_changed = current_bue_id_to_coord != self.prev_bue_id_to_coords + + if state_changed or pings_changed: + self.bue_table.populate_table() + # Update our tracked state + self.prev_bue_state = current_state + self.prev_missed_pings = current_missed_pings + + if messages_changed: + self.populate_messages() + self.prev_bue_tout = current_bue_tout + + if coords_changed: + self.prev_bue_id_to_coords = current_bue_id_to_coord + self.map_manager.populate_map() + self.distance_table.populate_distance_table() + self.coords_table.populate_coords_table() + + + def populate_messages(self): + """Populate the text browser with messages from base_station.bue_tout.""" + # Save current scroll position + scrollbar = self.textBrowser_messages.verticalScrollBar() + current_position = scrollbar.value() + max_position = scrollbar.maximum() + + # Check if user was at the bottom (auto-scroll) or somewhere else (manual scroll) + was_at_bottom = current_position == max_position + + self.textBrowser_messages.clear() + + # Add messages without moving cursor + for message in self.base_station.bue_tout: + self.textBrowser_messages.append(message) + + # Only auto-scroll if user was previously at the bottom + if was_at_bottom: + scrollbar.setValue(scrollbar.maximum()) + else: + # Restore previous position (approximately) + scrollbar.setValue(current_position) + + +if __name__ == "__main__": + app = QtWidgets.QApplication(sys.argv) + window = MainWindow() + window.show() + sys.exit(app.exec()) diff --git a/gui/requirements.txt b/gui/requirements.txt new file mode 100644 index 0000000..681bc5a --- /dev/null +++ b/gui/requirements.txt @@ -0,0 +1,23 @@ +# Up to date as of February 12 2026 +colorama==0.4.6 +coverage==7.13.4 +crc8==0.2.1 +decorator==5.2.1 +geographiclib==2.1 +geopy==2.4.1 +iniconfig==2.3.0 +loguru==0.7.3 +numpy==2.4.2 +packaging==26.0 +pluggy==1.6.0 +Pygments==2.19.2 +pyqtgraph==0.14.0 +pyserial==3.5 +PySide6==6.10.2 +PySide6_Addons==6.10.2 +PySide6_Essentials==6.10.2 +pytest==9.0.2 +pytest-cov==7.0.0 +PyYAML==6.0.3 +qgmap==1.1.0 +shiboken6==6.10.2 \ No newline at end of file diff --git a/gui/ui/DialogCancelTestsUi.py b/gui/ui/DialogCancelTestsUi.py new file mode 100644 index 0000000..9658987 --- /dev/null +++ b/gui/ui/DialogCancelTestsUi.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'dialog_cancel_tests.ui' +## +## Created by: Qt User Interface Compiler version 6.10.2 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import ( + QCoreApplication, + QDate, + QDateTime, + QLocale, + QMetaObject, + QObject, + QPoint, + QRect, + QSize, + QTime, + QUrl, + Qt, +) +from PySide6.QtGui import ( + QBrush, + QColor, + QConicalGradient, + QCursor, + QFont, + QFontDatabase, + QGradient, + QIcon, + QImage, + QKeySequence, + QLinearGradient, + QPainter, + QPalette, + QPixmap, + QRadialGradient, + QTransform, +) +from PySide6.QtWidgets import ( + QApplication, + QDialog, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QSpacerItem, + QVBoxLayout, + QWidget, +) + + +class Ui_dialog_cancel_tests(object): + def setupUi(self, dialog_cancel_tests): + if not dialog_cancel_tests.objectName(): + dialog_cancel_tests.setObjectName("dialog_cancel_tests") + dialog_cancel_tests.resize(446, 336) + self.verticalLayout = QVBoxLayout(dialog_cancel_tests) + self.verticalLayout.setObjectName("verticalLayout") + self.widget = QWidget(dialog_cancel_tests) + self.widget.setObjectName("widget") + sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + sizePolicy.setHorizontalStretch(1) + sizePolicy.setVerticalStretch(5) + sizePolicy.setHeightForWidth(self.widget.sizePolicy().hasHeightForWidth()) + self.widget.setSizePolicy(sizePolicy) + self.horizontalLayout_2 = QHBoxLayout(self.widget) + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + self.widget_bue_selection = QWidget(self.widget) + self.widget_bue_selection.setObjectName("widget_bue_selection") + sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + sizePolicy1.setHorizontalStretch(1) + sizePolicy1.setVerticalStretch(1) + sizePolicy1.setHeightForWidth(self.widget_bue_selection.sizePolicy().hasHeightForWidth()) + self.widget_bue_selection.setSizePolicy(sizePolicy1) + self.widget_bue_selection.setMinimumSize(QSize(400, 0)) + + self.horizontalLayout_2.addWidget(self.widget_bue_selection) + + self.label = QLabel(self.widget) + self.label.setObjectName("label") + sizePolicy1.setHeightForWidth(self.label.sizePolicy().hasHeightForWidth()) + self.label.setSizePolicy(sizePolicy1) + self.label.setPixmap(QPixmap("/home/ty22117/bUE-lake-tests/gui/ui/image.png")) + self.label.setScaledContents(True) + + self.horizontalLayout_2.addWidget(self.label) + + self.verticalLayout.addWidget(self.widget) + + self.widget_2 = QWidget(dialog_cancel_tests) + self.widget_2.setObjectName("widget_2") + sizePolicy1.setHeightForWidth(self.widget_2.sizePolicy().hasHeightForWidth()) + self.widget_2.setSizePolicy(sizePolicy1) + self.horizontalLayout = QHBoxLayout(self.widget_2) + self.horizontalLayout.setObjectName("horizontalLayout") + self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + + self.horizontalLayout.addItem(self.horizontalSpacer) + + self.button_exit = QPushButton(self.widget_2) + self.button_exit.setObjectName("button_exit") + + self.horizontalLayout.addWidget(self.button_exit) + + self.button_send_cancel = QPushButton(self.widget_2) + self.button_send_cancel.setObjectName("button_send_cancel") + + self.horizontalLayout.addWidget(self.button_send_cancel) + + self.verticalLayout.addWidget(self.widget_2) + + self.retranslateUi(dialog_cancel_tests) + + QMetaObject.connectSlotsByName(dialog_cancel_tests) + + # setupUi + + def retranslateUi(self, dialog_cancel_tests): + dialog_cancel_tests.setWindowTitle(QCoreApplication.translate("dialog_cancel_tests", "Cancel Tests", None)) + self.label.setText("") + self.button_exit.setText(QCoreApplication.translate("dialog_cancel_tests", "Exit", None)) + self.button_send_cancel.setText(QCoreApplication.translate("dialog_cancel_tests", "Cancel Tests", None)) + + # retranslateUi diff --git a/gui/ui/DialogRunTestsUi.py b/gui/ui/DialogRunTestsUi.py new file mode 100644 index 0000000..d60661d --- /dev/null +++ b/gui/ui/DialogRunTestsUi.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'dialog_run_tests.ui' +## +## Created by: Qt User Interface Compiler version 6.10.2 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, + QMetaObject, QObject, QPoint, QRect, + QSize, QTime, QUrl, Qt) +from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, + QFont, QFontDatabase, QGradient, QIcon, + QImage, QKeySequence, QLinearGradient, QPainter, + QPalette, QPixmap, QRadialGradient, QTransform) +from PySide6.QtWidgets import (QAbstractButton, QApplication, QDialog, QDialogButtonBox, + QPushButton, QSizePolicy, QWidget) + +class Ui_dialog_run_tests(object): + def setupUi(self, dialog_run_tests): + if not dialog_run_tests.objectName(): + dialog_run_tests.setObjectName(u"dialog_run_tests") + dialog_run_tests.resize(400, 300) + palette = QPalette() + brush = QBrush(QColor(222, 221, 218, 255)) + brush.setStyle(Qt.BrushStyle.SolidPattern) + palette.setBrush(QPalette.ColorGroup.Active, QPalette.ColorRole.Base, brush) + palette.setBrush(QPalette.ColorGroup.Active, QPalette.ColorRole.Window, brush) + brush1 = QBrush(QColor(255, 255, 255, 255)) + brush1.setStyle(Qt.BrushStyle.SolidPattern) + palette.setBrush(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Base, brush1) + brush2 = QBrush(QColor(239, 239, 239, 255)) + brush2.setStyle(Qt.BrushStyle.SolidPattern) + palette.setBrush(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Window, brush2) + palette.setBrush(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, brush2) + palette.setBrush(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Window, brush) + dialog_run_tests.setPalette(palette) + dialog_run_tests.setCursor(QCursor(Qt.CursorShape.ArrowCursor)) + self.buttonBox = QDialogButtonBox(dialog_run_tests) + self.buttonBox.setObjectName(u"buttonBox") + self.buttonBox.setGeometry(QRect(30, 240, 341, 32)) + self.buttonBox.setOrientation(Qt.Horizontal) + self.buttonBox.setStandardButtons(QDialogButtonBox.Cancel|QDialogButtonBox.Ok) + self.button_hello_world = QPushButton(dialog_run_tests) + self.button_hello_world.setObjectName(u"button_hello_world") + self.button_hello_world.setGeometry(QRect(210, 170, 141, 61)) + self.widget_bue_selection = QWidget(dialog_run_tests) + self.widget_bue_selection.setObjectName(u"widget_bue_selection") + self.widget_bue_selection.setGeometry(QRect(20, 20, 161, 131)) + self.pushButton_resp = QPushButton(dialog_run_tests) + self.pushButton_resp.setObjectName(u"pushButton_resp") + self.pushButton_resp.setGeometry(QRect(220, 40, 121, 51)) + self.pushButton_init = QPushButton(dialog_run_tests) + self.pushButton_init.setObjectName(u"pushButton_init") + self.pushButton_init.setGeometry(QRect(209, 110, 121, 41)) + + self.retranslateUi(dialog_run_tests) + self.buttonBox.accepted.connect(dialog_run_tests.accept) + self.buttonBox.rejected.connect(dialog_run_tests.reject) + + QMetaObject.connectSlotsByName(dialog_run_tests) + # setupUi + + def retranslateUi(self, dialog_run_tests): + dialog_run_tests.setWindowTitle(QCoreApplication.translate("dialog_run_tests", u"Dialog", None)) + self.button_hello_world.setText(QCoreApplication.translate("dialog_run_tests", u"Run Hello World", None)) + self.pushButton_resp.setText(QCoreApplication.translate("dialog_run_tests", u"Resp", None)) + self.pushButton_init.setText(QCoreApplication.translate("dialog_run_tests", u"Init", None)) + # retranslateUi + diff --git a/gui/ui/MainWindowUi.py b/gui/ui/MainWindowUi.py new file mode 100644 index 0000000..f688985 --- /dev/null +++ b/gui/ui/MainWindowUi.py @@ -0,0 +1,299 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'main_window.ui' +## +## Created by: Qt User Interface Compiler version 6.10.2 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, + QMetaObject, QObject, QPoint, QRect, + QSize, QTime, QUrl, Qt) +from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient, + QCursor, QFont, QFontDatabase, QGradient, + QIcon, QImage, QKeySequence, QLinearGradient, + QPainter, QPalette, QPixmap, QRadialGradient, + QTransform) +from PySide6.QtWidgets import (QAbstractScrollArea, QApplication, QFrame, QGroupBox, + QHBoxLayout, QHeaderView, QMainWindow, QPushButton, + QSizePolicy, QTableWidget, QTableWidgetItem, QTextBrowser, + QVBoxLayout, QWidget) + +class Ui_MainWindow(object): + def setupUi(self, MainWindow): + if not MainWindow.objectName(): + MainWindow.setObjectName(u"MainWindow") + MainWindow.setEnabled(True) + MainWindow.resize(1040, 851) + sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(MainWindow.sizePolicy().hasHeightForWidth()) + MainWindow.setSizePolicy(sizePolicy) + self.actiontest = QAction(MainWindow) + self.actiontest.setObjectName(u"actiontest") + icon = QIcon() + iconThemeName = u"battery" + if QIcon.hasThemeIcon(iconThemeName): + icon = QIcon.fromTheme(iconThemeName) + else: + icon.addFile(u"../../../../../.designer/.designer/.designer/.designer/backup", QSize(), QIcon.Mode.Normal, QIcon.State.Off) + + self.actiontest.setIcon(icon) + self.centralwidget = QWidget(MainWindow) + self.centralwidget.setObjectName(u"centralwidget") + self.verticalLayout_4 = QVBoxLayout(self.centralwidget) + self.verticalLayout_4.setObjectName(u"verticalLayout_4") + self.frame_top = QFrame(self.centralwidget) + self.frame_top.setObjectName(u"frame_top") + sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + sizePolicy1.setHorizontalStretch(0) + sizePolicy1.setVerticalStretch(80) + sizePolicy1.setHeightForWidth(self.frame_top.sizePolicy().hasHeightForWidth()) + self.frame_top.setSizePolicy(sizePolicy1) + self.frame_top.setMinimumSize(QSize(0, 400)) + self.frame_top.setFrameShape(QFrame.StyledPanel) + self.frame_top.setFrameShadow(QFrame.Raised) + self.horizontalLayout = QHBoxLayout(self.frame_top) + self.horizontalLayout.setObjectName(u"horizontalLayout") + self.frame_left = QFrame(self.frame_top) + self.frame_left.setObjectName(u"frame_left") + sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + sizePolicy2.setHorizontalStretch(25) + sizePolicy2.setVerticalStretch(0) + sizePolicy2.setHeightForWidth(self.frame_left.sizePolicy().hasHeightForWidth()) + self.frame_left.setSizePolicy(sizePolicy2) + self.frame_left.setFrameShape(QFrame.StyledPanel) + self.frame_left.setFrameShadow(QFrame.Raised) + self.verticalLayout_7 = QVBoxLayout(self.frame_left) + self.verticalLayout_7.setObjectName(u"verticalLayout_7") + self.tableWidget_bue = QTableWidget(self.frame_left) + if (self.tableWidget_bue.columnCount() < 3): + self.tableWidget_bue.setColumnCount(3) + __qtablewidgetitem = QTableWidgetItem() + self.tableWidget_bue.setHorizontalHeaderItem(0, __qtablewidgetitem) + __qtablewidgetitem1 = QTableWidgetItem() + self.tableWidget_bue.setHorizontalHeaderItem(1, __qtablewidgetitem1) + __qtablewidgetitem2 = QTableWidgetItem() + self.tableWidget_bue.setHorizontalHeaderItem(2, __qtablewidgetitem2) + self.tableWidget_bue.setObjectName(u"tableWidget_bue") + sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + sizePolicy3.setHorizontalStretch(0) + sizePolicy3.setVerticalStretch(75) + sizePolicy3.setHeightForWidth(self.tableWidget_bue.sizePolicy().hasHeightForWidth()) + self.tableWidget_bue.setSizePolicy(sizePolicy3) + self.tableWidget_bue.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.tableWidget_bue.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.tableWidget_bue.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents) + self.tableWidget_bue.setShowGrid(True) + self.tableWidget_bue.setGridStyle(Qt.SolidLine) + self.tableWidget_bue.setRowCount(0) + self.tableWidget_bue.horizontalHeader().setVisible(True) + self.tableWidget_bue.horizontalHeader().setCascadingSectionResizes(True) + self.tableWidget_bue.horizontalHeader().setMinimumSectionSize(73) + self.tableWidget_bue.horizontalHeader().setDefaultSectionSize(73) + self.tableWidget_bue.horizontalHeader().setHighlightSections(True) + self.tableWidget_bue.horizontalHeader().setStretchLastSection(False) + self.tableWidget_bue.verticalHeader().setVisible(False) + self.tableWidget_bue.verticalHeader().setCascadingSectionResizes(False) + self.tableWidget_bue.verticalHeader().setProperty(u"showSortIndicator", False) + self.tableWidget_bue.verticalHeader().setStretchLastSection(False) + + self.verticalLayout_7.addWidget(self.tableWidget_bue) + + self.groupBox_controls = QGroupBox(self.frame_left) + self.groupBox_controls.setObjectName(u"groupBox_controls") + sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + sizePolicy4.setHorizontalStretch(0) + sizePolicy4.setVerticalStretch(25) + sizePolicy4.setHeightForWidth(self.groupBox_controls.sizePolicy().hasHeightForWidth()) + self.groupBox_controls.setSizePolicy(sizePolicy4) + self.verticalLayout_2 = QVBoxLayout(self.groupBox_controls) + self.verticalLayout_2.setObjectName(u"verticalLayout_2") + self.button_run_tests = QPushButton(self.groupBox_controls) + self.button_run_tests.setObjectName(u"button_run_tests") + + self.verticalLayout_2.addWidget(self.button_run_tests) + + self.button_cancel_tests = QPushButton(self.groupBox_controls) + self.button_cancel_tests.setObjectName(u"button_cancel_tests") + + self.verticalLayout_2.addWidget(self.button_cancel_tests) + + self.button_switch_map_type = QPushButton(self.groupBox_controls) + self.button_switch_map_type.setObjectName(u"button_switch_map_type") + + self.verticalLayout_2.addWidget(self.button_switch_map_type) + + + self.verticalLayout_7.addWidget(self.groupBox_controls) + + + self.horizontalLayout.addWidget(self.frame_left) + + self.frame_middle = QFrame(self.frame_top) + self.frame_middle.setObjectName(u"frame_middle") + sizePolicy5 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + sizePolicy5.setHorizontalStretch(50) + sizePolicy5.setVerticalStretch(0) + sizePolicy5.setHeightForWidth(self.frame_middle.sizePolicy().hasHeightForWidth()) + self.frame_middle.setSizePolicy(sizePolicy5) + self.frame_middle.setFrameShape(QFrame.StyledPanel) + self.frame_middle.setFrameShadow(QFrame.Raised) + self.verticalLayout_6 = QVBoxLayout(self.frame_middle) + self.verticalLayout_6.setObjectName(u"verticalLayout_6") + self.frame_map = QFrame(self.frame_middle) + self.frame_map.setObjectName(u"frame_map") + sizePolicy6 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + sizePolicy6.setHorizontalStretch(0) + sizePolicy6.setVerticalStretch(60) + sizePolicy6.setHeightForWidth(self.frame_map.sizePolicy().hasHeightForWidth()) + self.frame_map.setSizePolicy(sizePolicy6) + self.frame_map.setMinimumSize(QSize(0, 0)) + self.frame_map.setFrameShape(QFrame.StyledPanel) + self.frame_map.setFrameShadow(QFrame.Raised) + + self.verticalLayout_6.addWidget(self.frame_map) + + self.group_messages = QGroupBox(self.frame_middle) + self.group_messages.setObjectName(u"group_messages") + sizePolicy7 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + sizePolicy7.setHorizontalStretch(0) + sizePolicy7.setVerticalStretch(40) + sizePolicy7.setHeightForWidth(self.group_messages.sizePolicy().hasHeightForWidth()) + self.group_messages.setSizePolicy(sizePolicy7) + self.group_messages.setMinimumSize(QSize(499, 0)) + self.verticalLayout_3 = QVBoxLayout(self.group_messages) + self.verticalLayout_3.setObjectName(u"verticalLayout_3") + self.textBrowser_messages = QTextBrowser(self.group_messages) + self.textBrowser_messages.setObjectName(u"textBrowser_messages") + sizePolicy8 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + sizePolicy8.setHorizontalStretch(0) + sizePolicy8.setVerticalStretch(1) + sizePolicy8.setHeightForWidth(self.textBrowser_messages.sizePolicy().hasHeightForWidth()) + self.textBrowser_messages.setSizePolicy(sizePolicy8) + + self.verticalLayout_3.addWidget(self.textBrowser_messages) + + self.button_clear_messages = QPushButton(self.group_messages) + self.button_clear_messages.setObjectName(u"button_clear_messages") + sizePolicy.setHeightForWidth(self.button_clear_messages.sizePolicy().hasHeightForWidth()) + self.button_clear_messages.setSizePolicy(sizePolicy) + + self.verticalLayout_3.addWidget(self.button_clear_messages) + + + self.verticalLayout_6.addWidget(self.group_messages) + + + self.horizontalLayout.addWidget(self.frame_middle) + + self.frame_right = QFrame(self.frame_top) + self.frame_right.setObjectName(u"frame_right") + sizePolicy9 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + sizePolicy9.setHorizontalStretch(10) + sizePolicy9.setVerticalStretch(0) + sizePolicy9.setHeightForWidth(self.frame_right.sizePolicy().hasHeightForWidth()) + self.frame_right.setSizePolicy(sizePolicy9) + self.frame_right.setMinimumSize(QSize(200, 0)) + self.frame_right.setFrameShape(QFrame.StyledPanel) + self.frame_right.setFrameShadow(QFrame.Raised) + self.verticalLayout_5 = QVBoxLayout(self.frame_right) + self.verticalLayout_5.setObjectName(u"verticalLayout_5") + self.tableWidget_coords = QTableWidget(self.frame_right) + if (self.tableWidget_coords.columnCount() < 2): + self.tableWidget_coords.setColumnCount(2) + __qtablewidgetitem3 = QTableWidgetItem() + self.tableWidget_coords.setHorizontalHeaderItem(0, __qtablewidgetitem3) + __qtablewidgetitem4 = QTableWidgetItem() + self.tableWidget_coords.setHorizontalHeaderItem(1, __qtablewidgetitem4) + self.tableWidget_coords.setObjectName(u"tableWidget_coords") + sizePolicy.setHeightForWidth(self.tableWidget_coords.sizePolicy().hasHeightForWidth()) + self.tableWidget_coords.setSizePolicy(sizePolicy) + self.tableWidget_coords.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.tableWidget_coords.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents) + self.tableWidget_coords.setColumnCount(2) + self.tableWidget_coords.horizontalHeader().setDefaultSectionSize(75) + self.tableWidget_coords.horizontalHeader().setStretchLastSection(True) + self.tableWidget_coords.verticalHeader().setVisible(False) + + self.verticalLayout_5.addWidget(self.tableWidget_coords) + + self.tableWidget_distances = QTableWidget(self.frame_right) + if (self.tableWidget_distances.columnCount() < 2): + self.tableWidget_distances.setColumnCount(2) + __qtablewidgetitem5 = QTableWidgetItem() + self.tableWidget_distances.setHorizontalHeaderItem(0, __qtablewidgetitem5) + __qtablewidgetitem6 = QTableWidgetItem() + self.tableWidget_distances.setHorizontalHeaderItem(1, __qtablewidgetitem6) + self.tableWidget_distances.setObjectName(u"tableWidget_distances") + sizePolicy.setHeightForWidth(self.tableWidget_distances.sizePolicy().hasHeightForWidth()) + self.tableWidget_distances.setSizePolicy(sizePolicy) + self.tableWidget_distances.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.tableWidget_distances.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents) + self.tableWidget_distances.setWordWrap(False) + self.tableWidget_distances.setColumnCount(2) + self.tableWidget_distances.horizontalHeader().setVisible(True) + self.tableWidget_distances.horizontalHeader().setCascadingSectionResizes(False) + self.tableWidget_distances.horizontalHeader().setMinimumSectionSize(50) + self.tableWidget_distances.horizontalHeader().setDefaultSectionSize(88) + self.tableWidget_distances.horizontalHeader().setHighlightSections(False) + self.tableWidget_distances.horizontalHeader().setStretchLastSection(True) + self.tableWidget_distances.verticalHeader().setVisible(False) + + self.verticalLayout_5.addWidget(self.tableWidget_distances) + + + self.horizontalLayout.addWidget(self.frame_right) + + + self.verticalLayout_4.addWidget(self.frame_top) + + self.frame_base_station_log = QFrame(self.centralwidget) + self.frame_base_station_log.setObjectName(u"frame_base_station_log") + sizePolicy10 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + sizePolicy10.setHorizontalStretch(0) + sizePolicy10.setVerticalStretch(20) + sizePolicy10.setHeightForWidth(self.frame_base_station_log.sizePolicy().hasHeightForWidth()) + self.frame_base_station_log.setSizePolicy(sizePolicy10) + self.frame_base_station_log.setMinimumSize(QSize(0, 0)) + + self.verticalLayout_4.addWidget(self.frame_base_station_log) + + MainWindow.setCentralWidget(self.centralwidget) + + self.retranslateUi(MainWindow) + + QMetaObject.connectSlotsByName(MainWindow) + # setupUi + + def retranslateUi(self, MainWindow): + MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"Base Station", None)) + self.actiontest.setText(QCoreApplication.translate("MainWindow", u"test", None)) +#if QT_CONFIG(tooltip) + self.actiontest.setToolTip(QCoreApplication.translate("MainWindow", u"test", None)) +#endif // QT_CONFIG(tooltip) + ___qtablewidgetitem = self.tableWidget_bue.horizontalHeaderItem(0) + ___qtablewidgetitem.setText(QCoreApplication.translate("MainWindow", u"bUE", None)); + ___qtablewidgetitem1 = self.tableWidget_bue.horizontalHeaderItem(1) + ___qtablewidgetitem1.setText(QCoreApplication.translate("MainWindow", u"State", None)); + ___qtablewidgetitem2 = self.tableWidget_bue.horizontalHeaderItem(2) + ___qtablewidgetitem2.setText(QCoreApplication.translate("MainWindow", u"Pings", None)); + self.groupBox_controls.setTitle(QCoreApplication.translate("MainWindow", u"Controls", None)) + self.button_run_tests.setText(QCoreApplication.translate("MainWindow", u"Run Tests", None)) + self.button_cancel_tests.setText(QCoreApplication.translate("MainWindow", u"Cancel Tests", None)) + self.button_switch_map_type.setText(QCoreApplication.translate("MainWindow", u"Switch Map Type", None)) + self.group_messages.setTitle(QCoreApplication.translate("MainWindow", u"Messages", None)) + self.button_clear_messages.setText(QCoreApplication.translate("MainWindow", u"Clear Messages", None)) + ___qtablewidgetitem3 = self.tableWidget_coords.horizontalHeaderItem(0) + ___qtablewidgetitem3.setText(QCoreApplication.translate("MainWindow", u"bUE", None)); + ___qtablewidgetitem4 = self.tableWidget_coords.horizontalHeaderItem(1) + ___qtablewidgetitem4.setText(QCoreApplication.translate("MainWindow", u"Coords", None)); + ___qtablewidgetitem5 = self.tableWidget_distances.horizontalHeaderItem(0) + ___qtablewidgetitem5.setText(QCoreApplication.translate("MainWindow", u"bUEs", None)); + ___qtablewidgetitem6 = self.tableWidget_distances.horizontalHeaderItem(1) + ___qtablewidgetitem6.setText(QCoreApplication.translate("MainWindow", u"Distances (m)", None)); + # retranslateUi + diff --git a/gui/ui/dialog_cancel_tests.ui b/gui/ui/dialog_cancel_tests.ui new file mode 100644 index 0000000..66ae58f --- /dev/null +++ b/gui/ui/dialog_cancel_tests.ui @@ -0,0 +1,107 @@ + + + dialog_cancel_tests + + + + 0 + 0 + 847 + 472 + + + + Cancel Tests + + + + + + + 1 + 5 + + + + + + + + 1 + 1 + + + + + 400 + 0 + + + + + + + + + 1 + 1 + + + + + + + image.png + + + true + + + + + + + + + + + 1 + 1 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Exit + + + + + + + Cancel Tests + + + + + + + + + + + diff --git a/gui/ui/dialog_run_tests.ui b/gui/ui/dialog_run_tests.ui new file mode 100644 index 0000000..9f58cdb --- /dev/null +++ b/gui/ui/dialog_run_tests.ui @@ -0,0 +1,184 @@ + + + dialog_run_tests + + + + 0 + 0 + 400 + 300 + + + + + + + + + 222 + 221 + 218 + + + + + + + 222 + 221 + 218 + + + + + + + + + 255 + 255 + 255 + + + + + + + 239 + 239 + 239 + + + + + + + + + 239 + 239 + 239 + + + + + + + 222 + 221 + 218 + + + + + + + + ArrowCursor + + + Dialog + + + + + 30 + 240 + 341 + 32 + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + 210 + 170 + 141 + 61 + + + + Run Hello World + + + + + + 20 + 20 + 161 + 131 + + + + + + + 220 + 40 + 121 + 51 + + + + Resp + + + + + + 209 + 110 + 121 + 41 + + + + Init + + + + + + + buttonBox + accepted() + dialog_run_tests + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + dialog_run_tests + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/gui/ui/image.png b/gui/ui/image.png new file mode 100644 index 0000000000000000000000000000000000000000..5226a645e2eb2de2fdf2cb346833156b0de8698d GIT binary patch literal 220553 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 literal 0 HcmV?d00001 diff --git a/gui/ui/main_window.ui b/gui/ui/main_window.ui new file mode 100644 index 0000000..e05e3ff --- /dev/null +++ b/gui/ui/main_window.ui @@ -0,0 +1,410 @@ + + + MainWindow + + + true + + + + 0 + 0 + 1040 + 851 + + + + + 0 + 0 + + + + Base Station + + + + + + + + 0 + 80 + + + + + 0 + 400 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + 25 + 0 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + 0 + 75 + + + + Qt::ScrollBarAsNeeded + + + Qt::ScrollBarAlwaysOff + + + QAbstractScrollArea::AdjustToContents + + + true + + + Qt::SolidLine + + + 0 + + + true + + + true + + + 73 + + + 73 + + + true + + + false + + + false + + + false + + + false + + + false + + + + bUE + + + + + State + + + + + Pings + + + + + + + + + 0 + 25 + + + + Controls + + + + + + Run Tests + + + + + + + Cancel Tests + + + + + + + Switch Map Type + + + + + + + + + + + + + + 50 + 0 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + 0 + 60 + + + + + 0 + 0 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + + 0 + 40 + + + + + 499 + 0 + + + + Messages + + + + + + + 0 + 1 + + + + + + + + + 0 + 0 + + + + Clear Messages + + + + + + + + + + + + + + 10 + 0 + + + + + 200 + 0 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + 0 + 0 + + + + Qt::ScrollBarAlwaysOff + + + QAbstractScrollArea::AdjustToContents + + + 2 + + + 75 + + + true + + + false + + + + bUE + + + + + Coords + + + + + + + + + 0 + 0 + + + + Qt::ScrollBarAlwaysOff + + + QAbstractScrollArea::AdjustToContents + + + false + + + 2 + + + true + + + false + + + 50 + + + 88 + + + false + + + true + + + false + + + + bUEs + + + + + Distances (m) + + + + + + + + + + + + + + + 0 + 20 + + + + + 0 + 0 + + + + + + + + + + ../../../../../.designer/.designer/.designer/.designer/backup../../../../../.designer/.designer/.designer/.designer/backup + + + test + + + test + + + + + + diff --git a/gui_test_tkinter.py b/gui_test_tkinter.py deleted file mode 100644 index 7512d8b..0000000 --- a/gui_test_tkinter.py +++ /dev/null @@ -1,248 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for the tkinter base station GUI with mock data -This will populate the GUI with fake bUEs, coordinates, and messages -so you can see how all the features work. -""" - -import sys -import os -import threading -import time -import random -from datetime import datetime -from collections import deque - -# Add the current directory to the path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -import tkinter as tk -from base_station_gui import BaseStationGUI - - -class MockOta: - """Mock OTA communication for testing""" - - def __init__(self, port, baudrate, ota_id): - self.id = ota_id - - def send_ota_message(self, bue_id, message): - print(f"Mock OTA: Sending to bUE {bue_id}: {message}") - - def get_new_messages(self): - return [] # No new messages for testing - - -class MockBaseStation: - """Mock base station with realistic test data""" - - def __init__(self): - # Basic setup - self.EXIT = False - self.tick_enabled = True - - # Mock OTA - self.ota = MockOta("/dev/ttyUSB0", 9600, 1) - - # Connected bUEs with realistic IDs - self.connected_bues = [10, 20, 30, 40] - - # Testing status - no bUEs start in testing mode - self.testing_bues = [] - - # Realistic coordinates (around BYU campus area) - self.bue_coordinates = { - 10: [40.2518, -111.6493], # BYU campus area - 20: [40.2528, -111.6503], # Slightly north - 30: [40.2508, -111.6483], # Slightly south - 40: [40.2538, -111.6513], # Further north - } - - # Timeout tracking - different connection qualities - self.bue_timeout_tracker = { - 10: 8, # Excellent connection - 20: 5, # Good connection - 30: 2, # Warning - poor connection - 40: 0, # Lost connection - } - - # Message history with realistic test messages - self.stdout_history = deque( - [ - "[helloworld.py] Starting test execution...", - "[gpstest.py] GPS lock acquired, lat: 40.2518, lon: -111.6493", - "[lora_tu_rd.py] LoRa transmission successful", - "[helloworld.py] Test completed successfully", - "[gpstest2.py] GPS accuracy: 3.2m", - "[lora_td_ru.py] Received signal strength: -85 dBm", - "[helloworld.py] Memory usage: 45%", - "[gpstest.py] Satellite count: 8", - "[custom_test.py] Battery level: 87%", - "[system_info.py] Temperature: 23.5°C", - ], - maxlen=10, - ) - - # Start mock update thread to simulate dynamic data - self.mock_thread = threading.Thread(target=self.mock_updates, daemon=True) - self.mock_thread.start() - - def send_test_to_bue(self, bue_id, test_script): - """Simulate sending a test to a bUE (like PREPR message)""" - if bue_id in self.connected_bues and bue_id not in self.testing_bues: - self.testing_bues.append(bue_id) - self.stdout_history.append(f"[test_manager.py] PREPR sent to bUE {bue_id} for {test_script}") - - # Simulate test completion after random time (5-15 seconds) - completion_delay = random.uniform(5, 15) - completion_timer = threading.Timer(completion_delay, self._complete_test, args=[bue_id]) - completion_timer.daemon = True - completion_timer.start() - - def _complete_test(self, bue_id): - """Internal method to simulate test completion""" - if bue_id in self.testing_bues: - self.testing_bues.remove(bue_id) - completion_type = random.choice(["DONE", "FAIL"]) - self.stdout_history.append(f"[test_manager.py] Test {completion_type} on bUE {bue_id}") - - def cancel_test_on_bue(self, bue_id): - """Simulate canceling a test on a bUE (like CANCD message)""" - if bue_id in self.testing_bues: - self.testing_bues.remove(bue_id) - self.stdout_history.append(f"[test_manager.py] CANCD sent to bUE {bue_id} - test cancelled") - - def mock_updates(self): - """Simulate dynamic updates to the base station data""" - message_templates = [ - "[test_script.py] Processing data...", - "[gps_monitor.py] Position updated", - "[sensor_read.py] Temperature: {temp}°C", - "[battery_check.py] Battery: {battery}%", - "[signal_test.py] RSSI: {rssi} dBm", - "[memory_check.py] RAM usage: {mem}%", - "[network_test.py] Ping successful", - "[data_logger.py] Log entry created", - ] - - counter = 0 - while not self.EXIT: - time.sleep(3) # Update every 3 seconds - - # Simulate changing coordinates slightly (GPS drift) - for bue_id in self.bue_coordinates: - current_coords = self.bue_coordinates[bue_id] - # Add small random movement (GPS drift simulation) - lat_drift = random.uniform(-0.0001, 0.0001) # ~10 meter drift - lon_drift = random.uniform(-0.0001, 0.0001) - - new_lat = current_coords[0] + lat_drift - new_lon = current_coords[1] + lon_drift - self.bue_coordinates[bue_id] = [new_lat, new_lon] - - # Simulate changing connection quality - for bue_id in self.bue_timeout_tracker: - # Random connection quality changes - change = random.randint(-1, 1) - current_val = self.bue_timeout_tracker[bue_id] - new_val = max(0, min(8, current_val + change)) - self.bue_timeout_tracker[bue_id] = new_val - - # Add new mock messages occasionally - if counter % 2 == 0: # Every 6 seconds - template = random.choice(message_templates) - if "{temp}" in template: - message = template.format(temp=round(random.uniform(20, 30), 1)) - elif "{battery}" in template: - message = template.format(battery=random.randint(70, 95)) - elif "{rssi}" in template: - message = template.format(rssi=random.randint(-95, -70)) - elif "{mem}" in template: - message = template.format(mem=random.randint(30, 80)) - else: - message = template - - self.stdout_history.append(message) - - counter += 1 - - def get_distance(self, bue_1, bue_2): - """Calculate distance between two bUEs (simplified for testing)""" - try: - c1 = self.bue_coordinates[bue_1] - c2 = self.bue_coordinates[bue_2] - - # Simple distance calculation (not great circle, but good for testing) - lat_diff = c1[0] - c2[0] - lon_diff = c1[1] - c2[1] - - # Convert to approximate meters (rough calculation) - lat_meters = lat_diff * 111000 # 1 degree lat ≈ 111km - lon_meters = lon_diff * 111000 * 0.8 # Adjust for longitude at this latitude - - distance = (lat_meters**2 + lon_meters**2) ** 0.5 - return distance - - except Exception as e: - print(f"Error calculating distance: {e}") - return None - - -def create_test_gui(): - """Create and run the test GUI with mock data""" - root = tk.Tk() - - # Create the GUI but override the base station with our mock - gui = BaseStationGUI(root) - - # Replace the real base station with our mock - if gui.base_station: - gui.base_station.EXIT = True # Stop the real base station - - gui.base_station = MockBaseStation() - - # Add some test custom markers - gui.custom_markers = { - 0: { - "name": "Building A", - "lat": 40.2520, - "lon": -111.6495, - "paired_bue": 10, # Paired with bUE 10 - }, - 1: { - "name": "Parking Lot", - "lat": 40.2530, - "lon": -111.6505, - "paired_bue": None, # Not paired - }, - 2: { - "name": "Test Point C", - "lat": 40.2510, - "lon": -111.6485, - "paired_bue": 30, # Paired with bUE 30 - }, - } - gui.marker_counter = 3 - - # Update the status - gui.status_var.set("Mock Base Station Running - Test Data Active") - - print("🚀 Test GUI launched with mock data!") - print("Features to test:") - print(" - Connected bUEs: 10, 20, 30, 40") - print(" - Testing bUEs: 10, 30 (changes dynamically)") - print(" - Map shows bUE locations around BYU campus") - print(" - Custom markers with proximity detection") - print(" - Live message updates every few seconds") - print(" - Connection quality changes dynamically") - print(" - Right-click bUEs for context menu") - print(" - Try adding/managing custom markers") - print(" - Watch proximity detection (bUEs turn green near paired markers)") - - return gui - - -if __name__ == "__main__": - print("Starting tkinter GUI test with mock data...") - gui = create_test_gui() - gui.root.mainloop() diff --git a/launch_gui.sh b/launch_gui.sh deleted file mode 100755 index 07ab093..0000000 --- a/launch_gui.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/bash -# -# launch_gui.sh -# Launcher script for the real base station GUI -# -# Usage: -# ./launch_gui.sh [config_file] -# -# If no config file is specified, uses config_base.yaml - -CONFIG_FILE="${1:-config_base.yaml}" - -echo "===================================" -echo "Base Station GUI Launcher" -echo "===================================" -echo "Config file: $CONFIG_FILE" -echo "Working directory: $(pwd)" -echo "Python version: $(python3 --version)" -echo "===================================" - -# Check if config file exists -if [ ! -f "$CONFIG_FILE" ]; then - echo "ERROR: Config file '$CONFIG_FILE' not found!" - echo "Available config files:" - ls -la *.yaml 2>/dev/null || echo "No .yaml files found" - exit 1 -fi - -# Check if virtual environment exists and activate it -if [ -d "uw_env" ]; then - echo "Activating virtual environment..." - source uw_env/bin/activate - echo "Virtual environment activated: $VIRTUAL_ENV" -else - echo "WARNING: No virtual environment found at 'uw_env'" - echo "Using system Python" -fi - -# Check if required files exist -REQUIRED_FILES=("real_base_station_gui.py" "base_station_main.py" "constants.py" "base_station_gui.py") -for file in "${REQUIRED_FILES[@]}"; do - if [ ! -f "$file" ]; then - echo "ERROR: Required file '$file' not found!" - exit 1 - fi -done - -echo "===================================" -echo "Starting Base Station GUI..." -echo "Press Ctrl+C to stop" -echo "" -echo "IMPORTANT:" -echo "1. Make sure your bUEs are configured to connect to this base station" -echo "2. Check that the base station shows 'LISTENING FOR bUEs' status" -echo "3. Monitor the base station log for connection messages" -echo "4. If no bUEs connect, check your config file network settings" -echo "===================================" - -# Run the GUI -python3 real_base_station_gui.py "$CONFIG_FILE" diff --git a/main_ui.py b/main_ui.py deleted file mode 100644 index 017e3fb..0000000 --- a/main_ui.py +++ /dev/null @@ -1,276 +0,0 @@ -import sys -import signal -import time -import os -import subprocess -import threading -from datetime import datetime -from enum import Enum, auto - -from loguru import logger -from rich.console import Console -import survey # type:ignore -import keyboard # type:ignore - -from base_station_main import Base_Station_Main -from UI import create_compact_dashboard -from constants import bUEs - - -class Command(Enum): - REFRESH = 0 - TEST = auto() - # DISTANCE = auto() - CANCEL = auto() - DISCONNECT = auto() - RELOAD = auto() - RESTART = auto() - # LIST = auto() - EXIT = auto() - - -console = Console() - -timer = 0 -keyboard_input_detected = False -is_user_inputting = False - - -def on_key_press(event): - """Callback function triggered on any key press.""" - global keyboard_input_detected - keyboard_input_detected = True - - -def keystroke_handler(): - global timer, keyboard_input_detected - - # Set up keyboard listener - keyboard.on_press(on_key_press) - - while True: - # Check if keyboard input was detected - if keyboard_input_detected: - timer = 0 - keyboard_input_detected = False - - if not is_user_inputting and timer > 5: # How many seconds before auto refresh - os.kill(os.getpid(), signal.SIGINT) - timer = 0 - else: - timer += 0.5 - - time.sleep(0.5) - - -def user_input_handler(base_station): - """User input handler with OS clear for stable positioning.""" - global is_user_inputting - - COMMANDS_WITH_DESC = [ - ("REFRESH", "Update the dashboard display"), - ("TEST", "Run a test file on selected bUEs"), - # ("DISTANCE", "Calculate distance between two bUEs"), - ("CANCEL", "Cancel running tests on selected bUEs"), - ("DISCONNECT", "Disconnect from selected bUEs"), - ("RELOAD", "Reload bue.service script on selected bUEs"), - ("RESTART", "Completely restart bUE (sudo reboot)"), - # ("LIST", "Show all currently connected bUEs"), - ("EXIT", "Exit the base station application"), - ] - formatted_options = [f"{cmd:<12} - {desc}" for cmd, desc in COMMANDS_WITH_DESC] - - FILES = ("lora_td_ru", "lora_tu_rd", "helloworld", "gpstest", "gpstest2") - - while not base_station.EXIT: - try: - console.clear() - console.print(create_compact_dashboard(base_station), end="") - print() - is_user_inputting = False - - # Get input with simple prompt - index = survey.routines.select("Pick a command: ", options=formatted_options) - is_user_inputting = True - - if index != (len(COMMANDS_WITH_DESC) - 1) and len(base_station.connected_bues) == 0: - print("Currently not connected to any bUEs") - continue - - if index == Command.REFRESH.value: - continue - - elif index == Command.TEST.value: - connected_bues = tuple(bUEs[str(x)] for x in base_station.connected_bues) - - bues_indexes = [] - bue_test = {} - bue_params = {} - - while len(bues_indexes) == 0: - bues_indexes = survey.routines.basket("What bUEs will be running tests? ", options=connected_bues) - if len(bues_indexes) == 0: - print("You must select at least one bUE...") - - start_time = survey.routines.datetime( - "What time do you want to run the test?: ", - attrs=("hour", "minute", "second"), - ) - - bues = [base_station.connected_bues[index] for index in bues_indexes] - for bue in bues: - file_index = survey.routines.select( - f"What file would you like to run on {bUEs[str(bue)]}? ", - options=FILES, - ) - file_name = FILES[file_index] - bue_test[bue] = file_name - parameters = survey.routines.input( - f"Enter parameters for {bUEs[str(bue)]}, {file_name} separated by a space: " - ) - bue_params[int(bue)] = parameters - - logger.debug(bue_params) - - send_test(base_station, bue_test, start_time, bue_params) - - elif index == Command.CANCEL.value: - testing_bues = tuple(bUEs[str(x)] for x in base_station.testing_bues) - if len(testing_bues) == 0: - print("No bUEs are currently running any tests") - - indexes = survey.routines.basket("What bUE tests do you want to cancel? ", options=testing_bues) - - print("\n") - for i in indexes: - bue = base_station.testing_bues[i] - print(f"Ending test for {base_station.testing_bues[i]}") - base_station.ota.send_ota_message(bue, "CANC") - logger.info(f"Sending CANC to {bue}") - print("\n") - - elif index == Command.DISCONNECT.value: - connected_bues = tuple(bUEs[str(x)] for x in base_station.connected_bues) - - indexes = survey.routines.basket("What bUEs do you want to disconnect from? ", options=connected_bues) - - for i in indexes: - disconnect(base_station, i) - print("\n") - - elif index == Command.RELOAD.value: - connected_bues = tuple(bUEs[str(x)] for x in base_station.connected_bues) - - indexes = survey.routines.basket("What bUEs do you want to reload? ", options=connected_bues) - - for i in indexes: - bue = base_station.connected_bues[i] - logger.info(f"Reloading script for {bue}") - base_station.ota.send_ota_message(bue, "RELOAD") - - # Disconnect from the bUE - disconnect(base_station, i) - - elif index == Command.RESTART.value: - connected_bues = tuple(bUEs[str(x)] for x in base_station.connected_bues) - - indexes = survey.routines.basket("What bUEs do you want to restart? ", options=connected_bues) - - for i in indexes: - bue = base_station.connected_bues[i] - logger.info(f"Restarting {bue}") - base_station.ota.send_ota_message(bue, "RESTART") - - # Disconnect from the bUE - disconnect(base_station, i) - - elif index == Command.EXIT.value: - base_station.EXIT = True - base_station.__del__() - # if 'user_input_thread' in locals() and user_input_thread.is_alive(): - # user_input_thread.join(timeout=2.0) - sys.exit(0) - else: # Catch all - continue - - except KeyboardInterrupt: - print("\n[Escape] Cancelled input. Returning to command prompt.\n") - is_user_inputting = False - continue - - except Exception as e: - logger.error(f"[User Input] Error {e}") - print(e) - - -def send_test(base_station, bue_test, start_time, bue_params): - """Send test command to selected bUEs.""" - # Convert datetime to Unix timestamp - import time as time_module - from datetime import datetime, date - - if isinstance(start_time, datetime): - time_part = start_time.time() - else: - time_part = start_time - - # Combine today's date with the selected time - today = date.today() - full_datetime = datetime.combine(today, time_part) - unix_timestamp = int(full_datetime.timestamp()) - - for bue in bue_test.keys(): - if not hasattr(base_station, "testing_bues"): - base_station.testing_bues = [] - base_station.ota.send_ota_message(bue, f"TEST-{bue_test[int(bue)]}-{unix_timestamp}-{bue_params[bue]}") - - -def disconnect(base_station, index): - bue = base_station.connected_bues[index] - base_station.connected_bues.remove(bue) - if bue in base_station.bue_coordinates.keys(): - del base_station.bue_coordinates[bue] - if bue in base_station.testing_bues: - base_station.testing_bues.remove(bue) - if bue in base_station.bue_timeout_tracker.keys(): - del base_station.bue_timeout_tracker[bue] - - -def open_new_terminal(): - """Open a new terminal window and run this script in it.""" - # Get the current script path - script_path = os.path.abspath(__file__) - - # Open new terminal with this script - subprocess.Popen(["gnome-terminal", "--", "python3", script_path]) - - # Exit the current process since we're running in new terminal - sys.exit(0) - - -if __name__ == "__main__": - # if not os.environ.get('DASHBOARD_TERMINAL'): - # os.environ['DASHBOARD_TERMINAL'] = '1' - # open_new_terminal() - - start_time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - logger.info(f"This marks the start of the base station service at {start_time}") - - threading.Thread(target=keystroke_handler, daemon=True).start() - - try: - base_station = Base_Station_Main(yaml_str="config_base.yaml") - base_station.tick_enabled = True - - user_input_handler(base_station) - - while not base_station.EXIT: - time.sleep(0.2) - - except KeyboardInterrupt: - logger.info("Exiting the Base Station service") - if "base_station" in locals() and base_station is not None: - base_station.EXIT = True - time.sleep(0.5) - base_station.__del__() - sys.exit(0) diff --git a/real_base_station_gui.py b/real_base_station_gui.py deleted file mode 100755 index 2ee8b84..0000000 --- a/real_base_station_gui.py +++ /dev/null @@ -1,830 +0,0 @@ -#!/usr/bin/env python3 -""" -real_base_station_gui.py -Ty Young - -A GUI for the base station that works with actual bUEs. -This script launches the GUI with real base station functionality. - -Usage: - python3 real_base_station_gui.py [config_file] - -If no config file is specified, it will use 'config_base.yaml' -""" - -import sys -import os -import threading -import time -import math -import tkinter as tk -from tkinter import ttk, messagebox, filedialog, scrolledtext -from datetime import datetime, date, timedelta -from loguru import logger - -# Import the base station and constants -from base_station_main import Base_Station_Main -from constants import bUEs, TIMEOUT - - -class RealBaseStationGUI: - """GUI for real base station operations""" - - def __init__(self, root, config_file="config_base.yaml"): - self.root = root - self.config_file = config_file - self.root.title(f"Base Station Control Panel - {config_file}") - self.root.geometry("1600x1000") - - # Initialize base station - self.base_station = None - self.update_thread = None - self.running = False - - # Custom markers for the map - self.custom_markers = {} # {marker_id: {'name': str, 'lat': float, 'lon': float, 'paired_bue': int}} - self.marker_counter = 0 - - # Status variables - self.listening_status_var = None # Will be initialized in setup_gui - - # Setup GUI - self.setup_gui() - - # Start base station - self.start_base_station() - - # Handle window close - self.root.protocol("WM_DELETE_WINDOW", self.on_closing) - - def setup_gui(self): - """Setup the main GUI layout with all panels always visible""" - # Create main container with grid layout - main_frame = ttk.Frame(self.root) - main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Configure grid weights for responsive resizing - main_frame.grid_columnconfigure(0, weight=1) # Left column - main_frame.grid_columnconfigure(1, weight=2) # Middle column (map) - main_frame.grid_columnconfigure(2, weight=1) # Right column - main_frame.grid_rowconfigure(0, weight=1) # Top row - main_frame.grid_rowconfigure(1, weight=1) # Bottom row - - # Left panel - bUE list and controls - left_frame = ttk.Frame(main_frame) - left_frame.grid(row=0, column=0, rowspan=2, sticky="nsew", padx=(0, 2)) - self.setup_left_panel(left_frame) - - # Middle top panel - Map - map_frame = ttk.LabelFrame(main_frame, text="bUE Location Map") - map_frame.grid(row=0, column=1, sticky="nsew", padx=2) - self.setup_map_view(map_frame) - - # Middle bottom panel - Messages - messages_frame = ttk.LabelFrame(main_frame, text="Messages") - messages_frame.grid(row=1, column=1, sticky="nsew", padx=2, pady=(2, 0)) - self.setup_messages_view(messages_frame) - - # Right panel - Data tables - tables_frame = ttk.Frame(main_frame) - tables_frame.grid(row=0, column=2, rowspan=2, sticky="nsew", padx=(2, 0)) - self.setup_tables_view(tables_frame) - - # Status bar - self.status_var = tk.StringVar() - self.status_var.set("Initializing...") - status_bar = ttk.Label(self.root, textvariable=self.status_var, relief=tk.SUNKEN) - status_bar.pack(side=tk.BOTTOM, fill=tk.X) - - def setup_left_panel(self, parent): - """Setup the left panel with bUE list and controls""" - # bUE List Frame - bue_frame = ttk.LabelFrame(parent, text="Connected bUEs") - bue_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # bUE Treeview - self.bue_tree = ttk.Treeview(bue_frame, columns=("status", "ping"), show="tree headings") - self.bue_tree.heading("#0", text="bUE ID") - self.bue_tree.heading("status", text="Status") - self.bue_tree.heading("ping", text="Ping Status") - - self.bue_tree.column("#0", width=100) - self.bue_tree.column("status", width=100) - self.bue_tree.column("ping", width=100) - - # Scrollbar for treeview - bue_scrollbar = ttk.Scrollbar(bue_frame, orient=tk.VERTICAL, command=self.bue_tree.yview) - self.bue_tree.configure(yscrollcommand=bue_scrollbar.set) - - self.bue_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - bue_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - # Bind right-click context menu - self.bue_tree.bind("", self.show_bue_context_menu) - - # Bind left-click to handle deselection when clicking empty space - self.bue_tree.bind("", self.on_bue_tree_click) - - # Control buttons frame - control_frame = ttk.LabelFrame(parent, text="Base Station Controls") - control_frame.pack(fill=tk.X, padx=5, pady=5) - - # Connection status indicator (read-only) - status_frame = ttk.LabelFrame(control_frame, text="Connection Status") - status_frame.pack(fill=tk.X, padx=5, pady=5) - - # Status indicator - self.listening_status_var = tk.StringVar() - self.listening_status_label = ttk.Label( - status_frame, - textvariable=self.listening_status_var, - font=("TkDefaultFont", 10, "bold"), - ) - self.listening_status_label.pack(pady=5) - - # Test controls - test_frame = ttk.LabelFrame(control_frame, text="Test Controls") - test_frame.pack(fill=tk.X, padx=5, pady=5) - - ttk.Button(test_frame, text="Run Test", command=self.run_test).pack(fill=tk.X, pady=2) - ttk.Button(test_frame, text="Cancel Tests", command=self.cancel_tests).pack(fill=tk.X, pady=2) - - # Log controls - log_frame = ttk.LabelFrame(control_frame, text="Logs") - log_frame.pack(fill=tk.X, padx=5, pady=5) - - ttk.Button(log_frame, text="Open Base Station Log", command=self.open_base_log).pack(fill=tk.X, pady=2) - - # Map controls frame - map_control_frame = ttk.LabelFrame(parent, text="Map Controls") - map_control_frame.pack(fill=tk.X, padx=5, pady=5) - - ttk.Button(map_control_frame, text="Add Custom Marker", command=self.add_custom_marker).pack(fill=tk.X, pady=2) - ttk.Button(map_control_frame, text="Manage Markers", command=self.manage_markers).pack(fill=tk.X, pady=2) - - def setup_map_view(self, parent): - """Setup the map view with bUE locations and custom markers""" - # Map canvas - self.map_canvas = tk.Canvas(parent, bg="lightblue", width=600, height=300) - self.map_canvas.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Map info frame - map_info_frame = ttk.Frame(parent) - map_info_frame.pack(fill=tk.X, padx=5, pady=5) - - ttk.Label(map_info_frame, text="Legend:").pack(side=tk.LEFT) - ttk.Label(map_info_frame, text="🔵 bUE", foreground="blue").pack(side=tk.LEFT, padx=5) - ttk.Label(map_info_frame, text="📍 Marker", foreground="red").pack(side=tk.LEFT, padx=5) - ttk.Label(map_info_frame, text="🟢 Close", foreground="green").pack(side=tk.LEFT, padx=5) - - # Bind canvas events - self.map_canvas.bind("", self.on_map_click) - self.map_canvas.bind("", self.on_map_hover) - - def setup_tables_view(self, parent): - """Setup the tables view with coordinates and distances""" - # Create paned window for tables - tables_paned = ttk.PanedWindow(parent, orient=tk.VERTICAL) - tables_paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Coordinates table - coord_frame = ttk.LabelFrame(tables_paned, text="bUE Coordinates") - tables_paned.add(coord_frame, weight=1) - - self.coord_tree = ttk.Treeview(coord_frame, columns=("latitude", "longitude"), show="tree headings") - self.coord_tree.heading("#0", text="bUE ID") - self.coord_tree.heading("latitude", text="Latitude") - self.coord_tree.heading("longitude", text="Longitude") - self.coord_tree.pack(fill=tk.BOTH, expand=True) - - # Distance table - dist_frame = ttk.LabelFrame(tables_paned, text="bUE Distances") - tables_paned.add(dist_frame, weight=1) - - self.dist_tree = ttk.Treeview(dist_frame, columns=("distance",), show="tree headings") - self.dist_tree.heading("#0", text="bUE Pair") - self.dist_tree.heading("distance", text="Distance (m)") - self.dist_tree.pack(fill=tk.BOTH, expand=True) - - def setup_messages_view(self, parent): - """Setup the messages view""" - # Messages text area - self.messages_text = scrolledtext.ScrolledText(parent, height=12, wrap=tk.WORD) - self.messages_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Control frame for buttons - control_frame = ttk.Frame(parent) - control_frame.pack(fill=tk.X, padx=5, pady=5) - - # Clear messages button - ttk.Button(control_frame, text="Clear Messages", command=self.clear_messages).pack(side=tk.LEFT) - - def start_base_station(self): - """Initialize and start the base station""" - try: - logger.info(f"Starting base station with config: {self.config_file}") - self.base_station = Base_Station_Main(self.config_file) - - # CRITICAL: Enable the tick system to start listening for bUEs - # This matches the working main_ui.py implementation - self.base_station.tick_enabled = True - - self.running = True - - # Start update thread - self.update_thread = threading.Thread(target=self.update_loop, daemon=True) - self.update_thread.start() - - self.status_var.set(f"Base Station Listening - Config: {self.config_file}") - logger.info("Base Station GUI started successfully and listening for bUEs") - - except Exception as e: - messagebox.showerror("Error", f"Failed to start base station: {e}") - logger.error(f"Failed to start base station: {e}") - self.status_var.set(f"Error: {e}") - - def update_loop(self): - """Main update loop for GUI refresh""" - while self.running: - try: - if self.base_station: - self.root.after(0, self.update_display) - time.sleep(1) # Update every second - except Exception as e: - logger.error(f"Error in update loop: {e}") - - def update_display(self): - """Update all GUI elements with current data""" - if not self.base_station: - return - - try: - self.update_bue_list() - self.update_map() - self.update_tables() - self.update_messages() - self.update_status() - - # Debug logging every 10 seconds - if hasattr(self, "_debug_counter"): - self._debug_counter += 1 - else: - self._debug_counter = 1 - - if self._debug_counter % 10 == 0: # Every 10 seconds - logger.debug( - f"GUI Update - Connected bUEs: {len(self.base_station.connected_bues)}, " - f"Tick enabled: {getattr(self.base_station, 'tick_enabled', False)}, " - f"Has coordinates: {len(getattr(self.base_station, 'bue_coordinates', {}))}" - ) - except Exception as e: - logger.error(f"Error updating display: {e}") - - def update_bue_list(self): - """Update the bUE list with current connections and status""" - # Store current selection - selected_items = self.bue_tree.selection() - - # Get current items for comparison - current_items = set(self.bue_tree.get_children()) - new_items = set(str(bue_id) for bue_id in self.base_station.connected_bues) - - # Only rebuild if the set of bUEs has changed - if current_items != new_items: - # Clear existing items - for item in self.bue_tree.get_children(): - self.bue_tree.delete(item) - - # Add connected bUEs - for bue_id in self.base_station.connected_bues: - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - - # Determine status - if hasattr(self.base_station, "testing_bues") and bue_id in self.base_station.testing_bues: - status = "🧪 Testing" - else: - status = "💤 Idle" - - # Determine ping status - timeout_val = self.base_station.bue_timeout_tracker.get(bue_id, 0) - if timeout_val >= TIMEOUT / 2: - ping_status = "🟢 Good" - elif timeout_val > 0: - ping_status = "🟡 Warning" - else: - ping_status = "🔴 Lost" - - self.bue_tree.insert("", "end", iid=bue_id, text=bue_name, values=(status, ping_status)) - - # Restore selection if the item still exists - for item in selected_items: - if self.bue_tree.exists(item): - self.bue_tree.selection_add(item) - else: - # Just update the values for existing items without clearing - for bue_id in self.base_station.connected_bues: - item_id = str(bue_id) - if self.bue_tree.exists(item_id): - # Determine status - if hasattr(self.base_station, "testing_bues") and bue_id in self.base_station.testing_bues: - status = "🧪 Testing" - else: - status = "💤 Idle" - - # Determine ping status - timeout_val = self.base_station.bue_timeout_tracker.get(bue_id, 0) - if timeout_val >= TIMEOUT / 2: - ping_status = "🟢 Good" - elif timeout_val > 0: - ping_status = "🟡 Warning" - else: - ping_status = "🔴 Lost" - - # Update values without disturbing selection - self.bue_tree.item(item_id, values=(status, ping_status)) - - def update_map(self): - """Update the map with bUE locations and markers""" - # Clear canvas - self.map_canvas.delete("all") - - if not self.base_station or not hasattr(self.base_station, "bue_coordinates") or not self.base_station.bue_coordinates: - self.map_canvas.create_text( - 300, - 200, - text="No bUE coordinates available", - font=("Arial", 14), - fill="gray", - ) - return - - # Calculate map bounds - lats = [] - lons = [] - - # Get bUE coordinates - for coords in self.base_station.bue_coordinates.values(): - try: - lat, lon = float(coords[0]), float(coords[1]) - lats.append(lat) - lons.append(lon) - except (ValueError, IndexError, TypeError): - continue - - # Add custom marker coordinates - for marker in self.custom_markers.values(): - lats.append(marker["lat"]) - lons.append(marker["lon"]) - - if not lats or not lons: - self.map_canvas.create_text( - 300, - 200, - text="No valid coordinates available", - font=("Arial", 14), - fill="gray", - ) - return - - # Calculate bounds with padding - min_lat, max_lat = min(lats), max(lats) - min_lon, max_lon = min(lons), max(lons) - - # Add padding - lat_padding = (max_lat - min_lat) * 0.1 or 0.001 - lon_padding = (max_lon - min_lon) * 0.1 or 0.001 - - min_lat -= lat_padding - max_lat += lat_padding - min_lon -= lon_padding - max_lon += lon_padding - - # Get canvas dimensions - canvas_width = self.map_canvas.winfo_width() or 600 - canvas_height = self.map_canvas.winfo_height() or 400 - - # Map coordinate conversion functions - def lat_to_y(lat): - return canvas_height - ((lat - min_lat) / (max_lat - min_lat)) * canvas_height - - def lon_to_x(lon): - return ((lon - min_lon) / (max_lon - min_lon)) * canvas_width - - # Draw bUEs - for bue_id, coords in self.base_station.bue_coordinates.items(): - try: - lat, lon = float(coords[0]), float(coords[1]) - x, y = lon_to_x(lon), lat_to_y(lat) - - # Check proximity to custom markers - is_close = False - for marker in self.custom_markers.values(): - if marker.get("paired_bue") == bue_id: - distance = self.calculate_distance(lat, lon, marker["lat"], marker["lon"]) - if distance <= 20: # 20 meters proximity - is_close = True - break - - # Choose color based on proximity - color = "green" if is_close else "blue" - - # Draw bUE circle - radius = 8 - self.map_canvas.create_oval( - x - radius, - y - radius, - x + radius, - y + radius, - fill=color, - outline="darkblue", - width=2, - tags=f"bue_{bue_id}", - ) - - # Label - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - self.map_canvas.create_text( - x, - y - 15, - text=bue_name, - font=("Arial", 8), - fill="black", - tags=f"bue_{bue_id}", - ) - - except (ValueError, IndexError, TypeError) as e: - logger.debug(f"Error plotting bUE {bue_id}: {e}") - - # Draw custom markers - for marker_id, marker in self.custom_markers.items(): - x, y = lon_to_x(marker["lon"]), lat_to_y(marker["lat"]) - - # Draw marker - radius = 6 - self.map_canvas.create_oval( - x - radius, - y - radius, - x + radius, - y + radius, - fill="red", - outline="darkred", - width=2, - tags=f"marker_{marker_id}", - ) - - # Label - self.map_canvas.create_text( - x, - y - 15, - text=marker["name"], - font=("Arial", 8), - fill="red", - tags=f"marker_{marker_id}", - ) - - def update_tables(self): - """Update coordinate and distance tables""" - # Update coordinates table - for item in self.coord_tree.get_children(): - self.coord_tree.delete(item) - - if self.base_station and hasattr(self.base_station, "bue_coordinates"): - for bue_id, coords in self.base_station.bue_coordinates.items(): - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - try: - lat, lon = coords[0], coords[1] - self.coord_tree.insert("", "end", text=bue_name, values=(lat, lon)) - except (IndexError, ValueError, TypeError): - self.coord_tree.insert("", "end", text=bue_name, values=("Invalid", "Invalid")) - - # Update distance table - for item in self.dist_tree.get_children(): - self.dist_tree.delete(item) - - if ( - self.base_station - and hasattr(self.base_station, "connected_bues") - and len(self.base_station.connected_bues) > 1 - and hasattr(self.base_station, "bue_coordinates") - ): - - processed_pairs = set() - for bue1 in self.base_station.connected_bues: - for bue2 in self.base_station.connected_bues: - if ( - bue1 != bue2 - and bue1 in self.base_station.bue_coordinates - and bue2 in self.base_station.bue_coordinates - and (bue1, bue2) not in processed_pairs - and (bue2, bue1) not in processed_pairs - ): - - if hasattr(self.base_station, "get_distance"): - distance = self.base_station.get_distance(bue1, bue2) - if distance is not None: - pair_name = f"{bUEs.get(str(bue1), str(bue1))} ↔ {bUEs.get(str(bue2), str(bue2))}" - self.dist_tree.insert( - "", - "end", - text=pair_name, - values=(f"{distance:.2f}"), - ) - - processed_pairs.add((bue1, bue2)) - - # Add bUE to custom marker distances - if ( - self.base_station - and hasattr(self.base_station, "connected_bues") - and hasattr(self.base_station, "bue_coordinates") - and self.custom_markers - ): - - for bue_id in self.base_station.connected_bues: - if bue_id in self.base_station.bue_coordinates: - bue_coords = self.base_station.bue_coordinates[bue_id] - try: - bue_lat, bue_lon = float(bue_coords[0]), float(bue_coords[1]) - - # Find paired marker for this bUE - for marker in self.custom_markers.values(): - if marker.get("paired_bue") == bue_id: - distance = self.calculate_distance(bue_lat, bue_lon, marker["lat"], marker["lon"]) - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - marker_name = marker["name"] - pair_name = f"{bue_name} ↔ {marker_name}" - self.dist_tree.insert( - "", - "end", - text=pair_name, - values=(f"{distance:.2f}"), - ) - break # Only one paired marker per bUE - - except (ValueError, TypeError, IndexError) as e: - # Skip invalid coordinates - continue - - def update_messages(self): - """Update the messages display""" - if self.base_station and hasattr(self.base_station, "stdout_history"): - # Get current content - current_content = self.messages_text.get(1.0, tk.END) - - # Build new content - new_content = "\n".join(self.base_station.stdout_history) - - # Only update if content changed - if new_content.strip() != current_content.strip(): - self.messages_text.delete(1.0, tk.END) - self.messages_text.insert(1.0, new_content) - self.messages_text.see(tk.END) # Scroll to bottom - - def update_status(self): - """Update the status bar and connection status""" - if self.base_station: - connected = len(self.base_station.connected_bues) - testing = len(getattr(self.base_station, "testing_bues", [])) - is_listening = getattr(self.base_station, "tick_enabled", False) - - # Update connection status indicator - always shows listening since it should always be on - self.listening_status_var.set("🟢 LISTENING FOR bUEs") - - # Update main status bar - current_time = datetime.now().strftime("%H:%M:%S") - self.status_var.set(f"Time: {current_time} | Connected: {connected} | Testing: {testing}") - - def on_bue_tree_click(self, event): - """Handle left-click on bUE tree to allow deselection""" - # Get the item that was clicked - item = self.bue_tree.identify_row(event.y) - - # If no item was clicked (clicked in empty space), deselect all - if not item: - self.bue_tree.selection_remove(self.bue_tree.selection()) - - def show_bue_context_menu(self, event): - """Show context menu for bUE operations""" - item = self.bue_tree.selection()[0] if self.bue_tree.selection() else None - if not item: - return - - bue_id = int(item) - - # Create context menu - context_menu = tk.Menu(self.root, tearoff=0) - - # Add menu items with commands that also dismiss the menu - def disconnect_and_close(): - context_menu.unpost() - self.disconnect_bue(bue_id) - - def reload_and_close(): - context_menu.unpost() - self.reload_bue(bue_id) - - def restart_and_close(): - context_menu.unpost() - self.restart_bue(bue_id) - - def open_log_and_close(): - context_menu.unpost() - self.open_bue_log(bue_id) - - context_menu.add_command(label="Disconnect", command=disconnect_and_close) - context_menu.add_command(label="Reload", command=reload_and_close) - context_menu.add_command(label="Restart", command=restart_and_close) - context_menu.add_separator() - context_menu.add_command(label="Open Log File", command=open_log_and_close) - - # Store the menu reference so we can dismiss it - self.active_context_menu = context_menu - - # Function to dismiss menu when clicking elsewhere - def dismiss_menu(event): - if hasattr(self, "active_context_menu") and self.active_context_menu: - try: - self.active_context_menu.unpost() - self.active_context_menu = None - except: - pass - - # Remove the binding after use to prevent interference - # Check if we have a binding ID and it hasn't been unbound already - if hasattr(self, "dismiss_binding_id") and self.dismiss_binding_id: - try: - self.root.unbind("", self.dismiss_binding_id) - self.dismiss_binding_id = None # Clear the binding ID - except tk.TclError: - # Binding was already removed, ignore the error - pass - except Exception: - # Any other error, just clear the binding ID - self.dismiss_binding_id = None - - # Show menu - try: - context_menu.tk_popup(event.x_root, event.y_root) - # Bind left-click elsewhere to dismiss menu - self.dismiss_binding_id = self.root.bind("", dismiss_menu, add="+") - except Exception as e: - print(f"Error showing context menu: {e}") - finally: - context_menu.grab_release() - - def disconnect_bue(self, bue_id): - """Disconnect a specific bUE""" - if messagebox.askyesno( - "Confirm Disconnect", - f"Disconnect from {bUEs.get(str(bue_id), str(bue_id))}?", - ): - try: - if bue_id in self.base_station.connected_bues: - self.base_station.connected_bues.remove(bue_id) - if hasattr(self.base_station, "bue_coordinates") and bue_id in self.base_station.bue_coordinates: - del self.base_station.bue_coordinates[bue_id] - if hasattr(self.base_station, "testing_bues") and bue_id in self.base_station.testing_bues: - self.base_station.testing_bues.remove(bue_id) - if bue_id in self.base_station.bue_timeout_tracker: - del self.base_station.bue_timeout_tracker[bue_id] - logger.info(f"Disconnected from bUE {bue_id}") - except Exception as e: - messagebox.showerror("Error", f"Failed to disconnect: {e}") - - def reload_bue(self, bue_id): - """Reload a specific bUE""" - if messagebox.askyesno("Confirm Reload", f"Reload {bUEs.get(str(bue_id), str(bue_id))}?"): - try: - if hasattr(self.base_station, "ota"): - self.base_station.ota.send_ota_message(bue_id, "RELOAD") - self.disconnect_bue(bue_id) - logger.info(f"Sent reload command to bUE {bue_id}") - else: - messagebox.showwarning("Feature Unavailable", "OTA functionality not available") - except Exception as e: - messagebox.showerror("Error", f"Failed to reload: {e}") - - def restart_bue(self, bue_id): - """Restart a specific bUE""" - if messagebox.askyesno("Confirm Restart", f"Restart {bUEs.get(str(bue_id), str(bue_id))}?"): - try: - if hasattr(self.base_station, "ota"): - self.base_station.ota.send_ota_message(bue_id, "RESTART") - self.disconnect_bue(bue_id) - logger.info(f"Sent restart command to bUE {bue_id}") - else: - messagebox.showwarning("Feature Unavailable", "OTA functionality not available") - except Exception as e: - messagebox.showerror("Error", f"Failed to restart: {e}") - - def open_bue_log(self, bue_id): - """Open the log file for a specific bUE""" - log_path = f"logs/bue_{bue_id}.log" - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - LogViewerDialog(self.root, log_path, f"{bue_name} Log") - - def open_base_log(self): - """Open the base station log file""" - log_path = "logs/base_station.log" - LogViewerDialog(self.root, log_path, "Base Station Log") - - def run_test(self): - """Run test dialog and execute tests""" - if not self.base_station or not self.base_station.connected_bues: - messagebox.showwarning("No bUEs", "No bUEs currently connected") - return - - # Create test dialog - TestDialog(self.root, self.base_station) - - def cancel_tests(self): - """Cancel running tests""" - if not hasattr(self.base_station, "testing_bues") or not self.base_station.testing_bues: - messagebox.showinfo("No Tests", "No tests currently running") - return - - # Create cancel dialog - CancelTestDialog(self.root, self.base_station) - - def clear_messages(self): - """Clear the messages display""" - self.messages_text.delete(1.0, tk.END) - if self.base_station and hasattr(self.base_station, "stdout_history"): - self.base_station.stdout_history.clear() - - def add_custom_marker(self): - """Add a custom marker to the map""" - AddMarkerDialog(self.root, self) - - def manage_markers(self): - """Manage existing custom markers""" - ManageMarkersDialog(self.root, self) - - def on_map_click(self, event): - """Handle map click events""" - pass - - def on_map_hover(self, event): - """Handle map hover events""" - pass - - def calculate_distance(self, lat1, lon1, lat2, lon2): - """Calculate distance between two coordinates in meters""" - # Haversine formula - R = 6371000 # Earth's radius in meters - - lat1_rad = math.radians(lat1) - lat2_rad = math.radians(lat2) - delta_lat = math.radians(lat2 - lat1) - delta_lon = math.radians(lon2 - lon1) - - a = math.sin(delta_lat / 2) * math.sin(delta_lat / 2) + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin( - delta_lon / 2 - ) * math.sin(delta_lon / 2) - c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) - - return R * c - - def on_closing(self): - """Handle application closing""" - if messagebox.askokcancel("Quit", "Do you want to quit the Base Station GUI?"): - self.running = False - if self.base_station: - try: - self.base_station.EXIT = True - if hasattr(self.base_station, "__del__"): - self.base_station.__del__() - except Exception as e: - logger.error(f"Error closing base station: {e}") - self.root.destroy() - - -# Import the dialog classes from the main GUI -from base_station_gui import ( - TestDialog, - CancelTestDialog, - AddMarkerDialog, - ManageMarkersDialog, - LogViewerDialog, -) - - -def main(): - """Main function to start the real GUI""" - # Get config file from command line arguments - config_file = "config_base.yaml" - if len(sys.argv) > 1: - config_file = sys.argv[1] - if not os.path.exists(config_file): - print(f"Error: Config file '{config_file}' not found") - sys.exit(1) - - print(f"Starting Base Station GUI with config: {config_file}") - - root = tk.Tk() - app = RealBaseStationGUI(root, config_file) - root.mainloop() - - -if __name__ == "__main__": - main() diff --git a/setup/lake_test_setup.txt b/setup/lake_test_setup.txt deleted file mode 100644 index 8e9bea1..0000000 --- a/setup/lake_test_setup.txt +++ /dev/null @@ -1,46 +0,0 @@ -For Ty <3 -------------------------------------------------------------------------------------------- -Base Station Location: [40.43139, -111.49199] -??? - - -When running tu_rd.py or td_ru.py scripts, you'll need to pass the 2 physical parameters in like so: - - python3 lora_tu_rd.py s 1.5 d 1000 - - -hydrophone-separation values will be 2, 1.5, 1.0, and 0.5 - -distance values will be 1000, 500, 250, 100 - -you MUST pass these in correctly - very important, make sure to double check the config -with the bUE adjusters on the lake (Eli, Bryson) - - -------------------------------------------------------------------------------------------- - -TEST 1 (.1km): - -Eli, Perry, tu_rd.py, [40.42881, -111.49769] - -Bryson, Major, td_ru.py, [40.42782, -111.49798] - - -TEST 2 (.25km): - -Eli, Perry, tu_rd.py, [40.42955, -111.49751] - -Bryson, Major, td_ru.py, [40.42751, -111.49819] - - -TEST 3 (.5km): - -Eli, Perry, tu_rd.py, [40.43241, -111.49706] - -Bryson, Major, td_ru.py, [40.42732, -111.49823] - - -TEST 4 (1.0km): - -Eli, Perry, tu_rd.py, [40.43609, -111.49677] - -Bryson, Major, td_ru.py, [40.42686, -111.49840] diff --git a/setup/requirements.txt b/setup/requirements.txt index 1adf2fd..681bc5a 100644 --- a/setup/requirements.txt +++ b/setup/requirements.txt @@ -1,33 +1,23 @@ -certifi==2025.8.3 -charset-normalizer==3.4.3 -click==8.2.1 +# Up to date as of February 12 2026 +colorama==0.4.6 +coverage==7.13.4 crc8==0.2.1 -customtkinter==5.2.2 -darkdetect==0.8.0 decorator==5.2.1 -future==1.0.0 -geocoder==1.38.1 -geographiclib==2.0 +geographiclib==2.1 geopy==2.4.1 -idna==3.10 -keyboard==0.13.5 +iniconfig==2.3.0 loguru==0.7.3 -markdown-it-py==3.0.0 -mdurl==0.1.2 -packaging==25.0 -pillow==11.3.0 +numpy==2.4.2 +packaging==26.0 +pluggy==1.6.0 Pygments==2.19.2 -pynmeagps==1.0.50 -pyperclip==1.9.0 +pyqtgraph==0.14.0 pyserial==3.5 -PyYAML==6.0.2 -ratelim==0.1.6 -requests==2.32.5 -rich==14.1.0 -six==1.17.0 -survey==5.4.2 -tkintermapview==1.29 -urllib3==2.5.0 -qt6-applications==6.5.0.2.3 -qt6-tools==6.5.0.1.3 - +PySide6==6.10.2 +PySide6_Addons==6.10.2 +PySide6_Essentials==6.10.2 +pytest==9.0.2 +pytest-cov==7.0.0 +PyYAML==6.0.3 +qgmap==1.1.0 +shiboken6==6.10.2 \ No newline at end of file diff --git a/simplified_test_dialog.py b/simplified_test_dialog.py deleted file mode 100644 index 60afcd3..0000000 --- a/simplified_test_dialog.py +++ /dev/null @@ -1,285 +0,0 @@ -#!/usr/bin/env python3 -""" -Simplified Test Dialog that matches main_ui.py workflow -""" - -import tkinter as tk -from tkinter import ttk, messagebox -from datetime import datetime, date -from loguru import logger -from constants import bUEs - - -class TestDialog: - """Simplified test dialog that follows main_ui.py workflow""" - - def __init__(self, parent, base_station): - self.parent = parent - self.base_station = base_station - - self.dialog = tk.Toplevel(parent) - self.dialog.title("Test Management") - self.dialog.geometry("500x600") - self.dialog.grab_set() - - # Available test files - self.test_files = [ - "lora_td_ru", - "lora_tu_rd", - "helloworld", - "gpstest", - "gpstest2", - ] - - # Selected bUEs and their configurations - self.selected_bues = [] - self.bue_configs = {} # {bue_id: {'file': str, 'params': str}} - - self.setup_dialog() - - def setup_dialog(self): - """Setup the simplified test dialog like main_ui.py""" - # Step 1: bUE Selection (like basket in main_ui) - selection_frame = ttk.LabelFrame(self.dialog, text="Step 1: Select bUEs for Testing", padding="10") - selection_frame.pack(fill=tk.X, padx=10, pady=5) - - ttk.Label(selection_frame, text="Choose which bUEs will run tests:").pack(anchor=tk.W, pady=(0, 10)) - - # Create checkboxes for connected bUEs - self.bue_vars = {} - checkbox_frame = ttk.Frame(selection_frame) - checkbox_frame.pack(fill=tk.X) - - row = 0 - col = 0 - for i, bue_id in enumerate(self.base_station.connected_bues): - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - var = tk.BooleanVar() - self.bue_vars[bue_id] = var - - cb = ttk.Checkbutton( - checkbox_frame, - text=bue_name, - variable=var, - command=self.update_selection, - ) - cb.grid(row=row, column=col, sticky=tk.W, padx=20, pady=2) - - col += 1 - if col > 1: # 2 columns - col = 0 - row += 1 - - # Selection summary - self.selection_label = ttk.Label(selection_frame, text="No bUEs selected", foreground="gray") - self.selection_label.pack(anchor=tk.W, pady=(10, 0)) - - # Step 2: Start Time (like main_ui datetime prompt) - time_frame = ttk.LabelFrame(self.dialog, text="Step 2: Set Start Time", padding="10") - time_frame.pack(fill=tk.X, padx=10, pady=5) - - now = datetime.now() - time_controls = ttk.Frame(time_frame) - time_controls.pack() - - ttk.Label(time_controls, text="Hour:").grid(row=0, column=0, padx=5) - self.hour_var = tk.StringVar(value=str(now.hour)) - ttk.Spinbox(time_controls, from_=0, to=23, textvariable=self.hour_var, width=5).grid(row=0, column=1, padx=5) - - ttk.Label(time_controls, text="Minute:").grid(row=0, column=2, padx=5) - self.minute_var = tk.StringVar(value=str(now.minute)) - ttk.Spinbox(time_controls, from_=0, to=59, textvariable=self.minute_var, width=5).grid(row=0, column=3, padx=5) - - ttk.Label(time_controls, text="Second:").grid(row=0, column=4, padx=5) - self.second_var = tk.StringVar(value=str(now.second)) - ttk.Spinbox(time_controls, from_=0, to=59, textvariable=self.second_var, width=5).grid(row=0, column=5, padx=5) - - # Step 3: Configure Individual bUEs - config_frame = ttk.LabelFrame(self.dialog, text="Step 3: Configure Selected bUEs", padding="10") - config_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) - - ttk.Label( - config_frame, - text="Click 'Configure bUEs' after selecting which ones to test", - ).pack(anchor=tk.W) - - # Configuration display/status - self.config_text = tk.Text(config_frame, height=10, wrap=tk.WORD, state=tk.DISABLED) - config_scroll = ttk.Scrollbar(config_frame, command=self.config_text.yview) - self.config_text.configure(yscrollcommand=config_scroll.set) - - self.config_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - config_scroll.pack(side=tk.RIGHT, fill=tk.Y) - - # Buttons - button_frame = ttk.Frame(self.dialog) - button_frame.pack(fill=tk.X, padx=10, pady=10) - - self.config_btn = ttk.Button( - button_frame, - text="Configure bUEs", - command=self.configure_selected_bues, - state=tk.DISABLED, - ) - self.config_btn.pack(side=tk.LEFT, padx=5) - - self.run_btn = ttk.Button(button_frame, text="Run Tests", command=self.run_tests, state=tk.DISABLED) - self.run_btn.pack(side=tk.LEFT, padx=5) - - ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack(side=tk.RIGHT, padx=5) - - def update_selection(self): - """Update the selection when checkboxes change""" - self.selected_bues = [bue_id for bue_id, var in self.bue_vars.items() if var.get()] - - if self.selected_bues: - bue_names = [bUEs.get(str(bid), f"bUE {bid}") for bid in self.selected_bues] - self.selection_label.config(text=f"Selected: {', '.join(bue_names)}", foreground="blue") - self.config_btn.config(state=tk.NORMAL) - - # Clear previous configs if selection changed - self.bue_configs = {} - self.update_config_display() - else: - self.selection_label.config(text="No bUEs selected", foreground="gray") - self.config_btn.config(state=tk.DISABLED) - self.run_btn.config(state=tk.DISABLED) - self.bue_configs = {} - self.update_config_display() - - def configure_selected_bues(self): - """Configure each selected bUE individually (like main_ui loop)""" - if not self.selected_bues: - return - - # Configure each bUE one by one (like main_ui.py does) - for bue_id in self.selected_bues: - if bue_id not in self.bue_configs: - # Launch individual bUE configuration dialog - config_dialog = IndividualBueConfigDialog(self.dialog, bue_id, self.test_files, self.bue_configs) - self.dialog.wait_window(config_dialog.dialog) - - self.update_config_display() - - # Enable run button if all bUEs are configured - if len(self.bue_configs) == len(self.selected_bues): - self.run_btn.config(state=tk.NORMAL) - - def update_config_display(self): - """Update the configuration display""" - self.config_text.config(state=tk.NORMAL) - self.config_text.delete(1.0, tk.END) - - if self.bue_configs: - self.config_text.insert(tk.END, "Test Configuration Summary:\n\n") - for bue_id, config in self.bue_configs.items(): - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - self.config_text.insert(tk.END, f"{bue_name}:\n") - self.config_text.insert(tk.END, f" File: {config['file']}\n") - self.config_text.insert(tk.END, f" Parameters: {config['params']}\n\n") - else: - self.config_text.insert( - tk.END, - "No bUEs configured yet.\n\nSelect bUEs above, then click 'Configure bUEs' to set up tests.", - ) - - self.config_text.config(state=tk.DISABLED) - - def run_tests(self): - """Execute the configured tests (like main_ui send_test)""" - if not self.bue_configs: - messagebox.showwarning("No Configuration", "Please configure at least one bUE for testing") - return - - # Calculate start time (like main_ui.py) - try: - hour = int(self.hour_var.get()) - minute = int(self.minute_var.get()) - second = int(self.second_var.get()) - - today = date.today() - start_time = datetime.combine( - today, - datetime.min.time().replace(hour=hour, minute=minute, second=second), - ) - unix_timestamp = int(start_time.timestamp()) - - # Send test commands (like main_ui.py) - for bue_id, config in self.bue_configs.items(): - command = f"TEST-{config['file']}-{unix_timestamp}-{config['params']}" - self.base_station.ota.send_ota_message(bue_id, command) - logger.info(f"Sent test command to bUE {bue_id}: {command}") - - bue_names = [bUEs.get(str(bue_id), str(bue_id)) for bue_id in self.bue_configs.keys()] - messagebox.showinfo("Tests Started", f"Started tests on: {', '.join(bue_names)}") - self.dialog.destroy() - - except Exception as e: - messagebox.showerror("Error", f"Failed to start tests: {e}") - - -class IndividualBueConfigDialog: - """Individual bUE configuration dialog (like main_ui.py prompts)""" - - def __init__(self, parent, bue_id, test_files, bue_configs): - self.parent = parent - self.bue_id = bue_id - self.test_files = test_files - self.bue_configs = bue_configs - - bue_name = bUEs.get(str(bue_id), f"bUE {bue_id}") - - self.dialog = tk.Toplevel(parent) - self.dialog.title(f"Configure {bue_name}") - self.dialog.geometry("400x300") - self.dialog.wait_visibility() - self.dialog.grab_set() - - self.setup_dialog() - - def setup_dialog(self): - """Setup individual bUE configuration (like main_ui.py prompts)""" - bue_name = bUEs.get(str(self.bue_id), f"bUE {self.bue_id}") - - # Header - header_frame = ttk.Frame(self.dialog) - header_frame.pack(fill=tk.X, padx=20, pady=10) - - ttk.Label( - header_frame, - text=f"Configure test for {bue_name}", - font=("TkDefaultFont", 12, "bold"), - ).pack() - - # Test file selection (like main_ui.py select prompt) - file_frame = ttk.LabelFrame(self.dialog, text="Select Test File", padding="10") - file_frame.pack(fill=tk.X, padx=20, pady=10) - - ttk.Label(file_frame, text="What file would you like to run?").pack(anchor=tk.W, pady=(0, 5)) - - self.file_var = tk.StringVar(value=self.test_files[0]) - for i, test_file in enumerate(self.test_files): - ttk.Radiobutton(file_frame, text=test_file, variable=self.file_var, value=test_file).pack(anchor=tk.W, padx=20) - - # Parameters (like main_ui.py input prompt) - params_frame = ttk.LabelFrame(self.dialog, text="Parameters", padding="10") - params_frame.pack(fill=tk.X, padx=20, pady=10) - - ttk.Label(params_frame, text="Enter parameters separated by space:").pack(anchor=tk.W, pady=(0, 5)) - self.params_var = tk.StringVar() - ttk.Entry(params_frame, textvariable=self.params_var, width=40).pack(fill=tk.X) - - # Buttons - button_frame = ttk.Frame(self.dialog) - button_frame.pack(fill=tk.X, padx=20, pady=20) - - ttk.Button(button_frame, text="Save Configuration", command=self.save_config).pack(side=tk.LEFT, padx=5) - ttk.Button(button_frame, text="Cancel", command=self.dialog.destroy).pack(side=tk.RIGHT, padx=5) - - def save_config(self): - """Save the bUE configuration""" - self.bue_configs[self.bue_id] = { - "file": self.file_var.get(), - "params": self.params_var.get(), - } - self.dialog.destroy() diff --git a/tker.py b/tker.py deleted file mode 100644 index f1b0841..0000000 --- a/tker.py +++ /dev/null @@ -1,186 +0,0 @@ -import tkinter as tk -from tkinter import ttk -from datetime import datetime -from enum import Enum, auto - -import tkintermapview # type:ignore -import customtkinter # type:ignore - -from loguru import logger - -from constants import TIMEOUT, bUEs - - -class Command(Enum): - REFRESH = 0 - TEST = auto() - DISTANCE = auto() - DISCONNECT = auto() - CANCEL = auto() - EXIT = auto() - - -class BaseStationDashboard(tk.Tk): - def __init__(self, base_station): - super().__init__() - self.title("Base Station Dashboard") - self.base_station = base_station - - self.header_label = tk.Label(self, text="", font=("Arial", 14), bg="black", fg="white", padx=10, pady=5) - self.header_label.pack(fill="x") - - self.tables_frame = tk.Frame(self) - self.tables_frame.pack(expand=True, fill="both") - - self.tables = {} - self.create_table("Connected bUEs", ["bUE ID", "Status"]) - self.create_table("bUE PINGs", ["bUE ID", "Receiving PINGs"]) - self.create_table("bUE Coordinates", ["bUE ID", "Coordinates"]) - self.create_table("bUE Distances", ["bUE Pair", "Distance"]) - self.create_table("Received Messages", ["Messages"]) - - self.create_control_panel() - - self.update_dashboard() - - def create_table(self, title, columns): - lf = ttk.LabelFrame(self.tables_frame, text=title) - lf.pack(side="left", fill="both", expand=True, padx=5, pady=5) - - tree = ttk.Treeview(lf, columns=columns, show="headings", height=10) - for col in columns: - tree.heading(col, text=col) - tree.column(col, anchor="center") - tree.pack(expand=True, fill="both") - self.tables[title] = tree - - def create_control_panel(self): - cp_frame = tk.Frame(self) - cp_frame.pack(fill="x") - - # Add buttons for each command - self.command_buttons = {} - for command in Command: - if command == Command.REFRESH: - continue # Skip refresh button, it's handled in update_dashboard - btn = tk.Button(cp_frame, text=command.name.capitalize(), command=lambda cmd=command: self.execute_command(cmd)) - btn.pack(side="left", padx=5, pady=5) - self.command_buttons[command] = btn - - def execute_command(self, command): - logger.info(f"Executing command: {command.name}") - if command == Command.TEST: - self.run_test() - elif command == Command.DISTANCE: - self.calculate_distances() - elif command == Command.DISCONNECT: - self.disconnect_bues() - elif command == Command.CANCEL: - self.cancel_operations() - elif command == Command.EXIT: - self.quit() - self.update_dashboard() - - def run_test(self): - logger.info("Running tests on all connected bUEs") - self.base_station.send_tests() - - def calculate_distances(self): - logger.info("Calculating distances between all connected bUEs") - self.base_station.calculate_all_distances() - - def disconnect_bues(self): - logger.info("Disconnecting all bUEs") - for bue in self.base_station.connected_bues: - self.base_station.disconnect_bue(bue) - - def cancel_operations(self): - logger.info("Cancelling all ongoing operations") - self.base_station.cancel_all_operations() - - def update_dashboard(self): - now = datetime.now().strftime("%H:%M:%S") - connected_count = len(self.base_station.connected_bues) - testing_count = len(getattr(self.base_station, "testing_bues", [])) - self.header_label.config( - text=f"🏢 Base Station Dashboard - {now} | Connected: {connected_count} | Testing: {testing_count}" - ) - - self.populate_connected_table() - self.populate_ping_table() - self.populate_coordinates_table() - self.populate_distance_table() - self.populate_messages_table() - - self.after(5000, self.update_dashboard) # Refresh every 5s - - def clear_table(self, title): - for row in self.tables[title].get_children(): - self.tables[title].delete(row) - - def populate_connected_table(self): - self.clear_table("Connected bUEs") - tree = self.tables["Connected bUEs"] - if not self.base_station.connected_bues: - tree.insert("", "end", values=("No bUEs connected", "N/A")) - return - for bue in self.base_station.connected_bues: - status = "🧪 Testing" if bue in getattr(self.base_station, "testing_bues", []) else "💤 Idle" - tree.insert("", "end", values=(bUEs[str(bue)], status)) - - def populate_ping_table(self): - self.clear_table("bUE PINGs") - tree = self.tables["bUE PINGs"] - if not self.base_station.connected_bues: - tree.insert("", "end", values=("No bUEs connected", "N/A")) - return - for bue in self.base_station.connected_bues: - timeout = self.base_station.bue_timeout_tracker.get(bue, 0) - if timeout >= TIMEOUT / 2: - status = "🟢 Good" - elif timeout > 0: - status = "🟡 Warning" - else: - status = "🔴 Lost" - tree.insert("", "end", values=(bUEs[str(bue)], status)) - - def populate_coordinates_table(self): - self.clear_table("bUE Coordinates") - tree = self.tables["bUE Coordinates"] - if not self.base_station.bue_coordinates: - tree.insert("", "end", values=("No coordinates available", "N/A")) - return - for bue, coords in self.base_station.bue_coordinates.items(): - tree.insert("", "end", values=(bUEs[str(bue)], str(coords))) - - def populate_distance_table(self): - self.clear_table("bUE Distances") - tree = self.tables["bUE Distances"] - coords = self.base_station.bue_coordinates - processed = set() - if not coords: - tree.insert("", "end", values=("No distances available", "N/A")) - return - for b1 in self.base_station.connected_bues: - for b2 in self.base_station.connected_bues: - if b1 != b2 and (b1, b2) not in processed and (b2, b1) not in processed: - processed.add((b1, b2)) - try: - dist = self.base_station.get_distance(b1, b2) - if dist is not None: - value = f"{dist:.2f}m" - else: - value = "Invalid coordinates" - except Exception as e: - value = f"Error: {str(e)}" - label = f"{bUEs[str(b1)]} ↔ {bUEs[str(b2)]}" - tree.insert("", "end", values=(label, value)) - - def populate_messages_table(self): - self.clear_table("Received Messages") - tree = self.tables["Received Messages"] - if not self.base_station.stdout_history: - tree.insert("", "end", values=("No messages",)) - return - for msg in self.base_station.stdout_history: - tree.insert("", "end", values=(msg,)) From d08e840d25807166d35211027e882dd23dd1b02e Mon Sep 17 00:00:00 2001 From: Ty Young Date: Thu, 5 Mar 2026 10:05:19 -0700 Subject: [PATCH 2/6] Removed all old testing code --- .flake8 | 13 - Makefile | 36 -- TEST_ASSESSMENT_SUMMARY.md | 114 ----- pyproject.toml | 46 -- run_tests.py | 61 --- setup/message_dict.txt | 2 +- setup/requirements_test.txt | 3 - test_base_station.py | 104 ----- tests/README.md | 177 ------- tests/__init__.py | 1 - tests/conftest.py | 98 ---- tests/mock_serial.py | 76 --- tests/test_essential_integration.py | 623 ------------------------- tests/test_integration.py | 285 ----------- tests/test_ota.py | 391 ---------------- tests/test_system_integration_fixed.py | 435 ----------------- 16 files changed, 1 insertion(+), 2464 deletions(-) delete mode 100644 .flake8 delete mode 100644 Makefile delete mode 100644 TEST_ASSESSMENT_SUMMARY.md delete mode 100644 pyproject.toml delete mode 100644 run_tests.py delete mode 100644 setup/requirements_test.txt delete mode 100644 test_base_station.py delete mode 100644 tests/README.md delete mode 100644 tests/__init__.py delete mode 100644 tests/conftest.py delete mode 100644 tests/mock_serial.py delete mode 100644 tests/test_essential_integration.py delete mode 100644 tests/test_integration.py delete mode 100644 tests/test_ota.py delete mode 100644 tests/test_system_integration_fixed.py diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 4d8d5fa..0000000 --- a/.flake8 +++ /dev/null @@ -1,13 +0,0 @@ -[flake8] -max-line-length = 127 -extend-ignore = E203, W503, E501 -exclude = - .git, - __pycache__, - .venv, - venv, - htmlcov, - Old, - setup -per-file-ignores = - tests/*:F401,F811 diff --git a/Makefile b/Makefile deleted file mode 100644 index 8bbf0e4..0000000 --- a/Makefile +++ /dev/null @@ -1,36 +0,0 @@ -# Makefile for bUE-lake_tests project - -.PHONY: install test test-verbose coverage clean help - -# Default target -help: - @echo "Available targets:" - @echo " install - Install test dependencies" - @echo " test - Run all tests" - @echo " test-verbose - Run tests with verbose output" - @echo " coverage - Run tests with coverage report" - @echo " clean - Clean up generated files" - -# Install test dependencies -install: - pip install -r setup/requirements_test.txt - -# Run tests -test: - python -m pytest tests/ -v - -# Run tests with verbose output -test-verbose: - python -m pytest tests/ -v -s - -# Run tests with coverage -coverage: - python -m pytest tests/ -v --cov=ota --cov-report=html --cov-report=term-missing - -# Clean up generated files -clean: - rm -rf htmlcov/ - rm -rf .coverage - rm -rf .pytest_cache/ - find . -type d -name __pycache__ -exec rm -rf {} + - find . -type f -name "*.pyc" -delete diff --git a/TEST_ASSESSMENT_SUMMARY.md b/TEST_ASSESSMENT_SUMMARY.md deleted file mode 100644 index a264586..0000000 --- a/TEST_ASSESSMENT_SUMMARY.md +++ /dev/null @@ -1,114 +0,0 @@ -# Comprehensive Test Suite Assessment - -## Summary - -I've created a comprehensive test suite that addresses the critical gaps identified in your colleague's original tests. Here's what the new tests provide: - -## ✅ **Tests Successfully Implemented** - -### 1. **Advanced OTA Communication Testing** -- **Concurrent Message Handling**: Tests system behavior under high load with multiple threads -- **Message Ordering**: Ensures messages are processed in correct sequence under stress -- **Simultaneous Send/Receive**: Validates bidirectional communication works correctly - -### 2. **Protocol Edge Cases** -- **Message Boundary Conditions**: Tests empty messages, large messages, special characters -- **Malformed Message Handling**: Ensures system gracefully handles invalid input -- **Rapid Connection Cycles**: Tests connection establishment/teardown under stress - -### 3. **Configuration Validation** -- **YAML Parsing**: Tests configuration file loading and validation -- **Parameter Ranges**: Validates configuration parameters are within acceptable ranges -- **Error Handling**: Tests behavior with missing or invalid configurations - -### 4. **Multi-Device Scenarios** -- **Message Isolation**: Ensures messages between devices don't interfere -- **Broadcast Handling**: Tests broadcast message functionality -- **Device Independence**: Verifies multiple devices can operate simultaneously - -### 5. **Error Recovery & Resilience** -- **Connection Loss Simulation**: Tests behavior when serial connection fails -- **Thread Safety Under Stress**: Validates thread safety under concurrent operations -- **Resource Management**: Ensures proper cleanup and no memory leaks - -## 🎯 **Critical Issues These Tests Will Catch** - -### **Before Lake Deployment:** -1. **Message Loss Under Load**: Would catch if high message volumes cause drops -2. **Deadlocks**: Would identify threading issues that could freeze the system -3. **Memory Leaks**: Would detect if long-running operations consume excessive memory -4. **Protocol Violations**: Would catch malformed message handling issues -5. **Configuration Errors**: Would identify invalid settings before deployment - -### **During Lake Operations:** -1. **Multi-bUE Interference**: Would catch if multiple devices interfere with each other -2. **Connection Recovery**: Would identify if reconnection logic doesn't work properly -3. **Error Cascades**: Would catch if one failure causes system-wide issues -4. **Resource Exhaustion**: Would identify performance bottlenecks - -## 📊 **Test Results Analysis** - -From the test run, we can see: - -✅ **9 out of 12 tests PASSED** - This indicates the core OTA communication is robust - -❌ **3 tests FAILED** - These reveal areas that need attention: - -1. **Concurrent Message Test**: Found potential race condition in message processing -2. **Malformed Message Test**: Revealed edge case in message filtering -3. **Thread Safety Test**: Identified potential threading issue under extreme load - -## 🚨 **Critical Recommendations for Lake Deployment** - -### **High Priority - Fix Before Lake:** -1. **Fix Threading Issues**: The concurrent message failures suggest potential race conditions -2. **Strengthen Input Validation**: Malformed message handling needs improvement -3. **Load Testing**: The system needs testing under realistic lake conditions - -### **Medium Priority:** -1. **Add State Machine Tests**: Still need tests for the bUE/base station state machines -2. **GPS Integration Testing**: Need tests for GPS coordinate handling -3. **Real Hardware Testing**: Mock tests can't catch hardware-specific issues - -### **For Lake Operations:** -1. **Monitoring**: Add real-time monitoring for the issues these tests identify -2. **Fallback Procedures**: Plan for the failure modes these tests revealed -3. **Performance Baselines**: Use test results to set performance expectations - -## 💡 **Immediate Next Steps** - -1. **Run the full existing test suite** to ensure no regressions: - ```bash - cd /home/ty22117/projects/lake_tests/tests - source uw_env/bin/activate - python -m pytest tests/ -v - ``` - -2. **Fix the failing new tests** by addressing the specific issues found - -3. **Add state machine tests** using the framework I provided in `test_system_integration.py` - -4. **Test with real hardware** to validate mock assumptions - -## 🎖️ **Overall Assessment** - -**Your colleague's original tests: B- (Good foundation)** -- Excellent protocol coverage -- Well-structured architecture -- Missing critical system integration - -**Combined test suite: B+ (Strong foundation for deployment)** -- Comprehensive protocol testing -- Advanced error scenarios -- Multi-device validation -- Performance characteristics -- Still missing full state machine coverage - -## 🌊 **Lake Deployment Confidence** - -**Before new tests**: 60% confident - Protocol works, but system integration unknown -**After new tests**: 80% confident - Communication layer is robust, with identified areas to monitor - -The tests I've created will significantly reduce the risk of deployment failures by catching the most common causes of system failures in distributed communication systems. - -**Recommendation**: Fix the 3 failing tests, add basic state machine tests, then proceed with lake deployment while monitoring the specific failure modes identified. diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index dad7d24..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,46 +0,0 @@ -[tool.pytest.ini_options] -testpaths = ["tests"] -python_files = ["test_*.py", "*_test.py"] -python_classes = ["Test*"] -python_functions = ["test_*"] -addopts = [ - "--verbose", - "--tb=short" -] -filterwarnings = [ - "ignore::DeprecationWarning" -] - -[tool.black] -line-length = 127 -target-version = ['py39', 'py311', 'py312'] -include = '\.pyi?$' -extend-exclude = ''' -/( - # directories - \.eggs - | \.git - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | venv - | _build - | buck-out - | build - | dist - | htmlcov - | Old - | grc -)/ -''' - -[tool.isort] -profile = "black" -line_length = 127 -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -use_parentheses = true -ensure_newline_before_comments = true -skip_glob = ["Old/*", "htmlcov/*", "setup/*"] diff --git a/run_tests.py b/run_tests.py deleted file mode 100644 index 083d016..0000000 --- a/run_tests.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -""" -Test runner for bUE-lake_tests project. -Run all tests with coverage reporting. -""" - -import subprocess -import sys -import os - - -def run_tests(): - """Run the test suite""" - - # Change to the project root directory - project_root = os.path.dirname(os.path.abspath(__file__)) - os.chdir(project_root) - - # Install test requirements if pytest is not available - try: - import pytest - except ImportError: - print("Installing test requirements...") - subprocess.run( - [ - sys.executable, - "-m", - "pip", - "install", - "-r", - "setup/requirements_test.txt", - ], - check=True, - ) - - # Run pytest with coverage - cmd = [ - sys.executable, - "-m", - "pytest", - "tests/", - "-v", - "--tb=short", - "--cov=ota", - "--cov-report=html:htmlcov", - "--cov-report=term-missing", - ] - - print("Running tests...") - result = subprocess.run(cmd) - - if result.returncode == 0: - print("\n✅ All tests passed!") - print("📊 Coverage report generated in htmlcov/index.html") - else: - print("\n❌ Some tests failed!") - sys.exit(1) - - -if __name__ == "__main__": - run_tests() diff --git a/setup/message_dict.txt b/setup/message_dict.txt index 28d5318..5883611 100644 --- a/setup/message_dict.txt +++ b/setup/message_dict.txt @@ -50,7 +50,7 @@ PINGR: TEST: Direction: base -> bUE - Meaning: The base station sends a UTW test configuration, a file, a start time, and parameters. + Meaning: The base station sends a UTW test configuration, a file, a start time, and parameters. The parameters should be separated with spaces Body: ,, Example: (bue_main.py) self.ota.send_ota_message(5, TEST:,,) Response: The bUE will respond with a TESTR, confirming that it has received the test diff --git a/setup/requirements_test.txt b/setup/requirements_test.txt deleted file mode 100644 index 620031a..0000000 --- a/setup/requirements_test.txt +++ /dev/null @@ -1,3 +0,0 @@ -pytest==7.4.4 -pytest-mock==3.12.0 -pytest-cov==4.1.0 diff --git a/test_base_station.py b/test_base_station.py deleted file mode 100644 index f179368..0000000 --- a/test_base_station.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -""" -test_base_station.py -Ty Young - -A simple test script to verify the base station is working correctly without GUI. -This matches the working main_ui.py structure to help debug connection issues. -""" - -import sys -import time -import threading -from datetime import datetime -from loguru import logger - -from base_station_main import Base_Station_Main - - -def test_base_station(config_file="config_base.yaml"): - """Test the base station functionality""" - print(f"Testing Base Station with config: {config_file}") - print("=" * 50) - - try: - # Initialize base station (same as working main_ui.py) - base_station = Base_Station_Main(config_file) - - # CRITICAL: Enable tick system (same as main_ui.py) - base_station.tick_enabled = True - - print(f"✅ Base station initialized successfully") - print(f"✅ Tick system enabled: {base_station.tick_enabled}") - print(f"✅ OTA ID: {base_station.ota.id}") - print(f"✅ Connected bUEs: {base_station.connected_bues}") - print("") - print("🔍 Listening for bUE connections...") - print(" (Press Ctrl+C to stop)") - print("") - - # Monitor for connections - start_time = time.time() - last_status_time = 0 - - while True: - current_time = time.time() - - # Print status every 5 seconds - if current_time - last_status_time >= 5: - elapsed = int(current_time - start_time) - connected_count = len(base_station.connected_bues) - testing_count = len(getattr(base_station, "testing_bues", [])) - - print( - f"[{elapsed:03d}s] Connected: {connected_count} | Testing: {testing_count} | " - f"Listening: {base_station.tick_enabled}" - ) - - if connected_count > 0: - print(f" 📡 Connected bUEs: {base_station.connected_bues}") - if hasattr(base_station, "bue_coordinates") and base_station.bue_coordinates: - print(f" 📍 Coordinates available for: {list(base_station.bue_coordinates.keys())}") - - if testing_count > 0: - print(f" 🧪 Testing bUEs: {base_station.testing_bues}") - - # Show recent messages - if hasattr(base_station, "stdout_history") and base_station.stdout_history: - recent_msgs = list(base_station.stdout_history)[-2:] # Last 2 messages - for msg in recent_msgs: - print(f" 💬 Recent: {msg}") - - print("") - last_status_time = current_time - - time.sleep(0.5) - - except KeyboardInterrupt: - print("\n🛑 Stopping base station test...") - if "base_station" in locals(): - base_station.EXIT = True - time.sleep(0.5) - base_station.__del__() - print("✅ Test completed") - - except Exception as e: - print(f"❌ Error: {e}") - logger.error(f"Base station test failed: {e}") - return False - - return True - - -def main(): - """Main function""" - config_file = "config_base.yaml" - if len(sys.argv) > 1: - config_file = sys.argv[1] - - if not test_base_station(config_file): - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index 0b11425..0000000 --- a/tests/README.md +++ /dev/null @@ -1,177 +0,0 @@ -# Testing Framework for bUE-lake_tests - -This directory contains a comprehensive testing framework for the bUE (Buoyant Underwater Equipment) over-the-air communication system. - -## Overview - -The testing framework provides: -- **Mock Serial Implementation**: Stubbed serial communication for testing without hardware -- **Protocol Testing**: Tests for all message types defined in `message_dict.txt` -- **Integration Testing**: End-to-end protocol flow testing -- **Concurrent Testing**: Multi-threaded communication scenarios - -## Test Structure - -``` -tests/ -├── __init__.py # Package initialization -├── conftest.py # Test fixtures and utilities -├── mock_serial.py # Mock serial port implementation -├── test_ota.py # Unit tests for OTA functionality -└── test_integration.py # Integration tests for protocol flows -``` - -## Message Protocol Testing - -The tests cover all message types from the protocol specification: - -### Connection Protocol -- **REQ**: bUE requests to join network (broadcast) -- **CON**: Base station confirms connection with ID -- **ACK**: bUE acknowledges connection - -### Keep-Alive Protocol -- **PING**: bUE periodic ping to base station -- **PINGR**: Base station ping response - -### Test Coordination Protocol -- **TEST**: Base station sends test configuration -- **PREPR**: bUE confirms test preparation -- **BEGIN**: bUE notifies test start -- **UPD**: bUE sends test progress updates -- **DONE**: bUE notifies test completion -- **FAIL**: bUE reports test failure -- **CANC**: Base station cancels ongoing test - -## Installation - -Install test dependencies: - -```bash -# Using pip -pip install -r setup/requirements_test.txt - -# Using make -make install -``` - -## Running Tests - -### Basic Test Run -```bash -# Using pytest directly -python -m pytest tests/ -v - -# Using make -make test -``` - -### Verbose Output -```bash -# Using pytest -python -m pytest tests/ -v -s - -# Using make -make test-verbose -``` - -### Coverage Report -```bash -# Using pytest -python -m pytest tests/ -v --cov=ota --cov-report=html --cov-report=term-missing - -# Using make -make coverage -``` - -### Using the Test Runner -```bash -python run_tests.py -``` - -## Test Features - -### Mock Serial Port -The `MockSerial` class provides: -- Simulated read/write operations -- Message buffering -- Error simulation -- Thread-safe operations - -### Message Helpers -The `MessageHelper` class provides utilities for: -- Creating properly formatted +RCV messages -- Generating AT+SEND commands -- Parsing message types and bodies - -### Integration Tests -Full protocol flow testing including: -- Complete connection establishment -- Ping-pong keep-alive sequences -- Test configuration and execution -- Error handling and cancellation - -### Concurrent Testing -Tests for multi-threaded scenarios: -- Simultaneous message sending -- Concurrent reading and writing -- Thread safety verification - -## Test Coverage - -The framework tests: -- ✅ Message sending and receiving -- ✅ Protocol message formats -- ✅ Connection establishment -- ✅ Keep-alive mechanisms -- ✅ Test coordination -- ✅ Error handling -- ✅ Thread safety -- ✅ Serial port simulation -- ✅ Message filtering -- ✅ Device cleanup - -## Example Usage - -```python -# Create a mock OTA device for testing -from tests.conftest import ota_device, MessageHelper, MessageTypes, DeviceIds - -# In a test function -def test_my_protocol(ota_device): - device, mock_serial = ota_device - - # Send a message - device.send_ota_message(DeviceIds.BASE_STATION, MessageTypes.PING) - - # Simulate receiving a response - response = MessageHelper.create_rcv_message( - DeviceIds.BASE_STATION, MessageTypes.PINGR - ) - mock_serial.add_incoming_message(response) - - # Verify the exchange - messages = device.get_new_messages() - assert MessageTypes.PINGR in messages[0] -``` - -## Cleanup - -Remove generated test files: - -```bash -# Using make -make clean - -# Manual cleanup -rm -rf htmlcov/ .coverage .pytest_cache/ -``` - -## Dependencies - -- `pytest`: Test framework -- `pytest-mock`: Mocking utilities -- `pytest-cov`: Coverage reporting -- `unittest-mock`: Mock objects (fallback) - -The mock serial implementation eliminates the need for physical hardware during testing, allowing for comprehensive protocol validation in a controlled environment. diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 6719e32..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Tests package for bUE-lake_tests diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 3a27f4e..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Test fixtures and utilities for OTA testing. -""" - -import pytest -import sys -import os -from unittest.mock import patch, MagicMock -import time -import crc8 - -# Add the parent directory to the path so we can import ota -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from tests.mock_serial import MockSerial, MockSerialException -from ota import Ota - - -@pytest.fixture -def mock_serial(): - """Create a mock serial instance""" - return MockSerial("/dev/ttyUSB0", 9600) - - -@pytest.fixture -def ota_device(mock_serial): - """Create an OTA device with mocked serial""" - with patch("serial.Serial", return_value=mock_serial): - device = Ota("/dev/ttyUSB0", 9600, 5) - # Give the thread a moment to start - time.sleep(0.1) - yield device, mock_serial - # Clean up - device.__del__() - - -class MessageHelper: - """Helper class for creating and parsing OTA messages""" - - @staticmethod - def calculate_crc8(message: str) -> str: - """Calculate CRC8 checksum for a message (same as OTA class)""" - crc8_calculator = crc8.crc8() - crc8_calculator.update(message.encode("utf-8")) - return format(crc8_calculator.digest()[0], "02x") - - @staticmethod - def create_rcv_message(sender_id: int, message: str, rssi: int = -80, snr: int = 10, include_crc: bool = True) -> str: - """Create a +RCV message as received from the LoRa module""" - if include_crc: - # Create the message format used for CRC: "{len(message)},{message}" - crc = MessageHelper.calculate_crc8(message) - message_with_crc = f"{message}{crc}" - return f"+RCV={sender_id},{len(message_with_crc)},{message_with_crc},{rssi},{snr}" - else: - return f"+RCV={sender_id},{len(message)},{message},{rssi},{snr}" - - @staticmethod - def create_at_command(dest_id: int, message: str, include_crc: bool = True) -> str: - """Create an AT+SEND command as sent to the LoRa module""" - if include_crc: - # Create the message format used for CRC: "{len(message)},{message}" - crc = MessageHelper.calculate_crc8(message) - message_with_crc = f"{message}{crc}" - return f"AT+SEND={dest_id},{len(message_with_crc)},{message_with_crc}\r\n" - else: - msg_for_send = f"{len(message)},{message}" - return f"AT+SEND={dest_id},{msg_for_send}\r\n" - - @staticmethod - def parse_message_type(message: str) -> tuple: - """Parse a message to extract type and body""" - if ":" in message: - msg_type, body = message.split(":", 1) - return msg_type, body - return message, None - - -# Message constants based on message_dict.txt -class MessageTypes: - REQ = "REQ" # bUE -> base: Request to join network - CON = "CON" # base -> bUE: Confirm join with base station id - ACK = "ACK" # bUE -> base: Acknowledge connection - PING = "PING" # bUE -> base: Periodic ping - PINGR = "PINGR" # base -> bUE: Ping response - TEST = "TEST" # base -> bUE: Test configuration - FAIL = "FAIL" # bUE -> base: Test failure - CANC = "CANC" # base -> bUE: Cancel test - PREPR = "PREPR" # bUE -> base: Test preparation response - UPD = "UPD" # bUE -> base: Test update - DONE = "DONE" # bUE -> base: Test completion - - -# Device ID constants -class DeviceIds: - BROADCAST = 0 - BASE_STATION = 1 - BUE_DEVICE = 10 diff --git a/tests/mock_serial.py b/tests/mock_serial.py deleted file mode 100644 index cce3e86..0000000 --- a/tests/mock_serial.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -Mock serial implementation for testing OTA functionality. -""" - -import time -from typing import List, Optional -from unittest.mock import Mock - - -class MockSerial: - """Mock implementation of pyserial.Serial for testing""" - - def __init__(self, port: str, baudrate: int, timeout: float = 0.1): - self.port = port - self.baudrate = baudrate - self.timeout = timeout - self.is_open = True - - # Buffer to store incoming messages - self._incoming_buffer: List[str] = [] - self._sent_messages: List[str] = [] - - # Simulate serial read position - self._read_position = 0 - - @property - def in_waiting(self) -> int: - """Return number of bytes waiting to be read""" - return len(self._get_current_message()) - - def write(self, data: bytes) -> int: - """Write data to the mock serial port""" - message = data.decode("utf-8") - self._sent_messages.append(message) - return len(data) - - def readline(self) -> bytes: - """Read a line from the mock serial port""" - message = self._get_current_message() - if message: - self._read_position += 1 - return f"{message}\r\n".encode("utf-8") - return b"" - - def close(self): - """Close the mock serial port""" - self.is_open = False - - def _get_current_message(self) -> str: - """Get the current message to be read""" - if self._read_position < len(self._incoming_buffer): - return self._incoming_buffer[self._read_position] - return "" - - def add_incoming_message(self, message: str): - """Add a message to the incoming buffer (for testing)""" - self._incoming_buffer.append(message) - - def get_sent_messages(self) -> List[str]: - """Get all messages that were sent (for testing)""" - return self._sent_messages.copy() - - def clear_sent_messages(self): - """Clear the sent messages buffer (for testing)""" - self._sent_messages.clear() - - def clear_incoming_messages(self): - """Clear the incoming messages buffer (for testing)""" - self._incoming_buffer.clear() - self._read_position = 0 - - -class MockSerialException(Exception): - """Mock serial exception for testing error conditions""" - - pass diff --git a/tests/test_essential_integration.py b/tests/test_essential_integration.py deleted file mode 100644 index 7b1546c..0000000 --- a/tests/test_essential_integration.py +++ /dev/null @@ -1,623 +0,0 @@ -""" -Essential system integration tests for bUE-base station communication. - -This test suite focuses on testable components and addresses critical gaps: -1. OTA communication reliability under stress -2. Message protocol edge cases -3. Configuration validation -4. Multi-device message handling -5. Error recovery scenarios - -These tests can run without GPS dependencies and focus on the core communication logic. -""" - -import pytest -import time -import threading -import queue -import tempfile -import os -import yaml -from unittest.mock import patch, MagicMock -import sys - -# Add the parent directory to the path so we can import modules -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from tests.conftest import MessageHelper, MessageTypes, DeviceIds -from tests.mock_serial import MockSerial -from ota import Ota - - -class TestAdvancedOTACommunication: - """Test advanced OTA communication scenarios critical for lake deployment""" - - def test_realistic_bue_message_queue_handling(self): - """Test realistic bUE message handling through task queues like the actual system""" - mock_serial = MockSerial("/dev/ttyUSB0", 9600) - - with patch("serial.Serial", return_value=mock_serial): - ota_device = Ota("/dev/ttyUSB0", 9600, 10) - time.sleep(0.1) # Let thread start - - # Simulate the bUE task queue system - message_queue = queue.Queue() - messages_sent = [] - - def queue_processor(): - """Simulate the ota_task_queue_handler from bUE_Main""" - while True: - try: - task = message_queue.get(timeout=0.5) - if task is None: # Shutdown signal - break - # Execute the queued message task - target_id, message = task - ota_device.send_ota_message(target_id, message) - messages_sent.append(message) - message_queue.task_done() - except queue.Empty: - break - - # Start the queue processor (like bUE's ota_thread) - processor_thread = threading.Thread(target=queue_processor) - processor_thread.start() - - # Simulate realistic bUE state machine behavior: - # 1. CONNECT_OTA state - sending REQ messages - for i in range(5): - message_queue.put((1, f"REQ_{i}")) # Send to base station (ID 1) - time.sleep(0.1) # Simulate CONNECT_OTA_REQ_INTERVAL - - # 2. IDLE state - sending PING messages with GPS coordinates - for i in range(10): - lat, lon = 40.2518 + i * 0.001, -111.6493 + i * 0.001 - message_queue.put((1, f"PING,{lat},{lon}")) - time.sleep(0.1) # Simulate IDLE_PING_OTA_INTERVAL - - # 3. UTW_TEST state - sending UPD messages - for i in range(8): - lat, lon = 40.2518 + i * 0.001, -111.6493 + i * 0.001 - message_queue.put((1, f"UPD:,{lat},{lon},Test progress {i*12.5}%")) - time.sleep(0.1) # Simulate UTW_UPD_OTA_INTERVAL - - # Signal shutdown and wait for completion - message_queue.put(None) - processor_thread.join() - - # Verify sequential message processing (like real bUE) - sent_messages = mock_serial.get_sent_messages() - assert len(sent_messages) == 23, f"Expected 23 messages, got {len(sent_messages)}" - - # Verify message order reflects state machine sequence - assert any("REQ_0" in msg for msg in sent_messages[:5]), "First REQ message not found" - assert any("PING" in msg for msg in sent_messages[5:15]), "PING messages not in expected range" - assert any("UPD:" in msg for msg in sent_messages[15:]), "UPD messages not in expected range" - - # Verify no message duplication (critical for reliable communication) - for expected_msg in messages_sent: - matching_messages = [msg for msg in sent_messages if expected_msg in msg] - assert len(matching_messages) == 1, f"Message {expected_msg} not found or duplicated" - - ota_device.__del__() - - def test_message_ordering_under_load(self): - """Test that message ordering is preserved under load""" - mock_serial = MockSerial("/dev/ttyUSB0", 9600) - - with patch("serial.Serial", return_value=mock_serial): - ota_device = Ota("/dev/ttyUSB0", 9600, 10) - time.sleep(0.1) - - # Send a sequence of numbered messages rapidly - message_count = 50 - for i in range(message_count): - ota_device.send_ota_message(1, f"SEQ_{i:03d}") - - time.sleep(0.5) # Let all messages process - - sent_messages = mock_serial.get_sent_messages() - assert len(sent_messages) == message_count - - # Verify messages are in order - for i, message in enumerate(sent_messages): - expected_seq = f"SEQ_{i:03d}" - assert expected_seq in message, f"Message {i} out of order: {message}" - - ota_device.__del__() - - def test_simultaneous_send_receive_realistic(self): - """Test realistic scenario: state machine sending while receiving base station messages""" - mock_serial = MockSerial("/dev/ttyUSB0", 9600) - - with patch("serial.Serial", return_value=mock_serial): - ota_device = Ota("/dev/ttyUSB0", 9600, 10) - time.sleep(0.1) - - # Simulate realistic bUE operation - state_machine_queue = queue.Queue() - - def simulate_state_machine(): - """Simulate bUE state machine sending periodic messages""" - # IDLE state behavior - send PING every few seconds - for i in range(15): - lat, lon = 40.2518 + i * 0.001, -111.6493 + i * 0.001 - state_machine_queue.put((1, f"PING,{lat},{lon}")) - time.sleep(0.02) # Faster for testing - - # UTW_TEST state behavior - send UPD messages - for i in range(10): - lat, lon = 40.2518 + i * 0.001, -111.6493 + i * 0.001 - state_machine_queue.put((1, f"UPD:,{lat},{lon},Progress {i*10}%")) - time.sleep(0.02) - - def simulate_base_station_messages(): - """Simulate receiving messages from base station during operation""" - # Base station responses and commands - for i in range(20): - if i < 10: - # PINGR responses to our PINGs - msg = MessageHelper.create_rcv_message(1, "PINGR") - elif i < 15: - # CON messages (connection confirmations) - msg = MessageHelper.create_rcv_message(1, "CON:1") - else: - # TEST commands - future_time = int(time.time()) + 10 - msg = MessageHelper.create_rcv_message(1, f"TEST-script{i}-{future_time}-param") - - mock_serial.add_incoming_message(msg) - time.sleep(0.025) # Slightly different timing to create realistic overlap - - def queue_processor(): - """Process state machine messages sequentially""" - while True: - try: - task = state_machine_queue.get(timeout=1.0) - if task is None: - break - target_id, message = task - ota_device.send_ota_message(target_id, message) - state_machine_queue.task_done() - except queue.Empty: - break - - # Start all threads simultaneously (like real bUE operation) - state_thread = threading.Thread(target=simulate_state_machine) - base_station_thread = threading.Thread(target=simulate_base_station_messages) - processor_thread = threading.Thread(target=queue_processor) - - state_thread.start() - base_station_thread.start() - processor_thread.start() - - # Wait for message generation to complete - state_thread.join() - base_station_thread.join() - - # Signal queue processor to finish and wait - state_machine_queue.put(None) - processor_thread.join() - - time.sleep(0.2) # Let final processing complete - - # Verify both sending and receiving worked correctly - sent_messages = mock_serial.get_sent_messages() - received_messages = ota_device.get_new_messages() - - # Should have sent 25 messages (15 PING + 10 UPD) - assert len(sent_messages) == 25, f"Expected 25 sent messages, got {len(sent_messages)}" - - # Should have received 20 messages from base station - assert len(received_messages) == 20, f"Expected 20 received messages, got {len(received_messages)}" - - # Verify realistic message types are present - sent_text = " ".join(sent_messages) - received_text = " ".join(received_messages) - - assert "PING," in sent_text, "PING messages not found in sent messages" - assert "UPD:" in sent_text, "UPD messages not found in sent messages" - assert "PINGR" in received_text, "PINGR responses not found in received messages" - assert "CON:" in received_text, "CON messages not found in received messages" - assert "TEST-" in received_text, "TEST commands not found in received messages" - - # Verify no cross-contamination between send and receive - for msg in sent_messages: - assert "+RCV=" not in msg, "Received message format found in sent messages" - - for msg in received_messages: - assert "AT+SEND=" not in msg, "Send command format found in received messages" - - ota_device.__del__() - - -class TestProtocolEdgeCases: - """Test edge cases in the communication protocol""" - - def test_message_boundary_conditions(self): - """Test messages at size boundaries""" - mock_serial = MockSerial("/dev/ttyUSB0", 9600) - - with patch("serial.Serial", return_value=mock_serial): - ota_device = Ota("/dev/ttyUSB0", 9600, 10) - time.sleep(0.1) - - # Test various message sizes - test_cases = [ - "", # Empty message - "A", # Single character - "A" * 50, # Medium message - "A" * 200, # Large message - "SPECIAL:CHARS!@#$%^&*()", # Special characters - "MESSAGE:WITH:COLONS:AND:STRUCTURE", # Structured message - ] - - for i, test_message in enumerate(test_cases): - ota_device.send_ota_message(1, test_message) - - # Verify message was formatted correctly - sent_messages = mock_serial.get_sent_messages() - latest_message = sent_messages[i] - - expected_format = MessageHelper.create_at_command(1, test_message, True) - assert latest_message == expected_format, f"Message format incorrect for: {test_message}" - - ota_device.__del__() - - def test_malformed_incoming_message_handling(self): - """Test handling of various malformed incoming messages""" - mock_serial = MockSerial("/dev/ttyUSB0", 9600) - - with patch("serial.Serial", return_value=mock_serial): - ota_device = Ota("/dev/ttyUSB0", 9600, 10) - time.sleep(0.1) - - # Add various malformed messages - malformed_messages = [ - "+RCV=", # Incomplete header - "+RCV=1", # Missing fields - "+RCV=1,5", # Missing message content - "+RCV=abc,5,HELLO,-50,10", # Invalid sender ID - "+RCV=1,999,SHORT,-50,10", # Length mismatch (too long) - "+RCV=1,2,TOOLONG,-50,10", # Length mismatch (too short) - "+RCV=1,5,HELLO,-50", # Missing SNR - "+RCV=1,5,HELLO,-50,abc", # Invalid SNR - "COMPLETELY_INVALID_FORMAT", # Not a +RCV message - "", # Empty message - "+RCV=1,5,HELLO,-50,10,EXTRA", # Extra fields - ] - - for bad_message in malformed_messages: - mock_serial.add_incoming_message(bad_message) - - time.sleep(0.5) # Let messages process - - # System should filter out malformed messages - received_messages = ota_device.get_new_messages() - - # Only properly formatted messages should be received - # (in this case, none of the test messages are properly formatted) - valid_messages = [msg for msg in received_messages if msg.startswith("+RCV=") and len(msg.split(",")) >= 5] - - # The system should handle malformed messages gracefully without crashing - # and should not pass them through to the application - - for msg in received_messages: - print(msg) - # Should not contain obviously malformed messages - # assert not msg.startswith("COMPLETELY_INVALID_FORMAT") - assert msg != "" # Empty messages should be filtered - - ota_device.__del__() - - def test_rapid_connection_disconnection(self): - """Test rapid connection/disconnection scenarios""" - mock_serial = MockSerial("/dev/ttyUSB0", 9600) - - with patch("serial.Serial", return_value=mock_serial): - ota_device = Ota("/dev/ttyUSB0", 9600, 10) - time.sleep(0.1) - - # Simulate rapid connection establishment and teardown - for cycle in range(10): - # Connection sequence - req_msg = MessageHelper.create_rcv_message(10, MessageTypes.REQ) - con_msg = MessageHelper.create_rcv_message(1, f"{MessageTypes.CON}:1") - ack_msg = MessageHelper.create_rcv_message(10, MessageTypes.ACK) - - mock_serial.add_incoming_message(req_msg) - mock_serial.add_incoming_message(con_msg) - mock_serial.add_incoming_message(ack_msg) - - # Ping sequence - ping_msg = MessageHelper.create_rcv_message(10, MessageTypes.PING) - pingr_msg = MessageHelper.create_rcv_message(1, MessageTypes.PINGR) - - mock_serial.add_incoming_message(ping_msg) - mock_serial.add_incoming_message(pingr_msg) - - time.sleep(0.01) # Very short processing time - - time.sleep(0.5) # Final processing time - - # Should have processed all messages without errors - received_messages = ota_device.get_new_messages() - assert len(received_messages) == 50 # 10 cycles * 5 messages each - - # Verify message types are present - message_text = " ".join(received_messages) - assert MessageTypes.REQ in message_text - assert MessageTypes.CON in message_text - assert MessageTypes.ACK in message_text - assert MessageTypes.PING in message_text - assert MessageTypes.PINGR in message_text - - ota_device.__del__() - - -class TestConfigurationScenarios: - """Test configuration-related scenarios""" - - def test_yaml_configuration_validation(self): - """Test YAML configuration parsing and validation""" - # Test valid configuration - valid_config = {"OTA_PORT": "/dev/ttyUSB0", "OTA_BAUDRATE": 9600, "OTA_ID": 10} - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - yaml.dump(valid_config, f) - valid_config_file = f.name - - try: - # Should load without errors - with open(valid_config_file) as file: - loaded_config = yaml.load(file, Loader=yaml.Loader) - assert loaded_config == valid_config - finally: - os.unlink(valid_config_file) - - # Test invalid YAML syntax - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - f.write("invalid: yaml: [unclosed") - invalid_config_file = f.name - - try: - with pytest.raises(yaml.YAMLError): - with open(invalid_config_file) as file: - yaml.load(file, Loader=yaml.Loader) - finally: - os.unlink(invalid_config_file) - - def test_configuration_parameter_ranges(self): - """Test configuration parameter validation""" - # Test various parameter combinations - test_configs = [ - {"OTA_PORT": "/dev/ttyUSB0", "OTA_BAUDRATE": 9600, "OTA_ID": 1}, # Min ID - {"OTA_PORT": "/dev/ttyUSB0", "OTA_BAUDRATE": 9600, "OTA_ID": 255}, # Max typical ID - {"OTA_PORT": "/dev/ttyUSB1", "OTA_BAUDRATE": 115200, "OTA_ID": 50}, # High baudrate - {"OTA_PORT": "/dev/ttyACM0", "OTA_BAUDRATE": 2400, "OTA_ID": 10}, # Low baudrate - ] - - for config in test_configs: - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - yaml.dump(config, f) - config_file = f.name - - try: - # Configuration should be loadable - with open(config_file) as file: - loaded = yaml.load(file, Loader=yaml.Loader) - assert loaded["OTA_ID"] > 0 - assert loaded["OTA_BAUDRATE"] > 0 - assert isinstance(loaded["OTA_PORT"], str) - assert len(loaded["OTA_PORT"]) > 0 - finally: - os.unlink(config_file) - - -class TestMultiDeviceMessageHandling: - """Test message handling with multiple simulated devices""" - - def test_message_isolation_between_devices(self): - """Test that messages from different devices don't interfere""" - # Create multiple OTA devices - devices = [] - mock_serials = [] - - for device_id in [10, 20, 30]: - mock_serial = MockSerial(f"/dev/ttyUSB{device_id}", 9600) - mock_serials.append(mock_serial) - - with patch("serial.Serial", return_value=mock_serial): - device = Ota(f"/dev/ttyUSB{device_id}", 9600, device_id) - devices.append(device) - - time.sleep(0.2) # Let all devices initialize - - try: - # Each device sends messages to a different target - for i, device in enumerate(devices): - target_id = [1, 2, 3][i] - device.send_ota_message(target_id, f"MESSAGE_FROM_{device.id}_TO_{target_id}") - - # Each device receives different messages - for i, mock_serial in enumerate(mock_serials): - sender_id = [100, 200, 300][i] - device_id = [10, 20, 30][i] - test_message = MessageHelper.create_rcv_message(sender_id, f"MESSAGE_TO_{device_id}") - mock_serial.add_incoming_message(test_message) - - time.sleep(0.5) # Let processing complete - - # Verify each device only received its own messages - for i, device in enumerate(devices): - device_id = [10, 20, 30][i] - received_messages = device.get_new_messages() - - assert len(received_messages) == 1 - assert f"MESSAGE_TO_{device_id}" in received_messages[0] - - # Verify no cross-contamination - other_device_ids = [10, 20, 30] - other_device_ids.remove(device_id) - for other_id in other_device_ids: - for msg in received_messages: - assert f"MESSAGE_TO_{other_id}" not in msg - - # Verify sent messages are isolated - for i, mock_serial in enumerate(mock_serials): - device_id = [10, 20, 30][i] - target_id = [1, 2, 3][i] - sent_messages = mock_serial.get_sent_messages() - - assert len(sent_messages) == 1 - assert f"MESSAGE_FROM_{device_id}_TO_{target_id}" in sent_messages[0] - - finally: - # Cleanup all devices - for device in devices: - device.__del__() - - def test_broadcast_message_handling(self): - """Test handling of broadcast messages""" - mock_serial = MockSerial("/dev/ttyUSB0", 9600) - - with patch("serial.Serial", return_value=mock_serial): - ota_device = Ota("/dev/ttyUSB0", 9600, 10) - time.sleep(0.1) - - # Send broadcast message (address 0) - ota_device.send_ota_message(0, MessageTypes.REQ) - - # Verify broadcast format - sent_messages = mock_serial.get_sent_messages() - assert len(sent_messages) == 1 - assert "AT+SEND=0," in sent_messages[0] - assert MessageTypes.REQ in sent_messages[0] - - # Simulate receiving broadcast-type messages - broadcast_msg = MessageHelper.create_rcv_message(0, "BROADCAST_ANNOUNCEMENT") - mock_serial.add_incoming_message(broadcast_msg) - - time.sleep(0.2) - - received_messages = ota_device.get_new_messages() - assert len(received_messages) == 1 - assert "BROADCAST_ANNOUNCEMENT" in received_messages[0] - - ota_device.__del__() - - -class TestErrorRecoveryScenarios: - """Test error recovery and resilience""" - - def test_serial_reconnection_simulation(self): - """Test behavior when serial connection is lost and restored""" - mock_serial = MockSerial("/dev/ttyUSB0", 9600) - - with patch("serial.Serial", return_value=mock_serial): - ota_device = Ota("/dev/ttyUSB0", 9600, 10) - time.sleep(0.1) - - # Normal operation - ota_device.send_ota_message(1, "BEFORE_DISCONNECT") - - # Simulate connection loss by making serial operations fail - original_write = mock_serial.write - original_readline = mock_serial.readline - - def failing_write(data): - raise Exception("Connection lost") - - def failing_readline(): - raise Exception("Connection lost") - - mock_serial.write = failing_write - mock_serial.readline = failing_readline - - # Try to send during connection loss - should handle gracefully - ota_device.send_ota_message(1, "DURING_DISCONNECT") - - time.sleep(0.2) - - # Restore connection - mock_serial.write = original_write - mock_serial.readline = original_readline - - # Should work again after restoration - ota_device.send_ota_message(1, "AFTER_RECONNECT") - - time.sleep(0.2) - - # Verify system recovered - sent_messages = mock_serial.get_sent_messages() - - # Should have the first and last messages (middle one may have failed) - message_text = " ".join(sent_messages) - assert "BEFORE_DISCONNECT" in message_text - assert "AFTER_RECONNECT" in message_text - - ota_device.__del__() - - def test_thread_safety_under_stress(self): - """Test thread safety under stress conditions""" - mock_serial = MockSerial("/dev/ttyUSB0", 9600) - - with patch("serial.Serial", return_value=mock_serial): - ota_device = Ota("/dev/ttyUSB0", 9600, 10) - time.sleep(0.1) - - # Create stress conditions with multiple operations - results = {"send_errors": 0, "receive_errors": 0} - - def stress_sender(): - try: - for i in range(100): - ota_device.send_ota_message(1, f"STRESS_{threading.current_thread().ident}_{i}") - if i % 10 == 0: - time.sleep(0.001) # Occasional small delay - except Exception: - results["send_errors"] += 1 - - def stress_receiver(): - try: - for i in range(100): - test_msg = MessageHelper.create_rcv_message(1, f"RECV_{threading.current_thread().ident}_{i}") - mock_serial.add_incoming_message(test_msg) - if i % 10 == 0: - # Occasionally read messages - ota_device.get_new_messages() - time.sleep(0.001) - except Exception: - results["receive_errors"] += 1 - - # Start multiple stress threads - threads = [] - for _ in range(5): - threads.append(threading.Thread(target=stress_sender)) - threads.append(threading.Thread(target=stress_receiver)) - - for thread in threads: - thread.start() - - for thread in threads: - thread.join() - - time.sleep(1.0) # Let all operations complete - - # Should have completed without errors - assert results["send_errors"] == 0, f"Send errors: {results['send_errors']}" - assert results["receive_errors"] == 0, f"Receive errors: {results['receive_errors']}" - - # Verify substantial message throughput - sent_messages = mock_serial.get_sent_messages() - received_messages = ota_device.get_new_messages() - - assert len(sent_messages) >= 400, f"Low send throughput: {len(sent_messages)}" - assert len(received_messages) >= 400, f"Low receive throughput: {len(received_messages)}" - - ota_device.__del__() - - -if __name__ == "__main__": - # Allow running tests directly - pytest.main([__file__, "-v"]) diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index 3430438..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,285 +0,0 @@ -""" -Integration tests for the complete bUE-base station communication protocol. - -These tests simulate the full message exchange sequences as described in message_dict.txt. -""" - -import time -import sys -import os - -# Add the parent directory to the path so we can import ota -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -try: - import pytest - from unittest.mock import patch - from tests.conftest import MessageHelper, MessageTypes, DeviceIds - from tests.mock_serial import MockSerial - from ota import Ota - - class TestProtocolIntegration: - """Integration tests for complete protocol flows""" - - def test_complete_connection_sequence(self): - """Test the complete connection establishment sequence""" - # Create mock serial devices for base station and bUE - base_mock = MockSerial("/dev/ttyUSB0", 9600) - bue_mock = MockSerial("/dev/ttyUSB1", 9600) - - # Create OTA devices - with patch("serial.Serial", side_effect=[base_mock, bue_mock]): - base_ota = Ota("/dev/ttyUSB0", 9600, DeviceIds.BASE_STATION) - bue_ota = Ota("/dev/ttyUSB1", 9600, DeviceIds.BUE_DEVICE) - - time.sleep(0.1) # Let threads start - - try: - # Step 1: bUE sends REQ to broadcast - bue_ota.send_ota_message(DeviceIds.BROADCAST, MessageTypes.REQ) - - # Simulate base station receiving REQ - req_message = MessageHelper.create_rcv_message(DeviceIds.BUE_DEVICE, MessageTypes.REQ) - base_mock.add_incoming_message(req_message) - - # Step 2: Base station responds with CON - con_message = f"{MessageTypes.CON}:{DeviceIds.BASE_STATION}" - base_ota.send_ota_message(DeviceIds.BUE_DEVICE, con_message) - - # Simulate bUE receiving CON - con_rcv = MessageHelper.create_rcv_message(DeviceIds.BASE_STATION, con_message) - bue_mock.add_incoming_message(con_rcv) - - # Step 3: bUE sends ACK - bue_ota.send_ota_message(DeviceIds.BASE_STATION, MessageTypes.ACK) - - # Simulate base station receiving ACK - ack_message = MessageHelper.create_rcv_message(DeviceIds.BUE_DEVICE, MessageTypes.ACK) - base_mock.add_incoming_message(ack_message) - - # Wait for processing - time.sleep(0.3) - - # Verify the message exchanges - base_messages = base_ota.get_new_messages() - bue_messages = bue_ota.get_new_messages() - - # Base should have received REQ and ACK - assert len(base_messages) == 2 - assert any(MessageTypes.REQ in msg for msg in base_messages) - assert any(MessageTypes.ACK in msg for msg in base_messages) - - # bUE should have received CON - assert len(bue_messages) == 1 - assert MessageTypes.CON in bue_messages[0] - assert str(DeviceIds.BASE_STATION) in bue_messages[0] - - finally: - base_ota.__del__() - bue_ota.__del__() - - def test_ping_pong_sequence(self): - """Test the ping-pong keep-alive sequence""" - base_mock = MockSerial("/dev/ttyUSB0", 9600) - bue_mock = MockSerial("/dev/ttyUSB1", 9600) - - with patch("serial.Serial", side_effect=[base_mock, bue_mock]): - base_ota = Ota("/dev/ttyUSB0", 9600, DeviceIds.BASE_STATION) - bue_ota = Ota("/dev/ttyUSB1", 9600, DeviceIds.BUE_DEVICE) - - time.sleep(0.1) - - try: - # bUE sends PING - bue_ota.send_ota_message(DeviceIds.BASE_STATION, MessageTypes.PING) - - # Simulate base station receiving PING - ping_message = MessageHelper.create_rcv_message(DeviceIds.BUE_DEVICE, MessageTypes.PING) - base_mock.add_incoming_message(ping_message) - - # Base station responds with PINGR - base_ota.send_ota_message(DeviceIds.BUE_DEVICE, MessageTypes.PINGR) - - # Simulate bUE receiving PINGR - pingr_message = MessageHelper.create_rcv_message(DeviceIds.BASE_STATION, MessageTypes.PINGR) - bue_mock.add_incoming_message(pingr_message) - - time.sleep(0.3) - - # Verify exchanges - base_messages = base_ota.get_new_messages() - bue_messages = bue_ota.get_new_messages() - - assert len(base_messages) == 1 - assert MessageTypes.PING in base_messages[0] - - assert len(bue_messages) == 1 - assert MessageTypes.PINGR in bue_messages[0] - - finally: - base_ota.__del__() - bue_ota.__del__() - - def test_test_configuration_sequence(self): - """Test the complete test configuration and execution sequence""" - base_mock = MockSerial("/dev/ttyUSB0", 9600) - bue_mock = MockSerial("/dev/ttyUSB1", 9600) - - with patch("serial.Serial", side_effect=[base_mock, bue_mock]): - base_ota = Ota("/dev/ttyUSB0", 9600, DeviceIds.BASE_STATION) - bue_ota = Ota("/dev/ttyUSB1", 9600, DeviceIds.BUE_DEVICE) - - time.sleep(0.1) - - try: - # Step 1: Base sends TEST configuration - test_config = "0.1.1745004290" # config.role.starttime - test_message = f"{MessageTypes.TEST}:{test_config}" - base_ota.send_ota_message(DeviceIds.BUE_DEVICE, test_message) - - # Simulate bUE receiving TEST - test_rcv = MessageHelper.create_rcv_message(DeviceIds.BASE_STATION, test_message) - bue_mock.add_incoming_message(test_rcv) - - # Step 2: bUE responds with PREPR - start_time = "1745004290" - prepr_message = f"{MessageTypes.PREPR}:{start_time}" - bue_ota.send_ota_message(DeviceIds.BASE_STATION, prepr_message) - - # Simulate base receiving PREPR - prepr_rcv = MessageHelper.create_rcv_message(DeviceIds.BUE_DEVICE, prepr_message) - base_mock.add_incoming_message(prepr_rcv) - - # Step 4: bUE sends update - upd_message = f"{MessageTypes.UPD}:test_progress_50" - bue_ota.send_ota_message(DeviceIds.BASE_STATION, upd_message) - - # Simulate base receiving UPD - upd_rcv = MessageHelper.create_rcv_message(DeviceIds.BUE_DEVICE, upd_message) - base_mock.add_incoming_message(upd_rcv) - - # Step 5: bUE finishes test - bue_ota.send_ota_message(DeviceIds.BASE_STATION, MessageTypes.DONE) - - # Simulate base receiving DONE - done_rcv = MessageHelper.create_rcv_message(DeviceIds.BUE_DEVICE, MessageTypes.DONE) - base_mock.add_incoming_message(done_rcv) - - time.sleep(0.5) - - # Verify all messages were exchanged correctly - base_messages = base_ota.get_new_messages() - bue_messages = bue_ota.get_new_messages() - - # Base should have received PREPR, UPD, DONE - assert len(base_messages) == 3 - message_text = " ".join(base_messages) - assert MessageTypes.PREPR in message_text - assert MessageTypes.UPD in message_text - assert MessageTypes.DONE in message_text - - # bUE should have received TEST - assert len(bue_messages) == 1 - assert MessageTypes.TEST in bue_messages[0] - assert test_config in bue_messages[0] - - finally: - base_ota.__del__() - bue_ota.__del__() - - def test_test_failure_sequence(self): - """Test the test failure handling sequence""" - base_mock = MockSerial("/dev/ttyUSB0", 9600) - bue_mock = MockSerial("/dev/ttyUSB1", 9600) - - with patch("serial.Serial", side_effect=[base_mock, bue_mock]): - base_ota = Ota("/dev/ttyUSB0", 9600, DeviceIds.BASE_STATION) - bue_ota = Ota("/dev/ttyUSB1", 9600, DeviceIds.BUE_DEVICE) - - time.sleep(0.1) - - try: - # Base sends invalid TEST configuration - bad_test_message = f"{MessageTypes.TEST}:invalid_config" - base_ota.send_ota_message(DeviceIds.BUE_DEVICE, bad_test_message) - - # Simulate bUE receiving bad TEST - test_rcv = MessageHelper.create_rcv_message(DeviceIds.BASE_STATION, bad_test_message) - bue_mock.add_incoming_message(test_rcv) - - # bUE responds with FAIL - fail_message = f"{MessageTypes.FAIL}:BAD_CONFIG" - bue_ota.send_ota_message(DeviceIds.BASE_STATION, fail_message) - - # Simulate base receiving FAIL - fail_rcv = MessageHelper.create_rcv_message(DeviceIds.BUE_DEVICE, fail_message) - base_mock.add_incoming_message(fail_rcv) - - time.sleep(0.3) - - # Verify failure handling - base_messages = base_ota.get_new_messages() - bue_messages = bue_ota.get_new_messages() - - # Base should have received FAIL - assert len(base_messages) == 1 - assert MessageTypes.FAIL in base_messages[0] - assert "BAD_CONFIG" in base_messages[0] - - # bUE should have received bad TEST - assert len(bue_messages) == 1 - assert MessageTypes.TEST in bue_messages[0] - assert "invalid_config" in bue_messages[0] - - finally: - base_ota.__del__() - bue_ota.__del__() - - def test_test_cancellation_sequence(self): - """Test the test cancellation sequence""" - base_mock = MockSerial("/dev/ttyUSB0", 9600) - bue_mock = MockSerial("/dev/ttyUSB1", 9600) - - with patch("serial.Serial", side_effect=[base_mock, bue_mock]): - base_ota = Ota("/dev/ttyUSB0", 9600, DeviceIds.BASE_STATION) - bue_ota = Ota("/dev/ttyUSB1", 9600, DeviceIds.BUE_DEVICE) - - time.sleep(0.1) - - try: - # Base sends CANC message to cancel ongoing test - base_ota.send_ota_message(DeviceIds.BUE_DEVICE, MessageTypes.CANC) - - # Simulate bUE receiving CANC - canc_rcv = MessageHelper.create_rcv_message(DeviceIds.BASE_STATION, MessageTypes.CANC) - bue_mock.add_incoming_message(canc_rcv) - - # After cancellation, bUE should resume pinging - bue_ota.send_ota_message(DeviceIds.BASE_STATION, MessageTypes.PING) - - time.sleep(0.3) - - # Verify cancellation handling - bue_messages = bue_ota.get_new_messages() - - # bUE should have received CANC - assert len(bue_messages) == 1 - assert MessageTypes.CANC in bue_messages[0] - - # Verify PING was sent after cancellation - bue_sent = bue_mock.get_sent_messages() - ping_sent = any(MessageTypes.PING in msg for msg in bue_sent) - assert ping_sent - - finally: - base_ota.__del__() - bue_ota.__del__() - -except ImportError as e: - print(f"Warning: Could not import test dependencies: {e}") - print("Run 'pip install -r setup/requirements_test.txt' to install test dependencies") - - # Create dummy test class for when pytest is not available - class TestProtocolIntegration: - def test_placeholder(self): - pass diff --git a/tests/test_ota.py b/tests/test_ota.py deleted file mode 100644 index c72bbc2..0000000 --- a/tests/test_ota.py +++ /dev/null @@ -1,391 +0,0 @@ -""" -Tests for the OTA (Over-The-Air) communication module. - -This test suite covers the message protocol defined in message_dict.txt, -including connection establishment, ping/pong, test coordination, and error handling. -""" - -import pytest -import time -import threading -from unittest.mock import patch, MagicMock -import sys -import os - -# Add the parent directory to the path so we can import ota -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from tests.conftest import MessageHelper, MessageTypes, DeviceIds -from ota import Ota - - -class TestOtaBasicFunctionality: - """Test basic OTA functionality""" - - def test_send_ping_ota_message(self, ota_device): - """Test sending OTA messages with CRC8""" - device, mock_serial = ota_device - - # Send a simple message - device.send_ota_message(1, "PING") - - # Check that the correct AT command was sent (with CRC8) - sent_messages = mock_serial.get_sent_messages() - expected = MessageHelper.create_at_command(1, "PING", include_crc=True) - assert len(sent_messages) == 1 - assert sent_messages[0] == expected - - def test_receive_ota_message(self, ota_device): - """Test receiving OTA messages with CRC8 validation""" - device, mock_serial = ota_device - - # Add a message to the mock serial buffer (with valid CRC8) - test_message = MessageHelper.create_rcv_message(1, "PINGR", -50, 1, include_crc=True) - mock_serial.add_incoming_message(test_message) - - # Wait for the message to be processed - time.sleep(0.2) - - # Get new messages - should have CRC stripped and be prefixed with "RCV=" - messages = device.get_new_messages() - assert len(messages) == 1 - assert messages[0] == "1,PINGR" - - def test_message_filtering(self, ota_device): - """Test that messages with bad CRC are filtered out""" - device, mock_serial = ota_device - - # Add a valid message with correct CRC - valid_message = MessageHelper.create_rcv_message(1, "ACK", -50, 1, include_crc=True) - - # Add an invalid message (manually create with wrong CRC) - invalid_message = "+RCV=1,5,ACKff,-50,1" # 'ff' is likely wrong CRC - - mock_serial.add_incoming_message("OK") # Should be filtered (wrong format) - mock_serial.add_incoming_message(invalid_message) # Should be filtered (bad CRC) - mock_serial.add_incoming_message(valid_message) # Should pass - - # Wait for processing - time.sleep(0.2) - - # Should only get the valid message - messages = device.get_new_messages() - assert len(messages) == 1 - assert messages[0] == "1,ACK" - - def test_crc8_validation(self, ota_device): - """Test CRC8 checksum validation specifically""" - device, mock_serial = ota_device - - # Test with a known message and CRC - message = "TEST" - crc = MessageHelper.calculate_crc8(f"{message}") - - # Create a valid message with correct CRC - valid_rcv = f"+RCV=1,{len(message)},{message}{crc},-50,1" - mock_serial.add_incoming_message(valid_rcv) - - # Create an invalid message with wrong CRC - invalid_rcv = f"+RCV=1,{len(message)}99,{message}99,-50,1" - mock_serial.add_incoming_message(invalid_rcv) - - # Wait for processing - time.sleep(0.2) - - # Should only get the valid message - messages = device.get_new_messages() - assert len(messages) == 1 - assert messages[0] == f"1,{message}" - - -class TestConnectionProtocol: - """Test the connection establishment protocol""" - - def test_request_connection(self, ota_device): - """Test bUE requesting connection (REQ message)""" - device, mock_serial = ota_device - - # bUE sends REQ to broadcast address - device.send_ota_message(DeviceIds.BROADCAST, MessageTypes.REQ) - - # Verify the correct AT command was sent (with CRC) - sent_messages = mock_serial.get_sent_messages() - expected = MessageHelper.create_at_command(DeviceIds.BROADCAST, MessageTypes.REQ, include_crc=True) - assert expected in sent_messages - - def test_connection_confirm(self, ota_device): - """Test base station confirming connection (CON message)""" - device, mock_serial = ota_device - - # Simulate base station sending CON message with its ID - con_message = f"{MessageTypes.CON}:{DeviceIds.BASE_STATION}" - rcv_message = MessageHelper.create_rcv_message(DeviceIds.BASE_STATION, con_message, include_crc=True) - mock_serial.add_incoming_message(rcv_message) - - # Wait and get messages - time.sleep(0.2) - messages = device.get_new_messages() - - assert len(messages) == 1 - assert con_message in messages[0] - - def test_acknowledge_connection(self, ota_device): - """Test bUE acknowledging connection (ACK message)""" - device, mock_serial = ota_device - - # bUE sends ACK to base station - device.send_ota_message(DeviceIds.BASE_STATION, MessageTypes.ACK) - - # Verify the correct AT command was sent (with CRC) - sent_messages = mock_serial.get_sent_messages() - expected = MessageHelper.create_at_command(DeviceIds.BASE_STATION, MessageTypes.ACK, include_crc=True) - assert expected in sent_messages - - -class TestPingProtocol: - """Test the ping/pong keep-alive protocol""" - - def test_ping_message(self, ota_device): - """Test bUE sending ping message""" - device, mock_serial = ota_device - - # bUE sends PING to base station - device.send_ota_message(DeviceIds.BASE_STATION, MessageTypes.PING) - - # Verify the correct AT command was sent (with CRC) - sent_messages = mock_serial.get_sent_messages() - expected = MessageHelper.create_at_command(DeviceIds.BASE_STATION, MessageTypes.PING, include_crc=True) - assert expected in sent_messages - - def test_ping_response(self, ota_device): - """Test base station responding to ping (PINGR message)""" - device, mock_serial = ota_device - - # Simulate base station sending PINGR message (with CRC) - rcv_message = MessageHelper.create_rcv_message(DeviceIds.BASE_STATION, MessageTypes.PINGR, include_crc=True) - mock_serial.add_incoming_message(rcv_message) - - # Wait and get messages - time.sleep(0.2) - messages = device.get_new_messages() - - assert len(messages) == 1 - assert MessageTypes.PINGR in messages[0] - - -class TestTestProtocol: - """Test the UTW test coordination protocol""" - - def test_test_configuration_message(self, ota_device): - """Test base station sending test configuration""" - device, mock_serial = ota_device - - # Simulate base station sending TEST message (with CRC) - test_config = "0.1.1745004290" # config.role.starttime - test_message = f"{MessageTypes.TEST}:{test_config}" - rcv_message = MessageHelper.create_rcv_message(DeviceIds.BASE_STATION, test_message, include_crc=True) - mock_serial.add_incoming_message(rcv_message) - - # Wait and get messages - time.sleep(0.2) - messages = device.get_new_messages() - - assert len(messages) == 1 - assert test_config in messages[0] - - def test_test_failure_message(self, ota_device): - """Test bUE sending test failure message""" - device, mock_serial = ota_device - - # bUE sends FAIL message with reason - fail_reason = "BAD_CONFIG" - fail_message = f"{MessageTypes.FAIL}:{fail_reason}" - device.send_ota_message(DeviceIds.BASE_STATION, fail_message) - - # Verify the correct AT command was sent (with CRC) - sent_messages = mock_serial.get_sent_messages() - expected = MessageHelper.create_at_command(DeviceIds.BASE_STATION, fail_message, include_crc=True) - assert expected in sent_messages - - def test_test_cancel_message(self, ota_device): - """Test base station canceling test""" - device, mock_serial = ota_device - - # Simulate base station sending CANC message (with CRC) - rcv_message = MessageHelper.create_rcv_message(DeviceIds.BASE_STATION, MessageTypes.CANC, include_crc=True) - mock_serial.add_incoming_message(rcv_message) - - # Wait and get messages - time.sleep(0.2) - messages = device.get_new_messages() - - assert len(messages) == 1 - assert MessageTypes.CANC in messages[0] - - def test_test_preparation_response(self, ota_device): - """Test bUE confirming test preparation""" - device, mock_serial = ota_device - - # bUE sends PREPR message with start time - start_time = "1745004290" - prepr_message = f"{MessageTypes.PREPR}:{start_time}" - device.send_ota_message(DeviceIds.BASE_STATION, prepr_message) - - # Verify the correct AT command was sent (with CRC) - sent_messages = mock_serial.get_sent_messages() - expected = MessageHelper.create_at_command(DeviceIds.BASE_STATION, prepr_message, include_crc=True) - assert expected in sent_messages - - def test_test_lifecycle_messages(self, ota_device): - """Test the complete test lifecycle messages""" - device, mock_serial = ota_device - - # Test UPD message with body - upd_message = f"{MessageTypes.UPD}:test_update_data" - device.send_ota_message(DeviceIds.BASE_STATION, upd_message) - - # Test DONE message - device.send_ota_message(DeviceIds.BASE_STATION, MessageTypes.DONE) - - # Verify all messages were sent (with CRC) - sent_messages = mock_serial.get_sent_messages() - assert len(sent_messages) == 2 - - expected_upd = MessageHelper.create_at_command(DeviceIds.BASE_STATION, upd_message, include_crc=True) - expected_done = MessageHelper.create_at_command(DeviceIds.BASE_STATION, MessageTypes.DONE, include_crc=True) - - assert expected_upd in sent_messages - assert expected_done in sent_messages - - -class TestErrorHandling: - """Test error handling scenarios""" - - def test_serial_write_error(self, ota_device): - """Test handling of serial write errors""" - device, mock_serial = ota_device - - # Mock the write method to raise an exception - with patch.object(mock_serial, "write", side_effect=Exception("Write error")): - # This should not raise an exception, but print an error message - device.send_ota_message(DeviceIds.BASE_STATION, MessageTypes.PING) - - def test_serial_read_error(self, ota_device): - """Test handling of serial read errors""" - device, mock_serial = ota_device - - # Mock the readline method to raise an exception - with patch.object(mock_serial, "readline", side_effect=Exception("Read error")): - # Add a message that would trigger the error - mock_serial.add_incoming_message("test") - - # Wait a bit for the thread to process - time.sleep(0.2) - - # Should still be able to get messages (empty list) - messages = device.get_new_messages() - assert isinstance(messages, list) - - def test_device_cleanup(self, mock_serial): - """Test proper cleanup of OTA device""" - with patch("serial.Serial", return_value=mock_serial): - device = Ota("/dev/ttyUSB0", 9600, 5) - time.sleep(0.1) # Let thread start - - # Cleanup - device.__del__() - - # Wait for thread to finish - time.sleep(0.2) - - # Thread should be stopped - assert device.exit_event.is_set() - - -class TestMessageHelper: - """Test the message helper utilities""" - - def test_create_rcv_message(self): - """Test creating RCV messages""" - message = MessageHelper.create_rcv_message(5, MessageTypes.PING, -80, 10, include_crc=True) - # With CRC, the length and content will be different - expected_crc = MessageHelper.calculate_crc8(f"{MessageTypes.PING}") - expected = f"+RCV=5,{len(MessageTypes.PING)+2},{MessageTypes.PING}{expected_crc},-80,10" - assert message == expected - - def test_create_at_command(self): - """Test creating AT commands""" - command = MessageHelper.create_at_command(10, MessageTypes.PING, include_crc=True) - # With CRC, the command will include the checksum - expected_crc = MessageHelper.calculate_crc8(MessageTypes.PING) - expected = f"AT+SEND=10,{4+len(expected_crc)},PING{expected_crc}\r\n" - assert command == expected - - def test_parse_message_type(self): - """Test parsing message types""" - # Message without body - msg_type, body = MessageHelper.parse_message_type(MessageTypes.PING) - assert msg_type == MessageTypes.PING - assert body is None - - # Message with body - msg_type, body = MessageHelper.parse_message_type("CON:10") - assert msg_type == "CON" - assert body == "10" - - # Message with complex body - msg_type, body = MessageHelper.parse_message_type("TEST:0.1.1745004290") - assert msg_type == "TEST" - assert body == "0.1.1745004290" - - -class TestConcurrentAccess: - """Test concurrent access scenarios""" - - def test_concurrent_message_sending(self, ota_device): - """Test sending messages from multiple threads""" - device, mock_serial = ota_device - - def send_messages(thread_id): - for i in range(5): - device.send_ota_message(DeviceIds.BASE_STATION, f"MSG_{thread_id}_{i}") - time.sleep(0.01) - - # Start multiple threads sending messages - threads = [] - for i in range(3): - thread = threading.Thread(target=send_messages, args=(i,)) - threads.append(thread) - thread.start() - - # Wait for all threads to complete - for thread in threads: - thread.join() - - # Should have received 15 messages total (3 threads * 5 messages each) - sent_messages = mock_serial.get_sent_messages() - assert len(sent_messages) == 15 - - def test_concurrent_message_receiving(self, ota_device): - """Test receiving messages while sending""" - device, mock_serial = ota_device - - # Add multiple messages to the buffer (with CRC) - for i in range(10): - test_message = MessageHelper.create_rcv_message(DeviceIds.BASE_STATION, f"MSG_{i}", include_crc=True) - mock_serial.add_incoming_message(test_message) - - # Send some messages while receiving - for i in range(5): - device.send_ota_message(DeviceIds.BASE_STATION, f"SEND_{i}") - - # Wait for processing - time.sleep(0.3) - - # Get received messages - messages = device.get_new_messages() - assert len(messages) == 10 - - # Check sent messages - sent_messages = mock_serial.get_sent_messages() - assert len(sent_messages) == 5 diff --git a/tests/test_system_integration_fixed.py b/tests/test_system_integration_fixed.py deleted file mode 100644 index c71d04d..0000000 --- a/tests/test_system_integration_fixed.py +++ /dev/null @@ -1,435 +0,0 @@ -""" -System Integration Tests for bUE Lake Deployment - -This module provides comprehensive integration tests for the bUE system, -focusing on real-world scenarios that might occur during lake deployment. -These tests require actual bUE modules and handle cases where dependencies -might not be available in the test environment. -""" - -import pytest -import time -import tempfile -import os -import sys -import yaml -from unittest.mock import patch, MagicMock - -# Add multiple paths to find modules -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) - -# Mock problematic modules before importing -sys.modules["gps"] = MagicMock() -sys.modules["gpsd"] = MagicMock() -sys.modules["pynmea2"] = MagicMock() - -# Mock subprocess for test execution -mock_subprocess = MagicMock() -mock_subprocess.Popen = MagicMock() -sys.modules["subprocess"] = mock_subprocess - -# Try to import required modules - handle gracefully if missing -try: - from bue_main import bUE_Main, State - from base_station_main import Base_Station_Main - from .conftest import MessageTypes, DeviceIds - - MODULES_AVAILABLE = True - print("Successfully imported all required modules") -except ImportError as e: - print(f"Warning: Could not import required modules: {e}") - MODULES_AVAILABLE = False - -# Import test helpers that should always be available -from .mock_serial import MockSerial -from .conftest import MessageHelper - - -if MODULES_AVAILABLE: - - class TestStateMachine: - """Test bUE state machine behavior and transitions""" - - @pytest.fixture - def mock_config_file(self): - """Create a temporary config file for testing""" - config_data = {"OTA_PORT": "/dev/ttyUSB0", "OTA_BAUDRATE": 9600, "OTA_ID": 10} - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - yaml.dump(config_data, f) - yield f.name - os.unlink(f.name) - - def test_complete_connection_flow(self, mock_config_file): - """Test complete connection establishment state flow""" - with patch("serial.Serial") as mock_serial: - mock_ota = MockSerial("/dev/ttyUSB0", 9600) - mock_serial.return_value = mock_ota - - bue = bUE_Main(mock_config_file) - bue.tick_enabled = True - - # Wait for REQ to be sent - time.sleep(1.2) # Should trigger REQ after 1 second - - # Verify REQ was sent - sent_messages = mock_ota.get_sent_messages() - req_sent = any("REQ" in msg for msg in sent_messages) - assert req_sent, "bUE should send REQ message" - - # Simulate base station CON response - con_message = MessageHelper.create_rcv_message(1, "CON:1") - mock_ota.add_incoming_message(con_message) - - # Wait for processing - time.sleep(2) - - # Should now be connected and in IDLE state - assert bue.status_ota_connected - assert bue.ota_base_station_id == DeviceIds.BASE_STATION - assert bue.cur_st == State.IDLE - - # Verify ACK was sent - sent_messages = mock_ota.get_sent_messages() - ack_sent = any("ACK" in msg for msg in sent_messages) - assert ack_sent, "bUE should send ACK after receiving CON" - - bue.__del__() - - def test_timeout_and_reconnection_cycle(self, mock_config_file): - """Test that bUE properly handles timeouts and reconnects""" - with patch("serial.Serial") as mock_serial: - mock_ota = MockSerial("/dev/ttyUSB0", 9600) - mock_serial.return_value = mock_ota - - bue = bUE_Main(mock_config_file) - bue.tick_enabled = True - - # Establish connection first - time.sleep(1.2) - con_message = MessageHelper.create_rcv_message( - DeviceIds.BASE_STATION, f"{MessageTypes.CON}:{DeviceIds.BASE_STATION}" - ) - mock_ota.add_incoming_message(con_message) - time.sleep(2) - - assert bue.status_ota_connected - initial_timeout = bue.ota_timeout - - # Send PING but don't respond with PINGR (simulate timeout) - time.sleep(11) # Should trigger PING after 10 seconds - - # Verify PING was sent - sent_messages = mock_ota.get_sent_messages() - ping_sent = any("PING" in msg for msg in sent_messages[-5:]) - assert ping_sent, "bUE should send PING periodically" - - # Wait for timeout to decrease (no PINGR received) - time.sleep(1) - assert bue.ota_timeout < initial_timeout, "Timeout should decrease when no PINGR received" - - # Simulate multiple missed PINGs to trigger disconnection - bue.ota_timeout = 0 - time.sleep(11) - - # Should transition back to CONNECT_OTA - assert not bue.status_ota_connected - assert bue.cur_st == State.CONNECT_OTA - - bue.__del__() - - def test_test_state_transition(self, mock_config_file): - """Test transition to UTW_TEST state when receiving TEST message""" - with patch("serial.Serial") as mock_serial: - mock_ota = MockSerial("/dev/ttyUSB0", 9600) - mock_serial.return_value = mock_ota - - # Mock subprocess to prevent actual script execution - with patch("subprocess.Popen") as mock_popen: - mock_process = MagicMock() - mock_process.poll.return_value = None # Process running - mock_process.stdout.readline.return_value = "" - mock_process.stderr.readline.return_value = "" - mock_popen.return_value = mock_process - - bue = bUE_Main(mock_config_file) - bue.tick_enabled = True - - # Establish connection - time.sleep(1.2) - con_message = MessageHelper.create_rcv_message( - DeviceIds.BASE_STATION, f"{MessageTypes.CON}:{DeviceIds.BASE_STATION}" - ) - mock_ota.add_incoming_message(con_message) - time.sleep(2) - - assert bue.cur_st == State.IDLE - - # Send TEST message - future_time = int(time.time()) + 2 - test_message = f"TEST,helloworld,{future_time},param1 param2" - test_rcv = MessageHelper.create_rcv_message(DeviceIds.BASE_STATION, test_message) - mock_ota.add_incoming_message(test_rcv) - - # Wait for processing - time.sleep(10) - - # Should transition to UTW_TEST state - assert bue.is_testing - assert bue.cur_st == State.UTW_TEST - - # Verify PREPR was sent - sent_messages = mock_ota.get_sent_messages() - prepr_sent = any("PREPR" in msg for msg in sent_messages) - assert prepr_sent, "bUE should send PREPR when receiving valid TEST" - - # Simulate test completion - mock_process.poll.return_value = 0 # Process completed successfully - bue.is_testing = False - time.sleep(0.1) - - # Should return to IDLE state - assert bue.cur_st == State.IDLE - - bue.__del__() - - class TestMultiDeviceScenarios: - """Test scenarios with multiple bUEs connecting to a base station""" - - @pytest.fixture - def base_station_config(self): - """Create base station config""" - config_data = {"OTA_PORT": "/dev/ttyUSB0", "OTA_BAUDRATE": 9600, "OTA_ID": 1} - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - yaml.dump(config_data, f) - yield f.name - os.unlink(f.name) - - @pytest.fixture - def bue_configs(self): - """Create multiple bUE configs with different IDs""" - configs = [] - for bue_id in [10, 20, 30]: - config_data = {"OTA_PORT": f"/dev/ttyUSB{bue_id}", "OTA_BAUDRATE": 9600, "OTA_ID": bue_id} - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - yaml.dump(config_data, f) - configs.append(f.name) - - yield configs - - for config_file in configs: - if os.path.exists(config_file): - os.unlink(config_file) - - def test_multiple_bue_connections(self, base_station_config, bue_configs): - """Test base station handling multiple bUE connections""" - # Create mock serials for each device - base_mock = MockSerial("/dev/ttyUSB0", 9600) - bue_mocks = [MockSerial(f"/dev/ttyUSB{bue_id}", 9600) for bue_id in [10, 20, 30]] - - def mock_serial_factory(port, baudrate, timeout=0.1): - if port == "/dev/ttyUSB0": - return base_mock - elif port == "/dev/ttyUSB10": - return bue_mocks[0] - elif port == "/dev/ttyUSB20": - return bue_mocks[1] - elif port == "/dev/ttyUSB30": - return bue_mocks[2] - return MockSerial(port, baudrate) - - with patch("serial.Serial", side_effect=mock_serial_factory): - # Initialize base station - base_station = Base_Station_Main(base_station_config) - base_station.tick_enabled = True - print(f"Created base_station object: {id(base_station)}") - print(f"Base station using MockSerial: {id(base_station.ota.ser)}") - - # Initialize bUEs - bues = [] - for config_file in bue_configs: - bue = bUE_Main(config_file) - bue.tick_enabled = True - bues.append(bue) - - time.sleep(0.5) # Let initialization complete - - # Test connection establishment for each bUE - for i, bue in enumerate(bues): - bue_id = [10, 20, 30][i] - - # Simulate connection process - bue.ota_connected = True - bue.ota_base_station_id = DeviceIds.BASE_STATION - bue.cur_st = State.IDLE - base_station.connected_bues.append(bue_id) - - # Verify all three bUEs are connected - assert len(base_station.connected_bues) == 3 - assert set(base_station.connected_bues) == {10, 20, 30} - - # Test simultaneous PING handling - print(f"About to add messages to base_mock: {id(base_mock)}") - print(f"Base station's actual MockSerial: {id(base_station.ota.ser)}") - - # Use the base station's actual MockSerial instead of base_mock - actual_base_mock = base_station.ota.ser - - for i, bue in enumerate(bues): - bue_id = [10, 20, 30][i] - - # Send PING with GPS coordinates - ping_message = f"PING,40.{i},-111.{i}" - # bue.ota.send_ota_message(DeviceIds.BASE_STATION, ping_message) - - # Base station receives PING - use the correct MockSerial - ping_rcv = MessageHelper.create_rcv_message(bue_id, ping_message) - actual_base_mock.add_incoming_message(ping_rcv) - print(f"Added PING message for bUE {bue_id}") - - # Wait longer for message processing and add polling - print("Waiting for message processing...") - max_wait = 12 # Maximum wait time in seconds - wait_interval = 0.5 # Check every 0.5 seconds - waited = 0 - - while len(base_station.bue_coordinates) < 3 and waited < max_wait: - time.sleep(wait_interval) - waited += wait_interval - print(f"Waited {waited}s, coordinates: {len(base_station.bue_coordinates)}") - - print(f"Final wait time: {waited}s") - - # Verify all bUEs have coordinates stored - assert len(base_station.bue_coordinates) == 3 - for i, bue_id in enumerate([10, 20, 30]): - assert bue_id in base_station.bue_coordinates - coords = base_station.bue_coordinates[bue_id] - assert coords[0] == f"40.{i}" # latitude - assert coords[1] == f"-111.{i}" # longitude - - # Cleanup - base_station.__del__() - for bue in bues: - bue.__del__() - - class TestConfigurationAndErrorHandling: - """Test configuration loading and error scenarios""" - - def test_invalid_config_file(self): - """Test handling of invalid configuration files""" - # Test missing file - with pytest.raises(SystemExit): - bUE_Main("nonexistent_config.yaml") - - # Test malformed YAML - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - f.write("invalid: yaml: content: [") - invalid_config = f.name - - try: - with pytest.raises(yaml.YAMLError): - with open(invalid_config) as file: - yaml.load(file, Loader=yaml.Loader) - finally: - os.unlink(invalid_config) - - def test_missing_required_config_keys(self): - """Test handling of missing required configuration keys""" - incomplete_config = { - "OTA_PORT": "/dev/ttyUSB0", - # Missing OTA_BAUDRATE and OTA_ID - } - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - yaml.dump(incomplete_config, f) - config_file = f.name - - try: - with patch("serial.Serial"): - # Should handle missing keys gracefully - bue = None - try: - bue = bUE_Main(config_file) - # If we get here without an exception, that's unexpected - assert False, "Expected KeyError was not raised" - except KeyError: - # This is expected - the test should pass - pass - finally: - # Cleanup any created bUE object - if bue is not None: - bue.EXIT = True - bue.tick_enabled = False - time.sleep(0.1) # Brief pause for threads to see the flags - bue.__del__() - finally: - os.unlink(config_file) - - class TestRealWorldScenarios: - """Test scenarios that might occur during actual lake deployment""" - - @pytest.fixture - def mock_config_file(self): - """Create a temporary config file for testing""" - config_data = {"OTA_PORT": "/dev/ttyUSB0", "OTA_BAUDRATE": 9600, "OTA_ID": 10} - - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: - yaml.dump(config_data, f) - yield f.name - os.unlink(f.name) - - def test_gps_coordinate_validation(self, mock_config_file): - """Test GPS coordinate handling and validation""" - with patch("serial.Serial") as mock_serial: - mock_ota = MockSerial("/dev/ttyUSB0", 9600) - mock_serial.return_value = mock_ota - - # Mock GPS handler to return test coordinates - def mock_gps_handler(): - return "40.2518", "-111.6493" # BYU coordinates - - bue = bUE_Main(mock_config_file) - bue.gps_handler = mock_gps_handler - bue.tick_enabled = True - - # Setup connected state - bue.status_ota_connected = True - bue.ota_base_station_id = DeviceIds.BASE_STATION - bue.cur_st = State.IDLE - - # Trigger PING with GPS coordinates - time.sleep(11) # Should trigger PING - - # Verify PING contains GPS coordinates - sent_messages = mock_ota.get_sent_messages() - ping_with_coords = None - for msg in sent_messages: - if "PING" in msg and "40.2518" in msg and "-111.6493" in msg: - ping_with_coords = msg - break - - assert ping_with_coords is not None, "PING should include GPS coordinates" - - bue.__del__() - -else: - # If modules are not available, create placeholder tests that skip - class TestStateMachine: - def test_modules_not_available(self): - pytest.skip("Required modules not available - install missing dependencies") - - class TestMultiDeviceScenarios: - def test_modules_not_available(self): - pytest.skip("Required modules not available - install missing dependencies") - - class TestConfigurationAndErrorHandling: - def test_modules_not_available(self): - pytest.skip("Required modules not available - install missing dependencies") - - class TestRealWorldScenarios: - def test_modules_not_available(self): - pytest.skip("Required modules not available - install missing dependencies") From 221260039b78001e5ceab34fe471243761e2c60d Mon Sep 17 00:00:00 2001 From: Ty Young Date: Thu, 5 Mar 2026 10:17:33 -0700 Subject: [PATCH 3/6] Updated bue requirements --- setup/requirements_bue.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup/requirements_bue.txt b/setup/requirements_bue.txt index c0a21ec..34b7f8e 100644 --- a/setup/requirements_bue.txt +++ b/setup/requirements_bue.txt @@ -1,3 +1,4 @@ pynmeagps==1.0.50 loguru==0.7.3 -crc8==0.2.1 \ No newline at end of file +crc8==0.2.1 +pyserial==3.5 \ No newline at end of file From c155268c0da8d94b14ad2823ce940476536716da Mon Sep 17 00:00:00 2001 From: Ty Young Date: Thu, 19 Mar 2026 10:13:09 -0600 Subject: [PATCH 4/6] ReadME updated --- README.md | 273 +++++++++++++++++++++++++++++-------- setup/requirements_bue.txt | 3 +- 2 files changed, 217 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 23020e5..c485391 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,277 @@ -# Lake Tests Branch +## bUE Lake Tests -The next major step for our project is to put everything in a lake and test communication. This repository will store the files needed for this task. +This repository contains the code used for lake communication tests between a shore-side **base station** and multiple remote **bUE** nodes. Communication is handled by Reyax LoRa modules, with optional GPS integration on the bUEs and a Qt-based GUI for monitoring from the base station. -Hint: to run any commands listed in this file, be sure that your terminal is active in the `lake_tests` directory. +All paths and commands below assume your shell is in the project root directory: -## If on the Base Station +```bash +cd bUE-lake_tests +``` -Setup a virtual environment and install all the needed packages. +--- -``` -python3 -m venv -source /path/to/venv/bin/activate -pip install -r requirements.txt -``` +## Project Overview -Setup the config file for the base station. Do this by copying `config.example` from the setup folder into the main directory. Rename it `config_base.yaml`. If not uses Linux, you will need to update the `PORT` option of this file to reflect what port the Rayex device is connected to. +- **Base station (shore)** + - Talks to one or more bUE nodes over LoRa via a Reyax module. + - Tracks connection status, missed pings, and per-bUE GPS coordinates. + - Can be run as a simple CLI (for quick testing) or with a PySide6 GUI. -### Running Base Station without UI +- **bUE node (field device / Pi)** + - Connects to the base station using its own Reyax module. + - Periodically sends `PING` messages, state, and optional GPS coordinates. + - Receives commands such as `TEST`, `CANC`, `RELOAD`, and `RESTART`. + - Can be run manually or as a systemd service on a Raspberry Pi. -``` -python3 main.py -``` +--- -### Running Base Station with UI +## Repository Layout -Make sure that the keystroke handler thread is commented out in `main_ui.py` and run the following: -``` -python3 main_ui.py -``` +- `main.py` – CLI entry point for the base station (interactive test runner). +- `base_station_main.py` – Base station core logic and OTA handling. +- `bue_main.py` – Main state machine for a single bUE node. +- `ota.py` – Low-level wrapper around the Reyax serial interface. +- `constants.py` – Shared enums/constants (e.g., bUE state codes). +- `config_base.yaml` – Base station config used by `main.py` and the GUI. +- `base_station.yaml` – Alternative base station config used by `base_station_main.py` when run directly. +- `auto_config.yaml` – Parameter sets for automated over-the-air tests. +- `gui/` – PySide6 GUI (entry point: `gui/main.py`, UI files in `gui/ui/`). +- `setup/` – Example configs, systemd unit template, GPS setup script, and requirements files. +- `logs/` – Log files written by the base station and bUE code. +- `tdo_rup.py`, `tup_rdo.py` and `*.grc` – Test / GNU Radio-related utilities. +- `uw_env/` – Example Python virtual environment directory (may not exist or may be local-only; you can create your own instead). -### Running Base Station with UI and auto refresh +--- -Make sure that the keystroke handler thread is uncommented in `main_ui.py` and run the following: -``` -sudo -E /path/to/venv/bin/python main_ui.py -``` +## Base Station Setup (PC / Laptop) -### Running Base Station GUI +### 1. Create and activate a virtual environment -There are two options for this. If on Linux/Mac, run the following: +```bash +python3 -m venv uw_env +source uw_env/bin/activate ``` -./launch_gui.sh + +### 2. Install Python dependencies + +Install the base station + GUI dependencies: + +```bash +pip install -r setup/requirements.txt ``` -If on Windows, run +> Note: `setup/requirements.txt` and `gui/requirements.txt` are aligned; installing from `setup/requirements.txt` is sufficient for both CLI and GUI. + +### 3. Configure the base station Reyax module + +`config_base.yaml` (used by `main.py` and the GUI) looks like: +```yaml +OTA_PORT: "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0" +OTA_ID: 1 +OTA_BAUDRATE: 9600 ``` -python base_station_gui.py + +Update **`OTA_PORT`** to match the serial device for your Reyax module. On Linux this is typically under `/dev/serial/by-id/` or `/dev/ttyUSB*`. + +If you prefer, you can use the simpler `base_station.yaml` with `base_station_main.py` instead; the same fields apply. + +--- + +## Running the Base Station + +Make sure your virtual environment is active and you are in the project root. + +### Option A: CLI base station (interactive tests) + +```bash +python main.py ``` -## If on a bUE +This will: -### Install dependencies +- Initialize a `Base_Station_Main` instance using `config_base.yaml`. +- Open an interactive prompt (via `survey`) with commands like `TEST`, `DISTANCE`, `DISCONNECT`, `CANCEL`, `LIST`, and `EXIT`. -The bUE should come with a venv. If it doesn't, create one. Install dependencies into this venv. +Use this mode when you want a simple terminal-based interface. +### Option B: Base station core only + +```bash +python base_station_main.py ``` -pip install -r requirements_bue.txt + +This runs only the base station service logic (no CLI, no GUI) using `base_station.yaml`. It is useful if you want to embed or monitor the service separately. + +### Option C: PySide6 GUI (RECOMMENDED) + +Run the GUI from the project root so that logs and paths resolve correctly: + +```bash +python -m gui.main +# or equivalently +python gui/main.py ``` -Install `python3-gps` +The GUI provides: + +- A map view of bUE locations (via `MapManager` and `qgmap`). +- Tables for connected bUEs, distances, and coordinates. +- A log viewer for `logs/last_run.log`. +- Dialogs to run and cancel tests on selected bUEs. +--- + +## bUE Node Setup (e.g., Raspberry Pi) + +These steps assume you are on the device that will act as a bUE. + +### 1. Clone or copy the repository + +On the bUE device: + +```bash +git clone bUE-lake_tests +cd bUE-lake_tests ``` -sudo apt-get install python3-gps + +### 2. Create a virtual environment and install dependencies + +```bash +python3 -m venv uw_env +source uw_env/bin/activate +pip install -r setup/requirements_bue.txt ``` -Run `gpsd.sh` to get all the gps files configured. It is good to reboot after this +### 3. Install GPS-related system packages (if using GPS) + +On Debian/Raspberry Pi OS: +```bash +sudo apt-get update +sudo apt-get install python3-gps gpsd gpsd-clients ``` + +Run the GPS setup script provided in `setup/`: + +```bash +cd setup sudo ./gpsd.sh +cd .. ``` -### Creating the `.service` file - -If this device is a Raspberry Pi bUE, then make sure that you have pointed systemd to start the process upon booting the Pi. +It is usually a good idea to reboot after configuring GPS: +```bash +sudo reboot ``` -sudo cp bue.service.txt /etc/systemd/system/bue.service + +### 4. Configure the bUE Reyax module + +`bue_main.py` expects a YAML file (by default `bue_config.yaml`) in the project root. Start from the example in `setup/`: + +```bash +cp setup/config.example bue_config.yaml ``` -Before activating the service, be sure to edit lines 7 and 8 in your newly created `/etc/systemd/system/bue.service` by changing the value `/path/to/uw_env/...` to whatever path it takes to get to `/uw_env/` and the other paths to your `lake_tests/` folder. There are three isntances of this, so be sure to change them all. +Then edit `bue_config.yaml` and set at least: -### Creating the `config.yaml` file +- `OTA_PORT` – Serial device for the bUE’s Reyax module. +- `OTA_ID` – The Reyax address for this bUE (must be unique per node). +- `OTA_BAUDRATE` – Usually `9600` unless your module is configured differently. -Additionally, you need to make sure that there is a `config.yaml` file that the bUE can access. An example with all the fields that you need is found in this repository as `config.example`. You can run the following command: +Additional fields can be added as the code evolves; check `bue_main.py` for what is read from the YAML. -``` -cp config.example config.yaml +### 5. Running the bUE manually + +From the project root on the bUE device (with the venv activated): + +```bash +python bue_main.py ``` -All the fields should be correct except for `OTA_ID`. Make sure this is set to your Reyex device's address. +This starts the bUE state machine, which will: -### Testing and running the service +- Initialize the Reyax driver via `ota.Ota`. +- Fetch its Reyax ID. +- Connect to the base station when requested and begin sending `PING` messages and receiving commands. -Now that you have both the `bue.service` file and `config.yaml` file, let's make sure that they run smoothly. +--- -To test this, run the following commands: +## Running the bUE as a systemd Service (Pi) +On a Raspberry Pi bUE, you can have the bUE code start automatically on boot. + +### 1. Install the service unit + +From the project root on the bUE device: + +```bash +sudo cp setup/bue.service.txt /etc/systemd/system/bue.service ``` + +### 2. Edit paths in the service file + +Open `/etc/systemd/system/bue.service` with your editor of choice and update: + +- Any `/path/to/uw_env/...` segments so they point to the actual Python executable in your venv. +- Any `/path/to/bUE-lake_tests/...` segments so they point to this project’s directory on the bUE. + +There are multiple instances; make sure you update them all. + +### 3. Test the service + +```bash +sudo systemctl daemon-reload sudo systemctl start bue.service ``` -You should see a new log file appear in the `bue_logs` directory. In the first few lines, you should find all the settings from the `config.yaml` file. At this point, if you need to change the yaml file, you will need to run the following command after editing it: +You should see logs under `logs/` on the bUE (e.g., `logs/bue.log`). If configuration settings are incorrect, adjust `bue_config.yaml` and restart: -``` +```bash sudo systemctl restart bue.service ``` -and make sure that the changes are good to go. +### 4. Enable on boot -### Enabling the bUE service to run on power cycle +```bash +sudo systemctl enable bue.service +``` -The last step for the bUE is to enable it to run when the device power cycles. This is done simply by running the following command: +This will cause the bUE process to start automatically on each boot. +--- + +## Logs and Troubleshooting + +- Base station logs: + - `logs/base_station.log` – Rotating log of base station activity. + - `logs/last_run.log` – Overwritten each run; displayed in the GUI log viewer. +- bUE logs: + - `logs/bue.log` – Rotating log of bUE activity (on the bUE device). + +If you are not seeing any traffic: + +- Confirm both base station and bUE are using matching LoRa parameters (address, baudrate, bandwidth, etc.). +- Check that `OTA_PORT` paths are correct on both sides. +- Ensure that only one process is talking to a given serial device at a time. + +--- + +## Development and Testing + +- The project uses `pytest` and `pytest-cov` (installed via `setup/requirements.txt`). +- From the project root (with the venv active): + +```bash +pytest ``` -sudo systemctl enable bue.service -``` -If you also wish the service to start during this power cycle, just run the `systemctl start` command above. +GitHub Actions workflows under `.github/workflows/` run basic code-quality and test checks on pushes/PRs. + +--- + +## Notes + +- This README is intentionally focused on getting a base station + one or more bUEs talking over Reyax modules for lake experiments. +- For deeper architectural details (message formats, state diagrams, etc.), refer to comments in `bue_main.py`, `base_station_main.py`, and `ota.py`, and any accompanying lab documentation (e.g., internal Notion pages). diff --git a/setup/requirements_bue.txt b/setup/requirements_bue.txt index 34b7f8e..73a9b30 100644 --- a/setup/requirements_bue.txt +++ b/setup/requirements_bue.txt @@ -1,4 +1,5 @@ pynmeagps==1.0.50 loguru==0.7.3 crc8==0.2.1 -pyserial==3.5 \ No newline at end of file +pyserial==3.5 +PyYAML==6.0.3 \ No newline at end of file From 5a4a2f77215d230e0fb40c91a68005700f7cbb91 Mon Sep 17 00:00:00 2001 From: Ty Young Date: Thu, 19 Mar 2026 10:26:14 -0600 Subject: [PATCH 5/6] ReadME update --- README.md | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/README.md b/README.md index c485391..c067d13 100644 --- a/README.md +++ b/README.md @@ -82,28 +82,7 @@ If you prefer, you can use the simpler `base_station.yaml` with `base_station_ma Make sure your virtual environment is active and you are in the project root. -### Option A: CLI base station (interactive tests) - -```bash -python main.py -``` - -This will: - -- Initialize a `Base_Station_Main` instance using `config_base.yaml`. -- Open an interactive prompt (via `survey`) with commands like `TEST`, `DISTANCE`, `DISCONNECT`, `CANCEL`, `LIST`, and `EXIT`. - -Use this mode when you want a simple terminal-based interface. - -### Option B: Base station core only - -```bash -python base_station_main.py -``` - -This runs only the base station service logic (no CLI, no GUI) using `base_station.yaml`. It is useful if you want to embed or monitor the service separately. - -### Option C: PySide6 GUI (RECOMMENDED) +# PySide6 GUI (RECOMMENDED) Run the GUI from the project root so that logs and paths resolve correctly: From 1bd4cfca8eee6d4041babc652411cd7c95ac05a6 Mon Sep 17 00:00:00 2001 From: Ty Young Date: Thu, 19 Mar 2026 11:07:37 -0600 Subject: [PATCH 6/6] Read through the chat generated readme and made necessary changes --- README.md | 54 +++++---------- main.py | 156 ------------------------------------------- setup/config.example | 1 - setup/gpsd.sh | 2 +- 4 files changed, 16 insertions(+), 197 deletions(-) delete mode 100644 main.py diff --git a/README.md b/README.md index c067d13..9401fa6 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,6 @@ cd bUE-lake_tests ## Repository Layout -- `main.py` – CLI entry point for the base station (interactive test runner). - `base_station_main.py` – Base station core logic and OTA handling. - `bue_main.py` – Main state machine for a single bUE node. - `ota.py` – Low-level wrapper around the Reyax serial interface. @@ -68,13 +67,10 @@ pip install -r setup/requirements.txt ```yaml OTA_PORT: "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0" -OTA_ID: 1 OTA_BAUDRATE: 9600 ``` -Update **`OTA_PORT`** to match the serial device for your Reyax module. On Linux this is typically under `/dev/serial/by-id/` or `/dev/ttyUSB*`. - -If you prefer, you can use the simpler `base_station.yaml` with `base_station_main.py` instead; the same fields apply. +**NOTE:** "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0" should be the same for all Linux devices. If running on a Windows or Mac you will need to configure for those ports --- @@ -103,7 +99,7 @@ The GUI provides: ## bUE Node Setup (e.g., Raspberry Pi) -These steps assume you are on the device that will act as a bUE. +These steps assume you are on the device that will act as a bUE. Hopefully this was already done when the pi was setup, but if not here is how to do it manually ### 1. Clone or copy the repository @@ -114,37 +110,31 @@ git clone bUE-lake_tests cd bUE-lake_tests ``` -### 2. Create a virtual environment and install dependencies - -```bash -python3 -m venv uw_env -source uw_env/bin/activate -pip install -r setup/requirements_bue.txt -``` - -### 3. Install GPS-related system packages (if using GPS) +### 2. Install GPS-related system packages On Debian/Raspberry Pi OS: ```bash -sudo apt-get update -sudo apt-get install python3-gps gpsd gpsd-clients +sudo setup/gpsd.sh ``` -Run the GPS setup script provided in `setup/`: +It is usually a good idea to reboot after configuring GPS: ```bash -cd setup -sudo ./gpsd.sh -cd .. +sudo reboot ``` -It is usually a good idea to reboot after configuring GPS: +### 3. Create a virtual environment and install dependencies + +The python environment needs to include system gpsd packages that we installed in the first step. By using the `--system-site-packages` flag, we tell the system to include these packages in our environment ```bash -sudo reboot +python3 -m venv uw_env --system-site-packages +source uw_env/bin/activate +pip install -r setup/requirements_bue.txt ``` + ### 4. Configure the bUE Reyax module `bue_main.py` expects a YAML file (by default `bue_config.yaml`) in the project root. Start from the example in `setup/`: @@ -153,13 +143,12 @@ sudo reboot cp setup/config.example bue_config.yaml ``` -Then edit `bue_config.yaml` and set at least: +Then edit `bue_config.yaml`. - `OTA_PORT` – Serial device for the bUE’s Reyax module. -- `OTA_ID` – The Reyax address for this bUE (must be unique per node). - `OTA_BAUDRATE` – Usually `9600` unless your module is configured differently. -Additional fields can be added as the code evolves; check `bue_main.py` for what is read from the YAML. +**NOTE:** "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0" should be the same for all Linux devices. If running on a Windows or Mac you will need to configure for those ports ### 5. Running the bUE manually @@ -237,19 +226,6 @@ If you are not seeing any traffic: --- -## Development and Testing - -- The project uses `pytest` and `pytest-cov` (installed via `setup/requirements.txt`). -- From the project root (with the venv active): - -```bash -pytest -``` - -GitHub Actions workflows under `.github/workflows/` run basic code-quality and test checks on pushes/PRs. - ---- - ## Notes - This README is intentionally focused on getting a base station + one or more bUEs talking over Reyax modules for lake experiments. diff --git a/main.py b/main.py deleted file mode 100644 index 0fe497f..0000000 --- a/main.py +++ /dev/null @@ -1,156 +0,0 @@ -import sys -import time -import threading -from datetime import datetime -from loguru import logger - -from rich.live import Live -from rich.table import Table -from rich.console import Group -import survey # type:ignore - -from base_station_main import Base_Station_Main - - -def send_test(base_station, bue_indexes, file_name, start_time, parameters): - bues = [base_station.connected_bues[index] for index in bue_indexes] - for bue in bues: - base_station.testing_bues.append(bue) - base_station.ota.send_ota_message(bue, f"TEST-{file_name}-{start_time}-{parameters}") - - -def user_input_handler(base_station): - - ## TODO: GPS timing needs to be included - - COMMANDS = ("TEST", "DISTANCE", "DISCONNECT", "CANCEL", "LIST", "EXIT") - - FILES = ("lora_td_ru", "lora_tu_rd", "helloworld", "gpstest", "gpstest2") - - while not base_station.EXIT: ## TODO: Make sure that connected_bues_tests are taken out - try: - - index = survey.routines.select( - "Pick a command: ", - options=COMMANDS, - ) - - if index != 5 and len(base_station.connected_bues) == 0: - print("Currently not connected to any bUEs") - continue - - if index == 0: # TEST - connected_bues = tuple(str(x) for x in base_station.connected_bues) - if len(connected_bues) == 0: - print("Currently not connected to any bUEs") - - bues_indexes = survey.routines.basket("What bUEs will be running tests? ", options=connected_bues) - ## TODO: Should there be a check to see if a bUE is currently being tested or trust the user to handle this themselves? - - file_index = survey.routines.select("What file would you like to run? ", options=FILES) - file_name = FILES[file_index] - - start_time = survey.routines.datetime( - "When would you like to run the test? ", - attrs=("hour", "minute", "second"), - ).time() - ## TODO: It would be nice if these parameters setup to conincide with the script being run - - parameters = survey.routines.input("Enter parameters separated by a space:\n") - - send_test(base_station, bues_indexes, file_name, start_time, parameters) - - elif index == 1: # DISTANCE - connected_bues = tuple(str(x) for x in base_station.connected_bues) - if len(connected_bues) == 0: - print("Currently not connected to any bUEs") - - indexes = survey.routines.basket("Select two bUEs: ", options=connected_bues) - - ## TODO: Need to implement the rest of this once I fixed the coordinates - - bues = [] - for i in indexes: - bues.append(base_station.connected_bues[i]) - - print(base_station.bue_coordinates) - print(base_station.get_distance(bues[0], bues[1])) - - if index == 2: # DISCONNECT - connected_bues = tuple(str(x) for x in base_station.connected_bues) - if len(connected_bues) == 0: - print("Currently not connected to any bUEs") - - indexes = survey.routines.basket("What bUEs do you want to disconnect from? ", options=connected_bues) - - print("\n") - for i in indexes: - bue = base_station.connected_bues[i] - print(f"Disconnected from {base_station.connected_bues[i]}") - base_station.connected_bues.remove(bue) - print(f"Connected bUES: {base_station.connected_bues}") - if bue in base_station.bue_coordinates.keys(): - del base_station.bue_coordinates[bue] - print("\n") - - elif index == 3: # CANCEL - testing_bues = tuple(str(x) for x in base_station.testing_bues) - if len(testing_bues) == 0: - print("No bUEs are currently running any tests") - - indexes = survey.routines.basket("What bUE tests do you want to cancel? ", options=testing_bues) - - print("\n") - ## TODO: Send a CANC to each of these bUEs - for i in indexes: - bue = base_station.testing_bues[i] - print(f"Ending test for {base_station.testing_bues[i]}") - base_station.ota.send_ota_message(bue, "CANC") - logger.info(f"Sending CANC to {bue}") - print("\n") - - elif index == 4: # LIST - print("\n") - connected_bues = " ".join(str(bue) for bue in base_station.connected_bues) - print(f"Currently connected to {connected_bues}\n\n") - logger.info(f"Currently connected to {connected_bues}") - - elif index == 5: # EXIT - base_station.EXIT = True - base_station.__del__() - # if 'user_input_thread' in locals() and user_input_thread.is_alive(): - # user_input_thread.join(timeout=2.0) - sys.exit(0) - - except KeyboardInterrupt: - print("\n[Escape] Cancelled input. Returning to command prompt.\n") - continue - - except Exception as e: - logger.error(f"[User Input] Error {e}") - print(e) - - -if __name__ == "__main__": - start_time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - - logger.info(f"This marks the start of the base station service at {start_time}") - - try: - base_station = Base_Station_Main(yaml_str="config_base.yaml") - base_station.tick_enabled = True - - user_input_handler(base_station) - - while not base_station.EXIT: - time.sleep(0.2) - - except KeyboardInterrupt: - logger.info("Exiting the Base Station service") - if base_station is not None: - base_station.EXIT = True - time.sleep(0.5) - base_station.__del__() - # if 'user_input_thread' in locals() and user_input_thread.is_alive(): - # user_input_thread.join(timeout=2.0) - sys.exit(0) diff --git a/setup/config.example b/setup/config.example index bc3785a..7970ede 100644 --- a/setup/config.example +++ b/setup/config.example @@ -1,3 +1,2 @@ OTA_PORT: "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0" -OTA_ID: 1 OTA_BAUDRATE: 9600 diff --git a/setup/gpsd.sh b/setup/gpsd.sh index 50c4eae..8dc924f 100755 --- a/setup/gpsd.sh +++ b/setup/gpsd.sh @@ -5,7 +5,7 @@ sudo apt-get update sudo apt-get upgrade -y # Install necessary packages -sudo apt-get install -y gpsd gpsd-clients chrony +sudo apt-get install -y gpsd gpsd-clients chrony python3-gps # Stop gpsd service if running sudo systemctl stop gpsd.socket