Uart Tweaking

main
Kynsight 4 weeks ago
parent 0277eec76a
commit ac173bf6f3

4
.gitignore vendored

@ -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/

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

@ -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.

@ -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"]

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -69,7 +69,7 @@ class I2CConfig:
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

@ -370,6 +370,21 @@ class MainWindow(QMainWindow):
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
# =========================================================================

@ -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

@ -68,6 +68,7 @@ class RunExecutor:
"""
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,6 +110,7 @@ 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(
@ -251,6 +256,10 @@ 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:

@ -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,22 +154,26 @@ 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:
@ -187,6 +191,7 @@ class Session(QObject):
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,62 +223,136 @@ 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.error_occurred.emit(f"{phase_name} session profile {profile_id} not found")
return (False, None)
self.session_profile_id = session_profile_id
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
self.error_occurred.emit(f"{phase_name}: Invalid JSON - {str(e)}")
return (False, None)
# ===================================================================
# 3. Validate all commands exist in database
# ===================================================================
# 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)
for cmd in self.commands:
# 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
self.error_occurred.emit(f"{phase_name}: Command missing command_id")
return (False, None)
# Check if command exists
# Check if command exists and load details
cursor = self.db_conn.execute("""
SELECT command_name, hex_string
FROM uart_commands
@ -282,36 +361,27 @@ class Session(QObject):
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}")
# 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
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
@ -375,7 +450,14 @@ class Session(QObject):
# 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
@ -422,8 +504,9 @@ 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']:
@ -459,88 +542,7 @@ class Session(QObject):
# 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
@ -622,7 +624,7 @@ class Session(QObject):
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
@ -635,6 +637,11 @@ class Session(QObject):
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,7 +653,7 @@ 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,22 +695,36 @@ 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
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)
@ -719,8 +740,11 @@ class Session(QObject):
# ===============================================================
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}")
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
@ -730,14 +754,14 @@ class Session(QObject):
db_connection=self.db_conn,
session_id=self.current_session_id,
session_name=self.session_name,
run_no=cmd_index,
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, # Note: run.py calls it i2c_port but it's actually I2CHandle
i2c_port=self.i2c_handle,
packet_config=self.packet_config,
stop_timeout_ms=5000,
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)
)
@ -746,19 +770,19 @@ class Session(QObject):
# ===============================================================
if status == "error":
# Run failed - abort session
self.error_occurred.emit(f"Run {cmd_index} failed: {error_msg}")
# 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(cmd_index, packet_count)
self.status_changed.emit(f"Run {cmd_index} complete: {packet_count} packets")
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 = ?
""", (cmd_index, self.current_session_id))
""", (global_cmd_index, self.current_session_id))
self.db_conn.commit()
# ===============================================================
@ -768,8 +792,8 @@ class Session(QObject):
# 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:
# 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
@ -785,13 +809,17 @@ class Session(QObject):
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)}")

@ -139,12 +139,26 @@ class SessionWidget(QWidget):
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,16 +289,32 @@ 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")
@ -306,10 +336,18 @@ 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()
init_id = self.init_session_combo.currentData()
execute_id = self.execute_session_combo.currentData()
deinit_id = self.deinit_session_combo.currentData()
if interface_id is None or session_id is None:
self._log_error("Please select both interface and session profiles")
# 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
@ -317,13 +355,19 @@ class SessionWidget(QWidget):
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

@ -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
@ -656,7 +657,15 @@ 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
@ -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)
# =============================================================================
@ -950,6 +1242,11 @@ def _wait_for_packet(port: UARTPort, start_w: int, start_time: float,
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

@ -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,6 +97,7 @@ 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
@ -221,6 +256,13 @@ class UARTWidget(QWidget):
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
pgkomm2_mode = (mode_idx == 3) # PGKomm2
mode_name = ["Request-Response", "Polling", "Listening"][mode_idx]
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
@ -492,13 +535,19 @@ class UARTWidget(QWidget):
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()
@ -584,6 +633,16 @@ class UARTWidget(QWidget):
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,6 +716,7 @@ 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)
@ -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)
@ -901,6 +975,12 @@ class UARTWidget(QWidget):
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()
if not text:
@ -927,7 +1007,7 @@ class UARTWidget(QWidget):
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)
@ -938,48 +1018,182 @@ class UARTWidget(QWidget):
# Log TX in green
self._log_tx(data)
# Send command and wait for response
status, packet = uart_send_and_receive(self.port, data)
# 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)
# =====================================================================
# 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")
self.lbl_status.setStyleSheet("color: green;")
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:
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._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.setText(f"✓ TX: sent, RX: {len(packet.data)}B")
self.lbl_status.setStyleSheet("color: green;")
elif status == Status.TIMEOUT:
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
# Other error (IO_ERROR, etc.)
self._show_error(f"Send failed: {status}")
# =====================================================================
# Polling or Listening Mode: Just write data
# =====================================================================
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:
# Log TX in green
self._log_tx(data)
self._log_rx(frame, f"Frame {i}/{len(frames)}: Invalid | '{ascii_str}'")
status, written = uart_write(self.port, data)
# Emit signal for external handlers
self.data_received_display.emit(frame, f"PGKomm2 Frame {i}")
if status == Status.OK:
self.edit_send.clear() # Clear input on success
self.lbl_status.setText(f"✓ Sent {written} bytes")
self.lbl_status.setText(f"TX: sent, RX: {len(frames)} frames ({total_bytes}B)")
self.lbl_status.setStyleSheet("color: green;")
self._log_info(f"Write successful: {written} bytes")
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:
self._show_error(f"Write failed: {status}")
# 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"<span style='color: green;'>[TX] {len(data)}B: {hex_str}</span>")
# 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"<span style='color: blue;'>{prefix}{len(data)}B: {hex_str}</span>")
# 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

@ -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

@ -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:
[..] <TX_HEX> | '<TX_ASCII>'
[..] <ECHO_HEX> - <REPLY_HEX> | '<REPLY_ASCII>'
or, if no reply:
[..] <ECHO_HEX> | '<ECHO_ASCII>'
- 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", "← <no response>")
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()

@ -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(),
}

@ -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 isnt GCd
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()}

@ -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 chunks 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))

@ -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
Loading…
Cancel
Save