diff --git a/buffer_kit/buffer_widget_compact.py b/buffer_kit/buffer_widget_compact.py new file mode 100644 index 0000000..00faaca --- /dev/null +++ b/buffer_kit/buffer_widget_compact.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +""" +Compact Buffer Widget - One Line Display +========================================= +Minimal, compact widget showing everything in ONE line. + +Shows: [Capacity] [Progress Bar] [Overflow] + +Manual update - call update_display() when you want. +""" + +from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel, QProgressBar +from PyQt6.QtCore import Qt +from .circular_buffer import ( + CircularBufferHandle, + cb_capacity, cb_fill_bytes, cb_fill_pct, cb_overflows +) + + +class CompactBufferWidget(QWidget): + """ + Compact one-line buffer display. + + Layout: [Size Label] [===== Progress Bar =====] [Overflow] + + Usage: + widget = CompactBufferWidget(buffer) + + # Update manually: + widget.update_display() + """ + + def __init__(self, buffer: CircularBufferHandle, parent=None): + super().__init__(parent) + self.buffer = buffer + + # Horizontal layout (everything in ONE line) + layout = QHBoxLayout() + layout.setContentsMargins(2, 2, 2, 2) # Tight margins + layout.setSpacing(5) + self.setLayout(layout) + + # Size label (left) + self.lbl_size = QLabel() + self.lbl_size.setMinimumWidth(80) + layout.addWidget(self.lbl_size) + + # Progress bar (center - takes most space) + self.progress = QProgressBar() + self.progress.setRange(0, 100) + self.progress.setTextVisible(True) + self.progress.setFormat("%p%") + self.progress.setMaximumHeight(20) # Compact height + layout.addWidget(self.progress, stretch=1) # Stretch to fill + + # Overflow label (right) + self.lbl_overflow = QLabel() + self.lbl_overflow.setMinimumWidth(100) # Wider for "Overflows: X" + self.lbl_overflow.setAlignment(Qt.AlignmentFlag.AlignRight) + layout.addWidget(self.lbl_overflow) + + self.update_display() + + def update_display(self): + """Update display - call manually when needed.""" + cap = cb_capacity(self.buffer) + fill = cb_fill_bytes(self.buffer) + pct = cb_fill_pct(self.buffer) + ovf = cb_overflows(self.buffer) + + # Update labels + self.lbl_size.setText(f"{fill}/{cap}B") + self.progress.setValue(pct) + + # Overflow label - just show count (no color logic) + self.lbl_overflow.setText(f"Overflows: {ovf}") + if ovf > 0: + self.lbl_overflow.setStyleSheet("color: red; font-weight: bold;") + else: + self.lbl_overflow.setStyleSheet("color: gray;") + + +# ============================================================================= +# Version with callback support +# ============================================================================= + +class CompactBufferWidgetWithCallback(QWidget): + """ + Compact widget WITH callback support. + + Callback is called whenever update_display() is called. + Useful for logging, debugging, or triggering other actions. + + Usage: + def my_callback(capacity, fill, percent, overflows): + print(f"Buffer: {fill}/{capacity} ({percent}%)") + + widget = CompactBufferWidgetWithCallback(buffer, on_update=my_callback) + widget.update_display() # Callback is called! + """ + + def __init__(self, buffer: CircularBufferHandle, + on_update=None, parent=None): + """ + Args: + buffer: CircularBufferHandle to monitor + on_update: Callback function(capacity, fill, percent, overflows) + parent: Parent widget + """ + super().__init__(parent) + self.buffer = buffer + self.on_update_callback = on_update + + # Same layout as compact version + layout = QHBoxLayout() + layout.setContentsMargins(2, 2, 2, 2) + layout.setSpacing(5) + self.setLayout(layout) + + self.lbl_size = QLabel() + self.lbl_size.setMinimumWidth(80) + layout.addWidget(self.lbl_size) + + self.progress = QProgressBar() + self.progress.setRange(0, 100) + self.progress.setTextVisible(True) + self.progress.setFormat("%p%") + self.progress.setMaximumHeight(20) + layout.addWidget(self.progress, stretch=1) + + self.lbl_overflow = QLabel() + self.lbl_overflow.setMinimumWidth(100) # Wider for "Overflows: X" + self.lbl_overflow.setAlignment(Qt.AlignmentFlag.AlignRight) + layout.addWidget(self.lbl_overflow) + + self.update_display() + + def update_display(self): + """ + Update display and call callback if set. + + Callback signature: + callback(capacity: int, fill: int, percent: int, overflows: int) + """ + cap = cb_capacity(self.buffer) + fill = cb_fill_bytes(self.buffer) + pct = cb_fill_pct(self.buffer) + ovf = cb_overflows(self.buffer) + + # Update UI + self.lbl_size.setText(f"{fill}/{cap}B") + self.progress.setValue(pct) + + # Overflow label - just show count + self.lbl_overflow.setText(f"Overflows: {ovf}") + if ovf > 0: + self.lbl_overflow.setStyleSheet("color: red; font-weight: bold;") + else: + self.lbl_overflow.setStyleSheet("color: gray;") + + # Call callback if set + if self.on_update_callback: + try: + self.on_update_callback(cap, fill, pct, ovf) + except Exception as e: + print(f"Callback error: {e}") + + +# ============================================================================= +# Demo +# ============================================================================= + +def demo(): + """Demo showing both widgets.""" + import sys + from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QVBoxLayout, + QWidget, QPushButton, QLabel, QGroupBox + ) + from circular_buffer import cb_init, cb_write + + class DemoWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Compact Buffer Widget Demo") + + central = QWidget() + self.setCentralWidget(central) + layout = QVBoxLayout() + central.setLayout(layout) + + # Title + title = QLabel("Compact One-Line Buffer Widgets") + title.setStyleSheet("font-size: 14px; font-weight: bold;") + layout.addWidget(title) + + # ====== Widget 1: Without callback ====== + group1 = QGroupBox("Widget 1: Basic (no callback)") + group1_layout = QVBoxLayout() + group1.setLayout(group1_layout) + + status1, self.buffer1 = cb_init(100) + self.widget1 = CompactBufferWidget(self.buffer1) + group1_layout.addWidget(self.widget1) + + layout.addWidget(group1) + + # ====== Widget 2: With callback ====== + group2 = QGroupBox("Widget 2: With Callback") + group2_layout = QVBoxLayout() + group2.setLayout(group2_layout) + + status2, self.buffer2 = cb_init(100) + + # Define callback + def on_buffer_update(cap, fill, pct, ovf): + print(f"Callback: {fill}/{cap}B ({pct}%) - Overflows: {ovf}") + + self.widget2 = CompactBufferWidgetWithCallback( + self.buffer2, + on_update=on_buffer_update + ) + group2_layout.addWidget(self.widget2) + + layout.addWidget(group2) + + # ====== Controls ====== + controls = QGroupBox("Controls") + controls_layout = QVBoxLayout() + controls.setLayout(controls_layout) + + btn_write_5 = QPushButton("Write 5 bytes to both") + btn_write_5.clicked.connect(self.write_5) + controls_layout.addWidget(btn_write_5) + + btn_write_50 = QPushButton("Write 50 bytes to both") + btn_write_50.clicked.connect(self.write_50) + controls_layout.addWidget(btn_write_50) + + btn_update = QPushButton("⟳ Update Displays") + btn_update.setStyleSheet("background-color: #4CAF50; color: white;") + btn_update.clicked.connect(self.update_all) + controls_layout.addWidget(btn_update) + + layout.addWidget(controls) + + # Instructions + instructions = QLabel( + "\nπŸ’‘ Instructions:\n" + "1. Click 'Write X bytes' to add data\n" + "2. Click 'Update Displays' to refresh\n" + "3. Widget 2 prints callback info to console" + ) + instructions.setStyleSheet("background-color: #f0f0f0; padding: 5px;") + layout.addWidget(instructions) + + self.resize(500, 400) + + def write_5(self): + cb_write(self.buffer1, b"HELLO") + cb_write(self.buffer2, b"HELLO") + print("Wrote 5 bytes - click Update to see") + + def write_50(self): + cb_write(self.buffer1, b"X" * 50) + cb_write(self.buffer2, b"X" * 50) + print("Wrote 50 bytes - click Update to see") + + def update_all(self): + print("\n--- Updating displays ---") + self.widget1.update_display() + self.widget2.update_display() # This calls the callback! + + app = QApplication(sys.argv) + window = DemoWindow() + window.show() + + print("\n" + "="*60) + print("COMPACT BUFFER WIDGET DEMO") + print("="*60) + print("Widget 1: Basic compact widget") + print("Widget 2: Same but with callback function") + print("\nClick buttons and watch:") + print(" β€’ Compact one-line display") + print(" β€’ Manual updates") + print(" β€’ Callback prints to console") + print("="*60 + "\n") + + sys.exit(app.exec()) + + +if __name__ == "__main__": + demo() diff --git a/buffer_kit/circular_buffer.py b/buffer_kit/circular_buffer.py new file mode 100644 index 0000000..33f6d7a --- /dev/null +++ b/buffer_kit/circular_buffer.py @@ -0,0 +1,618 @@ +# ============================================================================= +# Kynsight β€” Circular Buffer Kit (Improved Readability) +# +# Author: Kerem Yollu (improved with Claude) +# License: MIT +# Company: Kynsight +# Created: 2025-10-24 +# +# WHAT IS A CIRCULAR BUFFER? +# =========================== +# Think of it like a tape recorder with limited tape that loops back: +# +# Write Position (wraps around) +# ↓ +# [D][E][F][A][B][C] ← Buffer (capacity = 6 bytes) +# ↑ ↑ +# oldest newest +# +# When full and you write more β†’ oldest data gets overwritten (drop-oldest) +# +# KEY FEATURE: Absolute Write Offset (w_abs) +# ========================================== +# Unlike typical circular buffers that only track position (0-5 above), +# we ALSO track total bytes ever written: +# +# w_abs = 0: [] (empty) +# w_abs = 3: [A][B][C][ ][ ][ ] (3 bytes written) +# w_abs = 6: [A][B][C][D][E][F] (6 bytes, full) +# w_abs = 9: [G][H][I][D][E][F] (9 bytes total, overwrote A,B,C) +# ↑ newest ↑ oldest +# +# WHY? To copy data spans unambiguously: +# - "Give me bytes from w_abs=5 to w_abs=9" β†’ [F][G][H][I] +# - Works even if buffer wrapped around multiple times +# +# C EQUIVALENT: +# ============= +# In C, you'd use: +# - uint8_t* buffer (malloc'd array) +# - size_t write_pos = w_abs % capacity (for indexing) +# - pthread_mutex_t lock (for thread safety) +# +# In Python: +# - bytearray (like uint8_t* but managed memory) +# - % operator works same as C +# - threading.Lock (like pthread_mutex_t) +# +# ============================================================================= + +from __future__ import annotations +from dataclasses import dataclass, field +from enum import Enum +from typing import Tuple, Optional +import threading + + +# ============================================================================= +# STATUS CODES (like errno in C) +# ============================================================================= + +class Status(Enum): + """ + Return codes for all operations. + + C equivalent: + enum Status { OK=0, BAD_CONFIG=1, NOT_READY=2, OVERFLOW=3 }; + + Python advantage: Enum names are more readable than raw ints + """ + OK = 0 # Operation succeeded + BAD_CONFIG = 1 # Invalid parameter (e.g., size <= 0) + NOT_READY = 2 # Buffer not initialized + OVERFLOW = 3 # Data larger than capacity (was truncated) + + +# ============================================================================= +# BUFFER HANDLE (like a struct in C) +# ============================================================================= + +@dataclass +class CircularBufferHandle: + """ + Opaque handle for the circular buffer state. + + C equivalent: + typedef struct { + size_t capacity; + uint8_t* buffer; + size_t write_absolute; + int overflow_count; + pthread_mutex_t lock; + } CircularBuffer; + + Python @dataclass: Automatically generates __init__, __repr__, etc. + Like a C struct but with methods and automatic constructor. + + Fields explained: + ----------------- + capacity: + Total size in bytes (like malloc(capacity) in C) + + buffer: + Actual storage (bytearray = Python's managed uint8_t array) + + write_absolute: + Total bytes written since creation (monotonic, never wraps) + To get position in buffer: write_absolute % capacity + + overflow_count: + How many times we wrote more data than capacity at once + (these writes get truncated to keep newest data) + + lock: + For thread safety (like pthread_mutex_t in C) + 'with h.lock:' in Python = pthread_mutex_lock/unlock in C + """ + + capacity: int + buffer: bytearray = field(default_factory=bytearray) + write_absolute: int = 0 + overflow_count: int = 0 + lock: threading.Lock = field(default_factory=threading.Lock) + + +# ============================================================================= +# INITIALIZATION / CLEANUP +# ============================================================================= + +def cb_init(capacity_bytes: int) -> Tuple[Status, Optional[CircularBufferHandle]]: + """ + Create and initialize a circular buffer. + + C equivalent: + CircularBuffer* cb_init(size_t capacity) { + if (capacity <= 0) return NULL; + CircularBuffer* cb = malloc(sizeof(CircularBuffer)); + cb->buffer = malloc(capacity); + cb->capacity = capacity; + cb->write_absolute = 0; + cb->overflow_count = 0; + pthread_mutex_init(&cb->lock, NULL); + return cb; + } + + Args: + capacity_bytes: Size of buffer in bytes (must be > 0) + + Returns: + (Status.OK, handle) if successful + (Status.BAD_CONFIG, None) if capacity_bytes <= 0 + + Example: + status, buffer = cb_init(1024) # 1KB buffer + if status == Status.OK: + # use buffer... + """ + # Validation + if capacity_bytes <= 0: + return (Status.BAD_CONFIG, None) + + # Create handle (like malloc + init in C) + handle = CircularBufferHandle( + capacity=capacity_bytes, + buffer=bytearray(capacity_bytes) # Pre-allocated, zero-filled + ) + + return (Status.OK, handle) + + +def cb_deinit(handle: CircularBufferHandle) -> Status: + """ + Clean up buffer resources. + + C equivalent: + void cb_deinit(CircularBuffer* cb) { + free(cb->buffer); + pthread_mutex_destroy(&cb->lock); + free(cb); + } + + Python note: Not strictly needed (garbage collector handles cleanup), + but included for API consistency and explicit resource management. + + Args: + handle: Buffer to clean up + + Returns: + Status.OK always + """ + # In Python, just returning is enough (GC will clean up) + # But we could explicitly clear if needed: + # handle.buffer = bytearray() + return Status.OK + + +def cb_reset(handle: CircularBufferHandle) -> Status: + """ + Reset buffer to empty state (zero bytes written). + + Like flushing or clearing the buffer. + + C equivalent: + void cb_reset(CircularBuffer* cb) { + pthread_mutex_lock(&cb->lock); + memset(cb->buffer, 0, cb->capacity); + cb->write_absolute = 0; + cb->overflow_count = 0; + pthread_mutex_unlock(&cb->lock); + } + + Effects: + - Zeros all bytes in buffer + - Resets write_absolute to 0 + - Resets overflow_count to 0 + + Thread-safe: Uses internal lock + + Args: + handle: Buffer to reset + + Returns: + Status.OK always + """ + with handle.lock: # ← Python's 'with' = pthread_mutex_lock/unlock in C + # Zero out the buffer + handle.buffer[:] = b"\x00" * handle.capacity + + # Reset counters + handle.write_absolute = 0 + handle.overflow_count = 0 + + return Status.OK + + +# ============================================================================= +# WRITE OPERATION +# ============================================================================= + +def cb_write(handle: CircularBufferHandle, data: bytes) -> Tuple[Status, int]: + """ + Write bytes to the buffer (drop-oldest on overflow). + + BEHAVIOR: + --------- + If data fits: Write normally + If data > capacity: Keep only the NEWEST 'capacity' bytes + + Example with capacity=4: + Initial: [ ][ ][ ][ ] + Write ABC: [A][B][C][ ] β†’ w_abs=3 + Write DE: [A][B][C][D] β†’ w_abs=4 (full) + Write FG: [F][G][C][D] β†’ w_abs=6 (overwrote A,B) + ↑ newest ↑ oldest + + C equivalent: + int cb_write(CircularBuffer* cb, const uint8_t* data, size_t len) { + pthread_mutex_lock(&cb->lock); + + // Handle overflow + if (len > cb->capacity) { + data = data + (len - cb->capacity); // keep last N bytes + len = cb->capacity; + cb->overflow_count++; + } + + // Calculate positions + size_t pos = cb->write_absolute % cb->capacity; + + // Write (might wrap around) + if (pos + len <= cb->capacity) { + memcpy(&cb->buffer[pos], data, len); + } else { + size_t first_part = cb->capacity - pos; + memcpy(&cb->buffer[pos], data, first_part); + memcpy(&cb->buffer[0], data + first_part, len - first_part); + } + + cb->write_absolute += len; + pthread_mutex_unlock(&cb->lock); + return len; + } + + Args: + handle: Buffer to write to + data: Bytes to write + + Returns: + (Status.OK, bytes_written) + where bytes_written = min(len(data), capacity) + + Thread-safe: Yes + """ + # Edge case: empty write + if not data: + return (Status.OK, 0) + + with handle.lock: + bytes_to_write = len(data) + + # OVERFLOW HANDLING: If data > capacity, keep only newest bytes + if bytes_to_write > handle.capacity: + # Keep last 'capacity' bytes only + data = data[-handle.capacity:] # Python slice (negative index from end) + bytes_to_write = handle.capacity + handle.overflow_count += 1 + + # CALCULATE WRITE POSITION + # Like: size_t pos = write_absolute % capacity in C + position = handle.write_absolute % handle.capacity + end_position = position + bytes_to_write + + # WRITE DATA (handle wrap-around) + if end_position <= handle.capacity: + # Case 1: Data fits without wrapping + # [___ABC___] β†’ write at position 3 + handle.buffer[position:end_position] = data + else: + # Case 2: Data wraps around to start + # [___ABC] β†’ write "DEFGH" becomes [GH_DEF] + # ↑wrap here + + # Split into two parts + first_part_size = handle.capacity - position # bytes until end + + # Write first part (to end of buffer) + handle.buffer[position:] = data[:first_part_size] + + # Write second part (wrapped to start) + handle.buffer[:end_position - handle.capacity] = data[first_part_size:] + + # UPDATE ABSOLUTE POSITION (always increases, never wraps) + handle.write_absolute += bytes_to_write + + return (Status.OK, bytes_to_write) + + +# ============================================================================= +# READ OPERATION (SPAN COPY) +# ============================================================================= + +def cb_copy_span( + handle: CircularBufferHandle, + start_absolute: int, + end_absolute: int +) -> Tuple[Status, bytes]: + """ + Copy a span of bytes [start_absolute, end_absolute) from the buffer. + + KEY CONCEPT: Uses absolute offsets, not ring positions! + + Example with capacity=6, w_abs=10: + Buffer state: [G][H][I][J][D][E][F] + ↑ newest (w_abs=10) ↑ oldest (w_abs=4) + + Request: copy_span(5, 8) β†’ want bytes at w_abs [5,6,7] + Result: [F][G][H] + + If requested span > capacity: + Returns the newest 'capacity' bytes + (because older data was already overwritten) + + C equivalent: + int cb_copy_span(CircularBuffer* cb, size_t start, size_t end, + uint8_t* out_buffer) { + pthread_mutex_lock(&cb->lock); + + size_t len = end - start; + if (len > cb->capacity) { + // Can't retrieve more than capacity + start = cb->write_absolute - cb->capacity; + end = cb->write_absolute; + len = cb->capacity; + } + + for (size_t i = 0; i < len; i++) { + size_t pos = (start + i) % cb->capacity; + out_buffer[i] = cb->buffer[pos]; + } + + pthread_mutex_unlock(&cb->lock); + return len; + } + + Args: + handle: Buffer to read from + start_absolute: Start offset (absolute, not position in ring) + end_absolute: End offset (exclusive) + + Returns: + (Status.OK, bytes_data) + + Thread-safe: Yes + """ + # Edge case: empty span + if end_absolute <= start_absolute: + return (Status.OK, b"") + + span_length = end_absolute - start_absolute + + # OVERFLOW PROTECTION: Can't retrieve more than capacity + if span_length > handle.capacity: + # Adjust to newest available window + with handle.lock: + start_absolute = handle.write_absolute - handle.capacity + end_absolute = handle.write_absolute + span_length = handle.capacity + + # ALLOCATE OUTPUT BUFFER + output = bytearray(span_length) + + # COPY BYTES ONE BY ONE (handling wrap-around automatically) + with handle.lock: + for i in range(span_length): + # Calculate position in circular buffer + # Like: size_t pos = (start + i) % capacity in C + position = (start_absolute + i) % handle.capacity + output[i] = handle.buffer[position] + + return (Status.OK, bytes(output)) + + +# ============================================================================= +# QUERY FUNCTIONS (read-only, thread-safe) +# ============================================================================= + +def cb_overflows(handle: CircularBufferHandle) -> int: + """ + Get number of overflow events. + + An overflow happens when a single write is larger than capacity. + + C equivalent: + int cb_overflows(CircularBuffer* cb) { + pthread_mutex_lock(&cb->lock); + int count = cb->overflow_count; + pthread_mutex_unlock(&cb->lock); + return count; + } + + Returns: + Number of times data was truncated + """ + with handle.lock: + return handle.overflow_count + + +def cb_w_abs(handle: CircularBufferHandle) -> int: + """ + Get total bytes written since creation. + + This is the absolute write offset (monotonic counter). + + C equivalent: + size_t cb_w_abs(CircularBuffer* cb) { + pthread_mutex_lock(&cb->lock); + size_t w = cb->write_absolute; + pthread_mutex_unlock(&cb->lock); + return w; + } + + Returns: + Total bytes written (never decreases, never wraps) + """ + with handle.lock: + return handle.write_absolute + + +def cb_capacity(handle: CircularBufferHandle) -> int: + """ + Get buffer capacity in bytes. + + C equivalent: + size_t cb_capacity(CircularBuffer* cb) { + return cb->capacity; // No lock needed (const) + } + + Returns: + Total capacity (fixed at creation) + """ + # No lock needed - capacity never changes + return handle.capacity + + +def cb_fill_bytes(handle: CircularBufferHandle) -> int: + """ + Get current fill level in bytes. + + Definition: + fill = min(write_absolute, capacity) + + Why min? Once we've written 'capacity' bytes, buffer is considered + full (even though we keep writing, overwriting old data). + + Example with capacity=100: + w_abs=50 β†’ fill=50 (half full) + w_abs=100 β†’ fill=100 (full) + w_abs=200 β†’ fill=100 (still "full", old data overwritten) + + C equivalent: + size_t cb_fill_bytes(CircularBuffer* cb) { + pthread_mutex_lock(&cb->lock); + size_t fill = (cb->write_absolute < cb->capacity) + ? cb->write_absolute + : cb->capacity; + pthread_mutex_unlock(&cb->lock); + return fill; + } + + Returns: + Bytes currently in buffer (0 to capacity) + """ + with handle.lock: + # Ternary operator in Python: value_if_true if condition else value_if_false + # C equivalent: write_absolute < capacity ? write_absolute : capacity + return handle.write_absolute if handle.write_absolute < handle.capacity else handle.capacity + + +def cb_fill_pct(handle: CircularBufferHandle) -> int: + """ + Get fill level as percentage (0-100). + + Useful for progress bars or monitoring. + + C equivalent: + int cb_fill_pct(CircularBuffer* cb) { + size_t fill = cb_fill_bytes(cb); + size_t cap = cb->capacity > 0 ? cb->capacity : 1; + return (fill * 100) / cap; // Integer division + } + + Returns: + Integer percentage 0-100 (no decimal, no '%' sign) + + Example: + capacity=1000, fill=750 β†’ returns 75 + """ + fill = cb_fill_bytes(handle) + cap = handle.capacity if handle.capacity > 0 else 1 # Avoid division by zero + + # Integer division (// in Python = / in C for integers) + return (fill * 100) // cap + + +# ============================================================================= +# HELPER: Visual representation (for debugging) +# ============================================================================= + +def cb_debug_info(handle: CircularBufferHandle) -> str: + """ + Get human-readable debug information. + + Not in original, but useful for learning/debugging! + + Returns: + Multi-line string with buffer state + """ + with handle.lock: + info = f""" +Circular Buffer State: +---------------------- +Capacity: {handle.capacity} bytes +Written total: {handle.write_absolute} bytes +Fill level: {cb_fill_bytes(handle)} bytes ({cb_fill_pct(handle)}%) +Overflows: {handle.overflow_count} +Position: {handle.write_absolute % handle.capacity} +""" + return info + + +# ============================================================================= +# TEACHING NOTES +# ============================================================================= + +""" +KEY PYTHON vs C DIFFERENCES: +============================= + +1. Memory Management: + C: malloc/free, manual cleanup + Python: Automatic garbage collection + +2. Locking: + C: pthread_mutex_lock(&lock); ... pthread_mutex_unlock(&lock); + Python: with lock: ... (automatic unlock even if exception) + +3. Arrays: + C: uint8_t* buffer (pointer arithmetic) + Python: bytearray (looks like list but is byte-optimized) + +4. Slicing: + C: memcpy(dst, src, len) + Python: dst[:] = src (copies entire array) + dst[2:5] = src[2:5] (copies slice) + +5. Modulo: + C: pos = offset % capacity; + Python: pos = offset % capacity (same!) + +6. Enums: + C: enum Status { OK=0, ERROR=1 }; + Python: class Status(Enum): OK=0; ERROR=1 + +7. Structs: + C: typedef struct { int x; } MyStruct; + Python: @dataclass class MyStruct: x: int + +WHEN TO USE THIS BUFFER: +========================= +βœ… Streaming data (serial, network) +βœ… Multi-threaded producer/consumer +βœ… Fixed memory budget +βœ… Don't care about old data +βœ… Need thread-safe operations + +❌ Need to keep ALL data (use list/deque instead) +❌ Single-threaded (simpler structures work) +❌ Need to read from middle frequently (use deque) +""" diff --git a/buffer_kit/circular_buffer_test.py b/buffer_kit/circular_buffer_test.py new file mode 100644 index 0000000..90fd475 --- /dev/null +++ b/buffer_kit/circular_buffer_test.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +""" +Circular Buffer - Step-by-Step Tutorial +======================================== +Follow the examples or type your own commands! +""" + +from circular_buffer import cb_init, cb_write, cb_copy_span, cb_w_abs, cb_overflows, cb_fill_bytes, cb_fill_pct, Status + + +def show_buffer(buf, label="Buffer"): + """Simple buffer visualization.""" + w_abs = cb_w_abs(buf) + w_overflow = cb_overflows(buf) + fill_pct = cb_fill_pct(buf) + fill = cb_fill_bytes(buf) + pos = w_abs % buf.capacity + + print(f"\n {label}:") + print(" ", end="") + for i in range(buf.capacity): + b = buf.buffer[i] + if b == 0: + print("[ ]", end="") + else: + c = chr(b) if 32 <= b < 127 else f"{b:02X}" + print(f"[{c}]", end="") + + print(f" ← capacity={buf.capacity}") + print(" ", end="") + for i in range(buf.capacity): + print(" ↑ " if i == pos else " ", end="") + print(f" ← write position={pos}") + print(f" Fill: {fill}/{buf.capacity} bytes | Total written: {w_abs} | Percent Full: {fill_pct}\n") + print(f" Overflow Count : {w_overflow} \n") + + +def tutorial_mode(): + """Guided tutorial.""" + print("\n" + "="*70) + print(" TUTORIAL MODE - Follow Along!") + print("="*70 + "\n") + + # Create small buffer + capacity = 8 + print(f"Step 1: Create buffer with capacity={capacity}") + status, buf = cb_init(capacity) + print(f" β†’ Created!") + show_buffer(buf, "Empty Buffer") + + input("Press ENTER to continue...") + + # Write ABC + print("\nStep 2: Write 'ABC'") + data = b"ABC" + print(f" β†’ cb_write(buffer, {data})") + st, written = cb_write(buf, data) + print(f" β†’ Wrote {written} bytes") + show_buffer(buf, "After Writing 'ABC'") + + input("Press ENTER to continue...") + + # Write more + print("\nStep 3: Write 'DEFG'") + data = b"DEFG" + print(f" β†’ cb_write(buffer, {data})") + st, written = cb_write(buf, data) + print(f" β†’ Wrote {written} bytes") + show_buffer(buf, "After Writing 'DEFG'") + print(" Notice: Buffer is full! (7/8 bytes)") + + input("Press ENTER to continue...") + + # Read data + print("\nStep 4: Read all data") + w_abs = cb_w_abs(buf) + print(f" β†’ cb_copy_span(buffer, 0, {w_abs})") + st, data = cb_copy_span(buf, 0, w_abs) + print(f" β†’ Read: {data}") + print(f" β†’ Decoded: '{data.decode()}'") + show_buffer(buf, "Buffer Still Has Data") + print(" Note: Reading doesn't remove data!") + + input("Press ENTER to continue...") + + # Overflow + print("\nStep 5: Write more data than capacity (OVERFLOW!)") + data = b"HIJKL" + print(f" β†’ cb_write(buffer, {data}) - that's 5 more bytes!") + print(f" β†’ Buffer is full, so oldest data will be overwritten...") + st, written = cb_write(buf, data) + print(f" β†’ Wrote {written} bytes") + show_buffer(buf, "After Overflow") + print(" Notice: 'ABC' got overwritten! Oldest data lost!") + print(" Buffer now has: 'DEFGHIJK' (last 8 bytes)") + + input("Press ENTER to continue...") + + # Read after overflow + print("\nStep 6: Read data after overflow") + w_abs = cb_w_abs(buf) + start = w_abs - capacity # Only keep what fits + print(f" β†’ cb_copy_span(buffer, {start}, {w_abs})") + st, data = cb_copy_span(buf, start, w_abs) + print(f" β†’ Read: {data}") + print(f" β†’ Decoded: '{data.decode()}'") + print("\n βœ… This is how circular buffer works!") + print(" β€’ Fixed size (no growing)") + print(" β€’ Drop oldest when full") + print(" β€’ Keep most recent data") + + +def playground_mode(): + """Free-form playground.""" + print("\n" + "="*70) + print(" PLAYGROUND MODE - Type Your Own Commands!") + print("="*70 + "\n") + + print("Commands:") + print(" w - Write text (e.g., 'w HELLO')") + print(" r - Read all data") + print(" r - Read last n bytes") + print(" s - Show status") + print(" c - Clear (reset) buffer") + print(" q - Quit") + print() + + # Create buffer + capacity = 12 + status, buf = cb_init(capacity) + print(f"Created buffer: capacity={capacity}") + show_buffer(buf, "Initial State") + + while True: + try: + cmd = input("cmd> ").strip() + + if not cmd: + continue + + parts = cmd.split(maxsplit=1) + action = parts[0].lower() + + if action == 'q': + print("Goodbye!\n") + break + + elif action == 'w': + if len(parts) < 2: + print("Usage: w ") + continue + text = parts[1] + data = text.encode('ascii') + print(f"\nWriting: {data} ('{text}')") + st, written = cb_write(buf, data) + print(f"Wrote: {written} bytes") + show_buffer(buf) + + elif action == 'r': + w_abs = cb_w_abs(buf) + if w_abs == 0: + print("\nBuffer is empty!") + continue + + if len(parts) > 1: + try: + n = int(parts[1]) + start = max(0, w_abs - n) + except: + print("Invalid number") + continue + else: + start = max(0, w_abs - capacity) + + st, data = cb_copy_span(buf, start, w_abs) + print(f"\nRead: {data}") + print(f"Text: '{data.decode('ascii', errors='replace')}'") + + elif action == 's': + show_buffer(buf, "Current State") + + elif action == 'c': + buf.buffer[:] = b'\x00' * capacity + buf.write_absolute = 0 + buf.overflows = 0 + print("\nBuffer cleared!") + show_buffer(buf) + + else: + print(f"Unknown command: {action}") + + except KeyboardInterrupt: + print("\n\nGoodbye!\n") + break + except EOFError: + print("\n\nGoodbye!\n") + break + except Exception as e: + print(f"Error: {e}") + + +def quick_test(): + """Quick automated test you can watch.""" + print("\n" + "="*70) + print(" QUICK TEST - Watch What Happens!") + print("="*70 + "\n") + + import time + + status, buf = cb_init(10) + print("Created buffer: capacity=10") + show_buffer(buf) + time.sleep(1) + + print("Writing: ABC") + cb_write(buf, b"ABC") + show_buffer(buf) + time.sleep(1) + + print("Writing: DEFGH") + cb_write(buf, b"DEFGH") + show_buffer(buf) + time.sleep(1) + + print("Writing: IJKLM (OVERFLOW!)") + cb_write(buf, b"IJKLM") + show_buffer(buf) + print("Notice: Oldest data (ABC) was overwritten!") + time.sleep(1) + + w_abs = cb_w_abs(buf) + st, data = cb_copy_span(buf, w_abs - 10, w_abs) + print(f"\nFinal data: {data.decode()}") + print("That's the last 10 bytes written!\n") + + +def main(): + """Main menu.""" + print("\n" + "="*70) + print(" CIRCULAR BUFFER - LEARNING PLAYGROUND") + print("="*70) + print("\nChoose a mode:") + print(" 1. Tutorial (step-by-step guided)") + print(" 2. Playground (type your own commands)") + print(" 3. Quick Test (watch automated demo)") + print(" q. Quit") + print() + + choice = input("Select [1/2/3/q]: ").strip() + + if choice == '1': + tutorial_mode() + elif choice == '2': + playground_mode() + elif choice == '3': + quick_test() + elif choice.lower() == 'q': + print("\nGoodbye!\n") + else: + print("\nInvalid choice. Goodbye!\n") + + +if __name__ == "__main__": + main() diff --git a/command_table/command_table.py b/command_table/command_table.py new file mode 100644 index 0000000..aa38428 --- /dev/null +++ b/command_table/command_table.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +""" +Command Table Widget - Reusable +================================ +Auto-detecting command table with CRUD operations. + +Features: +- Auto-detect columns from database +- Search + filter +- Add/Edit/Delete commands +- Gray out when disconnected +- Double-click to execute +- Works for both UART and I2C + +Author: Kynsight +Version: 1.0.0 +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, + QPushButton, QLineEdit, QLabel, QComboBox, QHeaderView, + QAbstractItemView, QDialog, QFormLayout, QDialogButtonBox, + QMessageBox, QTextEdit +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QColor + + +class CommandDialog(QDialog): + """Dialog for adding/editing commands.""" + + def __init__(self, command_type, all_columns, values=None, parent=None): + super().__init__(parent) + + self.command_type = command_type + self.all_columns = all_columns + self.fields = {} + + self.setWindowTitle("Add Command" if values is None else "Edit Command") + self.setModal(True) + + layout = QVBoxLayout() + self.setLayout(layout) + + # Form + form_layout = QFormLayout() + + for col in all_columns: + if col == 'command_id': + continue # Skip ID + + # Create appropriate widget + if col == 'description': + widget = QTextEdit() + widget.setMaximumHeight(80) + if values and col in values: + widget.setPlainText(str(values[col])) + else: + widget = QLineEdit() + if values and col in values: + widget.setText(str(values[col])) + + self.fields[col] = widget + form_layout.addRow(col.replace('_', ' ').title() + ":", widget) + + layout.addLayout(form_layout) + + # Buttons + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | + QDialogButtonBox.StandardButton.Cancel + ) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def get_values(self): + """Get values from form.""" + values = {} + for col, widget in self.fields.items(): + if isinstance(widget, QTextEdit): + values[col] = widget.toPlainText() + else: + values[col] = widget.text() + return values + + +class CommandTableWidget(QWidget): + """ + Reusable command table with auto-detection. + + Signals: + command_selected: (command_id, command_data) + command_double_clicked: (command_id, command_data) + """ + + command_selected = pyqtSignal(int, dict) # command_id, all column data + command_double_clicked = pyqtSignal(int, dict) + + def __init__(self, db_connection, command_type='uart', visible_columns=None, parent=None): + """ + Args: + db_connection: SQLite connection + command_type: 'uart' or 'i2c' + visible_columns: List of columns to display (None = all) + """ + super().__init__(parent) + + self.conn = db_connection + self.command_type = command_type + self.table_name = f"{command_type}_commands" + + self.all_commands = [] + self.all_columns = [] # All columns from DB + self.visible_columns = visible_columns # Columns to show + self.filter_column = None # For category/operation filter + + self._init_ui() + self._detect_columns() + self._load_commands() + + def _init_ui(self): + """Initialize UI.""" + layout = QVBoxLayout() + self.setLayout(layout) + + # Search and filter + filter_layout = QHBoxLayout() + + self.txt_search = QLineEdit() + self.txt_search.setPlaceholderText("Search commands...") + self.txt_search.textChanged.connect(self._filter_table) + filter_layout.addWidget(self.txt_search, 2) + + self.combo_filter = QComboBox() + self.combo_filter.currentTextChanged.connect(self._filter_table) + filter_layout.addWidget(self.combo_filter, 1) + + layout.addLayout(filter_layout) + + # Table + self.table = QTableWidget() + self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.table.setAlternatingRowColors(True) + self.table.verticalHeader().setVisible(False) + self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + + self.table.itemSelectionChanged.connect(self._on_selection_changed) + self.table.cellDoubleClicked.connect(self._on_double_click) + + layout.addWidget(self.table) + + # CRUD buttons + btn_layout = QHBoxLayout() + + self.btn_add = QPushButton("+ Add") + self.btn_add.clicked.connect(self._add_command) + btn_layout.addWidget(self.btn_add) + + self.btn_edit = QPushButton("✎ Edit") + self.btn_edit.setEnabled(False) + self.btn_edit.clicked.connect(self._edit_command) + btn_layout.addWidget(self.btn_edit) + + self.btn_delete = QPushButton("πŸ—‘ Delete") + self.btn_delete.setEnabled(False) + self.btn_delete.clicked.connect(self._delete_command) + btn_layout.addWidget(self.btn_delete) + + btn_layout.addStretch() + + self.lbl_count = QLabel() + btn_layout.addWidget(self.lbl_count) + + layout.addLayout(btn_layout) + + def _detect_columns(self): + """Auto-detect columns from database.""" + cursor = self.conn.execute(f"PRAGMA table_info({self.table_name})") + self.all_columns = [row[1] for row in cursor.fetchall()] + + # If no visible_columns specified, show all + if self.visible_columns is None: + self.visible_columns = self.all_columns + + # Setup table columns (only visible ones) + self.table.setColumnCount(len(self.visible_columns)) + self.table.setHorizontalHeaderLabels([ + col.replace('_', ' ').title() for col in self.visible_columns + ]) + + # Make ALL columns user-resizable (Interactive mode) + header = self.table.horizontalHeader() + for i in range(len(self.visible_columns)): + header.setSectionResizeMode(i, QHeaderView.ResizeMode.Interactive) + + # Detect filter column (category for UART, operation for I2C) + if 'category' in self.all_columns: + self.filter_column = 'category' + self.combo_filter.addItem("All Categories") + elif 'operation' in self.all_columns: + self.filter_column = 'operation' + self.combo_filter.addItem("All Operations") + else: + self.combo_filter.setVisible(False) + + def _load_commands(self): + """Load commands from database.""" + cursor = self.conn.execute(f""" + SELECT * FROM {self.table_name} + WHERE is_active = 1 + ORDER BY {self.all_columns[1]} + """) + + self.all_commands = [] + for row in cursor.fetchall(): + cmd_dict = {self.all_columns[i]: row[i] for i in range(len(self.all_columns))} + self.all_commands.append(cmd_dict) + + # Populate filter dropdown + if self.filter_column: + values = sorted(set(cmd[self.filter_column] for cmd in self.all_commands + if cmd[self.filter_column])) + self.combo_filter.clear() + self.combo_filter.addItem(f"All {self.filter_column.title()}s") + self.combo_filter.addItems(values) + + self._display_commands(self.all_commands) + + def _display_commands(self, commands): + """Display commands in table (only visible columns).""" + self.table.setRowCount(len(commands)) + + for row_idx, cmd in enumerate(commands): + for col_idx, col_name in enumerate(self.visible_columns): + value = cmd.get(col_name, '') + + item = QTableWidgetItem(str(value) if value is not None else '') + + # Center-align ID + if col_name == 'command_id': + item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + + self.table.setItem(row_idx, col_idx, item) + + # Color by category/operation (dark theme compatible) + color = self._get_row_color(cmd) + if color: + item.setBackground(color) + + self.lbl_count.setText(f"Count: {len(commands)}") + + def _get_row_color(self, cmd): + """Get row color based on category/operation (dark theme compatible).""" + if self.command_type == 'uart': + colors = { + "Door": QColor(70, 130, 180), # Steel blue + "Action": QColor(255, 165, 0), # Orange + "Error": QColor(220, 20, 60), # Crimson + "Status": QColor(60, 179, 113), # Medium sea green + "Motor": QColor(218, 165, 32), # Goldenrod + "Sensor": QColor(147, 112, 219), # Medium purple + "Power": QColor(138, 43, 226), # Blue violet + "Info": QColor(100, 149, 237), # Cornflower blue + } + return colors.get(cmd.get('category')) + else: # i2c + operation = cmd.get('operation') + if operation == 'read': + return QColor(60, 179, 113) # Medium sea green + elif operation == 'write': + return QColor(255, 140, 0) # Dark orange + return None + + def _filter_table(self): + """Filter table by search and dropdown.""" + search_text = self.txt_search.text().lower() + filter_value = self.combo_filter.currentText() + + # Filter commands + filtered = [] + for cmd in self.all_commands: + # Filter by dropdown + if self.filter_column and not filter_value.startswith("All"): + if cmd.get(self.filter_column) != filter_value: + continue + + # Filter by search text + if search_text: + found = False + for col_name, value in cmd.items(): + if search_text in str(value).lower(): + found = True + break + if not found: + continue + + filtered.append(cmd) + + self._display_commands(filtered) + + def _on_selection_changed(self): + """Handle selection change.""" + selected = self.table.selectedItems() + has_selection = bool(selected) + + # Only auto-enable edit/delete if they're not externally disabled + # (i.e., if Add button is enabled, we're in edit mode) + if self.btn_add.isEnabled(): + self.btn_edit.setEnabled(has_selection) + self.btn_delete.setEnabled(has_selection) + + if has_selection: + row = selected[0].row() + + # Get command_id from visible columns + cmd_id_col_idx = self.visible_columns.index('command_id') if 'command_id' in self.visible_columns else 0 + command_id = int(self.table.item(row, cmd_id_col_idx).text()) + + # Get ALL column data (from all_commands, not just visible) + cmd_data = next((cmd for cmd in self.all_commands + if cmd['command_id'] == command_id), {}) + + # Emit signal + self.command_selected.emit(command_id, cmd_data) + + def _on_double_click(self, row, col): + """Handle double-click.""" + # Get command_id from visible columns + cmd_id_col_idx = self.visible_columns.index('command_id') if 'command_id' in self.visible_columns else 0 + command_id = int(self.table.item(row, cmd_id_col_idx).text()) + + # Get ALL column data + cmd_data = next((cmd for cmd in self.all_commands + if cmd['command_id'] == command_id), {}) + + # Emit signal + self.command_double_clicked.emit(command_id, cmd_data) + + def _add_command(self): + """Add new command.""" + dialog = CommandDialog(self.command_type, self.all_columns, parent=self) + + if dialog.exec() == QDialog.DialogCode.Accepted: + values = dialog.get_values() + + # Build INSERT query + cols = [col for col in self.all_columns if col != 'command_id'] + placeholders = ', '.join(['?'] * len(cols)) + col_names = ', '.join(cols) + + try: + self.conn.execute(f""" + INSERT INTO {self.table_name} ({col_names}) + VALUES ({placeholders}) + """, [values.get(col, '') for col in cols]) + + self.conn.commit() + self._load_commands() + + QMessageBox.information(self, "Success", "Command added successfully!") + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to add command:\n{e}") + + def _edit_command(self): + """Edit selected command.""" + selected = self.table.selectedItems() + if not selected: + return + + row = selected[0].row() + + # Get command_id from visible columns + cmd_id_col_idx = self.visible_columns.index('command_id') if 'command_id' in self.visible_columns else 0 + command_id = int(self.table.item(row, cmd_id_col_idx).text()) + + # Get current values (all columns) + current_values = next((cmd for cmd in self.all_commands + if cmd['command_id'] == command_id), {}) + + dialog = CommandDialog(self.command_type, self.all_columns, + current_values, parent=self) + + if dialog.exec() == QDialog.DialogCode.Accepted: + values = dialog.get_values() + + # Build UPDATE query + set_clause = ', '.join([f"{col} = ?" for col in values.keys()]) + + try: + self.conn.execute(f""" + UPDATE {self.table_name} + SET {set_clause} + WHERE command_id = ? + """, list(values.values()) + [command_id]) + + self.conn.commit() + self._load_commands() + + QMessageBox.information(self, "Success", "Command updated successfully!") + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to update command:\n{e}") + + def _delete_command(self): + """Delete selected command.""" + selected = self.table.selectedItems() + if not selected: + return + + row = selected[0].row() + + # Get command_id and name + cmd_id_col_idx = self.visible_columns.index('command_id') if 'command_id' in self.visible_columns else 0 + cmd_name_col_idx = self.visible_columns.index('command_name') if 'command_name' in self.visible_columns else 1 + + command_id = int(self.table.item(row, cmd_id_col_idx).text()) + name = self.table.item(row, cmd_name_col_idx).text() + + reply = QMessageBox.question( + self, + "Delete Command", + f"Delete command '{name}'?\n\nThis will mark it as inactive.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.Yes: + try: + # Soft delete (mark as inactive) + self.conn.execute(f""" + UPDATE {self.table_name} + SET is_active = 0 + WHERE command_id = ? + """, (command_id,)) + + self.conn.commit() + self._load_commands() + + QMessageBox.information(self, "Success", "Command deleted successfully!") + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to delete command:\n{e}") + + def set_enabled_state(self, enabled: bool): + """Enable/disable table (gray out when disconnected).""" + self.table.setEnabled(enabled) + self.txt_search.setEnabled(enabled) + self.combo_filter.setEnabled(enabled) + # Keep CRUD buttons always enabled + + def get_selected_command_id(self): + """Get selected command ID.""" + selected = self.table.selectedItems() + if selected: + return int(self.table.item(selected[0].row(), 0).text()) + return None + + def refresh(self): + """Reload commands from database.""" + self._load_commands() + + +if __name__ == "__main__": + import sys + from PyQt6.QtWidgets import QApplication + import sqlite3 + + app = QApplication(sys.argv) + + conn = sqlite3.connect("./database/ehinge.db") + + widget = CommandTableWidget(conn, 'uart') + widget.setWindowTitle("Command Table - UART") + widget.resize(900, 600) + widget.show() + + sys.exit(app.exec()) diff --git a/configure_interface_widget.py b/configure_interface_widget.py new file mode 100644 index 0000000..559dd8c --- /dev/null +++ b/configure_interface_widget.py @@ -0,0 +1,557 @@ +#!/usr/bin/env python3 +""" +Configure Interface Widget - vzug-e-hinge +========================================== +Manage interface profiles (UART/I2C configuration). +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, + QLineEdit, QPushButton, QListWidget, QListWidgetItem, + QMessageBox, QSplitter, QFormLayout, QSpinBox, QComboBox, + QCheckBox, QScrollArea, QTabWidget +) +from PyQt6.QtCore import Qt, pyqtSignal +from database.init_database import DatabaseManager + +try: + from serial.tools import list_ports + SERIAL_AVAILABLE = True +except ImportError: + SERIAL_AVAILABLE = False + +import os +import glob + + +# Standard UART settings +BAUD_RATES = ["9600", "19200", "38400", "57600", "115200", "230400", "460800", "921600"] +PARITY_OPTIONS = ["N", "E", "O", "M", "S"] + + +class ConfigureInterfaceWidget(QWidget): + """Widget for managing interface profiles.""" + + profile_saved = pyqtSignal(int) + profile_deleted = pyqtSignal(int) + + def __init__(self, db_manager: DatabaseManager): + super().__init__() + self.db_manager = db_manager + self.db_conn = db_manager.get_connection() + self.current_profile_id = None + self._init_ui() + self._load_profiles() + + def _init_ui(self): + """Initialize UI.""" + layout = QVBoxLayout() + + title = QLabel("Configure Interface Profiles") + title.setStyleSheet("font-size: 16px; font-weight: bold;") + layout.addWidget(title) + + splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.addWidget(self._create_list_panel()) + splitter.addWidget(self._create_editor_panel()) + splitter.setSizes([300, 700]) + + layout.addWidget(splitter) + self.setLayout(layout) + + def _create_list_panel(self): + """Create profile list panel.""" + widget = QWidget() + layout = QVBoxLayout() + + layout.addWidget(QLabel("Interface Profiles:")) + + self.profile_list = QListWidget() + self.profile_list.currentItemChanged.connect(self._on_profile_selected) + layout.addWidget(self.profile_list) + + btn_layout = QHBoxLayout() + self.btn_add = QPushButton("Add New") + self.btn_add.clicked.connect(self._on_add_clicked) + btn_layout.addWidget(self.btn_add) + + self.btn_delete = QPushButton("Delete") + self.btn_delete.clicked.connect(self._on_delete_clicked) + self.btn_delete.setEnabled(False) + btn_layout.addWidget(self.btn_delete) + + layout.addLayout(btn_layout) + widget.setLayout(layout) + return widget + + def _create_editor_panel(self): + """Create editor panel with tabs.""" + widget = QWidget() + layout = QVBoxLayout() + + name_layout = QHBoxLayout() + name_layout.addWidget(QLabel("Profile Name:")) + self.name_input = QLineEdit() + self.name_input.textChanged.connect(self._enable_save) + name_layout.addWidget(self.name_input) + layout.addLayout(name_layout) + + self.tabs = QTabWidget() + self.tabs.addTab(self._create_uart_command_tab(), "UART Command") + self.tabs.addTab(self._create_uart_logger_tab(), "UART Logger") + self.tabs.addTab(self._create_i2c_tab(), "I2C") + layout.addWidget(self.tabs) + + btn_layout = QHBoxLayout() + self.btn_save = QPushButton("Save Profile") + self.btn_save.clicked.connect(self._on_save_clicked) + self.btn_save.setEnabled(False) + btn_layout.addWidget(self.btn_save) + btn_layout.addStretch() + + layout.addLayout(btn_layout) + widget.setLayout(layout) + return widget + + def _create_uart_command_tab(self): + """Create UART Command tab (no packet detection).""" + scroll = QScrollArea() + scroll.setWidgetResizable(True) + container = QWidget() + layout = QVBoxLayout() + + uart_group = QGroupBox("UART Command Settings") + form = QFormLayout() + + # Port with refresh + port_layout = QHBoxLayout() + self.cmd_port = QComboBox() + self.cmd_port.setEditable(True) + port_layout.addWidget(self.cmd_port) + btn_refresh_cmd = QPushButton("πŸ”„") + btn_refresh_cmd.setMaximumWidth(40) + btn_refresh_cmd.clicked.connect(lambda: self._refresh_uart_ports(self.cmd_port)) + port_layout.addWidget(btn_refresh_cmd) + form.addRow("Port:", port_layout) + + self.cmd_baud = QComboBox() + self.cmd_baud.addItems(BAUD_RATES) + self.cmd_baud.setCurrentText("115200") + form.addRow("Baud Rate:", self.cmd_baud) + + self.cmd_data_bits = QSpinBox() + self.cmd_data_bits.setRange(5, 8) + self.cmd_data_bits.setValue(8) + form.addRow("Data Bits:", self.cmd_data_bits) + + self.cmd_stop_bits = QSpinBox() + self.cmd_stop_bits.setRange(1, 2) + self.cmd_stop_bits.setValue(1) + form.addRow("Stop Bits:", self.cmd_stop_bits) + + self.cmd_parity = QComboBox() + self.cmd_parity.addItems(PARITY_OPTIONS) + form.addRow("Parity:", self.cmd_parity) + + self.cmd_timeout = QSpinBox() + self.cmd_timeout.setRange(100, 10000) + self.cmd_timeout.setValue(1000) + self.cmd_timeout.setSuffix(" ms") + form.addRow("Timeout:", self.cmd_timeout) + + uart_group.setLayout(form) + layout.addWidget(uart_group) + layout.addStretch() + + container.setLayout(layout) + scroll.setWidget(container) + + # Initial port scan + self._refresh_uart_ports(self.cmd_port) + return scroll + + def _create_uart_logger_tab(self): + """Create UART Logger tab (with packet detection).""" + scroll = QScrollArea() + scroll.setWidgetResizable(True) + container = QWidget() + layout = QVBoxLayout() + + uart_group = QGroupBox("UART Logger Settings") + form = QFormLayout() + + # Port with refresh + port_layout = QHBoxLayout() + self.log_port = QComboBox() + self.log_port.setEditable(True) + port_layout.addWidget(self.log_port) + btn_refresh_log = QPushButton("πŸ”„") + btn_refresh_log.setMaximumWidth(40) + btn_refresh_log.clicked.connect(lambda: self._refresh_uart_ports(self.log_port)) + port_layout.addWidget(btn_refresh_log) + form.addRow("Port:", port_layout) + + self.log_baud = QComboBox() + self.log_baud.addItems(BAUD_RATES) + self.log_baud.setCurrentText("115200") + form.addRow("Baud Rate:", self.log_baud) + + self.log_data_bits = QSpinBox() + self.log_data_bits.setRange(5, 8) + self.log_data_bits.setValue(8) + form.addRow("Data Bits:", self.log_data_bits) + + self.log_stop_bits = QSpinBox() + self.log_stop_bits.setRange(1, 2) + self.log_stop_bits.setValue(1) + form.addRow("Stop Bits:", self.log_stop_bits) + + self.log_parity = QComboBox() + self.log_parity.addItems(PARITY_OPTIONS) + form.addRow("Parity:", self.log_parity) + + self.log_timeout = QSpinBox() + self.log_timeout.setRange(100, 10000) + self.log_timeout.setValue(1000) + self.log_timeout.setSuffix(" ms") + form.addRow("Timeout:", self.log_timeout) + + uart_group.setLayout(form) + layout.addWidget(uart_group) + + # Packet Detection + packet_group = QGroupBox("Packet Detection") + packet_form = QFormLayout() + + self.packet_enable = QCheckBox("Enable Packet Detection") + self.packet_enable.stateChanged.connect(self._enable_save) + packet_form.addRow("", self.packet_enable) + + self.packet_start = QLineEdit("EF FE") + packet_form.addRow("Start Marker:", self.packet_start) + + self.packet_length = QSpinBox() + self.packet_length.setRange(1, 1024) + self.packet_length.setValue(17) + packet_form.addRow("Packet Length:", self.packet_length) + + self.packet_end = QLineEdit("EE") + packet_form.addRow("End Marker:", self.packet_end) + + packet_group.setLayout(packet_form) + layout.addWidget(packet_group) + layout.addStretch() + + container.setLayout(layout) + scroll.setWidget(container) + + # Initial port scan + self._refresh_uart_ports(self.log_port) + return scroll + + def _create_i2c_tab(self): + """Create I2C tab.""" + scroll = QScrollArea() + scroll.setWidgetResizable(True) + container = QWidget() + layout = QVBoxLayout() + + i2c_group = QGroupBox("I2C Settings") + form = QFormLayout() + + # Bus with refresh + bus_layout = QHBoxLayout() + self.i2c_bus = QComboBox() + self.i2c_bus.setEditable(True) + bus_layout.addWidget(self.i2c_bus) + btn_refresh_i2c = QPushButton("πŸ”„") + btn_refresh_i2c.setMaximumWidth(40) + btn_refresh_i2c.clicked.connect(self._refresh_i2c_buses) + bus_layout.addWidget(btn_refresh_i2c) + form.addRow("Bus:", bus_layout) + + self.i2c_address = QLineEdit("0x40") + form.addRow("Slave Address:", self.i2c_address) + + self.i2c_register = QLineEdit("0xFE") + form.addRow("Read Register:", self.i2c_register) + + self.i2c_length = QSpinBox() + self.i2c_length.setRange(1, 256) + self.i2c_length.setValue(2) + form.addRow("Read Length:", self.i2c_length) + + i2c_group.setLayout(form) + layout.addWidget(i2c_group) + layout.addStretch() + + container.setLayout(layout) + scroll.setWidget(container) + + # Initial bus scan + self._refresh_i2c_buses() + return scroll + + def _refresh_uart_ports(self, combo: QComboBox): + """Refresh UART ports in combo box.""" + current = combo.currentText().split(' - ')[0] if combo.currentText() else None + combo.clear() + + if SERIAL_AVAILABLE: + ports = list_ports.comports() + for port in ports: + display = f"{port.device} - {port.description}" + combo.addItem(display, port.device) + else: + # Fallback: scan /dev/tty* + for pattern in ['/dev/ttyUSB*', '/dev/ttyACM*', '/dev/ttyS*']: + for port in glob.glob(pattern): + combo.addItem(port, port) + + # Restore selection if exists + if current: + for i in range(combo.count()): + if combo.itemData(i) == current: + combo.setCurrentIndex(i) + break + + def _refresh_i2c_buses(self): + """Refresh I2C buses.""" + current = self.i2c_bus.currentText() + self.i2c_bus.clear() + + # Scan for I2C buses + for i in range(10): # Check buses 0-9 + bus_path = f"/dev/i2c-{i}" + if os.path.exists(bus_path): + self.i2c_bus.addItem(str(i)) + + # Restore selection + if current: + idx = self.i2c_bus.findText(current) + if idx >= 0: + self.i2c_bus.setCurrentIndex(idx) + + def _load_profiles(self): + """Load profiles from database.""" + self.profile_list.clear() + cursor = self.db_conn.execute( + "SELECT profile_id, profile_name FROM interface_profiles ORDER BY profile_name" + ) + for row in cursor.fetchall(): + item = QListWidgetItem(row[1]) + item.setData(Qt.ItemDataRole.UserRole, row[0]) + self.profile_list.addItem(item) + + def _load_profile_details(self, profile_id: int): + """Load profile into editor.""" + cursor = self.db_conn.execute(""" + SELECT profile_name, + uart_command_port, uart_command_baud, uart_command_data_bits, + uart_command_stop_bits, uart_command_parity, uart_command_timeout_ms, + uart_logger_port, uart_logger_baud, uart_logger_data_bits, + uart_logger_stop_bits, uart_logger_parity, uart_logger_timeout_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 + FROM interface_profiles WHERE profile_id = ? + """, (profile_id,)) + + row = cursor.fetchone() + if row: + self.name_input.setText(row[0] or "") + + # UART Command + self._set_combo_value(self.cmd_port, row[1]) + self.cmd_baud.setCurrentText(str(row[2] or 115200)) + self.cmd_data_bits.setValue(row[3] or 8) + self.cmd_stop_bits.setValue(row[4] or 1) + self.cmd_parity.setCurrentText(row[5] or "N") + self.cmd_timeout.setValue(row[6] or 1000) + + # UART Logger + self._set_combo_value(self.log_port, row[7]) + self.log_baud.setCurrentText(str(row[8] or 115200)) + self.log_data_bits.setValue(row[9] or 8) + self.log_stop_bits.setValue(row[10] or 1) + self.log_parity.setCurrentText(row[11] or "N") + self.log_timeout.setValue(row[12] or 1000) + + # Packet detection + self.packet_enable.setChecked(bool(row[13])) + self.packet_start.setText(row[14] or "") + self.packet_length.setValue(row[15] or 17) + self.packet_end.setText(row[16] or "") + + # I2C + self.i2c_bus.setCurrentText(row[17] or "") + self.i2c_address.setText(row[18] or "") + self.i2c_register.setText(row[19] or "") + self.i2c_length.setValue(row[20] or 2) + + self.current_profile_id = profile_id + self.btn_delete.setEnabled(True) + self.btn_save.setEnabled(True) # Always allow saving + + def _set_combo_value(self, combo: QComboBox, value: str): + """Set combo value by matching device.""" + if not value: + return + for i in range(combo.count()): + if combo.itemData(i) == value: + combo.setCurrentIndex(i) + return + # Not found, set as text + combo.setCurrentText(value) + + def _on_profile_selected(self, current, previous): + """Handle profile selection.""" + if current: + profile_id = current.data(Qt.ItemDataRole.UserRole) + self._load_profile_details(profile_id) + + def _on_add_clicked(self): + """Handle add button.""" + self.name_input.clear() + self._refresh_uart_ports(self.cmd_port) + self._refresh_uart_ports(self.log_port) + self._refresh_i2c_buses() + + self.profile_list.clearSelection() + self.current_profile_id = None + self.btn_delete.setEnabled(False) + self.btn_save.setEnabled(True) + self.name_input.setFocus() + + def _on_delete_clicked(self): + """Handle delete button.""" + if not self.current_profile_id: + return + + reply = QMessageBox.question( + self, "Confirm Delete", + f"Delete profile '{self.name_input.text()}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if reply != QMessageBox.StandardButton.Yes: + return + + try: + self.db_conn.execute( + "DELETE FROM interface_profiles WHERE profile_id = ?", + (self.current_profile_id,) + ) + self.db_conn.commit() + self.profile_deleted.emit(self.current_profile_id) + self._load_profiles() + self.current_profile_id = None + self.btn_save.setEnabled(False) + self.btn_delete.setEnabled(False) + QMessageBox.information(self, "Success", "Profile deleted") + except Exception as e: + QMessageBox.critical(self, "Error", f"Delete failed: {str(e)}") + + def _on_save_clicked(self): + """Handle save button.""" + name = self.name_input.text().strip() + if not name: + QMessageBox.warning(self, "Error", "Profile name required") + return + + # Validate: UART Command and UART Logger must use different ports + cmd_port = self.cmd_port.currentData() or self.cmd_port.currentText() + log_port = self.log_port.currentData() or self.log_port.currentText() + + if cmd_port and log_port and cmd_port == log_port: + QMessageBox.warning( + self, + "Port Conflict", + "UART Command and UART Logger cannot use the same port!\n\n" + f"Both are set to: {cmd_port}\n\n" + "Please select different ports." + ) + return + + try: + values = ( + name, + self.cmd_port.currentData() or self.cmd_port.currentText(), + int(self.cmd_baud.currentText()), + self.cmd_data_bits.value(), + self.cmd_stop_bits.value(), + self.cmd_parity.currentText(), + self.cmd_timeout.value(), + self.log_port.currentData() or self.log_port.currentText(), + int(self.log_baud.currentText()), + self.log_data_bits.value(), + self.log_stop_bits.value(), + self.log_parity.currentText(), + self.log_timeout.value(), + int(self.packet_enable.isChecked()), + self.packet_start.text(), + self.packet_length.value(), + self.packet_end.text(), + self.i2c_bus.currentText(), + self.i2c_address.text(), + self.i2c_register.text(), + self.i2c_length.value() + ) + + if self.current_profile_id: + self.db_conn.execute(""" + UPDATE interface_profiles SET + profile_name=?, uart_command_port=?, uart_command_baud=?, + uart_command_data_bits=?, uart_command_stop_bits=?, uart_command_parity=?, + uart_command_timeout_ms=?, uart_logger_port=?, uart_logger_baud=?, + uart_logger_data_bits=?, uart_logger_stop_bits=?, uart_logger_parity=?, + uart_logger_timeout_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=?, + last_modified=datetime('now') + WHERE profile_id=? + """, values + (self.current_profile_id,)) + msg = "Profile updated" + else: + cursor = self.db_conn.execute(""" + INSERT INTO interface_profiles ( + profile_name, uart_command_port, uart_command_baud, + uart_command_data_bits, uart_command_stop_bits, uart_command_parity, + uart_command_timeout_ms, uart_logger_port, uart_logger_baud, + uart_logger_data_bits, uart_logger_stop_bits, uart_logger_parity, + uart_logger_timeout_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 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, values) + self.current_profile_id = cursor.lastrowid + msg = "Profile created" + + self.db_conn.commit() + self.profile_saved.emit(self.current_profile_id) + self._load_profiles() + QMessageBox.information(self, "Success", msg) + self.btn_save.setEnabled(False) + except Exception as e: + QMessageBox.critical(self, "Error", f"Save failed: {str(e)}") + + def _enable_save(self): + """Enable save button.""" + self.btn_save.setEnabled(True) + + +if __name__ == "__main__": + import sys + from PyQt6.QtWidgets import QApplication + + app = QApplication(sys.argv) + db = DatabaseManager("database/ehinge.db") + db.initialize() + + widget = ConfigureInterfaceWidget(db) + widget.setWindowTitle("Configure Interface Profiles") + widget.resize(1000, 600) + widget.show() + + sys.exit(app.exec()) diff --git a/configure_session_widget.py b/configure_session_widget.py new file mode 100644 index 0000000..6d73427 --- /dev/null +++ b/configure_session_widget.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 +""" +Configure Session Widget - vzug-e-hinge +======================================== +Visual command sequence builder. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, + QLineEdit, QPushButton, QListWidget, QListWidgetItem, + QMessageBox, QSplitter, QFormLayout, QSpinBox, QComboBox +) +from PyQt6.QtCore import Qt, pyqtSignal +import json + +from database.init_database import DatabaseManager + + +class ConfigureSessionWidget(QWidget): + """Visual command sequence builder.""" + + profile_saved = pyqtSignal(int) + profile_deleted = pyqtSignal(int) + + def __init__(self, db_manager: DatabaseManager): + super().__init__() + self.db_manager = db_manager + self.db_conn = db_manager.get_connection() + self.current_profile_id = None + self.command_sequence = [] # List of {command_id, command_name, delay_ms} + self._init_ui() + self._load_profiles() + self._load_uart_commands() + + def _init_ui(self): + """Initialize UI.""" + layout = QVBoxLayout() + + title = QLabel("Configure Session Profiles") + title.setStyleSheet("font-size: 16px; font-weight: bold;") + layout.addWidget(title) + + splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.addWidget(self._create_list_panel()) + splitter.addWidget(self._create_editor_panel()) + splitter.setSizes([300, 700]) + + layout.addWidget(splitter) + self.setLayout(layout) + + def _create_list_panel(self): + """Create profile list panel.""" + widget = QWidget() + layout = QVBoxLayout() + + layout.addWidget(QLabel("Session Profiles:")) + + self.profile_list = QListWidget() + self.profile_list.currentItemChanged.connect(self._on_profile_selected) + layout.addWidget(self.profile_list) + + btn_layout = QHBoxLayout() + self.btn_add = QPushButton("Add New") + self.btn_add.clicked.connect(self._on_add_clicked) + btn_layout.addWidget(self.btn_add) + + self.btn_delete = QPushButton("Delete") + self.btn_delete.clicked.connect(self._on_delete_clicked) + self.btn_delete.setEnabled(False) + btn_layout.addWidget(self.btn_delete) + + layout.addLayout(btn_layout) + widget.setLayout(layout) + return widget + + def _create_editor_panel(self): + """Create visual command builder.""" + widget = QWidget() + layout = QVBoxLayout() + + # Profile name and description + form = QFormLayout() + self.name_input = QLineEdit() + form.addRow("Profile Name:", self.name_input) + self.desc_input = QLineEdit() + form.addRow("Description:", self.desc_input) + layout.addLayout(form) + + # Add command section + add_group = QGroupBox("Add Command") + add_layout = QVBoxLayout() + + add_form = QFormLayout() + self.cmd_combo = QComboBox() + add_form.addRow("UART Command:", self.cmd_combo) + + self.delay_spin = QSpinBox() + self.delay_spin.setRange(0, 60000) + self.delay_spin.setValue(3000) + self.delay_spin.setSuffix(" ms") + add_form.addRow("Delay After:", self.delay_spin) + + add_layout.addLayout(add_form) + + btn_add_cmd = QPushButton("βž• Add to Sequence") + btn_add_cmd.clicked.connect(self._on_add_command) + add_layout.addWidget(btn_add_cmd) + + add_group.setLayout(add_layout) + layout.addWidget(add_group) + + # Command sequence list + seq_group = QGroupBox("Command Sequence") + seq_layout = QVBoxLayout() + + self.sequence_list = QListWidget() + seq_layout.addWidget(self.sequence_list) + + # Sequence controls + seq_btn_layout = QHBoxLayout() + + self.btn_up = QPushButton("↑ Move Up") + self.btn_up.clicked.connect(self._on_move_up) + seq_btn_layout.addWidget(self.btn_up) + + self.btn_down = QPushButton("↓ Move Down") + self.btn_down.clicked.connect(self._on_move_down) + seq_btn_layout.addWidget(self.btn_down) + + self.btn_remove = QPushButton("βœ– Remove") + self.btn_remove.clicked.connect(self._on_remove_command) + seq_btn_layout.addWidget(self.btn_remove) + + seq_layout.addLayout(seq_btn_layout) + seq_group.setLayout(seq_layout) + layout.addWidget(seq_group) + + # Save button + btn_layout = QHBoxLayout() + self.btn_save = QPushButton("Save Profile") + self.btn_save.clicked.connect(self._on_save_clicked) + self.btn_save.setEnabled(False) + btn_layout.addWidget(self.btn_save) + btn_layout.addStretch() + + layout.addLayout(btn_layout) + widget.setLayout(layout) + return widget + + def _load_uart_commands(self): + """Load UART commands into dropdown.""" + self.cmd_combo.clear() + cursor = self.db_conn.execute( + "SELECT command_id, command_name FROM uart_commands WHERE is_active=1 ORDER BY command_name" + ) + for row in cursor.fetchall(): + self.cmd_combo.addItem(row[1], row[0]) + + def _load_profiles(self): + """Load profiles from database.""" + self.profile_list.clear() + cursor = self.db_conn.execute( + "SELECT profile_id, profile_name FROM session_profiles ORDER BY profile_name" + ) + for row in cursor.fetchall(): + item = QListWidgetItem(row[1]) + item.setData(Qt.ItemDataRole.UserRole, row[0]) + self.profile_list.addItem(item) + + def _load_profile_details(self, profile_id: int): + """Load profile into editor.""" + cursor = self.db_conn.execute( + "SELECT profile_name, description, command_sequence FROM session_profiles WHERE profile_id = ?", + (profile_id,) + ) + row = cursor.fetchone() + if row: + self.name_input.setText(row[0] or "") + self.desc_input.setText(row[1] or "") + + # Parse JSON and populate sequence + try: + json_obj = json.loads(row[2] or "{}") + self.command_sequence = [] + for cmd in json_obj.get("commands", []): + cmd_id = cmd["command_id"] + # Get command name from database + c = self.db_conn.execute( + "SELECT command_name FROM uart_commands WHERE command_id = ?", (cmd_id,) + ) + cmd_row = c.fetchone() + if cmd_row: + self.command_sequence.append({ + "command_id": cmd_id, + "command_name": cmd_row[0], + "delay_ms": cmd["delay_ms"] + }) + self._update_sequence_list() + except: + self.command_sequence = [] + + self.current_profile_id = profile_id + self.btn_delete.setEnabled(True) + self.btn_save.setEnabled(True) # Always allow saving + + def _update_sequence_list(self): + """Update sequence list display.""" + self.sequence_list.clear() + for i, cmd in enumerate(self.command_sequence, 1): + text = f"{i}. {cmd['command_name']} ({cmd['delay_ms']}ms)" + self.sequence_list.addItem(text) + + def _on_add_command(self): + """Add command to sequence.""" + if self.cmd_combo.count() == 0: + QMessageBox.warning(self, "Error", "No UART commands available") + return + + cmd_id = self.cmd_combo.currentData() + cmd_name = self.cmd_combo.currentText() + delay = self.delay_spin.value() + + self.command_sequence.append({ + "command_id": cmd_id, + "command_name": cmd_name, + "delay_ms": delay + }) + + self._update_sequence_list() + self.btn_save.setEnabled(True) + + def _on_move_up(self): + """Move selected command up.""" + idx = self.sequence_list.currentRow() + if idx > 0: + self.command_sequence[idx], self.command_sequence[idx-1] = \ + self.command_sequence[idx-1], self.command_sequence[idx] + self._update_sequence_list() + self.sequence_list.setCurrentRow(idx-1) + self.btn_save.setEnabled(True) + + def _on_move_down(self): + """Move selected command down.""" + idx = self.sequence_list.currentRow() + if 0 <= idx < len(self.command_sequence) - 1: + self.command_sequence[idx], self.command_sequence[idx+1] = \ + self.command_sequence[idx+1], self.command_sequence[idx] + self._update_sequence_list() + self.sequence_list.setCurrentRow(idx+1) + self.btn_save.setEnabled(True) + + def _on_remove_command(self): + """Remove selected command.""" + idx = self.sequence_list.currentRow() + if idx >= 0: + del self.command_sequence[idx] + self._update_sequence_list() + self.btn_save.setEnabled(True) + + def _on_profile_selected(self, current, previous): + """Handle profile selection.""" + if current: + profile_id = current.data(Qt.ItemDataRole.UserRole) + self._load_profile_details(profile_id) + + def _on_add_clicked(self): + """Handle add button.""" + self.name_input.clear() + self.desc_input.clear() + self.command_sequence = [] + self._update_sequence_list() + + self.profile_list.clearSelection() + self.current_profile_id = None + self.btn_delete.setEnabled(False) + self.btn_save.setEnabled(True) + self.name_input.setFocus() + + def _on_delete_clicked(self): + """Handle delete button.""" + if not self.current_profile_id: + return + + reply = QMessageBox.question( + self, "Confirm Delete", + f"Delete profile '{self.name_input.text()}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if reply != QMessageBox.StandardButton.Yes: + return + + try: + self.db_conn.execute( + "DELETE FROM session_profiles WHERE profile_id = ?", + (self.current_profile_id,) + ) + self.db_conn.commit() + self.profile_deleted.emit(self.current_profile_id) + self._load_profiles() + self.name_input.clear() + self.desc_input.clear() + self.command_sequence = [] + self._update_sequence_list() + self.current_profile_id = None + self.btn_save.setEnabled(False) + self.btn_delete.setEnabled(False) + QMessageBox.information(self, "Success", "Profile deleted") + except Exception as e: + QMessageBox.critical(self, "Error", f"Delete failed: {str(e)}") + + def _on_save_clicked(self): + """Handle save button.""" + name = self.name_input.text().strip() + if not name: + QMessageBox.warning(self, "Error", "Profile name required") + return + + if not self.command_sequence: + QMessageBox.warning(self, "Error", "Add at least one command") + return + + desc = self.desc_input.text().strip() + + # Build JSON + json_obj = { + "commands": [ + {"command_id": cmd["command_id"], "delay_ms": cmd["delay_ms"]} + for cmd in self.command_sequence + ] + } + json_text = json.dumps(json_obj) + + try: + if self.current_profile_id: + self.db_conn.execute( + "UPDATE session_profiles SET profile_name=?, description=?, " + "command_sequence=?, last_modified=datetime('now') WHERE profile_id=?", + (name, desc, json_text, self.current_profile_id) + ) + msg = "Profile updated" + else: + cursor = self.db_conn.execute( + "INSERT INTO session_profiles (profile_name, description, command_sequence) " + "VALUES (?, ?, ?)", + (name, desc, json_text) + ) + self.current_profile_id = cursor.lastrowid + msg = "Profile created" + + self.db_conn.commit() + self.profile_saved.emit(self.current_profile_id) + self._load_profiles() + QMessageBox.information(self, "Success", msg) + self.btn_save.setEnabled(False) + except Exception as e: + QMessageBox.critical(self, "Error", f"Save failed: {str(e)}") + + +if __name__ == "__main__": + import sys + from PyQt6.QtWidgets import QApplication + + app = QApplication(sys.argv) + db = DatabaseManager("database/ehinge.db") + db.initialize() + + widget = ConfigureSessionWidget(db) + widget.setWindowTitle("Configure Session Profiles") + widget.resize(1000, 600) + widget.show() + + sys.exit(app.exec()) diff --git a/database/database/ehinge.db b/database/database/ehinge.db new file mode 100644 index 0000000..e48a96a Binary files /dev/null and b/database/database/ehinge.db differ diff --git a/database/ehinge.db b/database/ehinge.db new file mode 100644 index 0000000..4f59f59 Binary files /dev/null and b/database/ehinge.db differ diff --git a/database/init_database.py b/database/init_database.py new file mode 100644 index 0000000..2f44b65 --- /dev/null +++ b/database/init_database.py @@ -0,0 +1,759 @@ +#!/usr/bin/env python3 +""" +Database Schema Generator - vzug-e-hinge +========================================= +Creates and initializes the SQLite database schema. + +Includes: +- Schema creation +- Initial data population +- Database connection management +- Size monitoring + +Author: Kynsight +Version: 1.0.0 +""" + +import sqlite3 +import os +import sys +from pathlib import Path +from datetime import datetime + + +# ============================================================================= +# Configuration +# ============================================================================= + +DEFAULT_DB_PATH = "./database/ehinge.db" +MAX_DB_SIZE_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB limit + + +# ============================================================================= +# Database Schema +# ============================================================================= + +SCHEMA_SQL = """ +-- ============================================================================= +-- 1. EXISTING COMMAND TABLES +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS "uart_commands" ( + "command_id" INTEGER, + "command_name" TEXT NOT NULL UNIQUE, + "description" TEXT, + "category" TEXT, + "hex_string" TEXT NOT NULL, + "expected_response" TEXT, + "timeout_ms" INTEGER, + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "is_active" BOOLEAN DEFAULT 1, + PRIMARY KEY("command_id" AUTOINCREMENT) +); + +CREATE TABLE IF NOT EXISTS "i2c_commands" ( + "command_id" INTEGER, + "command_name" TEXT NOT NULL UNIQUE, + "description" TEXT, + "category" TEXT, + "operation" TEXT NOT NULL, + "register" TEXT NOT NULL, + "hex_string" TEXT NOT NULL, + "device_address" TEXT NOT NULL, + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "is_active" BOOLEAN DEFAULT 1, + PRIMARY KEY("command_id" AUTOINCREMENT) +); + +-- ============================================================================= +-- 2. INTERFACE PROFILES +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS "interface_profiles" ( + "profile_id" INTEGER PRIMARY KEY AUTOINCREMENT, + "profile_name" TEXT UNIQUE NOT NULL, + "description" TEXT, + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "last_modified" TIMESTAMP, + + -- UART Command Interface + "uart_command_mode" TEXT, + "uart_command_port" TEXT, + "uart_command_baud" INTEGER, + "uart_command_data_bits" INTEGER, + "uart_command_stop_bits" INTEGER, + "uart_command_parity" TEXT, + "uart_command_timeout_ms" INTEGER, + + -- UART Logger Interface + "uart_logger_mode" TEXT, + "uart_logger_port" TEXT, + "uart_logger_baud" INTEGER, + "uart_logger_data_bits" INTEGER, + "uart_logger_stop_bits" INTEGER, + "uart_logger_parity" TEXT, + "uart_logger_timeout_ms" INTEGER, + "uart_logger_grace_ms" INTEGER, + + -- UART Logger Packet Detection + "uart_logger_packet_detect_enable" BOOLEAN DEFAULT 0, + "uart_logger_packet_detect_start" TEXT, + "uart_logger_packet_detect_length" INTEGER, + "uart_logger_packet_detect_end" TEXT, + + -- I2C Interface + "i2c_port" TEXT, + "i2c_slave_address" TEXT, + "i2c_slave_read_register" TEXT, + "i2c_slave_read_length" INTEGER +); + +-- ============================================================================= +-- 3. SESSION PROFILES +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS "session_profiles" ( + "profile_id" INTEGER PRIMARY KEY AUTOINCREMENT, + "profile_name" TEXT UNIQUE NOT NULL, + "description" TEXT, + "command_sequence" TEXT, + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "last_modified" TIMESTAMP +); + +-- ============================================================================= +-- 4. SESSIONS +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS "sessions" ( + "session_id" TEXT PRIMARY KEY, + "session_name" TEXT NOT NULL, + "session_date" TEXT NOT NULL, + "description" TEXT, + + "interface_profile_id" INTEGER, + "session_profile_id" INTEGER, + + "status" TEXT DEFAULT 'active', + "total_runs" INTEGER DEFAULT 0, + + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + "ended_at" TIMESTAMP, + + FOREIGN KEY ("interface_profile_id") REFERENCES "interface_profiles"("profile_id"), + FOREIGN KEY ("session_profile_id") REFERENCES "session_profiles"("profile_id") +); + +-- ============================================================================= +-- 5. TELEMETRY_RAW +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS "telemetry_raw" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + + "session_id" TEXT NOT NULL, + "session_name" TEXT NOT NULL, + "run_no" INTEGER NOT NULL, + "run_command_id" INTEGER NOT NULL, + + "t_ns" INTEGER NOT NULL, + "time_ms" REAL, + + "uart_raw_packet" BLOB, + "i2c_raw_bytes" BLOB, + + FOREIGN KEY ("session_id") REFERENCES "sessions"("session_id"), + FOREIGN KEY ("run_command_id") REFERENCES "uart_commands"("command_id") +); + +-- ============================================================================= +-- 6. TELEMETRY_DECODED +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS "telemetry_decoded" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + + "session_id" TEXT NOT NULL, + "session_name" TEXT NOT NULL, + "run_no" INTEGER NOT NULL, + "run_command_id" INTEGER NOT NULL, + + "t_ns" INTEGER NOT NULL, + "time_ms" REAL, + + -- UART decoded + "motor_current" INTEGER, + "encoder_value" INTEGER, + "relative_encoder_value" INTEGER, + "v24_pec_diff" INTEGER, + "pwm" INTEGER, + + -- I2C decoded + "i2c_raw14" INTEGER, + "i2c_zero_raw14" INTEGER, + "i2c_delta_raw14" INTEGER, + "i2c_angle_deg" REAL, + "i2c_zero_angle_deg" REAL, + + -- Derived + "angular_velocity" REAL, + "angular_acceleration" REAL, + + FOREIGN KEY ("session_id") REFERENCES "sessions"("session_id"), + FOREIGN KEY ("run_command_id") REFERENCES "uart_commands"("command_id") +); + +-- ============================================================================= +-- 7. INDEXES +-- ============================================================================= + +CREATE INDEX IF NOT EXISTS "idx_telemetry_decoded_session" +ON "telemetry_decoded" ("session_id", "run_no"); + +CREATE INDEX IF NOT EXISTS "idx_telemetry_raw_session" +ON "telemetry_raw" ("session_id", "run_no"); + +CREATE INDEX IF NOT EXISTS "idx_telemetry_decoded_session_name" +ON "telemetry_decoded" ("session_name", "run_no"); + +CREATE INDEX IF NOT EXISTS "idx_telemetry_decoded_time" +ON "telemetry_decoded" ("t_ns"); + + +-- ============================================ +-- 8. GUI PROFILES +-- ============================================ + +CREATE TABLE IF NOT EXISTS gui_profiles ( + profile_id INTEGER PRIMARY KEY AUTOINCREMENT, + profile_name TEXT UNIQUE NOT NULL, + is_active BOOLEAN DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- Theme + theme_name TEXT DEFAULT 'dark', + primary_color TEXT DEFAULT '#1f77b4', + background_color TEXT DEFAULT '#2e2e2e', + text_color TEXT DEFAULT '#ffffff', + + -- Font + font_family TEXT DEFAULT 'Arial', + font_size INTEGER DEFAULT 10, + + -- Window + window_width INTEGER DEFAULT 1280, + window_height INTEGER DEFAULT 720 +); + +-- ============================================ +-- 9. DATABASE METADATA +-- ============================================ + +CREATE TABLE IF NOT EXISTS database_metadata ( + key TEXT PRIMARY KEY, + value TEXT +); +""" + + + +# ============================================================================= +# Initial Data +# ============================================================================= + +INITIAL_DATA_SQL = """ +-- ============================================ +-- initial data population +-- ============================================ + +insert or ignore into database_metadata (key, value) values + ('max_size_bytes', '2147483648'), + ('created_at', datetime('now')), + ('version', '1.0'), + ('schema_version', '1.0.0'); + +-- default gui profile (dark theme) +insert or ignore into gui_profiles ( + profile_name, is_active, theme_name, + primary_color, background_color, text_color, + font_family, font_size, + window_width, window_height +) values ( + 'dark theme', 1, 'dark', + '#1f77b4', '#2e2e2e', '#ffffff', + 'arial', 10, + 1280, 720 +); + +-- uart commands (from aurt_commands.csv) +insert or ignore into uart_commands (command_name, description, category, hex_string) values + ('query', 'query current action and feedback status', 'action', 'dd 22 50 48 02 41 52 09'), + ('reset', 'reset current action state', 'action', 'dd 22 50 48 02 41 43 18'), + ('open', 'open door fully', 'door', 'dd 22 50 48 02 43 4f 16'), + ('close', 'close door fully', 'door', 'dd 22 50 48 02 43 43 1a'), + ('partopen', 'open door partially', 'door', 'dd 22 50 48 02 43 47 1e'), + ('ref drive', 'perform reference drive', 'door', 'dd 22 50 48 02 43 52 0b'), + ('status', 'query basic error flags', 'error', 'dd 22 50 48 01 45 5c'), + ('software version', 'get firmware/software version', 'info', 'dd 22 50 48 01 44 5d'), + ('read', 'read extended error state', 'error', 'dd 22 50 48 02 45 52 0d'), + ('clear', 'clear all active error flags', 'error', 'dd 22 50 48 02 45 43 1c'), + ('init status', 'check initialization complete', 'status', 'dd 22 50 48 01 49 50'), + ('motor status: query', 'get motor state (ready, driving, etc.)', 'status', 'dd 22 50 48 01 4d 54'), + ('off', 'power down system in safe state', 'power', 'dd 22 50 48 01 4f 56'), + ('power reset', 'power off and reset controller', 'power', 'dd 22 50 48 02 4f 72 27'), + ('parameter start', 'start motor parameter upload process', 'motor', 'dd 22 50 48 04 50 73 74 61 2a'), + ('parameter version: set', 'store current parameter version id', 'motor', 'dd 22 50 48 0c 51 31 32 33 34 35 36 37 2d 41 41 20 78'), + ('parameter version: query', 'get stored parameter version', 'motor', 'dd 22 50 48 01 51 48'), + ('temperature', 'get device internal temperature', 'sensor', 'dd 22 50 48 01 54 4d'), + ('door switch', 'check if door is open/closed via sensor', 'sensor', 'dd 22 50 48 01 53 4a'), + ('hardware version', 'get hardware revision info', 'info', 'dd 22 50 48 01 68 71'), + ('manufacturer', 'get manufacturer identification', 'info', 'dd 22 50 48 01 69 70'), + ('send id 02', 'write model identifier 02', 'identification', 'dd 22 50 48 03 6d 30 32 74'), + ('send id 10', 'write model identifier 10', 'identification', 'dd 22 50 48 03 6d 31 30 77'), + ('check', 'check if in programming/normal state', 'programming', 'dd 22 50 48 02 70 67 0d'), + ('exit', 'exit programming state', 'programming', 'dd 22 50 48 04 70 73 30 30 1f'), + ('production', 'enter production mode', 'mode', 'dd 22 50 48 02 74 73 1d'), + ('mode reset', 'reset production mode settings', 'mode', 'dd 22 50 48 02 74 72 1c'), + ('mode query', 'check if in production mode', 'mode', 'dd 22 50 48 01 74 6d'), + ('logging mode', 'direct command for logging', 'test', 'dd 22 50 48 03 50 53 50 48'), + ('read paramter set 01', 'reads motors paramter set 1', 'motor', 'dd 22 50 48 04 50 72 30 31 3f'), + ('read paramter set 25', 'reads motors paramter set 25', 'motor', 'dd 22 50 48 04 50 72 32 35 39'); + +-- i2c commands (from i2c_commands.csv) +insert or ignore into i2c_commands (command_name, description, category, operation, register, hex_string, device_address) values + ('read angle', '14-bit angle (msb at 0xfe, lsb at 0xff), 2 bytes', 'sensor', 'read', '0xfe', '02', '0x40'), + ('read magnitude', 'cordic magnitude, 2 bytes (0xfc/0xfd)', 'sensor', 'read', '0xfc', '02', '0x40'), + ('read diagnostics', 'diagnostic flags: ocf, cof, comp high/low (0xfb)', 'sensor', 'read', '0xfb', '01', '0x40'), + ('read agc', 'automatic gain control value (0xfa)', 'sensor', 'read', '0xfa', '01', '0x40'), + ('read prog control', 'programming control register (pe/verify/burn at 0x03)', 'system', 'read', '0x03', '01', '0x40'), + ('read i2c address', 'i2c slave address bits (volatile reg 0x15)', 'system', 'read', '0x15', '01', '0x40'), + ('read zeropos high', 'zero position high byte (reg 0x16)', 'sensor', 'read', '0x16', '01', '0x40'), + ('read zeropos low', 'zero position low 6 bits (reg 0x17)', 'sensor', 'read', '0x17', '01', '0x40'), + ('read angle msb', 'angle msb only (0xfe)', 'sensor', 'read', '0xfe', '01', '0x40'), + ('read angle lsb', 'angle lsb only (0xff)', 'sensor', 'read', '0xff', '01', '0x40'), + ('progctrl: pe=1', 'enable programming (bit0=1) β€” volatile', 'system', 'write', '0x03', '01', '0x40'), + ('progctrl: pe=0', 'disable programming (bit0=0)', 'system', 'write', '0x03', '00', '0x40'), + ('progctrl: verify=1', 'set verify bit (bit6=1) to reload otp to internal regs', 'system', 'write', '0x03', '40', '0x40'), + ('progctrl: verify=0', 'clear verify bit (bit6=0)', 'system', 'write', '0x03', '00', '0x40'), + ('zeropos: clear high', 'set zero position high byte to 0x00 (no burn)', 'sensor', 'write', '0x16', '00', '0x40'), + ('zeropos: clear low', 'set zero position low 6 bits to 0x00 (no burn)', 'sensor', 'write', '0x17', '00', '0x40'), + ('I2C Addr: Set Bits=0', 'Set I2C address bits in 0x15 to 0x00 (A1/A2 pins still apply)', 'System', 'write', '0x15', '00', '0x40'); +""" + + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def create_database(db_path: str, overwrite: bool = False) -> bool: + """ + Create and initialize database. + + Args: + db_path: Path to database file + overwrite: If True, delete existing database first + + Returns: + True on success, False on failure + """ + try: + # Create directory if needed + db_dir = os.path.dirname(db_path) + if db_dir: + os.makedirs(db_dir, exist_ok=True) + print(f"[βœ“] Created directory: {db_dir}") + + # Overwrite if requested + if overwrite and os.path.exists(db_path): + os.remove(db_path) + print(f"[βœ“] Removed existing database: {db_path}") + + # Connect to database + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + print(f"[βœ“] Connected to database: {db_path}") + + # Enable foreign keys + conn.execute("PRAGMA foreign_keys = ON") + + # Create schema + print("[...] Creating schema...") + conn.executescript(SCHEMA_SQL) + + conn.commit() + print("[βœ“] Schema created successfully!") + + # Initalt Data + print("[...] Intial Data Upload...") + conn.executescript(INITIAL_DATA_SQL) + + print("[βœ“] Inital Data uploaded successfully!") + + # Get database info + size_bytes = os.path.getsize(db_path) + size_mb = size_bytes / (1024 * 1024) + + # Get table counts + cursor = conn.cursor() + tables = [ + 'uart_commands', + 'i2c_commands', + 'interface_profiles', + 'session_profiles', + 'sessions', + 'telemetry_raw', + 'telemetry_decoded' + ] + + print("\n" + "=" * 60) + print("DATABASE INFORMATION") + print("=" * 60) + print(f"Path: {db_path}") + print(f"Size: {size_mb:.2f} MB") + print(f"\nTABLES:") + + for table in tables: + try: + cursor.execute(f"SELECT COUNT(*) FROM {table}") + count = cursor.fetchone()[0] + print(f" {table:25s} {count:6d} rows") + except: + print(f" {table:25s} ERROR") + + print("=" * 60) + + conn.close() + return True + + except Exception as e: + print(f"[βœ—] ERROR: {e}") + return False + + +def check_database_health(db_path: str): + """ + Check database health and integrity. + + Args: + db_path: Path to database file + """ + if not os.path.exists(db_path): + print(f"[βœ—] Database not found: {db_path}") + return + + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Check integrity + cursor.execute("PRAGMA integrity_check") + result = cursor.fetchone()[0] + + if result == "ok": + print("[βœ“] Database integrity: OK") + else: + print(f"[βœ—] Database integrity: {result}") + + # Check foreign keys + cursor.execute("PRAGMA foreign_key_check") + fk_errors = cursor.fetchall() + + if not fk_errors: + print("[βœ“] Foreign keys: OK") + else: + print(f"[βœ—] Foreign key errors found: {len(fk_errors)}") + + conn.close() + + except Exception as e: + print(f"[βœ—] Health check failed: {e}") + + +# ============================================================================= +# Database Manager Class +# ============================================================================= + +class DatabaseManager: + """ + Database manager for vzug-e-hinge project. + + Provides a high-level interface for database operations: + - Connection management + - Schema initialization + - Size monitoring + - Health checking + + Usage: + db = DatabaseManager("database/ehinge.db") + db.initialize() + conn = db.get_connection() + # Use connection... + db.close() + """ + + def __init__(self, db_path: str = DEFAULT_DB_PATH): + """ + Initialize database manager. + + Args: + db_path: Path to SQLite database file + """ + self.db_path = db_path + self.connection = None + + def initialize(self, overwrite: bool = False) -> bool: + """ + Initialize database (create schema and tables). + + Args: + overwrite: If True, delete and recreate database + + Returns: + True if successful, False otherwise + """ + success = create_database(self.db_path, overwrite) + + if success: + # Open connection after initialization + self.connect() + + return success + + def connect(self) -> bool: + """ + Connect to database. + + Returns: + True if successful, False otherwise + """ + try: + if self.connection: + self.connection.close() + + self.connection = sqlite3.connect(self.db_path) + + # Enable foreign keys + self.connection.execute("PRAGMA foreign_keys = ON") + + return True + + except Exception as e: + print(f"Error connecting to database: {e}") + return False + + def get_connection(self) -> sqlite3.Connection: + """ + Get database connection. + + Opens connection if not already open. + + Returns: + sqlite3.Connection object + """ + if not self.connection: + self.connect() + + return self.connection + + def close(self): + """Close database connection.""" + if self.connection: + self.connection.close() + self.connection = None + + def check_size(self) -> tuple: + """ + Check database size. + + Returns: + Tuple of (size_bytes, percentage, status) + - size_bytes: Database size in bytes + - percentage: Percentage of MAX_DB_SIZE_BYTES + - status: 'ok', 'warning', or 'critical' + """ + try: + size_bytes = os.path.getsize(self.db_path) + percentage = (size_bytes / MAX_DB_SIZE_BYTES) * 100 + + if percentage < 50: + status = 'ok' + elif percentage < 80: + status = 'warning' + else: + status = 'critical' + + return (size_bytes, percentage, status) + + except Exception as e: + print(f"Error checking database size: {e}") + return (0, 0, 'error') + + def vacuum(self): + """ + Vacuum database (reclaim unused space). + + This can significantly reduce database size after deletions. + """ + try: + conn = self.get_connection() + conn.execute("VACUUM") + conn.commit() + print("Database vacuumed successfully") + except Exception as e: + print(f"Error vacuuming database: {e}") + + def check_health(self): + """Run database health check.""" + check_database_health(self.db_path) + + def get_table_list(self) -> list: + """ + Get list of all tables in database. + + Returns: + List of table names + """ + try: + conn = self.get_connection() + cursor = conn.execute(""" + SELECT name FROM sqlite_master + WHERE type='table' + ORDER BY name + """) + return [row[0] for row in cursor.fetchall()] + except Exception as e: + print(f"Error getting table list: {e}") + return [] + + def get_table_info(self, table_name: str) -> list: + """ + Get column information for a table. + + Args: + table_name: Name of table + + Returns: + List of tuples with column info (cid, name, type, notnull, dflt_value, pk) + """ + try: + conn = self.get_connection() + cursor = conn.execute(f"PRAGMA table_info({table_name})") + return cursor.fetchall() + except Exception as e: + print(f"Error getting table info: {e}") + return [] + + def get_row_count(self, table_name: str) -> int: + """ + Get number of rows in a table. + + Args: + table_name: Name of table + + Returns: + Number of rows + """ + try: + conn = self.get_connection() + cursor = conn.execute(f"SELECT COUNT(*) FROM {table_name}") + return cursor.fetchone()[0] + except Exception as e: + print(f"Error getting row count: {e}") + return 0 + + def __enter__(self): + """Context manager entry.""" + self.connect() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.close() + + def __del__(self): + """Destructor - ensure connection is closed.""" + self.close() + + +# ============================================================================= +# Main +# ============================================================================= + +def main(): + """Main entry point.""" + import argparse + + parser = argparse.ArgumentParser( + description='Initialize vzug-e-hinge database' + ) + parser.add_argument( + '--path', + default=DEFAULT_DB_PATH, + help=f'Database path (default: {DEFAULT_DB_PATH})' + ) + parser.add_argument( + '--overwrite', + action='store_true', + help='Delete and recreate database' + ) + parser.add_argument( + '--check', + action='store_true', + help='Check database health' + ) + + args = parser.parse_args() + + print("=" * 60) + print("vzug-e-hinge Database Initialization") + print("=" * 60) + print() + + if args.check: + check_database_health(args.path) + return + + # Create database + success = create_database(args.path, args.overwrite) + + if success: + print("\n[βœ“] Database initialization complete!") + print(f"\nYou can now use the database at: {args.path}") + sys.exit(0) + else: + print("\n[βœ—] Database initialization failed!") + sys.exit(1) + + +if __name__ == "__main__": + main() + + # Example usage of DatabaseManager: + # + # from database.init_database import DatabaseManager + # + # # Create manager + # db = DatabaseManager("database/ehinge.db") + # + # # Initialize database + # db.initialize() + # + # # Get connection + # conn = db.get_connection() + # + # # Use connection + # cursor = conn.execute("SELECT * FROM sessions") + # + # # Check size + # size, percentage, status = db.check_size() + # print(f"Database size: {size} bytes ({percentage:.1f}%)") + # + # # Close when done + # db.close() + # + # # Or use context manager: + # with DatabaseManager("database/ehinge.db") as db: + # conn = db.get_connection() + # # Use connection... + # # Automatically closed when exiting context diff --git a/decoder.py b/decoder.py new file mode 100644 index 0000000..70d880f --- /dev/null +++ b/decoder.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +""" +Decoder Module - vzug-e-hinge +============================== +Decodes raw UART and I2C data into telemetry fields. + +Current Implementation: Simple pass-through +TODO: Implement actual byte unpacking later + +Author: Kynsight +Version: 1.0.0 (pass-through) +Date: 2025-11-09 +""" + +from typing import Dict, Any + + +def decode_uart_packet(packet_bytes: bytes) -> Dict[str, Any]: + """ + Decode UART packet bytes into telemetry fields. + + Current Implementation: Pass-through (returns raw data) + TODO: Implement actual decoding based on packet format + + Expected packet format: EF FE [14 bytes] EE (17 bytes total) + + Future implementation should extract: + - motor_current (2 bytes) + - encoder_value (2 bytes) + - relative_encoder_value (2 bytes) + - v24_pec_diff (2 bytes) + - pwm (1 byte) + + Args: + packet_bytes: Raw packet bytes (including start/end markers) + + Returns: + Dictionary with decoded fields (currently just raw data) + + Example: + packet_bytes = b'\\xEF\\xFE...[14 bytes]...\\xEE' + decoded = decode_uart_packet(packet_bytes) + # Currently returns: {'raw_hex': 'ef fe ... ee', 'raw_bytes': b'...'} + # Future: {'motor_current': 123, 'encoder_value': 456, ...} + """ + return { + 'raw_hex': packet_bytes.hex(' '), + 'raw_bytes': packet_bytes, + 'packet_length': len(packet_bytes) + } + + +def decode_i2c_sample(i2c_bytes: bytes) -> Dict[str, Any]: + """ + Decode I2C sample bytes into angle telemetry. + + Current Implementation: Pass-through (returns raw data) + TODO: Implement actual decoding based on I2C format + + Expected I2C format: 2 bytes (14-bit angle value) + + Future implementation should extract: + - i2c_raw14 (14-bit raw value) + - i2c_angle_deg (converted to degrees) + - i2c_zero_raw14 (zero position) + - i2c_delta_raw14 (delta from zero) + + Args: + i2c_bytes: Raw I2C bytes (typically 2 bytes for angle) + + Returns: + Dictionary with decoded fields (currently just raw data) + + Example: + i2c_bytes = b'\\x3F\\xFF' # 14-bit angle + decoded = decode_i2c_sample(i2c_bytes) + # Currently returns: {'raw_hex': '3f ff', 'raw_bytes': b'...'} + # Future: {'i2c_raw14': 16383, 'i2c_angle_deg': 359.98, ...} + """ + return { + 'raw_hex': i2c_bytes.hex(' '), + 'raw_bytes': i2c_bytes, + 'sample_length': len(i2c_bytes) + } + + +# ============================================================================= +# Future Implementation Template +# ============================================================================= + +# def decode_uart_packet_full(packet_bytes: bytes) -> Dict[str, Any]: +# """ +# Full UART packet decoder (to be implemented). +# +# Packet format: EF FE [14 bytes] EE +# +# Byte layout: +# [0-1]: Start marker (EF FE) +# [2-3]: motor_current (signed 16-bit, little-endian) +# [4-5]: encoder_value (unsigned 16-bit, little-endian) +# [6-7]: relative_encoder_value (signed 16-bit, little-endian) +# [8-9]: v24_pec_diff (signed 16-bit, little-endian) +# [10]: pwm (unsigned 8-bit) +# [11-15]: Reserved +# [16]: End marker (EE) +# """ +# # Verify packet length +# if len(packet_bytes) != 17: +# raise ValueError(f"Invalid packet length: {len(packet_bytes)}, expected 17") +# +# # Verify markers +# if packet_bytes[0:2] != b'\xEF\xFE': +# raise ValueError("Invalid start marker") +# if packet_bytes[16:17] != b'\xEE': +# raise ValueError("Invalid end marker") +# +# # Extract data bytes (skip markers) +# data = packet_bytes[2:16] +# +# return { +# 'motor_current': int.from_bytes(data[0:2], 'little', signed=True), +# 'encoder_value': int.from_bytes(data[2:4], 'little', signed=False), +# 'relative_encoder_value': int.from_bytes(data[4:6], 'little', signed=True), +# 'v24_pec_diff': int.from_bytes(data[6:8], 'little', signed=True), +# 'pwm': data[8] +# } + + +# def decode_i2c_sample_full(i2c_bytes: bytes) -> Dict[str, Any]: +# """ +# Full I2C sample decoder (to be implemented). +# +# I2C format: 2 bytes (14-bit angle value) +# +# Byte layout: +# [0]: High byte (bits 13-6) +# [1]: Low byte (bits 5-0 in upper 6 bits) +# """ +# if len(i2c_bytes) != 2: +# raise ValueError(f"Invalid I2C sample length: {len(i2c_bytes)}, expected 2") +# +# # Extract 14-bit value +# raw14 = ((i2c_bytes[0] << 6) | (i2c_bytes[1] >> 2)) & 0x3FFF +# +# # Convert to degrees (14-bit = 0-360Β°) +# angle_deg = (raw14 / 16384.0) * 360.0 +# +# return { +# 'i2c_raw14': raw14, +# 'i2c_angle_deg': angle_deg +# } + + +if __name__ == "__main__": + # Simple test + print("Decoder Module - Pass-Through Mode") + print("=" * 60) + + # Test UART packet decoding + test_uart = b'\xEF\xFE' + b'\x01' * 14 + b'\xEE' + decoded_uart = decode_uart_packet(test_uart) + print(f"UART packet decoded: {decoded_uart}") + + # Test I2C sample decoding + test_i2c = b'\x3F\xFF' + decoded_i2c = decode_i2c_sample(test_i2c) + print(f"I2C sample decoded: {decoded_i2c}") + + print() + print("βœ“ Decoder ready (pass-through mode)") + print("TODO: Implement actual decoding later") diff --git a/global_clock.py b/global_clock.py new file mode 100644 index 0000000..53eae60 --- /dev/null +++ b/global_clock.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +Global Clock - Timestamp Synchronization +========================================= +Singleton clock for synchronizing timestamps across UART, I2C, and other modules. + +All timestamps use the same time reference (app start), enabling: +- Aligned plotting of UART and I2C data +- Consistent timeline across all modules +- Database storage with unified timestamp column + +Usage: + from global_clock import GlobalClock + + clock = GlobalClock.instance() + timestamp = clock.now() # Seconds since app start + +Author: Kynsight +Version: 1.0.0 +""" + +import time +from typing import Optional + + +class GlobalClock: + """ + Singleton clock for application-wide timestamp synchronization. + + Uses monotonic time (time.perf_counter) for stability. + All timestamps are relative to application start time. + """ + + _instance: Optional['GlobalClock'] = None + + def __init__(self): + """Initialize clock at application start.""" + self._start_time = time.perf_counter() + + @classmethod + def instance(cls) -> 'GlobalClock': + """Get singleton instance (creates if needed).""" + if cls._instance is None: + cls._instance = GlobalClock() + return cls._instance + + def now(self) -> float: + """ + Get current timestamp in seconds since app start. + + Returns: + float: Time in seconds (e.g., 123.456789) + """ + return time.perf_counter() - self._start_time + + def now_ns(self) -> int: + """ + Get current timestamp in nanoseconds since app start. + + Returns: + int: Time in nanoseconds + """ + return int((time.perf_counter() - self._start_time) * 1_000_000_000) + + def reset(self): + """Reset clock to zero (useful for testing or new sessions).""" + self._start_time = time.perf_counter() + + +# ============================================================================= +# Module-level convenience functions +# ============================================================================= + +def now() -> float: + """ + Get current timestamp (seconds since app start). + + Convenience function for GlobalClock.instance().now() + """ + return GlobalClock.instance().now() + + +def now_ns() -> int: + """ + Get current timestamp (nanoseconds since app start). + + Convenience function for GlobalClock.instance().now_ns() + """ + return GlobalClock.instance().now_ns() + + +# ============================================================================= +# Demo +# ============================================================================= + +if __name__ == "__main__": + import time as time_module + + print("Global Clock Demo") + print("=" * 50) + + # Get clock instance + clock = GlobalClock.instance() + + print(f"App start time: {clock.now():.6f}s") + + # Simulate some operations + time_module.sleep(0.1) + print(f"After 100ms: {clock.now():.6f}s") + + time_module.sleep(0.2) + print(f"After 300ms total: {clock.now():.6f}s") + + # Nanosecond precision + print(f"\nNanoseconds: {clock.now_ns()} ns") + + # Demonstrate synchronization + print("\n" + "=" * 50) + print("Synchronization Demo:") + print("=" * 50) + + uart_timestamp = clock.now() + print(f"UART packet at: {uart_timestamp:.6f}s") + + time_module.sleep(0.05) + + i2c_timestamp = clock.now() + print(f"I2C reading at: {i2c_timestamp:.6f}s") + + print(f"\nTime difference: {(i2c_timestamp - uart_timestamp)*1000:.2f}ms") + print("βœ“ Both timestamps use same reference!") diff --git a/graph/graph_kit/graph_core.py b/graph/graph_kit/graph_core.py new file mode 100644 index 0000000..82c38ee --- /dev/null +++ b/graph/graph_kit/graph_core.py @@ -0,0 +1,539 @@ +#!/usr/bin/env python3 +""" +Graph Core - vzug-e-hinge +========================== +Pure plotting functions for telemetry data visualization. + +Database-agnostic - works with any data source via table_query adapters. + +Features: +- Matplotlib plotting functions +- Overlay, subplots, drift comparison, multi-series +- XY scatter plots +- Export PNG and CSV +- No database dependency (uses TelemetryData from table_query) + +Author: Kynsight +Version: 2.0.0 +""" + +from __future__ import annotations +from typing import List, Dict, Tuple, Optional, Any +from dataclasses import dataclass + +import matplotlib +matplotlib.use('QtAgg') # For PyQt6 integration +import matplotlib.pyplot as plt +from matplotlib.figure import Figure +import numpy as np + +# Import data structures from table_query +from graph_table_query import TelemetryData, get_column_label + + +# ============================================================================= +# Plot Configuration +# ============================================================================= + +@dataclass +class PlotConfig: + """Plot configuration.""" + title: str = "Telemetry Data" + xlabel: str = "Time (s)" + figsize: Tuple[int, int] = (12, 8) + dpi: int = 100 + grid: bool = True + legend: bool = True + style: str = "default" # matplotlib style + linestyle: str = "-" # Line style + marker: Optional[str] = None # Marker style + markersize: int = 3 # Marker size + + +# ============================================================================= +# Plotting Functions +# ============================================================================= + +def plot_overlay( + data_list: List[TelemetryData], + x_column: str, + y_column: str, + xlabel: str, + ylabel: str, + config: Optional[PlotConfig] = None +) -> Figure: + """ + Create overlay plot (multiple runs on same axes). + + Args: + data_list: List of TelemetryData objects + x_column: Column name for X-axis (e.g., 'time_ms', 't_ns') + y_column: Column name for Y-axis (e.g., 'pwm', 'motor_current') + xlabel: X-axis label + ylabel: Y-axis label + config: Plot configuration + + Returns: + Matplotlib Figure object + """ + if config is None: + config = PlotConfig() + + fig, ax = plt.subplots(figsize=config.figsize, dpi=config.dpi) + + # Set color cycle + ax.set_prop_cycle(color=plt.cm.tab10.colors) + + # Plot each run + for data in data_list: + x_data = getattr(data, x_column, None) + y_data = getattr(data, y_column, None) + + if x_data is None or y_data is None: + continue + + # Updated label format: "session_name run_no (name)" + label = f"{data.session_id} {data.run_no} ({data.run_name})" + ax.plot(x_data, y_data, label=label, alpha=0.8, + linestyle=config.linestyle, marker=config.marker, + markersize=config.markersize) + + # Formatting + ax.set_xlabel(xlabel) + ax.set_ylabel(ylabel) + ax.set_title(config.title) + + if config.grid: + ax.grid(True, alpha=0.3) + + if config.legend: + # Legend at bottom, outside plot + ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.12), ncol=2) + + fig.tight_layout(rect=[0, 0.08, 1, 1]) # Leave space for legend + + return fig + + +def plot_subplots( + data_list: List[TelemetryData], + x_column: str, + y_column: str, + xlabel: str, + ylabel: str, + config: Optional[PlotConfig] = None +) -> Figure: + """ + Create subplot grid (one subplot per run). + + Args: + data_list: List of TelemetryData objects + x_column: Column name for X-axis + y_column: Column name for Y-axis + xlabel: X-axis label + ylabel: Y-axis label + config: Plot configuration + + Returns: + Matplotlib Figure object + """ + if config is None: + config = PlotConfig() + + n_runs = len(data_list) + + # Calculate grid dimensions + n_cols = min(2, n_runs) + n_rows = (n_runs + n_cols - 1) // n_cols + + fig, axes = plt.subplots( + n_rows, n_cols, + figsize=config.figsize, + dpi=config.dpi, + squeeze=False + ) + + # Flatten axes for easy iteration + axes_flat = axes.flatten() + + # Plot each run + for idx, data in enumerate(data_list): + ax = axes_flat[idx] + x_data = getattr(data, x_column, None) + y_data = getattr(data, y_column, None) + + if x_data is None or y_data is None: + ax.text(0.5, 0.5, 'No data', ha='center', va='center') + ax.set_title(f"{data.session_id} - Run {data.run_no} ({data.run_name})") + continue + + ax.plot(x_data, y_data, alpha=0.8, + linestyle=config.linestyle, marker=config.marker, + markersize=config.markersize) + ax.set_xlabel(xlabel) + ax.set_ylabel(ylabel) + # Updated title format + ax.set_title(f"{data.session_id} {data.run_no} ({data.run_name})") + + if config.grid: + ax.grid(True, alpha=0.3) + + # Hide unused subplots + for idx in range(len(data_list), len(axes_flat)): + axes_flat[idx].set_visible(False) + + fig.tight_layout() + + return fig + + +def plot_comparison( + data_list: List[TelemetryData], + x_column: str, + y_column: str, + xlabel: str, + ylabel: str, + reference_index: int = 0, + config: Optional[PlotConfig] = None +) -> Figure: + """ + Create drift comparison plot (deviation from reference run). + + Args: + data_list: List of TelemetryData objects + x_column: Column name for X-axis + y_column: Column name for Y-axis + xlabel: X-axis label + ylabel: Y-axis label + reference_index: Index of reference run (default: 0 = first run) + config: Plot configuration + + Returns: + Matplotlib Figure object + """ + if config is None: + config = PlotConfig() + + if reference_index >= len(data_list): + reference_index = 0 + + fig, ax = plt.subplots(figsize=config.figsize, dpi=config.dpi) + + reference = data_list[reference_index] + ref_x = getattr(reference, x_column, None) + ref_y = getattr(reference, y_column, None) + + if ref_x is None or ref_y is None: + ax.text(0.5, 0.5, 'No reference data', ha='center', va='center') + return fig + + # Plot reference as baseline (zero) + ax.axhline(y=0, color='black', linestyle='--', + # Updated label format + label=f'Reference: {reference.session_id} {reference.run_no} ({reference.run_name})') + + # Set color cycle + ax.set_prop_cycle(color=plt.cm.tab10.colors) + + # Plot deviations + for idx, data in enumerate(data_list): + if idx == reference_index: + continue + + x_data = getattr(data, x_column, None) + y_data = getattr(data, y_column, None) + + if x_data is None or y_data is None: + continue + + # Interpolate to match reference x points (for comparison) + if len(x_data) != len(ref_x) or not np.array_equal(x_data, ref_x): + y_interp = np.interp(ref_x, x_data, y_data) + else: + y_interp = y_data + + # Calculate deviation + deviation = y_interp - ref_y + + # Updated label format + label = f"{data.session_id} {data.run_no} ({data.run_name})" + ax.plot(ref_x, deviation, label=label, alpha=0.8, + linestyle=config.linestyle, marker=config.marker, + markersize=config.markersize) + + # Formatting + ax.set_xlabel(xlabel) + ax.set_ylabel(f"Deviation in {ylabel}") + ax.set_title(f"{config.title} - Drift Analysis") + + if config.grid: + ax.grid(True, alpha=0.3) + + if config.legend: + # Legend at bottom + ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.12), ncol=2) + + fig.tight_layout(rect=[0, 0.08, 1, 1]) + + return fig + + +def plot_multi_series( + data_list: List[TelemetryData], + x_column: str, + y_columns: List[str], + xlabel: str, + ylabels: List[str], + config: Optional[PlotConfig] = None +) -> Figure: + """ + Create multi-series plot (multiple data columns, multiple runs). + + Args: + data_list: List of TelemetryData objects + x_column: Column name for X-axis + y_columns: List of column names to plot + xlabel: X-axis label + ylabels: List of Y-axis labels + config: Plot configuration + + Returns: + Matplotlib Figure object + """ + if config is None: + config = PlotConfig() + + n_series = len(y_columns) + + fig, axes = plt.subplots( + n_series, 1, + figsize=config.figsize, + dpi=config.dpi, + sharex=True + ) + + # Handle single subplot case + if n_series == 1: + axes = [axes] + + # Plot each series + for idx, (y_col, ylabel) in enumerate(zip(y_columns, ylabels)): + ax = axes[idx] + + # Set color cycle + ax.set_prop_cycle(color=plt.cm.tab10.colors) + + for data in data_list: + x_data = getattr(data, x_column, None) + y_data = getattr(data, y_col, None) + + if x_data is None or y_data is None: + continue + + # Updated label format + label = f"{data.session_id} {data.run_no} ({data.run_name})" + ax.plot(x_data, y_data, label=label, alpha=0.8, + linestyle=config.linestyle, marker=config.marker, + markersize=config.markersize) + + ax.set_ylabel(ylabel) + + if config.grid: + ax.grid(True, alpha=0.3) + + if config.legend and idx == 0: # Legend only on first subplot + ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.15), ncol=2) + + # X-label only on bottom subplot + axes[-1].set_xlabel(xlabel) + + fig.suptitle(config.title) + fig.tight_layout(rect=[0, 0.08, 1, 0.96]) + + return fig + + +def plot_xy_scatter( + data_list: List[TelemetryData], + x_column: str, + y_column: str, + xlabel: str, + ylabel: str, + config: Optional[PlotConfig] = None +) -> Figure: + """ + Create XY scatter/line plot (any column vs any column). + + Useful for phase plots, correlation analysis, etc. + Example: motor_current vs pwm, angle vs encoder_value + + Args: + data_list: List of TelemetryData objects + x_column: Column name for X-axis + y_column: Column name for Y-axis + xlabel: X-axis label + ylabel: Y-axis label + config: Plot configuration + + Returns: + Matplotlib Figure object + """ + if config is None: + config = PlotConfig() + + fig, ax = plt.subplots(figsize=config.figsize, dpi=config.dpi) + + # Set color cycle + ax.set_prop_cycle(color=plt.cm.tab10.colors) + + # Plot each run + for data in data_list: + x_data = getattr(data, x_column, None) + y_data = getattr(data, y_column, None) + + if x_data is None or y_data is None: + continue + + # Updated label format + label = f"{data.session_id} {data.run_no} ({data.run_name})" + ax.plot(x_data, y_data, label=label, alpha=0.8, + linestyle=config.linestyle, marker=config.marker or 'o', + markersize=config.markersize if config.marker else 2, + linewidth=1) + + # Formatting + ax.set_xlabel(xlabel) + ax.set_ylabel(ylabel) + ax.set_title(config.title) + + if config.grid: + ax.grid(True, alpha=0.3) + + if config.legend: + # Legend at bottom + ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.12), ncol=2) + + fig.tight_layout(rect=[0, 0.08, 1, 1]) + + return fig + + +# ============================================================================= +# Export Functions +# ============================================================================= + +def export_png(fig: Figure, filepath: str, dpi: int = 300) -> bool: + """ + Export figure to PNG. + + Args: + fig: Matplotlib figure + filepath: Output file path + dpi: Resolution + + Returns: + True on success + """ + try: + fig.savefig(filepath, dpi=dpi, bbox_inches='tight') + return True + except Exception as e: + print(f"[EXPORT ERROR] Failed to save PNG: {e}") + return False + + +def export_csv( + data_list: List[TelemetryData], + filepath: str, + x_column: str, + y_columns: List[str] +) -> bool: + """ + Export telemetry data to CSV (only selected columns). + + Args: + data_list: List of TelemetryData objects + filepath: Output file path + x_column: X-axis column name (e.g., 'time_ms') + y_columns: List of Y-axis column names (e.g., ['motor_current', 'pwm']) + + Returns: + True on success + """ + try: + import csv + + with open(filepath, 'w', newline='') as f: + writer = csv.writer(f) + + # Header: metadata + X column + selected Y columns + header = ['session_id', 'run_name', 'run_no', x_column] + y_columns + writer.writerow(header) + + # Data rows + for data in data_list: + # Get X data + x_data = getattr(data, x_column, None) + + if x_data is None: + continue + + # Get length from X column + length = len(x_data) + + # Write each data point + for i in range(length): + row = [ + data.session_id, + data.run_name, + data.run_no, + x_data[i] + ] + + # Add selected Y columns + for y_col in y_columns: + y_data = getattr(data, y_col, None) + if y_data is not None and i < len(y_data): + row.append(y_data[i]) + else: + row.append('') # Empty if column doesn't exist + + writer.writerow(row) + + return True + + except Exception as e: + print(f"[EXPORT ERROR] Failed to save CSV: {e}") + return False + + +# ============================================================================= +# Demo +# ============================================================================= + +if __name__ == "__main__": + print("Graph Core - Pure Plotting Functions") + print("=" * 50) + print() + print("Database-agnostic plotting library!") + print() + print("Usage:") + print(" from table_query import SQLiteAdapter, CSVAdapter") + print(" from graph_core import plot_overlay, plot_xy_scatter") + print() + print(" # Load data via adapter") + print(" adapter = SQLiteAdapter('./database/ehinge.db')") + print(" adapter.connect()") + print(" data = adapter.load_run_data('Session_A', 1)") + print() + print(" # Plot (pure function - no database!)") + print(" fig = plot_overlay([data], 'time_ms', 'pwm', 'Time', 'PWM')") + print() + print("Available functions:") + print(" β€’ plot_overlay() - Multiple runs on same axes") + print(" β€’ plot_subplots() - One subplot per run") + print(" β€’ plot_comparison() - Drift analysis") + print(" β€’ plot_multi_series() - Stacked subplots") + print(" β€’ plot_xy_scatter() - XY scatter/line plot") + print(" β€’ export_png() - Save as PNG") + print(" β€’ export_csv() - Export data as CSV") diff --git a/graph/graph_kit/graph_core_widget.py b/graph/graph_kit/graph_core_widget.py new file mode 100644 index 0000000..bc82bb9 --- /dev/null +++ b/graph/graph_kit/graph_core_widget.py @@ -0,0 +1,921 @@ +#!/usr/bin/env python3 +""" +Graph Widget (PyQt6) +==================== +GUI for visualizing telemetry data from SQLite database. + +Features: +- Session/run tree selector +- Data series checkboxes +- Plot type selector (overlay, subplots, drift) +- Embedded matplotlib canvas +- Zoom/pan controls +- Export PNG/CSV +- Independent from UART/I2C widgets + +Author: Kynsight +Version: 1.0.0 +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QPushButton, QComboBox, + QGroupBox, QTreeWidget, QTreeWidgetItem, + QCheckBox, QFileDialog, QMessageBox, QSplitter +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QFont + +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar +from matplotlib.figure import Figure + +# Import from table_query (data access layer) +from graph_table_query import ( + DataAdapter, + SQLiteAdapter, + CSVAdapter, + TelemetryData, + get_column_label, + get_available_columns, + get_default_columns +) + +# Import from graph_core (pure plotting) +from graph_core import ( + PlotConfig, + plot_overlay, + plot_subplots, + plot_comparison, + plot_multi_series, + plot_xy_scatter, + export_png, + export_csv +) + + +class GraphWidget(QWidget): + """ + Telemetry data visualization widget. + + Loads data from SQLite and generates matplotlib plots. + """ + + # Signals + plot_updated = pyqtSignal() + + def __init__(self, db_path: str = "./database/ehinge.db", parent=None): + super().__init__(parent) + + self.db_path = db_path + self.adapter: Optional[DataAdapter] = None # Use adapter pattern + + # Data + self.sessions = [] + self.selected_runs = [] # List of (session_id, run_no) tuples + self.loaded_data = [] # List of TelemetryData objects + + # Build UI + self._init_ui() + + # Connect to database + self._connect_database() + + def _init_ui(self): + """Create user interface.""" + layout = QHBoxLayout() + self.setLayout(layout) + + # Left panel: Session/run selection + left_panel = self._create_selection_panel() + + # Right panel: Plot area + right_panel = self._create_plot_panel() + + # Splitter + splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.addWidget(left_panel) + splitter.addWidget(right_panel) + splitter.setStretchFactor(0, 1) # Left: 25% + splitter.setStretchFactor(1, 3) # Right: 75% + + layout.addWidget(splitter) + + self.setMinimumWidth(1200) + self.setMinimumHeight(700) + + # ========================================================================= + # UI Creation + # ========================================================================= + + def _create_selection_panel(self): + """Create left panel with session/run selection.""" + panel = QWidget() + layout = QVBoxLayout() + panel.setLayout(layout) + + # 1. Database selector (top) + db_group = QGroupBox("Database") + db_layout = QVBoxLayout() + db_group.setLayout(db_layout) + + db_row = QHBoxLayout() + self.combo_database = QComboBox() + self.combo_database.setEditable(True) + self.combo_database.addItem("./database/ehinge.db") + self.combo_database.setCurrentText(self.db_path) + db_row.addWidget(self.combo_database) + + btn_browse = QPushButton("Browse...") + btn_browse.clicked.connect(self._browse_database) + btn_browse.setMaximumWidth(80) + db_row.addWidget(btn_browse) + + db_layout.addLayout(db_row) + + btn_connect = QPushButton("πŸ”— Connect") + btn_connect.clicked.connect(self._connect_database) + db_layout.addWidget(btn_connect) + + layout.addWidget(db_group) + + # 2. Plot controls (right after database!) + plot_controls = self._create_plot_controls() + layout.addWidget(plot_controls) + + # 3. Refresh button + btn_refresh = QPushButton("πŸ”„ Refresh Sessions") + btn_refresh.clicked.connect(self._load_sessions) + layout.addWidget(btn_refresh) + + # 4. Data series selection (MOVED UP - before Sessions!) + self.series_group = self._create_series_selector() + layout.addWidget(self.series_group) + + # 5. Session/run tree (MOVED DOWN - after Data Series!) + tree_group = QGroupBox("Sessions & Runs") + tree_layout = QVBoxLayout() + tree_group.setLayout(tree_layout) + + self.tree_sessions = QTreeWidget() + self.tree_sessions.setHeaderLabels(["Session / Run", "Samples", "Duration"]) + self.tree_sessions.itemChanged.connect(self._on_selection_changed) + tree_layout.addWidget(self.tree_sessions) + + # Buttons under tree + btn_row = QHBoxLayout() + btn_generate = QPushButton("πŸ“Š Generate Plot") + btn_generate.clicked.connect(self._generate_plot) + btn_row.addWidget(btn_generate) + + btn_export_png = QPushButton("πŸ’Ύ PNG") + btn_export_png.clicked.connect(self._export_png) + btn_row.addWidget(btn_export_png) + + btn_export_csv = QPushButton("πŸ“„ CSV") + btn_export_csv.clicked.connect(self._export_csv) + btn_row.addWidget(btn_export_csv) + + tree_layout.addLayout(btn_row) + + layout.addWidget(tree_group) + + layout.addStretch() + + return panel + + def _create_series_selector(self): + """Create data series checkboxes.""" + group = QGroupBox("Data Series (Y-Axis)") + layout = QVBoxLayout() + group.setLayout(layout) + + # Simple vertical list - no grouping + self.check_t_ns = QCheckBox("t_ns") + layout.addWidget(self.check_t_ns) + + self.check_time_ms = QCheckBox("time_ms") + layout.addWidget(self.check_time_ms) + + self.check_motor_current = QCheckBox("motor_current") + self.check_motor_current.setChecked(True) # Default + layout.addWidget(self.check_motor_current) + + self.check_encoder_value = QCheckBox("encoder_value") + self.check_encoder_value.setChecked(True) # Default + layout.addWidget(self.check_encoder_value) + + self.check_relative_encoder_value = QCheckBox("relative_encoder_value") + self.check_relative_encoder_value.setChecked(True) # Default + layout.addWidget(self.check_relative_encoder_value) + + self.check_v24_pec_diff = QCheckBox("v24_pec_diff") + layout.addWidget(self.check_v24_pec_diff) + + self.check_pwm = QCheckBox("pwm") + self.check_pwm.setChecked(True) # Default + layout.addWidget(self.check_pwm) + + self.check_i2c_raw14 = QCheckBox("i2c_raw14") + layout.addWidget(self.check_i2c_raw14) + + self.check_i2c_zero_raw14 = QCheckBox("i2c_zero_raw14") + layout.addWidget(self.check_i2c_zero_raw14) + + self.check_i2c_delta_raw14 = QCheckBox("i2c_delta_raw14") + layout.addWidget(self.check_i2c_delta_raw14) + + self.check_i2c_angle_deg = QCheckBox("i2c_angle_deg") + self.check_i2c_angle_deg.setChecked(True) # Default + layout.addWidget(self.check_i2c_angle_deg) + + self.check_i2c_zero_angle_deg = QCheckBox("i2c_zero_angle_deg") + layout.addWidget(self.check_i2c_zero_angle_deg) + + self.check_angular_velocity = QCheckBox("angular_velocity") + self.check_angular_velocity.setChecked(True) # Default + layout.addWidget(self.check_angular_velocity) + + return group + + def _create_plot_panel(self): + """Create right panel with plot area - FULL HEIGHT!""" + panel = QWidget() + layout = QVBoxLayout() + panel.setLayout(layout) + + # Remove all margins for maximum space + layout.setContentsMargins(0, 0, 0, 0) + + # Store layout reference for canvas recreation + self.plot_layout = layout + + # Matplotlib canvas (FULL SPACE - top to bottom!) + self.figure = Figure(figsize=(10, 6), dpi=100) + self.canvas = FigureCanvas(self.figure) + layout.addWidget(self.canvas) + + # Matplotlib toolbar (zoom, pan, save) + self.toolbar = NavigationToolbar(self.canvas, self) + layout.addWidget(self.toolbar) + + return panel + + def _create_plot_controls(self): + """Create plot control buttons.""" + group = QGroupBox("Plot Configuration") + layout = QVBoxLayout() + group.setLayout(layout) + + # Plot mode selector + mode_row = QHBoxLayout() + mode_row.addWidget(QLabel("Mode:")) + self.combo_plot_mode = QComboBox() + self.combo_plot_mode.addItems(["Time Series", "XY Plot"]) + self.combo_plot_mode.currentTextChanged.connect(self._on_plot_mode_changed) + mode_row.addWidget(self.combo_plot_mode) + layout.addLayout(mode_row) + + # Line type selector + line_type_row = QHBoxLayout() + line_type_row.addWidget(QLabel("Line Type:")) + self.combo_line_type = QComboBox() + self.combo_line_type.addItems([ + "Line", + "Line + Markers", + "Markers Only", + "Steps" + ]) + line_type_row.addWidget(self.combo_line_type) + layout.addLayout(line_type_row) + + # Separate window checkbox + self.check_separate_window = QCheckBox("Open in separate window") + layout.addWidget(self.check_separate_window) + + # X-axis selector (for time series mode) + self.xaxis_row = QHBoxLayout() + self.xaxis_label = QLabel("X-Axis:") + self.xaxis_row.addWidget(self.xaxis_label) + self.combo_xaxis = QComboBox() + self.combo_xaxis.addItems(["time_ms", "t_ns"]) + self.xaxis_row.addWidget(self.combo_xaxis) + layout.addLayout(self.xaxis_row) + + # Plot type selector (for time series mode) + self.type_row = QHBoxLayout() + self.type_label = QLabel("Type:") + self.type_row.addWidget(self.type_label) + self.combo_plot_type = QComboBox() + self.combo_plot_type.addItems([ + "Overlay", + "Subplots", + "Drift Comparison", + "Multi-Series" + ]) + self.type_row.addWidget(self.combo_plot_type) + layout.addLayout(self.type_row) + + # XY plot selectors (hidden by default) + self.xy_row = QHBoxLayout() + self.xy_x_label = QLabel("X:") + self.xy_row.addWidget(self.xy_x_label) + self.combo_xy_x = QComboBox() + self.combo_xy_x.addItems([ + "time_ms", "t_ns", + "motor_current", "encoder_value", "relative_encoder_value", "v24_pec_diff", "pwm", + "i2c_raw14", "i2c_zero_raw14", "i2c_delta_raw14", "i2c_angle_deg", "i2c_zero_angle_deg", + "angular_velocity" + ]) + self.combo_xy_x.setCurrentText("motor_current") + self.xy_row.addWidget(self.combo_xy_x) + + self.xy_y_label = QLabel("Y:") + self.xy_row.addWidget(self.xy_y_label) + self.combo_xy_y = QComboBox() + self.combo_xy_y.addItems([ + "time_ms", "t_ns", + "motor_current", "encoder_value", "relative_encoder_value", "v24_pec_diff", "pwm", + "i2c_raw14", "i2c_zero_raw14", "i2c_delta_raw14", "i2c_angle_deg", "i2c_zero_angle_deg", + "angular_velocity" + ]) + self.combo_xy_y.setCurrentText("pwm") + self.xy_row.addWidget(self.combo_xy_y) + layout.addLayout(self.xy_row) + + # Hide XY controls initially + self.xy_x_label.setVisible(False) + self.xy_y_label.setVisible(False) + self.combo_xy_x.setVisible(False) + self.combo_xy_y.setVisible(False) + + return group + + def _on_plot_mode_changed(self, mode: str): + """Handle plot mode change.""" + if mode == "XY Plot": + # Hide time series controls + self.xaxis_label.setVisible(False) + self.combo_xaxis.setVisible(False) + self.type_label.setVisible(False) + self.combo_plot_type.setVisible(False) + + # Show XY controls + self.xy_x_label.setVisible(True) + self.xy_y_label.setVisible(True) + self.combo_xy_x.setVisible(True) + self.combo_xy_y.setVisible(True) + + # Hide data series panel (not needed for XY) + self.series_group.setVisible(False) + else: + # Show time series controls + self.xaxis_label.setVisible(True) + self.combo_xaxis.setVisible(True) + self.type_label.setVisible(True) + self.combo_plot_type.setVisible(True) + + # Hide XY controls + self.xy_x_label.setVisible(False) + self.xy_y_label.setVisible(False) + self.combo_xy_x.setVisible(False) + self.combo_xy_y.setVisible(False) + + # Show data series panel + self.series_group.setVisible(True) + + # ========================================================================= + # Database Connection + # ========================================================================= + + def _browse_database(self): + """Browse for database file.""" + filepath, _ = QFileDialog.getOpenFileName( + self, + "Select Database", + "./database", + "Database Files (*.db *.sqlite);;All Files (*)" + ) + + if filepath: + self.combo_database.setCurrentText(filepath) + self._connect_database() + + def _connect_database(self): + """Connect to selected database.""" + db_path = self.combo_database.currentText() + + # Close previous connection + if self.adapter: + self.adapter.close() + + # Determine adapter type based on file extension + if db_path.endswith('.csv'): + self.adapter = CSVAdapter(db_path) + adapter_type = "CSV" + else: + self.adapter = SQLiteAdapter(db_path) + adapter_type = "SQLite" + + # Open new connection + self.db_path = db_path + + if self.adapter.connect(): + self._load_sessions() + else: + QMessageBox.critical(self, "Connection Error", f"Failed to connect to database:\n{db_path}") + + # ========================================================================= + # Session/Run Loading + # ========================================================================= + + def _load_sessions(self): + """Load sessions from database and populate tree.""" + self.sessions = self.adapter.get_sessions() + self.tree_sessions.clear() + + for session in self.sessions: + # Create session item + session_item = QTreeWidgetItem(self.tree_sessions) + session_item.setText(0, session.session_id) + session_item.setText(1, f"{session.run_count} runs") + session_item.setText(2, session.created_at) + session_item.setFlags(session_item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + session_item.setCheckState(0, Qt.CheckState.Unchecked) + session_item.setData(0, Qt.ItemDataRole.UserRole, session.session_id) + + # Load runs for this session + runs = self.adapter.get_runs(session.session_id) + + for run in runs: + run_item = QTreeWidgetItem(session_item) + run_item.setText(0, f"Run {run.run_number} ({run.run_name})") + run_item.setText(1, f"{run.sample_count} samples") + run_item.setText(2, f"{run.duration_ms:.1f}ms") + run_item.setFlags(run_item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + run_item.setCheckState(0, Qt.CheckState.Unchecked) + run_item.setData(0, Qt.ItemDataRole.UserRole, (session.session_id, run.run_number)) + + self.tree_sessions.expandAll() + + def _on_selection_changed(self, item: QTreeWidgetItem, column: int): + """Handle checkbox changes in tree.""" + # Update parent/child checkboxes + if item.childCount() > 0: # Session item + # Update all children + state = item.checkState(0) + for i in range(item.childCount()): + item.child(i).setCheckState(0, state) + else: # Run item + # Check if all siblings checked -> check parent + parent = item.parent() + if parent: + all_checked = all( + parent.child(i).checkState(0) == Qt.CheckState.Checked + for i in range(parent.childCount()) + ) + parent.setCheckState(0, Qt.CheckState.Checked if all_checked else Qt.CheckState.Unchecked) + + def _get_selected_runs(self): + """Get list of selected (session_id, run_number) tuples.""" + selected = [] + + # Iterate through tree + for i in range(self.tree_sessions.topLevelItemCount()): + session_item = self.tree_sessions.topLevelItem(i) + + for j in range(session_item.childCount()): + run_item = session_item.child(j) + + if run_item.checkState(0) == Qt.CheckState.Checked: + data = run_item.data(0, Qt.ItemDataRole.UserRole) + if data: + selected.append(data) + + return selected + + def _load_selected_data(self): + """Load telemetry data for selected runs.""" + self.selected_runs = self._get_selected_runs() + + if not self.selected_runs: + QMessageBox.warning(self, "No Selection", "Please select at least one run") + return + + self.loaded_data = [] + + for session_id, run_no in self.selected_runs: + data = self.adapter.load_run_data(session_id, run_no) + if data: + self.loaded_data.append(data) + + if self.loaded_data: + QMessageBox.information( + self, + "Data Loaded", + f"Successfully loaded {len(self.loaded_data)} run(s)" + ) + else: + QMessageBox.warning(self, "Load Error", "Failed to load data from database") + + # ========================================================================= + # Plotting + # ========================================================================= + + def _get_selected_series(self): + """Get list of selected data series.""" + series = [] + labels = [] + + # UART data + if self.check_motor_current.isChecked(): + series.append('motor_current') + labels.append('Motor Current') + + if self.check_encoder_value.isChecked(): + series.append('encoder_value') + labels.append('Encoder Value') + + if self.check_relative_encoder_value.isChecked(): + series.append('relative_encoder_value') + labels.append('Relative Encoder') + + if self.check_v24_pec_diff.isChecked(): + series.append('v24_pec_diff') + labels.append('V24 PEC Diff') + + if self.check_pwm.isChecked(): + series.append('pwm') + labels.append('PWM') + + # I2C data + if self.check_i2c_raw14.isChecked(): + series.append('i2c_raw14') + labels.append('I2C Raw (14-bit)') + + if self.check_i2c_zero_raw14.isChecked(): + series.append('i2c_zero_raw14') + labels.append('I2C Zero Raw') + + if self.check_i2c_delta_raw14.isChecked(): + series.append('i2c_delta_raw14') + labels.append('I2C Delta Raw') + + if self.check_i2c_angle_deg.isChecked(): + series.append('i2c_angle_deg') + labels.append('Angle (degrees)') + + if self.check_i2c_zero_angle_deg.isChecked(): + series.append('i2c_zero_angle_deg') + labels.append('Zero Angle (degrees)') + + # Derived data + if self.check_angular_velocity.isChecked(): + series.append('angular_velocity') + labels.append('Angular Velocity') + + return series, labels + + def _generate_plot(self): + """Generate plot based on selected data and settings.""" + # Auto-load selected runs first + self.selected_runs = self._get_selected_runs() + + if not self.selected_runs: + QMessageBox.warning(self, "No Selection", "Please select at least one run") + return + + # Load data + self.loaded_data = [] + + for session_id, run_no in self.selected_runs: + data = self.adapter.load_run_data(session_id, run_no) + if data: + self.loaded_data.append(data) + + if not self.loaded_data: + QMessageBox.warning(self, "Load Error", "Failed to load data from database") + return + + # Get plot mode + plot_mode = self.combo_plot_mode.currentText() + + # Get line style settings + line_type = self.combo_line_type.currentText() + linestyle, marker, markersize = self._get_line_style(line_type) + + config = PlotConfig( + title="Telemetry Data Visualization", + figsize=(10, 6), + linestyle=linestyle, + marker=marker, + markersize=markersize + ) + + try: + if plot_mode == "XY Plot": + # XY scatter plot mode + x_col = self.combo_xy_x.currentText() + y_col = self.combo_xy_y.currentText() + + xlabel = self._get_axis_label(x_col) + ylabel = self._get_axis_label(y_col) + + self.figure = plot_xy_scatter( + self.loaded_data, + x_col, + y_col, + xlabel, + ylabel, + config + ) + + else: + # Time series mode + series, labels = self._get_selected_series() + + if not series: + QMessageBox.warning(self, "No Series", "Please select at least one data series") + return + + # Get X-axis + x_col = self.combo_xaxis.currentText() + xlabel = self._get_axis_label(x_col) + + plot_type = self.combo_plot_type.currentText() + + if plot_type == "Overlay": + # FIXED: All series on SAME plot (not stacked) + self.figure = self._plot_overlay_all( + self.loaded_data, + x_col, + series, + xlabel, + labels, + config + ) + + elif plot_type == "Subplots": + # One subplot per run (first series only) + self.figure = plot_subplots( + self.loaded_data, + x_col, + series[0], + xlabel, + labels[0], + config + ) + + elif plot_type == "Drift Comparison": + # Drift comparison (first series only) + self.figure = plot_comparison( + self.loaded_data, + x_col, + series[0], + xlabel, + labels[0], + reference_index=0, + config=config + ) + + elif plot_type == "Multi-Series": + # Multi-series stacked subplots + self.figure = plot_multi_series( + self.loaded_data, + x_col, + series, + xlabel, + labels, + config + ) + + # Display plot + if self.check_separate_window.isChecked(): + # Open in separate window + self._show_plot_window(self.figure) + else: + # Embed in main window - recreate canvas to fix matplotlib toolbar + self._update_canvas(self.figure) + + self.plot_updated.emit() + + except Exception as e: + import traceback + traceback.print_exc() + QMessageBox.critical(self, "Plot Error", f"Failed to generate plot:\n{e}") + + def _plot_overlay_all(self, data_list, x_col, y_columns, xlabel, ylabels, config): + """ + Plot all series from all runs on SAME axes (true overlay). + + Fixes the stacking issue - everything goes on one plot. + """ + import matplotlib.pyplot as plt + + fig, ax = plt.subplots(figsize=config.figsize, dpi=config.dpi) + + # Set color cycle for distinguishable colors + ax.set_prop_cycle(color=plt.cm.tab10.colors) # 20 colors + + # Get line type settings + line_type = self.combo_line_type.currentText() + linestyle, marker, markersize = self._get_line_style(line_type) + + # Plot each combination of run Γ— series + for data in data_list: + x_data = getattr(data, x_col, None) + + if x_data is None: + continue + + for y_col, ylabel in zip(y_columns, ylabels): + y_data = getattr(data, y_col, None) + + if y_data is None: + continue + + # Label format: "session_name run_no (name) - series" + label = f"{data.session_id} {data.run_no} ({data.run_name}) - {ylabel}" + ax.plot(x_data, y_data, label=label, alpha=0.8, + linestyle=linestyle, marker=marker, markersize=markersize) + + # Formatting + ax.set_xlabel(xlabel) + ax.set_ylabel("Value") + ax.set_title(config.title) + + if config.grid: + ax.grid(True, alpha=0.3) + + if config.legend: + # Legend at bottom, outside plot, 2 columns + ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.12), ncol=2) + + fig.tight_layout(rect=[0, 0.08, 1, 1]) # Leave space for legend + + return fig + + def _get_line_style(self, line_type): + """ + Get matplotlib line style parameters. + + Returns: + (linestyle, marker, markersize) + """ + if line_type == "Line": + return '-', None, 0 + elif line_type == "Line + Markers": + return '-', 'o', 3 + elif line_type == "Markers Only": + return '', 'o', 4 + elif line_type == "Steps": + return 'steps-post', None, 0 + else: + return '-', None, 0 + + def _update_canvas(self, figure): + """Update canvas with new figure - recreates canvas to fix matplotlib toolbar.""" + # Remove old canvas and toolbar + if hasattr(self, 'canvas'): + self.plot_layout.removeWidget(self.canvas) + self.plot_layout.removeWidget(self.toolbar) + self.canvas.deleteLater() + self.toolbar.deleteLater() + + # Create fresh canvas and toolbar + self.canvas = FigureCanvas(figure) + self.toolbar = NavigationToolbar(self.canvas, self) + + # Add to layout + self.plot_layout.insertWidget(1, self.canvas) # After controls + self.plot_layout.insertWidget(2, self.toolbar) # After canvas + + self.figure = figure + + def _show_plot_window(self, figure): + """Show plot in separate window.""" + from PyQt6.QtWidgets import QDialog, QVBoxLayout + + dialog = QDialog(self) + dialog.setWindowTitle("Telemetry Plot") + dialog.resize(1000, 700) + + layout = QVBoxLayout() + dialog.setLayout(layout) + + # Canvas and toolbar + canvas = FigureCanvas(figure) + toolbar = NavigationToolbar(canvas, dialog) + + layout.addWidget(canvas) + layout.addWidget(toolbar) + + dialog.show() + + def _get_axis_label(self, column: str) -> str: + """Get human-readable label for column.""" + labels = { + 't_ns': 'Time (ns)', + 'time_ms': 'Time (ms)', + 'motor_current': 'Motor Current', + 'encoder_value': 'Encoder Value', + 'relative_encoder_value': 'Relative Encoder', + 'v24_pec_diff': 'V24 PEC Diff', + 'pwm': 'PWM', + 'i2c_raw14': 'I2C Raw (14-bit)', + 'i2c_zero_raw14': 'I2C Zero Raw', + 'i2c_delta_raw14': 'I2C Delta Raw', + 'i2c_angle_deg': 'Angle (degrees)', + 'i2c_zero_angle_deg': 'Zero Angle (degrees)', + 'angular_velocity': 'Angular Velocity' + } + return labels.get(column, column) + + # ========================================================================= + # Export + # ========================================================================= + + def _export_png(self): + """Export current plot to PNG.""" + if not self.loaded_data: + QMessageBox.warning(self, "No Data", "Please generate a plot first") + return + + filepath, _ = QFileDialog.getSaveFileName( + self, + "Export PNG", + "", + "PNG Files (*.png)" + ) + + if filepath: + if export_png(self.figure, filepath, dpi=300): + QMessageBox.information(self, "Success", f"Plot saved to:\n{filepath}") + else: + QMessageBox.critical(self, "Error", "Failed to export PNG") + + def _export_csv(self): + """Export loaded data to CSV (only selected columns).""" + if not self.loaded_data: + QMessageBox.warning(self, "No Data", "Please load data first") + return + + # Get selected columns based on plot mode + plot_mode = self.combo_plot_mode.currentText() + + if plot_mode == "XY Plot": + # XY mode: X and Y from dropdowns + x_col = self.combo_xy_x.currentText() + y_cols = [self.combo_xy_y.currentText()] + else: + # Time series mode: X from dropdown, Y from checkboxes + x_col = self.combo_xaxis.currentText() + series, labels = self._get_selected_series() + + if not series: + QMessageBox.warning(self, "No Series", "Please select at least one data series") + return + + y_cols = series + + # Get save path + filepath, _ = QFileDialog.getSaveFileName( + self, + "Export CSV", + "", + "CSV Files (*.csv)" + ) + + if filepath: + if export_csv(self.loaded_data, filepath, x_col, y_cols): + QMessageBox.information(self, "Success", f"Data saved to:\n{filepath}") + else: + QMessageBox.critical(self, "Error", "Failed to export CSV") + + # ========================================================================= + # Cleanup + # ========================================================================= + + def closeEvent(self, event): + """Close database connection on widget close.""" + if self.adapter: + self.adapter.close() + event.accept() + + +# ============================================================================= +# Demo +# ============================================================================= + +if __name__ == "__main__": + import sys + from PyQt6.QtWidgets import QApplication + + app = QApplication(sys.argv) + + # You need to provide a database path + widget = GraphWidget(db_path="telemetry.db") + widget.setWindowTitle("Telemetry Graph Visualizer") + widget.show() + + sys.exit(app.exec()) diff --git a/graph_table_query.py b/graph_table_query.py new file mode 100644 index 0000000..ba0fbdd --- /dev/null +++ b/graph_table_query.py @@ -0,0 +1,720 @@ +#!/usr/bin/env python3 +""" +Table Query - Data Access Layer +================================ +Abstracts data sources (SQLite, CSV, etc.) for graph module. + +Provides adapter pattern for different data sources while maintaining +consistent interface for plotting functions. + +Features: +- Abstract DataAdapter interface +- SQLiteAdapter for database files +- CSVAdapter for CSV files +- Column utilities and labels +- Data alignment for drift comparison + +Author: Kynsight +Version: 1.0.0 +""" + +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import List, Dict, Tuple, Optional, Any +from dataclasses import dataclass +from pathlib import Path +import sqlite3 + +import numpy as np + + +# ============================================================================= +# Data Structures +# ============================================================================= + +@dataclass +class SessionInfo: + """Session metadata.""" + session_id: str + created_at: str + description: str + run_count: int + + +@dataclass +class RunInfo: + """Run metadata.""" + session_id: str + run_name: str + run_number: int + sample_count: int + start_time_ns: int + end_time_ns: int + duration_ms: float + + +@dataclass +class TelemetryData: + """Decoded telemetry data (source-agnostic).""" + session_id: str + run_name: str + run_no: int + + # Time axes + t_ns: Optional[np.ndarray] = None + time_ms: Optional[np.ndarray] = None + + # UART decoded data + motor_current: Optional[np.ndarray] = None + encoder_value: Optional[np.ndarray] = None + relative_encoder_value: Optional[np.ndarray] = None + v24_pec_diff: Optional[np.ndarray] = None + pwm: Optional[np.ndarray] = None + + # I2C decoded data + i2c_raw14: Optional[np.ndarray] = None + i2c_zero_raw14: Optional[np.ndarray] = None + i2c_delta_raw14: Optional[np.ndarray] = None + i2c_angle_deg: Optional[np.ndarray] = None + i2c_zero_angle_deg: Optional[np.ndarray] = None + + # Derived data + angular_velocity: Optional[np.ndarray] = None + + +# ============================================================================= +# Abstract Adapter +# ============================================================================= + +class DataAdapter(ABC): + """ + Abstract data source adapter. + + Implement this interface for each data source type (SQLite, CSV, etc.) + All adapters provide the same interface for loading telemetry data. + """ + + @abstractmethod + def connect(self) -> bool: + """ + Connect to data source. + + Returns: + True on success, False on failure + """ + pass + + @abstractmethod + def close(self): + """Close connection to data source.""" + pass + + @abstractmethod + def get_sessions(self) -> List[SessionInfo]: + """ + Get all sessions from data source. + + Returns: + List of SessionInfo objects + """ + pass + + @abstractmethod + def get_runs(self, session_id: str) -> List[RunInfo]: + """ + Get all runs for a specific session. + + Args: + session_id: Session identifier + + Returns: + List of RunInfo objects + """ + pass + + @abstractmethod + def load_run_data(self, session_id: str, run_no: int) -> Optional[TelemetryData]: + """ + Load telemetry data for one run. + + Args: + session_id: Session identifier + run_no: Run number + + Returns: + TelemetryData object or None on error + """ + pass + + +# ============================================================================= +# SQLite Adapter +# ============================================================================= + +class SQLiteAdapter(DataAdapter): + """ + SQLite database adapter. + + Works with decoded_telemetry table schema. + """ + + def __init__(self, db_path: str): + """ + Initialize SQLite adapter. + + Args: + db_path: Path to SQLite database file + """ + self.db_path = db_path + self._conn: Optional[sqlite3.Connection] = None + + def connect(self) -> bool: + """Open database connection.""" + try: + self._conn = sqlite3.connect(self.db_path) + self._conn.row_factory = sqlite3.Row + return True + except Exception as e: + print(f"[SQLite ERROR] Failed to connect: {e}") + return False + + def close(self): + """Close database connection.""" + if self._conn: + self._conn.close() + self._conn = None + + def get_sessions(self) -> List[SessionInfo]: + """Get all sessions from database.""" + if not self._conn: + return [] + + try: + cursor = self._conn.cursor() + + cursor.execute(""" + SELECT + s.session_id, + s.created_at, + s.description, + COUNT(DISTINCT t.run_no) as run_count + FROM sessions s + LEFT JOIN decoded_telemetry t ON s.session_id = t.session_id + GROUP BY s.session_id + ORDER BY s.created_at DESC + """) + + sessions = [] + for row in cursor.fetchall(): + sessions.append(SessionInfo( + session_id=row['session_id'], + created_at=row['created_at'] or '', + description=row['description'] or '', + run_count=row['run_count'] or 0 + )) + + return sessions + + except Exception as e: + print(f"[SQLite ERROR] Failed to get sessions: {e}") + return [] + + def get_runs(self, session_id: str) -> List[RunInfo]: + """Get all runs for a session.""" + if not self._conn: + return [] + + try: + cursor = self._conn.cursor() + + cursor.execute(""" + SELECT + session_id, + run_name, + run_no, + COUNT(*) as sample_count, + MIN(t_ns) as start_time_ns, + MAX(t_ns) as end_time_ns + FROM decoded_telemetry + WHERE session_id = ? + GROUP BY session_id, run_no, run_name + ORDER BY run_no + """, (session_id,)) + + runs = [] + for row in cursor.fetchall(): + duration_ns = row['end_time_ns'] - row['start_time_ns'] + duration_ms = duration_ns / 1_000_000.0 + + runs.append(RunInfo( + session_id=row['session_id'], + run_name=row['run_name'], + run_number=row['run_no'], + sample_count=row['sample_count'], + start_time_ns=row['start_time_ns'], + end_time_ns=row['end_time_ns'], + duration_ms=duration_ms + )) + + return runs + + except Exception as e: + print(f"[SQLite ERROR] Failed to get runs: {e}") + return [] + + def load_run_data(self, session_id: str, run_no: int) -> Optional[TelemetryData]: + """Load telemetry data for one run.""" + if not self._conn: + return None + + try: + cursor = self._conn.cursor() + + cursor.execute(""" + SELECT + run_name, + t_ns, + time_ms, + motor_current, + encoder_value, + relative_encoder_value, + v24_pec_diff, + pwm, + i2c_raw14, + i2c_zero_raw14, + i2c_delta_raw14, + i2c_angle_deg, + i2c_zero_angle_deg, + angular_velocity + FROM decoded_telemetry + WHERE session_id = ? AND run_no = ? + ORDER BY t_ns + """, (session_id, run_no)) + + rows = cursor.fetchall() + + if not rows: + return None + + # Get run_name from first row + run_name = rows[0]['run_name'] + + # Extract columns + data = TelemetryData( + session_id=session_id, + run_name=run_name, + run_no=run_no, + t_ns=self._extract_column(rows, 't_ns', dtype=np.int64), + time_ms=self._extract_column(rows, 'time_ms', dtype=np.int64), + motor_current=self._extract_column(rows, 'motor_current', dtype=np.float32), + encoder_value=self._extract_column(rows, 'encoder_value', dtype=np.float32), + relative_encoder_value=self._extract_column(rows, 'relative_encoder_value', dtype=np.float32), + v24_pec_diff=self._extract_column(rows, 'v24_pec_diff', dtype=np.float32), + pwm=self._extract_column(rows, 'pwm', dtype=np.float32), + i2c_raw14=self._extract_column(rows, 'i2c_raw14', dtype=np.float32), + i2c_zero_raw14=self._extract_column(rows, 'i2c_zero_raw14', dtype=np.float32), + i2c_delta_raw14=self._extract_column(rows, 'i2c_delta_raw14', dtype=np.float32), + i2c_angle_deg=self._extract_column(rows, 'i2c_angle_deg', dtype=np.float32), + i2c_zero_angle_deg=self._extract_column(rows, 'i2c_zero_angle_deg', dtype=np.float32), + angular_velocity=self._extract_column(rows, 'angular_velocity', dtype=np.float32) + ) + + return data + + except Exception as e: + print(f"[SQLite ERROR] Failed to load run data: {e}") + return None + + def _extract_column(self, rows: List, column: str, dtype=np.float32) -> Optional[np.ndarray]: + """ + Extract column from rows, handling NULL values. + + Returns None if all values are NULL. + """ + values = [row[column] for row in rows] + + # Check if any non-NULL values + if all(v is None for v in values): + return None + + # Replace None with NaN + values = [v if v is not None else np.nan for v in values] + + return np.array(values, dtype=dtype) + + +# ============================================================================= +# CSV Adapter +# ============================================================================= + +class CSVAdapter(DataAdapter): + """ + CSV file adapter. + + Expected CSV format: + session_id,run_name,run_no,t_ns,time_ms,motor_current,encoder_value,... + + Single CSV file containing all sessions and runs. + """ + + def __init__(self, csv_path: str): + """ + Initialize CSV adapter. + + Args: + csv_path: Path to CSV file + """ + self.csv_path = csv_path + self._df = None # pandas DataFrame + + def connect(self) -> bool: + """Load CSV file.""" + try: + import pandas as pd + self._df = pd.read_csv(self.csv_path) + + # Validate required columns + required = ['session_id', 'run_name', 'run_no'] + missing = [col for col in required if col not in self._df.columns] + + if missing: + print(f"[CSV ERROR] Missing required columns: {missing}") + return False + + return True + + except Exception as e: + print(f"[CSV ERROR] Failed to load CSV: {e}") + return False + + def close(self): + """Release DataFrame.""" + self._df = None + + def get_sessions(self) -> List[SessionInfo]: + """Get all sessions from CSV.""" + if self._df is None: + return [] + + try: + # Group by session_id + session_groups = self._df.groupby('session_id') + + sessions = [] + for session_id, group in session_groups: + run_count = group['run_no'].nunique() + + # Try to get created_at if column exists + created_at = '' + if 'created_at' in group.columns and len(group) > 0: + created_at = str(group['created_at'].iloc[0]) + + sessions.append(SessionInfo( + session_id=str(session_id), + created_at=created_at, + description='', # Not in CSV + run_count=run_count + )) + + return sessions + + except Exception as e: + print(f"[CSV ERROR] Failed to get sessions: {e}") + return [] + + def get_runs(self, session_id: str) -> List[RunInfo]: + """Get all runs for a session.""" + if self._df is None: + return [] + + try: + # Filter by session + session_df = self._df[self._df['session_id'] == session_id] + + # Group by run_no + run_groups = session_df.groupby('run_no') + + runs = [] + for run_no, group in run_groups: + sample_count = len(group) + + # Get run_name + run_name = str(group['run_name'].iloc[0]) if 'run_name' in group.columns else '' + + # Get time info + if 't_ns' in group.columns: + start_time_ns = int(group['t_ns'].min()) + end_time_ns = int(group['t_ns'].max()) + duration_ms = (end_time_ns - start_time_ns) / 1_000_000.0 + else: + start_time_ns = 0 + end_time_ns = 0 + duration_ms = 0.0 + + runs.append(RunInfo( + session_id=session_id, + run_name=run_name, + run_number=int(run_no), + sample_count=sample_count, + start_time_ns=start_time_ns, + end_time_ns=end_time_ns, + duration_ms=duration_ms + )) + + # Sort by run_number + runs.sort(key=lambda r: r.run_number) + + return runs + + except Exception as e: + print(f"[CSV ERROR] Failed to get runs: {e}") + return [] + + def load_run_data(self, session_id: str, run_no: int) -> Optional[TelemetryData]: + """Load telemetry data for one run.""" + if self._df is None: + return None + + try: + # Filter by session and run + run_df = self._df[ + (self._df['session_id'] == session_id) & + (self._df['run_no'] == run_no) + ] + + if len(run_df) == 0: + return None + + # Sort by timestamp + if 't_ns' in run_df.columns: + run_df = run_df.sort_values('t_ns') + + # Get run_name + run_name = str(run_df['run_name'].iloc[0]) if 'run_name' in run_df.columns else '' + + # Extract columns + data = TelemetryData( + session_id=session_id, + run_name=run_name, + run_no=run_no, + t_ns=self._extract_column_csv(run_df, 't_ns', dtype=np.int64), + time_ms=self._extract_column_csv(run_df, 'time_ms', dtype=np.int64), + motor_current=self._extract_column_csv(run_df, 'motor_current', dtype=np.float32), + encoder_value=self._extract_column_csv(run_df, 'encoder_value', dtype=np.float32), + relative_encoder_value=self._extract_column_csv(run_df, 'relative_encoder_value', dtype=np.float32), + v24_pec_diff=self._extract_column_csv(run_df, 'v24_pec_diff', dtype=np.float32), + pwm=self._extract_column_csv(run_df, 'pwm', dtype=np.float32), + i2c_raw14=self._extract_column_csv(run_df, 'i2c_raw14', dtype=np.float32), + i2c_zero_raw14=self._extract_column_csv(run_df, 'i2c_zero_raw14', dtype=np.float32), + i2c_delta_raw14=self._extract_column_csv(run_df, 'i2c_delta_raw14', dtype=np.float32), + i2c_angle_deg=self._extract_column_csv(run_df, 'i2c_angle_deg', dtype=np.float32), + i2c_zero_angle_deg=self._extract_column_csv(run_df, 'i2c_zero_angle_deg', dtype=np.float32), + angular_velocity=self._extract_column_csv(run_df, 'angular_velocity', dtype=np.float32) + ) + + return data + + except Exception as e: + print(f"[CSV ERROR] Failed to load run data: {e}") + return None + + def _extract_column_csv(self, df, column: str, dtype=np.float32) -> Optional[np.ndarray]: + """ + Extract column from DataFrame, handling missing columns and NaN. + + Returns None if column doesn't exist or all values are NaN. + """ + if column not in df.columns: + return None + + values = df[column].values + + # Check if all NaN + if np.all(np.isnan(values.astype(float, errors='ignore'))): + return None + + return values.astype(dtype) + + +# ============================================================================= +# Column Utilities +# ============================================================================= + +def get_available_columns() -> List[str]: + """ + Get list of all plottable columns. + + Returns: + List of column names + """ + return [ + 't_ns', + 'time_ms', + 'motor_current', + 'encoder_value', + 'relative_encoder_value', + 'v24_pec_diff', + 'pwm', + 'i2c_raw14', + 'i2c_zero_raw14', + 'i2c_delta_raw14', + 'i2c_angle_deg', + 'i2c_zero_angle_deg', + 'angular_velocity' + ] + + +def get_column_label(column: str) -> str: + """ + Get human-readable label for column. + + Args: + column: Column name + + Returns: + Human-readable label + """ + labels = { + 't_ns': 'Time (ns)', + 'time_ms': 'Time (ms)', + 'motor_current': 'Motor Current', + 'encoder_value': 'Encoder Value', + 'relative_encoder_value': 'Relative Encoder', + 'v24_pec_diff': 'V24 PEC Diff', + 'pwm': 'PWM', + 'i2c_raw14': 'I2C Raw (14-bit)', + 'i2c_zero_raw14': 'I2C Zero Raw', + 'i2c_delta_raw14': 'I2C Delta Raw', + 'i2c_angle_deg': 'Angle (degrees)', + 'i2c_zero_angle_deg': 'Zero Angle (degrees)', + 'angular_velocity': 'Angular Velocity' + } + return labels.get(column, column) + + +def get_column_groups() -> Dict[str, List[str]]: + """ + Get columns grouped by category. + + Returns: + Dictionary of category -> list of columns + """ + return { + 'Time': ['t_ns', 'time_ms'], + 'UART': ['motor_current', 'encoder_value', 'relative_encoder_value', + 'v24_pec_diff', 'pwm'], + 'I2C': ['i2c_raw14', 'i2c_zero_raw14', 'i2c_delta_raw14', + 'i2c_angle_deg', 'i2c_zero_angle_deg'], + 'Derived': ['angular_velocity'] + } + + +def get_default_columns() -> List[str]: + """ + Get default selected columns. + + Returns: + List of column names to check by default + """ + return [ + 'time_ms', + 'motor_current', + 'encoder_value', + 'relative_encoder_value', + 'pwm', + 'i2c_angle_deg', + 'angular_velocity' + ] + + +# ============================================================================= +# Data Alignment +# ============================================================================= + +def align_data_to_reference( + data_list: List[TelemetryData], + x_column: str, + reference_index: int = 0 +) -> List[TelemetryData]: + """ + Align multiple runs to reference run's X-axis. + + Useful for drift comparison when runs have different timestamps + or sampling rates. + + Args: + data_list: List of TelemetryData objects + x_column: Column name to use as X-axis + reference_index: Index of reference run (default: 0) + + Returns: + List of TelemetryData objects aligned to reference + """ + if reference_index >= len(data_list): + reference_index = 0 + + reference = data_list[reference_index] + ref_x = getattr(reference, x_column, None) + + if ref_x is None: + return data_list + + aligned_list = [reference] # Reference stays as-is + + for idx, data in enumerate(data_list): + if idx == reference_index: + continue + + x_data = getattr(data, x_column, None) + + if x_data is None: + aligned_list.append(data) + continue + + # Create aligned data object + aligned = TelemetryData( + session_id=data.session_id, + run_name=data.run_name, + run_no=data.run_no + ) + + # Interpolate all columns to reference X + for col in get_available_columns(): + if col == x_column: + # X column uses reference + setattr(aligned, col, ref_x) + else: + y_data = getattr(data, col, None) + if y_data is not None and len(y_data) == len(x_data): + # Interpolate + y_aligned = np.interp(ref_x, x_data, y_data) + setattr(aligned, col, y_aligned) + + aligned_list.append(aligned) + + return aligned_list + + +# ============================================================================= +# Demo +# ============================================================================= + +if __name__ == "__main__": + print("Table Query - Data Access Layer") + print("=" * 60) + print() + print("Available Adapters:") + print(" β€’ SQLiteAdapter - for .db files") + print(" β€’ CSVAdapter - for .csv files") + print() + print("Column Utilities:") + print(f" β€’ {len(get_available_columns())} plottable columns") + print(f" β€’ {len(get_column_groups())} categories") + print(f" β€’ {len(get_default_columns())} default selections") + print() + print("Usage:") + print(" from table_query import SQLiteAdapter, CSVAdapter") + print(" adapter = SQLiteAdapter('./database/ehinge.db')") + print(" adapter.connect()") + print(" sessions = adapter.get_sessions()") + print(" data = adapter.load_run_data('Session_A', 1)") diff --git a/i2c/i2c_integrated_widget.py b/i2c/i2c_integrated_widget.py new file mode 100644 index 0000000..d733f5b --- /dev/null +++ b/i2c/i2c_integrated_widget.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +I2C Control Widget - Integrated +================================ +Command table (left) + I2C core widget (right) + +Author: Kynsight +Version: 3.0.0 +""" + +from PyQt6.QtWidgets import QWidget, QHBoxLayout, QSplitter +from PyQt6.QtCore import Qt, pyqtSignal + +from command_table.command_table import CommandTableWidget +from i2c.i2c_kit.i2c_core_widget import I2CWidget + + +class I2CControlWidget(QWidget): + """ + Integrated I2C control widget. + + Layout: Command table (left) | I2C core (right) + + Signals: + command_sent: (command_id, operation, register) + reading_received: (reading_info) + connection_changed: (is_connected) + """ + + command_sent = pyqtSignal(int, str, str) # command_id, operation, register + reading_received = pyqtSignal(object) + connection_changed = pyqtSignal(bool) + + def __init__(self, db_connection, parent=None): + super().__init__(parent) + + self.conn = db_connection + + self._init_ui() + self._setup_connections() + + def _init_ui(self): + """Initialize UI - side by side layout.""" + layout = QHBoxLayout() + self.setLayout(layout) + + # Splitter for resizable layout + splitter = QSplitter(Qt.Orientation.Horizontal) + + # Left: Command table + self.command_table = CommandTableWidget(self.conn, 'i2c') + splitter.addWidget(self.command_table) + + # Right: I2C core widget + self.i2c_core = I2CWidget() + splitter.addWidget(self.i2c_core) + + # Set initial sizes (40% table, 60% core) + splitter.setSizes([400, 600]) + + layout.addWidget(splitter) + + # Initialize buses on startup + self.i2c_core._refresh_buses() + + # Table always enabled, CRUD buttons disabled initially (not connected) + self._update_table_mode(False) + + def _setup_connections(self): + """Connect signals between table and core.""" + + # Command table β†’ Execute via I2C (when connected) OR Edit (when disconnected) + self.command_table.command_double_clicked.connect(self._on_command_double_click) + + # I2C connection β†’ Change table mode + self.i2c_core.connection_changed.connect(self._on_connection_changed) + + # Forward I2C signals + self.i2c_core.reading_received.connect(self.reading_received.emit) + + def _update_table_mode(self, is_connected): + """Update table mode based on connection state.""" + if is_connected: + # Connected mode: CRUD buttons disabled, double-click executes + self.command_table.btn_add.setEnabled(False) + self.command_table.btn_edit.setEnabled(False) + self.command_table.btn_delete.setEnabled(False) + else: + # Disconnected mode: CRUD buttons enabled, double-click edits + self.command_table.btn_add.setEnabled(True) + # Edit/Delete enabled only if something selected + has_selection = bool(self.command_table.table.selectedItems()) + self.command_table.btn_edit.setEnabled(has_selection) + self.command_table.btn_delete.setEnabled(has_selection) + + def _on_command_double_click(self, command_id, cmd_data): + """Handle double-click: Execute if connected, Edit if disconnected.""" + if self.i2c_core.is_connected: + # Connected: Execute command + self._execute_command(command_id, cmd_data) + else: + # Disconnected: Edit command + self.command_table._edit_command() + + def _execute_command(self, command_id, cmd_data): + """Execute I2C command.""" + operation = cmd_data.get('operation', '') + register = cmd_data.get('register', '') + hex_string = cmd_data.get('hex_string', '') + device_address = cmd_data.get('device_address', '0x40') + + if not operation or not register: + return + + try: + # Remove 0x prefix if present for consistency + device_addr_clean = device_address.replace('0x', '').replace('0X', '') + register_clean = register.replace('0x', '').replace('0X', '') + + # Populate manual I/O fields (correct field names from i2c_core_widget) + self.i2c_core.edit_io_addr.setText(device_addr_clean) + self.i2c_core.edit_io_reg.setText(register_clean) + + if operation == 'read': + # Determine bytes to read + if 'Angle' in cmd_data.get('command_name', ''): + num_bytes = 2 + elif 'Magnitude' in cmd_data.get('command_name', ''): + num_bytes = 2 + else: + num_bytes = 1 + + # Set length + self.i2c_core.spin_io_length.setValue(num_bytes) + + # Trigger read (use the button's method) + self.i2c_core._on_read() + + else: # write + # Parse data value and set write data field + if hex_string: + data_clean = hex_string.replace('0x', '').replace('0X', '') + self.i2c_core.edit_write_data.setText(data_clean) + + # Trigger write + self.i2c_core._on_write() + + # Emit signal + self.command_sent.emit(command_id, operation, register) + + except Exception as e: + print(f"I2C execute error: {e}") + + def _on_connection_changed(self, is_connected): + """Handle connection state change.""" + # Update table mode (CRUD buttons, double-click behavior) + self._update_table_mode(is_connected) + + # Forward signal + self.connection_changed.emit(is_connected) + + def get_i2c_core(self): + """Get I2C core widget for direct access.""" + return self.i2c_core + + def get_command_table(self): + """Get command table for direct access.""" + return self.command_table + + +if __name__ == "__main__": + import sys + from PyQt6.QtWidgets import QApplication + import sqlite3 + + app = QApplication(sys.argv) + + conn = sqlite3.connect("./database/ehinge.db") + + widget = I2CControlWidget(conn) + widget.setWindowTitle("I2C Control - Integrated") + widget.resize(1400, 800) + widget.show() + + sys.exit(app.exec()) diff --git a/i2c/i2c_kit/i2c_core.py b/i2c/i2c_kit/i2c_core.py new file mode 100644 index 0000000..4875354 --- /dev/null +++ b/i2c/i2c_kit/i2c_core.py @@ -0,0 +1,684 @@ +#!/usr/bin/env python3 +""" +I2C Core - vzug-e-hinge +======================= +Generic I2C bus management with reading/writing and continuous logging. + +Features: +- Bus scanning (detect available /dev/i2c-*) +- Device scanning (like i2cdetect) +- Read/write operations (byte or block) +- Continuous logger thread (background polling) +- Circular buffer for logger data +- Timestamp synchronization (inject clock) +- Thread-safe operations +- Sensor-agnostic (generic register access) + +Author: Kynsight +Version: 1.0.0 +""" + +from __future__ import annotations +import os +import threading +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, Callable, Tuple, List + +try: + import smbus2 +except ImportError: + smbus2 = None + +from buffer_kit.circular_buffer import ( + Status as BufferStatus, + CircularBufferHandle, + cb_init, + cb_write, + cb_capacity, + cb_fill_bytes, + cb_fill_pct, + cb_overflows, +) + + +# ============================================================================= +# Status & Configuration +# ============================================================================= + +class Status(Enum): + """Operation status codes.""" + OK = 0 + ERROR = 1 + BUS_NOT_FOUND = 2 + DEVICE_NOT_FOUND = 3 + ALREADY_OPEN = 4 + NOT_OPEN = 5 + LOGGER_RUNNING = 6 + NACK = 7 # Device did not acknowledge + + +@dataclass(frozen=True) +class I2CConfig: + """ + I2C bus configuration. + + bus_id: Bus number (e.g., 1 for /dev/i2c-1) + buffer_size: Logger circular buffer capacity + timestamp_source: Optional external time function (for sync with UART) + """ + bus_id: int + buffer_size: int = 4096 + timestamp_source: Optional[Callable[[], float]] = None + + +@dataclass +class I2CReading: + """ + Single I2C read/write operation record. + + reading_id: Sequential ID (per session) + timestamp: When operation occurred + address: Device address (7-bit) + register: Register address + data: Raw bytes read/written + operation: "READ" or "WRITE" + """ + reading_id: int + timestamp: float + address: int + register: int + data: bytes + operation: str # "READ" or "WRITE" + + +@dataclass +class BusInfo: + """ + Information about I2C bus status. + + is_open: Bus is opened + is_logger_running: Logger thread active + buffer_capacity: Buffer size + buffer_fill_bytes: Current buffer usage + buffer_fill_percent: Fill percentage + buffer_overflows: Overflow count + total_reads: Total read operations + total_writes: Total write operations + total_errors: Total errors encountered + """ + is_open: bool + is_logger_running: bool + buffer_capacity: int + buffer_fill_bytes: int + buffer_fill_percent: int + buffer_overflows: int + total_reads: int + total_writes: int + total_errors: int + + +# ============================================================================= +# I2C Handle +# ============================================================================= + +@dataclass +class I2CHandle: + """ + I2C bus handle (internal state - use API functions). + + Do not access fields directly - use i2c_* functions. + """ + config: I2CConfig + + # SMBus + _bus: Optional[smbus2.SMBus] = None + _is_open: bool = False + + # Circular buffer for logger + _buffer: Optional[CircularBufferHandle] = None + + # Logger thread + _logger_thread: Optional[threading.Thread] = None + _logger_running: bool = False + _stop_logger_event: threading.Event = field(default_factory=threading.Event) + + # Logger configuration + _logger_addr: int = 0 + _logger_reg: int = 0 + _logger_len: int = 1 + _logger_interval_ms: int = 100 + + # Thread safety + _lock: threading.RLock = field(default_factory=threading.RLock) + + # Statistics + _total_reads: int = 0 + _total_writes: int = 0 + _total_errors: int = 0 + _reading_counter: int = 0 + + # Timestamp function + _get_timestamp: Callable[[], float] = field(default=time.perf_counter) + + def __post_init__(self): + """Initialize timestamp source.""" + if self.config.timestamp_source: + self._get_timestamp = self.config.timestamp_source + + +# ============================================================================= +# Bus Discovery +# ============================================================================= + +def i2c_scan_buses() -> List[int]: + """ + Scan for available I2C buses. + + Returns: + List of bus IDs (e.g., [0, 1, 2] for /dev/i2c-0, /dev/i2c-1, etc.) + """ + buses = [] + try: + for entry in os.listdir("/dev"): + if entry.startswith("i2c-"): + try: + bus_id = int(entry.split("-")[1]) + buses.append(bus_id) + except ValueError: + continue + except Exception: + pass + + return sorted(buses) + + +# ============================================================================= +# Bus Lifecycle +# ============================================================================= + +def i2c_create(config: I2CConfig) -> Tuple[Status, Optional[I2CHandle]]: + """ + Create I2C handle. + + Args: + config: I2C configuration + + Returns: + (Status.OK, handle) on success + (Status.ERROR, None) on failure + """ + if not smbus2: + return (Status.ERROR, None) + + if config.bus_id < 0: + return (Status.ERROR, None) + + handle = I2CHandle(config=config) + return (Status.OK, handle) + + +def i2c_open(handle: I2CHandle) -> Status: + """ + Open I2C bus. + + Args: + handle: I2C handle + + Returns: + Status.OK on success + Status.ALREADY_OPEN if already open + Status.BUS_NOT_FOUND if bus not available + """ + if handle._is_open: + return Status.ALREADY_OPEN + + try: + # Open SMBus + handle._bus = smbus2.SMBus(handle.config.bus_id) + + # Create buffer for logger + status, buffer = cb_init(handle.config.buffer_size) + if status != BufferStatus.OK: + handle._bus.close() + return Status.ERROR + + handle._buffer = buffer + handle._is_open = True + + # Reset statistics + with handle._lock: + handle._total_reads = 0 + handle._total_writes = 0 + handle._total_errors = 0 + handle._reading_counter = 0 + + return Status.OK + + except FileNotFoundError: + return Status.BUS_NOT_FOUND + except Exception: + return Status.ERROR + + +def i2c_close(handle: I2CHandle) -> Status: + """ + Close I2C bus and cleanup. + + Stops logger if running. + + Args: + handle: I2C handle + + Returns: + Status.OK + """ + if not handle._is_open: + return Status.OK + + # Stop logger first + if handle._logger_running: + i2c_stop_logger(handle) + + # Close bus + with handle._lock: + if handle._bus: + try: + handle._bus.close() + except: + pass + handle._bus = None + + handle._buffer = None + handle._is_open = False + + return Status.OK + + +# ============================================================================= +# Device Scanning +# ============================================================================= + +def i2c_scan_devices(handle: I2CHandle) -> Tuple[Status, List[int]]: + """ + Scan bus for devices (like i2cdetect). + + Probes addresses 0x03 to 0x77 (valid 7-bit range). + + Args: + handle: I2C handle (must be open) + + Returns: + (Status.OK, [addresses]) on success + (Status.NOT_OPEN, []) if bus not open + """ + if not handle._is_open or not handle._bus: + return (Status.NOT_OPEN, []) + + devices = [] + + with handle._lock: + for addr in range(0x03, 0x78): # Valid 7-bit address range + try: + # Quick write to probe device + handle._bus.write_quick(addr) + devices.append(addr) + except OSError: + # Device not present (NACK) + continue + except Exception: + # Other error - skip + continue + + return (Status.OK, devices) + + +# ============================================================================= +# Read Operations +# ============================================================================= + +def i2c_read_byte(handle: I2CHandle, addr: int, reg: int) -> Tuple[Status, int]: + """ + Read single byte from register. + + Args: + handle: I2C handle + addr: Device address (7-bit) + reg: Register address + + Returns: + (Status.OK, value) on success + (Status.NOT_OPEN, 0) if bus not open + (Status.NACK, 0) if device did not respond + (Status.ERROR, 0) on other error + """ + if not handle._is_open or not handle._bus: + return (Status.NOT_OPEN, 0) + + try: + with handle._lock: + value = handle._bus.read_byte_data(addr, reg) + handle._total_reads += 1 + + return (Status.OK, value) + + except OSError as e: + with handle._lock: + handle._total_errors += 1 + # errno 121 = Remote I/O error (NACK) + if e.errno == 121: + return (Status.NACK, 0) + return (Status.ERROR, 0) + + except Exception: + with handle._lock: + handle._total_errors += 1 + return (Status.ERROR, 0) + + +def i2c_read_block(handle: I2CHandle, addr: int, reg: int, length: int) -> Tuple[Status, bytes]: + """ + Read block of bytes from register. + + Args: + handle: I2C handle + addr: Device address (7-bit) + reg: Register address + length: Number of bytes to read (1-32) + + Returns: + (Status.OK, data) on success + (Status.NOT_OPEN, b"") if bus not open + (Status.NACK, b"") if device did not respond + (Status.ERROR, b"") on other error + """ + if not handle._is_open or not handle._bus: + return (Status.NOT_OPEN, b"") + + if length < 1 or length > 32: + return (Status.ERROR, b"") + + try: + with handle._lock: + data = handle._bus.read_i2c_block_data(addr, reg, length) + handle._total_reads += 1 + + return (Status.OK, bytes(data)) + + except OSError as e: + with handle._lock: + handle._total_errors += 1 + if e.errno == 121: + return (Status.NACK, b"") + return (Status.ERROR, b"") + + except Exception: + with handle._lock: + handle._total_errors += 1 + return (Status.ERROR, b"") + + +# ============================================================================= +# Write Operations +# ============================================================================= + +def i2c_write_byte(handle: I2CHandle, addr: int, reg: int, value: int) -> Status: + """ + Write single byte to register. + + Args: + handle: I2C handle + addr: Device address (7-bit) + reg: Register address + value: Byte value (0-255) + + Returns: + Status.OK on success + Status.NOT_OPEN if bus not open + Status.NACK if device did not respond + Status.ERROR on other error + """ + if not handle._is_open or not handle._bus: + return Status.NOT_OPEN + + if value < 0 or value > 255: + return Status.ERROR + + try: + with handle._lock: + handle._bus.write_byte_data(addr, reg, value) + handle._total_writes += 1 + + return Status.OK + + except OSError as e: + with handle._lock: + handle._total_errors += 1 + if e.errno == 121: + return Status.NACK + return Status.ERROR + + except Exception: + with handle._lock: + handle._total_errors += 1 + return Status.ERROR + + +def i2c_write_block(handle: I2CHandle, addr: int, reg: int, data: bytes) -> Status: + """ + Write block of bytes to register. + + Args: + handle: I2C handle + addr: Device address (7-bit) + reg: Register address + data: Bytes to write (1-32 bytes) + + Returns: + Status.OK on success + Status.NOT_OPEN if bus not open + Status.NACK if device did not respond + Status.ERROR on other error + """ + if not handle._is_open or not handle._bus: + return Status.NOT_OPEN + + if len(data) < 1 or len(data) > 32: + return Status.ERROR + + try: + with handle._lock: + handle._bus.write_i2c_block_data(addr, reg, list(data)) + handle._total_writes += 1 + + return Status.OK + + except OSError as e: + with handle._lock: + handle._total_errors += 1 + if e.errno == 121: + return Status.NACK + return Status.ERROR + + except Exception: + with handle._lock: + handle._total_errors += 1 + return Status.ERROR + + +# ============================================================================= +# Logger (Background Polling) +# ============================================================================= + +def i2c_start_logger(handle: I2CHandle, addr: int, reg: int, length: int, + interval_ms: int) -> Status: + """ + Start background logger thread. + + Continuously polls register at specified rate and stores in buffer. + + Args: + handle: I2C handle + addr: Device address (7-bit) + reg: Register address + length: Bytes to read (1-32) + interval_ms: Polling interval in milliseconds + + Returns: + Status.OK on success + Status.NOT_OPEN if bus not open + Status.LOGGER_RUNNING if already running + """ + if not handle._is_open: + return Status.NOT_OPEN + + if handle._logger_running: + return Status.LOGGER_RUNNING + + if length < 1 or length > 32 or interval_ms < 1: + return Status.ERROR + + # Configure logger + handle._logger_addr = addr + handle._logger_reg = reg + handle._logger_len = length + handle._logger_interval_ms = interval_ms + + # Start thread + handle._stop_logger_event.clear() + handle._logger_running = True + + handle._logger_thread = threading.Thread( + target=_logger_thread_func, + args=(handle,), + daemon=True, + name=f"I2C-Logger-{handle.config.bus_id}" + ) + handle._logger_thread.start() + + return Status.OK + + +def i2c_stop_logger(handle: I2CHandle) -> Status: + """ + Stop background logger thread. + + Blocks until thread exits (with timeout). + + Args: + handle: I2C handle + + Returns: + Status.OK + """ + if not handle._logger_running: + return Status.OK + + handle._stop_logger_event.set() + + if handle._logger_thread: + handle._logger_thread.join(timeout=2.0) + handle._logger_thread = None + + handle._logger_running = False + + return Status.OK + + +def _logger_thread_func(handle: I2CHandle): + """ + Logger thread implementation. + + Polls register at configured interval and stores readings in buffer. + """ + addr = handle._logger_addr + reg = handle._logger_reg + length = handle._logger_len + interval_s = handle._logger_interval_ms / 1000.0 + + while not handle._stop_logger_event.is_set(): + try: + # Read from device + with handle._lock: + if not handle._bus: + break + + data = handle._bus.read_i2c_block_data(addr, reg, length) + timestamp = handle._get_timestamp() + handle._total_reads += 1 + handle._reading_counter += 1 + reading_id = handle._reading_counter + + # Create reading record + reading = I2CReading( + reading_id=reading_id, + timestamp=timestamp, + address=addr, + register=reg, + data=bytes(data), + operation="READ" + ) + + # Store in buffer (serialize reading to bytes) + # Format: [timestamp(8)][addr(1)][reg(1)][len(1)][data(N)] + buf_data = ( + int(timestamp * 1_000_000).to_bytes(8, 'little') + + addr.to_bytes(1, 'little') + + reg.to_bytes(1, 'little') + + len(data).to_bytes(1, 'little') + + bytes(data) + ) + cb_write(handle._buffer, buf_data) + + except Exception: + with handle._lock: + handle._total_errors += 1 + + # Sleep for interval (check stop event periodically) + handle._stop_logger_event.wait(interval_s) + + +# ============================================================================= +# Status Query +# ============================================================================= + +def i2c_get_status(handle: I2CHandle) -> BusInfo: + """ + Get current bus status. + + Args: + handle: I2C handle + + Returns: + BusInfo with current metrics + """ + if not handle._buffer: + return BusInfo( + is_open=handle._is_open, + is_logger_running=handle._logger_running, + buffer_capacity=0, + buffer_fill_bytes=0, + buffer_fill_percent=0, + buffer_overflows=0, + total_reads=0, + total_writes=0, + total_errors=0 + ) + + with handle._lock: + total_reads = handle._total_reads + total_writes = handle._total_writes + total_errors = handle._total_errors + + return BusInfo( + is_open=handle._is_open, + is_logger_running=handle._logger_running, + buffer_capacity=cb_capacity(handle._buffer), + buffer_fill_bytes=cb_fill_bytes(handle._buffer), + buffer_fill_percent=cb_fill_pct(handle._buffer), + buffer_overflows=cb_overflows(handle._buffer), + total_reads=total_reads, + total_writes=total_writes, + total_errors=total_errors + ) diff --git a/i2c/i2c_kit/i2c_core_widget.py b/i2c/i2c_kit/i2c_core_widget.py new file mode 100644 index 0000000..81f49a2 --- /dev/null +++ b/i2c/i2c_kit/i2c_core_widget.py @@ -0,0 +1,870 @@ +#!/usr/bin/env python3 +""" +I2C Widget (PyQt6) +================== +GUI for I2C bus control with manual I/O and continuous logging. + +Features: +- Bus scanning (detect /dev/i2c-*) +- Device scanning (like i2cdetect) +- Manual read/write operations +- Continuous logger (background polling) +- Data monitor (color-coded) +- Buffer status (embedded compact widget) +- Timestamp synchronization ready +- Theme-ready (global theme controlled) + +Author: Kynsight +Version: 1.0.0 +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QLabel, QComboBox, QPushButton, QLineEdit, + QGroupBox, QTextEdit, QSpinBox +) +from PyQt6.QtCore import Qt, QTimer, pyqtSignal +from PyQt6.QtGui import QFont + +# Import I2C core +from i2c.i2c_kit.i2c_core import * +from buffer_kit.buffer_widget_compact import CompactBufferWidget + + +class I2CWidget(QWidget): + """ + I2C bus control widget. + + Signals: + reading_received: Emitted when logger reading received + connection_changed: Emitted on connect/disconnect + """ + + # Qt signals + reading_received = pyqtSignal(object) # I2CReading object + connection_changed = pyqtSignal(bool) # True=connected + + def __init__(self, parent=None): + super().__init__(parent) + + # Handle state + self.handle = None + self.is_connected = False + + # Device list (from scan) + self.detected_devices = [] + + # Reading history + self.reading_history = [] + self.max_reading_history = 200 + + # Overflow tracking (for Data Monitor warnings) + self._last_overflow_count = 0 + + # Build UI + self._init_ui() + self._setup_timers() + + def _init_ui(self): + """Create user interface.""" + layout = QVBoxLayout() + self.setLayout(layout) + + # Configuration section (compact, horizontal) + config_group = self._create_config_section() + layout.addWidget(config_group) + + # Status + Buffer on same line + status_buffer_layout = QHBoxLayout() + + # Status label (left) + self.lbl_status = QLabel("Status: Disconnected") + status_buffer_layout.addWidget(self.lbl_status, stretch=2) + + # Buffer status - compact on right + buffer_group = self._create_buffer_section() + status_buffer_layout.addWidget(buffer_group, stretch=1) + + layout.addLayout(status_buffer_layout) + + # Device scanner + device_group = self._create_device_section() + layout.addWidget(device_group) + + # Data monitor (shows everything: reads, writes, errors, info) + display_group = self._create_display_section() + layout.addWidget(display_group) + + # Manual I/O panel + io_group = self._create_io_section() + layout.addWidget(io_group) + + # Logger panel + logger_group = self._create_logger_section() + layout.addWidget(logger_group) + + self.setMinimumWidth(800) + + # ========================================================================= + # UI Creation + # ========================================================================= + + def _create_config_section(self): + """Bus configuration controls - compact horizontal.""" + group = QGroupBox("I2C Configuration") + layout = QHBoxLayout() + group.setLayout(layout) + + # Bus selection + layout.addWidget(QLabel("Bus:")) + self.combo_bus = QComboBox() + self.combo_bus.setMinimumWidth(80) + layout.addWidget(self.combo_bus) + + # Refresh buses button + self.btn_refresh_bus = QPushButton("πŸ”„ Scan") + self.btn_refresh_bus.clicked.connect(self._refresh_buses) + layout.addWidget(self.btn_refresh_bus) + + layout.addSpacing(20) + + # Connect/Disconnect + self.btn_connect = QPushButton("Connect") + self.btn_connect.clicked.connect(self._on_connect) + layout.addWidget(self.btn_connect) + + self.btn_disconnect = QPushButton("Disconnect") + self.btn_disconnect.clicked.connect(self._on_disconnect) + self.btn_disconnect.setEnabled(False) + layout.addWidget(self.btn_disconnect) + + layout.addStretch() + + return group + + def _create_buffer_section(self): + """Buffer status display.""" + group = QGroupBox("Buffer Status") + layout = QVBoxLayout() + group.setLayout(layout) + + # Placeholder (replaced with CompactBufferWidget when connected) + self.buffer_widget = QLabel("Not connected") + layout.addWidget(self.buffer_widget) + + # Clear buffer button (only visible when connected) + self.btn_clear_buffer = QPushButton("Clear Buffer") + self.btn_clear_buffer.clicked.connect(self._on_clear_buffer) + self.btn_clear_buffer.setEnabled(False) + self.btn_clear_buffer.setMaximumHeight(25) + layout.addWidget(self.btn_clear_buffer) + + return group + + def _create_device_section(self): + """Device scanner section.""" + group = QGroupBox("Devices") + layout = QHBoxLayout() + group.setLayout(layout) + + layout.addWidget(QLabel("Found:")) + + # Device dropdown (populated after scan) + self.combo_devices = QComboBox() + self.combo_devices.setMinimumWidth(150) + self.combo_devices.currentIndexChanged.connect(self._on_device_selected) + layout.addWidget(self.combo_devices) + + # Scan button + self.btn_scan_devices = QPushButton("Scan Bus") + self.btn_scan_devices.clicked.connect(self._scan_devices) + self.btn_scan_devices.setEnabled(False) + layout.addWidget(self.btn_scan_devices) + + layout.addStretch() + + return group + + def _create_display_section(self): + """Data monitor - shows all activity.""" + group = QGroupBox("Data Monitor (Read=Blue, Write=Green, Error=Red, Info=Gray)") + layout = QVBoxLayout() + group.setLayout(layout) + + # Display area FIRST (takes most vertical space) + self.text_display = QTextEdit() + self.text_display.setReadOnly(True) + self.text_display.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) # Enable word wrap + font = QFont("Courier New", 9) + self.text_display.setFont(font) + layout.addWidget(self.text_display) + + # Controls at BOTTOM (single line, compact) + ctrl_layout = QHBoxLayout() + + ctrl_layout.addWidget(QLabel("History:")) + self.spin_history = QSpinBox() + self.spin_history.setRange(10, 500) + self.spin_history.setValue(200) + self.spin_history.valueChanged.connect(self._on_history_changed) + self.spin_history.setMaximumWidth(70) + ctrl_layout.addWidget(self.spin_history) + + self.btn_clear_display = QPushButton("Clear") + self.btn_clear_display.setMaximumWidth(80) + self.btn_clear_display.clicked.connect(self._clear_display) + ctrl_layout.addWidget(self.btn_clear_display) + + ctrl_layout.addStretch() + + layout.addLayout(ctrl_layout) + + return group + + def _create_io_section(self): + """Manual read/write panel.""" + group = QGroupBox("Manual I/O") + layout = QHBoxLayout() + group.setLayout(layout) + + # Device address + layout.addWidget(QLabel("Device:")) + self.edit_io_addr = QLineEdit("40") + self.edit_io_addr.setMaximumWidth(60) + layout.addWidget(self.edit_io_addr) + + # Register address + layout.addWidget(QLabel("Reg:")) + self.edit_io_reg = QLineEdit("0C") + self.edit_io_reg.setMaximumWidth(60) + self.edit_io_reg.returnPressed.connect(self._on_read) # Enter key reads + layout.addWidget(self.edit_io_reg) + + # Length (for read) + layout.addWidget(QLabel("Length:")) + self.spin_io_length = QSpinBox() + self.spin_io_length.setRange(1, 32) + self.spin_io_length.setValue(2) + self.spin_io_length.setMaximumWidth(60) + layout.addWidget(self.spin_io_length) + + # Read button + self.btn_read = QPushButton("Read") + self.btn_read.clicked.connect(self._on_read) + self.btn_read.setEnabled(False) + layout.addWidget(self.btn_read) + + # Write data + layout.addWidget(QLabel("Write:")) + self.edit_write_data = QLineEdit() + self.edit_write_data.setPlaceholderText("FF or FF AA BB") + self.edit_write_data.setMaximumWidth(150) + self.edit_write_data.returnPressed.connect(self._on_write) # Enter key writes + layout.addWidget(self.edit_write_data) + + # Write button + self.btn_write = QPushButton("Write") + self.btn_write.clicked.connect(self._on_write) + self.btn_write.setEnabled(False) + layout.addWidget(self.btn_write) + + # Format selector + self.combo_format = QComboBox() + self.combo_format.addItems(["Hex"]) + self.combo_format.setMaximumWidth(80) + layout.addWidget(self.combo_format) + + layout.addStretch() + + return group + + def _create_logger_section(self): + """Logger controls.""" + group = QGroupBox("Logger (Continuous Polling)") + layout = QHBoxLayout() + group.setLayout(layout) + + # Device + layout.addWidget(QLabel("Device:")) + self.edit_logger_addr = QLineEdit("40") + self.edit_logger_addr.setMaximumWidth(60) + layout.addWidget(self.edit_logger_addr) + + # Register + layout.addWidget(QLabel("Reg:")) + self.edit_logger_reg = QLineEdit("FE") + self.edit_logger_reg.setMaximumWidth(60) + layout.addWidget(self.edit_logger_reg) + + # Length + layout.addWidget(QLabel("Len:")) + self.spin_logger_length = QSpinBox() + self.spin_logger_length.setRange(1, 32) + self.spin_logger_length.setValue(2) + self.spin_logger_length.setMaximumWidth(60) + layout.addWidget(self.spin_logger_length) + + # Polling rate + layout.addWidget(QLabel("Rate:")) + self.combo_logger_rate = QComboBox() + self.combo_logger_rate.addItems([ + "1000 Hz (1ms)", + "500 Hz (2ms)", + "200 Hz (5ms)", + "100 Hz (10ms)", + "50 Hz (20ms)", + "20 Hz (50ms)", + "10 Hz (100ms)", + "5 Hz (200ms)", + "1 Hz (1000ms)" + ]) + self.combo_logger_rate.setCurrentText("100 Hz (10ms)") + self.combo_logger_rate.setMinimumWidth(120) + layout.addWidget(self.combo_logger_rate) + + # Start/Stop + self.btn_logger_start = QPushButton("Start Logger") + self.btn_logger_start.clicked.connect(self._on_logger_start) + self.btn_logger_start.setEnabled(False) + layout.addWidget(self.btn_logger_start) + + self.btn_logger_stop = QPushButton("Stop Logger") + self.btn_logger_stop.clicked.connect(self._on_logger_stop) + self.btn_logger_stop.setEnabled(False) + layout.addWidget(self.btn_logger_stop) + + layout.addStretch() + + return group + + def _setup_timers(self): + """Setup update timers.""" + # Buffer update timer + self.buffer_timer = QTimer() + self.buffer_timer.timeout.connect(self._update_buffer_display) + self.buffer_timer.setInterval(100) # 100ms + + # ========================================================================= + # Bus Management + # ========================================================================= + + def _refresh_buses(self): + """Scan for available I2C buses.""" + self.combo_bus.clear() + + buses = i2c_scan_buses() + + if buses: + for bus_id in buses: + self.combo_bus.addItem(f"/dev/i2c-{bus_id}", bus_id) + self._log_info(f"Found {len(buses)} I2C bus(es): {buses}") + else: + self.combo_bus.addItem("No buses found") + self._log_warning("No I2C buses found") + + def _on_connect(self): + """Connect to I2C bus.""" + if self.combo_bus.count() == 0: + self._show_error("No bus selected") + return + + bus_id = self.combo_bus.currentData() + if bus_id is None: + self._show_error("Invalid bus selection") + return + + self._log_info(f"Connecting to I2C bus {bus_id}...") + + # Create I2C handle + config = I2CConfig(bus_id=bus_id) + status, self.handle = i2c_create(config) + + if status != Status.OK: + self._show_error(f"Failed to create handle: {status}") + return + + # Open bus + status = i2c_open(self.handle) + + if status == Status.BUS_NOT_FOUND: + self._show_error(f"Bus /dev/i2c-{bus_id} not found") + return + elif status != Status.OK: + self._show_error(f"Failed to open bus: {status}") + return + + # Update buffer widget + self._update_buffer_widget() + + # Update UI state + self.is_connected = True + self._last_overflow_count = 0 # Reset overflow tracking + self._update_ui_state() + self.lbl_status.setText(f"Status: ● Connected to bus {bus_id}") + self.lbl_status.setStyleSheet("color: green;") + + self._log_info(f"βœ“ Connected to I2C bus {bus_id}") + + # Start buffer update timer + self.buffer_timer.start() + + # Auto-scan for devices + self._log_info("Auto-scanning for devices...") + QTimer.singleShot(100, self._scan_devices) # Slight delay for UI update + + self.connection_changed.emit(True) + + def _on_disconnect(self): + """Disconnect from I2C bus.""" + self._log_info("Disconnecting...") + + if not self.handle: + return + + # Stop logger if running + if self.handle._logger_running: + i2c_stop_logger(self.handle) + + # Close bus + i2c_close(self.handle) + + self.handle = None + self.is_connected = False + + # Stop buffer timer + self.buffer_timer.stop() + + # Update UI + self._update_ui_state() + self.lbl_status.setText("Status: Disconnected") + self.lbl_status.setStyleSheet("") + + self._log_info("βœ“ Disconnected") + + # Replace buffer widget with placeholder + if isinstance(self.buffer_widget, CompactBufferWidget): + self.buffer_widget.setParent(None) + self.buffer_widget = QLabel("Not connected") + status_buffer_layout = self.layout().itemAt(1) + buffer_group_widget = status_buffer_layout.itemAt(1).widget() + buffer_group_widget.layout().addWidget(self.buffer_widget) + + self.connection_changed.emit(False) + + # ========================================================================= + # Device Scanning + # ========================================================================= + + def _scan_devices(self): + """Scan bus for devices.""" + if not self.handle: + return + + self._log_info("Scanning for devices...") + + status, devices = i2c_scan_devices(self.handle) + + if status != Status.OK: + self._show_error("Device scan failed") + return + + # Update device list + self.detected_devices = devices + self.combo_devices.clear() + + if devices: + for addr in devices: + self.combo_devices.addItem(f"{addr:02X}", addr) + + self._log_info(f"Found {len(devices)} device(s): {[f'{a:02X}' for a in devices]}") + + # First device will be auto-selected, triggering _on_device_selected + else: + self.combo_devices.addItem("No devices found") + self._log_warning("No devices found on bus") + + def _on_device_selected(self, index): + """Handle device selection from dropdown.""" + if index < 0: + return + + addr = self.combo_devices.currentData() + if addr is None: + return + + # Update I/O and Logger device fields + addr_str = f"{addr:02X}" + self.edit_io_addr.setText(addr_str) + self.edit_logger_addr.setText(addr_str) + + self._log_info(f"Selected device: {addr_str}") + + # ========================================================================= + # Manual I/O + # ========================================================================= + + def _parse_device_address(self, text: str) -> Tuple[bool, int, str]: + """Parse device address (7-bit I2C: 0x03-0x77).""" + try: + text = text.strip() + if text.startswith("0x") or text.startswith("0X"): + addr = int(text, 16) + else: + addr = int(text, 16) # Assume hex + + if addr < 0x03 or addr > 0x77: + return (False, 0, "Device address out of range (03-77)") + + return (True, addr, "") + except: + return (False, 0, "Invalid device address format") + + def _parse_register_address(self, text: str) -> Tuple[bool, int, str]: + """Parse register address (8-bit: 0x00-0xFF).""" + try: + text = text.strip() + if text.startswith("0x") or text.startswith("0X"): + addr = int(text, 16) + else: + addr = int(text, 16) # Assume hex + + if addr < 0x00 or addr > 0xFF: + return (False, 0, "Register address out of range (00-FF)") + + return (True, addr, "") + except: + return (False, 0, "Invalid register address format") + + def _parse_hex_bytes(self, text: str) -> Tuple[bool, bytes, str]: + """Parse hex bytes (space or comma separated).""" + try: + text = text.strip().replace(",", " ") + tokens = text.split() + + data = [] + for token in tokens: + if token.startswith("0x"): + data.append(int(token, 16)) + else: + data.append(int(token, 16)) + + if not data: + return (False, b"", "Empty data") + + if len(data) > 32: + return (False, b"", f"Too many bytes (max 32, got {len(data)})") + + return (True, bytes(data), "") + except: + return (False, b"", "Invalid hex format (use: FF or FF AA BB)") + + def _on_read(self): + """Read from device.""" + if not self.handle: + return + + # Parse device address + success, addr, error = self._parse_device_address(self.edit_io_addr.text()) + if not success: + self._show_error(error) + return + + # Parse register address + success, reg, error = self._parse_register_address(self.edit_io_reg.text()) + if not success: + self._show_error(f"Register: {error}") + return + + length = self.spin_io_length.value() + + # Log TX + self._log_info(f"Reading: Addr={addr:02X} Reg={reg:02X} Len={length}") + + # Read + if length == 1: + status, value = i2c_read_byte(self.handle, addr, reg) + if status == Status.OK: + data = bytes([value]) + else: + data = b"" + else: + status, data = i2c_read_block(self.handle, addr, reg, length) + + # Handle result + if status == Status.OK: + hex_str = " ".join(f"{b:02X}" for b in data) + self._log_rx(data, f"{addr:02X}:{reg:02X}") + self.lbl_status.setText(f"βœ“ Read {len(data)} bytes") + self.lbl_status.setStyleSheet("color: green;") + elif status == Status.NACK: + self._show_error(f"Device {addr:02X} did not respond (NACK)") + else: + self._show_error(f"Read failed: {status}") + + def _on_write(self): + """Write to device.""" + if not self.handle: + return + + # Parse device address + success, addr, error = self._parse_device_address(self.edit_io_addr.text()) + if not success: + self._show_error(error) + return + + # Parse register address + success, reg, error = self._parse_register_address(self.edit_io_reg.text()) + if not success: + self._show_error(f"Register: {error}") + return + + # Parse data + success, data, error = self._parse_hex_bytes(self.edit_write_data.text()) + if not success: + self._show_error(error) + return + + # Log TX + self._log_tx(data, f"{addr:02X}:{reg:02X}") + + # Write + if len(data) == 1: + status = i2c_write_byte(self.handle, addr, reg, data[0]) + else: + status = i2c_write_block(self.handle, addr, reg, data) + + # Handle result + if status == Status.OK: + self.edit_write_data.clear() + self.lbl_status.setText(f"βœ“ Wrote {len(data)} bytes") + self.lbl_status.setStyleSheet("color: green;") + self._log_info(f"Write successful: {addr:02X}:{reg:02X}") + elif status == Status.NACK: + self._show_error(f"Device {addr:02X} did not respond (NACK)") + else: + self._show_error(f"Write failed: {status}") + + # ========================================================================= + # Logger + # ========================================================================= + + def _parse_logger_rate(self, text: str) -> int: + """Parse logger rate string to interval_ms.""" + # "100 Hz (10ms)" -> 10 + try: + parts = text.split("(")[1].split(")")[0] # Extract "(10ms)" + return int(parts.replace("ms", "")) + except: + return 10 # Default 10ms + + def _on_logger_start(self): + """Start logger.""" + if not self.handle: + return + + # Parse device address + success, addr, error = self._parse_device_address(self.edit_logger_addr.text()) + if not success: + self._show_error(error) + return + + # Parse register address + success, reg, error = self._parse_register_address(self.edit_logger_reg.text()) + if not success: + self._show_error(f"Register: {error}") + return + + length = self.spin_logger_length.value() + interval_ms = self._parse_logger_rate(self.combo_logger_rate.currentText()) + + # Start logger + status = i2c_start_logger(self.handle, addr, reg, length, interval_ms) + + if status == Status.OK: + self._log_info(f"βœ“ Logger started: {addr:02X}:{reg:02X} [{length}B] @ {1000/interval_ms:.0f}Hz") + self.lbl_status.setText("Logger running") + self.lbl_status.setStyleSheet("color: blue;") + self._update_ui_state() + elif status == Status.LOGGER_RUNNING: + self._show_error("Logger already running") + else: + self._show_error(f"Failed to start logger: {status}") + + def _on_logger_stop(self): + """Stop logger.""" + if not self.handle: + return + + i2c_stop_logger(self.handle) + + self._log_info("Logger stopped") + self.lbl_status.setText("Logger stopped") + self.lbl_status.setStyleSheet("") + self._update_ui_state() + + # ========================================================================= + # UI State Management + # ========================================================================= + + def _update_ui_state(self): + """Enable/disable controls based on state.""" + connected = self.is_connected + logger_running = self.handle._logger_running if self.handle else False + + # Connection controls + self.combo_bus.setEnabled(not connected) + self.btn_refresh_bus.setEnabled(not connected) + self.btn_connect.setEnabled(not connected) + self.btn_disconnect.setEnabled(connected) + + # Device scan + self.btn_scan_devices.setEnabled(connected and not logger_running) + + # Manual I/O + self.btn_read.setEnabled(connected and not logger_running) + self.btn_write.setEnabled(connected and not logger_running) + + # Logger + self.btn_logger_start.setEnabled(connected and not logger_running) + self.btn_logger_stop.setEnabled(connected and logger_running) + + # Clear buffer button (enabled when connected and logger not running) + self.btn_clear_buffer.setEnabled(connected and not logger_running) + + # Disable logger config when running + self.edit_logger_addr.setEnabled(not logger_running) + self.edit_logger_reg.setEnabled(not logger_running) + self.spin_logger_length.setEnabled(not logger_running) + self.combo_logger_rate.setEnabled(not logger_running) + + def _update_buffer_widget(self): + """Replace placeholder with actual buffer widget.""" + if not self.handle or not isinstance(self.buffer_widget, QLabel): + return + + # Remove old label + self.buffer_widget.setParent(None) + + # Create buffer widget + self.buffer_widget = CompactBufferWidget(self.handle._buffer) + + # Add to layout + status_buffer_layout = self.layout().itemAt(1) + buffer_group_widget = status_buffer_layout.itemAt(1).widget() + buffer_group_widget.layout().addWidget(self.buffer_widget) + + def _update_buffer_display(self): + """Update buffer widget periodically and check for overflows.""" + if isinstance(self.buffer_widget, CompactBufferWidget): + # Get current overflow count + from buffer_kit.circular_buffer import cb_overflows + overflow_count = cb_overflows(self.handle._buffer) + + # Check if overflow increased + if overflow_count > self._last_overflow_count: + overflow_delta = overflow_count - self._last_overflow_count + # Display RED warning in Data Monitor + overflow_warning = ( + f"" + f"[WARNING] ⚠ BUFFER OVERFLOW! Lost data. " + f"Overflow count: {overflow_count} (+{overflow_delta})" + f"" + ) + self.text_display.append(overflow_warning) + self._last_overflow_count = overflow_count + + # Update buffer widget display + self.buffer_widget.update_display() + + # ========================================================================= + # Display + # ========================================================================= + + def _clear_display(self): + """Clear data monitor.""" + self.text_display.clear() + self.reading_history.clear() + + def _on_history_changed(self, value): + """Update maximum reading history size.""" + self.max_reading_history = value + + def _on_clear_buffer(self): + """Clear the circular buffer and reset overflow counter.""" + if not self.handle or not self.handle._buffer: + return + + from buffer_kit.circular_buffer import cb_reset + + # Reset buffer (clears data and overflow count) + cb_reset(self.handle._buffer) + + # Reset our overflow tracking + self._last_overflow_count = 0 + + # Update buffer display + if isinstance(self.buffer_widget, CompactBufferWidget): + self.buffer_widget.update_display() + + # Log action + self._log_info("Buffer cleared (data and overflow count reset)") + self.lbl_status.setText("βœ“ Buffer cleared") + self.lbl_status.setStyleSheet("color: green;") + + def _log_info(self, message: str): + """Log info message (gray).""" + self.text_display.append(f"[INFO] {message}") + + def _log_warning(self, message: str): + """Log warning (orange).""" + self.text_display.append(f"[WARNING] {message}") + + def _show_error(self, message: str): + """Show error (red).""" + self.lbl_status.setText(f"Error: {message}") + self.lbl_status.setStyleSheet("color: red;") + self.text_display.append(f"[ERROR] {message}") + + def _log_tx(self, data: bytes, info: str = ""): + """Log write operation (green) - full data, no truncation.""" + hex_str = " ".join(f"{b:02X}" for b in data) + prefix = f"[WRITE] {info} " if info else "[WRITE] " + self.text_display.append(f"{prefix}{len(data)}B: {hex_str}") + + def _log_rx(self, data: bytes, info: str = ""): + """Log read operation (blue) - full data, no truncation.""" + hex_str = " ".join(f"{b:02X}" for b in data) + prefix = f"[READ] {info} " if info else "[READ] " + self.text_display.append(f"{prefix}{len(data)}B: {hex_str}") + + # ========================================================================= + # Public API + # ========================================================================= + + def get_handle(self): + """Get current I2C handle.""" + return self.handle + + def is_bus_connected(self) -> bool: + """Check if bus is connected.""" + return self.is_connected + + +# ============================================================================= +# Demo +# ============================================================================= + +if __name__ == "__main__": + import sys + from PyQt6.QtWidgets import QApplication + + app = QApplication(sys.argv) + + widget = I2CWidget() + widget.setWindowTitle("I2C Control") + widget.show() + + # Auto-refresh buses on startup + widget._refresh_buses() + + sys.exit(app.exec()) diff --git a/main.py b/main.py new file mode 100644 index 0000000..840cf23 --- /dev/null +++ b/main.py @@ -0,0 +1,643 @@ +#!/usr/bin/env python3 +""" +vzug-e-hinge Main Application +============================== +Entry point for the integrated test and control system. + +Integrates: +- Session management +- UART control +- I2C control +- UART Logger +- I2C Logger +- Graph visualization +- Test profile execution + +Author: Kynsight +Version: 1.0.0 +""" + +import sys +import os +from pathlib import Path + +# Force X11 backend for Qt (Wayland workaround) +# os.environ['QT_QPA_PLATFORM'] = 'xcb' + +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QTabWidget, QWidget, QVBoxLayout, + QHBoxLayout, QLabel, QMenuBar, QMenu, QMessageBox, QStatusBar +) +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QAction + +# Import database +from database.init_database import DatabaseManager + +# Import widgets (will be created next) +# from session_widget import SessionWidget +# from uart_widget import UARTWidget +# from uart_logger_widget import UARTLoggerWidget +# from i2c_widget import I2CWidget +# from i2c_logger_widget import I2CLoggerWidget +# from graph_widget import GraphWidget + +# Import session manager (will be created next) +# from session_manager import SessionManager + + +class MainWindow(QMainWindow): + """ + Main application window with tabbed interface. + + Tabs: + - Session: Control panel, command execution, profile management + - UART: Direct UART control + - UART Logger: UART logging interface + - I2C: Direct I2C control + - I2C Logger: I2C logging interface + - Graph: Data visualization + """ + + def __init__(self, db_path: str = "./database/ehinge.db"): + super().__init__() + + self.db_path = db_path + self.db_manager = None + + # Initialize database + self._init_database() + + # Setup UI + self._init_ui() + + # Setup session manager + self._init_session_manager() + + # Setup connections + self._setup_connections() + + # Status update timer + self.status_timer = QTimer() + self.status_timer.timeout.connect(self._update_status) + self.status_timer.start(1000) # Update every second + + def _init_database(self): + """Initialize database connection.""" + self.db_manager = DatabaseManager(self.db_path) + + # Check if database exists + if not os.path.exists(self.db_path): + reply = QMessageBox.question( + self, + "Database Not Found", + f"Database not found at:\n{self.db_path}\n\nCreate new database?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.Yes: + success = self.db_manager.initialize_database() + if not success: + QMessageBox.critical( + self, + "Database Error", + "Failed to create database. Application cannot continue." + ) + sys.exit(1) + else: + sys.exit(0) + else: + # Connect to existing database + if not self.db_manager.connect(): + QMessageBox.critical( + self, + "Database Error", + "Failed to connect to database. Application cannot continue." + ) + sys.exit(1) + + def _init_ui(self): + """Initialize user interface.""" + self.setWindowTitle("vzug-e-hinge Test & Control System") + self.setGeometry(100, 100, 1400, 900) + + # Central widget with tabs + central_widget = QWidget() + self.setCentralWidget(central_widget) + + layout = QVBoxLayout() + central_widget.setLayout(layout) + + # Session info bar (shows current session) + self.session_info_bar = self._create_session_info_bar() + layout.addWidget(self.session_info_bar) + + # Tab widget + self.tabs = QTabWidget() + layout.addWidget(self.tabs) + + # Create placeholder tabs (will be replaced with actual widgets) + self._create_tabs() + + # Connect tab change to refresh profiles + self.tabs.currentChanged.connect(self._on_tab_changed) + + # Menu bar + self._create_menu_bar() + + # Status bar + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self.status_bar.showMessage("Ready") + + def _create_session_info_bar(self) -> QWidget: + """Create session information bar at top.""" + widget = QWidget() + widget.setStyleSheet("background-color: #1e1e1e; padding: 5px;") + + layout = QHBoxLayout() + widget.setLayout(layout) + + # Session info + self.lbl_session_name = QLabel("Session: None") + self.lbl_session_name.setStyleSheet("color: #ffffff; font-weight: bold;") + layout.addWidget(self.lbl_session_name) + + layout.addStretch() + + # Status indicator + self.lbl_session_status = QLabel("Status: Idle") + self.lbl_session_status.setStyleSheet("color: #00ff00;") + layout.addWidget(self.lbl_session_status) + + # Run counter + self.lbl_run_count = QLabel("Runs: 0") + self.lbl_run_count.setStyleSheet("color: #ffffff;") + layout.addWidget(self.lbl_run_count) + + # Database size + self.lbl_db_size = QLabel("DB: 0%") + self.lbl_db_size.setStyleSheet("color: #00ff00;") + layout.addWidget(self.lbl_db_size) + + return widget + + def _create_tabs(self): + """Create tab widgets.""" + # Tab 1: Session Control (integrate SessionWidget) + try: + from session_widget import SessionWidget + self.session_widget = SessionWidget(self.db_manager) + self.tabs.addTab(self.session_widget, "Session") + except ImportError as e: + # Fallback to placeholder if SessionWidget not available + print(f"Warning: SessionWidget not available: {e}") + self.tab_session = self._create_placeholder_tab("Session Control") + self.tabs.addTab(self.tab_session, "Session") + self.session_widget = None + + # Tab 2: Configure Session Profiles + try: + from configure_session_widget import ConfigureSessionWidget + self.configure_session_widget = ConfigureSessionWidget(self.db_manager) + self.tabs.addTab(self.configure_session_widget, "Configure Session") + except ImportError as e: + print(f"Warning: ConfigureSessionWidget not available: {e}") + self.tab_configure_session = self._create_placeholder_tab("Configure Session") + self.tabs.addTab(self.tab_configure_session, "Configure Session") + self.configure_session_widget = None + + # Tab 3: Configure Interface Profiles + try: + from configure_interface_widget import ConfigureInterfaceWidget + self.configure_interface_widget = ConfigureInterfaceWidget(self.db_manager) + self.tabs.addTab(self.configure_interface_widget, "Configure Interface") + except ImportError as e: + print(f"Warning: ConfigureInterfaceWidget not available: {e}") + self.tab_configure_interface = self._create_placeholder_tab("Configure Interface") + self.tabs.addTab(self.tab_configure_interface, "Configure Interface") + self.configure_interface_widget = None + + # Tab 4: UART (integrated: table + core) + try: + from uart.uart_integrated_widget import UARTControlWidget + self.uart_widget = UARTControlWidget(self.db_manager.get_connection()) + self.tabs.addTab(self.uart_widget, "UART") + except ImportError as e: + print(f"UART widget import error: {e}") + self.tab_uart = self._create_placeholder_tab("UART Control") + self.tabs.addTab(self.tab_uart, "UART") + self.uart_widget = None + + + # Tab 5: I2C (integrated: table + core) + try: + from i2c.i2c_integrated_widget import I2CControlWidget + self.i2c_widget = I2CControlWidget(self.db_manager.get_connection()) + self.tabs.addTab(self.i2c_widget, "I2C") + except ImportError as e: + print(f"I2C widget import error: {e}") + self.tab_i2c = self._create_placeholder_tab("I2C Control") + self.tabs.addTab(self.tab_i2c, "I2C") + self.i2c_widget = None + + # Tab 6: Graph + self.tab_graph = self._create_placeholder_tab("Graph Visualization") + self.tabs.addTab(self.tab_graph, "Graph") + + def _create_placeholder_tab(self, title: str) -> QWidget: + """Create placeholder tab widget.""" + widget = QWidget() + layout = QVBoxLayout() + widget.setLayout(layout) + + label = QLabel(f"{title}\n\n(Coming soon...)") + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + label.setStyleSheet("font-size: 16pt; color: #888888;") + layout.addWidget(label) + + return widget + + def _create_menu_bar(self): + """Create menu bar.""" + menubar = self.menuBar() + + # File menu + file_menu = menubar.addMenu("&File") + + action_new_session = QAction("&New Session", self) + action_new_session.setShortcut("Ctrl+N") + action_new_session.triggered.connect(self._on_new_session) + file_menu.addAction(action_new_session) + + action_end_session = QAction("&End Session", self) + action_end_session.setShortcut("Ctrl+E") + action_end_session.triggered.connect(self._on_end_session) + file_menu.addAction(action_end_session) + + file_menu.addSeparator() + + action_export_session = QAction("E&xport Session...", self) + action_export_session.triggered.connect(self._on_export_session) + file_menu.addAction(action_export_session) + + file_menu.addSeparator() + + action_exit = QAction("E&xit", self) + action_exit.setShortcut("Ctrl+Q") + action_exit.triggered.connect(self.close) + file_menu.addAction(action_exit) + + # Config menu + config_menu = menubar.addMenu("&Config") + + action_uart_config = QAction("&UART Configuration...", self) + action_uart_config.triggered.connect(self._on_uart_config) + config_menu.addAction(action_uart_config) + + action_i2c_config = QAction("&I2C Configuration...", self) + action_i2c_config.triggered.connect(self._on_i2c_config) + config_menu.addAction(action_i2c_config) + + action_debugger_config = QAction("&Debugger Configuration...", self) + action_debugger_config.triggered.connect(self._on_debugger_config) + config_menu.addAction(action_debugger_config) + + config_menu.addSeparator() + + action_gui_profile = QAction("&GUI Profile...", self) + action_gui_profile.triggered.connect(self._on_gui_profile) + config_menu.addAction(action_gui_profile) + + # Profiles menu + profiles_menu = menubar.addMenu("&Profiles") + + action_manage_profiles = QAction("&Manage Test Profiles...", self) + action_manage_profiles.triggered.connect(self._on_manage_profiles) + profiles_menu.addAction(action_manage_profiles) + + action_new_profile = QAction("&New Profile...", self) + action_new_profile.triggered.connect(self._on_new_profile) + profiles_menu.addAction(action_new_profile) + + # Database menu + database_menu = menubar.addMenu("&Database") + + action_vacuum = QAction("&Vacuum Database", self) + action_vacuum.triggered.connect(self._on_vacuum_database) + database_menu.addAction(action_vacuum) + + action_db_info = QAction("Database &Info...", self) + action_db_info.triggered.connect(self._on_database_info) + database_menu.addAction(action_db_info) + + database_menu.addSeparator() + + action_cleanup = QAction("&Cleanup Old Data...", self) + action_cleanup.triggered.connect(self._on_cleanup_data) + database_menu.addAction(action_cleanup) + + # Help menu + help_menu = menubar.addMenu("&Help") + + action_about = QAction("&About", self) + action_about.triggered.connect(self._on_about) + help_menu.addAction(action_about) + + action_docs = QAction("&Documentation", self) + action_docs.triggered.connect(self._on_documentation) + help_menu.addAction(action_docs) + + def _init_session_manager(self): + """Initialize session manager (coordination logic).""" + # NOTE: Session management is now integrated into SessionWidget + # The Session class is instantiated by SessionWidget internally + # No need for separate SessionManager anymore + self.session_manager = None + + def _setup_connections(self): + """Setup signal/slot connections between components.""" + # NOTE: Session management is now self-contained in SessionWidget + # All signals/slots are handled internally by the widget + # Main window just needs to monitor session state if desired + + if self.session_widget: + # Connect to session widget's session object signals for monitoring + try: + self.session_widget.session.session_started.connect(self._on_session_started_internal) + self.session_widget.session.session_finished.connect(self._on_session_finished_internal) + self.session_widget.session.status_changed.connect(self._on_session_status_changed_internal) + except Exception as e: + print(f"Warning: Could not connect to session signals: {e}") + + # ========================================================================= + # Signal Handlers - Session Widget Actions + # ========================================================================= + + # ========================================================================= + # OLD Signal Handlers - NO LONGER USED (kept for reference) + # Session management is now self-contained in SessionWidget + # ========================================================================= + + # def _on_execute_command_requested(self, command_type: str, command_id: int): + # """Handle execute command request from session widget.""" + # # NO LONGER USED + # pass + + # def _on_execute_profile_requested(self, profile_id: int): + # """Handle execute profile request from session widget.""" + # # NO LONGER USED + # pass + + # def _on_pause_profile_requested(self): + # """Handle pause profile request.""" + # # NO LONGER USED + # pass + + # def _on_resume_profile_requested(self): + # """Handle resume profile request.""" + # # NO LONGER USED + # pass + + # def _on_abort_profile_requested(self): + # """Handle abort profile request.""" + # # NO LONGER USED + # pass + + # def _on_session_created(self, session_id: str): + # """Handle session created.""" + # # NO LONGER USED + # pass + + # def _on_session_ended(self, session_id: str, status: str): + # """Handle session ended.""" + # # NO LONGER USED + # pass + self.lbl_session_status.setText("Status: Idle") + self.lbl_session_status.setStyleSheet("color: #888888;") + + # def _on_run_started(self, session_id: str, run_no: int): + # """Handle run started.""" + # # NO LONGER USED + # pass + + # def _on_run_completed(self, session_id: str, run_no: int, status: str): + # """Handle run completed.""" + # # NO LONGER USED + # pass + + # def _on_profile_step_changed(self, current_repeat: int, current_step: int): + # """Handle profile step changed.""" + # # NO LONGER USED + # pass + + # ========================================================================= + # Internal Session Monitoring (from new Session class) + # ========================================================================= + + def _on_session_started_internal(self, session_id: str): + """Handle session started (internal monitoring).""" + if self.session_widget: + session_name = self.session_widget.session.get_session_name() + self.lbl_session_name.setText(f"Session: {session_name}") + self.lbl_session_status.setText("Status: Running") + self.lbl_session_status.setStyleSheet("color: #00ff00;") + self.status_bar.showMessage(f"Session started: {session_id}") + + def _on_session_finished_internal(self): + """Handle session finished (internal monitoring).""" + self.lbl_session_status.setText("Status: Finished") + self.lbl_session_status.setStyleSheet("color: #00ff00;") + self.status_bar.showMessage("Session completed successfully") + + def _on_session_status_changed_internal(self, status_text: str): + """Handle session status changes (internal monitoring).""" + # Update status bar with session status + self.status_bar.showMessage(status_text) + + def _update_status(self): + """Update status bar and session info (called every second).""" + # Update database size + try: + size_bytes, percentage, status = self.db_manager.check_size() + + color = "#00ff00" # Green + if status == "warning": + color = "#ffaa00" # Orange + elif status == "critical": + color = "#ff0000" # Red + + self.lbl_db_size.setText(f"DB: {percentage:.1f}%") + self.lbl_db_size.setStyleSheet(f"color: {color};") + except: + pass + + # Update run count from session widget + if self.session_widget and self.session_widget.session: + try: + # Get current session from widget + session_id = self.session_widget.session.get_current_session_id() + if session_id: + # Query database for run count + cursor = self.db_manager.get_connection().execute(""" + SELECT total_runs FROM sessions WHERE session_id = ? + """, (session_id,)) + row = cursor.fetchone() + if row: + self.lbl_run_count.setText(f"Runs: {row[0]}") + except: + pass + + # ========================================================================= + # Menu Actions + # ========================================================================= + + def _on_new_session(self): + """Start new session.""" + # TODO: Implement + QMessageBox.information(self, "New Session", "New session dialog (coming soon)") + + def _on_end_session(self): + """End current session.""" + # TODO: Implement + QMessageBox.information(self, "End Session", "End session (coming soon)") + + def _on_export_session(self): + """Export session data.""" + # TODO: Implement + QMessageBox.information(self, "Export", "Export session (coming soon)") + + def _on_uart_config(self): + """Configure UART.""" + # TODO: Implement + QMessageBox.information(self, "UART Config", "UART configuration dialog (coming soon)") + + def _on_i2c_config(self): + """Configure I2C.""" + # TODO: Implement + QMessageBox.information(self, "I2C Config", "I2C configuration dialog (coming soon)") + + def _on_debugger_config(self): + """Configure debugger.""" + # TODO: Implement + QMessageBox.information(self, "Debugger Config", "Debugger configuration dialog (coming soon)") + + def _on_gui_profile(self): + """Manage GUI profile.""" + # TODO: Implement + QMessageBox.information(self, "GUI Profile", "GUI profile settings (coming soon)") + + def _on_manage_profiles(self): + """Manage test profiles.""" + # TODO: Implement + QMessageBox.information(self, "Manage Profiles", "Test profile manager (coming soon)") + + def _on_new_profile(self): + """Create new test profile.""" + # TODO: Implement + QMessageBox.information(self, "New Profile", "Create test profile (coming soon)") + + def _on_vacuum_database(self): + """Vacuum database.""" + reply = QMessageBox.question( + self, + "Vacuum Database", + "Vacuum database to reclaim space?\n\nThis may take a few seconds.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.Yes: + self.db_manager.vacuum() + QMessageBox.information(self, "Success", "Database vacuumed successfully!") + + def _on_database_info(self): + """Show database information.""" + size_bytes, percentage, status = self.db_manager.check_size() + info = self.db_manager.get_table_info() + + msg = f"Database: {self.db_path}\n\n" + msg += f"Size: {self.db_manager.format_size(size_bytes)} ({percentage:.1f}% of 2 GB)\n" + msg += f"Status: {status}\n\n" + msg += "Table Row Counts:\n" + + for table, count in info.items(): + msg += f" {table}: {count}\n" + + QMessageBox.information(self, "Database Info", msg) + + def _on_cleanup_data(self): + """Cleanup old data.""" + # TODO: Implement + QMessageBox.information(self, "Cleanup", "Data cleanup dialog (coming soon)") + + def _on_about(self): + """Show about dialog.""" + msg = "vzug-e-hinge Test & Control System\n\n" + msg += "Version 1.0.0\n\n" + msg += "Integrated test and control system for e-hinge devices.\n\n" + msg += "Author: Kynsight\n" + msg += "Β© 2025" + + QMessageBox.about(self, "About", msg) + + def _on_documentation(self): + """Show documentation.""" + # TODO: Open documentation + QMessageBox.information(self, "Documentation", "Documentation (coming soon)") + + def _on_tab_changed(self, index: int): + """ + Handle tab change event. + Refresh profiles when Session tab becomes active. + + Args: + index: Index of newly selected tab + """ + # Check if Session tab (index 0) is now active + if index == 0 and hasattr(self, 'session_widget') and self.session_widget: + self.session_widget.refresh_profiles() + + def closeEvent(self, event): + """Handle application close.""" + reply = QMessageBox.question( + self, + "Exit", + "Are you sure you want to exit?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.Yes: + # Close database + if self.db_manager: + self.db_manager.close() + event.accept() + else: + event.ignore() + + +def main(): + """Main entry point.""" + # Parse command line arguments + db_path = "./database/ehinge.db" + + if len(sys.argv) > 1: + db_path = sys.argv[1] + + # Create application + app = QApplication(sys.argv) + + # Set application metadata + app.setApplicationName("vzug-e-hinge") + app.setOrganizationName("Kynsight") + app.setOrganizationDomain("kynsight.com") + + # Create main window + window = MainWindow(db_path) + window.show() + + # Run application + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/run.py b/run.py new file mode 100644 index 0000000..d9e8306 --- /dev/null +++ b/run.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +""" +Run Module - vzug-e-hinge +========================== +Executes a single RUN (one UART command with data collection). + +Flow: +1. Configure UART packet detection with callback +2. Callback triggers I2C read (real-time correlation) +3. Send UART command +4. Wait for stop condition +5. Decode packets (call decoder.py) +6. Save to database (telemetry_decoded + telemetry_raw) + +Author: Kynsight +Version: 1.0.0 +Date: 2025-11-09 +""" + +import time +from typing import Tuple, Optional, List +import sqlite3 + +# Import UART core +from uart.uart_kit.uart_core import ( + UARTPort, + PacketConfig, + PacketInfo, + uart_write, + uart_start_listening_with_packets, + uart_stop_listening, + uart_get_detected_packets, + uart_clear_detected_packets, + uart_read_buffer, + Status as UARTStatus +) + +# Import I2C core +from i2c.i2c_kit.i2c_core import ( + I2CHandle, + i2c_read_block, + Status as I2CStatus +) + +# Import decoder +from decoder import decode_uart_packet, decode_i2c_sample + + +class RunExecutor: + """ + Executes a single RUN. + + A RUN consists of: + - Send UART command + - Collect UART packets (with timestamps) + - Trigger I2C reads via callback (correlated timestamps) + - Wait for stop condition + - Decode all data + - Save to database + """ + + def __init__(self, db_connection: sqlite3.Connection): + """ + Initialize run executor. + + Args: + db_connection: Database connection + """ + self.db_conn = db_connection + self.i2c_readings = [] # Storage for I2C readings from callback + + def execute_run( + self, + session_id: str, + session_name: str, + run_no: int, + command_id: int, + command_hex: str, + uart_port: UARTPort, + i2c_port: Optional[I2CHandle], + packet_config: PacketConfig, + stop_timeout_ms: int = 5000, + raw_data_callback = None + ) -> Tuple[str, int, str]: + """ + Execute a single RUN. + + Args: + session_id: Session ID + session_name: Session name + run_no: Run number (1, 2, 3, ...) + command_id: UART command ID from database + command_hex: Command hex string (e.g., "DD 22 50 48...") + uart_port: UART port (already open and reader running) + i2c_port: I2C port (optional, for angle readings) + packet_config: Packet detection configuration + stop_timeout_ms: Maximum wait time for stop condition + + Returns: + (status, packet_count, error_msg) + - status: "success" or "error" + - packet_count: Number of packets detected + - error_msg: Error message if status="error", empty otherwise + """ + try: + # Clear previous packets + uart_clear_detected_packets(uart_port) + self.i2c_readings.clear() + + # Record run start time + run_start_ns = time.time_ns() + + # ================================================================ + # 1. Configure packet detection with callback + # ================================================================ + + # Create callback for I2C triggering + def on_uart_packet_detected(timestamp_ns: int): + """ + Called immediately when UART packet detected. + Triggers I2C read for timestamp correlation. + """ + if i2c_port: + # Read I2C angle immediately + # Note: i2c_read_block requires (handle, addr, reg, length) + # But we're using the handle's default address + status, i2c_bytes = i2c_read_block( + i2c_port, + i2c_port.config.address, # Use configured address + 0xFE, # Angle register + 2 # Read 2 bytes + ) + + if status == I2CStatus.OK: + # Store with correlated timestamp + self.i2c_readings.append({ + 'timestamp_ns': timestamp_ns, + 'i2c_bytes': i2c_bytes + }) + + # Create packet config with callback + packet_config_with_callback = PacketConfig( + enable=packet_config.enable, + start_marker=packet_config.start_marker, + packet_length=packet_config.packet_length, + end_marker=packet_config.end_marker, + on_packet_callback=on_uart_packet_detected if i2c_port else None + ) + + # Start listening with packet detection + status = uart_start_listening_with_packets(uart_port, packet_config_with_callback) + + if status != UARTStatus.OK: + return ("error", 0, "Failed to start UART packet detection") + + # ================================================================ + # 2. Send UART command + # ================================================================ + + # Parse hex string to bytes + command_bytes = self._parse_hex_string(command_hex) + if not command_bytes: + uart_stop_listening(uart_port) + return ("error", 0, f"Invalid command hex string: {command_hex}") + + # Send command + status, written = uart_write(uart_port, command_bytes) + + if status != UARTStatus.OK: + uart_stop_listening(uart_port) + return ("error", 0, "Failed to send UART command") + + # Emit TX data (command sent) + if raw_data_callback: + hex_tx = ' '.join(f'{b:02X}' for b in command_bytes) + raw_data_callback("TX", hex_tx) + + # ================================================================ + # 3. Wait for stop condition + # ================================================================ + + # Wait for timeout + time.sleep(stop_timeout_ms / 1000.0) + + # ================================================================ + # 3.5. Handle raw data if packet detection disabled + # ================================================================ + + if not packet_config.enable: + # No packet detection - read raw buffer + status_read, raw_data = uart_read_buffer(uart_port) + + if status_read == UARTStatus.OK and raw_data: + # Emit RX data + if raw_data_callback: + hex_rx = ' '.join(f'{b:02X}' for b in raw_data) + raw_data_callback("RX", hex_rx) + + # Stop listening + uart_stop_listening(uart_port) + + # ================================================================ + # 4. Get detected packets + # ================================================================ + + uart_packets = uart_get_detected_packets(uart_port) + packet_count = len(uart_packets) + + if packet_count == 0 and packet_config.enable: + # Only error if packet detection was enabled + return ("error", 0, "No packets detected (timeout or no data)") + + # ================================================================ + # 5. Decode and save data + # ================================================================ + + # Decode and save UART packets + for pkt in uart_packets: + self._save_uart_telemetry( + session_id=session_id, + session_name=session_name, + run_no=run_no, + run_command_id=command_id, + packet_info=pkt, + run_start_ns=run_start_ns + ) + + # Decode and save I2C readings + for reading in self.i2c_readings: + self._save_i2c_telemetry( + session_id=session_id, + session_name=session_name, + run_no=run_no, + run_command_id=command_id, + timestamp_ns=reading['timestamp_ns'], + i2c_bytes=reading['i2c_bytes'], + run_start_ns=run_start_ns + ) + + # Commit database changes + self.db_conn.commit() + + return ("success", packet_count, "") + + except Exception as e: + # Stop listening if still active + try: + uart_stop_listening(uart_port) + except: + pass + + return ("error", 0, f"Exception during run: {str(e)}") + + def _parse_hex_string(self, hex_str: str) -> Optional[bytes]: + """ + Parse hex string to bytes. + + Args: + hex_str: Hex string (e.g., "DD 22 50 48" or "DD225048") + + Returns: + Bytes or None if invalid + """ + try: + # Remove spaces and convert + hex_clean = hex_str.replace(' ', '') + return bytes.fromhex(hex_clean) + except: + return None + + def _save_uart_telemetry( + self, + session_id: str, + session_name: str, + run_no: int, + run_command_id: int, + packet_info: PacketInfo, + run_start_ns: int + ): + """ + Save UART telemetry to database. + + Saves to both telemetry_raw and telemetry_decoded tables. + """ + # Decode packet + decoded = decode_uart_packet(packet_info.data) + + # Calculate relative time from run start + time_ms = (packet_info.start_timestamp - run_start_ns) / 1_000_000.0 + + # Save to telemetry_raw (backup) + cursor = self.db_conn.cursor() + cursor.execute(""" + INSERT INTO telemetry_raw ( + session_id, session_name, run_no, run_command_id, + t_ns, time_ms, uart_raw_packet + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + session_id, + session_name, + run_no, + run_command_id, + packet_info.start_timestamp, + time_ms, + packet_info.data + )) + + # Save to telemetry_decoded (main data) + # For now, just save raw hex (decoder is pass-through) + # TODO: Update when decoder is fully implemented + cursor.execute(""" + INSERT INTO telemetry_decoded ( + session_id, session_name, run_no, run_command_id, + t_ns, time_ms + ) VALUES (?, ?, ?, ?, ?, ?) + """, ( + session_id, + session_name, + run_no, + run_command_id, + packet_info.start_timestamp, + time_ms + )) + + # TODO: When decoder is fully implemented, also save: + # motor_current, encoder_value, relative_encoder_value, v24_pec_diff, pwm + + def _save_i2c_telemetry( + self, + session_id: str, + session_name: str, + run_no: int, + run_command_id: int, + timestamp_ns: int, + i2c_bytes: bytes, + run_start_ns: int + ): + """ + Save I2C telemetry to database. + + Saves to both telemetry_raw and telemetry_decoded tables. + """ + # Decode I2C sample + decoded = decode_i2c_sample(i2c_bytes) + + # Calculate relative time from run start + time_ms = (timestamp_ns - run_start_ns) / 1_000_000.0 + + # Save to telemetry_raw (backup) + cursor = self.db_conn.cursor() + cursor.execute(""" + INSERT INTO telemetry_raw ( + session_id, session_name, run_no, run_command_id, + t_ns, time_ms, i2c_raw_bytes + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + session_id, + session_name, + run_no, + run_command_id, + timestamp_ns, + time_ms, + i2c_bytes + )) + + # Save to telemetry_decoded (main data) + # For now, decoder is pass-through + # TODO: Update when decoder is fully implemented with angle conversion + cursor.execute(""" + INSERT INTO telemetry_decoded ( + session_id, session_name, run_no, run_command_id, + t_ns, time_ms + ) VALUES (?, ?, ?, ?, ?, ?) + """, ( + session_id, + session_name, + run_no, + run_command_id, + timestamp_ns, + time_ms + )) + + # TODO: When decoder is fully implemented, also save: + # i2c_raw14, i2c_angle_deg, i2c_zero_raw14, etc. + + +# ============================================================================= +# Convenience function for external use +# ============================================================================= + +def execute_run( + db_connection: sqlite3.Connection, + session_id: str, + session_name: str, + run_no: int, + command_id: int, + command_hex: str, + uart_port: UARTPort, + i2c_port: Optional[I2CHandle], + packet_config: PacketConfig, + stop_timeout_ms: int = 5000, + raw_data_callback = None +) -> Tuple[str, int, str]: + """ + Execute a single RUN (convenience function). + + Args: + db_connection: Database connection + session_id: Session ID + session_name: Session name + run_no: Run number + command_id: UART command ID + command_hex: Command hex string + uart_port: UART port (open and ready) + i2c_port: I2C port (optional) + packet_config: Packet detection configuration + stop_timeout_ms: Stop condition timeout + raw_data_callback: Callback for raw data display (direction, hex_string) + + Returns: + (status, packet_count, error_msg) + """ + executor = RunExecutor(db_connection) + return executor.execute_run( + session_id=session_id, + session_name=session_name, + run_no=run_no, + command_id=command_id, + command_hex=command_hex, + uart_port=uart_port, + i2c_port=i2c_port, + packet_config=packet_config, + stop_timeout_ms=stop_timeout_ms, + raw_data_callback=raw_data_callback + ) + + +if __name__ == "__main__": + print("Run Module") + print("=" * 60) + print("This module executes a single RUN.") + print("It should be called by session.py, not run directly.") + print() + print("Features:") + print("βœ“ UART packet detection with callback") + print("βœ“ Real-time I2C triggering") + print("βœ“ Decoder integration") + print("βœ“ Database storage (telemetry_raw + telemetry_decoded)") + print("βœ“ Error handling") + print() + print("Ready to be used by session.py!") diff --git a/send.sh b/send.sh new file mode 100755 index 0000000..a2e9d51 --- /dev/null +++ b/send.sh @@ -0,0 +1,2 @@ +#!/bin/bash +rsync -a --delete --info=progress2 -A -X --partial --delete-excluded --exclude=.git --exclude=.venv/ --exclude=__pycache__ ./ vzug:/home/vzug/kit diff --git a/session.py b/session.py new file mode 100644 index 0000000..badbd49 --- /dev/null +++ b/session.py @@ -0,0 +1,890 @@ +#!/usr/bin/env python3 +""" +Session Module - vzug-e-hinge +============================== +Orchestrates session execution with queued pause/stop behavior. + +This module coordinates the entire automated test session flow: +- Loading interface and session profiles from database +- Opening UART/I2C ports based on configuration +- Executing command sequences via run.py +- Managing pause/stop requests (queued after current run) +- Providing real-time status updates via Qt signals +- Handling session lifecycle (create, pause, resume, abort) + +Key Features: +- Queued pause/stop (executes AFTER current run completes) +- Countdown during delays with second-by-second updates +- Real-time timestamp correlation (UART/I2C) +- Automatic port management +- Comprehensive error handling +- Database integration with status tracking + +Author: Kynsight +Version: 2.0.0 +Date: 2025-11-09 +""" + +# ============================================================================= +# IMPORTS +# ============================================================================= + +from PyQt6.QtCore import QObject, pyqtSignal +from typing import Optional, Dict, List, Any +import sqlite3 +import json +import time +from datetime import datetime + +# UART core +from uart.uart_kit.uart_core import ( + UARTPort, + UARTConfig, + PacketConfig, + uart_create, + uart_open, + uart_close, + uart_start_reader, + uart_stop_reader, + Status as UARTStatus +) + +# I2C core +from i2c.i2c_kit.i2c_core import ( + I2CHandle, + I2CConfig, + i2c_create, + i2c_open, + i2c_close, + Status as I2CStatus +) + +# Run executor +from run import execute_run + +# Database manager +from database.init_database import DatabaseManager + + +# ============================================================================= +# SESSION CLASS +# ============================================================================= + +class Session(QObject): + """ + Session orchestration class. + + Manages the complete lifecycle of an automated test session: + 1. Load profiles (interface + session) from database + 2. Open hardware ports (UART + I2C) + 3. Execute command sequence + 4. Handle pause/stop requests (queued) + 5. Provide real-time status updates + 6. Close ports and finalize session + + Signals: + session_started: Emitted when session starts (session_id) + command_started: Emitted when command starts (command_no, command_name) + run_completed: Emitted when run completes (run_no, packet_count) + delay_countdown: Emitted during delay countdown (seconds_remaining) + session_paused: Emitted when session pauses + session_finished: Emitted when session completes successfully + error_occurred: Emitted on error (error_message) + status_changed: Emitted on status change (status_text) + """ + + # ========================================================================= + # SIGNALS (for GUI updates) + # ========================================================================= + + session_started = pyqtSignal(str) # session_id + command_started = pyqtSignal(int, str) # command_no, command_name + run_completed = pyqtSignal(int, int) # run_no, packet_count + delay_countdown = pyqtSignal(int) # seconds_remaining + session_paused = pyqtSignal() + session_finished = pyqtSignal() + error_occurred = pyqtSignal(str) # error_message + status_changed = pyqtSignal(str) # status_text + raw_data_received = pyqtSignal(str, str) # direction (TX/RX), hex_string + + # ========================================================================= + # CONSTRUCTOR + # ========================================================================= + + def __init__(self, db_manager: DatabaseManager): + """ + Initialize session manager. + + Args: + db_manager: Database manager instance + """ + super().__init__() + + # Database connection + self.db_manager = db_manager + self.db_conn = db_manager.get_connection() + + # Session state + self.current_session_id: Optional[str] = None + self.session_name: Optional[str] = None + self.interface_profile_id: Optional[int] = None + self.session_profile_id: Optional[int] = None + + # Hardware ports (managed by this class) + self.uart_port: Optional[UARTPort] = None + 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 + + # Execution control flags + self.is_running: bool = False + self.is_paused: bool = False + self.pause_queued: bool = False + self.stop_queued: bool = False + + # ========================================================================= + # PROFILE LOADING + # ========================================================================= + + def load_session( + self, + interface_profile_id: int, + session_profile_id: int, + session_name: Optional[str] = None + ) -> bool: + """ + Load session and interface profiles from database. + + This method: + 1. Reads interface_profile (UART/I2C config, packet detection) + 2. Reads session_profile (command sequence JSON) + 3. Parses command sequence + 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 + session_name: Custom session name (auto-generated if None) + + Returns: + True if successful, False on error + """ + try: + # =================================================================== + # 1. Load interface profile (UART/I2C configuration) + # =================================================================== + + cursor = self.db_conn.execute(""" + SELECT + profile_name, + uart_command_port, uart_command_baud, uart_command_data_bits, + uart_command_stop_bits, uart_command_parity, uart_command_timeout_ms, + uart_logger_port, uart_logger_baud, uart_logger_data_bits, + uart_logger_stop_bits, uart_logger_parity, uart_logger_timeout_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 + FROM interface_profiles + WHERE profile_id = ? + """, (interface_profile_id,)) + + row = cursor.fetchone() + if not row: + self.error_occurred.emit(f"Interface profile {interface_profile_id} not found") + return False + + # Store interface configuration + self.interface_profile_id = interface_profile_id + self.interface_config = { + 'profile_name': row[0], + # UART Command interface + 'uart_command_port': row[1], + 'uart_command_baud': row[2], + 'uart_command_data_bits': row[3], + 'uart_command_stop_bits': row[4], + 'uart_command_parity': row[5], + 'uart_command_timeout_ms': row[6], + # UART Logger interface + 'uart_logger_port': row[7], + 'uart_logger_baud': row[8], + 'uart_logger_data_bits': row[9], + 'uart_logger_stop_bits': row[10], + 'uart_logger_parity': row[11], + 'uart_logger_timeout_ms': row[12], + # Packet detection + 'packet_detect_enable': row[13], + 'packet_detect_start': row[14], + 'packet_detect_length': row[15], + 'packet_detect_end': row[16], + # I2C configuration + 'i2c_port': row[17], + 'i2c_slave_address': row[18], + 'i2c_slave_read_register': row[19], + 'i2c_slave_read_length': row[20] + } + + # =================================================================== + # 2. Load session profile (command sequence) + # =================================================================== + + cursor = self.db_conn.execute(""" + SELECT profile_name, command_sequence, description + FROM session_profiles + WHERE profile_id = ? + """, (session_profile_id,)) + + row = cursor.fetchone() + if not row: + self.error_occurred.emit(f"Session profile {session_profile_id} not found") + return False + + self.session_profile_id = session_profile_id + profile_name = row[0] + command_sequence_json = row[1] + + # Parse JSON command sequence + try: + command_sequence = json.loads(command_sequence_json) + self.commands = command_sequence.get('commands', []) + self.total_commands = len(self.commands) + except json.JSONDecodeError as e: + self.error_occurred.emit(f"Invalid JSON in session profile: {str(e)}") + return False + + if self.total_commands == 0: + self.error_occurred.emit("Session profile has no commands") + return False + + # =================================================================== + # 3. Validate all commands exist in database + # =================================================================== + + for cmd in self.commands: + cmd_id = cmd.get('command_id') + if not cmd_id: + self.error_occurred.emit("Command missing command_id") + return False + + # Check if command exists + cursor = self.db_conn.execute(""" + SELECT command_name, hex_string + FROM uart_commands + WHERE command_id = ? + """, (cmd_id,)) + + row = cursor.fetchone() + if not row: + self.error_occurred.emit(f"UART command {cmd_id} not found") + return False + + # Store command details + cmd['command_name'] = row[0] + cmd['hex_string'] = row[1] + + # =================================================================== + # 4. Generate session name if not provided + # =================================================================== + + if session_name is None or session_name.strip() == "": + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + self.session_name = f"{profile_name}_{timestamp}" + else: + self.session_name = session_name + + # =================================================================== + # 5. Emit success status + # =================================================================== + + self.status_changed.emit(f"Session loaded: {self.session_name}") + self.status_changed.emit(f"Interface: {self.interface_config['profile_name']}") + self.status_changed.emit(f"Commands: {self.total_commands}") + + return True + + except Exception as e: + self.error_occurred.emit(f"Failed to load session: {str(e)}") + return False + + # ========================================================================= + # PORT MANAGEMENT + # ========================================================================= + + def _open_ports(self) -> bool: + """ + Open UART and I2C ports based on interface configuration. + + This method: + 1. Opens UART logger port (for packet detection) + 2. Starts UART reader thread + 3. Opens I2C port (for angle readings) + 4. Creates PacketConfig from interface profile + + Returns: + True if successful, False on error + """ + try: + # =================================================================== + # 1. Open UART Logger Port (for packet detection) + # =================================================================== + + # Create UART config (only device and baudrate are required) + uart_config = UARTConfig( + device=self.interface_config['uart_logger_port'], + baudrate=self.interface_config['uart_logger_baud'] + ) + + # Create UART port + status, self.uart_port = uart_create(uart_config) + + if status != UARTStatus.OK or self.uart_port is None: + self.error_occurred.emit(f"Failed to create UART port") + 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.device}") + 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.device}") + + # =================================================================== + # 2. Create PacketConfig from interface profile + # =================================================================== + + if 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"]), + ) + + # 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 + # =================================================================== + + if 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"]), + ) + + # 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 + + def _close_ports(self): + """ + Close UART and I2C ports. + + Called at end of session or on error. + Ensures clean shutdown of hardware interfaces. + """ + try: + # Close UART + if self.uart_port: + uart_stop_reader(self.uart_port) + uart_close(self.uart_port) + self.uart_port = None + self.status_changed.emit("UART closed") + + # Close I2C + if self.i2c_handle: + i2c_close(self.i2c_handle) + self.i2c_handle = None + self.status_changed.emit("I2C closed") + + except Exception as e: + self.error_occurred.emit(f"Error closing ports: {str(e)}") + + # ========================================================================= + # SESSION EXECUTION + # ========================================================================= + + def start_session(self) -> bool: + """ + Start session execution. + + This method: + 1. Creates session record in database + 2. Opens hardware ports + 3. Executes command loop + 4. Handles delays with countdown + 5. Checks pause/stop queue between runs + 6. Finalizes session on completion + + Returns: + True if started successfully, False on error + """ + if self.is_running: + self.error_occurred.emit("Session already running") + return False + + if not self.commands: + self.error_occurred.emit("No session loaded") + return False + + try: + # =================================================================== + # 1. Create session record in database + # =================================================================== + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + self.current_session_id = f"session_{timestamp}" + session_date = datetime.now().strftime("%Y-%m-%d") + + self.db_conn.execute(""" + INSERT INTO sessions ( + session_id, session_name, session_date, + interface_profile_id, session_profile_id, + status, total_runs + ) VALUES (?, ?, ?, ?, ?, 'active', 0) + """, ( + self.current_session_id, + self.session_name, + session_date, + self.interface_profile_id, + self.session_profile_id + )) + + self.db_conn.commit() + + # =================================================================== + # 2. Open hardware ports + # =================================================================== + + if not self._open_ports(): + self._finalize_session('error') + return False + + # =================================================================== + # 3. Set execution state + # =================================================================== + + self.is_running = True + self.is_paused = False + self.pause_queued = False + self.stop_queued = False + self.current_command_index = 0 + + # Emit session started + self.session_started.emit(self.current_session_id) + self.status_changed.emit(f"Session started: {self.session_name}") + + # =================================================================== + # 4. Execute command loop + # =================================================================== + + self._execute_command_loop() + + return True + + except Exception as e: + self.error_occurred.emit(f"Failed to start session: {str(e)}") + self._finalize_session('error') + return False + + def _execute_command_loop(self): + """ + Execute all commands in sequence. + + 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 + + CRITICAL: Pause/Stop are QUEUED and only execute between runs + during the delay phase, never during a run itself. + """ + try: + # Loop through all commands + for cmd_index, cmd in enumerate(self.commands, 1): + self.current_command_index = cmd_index + + # =============================================================== + # 1. Check if stop was queued (before starting new run) + # =============================================================== + + if self.stop_queued: + self.status_changed.emit("Session stopped by user") + self._finalize_session('aborted') + return + + # =============================================================== + # 2. Emit command started + # =============================================================== + + command_name = cmd['command_name'] + self.command_started.emit(cmd_index, command_name) + self.status_changed.emit(f"Command {cmd_index}/{self.total_commands}: {command_name}") + + # =============================================================== + # 3. Execute run via run.py + # =============================================================== + + status, packet_count, error_msg = execute_run( + db_connection=self.db_conn, + session_id=self.current_session_id, + session_name=self.session_name, + run_no=cmd_index, + command_id=cmd['command_id'], + command_hex=cmd['hex_string'], + uart_port=self.uart_port, + i2c_port=self.i2c_handle, # Note: run.py calls it i2c_port but it's actually I2CHandle + packet_config=self.packet_config, + stop_timeout_ms=5000, + raw_data_callback=lambda direction, hex_str: self.raw_data_received.emit(direction, hex_str) + ) + + # =============================================================== + # 4. Handle run result + # =============================================================== + + if status == "error": + # Run failed - abort session + self.error_occurred.emit(f"Run {cmd_index} failed: {error_msg}") + self._finalize_session('error') + return + + # Run succeeded - emit completion + self.run_completed.emit(cmd_index, packet_count) + self.status_changed.emit(f"Run {cmd_index} complete: {packet_count} packets") + + # Update total runs in database + self.db_conn.execute(""" + UPDATE sessions SET total_runs = ? WHERE session_id = ? + """, (cmd_index, self.current_session_id)) + self.db_conn.commit() + + # =============================================================== + # 5. Delay between commands (with countdown and queue check) + # =============================================================== + + # Get delay from command (default 3000ms if not specified) + delay_ms = cmd.get('delay_ms', 3000) + + # Only delay if not last command + if cmd_index < self.total_commands: + self._execute_delay(delay_ms) + + # Check if pause/stop was queued during delay + if self.pause_queued: + self._finalize_session('paused') + self.is_paused = True + self.session_paused.emit() + self.status_changed.emit("Session paused") + return + + if self.stop_queued: + self._finalize_session('aborted') + self.status_changed.emit("Session stopped by user") + return + + # =================================================================== + # 6. All commands completed successfully + # =================================================================== + + self._finalize_session('completed') + self.session_finished.emit() + self.status_changed.emit("Session completed successfully") + + except Exception as e: + self.error_occurred.emit(f"Exception during session: {str(e)}") + self._finalize_session('error') + + def _execute_delay(self, delay_ms: int): + """ + Execute delay with countdown. + + Emits delay_countdown signal every second so GUI can display + countdown timer. Also checks pause/stop queue each second. + + Args: + delay_ms: Delay in milliseconds + """ + # Convert to seconds + delay_sec = delay_ms / 1000.0 + + # Countdown in 1-second steps + for remaining in range(int(delay_sec), 0, -1): + # Emit countdown + self.delay_countdown.emit(remaining) + self.status_changed.emit(f"Waiting... ({remaining}s remaining)") + + # Sleep for 1 second + time.sleep(1.0) + + # Check if pause/stop was queued + if self.pause_queued or self.stop_queued: + return # Exit delay early + + # Handle fractional second at end + fractional = delay_sec - int(delay_sec) + if fractional > 0: + time.sleep(fractional) + + def _finalize_session(self, status: str): + """ + Finalize session and cleanup. + + Args: + status: 'completed', 'paused', 'aborted', or 'error' + """ + try: + # Update session status in database + self.db_conn.execute(""" + UPDATE sessions + SET status = ?, ended_at = datetime('now') + WHERE session_id = ? + """, (status, self.current_session_id)) + + self.db_conn.commit() + + # Close hardware ports + self._close_ports() + + # Reset execution state (unless paused) + if status != 'paused': + self.is_running = False + self.current_session_id = None + + except Exception as e: + self.error_occurred.emit(f"Error finalizing session: {str(e)}") + + # ========================================================================= + # PAUSE/RESUME/STOP CONTROL + # ========================================================================= + + def pause_session(self): + """ + Queue pause request. + + CRITICAL: Pause is QUEUED and executes AFTER current run completes. + User can press pause anytime, but it only takes effect during + the delay phase between runs. + """ + if not self.is_running or self.is_paused: + return + + self.pause_queued = True + self.status_changed.emit("Pause queued (will execute after current run)") + + def resume_session(self): + """ + Resume paused session. + + Continues execution from where it left off, immediately proceeding + to the next command without delay. + """ + if not self.is_paused: + return + + # Clear pause flags + self.is_paused = False + self.pause_queued = False + + # Update database + self.db_conn.execute(""" + UPDATE sessions SET status = 'active' WHERE session_id = ? + """, (self.current_session_id,)) + self.db_conn.commit() + + # Reopen ports + if not self._open_ports(): + self.error_occurred.emit("Failed to reopen ports") + return + + # Resume command loop from where we left off + self.status_changed.emit("Session resumed") + self._execute_command_loop() + + def stop_session(self): + """ + Queue stop request. + + CRITICAL: Stop is QUEUED and executes AFTER current run completes. + User can press stop anytime, but it only takes effect during + the delay phase between runs. + """ + if not self.is_running: + return + + self.stop_queued = True + self.status_changed.emit("Stop queued (will execute after current run)") + + # ========================================================================= + # STATUS QUERIES + # ========================================================================= + + def get_current_session_id(self) -> Optional[str]: + """Get current session ID.""" + return self.current_session_id + + def get_session_name(self) -> Optional[str]: + """Get current session name.""" + return self.session_name + + def is_session_running(self) -> bool: + """Check if session is running.""" + return self.is_running + + def is_session_paused(self) -> bool: + """Check if session is paused.""" + return self.is_paused + + +# ============================================================================= +# MAIN (for testing) +# ============================================================================= + +if __name__ == "__main__": + print("Session Module - vzug-e-hinge") + print("=" * 60) + print() + print("This module orchestrates automated test sessions.") + print() + print("Features:") + print(" βœ“ Profile loading (interface + session)") + print(" βœ“ Automatic port management") + print(" βœ“ Command sequence execution") + print(" βœ“ Queued pause/stop (executes after current run)") + print(" βœ“ Real-time status updates via signals") + print(" βœ“ Comprehensive error handling") + print() + print("Usage:") + print(" from session import Session") + print(" session = Session(db_manager)") + print(" session.load_session(interface_id, session_id)") + print(" session.start_session()") + print() + print("Ready to be used by session_widget.py!") diff --git a/session_widget.py b/session_widget.py new file mode 100644 index 0000000..f501d02 --- /dev/null +++ b/session_widget.py @@ -0,0 +1,542 @@ +#!/usr/bin/env python3 +""" +Session Widget - vzug-e-hinge +============================== +PyQt6 GUI widget for session orchestration. + +This widget provides the user interface for automated test sessions: +- Load interface and session profiles +- Start/pause/resume/stop session execution +- Display real-time status updates +- Show command progress and packet counts +- Display countdown timers during delays +- Monitor execution via data log + +The widget connects to Session class via Qt signals/slots for +asynchronous updates without blocking the GUI. + +Author: Kynsight +Version: 2.0.0 +Date: 2025-11-09 +""" + +# ============================================================================= +# IMPORTS +# ============================================================================= + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, + QLabel, QLineEdit, QComboBox, QPushButton, + QTextEdit, QFrame +) +from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtGui import QFont + +from session import Session +from database.init_database import DatabaseManager + + +# ============================================================================= +# SESSION WIDGET +# ============================================================================= + +class SessionWidget(QWidget): + """ + Session widget - GUI for automated test sessions. + + Layout: + β”Œβ”€ Session Widget ────────────────────────────────────────┐ + β”‚ Session Name: [________________] [Load Session β–Ό] β”‚ + β”‚ Interface Profile: [Auto-loaded β–Ό] β”‚ + β”‚ Session Profile: [Auto-loaded β–Ό] β”‚ + β”‚ β”‚ + β”‚ Status: Idle β”‚ + β”‚ Executing: --- β”‚ + β”‚ Command: 0 / 0 β”‚ + β”‚ β”‚ + β”‚ [▢️ Start] [⏸️ Pause] [⏹️ Stop] β”‚ + β”‚ β”‚ + β”‚ β”Œβ”€ Data Monitor ──────────────────────────────────┐ β”‚ + β”‚ β”‚ [INFO] Session loaded: Test_Session_01 β”‚ β”‚ + β”‚ β”‚ [INFO] Command 1/4: Open Door β”‚ β”‚ + β”‚ β”‚ [INFO] Run 1 complete: 127 packets detected β”‚ β”‚ + β”‚ β”‚ [INFO] Waiting... (3s remaining) β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + + Status Values: + - Idle: No session loaded (gray) + - Running: Executing run (green) + - Waiting: Delay between runs (yellow) + - Paused: User paused (orange) + - Finished: Session completed (green) + - Error: Communication error (red) + """ + + # ========================================================================= + # CONSTRUCTOR + # ========================================================================= + + def __init__(self, db_manager: DatabaseManager): + """ + Initialize session widget. + + Args: + db_manager: Database manager instance + """ + super().__init__() + + # Store database manager + self.db_manager = db_manager + self.db_conn = db_manager.get_connection() + + # Create session object + self.session = Session(db_manager) + + # Connect signals from session to widget slots + self._connect_signals() + + # Initialize UI + self._init_ui() + + # Load initial data + self._load_profiles() + + # ========================================================================= + # UI INITIALIZATION + # ========================================================================= + + def _init_ui(self): + """Initialize user interface.""" + # Main layout + layout = QVBoxLayout() + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(10) + + # ===================================================================== + # Configuration Section + # ===================================================================== + + config_group = QGroupBox("Session Configuration") + config_layout = QVBoxLayout() + + # Session name input + name_layout = QHBoxLayout() + name_layout.addWidget(QLabel("Session Name:")) + self.session_name_input = QLineEdit() + self.session_name_input.setPlaceholderText("Auto-generated if empty") + name_layout.addWidget(self.session_name_input) + config_layout.addLayout(name_layout) + + # Interface profile dropdown + interface_layout = QHBoxLayout() + interface_layout.addWidget(QLabel("Interface Profile:")) + self.interface_profile_combo = QComboBox() + interface_layout.addWidget(self.interface_profile_combo) + config_layout.addLayout(interface_layout) + + # Session profile dropdown + session_layout = QHBoxLayout() + session_layout.addWidget(QLabel("Session Profile:")) + self.session_profile_combo = QComboBox() + session_layout.addWidget(self.session_profile_combo) + config_layout.addLayout(session_layout) + + # Load button + load_btn_layout = QHBoxLayout() + load_btn_layout.addStretch() + self.load_button = QPushButton("Load Session") + self.load_button.clicked.connect(self._on_load_clicked) + load_btn_layout.addWidget(self.load_button) + config_layout.addLayout(load_btn_layout) + + config_group.setLayout(config_layout) + layout.addWidget(config_group) + + # ===================================================================== + # Status Section + # ===================================================================== + + status_group = QGroupBox("Session Status") + status_layout = QVBoxLayout() + + # Status label + self.status_label = QLabel("Status: Idle") + self.status_label.setTextFormat(Qt.TextFormat.RichText) + status_layout.addWidget(self.status_label) + + # Executing label + self.executing_label = QLabel("Executing: ---") + status_layout.addWidget(self.executing_label) + + # Command progress label + self.command_label = QLabel("Command: 0 / 0") + status_layout.addWidget(self.command_label) + + status_group.setLayout(status_layout) + layout.addWidget(status_group) + + # ===================================================================== + # Control Buttons + # ===================================================================== + + btn_layout = QHBoxLayout() + + # Start button + self.start_button = QPushButton("▢️ Start") + self.start_button.clicked.connect(self._on_start_clicked) + self.start_button.setEnabled(False) + btn_layout.addWidget(self.start_button) + + # Pause button + self.pause_button = QPushButton("⏸️ Pause") + self.pause_button.clicked.connect(self._on_pause_clicked) + self.pause_button.setEnabled(False) + btn_layout.addWidget(self.pause_button) + + # Resume button + self.resume_button = QPushButton("▢️ Resume") + self.resume_button.clicked.connect(self._on_resume_clicked) + self.resume_button.setEnabled(False) + self.resume_button.setVisible(False) + btn_layout.addWidget(self.resume_button) + + # Stop button + self.stop_button = QPushButton("⏹️ Stop") + self.stop_button.clicked.connect(self._on_stop_clicked) + self.stop_button.setEnabled(False) + btn_layout.addWidget(self.stop_button) + + layout.addLayout(btn_layout) + + # ===================================================================== + # Data Monitor + # ===================================================================== + + monitor_group = QGroupBox("Data Monitor") + monitor_layout = QVBoxLayout() + + # Text display for log messages + self.log_display = QTextEdit() + self.log_display.setReadOnly(True) + self.log_display.setMinimumHeight(150) # Minimum height, can expand + + # Set monospace font for log + font = QFont("Courier New") + font.setPointSize(9) + self.log_display.setFont(font) + + monitor_layout.addWidget(self.log_display) + monitor_group.setLayout(monitor_layout) + layout.addWidget(monitor_group, 1) # Stretch factor 1 = expands to fill space + + # ===================================================================== + # Finalize Layout + # ===================================================================== + + self.setLayout(layout) + self.setMinimumWidth(600) + + # ========================================================================= + # SIGNAL CONNECTION + # ========================================================================= + + def _connect_signals(self): + """Connect session signals to widget slots.""" + self.session.session_started.connect(self._on_session_started) + self.session.command_started.connect(self._on_command_started) + self.session.run_completed.connect(self._on_run_completed) + self.session.delay_countdown.connect(self._on_delay_countdown) + self.session.session_paused.connect(self._on_session_paused) + self.session.session_finished.connect(self._on_session_finished) + self.session.error_occurred.connect(self._on_error_occurred) + self.session.status_changed.connect(self._on_status_changed) + self.session.raw_data_received.connect(self._on_raw_data_received) + + # ========================================================================= + # PROFILE LOADING + # ========================================================================= + + def _load_profiles(self): + """Load interface and session profiles from database into dropdowns.""" + try: + # Load interface profiles + cursor = self.db_conn.execute(""" + SELECT profile_id, profile_name + FROM interface_profiles + ORDER BY profile_name + """) + + self.interface_profile_combo.clear() + for row in cursor.fetchall(): + self.interface_profile_combo.addItem(row[1], row[0]) # text, data + + # Load session profiles + 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 + + self._log_info("Profiles loaded from database") + + except Exception as e: + self._log_error(f"Failed to load profiles: {str(e)}") + + def refresh_profiles(self): + """ + Public method to refresh profile dropdowns. + Called when Session tab becomes active. + """ + self._load_profiles() + + # ========================================================================= + # BUTTON HANDLERS + # ========================================================================= + + def _on_load_clicked(self): + """Handle load session button click.""" + # Get selected profile IDs + interface_id = self.interface_profile_combo.currentData() + session_id = self.session_profile_combo.currentData() + + if interface_id is None or session_id is None: + self._log_error("Please select both interface and session profiles") + return + + # Get session name + session_name = self.session_name_input.text().strip() + if not session_name: + session_name = None # Will be auto-generated + + # Load session + success = self.session.load_session(interface_id, session_id, session_name) + + if success: + # Enable start button + self.start_button.setEnabled(True) + self._log_info("Session ready to start") + else: + self._log_error("Failed to load session") + + def _on_start_clicked(self): + """Handle start button click.""" + # Disable controls during startup + self.start_button.setEnabled(False) + self.load_button.setEnabled(False) + self.interface_profile_combo.setEnabled(False) + self.session_profile_combo.setEnabled(False) + self.session_name_input.setEnabled(False) + + # Start session + success = self.session.start_session() + + if success: + # Enable pause/stop buttons + self.pause_button.setEnabled(True) + self.stop_button.setEnabled(True) + else: + # Re-enable start button on failure + self.start_button.setEnabled(True) + self.load_button.setEnabled(True) + self.interface_profile_combo.setEnabled(True) + self.session_profile_combo.setEnabled(True) + self.session_name_input.setEnabled(True) + + def _on_pause_clicked(self): + """Handle pause button click.""" + self.session.pause_session() + # Button states will be updated by signal handlers + + def _on_resume_clicked(self): + """Handle resume button click.""" + self.session.resume_session() + # Button states will be updated by signal handlers + + def _on_stop_clicked(self): + """Handle stop button click.""" + self.session.stop_session() + # Button states will be updated by signal handlers + + # ========================================================================= + # SIGNAL HANDLERS (SLOTS) + # ========================================================================= + + @pyqtSlot(str) + def _on_session_started(self, session_id: str): + """Handle session started signal.""" + self._update_status("Running", "green") + self._log_info(f"Session started: {session_id}") + + @pyqtSlot(int, str) + def _on_command_started(self, command_no: int, command_name: str): + """Handle command started signal.""" + self.executing_label.setText(f"Executing: {command_name}") + total = self.session.total_commands + self.command_label.setText(f"Command: {command_no} / {total}") + self._log_info(f"[{command_no}/{total}] {command_name}") + + @pyqtSlot(int, int) + def _on_run_completed(self, run_no: int, packet_count: int): + """Handle run completed signal.""" + self._log_info(f"Run {run_no} complete: {packet_count} packets detected") + + @pyqtSlot(int) + def _on_delay_countdown(self, seconds_remaining: int): + """Handle delay countdown signal.""" + self._update_status("Waiting", "orange") + self.executing_label.setText(f"Waiting... ({seconds_remaining}s remaining)") + + @pyqtSlot() + def _on_session_paused(self): + """Handle session paused signal.""" + self._update_status("Paused", "orange") + self.executing_label.setText("Paused by user") + + # Update button states + self.pause_button.setVisible(False) + self.pause_button.setEnabled(False) + self.resume_button.setVisible(True) + self.resume_button.setEnabled(True) + self.stop_button.setEnabled(False) + + self._log_info("Session paused") + + @pyqtSlot() + def _on_session_finished(self): + """Handle session finished signal.""" + self._update_status("Finished", "green") + self.executing_label.setText("Session completed successfully") + + # Reset button states + self._reset_controls() + + self._log_info("Session completed successfully βœ“") + + @pyqtSlot(str) + def _on_error_occurred(self, error_msg: str): + """Handle error signal.""" + self._update_status("Error", "red") + self._log_error(error_msg) + + # Reset button states + self._reset_controls() + + @pyqtSlot(str) + def _on_status_changed(self, status_text: str): + """Handle status changed signal.""" + # Log status updates (but filter out countdown spam) + if "remaining)" not in status_text: + self._log_info(status_text) + + @pyqtSlot(str, str) + def _on_raw_data_received(self, direction: str, hex_string: str): + """ + Handle raw UART data display. + + Args: + direction: "TX" or "RX" + hex_string: Hex bytes (e.g., "EF FE 01 02 03") + """ + if direction == "TX": + color = "green" + prefix = "β†’ TX" + else: + color = "blue" + prefix = "← RX" + + self.log_display.append( + f"[{prefix}] {hex_string}" + ) + # Auto-scroll to bottom + self.log_display.verticalScrollBar().setValue( + self.log_display.verticalScrollBar().maximum() + ) + + # ========================================================================= + # UI HELPER METHODS + # ========================================================================= + + def _update_status(self, status: str, color: str): + """ + Update status label with color. + + Args: + status: Status text + color: HTML color name + """ + self.status_label.setText(f"Status: {status}") + + def _reset_controls(self): + """Reset controls to initial state after session ends.""" + # Enable configuration controls + self.load_button.setEnabled(True) + self.interface_profile_combo.setEnabled(True) + self.session_profile_combo.setEnabled(True) + self.session_name_input.setEnabled(True) + + # Reset button states + self.start_button.setEnabled(True) + self.pause_button.setVisible(True) + self.pause_button.setEnabled(False) + self.resume_button.setVisible(False) + self.resume_button.setEnabled(False) + self.stop_button.setEnabled(False) + + # Reset labels + self.executing_label.setText("Executing: ---") + self.command_label.setText("Command: 0 / 0") + + def _log_info(self, message: str): + """ + Log info message to data monitor. + + Args: + message: Message to log + """ + self.log_display.append(f"[INFO] {message}") + # Auto-scroll to bottom + self.log_display.verticalScrollBar().setValue( + self.log_display.verticalScrollBar().maximum() + ) + + def _log_error(self, message: str): + """ + Log error message to data monitor. + + Args: + message: Error message to log + """ + self.log_display.append(f"[ERROR] {message}") + # Auto-scroll to bottom + self.log_display.verticalScrollBar().setValue( + self.log_display.verticalScrollBar().maximum() + ) + + +# ============================================================================= +# MAIN (for testing) +# ============================================================================= + +if __name__ == "__main__": + import sys + from PyQt6.QtWidgets import QApplication + + # Create application + app = QApplication(sys.argv) + + # Create database manager + db_manager = DatabaseManager("database/ehinge.db") + db_manager.initialize() + + # Create widget + widget = SessionWidget(db_manager) + widget.setWindowTitle("Session Widget - vzug-e-hinge") + widget.show() + + # Run application + sys.exit(app.exec()) diff --git a/uart/uart_integrated_widget.py b/uart/uart_integrated_widget.py new file mode 100644 index 0000000..1fd665a --- /dev/null +++ b/uart/uart_integrated_widget.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +UART Control Widget - Integrated +================================= +Command table (left) + UART core widget (right) + +Author: Kynsight +Version: 3.0.0 +""" + +from PyQt6.QtWidgets import QWidget, QHBoxLayout, QSplitter +from PyQt6.QtCore import Qt, pyqtSignal + +from command_table.command_table import CommandTableWidget +from uart.uart_kit.uart_core_widget import UARTWidget + + +class UARTControlWidget(QWidget): + """ + Integrated UART control widget. + + Layout: Command table (left) | UART core (right) + + Signals: + command_sent: (command_id, hex_string) + packet_received: (packet_info) + connection_changed: (is_connected) + """ + + command_sent = pyqtSignal(int, str) + packet_received = pyqtSignal(object) + connection_changed = pyqtSignal(bool) + + def __init__(self, db_connection, parent=None): + super().__init__(parent) + + self.conn = db_connection + + self._init_ui() + self._setup_connections() + + def _init_ui(self): + """Initialize UI - side by side layout.""" + layout = QHBoxLayout() + self.setLayout(layout) + + # Splitter for resizable layout + splitter = QSplitter(Qt.Orientation.Horizontal) + + # Left: Command table + self.command_table = CommandTableWidget(self.conn, 'uart') + splitter.addWidget(self.command_table) + + # Right: UART core widget + self.uart_core = UARTWidget() + splitter.addWidget(self.uart_core) + + # Set initial sizes (40% table, 60% core) + splitter.setSizes([400, 600]) + + layout.addWidget(splitter) + + # Initialize ports on startup + self.uart_core._refresh_ports() + + # Table always enabled, CRUD buttons disabled initially (not connected) + self._update_table_mode(False) + + def _setup_connections(self): + """Connect signals between table and core.""" + + # Command table β†’ Send via UART (when connected) OR Edit (when disconnected) + self.command_table.command_double_clicked.connect(self._on_command_double_click) + + # UART connection β†’ Change table mode + self.uart_core.connection_changed.connect(self._on_connection_changed) + + # Forward UART signals + self.uart_core.packet_received.connect(self.packet_received.emit) + + def _update_table_mode(self, is_connected): + """Update table mode based on connection state.""" + if is_connected: + # Connected mode: CRUD buttons disabled, double-click sends + self.command_table.btn_add.setEnabled(False) + self.command_table.btn_edit.setEnabled(False) + self.command_table.btn_delete.setEnabled(False) + else: + # Disconnected mode: CRUD buttons enabled, double-click edits + self.command_table.btn_add.setEnabled(True) + # Edit/Delete enabled only if something selected + has_selection = bool(self.command_table.table.selectedItems()) + self.command_table.btn_edit.setEnabled(has_selection) + self.command_table.btn_delete.setEnabled(has_selection) + + def _on_command_double_click(self, command_id, cmd_data): + """Handle double-click: Send if connected, Edit if disconnected.""" + if self.uart_core.is_connected: + # Connected: Send command + self._send_command(command_id, cmd_data) + else: + # Disconnected: Edit command + self.command_table._edit_command() + + def _send_command(self, command_id, cmd_data): + """Send command via UART.""" + # Get hex string + hex_string = cmd_data.get('hex_string', '') + if not hex_string: + return + + # Send via UART core - Use the input field and send button + try: + # Set hex string in input field + self.uart_core.edit_send.setText(hex_string) + + # Ensure format is set to Hex + self.uart_core.combo_format.setCurrentText("Hex") + + # Trigger send + self.uart_core._on_send() + + # Emit signal + self.command_sent.emit(command_id, hex_string) + + except Exception as e: + print(f"Send error: {e}") + + def _on_connection_changed(self, is_connected): + """Handle connection state change.""" + # Update table mode (CRUD buttons, double-click behavior) + self._update_table_mode(is_connected) + + # Forward signal + self.connection_changed.emit(is_connected) + + def get_uart_core(self): + """Get UART core widget for direct access.""" + return self.uart_core + + def get_command_table(self): + """Get command table for direct access.""" + return self.command_table + + +if __name__ == "__main__": + import sys + from PyQt6.QtWidgets import QApplication + import sqlite3 + + app = QApplication(sys.argv) + + conn = sqlite3.connect("./database/ehinge.db") + + widget = UARTControlWidget(conn) + widget.setWindowTitle("UART Control - Integrated") + widget.resize(1400, 800) + widget.show() + + sys.exit(app.exec()) diff --git a/uart/uart_kit/uart_core.py b/uart/uart_kit/uart_core.py new file mode 100644 index 0000000..a0e4fec --- /dev/null +++ b/uart/uart_kit/uart_core.py @@ -0,0 +1,1057 @@ +#!/usr/bin/env python3 +""" +UART Core - vzug-e-hinge +========================= +Clean UART port management with packet detection and timestamping. + +Features: +- Single port per instance (no multi-port management) +- Reader thread writes to circular buffer +- Start/stop condition detection with timestamps +- Packet counting per connection +- Configurable stop conditions (timeout or terminator byte) +- Polling mode with grace period +- **NEW: Packet detection in listening mode with real-time timestamps** +- Buffer overflow tracking +- Clean connect/disconnect (no auto-reconnect) + +Author: Kynsight +Version: 2.1.0 - Added packet detection for listening mode +""" + +from __future__ import annotations +import threading +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, Callable, Tuple + +try: + import serial +except ImportError: + serial = None + +from buffer_kit.circular_buffer import ( + Status as BufferStatus, + CircularBufferHandle, + cb_init, + cb_write, + cb_copy_span, + cb_w_abs, + cb_capacity, + cb_fill_bytes, + cb_fill_pct, + cb_overflows, +) + + +__all__ = [ + # Status & Configuration + 'Status', + 'StopConditionMode', + 'UARTConfig', + 'PacketConfig', + 'PacketInfo', + 'PortStatus', + 'UARTPort', + + # Lifecycle + 'uart_create', + 'uart_open', + 'uart_close', + + # Reader thread + 'uart_start_reader', + 'uart_stop_reader', + + # Write + 'uart_write', + + # Packet detection modes + 'uart_send_and_receive', + 'uart_poll_packet', + + # Listening mode + 'uart_start_listening', + 'uart_stop_listening', + 'uart_read_buffer', + + # NEW: Listening with packet detection + 'uart_start_listening_with_packets', + 'uart_get_detected_packets', + 'uart_clear_detected_packets', + + # Status + 'uart_get_status', +] + + +# ============================================================================= +# Status & Configuration +# ============================================================================= + +class Status(Enum): + """Operation status codes.""" + OK = 0 + TIMEOUT = 1 + TIMEOUT_NO_DATA = 2 + BUFFER_OVERFLOW = 3 + IO_ERROR = 4 + BAD_CONFIG = 5 + PORT_CLOSED = 6 + ALREADY_OPEN = 7 + READER_NOT_RUNNING = 8 + + +class StopConditionMode(Enum): + """How to detect packet end.""" + TIMEOUT = 0 # No data for N ms + TERMINATOR = 1 # Specific byte received + + +@dataclass(frozen=True) +class UARTConfig: + """ + UART port configuration. + + device: Serial device path (e.g., "/dev/ttyUSB0") + baudrate: Baud rate (e.g., 115200) + buffer_size: RX circular buffer capacity in bytes + read_chunk_size: Max bytes to read per loop iteration + + stop_mode: TIMEOUT or TERMINATOR + stop_timeout_ms: Timeout in ms (for TIMEOUT mode or grace period) + stop_terminator: Byte to detect (for TERMINATOR mode) + + polling_mode: If True, enables grace period before timeout + grace_timeout_ms: Max wait for first byte in polling mode + + timestamp_source: Optional external time function (default: time.perf_counter) + """ + device: str + baudrate: int + buffer_size: int = 496 + read_chunk_size: int = 512 + + stop_mode: StopConditionMode = StopConditionMode.TIMEOUT + stop_timeout_ms: int = 150 + stop_terminator: int = 0x0A # Newline + + polling_mode: bool = False + grace_timeout_ms: int = 150 + + timestamp_source: Optional[Callable[[], float]] = None + + +@dataclass(frozen=True) +class PacketConfig: + """ + Packet detection configuration for listening mode. + + Used to detect packet boundaries in continuous data stream. + + Example for format: EF FE [14 bytes] EE + PacketConfig( + enable=True, + start_marker=b'\\xEF\\xFE', + packet_length=17, + end_marker=b'\\xEE', + on_packet_callback=my_callback_function + ) + + Fields: + enable: Enable/disable packet detection + start_marker: Bytes that mark packet start (e.g., b'\\xEF\\xFE') + packet_length: Total packet length in bytes (including markers) + end_marker: Bytes that mark packet end (e.g., b'\\xEE') + on_packet_callback: Optional callback function called when packet detected + Signature: callback(timestamp_ns: int) -> None + Called in reader thread with packet timestamp + """ + enable: bool = False + start_marker: Optional[bytes] = None + packet_length: Optional[int] = None + end_marker: Optional[bytes] = None + on_packet_callback: Optional[Callable[[int], None]] = None + + +@dataclass +class PacketInfo: + """ + Information about a detected packet. + + packet_id: Sequential ID (resets per connection) + start_timestamp: When first byte arrived (nanoseconds) + stop_timestamp: When stop condition met (not used in listening mode) + data: Packet payload + stop_reason: "timeout", "terminator", "grace_timeout", "packet_complete" + """ + packet_id: int + start_timestamp: float + stop_timestamp: float + data: bytes + stop_reason: str + + +@dataclass +class PortStatus: + """ + Current port status. + + is_open: Port is opened + is_reader_running: Reader thread is active + buffer_capacity: Buffer size in bytes + buffer_fill_bytes: Current buffer usage + buffer_fill_percent: Fill percentage (0-100) + buffer_overflows: Number of buffer overflows + total_bytes_received: Total bytes since connection + total_packets: Total packets detected + """ + is_open: bool + is_reader_running: bool + buffer_capacity: int + buffer_fill_bytes: int + buffer_fill_percent: int + buffer_overflows: int + total_bytes_received: int + total_packets: int + + +# ============================================================================= +# UART Port Handle +# ============================================================================= + +@dataclass +class UARTPort: + """ + UART port instance. + + Internal state - do not access directly. + Use the public API functions. + """ + config: UARTConfig + + # Serial port + _serial_port: Optional[serial.Serial] = None + _is_open: bool = False + + # Circular buffer + _rx_buffer: Optional[CircularBufferHandle] = None + + # Reader thread + _reader_thread: Optional[threading.Thread] = None + _reader_running: bool = False + _stop_reader_event: threading.Event = field(default_factory=threading.Event) + + # Statistics (protected by lock) + _lock: threading.Lock = field(default_factory=threading.Lock) + _total_bytes_received: int = 0 + _total_packets: int = 0 + _last_rx_timestamp: float = 0.0 + _listening_start_time: float = 0.0 + + # Packet detection (for listening mode with packet detection) + _packet_config: Optional[PacketConfig] = None + _detected_packets: list = field(default_factory=list) + _packet_buffer: bytearray = field(default_factory=bytearray) + _packet_detection_active: bool = False + _packet_start_timestamp: float = 0.0 + + # Timestamp function + _get_timestamp: Callable[[], float] = field(default=time.perf_counter) + + def __post_init__(self): + """Initialize timestamp source.""" + if self.config.timestamp_source: + self._get_timestamp = self.config.timestamp_source + + +# ============================================================================= +# Port Lifecycle +# ============================================================================= + +def uart_create(config: UARTConfig) -> Tuple[Status, Optional[UARTPort]]: + """ + Create UART port instance. + + Returns: + (Status.OK, port) on success + (Status.BAD_CONFIG, None) on invalid config + """ + if not config.device or config.baudrate <= 0: + return (Status.BAD_CONFIG, None) + + if not serial: + return (Status.IO_ERROR, None) + + port = UARTPort(config=config) + return (Status.OK, port) + + +def uart_open(port: UARTPort) -> Status: + """ + Open serial port and initialize buffer. + + Returns: + Status.OK on success + Status.ALREADY_OPEN if already open + Status.IO_ERROR on failure + """ + if port._is_open: + return Status.ALREADY_OPEN + + try: + # Open serial port + port._serial_port = serial.Serial( + port=port.config.device, + baudrate=port.config.baudrate, + timeout=0.01, # Non-blocking with small timeout + write_timeout=0.5 + ) + + # Create RX buffer + status, buffer = cb_init(port.config.buffer_size) + if status != BufferStatus.OK: + port._serial_port.close() + return Status.IO_ERROR + + port._rx_buffer = buffer + port._is_open = True + + # Reset statistics + with port._lock: + port._total_bytes_received = 0 + port._total_packets = 0 + port._last_rx_timestamp = 0.0 + + return Status.OK + + except Exception as e: + return Status.IO_ERROR + + +def uart_close(port: UARTPort) -> Status: + """ + Close serial port and cleanup resources. + + Stops reader thread if running. + + Returns: + Status.OK on success + """ + if not port._is_open: + return Status.OK + + # Stop reader first + if port._reader_running: + uart_stop_reader(port) + + # Close serial port + if port._serial_port: + try: + port._serial_port.close() + except: + pass + port._serial_port = None + + # Cleanup buffer + port._rx_buffer = None + port._is_open = False + + return Status.OK + + +# ============================================================================= +# Reader Thread +# ============================================================================= + +def uart_start_reader(port: UARTPort) -> Status: + """ + Start background reader thread. + + Thread reads from serial port and writes to circular buffer. + + Returns: + Status.OK on success + Status.PORT_CLOSED if port not open + """ + if not port._is_open: + return Status.PORT_CLOSED + + if port._reader_running: + return Status.OK # Already running + + port._stop_reader_event.clear() + port._reader_running = True + + port._reader_thread = threading.Thread( + target=_reader_thread_func, + args=(port,), + daemon=True, + name=f"UART-Reader-{port.config.device}" + ) + port._reader_thread.start() + + return Status.OK + + +def uart_stop_reader(port: UARTPort) -> Status: + """ + Stop background reader thread. + + Blocks until thread exits (with timeout). + + Returns: + Status.OK on success + """ + if not port._reader_running: + return Status.OK + + port._stop_reader_event.set() + + if port._reader_thread: + port._reader_thread.join(timeout=1.0) + port._reader_thread = None + + port._reader_running = False + + return Status.OK + + +def _reader_thread_func(port: UARTPort) -> None: + """ + Background reader thread implementation. + + Continuously reads from serial port and writes to circular buffer. + Updates timestamps and byte counters. + + If packet detection is enabled, also detects packet boundaries + and stores detected packets with timestamps. + """ + while not port._stop_reader_event.is_set(): + try: + if port._serial_port and port._serial_port.in_waiting > 0: + # Read available data + chunk = port._serial_port.read(port.config.read_chunk_size) + + if chunk: + # Write to circular buffer + cb_write(port._rx_buffer, chunk) + + # Update statistics + timestamp = port._get_timestamp() + with port._lock: + port._total_bytes_received += len(chunk) + port._last_rx_timestamp = timestamp + + # Packet detection (if enabled) + if port._packet_detection_active and port._packet_config: + _detect_packets_in_chunk(port, chunk, timestamp) + + else: + # No data available, sleep briefly + time.sleep(0.001) + + except Exception: + # IO error - exit thread + break + + +def _detect_packets_in_chunk(port: UARTPort, chunk: bytes, timestamp: float) -> None: + """ + Detect packets in received chunk. + + Uses configured packet format (start marker, length, end marker). + Stores complete packets in _detected_packets list with timestamps. + + Packet format: [START_MARKER][DATA][END_MARKER] + Example: EF FE [14 bytes] EE + + Args: + port: UART port instance + chunk: Received data chunk + timestamp: Timestamp when chunk received + """ + if not port._packet_config or not port._packet_config.enable: + return + + cfg = port._packet_config + + # Add chunk to packet buffer + port._packet_buffer.extend(chunk) + + # Process buffer looking for complete packets + while len(port._packet_buffer) >= (cfg.packet_length or 0): + # Look for start marker + if cfg.start_marker: + # Find start marker position + start_idx = port._packet_buffer.find(cfg.start_marker) + + if start_idx == -1: + # No start marker found - clear old data, keep last few bytes + # (in case start marker is split across chunks) + if len(port._packet_buffer) > 100: + port._packet_buffer = port._packet_buffer[-10:] + break + + # Remove everything before start marker + if start_idx > 0: + port._packet_buffer = port._packet_buffer[start_idx:] + + # Check if we have enough bytes for complete packet + if len(port._packet_buffer) < cfg.packet_length: + break # Wait for more data + + # Extract potential packet + packet_bytes = bytes(port._packet_buffer[:cfg.packet_length]) + + # Verify end marker (if configured) + if cfg.end_marker: + expected_end_pos = cfg.packet_length - len(cfg.end_marker) + actual_end = packet_bytes[expected_end_pos:] + + if actual_end != cfg.end_marker: + # Invalid packet - discard first byte and try again + port._packet_buffer.pop(0) + continue + + # Valid packet found! + # Timestamp at packet START (when we found start marker) + if port._packet_start_timestamp == 0.0: + port._packet_start_timestamp = timestamp + + # Create packet info + with port._lock: + port._total_packets += 1 + packet_id = port._total_packets + + packet_info = PacketInfo( + packet_id=packet_id, + start_timestamp=int(port._packet_start_timestamp * 1e9), # Convert to nanoseconds + stop_timestamp=int(timestamp * 1e9), # Not used, but filled anyway + data=packet_bytes, + stop_reason="packet_complete" + ) + + # Store packet + port._detected_packets.append(packet_info) + + # Call callback if provided (with timestamp in nanoseconds) + if cfg.on_packet_callback: + try: + cfg.on_packet_callback(packet_info.start_timestamp) + except Exception: + # Don't crash reader thread if callback fails + pass + + # Remove packet from buffer + port._packet_buffer = port._packet_buffer[cfg.packet_length:] + + # Reset start timestamp for next packet + port._packet_start_timestamp = 0.0 + + else: + # No start marker - just use fixed length + if cfg.packet_length and len(port._packet_buffer) >= cfg.packet_length: + packet_bytes = bytes(port._packet_buffer[:cfg.packet_length]) + + with port._lock: + port._total_packets += 1 + packet_id = port._total_packets + + packet_info = PacketInfo( + packet_id=packet_id, + start_timestamp=int(timestamp * 1e9), + stop_timestamp=int(timestamp * 1e9), + data=packet_bytes, + stop_reason="packet_complete" + ) + + port._detected_packets.append(packet_info) + + # Call callback if provided (with timestamp in nanoseconds) + if cfg.on_packet_callback: + try: + cfg.on_packet_callback(packet_info.start_timestamp) + except Exception: + # Don't crash reader thread if callback fails + pass + + port._packet_buffer = port._packet_buffer[cfg.packet_length:] + else: + break + + +# ============================================================================= +# Write Operations +# ============================================================================= + +def uart_write(port: UARTPort, data: bytes) -> Tuple[Status, int]: + """ + Write data to serial port. + + Returns: + (Status.OK, bytes_written) on success + (Status.PORT_CLOSED, 0) if port not open + (Status.IO_ERROR, 0) on write failure + """ + if not port._is_open or not port._serial_port: + return (Status.PORT_CLOSED, 0) + + try: + written = port._serial_port.write(data) + port._serial_port.flush() + return (Status.OK, written) + + except Exception: + return (Status.IO_ERROR, 0) + + +# ============================================================================= +# Packet Detection (Request-Response Mode) +# ============================================================================= + +def uart_send_and_receive(port: UARTPort, tx_data: bytes, + timeout_ms: Optional[int] = None) -> Tuple[Status, Optional[PacketInfo]]: + """ + Send data and wait for response packet (request-response mode). + + Uses configured stop condition (timeout or terminator). + Timeout starts immediately after send. + + Args: + port: UART port instance + tx_data: Data to transmit + timeout_ms: Override configured timeout (optional) + + Returns: + (Status.OK, PacketInfo) on success + (Status.TIMEOUT, None) on timeout + (Status.PORT_CLOSED, None) if port not ready + """ + if not port._is_open or not port._reader_running: + return (Status.PORT_CLOSED, None) + + # 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 + start_w = cb_w_abs(port._rx_buffer) + + # Send data + status, _ = uart_write(port, tx_data) + if status != Status.OK: + return (status, None) + + # Start timestamp + start_time = port._get_timestamp() + timeout_s = timeout / 1000.0 + + # Wait for response + return _wait_for_packet( + port=port, + start_w=start_w, + start_time=start_time, + timeout_s=timeout_s, + grace_period=False + ) + + +# ============================================================================= +# Listening Mode (Continuous, No Auto-Stop) +# ============================================================================= + +def uart_start_listening(port: UARTPort) -> Status: + """ + Start listening mode - continuous data collection with no stop condition. + + Data fills buffer continuously. Use uart_read_buffer() to get current contents. + Call uart_stop_listening() to stop. + + Returns: + Status.OK on success + Status.PORT_CLOSED if port not ready + """ + if not port._is_open or not port._reader_running: + return Status.PORT_CLOSED + + # Listening mode has no special state - just reader running + # Mark listening start time for potential later use + with port._lock: + port._listening_start_time = port._get_timestamp() + + return Status.OK + + +def uart_stop_listening(port: UARTPort) -> Status: + """ + Stop listening mode. + + Returns: + Status.OK + """ + # Nothing special to do - reader keeps running + # Just mark end time + with port._lock: + port._listening_start_time = 0.0 + + # Stop packet detection + port._packet_detection_active = False + + return Status.OK + + +def uart_start_listening_with_packets(port: UARTPort, packet_config: PacketConfig) -> Status: + """ + Start listening mode WITH packet detection. + + Reader thread will: + - Fill circular buffer (continuous logging) + - Detect packet boundaries based on packet_config + - Store each detected packet with timestamp and count + - Call optional callback when packet detected (real-time trigger) + + Args: + port: UART port instance + packet_config: Packet detection configuration + + Returns: + Status.OK on success + Status.PORT_CLOSED if port not ready + + Example without callback: + # For packet format: EF FE [14 bytes] EE (17 bytes total) + packet_config = PacketConfig( + enable=True, + start_marker=b'\\xEF\\xFE', + packet_length=17, + end_marker=b'\\xEE' + ) + uart_start_listening_with_packets(port, packet_config) + + Example with callback (real-time trigger): + def on_packet_detected(timestamp_ns: int): + '''Called immediately when packet detected.''' + # Trigger I2C read or other action + i2c_read_angle(i2c_port) + + packet_config = PacketConfig( + enable=True, + start_marker=b'\\xEF\\xFE', + packet_length=17, + end_marker=b'\\xEE', + on_packet_callback=on_packet_detected + ) + uart_start_listening_with_packets(port, packet_config) + """ + if not port._is_open or not port._reader_running: + return Status.PORT_CLOSED + + # Configure packet detection + port._packet_config = packet_config + port._packet_buffer.clear() + port._packet_start_timestamp = 0.0 + + # Mark listening start time + with port._lock: + port._listening_start_time = port._get_timestamp() + + # Enable packet detection in reader thread + port._packet_detection_active = packet_config.enable + + return Status.OK + + +def uart_get_detected_packets(port: UARTPort) -> list: + """ + Get all packets detected since listening started. + + Returns list of PacketInfo objects, each containing: + - packet_id: Sequential packet number (1, 2, 3, ...) + - start_timestamp: When packet started (nanoseconds since epoch) + - data: Raw packet bytes + + Returns: + List of PacketInfo objects + + Example: + packets = uart_get_detected_packets(port) + print(f"Detected {len(packets)} packets") + for pkt in packets: + print(f"Packet {pkt.packet_id} at {pkt.start_timestamp}ns") + """ + return port._detected_packets.copy() + + +def uart_clear_detected_packets(port: UARTPort) -> Status: + """ + Clear detected packets list. + + Call at start of each RUN to reset packet counter and list. + + Returns: + Status.OK + """ + port._detected_packets.clear() + port._packet_buffer.clear() + port._packet_start_timestamp = 0.0 + + with port._lock: + port._total_packets = 0 + + return Status.OK + + +def uart_read_buffer(port: UARTPort, max_bytes: int = 0) -> Tuple[Status, bytes]: + """ + Read current buffer contents (for listening mode). + + Args: + port: UART port + max_bytes: Max bytes to read (0 = all available, or last N bytes if buffer larger) + + Returns: + (Status.OK, data) on success + (Status.PORT_CLOSED, b"") if port not ready + """ + if not port._is_open or not port._rx_buffer: + return (Status.PORT_CLOSED, b"") + + current_w = cb_w_abs(port._rx_buffer) + + if current_w == 0: + return (Status.OK, b"") + + if max_bytes > 0: + # Read last N bytes + start_w = max(0, current_w - max_bytes) + else: + # Read all available (up to buffer capacity) + capacity = cb_capacity(port._rx_buffer) + start_w = max(0, current_w - capacity) + + status, data = cb_copy_span(port._rx_buffer, start_w, current_w) + + if status != BufferStatus.OK: + return (Status.IO_ERROR, b"") + + return (Status.OK, data) + + +# ============================================================================= +# Packet Detection (Polling Mode) +# ============================================================================= + +def uart_poll_packet(port: UARTPort) -> Tuple[Status, Optional[PacketInfo]]: + """ + Poll for next packet (polling mode). + + Grace period: Waits for first byte, then timeout starts. + + Flow: + 1. Wait up to grace_timeout_ms for first byte + 2. Once first byte arrives, start stop_timeout_ms + 3. If grace expires without byte, start timeout anyway + 4. Detect stop condition (timeout or terminator) + + Returns: + (Status.OK, PacketInfo) on success + (Status.TIMEOUT_NO_DATA, None) if no data after grace + timeout + (Status.PORT_CLOSED, None) if port not ready + """ + if not port._is_open or not port._reader_running: + return (Status.PORT_CLOSED, None) + + if not port.config.polling_mode: + # Not configured for polling - use regular timeout + start_w = cb_w_abs(port._rx_buffer) + start_time = port._get_timestamp() + timeout_s = port.config.stop_timeout_ms / 1000.0 + + return _wait_for_packet( + port=port, + start_w=start_w, + start_time=start_time, + timeout_s=timeout_s, + grace_period=False + ) + + # Polling mode with grace period + start_w = cb_w_abs(port._rx_buffer) + start_time = port._get_timestamp() + grace_s = port.config.grace_timeout_ms / 1000.0 + timeout_s = port.config.stop_timeout_ms / 1000.0 + + # Phase 1: Grace period - wait for first byte + first_byte_seen = False + grace_start = start_time + + while (port._get_timestamp() - grace_start) < grace_s: + current_w = cb_w_abs(port._rx_buffer) + if current_w > start_w: + # First byte arrived! + first_byte_seen = True + break + time.sleep(0.001) + + # Phase 2: Timeout starts (whether or not byte arrived) + return _wait_for_packet( + port=port, + start_w=start_w, + start_time=port._get_timestamp(), # Reset start time + timeout_s=timeout_s, + grace_period=False, + grace_expired_no_data=(not first_byte_seen) + ) + + +def _wait_for_packet(port: UARTPort, start_w: int, start_time: float, + timeout_s: float, grace_period: bool, + grace_expired_no_data: bool = False) -> Tuple[Status, Optional[PacketInfo]]: + """ + Internal: Wait for stop condition and collect packet. + + Args: + port: UART port + start_w: Buffer write position at start + start_time: Start timestamp + timeout_s: Timeout in seconds + grace_period: If True, timeout starts after first byte + grace_expired_no_data: Grace expired without any byte + + Returns: + (Status, PacketInfo or None) + """ + mode = port.config.stop_mode + first_byte_seen = False + first_byte_time = 0.0 + last_rx_time = start_time + + while True: + now = port._get_timestamp() + current_w = cb_w_abs(port._rx_buffer) + + # Check for new data + if current_w > start_w: + if not first_byte_seen: + first_byte_seen = True + first_byte_time = now + + # Update last RX time from reader thread + with port._lock: + last_rx_time = port._last_rx_timestamp + + # Grace period: wait for first byte before starting timeout + if grace_period and not first_byte_seen: + if (now - start_time) >= timeout_s: + # Grace expired, no data + if grace_expired_no_data: + return (Status.TIMEOUT_NO_DATA, None) + # Start regular timeout now + grace_period = False + start_time = now + time.sleep(0.001) + continue + + # Timeout mode: silence timeout + if mode == StopConditionMode.TIMEOUT: + if first_byte_seen: + silence = now - last_rx_time + if silence >= (timeout_s): + # Stop condition: timeout met + status, data = cb_copy_span(port._rx_buffer, start_w, current_w) + if status != BufferStatus.OK: + return (Status.IO_ERROR, None) + + return _create_packet_info( + port=port, + data=data, + start_time=first_byte_time, + stop_time=now, + stop_reason="timeout" + ) + else: + # No byte yet, check absolute timeout + if (now - start_time) >= timeout_s: + return (Status.TIMEOUT_NO_DATA, None) + + # Terminator mode: look for specific byte + elif mode == StopConditionMode.TERMINATOR: + if current_w > start_w: + # Read current data + status, data = cb_copy_span(port._rx_buffer, start_w, current_w) + if status != BufferStatus.OK: + return (Status.IO_ERROR, None) + + # Check for terminator + if port.config.stop_terminator in data: + return _create_packet_info( + port=port, + data=data, + start_time=first_byte_time if first_byte_seen else start_time, + stop_time=now, + stop_reason="terminator" + ) + + # Absolute timeout fallback + if (now - start_time) >= timeout_s: + return (Status.TIMEOUT, None) + + time.sleep(0.001) + + +def _create_packet_info(port: UARTPort, data: bytes, start_time: float, + stop_time: float, stop_reason: str) -> Tuple[Status, PacketInfo]: + """Create packet info and increment counter.""" + with port._lock: + port._total_packets += 1 + packet_id = port._total_packets + + packet = PacketInfo( + packet_id=packet_id, + start_timestamp=start_time, + stop_timestamp=stop_time, + data=data, + stop_reason=stop_reason + ) + + return (Status.OK, packet) + + +# ============================================================================= +# Status Query +# ============================================================================= + +def uart_get_status(port: UARTPort) -> PortStatus: + """ + Get current port status. + + Returns: + PortStatus with current metrics + """ + if not port._rx_buffer: + return PortStatus( + is_open=port._is_open, + is_reader_running=port._reader_running, + buffer_capacity=0, + buffer_fill_bytes=0, + buffer_fill_percent=0, + buffer_overflows=0, + total_bytes_received=0, + total_packets=0 + ) + + with port._lock: + total_bytes = port._total_bytes_received + total_packets = port._total_packets + + return PortStatus( + is_open=port._is_open, + is_reader_running=port._reader_running, + buffer_capacity=cb_capacity(port._rx_buffer), + buffer_fill_bytes=cb_fill_bytes(port._rx_buffer), + buffer_fill_percent=cb_fill_pct(port._rx_buffer), + buffer_overflows=cb_overflows(port._rx_buffer), + total_bytes_received=total_bytes, + total_packets=total_packets + ) diff --git a/uart/uart_kit/uart_core_widget.py b/uart/uart_kit/uart_core_widget.py new file mode 100644 index 0000000..bc947f8 --- /dev/null +++ b/uart/uart_kit/uart_core_widget.py @@ -0,0 +1,1068 @@ +#!/usr/bin/env python3 +""" +UART Widget (PyQt6) +=================== +GUI for UART port control with 3 modes: +- Request-Response: Send command, get response +- Polling: Continuous packet detection with grace period +- Listening: Raw stream, no stop condition +- **NEW: Listening with Packet Detection (optional)** + +Features: +- Port configuration (device, baud, etc.) +- Mode selection (fixed after connect) +- Packet detection configuration (optional, for listening mode) +- Buffer status (embedded compact widget) +- Packet history (configurable) +- Send panel with hex validation +- Console logging for all events +- Idle check (can't send while busy) +- Theme-ready (global theme controlled) + +Author: Kynsight +Version: 2.1.0 - Added packet detection support for listening mode +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QLabel, QComboBox, QPushButton, QLineEdit, + QGroupBox, QTextEdit, QSpinBox, QCheckBox +) +from PyQt6.QtCore import Qt, QTimer, pyqtSignal +from PyQt6.QtGui import QFont + +# Import UART core and buffer widget +from uart.uart_kit.uart_core import * +from buffer_kit.buffer_widget_compact import CompactBufferWidget +from buffer_kit.circular_buffer import cb_overflows + + +class UARTWidget(QWidget): + """ + UART port control widget. + + Signals: + packet_received: Emitted when packet detected (mode: request/poll) + data_received: Emitted when data read (mode: listening) + connection_changed: Emitted on connect/disconnect + """ + + # Qt signals (use 'object' for custom types) + packet_received = pyqtSignal(object) # PacketInfo object + data_received = pyqtSignal(bytes) + connection_changed = pyqtSignal(bool) # True=connected + + def __init__(self, parent=None): + super().__init__(parent) + + # Port state + self.port = None + self.is_connected = False + + # Packet history + self.packet_history = [] + self.max_packet_history = 200 + + # Command execution state (failsafe) + self._command_in_progress = False # True when waiting for response + + # Overflow tracking (for Data Monitor warnings) + self._last_overflow_count = 0 + + # Packet detection state (NEW) + self._packet_detection_enabled = False + self._detected_packet_count = 0 + + # Build UI + self._init_ui() + self._setup_timers() + + def _init_ui(self): + """Create user interface.""" + layout = QVBoxLayout() + self.setLayout(layout) + + # Configuration section (compact, horizontal) + config_group = self._create_config_section() + layout.addWidget(config_group) + + # Status + Buffer on same line + status_buffer_layout = QHBoxLayout() + + # Status label (left) + self.lbl_status = QLabel("Status: Disconnected") + status_buffer_layout.addWidget(self.lbl_status, stretch=2) + + # Buffer status - compact on right + buffer_group = self._create_buffer_section() + status_buffer_layout.addWidget(buffer_group, stretch=1) + + layout.addLayout(status_buffer_layout) + + # Data monitor (shows everything: packets, errors, info) + display_group = self._create_display_section() + layout.addWidget(display_group) + + # Send panel + send_group = self._create_send_section() + layout.addWidget(send_group) + + self.setMinimumWidth(800) # Wider for horizontal layout + + # ========================================================================= + # UI Creation + # ========================================================================= + + def _create_config_section(self): + """Port configuration controls - TWO ROWS, left-aligned.""" + group = QGroupBox("UART Configuration") + + # Vertical layout to hold 2 rows + main_layout = QVBoxLayout() + group.setLayout(main_layout) + + # ===================================================================== + # ROW 1: Port, Baud, Data, Stop, Parity + # ===================================================================== + row1 = QHBoxLayout() + row1.setAlignment(Qt.AlignmentFlag.AlignLeft) + + # Port + row1.addWidget(QLabel("Port:")) + self.combo_port = QComboBox() + self.combo_port.setMinimumWidth(150) + row1.addWidget(self.combo_port) + + self.btn_refresh = QPushButton("πŸ”„") + self.btn_refresh.setMaximumWidth(30) + self.btn_refresh.clicked.connect(self._refresh_ports) + row1.addWidget(self.btn_refresh) + + # Baud + row1.addWidget(QLabel("Baud:")) + self.combo_baud = QComboBox() + self.combo_baud.addItems(["9600", "19200", "38400", "57600", "115200", "230400"]) + self.combo_baud.setCurrentText("115200") + self.combo_baud.setMinimumWidth(90) + row1.addWidget(self.combo_baud) + + # Data bits + row1.addWidget(QLabel("Data:")) + self.combo_databits = QComboBox() + self.combo_databits.addItems(["7", "8"]) + self.combo_databits.setCurrentText("8") + self.combo_databits.setMaximumWidth(50) + row1.addWidget(self.combo_databits) + + # Stop bits + row1.addWidget(QLabel("Stop:")) + self.combo_stopbits = QComboBox() + self.combo_stopbits.addItems(["1", "2"]) + self.combo_stopbits.setMaximumWidth(50) + row1.addWidget(self.combo_stopbits) + + # Parity + row1.addWidget(QLabel("Parity:")) + self.combo_parity = QComboBox() + self.combo_parity.addItems(["None", "Even", "Odd"]) + self.combo_parity.setMaximumWidth(80) + row1.addWidget(self.combo_parity) + + # Stretch at end + row1.addStretch() + + main_layout.addLayout(row1) + + # ===================================================================== + # ROW 2: Mode, Stop, Term, Timeout, Grace, Connect/Disconnect + # ===================================================================== + row2 = QHBoxLayout() + row2.setAlignment(Qt.AlignmentFlag.AlignLeft) + + # Mode + row2.addWidget(QLabel("Mode:")) + self.combo_mode = QComboBox() + self.combo_mode.addItems(["Request-Response", "Polling", "Listening"]) + self.combo_mode.currentIndexChanged.connect(self._on_mode_changed) + self.combo_mode.setMinimumWidth(140) + row2.addWidget(self.combo_mode) + + # Add spacing to separate from next section + row2.addSpacing(65) + + # Stop condition + row2.addWidget(QLabel("Stop:")) + self.combo_stop = QComboBox() + self.combo_stop.addItems(["Timeout", "Terminator"]) + self.combo_stop.setMaximumWidth(100) + row2.addWidget(self.combo_stop) + + # Terminator + row2.addWidget(QLabel("Term:")) + self.edit_terminator = QLineEdit("0x0A") + self.edit_terminator.setMaximumWidth(60) + row2.addWidget(self.edit_terminator) + + # Timeout + row2.addWidget(QLabel("Timeout:")) + self.spin_timeout = QSpinBox() + self.spin_timeout.setRange(10, 10000) + self.spin_timeout.setValue(150) + self.spin_timeout.setSuffix(" ms") + self.spin_timeout.setMaximumWidth(90) + row2.addWidget(self.spin_timeout) + + # Grace + row2.addWidget(QLabel("Grace:")) + self.spin_grace = QSpinBox() + self.spin_grace.setRange(10, 10000) + self.spin_grace.setValue(150) + self.spin_grace.setSuffix(" ms") + self.spin_grace.setMaximumWidth(90) + row2.addWidget(self.spin_grace) + + # Connect/Disconnect + self.btn_connect = QPushButton("Connect") + self.btn_connect.clicked.connect(self._on_connect) + row2.addWidget(self.btn_connect) + + self.btn_disconnect = QPushButton("Disconnect") + self.btn_disconnect.clicked.connect(self._on_disconnect) + self.btn_disconnect.setEnabled(False) + row2.addWidget(self.btn_disconnect) + + # Stretch at end + row2.addStretch() + + main_layout.addLayout(row2) + + # ===================================================================== + # ROW 3: Packet Detection (Optional, only for Listening mode) + # ===================================================================== + row3 = QHBoxLayout() + row3.setAlignment(Qt.AlignmentFlag.AlignLeft) + + # Packet detection enable + self.check_packet_detect = QCheckBox("Enable Packet Detection") + self.check_packet_detect.setToolTip("Detect packets in listening mode with timestamps") + self.check_packet_detect.stateChanged.connect(self._on_packet_detect_toggled) + row3.addWidget(self.check_packet_detect) + + # Start marker + row3.addWidget(QLabel("Start:")) + self.edit_packet_start = QLineEdit("EF FE") + self.edit_packet_start.setMaximumWidth(80) + self.edit_packet_start.setToolTip("Start marker (hex, e.g., EF FE)") + row3.addWidget(self.edit_packet_start) + + # Packet length + row3.addWidget(QLabel("Length:")) + self.spin_packet_length = QSpinBox() + self.spin_packet_length.setRange(1, 1024) + self.spin_packet_length.setValue(17) + self.spin_packet_length.setSuffix(" B") + self.spin_packet_length.setMaximumWidth(80) + self.spin_packet_length.setToolTip("Total packet length in bytes") + row3.addWidget(self.spin_packet_length) + + # End marker + row3.addWidget(QLabel("End:")) + self.edit_packet_end = QLineEdit("EE") + self.edit_packet_end.setMaximumWidth(60) + self.edit_packet_end.setToolTip("End marker (hex, e.g., EE)") + row3.addWidget(self.edit_packet_end) + + # Packet count label (shows detected packets) + self.lbl_packet_count = QLabel("Packets: 0") + self.lbl_packet_count.setStyleSheet("color: gray; font-weight: bold;") + row3.addWidget(self.lbl_packet_count) + + # Stretch at end + row3.addStretch() + + # Initially disabled (only for Listening mode) + self.check_packet_detect.setEnabled(False) + self._on_packet_detect_toggled(0) # Disable fields + + main_layout.addLayout(row3) + + # Update UI based on initial mode + self._on_mode_changed(0) + + return group + + def _create_buffer_section(self): + """Buffer status display.""" + group = QGroupBox("Buffer Status") + layout = QVBoxLayout() + group.setLayout(layout) + + # Placeholder (replaced with CompactBufferWidget when connected) + self.buffer_widget = QLabel("Not connected") + layout.addWidget(self.buffer_widget) + + # Clear buffer button (only visible when connected) + self.btn_clear_buffer = QPushButton("Clear Buffer") + self.btn_clear_buffer.clicked.connect(self._on_clear_buffer) + self.btn_clear_buffer.setEnabled(False) + self.btn_clear_buffer.setMaximumHeight(25) + layout.addWidget(self.btn_clear_buffer) + + return group + + def _create_display_section(self): + """Data monitor - shows packets (RX/TX), errors, info with colors.""" + group = QGroupBox("Data Monitor (TX=Green, RX=Blue, Errors=Red, Info=Gray)") + layout = QVBoxLayout() + group.setLayout(layout) + + # Display area FIRST (takes most vertical space) + self.text_display = QTextEdit() + self.text_display.setReadOnly(True) + self.text_display.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) # Enable word wrap + font = QFont("Courier New", 9) + self.text_display.setFont(font) + layout.addWidget(self.text_display) + + # Controls at BOTTOM (single line, compact) + ctrl_layout = QHBoxLayout() + + ctrl_layout.addWidget(QLabel("History:")) + self.spin_history = QSpinBox() + self.spin_history.setRange(10, 500) + self.spin_history.setValue(200) + self.spin_history.valueChanged.connect(self._on_history_changed) + self.spin_history.setMaximumWidth(70) + ctrl_layout.addWidget(self.spin_history) + + self.btn_clear_display = QPushButton("Clear") + self.btn_clear_display.setMaximumWidth(80) + self.btn_clear_display.clicked.connect(self._clear_display) + ctrl_layout.addWidget(self.btn_clear_display) + + ctrl_layout.addStretch() + + layout.addLayout(ctrl_layout) + + return group + + def _create_send_section(self): + """Send data controls with format selector.""" + group = QGroupBox("Send Data") + layout = QHBoxLayout() + group.setLayout(layout) + + layout.addWidget(QLabel("Data:")) + + self.edit_send = QLineEdit() + self.edit_send.returnPressed.connect(self._on_send) # Enter key sends + layout.addWidget(self.edit_send) + + self.combo_format = QComboBox() + self.combo_format.addItems(["Text", "Hex"]) + layout.addWidget(self.combo_format) + + self.btn_send = QPushButton("Send") + self.btn_send.clicked.connect(self._on_send) + self.btn_send.setEnabled(False) + layout.addWidget(self.btn_send) + + return group + + def _setup_timers(self): + """Setup update timers for different modes.""" + # Listening mode update timer + self.listen_timer = QTimer() + self.listen_timer.timeout.connect(self._update_listening_display) + + # Polling mode timer + self.poll_timer = QTimer() + self.poll_timer.timeout.connect(self._poll_next_packet) + + # ========================================================================= + # Port Management + # ========================================================================= + + def _refresh_ports(self): + """Refresh available serial ports with device descriptions.""" + self.combo_port.clear() + + # Try to auto-detect ports with descriptions + try: + import serial.tools.list_ports + ports = serial.tools.list_ports.comports() + for port in ports: + # Build a clean description from manufacturer and product + desc_parts = [] + if port.manufacturer and port.manufacturer != "n/a": + desc_parts.append(port.manufacturer) + if port.product and port.product != "n/a" and port.product != port.manufacturer: + desc_parts.append(port.product) + + # Format: "/dev/ttyUSB0 - FTDI FT232R USB UART" or just "/dev/ttyUSB0" + if desc_parts: + display_text = f"{port.device} - {' '.join(desc_parts)}" + else: + display_text = port.device + + self.combo_port.addItem(display_text, port.device) + except: + pass + + # Add common Linux ports (USB and ACM) without descriptions + for i in range(4): + device = f"/dev/ttyUSB{i}" + # Only add if not already in list + if self.combo_port.findData(device) == -1: + self.combo_port.addItem(device, device) + + device = f"/dev/ttyACM{i}" + if self.combo_port.findData(device) == -1: + self.combo_port.addItem(device, device) + + # Add Raspberry Pi serial ports + for device in ["/dev/serial0", "/dev/ttyAMA0"]: + if self.combo_port.findData(device) == -1: + self.combo_port.addItem(device, device) + + def _on_connect(self): + """Connect to serial port with current configuration.""" + self._log_info("Attempting to connect...") + + # Get configuration from UI + # Use currentData() to get actual device path (not display text) + device = self.combo_port.currentData() + if not device: # Fallback to text if no data stored + device = self.combo_port.currentText() + baudrate = int(self.combo_baud.currentText()) + + self._log_info(f"Device: {device}, Baud: {baudrate}") + + # Get mode + mode_idx = self.combo_mode.currentIndex() + polling_mode = (mode_idx == 1) # Polling + listening_mode = (mode_idx == 2) # Listening + + mode_name = ["Request-Response", "Polling", "Listening"][mode_idx] + self._log_info(f"Mode: {mode_name}") + + # Get stop condition + if listening_mode: + # No stop condition in listening mode + stop_mode = StopConditionMode.TIMEOUT + else: + stop_mode = (StopConditionMode.TIMEOUT if self.combo_stop.currentIndex() == 0 + else StopConditionMode.TERMINATOR) + + # Parse terminator (accept hex or decimal) + term_text = self.edit_terminator.text().strip() + try: + if term_text.startswith("0x"): + terminator = int(term_text, 16) + else: + terminator = int(term_text) + except: + terminator = 0x0A # Default to newline + self._log_info(f"Invalid terminator, using default 0x0A") + + # Create UART config + config = UARTConfig( + device=device, + baudrate=baudrate, + buffer_size=4096, + stop_mode=stop_mode, + stop_timeout_ms=self.spin_timeout.value(), + stop_terminator=terminator, + polling_mode=polling_mode, + grace_timeout_ms=self.spin_grace.value() + ) + + # Create port + status, self.port = uart_create(config) + if status != Status.OK: + self._show_error(f"Failed to create port: {status}") + return + + # Open port + status = uart_open(self.port) + if status != Status.OK: + self._show_error(f"Failed to open port: {status}") + return + + # Start reader thread + status = uart_start_reader(self.port) + if status != Status.OK: + uart_close(self.port) + self._show_error(f"Failed to start reader: {status}") + return + + # Replace buffer label with actual widget + self._update_buffer_widget() + + # Update UI state + self.is_connected = True + self._command_in_progress = False # Reset on connect + self._last_overflow_count = 0 # Reset overflow tracking + self._update_ui_state() + self.lbl_status.setText("Status: ● Connected") + self.lbl_status.setStyleSheet("color: green;") + + self._log_info(f"βœ“ Connected successfully!") + + # Start mode-specific operation + if listening_mode: + # Listening mode: Raw data, print everything, NO packet detection + uart_start_listening(self.port) + self._packet_detection_enabled = False + self.listen_timer.start(100) + self._log_info("Listening mode started (raw data, prints to monitor)") + elif polling_mode: + # Polling mode: Packet detection, NO printing + # Check if packet detection is enabled + packet_detect_enabled = self.check_packet_detect.isChecked() + + if packet_detect_enabled: + # Parse packet configuration + try: + # Parse start marker + start_str = self.edit_packet_start.text().strip().replace(' ', '') + start_marker = bytes.fromhex(start_str) + + # Parse end marker + end_str = self.edit_packet_end.text().strip().replace(' ', '') + end_marker = bytes.fromhex(end_str) if end_str else None + + # Get packet length + packet_length = self.spin_packet_length.value() + + # Create packet config + packet_config = PacketConfig( + enable=True, + start_marker=start_marker, + packet_length=packet_length, + end_marker=end_marker + ) + + # Clear previous packets + uart_clear_detected_packets(self.port) + self._detected_packet_count = 0 + self.lbl_packet_count.setText("Packets: 0") + + # Start listening with packet detection + uart_start_listening_with_packets(self.port, packet_config) + self._packet_detection_enabled = True + self.poll_timer.start(50) # Poll for packets + self._log_info(f"Polling mode started with packet detection") + self._log_info(f" Start: {start_marker.hex(' ')}, Length: {packet_length}, End: {end_marker.hex(' ') if end_marker else 'None'}") + + except Exception as e: + self._show_error(f"Invalid packet config: {e}") + # Fallback to regular polling + self.poll_timer.start(50) + self._packet_detection_enabled = False + self._log_warning("Fallback to polling mode without packet detection") + else: + # Regular polling mode (use uart_poll_packet) + self.poll_timer.start(50) + self._packet_detection_enabled = False + self._log_info("Polling mode started (no packet detection)") + self.text_display.append("[INFO] Logger Started") + else: + # Request-response mode (handled per-command) + pass + + # Emit connection changed signal + self.connection_changed.emit(True) + + def _on_disconnect(self): + """Disconnect from serial port.""" + self._log_info("Disconnecting...") + + if not self.port: + return + + # Stop timers + self.listen_timer.stop() + self.poll_timer.stop() + + # Get mode + mode_idx = self.combo_mode.currentIndex() + + # Stop listening/polling and show summary + if mode_idx == 2: # Listening mode + uart_stop_listening(self.port) + self._log_info("Listening stopped") + elif mode_idx == 1: # Polling mode + # Show packet detection summary if enabled + if self._packet_detection_enabled: + uart_stop_listening(self.port) # Stop packet detection + packets = uart_get_detected_packets(self.port) + packet_count = len(packets) + self._log_info(f"βœ“ Run complete: {packet_count} packets detected") + self.lbl_status.setText(f"Disconnected - {packet_count} packets") + self.lbl_status.setStyleSheet("color: green;") + + # Close port + uart_stop_reader(self.port) + uart_close(self.port) + + # Reset state + self.port = None + self.is_connected = False + self._command_in_progress = False + self._packet_detection_enabled = False + self._detected_packet_count = 0 + + # Update UI + self._update_ui_state() + if not (mode_idx == 1 and self._packet_detection_enabled): + self.lbl_status.setText("Status: Disconnected") + self.lbl_status.setStyleSheet("") + + self._log_info("βœ“ Disconnected") + + # Replace buffer widget with placeholder + if isinstance(self.buffer_widget, CompactBufferWidget): + self.buffer_widget.setParent(None) + self.buffer_widget = QLabel("Not connected") + status_buffer_layout = self.layout().itemAt(1) + buffer_group_widget = status_buffer_layout.itemAt(1).widget() + buffer_group_widget.layout().addWidget(self.buffer_widget) + + # Emit connection changed signal + self.connection_changed.emit(False) + + # ========================================================================= + # UI State Management + # ========================================================================= + + def _update_ui_state(self): + """Enable/disable controls based on connection state.""" + connected = self.is_connected + + # Disable configuration when connected + self.combo_port.setEnabled(not connected) + self.btn_refresh.setEnabled(not connected) + self.combo_baud.setEnabled(not connected) + self.combo_databits.setEnabled(not connected) + self.combo_stopbits.setEnabled(not connected) + self.combo_parity.setEnabled(not connected) + self.combo_mode.setEnabled(not connected) + self.combo_stop.setEnabled(not connected) + self.edit_terminator.setEnabled(not connected) + self.spin_timeout.setEnabled(not connected) + self.spin_grace.setEnabled(not connected) + + # Connect/disconnect buttons + self.btn_connect.setEnabled(not connected) + self.btn_disconnect.setEnabled(connected) + + # Send button (enabled when connected AND idle) + self.btn_send.setEnabled(connected and not self._command_in_progress) + + # Clear buffer button (enabled when connected) + self.btn_clear_buffer.setEnabled(connected) + + 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 + self.combo_stop.setEnabled(False) + self.edit_terminator.setEnabled(False) + self.spin_timeout.setEnabled(False) + self.spin_grace.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) + elif index == 1: # Polling - packet detection mode, no printing + self.combo_stop.setEnabled(True) + self.edit_terminator.setEnabled(True) + self.spin_timeout.setEnabled(True) + self.spin_grace.setEnabled(True) + # Enable packet detection (THIS is the mode for packet detection) + self.check_packet_detect.setEnabled(True) + else: # Request-Response - enable stop condition, disable grace + self.combo_stop.setEnabled(True) + self.edit_terminator.setEnabled(True) + self.spin_timeout.setEnabled(True) + self.spin_grace.setEnabled(False) + # Disable packet detection (not for request-response) + self.check_packet_detect.setEnabled(False) + self.check_packet_detect.setChecked(False) + + def _on_packet_detect_toggled(self, state): + """Enable/disable packet detection fields based on checkbox.""" + enabled = (state == Qt.CheckState.Checked.value) + self.edit_packet_start.setEnabled(enabled) + self.spin_packet_length.setEnabled(enabled) + self.edit_packet_end.setEnabled(enabled) + + def _update_buffer_widget(self): + """Replace placeholder label with actual buffer widget.""" + if not self.port or not isinstance(self.buffer_widget, QLabel): + return + + # Remove old label + self.buffer_widget.setParent(None) + + # Create compact buffer widget + self.buffer_widget = CompactBufferWidget(self.port._rx_buffer) + + # Find buffer group (in horizontal layout at index 1, second widget) + status_buffer_layout = self.layout().itemAt(1) + buffer_group_widget = status_buffer_layout.itemAt(1).widget() + buffer_group_widget.layout().addWidget(self.buffer_widget) + + # ========================================================================= + # Data Display + # ========================================================================= + + def _update_listening_display(self): + """Update display in listening mode (continuous stream with printing).""" + if not self.port: + return + + # Update buffer widget + if isinstance(self.buffer_widget, CompactBufferWidget): + self.buffer_widget.update_display() + + # Read current buffer contents and PRINT to monitor (Listening mode behavior) + status, data = uart_read_buffer(self.port, max_bytes=4096) + + if status == Status.OK and data: + # Listening mode ALWAYS prints to data monitor + self._log_rx(data, info="Listening") + # Emit signal + self.data_received.emit(data) + + def _poll_next_packet(self): + """Poll for next packet in polling mode (NO printing to monitor).""" + if not self.port: + return + + # Update buffer widget + if isinstance(self.buffer_widget, CompactBufferWidget): + self.buffer_widget.update_display() + + # If packet detection is enabled, update packet count (NO printing) + if self._packet_detection_enabled: + packets = uart_get_detected_packets(self.port) + current_count = len(packets) + + if current_count != self._detected_packet_count: + # New packets detected - update counter only, NO printing + self._detected_packet_count = current_count + self.lbl_packet_count.setText(f"Packets: {current_count}") + self.lbl_packet_count.setStyleSheet("color: green; font-weight: bold;") + return + + # Regular polling mode (without packet detection) + # Get current overflow count + overflow_count = cb_overflows(self.port._rx_buffer) + + # Check if overflow increased (new overflow occurred) + if overflow_count > self._last_overflow_count: + overflow_delta = overflow_count - self._last_overflow_count + # Display RED warning in Data Monitor + overflow_warning = ( + f"" + f"[WARNING] ⚠ BUFFER OVERFLOW! Lost data. " + f"Overflow count: {overflow_count} (+{overflow_delta})" + f"" + ) + self.text_display.append(overflow_warning) + self._last_overflow_count = overflow_count + + # Poll for packet (non-blocking) + status, packet = uart_poll_packet(self.port) + + if status == Status.OK: + # Display stop condition message in gray (logging mode only) + stop_msg = ( + f"" + f"[INFO] Stop condition detected | " + f"UART Status: IDLE | " + f"Registered Bytes: {len(packet.data)} | " + f"Overflows: {overflow_count}" + f"" + ) + self.text_display.append(stop_msg) + + # Update buffer widget + self.buffer_widget.update_display() + + # Emit packet signal + self.packet_received.emit(packet) + + def _display_packet(self, packet): + """Display received packet in monitor (blue for RX) - same format as TX.""" + # Add to history + self.packet_history.append(packet) + if len(self.packet_history) > self.max_packet_history: + self.packet_history.pop(0) + + # Use same format as TX - just show data + self._log_rx(packet.data) + + def _clear_display(self): + """Clear display area and history.""" + self.text_display.clear() + self.packet_history.clear() + + def _on_history_changed(self, value): + """Update maximum packet history size.""" + self.max_packet_history = value + + def _on_clear_buffer(self): + """Clear the circular buffer and reset overflow counter.""" + if not self.port or not self.port._rx_buffer: + return + + from buffer_kit.circular_buffer import cb_reset + + # Reset buffer (clears data and overflow count) + cb_reset(self.port._rx_buffer) + + # Reset our overflow tracking + self._last_overflow_count = 0 + + # Update buffer display + if isinstance(self.buffer_widget, CompactBufferWidget): + self.buffer_widget.update_display() + + # Log action + self._log_info("Buffer cleared (data and overflow count reset)") + self.lbl_status.setText("βœ“ Buffer cleared") + self.lbl_status.setStyleSheet("color: green;") + + # ========================================================================= + # Send Operations + # ========================================================================= + + def _parse_hex_string(self, hex_str: str): + """ + Parse hex string with validation. + + Accepts: + "AF 9B FF 89 AA" (space-separated) + "AF9BFF89AA" (no spaces) + "af9bff89aa" (lowercase OK) + + Returns: + (success: bool, data: bytes, error_message: str) + """ + hex_str = hex_str.strip() + + if not hex_str: + return (False, b"", "Empty hex string") + + # Remove all whitespace + hex_clean = ''.join(hex_str.split()) + + # Validate hex characters only + if not all(c in '0123456789ABCDEFabcdef' for c in hex_clean): + return (False, b"", "Invalid hex characters (use 0-9, A-F)") + + # Must have even number of characters (pairs) + if len(hex_clean) % 2 != 0: + return (False, b"", "Odd number of hex digits (need pairs)") + + # Convert to bytes + try: + data = bytes.fromhex(hex_clean) + return (True, data, "") + except ValueError as e: + return (False, b"", f"Hex parse error: {e}") + + def _on_send(self): + """ + Send data with validation and idle check. + + Features: + - Hex validation (AF 9B FF format) + - Length limit (500 bytes max) + - Idle check (can't send while busy) + - Console logging + - Auto-clear on success + """ + MAX_SEND_LENGTH = 500 # Maximum bytes to send + + # Check if port is connected + if not self.port: + self._show_error("Port not connected") + return + + # FAILSAFE: Check if port is idle (not waiting for response) + if self._command_in_progress: + self._show_error("UART busy! Wait for current command to complete") + return + + # Get input text + text = self.edit_send.text().strip() + if not text: + self._show_error("Empty send field") + return + + # Parse data based on format (Text or Hex) + if self.combo_format.currentText() == "Hex": + # Parse hex with validation + success, data, error = self._parse_hex_string(text) + if not success: + self._show_error(f"Hex format error: {error}") + return + else: + # Text mode - encode as UTF-8 + data = text.encode('utf-8') + + # Check length limit + if len(data) > MAX_SEND_LENGTH: + self._show_error(f"Data too long! Max {MAX_SEND_LENGTH} bytes, got {len(data)}") + return + + # Get current mode + mode_idx = self.combo_mode.currentIndex() + + # ===================================================================== + # Request-Response Mode: Send and wait for response + # ===================================================================== + if mode_idx == 0: + # Mark as busy (prevent multiple sends) + self._command_in_progress = True + self.btn_send.setEnabled(False) + self.lbl_status.setText("Sending command...") + + # Log TX in green + self._log_tx(data) + + # Send command and wait for response + status, packet = uart_send_and_receive(self.port, data) + + # Mark as idle (allow next send) + self._command_in_progress = False + self.btn_send.setEnabled(True) + + # Handle result + if status == Status.OK: + # Success - display packet (will be blue RX) + self._display_packet(packet) + self.buffer_widget.update_display() + self.packet_received.emit(packet) + self.edit_send.clear() # Clear input on success + #self._log_info(f"Response received: {len(packet.data)} bytes") + self.lbl_status.setText(f"βœ“ TX: {len(data)}B, RX: {len(packet.data)}B") + self.lbl_status.setStyleSheet("color: green;") + elif status == Status.TIMEOUT: + # Timeout - no response received + self._log_warning("No response received (timeout)") + self.lbl_status.setText("Timeout!") + self.lbl_status.setStyleSheet("color: orange;") + else: + # Other error + self._show_error(f"Send failed: {status}") + + # ===================================================================== + # Polling or Listening Mode: Just write data + # ===================================================================== + else: + # Log TX in green + self._log_tx(data) + + status, written = uart_write(self.port, data) + + if status == Status.OK: + self.edit_send.clear() # Clear input on success + self.lbl_status.setText(f"βœ“ Sent {written} bytes") + self.lbl_status.setStyleSheet("color: green;") + self._log_info(f"Write successful: {written} bytes") + else: + self._show_error(f"Write failed: {status}") + + # ========================================================================= + # Logging & Error Handling + # ========================================================================= + + def _show_error(self, message: str): + """Show error in status bar and data monitor (red).""" + self.lbl_status.setText(f"Error: {message}") + self.lbl_status.setStyleSheet("color: red;") + self.text_display.append(f"[ERROR] {message}") + + def _log_info(self, message: str): + """Log info to data monitor (gray).""" + self.text_display.append(f"[INFO] {message}") + + def _log_warning(self, message: str): + """Log warning to data monitor (orange).""" + self.text_display.append(f"[WARNING] {message}") + + def _log_tx(self, data: bytes): + """Log transmitted data (green) - full data, no truncation.""" + hex_str = data.hex(' ') # Show ALL data + self.text_display.append(f"[TX] {len(data)}B: {hex_str}") + + def _log_rx(self, data: bytes, info: str = ""): + """Log received data (blue) - full data, no truncation.""" + hex_str = data.hex(' ') # Show ALL data + prefix = f"[RX] {info} " if info else "[RX] " + self.text_display.append(f"{prefix}{len(data)}B: {hex_str}") + + # ========================================================================= + # Public API + # ========================================================================= + + def get_port(self): + """Get current port handle (for external access).""" + return self.port + + def is_port_connected(self) -> bool: + """Check if port is connected.""" + return self.is_connected + + def update_buffer_display(self): + """Manually update buffer widget (call from external timer if needed).""" + if isinstance(self.buffer_widget, CompactBufferWidget): + self.buffer_widget.update_display() + + def get_detected_packets(self): + """ + Get detected packets (if packet detection is enabled). + + Returns: + List of PacketInfo objects, or empty list if not enabled + """ + if self.port and self._packet_detection_enabled: + return uart_get_detected_packets(self.port) + return [] + + def clear_detected_packets(self): + """ + Clear detected packets list. + + Useful when starting a new run/session. + """ + if self.port and self._packet_detection_enabled: + uart_clear_detected_packets(self.port) + self._detected_packet_count = 0 + self.lbl_packet_count.setText("Packets: 0") + self._log_info("Packet list cleared") + + +# ============================================================================= +# Demo +# ============================================================================= + +if __name__ == "__main__": + import sys + from PyQt6.QtWidgets import QApplication + + app = QApplication(sys.argv) + + widget = UARTWidget() + widget.setWindowTitle("UART Control") + widget.show() + + # Auto-refresh ports on startup + widget._refresh_ports() + + sys.exit(app.exec()) diff --git a/uart/uart_kit/uart_test.py b/uart/uart_kit/uart_test.py new file mode 100644 index 0000000..c3f3bfd --- /dev/null +++ b/uart/uart_kit/uart_test.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +""" +UART Core Usage Examples +======================== +Shows how to use the new UART core. +""" + +import time +from uart_core import * + + +# ============================================================================= +# Example 1: Request-Response (Command Port) +# ============================================================================= + +def example_command_port(): + """UART command port - send command, get response.""" + print("\n" + "="*60) + print("Example 1: Command Port (Request-Response)") + print("="*60) + + # Configure for terminator-based stop (newline) + config = UARTConfig( + device="/dev/ttyUSB0", + baudrate=115200, + buffer_size=4096, + stop_mode=StopConditionMode.TERMINATOR, + stop_terminator=0x0A, # '\n' + stop_timeout_ms=1000, # Fallback timeout + polling_mode=False # No grace period + ) + + # Create and open + status, port = uart_create(config) + status = uart_open(port) + status = uart_start_reader(port) + + # Send command and wait for response + status, packet = uart_send_and_receive(port, b"GET_STATUS\n") + + if status == Status.OK: + print(f"Packet #{packet.packet_id}") + print(f"Start: {packet.start_timestamp:.6f}s") + print(f"Stop: {packet.stop_timestamp:.6f}s") + print(f"Duration: {(packet.stop_timestamp - packet.start_timestamp)*1000:.2f}ms") + print(f"Data: {packet.data}") + print(f"Reason: {packet.stop_reason}") + + # Cleanup + uart_stop_reader(port) + uart_close(port) + + +# ============================================================================= +# Example 2: Polling (Debug Port) +# ============================================================================= + +def example_debug_port(): + """UART debug port - continuous polling.""" + print("\n" + "="*60) + print("Example 2: Debug Port (Polling)") + print("="*60) + + # Configure for timeout-based stop with grace period + config = UARTConfig( + device="/dev/ttyUSB1", + baudrate=115200, + buffer_size=8192, + stop_mode=StopConditionMode.TIMEOUT, + stop_timeout_ms=150, # 150ms silence + grace_timeout_ms=150, # Wait up to 150ms for first byte + polling_mode=True # Enable grace period + ) + + status, port = uart_create(config) + uart_open(port) + uart_start_reader(port) + + # Poll for packets continuously + for i in range(5): + status, packet = uart_poll_packet(port) + + if status == Status.OK: + print(f"\nPacket #{packet.packet_id}") + print(f" Data: {packet.data[:50]}...") # First 50 bytes + print(f" Length: {len(packet.data)} bytes") + print(f" Duration: {(packet.stop_timestamp - packet.start_timestamp)*1000:.2f}ms") + elif status == Status.TIMEOUT_NO_DATA: + print(f"\nNo data (timeout)") + + time.sleep(0.1) + + uart_stop_reader(port) + uart_close(port) + + +# ============================================================================= +# Example 3: vzug-e-hinge Setup (Two Ports) +# ============================================================================= + +def example_vzug_e_hinge(): + """Full vzug-e-hinge setup with command and debug ports.""" + print("\n" + "="*60) + print("Example 3: vzug-e-hinge (Command + Debug)") + print("="*60) + + # Command port config + cmd_config = UARTConfig( + device="/dev/ttyUSB0", + baudrate=115200, + stop_mode=StopConditionMode.TERMINATOR, + stop_terminator=0x0A, + polling_mode=False + ) + + # Debug port config + debug_config = UARTConfig( + device="/dev/ttyUSB1", + baudrate=115200, + stop_mode=StopConditionMode.TIMEOUT, + stop_timeout_ms=150, + grace_timeout_ms=150, + polling_mode=True + ) + + # Create both ports + status, uart_cmd = uart_create(cmd_config) + status, uart_debug = uart_create(debug_config) + + # Open and start readers + uart_open(uart_cmd) + uart_open(uart_debug) + uart_start_reader(uart_cmd) + uart_start_reader(uart_debug) + + # Command example + print("\n--- Sending Command ---") + status, packet = uart_send_and_receive(uart_cmd, b"GET_ANGLE\n") + if status == Status.OK: + print(f"Response: {packet.data}") + + # Poll debug data + print("\n--- Polling Debug ---") + status, packet = uart_poll_packet(uart_debug) + if status == Status.OK: + print(f"Debug data: {packet.data[:100]}") + + # Check status + cmd_status = uart_get_status(uart_cmd) + debug_status = uart_get_status(uart_debug) + + print(f"\n--- Port Status ---") + print(f"Command: {cmd_status.total_packets} packets, " + f"{cmd_status.total_bytes_received} bytes, " + f"buffer {cmd_status.buffer_fill_percent}%") + print(f"Debug: {debug_status.total_packets} packets, " + f"{debug_status.total_bytes_received} bytes, " + f"buffer {debug_status.buffer_fill_percent}%") + + # Cleanup + uart_stop_reader(uart_cmd) + uart_stop_reader(uart_debug) + uart_close(uart_cmd) + uart_close(uart_debug) + + +# ============================================================================= +# Example 4: Custom Timestamp Source +# ============================================================================= + +def example_custom_timestamp(): + """Use custom timestamp source (for syncing with I2C).""" + print("\n" + "="*60) + print("Example 4: Custom Timestamp Source") + print("="*60) + + # Global time reference (shared with I2C, etc.) + class GlobalClock: + def __init__(self): + self.offset = time.perf_counter() + + def now(self): + return time.perf_counter() - self.offset + + clock = GlobalClock() + + # Configure UART with custom clock + config = UARTConfig( + device="/dev/ttyUSB0", + baudrate=115200, + stop_mode=StopConditionMode.TERMINATOR, + stop_terminator=0x0A, + timestamp_source=clock.now # ← Custom time function + ) + + status, port = uart_create(config) + uart_open(port) + uart_start_reader(port) + + # All timestamps now use clock.now() + status, packet = uart_send_and_receive(port, b"TEST\n") + + if status == Status.OK: + print(f"Timestamp: {packet.start_timestamp:.6f}s (from global clock)") + print(f"I2C can use same clock.now() for sync!") + + uart_stop_reader(port) + uart_close(port) + + +# ============================================================================= +# Example 5: Error Handling +# ============================================================================= + +def example_error_handling(): + """Handle disconnections and errors.""" + print("\n" + "="*60) + print("Example 5: Error Handling") + print("="*60) + + config = UARTConfig( + device="/dev/ttyUSB0", + baudrate=115200, + stop_mode=StopConditionMode.TIMEOUT, + stop_timeout_ms=500 + ) + + status, port = uart_create(config) + + # Check creation status + if status != Status.OK: + print(f"Failed to create: {status}") + return + + # Try to open + status = uart_open(port) + if status != Status.OK: + print(f"Failed to open: {status}") + return + + uart_start_reader(port) + + # Try to send + status, written = uart_write(port, b"TEST\n") + if status != Status.OK: + print(f"Write failed: {status}") + uart_close(port) + return + + # Poll for response + status, packet = uart_poll_packet(port) + + if status == Status.TIMEOUT_NO_DATA: + print("No response received (timeout)") + elif status == Status.OK: + print(f"Received: {packet.data}") + else: + print(f"Error: {status}") + + # Check buffer status + port_status = uart_get_status(port) + if port_status.buffer_overflows > 0: + print(f"WARNING: {port_status.buffer_overflows} buffer overflows!") + + uart_stop_reader(port) + uart_close(port) + + +# ============================================================================= +# Main +# ============================================================================= + +if __name__ == "__main__": + print("\nUART Core v2.0 Examples") + print("=======================") + + # Run examples (comment out as needed) + try: + example_command_port() + except Exception as e: + print(f"Example 1 error: {e}") + + try: + example_debug_port() + except Exception as e: + print(f"Example 2 error: {e}") + + try: + example_vzug_e_hinge() + except Exception as e: + print(f"Example 3 error: {e}") + + try: + example_custom_timestamp() + except Exception as e: + print(f"Example 4 error: {e}") + + try: + example_error_handling() + except Exception as e: + print(f"Example 5 error: {e}") + + print("\n" + "="*60) + print("Done!") + print("="*60) diff --git a/vzug-e-hinge.zip b/vzug-e-hinge.zip new file mode 100644 index 0000000..774df55 Binary files /dev/null and b/vzug-e-hinge.zip differ