diff --git a/.gitignore b/.gitignore index e15106e..55e20f4 100644 --- a/.gitignore +++ b/.gitignore @@ -214,3 +214,7 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +# Project-specific: V-ZUG proprietary documentation +# Contains confidential protocol specifications and application telegrams +v_zug_documentation/ diff --git a/Ausschnitt aus 1040487-00.PNG b/Ausschnitt aus 1040487-00.PNG new file mode 100644 index 0000000..8dfb687 Binary files /dev/null and b/Ausschnitt aus 1040487-00.PNG differ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..715bf2b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,541 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**vzug-e-hinge Test & Control System** - A PyQt6-based desktop application for testing and controlling e-hinge devices through UART and I2C interfaces. The system provides automated test session execution, real-time data logging, packet detection, and telemetry storage in SQLite. + +## Running the Application + +```bash +# Install dependencies +pip install -r requirements.txt + +# Run the application (uses default database path: ./database/ehinge.db) +python main.py + +# Run with custom database path +python main.py /path/to/custom.db +``` + +## Database Management + +```bash +# Initialize/recreate database +python database/init_database.py + +# Recreate database (overwrite existing) +python database/init_database.py --overwrite + +# Check database health +python database/init_database.py --check +``` + +## Architecture + +### Application Entry Point +- `main.py` - Main application window with tabbed interface (Session, Configure Session, Configure Interface, UART, I2C, Graph) +- `MainWindow` class manages all tabs and coordinates database connection + +### Core Session Execution Flow + +The application uses a **three-layer execution model**: + +1. **Session Layer** (`session.py`) - Orchestrates the complete test session + - Loads interface and session profiles from database + - Opens/closes hardware ports (UART command, UART logger, I2C) + - Manages command sequence execution + - Handles pause/stop requests (queued after current run) + - Emits PyQt signals for real-time GUI updates + +2. **Run Layer** (`run.py`) - Executes a single RUN (one command with data collection) + - Configures packet detection with callback + - Sends UART command via command port + - Collects telemetry packets from logger port (if enabled) + - Triggers I2C reads correlated to UART timestamps + - Decodes data and saves to database + +3. **Hardware Layer** (`uart/uart_kit/uart_core.py`, `i2c/i2c_kit/i2c_core.py`) - Low-level interface management + - UART: Packet detection, circular buffer, reader threads, timestamping + - I2C: Bus operations, device scanning, continuous logging + +### Critical Architectural Concepts + +#### Dual UART Port System +The application uses **two separate UART ports** for different purposes: +- **UART Command Port** (TX/RX): Sends commands to device and receives ACK/responses +- **UART Logger Port** (RX only, optional): Receives telemetry packets from device +- This separation allows simultaneous command sending and telemetry logging +- Logger port can be disabled for simple command/response mode + +#### Packet Detection vs. Raw Mode +- **Packet Detection Enabled**: UART Logger port detects packets with start/end markers, used for telemetry streams +- **Packet Detection Disabled**: Raw TX/RX mode for simple command/response (set `print_command_rx=True` in session profile) + +#### Queued Pause/Stop +- Pause and Stop requests are **queued** and execute AFTER the current run completes +- Users can press pause/stop anytime, but it only takes effect during the delay phase between runs +- This prevents data corruption by ensuring runs complete atomically + +#### PGKomm2 Protocol (V-ZUG Serial Communication) +The e-hinge devices use **PGKomm2** protocol (V-ZUG spec A5.5093D-AB) for UART communication: + +**Frame Format:** +``` +DD 22 | ADR1 ADR2 | LEN | DATA (0-255 bytes) | BCC +MAGIC | Address | Len | Payload | Checksum +``` + +**Key Characteristics:** +- **MAGIC**: `0xDD` (221), **INVMAGIC**: `0x22` (34) +- **Length-delimited**: LEN field specifies DATA length (no terminator bytes like `\n`) +- **Address swap**: Slave response swaps ADR1/ADR2 (if master sends `PH`, slave responds `HP`) +- **BCC**: Block check character calculated from ADR1 through DATA (XOR checksum) - **VALIDATED on receive** +- **Timing**: Response time < 15 ms, inter-byte time < 10 ms +- **Baud rates**: 4800 or 115200 baud, 8N1 with **Even Parity** + +**Common Addresses:** +- `PH` (`0x50 0x48`): Command from master → Echo from slave +- `HP` (`0x48 0x50`): Response from slave +- `SB` (`0x53 0x42`): Status broadcast + +**Multiple Frames:** +Device typically sends multiple frames per command: +1. **Echo (PH)**: Device echoes received command +2. **Response (HP)**: Actual response with data +3. **Status (SB)**: Optional status update + +Example: +``` +TX: DD 22 50 48 02 43 4F 16 (Master sends PH command) +RX: DD 22 50 48 02 43 4F 16 (Echo: PH) +RX: DD 22 48 50 02 43 4F 16 (Response: HP, addresses swapped) +RX: DD 22 53 42 01 4E 5E (Status: SB) +``` + +**Documentation:** +- Protocol spec: `v_zug_documentation/PGKomm2_Spec_A5.5093D_AB.pdf` +- Application telegrams: `v_zug_documentation/PgKomm2_ Application Telegrams-v122-20230417_181422.pdf` + +**Quick Start (Final Working Implementation):** +1. **GUI defaults** automatically set: Parity=Even, Mode=PGKomm2, Timeout=30ms +2. **Connect** → waits 100ms settle delay +3. **First 1-2 commands may timeout** (device synchronization) +4. **After 3-4 commands** → rock solid reliability +5. **BCC errors logged to console** if frame corruption detected + +**Implementation Notes (`uart/uart_kit/uart_core.py::uart_send_and_read_pgkomm2()`):** + +The implementation uses a **simple single-loop approach** (matches old working code exactly): + +```python +# Flush buffer and send +reset_input_buffer() +uart_write(tx_data) + +# Single loop with deadline +deadline = now + 30ms # Total timeout (spec <15ms + real-world margin) +while now < deadline: + if bytes_available: + rx_buffer += read_all_available() # Read IMMEDIATELY + + # Extract frames using LEN field + while True: + frame = extract_frame(rx_buffer) # Uses LEN to know size + validate_BCC(frame) # Reject corrupted + + if frame is None: + break # Need more bytes + + collected_frames.append(frame) + + if frame.address == HP: + return collected_frames # Stop on HP! + + # No sleep - just spin +``` + +**Key Implementation Details:** +- **Single timeout (30ms)**: Total window for entire operation (spec says < 15ms, +15ms margin for real-world conditions) +- **Read immediately**: When bytes arrive, read them instantly (no delays) +- **No sleep**: Pure spinning for minimum latency +- **Stop on HP**: Exits immediately when HP response detected +- **BCC validation**: Every frame validated before acceptance - corrupted frames rejected and logged +- **LEN field**: Uses frame length to know exactly how many bytes to read +- **Simple**: No phases, no grace periods, just read-parse-detect loop +- **Debug logging**: TIMEOUT and IO_ERROR cases logged with buffer contents for diagnostics + +**Default GUI Settings (for PGKomm2):** +- **Parity**: Even (required by spec) +- **Mode**: PGKomm2 +- **Timeout**: 30ms (hidden, automatic) + +**Why 30ms (not 15ms)?** +- **Spec says < 15ms**: Ideal conditions only +- **Real world**: Background SB telemetry, device load, USB latency, reader thread stop/restart add overhead +- **Testing results**: 20ms still showed occasional timeouts, 30ms more reliable +- **Still fast**: Stops immediately on HP detection (typically 8-12ms actual response time) + +**Error Handling:** +- **TIMEOUT**: No data received within 30ms (device not responding) or got frames but no HP response +- **IO_ERROR**: Unparseable data in buffer (incomplete frame or BCC failure) +- **BCC ERROR**: Logged to console when checksum validation fails (electrical noise/corruption) + - Format: `[PGKOMM2] ✗ BCC FAIL: ADR=XX YY, calc=ZZ, recv=WW` + - Shows calculated vs received BCC and full frame hex dump + +**Connection Behavior:** +- **100ms settle delay**: After port opens, waits 100ms for device to stabilize +- **Reason**: Device may reset on DTR/RTS change, needs boot/initialization time +- **First commands**: May still timeout if device is sending SB status broadcasts +- **After 3-4 commands**: Device synchronized, all commands work reliably + +**Threading Architecture:** +- **UART Reader Thread**: Always running (except during PGKomm2 operations) + - Reads serial port → circular buffer continuously + - Most modes read from circular buffer +- **PGKomm2**: No worker thread - called directly from GUI thread + - Blocks GUI for ~30ms (acceptable, keeps threading simple) + - **Temporarily stops reader thread** for exclusive serial access (prevents race condition) + - Reads directly from serial port (not circular buffer - lower latency) + - Restarts reader thread after completion +- **Why stop reader thread?**: PGKomm2 needs direct serial reads for low latency. Stopping reader prevents both threads competing for same bytes. + +**Key Design Decisions (Lessons Learned):** + +1. **Simplicity Over Complexity** + - Tried: Worker threads, two-phase reading, packet-driven with circular buffer + - Final: Direct serial reads with reader thread temporarily stopped + - Lesson: Simple approach that matches old working code is best + +2. **Timeout Tuning** + - Spec: < 15ms response time + - Reality: 30ms needed (background telemetry, thread overhead, USB latency) + - Testing: 15ms → occasional timeouts, 20ms → still issues, 30ms → reliable + +3. **Thread Safety** + - Problem: Reader thread and PGKomm2 competing for serial bytes (race condition) + - Solution: Stop reader during PGKomm2 operations (exclusive access) + - Benefit: Simple, predictable, no race conditions + +4. **BCC Validation Critical** + - Initially: Not validated (trusted device) + - Reality: Occasional electrical noise/corruption + - Solution: Validate BCC, log errors, reject corrupted frames + - Result: More robust, easier debugging + +5. **First Commands After Connect** + - Device needs time to stabilize after DTR/RTS change + - 100ms settle delay helps but not sufficient + - Accept first 1-2 timeouts as normal device synchronization + - After 3-4 commands: rock solid + +**PGKomm2 Implementation Status: ✅ Production Ready** + +### Database Schema + +Located in `database/init_database.py`: + +**Profiles:** +- `interface_profiles` - Hardware configuration (UART command/logger ports, I2C bus, packet detection settings) +- `session_profiles` - Test sequences (JSON command list with delays and repetitions) +- `gui_profiles` - GUI appearance settings + +**Execution:** +- `sessions` - Session records (links to profiles, tracks total runs, status) +- `uart_commands` - Predefined UART commands (hex strings, categories) +- `i2c_commands` - Predefined I2C operations (register access) + +**Telemetry:** +- `telemetry_raw` - Raw backup data (UART packets and I2C bytes as BLOB) +- `telemetry_decoded` - Processed data (motor current, encoder values, angles, etc.) + +Both telemetry tables use nanosecond timestamps (`t_ns`) for precise correlation between UART and I2C data. + +### Widget Organization + +**Configuration Widgets:** +- `configure_interface_widget.py` - Create/edit interface profiles (UART/I2C settings) +- `configure_session_widget.py` - Create/edit session profiles (command sequences) + +**Control Widgets:** +- `session_widget.py` - Main control panel for running sessions (start/pause/stop, profile selection, status display) +- `uart/uart_integrated_widget.py` - Manual UART testing (command table + direct control) +- `i2c/i2c_integrated_widget.py` - Manual I2C testing (command table + direct control) + +**UART Widget Modes (`uart/uart_kit/uart_core_widget.py`):** +The UART widget supports 4 operational modes: + +1. **Request-Response**: Send command, wait for response packet (timeout or terminator-based) + - Uses packet detection with configurable stop condition + - Best for generic packet-based protocols + +2. **Polling**: Continuous packet detection with grace period + - Waits for first byte (grace period), then captures burst + - Supports packet detection with callbacks + - Best for streaming telemetry + +3. **Listening**: Raw stream mode with no stop condition + - Captures everything continuously + - Manual buffer reading required + - Best for debugging and raw data capture + +4. **PGKomm2**: V-ZUG protocol with DD 22 framing and automatic HP detection + - **Automatic response detection** - No timing configuration needed + - Stops immediately when HP response detected (typically 5-10ms) + - 20ms safety timeout (hardcoded, hidden from user) + - Automatically parses multiple frames (PH echo + HP response) + - Identifies frame types by address bytes (PH/HP/SB) + - **Requires EVEN parity** (115200 8E1 per spec) + - **Use this mode for e-hinge device communication** + +**Session Worker:** +- `session_worker.py` - QThread wrapper for running sessions in background without blocking GUI + +### Supporting Modules + +- `decoder.py` - Packet decoding logic (UART telemetry and I2C angle conversion) +- `global_clock.py` - Shared timestamp source for UART/I2C synchronization +- `graph_table_query.py` - Database query utilities for graphing +- `buffer_kit/circular_buffer.py` - Thread-safe circular buffer for data streams +- `command_table/command_table.py` - Reusable command table widget + +## Development Workflow + +### Adding New UART Commands +1. Insert into `uart_commands` table via database or GUI +2. Commands are immediately available in session profiles and manual testing +3. Format: `hex_string` as space-separated hex bytes (e.g., "DD 22 50 48 02 41 52 09") + +### Creating Test Profiles +1. Create interface profile with port settings and packet detection config +2. Create session profile with JSON command sequence: +```json +{ + "commands": [ + { + "command_id": 1, + "delay_ms": 3000 + }, + { + "command_id": 5, + "delay_ms": 5000 + } + ] +} +``` +3. Use session widget to execute profile + +### Database Size Management +- Max size: 2 GB (configured in `init_database.py`) +- Monitor via status bar (shows percentage and color-coded warning) +- Use "Database > Vacuum Database" to reclaim space after deletions + +## Port Configuration Notes + +**UART Ports:** +- Linux: `/dev/ttyUSB0`, `/dev/ttyACM0`, etc. +- Must have read/write permissions (add user to `dialout` group) +- Typical baud rates: 9600, 115200 + +**I2C Bus:** +- Linux: Bus ID corresponds to `/dev/i2c-X` (e.g., bus_id=1 means `/dev/i2c-1`) +- Requires `smbus2` library +- Must have read/write permissions (add user to `i2c` group) + +## Signal/Slot Architecture + +The Session class emits PyQt signals for GUI updates: +- `session_started(session_id)` - Session begins +- `command_started(command_no, command_name)` - Command starts +- `run_completed(run_no, packet_count)` - Run finishes +- `delay_countdown(seconds_remaining)` - Countdown during delays +- `session_paused()` - Session paused +- `session_finished()` - Session completes +- `error_occurred(error_message)` - Error encountered +- `status_changed(status_text)` - General status update +- `raw_data_received(direction, hex_string)` - TX/RX data for display + +Connect to these signals in widgets for real-time updates without polling. + +## Testing + +Individual modules can be tested directly: +```bash +# Test UART core +python uart/uart_kit/uart_core.py + +# Test I2C core +python i2c/i2c_kit/i2c_core.py + +# Test circular buffer +python buffer_kit/circular_buffer_test.py +``` + +## Code Style Notes + +- Type hints used throughout (Python 3.10+) +- Dataclasses for configuration structures +- Enums for status codes and modes +- Thread-safe operations with locks where needed +- Extensive docstrings with Args/Returns/Raises sections + +--- + +## Technical Challenges & Solutions + +### Challenge 1: GUI Threading & Responsiveness +**Problem:** Qt requires GUI to never block. Session execution is a long-running loop that would freeze the interface. + +**Solution:** `session_worker.py` - QThread wrapper that: +- Runs session in background thread +- Creates thread-local SQLite connection (SQLite objects cannot cross threads) +- Uses Qt signals for thread-safe GUI updates +- Swaps database connections before/after execution + +**Critical Code:** +```python +# session_worker.py +thread_db_conn = sqlite3.connect(self.db_path) # Thread-local +self.session.db_conn = thread_db_conn # Temporary swap +self.session.start_session() # Blocking call runs in background +``` + +### Challenge 2: Timestamp Correlation (UART ↔ I2C) +**Problem:** UART packets arrive at unpredictable times. I2C angle readings must be timestamped at the exact moment the UART packet is detected to correlate motor state with door position. + +**Solution:** Callback-based triggering in `run.py`: +```python +def on_uart_packet_detected(timestamp_ns: int): + """Called IMMEDIATELY when packet detected""" + if i2c_port: + status, i2c_bytes = i2c_read_block(i2c_port, ...) + store_with_timestamp(timestamp_ns, i2c_bytes) # Same timestamp! +``` + +**Flow:** +1. UART reader thread detects packet start → captures `timestamp_ns` +2. Calls callback immediately with timestamp +3. Callback reads I2C angle sensor +4. Both readings share identical timestamp → perfect correlation in database + +**Hardware Notes:** +- Two physically separate USB UART interfaces (command + logger) +- I2C read must complete before next UART packet arrives +- Blocking I2C read is acceptable if fast enough (<17 byte receive time @ baud rate) + +### Challenge 3: Circular Buffer Absolute Positioning +**Problem:** Standard circular buffers wrap around. Need to extract data spans that might cross the wrap boundary using absolute offsets. + +**Solution:** `buffer_kit/circular_buffer.py` tracks `write_absolute` (total bytes ever written): +```python +write_absolute: int # Monotonic counter, never wraps +position_in_buffer = write_absolute % capacity # Actual index + +# Extract span handles wrapping automatically +cb_copy_span(buffer, start_abs=1000, end_abs=1050) # Works even if wrapped +``` + +**Use Case:** Read UART/I2C ring buffers after run completes, even if data wrapped during acquisition. + +### Challenge 4: Grace Period vs Stop Timeout +**Problem:** Device may take time to respond. Need to distinguish: +- **Grace period**: Wait for FIRST byte after command sent +- **Stop timeout**: Silence between packets within a burst + +**Solution in UART config:** +```python +polling_mode=True # Enable grace period +grace_timeout_ms=150 # Wait up to 150ms for first byte +stop_timeout_ms=150 # Silence between packets +``` + +**Behavior:** +1. Send command +2. Wait up to `grace_timeout_ms` for first byte → If no byte: "Device not responding" +3. Once first byte arrives, switch to stop condition mode +4. If `stop_timeout_ms` passes with no data → Packet burst complete + +**Configured per interface profile** in `interface_profiles` table. + +### Challenge 5: Buffer Sizing +**Configuration:** 40MB buffers for both UART and I2C + +**Rationale:** +- Buffers cleared per run, not shared across runs +- Large buffers prevent overflow during long runs +- System has 8GB RAM + 32GB NVMe → 40MB per port is negligible +- Prevents data loss if device sends unexpected burst + +**Locations:** +- Default: `uart_core.py` line 139, `i2c_core.py` line 72 +- Session overrides: `session.py` _open_ports() method + +### Challenge 6: Error Reporting +**Requirement:** Track I2C read failures without failing the entire run. + +**Solution in `run.py`:** +```python +self.i2c_failures = 0 # Counter initialized + +# In callback: +if i2c_read_status != OK: + self.i2c_failures += 1 # Count but continue + +# At end of run: +if self.i2c_failures > 0: + raw_data_callback("ERROR", f"I2C failures: {self.i2c_failures}") +``` + +**Display:** Errors appear in GUI Data Monitor, not as run failure. + +### Challenge 7: Queued Pause/Stop +**Problem:** User presses Stop during run execution. Cannot stop immediately (would corrupt data, leave hardware in bad state). + +**Solution:** Flag-based queuing: +```python +self.stop_queued = False # Flag set by user action + +# In command loop: +for cmd in commands: + if self.stop_queued: # Check BEFORE starting run + finalize_and_return() + + execute_run(...) # Runs atomically + + if self.stop_queued: # Check during delay + finalize_and_return() +``` + +**Behavior:** Pause/Stop take effect between runs, not during. Current run always completes. + +## Configuration Details + +### Stop Timeout Loading +**Fixed:** Stop timeout and grace period are now loaded from `interface_profiles` table and passed to `execute_run()`. + +**Previous Issue:** Hardcoded 5000ms timeout → Now uses configured value (typically 150ms) + +**Files Modified:** +- `session.py` line 190: Added `uart_logger_grace_ms` to SQL query +- `session.py` line 222: Added to `interface_config` dict +- `session.py` line 742: Changed from hardcoded 5000 to `interface_config['uart_logger_timeout_ms']` +- `session.py` lines 341-349, 378-388: Full UART config with all parameters +- `session.py` line 476: I2C config with 40MB buffer + +### Typical Configuration Values +```python +uart_logger_timeout_ms = 150 # Packet silence timeout +uart_logger_grace_ms = 150 # Wait for first byte +uart_command_timeout_ms = 1000 # Command response timeout +buffer_size = 40 * 1024 * 1024 # 40MB per port +``` + +## Diagrams + +Block diagrams available in `diagram/` folder: +- `Block Diagram.xml` - Overall system architecture (3 layers) +- `run.xml` - Single RUN execution flow +- `session.xml` - Session configuration and command loop + +View with draw.io or compatible tool. diff --git a/configure_interface_widget.py b/configure_interface_widget.py index 4d82caf..087e447 100644 --- a/configure_interface_widget.py +++ b/configure_interface_widget.py @@ -25,7 +25,7 @@ import glob # Standard UART settings -BAUD_RATES = ["9600", "19200", "38400", "57600", "115200", "230400", "460800", "921600"] +BAUD_RATES = ["9600", "19200", "38400", "57600", "115200", "230400", "256000" ,"460800", "921600"] PARITY_OPTIONS = ["N", "E", "O", "M", "S"] diff --git a/database/ehinge.db b/database/ehinge.db index 46361b8..a4c1f84 100644 Binary files a/database/ehinge.db and b/database/ehinge.db differ diff --git a/database/test_system.db b/database/test_system.db new file mode 100644 index 0000000..e69de29 diff --git a/diagram/Block Diagram.xml b/diagram/Block Diagram.xml new file mode 100644 index 0000000..a879763 --- /dev/null +++ b/diagram/Block Diagram.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/diagram/run.xml b/diagram/run.xml new file mode 100644 index 0000000..9a9a156 --- /dev/null +++ b/diagram/run.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/diagram/session.xml b/diagram/session.xml new file mode 100644 index 0000000..62ed4ed --- /dev/null +++ b/diagram/session.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/i2c/i2c_kit/i2c_core.py b/i2c/i2c_kit/i2c_core.py index 4875354..57df00d 100644 --- a/i2c/i2c_kit/i2c_core.py +++ b/i2c/i2c_kit/i2c_core.py @@ -63,13 +63,13 @@ class Status(Enum): class I2CConfig: """ I2C bus configuration. - + bus_id: Bus number (e.g., 1 for /dev/i2c-1) buffer_size: Logger circular buffer capacity timestamp_source: Optional external time function (for sync with UART) """ bus_id: int - buffer_size: int = 4096 + buffer_size: int = 40 * 1024 * 1024 # 40MB default buffer timestamp_source: Optional[Callable[[], float]] = None diff --git a/main.py b/main.py index 840cf23..8d9fef4 100644 --- a/main.py +++ b/main.py @@ -369,6 +369,21 @@ class MainWindow(QMainWindow): self.session_widget.session.status_changed.connect(self._on_session_status_changed_internal) except Exception as e: print(f"Warning: Could not connect to session signals: {e}") + + # Connect UART widget TX/RX to Session widget's data monitor + if hasattr(self, 'uart_widget') and self.uart_widget and self.session_widget: + try: + # Connect TX signal (bytes) → convert to hex string → display + self.uart_widget.uart_core.data_sent.connect( + lambda data: self.session_widget._on_raw_data_received("TX", data.hex(' ').upper()) + ) + # Connect RX signal (bytes, info) → convert to hex string → display + self.uart_widget.uart_core.data_received_display.connect( + lambda data, info: self.session_widget._on_raw_data_received("RX", data.hex(' ').upper()) + ) + print("[Main] Connected UART widget TX/RX to Session data monitor") + except Exception as e: + print(f"Warning: Could not connect UART signals to data monitor: {e}") # ========================================================================= # Signal Handlers - Session Widget Actions diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index c4bbd7d..0000000 --- a/requirements.txt +++ /dev/null @@ -1,15 +0,0 @@ -contourpy==1.3.3 -cycler==0.12.1 -fonttools==4.60.1 -kiwisolver==1.4.9 -matplotlib==3.10.7 -numpy==2.3.4 -packaging==25.0 -pillow==12.0.0 -pyparsing==3.2.5 -PyQt6==6.10.0 -PyQt6-Qt6==6.10.0 -PyQt6_sip==13.10.2 -pyserial==3.5 -python-dateutil==2.9.0.post0 -six==1.17.0 diff --git a/run.py b/run.py index 77639c4..5a8c254 100644 --- a/run.py +++ b/run.py @@ -62,12 +62,13 @@ class RunExecutor: def __init__(self, db_connection: sqlite3.Connection): """ Initialize run executor. - + Args: db_connection: Database connection """ self.db_conn = db_connection self.i2c_readings = [] # Storage for I2C readings from callback + self.i2c_failures = 0 # Counter for I2C read failures def execute_run( self, @@ -109,7 +110,8 @@ class RunExecutor: if uart_logger_port: uart_clear_detected_packets(uart_logger_port) self.i2c_readings.clear() - + self.i2c_failures = 0 # Reset error counter + # Record run start time run_start_ns = time.time_ns() @@ -141,6 +143,9 @@ class RunExecutor: 'timestamp_ns': timestamp_ns, 'i2c_bytes': i2c_bytes }) + else: + # I2C read failed - count the failure + self.i2c_failures += 1 # Create packet config with callback packet_config_with_callback = PacketConfig( @@ -250,7 +255,11 @@ class RunExecutor: # Commit database changes self.db_conn.commit() - + + # Report errors if any via callback + if self.i2c_failures > 0 and raw_data_callback: + raw_data_callback("ERROR", f"I2C read failures: {self.i2c_failures}") + return ("success", packet_count, "") except Exception as e: diff --git a/session.py b/session.py index 930663b..92234c1 100644 --- a/session.py +++ b/session.py @@ -136,10 +136,10 @@ class Session(QObject): self.i2c_handle: Optional[I2CHandle] = None self.packet_config: Optional[PacketConfig] = None - # Command sequence (loaded from session_profile) - self.commands: List[Dict[str, Any]] = [] - self.total_commands: int = 0 - self.current_command_index: int = 0 + # Phase execution (multi-phase support: Init, Execute, De-init) + self.phases: List[Dict[str, Any]] = [] # List of phase configs + self.total_commands: int = 0 # Total commands across all phases + self.current_command_index: int = 0 # Global command counter # Execution control flags self.is_running: bool = False @@ -154,24 +154,28 @@ class Session(QObject): def load_session( self, interface_profile_id: int, - session_profile_id: int, + init_session_id: Optional[int] = None, + execute_session_id: Optional[int] = None, + deinit_session_id: Optional[int] = None, session_name: Optional[str] = None ) -> bool: """ - Load session and interface profiles from database. - + Load session and interface profiles from database with 3 phases. + This method: 1. Reads interface_profile (UART/I2C config, packet detection) - 2. Reads session_profile (command sequence JSON) - 3. Parses command sequence + 2. Reads up to 3 session_profiles (Init, Execute, De-init) + 3. Parses command sequences for each phase 4. Validates all commands exist in uart_commands table 5. Stores configuration for execution - + Args: interface_profile_id: ID from interface_profiles table - session_profile_id: ID from session_profiles table + init_session_id: ID for Init phase (optional, None to skip) + execute_session_id: ID for Execute phase (optional, None to skip) + deinit_session_id: ID for De-init phase (optional, None to skip) session_name: Custom session name (auto-generated if None) - + Returns: True if successful, False on error """ @@ -181,12 +185,13 @@ class Session(QObject): # =================================================================== cursor = self.db_conn.execute(""" - SELECT + SELECT profile_name, uart_command_port, uart_command_baud, uart_command_data_bits, uart_command_stop_bits, uart_command_parity, uart_command_timeout_ms, uart_logger_enable, uart_logger_port, uart_logger_baud, uart_logger_data_bits, uart_logger_stop_bits, uart_logger_parity, uart_logger_timeout_ms, + uart_logger_grace_ms, uart_logger_packet_detect_enable, uart_logger_packet_detect_start, uart_logger_packet_detect_length, uart_logger_packet_detect_end, i2c_port, i2c_slave_address, i2c_slave_read_register, i2c_slave_read_length @@ -218,101 +223,166 @@ class Session(QObject): 'uart_logger_stop_bits': row[11], 'uart_logger_parity': row[12], 'uart_logger_timeout_ms': row[13], + 'uart_logger_grace_ms': row[14], # Packet detection - 'packet_detect_enable': row[14], - 'packet_detect_start': row[15], - 'packet_detect_length': row[16], - 'packet_detect_end': row[17], + 'packet_detect_enable': row[15], + 'packet_detect_start': row[16], + 'packet_detect_length': row[17], + 'packet_detect_end': row[18], # I2C configuration - 'i2c_port': row[18], - 'i2c_slave_address': row[19], - 'i2c_slave_read_register': row[20], - 'i2c_slave_read_length': row[21] + 'i2c_port': row[19], + 'i2c_slave_address': row[20], + 'i2c_slave_read_register': row[21], + 'i2c_slave_read_length': row[22] } # =================================================================== - # 2. Load session profile (command sequence) + # 2. Load session profiles (up to 3 phases: Init, Execute, De-init) + # =================================================================== + + # Storage for phase configurations + self.phases = [] # List of dicts: {'name': str, 'commands': list, 'profile_id': int} + self.total_commands = 0 + + # Load Init phase + if init_session_id is not None: + success, phase_config = self._load_phase_profile(init_session_id, "Init") + if not success: + return False + if phase_config: + self.phases.append(phase_config) + self.total_commands += len(phase_config['commands']) + + # Load Execute phase + if execute_session_id is not None: + success, phase_config = self._load_phase_profile(execute_session_id, "Execute") + if not success: + return False + if phase_config: + self.phases.append(phase_config) + self.total_commands += len(phase_config['commands']) + + # Load De-init phase + if deinit_session_id is not None: + success, phase_config = self._load_phase_profile(deinit_session_id, "De-init") + if not success: + return False + if phase_config: + self.phases.append(phase_config) + self.total_commands += len(phase_config['commands']) + + # Check at least one phase loaded + if len(self.phases) == 0: + self.error_occurred.emit("No session phases selected") + return False + + # =================================================================== + # 3. Generate session name if not provided + # =================================================================== + + if session_name is None or session_name.strip() == "": + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + phase_names = "_".join([p['name'] for p in self.phases]) + self.session_name = f"{phase_names}_{timestamp}" + else: + self.session_name = session_name + + # =================================================================== + # 4. Emit success status # =================================================================== + + phase_summary = ", ".join([f"{p['name']}({len(p['commands'])} cmds)" for p in self.phases]) + self.status_changed.emit(f"Multi-phase session loaded: {self.session_name}") + self.status_changed.emit(f"Interface: {self.interface_config['profile_name']}") + self.status_changed.emit(f"Phases: {phase_summary}") + self.status_changed.emit(f"Total commands: {self.total_commands}") + return True + + except Exception as e: + self.error_occurred.emit(f"Failed to load session: {str(e)}") + return False + + def _load_phase_profile(self, profile_id: int, phase_name: str) -> tuple[bool, Optional[dict]]: + """ + Load a single session profile for a phase. + + Args: + profile_id: Session profile ID + phase_name: Phase name ("Init", "Execute", or "De-init") + + Returns: + (success, phase_config) where phase_config is: + {'name': str, 'commands': list, 'profile_id': int, 'profile_name': str} + or None if profile has no commands + """ + try: cursor = self.db_conn.execute(""" SELECT profile_name, command_sequence, description, print_command_rx FROM session_profiles WHERE profile_id = ? - """, (session_profile_id,)) - + """, (profile_id,)) + row = cursor.fetchone() if not row: - self.error_occurred.emit(f"Session profile {session_profile_id} not found") - return False - - self.session_profile_id = session_profile_id + self.error_occurred.emit(f"{phase_name} session profile {profile_id} not found") + return (False, None) + profile_name = row[0] command_sequence_json = row[1] - self.print_command_rx = bool(row[3]) if len(row) > 3 else False - + print_command_rx = bool(row[3]) if len(row) > 3 else False + # Parse JSON command sequence try: command_sequence = json.loads(command_sequence_json) - self.commands = command_sequence.get('commands', []) - self.total_commands = len(self.commands) + commands = command_sequence.get('commands', []) except json.JSONDecodeError as e: - self.error_occurred.emit(f"Invalid JSON in session profile: {str(e)}") - return False - - if self.total_commands == 0: - self.error_occurred.emit("Session profile has no commands") - return False - - # =================================================================== - # 3. Validate all commands exist in database - # =================================================================== - - for cmd in self.commands: + self.error_occurred.emit(f"{phase_name}: Invalid JSON - {str(e)}") + return (False, None) + + # Empty profile is okay (skip phase) + if len(commands) == 0: + self.status_changed.emit(f"{phase_name} phase has no commands, skipping") + return (True, None) + + # Validate all commands exist in database + for cmd in commands: cmd_id = cmd.get('command_id') if not cmd_id: - self.error_occurred.emit("Command missing command_id") - return False - - # Check if command exists + self.error_occurred.emit(f"{phase_name}: Command missing command_id") + return (False, None) + + # Check if command exists and load details cursor = self.db_conn.execute(""" SELECT command_name, hex_string FROM uart_commands WHERE command_id = ? """, (cmd_id,)) - + row = cursor.fetchone() if not row: - self.error_occurred.emit(f"UART command {cmd_id} not found") - return False - + self.error_occurred.emit(f"{phase_name}: UART command {cmd_id} not found") + return (False, None) + # Store command details cmd['command_name'] = row[0] cmd['hex_string'] = row[1] - - # =================================================================== - # 4. Generate session name if not provided - # =================================================================== - - if session_name is None or session_name.strip() == "": - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - self.session_name = f"{profile_name}_{timestamp}" - else: - self.session_name = session_name - - # =================================================================== - # 5. Emit success status - # =================================================================== - - self.status_changed.emit(f"Session loaded: {self.session_name}") - self.status_changed.emit(f"Interface: {self.interface_config['profile_name']}") - self.status_changed.emit(f"Commands: {self.total_commands}") - - return True - + + # Create phase config + phase_config = { + 'name': phase_name, + 'profile_name': profile_name, + 'profile_id': profile_id, + 'commands': commands, + 'print_command_rx': print_command_rx + } + + return (True, phase_config) + except Exception as e: - self.error_occurred.emit(f"Failed to load session: {str(e)}") - return False - + self.error_occurred.emit(f"{phase_name}: Failed to load - {str(e)}") + return (False, None) + # ========================================================================= # PORT MANAGEMENT # ========================================================================= @@ -338,7 +408,12 @@ class Session(QObject): # Create UART command config cmd_uart_config = UARTConfig( device=self.interface_config['uart_command_port'], - baudrate=self.interface_config['uart_command_baud'] + baudrate=self.interface_config['uart_command_baud'], + data_bits=self.interface_config['uart_command_data_bits'], + stop_bits=self.interface_config['uart_command_stop_bits'], + parity=self.interface_config['uart_command_parity'], + buffer_size=40 * 1024 * 1024, # 40MB buffer + stop_timeout_ms=self.interface_config['uart_command_timeout_ms'] ) # Create UART command port @@ -371,11 +446,18 @@ class Session(QObject): if self.interface_config['uart_logger_enable']: # Logger enabled - open logger port - + # Create UART logger config log_uart_config = UARTConfig( device=self.interface_config['uart_logger_port'], - baudrate=self.interface_config['uart_logger_baud'] + baudrate=self.interface_config['uart_logger_baud'], + data_bits=self.interface_config['uart_logger_data_bits'], + stop_bits=self.interface_config['uart_logger_stop_bits'], + parity=self.interface_config['uart_logger_parity'], + buffer_size=40 * 1024 * 1024, # 40MB buffer + stop_timeout_ms=self.interface_config['uart_logger_timeout_ms'], + grace_timeout_ms=self.interface_config['uart_logger_grace_ms'], + polling_mode=True # Enable grace period for first byte ) # Create UART logger port @@ -421,9 +503,10 @@ class Session(QObject): # =================================================================== # 3. Create PacketConfig from interface profile # =================================================================== - - # Check if print_command_rx is enabled (session profile override) - if hasattr(self, 'print_command_rx') and self.print_command_rx: + + # Check if ANY phase has print_command_rx enabled (session profile override) + any_print_rx = any(phase.get('print_command_rx', False) for phase in self.phases) + if any_print_rx: # Force packet detection OFF - just print TX/RX to data monitor self.packet_config = PacketConfig(enable=False) elif not self.interface_config['uart_logger_enable']: @@ -452,95 +535,14 @@ class Session(QObject): # =================================================================== # 4. Open I2C Port (optional - for angle readings) # =================================================================== - - if self.interface_config['i2c_port']: - # Parse I2C address - - # Create I2C config - i2c_config = I2CConfig( - bus_id=int(self.interface_config["i2c_port"]), - ) - - # Create I2C handle - status, self.i2c_handle = i2c_create(i2c_config) - - if status != I2CStatus.OK or self.i2c_handle is None: - # I2C is optional, just warn - self.status_changed.emit("Warning: Could not create I2C handle") - self.i2c_handle = None - else: - # Open I2C - status = i2c_open(self.i2c_handle) - - if status != I2CStatus.OK: - # I2C is optional, just warn - self.status_changed.emit("Warning: I2C port not available") - self.i2c_handle = None - else: - self.status_changed.emit(f"I2C opened: bus {self.interface_config['i2c_port']}") - - return True - - except Exception as e: - self.error_occurred.emit(f"Failed to open ports: {str(e)}") - self._close_ports() - return False - - # Open UART port - status = uart_open(self.uart_port) - - if status != UARTStatus.OK: - self.error_occurred.emit(f"Failed to open UART port {uart_config.port}") - return False - - # Start UART reader thread - status = uart_start_reader(self.uart_port) - if status != UARTStatus.OK: - uart_close(self.uart_port) - self.uart_port = None - self.error_occurred.emit("Failed to start UART reader") - return False - - self.status_changed.emit(f"UART opened: {uart_config.port}") - - # =================================================================== - # 2. Create PacketConfig from interface profile - # =================================================================== - - # Check if print_command_rx is enabled (session profile override) - if hasattr(self, 'print_command_rx') and self.print_command_rx: - # Force packet detection OFF - just print TX/RX to data monitor - self.packet_config = PacketConfig(enable=False) - elif self.interface_config['packet_detect_enable']: - # Parse hex markers - start_marker = bytes.fromhex( - self.interface_config['packet_detect_start'].replace(' ', '') - ) if self.interface_config['packet_detect_start'] else None - - end_marker = bytes.fromhex( - self.interface_config['packet_detect_end'].replace(' ', '') - ) if self.interface_config['packet_detect_end'] else None - - self.packet_config = PacketConfig( - enable=True, - start_marker=start_marker, - packet_length=self.interface_config['packet_detect_length'], - end_marker=end_marker, - on_packet_callback=None # Will be set by run.py - ) - else: - self.packet_config = PacketConfig(enable=False) - - # =================================================================== - # 3. Open I2C Port (optional - for angle readings) - # =================================================================== - + if self.interface_config['i2c_port']: # Parse I2C address - + # Create I2C config i2c_config = I2CConfig( bus_id=int(self.interface_config["i2c_port"]), + buffer_size=40 * 1024 * 1024 # 40MB buffer ) # Create I2C handle @@ -562,12 +564,12 @@ class Session(QObject): self.status_changed.emit(f"I2C opened: bus {self.interface_config['i2c_port']}") return True - + except Exception as e: self.error_occurred.emit(f"Failed to open ports: {str(e)}") self._close_ports() return False - + def _close_ports(self): """ Close UART and I2C ports. @@ -621,8 +623,8 @@ class Session(QObject): if self.is_running: self.error_occurred.emit("Session already running") return False - - if not self.commands: + + if not hasattr(self, 'phases') or len(self.phases) == 0: self.error_occurred.emit("No session loaded") return False @@ -634,7 +636,12 @@ class Session(QObject): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") self.current_session_id = f"session_{timestamp}" session_date = datetime.now().strftime("%Y-%m-%d") - + + # NOTE: Database schema currently supports single session_profile_id + # We use the first phase's profile_id as the primary reference + # All phases and their profiles are tracked in self.phases list + first_phase_profile_id = self.phases[0]['profile_id'] if len(self.phases) > 0 else None + self.db_conn.execute(""" INSERT INTO sessions ( session_id, session_name, session_date, @@ -646,9 +653,9 @@ class Session(QObject): self.session_name, session_date, self.interface_profile_id, - self.session_profile_id + first_phase_profile_id )) - + self.db_conn.commit() # =================================================================== @@ -688,110 +695,131 @@ class Session(QObject): def _execute_command_loop(self): """ - Execute all commands in sequence. - + Execute all commands in sequence across multiple phases. + This is the main execution loop that: - 1. Iterates through all commands - 2. Calls run.py for each command - 3. Handles delays with countdown - 4. Checks pause/stop queue between runs - 5. Updates database and emits signals - + 1. Iterates through all phases (Init, Execute, De-init) + 2. Iterates through commands within each phase + 3. Calls run.py for each command + 4. Handles delays with countdown + 5. Checks pause/stop queue between runs + 6. Updates database and emits signals + CRITICAL: Pause/Stop are QUEUED and only execute between runs - during the delay phase, never during a run itself. + during the delay phase, never during a run itself. Stop cancels + ALL remaining phases. """ try: - # Loop through all commands - for cmd_index, cmd in enumerate(self.commands, 1): - self.current_command_index = cmd_index - - # =============================================================== - # 1. Check if stop was queued (before starting new run) - # =============================================================== - - if self.stop_queued: - self.status_changed.emit("Session stopped by user") - self._finalize_session('aborted') - return - - # =============================================================== - # 2. Emit command started - # =============================================================== - - command_name = cmd['command_name'] - self.command_started.emit(cmd_index, command_name) - self.status_changed.emit(f"Command {cmd_index}/{self.total_commands}: {command_name}") - - # =============================================================== - # 3. Execute run via run.py - # =============================================================== - - status, packet_count, error_msg = execute_run( - db_connection=self.db_conn, - session_id=self.current_session_id, - session_name=self.session_name, - run_no=cmd_index, - command_id=cmd['command_id'], - command_hex=cmd['hex_string'], - uart_command_port=self.uart_command_port, - uart_logger_port=self.uart_logger_port, - i2c_port=self.i2c_handle, # Note: run.py calls it i2c_port but it's actually I2CHandle - packet_config=self.packet_config, - stop_timeout_ms=5000, - raw_data_callback=lambda direction, hex_str: self.raw_data_received.emit(direction, hex_str) - ) - - # =============================================================== - # 4. Handle run result - # =============================================================== - - if status == "error": - # Run failed - abort session - self.error_occurred.emit(f"Run {cmd_index} failed: {error_msg}") - self._finalize_session('error') - return - - # Run succeeded - emit completion - self.run_completed.emit(cmd_index, packet_count) - self.status_changed.emit(f"Run {cmd_index} complete: {packet_count} packets") - - # Update total runs in database - self.db_conn.execute(""" - UPDATE sessions SET total_runs = ? WHERE session_id = ? - """, (cmd_index, self.current_session_id)) - self.db_conn.commit() - - # =============================================================== - # 5. Delay between commands (with countdown and queue check) - # =============================================================== - - # Get delay from command (default 3000ms if not specified) - delay_ms = cmd.get('delay_ms', 3000) - - # Only delay if not last command - if cmd_index < self.total_commands: - self._execute_delay(delay_ms) - - # Check if pause/stop was queued during delay - if self.pause_queued: - self._finalize_session('paused') - self.is_paused = True - self.session_paused.emit() - self.status_changed.emit("Session paused") - return - + global_cmd_index = 0 # Track overall command number + + # Loop through phases + for phase_index, phase in enumerate(self.phases, 1): + phase_name = phase['name'] + phase_commands = phase['commands'] + total_phases = len(self.phases) + + # Emit phase started + self.status_changed.emit(f"Starting Phase {phase_index}/{total_phases}: {phase_name}") + + # Loop through commands in this phase + for cmd_index_in_phase, cmd in enumerate(phase_commands, 1): + global_cmd_index += 1 + self.current_command_index = global_cmd_index + + # =============================================================== + # 1. Check if stop was queued (before starting new run) + # =============================================================== + if self.stop_queued: - self._finalize_session('aborted') self.status_changed.emit("Session stopped by user") + self._finalize_session('aborted') return + + # =============================================================== + # 2. Emit command started + # =============================================================== + + command_name = cmd['command_name'] + self.command_started.emit(global_cmd_index, command_name) + self.status_changed.emit( + f"[{phase_name}] Command {cmd_index_in_phase}/{len(phase_commands)} " + f"(Total: {global_cmd_index}/{self.total_commands}): {command_name}" + ) + + # =============================================================== + # 3. Execute run via run.py + # =============================================================== + + status, packet_count, error_msg = execute_run( + db_connection=self.db_conn, + session_id=self.current_session_id, + session_name=self.session_name, + run_no=global_cmd_index, + command_id=cmd['command_id'], + command_hex=cmd['hex_string'], + uart_command_port=self.uart_command_port, + uart_logger_port=self.uart_logger_port, + i2c_port=self.i2c_handle, + packet_config=self.packet_config, + stop_timeout_ms=self.interface_config['uart_logger_timeout_ms'], + raw_data_callback=lambda direction, hex_str: self.raw_data_received.emit(direction, hex_str) + ) + + # =============================================================== + # 4. Handle run result + # =============================================================== + + if status == "error": + # Run failed - abort session (all phases) + self.error_occurred.emit(f"[{phase_name}] Run {global_cmd_index} failed: {error_msg}") + self._finalize_session('error') + return + + # Run succeeded - emit completion + self.run_completed.emit(global_cmd_index, packet_count) + self.status_changed.emit(f"[{phase_name}] Run {global_cmd_index} complete: {packet_count} packets") + + # Update total runs in database + self.db_conn.execute(""" + UPDATE sessions SET total_runs = ? WHERE session_id = ? + """, (global_cmd_index, self.current_session_id)) + self.db_conn.commit() + + # =============================================================== + # 5. Delay between commands (with countdown and queue check) + # =============================================================== + + # Get delay from command (default 3000ms if not specified) + delay_ms = cmd.get('delay_ms', 3000) + + # Only delay if not last command overall + if global_cmd_index < self.total_commands: + self._execute_delay(delay_ms) + + # Check if pause/stop was queued during delay + if self.pause_queued: + self._finalize_session('paused') + self.is_paused = True + self.session_paused.emit() + self.status_changed.emit("Session paused") + return + + if self.stop_queued: + self._finalize_session('aborted') + self.status_changed.emit("Session stopped by user") + return + + # Phase completed + self.status_changed.emit(f"Phase {phase_index}/{total_phases} completed: {phase_name}") # =================================================================== - # 6. All commands completed successfully + # 6. All phases completed successfully # =================================================================== - + self._finalize_session('completed') self.session_finished.emit() - self.status_changed.emit("Session completed successfully") + phase_list = ", ".join([p['name'] for p in self.phases]) + self.status_changed.emit(f"All phases completed successfully: {phase_list}") except Exception as e: self.error_occurred.emit(f"Exception during session: {str(e)}") diff --git a/session_widget.py b/session_widget.py index 1de2c7d..54ed0cb 100644 --- a/session_widget.py +++ b/session_widget.py @@ -138,13 +138,27 @@ class SessionWidget(QWidget): self.interface_profile_combo = QComboBox() interface_layout.addWidget(self.interface_profile_combo) config_layout.addLayout(interface_layout) - - # Session profile dropdown - session_layout = QHBoxLayout() - session_layout.addWidget(QLabel("Session Profile:")) - self.session_profile_combo = QComboBox() - session_layout.addWidget(self.session_profile_combo) - config_layout.addLayout(session_layout) + + # Init session profile dropdown + init_layout = QHBoxLayout() + init_layout.addWidget(QLabel("Init Session:")) + self.init_session_combo = QComboBox() + init_layout.addWidget(self.init_session_combo) + config_layout.addLayout(init_layout) + + # Execute session profile dropdown + execute_layout = QHBoxLayout() + execute_layout.addWidget(QLabel("Execute Session:")) + self.execute_session_combo = QComboBox() + execute_layout.addWidget(self.execute_session_combo) + config_layout.addLayout(execute_layout) + + # De-init session profile dropdown + deinit_layout = QHBoxLayout() + deinit_layout.addWidget(QLabel("De-init Session:")) + self.deinit_session_combo = QComboBox() + deinit_layout.addWidget(self.deinit_session_combo) + config_layout.addLayout(deinit_layout) # Load button load_btn_layout = QHBoxLayout() @@ -275,17 +289,33 @@ class SessionWidget(QWidget): for row in cursor.fetchall(): self.interface_profile_combo.addItem(row[1], row[0]) # text, data - # Load session profiles + # Load session profiles (for all 3 phase dropdowns) cursor = self.db_conn.execute(""" SELECT profile_id, profile_name FROM session_profiles ORDER BY profile_name """) - - self.session_profile_combo.clear() - for row in cursor.fetchall(): - self.session_profile_combo.addItem(row[1], row[0]) # text, data - + + session_profiles = cursor.fetchall() + + # Populate Init session dropdown + self.init_session_combo.clear() + self.init_session_combo.addItem("(None - Skip Init)", None) # Optional + for row in session_profiles: + self.init_session_combo.addItem(row[1], row[0]) + + # Populate Execute session dropdown + self.execute_session_combo.clear() + self.execute_session_combo.addItem("(None - Skip Execute)", None) # Optional + for row in session_profiles: + self.execute_session_combo.addItem(row[1], row[0]) + + # Populate De-init session dropdown + self.deinit_session_combo.clear() + self.deinit_session_combo.addItem("(None - Skip De-init)", None) # Optional + for row in session_profiles: + self.deinit_session_combo.addItem(row[1], row[0]) + self._log_info("Profiles loaded from database") except Exception as e: @@ -306,24 +336,38 @@ class SessionWidget(QWidget): """Handle load session button click.""" # Get selected profile IDs interface_id = self.interface_profile_combo.currentData() - session_id = self.session_profile_combo.currentData() - - if interface_id is None or session_id is None: - self._log_error("Please select both interface and session profiles") + init_id = self.init_session_combo.currentData() + execute_id = self.execute_session_combo.currentData() + deinit_id = self.deinit_session_combo.currentData() + + # Check interface profile selected + if interface_id is None: + self._log_error("Please select an interface profile") return - + + # Check at least one session phase selected + if init_id is None and execute_id is None and deinit_id is None: + self._log_error("Please select at least one session phase (Init/Execute/De-init)") + return + # Get session name session_name = self.session_name_input.text().strip() if not session_name: session_name = None # Will be auto-generated - - # Load session - success = self.session.load_session(interface_id, session_id, session_name) - + + # Load session with 3 phases + success = self.session.load_session( + interface_profile_id=interface_id, + init_session_id=init_id, + execute_session_id=execute_id, + deinit_session_id=deinit_id, + session_name=session_name + ) + if success: # Enable start button self.start_button.setEnabled(True) - self._log_info("Session ready to start") + self._log_info("Multi-phase session ready to start") else: self._log_error("Failed to load session") @@ -333,7 +377,9 @@ class SessionWidget(QWidget): self.start_button.setEnabled(False) self.load_button.setEnabled(False) self.interface_profile_combo.setEnabled(False) - self.session_profile_combo.setEnabled(False) + self.init_session_combo.setEnabled(False) + self.execute_session_combo.setEnabled(False) + self.deinit_session_combo.setEnabled(False) self.session_name_input.setEnabled(False) # Create worker thread for non-blocking execution @@ -396,7 +442,9 @@ class SessionWidget(QWidget): self.start_button.setEnabled(True) self.load_button.setEnabled(True) self.interface_profile_combo.setEnabled(True) - self.session_profile_combo.setEnabled(True) + self.init_session_combo.setEnabled(True) + self.execute_session_combo.setEnabled(True) + self.deinit_session_combo.setEnabled(True) self.session_name_input.setEnabled(True) # Disable pause/stop buttons @@ -520,7 +568,9 @@ class SessionWidget(QWidget): # Enable configuration controls self.load_button.setEnabled(True) self.interface_profile_combo.setEnabled(True) - self.session_profile_combo.setEnabled(True) + self.init_session_combo.setEnabled(True) + self.execute_session_combo.setEnabled(True) + self.deinit_session_combo.setEnabled(True) self.session_name_input.setEnabled(True) # Reset button states diff --git a/uart/uart_kit/uart_core.py b/uart/uart_kit/uart_core.py index c04d11a..9b7a780 100644 --- a/uart/uart_kit/uart_core.py +++ b/uart/uart_kit/uart_core.py @@ -69,6 +69,7 @@ __all__ = [ # Packet detection modes 'uart_send_and_receive', + 'uart_send_and_read_pgkomm2', 'uart_poll_packet', # Listening mode @@ -136,7 +137,7 @@ class UARTConfig: data_bits: int = 8 stop_bits: int = 1 parity: str = 'N' - buffer_size: int = 496 + buffer_size: int = 40 * 1024 * 1024 # 40MB default buffer read_chunk_size: int = 512 stop_mode: StopConditionMode = StopConditionMode.TIMEOUT @@ -655,10 +656,18 @@ def uart_send_and_receive(port: UARTPort, tx_data: bytes, # Use configured timeout or override timeout = timeout_ms if timeout_ms is not None else port.config.stop_timeout_ms - - # Snapshot buffer position before send + + # CRITICAL: Flush serial input buffer BEFORE sending to clear any background traffic + # This ensures we only capture the response to OUR command, not stale data + try: + if port._serial_port: + port._serial_port.reset_input_buffer() + except Exception: + pass # Some platforms don't support this - continue anyway + + # Snapshot buffer position AFTER flush start_w = cb_w_abs(port._rx_buffer) - + # Send data status, _ = uart_write(port, tx_data) if status != Status.OK: @@ -678,6 +687,289 @@ def uart_send_and_receive(port: UARTPort, tx_data: bytes, ) +def uart_send_and_read_pgkomm2(port: UARTPort, + tx_data: bytes, + capture_max_ms: int = 30, + max_frames: int = 10) -> Tuple[Status, Optional[list]]: + """ + Send PGKomm2 command and read response frames. + + Uses EXACT logic from old working code (uart_old/pgkomm.py): + - Single timeout window for entire operation + - Read immediately when bytes arrive + - Stop immediately when HP response detected + - No sleep, no delays - pure spinning + - BCC validation rejects corrupted frames + + PGKomm2 protocol (V-ZUG spec A5.5093D-AB): + - Frame format: DD 22 | ADR1 ADR2 | LEN | DATA(0-255) | BCC + - Response time: < 15 ms (spec, ideal conditions) + - Multiple frames may be returned (SB status + PH echo + HP response) + - Length-delimited, no terminator bytes + - Background telemetry: Device sends unsolicited SB frames continuously + + Args: + port: UART port instance + tx_data: PGKomm2 command to transmit (must start with DD 22) + capture_max_ms: Total capture window in ms (default 30ms, spec says <15ms but real-world needs margin) + max_frames: Maximum number of frames to parse (safety limit) + + Returns: + (Status.OK, [frame1, frame2, ...]) on success + (Status.TIMEOUT, None) if no response within timeout + (Status.PORT_CLOSED, None) if port not ready + (Status.IO_ERROR, None) on read/write error + + Example: + # Send PGKomm2 command + status, frames = uart_send_and_read_pgkomm2( + port, + bytes.fromhex("DD 22 50 48 02 43 4F 16"), + capture_max_ms=30 # Default, can be adjusted if needed + ) + if status == Status.OK: + for frame in frames: + print(f"Frame: {frame.hex(' ')}") + + # BCC errors are logged to console automatically + """ + if not port._is_open or not port._serial_port: + return (Status.PORT_CLOSED, None) + + # Save original timeout + original_timeout = port._serial_port.timeout + + # Temporarily stop reader thread to get exclusive serial access + reader_was_running = port._reader_running + if reader_was_running: + uart_stop_reader(port) + + try: + # Flush serial input buffer (clear background traffic) + try: + port._serial_port.reset_input_buffer() + except Exception: + pass + + # Set non-blocking timeout + port._serial_port.timeout = 0 + + # Send command + status, _ = uart_write(port, tx_data) + if status != Status.OK: + return (status, None) + + # Calculate deadline (single timeout for entire operation) + start_time = time.time() + deadline = start_time + (capture_max_ms / 1000.0) + + rx_buffer = bytearray() + collected_frames = [] + echo_frame = None + reply_frame = None + + # Single loop - read directly from serial until HP or timeout (like old code) + while time.time() < deadline: + # Read available data IMMEDIATELY from serial port + n = port._serial_port.in_waiting or 0 + if n: + chunk = port._serial_port.read(n) + if chunk: + rx_buffer += chunk + + # Try to extract complete frames as they arrive + while True: + frame = _extract_pgkomm2_frame(rx_buffer) + if frame is None: + break # Need more bytes + + # Collect frame + collected_frames.append(frame) + + # Check frame type + if len(frame) >= 5: + adr1, adr2 = frame[2], frame[3] + + # First complete frame is typically PH echo + if echo_frame is None and adr1 == 0x50 and adr2 == 0x48: # PH + echo_frame = frame + continue + + # Prefer HP and stop looking once we have it + if adr1 == 0x48 and adr2 == 0x50: # HP + reply_frame = frame + break + + # If it's neither PH nor HP and we have no echo yet + if echo_frame is None: + echo_frame = frame + + # Stop immediately if we have HP + if reply_frame is not None: + break + + # No sleep - pure spinning (old code does this) + + # Return results - ONLY success if we got HP response! + if reply_frame is not None: + # Got HP response - success! + return (Status.OK, collected_frames) + elif len(collected_frames) > 0: + # Got frames but no HP response (only SB broadcasts or PH echo without answer) + print(f"[PGKOMM2] TIMEOUT: Got {len(collected_frames)} frame(s) but no HP response") + return (Status.TIMEOUT, None) + elif len(rx_buffer) > 0: + # Unparseable data - log for debugging + print(f"[PGKOMM2] IO_ERROR: Unparsed buffer ({len(rx_buffer)} bytes): {rx_buffer.hex(' ').upper()}") + return (Status.IO_ERROR, None) + else: + # No response + print(f"[PGKOMM2] TIMEOUT: No data received within {capture_max_ms}ms") + return (Status.TIMEOUT, None) + + except Exception as e: + return (Status.IO_ERROR, None) + + finally: + # Restore timeout + try: + if port._serial_port: + port._serial_port.timeout = original_timeout + except Exception: + pass + + # Restart reader thread if it was running + if reader_was_running: + uart_start_reader(port) + + +def _extract_pgkomm2_frame(buffer: bytearray) -> Optional[bytes]: + """ + Extract ONE complete PGKomm2 frame from buffer (destructive). + + Uses the EXACT logic from the old working code (uart_old/pgkomm.py). + + Searches for DD 22 header, reads LEN, extracts complete frame, + validates BCC checksum, and REMOVES it from the buffer. + + Frame format: [DD][22][ADR1][ADR2][LEN][DATA...][BCC] + BCC = XOR of all bytes from ADR1 through last DATA byte + + Args: + buffer: Bytearray to extract from (will be modified!) + + Returns: + Complete frame as bytes, or None if no complete frame available + Corrupted frames (BCC mismatch) are rejected and return None + """ + MAGIC = 0xDD + INVMAGIC = 0x22 + + # Hunt for header DD 22 + i = 0 + blen = len(buffer) + + while i + 1 < blen: + if buffer[i] == MAGIC and buffer[i + 1] == INVMAGIC: + # Have header; check if we have at least up to LEN + if i + 5 > blen: + # Need more bytes for ADR1/ADR2/LEN + return None + + adr1 = buffer[i + 2] + adr2 = buffer[i + 3] + length = buffer[i + 4] + total = 6 + length # full frame size + + if i + total <= blen: + # We have the whole frame + frame = bytes(buffer[i:i + total]) + + # Validate BCC (XOR from ADR1 through DATA) + # BCC = frame[-1], should equal XOR of frame[2:-1] + calculated_bcc = 0 + for byte in frame[2:-1]: # ADR1, ADR2, LEN, DATA... + calculated_bcc ^= byte + + received_bcc = frame[-1] + + if calculated_bcc != received_bcc: + # BCC mismatch - frame corrupted! + adr_str = f"{frame[2]:02X} {frame[3]:02X}" if len(frame) >= 4 else "??" + print(f"[PGKOMM2] ✗ BCC FAIL: ADR={adr_str}, calc={calculated_bcc:02X}, recv={received_bcc:02X}") + print(f"[PGKOMM2] Frame: {frame.hex(' ').upper()}") + # Drop this frame and continue searching + del buffer[:i + total] + return None # Reject corrupted frame + + # BCC valid - frame is good! + del buffer[:i + total] + return frame + else: + # Header found but incomplete body — wait for more bytes + return None + else: + i += 1 + + # If we advanced i without finding a header, drop garbage to i to avoid re-scanning + # BUT: Only if we either reached the end OR the buffer doesn't start with MAGIC + if i > 0 and (i >= blen or not (blen >= 2 and buffer[0] == MAGIC and buffer[1] == INVMAGIC)): + del buffer[:i] + + return None + + +def _parse_pgkomm2_frames(buffer: bytearray, max_frames: int = 10) -> list: + """ + Parse PGKomm2 frames from buffer. + + Frame format: DD 22 | ADR1 ADR2 | LEN | DATA | BCC + Total size: 6 + LEN bytes + + Args: + buffer: Raw data buffer + max_frames: Maximum frames to extract (safety limit) + + Returns: + List of frame bytes (each frame as bytes object) + """ + MAGIC = 0xDD + INVMAGIC = 0x22 + MIN_FRAME_SIZE = 6 # DD 22 ADR1 ADR2 LEN BCC (with LEN=0) + + frames = [] + pos = 0 + + while pos < len(buffer) and len(frames) < max_frames: + # Look for frame header DD 22 + if pos + 1 >= len(buffer): + break + + if buffer[pos] == MAGIC and buffer[pos + 1] == INVMAGIC: + # Found potential frame start + if pos + 5 >= len(buffer): # Need at least ADR1 ADR2 LEN + break + + # Extract length byte + length = buffer[pos + 4] + frame_size = 6 + length # DD 22 ADR1 ADR2 LEN DATA(length) BCC + + # Check if we have complete frame + if pos + frame_size <= len(buffer): + # Extract complete frame + frame = bytes(buffer[pos:pos + frame_size]) + frames.append(frame) + pos += frame_size + else: + # Incomplete frame at end of buffer + break + else: + # Not a frame start, advance + pos += 1 + + return frames + + # ============================================================================= # Listening Mode (Continuous, No Auto-Stop) # ============================================================================= @@ -931,7 +1223,7 @@ def _wait_for_packet(port: UARTPort, start_w: int, start_time: float, grace_expired_no_data: bool = False) -> Tuple[Status, Optional[PacketInfo]]: """ Internal: Wait for stop condition and collect packet. - + Args: port: UART port start_w: Buffer write position at start @@ -939,7 +1231,7 @@ def _wait_for_packet(port: UARTPort, start_w: int, start_time: float, timeout_s: Timeout in seconds grace_period: If True, timeout starts after first byte grace_expired_no_data: Grace expired without any byte - + Returns: (Status, PacketInfo or None) """ @@ -947,9 +1239,14 @@ def _wait_for_packet(port: UARTPort, start_w: int, start_time: float, first_byte_seen = False first_byte_time = 0.0 last_rx_time = start_time - + while True: now = port._get_timestamp() + + # Check if port was closed during execution + if not port._is_open or port._rx_buffer is None: + return (Status.PORT_CLOSED, None) + current_w = cb_w_abs(port._rx_buffer) # Check for new data @@ -983,7 +1280,7 @@ def _wait_for_packet(port: UARTPort, start_w: int, start_time: float, status, data = cb_copy_span(port._rx_buffer, start_w, current_w) if status != BufferStatus.OK: return (Status.IO_ERROR, None) - + return _create_packet_info( port=port, data=data, diff --git a/uart/uart_kit/uart_core_widget.py b/uart/uart_kit/uart_core_widget.py index e09ce2e..3273623 100644 --- a/uart/uart_kit/uart_core_widget.py +++ b/uart/uart_kit/uart_core_widget.py @@ -2,11 +2,12 @@ """ UART Widget (PyQt6) =================== -GUI for UART port control with 3 modes: -- Request-Response: Send command, get response +GUI for UART port control with 4 modes: +- Request-Response: Send command, get response (packet-based) - Polling: Continuous packet detection with grace period - Listening: Raw stream, no stop condition -- **NEW: Listening with Packet Detection (optional)** +- PGKomm2: V-ZUG PGKomm2 protocol (DD 22 framed, length-delimited) +- **Listening with Packet Detection (optional)** Features: - Port configuration (device, baud, etc.) @@ -20,7 +21,7 @@ Features: - Theme-ready (global theme controlled) Author: Kynsight -Version: 2.1.0 - Added packet detection support for listening mode +Version: 2.3.0 - Added PGKomm2 protocol support """ from PyQt6.QtWidgets import ( @@ -28,7 +29,7 @@ from PyQt6.QtWidgets import ( QLabel, QComboBox, QPushButton, QLineEdit, QGroupBox, QTextEdit, QSpinBox, QCheckBox ) -from PyQt6.QtCore import Qt, QTimer, pyqtSignal +from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal from PyQt6.QtGui import QFont # Import UART core and buffer widget @@ -37,6 +38,35 @@ from buffer_kit.buffer_widget_compact import CompactBufferWidget from buffer_kit.circular_buffer import cb_overflows +class UARTCommandWorker(QThread): + """ + Worker thread for non-blocking UART send/receive operations. + + Prevents GUI freezing when waiting for UART responses. + """ + + # Signals + finished = pyqtSignal(object, object) # (status, packet) - Status enum, PacketInfo + + def __init__(self, port, data): + super().__init__() + self.port = port + self.data = data + + def run(self): + """Execute UART send/receive in background thread.""" + try: + status, packet = uart_send_and_receive(self.port, self.data) + self.finished.emit(status, packet) + except Exception as e: + # Emit error status - ALWAYS emit to ensure cleanup + self.finished.emit(Status.IO_ERROR, None) + + +# UARTPGKomm2Worker removed - PGKomm2 now calls function directly (blocking is OK) +# Simplified threading: only one reader thread, all modes read from circular buffer + + class UARTWidget(QWidget): """ UART port control widget. @@ -51,6 +81,8 @@ class UARTWidget(QWidget): packet_received = pyqtSignal(object) # PacketInfo object data_received = pyqtSignal(bytes) connection_changed = pyqtSignal(bool) # True=connected + data_sent = pyqtSignal(bytes) # TX data (for data monitor) + data_received_display = pyqtSignal(bytes, str) # RX data (for data monitor), info string def __init__(self, parent=None): super().__init__(parent) @@ -65,7 +97,8 @@ class UARTWidget(QWidget): # Command execution state (failsafe) self._command_in_progress = False # True when waiting for response - + self._command_worker = None # Worker thread for non-blocking sends + # Overflow tracking (for Data Monitor warnings) self._last_overflow_count = 0 @@ -141,7 +174,7 @@ class UARTWidget(QWidget): # Baud row1.addWidget(QLabel("Baud:")) self.combo_baud = QComboBox() - self.combo_baud.addItems(["9600", "19200", "38400", "57600", "115200", "230400"]) + self.combo_baud.addItems(["9600", "19200", "38400", "57600", "115200", "230400", "256000"]) self.combo_baud.setCurrentText("115200") self.combo_baud.setMinimumWidth(90) row1.addWidget(self.combo_baud) @@ -165,6 +198,7 @@ class UARTWidget(QWidget): row1.addWidget(QLabel("Parity:")) self.combo_parity = QComboBox() self.combo_parity.addItems(["None", "Even", "Odd"]) + self.combo_parity.setCurrentIndex(1) # Default: Even (for PGKomm2) self.combo_parity.setMaximumWidth(80) row1.addWidget(self.combo_parity) @@ -182,9 +216,10 @@ class UARTWidget(QWidget): # Mode row2.addWidget(QLabel("Mode:")) self.combo_mode = QComboBox() - self.combo_mode.addItems(["Request-Response", "Polling", "Listening"]) + self.combo_mode.addItems(["Request-Response", "Polling", "Listening", "PGKomm2"]) + self.combo_mode.setCurrentIndex(3) # Default: PGKomm2 self.combo_mode.currentIndexChanged.connect(self._on_mode_changed) - self.combo_mode.setMinimumWidth(140) + self.combo_mode.setMinimumWidth(160) row2.addWidget(self.combo_mode) # Add spacing to separate from next section @@ -220,7 +255,14 @@ class UARTWidget(QWidget): self.spin_grace.setSuffix(" ms") self.spin_grace.setMaximumWidth(90) row2.addWidget(self.spin_grace) - + + # Capture window (for PGKomm2 mode) - HIDDEN, automatic detection + # Total timeout for command/response cycle (stops on HP detection) + self.spin_silence = QSpinBox() + self.spin_silence.setRange(10, 200) + self.spin_silence.setValue(30) # 30ms timeout (spec <15ms + margin for real-world conditions) + self.spin_silence.setVisible(False) # Hidden - automatic + # Connect/Disconnect self.btn_connect = QPushButton("Connect") self.btn_connect.clicked.connect(self._on_connect) @@ -442,13 +484,14 @@ class UARTWidget(QWidget): mode_idx = self.combo_mode.currentIndex() polling_mode = (mode_idx == 1) # Polling listening_mode = (mode_idx == 2) # Listening - - mode_name = ["Request-Response", "Polling", "Listening"][mode_idx] + pgkomm2_mode = (mode_idx == 3) # PGKomm2 + + mode_name = ["Request-Response", "Polling", "Listening", "PGKomm2"][mode_idx] self._log_info(f"Mode: {mode_name}") # Get stop condition - if listening_mode: - # No stop condition in listening mode + if listening_mode or pgkomm2_mode: + # No stop condition in listening/PGKomm2 mode (handled separately) stop_mode = StopConditionMode.TIMEOUT else: stop_mode = (StopConditionMode.TIMEOUT if self.combo_stop.currentIndex() == 0 @@ -491,14 +534,20 @@ class UARTWidget(QWidget): if status != Status.OK: self._show_error(f"Failed to open port: {status}") return - - # Start reader thread + + # Start reader thread (always - all modes use circular buffer) status = uart_start_reader(self.port) if status != Status.OK: uart_close(self.port) self._show_error(f"Failed to start reader: {status}") return - + + # Settle delay: Allow device to stabilize after port opens + # Device may reset on DTR/RTS change, needs time to boot/initialize + import time + self._log_info("Waiting 100ms for device to settle...") + time.sleep(0.1) # 100ms settle delay + # Replace buffer label with actual widget self._update_buffer_widget() @@ -580,10 +629,20 @@ class UARTWidget(QWidget): def _on_disconnect(self): """Disconnect from serial port.""" self._log_info("Disconnecting...") - + if not self.port: return - + + # Wait for command worker to finish if running + if hasattr(self, '_command_worker') and self._command_worker is not None: + if self._command_worker.isRunning(): + self._log_info("Waiting for command to finish...") + self._command_worker.wait(2000) # Wait up to 2 seconds + if self._command_worker.isRunning(): + self._log_info("⚠️ Force terminating worker thread") + self._command_worker.terminate() + self._command_worker.wait() + # Stop timers self.listen_timer.stop() self.poll_timer.stop() @@ -605,7 +664,9 @@ class UARTWidget(QWidget): self.lbl_status.setText(f"Disconnected - {packet_count} packets") self.lbl_status.setStyleSheet("color: green;") - # Close port + # Buffer contents at disconnect (continuous stream data) - not logged anymore + + # Close port (always stop reader thread) uart_stop_reader(self.port) uart_close(self.port) @@ -655,7 +716,8 @@ class UARTWidget(QWidget): self.edit_terminator.setEnabled(not connected) self.spin_timeout.setEnabled(not connected) self.spin_grace.setEnabled(not connected) - + self.spin_silence.setEnabled(not connected) + # Connect/disconnect buttons self.btn_connect.setEnabled(not connected) self.btn_disconnect.setEnabled(connected) @@ -668,13 +730,23 @@ class UARTWidget(QWidget): def _on_mode_changed(self, index): """Update UI when mode changes (enable/disable stop condition fields).""" - # 0=Request-Response, 1=Polling, 2=Listening - - if index == 2: # Listening - raw mode, no packet detection, prints to monitor + # 0=Request-Response, 1=Polling, 2=Listening, 3=PGKomm2 + + if index == 3: # PGKomm2 - V-ZUG protocol with DD 22 framing (auto-detects response) + self.combo_stop.setEnabled(False) # Protocol has built-in framing + self.edit_terminator.setEnabled(False) # Not used (length-delimited) + self.spin_timeout.setEnabled(False) # Not used (automatic command matching) + self.spin_grace.setEnabled(False) # No grace period + # spin_silence is hidden - automatic command matching, no user config needed + # Disable packet detection (protocol handles framing) + self.check_packet_detect.setEnabled(False) + self.check_packet_detect.setChecked(False) + elif index == 2: # Listening - raw mode, no packet detection, prints to monitor self.combo_stop.setEnabled(False) self.edit_terminator.setEnabled(False) self.spin_timeout.setEnabled(False) self.spin_grace.setEnabled(False) + self.spin_silence.setEnabled(False) # Disable packet detection (not for listening - it's for raw data) self.check_packet_detect.setEnabled(False) self.check_packet_detect.setChecked(False) @@ -683,6 +755,7 @@ class UARTWidget(QWidget): self.edit_terminator.setEnabled(True) self.spin_timeout.setEnabled(True) self.spin_grace.setEnabled(True) + self.spin_silence.setEnabled(False) # Enable packet detection (THIS is the mode for packet detection) self.check_packet_detect.setEnabled(True) else: # Request-Response - enable stop condition, disable grace @@ -690,6 +763,7 @@ class UARTWidget(QWidget): self.edit_terminator.setEnabled(True) self.spin_timeout.setEnabled(True) self.spin_grace.setEnabled(False) + self.spin_silence.setEnabled(False) # Disable packet detection (not for request-response) self.check_packet_detect.setEnabled(False) self.check_packet_detect.setChecked(False) @@ -900,6 +974,12 @@ class UARTWidget(QWidget): if self._command_in_progress: self._show_error("UART busy! Wait for current command to complete") return + + # FAILSAFE: Check if worker thread is still running + if hasattr(self, '_command_worker') and self._command_worker is not None: + if self._command_worker.isRunning(): + self._show_error("Worker thread still running! Wait for completion") + return # Get input text text = self.edit_send.text().strip() @@ -925,54 +1005,64 @@ class UARTWidget(QWidget): # Get current mode mode_idx = self.combo_mode.currentIndex() - + # ===================================================================== - # Request-Response Mode: Send and wait for response + # Request-Response Mode: Send and wait for response (NON-BLOCKING) # ===================================================================== if mode_idx == 0: # Mark as busy (prevent multiple sends) self._command_in_progress = True self.btn_send.setEnabled(False) self.lbl_status.setText("Sending command...") - + # Log TX in green self._log_tx(data) - - # Send command and wait for response - status, packet = uart_send_and_receive(self.port, data) - - # Mark as idle (allow next send) + + # Create worker thread for non-blocking send + self._command_worker = UARTCommandWorker(self.port, data) + self._command_worker.finished.connect(self._on_command_finished) + self._command_worker.start() + + # Safety timeout: Auto-reset busy flag after 30 seconds + # (prevents permanent lockout if worker crashes) + QTimer.singleShot(30000, self._reset_busy_flag_safety) + + # ===== GUI remains responsive while worker runs ===== + + # ===================================================================== + # PGKomm2 Mode: Send and read PGKomm2 frames (NON-BLOCKING) + # ===================================================================== + elif mode_idx == 3: + # Get capture window (from "Capture" spinbox) + capture_max_ms = self.spin_silence.value() + + # Mark as busy + self._command_in_progress = True + self.btn_send.setEnabled(False) + self.lbl_status.setText("Sending PGKomm2 command...") + + # Log TX in green + self._log_tx(data) + + # Call PGKomm2 function directly (blocking for ~20ms is OK) + status, frames = uart_send_and_read_pgkomm2(self.port, data, capture_max_ms) + + # Handle response immediately + self._on_pgkomm2_finished(status, frames) + + # Reset busy flag self._command_in_progress = False self.btn_send.setEnabled(True) - - # Handle result - if status == Status.OK: - # Success - display packet (will be blue RX) - self._display_packet(packet) - self.buffer_widget.update_display() - self.packet_received.emit(packet) - self.edit_send.clear() # Clear input on success - #self._log_info(f"Response received: {len(packet.data)} bytes") - self.lbl_status.setText(f"✓ TX: {len(data)}B, RX: {len(packet.data)}B") - self.lbl_status.setStyleSheet("color: green;") - elif status == Status.TIMEOUT: - # Timeout - no response received - self._log_warning("No response received (timeout)") - self.lbl_status.setText("Timeout!") - self.lbl_status.setStyleSheet("color: orange;") - else: - # Other error - self._show_error(f"Send failed: {status}") - + # ===================================================================== # Polling or Listening Mode: Just write data # ===================================================================== else: # Log TX in green self._log_tx(data) - + status, written = uart_write(self.port, data) - + if status == Status.OK: self.edit_send.clear() # Clear input on success self.lbl_status.setText(f"✓ Sent {written} bytes") @@ -980,7 +1070,131 @@ class UARTWidget(QWidget): self._log_info(f"Write successful: {written} bytes") else: self._show_error(f"Write failed: {status}") - + + def _reset_busy_flag_safety(self): + """ + Safety mechanism: Reset busy flag if worker is stuck. + + Called 30 seconds after worker starts. Only resets if worker + is still marked as in progress (which shouldn't happen normally). + """ + if self._command_in_progress: + self._command_in_progress = False + self.btn_send.setEnabled(True) + self._log_warning("Command timed out after 30 seconds") + self.lbl_status.setText("Worker timeout - reset") + self.lbl_status.setStyleSheet("color: orange;") + + def _on_command_finished(self, status, packet): + """ + Handle UART command worker completion (non-blocking result handler). + + Called when background worker finishes send/receive operation. + """ + # Cleanup worker + if self._command_worker: + self._command_worker.deleteLater() + self._command_worker = None + + # Mark as idle (allow next send) + self._command_in_progress = False + self.btn_send.setEnabled(True) + + # Handle result + if status == Status.OK and packet: + # Success - display packet (will be blue RX) + self._display_packet(packet) + self.buffer_widget.update_display() + self.packet_received.emit(packet) + self.edit_send.clear() # Clear input on success + self.lbl_status.setText(f"✓ TX: sent, RX: {len(packet.data)}B") + self.lbl_status.setStyleSheet("color: green;") + elif status == Status.TIMEOUT or status == Status.TIMEOUT_NO_DATA: + # Timeout - no response received + self._log_warning("No response received (timeout)") + self.lbl_status.setText("Timeout!") + self.lbl_status.setStyleSheet("color: orange;") + elif status == Status.PORT_CLOSED: + # Port closed during execution + self._log_warning("Port closed during command execution") + self.lbl_status.setText("Port closed") + self.lbl_status.setStyleSheet("color: red;") + else: + # Other error (IO_ERROR, etc.) + self._show_error(f"Send failed: {status}") + + def _on_pgkomm2_finished(self, status, frames): + """ + Handle PGKomm2 worker completion. + + Called when background worker finishes PGKomm2 send/receive operation. + + Args: + status: Status enum + frames: List of frame bytes (or None on error/timeout) + """ + # Cleanup worker + if self._command_worker: + self._command_worker.deleteLater() + self._command_worker = None + + # Mark as idle (allow next send) + self._command_in_progress = False + self.btn_send.setEnabled(True) + + # Handle result + if status == Status.OK and frames: + # Success - display all frames + total_bytes = sum(len(frame) for frame in frames) + self._log_info(f"✓ Received {len(frames)} PGKomm2 frame(s), {total_bytes} bytes total") + + # Log each frame with ASCII + for i, frame in enumerate(frames, 1): + # Convert frame to ASCII (printable chars only, others as '.') + ascii_str = ''.join(chr(b) if 32 <= b <= 126 else '.' for b in frame) + + # Parse frame address to identify type + if len(frame) >= 5: + adr1 = frame[2] + adr2 = frame[3] + length = frame[4] + + # Identify frame type + frame_type = "Unknown" + if adr1 == 0x50 and adr2 == 0x48: # PH + frame_type = "Echo (PH)" + elif adr1 == 0x48 and adr2 == 0x50: # HP + frame_type = "Response (HP)" + elif adr1 == 0x53 and adr2 == 0x42: # SB + frame_type = "Status (SB)" + + # Log with ASCII + self._log_rx(frame, f"Frame {i}/{len(frames)}: {frame_type} (LEN={length}) | '{ascii_str}'") + else: + self._log_rx(frame, f"Frame {i}/{len(frames)}: Invalid | '{ascii_str}'") + + # Emit signal for external handlers + self.data_received_display.emit(frame, f"PGKomm2 Frame {i}") + + self.edit_send.clear() # Clear input on success + self.lbl_status.setText(f"✓ TX: sent, RX: {len(frames)} frames ({total_bytes}B)") + self.lbl_status.setStyleSheet("color: green;") + + elif status == Status.TIMEOUT: + # Timeout - no response received + self._log_warning("No PGKomm2 response (timeout)") + self.lbl_status.setText("Timeout!") + self.lbl_status.setStyleSheet("color: orange;") + + elif status == Status.PORT_CLOSED: + # Port closed during execution + self._log_warning("Port closed during command execution") + self.lbl_status.setText("Port closed") + self.lbl_status.setStyleSheet("color: red;") + else: + # Other error (IO_ERROR, etc.) + self._show_error(f"PGKomm2 command failed: {status}") + # ========================================================================= # Logging & Error Handling # ========================================================================= @@ -1003,12 +1217,16 @@ class UARTWidget(QWidget): """Log transmitted data (green) - full data, no truncation.""" hex_str = data.hex(' ') # Show ALL data self.text_display.append(f"[TX] {len(data)}B: {hex_str}") - + # Emit signal for external displays (e.g., Data Monitor) + self.data_sent.emit(data) + def _log_rx(self, data: bytes, info: str = ""): """Log received data (blue) - full data, no truncation.""" hex_str = data.hex(' ') # Show ALL data prefix = f"[RX] {info} " if info else "[RX] " self.text_display.append(f"{prefix}{len(data)}B: {hex_str}") + # Emit signal for external displays (e.g., Data Monitor) + self.data_received_display.emit(data, info) # ========================================================================= # Public API @@ -1050,6 +1268,25 @@ class UARTWidget(QWidget): self.lbl_packet_count.setText("Packets: 0") self._log_info("Packet list cleared") + def closeEvent(self, event): + """Handle widget close - clean up threads properly.""" + # Wait for command worker to finish if running + if hasattr(self, '_command_worker') and self._command_worker is not None: + if self._command_worker.isRunning(): + print("[UARTWidget] Waiting for worker thread to finish before closing...") + self._command_worker.wait(2000) # Wait up to 2 seconds + if self._command_worker.isRunning(): + print("[UARTWidget] Force terminating worker thread") + self._command_worker.terminate() + self._command_worker.wait() + + # Disconnect if connected + if self.is_connected: + self._on_disconnect() + + # Accept close event + event.accept() + # ============================================================================= # Demo diff --git a/uart_old/packet_detector.py b/uart_old/packet_detector.py new file mode 100644 index 0000000..b59a4cd --- /dev/null +++ b/uart_old/packet_detector.py @@ -0,0 +1,161 @@ +# ----------------------- +# File: components/uart/packet_detector.py +# +# Purpose +# ------- +# Tiny, fast packet sniffer for fixed-format frames in a byte stream. +# Detects packets *in real time* as bytes arrive, without conversions. +# +# Default packet shape +# -------------------- +# - Start: 0xEF 0xFE +# - Total length: 14 bytes +# - Last byte must be: 0xEE +# +# When a packet is detected, we invoke an optional callback: +# on_packet(ts_ns: int, packet: bytes, abs_off_start: int) -> None +# where `ts_ns` is the detection/completion timestamp (now_ns at the moment +# we saw the last byte), and `abs_off_start` is the absolute offset of the +# first packet byte within the upstream ring buffer (if provided by caller). +# +# Notes +# ----- +# - The detector keeps a tiny internal buffer (max 14 bytes) and a simple FSM. +# - It is transport-agnostic: you feed() bytes from any source (UART, I2C, ...). +# - "Absolute offset" is optional. If you pass a base offset for each chunk, +# the detector computes the packet's start offset precisely. +# +from __future__ import annotations + +from typing import Optional, Callable + + +class PacketDetector: + """Realtime detector for fixed 14-byte packets starting with EF FE and ending with EE. + + Usage + ----- + det = PacketDetector(on_packet=my_handler) + det.feed(data, t_ns, abs_off_start) + + Where `abs_off_start` is the absolute ring offset of `data[0]`. + """ + + __slots__ = ( + "_on_packet", + "_buf", + "_count", + "_searching", + "_have_ef", + "_pkt_len", + "_start0", + "_start1", + "_end_byte", + "_pending_first_abs_off", + "_pending_first_ts_ns", + ) + + def __init__( + self, + on_packet: Optional[Callable[[int, bytes, int], None]] = None, + *, + start0: int = 0xEF, + start1: int = 0xFE, + end_byte: int = 0xEE, + length: int = 14, + ) -> None: + self._on_packet = on_packet + self._buf = bytearray() + self._count = 0 + self._searching = True + self._have_ef = False # helper to detect EF FE efficiently + self._pkt_len = int(length) + self._start0 = int(start0) & 0xFF + self._start1 = int(start1) & 0xFF + self._end_byte = int(end_byte) & 0xFF + self._pending_first_abs_off = 0 + self._pending_first_ts_ns = 0 + + # --------------------------- + # Configuration + # --------------------------- + def set_handler(self, fn: Optional[Callable[[int, bytes, int], None]]): + """Register or clear the on_packet callback.""" + self._on_packet = fn + + # --------------------------- + # Core detection + # --------------------------- + def feed(self, data: bytes, t_ns: int, abs_off_start: int = 0) -> None: + """Consume a chunk of bytes and emit packets via callback when found. + + Parameters + ---------- + data : bytes + Newly received bytes. + t_ns : int + Timestamp for *this chunk* (e.g., when the first byte was read). + We use this for the first-byte time if desired; for simplicity the + emitted event uses the completion time (call site passes a fresh now). + abs_off_start : int + Absolute offset of data[0] within the upstream ring buffer. Used to + compute the absolute start offset of detected packets. + """ + if not data: + return + + # Fast path: work byte-by-byte, minimal branching + for i, b in enumerate(data): + bi = b & 0xFF + + if self._searching: + # Find EF FE start sequence without growing the buffer + if not self._have_ef: + self._have_ef = bi == self._start0 + continue + else: + if bi == self._start1: + # Start found: reset buffer to EF FE + self._buf.clear() + self._buf.append(self._start0) + self._buf.append(self._start1) + self._count = 2 + self._searching = False + self._pending_first_abs_off = ( + abs_off_start + i - 1 + ) # index of EF + self._pending_first_ts_ns = t_ns + # Regardless, reset EF tracker (we only accept EF FE) + self._have_ef = bi == self._start0 + continue + + # Accumulating a candidate packet + self._buf.append(bi) + self._count += 1 + + if self._count < self._pkt_len: + continue + + # We have exactly `length` bytes; check end marker + if self._buf[-1] == self._end_byte: + # Detected a full packet → emit + if self._on_packet is not None: + # Completion timestamp should be the call-site's fresh now. + # We re-use t_ns here; callers should pass a fresh now when feeding. + self._on_packet(t_ns, bytes(self._buf), self._pending_first_abs_off) + # Reset to search for the next packet, even if the tail byte was wrong + self._buf.clear() + self._count = 0 + self._searching = True + self._have_ef = False + self._pending_first_abs_off = 0 + self._pending_first_ts_ns = 0 + + def reset(self) -> None: + """Clear detector state (used on reconnects, etc.).""" + self._buf.clear() + self._count = 0 + self._searching = True + self._have_ef = False + self._pending_first_abs_off = 0 + self._pending_first_ts_ns = 0 diff --git a/uart_old/pgkomm.py b/uart_old/pgkomm.py new file mode 100644 index 0000000..068d712 --- /dev/null +++ b/uart_old/pgkomm.py @@ -0,0 +1,242 @@ +# ----------------------- +# File: components/uart/pgkomm2.py +# +# PGKomm2 framing + send logic (NON-BLOCKING) +# ------------------------------------------- +# Frame format: +# [0xDD][0x22][ADR1][ADR2][LEN][DATA...][BCC] +# Total length = 6 + LEN (LEN at index 4) +# +# Goals: +# - Zero blocking: no sleep, no flush, no read timeouts. +# - Parse using a rolling buffer fed by ser.in_waiting. +# - Stop as soon as a COMPLETE frame is available. +# - Prefer HP; fall back to the last complete frame (often echo PH). +# - One concise log line per exchange: "→ TX ← RX" +# ----------------------- + +from __future__ import annotations +from components.scheduler.timebase import now_ns +from components.scheduler.coordinator import claim_uart_capture, release_uart_capture +import config.config as config + +MAGIC = 0xDD +INVMAGIC = 0x22 +ADR_PH = (0x50, 0x48) # 'P','H' (typical TX echo) +ADR_HP = (0x48, 0x50) # 'H','P' (slave reply we want) + +# --------------------------- +# Utils +# --------------------------- + + +def fmt_hex_ascii(data: bytes) -> str: + """HEX + ASCII, but hide leading DD 22 if present (constant PGKomm2 header).""" + hex_part = data.hex(" ").upper() + ascii_part = "".join(chr(b) if 32 <= b <= 126 else "." for b in data) + return f"{hex_part} | '{ascii_part}'" + + +def view_hex_no_magic(data: bytes) -> str: + """HEX view, but skip the leading DD 22 if present.""" + return data.hex(" ").upper() + + +def view_ascii_no_magic(data: bytes) -> str: + """ASCII view, but skip the leading DD 22 if present.""" + if len(data) >= 2 and data[0] == MAGIC and data[1] == INVMAGIC: + data = data[2:] + return "".join(chr(b) if 32 <= b <= 126 else "." for b in data) + + +def _is_ph_echo(frame: bytes) -> bool: + return len(frame) >= 6 and frame[2] == ADR_PH[0] and frame[3] == ADR_PH[1] + + +def _is_hp_reply(frame: bytes) -> bool: + return len(frame) >= 6 and frame[2] == ADR_HP[0] and frame[3] == ADR_HP[1] + + +# --------------------------- +# Non-blocking frame parser +# --------------------------- + + +def _extract_one_frame_from(buf: bytearray) -> bytes | None: + """ + Non-blocking parser: try to cut ONE complete PGKomm2 frame out of 'buf'. + If a full frame is found, it is REMOVED from 'buf' and returned (bytes). + If not enough bytes yet, returns None and leaves 'buf' as-is. + """ + # Hunt for header DD 22 + i = 0 + blen = len(buf) + while i + 1 < blen: + if buf[i] == MAGIC and buf[i + 1] == INVMAGIC: + # Have header; check if we have at least up to LEN + if i + 5 >= blen: + # Need more bytes for ADR1/ADR2/LEN + break + adr1 = buf[i + 2] + adr2 = buf[i + 3] + length = buf[i + 4] + total = 6 + length # full frame size + + if i + total <= blen: + # We have the whole frame + frame = bytes(buf[i : i + total]) + # Drop consumed bytes from buffer + del buf[: i + total] + return frame + else: + # Header found but incomplete body — wait for more bytes + break + else: + i += 1 + + # If we advanced i without finding a header, drop garbage to i to avoid re-scanning + if i > 0 and ( + i >= blen or not (blen >= 2 and buf[0] == MAGIC and buf[1] == INVMAGIC) + ): + del buf[:i] + return None + + +# --------------------------- +# Public API (non-blocking) +# --------------------------- + + +# --- replace your current send_pgkomm2 with this version --- + + +def send_pgkomm2(ser, hex_command: str, log, capture_max_ms: int = 15): + """ + Non-blocking PGKomm2 TX/RX with the exact log style requested: + + [..] → | '' + [..] ← - | '' + or, if no reply: + [..] ← | '' + + - Uses a rolling buffer + LEN-based framing (no blocking). + - Prefers HP; falls back to echo if no reply before the guard ends. + """ + # Basic checks & parse TX + if not config.DEBUG_MODE: + if not ser or not ser.is_open: + log("error", "⚠️ Not connected.") + return + try: + tx = bytes.fromhex(hex_command.strip()) + except ValueError: + log("error", "❌ Invalid hex string format.") + return + + if not ( + len(tx) >= 6 and tx[0] == MAGIC and tx[1] == INVMAGIC and len(tx) == 6 + tx[4] + ): + log("warning", "ℹ️ TX length pattern mismatch (expected 6+LEN). Sending anyway.") + + # Exclusive capture window + deadline_ns = now_ns() + int(capture_max_ms) * 1_000_000 + if not claim_uart_capture(deadline_ns): + log("warning", "⛔ Busy: could not acquire capture window.") + return + + old_timeout = None + try: + # TX (non-blocking; no flush, no sleep) + if not config.DEBUG_MODE: + try: + ser.reset_input_buffer() + except Exception: + pass + ser.write(tx) + + # Line 1: TX view (hide magic in both hex + ascii) + tx_hex = view_hex_no_magic(tx) + tx_ascii = view_ascii_no_magic(tx) + log("success", f"→ {tx_hex} | '{tx_ascii}'") + + # DEBUG path: fabricate echo + reply + if config.DEBUG_MODE: + echo = tx + reply = bytes.fromhex("DD 22 48 50 04 70 67 33 31 09") + # Line 2 (with reply): ECHO_HEX - REPLY_HEX | 'REPLY_ASCII' + line_left = view_hex_no_magic(echo) + line_right = view_hex_no_magic(reply) + right_ascii = view_ascii_no_magic(reply) + log("info", f"← {line_left} - {line_right} | '{right_ascii}'") + return reply + + # Non-blocking receive + old_timeout = ser.timeout + ser.timeout = 0 # non-blocking read() + + rx_buf = bytearray() + echo_frame: bytes | None = None + reply_frame: bytes | None = None + + while now_ns() < deadline_ns: + n = ser.in_waiting or 0 + if n: + rx_buf += ser.read(n) + + # Try to cut out complete frames as they arrive + while True: + frame = _extract_one_frame_from(rx_buf) + if frame is None: + break + + # First complete frame we see is typically PH echo + if echo_frame is None and _is_ph_echo(frame): + echo_frame = frame + continue + + # Prefer HP and stop looking once we have it + if _is_hp_reply(frame): + reply_frame = frame + break + + # If it's neither PH nor HP and we have no echo yet, use it as "echo-like" + if echo_frame is None: + echo_frame = frame + + if reply_frame is not None: + break # we have the answer; stop immediately + + # If nothing waiting, just spin (no sleeps) + + # Line 2: compose exactly like your examples + if reply_frame is not None: + # With answer: show echo hex (if any) then reply hex, ASCII only for reply + left_hex = view_hex_no_magic(echo_frame) if echo_frame else "" + right_hex = view_hex_no_magic(reply_frame) + right_ascii = view_ascii_no_magic(reply_frame) + if left_hex: + log("info", f"← {left_hex} - {right_hex} | '{right_ascii}'") + else: + log("info", f"← {right_hex} | '{right_ascii}'") + return reply_frame + + if echo_frame is not None: + # Only echo: show echo hex and its ASCII + left_hex = view_hex_no_magic(echo_frame) + left_ascii = view_ascii_no_magic(echo_frame) + log("info", f"← {left_hex} | '{left_ascii}'") + return echo_frame + + # Nothing at all + log("info", "← ") + return + + except Exception as e: + log("error", f"❌ UART send (PGKomm2) error: {e}") + finally: + try: + if old_timeout is not None and ser: + ser.timeout = old_timeout + except Exception: + pass + release_uart_capture() diff --git a/uart_old/uart_command_editor.py b/uart_old/uart_command_editor.py new file mode 100644 index 0000000..130108f --- /dev/null +++ b/uart_old/uart_command_editor.py @@ -0,0 +1,69 @@ +# File: components/uart/uart_command_editor.py + +from PyQt6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QLineEdit, + QPushButton, + QFormLayout, +) +from PyQt6.QtCore import Qt + + +class UartCommandEditorDialog(QDialog): + def __init__(self, command=None): + super().__init__() + + self.setWindowTitle("Modify UART Command" if command else "Add UART Command") + + # === Create form layout for consistent label alignment === + form_layout = QFormLayout() + form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight) + form_layout.setFormAlignment(Qt.AlignmentFlag.AlignTop) + form_layout.setSpacing(10) + + # === Input Fields === + self.name_input = QLineEdit() + self.category_input = QLineEdit() + self.description_input = QLineEdit() + self.hex_input = QLineEdit() + + # === Prefill if modifying === + if command: + self.name_input.setText(command.get("name", "")) + self.category_input.setText(command.get("category", "")) + self.description_input.setText(command.get("description", "")) + self.hex_input.setText( + command.get("hex_string", command.get("hex_string", "")) + ) + + form_layout.addRow("Name:", self.name_input) + form_layout.addRow("Category:", self.category_input) + form_layout.addRow("Description:", self.description_input) + form_layout.addRow("Hex:", self.hex_input) + + # === Buttons === + button_layout = QHBoxLayout() + self.ok_button = QPushButton("OK") + self.cancel_button = QPushButton("Cancel") + self.ok_button.clicked.connect(self.accept) + self.cancel_button.clicked.connect(self.reject) + button_layout.addStretch() + button_layout.addWidget(self.ok_button) + button_layout.addWidget(self.cancel_button) + + # === Final layout === + layout = QVBoxLayout(self) + layout.addLayout(form_layout) + layout.addLayout(button_layout) + + self.setMinimumWidth(400) + + def get_data(self): + return { + "name": self.name_input.text().strip(), + "category": self.category_input.text().strip(), + "hex_string": self.hex_input.text().strip(), + "description": self.description_input.text().strip(), + } diff --git a/uart_old/uart_logger_ui.py b/uart_old/uart_logger_ui.py new file mode 100644 index 0000000..80711a2 --- /dev/null +++ b/uart_old/uart_logger_ui.py @@ -0,0 +1,250 @@ +# components/uart/uart_logger_ui.py +# Minimal UART Logger UI (no commands/table) — connect and stream logs. + +import config.config as config + + +from PyQt6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QPushButton, + QComboBox, + QSplitter, + QLabel, +) + +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtCore import QObject, pyqtSignal + +# project-local deps +from components.console.console_ui import console_widget +from components.console.console_registry import log_main_console +import components.items.elements as elements + +from components.uart.uart_logic import UART_logic +from components.scheduler.coordinator import ( + uart_capture_active, + uart_capture_remaining_ms, +) + + +class _SafeConsoleProxy(QObject): + log_signal = pyqtSignal(str, str) # level, message + + def __init__(self, console): + super().__init__() + self.log_signal.connect(console.log) + + def __call__(self, level, msg): + self.log_signal.emit(level, msg) + + +class UartLoggerWidget(QWidget): + """ + Super-lean UART logger: + - Port + serial params + - Connect / Disconnect + - Auto-start continuous logger on connect + - One console + - Capture indicator (global coordinator) + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.uart_logic = UART_logic() + self.comboboxes = {} + self.connection_status = False + + # console + have uart_logic log into it + self.console = console_widget() + + # IMPORTANT: keep a strong reference so it isn’t GC’d + self._uart_console_proxy = _SafeConsoleProxy(self.console) + self.uart_logic.set_logger(self._uart_console_proxy) + + self.init_ui() + self._wire_signals() + self._setup_capture_guard() + self.disconnected_enable_status() + + # ---------------- UI ---------------- + def init_ui(self): + # === Top Control Row === + top_controls = QWidget() + top_controls_layout = QHBoxLayout(top_controls) + top_controls_layout.setContentsMargins(0, 0, 0, 0) + top_controls_layout.setSpacing(12) + top_controls_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Port + refresh + self.comboboxes["port"] = QComboBox() + self.comboboxes["port"].addItems(self.uart_logic.get_channels()) + top_controls_layout.addWidget( + elements.label_and_widget("Port", self.comboboxes["port"]) + ) + + self.button_refresh = elements.create_icon_button( + config.REFRESH_BUTTON_ICON_LINK, icon_size=30, border_size=4 + ) + top_controls_layout.addWidget(self.button_refresh) + + # Serial params + self.comboboxes["baudrate"] = QComboBox() + self.comboboxes["baudrate"].addItems(self.uart_logic.get_baud_rates()) + top_controls_layout.addWidget( + elements.label_and_widget("Baudrate", self.comboboxes["baudrate"]) + ) + + self.comboboxes["data_bits"] = QComboBox() + self.comboboxes["data_bits"].addItems(self.uart_logic.get_data_bits()) + top_controls_layout.addWidget( + elements.label_and_widget("Data Bits", self.comboboxes["data_bits"]) + ) + + self.comboboxes["stop_bits"] = QComboBox() + self.comboboxes["stop_bits"].addItems(self.uart_logic.get_stop_bits()) + top_controls_layout.addWidget( + elements.label_and_widget("Stop Bits", self.comboboxes["stop_bits"]) + ) + + self.comboboxes["parity"] = QComboBox() + self.comboboxes["parity"].addItems(self.uart_logic.get_parity()) + top_controls_layout.addWidget( + elements.label_and_widget("Parity", self.comboboxes["parity"]) + ) + + # Connect / Disconnect + self.button_connect = QPushButton("Connect") + top_controls_layout.addWidget( + elements.label_and_widget("", self.button_connect) + ) + + self.button_disconnect = QPushButton("Disconnect") + top_controls_layout.addWidget( + elements.label_and_widget("", self.button_disconnect) + ) + + # Capture status label + self.label_capture = QLabel("Capture: idle") + top_controls_layout.addWidget( + elements.label_and_widget("Status", self.label_capture) + ) + + # === Console only === + console_stack_widget = QWidget() + console_stack_layout = QVBoxLayout(console_stack_widget) + console_stack_layout.setContentsMargins(0, 0, 0, 0) + console_stack_layout.setSpacing(4) + console_stack_layout.addWidget(self.console) + + # === Splitter (kept for consistency, only console pane) === + splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.addWidget(console_stack_widget) + splitter.setStretchFactor(0, 1) + + # === Main Layout === + main_layout = QVBoxLayout() + main_layout.setContentsMargins(4, 4, 4, 4) + main_layout.setSpacing(6) + main_layout.addWidget(top_controls) + main_layout.addWidget(splitter, stretch=1) + self.setLayout(main_layout) + + def _wire_signals(self): + self.button_refresh.clicked.connect(self.refresh) + self.button_connect.clicked.connect(self.connect) + self.button_disconnect.clicked.connect(self.disconnect) + + # ---------- Capture guard: label updates ---------- + def _setup_capture_guard(self): + self._guard_timer = QTimer(self) + self._guard_timer.setInterval(50) # 20 Hz + self._guard_timer.timeout.connect(self._update_capture_label) + self._guard_timer.start() + + def _update_capture_label(self): + if uart_capture_active(): + rem = uart_capture_remaining_ms() + self.label_capture.setText(f"Capture: listening… {rem} ms") + else: + self.label_capture.setText("Capture: idle") + + # ---- UI state toggles ---- + def disconnected_enable_status(self): + for combo in self.comboboxes.values(): + if combo.property("inversedGray"): + elements.set_enabled_state(False, combo, grayOut=True) + else: + elements.set_enabled_state(True, combo, grayOut=False) + + elements.set_enabled_state(False, self.button_disconnect, grayOut=True) + elements.set_enabled_state(True, self.button_connect, grayOut=False) + + def connected_enable_status(self): + for combo in self.comboboxes.values(): + if combo.property("inversedGray"): + elements.set_enabled_state(True, combo, grayOut=False) + else: + elements.set_enabled_state(False, combo, grayOut=True) + + elements.set_enabled_state(True, self.button_disconnect, grayOut=False) + elements.set_enabled_state(False, self.button_connect, grayOut=True) + + # ---- Actions ---- + def connect(self): + log_main_console("info", "🔗 Connecting (logger)…") + port = self.comboboxes["port"].currentText() + baudrate = int(self.comboboxes["baudrate"].currentText()) + data_bits = int(self.comboboxes["data_bits"].currentText()) + stop_bits = float(self.comboboxes["stop_bits"].currentText()) + parity_label = self.comboboxes["parity"].currentText() # "Even", "None", "Odd" + parity_short = parity_label[0].upper() # E/N/O for your uart_logic + + ok = self.uart_logic.connect( + port=port, + baudrate=baudrate, + data_bits=data_bits, + stop_bits=stop_bits, + parity=parity_short, + ) + if ok: + # auto-start continuous logger (hex output) + self.uart_logic.start_logger(hex_output=True) + self.connected_enable_status() + self.connection_status = True + else: + elements.flash_button( + self.button_connect, flash_style="background-color: red;" + ) + + def disconnect(self): + log_main_console("info", "🔌 Disconnecting (logger)…") + if self.uart_logic.disconnect(): + self.disconnected_enable_status() + self.connection_status = False + self.refresh(silent=True) + self.uart_logic.stop_logger() + else: + elements.flash_button( + self.button_disconnect, flash_style="background-color: red;" + ) + + def refresh(self, silent: bool = False): + log_main_console("info", "🔄 Refreshing ports…") + self.comboboxes["port"].clear() + ports = self.uart_logic.get_channels() + if ports: + self.comboboxes["port"].addItems(ports) + if not silent: + elements.flash_button(self.button_refresh) + log_main_console("success", "✅ Ports refreshed.") + else: + elements.flash_button( + self.button_refresh, flash_style="background-color: red;" + ) + log_main_console("warning", "⚠️ No UART ports found.") + + # helpful for diagnostics + def get_current_config(self): + return {key: cb.currentText() for key, cb in self.comboboxes.items()} diff --git a/uart_old/uart_logic.py b/uart_old/uart_logic.py new file mode 100644 index 0000000..cd73696 --- /dev/null +++ b/uart_old/uart_logic.py @@ -0,0 +1,890 @@ +# ----------------------- +# File: components/uart/uart_logic.py +# +# Purpose +# ------- +# Unified UART logic that supports: +# 1) Command send with a protected "capture window" (using the global coordinator) +# 2) A continuous background logger (thread) that **buffers all bytes** into a +# persistent ring and groups them into sessions (gap-based). No per-byte printing. +# +# Key Ideas +# --------- +# - We use a *global coordinator* (components.scheduler.coordinator) to mark an +# exclusive "capture window" for the **command** UART path only. +# - The *logger* runs in its own *daemon thread* and continuously reads **unthrottled**. +# It appends to a persistent ByteRing and closes a session after an inactivity gap. +# UI/decoding happens elsewhere (no hex lines printed here). +# +# Tuning / Knobs +# -------------- +# - self._logger_sleep_s: idle yield when no bytes are available (~20 Hz default). +# - _gap_ns (150 ms): inactivity gap to auto-close the current session. +# - ByteRing capacity (4 MiB default): bounded buffer with drop-oldest policy. +# - capture_max_ms (send_command arg): only affects the command path. + +import serial +import time +import serial.tools.list_ports +import threading + +from components.console.console_registry import log_main_console +from components.scheduler.timebase import now_ns +from components.uart.pgkomm import send_pgkomm2 as pg_send_pgkomm2 +from components.data.db import get_uart_commands +import config.config as config +from components.data.db import ensure_telemetry_schema, insert_telemetry_rows +from components.uart.packet_detector import PacketDetector +from typing import Optional, Callable, List, Dict, Any +from components.buffer.buffer import ( + ByteRing, +) # persistent byte ring (shared by UART/I2C) + +# Global coordinator API: +# - claim_uart_capture(deadline_ns): enters exclusive capture until deadline +# - release_uart_capture(): leaves exclusive capture +# - uart_capture_active(): True if someone is currently in capture +from components.scheduler.coordinator import ( + claim_uart_capture, + release_uart_capture, + uart_capture_active, +) +from dataclasses import dataclass + +from components.i2c.i2c_logic import I2CLogic + + +@dataclass +class PacketMark: + """ + Index entry for a detected fixed-length packet in the ByteRing. + + - off_start/off_end: absolute byte offsets in the ByteRing [start, end) + (14 bytes total for EF FE ... EE). + - ts_end_ns: timestamp when the last byte (0xEE) was detected. + - dropped: True if the bytes were already evicted from the ring when we tried to read them. + """ + + id: int + off_start: int + off_end: int + ts_end_ns: int + dropped: bool = False + + +@dataclass +class SessionHeader: + """ + Metadata for a closed session (no bytes included here) + - off_start/off_end are absolute offsets into the ByteRing + - t_start_ns / t_end_ns are for diagnostics/UI only + """ + + id: int + t_start_ns: int + t_end_ns: int + off_start: int + off_end: int + dropped_bytes_during: int = 0 + + +@dataclass +class ClosedSession: + """A ready-to-decode session (header + payload snapshot).""" + + header: SessionHeader + payload: bytes + + +class UART_logic: + """ + Single class for: + - Connecting / disconnecting a UART port + - Sending one-shot hex commands with an exclusive capture window + - Running a continuous logger on the *same* port (optional) + + Threading model + --------------- + - The logger runs in a background daemon thread started by start_logger(). + It loops, checks for available bytes, and logs them. + - The command send path (send_command) runs in the UI/main thread, + but it briefly "claims" exclusivity via the coordinator so: + * Other sends are rejected, + * The logger throttles its output during the capture window. + """ + + def __init__(self): + # pyserial handle and connection info + self._run_session_id = now_ns() # stable for this program run + self._logger_run_number = 0 # increments each start_logger() + self._logger_run_tag = None # e.g., "log0001" + + self.serial = None + self.port = None + self.baudrate = None + self._hex_echo = False # set True only when you need to see raw hex in console + # --- Background logger configuration/state --- + # The logger runs in a separate daemon thread and continuously reads. + self._logger_thread = None # threading.Thread or None + self._logger_running = False # set True to keep logger loop alive + self._logger_hex = True # True: print hex; False: print utf-8 text + self._logger_sleep_s = ( + 0.05 # 50 ms idle sleep (~20 Hz). Tune for CPU vs responsiveness. + ) + + # --- Detected packet marks (EF FE ... 14 ... EE) anchored to ring offsets --- + self._packet_marks: list[PacketMark] = [] + self._packet_next_id: int = 1 + self._packet_count: int = 0 # visible counter for UI/metrics + self._on_packet_cb: Optional[Callable[[int, bytes, int], None]] = None + + # Packet detector: we keep it simple and feed from the reader loop + self._detector = PacketDetector(on_packet=self._on_packet_detected) + # --- Persistent logging buffer + sessionizer (for the logger thread) --- + # We keep a bounded byte ring so we can decode/save later without flooding the UI. + self._ring = ByteRing(capacity_bytes=4 * 1024 * 1024) # 4 MiB (tune as needed) + + # Sessionizer (gap-based): we open a session at start_logger(), extend on bytes, + # and auto-close after 150 ms inactivity or when forced. + self._gap_ns = 150_000_000 # 150 ms inactivity closes a session + self._next_session_id = 1 + self._active_header = None # type: Optional[SessionHeader] + self._last_rx_ns = 0 # last time we saw a byte (ns) + self._closed_sessions = [] # queue of SessionHeader (payload copied on pop) + self._sess_lock = threading.Lock() + + # --- I2C integration (silent, on-demand) --- + self._i2c = None # type: Optional[object] # expect components.i2c.i2c_logic.I2CLogic + self._i2c_cfg = None # type: Optional[dict] + self._i2c_results: List[ + Dict[str, Any] + ] = [] # small bounded buffer for correlation/export + + self._i2c_results_max = 10000 + self._i2c_inflight = False + self._i2c_inflight_lock = threading.Lock() + + # --- NEW: per-chunk timing references for unique per-packet timestamps --- + self._chunk_ref_off = 0 + self._chunk_ref_t_ns = 0 + + ensure_telemetry_schema() + self.set_db_writer(insert_telemetry_rows) # your UART_logic instance + + self.log = lambda level, msg: None # default: drop logs until UI injects one + + # ONLY for TEST + + self._i2c = I2CLogic() + + # Inject the console logger (must be a callable: (type, message) -> None) + def set_logger(self, log_func): + if callable(log_func): + self.log = log_func + + def set_db_writer(self, writer_callable): + """ + Inject a DB writer callable. It will be called with rows like: + { + 'session_id': str, + 'logger_session': str, + 'logger_session_number': int, + 'uart_raw': str | None, + 'i2c_raw': str | None, + } + """ + if callable(writer_callable): + self._db_writer = writer_callable + + # --- Combobox helpers (static lists for UI) --- + def get_baud_rates(self): + return ["115200", "9600", "19200", "38400", "57600", "256000"] + + def get_data_bits(self): + return ["8", "5", "6", "7"] + + def get_stop_bits(self): + return ["1", "1.5", "2"] + + def get_parity(self): + return ["Even", "None", "Odd"] + + def _on_packet_detected( + self, _ts_ns: int, packet: bytes, abs_off_start: int + ) -> None: + """ + Realtime packet hook (EF FE ... 14 ... EE). + - Stores a PacketMark pinned to ByteRing offsets + - Immediately performs a single I²C read (inline, no worker), silent + - Saves the I²C result in _i2c_results for later correlation + """ + # --- derive a unique end timestamp per packet based on byte position --- + off_start = int(abs_off_start) + off_end = off_start + len(packet) # expected 14 + + # Compute per-packet end time from the current chunk’s base using UART bitrate. + # Assume standard 8N1: 1 start + 8 data + 1 stop = 10 bits/byte. + bits_per_byte = 10 + baud = int(self.baudrate or 115200) + byte_ns = int((bits_per_byte * 1_000_000_000) // baud) + + # bytes from the beginning of this chunk to the *end* of the packet: + delta_bytes = max(0, off_end - int(getattr(self, "_chunk_ref_off", 0))) + base_t = int(getattr(self, "_chunk_ref_t_ns", _ts_ns or now_ns())) + end_ts = base_t + delta_bytes * byte_ns + + # Store the mark + mark = PacketMark( + id=self._packet_next_id, + off_start=off_start, + off_end=off_end, + ts_end_ns=end_ts, + ) + self._packet_marks.append(mark) + self._packet_next_id += 1 + self._packet_count += 1 + + # Optional external packet callback (kept compatible) + if self._on_packet_cb: + try: + self._on_packet_cb(end_ts, packet, abs_off_start) + except Exception as e: + self.log("warning", f"Packet handler error: {e}") + + # --- INLINE I²C READ (immediate-or-skip, no printing) --- + if self._i2c and self._i2c_cfg: + addr = self._i2c_cfg["addr"] + reg = self._i2c_cfg["reg"] + try: + res = self._i2c.read_2_bytes(addr, reg) + except Exception: + res = {"status": "ERR", "addr": addr, "reg": reg} + + out = {"t_pkt_detected_ns": int(end_ts), "pkt_id": mark.id} + if isinstance(res, dict): + out.update(res) + self._push_i2c_result(out) + + # Return a detailed list (not used by UI currently) + @staticmethod + def get_channels_with_product(self): + ports_info = [] + for port in serial.tools.list_ports.comports(): + ports_info.append( + { + "device": port.device, + "description": port.description, + "product": port.product, # ATTRS{product} + "manufacturer": port.manufacturer, # ATTRS{manufacturer} + "serial_number": port.serial_number, + } + ) + return ports_info + + # Return a simple list of user-facing labels for dropdowns + def get_channels(self) -> list[str]: + ports = serial.tools.list_ports.comports() + if not ports: + log_main_console("Error", "UART channels returned empty.") + return [] + + labels = [] + for p in ports: + label = p.device + if p.product: + label += f" — {p.product}" # Append ATTRS{product} to help identify + labels.append(label) + + log_main_console("success", "UART channels added with product info.") + return labels + + # Load predefined commands (from DB) for your command table (if used elsewhere) + def get_predefined_commands(self) -> list[dict]: + commands = get_uart_commands() + return [ + { + "id": id, + "name": name, + "description": description, + "category": category, + "hex_string": hex_string, + } + for id, name, description, category, hex_string in commands + ] + + # --------------------------- + # Connection lifecycle + # --------------------------- + def connect( + self, + port: str, + baudrate: int = 115200, + data_bits: int = 8, + stop_bits: float = 1, + parity: str = "N", + ) -> bool: + """ + Open the UART port with the given settings. + Note: `port` may be a label like "/dev/ttyUSB0 — RedBox 1017441". + We split on whitespace and use the first token as the actual device path. + """ + if config.DEBUG_MODE: + log_main_console("info", "DEBUG: fake connecting to UART") + log_main_console( + "success", + f"🔗 Connected to {port} | Baud: {baudrate} | Data: {data_bits} Bits | Stop: {stop_bits} Bits | Parity: {parity}.", + ) + return True + + try: + # Extract the pure path '/dev/tty*' even if label contains a ' — product' tail + port_path = str(port).split()[0] + self.serial = serial.Serial( + port=port_path, + baudrate=baudrate, + bytesize=data_bits, + stopbits=stop_bits, + parity=parity, # If you pass 'N'/'E'/'O': ok. If "None"/"Even"/"Odd", consider a mapper. + timeout=0.01, # Short timeout keeps reads responsive/non-blocking-ish + ) + if self.serial.is_open: + self.port = port_path + self.baudrate = baudrate + log_main_console( + "success", + f"🔗 Connected to {port_path} | Baud: {baudrate} | Data: {data_bits} Bits | Stop: {stop_bits} Bits | Parity: {parity}.", + ) + return True + except Exception as e: + log_main_console("error", f"❌ Connection failed: {e}") + return False + + def disconnect(self) -> bool: + """ + Stop logger if needed and close the port cleanly. + Safe to call even if already disconnected. + """ + if config.DEBUG_MODE: + self.log("info", "DEBUG: fake Disconnecting from UART") + return True + + # NEW: stop the logger thread first to avoid read/close races + try: + self.stop_logger() + except Exception: + pass + + if not getattr(self, "serial", None): + self.log("info", "🔌 Already disconnected (no serial).") + return True + + try: + if self.serial.is_open: + # Flush + attempt to reset buffers while still open + try: + self.serial.flush() + except Exception: + pass + try: + self.serial.reset_input_buffer() + self.serial.reset_output_buffer() + except Exception: + pass + + # Close the port + try: + self.serial.close() + except Exception as e: + self.log("error", f"❌ Close failed: {e}") + else: + self.log("info", "ℹ️ Port already closed.") + except Exception as e: + # Some drivers/platforms can throw on is_open/flush/close — keep it resilient + self.log("warning", f"Disconnect encountered an issue: {e}") + + # Clear local state so future connects start fresh + self.serial = None + self.port = None + self.baudrate = None + + self.log("info", "🔌 Disconnected") + return True + + # --------------------------- + # Helpers + # --------------------------- + def _fmt_hex_ascii(self, data: bytes) -> str: + """Return 'HEX | ASCII' view for console.""" + hex_part = data.hex(" ").upper() + ascii_part = "".join(chr(b) if 32 <= b <= 126 else "." for b in data) + return f"{hex_part} | '{ascii_part}'" + + # --------------------------- + # Command send + capture (ASCII + HEX logging) + # --------------------------- + def send_command(self, hex_command: str, capture_max_ms: int = 300): + """ + Send a hex-encoded command and read until newline. + Logs both HEX and printable CHARs. + """ + if not config.DEBUG_MODE: + if not self.serial or not self.serial.is_open: + self.log("error", "⚠️ Not connected.") + return + + if uart_capture_active(): + self.log("warning", "⛔ Busy: capture window active. Try again shortly.") + return + + try: + payload = bytes.fromhex(hex_command.strip()) + except ValueError: + self.log("error", "❌ Invalid hex string format.") + return + + deadline_ns = now_ns() + int(capture_max_ms) * 1_000_000 + if not claim_uart_capture(deadline_ns): + self.log("warning", "⛔ Busy: could not acquire capture window.") + return + + try: + # Send + if not config.DEBUG_MODE: + self.serial.write(payload) + self.log("success", f"➡️ {self._fmt_hex_ascii(payload)}") + + # Receive (newline-delimited) + if config.DEBUG_MODE: + time.sleep(min(0.2, capture_max_ms / 1000)) + response = b"HP 01 02 03\n" + else: + response = self.serial.read_until(expected=b"\n") + + if not response: + self.log("error", "❌ No response (timeout).") + return + + self.log("info", f"⬅️ {self._fmt_hex_ascii(response)}") + + return response + + except Exception as e: + self.log("error", f"❌ UART send error: {e}") + finally: + release_uart_capture() + + # --------------------------- + # PGKomm2 send + framed receive + # --------------------------- + # --- PGKomm2 send that discards the PH echo and returns only HP --- + def send_pgkomm2(self, hex_command: str, capture_max_ms: int = 100): + """ + Delegate to PGKomm2 module: sends TX, discards PH echo, + returns first HP reply. Logs HEX+ASCII. + """ + return pg_send_pgkomm2(self.serial, hex_command, self.log, capture_max_ms) + + # --------------------------- + # Continuous Logger (thread) + # --------------------------- + def start_logger(self, hex_output: bool = True) -> bool: + """ + Start the background logger thread. + + - If already running: no-op, returns True. + - If not connected (and not in DEBUG): returns False. + + Logger behavior: + - Continuously polls the serial port (non-blocking style) and **buffers** + any available bytes into a persistent ByteRing. + - Maintains a gap-based session: opens at start, extends on bytes, auto-closes + after inactivity (default 150 ms). No per-byte console output here; decoding + and UI happen outside the reader thread. + """ + if self._logger_running: + self.log("info", "ℹ️ Logger already running.") + return True + if not config.DEBUG_MODE and (not self.serial or not self.serial.is_open): + self.log("error", "⚠️ Cannot start logger: not connected.") + return False + + # Configure output style (hex vs text) + self._logger_hex = bool(hex_output) + + # --- NEW: explicitly open a session now (even before first byte arrives) --- + with self._sess_lock: + # We anchor the session start at the current ring end so we have a clean + # byte interval even if the first bytes arrive a bit later. + _, ring_end = self._ring.logical_window() + t0 = now_ns() + self._active_header = SessionHeader( + id=self._next_session_id, + t_start_ns=t0, + t_end_ns=t0, # will be updated on first data and on close + off_start=ring_end, + off_end=ring_end, # extended as bytes arrive + dropped_bytes_during=0, + ) + self._next_session_id += 1 + self._last_rx_ns = 0 # remains 0 until we see first real byte + + # Reset packet tracking for a fresh run + self._packet_marks.clear() + self._packet_next_id = 1 + self._packet_count = 0 + + # in start_logger(), before spinning up the thread + try: + if self._i2c.connect(1): + self.configure_i2c( + self._i2c, addr_7bit=0x40, angle_reg=0xFE, read_len=2 + ) + self.log("info", "I²C auto-initialized on /dev/i2c-1 for testing") + else: + self.log("warning", "I²C init failed (bus not available)") + except Exception as e: + self.log("error", f"I²C init exception: {e}") + + # Spin up the thread + self._logger_running = True + self._logger_thread = threading.Thread( + target=self._logger_loop, name="UARTLogger", daemon=True + ) + self._logger_thread.start() + + self.log("success", "🟢 UART logger started (session opened).") + self._logger_run_number += 1 + self._logger_run_tag = f"log{self._logger_run_number:04d}" + + # fresh per-run buffers + self._i2c_results.clear() + # (Optional) if you want sessions view limited to this run: + # self._closed_sessions.clear() + + self.log("info", f"Logger run tag: {self._logger_run_tag} ") + return True + + def stop_logger(self) -> bool: + """ + Stop the background logger thread if running. + Joins with a short timeout so shutdown remains responsive. + Prints a summary + last 10 packets with UART + I²C correlation. + """ + if not self._logger_running: + return True + + self._logger_running = False + t = self._logger_thread + self._logger_thread = None + if t and t.is_alive(): + t.join(timeout=1.0) + + self.log("info", "🔴 UART logger stopped.") + + # --- Summary counts (no historical byte counter; use ring window size) --- + try: + ring_start, ring_end = self._ring.logical_window() + uart_buffered = max(0, int(ring_end - ring_start)) # current buffered bytes + uart_pkts = len(self._packet_marks) + + i2c_results = list(getattr(self, "_i2c_results", [])) + i2c_total = len(i2c_results) + i2c_ok = sum(1 for r in i2c_results if r.get("status") == "OK") + i2c_err = sum( + 1 + for r in i2c_results + if r.get("status") in ("ERR", "ERR_NOT_CONNECTED") + ) + i2c_skipped = sum( + 1 for r in i2c_results if r.get("status") == "SKIPPED_BUSY" + ) + + self.log( + "info", + f"📊 Summary — UART buffered: {uart_buffered} B | UART packets: {uart_pkts} | " + f"I²C samples: {i2c_total} (OK {i2c_ok}, ERR {i2c_err}, SKIPPED {i2c_skipped})", + ) + except Exception as e: + self.log("warning", f"Summary failed: {e}") + + # stats = decode_raw_data.run(session_id=config.SESSION_NAME) + ##self.log("success", f"Decoded session '{config.SESSION_NAME}': {stats}") + return True + + def _logger_loop(self): + """ + Background reader thread (runs until stop_logger() or disconnect): + + Responsibilities + ---------------- + - Continuously poll the serial port for any available bytes. + - Append all received bytes into the persistent ByteRing (no printing here). + - Extend the currently active session with these bytes. + - Detect inactivity gaps (default 150 ms) and *close* the session, + enqueueing it for later decoding/saving. + - On exit, finalize any open session so nothing is lost. + + Notes + ----- + - No time-based throttling here. The reader stays hot and unthrottled. + - No capture-window checks (command UART is separate). + - "Information logging" means: session closed messages + error/warning events. + We do not log per-byte/hex lines in this loop. + """ + try: + # --- Real hardware path --- + while self._logger_running and self.serial and self.serial.is_open: + try: + # Non-blocking peek of pending bytes. + to_read = self.serial.in_waiting or 0 + if to_read == 0: + # No bytes available: check inactivity-based session close. + now_t = now_ns() + with self._sess_lock: + if self._active_header is not None and self._last_rx_ns: + if (now_t - self._last_rx_ns) >= self._gap_ns: + self._active_header.t_end_ns = self._last_rx_ns + self._closed_sessions.append(self._active_header) + self.log( + "info", + f"📦 Session {self._active_header.id} closed after gap", + ) + # Commit everything to the database + try: + written, failed = ( + self.flush_logger_session_to_db() + ) + self.log( + "success", + f"💾 Telemetry written: {written} rows (failed {failed}) for {self._logger_run_tag}", + ) + # optional cleanup after preview + self._packet_marks.clear() + self._i2c_results.clear() + except Exception as e: + self.log("warning", f"DB flush failed: {e}") + self._active_header = None + # Yield CPU very briefly; keep it small for responsiveness. + time.sleep(self._logger_sleep_s) + continue + + # Bytes available: read them all in one go. + data = self.serial.read(to_read) + + except Exception as e: + # Transient serial errors (e.g., unplug). Stay calm and retry. + self.log("warning", f"Logger read error: {e}") + time.sleep(self._logger_sleep_s) + continue + + if not data: + # Rare edge case: read() returned empty despite in_waiting>0. + time.sleep(self._logger_sleep_s) + continue + + # Append to the persistent ring; returns absolute offsets. + a, b, dropped = self._ring.write(data) + t = now_ns() + + # --- set per-chunk timestamp/offset for unique packet timing --- + self._chunk_ref_off = a + self._chunk_ref_t_ns = t + + try: + self._detector.feed(data, t, a) + except Exception: + pass + + # Extend (or open) the current session with these bytes. + with self._sess_lock: + if self._active_header is None: + # If for any reason no session is open (should have been opened at start), + # open one now so we never lose a contiguous interval. + self._active_header = SessionHeader( + id=self._next_session_id, + t_start_ns=t, + t_end_ns=t, + off_start=a, + off_end=b, + dropped_bytes_during=0, + ) + self._next_session_id += 1 + else: + self._active_header.off_end = b + self._active_header.t_end_ns = t + if dropped: + self._active_header.dropped_bytes_during += dropped + self._last_rx_ns = t + + # (No printing here; UI/decoder will consume from pop_closed_session()) + + finally: + # Finalize any open session so we don't lose the tail on stop/disconnect. + with self._sess_lock: + if self._active_header is not None: + self._active_header.t_end_ns = self._last_rx_ns or now_ns() + self._closed_sessions.append(self._active_header) + self.log( + "info", + f"📦 Session {self._active_header.id} finalized on shutdown", + ) + self._active_header = None + # Ensure the running flag is reset even if we broke out due to an error. + self._logger_running = False + + def pop_closed_session(self) -> Optional[ClosedSession]: + """ + Pop the oldest closed session and return (header, payload snapshot). + Use this from your decode/save worker. Safe to call while logger runs. + """ + with self._sess_lock: + if not self._closed_sessions: + return None + header = self._closed_sessions.pop(0) + # Copy outside the lock for minimal contention (ByteRing is internally locked) + payload = self._ring.copy_range(header.off_start, header.off_end) + return ClosedSession(header=header, payload=payload) + + def closed_count(self) -> int: + with self._sess_lock: + return len(self._closed_sessions) + + def stats_snapshot(self) -> dict: + ring_start, ring_end = self._ring.logical_window() + with self._sess_lock: + active_id = self._active_header.id if self._active_header else None + active_bytes = ( + (self._active_header.off_end - self._active_header.off_start) + if self._active_header + else 0 + ) + last_rx_age_ms = ( + None + if not self._last_rx_ns + else round((now_ns() - self._last_rx_ns) / 1_000_000) + ) + closed_ready = len(self._closed_sessions) + ring_stats = self._ring.stats_snapshot() + return { + "port": self.port, + "baudrate": self.baudrate, + "ring": ring_stats, + "window_start_off": ring_start, + "window_end_off": ring_end, + "active_session_id": active_id, + "active_session_bytes": active_bytes, + "last_rx_age_ms": last_rx_age_ms, + "closed_ready": closed_ready, + } + + # --------------------------- + # I²C configuration + trigger (silent) + # --------------------------- + def configure_i2c( + self, + i2c_obj, + *, + bus_id: int = 1, + addr_7bit: int = 0x00, + angle_reg: int = 0x00, + read_len: int = 2, + ) -> None: + """ + Stash the I²C handle and constants for on-demand angle sampling. + This does NOT connect/disconnect I²C. No logging. + """ + self._i2c = i2c_obj + self._i2c_cfg = { + "bus_id": int(bus_id), + "addr": int(addr_7bit) & 0x7F, + "reg": int(angle_reg) & 0xFF, + "len": int(read_len), + } + + def _push_i2c_result(self, item: Dict[str, Any]) -> None: + buf = self._i2c_results + buf.append(item) + # bounded buffer (drop oldest) + if len(buf) > self._i2c_results_max: + del buf[: len(buf) - self._i2c_results_max] + + def i2c_get_angle( + self, t_pkt_detected_ns: int, pkt_id: Optional[int] = None + ) -> None: + if not self._i2c or not self._i2c_cfg: + return + + with self._i2c_inflight_lock: + if self._i2c_inflight: + return + self._i2c_inflight = True + + addr = self._i2c_cfg["addr"] + reg = self._i2c_cfg["reg"] + + def _worker(): + try: + try: + res = self._i2c.read_2_bytes(addr, reg) + except Exception: + res = {"status": "ERR", "addr": addr, "reg": reg} + out = {"t_pkt_detected_ns": int(t_pkt_detected_ns), "pkt_id": pkt_id} + if isinstance(res, dict): + out.update(res) + self._push_i2c_result(out) + finally: + with self._i2c_inflight_lock: + self._i2c_inflight = False + + threading.Thread(target=_worker, name="I2CProbeOnce", daemon=True).start() + + def flush_logger_session_to_db(self) -> tuple[int, int]: + """ + Persist all packets detected in the current logger session using one bulk insert. + Returns (written_count, failed_count). + """ + if not getattr(self, "_db_writer", None): + return (0, 0) + + # Map pkt_id -> latest i2c sample + i2c_by_id = {} + for res in self._i2c_results: + pid = res.get("pkt_id") + if pid is not None: + i2c_by_id[pid] = res + + rows = [] + for mark in self._packet_marks: + # UART 14B payload snapshot (may be evicted) + try: + payload = self._ring.copy_range(mark.off_start, mark.off_end) + uart_hex = payload.hex(" ").upper() + except Exception: + uart_hex = None + + # I2C 2B raw (only when OK) + i2c_hex = None + i2c_res = i2c_by_id.get(mark.id) + if isinstance(i2c_res, dict) and i2c_res.get("status") == "OK": + b = i2c_res.get("bytes") or [] + if isinstance(b, (list, tuple)) and len(b) == 2: + i2c_hex = f"{int(b[0]) & 0xFF:02X} {int(b[1]) & 0xFF:02X}" + + rows.append( + { + "session_id": config.SESSION_NAME, + "logger_session": config.CURRENT_COMMAND, + "logger_session_number": int(self._next_session_id - 1), + "t_ns": int(mark.ts_end_ns), # NEW: packet timestamp (ns) + "uart_raw": uart_hex, + "i2c_raw": i2c_hex, + } + ) + + try: + written = self._db_writer(rows) # bulk insert once + failed = max(0, len(rows) - int(written or 0)) + return (int(written or 0), failed) + except Exception as e: + self.log("warning", f"DB bulk write failed: {e}") + return (0, len(rows)) diff --git a/uart_old/uart_ui.py b/uart_old/uart_ui.py new file mode 100644 index 0000000..9606de8 --- /dev/null +++ b/uart_old/uart_ui.py @@ -0,0 +1,289 @@ +# components/uart/uart_ui.py +# One-file UART page: dedicated handler + UI (no base_handler, no protocol_ui). + +from PyQt6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QPushButton, + QComboBox, + QLineEdit, + QSplitter, +) +from PyQt6.QtCore import Qt + +# project-local deps (unchanged) +from components.console.console_ui import console_widget +from components.console.console_registry import log_main_console +import components.items.elements as elements +from components.commands.command_table_ui import command_table_widget +import config.config as config + +# UART logic + dialogs + db (unchanged) +from components.uart.uart_logic import UART_logic +from components.uart.uart_command_editor import UartCommandEditorDialog +from components.data import db + + +# ----------------------------- +# Internal, dedicated UART handler +# ----------------------------- +# ----------------------------- +# Thin handler that calls back into the UI widget +# ----------------------------- +class UartHandler: + """ + Used by command_table_widget. It doesn't own UART logic. + It just forwards actions to the parent UartWidget. + """ + + def __init__(self, widget: "UartWidget"): + self.w = widget # back-reference to the UI + + # table may request the list via the handler + def get_command_list(self): + return self.w.uart_logic.get_predefined_commands() + + # ----- actions triggered from the command table ----- + def send_command(self, command: dict): + # command has "hex_string" + self.w.send_command(command) + + # command editor ops (invoked from command table) + def add_command(self): + dialog = UartCommandEditorDialog() + if dialog.exec(): + command = dialog.get_data() + db.add_uart_command(command) + + def modify_command(self, command): + dialog = UartCommandEditorDialog(command=command) + if dialog.exec(): + updated_command = dialog.get_data() + updated_command["id"] = command["id"] + db.modify_uart_command(updated_command) + + def delete_command(self, command): + db.delete_uart_command(command) + + +# ----------------------------- +# UART UI (layout preserved) +# ----------------------------- +class UartWidget(QWidget): + """ + Drops in where your old ProtocolUIWidget(UART) was used. + Same controls, same enable/disable behavior, same splitter/table/console layout. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.uart_logic = UART_logic() # all comms live here + self.handler = UartHandler(self) # thin handler for table actions + self.commands = self.uart_logic.get_predefined_commands() + self.comboboxes = {} + self.connection_status = False + self.init_ui() + + def init_ui(self): + # === Top Control Row === + top_controls = QWidget() + top_controls_layout = QHBoxLayout(top_controls) + top_controls_layout.setContentsMargins(0, 0, 0, 0) + top_controls_layout.setSpacing(12) + top_controls_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Dynamically create comboboxes based on handler configuration + # Static definition instead of get_combobox_config() + self.comboboxes["port"] = QComboBox() + self.comboboxes["port"].addItems(self.uart_logic.get_channels()) + top_controls_layout.addWidget( + elements.label_and_widget("Port", self.comboboxes["port"]) + ) + # Refresh button + self.button_refresh = elements.create_icon_button( + config.REFRESH_BUTTON_ICON_LINK, icon_size=30, border_size=4 + ) + top_controls_layout.addWidget(self.button_refresh) + + self.comboboxes["baudrate"] = QComboBox() + self.comboboxes["baudrate"].addItems(self.uart_logic.get_baud_rates()) + top_controls_layout.addWidget( + elements.label_and_widget("Baudrate", self.comboboxes["baudrate"]) + ) + + self.comboboxes["data_bits"] = QComboBox() + self.comboboxes["data_bits"].addItems(self.uart_logic.get_data_bits()) + top_controls_layout.addWidget( + elements.label_and_widget("Data Bits", self.comboboxes["data_bits"]) + ) + + self.comboboxes["stop_bits"] = QComboBox() + self.comboboxes["stop_bits"].addItems(self.uart_logic.get_stop_bits()) + top_controls_layout.addWidget( + elements.label_and_widget("Stop Bits", self.comboboxes["stop_bits"]) + ) + + self.comboboxes["parity"] = QComboBox() + self.comboboxes["parity"].addItems(self.uart_logic.get_parity()) + top_controls_layout.addWidget( + elements.label_and_widget("Parity", self.comboboxes["parity"]) + ) + + # Connect / Disconnect + self.button_connect = QPushButton("Connect") + top_controls_layout.addWidget( + elements.label_and_widget("", self.button_connect) + ) + + self.button_disconnect = QPushButton("Disconnect") + top_controls_layout.addWidget( + elements.label_and_widget("", self.button_disconnect) + ) + + # === Command Table === + self.command_table = command_table_widget( + commands=self.commands, + handler=self.handler, # table uses handler methods for send/CRUD + ) + + col1_widget = QWidget() + col1_layout = QVBoxLayout(col1_widget) + col1_layout.setContentsMargins(0, 0, 0, 0) + col1_layout.setSpacing(4) + col1_layout.addWidget(self.command_table) + + # === Input Field + Send Button === + self.input_hex = QLineEdit() + self.button_send_raw = QPushButton("Send Raw") + + input_line_layout = QHBoxLayout() + input_line_layout.setContentsMargins(0, 0, 0, 0) + input_line_layout.setSpacing(4) + input_line_layout.addWidget(self.input_hex) + input_line_layout.addWidget(self.button_send_raw) + + # === Console === + self.console = console_widget() + self.uart_logic.set_logger(self.console.log) + + console_stack_widget = QWidget() + console_stack_layout = QVBoxLayout(console_stack_widget) + console_stack_layout.setContentsMargins(0, 0, 0, 0) + console_stack_layout.setSpacing(4) + console_stack_layout.addLayout(input_line_layout) + console_stack_layout.addWidget(self.console) + + # === Horizontal Splitter: Table | Console (sizes preserved) === + splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.addWidget(col1_widget) + splitter.addWidget(console_stack_widget) + splitter.setSizes([740, 1200]) + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + + # === Main Layout === + main_layout = QVBoxLayout() + main_layout.setContentsMargins(4, 4, 4, 4) + main_layout.setSpacing(6) + main_layout.addWidget(top_controls) + main_layout.addWidget(splitter, stretch=1) + self.setLayout(main_layout) + + # === Signals === + self.button_refresh.clicked.connect(self.refresh) + self.button_connect.clicked.connect(self.connect) + self.button_disconnect.clicked.connect(self.disconnect) + self.button_send_raw.clicked.connect(self.send_command_raw) + + self.disconnected_enable_status() + + # ---- UI state toggles (unchanged) ---- + def disconnected_enable_status(self): + for combo in self.comboboxes.values(): + elements.set_enabled_state(True, combo, grayOut=False) + + elements.set_enabled_state(True, self.command_table, grayOut=False) + elements.set_enabled_state(False, self.input_hex, grayOut=True) + elements.set_enabled_state(False, self.button_send_raw, grayOut=True) + elements.set_enabled_state(False, self.button_disconnect, grayOut=True) + elements.set_enabled_state(True, self.button_connect, grayOut=False) + + def connected_enable_status(self): + for combo in self.comboboxes.values(): + elements.set_enabled_state(False, combo, grayOut=True) + + elements.set_enabled_state(True, self.command_table, grayOut=False) + elements.set_enabled_state(True, self.input_hex, grayOut=False) + elements.set_enabled_state(True, self.button_send_raw, grayOut=False) + elements.set_enabled_state(True, self.button_disconnect, grayOut=False) + elements.set_enabled_state(False, self.button_connect, grayOut=True) + + # ---- Button handlers (unchanged behavior) ---- + def connect(self): + log_main_console("info", "🔗 Connecting...") + port = self.comboboxes["port"].currentText() + baudrate = int(self.comboboxes["baudrate"].currentText()) + data_bits = int(self.comboboxes["data_bits"].currentText()) + stop_bits = float(self.comboboxes["stop_bits"].currentText()) + parity = self.comboboxes["parity"].currentText()[0].upper() + success = self.uart_logic.connect( + port=port, + baudrate=baudrate, + data_bits=data_bits, + stop_bits=stop_bits, + parity=parity, + ) + if success: + self.connected_enable_status() + self.command_table.set_connected_state() + self.connection_status = True + else: + elements.flash_button( + self.button_connect, flash_style="background-color: red;" + ) + + def disconnect(self): + log_main_console("info", "🔌 Disconnecting...") + success = self.uart_logic.disconnect() + if success: + log_main_console("info", "🔌 Disconnecting...") + self.disconnected_enable_status() + self.command_table.set_disconnected_state() + self.connection_status = False + self.refresh(silent=True) + else: + elements.flash_button( + self.button_disconnect, flash_style="background-color: red;" + ) + + def refresh(self, silent: bool = False): + log_main_console("info", "🔄 Refreshing...") + self.comboboxes["port"].clear() + port = self.uart_logic.get_channels() + if port: + self.comboboxes["port"].addItems(port) + if not silent: + elements.flash_button(self.button_refresh) + log_main_console("success", "🔄 Refresehd") + else: + elements.flash_button( + self.button_refresh, flash_style="background-color: red;" + ) + log_main_console("error", "🔄 Refresehd") + + def get_current_config(self): + return {key: cb.currentText() for key, cb in self.comboboxes.items()} + + def send_command(self, command): + # self.uart_logic.send_command(command["hex_string"]) + self.uart_logic.send_pgkomm2(command["hex_string"]) + config.CURRENT_COMMAND = command["name"] + + def send_command_raw(self): + hex_str = self.input_hex.text().strip() + if not hex_str: + self.console.log("warning", "⚠️ No command entered.") + else: + self.uart_logic.send_command(hex_str) + config.CURRENT_COMMAND = hex_str