aether-robotics 3.7.2__tar.gz → 3.7.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 (106) hide show
  1. {aether_robotics-3.7.2/aether_robotics.egg-info → aether_robotics-3.7.4}/PKG-INFO +2 -2
  2. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/README.md +1 -1
  3. aether_robotics-3.7.4/VERSION +1 -0
  4. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/__init__.py +1 -1
  5. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/adapters/__init__.py +45 -0
  6. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/app.py +40 -3
  7. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/tool_discovery.py +50 -6
  8. {aether_robotics-3.7.2 → aether_robotics-3.7.4/aether_robotics.egg-info}/PKG-INFO +2 -2
  9. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether_robotics.egg-info/SOURCES.txt +1 -0
  10. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/pyproject.toml +1 -1
  11. aether_robotics-3.7.4/tests/test_boot_resolver_flow.py +125 -0
  12. aether_robotics-3.7.4/tests/test_tool_discovery.py +133 -0
  13. aether_robotics-3.7.2/VERSION +0 -1
  14. aether_robotics-3.7.2/tests/test_tool_discovery.py +0 -46
  15. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/LICENSE +0 -0
  16. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/MANIFEST.in +0 -0
  17. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/__main__.py +0 -0
  18. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/actions/__init__.py +0 -0
  19. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/actions/abstract_actions.py +0 -0
  20. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/adapters/_generator.py +0 -0
  21. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/adapters/adeept_hat_v3.py +0 -0
  22. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/adapters/base_adapter.py +0 -0
  23. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/adapters/drone_adapter.py +0 -0
  24. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/adapters/freenove_4wd.py +0 -0
  25. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/adapters/freenove_mecanum.py +0 -0
  26. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/adapters/l298n_direct_gpio.py +0 -0
  27. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/adapters/pca9685_generic.py +0 -0
  28. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/adapters/rover_adapter.py +0 -0
  29. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/adapters/sunfounder_picarx.py +0 -0
  30. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/adapters/universal_adapter.py +0 -0
  31. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/adapters/waveshare_motor_driver.py +0 -0
  32. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/agents/__init__.py +0 -0
  33. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/agents/adaptation_agent.py +0 -0
  34. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/agents/camera_agent.py +0 -0
  35. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/agents/correction_agent.py +0 -0
  36. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/agents/execution_agent.py +0 -0
  37. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/agents/fault_agent.py +0 -0
  38. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/agents/memory_agent.py +0 -0
  39. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/agents/movement_agent.py +0 -0
  40. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/agents/navigation_agent.py +0 -0
  41. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/agents/perception_agent.py +0 -0
  42. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/agents/planner_agent.py +0 -0
  43. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/agents/power_agent.py +0 -0
  44. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/agents/task_manager.py +0 -0
  45. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/agents/thermal_agent.py +0 -0
  46. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/capabilities/__init__.py +0 -0
  47. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/capabilities/capability_loader.py +0 -0
  48. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/__init__.py +0 -0
  49. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/auto_installer.py +0 -0
  50. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/auto_updater.py +0 -0
  51. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/banner.py +0 -0
  52. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/calibration.py +0 -0
  53. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/executor.py +0 -0
  54. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/feedback.py +0 -0
  55. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/genome.py +0 -0
  56. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/goal_parser.py +0 -0
  57. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/llm_planner.py +0 -0
  58. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/mapper.py +0 -0
  59. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/mavlink_integration.py +0 -0
  60. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/memory.py +0 -0
  61. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/message_bus.py +0 -0
  62. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/metrics.py +0 -0
  63. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/navigation_engine.py +0 -0
  64. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/planner.py +0 -0
  65. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/task_scheduler.py +0 -0
  66. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/tool_builder.py +0 -0
  67. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/tool_registry.py +0 -0
  68. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/core/visualizer.py +0 -0
  69. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/faults/__init__.py +0 -0
  70. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/faults/fault_detector.py +0 -0
  71. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/faults/fault_injector.py +0 -0
  72. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/simulation/__init__.py +0 -0
  73. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/simulation/environment.py +0 -0
  74. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/simulation/real_perception.py +0 -0
  75. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether/simulation/scenarios.py +0 -0
  76. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether_robotics.egg-info/dependency_links.txt +0 -0
  77. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether_robotics.egg-info/entry_points.txt +0 -0
  78. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether_robotics.egg-info/requires.txt +0 -0
  79. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/aether_robotics.egg-info/top_level.txt +0 -0
  80. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/configs/calibrated_camera_only_20260331_121709.json +0 -0
  81. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/configs/calibrated_camera_only_20260331_122446.json +0 -0
  82. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/configs/calibrated_camera_only_20260401_094544.json +0 -0
  83. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/configs/calibrated_camera_only_20260401_094554.json +0 -0
  84. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/configs/drone_v1.json +0 -0
  85. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/configs/rover_v1.json +0 -0
  86. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/context/aether_definitions.txt +0 -0
  87. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/install.sh +0 -0
  88. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/main.py +0 -0
  89. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/requirements.txt +0 -0
  90. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/setup.cfg +0 -0
  91. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/tests/test_auto_adapter.py +0 -0
  92. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/tests/test_auto_updater.py +0 -0
  93. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/tests/test_calibration.py +0 -0
  94. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/tests/test_calibration_unit.py +0 -0
  95. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/tests/test_chaos.py +0 -0
  96. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/tests/test_entrypoint.py +0 -0
  97. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/tests/test_genome.py +0 -0
  98. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/tests/test_mapper.py +0 -0
  99. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/tests/test_mavlink_integration.py +0 -0
  100. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/tests/test_navigation_engine.py +0 -0
  101. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/tests/test_persistent_memory.py +0 -0
  102. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/tests/test_security.py +0 -0
  103. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/tests/test_task_scheduler.py +0 -0
  104. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/tests/test_tool_builder.py +0 -0
  105. {aether_robotics-3.7.2 → aether_robotics-3.7.4}/tests/test_yolo_integration.py +0 -0
  106. {aether_robotics-3.7.2 → aether_robotics-3.7.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.7.2
3
+ Version: 3.7.4
4
4
  Summary: Autonomous multi-agent robotics system with DRL-First Hybrid FDIR
5
5
  Author: Chahel Paatur
6
6
  License: MIT
@@ -57,7 +57,7 @@ Dynamic: license-file
57
57
  ████████████████████████████████
58
58
  ```
59
59
 
60
- # AETHER v3.7.2 — Autonomous Robotics Operating System
60
+ # AETHER v3.7.4 — Autonomous Robotics Operating System
61
61
 
62
62
  > AETHER is the autonomous operating system for robots. Plug in and talk to your robot in plain English and ask it to do anything you want.
63
63
 
@@ -16,7 +16,7 @@
16
16
  ████████████████████████████████
17
17
  ```
18
18
 
19
- # AETHER v3.7.2 — Autonomous Robotics Operating System
19
+ # AETHER v3.7.4 — Autonomous Robotics Operating System
20
20
 
21
21
  > AETHER is the autonomous operating system for robots. Plug in and talk to your robot in plain English and ask it to do anything you want.
22
22
 
@@ -0,0 +1 @@
1
+ 3.7.4
@@ -1,2 +1,2 @@
1
- __version__ = "3.7.2"
1
+ __version__ = "3.7.4"
2
2
  __author__ = "Chahel Paatur"
@@ -136,6 +136,51 @@ def _build_vendor_lib_list() -> List[str]:
136
136
  return present
137
137
 
138
138
 
139
+ def detect_adapter(manifest: Dict, i2c_devices: Optional[List] = None,
140
+ include_user_dir: bool = True) -> Optional[str]:
141
+ """Detection-only: would any adapter claim this hardware? No registration.
142
+
143
+ Mirrors the match logic of load_tier1 (signature_matches on each Tier 1
144
+ adapter) plus the presence of any user-dir generated/guided adapter,
145
+ WITHOUT calling register() or touching the registry. Used at boot to
146
+ decide whether the interactive calibration wizard should run: the
147
+ resolver's verdict must be known BEFORE the (blocking) wizard fires,
148
+ otherwise the wizard preempts the resolver on a fresh profile.
149
+
150
+ Returns the adapter_id that would match, or None.
151
+ """
152
+ i2c_devices = i2c_devices or (
153
+ manifest.get("hardware", {}).get("i2c", {}).get("devices", []))
154
+ vendor_libs = _build_vendor_lib_list()
155
+
156
+ for path in sorted(glob.glob(os.path.join(_ADAPTER_DIR, "*.py"))):
157
+ basename = os.path.basename(path)
158
+ if basename.startswith("_") or basename in ("__init__.py",
159
+ "base_adapter.py", "rover_adapter.py",
160
+ "drone_adapter.py", "universal_adapter.py"):
161
+ continue
162
+ mod_name = f"aether.adapters._detect_{basename[:-3]}"
163
+ try:
164
+ spec = importlib.util.spec_from_file_location(mod_name, path)
165
+ mod = importlib.util.module_from_spec(spec)
166
+ spec.loader.exec_module(mod)
167
+ if mod.signature_matches(manifest, i2c_devices, vendor_libs):
168
+ return basename[:-3]
169
+ except Exception:
170
+ continue # a broken adapter never blocks detection
171
+
172
+ # A user-dir adapter (from a previous Tier 2/3 run) is loaded
173
+ # unconditionally by load_user_adapters, so its mere presence means the
174
+ # robot is already configured — count it as a match for the wizard gate.
175
+ if include_user_dir and os.path.isdir(_USER_ADAPTER_DIR):
176
+ for fname in sorted(os.listdir(_USER_ADAPTER_DIR)):
177
+ if fname.endswith(".py") and (
178
+ fname.startswith("generated_") or fname.startswith("guided_")):
179
+ return fname.replace(".py", "")
180
+
181
+ return None
182
+
183
+
139
184
  def load_tier1(manifest: Dict, registry, i2c_devices: Optional[List] = None) -> Optional[str]:
140
185
  """Iterate pre-built adapters. First match wins; returns adapter_id or None.
141
186
 
@@ -401,6 +401,22 @@ def _print_fault_alert(fault_type: str, details: str) -> None:
401
401
  print(f" !! FAULT: {fault_type} — {details}")
402
402
 
403
403
 
404
+ def _should_run_autocal_wizard(pre_adapter_id, calibration_profile,
405
+ needs_calibration: bool) -> bool:
406
+ """Auto-detect (non --calibrate) calibration-wizard gate.
407
+
408
+ The wizard fires only when NO adapter claims the hardware AND there is
409
+ no existing calibration profile AND the wizard reports new hardware.
410
+ An adapter match means the robot is already configured — the resolver
411
+ will build the genome from the adapter, so the wizard must be skipped.
412
+ """
413
+ if pre_adapter_id is not None:
414
+ return False
415
+ if calibration_profile is not None:
416
+ return False
417
+ return bool(needs_calibration)
418
+
419
+
404
420
  def _adapter_resolver(manifest, registry, genome,
405
421
  calibrating: bool = False,
406
422
  no_auto: bool = False):
@@ -592,16 +608,37 @@ def run_agent(args) -> None:
592
608
  auto_mode=auto_cal,
593
609
  force_remap=recalibrate,
594
610
  )
611
+
612
+ # Adapter-resolver verdict BEFORE the (blocking, interactive) wizard.
613
+ # If a pre-built or previously-generated adapter claims this hardware,
614
+ # the robot is already configured and the auto-detect wizard must be
615
+ # skipped — otherwise it preempts the resolver whenever no calibration
616
+ # profile is present. This is a side-effect-free detection pass; the
617
+ # full resolver (which registers the tools) still runs below at the
618
+ # Auto-Adapter step and prints the [ADAPTER] match line.
619
+ from aether.adapters import detect_adapter
620
+ no_auto = getattr(args, "no_auto_adapter", False)
621
+ pre_adapter_id = None
622
+ if not (calibrate or recalibrate):
623
+ pre_adapter_id = detect_adapter(
624
+ discovery.manifest, include_user_dir=(not no_auto))
625
+ if pre_adapter_id:
626
+ _print_activity("ADAPTER",
627
+ f"Detected '{pre_adapter_id}' — using adapter, skipping "
628
+ f"calibration wizard")
629
+
595
630
  if calibrate or recalibrate:
596
631
  if recalibrate or wizard.needs_calibration(force=True):
597
632
  calibration_profile = wizard.run()
598
633
  else:
599
- # Auto-detect: load existing or prompt if new hardware found
634
+ # Auto-detect: load existing profile, else run the wizard ONLY if no
635
+ # adapter matched and new hardware is present.
600
636
  calibration_profile = load_calibration()
601
637
  if calibration_profile:
602
638
  _print_activity("CAL", f"Loaded: {calibration_profile['robot_name']} "
603
639
  f"({calibration_profile['robot_type']})")
604
- elif wizard.needs_calibration():
640
+ elif _should_run_autocal_wizard(pre_adapter_id, calibration_profile,
641
+ wizard.needs_calibration()):
605
642
  _print_activity("CAL", "New hardware detected — running calibration wizard")
606
643
  calibration_profile = wizard.run()
607
644
 
@@ -710,7 +747,7 @@ def run_agent(args) -> None:
710
747
  _print_activity("MAVLINK", f"Registered {n_mav} MAVLink capability tools on {mav_port}")
711
748
 
712
749
  # ── Auto-Adapter System (Tier 1 / Tier 2 / Tier 3) ───────────────
713
- no_auto = getattr(args, "no_auto_adapter", False)
750
+ # no_auto already resolved above (before the wizard gate).
714
751
  adapter_id, adapter_tools = _adapter_resolver(
715
752
  manifest, registry, genome,
716
753
  calibrating=(calibrate or recalibrate),
@@ -97,13 +97,49 @@ def _probe_gpio_gpiozero() -> Tuple[bool, Dict]:
97
97
 
98
98
 
99
99
  def _probe_i2c() -> Tuple[bool, Dict]:
100
- """Try board + busio for I2C (Adafruit Blinka)."""
100
+ """Enumerate I2C devices on bus 1 so adapter signature_matches can
101
+ detect HATs by address (e.g. SunFounder Robot HAT at 0x14).
102
+
103
+ smbus2 (shipped in the `pi` install extra) is the authoritative
104
+ enumerator: opening SMBus(1) proves the kernel I2C bus is enabled and
105
+ the user has permission, and read_byte() ACK-probes each address.
106
+ This replaces the old Blinka-only check, which required board+busio
107
+ and so reported "not detected" on real Pi HATs that work fine over
108
+ smbus2.
109
+
110
+ Returns (True, {"type", "available": True, "bus": 1, "devices": [...]})
111
+ on success, or (False, {"available": False, "devices": []}) on any
112
+ failure. The "devices" field is ALWAYS a list, never None. The whole
113
+ body is wrapped so a bug here can never crash boot.
114
+ """
101
115
  try:
102
- import board # noqa: F401
103
- import busio # noqa: F401
104
- return True, {"type": "blinka_i2c"}
105
- except (ImportError, RuntimeError, NotImplementedError):
106
- return False, {"detail": "board/busio not available"}
116
+ try:
117
+ import smbus2
118
+ except ImportError:
119
+ # No enumerator available — report I2C absent rather than
120
+ # crash. (Blinka was the old path; smbus2 is required for the
121
+ # actual device scan, so its absence means no enumeration.)
122
+ return False, {"available": False, "devices": []}
123
+
124
+ bus = smbus2.SMBus(1)
125
+ devices: List[int] = []
126
+ try:
127
+ for addr in range(0x03, 0x78):
128
+ try:
129
+ bus.read_byte(addr)
130
+ devices.append(addr)
131
+ except OSError:
132
+ pass # no device ACKed at this address
133
+ finally:
134
+ bus.close()
135
+
136
+ print(f" [I2C] Scanned bus 1: found {len(devices)} device(s): "
137
+ f"{[hex(d) for d in devices]}")
138
+ return True, {"type": "smbus2_i2c", "available": True,
139
+ "bus": 1, "devices": devices}
140
+ except Exception:
141
+ # No /dev/i2c-1, permission error, or any unexpected failure.
142
+ return False, {"available": False, "devices": []}
107
143
 
108
144
 
109
145
  def _probe_mavlink_serial() -> Tuple[bool, Dict]:
@@ -1203,6 +1239,14 @@ class ToolDiscovery:
1203
1239
  if key == "oled":
1204
1240
  iface = info.get("interface", "spi").upper()
1205
1241
  detail = f"{hw_type} {resolution} {iface}"
1242
+ if key == "i2c":
1243
+ devs = info.get("devices", [])
1244
+ bus = info.get("bus", 1)
1245
+ if devs:
1246
+ addrs = ", ".join(hex(d) for d in devs)
1247
+ detail = f"bus {bus}, {len(devs)} device(s) at {addrs}"
1248
+ else:
1249
+ detail = f"bus {bus}, no devices found"
1206
1250
  else:
1207
1251
  if key == "oled":
1208
1252
  if info.get("spi_not_enabled"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aether-robotics
3
- Version: 3.7.2
3
+ Version: 3.7.4
4
4
  Summary: Autonomous multi-agent robotics system with DRL-First Hybrid FDIR
5
5
  Author: Chahel Paatur
6
6
  License: MIT
@@ -57,7 +57,7 @@ Dynamic: license-file
57
57
  ████████████████████████████████
58
58
  ```
59
59
 
60
- # AETHER v3.7.2 — Autonomous Robotics Operating System
60
+ # AETHER v3.7.4 — Autonomous Robotics Operating System
61
61
 
62
62
  > AETHER is the autonomous operating system for robots. Plug in and talk to your robot in plain English and ask it to do anything you want.
63
63
 
@@ -84,6 +84,7 @@ configs/rover_v1.json
84
84
  context/aether_definitions.txt
85
85
  tests/test_auto_adapter.py
86
86
  tests/test_auto_updater.py
87
+ tests/test_boot_resolver_flow.py
87
88
  tests/test_calibration.py
88
89
  tests/test_calibration_unit.py
89
90
  tests/test_chaos.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aether-robotics"
7
- version = "3.7.2"
7
+ version = "3.7.4"
8
8
  description = "Autonomous multi-agent robotics system with DRL-First Hybrid FDIR"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -0,0 +1,125 @@
1
+ """Regression tests for the v3.7.4 boot-flow fix.
2
+
3
+ Bug: the calibration wizard's auto-detect branch ran wizard.run() before
4
+ the adapter resolver was reached, so on a fresh profile (no robot.json /
5
+ no calibrated_*.json) the wizard preempted the resolver entirely — the
6
+ [ADAPTER] match line never printed and a known HAT (e.g. SunFounder
7
+ PiCar-X at I2C 0x14) was never auto-configured.
8
+
9
+ Fix: a side-effect-free detect_adapter() pass runs BEFORE the wizard, and
10
+ _should_run_autocal_wizard() skips the wizard whenever an adapter claims
11
+ the hardware. These tests pin both halves.
12
+ """
13
+ import importlib.machinery
14
+ import os
15
+ import sys
16
+ import tempfile
17
+ from types import ModuleType
18
+ from unittest.mock import MagicMock, patch
19
+
20
+ import pytest
21
+
22
+ from aether.adapters import detect_adapter
23
+ from aether.app import _should_run_autocal_wizard
24
+
25
+
26
+ def _manifest(i2c_devices=None, gpio=False):
27
+ return {
28
+ "hardware": {
29
+ "gpio": {"available": gpio},
30
+ "i2c": {"available": bool(i2c_devices),
31
+ "devices": i2c_devices or []},
32
+ "mavlink": {},
33
+ }
34
+ }
35
+
36
+
37
+ # ── detect_adapter: the resolver verdict that gates the wizard ─────────
38
+
39
+ def test_resolver_runs_when_no_calibration_profile():
40
+ """Detection is independent of calibration state — it runs and returns
41
+ a verdict whether or not a profile exists. (No profile is read here.)"""
42
+ # PiCar-X HAT present → a Tier 1 adapter must claim it...
43
+ assert detect_adapter(_manifest(i2c_devices=[0x14])) is not None
44
+ # ...and bare hardware with no matching HAT yields no adapter, so the
45
+ # caller (boot flow) will fall through to the wizard.
46
+ with patch("aether.adapters._build_vendor_lib_list", return_value=[]):
47
+ verdict = detect_adapter(_manifest(gpio=False), include_user_dir=False)
48
+ assert verdict is None
49
+
50
+
51
+ def test_resolver_matches_sunfounder_picarx_with_i2c_0x14():
52
+ """The SunFounder Robot HAT at 0x14 must be detected as picarx."""
53
+ with patch("aether.adapters._build_vendor_lib_list", return_value=[]):
54
+ adapter_id = detect_adapter(_manifest(i2c_devices=[0x14]),
55
+ include_user_dir=False)
56
+ assert adapter_id == "sunfounder_picarx"
57
+
58
+
59
+ def test_resolver_matches_user_dir_adapter_when_picarx_lib_present():
60
+ """Brief req #6 case 2: a generated user-dir adapter present AND picarx
61
+ importable → detection returns a match (non-None) so the wizard is
62
+ skipped. With picarx importable the Tier 1 sunfounder adapter claims it
63
+ first; that still satisfies the contract (an adapter loads, no wizard)."""
64
+ with tempfile.TemporaryDirectory() as udir:
65
+ with open(os.path.join(udir, "generated_picarx_test.py"), "w") as f:
66
+ f.write("# generated adapter stub\n")
67
+ fake_picarx = ModuleType("picarx")
68
+ fake_picarx.__spec__ = importlib.machinery.ModuleSpec("picarx", loader=None)
69
+ fake_picarx.Picarx = MagicMock()
70
+ with patch("aether.adapters._USER_ADAPTER_DIR", udir), \
71
+ patch.dict(sys.modules, {"picarx": fake_picarx}):
72
+ adapter_id = detect_adapter(_manifest(), include_user_dir=True)
73
+ assert adapter_id is not None # an adapter claims it → wizard skipped
74
+
75
+
76
+ def test_resolver_matches_user_dir_adapter_when_no_tier1_match():
77
+ """User-dir path in isolation: no Tier 1 adapter matches (no picarx, no
78
+ i2c, no vendor libs) → the generated user-dir adapter is the match."""
79
+ with tempfile.TemporaryDirectory() as udir:
80
+ with open(os.path.join(udir, "generated_picarx_test.py"), "w") as f:
81
+ f.write("# generated adapter stub\n")
82
+ with patch("aether.adapters._USER_ADAPTER_DIR", udir), \
83
+ patch("aether.adapters._build_vendor_lib_list", return_value=[]):
84
+ adapter_id = detect_adapter(_manifest(), include_user_dir=True)
85
+ assert adapter_id == "generated_picarx_test"
86
+
87
+
88
+ def test_user_dir_ignored_when_no_auto():
89
+ """include_user_dir=False (i.e. --no-auto-adapter) must not count
90
+ user-dir adapters toward the wizard-skip decision."""
91
+ with tempfile.TemporaryDirectory() as udir:
92
+ with open(os.path.join(udir, "generated_picarx_test.py"), "w") as f:
93
+ f.write("# stub\n")
94
+ with patch("aether.adapters._USER_ADAPTER_DIR", udir), \
95
+ patch("aether.adapters._build_vendor_lib_list", return_value=[]):
96
+ adapter_id = detect_adapter(_manifest(), include_user_dir=False)
97
+ assert adapter_id is None
98
+
99
+
100
+ # ── _should_run_autocal_wizard: the wizard gate ────────────────────────
101
+
102
+ def test_calibration_wizard_skipped_when_adapter_matches():
103
+ """Adapter matched → wizard must NOT fire, even with new hardware and
104
+ no profile."""
105
+ assert _should_run_autocal_wizard(
106
+ "sunfounder_picarx", None, needs_calibration=True) is False
107
+
108
+
109
+ def test_calibration_wizard_runs_when_no_adapter_and_no_profile():
110
+ """No adapter, no profile, new hardware → wizard fires (the today path)."""
111
+ assert _should_run_autocal_wizard(
112
+ None, None, needs_calibration=True) is True
113
+
114
+
115
+ def test_calibration_wizard_skipped_when_profile_present():
116
+ """No adapter but an existing profile → load profile, no wizard."""
117
+ profile = {"robot_name": "rover", "robot_type": "differential_drive"}
118
+ assert _should_run_autocal_wizard(
119
+ None, profile, needs_calibration=True) is False
120
+
121
+
122
+ def test_calibration_wizard_skipped_when_no_new_hardware():
123
+ """No adapter, no profile, but wizard reports no new hardware → no wizard."""
124
+ assert _should_run_autocal_wizard(
125
+ None, None, needs_calibration=False) is False
@@ -0,0 +1,133 @@
1
+ """Tests for ToolDiscovery: platform detection, manifest structure."""
2
+ import os
3
+ import sys
4
+ from types import ModuleType
5
+ from unittest.mock import patch
6
+
7
+ import pytest
8
+
9
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
10
+
11
+ from aether.core.tool_discovery import ToolDiscovery, _probe_i2c
12
+
13
+
14
+ @pytest.fixture(scope="module")
15
+ def manifest():
16
+ d = ToolDiscovery()
17
+ d.discover()
18
+ return d.manifest
19
+
20
+
21
+ class TestToolDiscovery:
22
+ def test_platform_detected(self, manifest):
23
+ assert "platform" in manifest
24
+ assert manifest["platform"] != ""
25
+
26
+ def test_camera_found_or_missing(self, manifest):
27
+ hw = manifest.get("hardware", {})
28
+ cam = hw.get("camera", {})
29
+ # camera key must exist with an 'available' field
30
+ assert "available" in cam
31
+
32
+ def test_manifest_has_capability_score(self, manifest):
33
+ assert "capability_score" in manifest
34
+ score = manifest["capability_score"]
35
+ assert isinstance(score, (int, float))
36
+ assert 0 <= score <= 100
37
+
38
+ def test_available_tools_non_empty(self, manifest):
39
+ tools = manifest.get("available_tools", [])
40
+ assert isinstance(tools, list)
41
+ assert len(tools) > 0
42
+
43
+ def test_missing_capabilities_is_list(self, manifest):
44
+ missing = manifest.get("missing_capabilities", [])
45
+ assert isinstance(missing, list)
46
+ # On a dev machine at least some capabilities will be missing
47
+ # (e.g., GPIO on macOS), so the list should be non-empty
48
+ assert len(missing) > 0
49
+
50
+
51
+ # ── I2C device enumeration (v3.7.3) ────────────────────────────────────
52
+
53
+
54
+ def _fake_smbus2(ack_addrs=None, instantiation_error=None):
55
+ """Build a fake smbus2 module whose SMBus(1).read_byte ACKs (returns)
56
+ only at ack_addrs and raises OSError elsewhere. If instantiation_error
57
+ is set, SMBus(bus) raises it (simulates a missing /dev/i2c-N)."""
58
+ ack = set(ack_addrs or [])
59
+ mod = ModuleType("smbus2")
60
+
61
+ class _SMBus:
62
+ def __init__(self, bus):
63
+ if instantiation_error is not None:
64
+ raise instantiation_error
65
+ self.bus = bus
66
+
67
+ def read_byte(self, addr):
68
+ if addr in ack:
69
+ return 0
70
+ raise OSError("no device ACKed")
71
+
72
+ def close(self):
73
+ pass
74
+
75
+ mod.SMBus = _SMBus
76
+ return mod
77
+
78
+
79
+ def test_probe_i2c_returns_devices_list_on_success():
80
+ fake = _fake_smbus2(ack_addrs=[0x14, 0x40])
81
+ with patch.dict(sys.modules, {"smbus2": fake}):
82
+ ok, info = _probe_i2c()
83
+ assert ok is True
84
+ assert info["available"] is True
85
+ assert info["bus"] == 1
86
+ assert info["type"] == "smbus2_i2c"
87
+ assert info["devices"] == [0x14, 0x40] # ascending scan order
88
+
89
+
90
+ def test_probe_i2c_returns_empty_list_on_no_devices():
91
+ fake = _fake_smbus2(ack_addrs=[]) # nothing ACKs
92
+ with patch.dict(sys.modules, {"smbus2": fake}):
93
+ ok, info = _probe_i2c()
94
+ assert ok is True # bus opened fine, just no peripherals
95
+ assert info["devices"] == []
96
+
97
+
98
+ def test_probe_i2c_returns_false_on_no_bus():
99
+ # SMBus(1) raises FileNotFoundError — /dev/i2c-1 absent / I2C disabled
100
+ fake = _fake_smbus2(instantiation_error=FileNotFoundError("/dev/i2c-1"))
101
+ with patch.dict(sys.modules, {"smbus2": fake}):
102
+ ok, info = _probe_i2c()
103
+ assert ok is False
104
+ assert info["available"] is False
105
+ assert info["devices"] == []
106
+
107
+
108
+ def test_probe_i2c_never_crashes_boot():
109
+ # smbus2 import raises ImportError — must return False cleanly, no crash
110
+ with patch.dict(sys.modules, {"smbus2": None}):
111
+ ok, info = _probe_i2c()
112
+ assert ok is False
113
+ assert info["available"] is False
114
+ assert info["devices"] == []
115
+
116
+
117
+ def test_capability_discovery_printer_shows_i2c_with_devices(capsys):
118
+ """With a device enumerated, the summary's i2c line must read OK with
119
+ the address — never the old '--- not detected'."""
120
+ with patch("aether.core.tool_discovery._probe_i2c",
121
+ return_value=(True, {"type": "smbus2_i2c", "available": True,
122
+ "bus": 1, "devices": [0x14]})):
123
+ d = ToolDiscovery()
124
+ d.discover()
125
+ d.print_summary()
126
+
127
+ out = capsys.readouterr().out
128
+ i2c_line = next(ln for ln in out.splitlines()
129
+ if ln.strip().startswith(("[+] i2c", "[-] i2c")))
130
+ assert "[+]" in i2c_line
131
+ assert "OK" in i2c_line
132
+ assert "0x14" in i2c_line
133
+ assert "not detected" not in i2c_line
@@ -1 +0,0 @@
1
- 3.7.2
@@ -1,46 +0,0 @@
1
- """Tests for ToolDiscovery: platform detection, manifest structure."""
2
- import os
3
- import sys
4
-
5
- import pytest
6
-
7
- sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
8
-
9
- from aether.core.tool_discovery import ToolDiscovery
10
-
11
-
12
- @pytest.fixture(scope="module")
13
- def manifest():
14
- d = ToolDiscovery()
15
- d.discover()
16
- return d.manifest
17
-
18
-
19
- class TestToolDiscovery:
20
- def test_platform_detected(self, manifest):
21
- assert "platform" in manifest
22
- assert manifest["platform"] != ""
23
-
24
- def test_camera_found_or_missing(self, manifest):
25
- hw = manifest.get("hardware", {})
26
- cam = hw.get("camera", {})
27
- # camera key must exist with an 'available' field
28
- assert "available" in cam
29
-
30
- def test_manifest_has_capability_score(self, manifest):
31
- assert "capability_score" in manifest
32
- score = manifest["capability_score"]
33
- assert isinstance(score, (int, float))
34
- assert 0 <= score <= 100
35
-
36
- def test_available_tools_non_empty(self, manifest):
37
- tools = manifest.get("available_tools", [])
38
- assert isinstance(tools, list)
39
- assert len(tools) > 0
40
-
41
- def test_missing_capabilities_is_list(self, manifest):
42
- missing = manifest.get("missing_capabilities", [])
43
- assert isinstance(missing, list)
44
- # On a dev machine at least some capabilities will be missing
45
- # (e.g., GPIO on macOS), so the list should be non-empty
46
- assert len(missing) > 0
File without changes
File without changes