From 7b6709973b017ad0e8108875c5682d5885391f94 Mon Sep 17 00:00:00 2001 From: Kerem Date: Sun, 9 Nov 2025 18:48:24 +0100 Subject: [PATCH] first --- buffer_kit/buffer_widget_compact.py | 293 +++++++ buffer_kit/circular_buffer.py | 618 +++++++++++++++ buffer_kit/circular_buffer_test.py | 264 +++++++ command_table/command_table.py | 479 ++++++++++++ configure_interface_widget.py | 557 ++++++++++++++ configure_session_widget.py | 372 +++++++++ database/database/ehinge.db | Bin 0 -> 98304 bytes database/ehinge.db | Bin 0 -> 98304 bytes database/init_database.py | 759 ++++++++++++++++++ decoder.py | 171 +++++ global_clock.py | 131 ++++ graph/graph_kit/graph_core.py | 539 +++++++++++++ graph/graph_kit/graph_core_widget.py | 921 ++++++++++++++++++++++ graph_table_query.py | 720 +++++++++++++++++ i2c/i2c_integrated_widget.py | 185 +++++ i2c/i2c_kit/i2c_core.py | 684 +++++++++++++++++ i2c/i2c_kit/i2c_core_widget.py | 870 +++++++++++++++++++++ main.py | 643 ++++++++++++++++ run.py | 451 +++++++++++ send.sh | 2 + session.py | 890 +++++++++++++++++++++ session_widget.py | 542 +++++++++++++ uart/uart_integrated_widget.py | 160 ++++ uart/uart_kit/uart_core.py | 1057 +++++++++++++++++++++++++ uart/uart_kit/uart_core_widget.py | 1068 ++++++++++++++++++++++++++ uart/uart_kit/uart_test.py | 305 ++++++++ vzug-e-hinge.zip | Bin 0 -> 104684 bytes 27 files changed, 12681 insertions(+) create mode 100644 buffer_kit/buffer_widget_compact.py create mode 100644 buffer_kit/circular_buffer.py create mode 100644 buffer_kit/circular_buffer_test.py create mode 100644 command_table/command_table.py create mode 100644 configure_interface_widget.py create mode 100644 configure_session_widget.py create mode 100644 database/database/ehinge.db create mode 100644 database/ehinge.db create mode 100644 database/init_database.py create mode 100644 decoder.py create mode 100644 global_clock.py create mode 100644 graph/graph_kit/graph_core.py create mode 100644 graph/graph_kit/graph_core_widget.py create mode 100644 graph_table_query.py create mode 100644 i2c/i2c_integrated_widget.py create mode 100644 i2c/i2c_kit/i2c_core.py create mode 100644 i2c/i2c_kit/i2c_core_widget.py create mode 100644 main.py create mode 100644 run.py create mode 100755 send.sh create mode 100644 session.py create mode 100644 session_widget.py create mode 100644 uart/uart_integrated_widget.py create mode 100644 uart/uart_kit/uart_core.py create mode 100644 uart/uart_kit/uart_core_widget.py create mode 100644 uart/uart_kit/uart_test.py create mode 100644 vzug-e-hinge.zip 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 0000000000000000000000000000000000000000..e48a96a2595b099a24dcc68e2aee7115520a2d47 GIT binary patch literal 98304 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..774df558a01a61cceaa0669f81aa5fe6a4bfe8d6 GIT binary patch literal 104684 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 E2Y5!Zvj6}9 literal 0 HcmV?d00001