aether-robotics 3.4.0__tar.gz → 3.4.2__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 (91) hide show
  1. {aether_robotics-3.4.0/aether_robotics.egg-info → aether_robotics-3.4.2}/PKG-INFO +2 -2
  2. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/README.md +1 -1
  3. aether_robotics-3.4.2/VERSION +1 -0
  4. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/__init__.py +1 -1
  5. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/agents/correction_agent.py +38 -2
  6. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/app.py +58 -0
  7. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/calibration.py +45 -13
  8. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/navigation_engine.py +3 -2
  9. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/tool_registry.py +84 -1
  10. {aether_robotics-3.4.0 → aether_robotics-3.4.2/aether_robotics.egg-info}/PKG-INFO +2 -2
  11. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/pyproject.toml +1 -1
  12. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/tests/test_calibration.py +200 -0
  13. aether_robotics-3.4.0/VERSION +0 -1
  14. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/LICENSE +0 -0
  15. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/MANIFEST.in +0 -0
  16. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/__main__.py +0 -0
  17. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/actions/__init__.py +0 -0
  18. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/actions/abstract_actions.py +0 -0
  19. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/adapters/__init__.py +0 -0
  20. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/adapters/base_adapter.py +0 -0
  21. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/adapters/drone_adapter.py +0 -0
  22. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/adapters/rover_adapter.py +0 -0
  23. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/adapters/universal_adapter.py +0 -0
  24. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/agents/__init__.py +0 -0
  25. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/agents/adaptation_agent.py +0 -0
  26. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/agents/camera_agent.py +0 -0
  27. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/agents/execution_agent.py +0 -0
  28. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/agents/fault_agent.py +0 -0
  29. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/agents/memory_agent.py +0 -0
  30. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/agents/movement_agent.py +0 -0
  31. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/agents/navigation_agent.py +0 -0
  32. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/agents/perception_agent.py +0 -0
  33. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/agents/planner_agent.py +0 -0
  34. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/agents/power_agent.py +0 -0
  35. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/agents/task_manager.py +0 -0
  36. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/agents/thermal_agent.py +0 -0
  37. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/capabilities/__init__.py +0 -0
  38. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/capabilities/capability_loader.py +0 -0
  39. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/__init__.py +0 -0
  40. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/auto_installer.py +0 -0
  41. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/auto_updater.py +0 -0
  42. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/banner.py +0 -0
  43. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/executor.py +0 -0
  44. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/feedback.py +0 -0
  45. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/goal_parser.py +0 -0
  46. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/llm_planner.py +0 -0
  47. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/mapper.py +0 -0
  48. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/memory.py +0 -0
  49. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/message_bus.py +0 -0
  50. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/metrics.py +0 -0
  51. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/planner.py +0 -0
  52. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/task_scheduler.py +0 -0
  53. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/tool_builder.py +0 -0
  54. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/tool_discovery.py +0 -0
  55. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/core/visualizer.py +0 -0
  56. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/faults/__init__.py +0 -0
  57. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/faults/fault_detector.py +0 -0
  58. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/faults/fault_injector.py +0 -0
  59. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/simulation/__init__.py +0 -0
  60. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/simulation/environment.py +0 -0
  61. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/simulation/real_perception.py +0 -0
  62. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether/simulation/scenarios.py +0 -0
  63. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether_robotics.egg-info/SOURCES.txt +0 -0
  64. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether_robotics.egg-info/dependency_links.txt +0 -0
  65. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether_robotics.egg-info/entry_points.txt +0 -0
  66. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether_robotics.egg-info/requires.txt +0 -0
  67. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/aether_robotics.egg-info/top_level.txt +0 -0
  68. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/configs/calibrated_camera_only_20260331_121709.json +0 -0
  69. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/configs/calibrated_camera_only_20260331_122446.json +0 -0
  70. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/configs/calibrated_camera_only_20260401_094544.json +0 -0
  71. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/configs/calibrated_camera_only_20260401_094554.json +0 -0
  72. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/configs/drone_v1.json +0 -0
  73. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/configs/rover_v1.json +0 -0
  74. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/context/aether_definitions.txt +0 -0
  75. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/install.sh +0 -0
  76. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/main.py +0 -0
  77. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/requirements.txt +0 -0
  78. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/setup.cfg +0 -0
  79. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/tests/test_auto_updater.py +0 -0
  80. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/tests/test_calibration_unit.py +0 -0
  81. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/tests/test_chaos.py +0 -0
  82. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/tests/test_entrypoint.py +0 -0
  83. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/tests/test_mapper.py +0 -0
  84. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/tests/test_navigation_engine.py +0 -0
  85. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/tests/test_persistent_memory.py +0 -0
  86. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/tests/test_security.py +0 -0
  87. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/tests/test_task_scheduler.py +0 -0
  88. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/tests/test_tool_builder.py +0 -0
  89. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/tests/test_tool_discovery.py +0 -0
  90. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/tests/test_yolo_integration.py +0 -0
  91. {aether_robotics-3.4.0 → aether_robotics-3.4.2}/weights/fault_agent.npy +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aether-robotics
