moteus-gui 0.3.92__py3-none-any.whl → 0.3.93__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- moteus_gui/tview.py +1317 -10
- moteus_gui/version.py +1 -1
- {moteus_gui-0.3.92.dist-info → moteus_gui-0.3.93.dist-info}/METADATA +2 -2
- moteus_gui-0.3.93.dist-info/RECORD +9 -0
- moteus_gui-0.3.92.dist-info/RECORD +0 -9
- {moteus_gui-0.3.92.dist-info → moteus_gui-0.3.93.dist-info}/WHEEL +0 -0
- {moteus_gui-0.3.92.dist-info → moteus_gui-0.3.93.dist-info}/entry_points.txt +0 -0
- {moteus_gui-0.3.92.dist-info → moteus_gui-0.3.93.dist-info}/top_level.txt +0 -0
moteus_gui/tview.py
CHANGED
|
@@ -18,10 +18,17 @@
|
|
|
18
18
|
'''
|
|
19
19
|
|
|
20
20
|
import argparse
|
|
21
|
+
import ast
|
|
21
22
|
import asyncio
|
|
23
|
+
import codeop
|
|
24
|
+
import csv
|
|
25
|
+
from dataclasses import dataclass
|
|
22
26
|
import io
|
|
27
|
+
import json
|
|
28
|
+
import math
|
|
23
29
|
import moteus
|
|
24
30
|
import moteus.moteus_tool
|
|
31
|
+
from moteus.moteus import namedtuple_to_dict
|
|
25
32
|
import numpy
|
|
26
33
|
import os
|
|
27
34
|
import re
|
|
@@ -73,6 +80,50 @@ if os.environ['QT_API'] == 'pyside6':
|
|
|
73
80
|
import asyncqt
|
|
74
81
|
|
|
75
82
|
import moteus.reader as reader
|
|
83
|
+
import moteus.multiplex
|
|
84
|
+
from moteus import Register, Mode
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Fault monitoring configuration
|
|
88
|
+
FAULT_POLLING_INTERVAL_MS = 500
|
|
89
|
+
|
|
90
|
+
# Fault code descriptions from docs/reference.md
|
|
91
|
+
FAULT_CODE_DESCRIPTIONS = {
|
|
92
|
+
32: "calibration fault",
|
|
93
|
+
33: "motor driver fault",
|
|
94
|
+
34: "over voltage",
|
|
95
|
+
35: "encoder fault",
|
|
96
|
+
36: "motor not configured",
|
|
97
|
+
37: "pwm cycle overrun",
|
|
98
|
+
38: "over temperature",
|
|
99
|
+
39: "outside limit",
|
|
100
|
+
40: "under voltage",
|
|
101
|
+
41: "config changed",
|
|
102
|
+
42: "theta invalid",
|
|
103
|
+
43: "position invalid",
|
|
104
|
+
44: "driver enable fault",
|
|
105
|
+
45: "stop position deprecated",
|
|
106
|
+
46: "timing violation",
|
|
107
|
+
47: "bemf feedforward without accel",
|
|
108
|
+
48: "invalid limits",
|
|
109
|
+
96: "limit: servo.max_velocity",
|
|
110
|
+
97: "limit: servo.max_power_W",
|
|
111
|
+
98: "limit: max system voltage",
|
|
112
|
+
99: "limit: servo.max_current_A",
|
|
113
|
+
100: "limit: servo.fault_temperature",
|
|
114
|
+
101: "limit: servo.motor_fault_temperature",
|
|
115
|
+
102: "limit: commanded max torque",
|
|
116
|
+
103: "limit: servopos limit",
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class FaultState:
|
|
122
|
+
"""Tracks fault status and user observation for a device."""
|
|
123
|
+
is_faulted: bool = False
|
|
124
|
+
observed: bool = True # Default to observed (no flashing for non-faulty devices)
|
|
125
|
+
current_mode: int = None # Current mode register value
|
|
126
|
+
current_fault_code: int = None # Current fault register value
|
|
76
127
|
|
|
77
128
|
|
|
78
129
|
try:
|
|
@@ -161,6 +212,43 @@ def _add_schema_item(parent, element, terminal_flags=None):
|
|
|
161
212
|
if terminal_flags:
|
|
162
213
|
parent.setFlags(terminal_flags)
|
|
163
214
|
|
|
215
|
+
def _is_servo_stats_fault_field(item):
|
|
216
|
+
"""Check if the tree widget item represents a servo_stats.fault field."""
|
|
217
|
+
# Check if this is a leaf node (has no children and is displaying a value)
|
|
218
|
+
if item.childCount() > 0:
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
# Get the field name
|
|
222
|
+
field_name = item.text(0).lower()
|
|
223
|
+
if field_name != "fault":
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
# Check if parent is servo_stats
|
|
227
|
+
parent = item.parent()
|
|
228
|
+
if parent is None:
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
parent_name = parent.text(0).lower()
|
|
232
|
+
return parent_name == "servo_stats"
|
|
233
|
+
|
|
234
|
+
def _format_fault_code(fault_code):
|
|
235
|
+
"""Format a fault code with human-readable description.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
fault_code: Integer fault code (can be None)
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
str: Formatted fault code string, "0" for no fault, empty string for None
|
|
242
|
+
"""
|
|
243
|
+
if fault_code is None:
|
|
244
|
+
return ""
|
|
245
|
+
|
|
246
|
+
if fault_code == 0:
|
|
247
|
+
return "0"
|
|
248
|
+
|
|
249
|
+
description = FAULT_CODE_DESCRIPTIONS.get(fault_code, "unknown fault code")
|
|
250
|
+
return f"{fault_code} ({description})"
|
|
251
|
+
|
|
164
252
|
def _set_tree_widget_data(item, struct, element, terminal_flags=None):
|
|
165
253
|
if (isinstance(element, reader.ObjectType) or
|
|
166
254
|
isinstance(element, reader.ArrayType) or
|
|
@@ -187,6 +275,9 @@ def _set_tree_widget_data(item, struct, element, terminal_flags=None):
|
|
|
187
275
|
text = None
|
|
188
276
|
if maybe_format == FMT_HEX and type(struct) == int:
|
|
189
277
|
text = f"{struct:x}"
|
|
278
|
+
elif _is_servo_stats_fault_field(item) and isinstance(struct, int):
|
|
279
|
+
# Special formatting for servo_stats.fault field
|
|
280
|
+
text = _format_fault_code(struct)
|
|
190
281
|
else:
|
|
191
282
|
text = repr(struct)
|
|
192
283
|
item.setText(1, text)
|
|
@@ -423,6 +514,392 @@ class TviewConsoleWidget(HistoryConsoleWidget):
|
|
|
423
514
|
return True
|
|
424
515
|
|
|
425
516
|
|
|
517
|
+
class TviewPythonConsole(HistoryConsoleWidget):
|
|
518
|
+
def __init__(self, parent=None, get_controller=None):
|
|
519
|
+
super().__init__(parent)
|
|
520
|
+
|
|
521
|
+
self.execute_on_complete_input = False
|
|
522
|
+
|
|
523
|
+
# Store our own copies of prompts since qtconsole modifies self._prompt
|
|
524
|
+
self._main_prompt = '>>> '
|
|
525
|
+
self._continuation_prompt_str = '... '
|
|
526
|
+
|
|
527
|
+
# Initialize the widget's prompt to main prompt
|
|
528
|
+
self._prompt = self._main_prompt
|
|
529
|
+
self._continuation_prompt = self._continuation_prompt_str
|
|
530
|
+
|
|
531
|
+
self.clear()
|
|
532
|
+
|
|
533
|
+
self._append_before_prompt_cursor.setPosition(0)
|
|
534
|
+
|
|
535
|
+
# Track currently running async task for cancellation
|
|
536
|
+
self._current_future = None
|
|
537
|
+
|
|
538
|
+
# Buffer for multi-line input
|
|
539
|
+
self._input_buffer = []
|
|
540
|
+
|
|
541
|
+
# Create custom print function for this console
|
|
542
|
+
def custom_print(*args, sep=' ', end='\n', file=None, flush=False):
|
|
543
|
+
"""Custom print function that outputs to the Python console widget."""
|
|
544
|
+
output = io.StringIO()
|
|
545
|
+
print(*args, sep=sep, end=end, file=output)
|
|
546
|
+
self._append_plain_text(output.getvalue())
|
|
547
|
+
|
|
548
|
+
self.namespace = {
|
|
549
|
+
'transport': None,
|
|
550
|
+
'controller': None,
|
|
551
|
+
'get_controller': get_controller,
|
|
552
|
+
'asyncio': asyncio,
|
|
553
|
+
'moteus': moteus,
|
|
554
|
+
'math': math,
|
|
555
|
+
'time': time,
|
|
556
|
+
'numpy': numpy,
|
|
557
|
+
'print': custom_print, # Custom print for console output
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
# Use a QShortcut for Ctrl+C
|
|
561
|
+
try:
|
|
562
|
+
try:
|
|
563
|
+
from PySide6.QtGui import QShortcut, QKeySequence
|
|
564
|
+
except ImportError:
|
|
565
|
+
from PySide6.QtWidgets import QShortcut
|
|
566
|
+
from PySide6.QtGui import QKeySequence
|
|
567
|
+
# PySide6 style
|
|
568
|
+
self._interrupt_shortcut = QShortcut(QKeySequence("Ctrl+C"), self._control)
|
|
569
|
+
self._interrupt_shortcut.activated.connect(self._handle_interrupt)
|
|
570
|
+
except ImportError:
|
|
571
|
+
try:
|
|
572
|
+
from PySide2.QtWidgets import QShortcut
|
|
573
|
+
from PySide2.QtGui import QKeySequence
|
|
574
|
+
# PySide2 requires a lambda wrapper for the callable
|
|
575
|
+
self._interrupt_shortcut = QShortcut(QKeySequence("Ctrl+C"), self._control, lambda: self._handle_interrupt())
|
|
576
|
+
except Exception as e:
|
|
577
|
+
print(f"Warning: Could not set up Ctrl+C shortcut: {e}")
|
|
578
|
+
self._interrupt_shortcut = None
|
|
579
|
+
|
|
580
|
+
for line in """
|
|
581
|
+
# Python REPL for moteus control
|
|
582
|
+
# Available: controller, controllers, get_controller(id), transport, moteus
|
|
583
|
+
# asyncio, math, numpy, time
|
|
584
|
+
# Use 'await' for async operations
|
|
585
|
+
# Press Ctrl+C to interrupt long-running operations
|
|
586
|
+
""".split('\n'):
|
|
587
|
+
self._append_plain_text(line + '\n')
|
|
588
|
+
|
|
589
|
+
self._show_prompt(self._main_prompt)
|
|
590
|
+
|
|
591
|
+
def sizeHint(self):
|
|
592
|
+
return QtCore.QSize(600, 200)
|
|
593
|
+
|
|
594
|
+
def _handle_interrupt(self):
|
|
595
|
+
"""Handle interrupt signal from QShortcut."""
|
|
596
|
+
# Append KeyboardInterrupt message on a new line
|
|
597
|
+
self._append_plain_text('\nKeyboardInterrupt\n')
|
|
598
|
+
|
|
599
|
+
# Cancel any running future
|
|
600
|
+
if self._current_future and not self._current_future.done():
|
|
601
|
+
self._current_future.cancel()
|
|
602
|
+
self._current_future = None
|
|
603
|
+
|
|
604
|
+
# Always clear all state and reset to main prompt
|
|
605
|
+
self._input_buffer = []
|
|
606
|
+
|
|
607
|
+
# Clear any temporary buffers in the widget itself
|
|
608
|
+
try:
|
|
609
|
+
self._clear_temporary_buffer()
|
|
610
|
+
except:
|
|
611
|
+
pass
|
|
612
|
+
|
|
613
|
+
# Finish any existing prompt
|
|
614
|
+
try:
|
|
615
|
+
self._prompt_finished()
|
|
616
|
+
except:
|
|
617
|
+
pass
|
|
618
|
+
|
|
619
|
+
# Force show the main prompt (not continuation)
|
|
620
|
+
self._show_prompt(self._main_prompt)
|
|
621
|
+
|
|
622
|
+
def _is_complete(self, source, interactive):
|
|
623
|
+
"""Check if the source code is complete and ready to execute.
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
(is_complete, indent_needed): Tuple indicating if code is complete
|
|
627
|
+
and if indentation should be added.
|
|
628
|
+
"""
|
|
629
|
+
try:
|
|
630
|
+
# Use codeop to determine if the code is complete
|
|
631
|
+
code = codeop.compile_command(source, '<console>', 'exec')
|
|
632
|
+
|
|
633
|
+
if code is None:
|
|
634
|
+
# More input is needed
|
|
635
|
+
return False, False
|
|
636
|
+
else:
|
|
637
|
+
# Code is complete
|
|
638
|
+
return True, False
|
|
639
|
+
|
|
640
|
+
except (SyntaxError, OverflowError, ValueError):
|
|
641
|
+
# Code has a syntax error but is complete (will error on execute)
|
|
642
|
+
return True, False
|
|
643
|
+
|
|
644
|
+
def _has_top_level_await(self, source):
|
|
645
|
+
"""Check if source code contains top-level await expressions.
|
|
646
|
+
|
|
647
|
+
This detects await expressions that are NOT inside async function definitions.
|
|
648
|
+
For example:
|
|
649
|
+
- `await foo()` -> True (needs wrapping)
|
|
650
|
+
- `async def bar(): await foo()` -> False (already in async context)
|
|
651
|
+
"""
|
|
652
|
+
try:
|
|
653
|
+
tree = ast.parse(source)
|
|
654
|
+
|
|
655
|
+
# Track which nodes are inside async function definitions
|
|
656
|
+
async_func_nodes = set()
|
|
657
|
+
|
|
658
|
+
# First pass: find all async function definition nodes and their children
|
|
659
|
+
for node in ast.walk(tree):
|
|
660
|
+
if isinstance(node, ast.AsyncFunctionDef):
|
|
661
|
+
# Mark all descendants of this async function
|
|
662
|
+
for child in ast.walk(node):
|
|
663
|
+
async_func_nodes.add(id(child))
|
|
664
|
+
|
|
665
|
+
# Second pass: find await expressions not inside async functions
|
|
666
|
+
for node in ast.walk(tree):
|
|
667
|
+
if isinstance(node, ast.Await):
|
|
668
|
+
# Check if this await is NOT inside an async function
|
|
669
|
+
if id(node) not in async_func_nodes:
|
|
670
|
+
return True
|
|
671
|
+
|
|
672
|
+
return False
|
|
673
|
+
except SyntaxError:
|
|
674
|
+
# If it doesn't parse, we'll handle the error later
|
|
675
|
+
return False
|
|
676
|
+
|
|
677
|
+
def _has_loops(self, source):
|
|
678
|
+
"""Check if source code contains loops (for/while)."""
|
|
679
|
+
try:
|
|
680
|
+
tree = ast.parse(source)
|
|
681
|
+
for node in ast.walk(tree):
|
|
682
|
+
if isinstance(node, (ast.While, ast.For)):
|
|
683
|
+
return True
|
|
684
|
+
return False
|
|
685
|
+
except SyntaxError:
|
|
686
|
+
return False
|
|
687
|
+
|
|
688
|
+
def _inject_yields_in_loops(self, source):
|
|
689
|
+
"""Transform source code to inject 'await asyncio.sleep(0)' in loop bodies.
|
|
690
|
+
|
|
691
|
+
This allows tight loops to be cancelled via CTRL-C by giving the event
|
|
692
|
+
loop a chance to process cancellation.
|
|
693
|
+
"""
|
|
694
|
+
try:
|
|
695
|
+
tree = ast.parse(source)
|
|
696
|
+
|
|
697
|
+
class LoopTransformer(ast.NodeTransformer):
|
|
698
|
+
def visit_While(self, node):
|
|
699
|
+
# Visit children first
|
|
700
|
+
self.generic_visit(node)
|
|
701
|
+
# Inject await asyncio.sleep(0) at the start of the loop body
|
|
702
|
+
yield_stmt = ast.Expr(
|
|
703
|
+
value=ast.Await(
|
|
704
|
+
value=ast.Call(
|
|
705
|
+
func=ast.Attribute(
|
|
706
|
+
value=ast.Name(id='asyncio', ctx=ast.Load()),
|
|
707
|
+
attr='sleep',
|
|
708
|
+
ctx=ast.Load()
|
|
709
|
+
),
|
|
710
|
+
args=[ast.Constant(value=0)],
|
|
711
|
+
keywords=[]
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
)
|
|
715
|
+
# Insert at the beginning of the body
|
|
716
|
+
node.body = [yield_stmt] + node.body
|
|
717
|
+
return node
|
|
718
|
+
|
|
719
|
+
def visit_For(self, node):
|
|
720
|
+
# Visit children first
|
|
721
|
+
self.generic_visit(node)
|
|
722
|
+
# Inject await asyncio.sleep(0) at the start of the loop body
|
|
723
|
+
yield_stmt = ast.Expr(
|
|
724
|
+
value=ast.Await(
|
|
725
|
+
value=ast.Call(
|
|
726
|
+
func=ast.Attribute(
|
|
727
|
+
value=ast.Name(id='asyncio', ctx=ast.Load()),
|
|
728
|
+
attr='sleep',
|
|
729
|
+
ctx=ast.Load()
|
|
730
|
+
),
|
|
731
|
+
args=[ast.Constant(value=0)],
|
|
732
|
+
keywords=[]
|
|
733
|
+
)
|
|
734
|
+
)
|
|
735
|
+
)
|
|
736
|
+
# Insert at the beginning of the body
|
|
737
|
+
node.body = [yield_stmt] + node.body
|
|
738
|
+
return node
|
|
739
|
+
|
|
740
|
+
transformer = LoopTransformer()
|
|
741
|
+
new_tree = transformer.visit(tree)
|
|
742
|
+
ast.fix_missing_locations(new_tree)
|
|
743
|
+
|
|
744
|
+
# Convert back to source code
|
|
745
|
+
return ast.unparse(new_tree)
|
|
746
|
+
except Exception:
|
|
747
|
+
# If transformation fails, return original source
|
|
748
|
+
return source
|
|
749
|
+
|
|
750
|
+
def _execute(self, source, hidden):
|
|
751
|
+
# Safety check: if we have a running future, we shouldn't be executing new code
|
|
752
|
+
# This shouldn't normally happen but is defensive
|
|
753
|
+
if self._current_future and not self._current_future.done():
|
|
754
|
+
return True
|
|
755
|
+
|
|
756
|
+
# If buffer is empty and source is empty, do nothing
|
|
757
|
+
if not self._input_buffer and not source.strip():
|
|
758
|
+
self._show_prompt(self._main_prompt)
|
|
759
|
+
return True
|
|
760
|
+
|
|
761
|
+
# Two modes:
|
|
762
|
+
# 1. Single-line mode (buffer empty): execute complete statements immediately
|
|
763
|
+
# 2. Multi-line mode (buffer has content): only execute on blank line
|
|
764
|
+
|
|
765
|
+
if not self._input_buffer:
|
|
766
|
+
# Single-line mode: check if this line starts a multi-line block
|
|
767
|
+
is_complete, indent_needed = self._is_complete(source, True)
|
|
768
|
+
|
|
769
|
+
if not is_complete:
|
|
770
|
+
# This starts a multi-line block - enter multi-line mode
|
|
771
|
+
self._input_buffer.append(source)
|
|
772
|
+
self._show_prompt(self._continuation_prompt_str)
|
|
773
|
+
return True
|
|
774
|
+
else:
|
|
775
|
+
# Complete single line - execute immediately
|
|
776
|
+
full_source = source
|
|
777
|
+
# Don't add to buffer, execute directly
|
|
778
|
+
else:
|
|
779
|
+
# Multi-line mode: we have buffered input
|
|
780
|
+
if source:
|
|
781
|
+
# Non-blank line in multi-line mode - add to buffer and continue
|
|
782
|
+
self._input_buffer.append(source)
|
|
783
|
+
self._show_prompt(self._continuation_prompt_str)
|
|
784
|
+
return True
|
|
785
|
+
else:
|
|
786
|
+
# Blank line in multi-line mode - check if ready to execute
|
|
787
|
+
self._input_buffer.append(source)
|
|
788
|
+
full_source = '\n'.join(self._input_buffer)
|
|
789
|
+
|
|
790
|
+
is_complete, indent_needed = self._is_complete(full_source, True)
|
|
791
|
+
|
|
792
|
+
if not is_complete:
|
|
793
|
+
# Still not complete - continue
|
|
794
|
+
self._show_prompt(self._continuation_prompt_str)
|
|
795
|
+
return True
|
|
796
|
+
|
|
797
|
+
# Complete - execute
|
|
798
|
+
self._input_buffer = []
|
|
799
|
+
|
|
800
|
+
# If the source was just blank lines, don't execute
|
|
801
|
+
if not full_source.strip():
|
|
802
|
+
self._show_prompt(self._main_prompt)
|
|
803
|
+
return True
|
|
804
|
+
|
|
805
|
+
loop = asyncio.get_event_loop()
|
|
806
|
+
|
|
807
|
+
# Determine if this should run in async context
|
|
808
|
+
# Use async only if: code has top-level await OR code has loops (for cancellation)
|
|
809
|
+
has_await = self._has_top_level_await(full_source)
|
|
810
|
+
has_loops = self._has_loops(full_source)
|
|
811
|
+
use_async = has_await or has_loops
|
|
812
|
+
|
|
813
|
+
try:
|
|
814
|
+
if use_async:
|
|
815
|
+
# Inject yield points in loops to allow cancellation of tight loops
|
|
816
|
+
transformed_source = self._inject_yields_in_loops(full_source)
|
|
817
|
+
|
|
818
|
+
# Determine if this is an expression or statement using AST
|
|
819
|
+
# We can't use compile() because await expressions fail in eval mode
|
|
820
|
+
is_expression = False
|
|
821
|
+
try:
|
|
822
|
+
tree = ast.parse(transformed_source)
|
|
823
|
+
# Check if it's a single expression statement
|
|
824
|
+
if (len(tree.body) == 1 and
|
|
825
|
+
isinstance(tree.body[0], ast.Expr)):
|
|
826
|
+
is_expression = True
|
|
827
|
+
except SyntaxError:
|
|
828
|
+
pass
|
|
829
|
+
|
|
830
|
+
# Wrap in an async function appropriately
|
|
831
|
+
if is_expression:
|
|
832
|
+
# For expressions, we can return the value
|
|
833
|
+
indented = '\n'.join(' ' + line for line in transformed_source.split('\n'))
|
|
834
|
+
wrapped = f"async def _async_exec():\n return (\n{indented}\n )"
|
|
835
|
+
else:
|
|
836
|
+
# For statements, just execute them
|
|
837
|
+
indented = '\n'.join(' ' + line for line in transformed_source.split('\n'))
|
|
838
|
+
wrapped = f"async def _async_exec():\n{indented}\n return None"
|
|
839
|
+
|
|
840
|
+
exec(wrapped, self.namespace)
|
|
841
|
+
# Now, run the function we just evaluated.
|
|
842
|
+
# Use create_task for better cancellation support
|
|
843
|
+
self._current_future = asyncio.create_task(self.namespace['_async_exec']())
|
|
844
|
+
|
|
845
|
+
def done_callback(future):
|
|
846
|
+
try:
|
|
847
|
+
result = future.result()
|
|
848
|
+
if result is not None:
|
|
849
|
+
self._append_plain_text(repr(result) + '\n')
|
|
850
|
+
except asyncio.CancelledError:
|
|
851
|
+
# Don't show anything extra - already handled in interrupt handler
|
|
852
|
+
# But ensure buffer is cleared (defensive)
|
|
853
|
+
self._input_buffer = []
|
|
854
|
+
pass
|
|
855
|
+
except Exception as e:
|
|
856
|
+
self._append_plain_text(f'Error: {type(e).__name__}: {e}\n')
|
|
857
|
+
finally:
|
|
858
|
+
# Clear the current future reference
|
|
859
|
+
if self._current_future is future:
|
|
860
|
+
self._current_future = None
|
|
861
|
+
|
|
862
|
+
# Only show prompt if not cancelled (already shown in interrupt handler)
|
|
863
|
+
if not future.cancelled():
|
|
864
|
+
self._show_prompt(self._main_prompt)
|
|
865
|
+
|
|
866
|
+
self._current_future.add_done_callback(done_callback)
|
|
867
|
+
else:
|
|
868
|
+
# Try as expression first, then as a statement.
|
|
869
|
+
try:
|
|
870
|
+
result = eval(full_source, self.namespace)
|
|
871
|
+
if result is not None:
|
|
872
|
+
self._append_plain_text(repr(result) + '\n')
|
|
873
|
+
except SyntaxError:
|
|
874
|
+
# Try as a statement.
|
|
875
|
+
exec(full_source, self.namespace)
|
|
876
|
+
|
|
877
|
+
self._show_prompt(self._main_prompt)
|
|
878
|
+
except Exception as e:
|
|
879
|
+
self._append_plain_text(f'Error: {type(e).__name__}: {e}\n')
|
|
880
|
+
self._show_prompt(self._main_prompt)
|
|
881
|
+
# Clear buffer on error to avoid getting stuck
|
|
882
|
+
self._input_buffer = []
|
|
883
|
+
|
|
884
|
+
return True
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
class TviewTabbedConsole(QtWidgets.QTabWidget):
|
|
888
|
+
def __init__(self, parent=None, get_controller=None):
|
|
889
|
+
super().__init__(parent)
|
|
890
|
+
|
|
891
|
+
self.diagnostic_console = TviewConsoleWidget()
|
|
892
|
+
self.diagnostic_console.ansi_codes = False
|
|
893
|
+
|
|
894
|
+
self.addTab(self.diagnostic_console, "Diagnostic")
|
|
895
|
+
|
|
896
|
+
self.python_console = TviewPythonConsole(
|
|
897
|
+
get_controller=get_controller)
|
|
898
|
+
self.addTab(self.python_console, "Python")
|
|
899
|
+
|
|
900
|
+
def sizeHint(self):
|
|
901
|
+
return QtCore.QSize(600, 200)
|
|
902
|
+
|
|
426
903
|
class Record:
|
|
427
904
|
def __init__(self, archive):
|
|
428
905
|
self.archive = archive
|
|
@@ -458,6 +935,298 @@ class Record:
|
|
|
458
935
|
return count != 0
|
|
459
936
|
|
|
460
937
|
|
|
938
|
+
def flatten_dict(d, parent_key='', sep='.'):
|
|
939
|
+
"""Flatten a nested dictionary into a single-level dict with dotted keys.
|
|
940
|
+
|
|
941
|
+
Args:
|
|
942
|
+
d: Dictionary to flatten
|
|
943
|
+
parent_key: Prefix for keys (used in recursion)
|
|
944
|
+
sep: Separator between nested keys
|
|
945
|
+
|
|
946
|
+
Returns:
|
|
947
|
+
Flattened dictionary with dotted keys
|
|
948
|
+
"""
|
|
949
|
+
items = []
|
|
950
|
+
for k, v in d.items():
|
|
951
|
+
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
|
952
|
+
if isinstance(v, dict):
|
|
953
|
+
items.extend(flatten_dict(v, new_key, sep=sep).items())
|
|
954
|
+
elif isinstance(v, list):
|
|
955
|
+
items.extend(_flatten_list(v, new_key, sep).items())
|
|
956
|
+
else:
|
|
957
|
+
items.append((new_key, v))
|
|
958
|
+
return dict(items)
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _flatten_list(lst, parent_key, sep='.'):
|
|
962
|
+
"""Helper to recursively flatten lists (including nested lists).
|
|
963
|
+
|
|
964
|
+
Args:
|
|
965
|
+
lst: List to flatten
|
|
966
|
+
parent_key: Prefix for keys
|
|
967
|
+
sep: Separator between nested keys
|
|
968
|
+
|
|
969
|
+
Returns:
|
|
970
|
+
Flattened dictionary with indexed keys
|
|
971
|
+
"""
|
|
972
|
+
items = []
|
|
973
|
+
for i, item in enumerate(lst):
|
|
974
|
+
new_key = f"{parent_key}[{i}]"
|
|
975
|
+
if isinstance(item, dict):
|
|
976
|
+
items.extend(flatten_dict(item, new_key, sep=sep).items())
|
|
977
|
+
elif isinstance(item, list):
|
|
978
|
+
items.extend(_flatten_list(item, new_key, sep).items())
|
|
979
|
+
else:
|
|
980
|
+
items.append((new_key, item))
|
|
981
|
+
return dict(items)
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
class LoggingManager:
|
|
985
|
+
"""Manages data logging to disk"""
|
|
986
|
+
|
|
987
|
+
def __init__(self):
|
|
988
|
+
self.log_file = None
|
|
989
|
+
self.log_filename = None
|
|
990
|
+
self.logging_devices = None
|
|
991
|
+
self.logging_channels = None
|
|
992
|
+
self.log_format = None
|
|
993
|
+
|
|
994
|
+
self.csv_writers = {}
|
|
995
|
+
self.csv_files = {}
|
|
996
|
+
self.csv_fieldnames = {}
|
|
997
|
+
self.csv_base_path = None
|
|
998
|
+
|
|
999
|
+
def is_logging(self):
|
|
1000
|
+
"""Check if any logging is currently active."""
|
|
1001
|
+
return (self.log_file is not None or
|
|
1002
|
+
len(self.csv_files) > 0 or
|
|
1003
|
+
self.csv_base_path is not None)
|
|
1004
|
+
|
|
1005
|
+
def start_logging(self, filename, devices=None, channels=None, format='jsonl'):
|
|
1006
|
+
"""Start logging to the specified file.
|
|
1007
|
+
|
|
1008
|
+
Args:
|
|
1009
|
+
filename: Path to the output file (for CSV, used as base name)
|
|
1010
|
+
devices: None for all devices, or set of device addresses to log
|
|
1011
|
+
channels: None for all channels, or set of channel names to log
|
|
1012
|
+
format: 'jsonl' or 'csv'
|
|
1013
|
+
"""
|
|
1014
|
+
if self.is_logging():
|
|
1015
|
+
self.stop_logging()
|
|
1016
|
+
|
|
1017
|
+
self.log_format = format
|
|
1018
|
+
self.logging_devices = devices
|
|
1019
|
+
self.logging_channels = channels
|
|
1020
|
+
|
|
1021
|
+
if format == 'jsonl':
|
|
1022
|
+
try:
|
|
1023
|
+
self.log_file = open(filename, 'w')
|
|
1024
|
+
self.log_filename = filename
|
|
1025
|
+
except Exception as e:
|
|
1026
|
+
print(f"Error opening log file {filename}: {e}")
|
|
1027
|
+
self.log_file = None
|
|
1028
|
+
self.log_filename = None
|
|
1029
|
+
raise
|
|
1030
|
+
elif format == 'csv':
|
|
1031
|
+
self.csv_base_path = filename
|
|
1032
|
+
self.log_filename = filename
|
|
1033
|
+
else:
|
|
1034
|
+
raise ValueError(f"Unknown log format: {format}")
|
|
1035
|
+
|
|
1036
|
+
def stop_logging(self):
|
|
1037
|
+
"""Stop logging and close all files."""
|
|
1038
|
+
if self.log_file:
|
|
1039
|
+
try:
|
|
1040
|
+
self.log_file.close()
|
|
1041
|
+
except Exception as e:
|
|
1042
|
+
print(f"Error closing log file: {e}")
|
|
1043
|
+
finally:
|
|
1044
|
+
self.log_file = None
|
|
1045
|
+
|
|
1046
|
+
for key, f in self.csv_files.items():
|
|
1047
|
+
try:
|
|
1048
|
+
f.close()
|
|
1049
|
+
except Exception as e:
|
|
1050
|
+
print(f"Error closing CSV file {key}: {e}")
|
|
1051
|
+
|
|
1052
|
+
self.csv_files.clear()
|
|
1053
|
+
self.csv_writers.clear()
|
|
1054
|
+
self.csv_fieldnames.clear()
|
|
1055
|
+
self.csv_base_path = None
|
|
1056
|
+
self.log_filename = None
|
|
1057
|
+
self.logging_devices = None
|
|
1058
|
+
self.logging_channels = None
|
|
1059
|
+
self.log_format = None
|
|
1060
|
+
|
|
1061
|
+
def should_log(self, device_address, channel_name):
|
|
1062
|
+
"""Check if this device/channel combination should be logged."""
|
|
1063
|
+
if not self.is_logging():
|
|
1064
|
+
return False
|
|
1065
|
+
|
|
1066
|
+
if self.logging_devices is not None:
|
|
1067
|
+
if device_address not in self.logging_devices:
|
|
1068
|
+
return False
|
|
1069
|
+
|
|
1070
|
+
if self.logging_channels is not None:
|
|
1071
|
+
if channel_name not in self.logging_channels:
|
|
1072
|
+
return False
|
|
1073
|
+
|
|
1074
|
+
return True
|
|
1075
|
+
|
|
1076
|
+
def _make_csv_filename(self, controller_address, channel_name):
|
|
1077
|
+
"""Generate a CSV filename for a specific controller/channel combo.
|
|
1078
|
+
|
|
1079
|
+
Args:
|
|
1080
|
+
controller_address: Address of the controller
|
|
1081
|
+
channel_name: Name of the channel
|
|
1082
|
+
|
|
1083
|
+
Returns:
|
|
1084
|
+
Full path for the CSV file
|
|
1085
|
+
"""
|
|
1086
|
+
import os.path
|
|
1087
|
+
|
|
1088
|
+
base_dir = os.path.dirname(self.csv_base_path)
|
|
1089
|
+
base_name = os.path.basename(self.csv_base_path)
|
|
1090
|
+
|
|
1091
|
+
name_parts = os.path.splitext(base_name)
|
|
1092
|
+
if len(name_parts) == 2 and name_parts[1]:
|
|
1093
|
+
base, ext = name_parts
|
|
1094
|
+
else:
|
|
1095
|
+
base = base_name
|
|
1096
|
+
ext = '.csv'
|
|
1097
|
+
|
|
1098
|
+
# Extract a clean controller identifier
|
|
1099
|
+
if isinstance(controller_address, int):
|
|
1100
|
+
controller_id = str(controller_address)
|
|
1101
|
+
elif hasattr(controller_address, 'can_id') and controller_address.can_id is not None:
|
|
1102
|
+
controller_id = str(controller_address.can_id)
|
|
1103
|
+
elif hasattr(controller_address, 'uuid') and controller_address.uuid is not None:
|
|
1104
|
+
controller_id = controller_address.uuid.hex()[:8]
|
|
1105
|
+
else:
|
|
1106
|
+
controller_id = str(controller_address).replace('/', '_').replace('\\', '_')
|
|
1107
|
+
|
|
1108
|
+
sanitized_channel = channel_name.replace('/', '_').replace('\\', '_')
|
|
1109
|
+
|
|
1110
|
+
filename = f"{base}_{controller_id}_{sanitized_channel}{ext}"
|
|
1111
|
+
return os.path.join(base_dir, filename) if base_dir else filename
|
|
1112
|
+
|
|
1113
|
+
def _get_csv_key(self, controller_address, channel_name):
|
|
1114
|
+
"""Get or create CSV file infrastructure for a controller/channel combo.
|
|
1115
|
+
|
|
1116
|
+
Creates file and initializes tracking dictionaries if needed.
|
|
1117
|
+
|
|
1118
|
+
Args:
|
|
1119
|
+
controller_address: Address of the controller
|
|
1120
|
+
channel_name: Name of the channel
|
|
1121
|
+
|
|
1122
|
+
Returns:
|
|
1123
|
+
Key tuple (controller_str, channel_name) for indexing csv_* dicts
|
|
1124
|
+
"""
|
|
1125
|
+
key = (str(controller_address), channel_name)
|
|
1126
|
+
|
|
1127
|
+
if key not in self.csv_writers:
|
|
1128
|
+
filename = self._make_csv_filename(controller_address, channel_name)
|
|
1129
|
+
|
|
1130
|
+
try:
|
|
1131
|
+
f = open(filename, 'w', newline='')
|
|
1132
|
+
self.csv_files[key] = f
|
|
1133
|
+
self.csv_fieldnames[key] = set(['timestamp'])
|
|
1134
|
+
self.csv_writers[key] = None
|
|
1135
|
+
except Exception as e:
|
|
1136
|
+
print(f"Error creating CSV file {filename}: {e}")
|
|
1137
|
+
raise
|
|
1138
|
+
|
|
1139
|
+
return key
|
|
1140
|
+
|
|
1141
|
+
def log_data(self, controller_address, timestamp, channel_name, data_struct, archive):
|
|
1142
|
+
"""Write a data record to the log file.
|
|
1143
|
+
|
|
1144
|
+
Args:
|
|
1145
|
+
controller_address: Address of the controller (int or DeviceAddress)
|
|
1146
|
+
timestamp: Unix timestamp as float
|
|
1147
|
+
channel_name: Name of the telemetry channel
|
|
1148
|
+
data_struct: The data structure to log
|
|
1149
|
+
archive: The schema/archive for this data (unused currently)
|
|
1150
|
+
"""
|
|
1151
|
+
if not self.should_log(controller_address, channel_name):
|
|
1152
|
+
return
|
|
1153
|
+
|
|
1154
|
+
if self.log_format == 'jsonl':
|
|
1155
|
+
self._log_jsonl(controller_address, timestamp, channel_name, data_struct)
|
|
1156
|
+
elif self.log_format == 'csv':
|
|
1157
|
+
self._log_csv(controller_address, timestamp, channel_name, data_struct)
|
|
1158
|
+
|
|
1159
|
+
def _log_jsonl(self, controller_address, timestamp, channel_name, data_struct):
|
|
1160
|
+
"""Write a data record to a JSONL file."""
|
|
1161
|
+
data_dict = namedtuple_to_dict(data_struct)
|
|
1162
|
+
|
|
1163
|
+
log_record = {
|
|
1164
|
+
'controller': str(controller_address),
|
|
1165
|
+
'timestamp': timestamp,
|
|
1166
|
+
'channel': channel_name,
|
|
1167
|
+
'data': data_dict
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
try:
|
|
1171
|
+
json.dump(log_record, self.log_file)
|
|
1172
|
+
self.log_file.write('\n')
|
|
1173
|
+
self.log_file.flush()
|
|
1174
|
+
except Exception as e:
|
|
1175
|
+
print(f"Error writing to log file: {e}")
|
|
1176
|
+
|
|
1177
|
+
def _log_csv(self, controller_address, timestamp, channel_name, data_struct):
|
|
1178
|
+
"""Write a data record to a CSV file."""
|
|
1179
|
+
try:
|
|
1180
|
+
key = self._get_csv_key(controller_address, channel_name)
|
|
1181
|
+
|
|
1182
|
+
data_dict = namedtuple_to_dict(data_struct)
|
|
1183
|
+
flat_data = flatten_dict(data_dict)
|
|
1184
|
+
flat_data['timestamp'] = timestamp
|
|
1185
|
+
|
|
1186
|
+
fieldnames_set = self.csv_fieldnames[key]
|
|
1187
|
+
current_fields = set(flat_data.keys())
|
|
1188
|
+
|
|
1189
|
+
needs_rewrite = False
|
|
1190
|
+
if not current_fields.issubset(fieldnames_set):
|
|
1191
|
+
fieldnames_set.update(current_fields)
|
|
1192
|
+
needs_rewrite = True
|
|
1193
|
+
|
|
1194
|
+
fieldnames = sorted(fieldnames_set)
|
|
1195
|
+
if 'timestamp' in fieldnames:
|
|
1196
|
+
fieldnames.remove('timestamp')
|
|
1197
|
+
fieldnames.insert(0, 'timestamp')
|
|
1198
|
+
|
|
1199
|
+
if needs_rewrite or self.csv_writers[key] is None:
|
|
1200
|
+
f = self.csv_files[key]
|
|
1201
|
+
|
|
1202
|
+
# Read existing rows if we're rewriting due to new columns
|
|
1203
|
+
existing_rows = []
|
|
1204
|
+
if needs_rewrite and self.csv_writers[key] is not None:
|
|
1205
|
+
f.seek(0)
|
|
1206
|
+
reader = csv.DictReader(f)
|
|
1207
|
+
existing_rows = list(reader)
|
|
1208
|
+
|
|
1209
|
+
# Truncate and write new header
|
|
1210
|
+
f.seek(0)
|
|
1211
|
+
f.truncate()
|
|
1212
|
+
|
|
1213
|
+
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction='ignore')
|
|
1214
|
+
writer.writeheader()
|
|
1215
|
+
|
|
1216
|
+
# Write back existing rows with new column structure
|
|
1217
|
+
for row in existing_rows:
|
|
1218
|
+
writer.writerow(row)
|
|
1219
|
+
|
|
1220
|
+
self.csv_writers[key] = writer
|
|
1221
|
+
|
|
1222
|
+
writer = self.csv_writers[key]
|
|
1223
|
+
writer.writerow(flat_data)
|
|
1224
|
+
self.csv_files[key].flush()
|
|
1225
|
+
|
|
1226
|
+
except Exception as e:
|
|
1227
|
+
print(f"Error writing CSV data: {e}")
|
|
1228
|
+
|
|
1229
|
+
|
|
461
1230
|
class NoEditDelegate(QtWidgets.QStyledItemDelegate):
|
|
462
1231
|
def __init__(self, parent=None):
|
|
463
1232
|
QtWidgets.QStyledItemDelegate.__init__(self, parent=parent)
|
|
@@ -627,7 +1396,10 @@ class Device:
|
|
|
627
1396
|
STATE_SCHEMA = 3
|
|
628
1397
|
STATE_DATA = 4
|
|
629
1398
|
|
|
630
|
-
def __init__(self, address,
|
|
1399
|
+
def __init__(self, address,
|
|
1400
|
+
source_can_id,
|
|
1401
|
+
python_source_can_id,
|
|
1402
|
+
transport, console, prefix,
|
|
631
1403
|
config_tree_item, data_tree_item,
|
|
632
1404
|
can_prefix, main_window, can_id):
|
|
633
1405
|
self.error_count = 0
|
|
@@ -636,6 +1408,7 @@ class Device:
|
|
|
636
1408
|
|
|
637
1409
|
self.address = address
|
|
638
1410
|
self.source_can_id = source_can_id
|
|
1411
|
+
self.python_source_can_id = python_source_can_id
|
|
639
1412
|
self._can_prefix = can_prefix
|
|
640
1413
|
|
|
641
1414
|
# We keep around an estimate of the current CAN ID to enable
|
|
@@ -666,6 +1439,9 @@ class Device:
|
|
|
666
1439
|
|
|
667
1440
|
self._updating_config = False
|
|
668
1441
|
|
|
1442
|
+
# Fault monitoring state
|
|
1443
|
+
self.fault_state = FaultState()
|
|
1444
|
+
|
|
669
1445
|
async def start(self):
|
|
670
1446
|
# Stop the spew.
|
|
671
1447
|
self.write('\r\ntel stop\r\n'.encode('latin1'))
|
|
@@ -952,6 +1728,10 @@ class Device:
|
|
|
952
1728
|
self._events[name].set()
|
|
953
1729
|
self._data_update_time[name] = time.time()
|
|
954
1730
|
|
|
1731
|
+
if self._main_window.logging_manager.is_logging():
|
|
1732
|
+
self._main_window.logging_manager.log_data(
|
|
1733
|
+
self.address, now, name, struct, record.archive)
|
|
1734
|
+
|
|
955
1735
|
async def wait_for_data(self, name):
|
|
956
1736
|
if name not in self._events:
|
|
957
1737
|
self._events[name] = asyncio.Event()
|
|
@@ -1122,6 +1902,63 @@ class Device:
|
|
|
1122
1902
|
_add_schema_item(item, schema_data)
|
|
1123
1903
|
return item
|
|
1124
1904
|
|
|
1905
|
+
async def check_fault_status(self):
|
|
1906
|
+
"""Check current fault status and return is_faulted, mode, fault_code)."""
|
|
1907
|
+
result = await self.controller.custom_query({
|
|
1908
|
+
Register.MODE: moteus.multiplex.INT8,
|
|
1909
|
+
Register.FAULT: moteus.multiplex.INT8,
|
|
1910
|
+
})
|
|
1911
|
+
|
|
1912
|
+
mode = result.values.get(Register.MODE, None)
|
|
1913
|
+
fault_code = result.values.get(Register.FAULT, None)
|
|
1914
|
+
is_faulted = (mode == Mode.FAULT) if mode is not None else False
|
|
1915
|
+
|
|
1916
|
+
return is_faulted, mode, fault_code
|
|
1917
|
+
|
|
1918
|
+
def update_fault_state(self, is_faulted):
|
|
1919
|
+
"""Update the fault state based on current status."""
|
|
1920
|
+
previous_faulted = self.fault_state.is_faulted
|
|
1921
|
+
fault_detected = False
|
|
1922
|
+
|
|
1923
|
+
if is_faulted and not previous_faulted:
|
|
1924
|
+
# New fault detected - needs observation
|
|
1925
|
+
self.fault_state.is_faulted = True
|
|
1926
|
+
self.fault_state.observed = False
|
|
1927
|
+
fault_detected = True
|
|
1928
|
+
elif not is_faulted and previous_faulted:
|
|
1929
|
+
# Fault cleared - mark as observed since it's no longer present
|
|
1930
|
+
self.fault_state.is_faulted = False
|
|
1931
|
+
self.fault_state.observed = True
|
|
1932
|
+
|
|
1933
|
+
return fault_detected
|
|
1934
|
+
|
|
1935
|
+
def mark_fault_observed(self):
|
|
1936
|
+
"""Mark the current fault as observed by the user."""
|
|
1937
|
+
if self.fault_state.is_faulted:
|
|
1938
|
+
self.fault_state.observed = True
|
|
1939
|
+
|
|
1940
|
+
def has_unobserved_fault(self):
|
|
1941
|
+
"""Check if device has a fault that hasn't been observed."""
|
|
1942
|
+
return self.fault_state.is_faulted and not self.fault_state.observed
|
|
1943
|
+
|
|
1944
|
+
async def check_and_update_fault_state(self):
|
|
1945
|
+
"""Check fault status and update state. Returns (fault_detected, fault_cleared)."""
|
|
1946
|
+
# Check current fault status
|
|
1947
|
+
is_faulted, mode, fault_code = await self.check_fault_status()
|
|
1948
|
+
|
|
1949
|
+
# Store previous state to detect fault clearing
|
|
1950
|
+
prev_faulted = self.fault_state.is_faulted
|
|
1951
|
+
|
|
1952
|
+
# Store current fault information for status bar
|
|
1953
|
+
self.fault_state.current_mode = mode
|
|
1954
|
+
self.fault_state.current_fault_code = fault_code
|
|
1955
|
+
|
|
1956
|
+
# Update fault state and check if new fault detected
|
|
1957
|
+
fault_detected = self.update_fault_state(is_faulted)
|
|
1958
|
+
fault_cleared = prev_faulted and not self.fault_state.is_faulted
|
|
1959
|
+
|
|
1960
|
+
return fault_detected, fault_cleared
|
|
1961
|
+
|
|
1125
1962
|
|
|
1126
1963
|
class TviewMainWindow():
|
|
1127
1964
|
def __init__(self, options, parent=None):
|
|
@@ -1139,6 +1976,12 @@ class TviewMainWindow():
|
|
|
1139
1976
|
self.device_uuid_support = {}
|
|
1140
1977
|
self.uuid_query_lock = asyncio.Lock()
|
|
1141
1978
|
|
|
1979
|
+
# Fault monitoring infrastructure
|
|
1980
|
+
self.fault_monitoring_task = None
|
|
1981
|
+
self.fault_flash_timer = None
|
|
1982
|
+
self.original_tab_color = None # Store original tab color
|
|
1983
|
+
self.fault_flash_state = False
|
|
1984
|
+
|
|
1142
1985
|
current_script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
1143
1986
|
uifilename = os.path.join(current_script_dir, "tview_main_window.ui")
|
|
1144
1987
|
|
|
@@ -1163,6 +2006,10 @@ class TviewMainWindow():
|
|
|
1163
2006
|
self.ui.telemetryTreeWidget.customContextMenuRequested.connect(
|
|
1164
2007
|
self._handle_telemetry_context_menu)
|
|
1165
2008
|
|
|
2009
|
+
# Track clicks for fault observation
|
|
2010
|
+
self.ui.telemetryTreeWidget.itemClicked.connect(
|
|
2011
|
+
self._handle_telemetry_item_clicked)
|
|
2012
|
+
|
|
1166
2013
|
self.ui.configTreeWidget.setItemDelegateForColumn(
|
|
1167
2014
|
0, NoEditDelegate(self.ui))
|
|
1168
2015
|
self.ui.configTreeWidget.setItemDelegateForColumn(
|
|
@@ -1176,10 +2023,13 @@ class TviewMainWindow():
|
|
|
1176
2023
|
self.ui.plotItemRemoveButton.clicked.connect(
|
|
1177
2024
|
self._handle_plot_item_remove)
|
|
1178
2025
|
|
|
1179
|
-
self.
|
|
1180
|
-
self.
|
|
2026
|
+
self.tabbed_console = TviewTabbedConsole(get_controller=self._python_get_controller)
|
|
2027
|
+
self.ui.consoleDock.setWidget(self.tabbed_console)
|
|
2028
|
+
|
|
2029
|
+
self.console = self.tabbed_console.diagnostic_console
|
|
1181
2030
|
self.console.line_input.connect(self._handle_user_input)
|
|
1182
|
-
|
|
2031
|
+
|
|
2032
|
+
self.python_console = self.tabbed_console.python_console
|
|
1183
2033
|
|
|
1184
2034
|
self.ui.tabifyDockWidget(self.ui.configDock, self.ui.telemetryDock)
|
|
1185
2035
|
|
|
@@ -1194,6 +2044,14 @@ class TviewMainWindow():
|
|
|
1194
2044
|
self.ui.plotWidget.history_s = value
|
|
1195
2045
|
self.ui.historySpin.valueChanged.connect(update_plotwidget)
|
|
1196
2046
|
|
|
2047
|
+
# Initialize logging manager
|
|
2048
|
+
self.logging_manager = LoggingManager()
|
|
2049
|
+
|
|
2050
|
+
# Add logging button to status bar
|
|
2051
|
+
self.logging_button = QtWidgets.QPushButton('Start Logging All')
|
|
2052
|
+
self.logging_button.clicked.connect(self._handle_logging_button_clicked)
|
|
2053
|
+
self.ui.statusbar.addPermanentWidget(self.logging_button)
|
|
2054
|
+
|
|
1197
2055
|
QtCore.QTimer.singleShot(0, self._handle_startup)
|
|
1198
2056
|
|
|
1199
2057
|
def show(self):
|
|
@@ -1289,6 +2147,8 @@ class TviewMainWindow():
|
|
|
1289
2147
|
return len(matching_devices) == 1
|
|
1290
2148
|
|
|
1291
2149
|
async def _populate_devices(self):
|
|
2150
|
+
self.python_console.namespace['transport'] = self.transport
|
|
2151
|
+
|
|
1292
2152
|
self.devices = []
|
|
1293
2153
|
|
|
1294
2154
|
targets = moteus.moteus_tool.expand_targets(self.options.devices)
|
|
@@ -1338,7 +2198,11 @@ class TviewMainWindow():
|
|
|
1338
2198
|
data_item.setText(0, tree_key)
|
|
1339
2199
|
self.ui.telemetryTreeWidget.addTopLevelItem(data_item)
|
|
1340
2200
|
|
|
1341
|
-
|
|
2201
|
+
python_source_can_id = source_can_id - 1
|
|
2202
|
+
|
|
2203
|
+
device = Device(device_address,
|
|
2204
|
+
source_can_id,
|
|
2205
|
+
python_source_can_id,
|
|
1342
2206
|
self.transport,
|
|
1343
2207
|
self.console, '{}>'.format(tree_key),
|
|
1344
2208
|
config_item,
|
|
@@ -1347,13 +2211,64 @@ class TviewMainWindow():
|
|
|
1347
2211
|
self,
|
|
1348
2212
|
current_can_id)
|
|
1349
2213
|
|
|
1350
|
-
source_can_id -=
|
|
2214
|
+
source_can_id -= 2
|
|
1351
2215
|
|
|
1352
2216
|
config_item.setData(0, QtCore.Qt.UserRole, device)
|
|
2217
|
+
data_item.setData(0, QtCore.Qt.UserRole, device)
|
|
1353
2218
|
asyncio.create_task(device.start())
|
|
1354
2219
|
|
|
1355
2220
|
self.devices.append(device)
|
|
1356
2221
|
|
|
2222
|
+
# Start fault monitoring after all devices are created
|
|
2223
|
+
if self.devices and not self.fault_monitoring_task:
|
|
2224
|
+
self.fault_monitoring_task = asyncio.create_task(self._monitor_device_faults())
|
|
2225
|
+
|
|
2226
|
+
if self.devices:
|
|
2227
|
+
self.python_console.namespace['controller'] = self._python_get_controller(self.devices[0].address)
|
|
2228
|
+
self.python_console.namespace['controllers'] = [
|
|
2229
|
+
self._python_get_controller(device.address)
|
|
2230
|
+
for device in self.devices]
|
|
2231
|
+
|
|
2232
|
+
def _python_get_controller(self, name_or_address):
|
|
2233
|
+
def get_device():
|
|
2234
|
+
# Is this an address that matches one of our devices
|
|
2235
|
+
# exactly?
|
|
2236
|
+
maybe_device_by_address = [
|
|
2237
|
+
device for device in self.devices
|
|
2238
|
+
if (device.address == name_or_address
|
|
2239
|
+
or (isinstance(device.address, int) and
|
|
2240
|
+
isinstance(name_or_address, int) and
|
|
2241
|
+
device.address == name_or_address)
|
|
2242
|
+
or (not isinstance(device.address, int) and
|
|
2243
|
+
device.address.can_id is not None and
|
|
2244
|
+
isinstance(name_or_address, int) and
|
|
2245
|
+
device.address.can_id == name_or_address))
|
|
2246
|
+
]
|
|
2247
|
+
if maybe_device_by_address:
|
|
2248
|
+
return maybe_device_by_address[0]
|
|
2249
|
+
|
|
2250
|
+
# Can we look it up by name?
|
|
2251
|
+
if isinstance(name_or_address, str):
|
|
2252
|
+
maybe_devices = [x for x in self.devices
|
|
2253
|
+
if self._match(x, name_or_address)]
|
|
2254
|
+
if maybe_devices:
|
|
2255
|
+
return maybe_devices[0]
|
|
2256
|
+
|
|
2257
|
+
return None
|
|
2258
|
+
|
|
2259
|
+
device = get_device()
|
|
2260
|
+
if device:
|
|
2261
|
+
return moteus.Controller(
|
|
2262
|
+
device.address,
|
|
2263
|
+
source_can_id=device.python_source_can_id,
|
|
2264
|
+
can_prefix=self.options.can_prefix)
|
|
2265
|
+
|
|
2266
|
+
# It doesn't appear to match one of our existing devices.
|
|
2267
|
+
# Just try to make a new instance assuming it is address-like.
|
|
2268
|
+
return moteus.Controller(
|
|
2269
|
+
name_or_address,
|
|
2270
|
+
can_prefix=self.options.can_prefix)
|
|
2271
|
+
|
|
1357
2272
|
def _handle_startup(self):
|
|
1358
2273
|
self.console._control.setFocus()
|
|
1359
2274
|
self._open()
|
|
@@ -1533,12 +2448,23 @@ class TviewMainWindow():
|
|
|
1533
2448
|
def _handle_tree_expanded(self, item):
|
|
1534
2449
|
self.ui.telemetryTreeWidget.resizeColumnToContents(0)
|
|
1535
2450
|
user_data = item.data(0, QtCore.Qt.UserRole)
|
|
1536
|
-
if user_data
|
|
2451
|
+
if (user_data and
|
|
2452
|
+
hasattr(user_data, 'expand') and
|
|
2453
|
+
callable(user_data.expand)):
|
|
1537
2454
|
user_data.expand()
|
|
1538
2455
|
|
|
2456
|
+
# Mark fault as observed if expanding servo_stats while
|
|
2457
|
+
# telemetry is visible
|
|
2458
|
+
|
|
2459
|
+
if (self.ui.telemetryDock.isVisible() and
|
|
2460
|
+
item.text(0).lower() == "servo_stats"):
|
|
2461
|
+
device = self._find_device_from_tree_item(item)
|
|
2462
|
+
if device and device.has_unobserved_fault():
|
|
2463
|
+
self._mark_fault_observed(device)
|
|
2464
|
+
|
|
1539
2465
|
def _handle_tree_collapsed(self, item):
|
|
1540
2466
|
user_data = item.data(0, QtCore.Qt.UserRole)
|
|
1541
|
-
if user_data:
|
|
2467
|
+
if user_data and hasattr(user_data, 'collapse') and callable(user_data.collapse):
|
|
1542
2468
|
user_data.collapse()
|
|
1543
2469
|
|
|
1544
2470
|
def _handle_telemetry_context_menu(self, pos):
|
|
@@ -1575,6 +2501,9 @@ class TviewMainWindow():
|
|
|
1575
2501
|
fmt_standard_action = menu.addAction('Standard Format')
|
|
1576
2502
|
fmt_hex_action = menu.addAction('Hex Format')
|
|
1577
2503
|
|
|
2504
|
+
menu.addSeparator()
|
|
2505
|
+
log_channel_action = menu.addAction('Log this channel')
|
|
2506
|
+
|
|
1578
2507
|
requested = menu.exec_(self.ui.telemetryTreeWidget.mapToGlobal(pos))
|
|
1579
2508
|
|
|
1580
2509
|
if requested in plot_actions:
|
|
@@ -1612,6 +2541,8 @@ class TviewMainWindow():
|
|
|
1612
2541
|
item.setData(1, FORMAT_ROLE, FMT_STANDARD)
|
|
1613
2542
|
elif requested == fmt_hex_action:
|
|
1614
2543
|
item.setData(1, FORMAT_ROLE, FMT_HEX)
|
|
2544
|
+
elif requested == log_channel_action:
|
|
2545
|
+
self._start_channel_logging(item)
|
|
1615
2546
|
else:
|
|
1616
2547
|
# The user cancelled.
|
|
1617
2548
|
pass
|
|
@@ -1641,6 +2572,376 @@ class TviewMainWindow():
|
|
|
1641
2572
|
self.ui.plotWidget.remove_plot(item)
|
|
1642
2573
|
self.ui.plotItemCombo.removeItem(index)
|
|
1643
2574
|
|
|
2575
|
+
# Fault Monitoring System
|
|
2576
|
+
async def _monitor_device_faults(self):
|
|
2577
|
+
"""Continuously monitor devices for fault conditions."""
|
|
2578
|
+
# Wait for devices to initialize
|
|
2579
|
+
|
|
2580
|
+
# Start monitoring immediately - devices can handle queries during initialization
|
|
2581
|
+
|
|
2582
|
+
while True:
|
|
2583
|
+
try:
|
|
2584
|
+
await asyncio.sleep(FAULT_POLLING_INTERVAL_MS / 1000.0)
|
|
2585
|
+
|
|
2586
|
+
fault_detected = False
|
|
2587
|
+
for device in self.devices:
|
|
2588
|
+
# Check and update fault state for this device
|
|
2589
|
+
device_fault_detected, device_fault_cleared = await device.check_and_update_fault_state()
|
|
2590
|
+
if device_fault_detected:
|
|
2591
|
+
fault_detected = True
|
|
2592
|
+
if device_fault_cleared:
|
|
2593
|
+
# Clear highlighting for this device when fault is cleared
|
|
2594
|
+
self._clear_device_highlighting(device)
|
|
2595
|
+
|
|
2596
|
+
# Start/stop flashing based on unobserved faults
|
|
2597
|
+
if fault_detected:
|
|
2598
|
+
self._start_fault_flashing()
|
|
2599
|
+
elif self._all_faults_observed():
|
|
2600
|
+
self._stop_fault_flashing()
|
|
2601
|
+
|
|
2602
|
+
# Update status bar with current fault information
|
|
2603
|
+
self._update_fault_status_bar()
|
|
2604
|
+
|
|
2605
|
+
except Exception as e:
|
|
2606
|
+
print(f"Error in fault monitoring: {e}")
|
|
2607
|
+
await asyncio.sleep(1.0)
|
|
2608
|
+
|
|
2609
|
+
def _all_faults_observed(self):
|
|
2610
|
+
"""Check if all current faults have been visually observed."""
|
|
2611
|
+
return all(not device.has_unobserved_fault() for device in self.devices)
|
|
2612
|
+
|
|
2613
|
+
def _start_fault_flashing(self):
|
|
2614
|
+
"""Start visual fault indicators."""
|
|
2615
|
+
if self.fault_flash_timer is None:
|
|
2616
|
+
self.fault_flash_timer = QtCore.QTimer()
|
|
2617
|
+
self.fault_flash_timer.timeout.connect(self._toggle_fault_flash)
|
|
2618
|
+
self.fault_flash_timer.start(500)
|
|
2619
|
+
self._toggle_fault_flash()
|
|
2620
|
+
|
|
2621
|
+
def _stop_fault_flashing(self):
|
|
2622
|
+
"""Stop visual fault indicators."""
|
|
2623
|
+
if self.fault_flash_timer is not None:
|
|
2624
|
+
self.fault_flash_timer.stop()
|
|
2625
|
+
self.fault_flash_timer = None
|
|
2626
|
+
self._clear_fault_highlighting()
|
|
2627
|
+
|
|
2628
|
+
def _toggle_fault_flash(self):
|
|
2629
|
+
"""Toggle fault visual indicators."""
|
|
2630
|
+
self.fault_flash_state = not self.fault_flash_state
|
|
2631
|
+
color = "#FF4444" if self.fault_flash_state else "#FFA500"
|
|
2632
|
+
|
|
2633
|
+
for device in self.devices:
|
|
2634
|
+
if device.has_unobserved_fault():
|
|
2635
|
+
self._highlight_device_fault(device, color)
|
|
2636
|
+
|
|
2637
|
+
def _highlight_device_fault(self, device, color):
|
|
2638
|
+
"""Apply fault highlighting to UI elements."""
|
|
2639
|
+
self._highlight_telemetry_tab(color)
|
|
2640
|
+
self._highlight_device_tree_items(device, color)
|
|
2641
|
+
self._highlight_servo_stats(device, color)
|
|
2642
|
+
|
|
2643
|
+
def _highlight_telemetry_tab(self, color):
|
|
2644
|
+
"""Flash telemetry tab text to indicate fault."""
|
|
2645
|
+
tab_bars = self.ui.findChildren(QtWidgets.QTabBar)
|
|
2646
|
+
for tab_bar in tab_bars:
|
|
2647
|
+
for i in range(tab_bar.count()):
|
|
2648
|
+
if "telemetry" in tab_bar.tabText(i).lower():
|
|
2649
|
+
# Store original color on first modification
|
|
2650
|
+
if self.original_tab_color is None:
|
|
2651
|
+
# Get the actual default color from the palette since tabTextColor
|
|
2652
|
+
# returns invalid QColor() for tabs that haven't been modified
|
|
2653
|
+
self.original_tab_color = tab_bar.palette().color(QtGui.QPalette.WindowText)
|
|
2654
|
+
|
|
2655
|
+
# This is the best visual indicator I've managed
|
|
2656
|
+
# to find for tab text.
|
|
2657
|
+
if self.fault_flash_state:
|
|
2658
|
+
tab_bar.setTabTextColor(i, QtGui.QColor("#FF0000"))
|
|
2659
|
+
else:
|
|
2660
|
+
tab_bar.setTabTextColor(i, QtGui.QColor("#FF8800"))
|
|
2661
|
+
return
|
|
2662
|
+
|
|
2663
|
+
def _highlight_device_tree_items(self, device, color):
|
|
2664
|
+
"""Highlight device tree items in telemetry view."""
|
|
2665
|
+
# Only highlight if device has unobserved fault
|
|
2666
|
+
if hasattr(device, '_data_tree_item') and device.has_unobserved_fault():
|
|
2667
|
+
brush = QtGui.QBrush(QtGui.QColor(color))
|
|
2668
|
+
device._data_tree_item.setBackground(0, brush)
|
|
2669
|
+
device._data_tree_item.setBackground(1, brush)
|
|
2670
|
+
|
|
2671
|
+
def _highlight_servo_stats(self, device, color):
|
|
2672
|
+
"""Highlight servo_stats entry for device."""
|
|
2673
|
+
# Only highlight if device has unobserved fault
|
|
2674
|
+
if hasattr(device, '_data_tree_item') and device.has_unobserved_fault():
|
|
2675
|
+
for i in range(device._data_tree_item.childCount()):
|
|
2676
|
+
child = device._data_tree_item.child(i)
|
|
2677
|
+
if child.text(0) == 'servo_stats':
|
|
2678
|
+
brush = QtGui.QBrush(QtGui.QColor(color))
|
|
2679
|
+
child.setBackground(0, brush)
|
|
2680
|
+
child.setBackground(1, brush)
|
|
2681
|
+
break
|
|
2682
|
+
|
|
2683
|
+
def _clear_fault_highlighting(self):
|
|
2684
|
+
"""Clear all fault highlighting."""
|
|
2685
|
+
# Clear telemetry tab color
|
|
2686
|
+
tab_bars = self.ui.findChildren(QtWidgets.QTabBar)
|
|
2687
|
+
for tab_bar in tab_bars:
|
|
2688
|
+
for i in range(tab_bar.count()):
|
|
2689
|
+
if "telemetry" in tab_bar.tabText(i).lower():
|
|
2690
|
+
# Reset the color using the stored original
|
|
2691
|
+
if self.original_tab_color and self.original_tab_color.isValid():
|
|
2692
|
+
tab_bar.setTabTextColor(i, self.original_tab_color)
|
|
2693
|
+
break # Found and processed the telemetry tab
|
|
2694
|
+
|
|
2695
|
+
# Clear tree highlighting for all devices
|
|
2696
|
+
for device in self.devices:
|
|
2697
|
+
self._clear_device_highlighting(device)
|
|
2698
|
+
|
|
2699
|
+
def _clear_device_highlighting(self, device):
|
|
2700
|
+
"""Clear fault highlighting for a specific device."""
|
|
2701
|
+
if hasattr(device, '_data_tree_item'):
|
|
2702
|
+
device._data_tree_item.setBackground(0, QtGui.QBrush())
|
|
2703
|
+
device._data_tree_item.setBackground(1, QtGui.QBrush())
|
|
2704
|
+
# Clear servo_stats
|
|
2705
|
+
for i in range(device._data_tree_item.childCount()):
|
|
2706
|
+
child = device._data_tree_item.child(i)
|
|
2707
|
+
if child.text(0) == 'servo_stats':
|
|
2708
|
+
child.setBackground(0, QtGui.QBrush())
|
|
2709
|
+
child.setBackground(1, QtGui.QBrush())
|
|
2710
|
+
|
|
2711
|
+
# Observation Tracking
|
|
2712
|
+
def _handle_telemetry_item_clicked(self, item, column):
|
|
2713
|
+
"""Handle clicks on telemetry items for fault observation."""
|
|
2714
|
+
if not self.ui.telemetryDock.isVisible():
|
|
2715
|
+
return
|
|
2716
|
+
|
|
2717
|
+
# We only consider a fault observed if the user expanded or
|
|
2718
|
+
# clicked on the 'servo_stats' entry, or the 'fault' or 'mode'
|
|
2719
|
+
# elements of 'servo_stats.
|
|
2720
|
+
item_text = item.text(0).lower()
|
|
2721
|
+
if item_text == "servo_stats":
|
|
2722
|
+
# Only count servo_stats click as observation if it's already expanded
|
|
2723
|
+
if item.isExpanded():
|
|
2724
|
+
device = self._find_device_from_tree_item(item)
|
|
2725
|
+
else:
|
|
2726
|
+
return # Don't count clicks on collapsed servo_stats
|
|
2727
|
+
elif item_text in ["mode", "fault"] and item.parent() and item.parent().text(0).lower() == "servo_stats":
|
|
2728
|
+
device = self._find_device_from_tree_item(item.parent().parent())
|
|
2729
|
+
else:
|
|
2730
|
+
return
|
|
2731
|
+
|
|
2732
|
+
if device and device.has_unobserved_fault():
|
|
2733
|
+
self._mark_fault_observed(device)
|
|
2734
|
+
|
|
2735
|
+
def _find_device_from_tree_item(self, item):
|
|
2736
|
+
"""Find device associated with tree item."""
|
|
2737
|
+
if not item:
|
|
2738
|
+
return None
|
|
2739
|
+
|
|
2740
|
+
# Traverse up to top-level item
|
|
2741
|
+
current = item
|
|
2742
|
+
while current.parent():
|
|
2743
|
+
current = current.parent()
|
|
2744
|
+
|
|
2745
|
+
# Check if top-level item has device data
|
|
2746
|
+
device_data = current.data(0, QtCore.Qt.UserRole)
|
|
2747
|
+
if isinstance(device_data, Device):
|
|
2748
|
+
return device_data
|
|
2749
|
+
|
|
2750
|
+
# Fallback: search by tree item reference
|
|
2751
|
+
for device in self.devices:
|
|
2752
|
+
if hasattr(device, '_data_tree_item') and device._data_tree_item == current:
|
|
2753
|
+
return device
|
|
2754
|
+
|
|
2755
|
+
return None
|
|
2756
|
+
|
|
2757
|
+
def _mark_fault_observed(self, device):
|
|
2758
|
+
"""Mark device fault as visually observed."""
|
|
2759
|
+
device.mark_fault_observed()
|
|
2760
|
+
|
|
2761
|
+
# Clear highlighting for this specific device immediately
|
|
2762
|
+
self._clear_device_highlighting(device)
|
|
2763
|
+
|
|
2764
|
+
# Check if all faults are now observed and stop global flashing if so
|
|
2765
|
+
if self._all_faults_observed():
|
|
2766
|
+
self._stop_fault_flashing()
|
|
2767
|
+
|
|
2768
|
+
# Update status bar to reflect observed fault
|
|
2769
|
+
self._update_fault_status_bar()
|
|
2770
|
+
|
|
2771
|
+
def _update_fault_status_bar(self):
|
|
2772
|
+
"""Update the status bar with current fault information."""
|
|
2773
|
+
# Collect all faulted devices
|
|
2774
|
+
faulted_devices = []
|
|
2775
|
+
for device in self.devices:
|
|
2776
|
+
if device.fault_state.is_faulted:
|
|
2777
|
+
faulted_devices.append(device)
|
|
2778
|
+
|
|
2779
|
+
if not faulted_devices:
|
|
2780
|
+
# No faults - clear status bar
|
|
2781
|
+
self.ui.statusbar.clearMessage()
|
|
2782
|
+
return
|
|
2783
|
+
|
|
2784
|
+
FULL_LIST_COUNT = 2
|
|
2785
|
+
|
|
2786
|
+
if len(faulted_devices) == 1:
|
|
2787
|
+
# Single fault - show device and fault code with description
|
|
2788
|
+
device = faulted_devices[0]
|
|
2789
|
+
fault_code = device.fault_state.current_fault_code
|
|
2790
|
+
fault_text = _format_fault_code(fault_code)
|
|
2791
|
+
if fault_text:
|
|
2792
|
+
message = f"Fault: {device.address} {fault_text}"
|
|
2793
|
+
else:
|
|
2794
|
+
message = f"Fault: {device.address}"
|
|
2795
|
+
elif len(faulted_devices) <= FULL_LIST_COUNT:
|
|
2796
|
+
# Multiple faults - show compact list with descriptions
|
|
2797
|
+
fault_strs = []
|
|
2798
|
+
for device in faulted_devices:
|
|
2799
|
+
fault_code = device.fault_state.current_fault_code
|
|
2800
|
+
fault_text = _format_fault_code(fault_code)
|
|
2801
|
+
if fault_text:
|
|
2802
|
+
fault_strs.append(f"{device.address} {fault_text}")
|
|
2803
|
+
else:
|
|
2804
|
+
fault_strs.append(f"{device.address}")
|
|
2805
|
+
message = f"Faults: {', '.join(fault_strs)}"
|
|
2806
|
+
else:
|
|
2807
|
+
# Many faults - show count with tooltip
|
|
2808
|
+
message = f"Faults: {len(faulted_devices)} devices - hover for details"
|
|
2809
|
+
|
|
2810
|
+
# Create tooltip with detailed fault information including descriptions
|
|
2811
|
+
tooltip_lines = []
|
|
2812
|
+
for device in faulted_devices:
|
|
2813
|
+
fault_code = device.fault_state.current_fault_code
|
|
2814
|
+
fault_text = _format_fault_code(fault_code)
|
|
2815
|
+
if fault_text:
|
|
2816
|
+
tooltip_lines.append(f"{device.address} {fault_text}")
|
|
2817
|
+
else:
|
|
2818
|
+
tooltip_lines.append(f"{device.address}")
|
|
2819
|
+
tooltip = "\n".join(tooltip_lines)
|
|
2820
|
+
self.ui.statusbar.setToolTip(tooltip)
|
|
2821
|
+
|
|
2822
|
+
# Display the message
|
|
2823
|
+
self.ui.statusbar.showMessage(message)
|
|
2824
|
+
|
|
2825
|
+
# Clear tooltip if not many faults
|
|
2826
|
+
if len(faulted_devices) <= FULL_LIST_COUNT:
|
|
2827
|
+
self.ui.statusbar.setToolTip("")
|
|
2828
|
+
|
|
2829
|
+
def _detect_log_format(self, filename, selected_filter):
|
|
2830
|
+
"""Detect log format from filename extension or file filter.
|
|
2831
|
+
|
|
2832
|
+
Args:
|
|
2833
|
+
filename: User-provided filename
|
|
2834
|
+
selected_filter: Selected file filter from dialog
|
|
2835
|
+
|
|
2836
|
+
Returns:
|
|
2837
|
+
Tuple of (final_filename, format) where format is 'csv' or 'jsonl'
|
|
2838
|
+
"""
|
|
2839
|
+
# Detect format from filename extension if present, otherwise from filter
|
|
2840
|
+
if filename.endswith('.csv'):
|
|
2841
|
+
log_format = 'csv'
|
|
2842
|
+
elif filename.endswith('.jsonl'):
|
|
2843
|
+
log_format = 'jsonl'
|
|
2844
|
+
else:
|
|
2845
|
+
# No recognized extension, use filter selection
|
|
2846
|
+
log_format = 'csv' if 'CSV' in selected_filter else 'jsonl'
|
|
2847
|
+
# Add appropriate extension
|
|
2848
|
+
if log_format == 'jsonl':
|
|
2849
|
+
filename += '.jsonl'
|
|
2850
|
+
elif log_format == 'csv':
|
|
2851
|
+
filename += '.csv'
|
|
2852
|
+
|
|
2853
|
+
return filename, log_format
|
|
2854
|
+
|
|
2855
|
+
def _handle_logging_button_clicked(self):
|
|
2856
|
+
"""Handle clicks on the status bar logging button."""
|
|
2857
|
+
if self.logging_manager.is_logging():
|
|
2858
|
+
self._stop_logging()
|
|
2859
|
+
else:
|
|
2860
|
+
self._start_global_logging()
|
|
2861
|
+
|
|
2862
|
+
def _start_global_logging(self):
|
|
2863
|
+
"""Start logging all data from all devices."""
|
|
2864
|
+
filename, selected_filter = QtWidgets.QFileDialog.getSaveFileName(
|
|
2865
|
+
self.ui,
|
|
2866
|
+
"Save Log File",
|
|
2867
|
+
"",
|
|
2868
|
+
"JSON Lines (*.jsonl);;CSV Files (*.csv);;All Files (*)"
|
|
2869
|
+
)
|
|
2870
|
+
|
|
2871
|
+
if not filename:
|
|
2872
|
+
return
|
|
2873
|
+
|
|
2874
|
+
filename, log_format = self._detect_log_format(filename, selected_filter)
|
|
2875
|
+
|
|
2876
|
+
try:
|
|
2877
|
+
self.logging_manager.start_logging(
|
|
2878
|
+
filename, devices=None, channels=None, format=log_format)
|
|
2879
|
+
self.logging_button.setText('Stop Logging')
|
|
2880
|
+
self.logging_button.setStyleSheet('background-color: #90EE90')
|
|
2881
|
+
print(f"Started logging all data to {filename} (format: {log_format})")
|
|
2882
|
+
except Exception as e:
|
|
2883
|
+
QtWidgets.QMessageBox.warning(
|
|
2884
|
+
self.ui,
|
|
2885
|
+
"Logging Error",
|
|
2886
|
+
f"Failed to start logging: {e}"
|
|
2887
|
+
)
|
|
2888
|
+
|
|
2889
|
+
def _stop_logging(self):
|
|
2890
|
+
"""Stop logging and update UI."""
|
|
2891
|
+
self.logging_manager.stop_logging()
|
|
2892
|
+
self.logging_button.setText('Start Logging All')
|
|
2893
|
+
self.logging_button.setStyleSheet('')
|
|
2894
|
+
print("Stopped logging")
|
|
2895
|
+
|
|
2896
|
+
def _start_channel_logging(self, item):
|
|
2897
|
+
"""Start logging a specific channel from the tree view."""
|
|
2898
|
+
channel_item = item
|
|
2899
|
+
while channel_item.parent() and channel_item.parent().parent():
|
|
2900
|
+
channel_item = channel_item.parent()
|
|
2901
|
+
|
|
2902
|
+
if not channel_item.parent():
|
|
2903
|
+
return
|
|
2904
|
+
|
|
2905
|
+
schema = channel_item.data(0, QtCore.Qt.UserRole)
|
|
2906
|
+
if not hasattr(schema, 'record'):
|
|
2907
|
+
return
|
|
2908
|
+
|
|
2909
|
+
channel_name = channel_item.text(0)
|
|
2910
|
+
|
|
2911
|
+
device_item = channel_item.parent()
|
|
2912
|
+
device = device_item.data(0, QtCore.Qt.UserRole)
|
|
2913
|
+
if not isinstance(device, Device):
|
|
2914
|
+
return
|
|
2915
|
+
|
|
2916
|
+
filename, selected_filter = QtWidgets.QFileDialog.getSaveFileName(
|
|
2917
|
+
self.ui,
|
|
2918
|
+
f"Save Log File for {channel_name}",
|
|
2919
|
+
"",
|
|
2920
|
+
"JSON Lines (*.jsonl);;CSV Files (*.csv);;All Files (*)"
|
|
2921
|
+
)
|
|
2922
|
+
|
|
2923
|
+
if not filename:
|
|
2924
|
+
return
|
|
2925
|
+
|
|
2926
|
+
filename, log_format = self._detect_log_format(filename, selected_filter)
|
|
2927
|
+
|
|
2928
|
+
try:
|
|
2929
|
+
self.logging_manager.start_logging(
|
|
2930
|
+
filename,
|
|
2931
|
+
devices={device.address},
|
|
2932
|
+
channels={channel_name},
|
|
2933
|
+
format=log_format
|
|
2934
|
+
)
|
|
2935
|
+
self.logging_button.setText('Stop Logging')
|
|
2936
|
+
self.logging_button.setStyleSheet('background-color: #90EE90')
|
|
2937
|
+
print(f"Started logging {channel_name} from device {device.address} to {filename} (format: {log_format})")
|
|
2938
|
+
except Exception as e:
|
|
2939
|
+
QtWidgets.QMessageBox.warning(
|
|
2940
|
+
self.ui,
|
|
2941
|
+
"Logging Error",
|
|
2942
|
+
f"Failed to start logging: {e}"
|
|
2943
|
+
)
|
|
2944
|
+
|
|
1644
2945
|
|
|
1645
2946
|
def main():
|
|
1646
2947
|
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
|
@@ -1665,11 +2966,17 @@ def main():
|
|
|
1665
2966
|
loop = asyncqt.QEventLoop(app)
|
|
1666
2967
|
asyncio.set_event_loop(loop)
|
|
1667
2968
|
|
|
2969
|
+
tv = TviewMainWindow(args)
|
|
2970
|
+
|
|
2971
|
+
# Cleanup logging on exit
|
|
2972
|
+
def cleanup():
|
|
2973
|
+
tv.logging_manager.stop_logging()
|
|
2974
|
+
os._exit(0)
|
|
2975
|
+
|
|
1668
2976
|
# Currently there are many things that can barf on exit, let's
|
|
1669
2977
|
# just ignore all of them because, hey, we're about to exit!
|
|
1670
|
-
app.aboutToQuit.connect(
|
|
2978
|
+
app.aboutToQuit.connect(cleanup)
|
|
1671
2979
|
|
|
1672
|
-
tv = TviewMainWindow(args)
|
|
1673
2980
|
tv.show()
|
|
1674
2981
|
|
|
1675
2982
|
app.exec_()
|