aether-robotics 3.2.0__tar.gz → 3.2.3__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 (89) hide show
  1. {aether_robotics-3.2.0/aether_robotics.egg-info → aether_robotics-3.2.3}/PKG-INFO +1 -1
  2. aether_robotics-3.2.3/VERSION +1 -0
  3. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/__init__.py +1 -1
  4. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/app.py +38 -0
  5. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/auto_installer.py +57 -1
  6. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/llm_planner.py +9 -0
  7. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/navigation_engine.py +27 -0
  8. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/tool_builder.py +363 -0
  9. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/tool_discovery.py +70 -3
  10. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/tool_registry.py +16 -0
  11. {aether_robotics-3.2.0 → aether_robotics-3.2.3/aether_robotics.egg-info}/PKG-INFO +1 -1
  12. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/pyproject.toml +1 -1
  13. aether_robotics-3.2.0/VERSION +0 -1
  14. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/LICENSE +0 -0
  15. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/MANIFEST.in +0 -0
  16. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/README.md +0 -0
  17. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/__main__.py +0 -0
  18. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/actions/__init__.py +0 -0
  19. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/actions/abstract_actions.py +0 -0
  20. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/adapters/__init__.py +0 -0
  21. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/adapters/base_adapter.py +0 -0
  22. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/adapters/drone_adapter.py +0 -0
  23. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/adapters/rover_adapter.py +0 -0
  24. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/adapters/universal_adapter.py +0 -0
  25. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/__init__.py +0 -0
  26. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/adaptation_agent.py +0 -0
  27. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/camera_agent.py +0 -0
  28. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/correction_agent.py +0 -0
  29. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/execution_agent.py +0 -0
  30. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/fault_agent.py +0 -0
  31. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/memory_agent.py +0 -0
  32. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/movement_agent.py +0 -0
  33. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/navigation_agent.py +0 -0
  34. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/perception_agent.py +0 -0
  35. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/planner_agent.py +0 -0
  36. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/power_agent.py +0 -0
  37. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/task_manager.py +0 -0
  38. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/thermal_agent.py +0 -0
  39. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/capabilities/__init__.py +0 -0
  40. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/capabilities/capability_loader.py +0 -0
  41. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/__init__.py +0 -0
  42. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/auto_updater.py +0 -0
  43. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/banner.py +0 -0
  44. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/calibration.py +0 -0
  45. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/executor.py +0 -0
  46. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/feedback.py +0 -0
  47. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/goal_parser.py +0 -0
  48. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/mapper.py +0 -0
  49. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/memory.py +0 -0
  50. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/message_bus.py +0 -0
  51. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/metrics.py +0 -0
  52. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/planner.py +0 -0
  53. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/task_scheduler.py +0 -0
  54. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/visualizer.py +0 -0
  55. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/faults/__init__.py +0 -0
  56. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/faults/fault_detector.py +0 -0
  57. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/faults/fault_injector.py +0 -0
  58. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/simulation/__init__.py +0 -0
  59. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/simulation/environment.py +0 -0
  60. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/simulation/real_perception.py +0 -0
  61. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/simulation/scenarios.py +0 -0
  62. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether_robotics.egg-info/SOURCES.txt +0 -0
  63. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether_robotics.egg-info/dependency_links.txt +0 -0
  64. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether_robotics.egg-info/entry_points.txt +0 -0
  65. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether_robotics.egg-info/requires.txt +0 -0
  66. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether_robotics.egg-info/top_level.txt +0 -0
  67. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/calibrated_camera_only_20260331_121709.json +0 -0
  68. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/calibrated_camera_only_20260331_122446.json +0 -0
  69. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/calibrated_camera_only_20260401_094544.json +0 -0
  70. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/calibrated_camera_only_20260401_094554.json +0 -0
  71. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/drone_v1.json +0 -0
  72. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/rover_v1.json +0 -0
  73. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/context/aether_definitions.txt +0 -0
  74. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/install.sh +0 -0
  75. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/main.py +0 -0
  76. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/requirements.txt +0 -0
  77. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/setup.cfg +0 -0
  78. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_auto_updater.py +0 -0
  79. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_calibration.py +0 -0
  80. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_calibration_unit.py +0 -0
  81. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_chaos.py +0 -0
  82. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_mapper.py +0 -0
  83. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_navigation_engine.py +0 -0
  84. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_persistent_memory.py +0 -0
  85. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_security.py +0 -0
  86. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_task_scheduler.py +0 -0
  87. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_tool_builder.py +0 -0
  88. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_tool_discovery.py +0 -0
  89. {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_yolo_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aether-robotics
3
- Version: 3.2.0
3
+ Version: 3.2.3
4
4
  Summary: Autonomous multi-agent robotics system with DRL-First Hybrid FDIR
5
5
  Author: Chahel Paatur
6
6
  License: MIT
@@ -0,0 +1 @@
1
+ 3.2.3
@@ -1,2 +1,2 @@
1
- __version__ = "3.2.0"
1
+ __version__ = "3.2.3"
2
2
  __author__ = "Chahel Paatur"
@@ -468,6 +468,15 @@ def run_agent(args) -> None:
468
468
  built_tools = builder.build_all()
469
469
  _print_activity("TOOLS", builder.build_summary())
470
470
 
471
+ # OLED startup animation (only on Pi with real hardware attached)
472
+ oled_tool = built_tools.get("oled")
473
+ if oled_tool is not None and getattr(oled_tool, "mode", "sim") == "real":
474
+ try:
475
+ oled_tool.show_startup()
476
+ _print_activity("OLED", "Startup animation played on hardware")
477
+ except Exception as e:
478
+ _print_activity("OLED", f"startup animation failed: {e}")
479
+
471
480
  nav_engine = NavigationEngine(discovery.manifest, tools=built_tools)
472
481
  nav_actions = nav_engine.available_actions()
473
482
  _print_activity("NAV", f"Level {nav_engine.level} ({nav_engine.level_name}) — "
@@ -621,6 +630,12 @@ def run_agent(args) -> None:
621
630
  if hints:
622
631
  _print_activity("MEM", "Found similar past objectives")
623
632
 
633
+ if oled_tool is not None:
634
+ try:
635
+ oled_tool.draw_face("thinking")
636
+ except Exception:
637
+ pass
638
+
624
639
  if llm_planner.available:
625
640
  _print_activity("PLAN", "Sending to LLM planner...")
626
641
  # Prepend planning hints to memory context
@@ -714,6 +729,11 @@ def run_agent(args) -> None:
714
729
  if not result.success:
715
730
  step_faults += 1
716
731
  faults_detected += 1
732
+ if oled_tool is not None:
733
+ try:
734
+ oled_tool.draw_face("alert")
735
+ except Exception:
736
+ pass
717
737
  _print_fault_alert("TASK_FAILURE",
718
738
  f"{tool_name} failed: {result.error} "
719
739
  f"({result.duration_ms:.0f}ms)")
@@ -728,6 +748,11 @@ def run_agent(args) -> None:
728
748
  sim_accumulator.append(("timeout", params.get("scenario", "?"), result.output))
729
749
  prev_output = result.output
730
750
  else:
751
+ if oled_tool is not None:
752
+ try:
753
+ oled_tool.draw_face("speaking")
754
+ except Exception:
755
+ pass
731
756
  summary = _summarize_output(result.output)
732
757
  label = "KNOWLEDGE" if (tool_name == "web_search"
733
758
  and str(result.output).startswith("[KNOWLEDGE]")) else "OK"
@@ -778,6 +803,19 @@ def run_agent(args) -> None:
778
803
 
779
804
  # Episode summary
780
805
  outcome = "SUCCESS" if step_faults == 0 else "DEGRADED"
806
+
807
+ # Auto-expression on OLED: happy on success, alert on faults
808
+ if oled_tool is not None:
809
+ try:
810
+ if step_faults == 0:
811
+ oled_tool.draw_face("happy")
812
+ time.sleep(0.5)
813
+ oled_tool.draw_face("neutral")
814
+ else:
815
+ oled_tool.draw_face("alert")
816
+ except Exception:
817
+ pass
818
+
781
819
  print(f"\n --- Objective Complete ---")
782
820
  print(f" Actions: {len(plan)}")
783
821
  print(f" Faults: {step_faults}")
@@ -32,6 +32,7 @@ INSTALL_MAP = {
32
32
  "anthropic": "anthropic",
33
33
  "psutil": "psutil",
34
34
  "PIL": "Pillow",
35
+ "luma.oled": "luma.oled",
35
36
  }
36
37
 
37
38
  # What each library enables
@@ -52,6 +53,7 @@ CAPABILITY_MAP = {
52
53
  "anthropic": "Anthropic API (LLM planner)",
53
54
  "psutil": "system monitoring",
54
55
  "PIL": "image processing",
56
+ "luma.oled": "SSD1306 OLED display output",
55
57
  }
56
58
 
57
59
 
@@ -116,6 +118,57 @@ class AutoInstaller:
116
118
  pass
117
119
  return int(installed / total * 100) if total > 0 else 0
118
120
 
121
+ def _auto_install_oled(self) -> bool:
122
+ """Auto-install OLED libraries when SPI device detected but luma.oled missing.
123
+
124
+ Called before the general install prompt. When the Pi has
125
+ /dev/spidev0.x it means the user configured SPI for a display,
126
+ so we install without asking.
127
+
128
+ Returns True if anything was installed.
129
+ """
130
+ hw = self._manifest.get("hardware", {})
131
+ oled = hw.get("oled", {})
132
+ if not oled.get("spi_ready") or oled.get("available"):
133
+ return False
134
+
135
+ # luma.oled not importable — install it
136
+ try:
137
+ importlib.import_module("luma.oled")
138
+ return False # already importable
139
+ except ImportError:
140
+ pass
141
+
142
+ print("[AutoInstaller] SPI device detected — installing OLED libraries...")
143
+
144
+ # apt dependencies (best-effort, may need sudo)
145
+ if self._is_linux:
146
+ apt_pkgs = ["python3-pil", "python3-dev", "python3-smbus",
147
+ "libfreetype6-dev", "libjpeg-dev", "libopenjp2-7"]
148
+ try:
149
+ print(f" Installing: {', '.join(apt_pkgs)} (apt)...",
150
+ end=" ", flush=True)
151
+ subprocess.run(
152
+ ["sudo", "apt-get", "install", "-y"] + apt_pkgs,
153
+ capture_output=True, text=True, timeout=120)
154
+ print("OK")
155
+ except (subprocess.TimeoutExpired, OSError, FileNotFoundError):
156
+ print("SKIPPED (sudo not available)")
157
+
158
+ # pip install luma.oled (pulls Pillow as dependency)
159
+ installed = False
160
+ for pkg in ("luma.oled", "Pillow"):
161
+ print(f" Installing: {pkg} (pip)...", end=" ", flush=True)
162
+ if self._pip_install(pkg):
163
+ print("OK")
164
+ installed = True
165
+ else:
166
+ print("FAILED")
167
+
168
+ if installed:
169
+ print("[AutoInstaller] OLED display ready")
170
+ return installed
171
+
119
172
  def run(self, auto_install: bool = False, no_install: bool = False) -> bool:
120
173
  """Run the auto-installer flow.
121
174
 
@@ -127,8 +180,11 @@ class AutoInstaller:
127
180
  Returns:
128
181
  True if any packages were installed.
129
182
  """
183
+ # Auto-install OLED libs when SPI is ready (always, before skip check)
184
+ oled_installed = self._auto_install_oled()
185
+
130
186
  if no_install or self._skip_session or _load_skip_pref():
131
- return False
187
+ return oled_installed
132
188
 
133
189
  missing = self.check_missing()
134
190
  if not missing:
@@ -52,6 +52,15 @@ _SYSTEM_PROMPT = (
52
52
  "motor or flight actions at a lower level.\n"
53
53
  "- Navigation action inputs use: color, direction, speed, altitude, pin, "
54
54
  "duration, timeout, marker_color, left_pin, right_pin, lat, lon, alt\n"
55
+ "- If an OLED display is available (display_text, draw_face, etc. appear "
56
+ "in available_tools), use display_text or draw_face to show results on "
57
+ "screen in addition to returning text. For finger counting: chain "
58
+ "capture_image → count_fingers → show_value(label=\"Fingers\", value=N). "
59
+ "For weather/news: chain web_search → display_text. For expressions: use "
60
+ "draw_face with an appropriate expression ('happy', 'thinking', 'alert', "
61
+ "'sleeping', 'speaking'). OLED action inputs use: text, font_size, x, y, "
62
+ "clear, label, value, unit, expression, state, times, duration, speed, "
63
+ "image_path, frames, fps.\n"
55
64
  "- Return valid JSON only — an object with a single key 'steps' containing "
56
65
  "the array. No markdown, no explanation, no code fences."
57
66
  )
@@ -1426,6 +1426,13 @@ _IMU_ACTIONS = [
1426
1426
  "get_orientation", "detect_vibration",
1427
1427
  ]
1428
1428
 
1429
+ # OLED display actions (added when OLED hardware detected)
1430
+ _OLED_ACTIONS = [
1431
+ "display_text", "display_image", "clear_oled", "show_animation",
1432
+ "draw_face", "draw_eyes", "animate_blink", "animate_speaking",
1433
+ "scroll_text", "show_value", "show_startup",
1434
+ ]
1435
+
1429
1436
 
1430
1437
  class NavigationEngine:
1431
1438
  """
@@ -1513,11 +1520,20 @@ class NavigationEngine:
1513
1520
  actions = list(_SYSTEM_ACTIONS)
1514
1521
  if self._imu.available:
1515
1522
  actions.extend(_IMU_ACTIONS)
1523
+ if self._has_oled():
1524
+ actions.extend(_OLED_ACTIONS)
1516
1525
  for lvl in range(1, self._level + 1):
1517
1526
  actions.extend(_LEVEL_ACTIONS.get(lvl, []))
1518
1527
  actions.append("observe")
1519
1528
  return sorted(set(actions))
1520
1529
 
1530
+ def _has_oled(self) -> bool:
1531
+ hw_oled = self._manifest.get("hardware", {}).get("oled", {})
1532
+ if hw_oled.get("available"):
1533
+ return True
1534
+ # sim-mode OLEDTool built when PIL is installed
1535
+ return "oled" in self._tools
1536
+
1521
1537
  def execute(self, action: str, params: Optional[Dict] = None):
1522
1538
  """Execute a navigation action by name.
1523
1539
 
@@ -1601,6 +1617,17 @@ class NavigationEngine:
1601
1617
  if action in yolo_map:
1602
1618
  return yolo_map[action]()
1603
1619
 
1620
+ # OLED actions (level 1+ when oled tool available)
1621
+ oled = self._tools.get("oled")
1622
+ if oled and action in _OLED_ACTIONS:
1623
+ fn = getattr(oled, action, None)
1624
+ if fn is not None:
1625
+ try:
1626
+ return fn(**params)
1627
+ except TypeError as e:
1628
+ return _err(f"bad params for {action}: {e}")
1629
+ return _err(f"oled has no method '{action}'")
1630
+
1604
1631
  # Level 2: Motor
1605
1632
  if self._motors:
1606
1633
  lp = params.get("left_pin", 17)
@@ -1556,6 +1556,356 @@ class TFLiteTool:
1556
1556
  return _err(str(e))
1557
1557
 
1558
1558
 
1559
+ # ── OLEDTool ──────────────────────────────────────────────────────────
1560
+
1561
+ _OLED_SIM_DIR = os.path.join("logs", "oled_sim")
1562
+ _OLED_W = 128
1563
+ _OLED_H = 64
1564
+
1565
+ _FACE_DEFS = {
1566
+ "neutral": {"eyes": "open", "mouth": "flat"},
1567
+ "happy": {"eyes": "open", "mouth": "smile"},
1568
+ "thinking": {"eyes": "squint", "mouth": "flat"},
1569
+ "alert": {"eyes": "wide", "mouth": "open"},
1570
+ "sleeping": {"eyes": "closed", "mouth": "flat"},
1571
+ "speaking": {"eyes": "open", "mouth": "open"},
1572
+ }
1573
+
1574
+
1575
+ class OLEDTool:
1576
+ """Controls an SSD1306/SSD1309 128x64 SPI OLED display (with simulation fallback).
1577
+
1578
+ Modes:
1579
+ - real: luma.oled SSD1306 driver on SPI (DC=GPIO25, RST=GPIO24, CE0)
1580
+ - sim: renders frames to PNG files under logs/oled_sim/ for review
1581
+ """
1582
+
1583
+ def __init__(self, spi_port: int = 0, spi_device: int = 0,
1584
+ gpio_dc: int = 25, gpio_rst: int = 24,
1585
+ width: int = _OLED_W, height: int = _OLED_H,
1586
+ force_sim: bool = False):
1587
+ self._width = width
1588
+ self._height = height
1589
+ self._spi_port = spi_port
1590
+ self._spi_device = spi_device
1591
+ self._gpio_dc = gpio_dc
1592
+ self._gpio_rst = gpio_rst
1593
+ self._mode = "sim"
1594
+ self._device = None
1595
+ self._Image = None
1596
+ self._ImageDraw = None
1597
+ self._ImageFont = None
1598
+ self._frame_counter = 0
1599
+
1600
+ # PIL is required for both real and sim modes
1601
+ try:
1602
+ from PIL import Image, ImageDraw, ImageFont
1603
+ self._Image = Image
1604
+ self._ImageDraw = ImageDraw
1605
+ self._ImageFont = ImageFont
1606
+ except ImportError:
1607
+ self._Image = None
1608
+
1609
+ if not force_sim and _ON_PI and self._Image is not None:
1610
+ try:
1611
+ from luma.core.interface.serial import spi
1612
+ from luma.oled.device import ssd1306
1613
+ serial = spi(port=spi_port, device=spi_device,
1614
+ gpio_DC=gpio_dc, gpio_RST=gpio_rst)
1615
+ self._device = ssd1306(serial, width=width, height=height)
1616
+ self._mode = "real"
1617
+ except Exception:
1618
+ self._device = None
1619
+ self._mode = "sim"
1620
+
1621
+ if self._mode == "sim":
1622
+ try:
1623
+ os.makedirs(_OLED_SIM_DIR, exist_ok=True)
1624
+ except OSError:
1625
+ pass
1626
+
1627
+ @property
1628
+ def mode(self) -> str:
1629
+ return self._mode
1630
+
1631
+ # ── Internal rendering helpers ─────────────────────────────────
1632
+
1633
+ def _new_image(self):
1634
+ return self._Image.new("1", (self._width, self._height), 0)
1635
+
1636
+ def _font(self, size: int = 12):
1637
+ try:
1638
+ return self._ImageFont.truetype(
1639
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", size)
1640
+ except (OSError, IOError):
1641
+ return self._ImageFont.load_default()
1642
+
1643
+ def _text_size(self, draw, text, font):
1644
+ try:
1645
+ l, t, r, b = draw.textbbox((0, 0), text, font=font)
1646
+ return r - l, b - t
1647
+ except AttributeError:
1648
+ return draw.textsize(text, font=font)
1649
+
1650
+ def _wrap_text(self, draw, text, font, max_w):
1651
+ words = text.split()
1652
+ lines: list = []
1653
+ current = ""
1654
+ for w in words:
1655
+ trial = (current + " " + w).strip()
1656
+ tw, _ = self._text_size(draw, trial, font)
1657
+ if tw <= max_w or not current:
1658
+ current = trial
1659
+ else:
1660
+ lines.append(current)
1661
+ current = w
1662
+ if current:
1663
+ lines.append(current)
1664
+ return lines
1665
+
1666
+ def _push(self, img, tag: str = "frame"):
1667
+ if self._mode == "real" and self._device is not None:
1668
+ try:
1669
+ self._device.display(img)
1670
+ return _ok({"mode": "real", "tag": tag})
1671
+ except Exception as e:
1672
+ return _err(f"oled display failed: {e}")
1673
+ # sim mode: save PNG (scaled 4x for visibility)
1674
+ try:
1675
+ self._frame_counter += 1
1676
+ fname = f"{tag}_{self._frame_counter:04d}.png"
1677
+ path = os.path.join(_OLED_SIM_DIR, fname)
1678
+ big = img.resize((self._width * 4, self._height * 4),
1679
+ self._Image.NEAREST)
1680
+ big.convert("L").save(path)
1681
+ return _ok({"mode": "sim", "path": path, "tag": tag})
1682
+ except Exception as e:
1683
+ return _err(f"oled sim save failed: {e}")
1684
+
1685
+ # ── Public methods ─────────────────────────────────────────────
1686
+
1687
+ def clear(self) -> Dict:
1688
+ if self._Image is None:
1689
+ return _err("PIL not available")
1690
+ img = self._new_image()
1691
+ return self._push(img, tag="clear")
1692
+
1693
+ def clear_oled(self) -> Dict:
1694
+ return self.clear()
1695
+
1696
+ def display_text(self, text: str = "", font_size: int = 12,
1697
+ x: int = 0, y: int = 0, clear: bool = True) -> Dict:
1698
+ if self._Image is None:
1699
+ return _err("PIL not available")
1700
+ img = self._new_image() if clear else self._new_image()
1701
+ draw = self._ImageDraw.Draw(img)
1702
+ font = self._font(font_size)
1703
+ max_w = self._width - x
1704
+ lines = self._wrap_text(draw, str(text), font, max_w)
1705
+ line_h = font_size + 2
1706
+ cy = y
1707
+ for line in lines:
1708
+ if cy + line_h > self._height:
1709
+ break
1710
+ draw.text((x, cy), line, fill=1, font=font)
1711
+ cy += line_h
1712
+ return self._push(img, tag="text")
1713
+
1714
+ def display_image(self, image_path: str = "") -> Dict:
1715
+ if self._Image is None:
1716
+ return _err("PIL not available")
1717
+ path = _extract_image_path(image_path)
1718
+ if not path or not os.path.exists(path):
1719
+ return _err(f"image not found: {path}")
1720
+ try:
1721
+ src = self._Image.open(path).convert("1")
1722
+ src = src.resize((self._width, self._height))
1723
+ return self._push(src, tag="image")
1724
+ except Exception as e:
1725
+ return _err(f"load image failed: {e}")
1726
+
1727
+ def show_animation(self, frames: list = None, fps: int = 10) -> Dict:
1728
+ if self._Image is None:
1729
+ return _err("PIL not available")
1730
+ frames = frames or []
1731
+ if not frames:
1732
+ return _err("no frames provided")
1733
+ delay = 1.0 / max(fps, 1)
1734
+ last = None
1735
+ for f in frames:
1736
+ if isinstance(f, str):
1737
+ if not os.path.exists(f):
1738
+ continue
1739
+ img = self._Image.open(f).convert("1").resize(
1740
+ (self._width, self._height))
1741
+ else:
1742
+ img = f.convert("1").resize((self._width, self._height))
1743
+ last = self._push(img, tag="anim")
1744
+ time.sleep(delay)
1745
+ return last or _err("no playable frames")
1746
+
1747
+ # ── Face drawing ───────────────────────────────────────────────
1748
+
1749
+ def _draw_eye_pair(self, draw, state: str):
1750
+ """Draw both eyes. Positions: left (35,25), right (93,25)."""
1751
+ lx, rx, ey = 35, 93, 25
1752
+ r = 10
1753
+ pr = 4 # pupil radius
1754
+
1755
+ if state == "closed":
1756
+ draw.line((lx - r, ey, lx + r, ey), fill=1, width=3)
1757
+ draw.line((rx - r, ey, rx + r, ey), fill=1, width=3)
1758
+ elif state == "squint":
1759
+ # Smaller ovals
1760
+ draw.ellipse((lx - r, ey - 5, lx + r, ey + 5), fill=1)
1761
+ draw.ellipse((rx - r, ey - 5, rx + r, ey + 5), fill=1)
1762
+ draw.ellipse((lx - pr, ey - 2, lx + pr, ey + 2), fill=0)
1763
+ draw.ellipse((rx - pr, ey - 2, rx + pr, ey + 2), fill=0)
1764
+ elif state == "wide":
1765
+ wr = r + 3
1766
+ draw.ellipse((lx - wr, ey - wr, lx + wr, ey + wr), fill=1)
1767
+ draw.ellipse((rx - wr, ey - wr, rx + wr, ey + wr), fill=1)
1768
+ draw.ellipse((lx - 5, ey - 5, lx + 5, ey + 5), fill=0)
1769
+ draw.ellipse((rx - 5, ey - 5, rx + 5, ey + 5), fill=0)
1770
+ elif state == "wink_left":
1771
+ draw.line((lx - r, ey, lx + r, ey), fill=1, width=3)
1772
+ draw.ellipse((rx - r, ey - r, rx + r, ey + r), fill=1)
1773
+ draw.ellipse((rx - pr, ey - pr, rx + pr, ey + pr), fill=0)
1774
+ elif state == "wink_right":
1775
+ draw.ellipse((lx - r, ey - r, lx + r, ey + r), fill=1)
1776
+ draw.ellipse((lx - pr, ey - pr, lx + pr, ey + pr), fill=0)
1777
+ draw.line((rx - r, ey, rx + r, ey), fill=1, width=3)
1778
+ else: # open
1779
+ draw.ellipse((lx - r, ey - r, lx + r, ey + r), fill=1)
1780
+ draw.ellipse((rx - r, ey - r, rx + r, ey + r), fill=1)
1781
+ draw.ellipse((lx - pr, ey - pr, lx + pr, ey + pr), fill=0)
1782
+ draw.ellipse((rx - pr, ey - pr, rx + pr, ey + pr), fill=0)
1783
+
1784
+ def _draw_mouth(self, draw, kind: str):
1785
+ my = 52
1786
+ if kind == "smile":
1787
+ draw.arc((44, my - 8, 84, my + 8), 0, 180, fill=1, width=2)
1788
+ elif kind == "open":
1789
+ draw.ellipse((54, my - 5, 74, my + 5), outline=1, width=2)
1790
+ elif kind == "frown":
1791
+ draw.arc((44, my - 8, 84, my + 8), 180, 0, fill=1, width=2)
1792
+ else: # flat
1793
+ draw.line((44, my, 84, my), fill=1, width=2)
1794
+
1795
+ def draw_face(self, expression: str = "neutral") -> Dict:
1796
+ if self._Image is None:
1797
+ return _err("PIL not available")
1798
+ spec = _FACE_DEFS.get(expression, _FACE_DEFS["neutral"])
1799
+ img = self._new_image()
1800
+ draw = self._ImageDraw.Draw(img)
1801
+ self._draw_eye_pair(draw, spec["eyes"])
1802
+ mouth = spec["mouth"]
1803
+ # thinking gets a frown/arc-up mouth
1804
+ if expression == "thinking":
1805
+ mouth = "frown"
1806
+ self._draw_mouth(draw, mouth)
1807
+ return self._push(img, tag=f"face_{expression}")
1808
+
1809
+ def draw_eyes(self, state: str = "open", blink: bool = False) -> Dict:
1810
+ if self._Image is None:
1811
+ return _err("PIL not available")
1812
+ img = self._new_image()
1813
+ draw = self._ImageDraw.Draw(img)
1814
+ self._draw_eye_pair(draw, "closed" if blink else state)
1815
+ return self._push(img, tag=f"eyes_{state}")
1816
+
1817
+ def animate_blink(self, times: int = 2) -> Dict:
1818
+ if self._Image is None:
1819
+ return _err("PIL not available")
1820
+ last = None
1821
+ for _ in range(max(1, int(times))):
1822
+ # Open eyes + smile mouth
1823
+ img = self._new_image()
1824
+ draw = self._ImageDraw.Draw(img)
1825
+ self._draw_eye_pair(draw, "open")
1826
+ self._draw_mouth(draw, "smile")
1827
+ self._push(img, tag="blink_open")
1828
+ time.sleep(0.4)
1829
+ # Closed eyes + smile mouth
1830
+ img = self._new_image()
1831
+ draw = self._ImageDraw.Draw(img)
1832
+ self._draw_eye_pair(draw, "closed")
1833
+ self._draw_mouth(draw, "smile")
1834
+ last = self._push(img, tag="blink_closed")
1835
+ time.sleep(0.15)
1836
+ # End with neutral face
1837
+ last = self.draw_face("neutral")
1838
+ return last or _ok({"mode": self._mode, "blinks": times})
1839
+
1840
+ def animate_speaking(self, duration: float = 2.0) -> Dict:
1841
+ if self._Image is None:
1842
+ return _err("PIL not available")
1843
+ end = time.time() + max(0.2, float(duration))
1844
+ last = None
1845
+ while time.time() < end:
1846
+ last = self.draw_face("speaking")
1847
+ time.sleep(0.15)
1848
+ last = self.draw_face("neutral")
1849
+ time.sleep(0.15)
1850
+ return last or _ok({"mode": self._mode, "duration": duration})
1851
+
1852
+ def scroll_text(self, text: str = "", speed: int = 3) -> Dict:
1853
+ if self._Image is None:
1854
+ return _err("PIL not available")
1855
+ font = self._font(16)
1856
+ tmp = self._new_image()
1857
+ draw = self._ImageDraw.Draw(tmp)
1858
+ tw, th = self._text_size(draw, str(text), font)
1859
+ y = (self._height - th) // 2
1860
+ step_px = max(1, int(speed))
1861
+ last = None
1862
+ for offset in range(self._width, -tw - 1, -step_px):
1863
+ img = self._new_image()
1864
+ d2 = self._ImageDraw.Draw(img)
1865
+ d2.text((offset, y), str(text), fill=1, font=font)
1866
+ last = self._push(img, tag="scroll")
1867
+ time.sleep(0.03)
1868
+ return last or _ok({"mode": self._mode})
1869
+
1870
+ def show_value(self, label: str = "", value: Any = "",
1871
+ unit: str = "") -> Dict:
1872
+ if self._Image is None:
1873
+ return _err("PIL not available")
1874
+ img = self._new_image()
1875
+ draw = self._ImageDraw.Draw(img)
1876
+ label_font = self._font(12)
1877
+ value_font = self._font(28)
1878
+ lw, lh = self._text_size(draw, str(label), label_font)
1879
+ draw.text(((self._width - lw) // 2, 2), str(label),
1880
+ fill=1, font=label_font)
1881
+ display_val = f"{value}{unit}" if unit else str(value)
1882
+ vw, vh = self._text_size(draw, display_val, value_font)
1883
+ draw.text(((self._width - vw) // 2,
1884
+ (self._height - vh) // 2 + 6),
1885
+ display_val, fill=1, font=value_font)
1886
+ return self._push(img, tag="value")
1887
+
1888
+ def show_startup(self) -> Dict:
1889
+ if self._Image is None:
1890
+ return _err("PIL not available")
1891
+ lx, rx, ey = 35, 93, 25
1892
+ # Eyes grow open animation
1893
+ for size in (2, 5, 8, 10):
1894
+ img = self._new_image()
1895
+ draw = self._ImageDraw.Draw(img)
1896
+ draw.ellipse((lx - size, ey - size, lx + size, ey + size), fill=1)
1897
+ draw.ellipse((rx - size, ey - size, rx + size, ey + size), fill=1)
1898
+ self._push(img, tag=f"startup_{size}")
1899
+ time.sleep(0.1)
1900
+ # Happy face
1901
+ self.draw_face("happy")
1902
+ time.sleep(0.5)
1903
+ # AETHER v3 text
1904
+ self.display_text("AETHER v3", clear=True)
1905
+ time.sleep(1.0)
1906
+ return self.draw_face("neutral")
1907
+
1908
+
1559
1909
  # ── ToolBuilder ───────────────────────────────────────────────────────
1560
1910
 
1561
1911
  class ToolBuilder:
@@ -1606,6 +1956,19 @@ class ToolBuilder:
1606
1956
  cam = tools.get("camera")
1607
1957
  tools["tflite"] = TFLiteTool(camera_tool=cam)
1608
1958
 
1959
+ # OLEDTool — if OLED detected OR PIL installed (sim fallback)
1960
+ oled_info = hw.get("oled", {})
1961
+ oled_available = bool(oled_info.get("available", False))
1962
+ if oled_available or sw.get("PIL", False):
1963
+ pins = oled_info.get("pins", {})
1964
+ tools["oled"] = OLEDTool(
1965
+ spi_port=pins.get("port", 0),
1966
+ spi_device=pins.get("device", 0),
1967
+ gpio_dc=pins.get("DC", 25),
1968
+ gpio_rst=pins.get("RST", 24),
1969
+ force_sim=not oled_available,
1970
+ )
1971
+
1609
1972
  # MotorTool — always (falls back to simulation logging)
1610
1973
  motor_ctrls = self._manifest.get("motor_controllers", [])
1611
1974
  motor_config = {
@@ -160,6 +160,43 @@ def _probe_imu_i2c_dev() -> Tuple[bool, Dict]:
160
160
  return False, {"detail": "/dev/i2c-1 not found"}
161
161
 
162
162
 
163
+ def _probe_oled() -> Tuple[bool, Dict]:
164
+ """Detect SPI-based SSD1306/SSD1309 OLED on Raspberry Pi.
165
+
166
+ Checks /dev/spidev0.0 or /dev/spidev0.1 for SPI availability,
167
+ then verifies luma.oled can be imported.
168
+ Returns a three-state result:
169
+ - available=True: SPI device exists AND luma.oled importable
170
+ - spi_ready=True but available=False: SPI ok, library missing
171
+ - spi_ready=False: SPI interface not enabled
172
+ """
173
+ base_info = {
174
+ "interface": "spi",
175
+ "type": "SSD1306",
176
+ "resolution": "128x64",
177
+ "pins": {"DC": 25, "RST": 24, "port": 0, "device": 0},
178
+ }
179
+
180
+ # Check SPI device nodes
181
+ spi_ready = (os.path.exists("/dev/spidev0.0")
182
+ or os.path.exists("/dev/spidev0.1"))
183
+
184
+ if not spi_ready:
185
+ return False, {**base_info, "spi_ready": False,
186
+ "spi_not_enabled": True,
187
+ "detail": "SPI not enabled"}
188
+
189
+ # SPI device exists — check if luma.oled is importable
190
+ try:
191
+ from luma.oled.device import ssd1306 as _ # noqa: F401
192
+ return True, {**base_info, "spi_ready": True,
193
+ "spi_not_enabled": False}
194
+ except ImportError:
195
+ return False, {**base_info, "spi_ready": True,
196
+ "spi_not_enabled": False,
197
+ "detail": "luma.oled not installed"}
198
+
199
+
163
200
  # ── Motor Controller Detection ────────────────────────────────────────
164
201
  #
165
202
  # Priority order: MAVLink FC → PCA9685 I2C → GPIO direct → ROS → USB
@@ -609,6 +646,8 @@ _SOFTWARE_PACKAGES = [
609
646
  ("serial", "pyserial", 3),
610
647
  ("tflite_runtime","TFLite Runtime", 5),
611
648
  ("ultralytics","Ultralytics", 5),
649
+ ("PIL", "Pillow", 4),
650
+ ("luma.oled", "luma.oled", 3),
612
651
  ]
613
652
 
614
653
 
@@ -747,6 +786,10 @@ _CAPABILITY_ACTION_MAP: Dict[str, List[str]] = {
747
786
  "microphone": ["audio_capture"],
748
787
  "speaker": ["audio_playback"],
749
788
  "display": ["display_render"],
789
+ "oled_ssd1306": ["display_text", "draw_face", "animate_blink",
790
+ "animate_speaking", "draw_eyes", "show_value",
791
+ "scroll_text", "clear_oled", "display_image",
792
+ "show_animation", "show_startup"],
750
793
  "gpu": ["drl_inference", "gpu_compute"],
751
794
  "storage": ["file_ops"],
752
795
  # Software → actions
@@ -779,7 +822,7 @@ _PROBE_WEIGHTS: Dict[str, int] = {
779
822
  "gpio": 8, "gpiozero": 5, "i2c": 5,
780
823
  "mavlink": 8, "dronekit": 5,
781
824
  "imu_mpu6050": 6, "i2c_imu": 3,
782
- "microphone": 4, "speaker": 3, "display": 3,
825
+ "microphone": 4, "speaker": 3, "display": 3, "oled_ssd1306": 5,
783
826
  "storage": 5, "gpu": 8,
784
827
  "internet": 10, "local_network": 3,
785
828
  "numpy": 12, "anthropic": 12, "psutil": 8, "torch": 8,
@@ -902,6 +945,11 @@ class ToolDiscovery:
902
945
  hw["imu"] = {"available": False}
903
946
  self._record("imu_mpu6050", False, imu_info, CAT_HARDWARE)
904
947
 
948
+ # OLED display (SPI SSD1306/SSD1309)
949
+ oled_ok, oled_info = _probe_oled()
950
+ hw["oled"] = {"available": oled_ok, **oled_info}
951
+ self._record("oled_ssd1306", oled_ok, oled_info, CAT_HARDWARE)
952
+
905
953
  # Motor controller auto-detection (replaces simple mavlink probe)
906
954
  motor_controllers = _detect_motor_controllers(self._serial_ports)
907
955
  self._motor_controllers = motor_controllers
@@ -1132,7 +1180,7 @@ class ToolDiscovery:
1132
1180
  # Hardware
1133
1181
  print(f"\n Hardware:")
1134
1182
  hw_order = ["camera", "gpio", "i2c", "imu", "mavlink",
1135
- "audio", "display", "storage", "gpu"]
1183
+ "audio", "display", "oled", "storage", "gpu"]
1136
1184
  for key in hw_order:
1137
1185
  info = self._hardware.get(key, {})
1138
1186
  avail = info.get("available", False)
@@ -1150,8 +1198,19 @@ class ToolDiscovery:
1150
1198
  detail = f"{info.get('free_gb', '?')}GB free / {info.get('total_gb', '?')}GB"
1151
1199
  if key == "gpu":
1152
1200
  detail = info.get("device", info.get("type", ""))
1201
+ if key == "oled":
1202
+ iface = info.get("interface", "spi").upper()
1203
+ detail = f"{hw_type} {resolution} {iface}"
1153
1204
  else:
1154
- detail = info.get("detail", "not detected")
1205
+ if key == "oled":
1206
+ if info.get("spi_not_enabled"):
1207
+ detail = "SPI not enabled"
1208
+ elif info.get("spi_ready"):
1209
+ detail = "library not installed"
1210
+ else:
1211
+ detail = info.get("detail", "not detected")
1212
+ else:
1213
+ detail = info.get("detail", "not detected")
1155
1214
  print(f" [{icon}] {key:<14} {status:<6} {detail}")
1156
1215
 
1157
1216
  # Software
@@ -1207,6 +1266,14 @@ class ToolDiscovery:
1207
1266
  print(f" Unavailable: {', '.join(unavail[:10])}"
1208
1267
  + ("..." if len(unavail) > 10 else ""))
1209
1268
 
1269
+ # OLED SPI warning if on Pi with SPI disabled
1270
+ oled_hw = self._hardware.get("oled", {})
1271
+ if oled_hw.get("spi_not_enabled"):
1272
+ print(f"\n [OLED] SPI interface not enabled.")
1273
+ print(f" [OLED] To enable: sudo raspi-config → "
1274
+ f"Interface Options → SPI → Enable → Reboot")
1275
+ print(f" [OLED] Then reconnect and OLED will auto-configure.")
1276
+
1210
1277
  # Score bar
1211
1278
  score = self._score
1212
1279
  bar_len = 25
@@ -846,6 +846,19 @@ def register_built_tools(registry: "ToolRegistry",
846
846
  ("count_objects", "Count objects of a given class via YOLO detection"),
847
847
  ("describe_scene", "Describe everything visible (YOLO or Anthropic vision API)"),
848
848
  ],
849
+ "oled": [
850
+ ("display_text", "Display text on OLED (auto-wraps long text)"),
851
+ ("display_image", "Display an image file on OLED (auto-resized to 128x64)"),
852
+ ("clear_oled", "Clear the OLED screen"),
853
+ ("show_animation", "Play a list of image frames on OLED at given fps"),
854
+ ("draw_face", "Draw a robot face expression on OLED (neutral, happy, thinking, alert, sleeping, speaking)"),
855
+ ("draw_eyes", "Draw animated eyes on OLED (open, closed, squint, wide, wink_left, wink_right)"),
856
+ ("animate_blink", "Blink eyes N times on OLED"),
857
+ ("animate_speaking", "Animate mouth opening/closing for N seconds"),
858
+ ("scroll_text", "Scroll text across the OLED from right to left"),
859
+ ("show_value", "Display a large centered value with label and unit"),
860
+ ("show_startup", "Play OLED startup animation with AETHER logo"),
861
+ ],
849
862
  # storage methods overlap with existing ReadFileTool/WriteFileTool,
850
863
  # so we skip them to avoid name collisions
851
864
  }
@@ -880,6 +893,7 @@ def register_built_tools(registry: "ToolRegistry",
880
893
  "i2c": hw.get("i2c", {}),
881
894
  "imu": hw.get("imu", {}),
882
895
  "mavlink": hw.get("mavlink", {}),
896
+ "oled": hw.get("oled", {}),
883
897
  "serial_ports": hw.get("serial_ports", []),
884
898
  },
885
899
  "software": {k: v for k, v in sw.items()},
@@ -899,6 +913,8 @@ def register_built_tools(registry: "ToolRegistry",
899
913
  missing.append("IMU sensor (would enable: accelerometer, gyroscope, orientation)")
900
914
  if not hw.get("i2c", {}).get("available"):
901
915
  missing.append("I2C bus (would enable: external sensors, OLED displays)")
916
+ if not hw.get("oled", {}).get("available"):
917
+ missing.append("OLED display at I2C 0x3C/0x3D (would enable: on-device text, robot face, status readouts)")
902
918
  if not hw.get("gpu", {}).get("available"):
903
919
  missing.append("CUDA/MPS GPU (would enable: fast inference, ML training)")
904
920
  if not net.get("internet"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aether-robotics
3
- Version: 3.2.0
3
+ Version: 3.2.3
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.2.0"
7
+ version = "3.2.3"
8
8
  description = "Autonomous multi-agent robotics system with DRL-First Hybrid FDIR"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -1 +0,0 @@
1
- 3.2.0
File without changes
File without changes