aether-robotics 3.9.2__tar.gz → 3.9.4__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 (116) hide show
  1. {aether_robotics-3.9.2/aether_robotics.egg-info → aether_robotics-3.9.4}/PKG-INFO +1 -1
  2. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/__init__.py +1 -1
  3. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/adapters/sunfounder_picarx.py +71 -2
  4. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/skills/drive_until_obstacle.py +79 -4
  5. {aether_robotics-3.9.2 → aether_robotics-3.9.4/aether_robotics.egg-info}/PKG-INFO +1 -1
  6. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/pyproject.toml +1 -1
  7. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_drive_until_obstacle.py +100 -0
  8. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_sunfounder_picarx_adapter.py +141 -1
  9. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/LICENSE +0 -0
  10. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/MANIFEST.in +0 -0
  11. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/README.md +0 -0
  12. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/VERSION +0 -0
  13. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/__main__.py +0 -0
  14. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/actions/__init__.py +0 -0
  15. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/actions/abstract_actions.py +0 -0
  16. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/adapters/__init__.py +0 -0
  17. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/adapters/_generator.py +0 -0
  18. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/adapters/adeept_hat_v3.py +0 -0
  19. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/adapters/base_adapter.py +0 -0
  20. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/adapters/drone_adapter.py +0 -0
  21. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/adapters/freenove_4wd.py +0 -0
  22. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/adapters/freenove_mecanum.py +0 -0
  23. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/adapters/l298n_direct_gpio.py +0 -0
  24. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/adapters/pca9685_generic.py +0 -0
  25. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/adapters/rover_adapter.py +0 -0
  26. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/adapters/universal_adapter.py +0 -0
  27. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/adapters/waveshare_motor_driver.py +0 -0
  28. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/agents/__init__.py +0 -0
  29. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/agents/adaptation_agent.py +0 -0
  30. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/agents/camera_agent.py +0 -0
  31. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/agents/correction_agent.py +0 -0
  32. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/agents/execution_agent.py +0 -0
  33. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/agents/fault_agent.py +0 -0
  34. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/agents/memory_agent.py +0 -0
  35. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/agents/movement_agent.py +0 -0
  36. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/agents/navigation_agent.py +0 -0
  37. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/agents/perception_agent.py +0 -0
  38. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/agents/planner_agent.py +0 -0
  39. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/agents/power_agent.py +0 -0
  40. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/agents/task_manager.py +0 -0
  41. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/agents/thermal_agent.py +0 -0
  42. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/app.py +0 -0
  43. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/capabilities/__init__.py +0 -0
  44. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/capabilities/capability_loader.py +0 -0
  45. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/__init__.py +0 -0
  46. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/auto_installer.py +0 -0
  47. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/auto_updater.py +0 -0
  48. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/banner.py +0 -0
  49. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/calibration.py +0 -0
  50. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/executor.py +0 -0
  51. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/feedback.py +0 -0
  52. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/genome.py +0 -0
  53. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/goal_parser.py +0 -0
  54. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/llm_planner.py +0 -0
  55. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/mapper.py +0 -0
  56. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/mavlink_integration.py +0 -0
  57. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/memory.py +0 -0
  58. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/message_bus.py +0 -0
  59. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/metrics.py +0 -0
  60. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/navigation_engine.py +0 -0
  61. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/planner.py +0 -0
  62. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/task_scheduler.py +0 -0
  63. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/tool_builder.py +0 -0
  64. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/tool_discovery.py +0 -0
  65. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/tool_registry.py +0 -0
  66. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/core/visualizer.py +0 -0
  67. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/faults/__init__.py +0 -0
  68. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/faults/fault_detector.py +0 -0
  69. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/faults/fault_injector.py +0 -0
  70. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/parallel/__init__.py +0 -0
  71. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/parallel/executor.py +0 -0
  72. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/simulation/__init__.py +0 -0
  73. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/simulation/environment.py +0 -0
  74. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/simulation/real_perception.py +0 -0
  75. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/simulation/scenarios.py +0 -0
  76. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether/skills/__init__.py +0 -0
  77. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether_robotics.egg-info/SOURCES.txt +0 -0
  78. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether_robotics.egg-info/dependency_links.txt +0 -0
  79. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether_robotics.egg-info/entry_points.txt +0 -0
  80. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether_robotics.egg-info/requires.txt +0 -0
  81. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/aether_robotics.egg-info/top_level.txt +0 -0
  82. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/configs/calibrated_camera_only_20260331_121709.json +0 -0
  83. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/configs/calibrated_camera_only_20260331_122446.json +0 -0
  84. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/configs/calibrated_camera_only_20260401_094544.json +0 -0
  85. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/configs/calibrated_camera_only_20260401_094554.json +0 -0
  86. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/configs/drone_v1.json +0 -0
  87. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/configs/rover_v1.json +0 -0
  88. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/context/aether_definitions.txt +0 -0
  89. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/install.sh +0 -0
  90. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/main.py +0 -0
  91. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/requirements.txt +0 -0
  92. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/setup.cfg +0 -0
  93. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_adapter_preflight.py +0 -0
  94. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_atexit_adapter_shutdown.py +0 -0
  95. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_auto_adapter.py +0 -0
  96. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_auto_updater.py +0 -0
  97. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_boot_resolver_flow.py +0 -0
  98. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_calibration.py +0 -0
  99. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_calibration_unit.py +0 -0
  100. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_chaos.py +0 -0
  101. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_correction_agent.py +0 -0
  102. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_entrypoint.py +0 -0
  103. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_genome.py +0 -0
  104. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_mapper.py +0 -0
  105. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_mavlink_integration.py +0 -0
  106. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_navigation_engine.py +0 -0
  107. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_parallel_executor.py +0 -0
  108. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_persistent_memory.py +0 -0
  109. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_security.py +0 -0
  110. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_task_scheduler.py +0 -0
  111. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_tier1_motion_cleanup.py +0 -0
  112. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_tool_builder.py +0 -0
  113. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_tool_discovery.py +0 -0
  114. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_tool_discovery_gpio_cleanup.py +0 -0
  115. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/tests/test_yolo_integration.py +0 -0
  116. {aether_robotics-3.9.2 → aether_robotics-3.9.4}/weights/fault_agent.npy +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aether-robotics