3
- Version: 3.4.0
3
+ Version: 3.4.2
4
4
  Summary: Autonomous multi-agent robotics system with DRL-First Hybrid FDIR
5
5
  Author: Chahel Paatur
6
6
  License: MIT
@@ -45,7 +45,7 @@ Dynamic: license-file
45
45
  <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License: MIT">
46
46
  <img src="https://img.shields.io/github/stars/ChahelPaatur/AETHER?style=flat-square" alt="GitHub Stars">
47
47
  <img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Raspberry%20Pi%20%7C%20Jetson-lightgrey?style=flat-square" alt="Platforms">
48
- <img src="https://img.shields.io/badge/version-3.3.3-red?style=flat-square" alt="v3.3.3">
48
+ <img src="https://img.shields.io/badge/version-3.4.0-red?style=flat-square" alt="v3.4.0">
49
49
  </p>
50
50
 
51
51
  ```
@@ -4,7 +4,7 @@
4
4
  <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License: MIT">
5
5
  <img src="https://img.shields.io/github/stars/ChahelPaatur/AETHER?style=flat-square" alt="GitHub Stars">
6
6
  <img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Raspberry%20Pi%20%7C%20Jetson-lightgrey?style=flat-square" alt="Platforms">
7
- <img src="https://img.shields.io/badge/version-3.3.3-red?style=flat-square" alt="v3.3.3">
7
+ <img src="https://img.shields.io/badge/version-3.4.0-red?style=flat-square" alt="v3.4.0">
8
8
  </p>
9
9
 
10
10
  ```
@@ -0,0 +1 @@
1
+ 3.4.2
@@ -1,2 +1,2 @@
1
- __version__ = "3.4.0"
1
+ __version__ = "3.4.2"
2
2
  __author__ = "Chahel Paatur"
@@ -28,6 +28,15 @@ _SYSTEM_PROMPT = (
28
28
  "do NOT suggest pip install. Instead, suggest an alternative tool that "
29
29
  "provides similar functionality (e.g. capture_image uses picamera2 as "
30
30
  "fallback, measure_brightness and detect_motion work without cv2).\n\n"
31
+ "- For continuous-rotation servo corrections (servo_type='continuous'), "
32
+ "angle is speed in the range -100..+100: 100 = full forward / clockwise "
33
+ "(180° PWM pulse), -100 = full reverse / counter-clockwise (0° PWM pulse), "
34
+ "0 = stop (90° PWM pulse). When the original intent was motion — 'go', "
35
+ "'move', 'forward', 'start', 'clockwise', 'drive' — use angle=100, "
36
+ "NEVER angle=90. angle=90 on a continuous servo means STOP.\n"
37
+ "- When a calibrated action tool fails and you fall back to servo_set or "
38
+ "motor_forward/motor_turn, use the BCM pin from 'Original tool metadata' "
39
+ "provided in the request, NOT the default pin=18.\n\n"
31
40
  "Return valid JSON only — no markdown, no explanation, no code fences:\n"
32
41
  "{\n"
33
42
  ' "success": true/false,\n'
@@ -115,6 +124,10 @@ class CorrectionAgent:
115
124
  expected = step.get("expected_output", "")
116
125
  params = step.get("params", {})
117
126
 
127
+ # Extract physical_map metadata from the registered tool (Bug B)
128
+ tool_obj = registry.get(tool_name)
129
+ tool_metadata = getattr(tool_obj, "metadata", {}) or {}
130
+
118
131
  # If no expected_output defined, skip LLM evaluation —
119
132
  # just use the result's own success flag
120
133
  if not expected:
@@ -153,7 +166,8 @@ class CorrectionAgent:
153
166
  # No fallback worked — still try the LLM loop below
154
167
 
155
168
  while attempt < MAX_RETRIES:
156
- verdict = self._call_llm(tool_name, params, expected, actual)
169
+ verdict = self._call_llm(
170
+ tool_name, params, expected, actual, tool_metadata)
157
171
  if verdict is None:
158
172
  # API failed — trust the result as-is
159
173
  break
@@ -175,6 +189,22 @@ class CorrectionAgent:
175
189
  corr_tool = verdict.get("correction_tool")
176
190
  corr_inputs = verdict.get("correction_inputs") or {}
177
191
 
192
+ # Belt-and-suspenders: when falling back to a servo/motor tool,
193
+ # enforce the BCM pin from the original tool's metadata regardless
194
+ # of what the LLM proposed — deterministic, not probabilistic.
195
+ _PIN_FALLBACK_TOOLS = {"servo_set", "motor_forward", "motor_turn"}
196
+ if tool_metadata and corr_tool in _PIN_FALLBACK_TOOLS:
197
+ meta_bcm = tool_metadata.get("bcm")
198
+ if meta_bcm is not None:
199
+ corr_inputs = dict(corr_inputs)
200
+ pin_keys = (["pin"] if corr_tool == "servo_set"
201
+ else ["left_pin", "right_pin"])
202
+ for pk in pin_keys:
203
+ if corr_inputs.get(pk) != meta_bcm:
204
+ corr_inputs[pk] = meta_bcm
205
+ print(f" [CORRECT] Overrode {pk} → "
206
+ f"BCM {meta_bcm} from original tool metadata")
207
+
178
208
  correction_entry = {
179
209
  "attempt": attempt,
180
210
  "issue": issue,
@@ -227,11 +257,17 @@ class CorrectionAgent:
227
257
  }
228
258
 
229
259
  def _call_llm(self, tool: str, params: Dict, expected: str,
230
- actual: str) -> Optional[Dict]:
260
+ actual: str,
261
+ tool_metadata: Optional[Dict] = None) -> Optional[Dict]:
231
262
  """Call the Anthropic API to evaluate step success."""
