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.
- {aether_robotics-3.2.0/aether_robotics.egg-info → aether_robotics-3.2.3}/PKG-INFO +1 -1
- aether_robotics-3.2.3/VERSION +1 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/__init__.py +1 -1
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/app.py +38 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/auto_installer.py +57 -1
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/llm_planner.py +9 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/navigation_engine.py +27 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/tool_builder.py +363 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/tool_discovery.py +70 -3
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/tool_registry.py +16 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3/aether_robotics.egg-info}/PKG-INFO +1 -1
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/pyproject.toml +1 -1
- aether_robotics-3.2.0/VERSION +0 -1
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/LICENSE +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/MANIFEST.in +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/README.md +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/__main__.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/actions/__init__.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/actions/abstract_actions.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/adapters/__init__.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/adapters/base_adapter.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/adapters/drone_adapter.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/adapters/rover_adapter.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/adapters/universal_adapter.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/__init__.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/adaptation_agent.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/camera_agent.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/correction_agent.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/execution_agent.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/fault_agent.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/memory_agent.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/movement_agent.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/navigation_agent.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/perception_agent.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/planner_agent.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/power_agent.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/task_manager.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/agents/thermal_agent.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/capabilities/__init__.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/capabilities/capability_loader.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/__init__.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/auto_updater.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/banner.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/calibration.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/executor.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/feedback.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/goal_parser.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/mapper.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/memory.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/message_bus.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/metrics.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/planner.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/task_scheduler.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/core/visualizer.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/faults/__init__.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/faults/fault_detector.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/faults/fault_injector.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/simulation/__init__.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/simulation/environment.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/simulation/real_perception.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether/simulation/scenarios.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether_robotics.egg-info/SOURCES.txt +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether_robotics.egg-info/dependency_links.txt +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether_robotics.egg-info/entry_points.txt +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether_robotics.egg-info/requires.txt +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether_robotics.egg-info/top_level.txt +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/calibrated_camera_only_20260331_121709.json +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/calibrated_camera_only_20260331_122446.json +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/calibrated_camera_only_20260401_094544.json +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/calibrated_camera_only_20260401_094554.json +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/drone_v1.json +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/rover_v1.json +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/context/aether_definitions.txt +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/install.sh +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/main.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/requirements.txt +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/setup.cfg +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_auto_updater.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_calibration.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_calibration_unit.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_chaos.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_mapper.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_navigation_engine.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_persistent_memory.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_security.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_task_scheduler.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_tool_builder.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_tool_discovery.py +0 -0
- {aether_robotics-3.2.0 → aether_robotics-3.2.3}/tests/test_yolo_integration.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.2.3
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
__version__ = "3.2.
|
|
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
|
|
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
|
-
|
|
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"):
|
aether_robotics-3.2.0/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
3.2.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aether_robotics-3.2.0 → aether_robotics-3.2.3}/aether_robotics.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/calibrated_camera_only_20260331_121709.json
RENAMED
|
File without changes
|
{aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/calibrated_camera_only_20260331_122446.json
RENAMED
|
File without changes
|
{aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/calibrated_camera_only_20260401_094544.json
RENAMED
|
File without changes
|
{aether_robotics-3.2.0 → aether_robotics-3.2.3}/configs/calibrated_camera_only_20260401_094554.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|