moteus-gui 0.3.92__py3-none-any.whl → 0.3.94__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 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, source_can_id, transport, console, prefix,
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.console = TviewConsoleWidget()
1180
- self.console.ansi_codes = False
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
- self.ui.consoleDock.setWidget(self.console)
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
- device = Device(device_address, source_can_id,
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 -= 1
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,47 +2448,81 @@ 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):
1545
2471
  item = self.ui.telemetryTreeWidget.itemAt(pos)
1546
- if item.childCount() > 0:
1547
- return
2472
+
2473
+ # Determine if this is a leaf item (field) or a channel item
2474
+ is_leaf = item.childCount() == 0
2475
+
2476
+ is_controller = item.parent() is None
1548
2477
 
1549
2478
  menu = QtWidgets.QMenu(self.ui)
1550
- left_action = menu.addAction('Plot Left')
1551
- right_action = menu.addAction('Plot Right')
1552
- left_std_action = menu.addAction('Plot StdDev Left')
1553
- right_std_action = menu.addAction('Plot StdDev Right')
1554
- left_mean_action = menu.addAction('Plot Mean Left')
1555
- right_mean_action = menu.addAction('Plot Mean Right')
1556
-
1557
- plot_actions = [
1558
- left_action,
1559
- right_action,
1560
- left_std_action,
1561
- right_std_action,
1562
- left_mean_action,
1563
- right_mean_action,
1564
- ]
1565
-
1566
- right_actions = [right_action, right_std_action, right_mean_action]
1567
- std_actions = [left_std_action, right_std_action]
1568
- mean_actions = [left_mean_action, right_mean_action]
1569
-
1570
- menu.addSeparator()
1571
- copy_name = menu.addAction('Copy Name')
1572
- copy_value = menu.addAction('Copy Value')
1573
2479
 
1574
- menu.addSeparator()
1575
- fmt_standard_action = menu.addAction('Standard Format')
1576
- fmt_hex_action = menu.addAction('Hex Format')
2480
+ # Plot actions only make sense for leaf items
2481
+ plot_actions = []
2482
+ if is_leaf:
2483
+ left_action = menu.addAction('Plot Left')
2484
+ right_action = menu.addAction('Plot Right')
2485
+ left_std_action = menu.addAction('Plot StdDev Left')
2486
+ right_std_action = menu.addAction('Plot StdDev Right')
2487
+ left_mean_action = menu.addAction('Plot Mean Left')
2488
+ right_mean_action = menu.addAction('Plot Mean Right')
2489
+
2490
+ plot_actions = [
2491
+ left_action,
2492
+ right_action,
2493
+ left_std_action,
2494
+ right_std_action,
2495
+ left_mean_action,
2496
+ right_mean_action,
2497
+ ]
2498
+
2499
+ right_actions = [right_action, right_std_action, right_mean_action]
2500
+ std_actions = [left_std_action, right_std_action]
2501
+ mean_actions = [left_mean_action, right_mean_action]
2502
+
2503
+ menu.addSeparator()
2504
+
2505
+ copy_name = menu.addAction('Copy Name')
2506
+ if is_leaf:
2507
+ copy_value = menu.addAction('Copy Value')
2508
+
2509
+ if is_leaf:
2510
+ menu.addSeparator()
2511
+ fmt_standard_action = menu.addAction('Standard Format')
2512
+ fmt_hex_action = menu.addAction('Hex Format')
2513
+
2514
+ menu.addSeparator()
2515
+ log_channel_action = menu.addAction('Log this channel')
2516
+
2517
+ # Sample rate menu items - available for both channels and
2518
+ # fields, but not controllers.
2519
+ if not is_controller:
2520
+ menu.addSeparator()
2521
+ rate_10hz_action = menu.addAction('Set Rate: 10Hz')
2522
+ rate_100hz_action = menu.addAction('Set Rate: 100Hz')
2523
+ else:
2524
+ rate_10hz_action = None
2525
+ rate_100hz_action = None
1577
2526
 
