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/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/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/README.md b/README.md index 23020e5..9401fa6 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,232 @@ -# 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 -``` +- `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: -``` -./launch_gui.sh +```bash +python3 -m venv uw_env +source uw_env/bin/activate ``` -If on Windows, run +### 2. Install Python dependencies -``` -python base_station_gui.py +Install the base station + GUI dependencies: + +```bash +pip install -r setup/requirements.txt ``` -## If on a bUE +> Note: `setup/requirements.txt` and `gui/requirements.txt` are aligned; installing from `setup/requirements.txt` is sufficient for both CLI and GUI. -### Install dependencies +### 3. Configure the base station Reyax module -The bUE should come with a venv. If it doesn't, create one. Install dependencies into this venv. +`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_BAUDRATE: 9600 ``` -pip install -r requirements_bue.txt -``` -Install `python3-gps` +**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 + +--- + +## Running the Base Station +Make sure your virtual environment is active and you are in the project root. + +# 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 ``` -sudo apt-get 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. 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 + +On the bUE device: + +```bash +git clone bUE-lake_tests +cd bUE-lake_tests ``` -Run `gpsd.sh` to get all the gps files configured. It is good to reboot after this +### 2. Install GPS-related system packages +On Debian/Raspberry Pi OS: + +```bash +sudo setup/gpsd.sh ``` -sudo ./gpsd.sh + +It is usually a good idea to reboot after configuring GPS: + +```bash +sudo reboot ``` -### Creating the `.service` file +### 3. Create a virtual environment and install dependencies -If this device is a Raspberry Pi bUE, then make sure that you have pointed systemd to start the process upon booting the Pi. +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 -``` -sudo cp bue.service.txt /etc/systemd/system/bue.service +```bash +python3 -m venv uw_env --system-site-packages +source uw_env/bin/activate +pip install -r setup/requirements_bue.txt ``` -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. -### Creating the `config.yaml` file +### 4. Configure the bUE Reyax module -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: +`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 ``` -cp config.example config.yaml + +Then edit `bue_config.yaml`. + +- `OTA_PORT` – Serial device for the bUE’s Reyax module. +- `OTA_BAUDRATE` – Usually `9600` unless your module is configured differently. + +**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 + +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. - -### Enabling the bUE service to run on power cycle +### 4. Enable on boot -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: - -``` +```bash sudo systemctl enable bue.service ``` -If you also wish the service to start during this power cycle, just run the `systemctl start` command above. +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. + +--- + +## 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/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/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 0000000..5226a64 Binary files /dev/null and b/gui/ui/image.png differ 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.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/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/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/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/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/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 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/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.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/setup/requirements_bue.txt b/setup/requirements_bue.txt index c0a21ec..73a9b30 100644 --- a/setup/requirements_bue.txt +++ b/setup/requirements_bue.txt @@ -1,3 +1,5 @@ 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 +PyYAML==6.0.3 \ No newline at end of file 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/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/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") 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,))