263
+ meta_line = ""
264
+ if tool_metadata:
265
+ meta_line = (f"Original tool metadata: "
266
+ f"{json.dumps(tool_metadata)}\n")
232
267
  user_msg = (
233
268
  f"Tool: {tool}\n"
234
269
  f"Inputs: {json.dumps(params, default=str)[:500]}\n"
270
+ f"{meta_line}"
235
271
  f"Expected: {expected}\n"
236
272
  f"Got: {actual}\n\n"
237
273
  f"Available tools for corrections: {', '.join(self._available_tools)}\n\n"
@@ -9,10 +9,12 @@ All paths to data files (configs/, context/, weights/) are resolved
9
9
  relative to the *project root*, which is auto-detected at import time.
10
10
  """
11
11
  import argparse
12
+ import atexit
12
13
  import json
13
14
  import logging
14
15
  import os
15
16
  import re
17
+ import signal as _signal
16
18
  import sys
17
19
  import time
18
20
 
@@ -88,6 +90,60 @@ def silence_http_logging() -> None:
88
90
  logging.getLogger("httpx").setLevel(logging.WARNING)
89
91
 
90
92
 
93
+ # ── GPIO cleanup on exit ──────────────────────────────────────────────
94
+
95
+ _gpio_cleanup_registered = False
96
+ _gpio_nav_refs: list = [] # holds nav_engine references for PWM draining
97
+
98
+
99
+ def _register_gpio_cleanup(nav_engine=None) -> None:
100
+ """Register atexit + SIGINT/SIGTERM handlers that drain PWMs and call
101
+ GPIO.cleanup(). Safe to call multiple times — registers only once.
102
+ """
103
+ global _gpio_cleanup_registered
104
+ if _gpio_cleanup_registered:
105
+ return
106
+ _gpio_cleanup_registered = True
107
+
108
+ if nav_engine is not None:
109
+ _gpio_nav_refs.append(nav_engine)
110
+
111
+ def _do_cleanup():
112
+ # 1. Stop live PWM instances held by NavigationEngine._motors
113
+ for nav in _gpio_nav_refs:
114
+ motors = getattr(nav, "_motors", None)
115
+ if motors is not None:
116
+ pwm_map = getattr(motors, "_pwm_instances", {})
117
+ n = len(pwm_map)
118
+ for pwm_obj in list(pwm_map.values()):
119
+ try:
120
+ pwm_obj.stop()
121
+ except Exception:
122
+ pass
123
+ pwm_map.clear()
124
+ if n:
125
+ print(f"\n[GPIO] Stopped {n} active PWM instance(s)")
126
+ # 2. Full GPIO cleanup
127
+ try:
128
+ import RPi.GPIO as GPIO
129
+ GPIO.cleanup()
130
+ print("\n[GPIO] Released all pins on exit")
131
+ except Exception:
132
+ pass
133
+
134
+ atexit.register(_do_cleanup)
135
+
136
+ def _sig_handler(sig, frame):
137
+ _do_cleanup()
138
+ sys.exit(0)
139
+
140
+ try:
141
+ _signal.signal(_signal.SIGINT, _sig_handler)
142
+ _signal.signal(_signal.SIGTERM, _sig_handler)
143
+ except Exception:
144
+ pass # not on main thread in some test runners
145
+
146
+
91
147
  def run_sim(args) -> None:
92
148
  """Existing simulation mode — unchanged."""
93
149
  from aether.simulation.environment import SimulationEnvironment
@@ -482,6 +538,7 @@ def run_agent(args) -> None:
482
538
  _print_activity("OLED", f"startup animation failed: {e}")
483
539
 
484
540
  nav_engine = NavigationEngine(discovery.manifest, tools=built_tools)
541
+ _register_gpio_cleanup(nav_engine) # drain PWMs + GPIO.cleanup on exit
485
542
  nav_actions = nav_engine.available_actions()
486
543
  _print_activity("NAV", f"Level {nav_engine.level} ({nav_engine.level_name}) — "
487
544
  f"{len(nav_actions)} navigation actions")
@@ -1720,6 +1777,7 @@ def main():
1720
1777
  discovery.discover()
1721
1778
  discovery.print_summary()
1722
1779
 
1780
+ _register_gpio_cleanup() # GPIO.cleanup on exit during calibration
1723
1781
  auto_mode = getattr(args, "auto_calibrate", False)
1724
1782
  wizard = CalibrationWizard(
1725
1783
  discovery.manifest,
@@ -131,6 +131,14 @@ _CATEGORY_ACTION_PREFIX: Dict[int, str] = {
131
131
  6: "motor",
132
132
  }
133
133
 
134
+ # Stopwords stripped before slugifying descriptions (Bug 4)
135
+ _SLUG_STOPWORDS = frozenset({
136
+ "the", "then", "and", "a", "an", "that", "moved", "move", "moves",
137
+ "is", "was", "it", "of", "to", "in", "at", "by", "for", "from",
138
+ "with", "or", "but", "on", "its", "when", "while", "also", "did",
139
+ "went", "going", "started", "which",
140
+ })
141
+
134
142
 
135
143
  # ── Module-level helpers ──────────────────────────────────────────────
136
144
 
@@ -145,10 +153,13 @@ def _validate_robot_name(name: str) -> Optional[str]:
145
153
 
146
154
 
147
155
  def _derive_action_label(category: int, description: str) -> str:
148
- """Slug-ify description and prepend category prefix."""
149
- slug = _re.sub(r"[^a-zA-Z0-9]+", "_", description.lower()).strip("_")
156
+ """Slug-ify description, strip stopwords, cap at 32 chars."""
157
+ words = _re.sub(r"[^a-zA-Z0-9\s]+", " ", description.lower()).split()
158
+ words = [w for w in words if w not in _SLUG_STOPWORDS]
159
+ slug = "_".join(words)
150
160
  prefix = _CATEGORY_ACTION_PREFIX.get(category, "action")
151
- return f"{prefix}_{slug}"
161
+ label = f"{prefix}_{slug}"
162
+ return label[:32].rstrip("_")
152
163
 
153
164
 
154
165
  def _input_int(prompt: str, valid_range: Optional[range] = None) -> int:
@@ -477,7 +488,7 @@ class CalibrationWizard:
477
488
  try:
478
489
  desc = input(
479
490
  " Describe what moved "
480
- "(e.g. 'front-left wheel forward'): "
491
+ "(e.g. 'left wheel' or 'gripper' — keep it short): "
481
492
  ).strip()
482
493
  except EOFError:
483
494
  desc = ""
@@ -511,12 +522,18 @@ class CalibrationWizard:
511
522
  print(f" Mapped: BCM {bcm_pin} → {action_label} "
512
523
  f"({_CATEGORY_LABELS[choice]})")
513
524
 
514
- try:
515
- cont = input(
516
- " Continue to next pin? [Y/n/s=stop]: "
517
- ).strip().lower()
518
- except EOFError:
519
- cont = ""
525
+ cont = ""
526
+ while True:
527
+ try:
528
+ cont = input(
529
+ " Continue to next pin? [Y/n/s=stop]: "
530
+ ).strip().lower()
531
+ except EOFError:
532
+ cont = ""
533
+ if cont in ("", "y", "yes", "n", "no", "s", "stop"):
534
+ break
535
+ print(" Please enter Y to continue, N to stop here, "
536
+ "or S to stop calibration.")
520
537
  if cont in ("n", "no", "s", "stop"):
521
538
  break
522
539
 
@@ -1031,9 +1048,15 @@ class CalibrationWizard:
1031
1048
 
1032
1049
  def _pulse_servo_pin(self, bcm_pin: int,
1033
1050
  servo_type: str = "continuous") -> bool:
1034
- """Pulse a servo pin. Returns True if pulse was sent."""
1051
+ """Pulse a servo pin. Returns True if pulse was sent.
1052
+
1053
+ cleanup() is in a finally block so GPIO releases even on KeyboardInterrupt.
1054
+ """
1055
+ pwm = None
1056
+ gpio_mod = None
1035
1057
  try:
1036
1058
  import RPi.GPIO as GPIO
1059
+ gpio_mod = GPIO
1037
1060
  GPIO.setmode(GPIO.BCM)
1038
1061
  GPIO.setwarnings(False)
1039
1062
  GPIO.setup(bcm_pin, GPIO.OUT)
@@ -1055,11 +1078,20 @@ class CalibrationWizard:
1055
1078
  time.sleep(0.3)
1056
1079
  pwm.ChangeDutyCycle(7.0) # back to 90°
1057
1080
  time.sleep(0.15)
1058
- pwm.stop()
1059
- GPIO.cleanup(bcm_pin)
1060
1081
  return True
1061
1082
  except (ImportError, RuntimeError, Exception):
1062
1083
  return False
1084
+ finally:
1085
+ if pwm is not None:
1086
+ try:
1087
+ pwm.stop()
1088
+ except Exception:
1089
+ pass
1090
+ if gpio_mod is not None:
1091
+ try:
1092
+ gpio_mod.cleanup(bcm_pin)
1093
+ except Exception:
1094
+ pass
1063
1095
 
1064
1096
  def _test_channel(self, channel: str, power: float = 0.1,
1065
1097
  duration: float = 0.5,
@@ -872,8 +872,9 @@ class _MotorNav:
872
872
  duty = 2.0 + (mapped / 180.0) * 10.0
873
873
  result = self.pwm_set(pin, 50, duty)
874
874
  if result.get("success"):
875
- result["data"]["speed"] = speed
876
- result["data"]["servo_type"] = "continuous"
875
+ # pwm_set returns a flat dict — annotate directly, no "data" key
876
+ result["speed"] = speed
877
+ result["servo_type"] = "continuous"
877
878
  return result
878
879
 
879
880
  # Positional servo (default): 2-12% duty cycle at 50Hz for 0-180°
@@ -11,6 +11,33 @@ from dataclasses import dataclass, field
11
11
  from pathlib import Path
12
12
  from typing import Dict, List, Optional, Any
13
13
 
14
+ # ── Continuous-servo direction helpers (used by physical_map tool closures) ──
15
+
16
+ _CONTINUOUS_STOP_TERMS = frozenset({
17
+ "stop", "halt", "off", "neutral", "brake", "idle", "0",
18
+ })
19
+ _CONTINUOUS_REVERSE_TERMS = frozenset({
20
+ "reverse", "backward", "back", "counter", "ccw",
21
+ "counterclockwise", "negative",
22
+ })
23
+
24
+
25
+ def _infer_continuous_speed(val) -> float:
26
+ """Map a direction hint to continuous-servo speed (-100..+100).
27
+
28
+ 0.0 = stop (90° PWM pulse)
29
+ 100.0 = full forward / clockwise (180° PWM pulse)
30
+ -100.0 = full reverse / counter-clockwise (0° PWM pulse)
31
+ """
32
+ if isinstance(val, (int, float)):
33
+ return float(max(-100.0, min(100.0, val)))
34
+ s = str(val).lower()
35
+ if any(t in s for t in _CONTINUOUS_STOP_TERMS):
36
+ return 0.0
37
+ if any(t in s for t in _CONTINUOUS_REVERSE_TERMS):
38
+ return -100.0
39
+ return 100.0 # forward / clockwise / go / start
40
+
14
41
 
15
42
  @dataclass
16
43
  class ToolResult:
@@ -25,6 +52,7 @@ class ToolResult:
25
52
  class BaseTool(ABC):
26
53
  """Abstract base matching AbstractAction interface."""
27
54
  name: str = ""
55
+ metadata: Dict = {} # overridden by DynamicTool for physical_map entries
28
56
 
29
57
  @abstractmethod
30
58
  def execute(self, params: Dict) -> ToolResult:
@@ -751,10 +779,12 @@ class DynamicTool(BaseTool):
751
779
  and NavigationEngine actions into the BaseTool/ToolResult interface.
752
780
  """
753
781
 
754
- def __init__(self, name: str, fn, desc: str = ""):
782
+ def __init__(self, name: str, fn, desc: str = "",
783
+ metadata: Optional[Dict] = None):
755
784
  self.name = name
756
785
  self._fn = fn
757
786
  self._desc = desc or f"Dynamic tool: {name}"
787
+ self.metadata: Dict = metadata or {}
758
788
 
759
789
  def description(self) -> str:
760
790
  return self._desc
@@ -973,6 +1003,59 @@ def register_built_tools(registry: "ToolRegistry",
973
1003
  f"Navigation action: {action} (level {nav_engine.level})"))