3
- Version: 3.9.2
3
+ Version: 3.9.4
4
4
  Summary: Autonomous multi-agent robotics system with DRL-First Hybrid FDIR
5
5
  Author: Chahel Paatur
6
6
  License: MIT
@@ -1,2 +1,2 @@
1
- __version__ = "3.9.2"
1
+ __version__ = "3.9.4"
2
2
  __author__ = "Chahel Paatur"
@@ -13,6 +13,38 @@ from typing import Dict
13
13
 
14
14
  _ADAPTER_ID = "sunfounder_picarx"
15
15
 
16
+ # Module-level reference to the live Picarx instance, set during register().
17
+ # Used ONLY by the skill-internal continuous-motion primitives below — never
18
+ # exposed as an LLM-callable tool.
19
+ _picarx_instance = None
20
+
21
+
22
+ def _continuous_forward(speed: float) -> None:
23
+ """Start the motor running forward at `speed`. Does NOT auto-stop.
24
+
25
+ INTERNAL: only callable by parallel skills that wrap the call in their
26
+ own try/finally with _continuous_stop() in the finally block. NOT
27
+ registered as an LLM-callable tool because it breaks the motion-cleanup
28
+ invariant. The adapter's atexit emergency_stop (v3.7.5) still neutralizes
29
+ the motor on AETHER exit.
30
+ """
31
+ global _picarx_instance
32
+ if _picarx_instance is None:
33
+ raise RuntimeError("sunfounder_picarx not initialized")
34
+ _picarx_instance.forward(float(speed))
35
+
36
+
37
+ def _continuous_stop() -> None:
38
+ """Stop the motor immediately. Safe to call multiple times (idempotent
39
+ at the Robot HAT level). Called by parallel skills in their try/finally."""
40
+ global _picarx_instance
41
+ if _picarx_instance is None:
42
+ return # nothing to stop, no init
43
+ try:
44
+ _picarx_instance.stop()
45
+ except Exception:
46
+ pass # never raise in cleanup paths
47
+
16
48
  # Config files robot_hat tries to write at runtime. When these are not
