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