974
1004
  count += 1
975
1005
 
1006
+ # -- Physical map tools ---------------------------------------------------
1007
+ # Register one real callable tool per physical_map entry. The LLM emits
1008
+ # the action label; this makes it actually executable against the right pin.
1009
+ physical_map = (manifest or {}).get("physical_map", {})
1010
+ if physical_map and nav_engine is not None:
1011
+ for bucket in ("wheels", "arms", "grippers", "servos", "dc_motors"):
1012
+ for entry in physical_map.get(bucket, []):
1013
+ label = entry.get("action")
1014
+ bcm = entry.get("bcm")
1015
+ ptype = entry.get("pwm_type", "servo_positional")
1016
+ desc = entry.get("description", label or "")
1017
+
1018
+ if not label or bcm is None:
1019
+ continue
1020
+ if registry.get(label) is not None:
1021
+ continue # already registered (e.g. from prior iteration)
1022
+
1023
+ def _make_physical_fn(pin, pwm_type):
1024
+ def _fn(**kwargs):
1025
+ if pwm_type == "servo_continuous":
1026
+ # Accept direction/speed/angle — any key the LLM uses
1027
+ hint = kwargs.get(
1028
+ "direction",
1029
+ kwargs.get("speed",
1030
+ kwargs.get("angle", "forward")))
1031
+ speed = _infer_continuous_speed(hint)
1032
+ return registry.execute(
1033
+ "servo_set",
1034
+ {"pin": pin, "angle": speed,
1035
+ "servo_type": "continuous"})
1036
+ elif pwm_type == "servo_positional":
1037
+ angle = float(kwargs.get("angle", 90))
1038
+ return registry.execute(
1039
+ "servo_set",
1040
+ {"pin": pin, "angle": angle,
1041
+ "servo_type": "positional"})
1042
+ else: # dc
1043
+ spd = float(kwargs.get("speed", 50))
1044
+ return registry.execute(
1045
+ "motor_forward",
1046
+ {"left_pin": pin, "right_pin": pin,
1047
+ "speed": spd})
1048
+ return _fn
1049
+
1050
+ registry.register(DynamicTool(
1051
+ label,
1052
+ _make_physical_fn(bcm, ptype),
1053
+ f"{desc} (BCM {bcm}, {ptype})",
1054
+ metadata={"bcm": bcm, "pwm_type": ptype,
1055
+ "description": desc},
1056
+ ))
1057
+ count += 1
1058
+
976
1059
  print(f" [ToolRegistry] Registered {count} tools from ToolBuilder")