17
49
  # writable by the current user, robot_hat's filedb.py / config.py invoke
18
50
  # os.popen('sudo chmod ...') / os.popen('sudo chown ...'), which pops a
@@ -367,6 +399,39 @@ def register(registry, manifest: dict) -> None:
367
399
  print(f" [ADAPTER] sunfounder_picarx: cali_dir_value inversion "
368
400
  f"failed: {e}")
369
401
 
402
+ # Expose the live instance to the skill-internal continuous-motion
403
+ # primitives (_continuous_forward / _continuous_stop). These are NOT
404
+ # registered as tools — only parallel skills with their own try/finally
405
+ # cleanup may call them. See the module-level helpers above.
406
+ global _picarx_instance
407
+ _picarx_instance = car
408
+
409
+ # v3.9.4: surface ultrasonic faults early so the user knows before running
410
+ # skills. This is detection only — it does NOT attempt to fix the sensor.
411
+ try:
412
+ # Take 3 quick reads; if all are -1, warn loudly
413
+ bad = 0
414
+ for _ in range(3):
415
+ d = car.ultrasonic.read()
416
+ if d is None or d < 0 or d > 400:
417
+ bad += 1
418
+ time.sleep(0.05)
419
+ if bad == 3:
420
+ print(" [ADAPTER] WARNING: ultrasonic sensor returned bad reads "
421
+ "on 3/3 probes — drive_until_obstacle may fail.")
422
+ print(" [ADAPTER] Check sensor wiring (TRIG=D2, ECHO=D3, "
423
+ "GND/3V3 power) or run:")
424
+ print(" [ADAPTER] python3 -c 'from picarx import Picarx; "
425
+ "print(Picarx().ultrasonic.read())'")
426
+ elif bad > 0:
427
+ print(f" [ADAPTER] NOTE: ultrasonic returned {bad}/3 bad reads "
428
+ f"— sensor may be intermittent.")
429
+ else:
430
+ print(" [ADAPTER] sunfounder_picarx: ultrasonic sensor OK")
431
+ except Exception:
432
+ # Never fail boot on a sensor probe — just skip
433
+ pass
434
+
370
435
  _meta = {"adapter_source": "tier1", "adapter_id": _ADAPTER_ID}
371
436
 
372
437
  def emergency_stop(**kwargs):
@@ -411,7 +476,9 @@ def register(registry, manifest: dict) -> None:
411
476
  speed = float(kwargs.get("speed", 40))
412
477
  duration = kwargs.get("duration")
413
478
  if duration is None:
414
- duration = 1.0
479
+ # v3.9.4: 1.0s produced too little heading change on the bench;
480
+ # turn longer before recentering. LLM can still override.
481
+ duration = 2.0
415
482
  try:
416
483
  car.set_dir_servo_angle(-30)
417
484
  car.forward(speed)
@@ -425,7 +492,9 @@ def register(registry, manifest: dict) -> None:
425
492
  speed = float(kwargs.get("speed", 40))
426
493
  duration = kwargs.get("duration")
427
494
  if duration is None:
428
- duration = 1.0
495
+ # v3.9.4: 1.0s produced too little heading change on the bench;
496
+ # turn longer before recentering. LLM can still override.
497
+ duration = 2.0
429
498
  try:
430
499
  car.set_dir_servo_angle(30)
431
500
  car.forward(speed)
@@ -1,10 +1,17 @@
1
- """AETHER v3.9.0 — drive_until_obstacle parallel skill.
1
+ """AETHER v3.9.3 — drive_until_obstacle parallel skill.
2
2
 
