parent
cb89c5c0ff
commit
7b6709973b
@ -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()
|
||||||
@ -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)
|
||||||
|
"""
|
||||||
@ -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 <text> - Write text (e.g., 'w HELLO')")
|
||||||
|
print(" r - Read all data")
|
||||||
|
print(" r <n> - 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 <text>")
|
||||||
|
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()
|
||||||
@ -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())
|
||||||
@ -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())
|
||||||
Binary file not shown.
Binary file not shown.
@ -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
|
||||||
@ -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")
|
||||||
@ -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!")
|
||||||
@ -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")
|
||||||
@ -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)")
|
||||||
@ -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())
|
||||||
@ -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
|
||||||
|
)
|
||||||
@ -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"<span style='color: red; font-weight: bold;'>"
|
||||||
|
f"[WARNING] ⚠ BUFFER OVERFLOW! Lost data. "
|
||||||
|
f"Overflow count: {overflow_count} (+{overflow_delta})"
|
||||||
|
f"</span>"
|
||||||
|
)
|
||||||
|
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"<span style='color: gray;'>[INFO] {message}</span>")
|
||||||
|
|
||||||
|
def _log_warning(self, message: str):
|
||||||
|
"""Log warning (orange)."""
|
||||||
|
self.text_display.append(f"<span style='color: orange;'>[WARNING] {message}</span>")
|
||||||
|
|
||||||
|
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"<span style='color: red;'>[ERROR] {message}</span>")
|
||||||
|
|
||||||
|
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"<span style='color: green;'>{prefix}{len(data)}B: {hex_str}</span>")
|
||||||
|
|
||||||
|
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"<span style='color: blue;'>{prefix}{len(data)}B: {hex_str}</span>")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 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())
|
||||||
@ -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()
|
||||||
@ -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!")
|
||||||
@ -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
|
||||||
@ -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!")
|
||||||
@ -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())
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||||
Binary file not shown.
Loading…
Reference in new issue