977
1060
  return count
978
1061
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aether-robotics
3
- Version: 3.4.0
3
+ Version: 3.4.2
4
4
  Summary: Autonomous multi-agent robotics system with DRL-First Hybrid FDIR
5
5
  Author: Chahel Paatur
6
6
  License: MIT
@@ -45,7 +45,7 @@ Dynamic: license-file
45
45
  <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License: MIT">
46
46
  <img src="https://img.shields.io/github/stars/ChahelPaatur/AETHER?style=flat-square" alt="GitHub Stars">
47
47
  <img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Raspberry%20Pi%20%7C%20Jetson-lightgrey?style=flat-square" alt="Platforms">
48
- <img src="https://img.shields.io/badge/version-3.3.3-red?style=flat-square" alt="v3.3.3">
48
+ <img src="https://img.shields.io/badge/version-3.4.0-red?style=flat-square" alt="v3.4.0">
49
49
  </p>
50
50
 
51
51
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aether-robotics"
7
- version = "3.4.0"
7
+ version = "3.4.2"
8
8
  description = "Autonomous multi-agent robotics system with DRL-First Hybrid FDIR"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -13,6 +13,7 @@ from aether.core.calibration import (
13
13
  CalibrationWizard,
14
14
  load_calibration,
15
15
  _expected_direction,
16
+ _derive_action_label,
16
17
  GROUND_VEHICLE,
17
18
  AERIAL_VEHICLE,
18
19
  ROBOTIC_ARM,
@@ -415,3 +416,202 @@ class TestFullAutoRun:
415
416
 
416
417
  assert profile["calibrated"] is True
417
418
  assert profile["robot_type"] == AERIAL_VEHICLE
419
+
420
+
421
+ class TestActionLabelSlugifier:
422
+ """Bug 4: _derive_action_label stopwords + 32-char cap."""
423
+
424
+ def test_strips_stopwords(self):
425
+ label = _derive_action_label(1, "the left wheel moved forward")
426
+ assert "the" not in label
427
+ assert "moved" not in label
428
+ assert "left" in label
429
+ assert "forward" in label
430
+
431
+ def test_caps_at_32_chars(self):
432
+ long_desc = "left wheel clockwise then counter clockwise slowly"
433
+ label = _derive_action_label(5, long_desc)
434
+ assert len(label) <= 32
435
+
436
+ def test_correct_prefix(self):
437
+ assert _derive_action_label(1, "left wheel").startswith("drive_")
438
+ assert _derive_action_label(3, "gripper").startswith("gripper_")
439
+ assert _derive_action_label(5, "left wheel").startswith("servo_cont_")
440
+ assert _derive_action_label(6, "motor A").startswith("motor_")
441
+
442
+ def test_no_trailing_underscore(self):
443
+ label = _derive_action_label(1, "the the the")
444
+ assert not label.endswith("_")
445
+
446
+
447
+ class TestPhysicalMapToolRegistration:
448
+ """Bug 1: physical_map entries become callable tools in ToolRegistry."""
449
+
450
+ _PHYSICAL_MAP = {
451
+ "wheels": [{"bcm": 17, "description": "left wheel forward",
452
+ "action": "drive_left_wheel_forward",
453
+ "pwm_type": "servo_positional"}],
454
+ "servos": [{"bcm": 22, "description": "left wheel",
455
+ "action": "servo_cont_left_wheel",
456
+ "pwm_type": "servo_continuous"}],
457
+ }
458
+
459
+ def test_physical_map_labels_registered(self):
460
+ """register_built_tools adds a DynamicTool for each physical_map entry."""
461
+ from aether.core.tool_registry import (
462
+ ToolRegistry, register_built_tools, _infer_continuous_speed,
463
+ )
464
+
465
+ registry = ToolRegistry()
466
+ manifest = {"physical_map": self._PHYSICAL_MAP}
467
+
468
+ # nav_engine stub: just needs to not be None and have available_actions()
469
+ class _FakeNav:
470
+ level = 2
471
+ def available_actions(self): return []
472
+ def execute(self, act, params): return {"success": True}
473
+
474
+ register_built_tools(registry, {}, _FakeNav(), manifest=manifest)
475
+
476
+ assert registry.get("drive_left_wheel_forward") is not None
477
+ assert registry.get("servo_cont_left_wheel") is not None
478
+
479
+ def test_infer_continuous_speed_forward(self):
480
+ from aether.core.tool_registry import _infer_continuous_speed
481
+ assert _infer_continuous_speed("forward") == 100.0
482
+ assert _infer_continuous_speed("go") == 100.0
483
+ assert _infer_continuous_speed("clockwise") == 100.0
484
+
485
+ def test_infer_continuous_speed_stop(self):
486
+ from aether.core.tool_registry import _infer_continuous_speed
487
+ assert _infer_continuous_speed("stop") == 0.0
488
+ assert _infer_continuous_speed("halt") == 0.0
489
+ assert _infer_continuous_speed(0) == 0.0
490
+
491
+ def test_infer_continuous_speed_reverse(self):
492
+ from aether.core.tool_registry import _infer_continuous_speed
493
+ assert _infer_continuous_speed("reverse") == -100.0
494
+ assert _infer_continuous_speed("backward") == -100.0
495
+ assert _infer_continuous_speed(-50) == -50.0
496
+
497
+ def test_physical_map_tool_has_metadata(self):
498
+ """Bug B: registered physical_map DynamicTool carries bcm + pwm_type metadata."""
499
+ from aether.core.tool_registry import ToolRegistry, register_built_tools
500
+
501
+ registry = ToolRegistry()
502
+ manifest = {"physical_map": self._PHYSICAL_MAP}
503
+
504
+ class _FakeNav:
505
+ level = 2
506
+ def available_actions(self): return []
507
+ def execute(self, act, params): return {"success": True}
508
+
509
+ register_built_tools(registry, {}, _FakeNav(), manifest=manifest)
510
+
511
+ tool = registry.get("drive_left_wheel_forward")
512
+ assert tool is not None
513
+ assert tool.metadata.get("bcm") == 17
514
+ assert tool.metadata.get("pwm_type") == "servo_positional"
515
+
516
+ tool_cont = registry.get("servo_cont_left_wheel")
517
+ assert tool_cont is not None
518
+ assert tool_cont.metadata.get("bcm") == 22
519
+ assert tool_cont.metadata.get("pwm_type") == "servo_continuous"
520
+
521
+
522
+ class TestBugFixes342:
523
+ """3.4.2 regression tests: servo_set flat dict, correction pin override,
524
+ pulse_servo_pin cleanup on interrupt."""
525
+
526
+ def test_servo_set_continuous_no_data_keyerror(self):
527
+ """Bug A: servo_set continuous result must not raise KeyError on 'data'."""
528
+ # Simulate the _MotorNav.servo_set logic without real GPIO
529
+ # pwm_set returns flat dict — no "data" key
530
+ result = {"pin": 17, "frequency": 50, "duty_cycle": 12.0,
531
+ "success": True, "error": ""}
532
+ # Apply the fixed logic
533
+ if result.get("success"):
534
+ result["speed"] = 100.0
535
+ result["servo_type"] = "continuous"
536
+ assert "speed" in result
537
+ assert result["servo_type"] == "continuous"
538
+ assert "data" not in result # old bug: this would have been needed
539
+
540
+ def test_correction_agent_pin_override(self):
541
+ """Bug B belt-and-suspenders: CorrectionAgent overrides wrong pin from LLM."""
542
+ # Simulate the override logic with tool_metadata bcm=17 and LLM proposing pin=18
543
+ tool_metadata = {"bcm": 17, "pwm_type": "servo_continuous"}
544
+ corr_tool = "servo_set"
545
+ corr_inputs = {"pin": 18, "angle": 100, "servo_type": "continuous"}
546
+
547
+ _PIN_FALLBACK_TOOLS = {"servo_set", "motor_forward", "motor_turn"}
548
+ if tool_metadata and corr_tool in _PIN_FALLBACK_TOOLS:
549
+ meta_bcm = tool_metadata.get("bcm")
550
+ if meta_bcm is not None:
551
+ corr_inputs = dict(corr_inputs)
552
+ pin_keys = (["pin"] if corr_tool == "servo_set"
553
+ else ["left_pin", "right_pin"])
554
+ for pk in pin_keys:
555
+ if corr_inputs.get(pk) != meta_bcm:
556
+ corr_inputs[pk] = meta_bcm
557
+
558
+ assert corr_inputs["pin"] == 17 # overridden from 18
559
+
560
+ def test_correction_agent_pin_override_missing_pin(self):
561
+ """Bug B: pin added when absent from correction inputs."""
562
+ tool_metadata = {"bcm": 22, "pwm_type": "servo_positional"}
563
+ corr_tool = "servo_set"
564
+ corr_inputs = {"angle": 90, "servo_type": "positional"} # no pin
565
+
566
+ _PIN_FALLBACK_TOOLS = {"servo_set", "motor_forward", "motor_turn"}
567
+ if tool_metadata and corr_tool in _PIN_FALLBACK_TOOLS:
568
+ meta_bcm = tool_metadata.get("bcm")
569
+ if meta_bcm is not None:
570
+ corr_inputs = dict(corr_inputs)
571
+ pin_keys = ["pin"]
572
+ for pk in pin_keys:
573
+ if corr_inputs.get(pk) != meta_bcm:
574
+ corr_inputs[pk] = meta_bcm
575
+
576
+ assert corr_inputs["pin"] == 22
577
+
578
+ def test_pulse_servo_pin_finally_on_simulated_interrupt(self):
579
+ """Bug C: _pulse_servo_pin cleanup runs even when GPIO raises."""
580
+ cleanup_called = []
581
+
582
+ class _FakeGPIO:
583
+ BCM = 11
584
+ OUT = 0
585
+ @staticmethod
586
+ def setmode(m): pass
587
+ @staticmethod
588
+ def setwarnings(w): pass
589
+ @staticmethod
590
+ def setup(p, m): pass
591
+ class PWM:
592
+ def __init__(self, pin, freq): pass
593
+ def start(self, dc): pass
594
+ def ChangeDutyCycle(self, dc):
595
+ raise RuntimeError("simulated mid-pulse error")
596
+ def stop(self): cleanup_called.append("pwm.stop")
597
+ @staticmethod
598
+ def cleanup(pin): cleanup_called.append(f"cleanup:{pin}")
599
+
600
+ import sys
601
+ sys.modules["RPi"] = type(sys)("RPi")
602
+ sys.modules["RPi.GPIO"] = _FakeGPIO
603
+ try:
604
+ wiz = CalibrationWizard(
605
+ {"hardware": {"gpio": {"available": False},
606
+ "mavlink": {"available": False}}},
607
+ configs_dir="/tmp",
608
+ auto_mode=True,
609
+ )
610
+ result = wiz._pulse_servo_pin(17, "positional")
611
+ finally:
612
+ del sys.modules["RPi.GPIO"]
613
+ del sys.modules["RPi"]
614
+
615
+ # Whether the pulse succeeded or not, cleanup must have been attempted
616
+ assert any("cleanup" in str(c) or "stop" in str(c)
617
+ for c in cleanup_called)
@@ -1 +0,0 @@
1
- 3.3.9
File without changes
File without changes