motor-python 0.0.7__tar.gz → 0.0.8__tar.gz

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.
Files changed (26) hide show
  1. {motor_python-0.0.7 → motor_python-0.0.8}/.gitignore +1 -0
  2. {motor_python-0.0.7 → motor_python-0.0.8}/PKG-INFO +2 -2
  3. {motor_python-0.0.7 → motor_python-0.0.8}/pyproject.toml +1 -1
  4. motor_python-0.0.8/src/motor_python/__init__.py +110 -0
  5. motor_python-0.0.8/src/motor_python/__main__.py +183 -0
  6. {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/base_motor.py +203 -0
  7. {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/cube_mars_motor.py +18 -0
  8. {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/cube_mars_motor_can.py +775 -103
  9. {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/definitions.py +262 -11
  10. {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/examples_can.py +65 -51
  11. motor_python-0.0.8/src/motor_python/motor_control_using_pid.py +113 -0
  12. motor_python-0.0.8/src/motor_python/motor_manager.py +184 -0
  13. motor_python-0.0.8/src/motor_python/pid_controller.py +112 -0
  14. motor_python-0.0.8/src/motor_python/second_order_low_pass_filter.py +567 -0
  15. motor_python-0.0.8/src/motor_python/utils.py +172 -0
  16. motor_python-0.0.7/src/motor_python/__init__.py +0 -17
  17. motor_python-0.0.7/src/motor_python/__main__.py +0 -128
  18. motor_python-0.0.7/src/motor_python/mit_mode_packer.py +0 -103
  19. motor_python-0.0.7/src/motor_python/utils.py +0 -74
  20. {motor_python-0.0.7 → motor_python-0.0.8}/LICENSE +0 -0
  21. {motor_python-0.0.7 → motor_python-0.0.8}/README.md +0 -0
  22. {motor_python-0.0.7 → motor_python-0.0.8}/scripts/README.md +0 -0
  23. {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/can_protocol.py +0 -0
  24. {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/can_utils.py +0 -0
  25. {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/examples.py +0 -0
  26. {motor_python-0.0.7 → motor_python-0.0.8}/src/motor_python/motor_status_parser.py +0 -0
@@ -185,5 +185,6 @@ tmp/
185
185
  .codex
186
186
  CSV/
187
187
  data/csv_logs/*.csv
188
+ data/csv_logs/*.png
188
189
  !data/csv_logs/.gitkeep
189
190
  repo_tree.py
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: motor_python
3
- Version: 0.0.7
3
+ Version: 0.0.8
4
4
  Summary: CubeMars motor module for Aries exosuits.
5
5
  Project-URL: homepage, https://github.com/TUM-Aries-Lab/motor-module
6
6
  Author-email: Tsmorz <tony.smoragiewicz@tum.de>, Hannes Nguyen <hannes.nguyen@tum.de>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "motor_python"
3
- version = "0.0.7"
3
+ version = "0.0.8"
4
4
  description = "CubeMars motor module for Aries exosuits."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Tsmorz", email = "tony.smoragiewicz@tum.de"},{ name = "Hannes Nguyen", email = "hannes.nguyen@tum.de" }]
@@ -0,0 +1,110 @@
1
+ """Motor control module for CubeMars AK60-6 exosuit actuators.
2
+
3
+ Primary interface: CAN (CubeMarsAK606v3CAN / Motor).
4
+ Legacy UART interface: CubeMarsAK606v3.
5
+ Base class: BaseMotor (for shared interface & safety logic).
6
+ """
7
+
8
+ __version__ = "0.0.8"
9
+
10
+ from typing import Literal
11
+
12
+ from motor_python.base_motor import BaseMotor
13
+ from motor_python.cube_mars_motor import CubeMarsAK606v3, CubeMarsAK806v2
14
+ from motor_python.cube_mars_motor_can import (
15
+ CubeMarsAK606v1CAN,
16
+ CubeMarsAK606v3CAN,
17
+ CubeMarsAK806v2CAN,
18
+ CubeMarsBaseCAN,
19
+ )
20
+ from motor_python.definitions import (
21
+ AK60_6_V1_1_MOTOR_SPEC,
22
+ AK60_6_V3_0_MOTOR_SPEC,
23
+ AK80_6_MOTOR_SPEC,
24
+ CAN_DEFAULTS,
25
+ MotorModel,
26
+ MotorSpec,
27
+ )
28
+ from motor_python.motor_manager import MotorManager
29
+
30
+ # Convenience alias — CAN is the primary interface. Later can be changed to AK806v2CAN.
31
+ Motor = CubeMarsAK606v3CAN
32
+
33
+
34
+ # ruff: noqa: PLR0913
35
+ def create_can_motor(
36
+ motor_model: str = MotorModel.AK60_6V3,
37
+ *,
38
+ motor_can_id: int = CAN_DEFAULTS.motor_can_id,
39
+ interface: str = CAN_DEFAULTS.interface,
40
+ bitrate: int = CAN_DEFAULTS.bitrate,
41
+ feedback_can_id: int | None = None,
42
+ mit_velocity_kd: float | None = None,
43
+ motor_spec: MotorSpec | None = None,
44
+ helper_policy: Literal["strict", "fcfd", "legacy"] = "fcfd",
45
+ auto_recover_bus: bool = True,
46
+ allow_legacy_feedback_ids: bool = True,
47
+ aggressive_bus_reset: bool = False,
48
+ ) -> CubeMarsBaseCAN:
49
+ """Build a CAN motor instance for the requested model."""
50
+ model = motor_model.strip().upper()
51
+ if model in {"AK60-6", "AK60_6", "AK60-6_V3.0"}:
52
+ return CubeMarsAK606v3CAN(
53
+ motor_can_id=motor_can_id,
54
+ interface=interface,
55
+ bitrate=bitrate,
56
+ feedback_can_id=feedback_can_id,
57
+ mit_velocity_kd=mit_velocity_kd,
58
+ motor_spec=motor_spec if motor_spec is not None else AK60_6_V3_0_MOTOR_SPEC,
59
+ helper_policy=helper_policy,
60
+ auto_recover_bus=auto_recover_bus,
61
+ allow_legacy_feedback_ids=True,
62
+ aggressive_bus_reset=aggressive_bus_reset,
63
+ )
64
+ if model in {"AK80-6", "AK80_6"}:
65
+ return CubeMarsAK806v2CAN(
66
+ motor_can_id=motor_can_id,
67
+ interface=interface,
68
+ bitrate=bitrate,
69
+ feedback_can_id=feedback_can_id,
70
+ mit_velocity_kd=mit_velocity_kd,
71
+ motor_spec=motor_spec if motor_spec is not None else AK80_6_MOTOR_SPEC,
72
+ helper_policy=helper_policy,
73
+ auto_recover_bus=auto_recover_bus,
74
+ allow_legacy_feedback_ids=False,
75
+ aggressive_bus_reset=aggressive_bus_reset,
76
+ )
77
+ if model in {"AK60-V1", "AK60-V1.1", "AK60-6_V1.1"}:
78
+ return CubeMarsAK606v1CAN(
79
+ motor_can_id=motor_can_id,
80
+ interface=interface,
81
+ bitrate=bitrate,
82
+ feedback_can_id=feedback_can_id,
83
+ mit_velocity_kd=mit_velocity_kd,
84
+ motor_spec=motor_spec if motor_spec is not None else AK60_6_V1_1_MOTOR_SPEC,
85
+ helper_policy=helper_policy,
86
+ auto_recover_bus=auto_recover_bus,
87
+ allow_legacy_feedback_ids=False,
88
+ aggressive_bus_reset=aggressive_bus_reset,
89
+ )
90
+ raise ValueError("Unknown motor model: must be AK60-6 or AK80-6")
91
+
92
+
93
+ __all__ = [
94
+ "AK60_6_V1_1_MOTOR_SPEC",
95
+ "AK60_6_V3_0_MOTOR_SPEC",
96
+ "AK80_6_MOTOR_SPEC",
97
+ "CAN_DEFAULTS",
98
+ "BaseMotor",
99
+ "CubeMarsAK606v1CAN",
100
+ "CubeMarsAK606v3",
101
+ "CubeMarsAK606v3CAN",
102
+ "CubeMarsAK806v2",
103
+ "CubeMarsAK806v2CAN",
104
+ "CubeMarsBaseCAN",
105
+ "Motor",
106
+ "MotorManager",
107
+ "MotorModel",
108
+ "MotorSpec",
109
+ "create_can_motor",
110
+ ]
@@ -0,0 +1,183 @@
1
+ """Motor control main entry point — CAN interface (CubeMarsAK606v3CAN).
2
+
3
+ Example usage:
4
+ python -m motor_python --motor-ids 0x03
5
+ python -m motor_python --motor-ids 0x03 0x04
6
+ python -m motor_python --discover
7
+ python -m motor_python --dual
8
+
9
+ """
10
+
11
+ import argparse
12
+
13
+ from loguru import logger
14
+
15
+ from motor_python.definitions import (
16
+ CAN_DEFAULTS,
17
+ DEFAULT_LOG_LEVEL,
18
+ DEFAULT_MOTOR_SPEC,
19
+ LogLevel,
20
+ MotorModel,
21
+ set_current_motor_model_by_name,
22
+ )
23
+ from motor_python.examples_can import (
24
+ run_motor_demo_can,
25
+ run_multi_motor_demo,
26
+ )
27
+ from motor_python.motor_manager import MotorManager
28
+ from motor_python.utils import setup_logger
29
+
30
+
31
+ def main( # noqa: PLR0913
32
+ log_level: str = DEFAULT_LOG_LEVEL,
33
+ stderr_level: str = DEFAULT_LOG_LEVEL,
34
+ dual: bool = False,
35
+ discover: bool = False,
36
+ motor_ids: list[int] | None = None,
37
+ interface: str = CAN_DEFAULTS.interface,
38
+ motor_model: str = DEFAULT_MOTOR_SPEC.model_name,
39
+ ) -> None:
40
+ """Run the main CAN motor control loop.
41
+
42
+ Pre-condition: CAN interface must be up.
43
+ sudo ip link set can0 up type can bitrate 1000000 berr-reporting on restart-ms 100
44
+
45
+ :param log_level: The log level to use.
46
+ :param stderr_level: The std err level to use.
47
+ :param dual: If True, run the two-motor synchronized demo instead.
48
+ :param motor_ids: List of CAN motor IDs to control.
49
+ :param interface: The CAN interface to use.
50
+ :return: None
51
+ """
52
+ setup_logger(log_level=log_level, stderr_level=stderr_level)
53
+
54
+ set_current_motor_model_by_name(motor_model)
55
+ logger.info(f"Selected motor model: {motor_model}")
56
+
57
+ # --- Two-motor mode ---
58
+ if dual:
59
+ motor_ids = [CAN_DEFAULTS.motor_can_id, CAN_DEFAULTS.motor_can_id_2]
60
+
61
+ if motor_ids is None:
62
+ motor_ids = [CAN_DEFAULTS.motor_can_id]
63
+
64
+ try:
65
+ if discover:
66
+ manager = MotorManager.discover(
67
+ interface=interface, motor_model=motor_model
68
+ ) # sets the discovered ids as motor_ids
69
+ else:
70
+ logger.info(
71
+ f"Starting CAN motor control loop on interface '{interface}' with IDs: {motor_ids}"
72
+ )
73
+ manager = MotorManager(
74
+ motor_ids=motor_ids, interface=interface, motor_model=motor_model
75
+ )
76
+ except Exception as e:
77
+ logger.error(f"Failed to initialize CAN motor manager: {e}")
78
+ return
79
+
80
+ with manager:
81
+ first_motor = next(iter(manager))
82
+ if not first_motor.connected:
83
+ logger.warning(
84
+ "CAN bus not available. Run: sudo ip link set can0 up "
85
+ "type can bitrate 1000000 berr-reporting on restart-ms 100"
86
+ )
87
+ return
88
+
89
+ manager.send_neutral_commands() # Send neutral commands to all motors
90
+
91
+ status = (
92
+ manager.check_all()
93
+ ) # Check communication with all motors before proceeding
94
+ if not any(status.values()):
95
+ logger.warning(
96
+ "No motors responding. Check power, CANH/CANL wiring, "
97
+ "120 ohm termination, and CAN IDs."
98
+ )
99
+ return
100
+
101
+ manager.enable_all() # Enable all motors
102
+ logger.info("All motors enabled. Checking communication...")
103
+
104
+ logger.info(
105
+ f"Motor communication verified for IDs: {[motor_id for motor_id, ok in status.items() if ok]}"
106
+ )
107
+ for motor in manager:
108
+ motor.get_status()
109
+
110
+ try:
111
+ if len(manager) == 1:
112
+ run_motor_demo_can(next(iter(manager)))
113
+ else:
114
+ run_multi_motor_demo(manager)
115
+ except KeyboardInterrupt:
116
+ logger.info("Interrupted by user")
117
+
118
+ logger.info("CAN motor control loop complete!")
119
+
120
+
121
+ if __name__ == "__main__": # pragma: no cover
122
+ parser = argparse.ArgumentParser("Run the pipeline.")
123
+ parser.add_argument(
124
+ "--log-level",
125
+ default=DEFAULT_LOG_LEVEL,
126
+ choices=list(LogLevel()),
127
+ help="Set the log level.",
128
+ required=False,
129
+ type=str,
130
+ )
131
+ parser.add_argument(
132
+ "--stderr-level",
133
+ default=DEFAULT_LOG_LEVEL,
134
+ choices=list(LogLevel()),
135
+ help="Set the std err level.",
136
+ required=False,
137
+ type=str,
138
+ )
139
+ parser.add_argument(
140
+ "--dual",
141
+ action="store_true",
142
+ default=False,
143
+ help="Run the two-motor synchronized demo (requires two motors on the bus).",
144
+ )
145
+ parser.add_argument(
146
+ "--discover",
147
+ action="store_true",
148
+ default=False,
149
+ help="Automatically discover connected motors on the CAN bus.",
150
+ )
151
+ parser.add_argument(
152
+ "--motor-ids",
153
+ nargs="+",
154
+ type=lambda x: int(x, 0),
155
+ default=None,
156
+ help=(
157
+ "List of CAN motor IDs to control. "
158
+ "Use space-separated values like --motor-ids 0x03 0x04."
159
+ ),
160
+ )
161
+ parser.add_argument(
162
+ "--interface",
163
+ default=CAN_DEFAULTS.interface,
164
+ help="SocketCAN interface to use (default: can0).",
165
+ )
166
+ parser.add_argument(
167
+ "--motor-model",
168
+ type=str,
169
+ choices=list(MotorModel),
170
+ default=DEFAULT_MOTOR_SPEC.model_name,
171
+ help="Select motor type (AK60-6 or AK80-6).",
172
+ )
173
+ args = parser.parse_args()
174
+
175
+ main(
176
+ log_level=args.log_level,
177
+ stderr_level=args.stderr_level,
178
+ dual=args.dual,
179
+ discover=args.discover,
180
+ motor_ids=args.motor_ids,
181
+ motor_model=args.motor_model,
182
+ interface=args.interface,
183
+ )
@@ -8,6 +8,7 @@ must implement the abstract transport-layer methods.
8
8
  from __future__ import annotations
9
9
 
10
10
  import abc
11
+ import math
11
12
  import time
12
13
  from dataclasses import dataclass
13
14
  from typing import Self
@@ -48,6 +49,8 @@ class MotorState:
48
49
  current_amps: float # Phase current in amps
49
50
  temperature_celsius: int # Driver board temperature in °C
50
51
  error_code: int # Fault code (0 = OK)
52
+ timestamp_monotonic: float = 0.0
53
+ is_fresh: bool = True
51
54
 
52
55
  @property
53
56
  def error_description(self) -> str:
@@ -137,6 +140,14 @@ class BaseMotor(abc.ABC):
137
140
  :param velocity_erpm: Validated and clamped velocity in ERPM.
138
141
  """
139
142
 
143
+ def degrees_to_radians(self, degrees: float) -> float:
144
+ """Convert degrees to radians."""
145
+ return degrees * math.pi / 180.0
146
+
147
+ def radians_to_degrees(self, radians: float) -> float:
148
+ """Convert radians to degrees."""
149
+ return radians * 180.0 / math.pi
150
+
140
151
  # ------------------------------------------------------------------
141
152
  # Unified Advanced Control Methods (Stubbed by default)
142
153
  # ------------------------------------------------------------------
@@ -364,3 +375,195 @@ class BaseMotor(abc.ABC):
364
375
  def __exit__(self, exc_type, exc_val, exc_tb) -> None:
365
376
  """Context manager exit."""
366
377
  self.close()
378
+
379
+ def get_timing_stats(self) -> dict:
380
+ """Return Refresh Loop Statistics.
381
+
382
+ Reports:
383
+ - Loop timing statistics (mean dt, std dt, min dt, max dt, and the effective Hz (1/mean_dt))
384
+ - Jitter count (Counts how many intervals exceed 2x the expected period)
385
+ - Cumulative send failures and missed feedback frames (never resets)
386
+ - CAN error counter deltas (current tx_err/rx_err vs values at start)
387
+ - TX pacing metrics (if available)
388
+
389
+ :return: Dictionary with timing statistics, or minimal dict if unavailable.
390
+ """
391
+ stats = {
392
+ "method": "get_timing_stats",
393
+ "available": False,
394
+ }
395
+
396
+ # Trying to get loop timing from refresh timestamps
397
+ refresh_timestamps = getattr(self, "_refresh_timestamps", None)
398
+ if refresh_timestamps is not None:
399
+ timestamps = np.array(refresh_timestamps)
400
+ if len(timestamps) > 1:
401
+ expected_period = (
402
+ getattr(self, "_refresh_interval", 0.01)
403
+ if hasattr(self, "_refresh_interval")
404
+ else 0.01
405
+ )
406
+ dts = np.diff(timestamps)
407
+ threshold = 2.0 * expected_period
408
+ jitter_count = np.sum(
409
+ dts > threshold
410
+ ) # Counting intervals that exceed the expected threshold
411
+
412
+ stats.update(
413
+ {
414
+ "available": True,
415
+ "loop_period_expected_s": expected_period,
416
+ "loop_period_mean_s": float(np.mean(dts)),
417
+ "loop_period_std_s": float(np.std(dts)),
418
+ "loop_period_min_s": float(np.min(dts)),
419
+ "loop_period_max_s": float(np.max(dts)),
420
+ "loop_effective_hz": 1.0 / float(np.mean(dts)),
421
+ "loop_intervals_total": len(dts),
422
+ "loop_jitter_count": int(jitter_count),
423
+ "loop_jitter_ratio": float(jitter_count / len(dts))
424
+ if len(dts) > 0
425
+ else 0.0,
426
+ }
427
+ )
428
+
429
+ # Cumulative send failures
430
+ cumulative_send_failures = getattr(
431
+ self, "_cumulative_refresh_send_failures", None
432
+ )
433
+ if cumulative_send_failures is not None:
434
+ stats["cumulative_send_failures"] = cumulative_send_failures
435
+
436
+ # Cumulative missed feedback
437
+ cumulative_no_feedback = getattr(self, "_cumulative_refresh_no_feedback", None)
438
+ if cumulative_no_feedback is not None:
439
+ stats["cumulative_missed_feedback"] = cumulative_no_feedback
440
+
441
+ # CAN error counter deltas
442
+ initial_can_state = getattr(self, "_initial_can_state", None)
443
+ last_can_state_cache = getattr(self, "_last_can_state_cache", None)
444
+ if initial_can_state is not None and last_can_state_cache is not None:
445
+ stats["can_tx_err_initial"] = initial_can_state.get("tx_err", 0)
446
+ stats["can_tx_err_final"] = last_can_state_cache.get("tx_err", 0)
447
+ stats["can_tx_err_delta"] = last_can_state_cache.get(
448
+ "tx_err", 0
449
+ ) - initial_can_state.get("tx_err", 0)
450
+ stats["can_rx_err_initial"] = initial_can_state.get("rx_err", 0)
451
+ stats["can_rx_err_final"] = last_can_state_cache.get("rx_err", 0)
452
+ stats["can_rx_err_delta"] = last_can_state_cache.get(
453
+ "rx_err", 0
454
+ ) - initial_can_state.get("rx_err", 0)
455
+
456
+ # TX pacing metrics tells how often pacing delays were needed and how much time was spent sleeping to pace transmissions.
457
+ tx_pace_sleep_count = getattr(self, "_tx_pace_sleep_count", None)
458
+ if tx_pace_sleep_count is not None:
459
+ stats["tx_pace_sleep_count"] = tx_pace_sleep_count
460
+ tx_pace_sleep_time_s = getattr(self, "_tx_pace_sleep_time_s", None)
461
+ if tx_pace_sleep_time_s is not None:
462
+ stats["tx_pace_sleep_time_s"] = tx_pace_sleep_time_s
463
+
464
+ return stats
465
+
466
+ def reset_timing_stats(self) -> None:
467
+ """Reset timing-related diagnostic state used by get_timing_stats().
468
+
469
+ This clears any transport-specific timestamp buffers and zeroes
470
+ counters that are safe to reset for a fresh measurement run.
471
+ """
472
+ # Clear refresh timestamps used for jitter analysis (CAN transport)
473
+ if hasattr(self, "_refresh_timestamps"):
474
+ try:
475
+ self._refresh_timestamps.clear()
476
+ except Exception:
477
+ try:
478
+ self._refresh_timestamps = type(self._refresh_timestamps)()
479
+ except Exception:
480
+ logger.warning("Failed to reset _refresh_timestamps")
481
+
482
+ if hasattr(self, "_refresh_send_failures"):
483
+ try:
484
+ self._refresh_send_failures = 0
485
+ except Exception:
486
+ logger.warning("Failed to reset _refresh_send_failures")
487
+ if hasattr(self, "_refresh_no_feedback"):
488
+ try:
489
+ self._refresh_no_feedback = 0
490
+ except Exception:
491
+ logger.warning("Failed to reset _refresh_no_feedback")
492
+
493
+
494
+ def print_timing_stats(
495
+ timing_stats: dict[str, float | int | bool],
496
+ total_feedback_samples: int,
497
+ separator: str,
498
+ ) -> None:
499
+ """Print timing and CAN health diagnostics."""
500
+ if not timing_stats.get("available", False):
501
+ return
502
+
503
+ logger.info(f"\n{separator}")
504
+ logger.info("Timing & Health Diagnostics")
505
+ logger.info(separator)
506
+
507
+ logger.info(
508
+ f"Loop effective Hz : {timing_stats.get('loop_effective_hz', 0):.1f}"
509
+ )
510
+ logger.info(
511
+ f"Loop period (expected) : "
512
+ f"{timing_stats.get('loop_period_expected_s', 0):.6f} s"
513
+ )
514
+ logger.info(
515
+ f"Loop period (mean) : {timing_stats.get('loop_period_mean_s', 0):.6f} s"
516
+ )
517
+ logger.info(
518
+ f"Loop period (std) : {timing_stats.get('loop_period_std_s', 0):.6f} s"
519
+ )
520
+ logger.info(
521
+ f"Loop period (min/max) : "
522
+ f"{timing_stats.get('loop_period_min_s', 0):.6f} / "
523
+ f"{timing_stats.get('loop_period_max_s', 0):.6f} s"
524
+ )
525
+
526
+ logger.info(
527
+ f"Jitter (>2x period) : "
528
+ f"{timing_stats.get('loop_jitter_count', 0)} / "
529
+ f"{timing_stats.get('loop_intervals_total', 0)} "
530
+ f"({100.0 * timing_stats.get('loop_jitter_ratio', 0):.1f}%)"
531
+ )
532
+
533
+ logger.info(
534
+ f"TX pace sleeps : "
535
+ f"{timing_stats.get('tx_pace_sleep_count', 0)} times, "
536
+ f"{timing_stats.get('tx_pace_sleep_time_s', 0):.3f} s total"
537
+ )
538
+
539
+ logger.info(
540
+ f"Send failures (cumul.) : {timing_stats.get('cumulative_send_failures', 0)}"
541
+ )
542
+
543
+ missed_feedback = timing_stats.get("cumulative_missed_feedback", 0)
544
+
545
+ if total_feedback_samples > 0:
546
+ missed_percentage = (missed_feedback / total_feedback_samples) * 100.0
547
+ logger.info(
548
+ f"Missed feedback (cumul) : "
549
+ f"{missed_feedback}/{total_feedback_samples} "
550
+ f"({missed_percentage:.1f}%)"
551
+ )
552
+ else:
553
+ logger.info(f"Feedback samples (total): {total_feedback_samples}")
554
+ logger.info(f"Missed feedback (cumul) : {missed_feedback}")
555
+
556
+ can_tx_delta = timing_stats.get("can_tx_err_delta", 0)
557
+ can_rx_delta = timing_stats.get("can_rx_err_delta", 0)
558
+
559
+ logger.info(
560
+ f"CAN errors : "
561
+ f"tx_err {timing_stats.get('can_tx_err_initial', 0)}"
562
+ f"→{timing_stats.get('can_tx_err_final', 0)} "
563
+ f"(Δ{can_tx_delta:+d}), "
564
+ f"rx_err {timing_stats.get('can_rx_err_initial', 0)}"
565
+ f"→{timing_stats.get('can_rx_err_final', 0)} "
566
+ f"(Δ{can_rx_delta:+d})"
567
+ )
568
+
569
+ logger.info(separator)
@@ -11,6 +11,7 @@ from loguru import logger
11
11
 
12
12
  from motor_python.base_motor import BaseMotor, MotorState
13
13
  from motor_python.definitions import (
14
+ AK80_6_MOTOR_SPEC,
14
15
  CRC16_TAB,
15
16
  CRC_CONSTANTS,
16
17
  FRAME_BYTES,
@@ -429,3 +430,20 @@ class CubeMarsAK606v3(BaseMotor):
429
430
  self.stop()
430
431
  self.serial.close()
431
432
  logger.info("Motor connection closed")
433
+
434
+
435
+ class CubeMarsAK806v2(CubeMarsAK606v3):
436
+ """AK80-6 Motor Controller for CubeMars V3 UART Protocol."""
437
+
438
+ def __init__(
439
+ self,
440
+ port: Path | str = MOTOR_DEFAULTS.port,
441
+ baudrate: int = MOTOR_DEFAULTS.baudrate,
442
+ ) -> None:
443
+ """Initialize AK80-6 UART motor connection.
444
+
445
+ :param port: Serial port path (default: MOTOR_DEFAULTS.port).
446
+ :param baudrate: Communication baudrate (default: MOTOR_DEFAULTS.baudrate).
447
+ """
448
+ self._motor_spec = AK80_6_MOTOR_SPEC
449
+ super().__init__(port=port, baudrate=baudrate)