Kynsight 1 month ago
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())

@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""
Configure Session Widget - vzug-e-hinge
========================================
Visual command sequence builder.
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel,
QLineEdit, QPushButton, QListWidget, QListWidgetItem,
QMessageBox, QSplitter, QFormLayout, QSpinBox, QComboBox
)
from PyQt6.QtCore import Qt, pyqtSignal
import json
from database.init_database import DatabaseManager
class ConfigureSessionWidget(QWidget):
"""Visual command sequence builder."""
profile_saved = pyqtSignal(int)
profile_deleted = pyqtSignal(int)
def __init__(self, db_manager: DatabaseManager):
super().__init__()
self.db_manager = db_manager
self.db_conn = db_manager.get_connection()
self.current_profile_id = None
self.command_sequence = [] # List of {command_id, command_name, delay_ms}
self._init_ui()
self._load_profiles()
self._load_uart_commands()
def _init_ui(self):
"""Initialize UI."""
layout = QVBoxLayout()
title = QLabel("Configure Session Profiles")
title.setStyleSheet("font-size: 16px; font-weight: bold;")
layout.addWidget(title)
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.addWidget(self._create_list_panel())
splitter.addWidget(self._create_editor_panel())
splitter.setSizes([300, 700])
layout.addWidget(splitter)
self.setLayout(layout)
def _create_list_panel(self):
"""Create profile list panel."""
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel("Session Profiles:"))
self.profile_list = QListWidget()
self.profile_list.currentItemChanged.connect(self._on_profile_selected)
layout.addWidget(self.profile_list)
btn_layout = QHBoxLayout()
self.btn_add = QPushButton("Add New")
self.btn_add.clicked.connect(self._on_add_clicked)
btn_layout.addWidget(self.btn_add)
self.btn_delete = QPushButton("Delete")
self.btn_delete.clicked.connect(self._on_delete_clicked)
self.btn_delete.setEnabled(False)
btn_layout.addWidget(self.btn_delete)
layout.addLayout(btn_layout)
widget.setLayout(layout)
return widget
def _create_editor_panel(self):
"""Create visual command builder."""
widget = QWidget()
layout = QVBoxLayout()
# Profile name and description
form = QFormLayout()
self.name_input = QLineEdit()
form.addRow("Profile Name:", self.name_input)
self.desc_input = QLineEdit()
form.addRow("Description:", self.desc_input)
layout.addLayout(form)
# Add command section
add_group = QGroupBox("Add Command")
add_layout = QVBoxLayout()
add_form = QFormLayout()
self.cmd_combo = QComboBox()
add_form.addRow("UART Command:", self.cmd_combo)
self.delay_spin = QSpinBox()
self.delay_spin.setRange(0, 60000)
self.delay_spin.setValue(3000)
self.delay_spin.setSuffix(" ms")
add_form.addRow("Delay After:", self.delay_spin)
add_layout.addLayout(add_form)
btn_add_cmd = QPushButton(" Add to Sequence")
btn_add_cmd.clicked.connect(self._on_add_command)
add_layout.addWidget(btn_add_cmd)
add_group.setLayout(add_layout)
layout.addWidget(add_group)
# Command sequence list
seq_group = QGroupBox("Command Sequence")
seq_layout = QVBoxLayout()
self.sequence_list = QListWidget()
seq_layout.addWidget(self.sequence_list)
# Sequence controls
seq_btn_layout = QHBoxLayout()
self.btn_up = QPushButton("↑ Move Up")
self.btn_up.clicked.connect(self._on_move_up)
seq_btn_layout.addWidget(self.btn_up)
self.btn_down = QPushButton("↓ Move Down")
self.btn_down.clicked.connect(self._on_move_down)
seq_btn_layout.addWidget(self.btn_down)
self.btn_remove = QPushButton("✖ Remove")
self.btn_remove.clicked.connect(self._on_remove_command)
seq_btn_layout.addWidget(self.btn_remove)
seq_layout.addLayout(seq_btn_layout)
seq_group.setLayout(seq_layout)
layout.addWidget(seq_group)
# Save button
btn_layout = QHBoxLayout()
self.btn_save = QPushButton("Save Profile")
self.btn_save.clicked.connect(self._on_save_clicked)
self.btn_save.setEnabled(False)
btn_layout.addWidget(self.btn_save)
btn_layout.addStretch()
layout.addLayout(btn_layout)
widget.setLayout(layout)
return widget
def _load_uart_commands(self):
"""Load UART commands into dropdown."""
self.cmd_combo.clear()
cursor = self.db_conn.execute(
"SELECT command_id, command_name FROM uart_commands WHERE is_active=1 ORDER BY command_name"
)
for row in cursor.fetchall():
self.cmd_combo.addItem(row[1], row[0])
def _load_profiles(self):
"""Load profiles from database."""
self.profile_list.clear()
cursor = self.db_conn.execute(
"SELECT profile_id, profile_name FROM session_profiles ORDER BY profile_name"
)
for row in cursor.fetchall():
item = QListWidgetItem(row[1])
item.setData(Qt.ItemDataRole.UserRole, row[0])
self.profile_list.addItem(item)
def _load_profile_details(self, profile_id: int):
"""Load profile into editor."""
cursor = self.db_conn.execute(
"SELECT profile_name, description, command_sequence FROM session_profiles WHERE profile_id = ?",
(profile_id,)
)
row = cursor.fetchone()
if row:
self.name_input.setText(row[0] or "")
self.desc_input.setText(row[1] or "")
# Parse JSON and populate sequence
try:
json_obj = json.loads(row[2] or "{}")
self.command_sequence = []
for cmd in json_obj.get("commands", []):
cmd_id = cmd["command_id"]
# Get command name from database
c = self.db_conn.execute(
"SELECT command_name FROM uart_commands WHERE command_id = ?", (cmd_id,)
)
cmd_row = c.fetchone()
if cmd_row:
self.command_sequence.append({
"command_id": cmd_id,
"command_name": cmd_row[0],
"delay_ms": cmd["delay_ms"]
})
self._update_sequence_list()
except:
self.command_sequence = []
self.current_profile_id = profile_id
self.btn_delete.setEnabled(True)
self.btn_save.setEnabled(True) # Always allow saving
def _update_sequence_list(self):
"""Update sequence list display."""
self.sequence_list.clear()
for i, cmd in enumerate(self.command_sequence, 1):
text = f"{i}. {cmd['command_name']} ({cmd['delay_ms']}ms)"
self.sequence_list.addItem(text)
def _on_add_command(self):
"""Add command to sequence."""
if self.cmd_combo.count() == 0:
QMessageBox.warning(self, "Error", "No UART commands available")
return
cmd_id = self.cmd_combo.currentData()
cmd_name = self.cmd_combo.currentText()
delay = self.delay_spin.value()
self.command_sequence.append({
"command_id": cmd_id,
"command_name": cmd_name,
"delay_ms": delay
})
self._update_sequence_list()
self.btn_save.setEnabled(True)
def _on_move_up(self):
"""Move selected command up."""
idx = self.sequence_list.currentRow()
if idx > 0:
self.command_sequence[idx], self.command_sequence[idx-1] = \
self.command_sequence[idx-1], self.command_sequence[idx]
self._update_sequence_list()
self.sequence_list.setCurrentRow(idx-1)
self.btn_save.setEnabled(True)
def _on_move_down(self):
"""Move selected command down."""
idx = self.sequence_list.currentRow()
if 0 <= idx < len(self.command_sequence) - 1:
self.command_sequence[idx], self.command_sequence[idx+1] = \
self.command_sequence[idx+1], self.command_sequence[idx]
self._update_sequence_list()
self.sequence_list.setCurrentRow(idx+1)
self.btn_save.setEnabled(True)
def _on_remove_command(self):
"""Remove selected command."""
idx = self.sequence_list.currentRow()
if idx >= 0:
del self.command_sequence[idx]
self._update_sequence_list()
self.btn_save.setEnabled(True)
def _on_profile_selected(self, current, previous):
"""Handle profile selection."""
if current:
profile_id = current.data(Qt.ItemDataRole.UserRole)
self._load_profile_details(profile_id)
def _on_add_clicked(self):
"""Handle add button."""
self.name_input.clear()
self.desc_input.clear()
self.command_sequence = []
self._update_sequence_list()
self.profile_list.clearSelection()
self.current_profile_id = None
self.btn_delete.setEnabled(False)
self.btn_save.setEnabled(True)
self.name_input.setFocus()
def _on_delete_clicked(self):
"""Handle delete button."""
if not self.current_profile_id:
return
reply = QMessageBox.question(
self, "Confirm Delete",
f"Delete profile '{self.name_input.text()}'?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
try:
self.db_conn.execute(
"DELETE FROM session_profiles WHERE profile_id = ?",
(self.current_profile_id,)
)
self.db_conn.commit()
self.profile_deleted.emit(self.current_profile_id)
self._load_profiles()
self.name_input.clear()
self.desc_input.clear()
self.command_sequence = []
self._update_sequence_list()
self.current_profile_id = None
self.btn_save.setEnabled(False)
self.btn_delete.setEnabled(False)
QMessageBox.information(self, "Success", "Profile deleted")
except Exception as e:
QMessageBox.critical(self, "Error", f"Delete failed: {str(e)}")
def _on_save_clicked(self):
"""Handle save button."""
name = self.name_input.text().strip()
if not name:
QMessageBox.warning(self, "Error", "Profile name required")
return
if not self.command_sequence:
QMessageBox.warning(self, "Error", "Add at least one command")
return
desc = self.desc_input.text().strip()
# Build JSON
json_obj = {
"commands": [
{"command_id": cmd["command_id"], "delay_ms": cmd["delay_ms"]}
for cmd in self.command_sequence
]
}
json_text = json.dumps(json_obj)
try:
if self.current_profile_id:
self.db_conn.execute(
"UPDATE session_profiles SET profile_name=?, description=?, "
"command_sequence=?, last_modified=datetime('now') WHERE profile_id=?",
(name, desc, json_text, self.current_profile_id)
)
msg = "Profile updated"
else:
cursor = self.db_conn.execute(
"INSERT INTO session_profiles (profile_name, description, command_sequence) "
"VALUES (?, ?, ?)",
(name, desc, json_text)
)
self.current_profile_id = cursor.lastrowid
msg = "Profile created"
self.db_conn.commit()
self.profile_saved.emit(self.current_profile_id)
self._load_profiles()
QMessageBox.information(self, "Success", msg)
self.btn_save.setEnabled(False)
except Exception as e:
QMessageBox.critical(self, "Error", f"Save failed: {str(e)}")
if __name__ == "__main__":
import sys
from PyQt6.QtWidgets import QApplication
app = QApplication(sys.argv)
db = DatabaseManager("database/ehinge.db")
db.initialize()
widget = ConfigureSessionWidget(db)
widget.setWindowTitle("Configure Session Profiles")
widget.resize(1000, 600)
widget.show()
sys.exit(app.exec())

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,921 @@
#!/usr/bin/env python3
"""
Graph Widget (PyQt6)
====================
GUI for visualizing telemetry data from SQLite database.
Features:
- Session/run tree selector
- Data series checkboxes
- Plot type selector (overlay, subplots, drift)
- Embedded matplotlib canvas
- Zoom/pan controls
- Export PNG/CSV
- Independent from UART/I2C widgets
Author: Kynsight
Version: 1.0.0
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QComboBox,
QGroupBox, QTreeWidget, QTreeWidgetItem,
QCheckBox, QFileDialog, QMessageBox, QSplitter
)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
from matplotlib.figure import Figure
# Import from table_query (data access layer)
from graph_table_query import (
DataAdapter,
SQLiteAdapter,
CSVAdapter,
TelemetryData,
get_column_label,
get_available_columns,
get_default_columns
)
# Import from graph_core (pure plotting)
from graph_core import (
PlotConfig,
plot_overlay,
plot_subplots,
plot_comparison,
plot_multi_series,
plot_xy_scatter,
export_png,
export_csv
)
class GraphWidget(QWidget):
"""
Telemetry data visualization widget.
Loads data from SQLite and generates matplotlib plots.
"""
# Signals
plot_updated = pyqtSignal()
def __init__(self, db_path: str = "./database/ehinge.db", parent=None):
super().__init__(parent)
self.db_path = db_path
self.adapter: Optional[DataAdapter] = None # Use adapter pattern
# Data
self.sessions = []
self.selected_runs = [] # List of (session_id, run_no) tuples
self.loaded_data = [] # List of TelemetryData objects
# Build UI
self._init_ui()
# Connect to database
self._connect_database()
def _init_ui(self):
"""Create user interface."""
layout = QHBoxLayout()
self.setLayout(layout)
# Left panel: Session/run selection
left_panel = self._create_selection_panel()
# Right panel: Plot area
right_panel = self._create_plot_panel()
# Splitter
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.addWidget(left_panel)
splitter.addWidget(right_panel)
splitter.setStretchFactor(0, 1) # Left: 25%
splitter.setStretchFactor(1, 3) # Right: 75%
layout.addWidget(splitter)
self.setMinimumWidth(1200)
self.setMinimumHeight(700)
# =========================================================================
# UI Creation
# =========================================================================
def _create_selection_panel(self):
"""Create left panel with session/run selection."""
panel = QWidget()
layout = QVBoxLayout()
panel.setLayout(layout)
# 1. Database selector (top)
db_group = QGroupBox("Database")
db_layout = QVBoxLayout()
db_group.setLayout(db_layout)
db_row = QHBoxLayout()
self.combo_database = QComboBox()
self.combo_database.setEditable(True)
self.combo_database.addItem("./database/ehinge.db")
self.combo_database.setCurrentText(self.db_path)
db_row.addWidget(self.combo_database)
btn_browse = QPushButton("Browse...")
btn_browse.clicked.connect(self._browse_database)
btn_browse.setMaximumWidth(80)
db_row.addWidget(btn_browse)
db_layout.addLayout(db_row)
btn_connect = QPushButton("🔗 Connect")
btn_connect.clicked.connect(self._connect_database)
db_layout.addWidget(btn_connect)
layout.addWidget(db_group)
# 2. Plot controls (right after database!)
plot_controls = self._create_plot_controls()
layout.addWidget(plot_controls)
# 3. Refresh button
btn_refresh = QPushButton("🔄 Refresh Sessions")
btn_refresh.clicked.connect(self._load_sessions)
layout.addWidget(btn_refresh)
# 4. Data series selection (MOVED UP - before Sessions!)
self.series_group = self._create_series_selector()
layout.addWidget(self.series_group)
# 5. Session/run tree (MOVED DOWN - after Data Series!)
tree_group = QGroupBox("Sessions & Runs")
tree_layout = QVBoxLayout()
tree_group.setLayout(tree_layout)
self.tree_sessions = QTreeWidget()
self.tree_sessions.setHeaderLabels(["Session / Run", "Samples", "Duration"])
self.tree_sessions.itemChanged.connect(self._on_selection_changed)
tree_layout.addWidget(self.tree_sessions)
# Buttons under tree
btn_row = QHBoxLayout()
btn_generate = QPushButton("📊 Generate Plot")
btn_generate.clicked.connect(self._generate_plot)
btn_row.addWidget(btn_generate)
btn_export_png = QPushButton("💾 PNG")
btn_export_png.clicked.connect(self._export_png)
btn_row.addWidget(btn_export_png)
btn_export_csv = QPushButton("📄 CSV")
btn_export_csv.clicked.connect(self._export_csv)
btn_row.addWidget(btn_export_csv)
tree_layout.addLayout(btn_row)
layout.addWidget(tree_group)
layout.addStretch()
return panel
def _create_series_selector(self):
"""Create data series checkboxes."""
group = QGroupBox("Data Series (Y-Axis)")
layout = QVBoxLayout()
group.setLayout(layout)
# Simple vertical list - no grouping
self.check_t_ns = QCheckBox("t_ns")
layout.addWidget(self.check_t_ns)
self.check_time_ms = QCheckBox("time_ms")
layout.addWidget(self.check_time_ms)
self.check_motor_current = QCheckBox("motor_current")
self.check_motor_current.setChecked(True) # Default
layout.addWidget(self.check_motor_current)
self.check_encoder_value = QCheckBox("encoder_value")
self.check_encoder_value.setChecked(True) # Default
layout.addWidget(self.check_encoder_value)
self.check_relative_encoder_value = QCheckBox("relative_encoder_value")
self.check_relative_encoder_value.setChecked(True) # Default
layout.addWidget(self.check_relative_encoder_value)
self.check_v24_pec_diff = QCheckBox("v24_pec_diff")
layout.addWidget(self.check_v24_pec_diff)
self.check_pwm = QCheckBox("pwm")
self.check_pwm.setChecked(True) # Default
layout.addWidget(self.check_pwm)
self.check_i2c_raw14 = QCheckBox("i2c_raw14")
layout.addWidget(self.check_i2c_raw14)
self.check_i2c_zero_raw14 = QCheckBox("i2c_zero_raw14")
layout.addWidget(self.check_i2c_zero_raw14)
self.check_i2c_delta_raw14 = QCheckBox("i2c_delta_raw14")
layout.addWidget(self.check_i2c_delta_raw14)
self.check_i2c_angle_deg = QCheckBox("i2c_angle_deg")
self.check_i2c_angle_deg.setChecked(True) # Default
layout.addWidget(self.check_i2c_angle_deg)
self.check_i2c_zero_angle_deg = QCheckBox("i2c_zero_angle_deg")
layout.addWidget(self.check_i2c_zero_angle_deg)
self.check_angular_velocity = QCheckBox("angular_velocity")
self.check_angular_velocity.setChecked(True) # Default
layout.addWidget(self.check_angular_velocity)
return group
def _create_plot_panel(self):
"""Create right panel with plot area - FULL HEIGHT!"""
panel = QWidget()
layout = QVBoxLayout()
panel.setLayout(layout)
# Remove all margins for maximum space
layout.setContentsMargins(0, 0, 0, 0)
# Store layout reference for canvas recreation
self.plot_layout = layout
# Matplotlib canvas (FULL SPACE - top to bottom!)
self.figure = Figure(figsize=(10, 6), dpi=100)
self.canvas = FigureCanvas(self.figure)
layout.addWidget(self.canvas)
# Matplotlib toolbar (zoom, pan, save)
self.toolbar = NavigationToolbar(self.canvas, self)
layout.addWidget(self.toolbar)
return panel
def _create_plot_controls(self):
"""Create plot control buttons."""
group = QGroupBox("Plot Configuration")
layout = QVBoxLayout()
group.setLayout(layout)
# Plot mode selector
mode_row = QHBoxLayout()
mode_row.addWidget(QLabel("Mode:"))
self.combo_plot_mode = QComboBox()
self.combo_plot_mode.addItems(["Time Series", "XY Plot"])
self.combo_plot_mode.currentTextChanged.connect(self._on_plot_mode_changed)
mode_row.addWidget(self.combo_plot_mode)
layout.addLayout(mode_row)
# Line type selector
line_type_row = QHBoxLayout()
line_type_row.addWidget(QLabel("Line Type:"))
self.combo_line_type = QComboBox()
self.combo_line_type.addItems([
"Line",
"Line + Markers",
"Markers Only",
"Steps"
])
line_type_row.addWidget(self.combo_line_type)
layout.addLayout(line_type_row)
# Separate window checkbox
self.check_separate_window = QCheckBox("Open in separate window")
layout.addWidget(self.check_separate_window)
# X-axis selector (for time series mode)
self.xaxis_row = QHBoxLayout()
self.xaxis_label = QLabel("X-Axis:")
self.xaxis_row.addWidget(self.xaxis_label)
self.combo_xaxis = QComboBox()
self.combo_xaxis.addItems(["time_ms", "t_ns"])
self.xaxis_row.addWidget(self.combo_xaxis)
layout.addLayout(self.xaxis_row)
# Plot type selector (for time series mode)
self.type_row = QHBoxLayout()
self.type_label = QLabel("Type:")
self.type_row.addWidget(self.type_label)
self.combo_plot_type = QComboBox()
self.combo_plot_type.addItems([
"Overlay",
"Subplots",
"Drift Comparison",
"Multi-Series"
])
self.type_row.addWidget(self.combo_plot_type)
layout.addLayout(self.type_row)
# XY plot selectors (hidden by default)
self.xy_row = QHBoxLayout()
self.xy_x_label = QLabel("X:")
self.xy_row.addWidget(self.xy_x_label)
self.combo_xy_x = QComboBox()
self.combo_xy_x.addItems([
"time_ms", "t_ns",
"motor_current", "encoder_value", "relative_encoder_value", "v24_pec_diff", "pwm",
"i2c_raw14", "i2c_zero_raw14", "i2c_delta_raw14", "i2c_angle_deg", "i2c_zero_angle_deg",
"angular_velocity"
])
self.combo_xy_x.setCurrentText("motor_current")
self.xy_row.addWidget(self.combo_xy_x)
self.xy_y_label = QLabel("Y:")
self.xy_row.addWidget(self.xy_y_label)
self.combo_xy_y = QComboBox()
self.combo_xy_y.addItems([
"time_ms", "t_ns",
"motor_current", "encoder_value", "relative_encoder_value", "v24_pec_diff", "pwm",
"i2c_raw14", "i2c_zero_raw14", "i2c_delta_raw14", "i2c_angle_deg", "i2c_zero_angle_deg",
"angular_velocity"
])
self.combo_xy_y.setCurrentText("pwm")
self.xy_row.addWidget(self.combo_xy_y)
layout.addLayout(self.xy_row)
# Hide XY controls initially
self.xy_x_label.setVisible(False)
self.xy_y_label.setVisible(False)
self.combo_xy_x.setVisible(False)
self.combo_xy_y.setVisible(False)
return group
def _on_plot_mode_changed(self, mode: str):
"""Handle plot mode change."""
if mode == "XY Plot":
# Hide time series controls
self.xaxis_label.setVisible(False)
self.combo_xaxis.setVisible(False)
self.type_label.setVisible(False)
self.combo_plot_type.setVisible(False)
# Show XY controls
self.xy_x_label.setVisible(True)
self.xy_y_label.setVisible(True)
self.combo_xy_x.setVisible(True)
self.combo_xy_y.setVisible(True)
# Hide data series panel (not needed for XY)
self.series_group.setVisible(False)
else:
# Show time series controls
self.xaxis_label.setVisible(True)
self.combo_xaxis.setVisible(True)
self.type_label.setVisible(True)
self.combo_plot_type.setVisible(True)
# Hide XY controls
self.xy_x_label.setVisible(False)
self.xy_y_label.setVisible(False)
self.combo_xy_x.setVisible(False)
self.combo_xy_y.setVisible(False)
# Show data series panel
self.series_group.setVisible(True)
# =========================================================================
# Database Connection
# =========================================================================
def _browse_database(self):
"""Browse for database file."""
filepath, _ = QFileDialog.getOpenFileName(
self,
"Select Database",
"./database",
"Database Files (*.db *.sqlite);;All Files (*)"
)
if filepath:
self.combo_database.setCurrentText(filepath)
self._connect_database()
def _connect_database(self):
"""Connect to selected database."""
db_path = self.combo_database.currentText()
# Close previous connection
if self.adapter:
self.adapter.close()
# Determine adapter type based on file extension
if db_path.endswith('.csv'):
self.adapter = CSVAdapter(db_path)
adapter_type = "CSV"
else:
self.adapter = SQLiteAdapter(db_path)
adapter_type = "SQLite"
# Open new connection
self.db_path = db_path
if self.adapter.connect():
self._load_sessions()
else:
QMessageBox.critical(self, "Connection Error", f"Failed to connect to database:\n{db_path}")
# =========================================================================
# Session/Run Loading
# =========================================================================
def _load_sessions(self):
"""Load sessions from database and populate tree."""
self.sessions = self.adapter.get_sessions()
self.tree_sessions.clear()
for session in self.sessions:
# Create session item
session_item = QTreeWidgetItem(self.tree_sessions)
session_item.setText(0, session.session_id)
session_item.setText(1, f"{session.run_count} runs")
session_item.setText(2, session.created_at)
session_item.setFlags(session_item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
session_item.setCheckState(0, Qt.CheckState.Unchecked)
session_item.setData(0, Qt.ItemDataRole.UserRole, session.session_id)
# Load runs for this session
runs = self.adapter.get_runs(session.session_id)
for run in runs:
run_item = QTreeWidgetItem(session_item)
run_item.setText(0, f"Run {run.run_number} ({run.run_name})")
run_item.setText(1, f"{run.sample_count} samples")
run_item.setText(2, f"{run.duration_ms:.1f}ms")
run_item.setFlags(run_item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
run_item.setCheckState(0, Qt.CheckState.Unchecked)
run_item.setData(0, Qt.ItemDataRole.UserRole, (session.session_id, run.run_number))
self.tree_sessions.expandAll()
def _on_selection_changed(self, item: QTreeWidgetItem, column: int):
"""Handle checkbox changes in tree."""
# Update parent/child checkboxes
if item.childCount() > 0: # Session item
# Update all children
state = item.checkState(0)
for i in range(item.childCount()):
item.child(i).setCheckState(0, state)
else: # Run item
# Check if all siblings checked -> check parent
parent = item.parent()
if parent:
all_checked = all(
parent.child(i).checkState(0) == Qt.CheckState.Checked
for i in range(parent.childCount())
)
parent.setCheckState(0, Qt.CheckState.Checked if all_checked else Qt.CheckState.Unchecked)
def _get_selected_runs(self):
"""Get list of selected (session_id, run_number) tuples."""
selected = []
# Iterate through tree
for i in range(self.tree_sessions.topLevelItemCount()):
session_item = self.tree_sessions.topLevelItem(i)
for j in range(session_item.childCount()):
run_item = session_item.child(j)
if run_item.checkState(0) == Qt.CheckState.Checked:
data = run_item.data(0, Qt.ItemDataRole.UserRole)
if data:
selected.append(data)
return selected
def _load_selected_data(self):
"""Load telemetry data for selected runs."""
self.selected_runs = self._get_selected_runs()
if not self.selected_runs:
QMessageBox.warning(self, "No Selection", "Please select at least one run")
return
self.loaded_data = []
for session_id, run_no in self.selected_runs:
data = self.adapter.load_run_data(session_id, run_no)
if data:
self.loaded_data.append(data)
if self.loaded_data:
QMessageBox.information(
self,
"Data Loaded",
f"Successfully loaded {len(self.loaded_data)} run(s)"
)
else:
QMessageBox.warning(self, "Load Error", "Failed to load data from database")
# =========================================================================
# Plotting
# =========================================================================
def _get_selected_series(self):
"""Get list of selected data series."""
series = []
labels = []
# UART data
if self.check_motor_current.isChecked():
series.append('motor_current')
labels.append('Motor Current')
if self.check_encoder_value.isChecked():
series.append('encoder_value')
labels.append('Encoder Value')
if self.check_relative_encoder_value.isChecked():
series.append('relative_encoder_value')
labels.append('Relative Encoder')
if self.check_v24_pec_diff.isChecked():
series.append('v24_pec_diff')
labels.append('V24 PEC Diff')
if self.check_pwm.isChecked():
series.append('pwm')
labels.append('PWM')
# I2C data
if self.check_i2c_raw14.isChecked():
series.append('i2c_raw14')
labels.append('I2C Raw (14-bit)')
if self.check_i2c_zero_raw14.isChecked():
series.append('i2c_zero_raw14')
labels.append('I2C Zero Raw')
if self.check_i2c_delta_raw14.isChecked():
series.append('i2c_delta_raw14')
labels.append('I2C Delta Raw')
if self.check_i2c_angle_deg.isChecked():
series.append('i2c_angle_deg')
labels.append('Angle (degrees)')
if self.check_i2c_zero_angle_deg.isChecked():
series.append('i2c_zero_angle_deg')
labels.append('Zero Angle (degrees)')
# Derived data
if self.check_angular_velocity.isChecked():
series.append('angular_velocity')
labels.append('Angular Velocity')
return series, labels
def _generate_plot(self):
"""Generate plot based on selected data and settings."""
# Auto-load selected runs first
self.selected_runs = self._get_selected_runs()
if not self.selected_runs:
QMessageBox.warning(self, "No Selection", "Please select at least one run")
return
# Load data
self.loaded_data = []
for session_id, run_no in self.selected_runs:
data = self.adapter.load_run_data(session_id, run_no)
if data:
self.loaded_data.append(data)
if not self.loaded_data:
QMessageBox.warning(self, "Load Error", "Failed to load data from database")
return
# Get plot mode
plot_mode = self.combo_plot_mode.currentText()
# Get line style settings
line_type = self.combo_line_type.currentText()
linestyle, marker, markersize = self._get_line_style(line_type)
config = PlotConfig(
title="Telemetry Data Visualization",
figsize=(10, 6),
linestyle=linestyle,
marker=marker,
markersize=markersize
)
try:
if plot_mode == "XY Plot":
# XY scatter plot mode
x_col = self.combo_xy_x.currentText()
y_col = self.combo_xy_y.currentText()
xlabel = self._get_axis_label(x_col)
ylabel = self._get_axis_label(y_col)
self.figure = plot_xy_scatter(
self.loaded_data,
x_col,
y_col,
xlabel,
ylabel,
config
)
else:
# Time series mode
series, labels = self._get_selected_series()
if not series:
QMessageBox.warning(self, "No Series", "Please select at least one data series")
return
# Get X-axis
x_col = self.combo_xaxis.currentText()
xlabel = self._get_axis_label(x_col)
plot_type = self.combo_plot_type.currentText()
if plot_type == "Overlay":
# FIXED: All series on SAME plot (not stacked)
self.figure = self._plot_overlay_all(
self.loaded_data,
x_col,
series,
xlabel,
labels,
config
)
elif plot_type == "Subplots":
# One subplot per run (first series only)
self.figure = plot_subplots(
self.loaded_data,
x_col,
series[0],
xlabel,
labels[0],
config
)
elif plot_type == "Drift Comparison":
# Drift comparison (first series only)
self.figure = plot_comparison(
self.loaded_data,
x_col,
series[0],
xlabel,
labels[0],
reference_index=0,
config=config
)
elif plot_type == "Multi-Series":
# Multi-series stacked subplots
self.figure = plot_multi_series(
self.loaded_data,
x_col,
series,
xlabel,
labels,
config
)
# Display plot
if self.check_separate_window.isChecked():
# Open in separate window
self._show_plot_window(self.figure)
else:
# Embed in main window - recreate canvas to fix matplotlib toolbar
self._update_canvas(self.figure)
self.plot_updated.emit()
except Exception as e:
import traceback
traceback.print_exc()
QMessageBox.critical(self, "Plot Error", f"Failed to generate plot:\n{e}")
def _plot_overlay_all(self, data_list, x_col, y_columns, xlabel, ylabels, config):
"""
Plot all series from all runs on SAME axes (true overlay).
Fixes the stacking issue - everything goes on one plot.
"""
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=config.figsize, dpi=config.dpi)
# Set color cycle for distinguishable colors
ax.set_prop_cycle(color=plt.cm.tab10.colors) # 20 colors
# Get line type settings
line_type = self.combo_line_type.currentText()
linestyle, marker, markersize = self._get_line_style(line_type)
# Plot each combination of run × series
for data in data_list:
x_data = getattr(data, x_col, None)
if x_data is None:
continue
for y_col, ylabel in zip(y_columns, ylabels):
y_data = getattr(data, y_col, None)
if y_data is None:
continue
# Label format: "session_name run_no (name) - series"
label = f"{data.session_id} {data.run_no} ({data.run_name}) - {ylabel}"
ax.plot(x_data, y_data, label=label, alpha=0.8,
linestyle=linestyle, marker=marker, markersize=markersize)
# Formatting
ax.set_xlabel(xlabel)
ax.set_ylabel("Value")
ax.set_title(config.title)
if config.grid:
ax.grid(True, alpha=0.3)
if config.legend:
# Legend at bottom, outside plot, 2 columns
ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.12), ncol=2)
fig.tight_layout(rect=[0, 0.08, 1, 1]) # Leave space for legend
return fig
def _get_line_style(self, line_type):
"""
Get matplotlib line style parameters.
Returns:
(linestyle, marker, markersize)
"""
if line_type == "Line":
return '-', None, 0
elif line_type == "Line + Markers":
return '-', 'o', 3
elif line_type == "Markers Only":
return '', 'o', 4
elif line_type == "Steps":
return 'steps-post', None, 0
else:
return '-', None, 0
def _update_canvas(self, figure):
"""Update canvas with new figure - recreates canvas to fix matplotlib toolbar."""
# Remove old canvas and toolbar
if hasattr(self, 'canvas'):
self.plot_layout.removeWidget(self.canvas)
self.plot_layout.removeWidget(self.toolbar)
self.canvas.deleteLater()
self.toolbar.deleteLater()
# Create fresh canvas and toolbar
self.canvas = FigureCanvas(figure)
self.toolbar = NavigationToolbar(self.canvas, self)
# Add to layout
self.plot_layout.insertWidget(1, self.canvas) # After controls
self.plot_layout.insertWidget(2, self.toolbar) # After canvas
self.figure = figure
def _show_plot_window(self, figure):
"""Show plot in separate window."""
from PyQt6.QtWidgets import QDialog, QVBoxLayout
dialog = QDialog(self)
dialog.setWindowTitle("Telemetry Plot")
dialog.resize(1000, 700)
layout = QVBoxLayout()
dialog.setLayout(layout)
# Canvas and toolbar
canvas = FigureCanvas(figure)
toolbar = NavigationToolbar(canvas, dialog)
layout.addWidget(canvas)
layout.addWidget(toolbar)
dialog.show()
def _get_axis_label(self, column: str) -> str:
"""Get human-readable label for column."""
labels = {
't_ns': 'Time (ns)',
'time_ms': 'Time (ms)',
'motor_current': 'Motor Current',
'encoder_value': 'Encoder Value',
'relative_encoder_value': 'Relative Encoder',
'v24_pec_diff': 'V24 PEC Diff',
'pwm': 'PWM',
'i2c_raw14': 'I2C Raw (14-bit)',
'i2c_zero_raw14': 'I2C Zero Raw',
'i2c_delta_raw14': 'I2C Delta Raw',
'i2c_angle_deg': 'Angle (degrees)',
'i2c_zero_angle_deg': 'Zero Angle (degrees)',
'angular_velocity': 'Angular Velocity'
}
return labels.get(column, column)
# =========================================================================
# Export
# =========================================================================
def _export_png(self):
"""Export current plot to PNG."""
if not self.loaded_data:
QMessageBox.warning(self, "No Data", "Please generate a plot first")
return
filepath, _ = QFileDialog.getSaveFileName(
self,
"Export PNG",
"",
"PNG Files (*.png)"
)
if filepath:
if export_png(self.figure, filepath, dpi=300):
QMessageBox.information(self, "Success", f"Plot saved to:\n{filepath}")
else:
QMessageBox.critical(self, "Error", "Failed to export PNG")
def _export_csv(self):
"""Export loaded data to CSV (only selected columns)."""
if not self.loaded_data:
QMessageBox.warning(self, "No Data", "Please load data first")
return
# Get selected columns based on plot mode
plot_mode = self.combo_plot_mode.currentText()
if plot_mode == "XY Plot":
# XY mode: X and Y from dropdowns
x_col = self.combo_xy_x.currentText()
y_cols = [self.combo_xy_y.currentText()]
else:
# Time series mode: X from dropdown, Y from checkboxes
x_col = self.combo_xaxis.currentText()
series, labels = self._get_selected_series()
if not series:
QMessageBox.warning(self, "No Series", "Please select at least one data series")
return
y_cols = series
# Get save path
filepath, _ = QFileDialog.getSaveFileName(
self,
"Export CSV",
"",
"CSV Files (*.csv)"
)
if filepath:
if export_csv(self.loaded_data, filepath, x_col, y_cols):
QMessageBox.information(self, "Success", f"Data saved to:\n{filepath}")
else:
QMessageBox.critical(self, "Error", "Failed to export CSV")
# =========================================================================
# Cleanup
# =========================================================================
def closeEvent(self, event):
"""Close database connection on widget close."""
if self.adapter:
self.adapter.close()
event.accept()
# =============================================================================
# Demo
# =============================================================================
if __name__ == "__main__":
import sys
from PyQt6.QtWidgets import QApplication
app = QApplication(sys.argv)
# You need to provide a database path
widget = GraphWidget(db_path="telemetry.db")
widget.setWindowTitle("Telemetry Graph Visualizer")
widget.show()
sys.exit(app.exec())

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

451
run.py

@ -0,0 +1,451 @@
#!/usr/bin/env python3
"""
Run Module - vzug-e-hinge
==========================
Executes a single RUN (one UART command with data collection).
Flow:
1. Configure UART packet detection with callback
2. Callback triggers I2C read (real-time correlation)
3. Send UART command
4. Wait for stop condition
5. Decode packets (call decoder.py)
6. Save to database (telemetry_decoded + telemetry_raw)
Author: Kynsight
Version: 1.0.0
Date: 2025-11-09
"""
import time
from typing import Tuple, Optional, List
import sqlite3
# Import UART core
from uart.uart_kit.uart_core import (
UARTPort,
PacketConfig,
PacketInfo,
uart_write,
uart_start_listening_with_packets,
uart_stop_listening,
uart_get_detected_packets,
uart_clear_detected_packets,
uart_read_buffer,
Status as UARTStatus
)
# Import I2C core
from i2c.i2c_kit.i2c_core import (
I2CHandle,
i2c_read_block,
Status as I2CStatus
)
# Import decoder
from decoder import decode_uart_packet, decode_i2c_sample
class RunExecutor:
"""
Executes a single RUN.
A RUN consists of:
- Send UART command
- Collect UART packets (with timestamps)
- Trigger I2C reads via callback (correlated timestamps)
- Wait for stop condition
- Decode all data
- Save to database
"""
def __init__(self, db_connection: sqlite3.Connection):
"""
Initialize run executor.
Args:
db_connection: Database connection
"""
self.db_conn = db_connection
self.i2c_readings = [] # Storage for I2C readings from callback
def execute_run(
self,
session_id: str,
session_name: str,
run_no: int,
command_id: int,
command_hex: str,
uart_port: UARTPort,
i2c_port: Optional[I2CHandle],
packet_config: PacketConfig,
stop_timeout_ms: int = 5000,
raw_data_callback = None
) -> Tuple[str, int, str]:
"""
Execute a single RUN.
Args:
session_id: Session ID
session_name: Session name
run_no: Run number (1, 2, 3, ...)
command_id: UART command ID from database
command_hex: Command hex string (e.g., "DD 22 50 48...")
uart_port: UART port (already open and reader running)
i2c_port: I2C port (optional, for angle readings)
packet_config: Packet detection configuration
stop_timeout_ms: Maximum wait time for stop condition
Returns:
(status, packet_count, error_msg)
- status: "success" or "error"
- packet_count: Number of packets detected
- error_msg: Error message if status="error", empty otherwise
"""
try:
# Clear previous packets
uart_clear_detected_packets(uart_port)
self.i2c_readings.clear()
# Record run start time
run_start_ns = time.time_ns()
# ================================================================
# 1. Configure packet detection with callback
# ================================================================
# Create callback for I2C triggering
def on_uart_packet_detected(timestamp_ns: int):
"""
Called immediately when UART packet detected.
Triggers I2C read for timestamp correlation.
"""
if i2c_port:
# Read I2C angle immediately
# Note: i2c_read_block requires (handle, addr, reg, length)
# But we're using the handle's default address
status, i2c_bytes = i2c_read_block(
i2c_port,
i2c_port.config.address, # Use configured address
0xFE, # Angle register
2 # Read 2 bytes
)
if status == I2CStatus.OK:
# Store with correlated timestamp
self.i2c_readings.append({
'timestamp_ns': timestamp_ns,
'i2c_bytes': i2c_bytes
})
# Create packet config with callback
packet_config_with_callback = PacketConfig(
enable=packet_config.enable,
start_marker=packet_config.start_marker,
packet_length=packet_config.packet_length,
end_marker=packet_config.end_marker,
on_packet_callback=on_uart_packet_detected if i2c_port else None
)
# Start listening with packet detection
status = uart_start_listening_with_packets(uart_port, packet_config_with_callback)
if status != UARTStatus.OK:
return ("error", 0, "Failed to start UART packet detection")
# ================================================================
# 2. Send UART command
# ================================================================
# Parse hex string to bytes
command_bytes = self._parse_hex_string(command_hex)
if not command_bytes:
uart_stop_listening(uart_port)
return ("error", 0, f"Invalid command hex string: {command_hex}")
# Send command
status, written = uart_write(uart_port, command_bytes)
if status != UARTStatus.OK:
uart_stop_listening(uart_port)
return ("error", 0, "Failed to send UART command")
# Emit TX data (command sent)
if raw_data_callback:
hex_tx = ' '.join(f'{b:02X}' for b in command_bytes)
raw_data_callback("TX", hex_tx)
# ================================================================
# 3. Wait for stop condition
# ================================================================
# Wait for timeout
time.sleep(stop_timeout_ms / 1000.0)
# ================================================================
# 3.5. Handle raw data if packet detection disabled
# ================================================================
if not packet_config.enable:
# No packet detection - read raw buffer
status_read, raw_data = uart_read_buffer(uart_port)
if status_read == UARTStatus.OK and raw_data:
# Emit RX data
if raw_data_callback:
hex_rx = ' '.join(f'{b:02X}' for b in raw_data)
raw_data_callback("RX", hex_rx)
# Stop listening
uart_stop_listening(uart_port)
# ================================================================
# 4. Get detected packets
# ================================================================
uart_packets = uart_get_detected_packets(uart_port)
packet_count = len(uart_packets)
if packet_count == 0 and packet_config.enable:
# Only error if packet detection was enabled
return ("error", 0, "No packets detected (timeout or no data)")
# ================================================================
# 5. Decode and save data
# ================================================================
# Decode and save UART packets
for pkt in uart_packets:
self._save_uart_telemetry(
session_id=session_id,
session_name=session_name,
run_no=run_no,
run_command_id=command_id,
packet_info=pkt,
run_start_ns=run_start_ns
)
# Decode and save I2C readings
for reading in self.i2c_readings:
self._save_i2c_telemetry(
session_id=session_id,
session_name=session_name,
run_no=run_no,
run_command_id=command_id,
timestamp_ns=reading['timestamp_ns'],
i2c_bytes=reading['i2c_bytes'],
run_start_ns=run_start_ns
)
# Commit database changes
self.db_conn.commit()
return ("success", packet_count, "")
except Exception as e:
# Stop listening if still active
try:
uart_stop_listening(uart_port)
except:
pass
return ("error", 0, f"Exception during run: {str(e)}")
def _parse_hex_string(self, hex_str: str) -> Optional[bytes]:
"""
Parse hex string to bytes.
Args:
hex_str: Hex string (e.g., "DD 22 50 48" or "DD225048")
Returns:
Bytes or None if invalid
"""
try:
# Remove spaces and convert
hex_clean = hex_str.replace(' ', '')
return bytes.fromhex(hex_clean)
except:
return None
def _save_uart_telemetry(
self,
session_id: str,
session_name: str,
run_no: int,
run_command_id: int,
packet_info: PacketInfo,
run_start_ns: int
):
"""
Save UART telemetry to database.
Saves to both telemetry_raw and telemetry_decoded tables.
"""
# Decode packet
decoded = decode_uart_packet(packet_info.data)
# Calculate relative time from run start
time_ms = (packet_info.start_timestamp - run_start_ns) / 1_000_000.0
# Save to telemetry_raw (backup)
cursor = self.db_conn.cursor()
cursor.execute("""
INSERT INTO telemetry_raw (
session_id, session_name, run_no, run_command_id,
t_ns, time_ms, uart_raw_packet
) VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
session_id,
session_name,
run_no,
run_command_id,
packet_info.start_timestamp,
time_ms,
packet_info.data
))
# Save to telemetry_decoded (main data)
# For now, just save raw hex (decoder is pass-through)
# TODO: Update when decoder is fully implemented
cursor.execute("""
INSERT INTO telemetry_decoded (
session_id, session_name, run_no, run_command_id,
t_ns, time_ms
) VALUES (?, ?, ?, ?, ?, ?)
""", (
session_id,
session_name,
run_no,
run_command_id,
packet_info.start_timestamp,
time_ms
))
# TODO: When decoder is fully implemented, also save:
# motor_current, encoder_value, relative_encoder_value, v24_pec_diff, pwm
def _save_i2c_telemetry(
self,
session_id: str,
session_name: str,
run_no: int,
run_command_id: int,
timestamp_ns: int,
i2c_bytes: bytes,
run_start_ns: int
):
"""
Save I2C telemetry to database.
Saves to both telemetry_raw and telemetry_decoded tables.
"""
# Decode I2C sample
decoded = decode_i2c_sample(i2c_bytes)
# Calculate relative time from run start
time_ms = (timestamp_ns - run_start_ns) / 1_000_000.0
# Save to telemetry_raw (backup)
cursor = self.db_conn.cursor()
cursor.execute("""
INSERT INTO telemetry_raw (
session_id, session_name, run_no, run_command_id,
t_ns, time_ms, i2c_raw_bytes
) VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
session_id,
session_name,
run_no,
run_command_id,
timestamp_ns,
time_ms,
i2c_bytes
))
# Save to telemetry_decoded (main data)
# For now, decoder is pass-through
# TODO: Update when decoder is fully implemented with angle conversion
cursor.execute("""
INSERT INTO telemetry_decoded (
session_id, session_name, run_no, run_command_id,
t_ns, time_ms
) VALUES (?, ?, ?, ?, ?, ?)
""", (
session_id,
session_name,
run_no,
run_command_id,
timestamp_ns,
time_ms
))
# TODO: When decoder is fully implemented, also save:
# i2c_raw14, i2c_angle_deg, i2c_zero_raw14, etc.
# =============================================================================
# Convenience function for external use
# =============================================================================
def execute_run(
db_connection: sqlite3.Connection,
session_id: str,
session_name: str,
run_no: int,
command_id: int,
command_hex: str,
uart_port: UARTPort,
i2c_port: Optional[I2CHandle],
packet_config: PacketConfig,
stop_timeout_ms: int = 5000,
raw_data_callback = None
) -> Tuple[str, int, str]:
"""
Execute a single RUN (convenience function).
Args:
db_connection: Database connection
session_id: Session ID
session_name: Session name
run_no: Run number
command_id: UART command ID
command_hex: Command hex string
uart_port: UART port (open and ready)
i2c_port: I2C port (optional)
packet_config: Packet detection configuration
stop_timeout_ms: Stop condition timeout
raw_data_callback: Callback for raw data display (direction, hex_string)
Returns:
(status, packet_count, error_msg)
"""
executor = RunExecutor(db_connection)
return executor.execute_run(
session_id=session_id,
session_name=session_name,
run_no=run_no,
command_id=command_id,
command_hex=command_hex,
uart_port=uart_port,
i2c_port=i2c_port,
packet_config=packet_config,
stop_timeout_ms=stop_timeout_ms,
raw_data_callback=raw_data_callback
)
if __name__ == "__main__":
print("Run Module")
print("=" * 60)
print("This module executes a single RUN.")
print("It should be called by session.py, not run directly.")
print()
print("Features:")
print("✓ UART packet detection with callback")
print("✓ Real-time I2C triggering")
print("✓ Decoder integration")
print("✓ Database storage (telemetry_raw + telemetry_decoded)")
print("✓ Error handling")
print()
print("Ready to be used by session.py!")

@ -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,542 @@
#!/usr/bin/env python3
"""
Session Widget - vzug-e-hinge
==============================
PyQt6 GUI widget for session orchestration.
This widget provides the user interface for automated test sessions:
- Load interface and session profiles
- Start/pause/resume/stop session execution
- Display real-time status updates
- Show command progress and packet counts
- Display countdown timers during delays
- Monitor execution via data log
The widget connects to Session class via Qt signals/slots for
asynchronous updates without blocking the GUI.
Author: Kynsight
Version: 2.0.0
Date: 2025-11-09
"""
# =============================================================================
# IMPORTS
# =============================================================================
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
QLabel, QLineEdit, QComboBox, QPushButton,
QTextEdit, QFrame
)
from PyQt6.QtCore import Qt, pyqtSlot
from PyQt6.QtGui import QFont
from session import Session
from database.init_database import DatabaseManager
# =============================================================================
# SESSION WIDGET
# =============================================================================
class SessionWidget(QWidget):
"""
Session widget - GUI for automated test sessions.
Layout:
Session Widget
Session Name: [________________] [Load Session ]
Interface Profile: [Auto-loaded ]
Session Profile: [Auto-loaded ]
Status: Idle
Executing: ---
Command: 0 / 0
[ Start] [ Pause] [ Stop]
Data Monitor
[INFO] Session loaded: Test_Session_01
[INFO] Command 1/4: Open Door
[INFO] Run 1 complete: 127 packets detected
[INFO] Waiting... (3s remaining)
Status Values:
- Idle: No session loaded (gray)
- Running: Executing run (green)
- Waiting: Delay between runs (yellow)
- Paused: User paused (orange)
- Finished: Session completed (green)
- Error: Communication error (red)
"""
# =========================================================================
# CONSTRUCTOR
# =========================================================================
def __init__(self, db_manager: DatabaseManager):
"""
Initialize session widget.
Args:
db_manager: Database manager instance
"""
super().__init__()
# Store database manager
self.db_manager = db_manager
self.db_conn = db_manager.get_connection()
# Create session object
self.session = Session(db_manager)
# Connect signals from session to widget slots
self._connect_signals()
# Initialize UI
self._init_ui()
# Load initial data
self._load_profiles()
# =========================================================================
# UI INITIALIZATION
# =========================================================================
def _init_ui(self):
"""Initialize user interface."""
# Main layout
layout = QVBoxLayout()
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10)
# =====================================================================
# Configuration Section
# =====================================================================
config_group = QGroupBox("Session Configuration")
config_layout = QVBoxLayout()
# Session name input
name_layout = QHBoxLayout()
name_layout.addWidget(QLabel("Session Name:"))
self.session_name_input = QLineEdit()
self.session_name_input.setPlaceholderText("Auto-generated if empty")
name_layout.addWidget(self.session_name_input)
config_layout.addLayout(name_layout)
# Interface profile dropdown
interface_layout = QHBoxLayout()
interface_layout.addWidget(QLabel("Interface Profile:"))
self.interface_profile_combo = QComboBox()
interface_layout.addWidget(self.interface_profile_combo)
config_layout.addLayout(interface_layout)
# Session profile dropdown
session_layout = QHBoxLayout()
session_layout.addWidget(QLabel("Session Profile:"))
self.session_profile_combo = QComboBox()
session_layout.addWidget(self.session_profile_combo)
config_layout.addLayout(session_layout)
# Load button
load_btn_layout = QHBoxLayout()
load_btn_layout.addStretch()
self.load_button = QPushButton("Load Session")
self.load_button.clicked.connect(self._on_load_clicked)
load_btn_layout.addWidget(self.load_button)
config_layout.addLayout(load_btn_layout)
config_group.setLayout(config_layout)
layout.addWidget(config_group)
# =====================================================================
# Status Section
# =====================================================================
status_group = QGroupBox("Session Status")
status_layout = QVBoxLayout()
# Status label
self.status_label = QLabel("Status: <span style='color: gray;'>Idle</span>")
self.status_label.setTextFormat(Qt.TextFormat.RichText)
status_layout.addWidget(self.status_label)
# Executing label
self.executing_label = QLabel("Executing: ---")
status_layout.addWidget(self.executing_label)
# Command progress label
self.command_label = QLabel("Command: 0 / 0")
status_layout.addWidget(self.command_label)
status_group.setLayout(status_layout)
layout.addWidget(status_group)
# =====================================================================
# Control Buttons
# =====================================================================
btn_layout = QHBoxLayout()
# Start button
self.start_button = QPushButton("▶️ Start")
self.start_button.clicked.connect(self._on_start_clicked)
self.start_button.setEnabled(False)
btn_layout.addWidget(self.start_button)
# Pause button
self.pause_button = QPushButton("⏸️ Pause")
self.pause_button.clicked.connect(self._on_pause_clicked)
self.pause_button.setEnabled(False)
btn_layout.addWidget(self.pause_button)
# Resume button
self.resume_button = QPushButton("▶️ Resume")
self.resume_button.clicked.connect(self._on_resume_clicked)
self.resume_button.setEnabled(False)
self.resume_button.setVisible(False)
btn_layout.addWidget(self.resume_button)
# Stop button
self.stop_button = QPushButton("⏹️ Stop")
self.stop_button.clicked.connect(self._on_stop_clicked)
self.stop_button.setEnabled(False)
btn_layout.addWidget(self.stop_button)
layout.addLayout(btn_layout)
# =====================================================================
# Data Monitor
# =====================================================================
monitor_group = QGroupBox("Data Monitor")
monitor_layout = QVBoxLayout()
# Text display for log messages
self.log_display = QTextEdit()
self.log_display.setReadOnly(True)
self.log_display.setMinimumHeight(150) # Minimum height, can expand
# Set monospace font for log
font = QFont("Courier New")
font.setPointSize(9)
self.log_display.setFont(font)
monitor_layout.addWidget(self.log_display)
monitor_group.setLayout(monitor_layout)
layout.addWidget(monitor_group, 1) # Stretch factor 1 = expands to fill space
# =====================================================================
# Finalize Layout
# =====================================================================
self.setLayout(layout)
self.setMinimumWidth(600)
# =========================================================================
# SIGNAL CONNECTION
# =========================================================================
def _connect_signals(self):
"""Connect session signals to widget slots."""
self.session.session_started.connect(self._on_session_started)
self.session.command_started.connect(self._on_command_started)
self.session.run_completed.connect(self._on_run_completed)
self.session.delay_countdown.connect(self._on_delay_countdown)
self.session.session_paused.connect(self._on_session_paused)
self.session.session_finished.connect(self._on_session_finished)
self.session.error_occurred.connect(self._on_error_occurred)
self.session.status_changed.connect(self._on_status_changed)
self.session.raw_data_received.connect(self._on_raw_data_received)
# =========================================================================
# PROFILE LOADING
# =========================================================================
def _load_profiles(self):
"""Load interface and session profiles from database into dropdowns."""
try:
# Load interface profiles
cursor = self.db_conn.execute("""
SELECT profile_id, profile_name
FROM interface_profiles
ORDER BY profile_name
""")
self.interface_profile_combo.clear()
for row in cursor.fetchall():
self.interface_profile_combo.addItem(row[1], row[0]) # text, data
# Load session profiles
cursor = self.db_conn.execute("""
SELECT profile_id, profile_name
FROM session_profiles
ORDER BY profile_name
""")
self.session_profile_combo.clear()
for row in cursor.fetchall():
self.session_profile_combo.addItem(row[1], row[0]) # text, data
self._log_info("Profiles loaded from database")
except Exception as e:
self._log_error(f"Failed to load profiles: {str(e)}")
def refresh_profiles(self):
"""
Public method to refresh profile dropdowns.
Called when Session tab becomes active.
"""
self._load_profiles()
# =========================================================================
# BUTTON HANDLERS
# =========================================================================
def _on_load_clicked(self):
"""Handle load session button click."""
# Get selected profile IDs
interface_id = self.interface_profile_combo.currentData()
session_id = self.session_profile_combo.currentData()
if interface_id is None or session_id is None:
self._log_error("Please select both interface and session profiles")
return
# Get session name
session_name = self.session_name_input.text().strip()
if not session_name:
session_name = None # Will be auto-generated
# Load session
success = self.session.load_session(interface_id, session_id, session_name)
if success:
# Enable start button
self.start_button.setEnabled(True)
self._log_info("Session ready to start")
else:
self._log_error("Failed to load session")
def _on_start_clicked(self):
"""Handle start button click."""
# Disable controls during startup
self.start_button.setEnabled(False)
self.load_button.setEnabled(False)
self.interface_profile_combo.setEnabled(False)
self.session_profile_combo.setEnabled(False)
self.session_name_input.setEnabled(False)
# Start session
success = self.session.start_session()
if success:
# Enable pause/stop buttons
self.pause_button.setEnabled(True)
self.stop_button.setEnabled(True)
else:
# Re-enable start button on failure
self.start_button.setEnabled(True)
self.load_button.setEnabled(True)
self.interface_profile_combo.setEnabled(True)
self.session_profile_combo.setEnabled(True)
self.session_name_input.setEnabled(True)
def _on_pause_clicked(self):
"""Handle pause button click."""
self.session.pause_session()
# Button states will be updated by signal handlers
def _on_resume_clicked(self):
"""Handle resume button click."""
self.session.resume_session()
# Button states will be updated by signal handlers
def _on_stop_clicked(self):
"""Handle stop button click."""
self.session.stop_session()
# Button states will be updated by signal handlers
# =========================================================================
# SIGNAL HANDLERS (SLOTS)
# =========================================================================
@pyqtSlot(str)
def _on_session_started(self, session_id: str):
"""Handle session started signal."""
self._update_status("Running", "green")
self._log_info(f"Session started: {session_id}")
@pyqtSlot(int, str)
def _on_command_started(self, command_no: int, command_name: str):
"""Handle command started signal."""
self.executing_label.setText(f"Executing: {command_name}")
total = self.session.total_commands
self.command_label.setText(f"Command: {command_no} / {total}")
self._log_info(f"[{command_no}/{total}] {command_name}")
@pyqtSlot(int, int)
def _on_run_completed(self, run_no: int, packet_count: int):
"""Handle run completed signal."""
self._log_info(f"Run {run_no} complete: {packet_count} packets detected")
@pyqtSlot(int)
def _on_delay_countdown(self, seconds_remaining: int):
"""Handle delay countdown signal."""
self._update_status("Waiting", "orange")
self.executing_label.setText(f"Waiting... ({seconds_remaining}s remaining)")
@pyqtSlot()
def _on_session_paused(self):
"""Handle session paused signal."""
self._update_status("Paused", "orange")
self.executing_label.setText("Paused by user")
# Update button states
self.pause_button.setVisible(False)
self.pause_button.setEnabled(False)
self.resume_button.setVisible(True)
self.resume_button.setEnabled(True)
self.stop_button.setEnabled(False)
self._log_info("Session paused")
@pyqtSlot()
def _on_session_finished(self):
"""Handle session finished signal."""
self._update_status("Finished", "green")
self.executing_label.setText("Session completed successfully")
# Reset button states
self._reset_controls()
self._log_info("Session completed successfully ✓")
@pyqtSlot(str)
def _on_error_occurred(self, error_msg: str):
"""Handle error signal."""
self._update_status("Error", "red")
self._log_error(error_msg)
# Reset button states
self._reset_controls()
@pyqtSlot(str)
def _on_status_changed(self, status_text: str):
"""Handle status changed signal."""
# Log status updates (but filter out countdown spam)
if "remaining)" not in status_text:
self._log_info(status_text)
@pyqtSlot(str, str)
def _on_raw_data_received(self, direction: str, hex_string: str):
"""
Handle raw UART data display.
Args:
direction: "TX" or "RX"
hex_string: Hex bytes (e.g., "EF FE 01 02 03")
"""
if direction == "TX":
color = "green"
prefix = "→ TX"
else:
color = "blue"
prefix = "← RX"
self.log_display.append(
f"<span style='color: {color};'>[{prefix}] {hex_string}</span>"
)
# Auto-scroll to bottom
self.log_display.verticalScrollBar().setValue(
self.log_display.verticalScrollBar().maximum()
)
# =========================================================================
# UI HELPER METHODS
# =========================================================================
def _update_status(self, status: str, color: str):
"""
Update status label with color.
Args:
status: Status text
color: HTML color name
"""
self.status_label.setText(f"Status: <span style='color: {color};'>{status}</span>")
def _reset_controls(self):
"""Reset controls to initial state after session ends."""
# Enable configuration controls
self.load_button.setEnabled(True)
self.interface_profile_combo.setEnabled(True)
self.session_profile_combo.setEnabled(True)
self.session_name_input.setEnabled(True)
# Reset button states
self.start_button.setEnabled(True)
self.pause_button.setVisible(True)
self.pause_button.setEnabled(False)
self.resume_button.setVisible(False)
self.resume_button.setEnabled(False)
self.stop_button.setEnabled(False)
# Reset labels
self.executing_label.setText("Executing: ---")
self.command_label.setText("Command: 0 / 0")
def _log_info(self, message: str):
"""
Log info message to data monitor.
Args:
message: Message to log
"""
self.log_display.append(f"[INFO] {message}")
# Auto-scroll to bottom
self.log_display.verticalScrollBar().setValue(
self.log_display.verticalScrollBar().maximum()
)
def _log_error(self, message: str):
"""
Log error message to data monitor.
Args:
message: Error message to log
"""
self.log_display.append(f"<span style='color: red;'>[ERROR] {message}</span>")
# Auto-scroll to bottom
self.log_display.verticalScrollBar().setValue(
self.log_display.verticalScrollBar().maximum()
)
# =============================================================================
# MAIN (for testing)
# =============================================================================
if __name__ == "__main__":
import sys
from PyQt6.QtWidgets import QApplication
# Create application
app = QApplication(sys.argv)
# Create database manager
db_manager = DatabaseManager("database/ehinge.db")
db_manager.initialize()
# Create widget
widget = SessionWidget(db_manager)
widget.setWindowTitle("Session Widget - vzug-e-hinge")
widget.show()
# Run application
sys.exit(app.exec())

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