1578
2527
  requested = menu.exec_(self.ui.telemetryTreeWidget.mapToGlobal(pos))
1579
2528
 
@@ -1606,12 +2555,35 @@ class TviewMainWindow():
1606
2555
  self.ui.plotItemCombo.addItem(name, plot_item)
1607
2556
  elif requested == copy_name:
1608
2557
  QtWidgets.QApplication.clipboard().setText(item.text(0))
1609
- elif requested == copy_value:
2558
+ elif is_leaf and requested == copy_value:
1610
2559
  QtWidgets.QApplication.clipboard().setText(item.text(1))
1611
- elif requested == fmt_standard_action:
2560
+ elif is_leaf and requested == fmt_standard_action:
1612
2561
  item.setData(1, FORMAT_ROLE, FMT_STANDARD)
1613
- elif requested == fmt_hex_action:
2562
+ elif is_leaf and requested == fmt_hex_action:
1614
2563
  item.setData(1, FORMAT_ROLE, FMT_HEX)
2564
+ elif is_leaf and requested == log_channel_action:
2565
+ self._start_channel_logging(item)
2566
+ elif (rate_10hz_action and requested == rate_10hz_action or
2567
+ rate_100hz_action and requested == rate_100hz_action):
2568
+ # Determine the channel schema and name
2569
+ if is_leaf:
2570
+ # For leaf items, find the parent channel item
2571
+ channel_item = item
2572
+ while channel_item.parent().parent():
2573
+ channel_item = channel_item.parent()
2574
+ schema = channel_item.data(0, QtCore.Qt.UserRole)
2575
+ else:
2576
+ # For channel items, use the item directly
2577
+ schema = item.data(0, QtCore.Qt.UserRole)
2578
+
2579
+ if schema and hasattr(schema, '_name') and hasattr(schema, '_parent'):
2580
+ channel_name = schema._name
2581
+ device = schema._parent
2582
+
2583
+ # 10Hz = 100ms, 100Hz = 10ms
2584
+ poll_rate_ms = 100 if requested == rate_10hz_action else 10
2585
+
2586
+ device.write_line(f'tel rate {channel_name} {poll_rate_ms}\r\n')
1615
2587
  else:
1616
2588
  # The user cancelled.
1617
2589
  pass
@@ -1641,6 +2613,376 @@ class TviewMainWindow():
1641
2613
  self.ui.plotWidget.remove_plot(item)
1642
2614
  self.ui.plotItemCombo.removeItem(index)
1643
2615
 