3
3
  Drives the robot forward while a second thread polls the ultrasonic sensor,
4
4
  stopping automatically when an obstacle is within target_cm. True threaded
5
- concurrency: motor pulses and sensor reads run on separate threads that share
5
+ concurrency: motor drive and sensor reads run on separate threads that share
6
6
  a stop event.
7
7
 
8
+ v3.9.3: the motor thread now drives CONTINUOUSLY (via the adapter's
9
+ skill-internal _continuous_forward / _continuous_stop primitives) and only
10
+ re-issues a command when the approach-speed band changes — eliminating the
11
+ start-stop jitter and inconsistent stopping distance of the pulsed path. The
12
+ pulsed path is retained as a fallback for adapters that don't expose the
13
+ continuous primitives.
14
+
8
15
  Demo objective this satisfies (verbatim): "Go forward and stop ~5 cm in front
9
16
  of the wall — keep going forward and use the ultrasonic sensor in parallel to
10
17
  detect the wall, and stop when it's within 5 cm."
@@ -85,11 +92,66 @@ def drive_until_obstacle(target_cm: float = 5.0, max_speed: float = 25.0,
85
92
  "final_distance_cm": None}
86
93
 
87
94
  def motor_task(should_stop):
88
- """Pulse the motor forward until told to stop; always stop on exit.
95
+ """Drive the motor until told to stop; always stop on exit.
96
+
97
+ Prefers CONTINUOUS motion (v3.9.3): the motor runs without pause and
98
+ we only re-issue a forward command when the approach-speed band
99
+ changes. This eliminates the start-stop-start jitter the pulsed path
100
+ produced (10 accel/decel cycles per second) and makes the stopping
101
+ distance consistent. Falls back to the pulsed v3.9.1 behavior when the
102
+ adapter doesn't expose continuous primitives (e.g. a Tier 2 generated
103
+ adapter), preserving backward compatibility.
89
104
 
90
105
  Speed follows an approach curve based on the most recent sensor
91
106
  reading — full speed when far, easing to ~35% near the target — so
92
- the final pulse is short and overshoot stays small."""
107
+ deceleration begins early and overshoot stays small."""
108
+ # Try to import the continuous primitives from the adapter. If the
109
+ # adapter doesn't expose them, fall back to the pulsed approach.
110
+ #
111
+ # Gate on _picarx_instance too: the sunfounder module is ALWAYS
112
+ # importable in-process, so import success alone does not mean the
113
+ # sunfounder adapter is the live one. A Tier 2 generated adapter
114
+ # leaves _picarx_instance None — in that case the primitives would
115
+ # raise RuntimeError, so we must fall back to the pulsed path. Only
116
+ # use continuous motion when the adapter is actually initialized.
117
+ try:
118
+ from aether.adapters.sunfounder_picarx import (
119
+ _continuous_forward, _continuous_stop, _picarx_instance
120
+ )
121
+ use_continuous = _picarx_instance is not None
122
+ except (ImportError, AttributeError):
123
+ use_continuous = False
124
+
125
+ if use_continuous:
126
+ try:
127
+ current_speed = None
128
+ while not should_halt(should_stop):
129
+ with state_lock:
130
+ current_dist = state.get("last_distance_cm")
131
+ target_speed = _approach_speed(
132
+ current_dist if current_dist is not None else 1000.0,
133
+ target_cm, max_speed)
134
+ # Only re-issue the forward command when speed actually
135
+ # changes. Motion stays continuous; we just adjust the
136
+ # speed setpoint — no stop between bands, no jitter.
137
+ if target_speed != current_speed:
138
+ with _MOTOR_LOCK:
139
+ if should_halt(should_stop):
140
+ break
141
+ _continuous_forward(target_speed)
142
+ current_speed = target_speed
143
+ time.sleep(_POLL_S) # poll cadence; motor keeps running
144
+ finally:
145
+ # Skill-owned cleanup: any exception (including a stop signal)
146
+ # halts the motor here. A daemon kill at interpreter exit skips
147
+ # this — the adapter emergency_stop from app's exit handler is
148
+ # the authoritative kill in that case.
149
+ with _MOTOR_LOCK:
150
+ _continuous_stop()
151
+ return
152
+
153
+ # Pulsed fallback (v3.9.1 behavior) for adapters without
154
+ # continuous-motion support.
93
155
  try:
94
156
  while not should_halt(should_stop):
95
157
  with state_lock:
@@ -137,6 +199,19 @@ def drive_until_obstacle(target_cm: float = 5.0, max_speed: float = 25.0,
137
199
  if failures > _MAX_SENSOR_FAILURES:
138
200
  with state_lock:
139
201
  state["stop_reason"] = "sensor failing"
202
+ # v3.9.4: surface the fault with actionable wiring guidance
203
+ # (detection only — no software fix for the sensor).
204
+ print(
205
+ f"\n [SENSOR FAULT] Ultrasonic sensor returned -1 or "
206
+ f"out-of-range {state['sensor_failures']} times during "
207
+ f"drive_until_obstacle.\n"
208
+ f" This usually means a wiring or hardware issue:\n"
209
+ f" • Check the ultrasonic sensor's 4-pin cable is firmly seated\n"
210
+ f" • TRIG should be on D2, ECHO on D3 (Robot HAT)\n"
211
+ f" • Verify power: red=3V3/5V, black=GND\n"
212
+ f" • Try: python3 -c 'from picarx import Picarx; print(Picarx().ultrasonic.read())'\n"
213
+ f" Skill halted safely for safety.\n"
214
+ )
140
215
  should_stop.set()
141
216
  return
142
217
  time.sleep(_POLL_S)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aether-robotics
3
- Version: 3.9.2
3
+ Version: 3.9.4
4
4
  Summary: Autonomous multi-agent robotics system with DRL-First Hybrid FDIR
5
5
  Author: Chahel Paatur
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aether-robotics"
7
- version = "3.9.2"
7
+ version = "3.9.4"
8
8
  description = "Autonomous multi-agent robotics system with DRL-First Hybrid FDIR"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -205,3 +205,103 @@ def test_demo_drive_until_obstacle_at_5cm_motor_ceases_after_stop():
205
205
  time.sleep(0.4)
206
206
  assert reg.move_calls == calls_at_return, "motor kept pulsing after stop"
207
207
  assert reg.stop_calls >= 1
208
+
209
+
210
+ # ── v3.9.3: continuous-motion motor_task ───────────────────────────────
211
+ #
212
+ # These exercise the path where the sunfounder adapter is LIVE (its
213
+ # _picarx_instance is wired) so the skill drives continuously instead of
214
+ # pulsing. The existing FakeRegistry tests above run with _picarx_instance
215
+ # None (autouse fixture below), so they keep exercising the pulsed path —
216
+ # this is the exact Tier 2 / generic-adapter backward-compat fallback.
217
+ import aether.adapters.sunfounder_picarx as sp # noqa: E402
218
+ from unittest.mock import MagicMock # noqa: E402
219
+
220
+
221
+ @pytest.fixture(autouse=True)
222
+ def _picarx_instance_none(monkeypatch):
223
+ """Default every test to 'sunfounder adapter not live' → pulsed path.
224
+ Continuous tests opt in by setting _picarx_instance themselves."""
225
+ monkeypatch.setattr(sp, "_picarx_instance", None, raising=False)
226
+
227
+
228
+ def test_motor_task_uses_continuous_when_available(monkeypatch):
229
+ """With the adapter live, the motor issues only a FEW forward commands
230
+ (one per approach-speed band change), NOT a pulse 10x/second."""
231
+ fwd, stop = MagicMock(), MagicMock()
232
+ monkeypatch.setattr(sp, "_continuous_forward", fwd)
233
+ monkeypatch.setattr(sp, "_continuous_stop", stop)
234
+ monkeypatch.setattr(sp, "_picarx_instance", object()) # adapter is live
235
+
236
+ reg = FakeRegistry([60, 50, 40, 30, 20, 12, 8, 5, 5, 5])
237
+ result = drive_until_obstacle(target_cm=5, max_speed=30, timeout_s=8, registry=reg)
238
+
239
+ assert "obstacle at" in result["stop_reason"]
240
+ # Continuous: forward issued once per band (max → 60% → 35%), never spammed.
241
+ assert 1 <= fwd.call_count <= 3, f"expected ≤3 forward calls, got {fwd.call_count}"
242
+ # The pulsed registry path must NOT have been used at all.
243
+ assert reg.move_calls == 0
244
+ # Speeds applied via the approach curve, monotonically non-increasing.
245
+ speeds = [c.args[0] for c in fwd.call_args_list]
246
+ assert speeds == sorted(speeds, reverse=True), speeds
247
+ stop.assert_called() # skill-owned cleanup fired
248
+
249
+
250
+ def test_motor_task_falls_back_to_pulsed_when_no_continuous(monkeypatch):
251
+ """Adapter without continuous primitives (e.g. Tier 2) → pulsed
252
+ move_forward, repeatedly (backward compatibility)."""
253
+ monkeypatch.delattr(sp, "_continuous_forward", raising=False)
254
+ monkeypatch.delattr(sp, "_continuous_stop", raising=False)
255
+
256
+ reg = FakeRegistry([100]) # wall never appears → keeps pulsing
257
+ result = drive_until_obstacle(target_cm=5, max_speed=30, timeout_s=1.0, registry=reg)
258
+
259
+ assert result["timed_out"] is True
260
+ assert reg.move_calls >= 2, f"pulsed path should re-issue; got {reg.move_calls}"
261
+ assert reg.stop_calls >= 1 # pulsed path's finally stop
262
+
263
+
264
+ def test_continuous_motor_task_always_stops_on_exception(monkeypatch):
265
+ """An exception mid-loop (e.g. HAT fault during a forward) must still stop
266
+ the motor via the skill's finally → _continuous_stop."""
267
+ boom = MagicMock(side_effect=RuntimeError("HAT fault mid-drive"))
268
+ stop = MagicMock()
269
+ monkeypatch.setattr(sp, "_continuous_forward", boom)
270
+ monkeypatch.setattr(sp, "_continuous_stop", stop)
271
+ monkeypatch.setattr(sp, "_picarx_instance", object())
272
+
273
+ reg = FakeRegistry([100])
274
+ drive_until_obstacle(target_cm=5, max_speed=30, timeout_s=1.0, registry=reg)
275
+
276
+ boom.assert_called() # the failing forward ran
277
+ stop.assert_called() # finally still stopped the motor
278
+
279
+
280
+ def test_continuous_motor_task_stops_on_should_stop(monkeypatch):
281
+ """When the sensor confirms an obstacle and sets should_stop, the motor's
282
+ finally fires _continuous_stop."""
283
+ fwd, stop = MagicMock(), MagicMock()
284
+ monkeypatch.setattr(sp, "_continuous_forward", fwd)
285
+ monkeypatch.setattr(sp, "_continuous_stop", stop)
286
+ monkeypatch.setattr(sp, "_picarx_instance", object())
287
+
288
+ reg = FakeRegistry([6, 4, 4, 4]) # obstacle confirmed quickly
289
+ result = drive_until_obstacle(target_cm=5, max_speed=30, timeout_s=5, registry=reg)
290
+
291
+ assert "obstacle at" in result["stop_reason"]
292
+ stop.assert_called()
293
+
294
+
295
+ # ── v3.9.4: clearer sensor-fault surfacing ─────────────────────────────
296
+
297
+ def test_sensor_fault_log_includes_wiring_guidance(capsys):
298
+ """When the sensor fails (>20 bad reads), the skill halts safely AND prints
299
+ actionable wiring guidance. Behavior is unchanged — only the log is richer."""
300
+ reg = FakeRegistry([-1]) # no echo, ever → sensor failing
301
+ result = drive_until_obstacle(target_cm=5, max_speed=30, timeout_s=5, registry=reg)
302
+
303
+ assert result["stop_reason"] == "sensor failing" # unchanged behavior
304
+ out = capsys.readouterr().out
305
+ assert "SENSOR FAULT" in out
306
+ assert "TRIG" in out and "ECHO" in out
307
+ assert "wiring" in out
@@ -125,7 +125,7 @@ def test_turn_left_recenters_servo_on_exception():
125
125
  def test_turn_left_default_duration_when_none_provided():
126
126
  reg, car = _register()
127
127
  _, sleep_mock = _call(reg, "turn_left") # no kwargs
128
- sleep_mock.assert_called_once_with(1.0)
128
+ sleep_mock.assert_called_once_with(2.0) # v3.9.4: 1.0 → 2.0
129
129
  # and it still recenters
130
130
  assert ("set_dir_servo_angle", 0) in car.calls
131
131
 
@@ -476,3 +476,143 @@ def test_is_gpio_busy_error_matches_varied_types():
476
476
  assert _is_gpio_busy_error(Exception("gpio line is busy"))
477
477
  assert not _is_gpio_busy_error(ValueError("bad pin number"))
478
478
  assert not _is_gpio_busy_error(ImportError("no module named picarx"))
479
+
480
+
481
+ # ── v3.9.3: skill-internal continuous-motion primitives ────────────────
482
+
483
+ @pytest.fixture(autouse=True)
484
+ def _reset_picarx_instance():
485
+ """register() sets the module-global _picarx_instance; isolate tests."""
486
+ saved = sp._picarx_instance
487
+ sp._picarx_instance = None
488
+ yield
489
+ sp._picarx_instance = saved
490
+
491
+
492
+ def test_continuous_primitives_not_in_registry():
493
+ """The continuous primitives must NEVER be LLM-callable — they break the
494
+ motion-cleanup invariant by design and are only safe inside a skill's own
495
+ try/finally. register() must not expose them as tools."""
496
+ reg, car = _register()
497
+ assert "_continuous_forward" not in reg.tools
498
+ assert "_continuous_stop" not in reg.tools
499
+ # And no alias under a non-underscore name either.
500
+ assert "continuous_forward" not in reg.tools
501
+ assert "continuous_stop" not in reg.tools
502
+ # The standard 12-tool vocabulary is still present.
503
+ assert "move_forward" in reg.tools
504
+ assert "emergency_stop" in reg.tools
505
+
506
+
507
+ def test_continuous_forward_requires_init():
508
+ """_continuous_forward before register() (no instance) must raise rather
509
+ than silently no-op — a skill calling it pre-init is a bug."""
510
+ sp._picarx_instance = None
511
+ with pytest.raises(RuntimeError):
512
+ sp._continuous_forward(20.0)
513
+
514
+
515
+ def test_continuous_stop_idempotent():
516
+ """_continuous_stop must be safe to call repeatedly and never raise —
517
+ it lives in cleanup paths."""
518
+ fake = FakePicarx()
519
+ sp._picarx_instance = fake
520
+ sp._continuous_stop()
521
+ sp._continuous_stop() # second call must not raise
522
+ assert fake.calls.count(("stop",)) == 2
523
+
524
+ # Also safe with no instance (returns early, no raise).
525
+ sp._picarx_instance = None
526
+ sp._continuous_stop()
527
+
528
+
529
+ def test_continuous_stop_never_raises_when_hat_errors():
530
+ """If the underlying car.stop() throws, _continuous_stop swallows it."""
531
+ class BoomStop(FakePicarx):
532
+ def stop(self):
533
+ raise RuntimeError("HAT I2C error")
534
+ sp._picarx_instance = BoomStop()
535
+ sp._continuous_stop() # must not propagate
536
+
537
+
538
+ def test_register_sets_picarx_instance():
539
+ """After register(), the module-global instance is wired so the skill's
540
+ continuous primitives can drive it."""
541
+ assert sp._picarx_instance is None # reset by fixture
542
+ reg, car = _register()
543
+ assert sp._picarx_instance is not None
544
+ assert sp._picarx_instance is car
545
+
546
+
547
+ def test_continuous_forward_drives_instance():
548
+ """_continuous_forward issues a forward() WITHOUT a following stop()."""
549
+ fake = FakePicarx()
550
+ sp._picarx_instance = fake
551
+ sp._continuous_forward(33.0)
552
+ assert ("forward", 33.0) in fake.calls
553
+ assert ("stop",) not in fake.calls # continuous: no auto-stop
554
+
555
+
556
+ # ── v3.9.4: turn default duration 1.0 → 2.0 ────────────────────────────
557
+
558
+ def test_turn_left_default_duration_is_2s():
559
+ reg, car = _register()
560
+ _, sleep_mock = _call(reg, "turn_left") # no duration → default
561
+ sleep_mock.assert_called_once_with(2.0)
562
+ assert ("set_dir_servo_angle", 0) in car.calls # still recenters
563
+
564
+
565
+ def test_turn_right_default_duration_is_2s():
566
+ reg, car = _register()
567
+ _, sleep_mock = _call(reg, "turn_right")
568
+ sleep_mock.assert_called_once_with(2.0)
569
+ assert ("set_dir_servo_angle", 0) in car.calls
570
+
571
+
572
+ def test_turn_left_explicit_duration_still_works():
573
+ reg, car = _register()
574
+ _, sleep_mock = _call(reg, "turn_left", duration=0.5)
575
+ sleep_mock.assert_called_once_with(0.5) # LLM override unaffected
576
+
577
+
578
+ # ── v3.9.4: boot-time ultrasonic health probe (detection only) ─────────
579
+
580
+ def _picarx_cls_with_reads(reads):
581
+ """A FakePicarx subclass whose ultrasonic.read() yields `reads`, then -1.
582
+ The boot probe reads exactly 3 times."""
583
+ class _USPicarx(FakePicarx):
584
+ def __init__(self):
585
+ super().__init__()
586
+ seq = list(reads)
587
+ us = MagicMock()
588
+ us.read.side_effect = lambda: seq.pop(0) if seq else -1
589
+ self.ultrasonic = us
590
+ return _USPicarx
591
+
592
+
593
+ def test_register_warns_on_all_bad_sensor_reads(capsys):
594
+ _register(picarx_cls=_picarx_cls_with_reads([-1, -1, -1]))
595
+ out = capsys.readouterr().out
596
+ assert "WARNING: ultrasonic sensor returned bad reads" in out
597
+ assert "TRIG=D2" in out and "ECHO=D3" in out
598
+
599
+
600
+ def test_register_quiet_on_all_good_sensor_reads(capsys):
601
+ _register(picarx_cls=_picarx_cls_with_reads([50, 50, 50]))
602
+ out = capsys.readouterr().out
603
+ assert "ultrasonic sensor OK" in out
604
+ assert "WARNING" not in out
605
+
606
+
607
+ def test_register_notes_intermittent_sensor(capsys):
608
+ _register(picarx_cls=_picarx_cls_with_reads([50, -1, 50]))
609
+ out = capsys.readouterr().out
610
+ assert "NOTE: ultrasonic returned 1/3 bad reads" in out
611
+
612
+
613
+ def test_register_never_fails_boot_when_ultrasonic_attr_missing():
614
+ """Base FakePicarx has no `ultrasonic` attr — the probe must swallow the
615
+ AttributeError and register the full tool vocabulary anyway."""
616
+ reg, car = _register() # default FakePicarx, no ultrasonic
617
+ assert "move_forward" in reg.tools
618
+ assert "get_distance" in reg.tools
File without changes
File without changes
File without changes