2616
+ # Fault Monitoring System
2617
+ async def _monitor_device_faults(self):
2618
+ """Continuously monitor devices for fault conditions."""
2619
+ # Wait for devices to initialize
2620
+
2621
+ # Start monitoring immediately - devices can handle queries during initialization
2622
+
2623
+ while True:
2624
+ try:
2625
+ await asyncio.sleep(FAULT_POLLING_INTERVAL_MS / 1000.0)
2626
+
2627
+ fault_detected = False
2628
+ for device in self.devices:
2629
+ # Check and update fault state for this device
2630
+ device_fault_detected, device_fault_cleared = await device.check_and_update_fault_state()
2631
+ if device_fault_detected:
2632
+ fault_detected = True
2633
+ if device_fault_cleared:
2634
+ # Clear highlighting for this device when fault is cleared
2635
+ self._clear_device_highlighting(device)
2636
+
2637
+ # Start/stop flashing based on unobserved faults
2638
+ if fault_detected:
2639
+ self._start_fault_flashing()
2640
+ elif self._all_faults_observed():
2641
+ self._stop_fault_flashing()
2642
+
2643
+ # Update status bar with current fault information
2644
+ self._update_fault_status_bar()
2645
+
2646
+ except Exception as e:
2647
+ print(f"Error in fault monitoring: {e}")
2648
+ await asyncio.sleep(1.0)
2649
+
2650
+ def _all_faults_observed(self):
2651
+ """Check if all current faults have been visually observed."""
2652
+ return all(not device.has_unobserved_fault() for device in self.devices)
2653
+
2654
+ def _start_fault_flashing(self):
2655
+ """Start visual fault indicators."""
2656
+ if self.fault_flash_timer is None:
2657
+ self.fault_flash_timer = QtCore.QTimer()
2658
+ self.fault_flash_timer.timeout.connect(self._toggle_fault_flash)
2659
+ self.fault_flash_timer.start(500)
2660
+ self._toggle_fault_flash()
2661
+
2662
+ def _stop_fault_flashing(self):
2663
+ """Stop visual fault indicators."""
2664
+ if self.fault_flash_timer is not None:
2665
+ self.fault_flash_timer.stop()
2666
+ self.fault_flash_timer = None
2667
+ self._clear_fault_highlighting()
2668
+
2669
+ def _toggle_fault_flash(self):
2670
+ """Toggle fault visual indicators."""
2671
+ self.fault_flash_state = not self.fault_flash_state
2672
+ color = "#FF4444" if self.fault_flash_state else "#FFA500"
2673
+
2674
+ for device in self.devices:
2675
+ if device.has_unobserved_fault():
2676
+ self._highlight_device_fault(device, color)
2677
+
2678
+ def _highlight_device_fault(self, device, color):
2679
+ """Apply fault highlighting to UI elements."""
2680
+ self._highlight_telemetry_tab(color)
2681
+ self._highlight_device_tree_items(device, color)
2682
+ self._highlight_servo_stats(device, color)
2683
+
2684
+ def _highlight_telemetry_tab(self, color):
2685
+ """Flash telemetry tab text to indicate fault."""
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
+ # Store original color on first modification
2691
+ if self.original_tab_color is None:
2692
+ # Get the actual default color from the palette since tabTextColor
2693
+ # returns invalid QColor() for tabs that haven't been modified
2694
+ self.original_tab_color = tab_bar.palette().color(QtGui.QPalette.WindowText)
2695
+
2696
+ # This is the best visual indicator I've managed
2697
+ # to find for tab text.
2698
+ if self.fault_flash_state:
2699
+ tab_bar.setTabTextColor(i, QtGui.QColor("#FF0000"))
2700
+ else:
2701
+ tab_bar.setTabTextColor(i, QtGui.QColor("#FF8800"))
2702
+ return
2703
+
2704
+ def _highlight_device_tree_items(self, device, color):
2705
+ """Highlight device tree items in telemetry view."""
2706
+ # Only highlight if device has unobserved fault
2707
+ if hasattr(device, '_data_tree_item') and device.has_unobserved_fault():
2708
+ brush = QtGui.QBrush(QtGui.QColor(color))
2709
+ device._data_tree_item.setBackground(0, brush)
2710
+ device._data_tree_item.setBackground(1, brush)
2711
+
2712
+ def _highlight_servo_stats(self, device, color):
2713
+ """Highlight servo_stats entry for device."""
2714
+ # Only highlight if device has unobserved fault
2715
+ if hasattr(device, '_data_tree_item') and device.has_unobserved_fault():
2716
+ for i in range(device._data_tree_item.childCount()):
2717
+ child = device._data_tree_item.child(i)
2718
+ if child.text(0) == 'servo_stats':
2719
+ brush = QtGui.QBrush(QtGui.QColor(color))
2720
+ child.setBackground(0, brush)
2721
+ child.setBackground(1, brush)
2722
+ break
2723
+
2724
+ def _clear_fault_highlighting(self):
2725
+ """Clear all fault highlighting."""
2726
+ # Clear telemetry tab color
2727
+ tab_bars = self.ui.findChildren(QtWidgets.QTabBar)
2728
+ for tab_bar in tab_bars:
2729
+ for i in range(tab_bar.count()):
2730
+ if "telemetry" in tab_bar.tabText(i).lower():
2731
+ # Reset the color using the stored original
2732
+ if self.original_tab_color and self.original_tab_color.isValid():
2733
+ tab_bar.setTabTextColor(i, self.original_tab_color)
2734
+ break # Found and processed the telemetry tab
2735
+
2736
+ # Clear tree highlighting for all devices
2737
+ for device in self.devices:
2738
+ self._clear_device_highlighting(device)
2739
+
2740
+ def _clear_device_highlighting(self, device):
2741
+ """Clear fault highlighting for a specific device."""
2742
+ if hasattr(device, '_data_tree_item'):
2743
+ device._data_tree_item.setBackground(0, QtGui.QBrush())
2744
+ device._data_tree_item.setBackground(1, QtGui.QBrush())
2745
+ # Clear servo_stats
2746
+ for i in range(device._data_tree_item.childCount()):
2747
+ child = device._data_tree_item.child(i)
2748
+ if child.text(0) == 'servo_stats':
2749
+ child.setBackground(0, QtGui.QBrush())
2750
+ child.setBackground(1, QtGui.QBrush())
2751
+
2752
+ # Observation Tracking
2753
+ def _handle_telemetry_item_clicked(self, item, column):
2754
+ """Handle clicks on telemetry items for fault observation."""
2755
+ if not self.ui.telemetryDock.isVisible():
2756
+ return
2757
+
2758
+ # We only consider a fault observed if the user expanded or
2759
+ # clicked on the 'servo_stats' entry, or the 'fault' or 'mode'
2760
+ # elements of 'servo_stats.
2761
+ item_text = item.text(0).lower()
2762
+ if item_text == "servo_stats":
2763
+ # Only count servo_stats click as observation if it's already expanded
2764
+ if item.isExpanded():
2765
+ device = self._find_device_from_tree_item(item)
2766
+ else:
2767
+ return # Don't count clicks on collapsed servo_stats
2768
+ elif item_text in ["mode", "fault"] and item.parent() and item.parent().text(0).lower() == "servo_stats":
2769
+ device = self._find_device_from_tree_item(item.parent().parent())
2770
+ else:
2771
+ return
2772
+
2773
+ if device and device.has_unobserved_fault():
2774
+ self._mark_fault_observed(device)
2775
+
2776
+ def _find_device_from_tree_item(self, item):
2777
+ """Find device associated with tree item."""
2778
+ if not item:
2779
+ return None
2780
+
2781
+ # Traverse up to top-level item
2782
+ current = item
2783
+ while current.parent():
2784
+ current = current.parent()
2785
+
2786
+ # Check if top-level item has device data
2787
+ device_data = current.data(0, QtCore.Qt.UserRole)
2788
+ if isinstance(device_data, Device):
2789
+ return device_data
2790
+
2791
+ # Fallback: search by tree item reference
2792
+ for device in self.devices:
2793
+ if hasattr(device, '_data_tree_item') and device._data_tree_item == current:
2794
+ return device
2795
+
2796
+ return None
2797
+
2798
+ def _mark_fault_observed(self, device):
2799
+ """Mark device fault as visually observed."""
2800
+ device.mark_fault_observed()
2801
+
2802
+ # Clear highlighting for this specific device immediately
2803
+ self._clear_device_highlighting(device)
2804
+
2805
+ # Check if all faults are now observed and stop global flashing if so
2806
+ if self._all_faults_observed():
2807
+ self._stop_fault_flashing()
2808
+
2809
+ # Update status bar to reflect observed fault
2810
+ self._update_fault_status_bar()
2811
+
2812
+ def _update_fault_status_bar(self):
2813
+ """Update the status bar with current fault information."""
2814
+ # Collect all faulted devices
2815
+ faulted_devices = []
2816
+ for device in self.devices:
2817
+ if device.fault_state.is_faulted:
2818
+ faulted_devices.append(device)
2819
+
2820
+ if not faulted_devices:
2821
+ # No faults - clear status bar
2822
+ self.ui.statusbar.clearMessage()
2823
+ return
2824
+
2825
+ FULL_LIST_COUNT = 2
2826
+
2827
+ if len(faulted_devices) == 1:
2828
+ # Single fault - show device and fault code with description
2829
+ device = faulted_devices[0]
2830
+ fault_code = device.fault_state.current_fault_code
2831
+ fault_text = _format_fault_code(fault_code)
2832
+ if fault_text:
2833
+ message = f"Fault: {device.address} {fault_text}"
2834
+ else:
2835
+ message = f"Fault: {device.address}"
2836
+ elif len(faulted_devices) <= FULL_LIST_COUNT:
2837
+ # Multiple faults - show compact list with descriptions
2838
+ fault_strs = []
2839
+ for device in faulted_devices:
2840
+ fault_code = device.fault_state.current_fault_code
2841
+ fault_text = _format_fault_code(fault_code)
2842
+ if fault_text:
2843
+ fault_strs.append(f"{device.address} {fault_text}")
2844
+ else:
2845
+ fault_strs.append(f"{device.address}")
2846
+ message = f"Faults: {', '.join(fault_strs)}"
2847
+ else:
2848
+ # Many faults - show count with tooltip
2849
+ message = f"Faults: {len(faulted_devices)} devices - hover for details"
2850
+
2851
+ # Create tooltip with detailed fault information including descriptions
2852
+ tooltip_lines = []
2853
+ for device in faulted_devices:
2854
+ fault_code = device.fault_state.current_fault_code
2855
+ fault_text = _format_fault_code(fault_code)
2856
+ if fault_text:
2857
+ tooltip_lines.append(f"{device.address} {fault_text}")
2858
+ else:
2859
+ tooltip_lines.append(f"{device.address}")
2860
+ tooltip = "\n".join(tooltip_lines)
2861
+ self.ui.statusbar.setToolTip(tooltip)
2862
+
2863
+ # Display the message
2864
+ self.ui.statusbar.showMessage(message)
2865
+
2866
+ # Clear tooltip if not many faults
2867
+ if len(faulted_devices) <= FULL_LIST_COUNT:
2868
+ self.ui.statusbar.setToolTip("")
2869
+
2870
+ def _detect_log_format(self, filename, selected_filter):
2871
+ """Detect log format from filename extension or file filter.
2872
+
2873
+ Args:
2874
+ filename: User-provided filename
2875
+ selected_filter: Selected file filter from dialog
2876
+
2877
+ Returns:
2878
+ Tuple of (final_filename, format) where format is 'csv' or 'jsonl'
2879
+ """
2880
+ # Detect format from filename extension if present, otherwise from filter
2881
+ if filename.endswith('.csv'):
2882
+ log_format = 'csv'
2883
+ elif filename.endswith('.jsonl'):
2884
+ log_format = 'jsonl'
2885
+ else:
2886
+ # No recognized extension, use filter selection
2887
+ log_format = 'csv' if 'CSV' in selected_filter else 'jsonl'
2888
+ # Add appropriate extension
2889
+ if log_format == 'jsonl':
2890
+ filename += '.jsonl'
2891
+ elif log_format == 'csv':
2892
+ filename += '.csv'
2893
+
2894
+ return filename, log_format
2895
+
2896
+ def _handle_logging_button_clicked(self):
2897
+ """Handle clicks on the status bar logging button."""
2898
+ if self.logging_manager.is_logging():
2899
+ self._stop_logging()
2900
+ else:
2901
+ self._start_global_logging()
2902
+
2903
+ def _start_global_logging(self):
2904
+ """Start logging all data from all devices."""
2905
+ filename, selected_filter = QtWidgets.QFileDialog.getSaveFileName(
2906
+ self.ui,
2907
+ "Save Log File",
2908
+ "",
2909
+ "JSON Lines (*.jsonl);;CSV Files (*.csv);;All Files (*)"
2910
+ )
2911
+
2912
+ if not filename:
2913
+ return
2914
+
2915
+ filename, log_format = self._detect_log_format(filename, selected_filter)
2916
+
2917
+ try:
2918
+ self.logging_manager.start_logging(
2919
+ filename, devices=None, channels=None, format=log_format)
2920
+ self.logging_button.setText('Stop Logging')
2921
+ self.logging_button.setStyleSheet('background-color: #90EE90')
2922
+ print(f"Started logging all data to {filename} (format: {log_format})")
2923
+ except Exception as e:
2924
+ QtWidgets.QMessageBox.warning(
2925
+ self.ui,
2926
+ "Logging Error",
2927
+ f"Failed to start logging: {e}"
2928
+ )
2929
+
2930
+ def _stop_logging(self):
2931
+ """Stop logging and update UI."""
2932
+ self.logging_manager.stop_logging()
2933
+ self.logging_button.setText('Start Logging All')
2934
+ self.logging_button.setStyleSheet('')
2935
+ print("Stopped logging")
2936
+
2937
+ def _start_channel_logging(self, item):
2938
+ """Start logging a specific channel from the tree view."""
2939
+ channel_item = item
2940
+ while channel_item.parent() and channel_item.parent().parent():
2941
+ channel_item = channel_item.parent()
2942
+
2943
+ if not channel_item.parent():
2944
+ return
2945
+
2946
+ schema = channel_item.data(0, QtCore.Qt.UserRole)
2947
+ if not hasattr(schema, 'record'):
2948
+ return
2949
+
2950
+ channel_name = channel_item.text(0)
2951
+
2952
+ device_item = channel_item.parent()
2953
+ device = device_item.data(0, QtCore.Qt.UserRole)
2954
+ if not isinstance(device, Device):
2955
+ return
2956
+
2957
+ filename, selected_filter = QtWidgets.QFileDialog.getSaveFileName(
2958
+ self.ui,
2959
+ f"Save Log File for {channel_name}",
2960
+ "",
2961
+ "JSON Lines (*.jsonl);;CSV Files (*.csv);;All Files (*)"
2962
+ )
2963
+
2964
+ if not filename:
2965
+ return
2966
+
2967
+ filename, log_format = self._detect_log_format(filename, selected_filter)
2968
+
2969
+ try:
2970
+ self.logging_manager.start_logging(
2971
+ filename,
2972
+ devices={device.address},
2973
+ channels={channel_name},
2974
+ format=log_format
2975
+ )
2976
+ self.logging_button.setText('Stop Logging')
2977
+ self.logging_button.setStyleSheet('background-color: #90EE90')
2978
+ print(f"Started logging {channel_name} from device {device.address} to {filename} (format: {log_format})")
2979
+ except Exception as e:
2980
+ QtWidgets.QMessageBox.warning(
2981
+ self.ui,
2982
+ "Logging Error",
2983
+ f"Failed to start logging: {e}"
2984
+ )
2985
+
1644
2986
 
1645
2987
  def main():
1646
2988
  signal.signal(signal.SIGINT, signal.SIG_DFL)
@@ -1665,11 +3007,17 @@ def main():
1665
3007
  loop = asyncqt.QEventLoop(app)
1666
3008
  asyncio.set_event_loop(loop)
1667
3009
 
3010
+ tv = TviewMainWindow(args)
3011
+
3012
+ # Cleanup logging on exit
3013
+ def cleanup():
3014
+ tv.logging_manager.stop_logging()
3015
+ os._exit(0)
3016
+
1668
3017
  # Currently there are many things that can barf on exit, let's
1669
3018
  # just ignore all of them because, hey, we're about to exit!
1670
- app.aboutToQuit.connect(lambda: os._exit(0))
3019
+ app.aboutToQuit.connect(cleanup)
1671
3020
 
1672
- tv = TviewMainWindow(args)
1673
3021
  tv.show()
1674
3022
 
1675
3023
  app.exec_()