robocrew 0.0.1__py3-none-any.whl
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.
- robocrew/__init__.py +0 -0
- robocrew/core/LLMAgent.py +131 -0
- robocrew/core/sound_receiver.py +206 -0
- robocrew/core/tools.py +8 -0
- robocrew/core/utils.py +40 -0
- robocrew/robots/XLeRobot/dual_mapper_config.json +0 -0
- robocrew/robots/XLeRobot/sdk.py +410 -0
- robocrew/robots/XLeRobot/tools.py +43 -0
- robocrew/robots/XLeRobot/wheel_controls.py +284 -0
- robocrew-0.0.1.dist-info/METADATA +19 -0
- robocrew-0.0.1.dist-info/RECORD +12 -0
- robocrew-0.0.1.dist-info/WHEEL +4 -0
robocrew/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from robocrew.core.utils import horizontal_angle_grid
|
|
2
|
+
from robocrew.core.sound_receiver import SoundReceiver
|
|
3
|
+
from dotenv import find_dotenv, load_dotenv
|
|
4
|
+
import cv2
|
|
5
|
+
import base64
|
|
6
|
+
from langchain_core.tools import tool
|
|
7
|
+
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
|
|
8
|
+
from langchain.chat_models import init_chat_model
|
|
9
|
+
import queue
|
|
10
|
+
load_dotenv(find_dotenv())
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LLMAgent():
|
|
14
|
+
def __init__(self, model, tools, system_prompt=None, main_camera_usb_port=None, camera_fov=120, sounddevice_index=None, wakeword="robot", history_len=None):
|
|
15
|
+
"""
|
|
16
|
+
model: name of the model to use
|
|
17
|
+
tools: list of langchain tools
|
|
18
|
+
system_prompt: custom system prompt - optional
|
|
19
|
+
main_camera_usb_port: provide usb port of your robot front camera if you want to use it.
|
|
20
|
+
camera_fov: field of view (degrees) of your main camera.
|
|
21
|
+
sounddevice_index: provide sounddevice index of your microphone if you want robot to hear.
|
|
22
|
+
wakeword: custom wakeword hearing which robot will set your sentence as a task o do.
|
|
23
|
+
history_len: if you want agent to have messages history cuttof, provide number of newest request-response pairs to keep.
|
|
24
|
+
"""
|
|
25
|
+
base_system_prompt = "You are mobile robot with two arms."
|
|
26
|
+
self.task = "You are standing in a room. Explore the environment, find a backpack and approach it."
|
|
27
|
+
system_prompt = system_prompt or base_system_prompt
|
|
28
|
+
llm = init_chat_model(model)
|
|
29
|
+
self.llm = llm.bind_tools(tools, parallel_tool_calls=False)
|
|
30
|
+
self.tools = tools
|
|
31
|
+
self.system_message = SystemMessage(content=system_prompt)
|
|
32
|
+
self.message_history = [self.system_message]
|
|
33
|
+
# cameras
|
|
34
|
+
self.main_camera = cv2.VideoCapture(main_camera_usb_port) if main_camera_usb_port else None
|
|
35
|
+
self.hitory_len = history_len
|
|
36
|
+
if self.main_camera:
|
|
37
|
+
self.main_camera.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
|
38
|
+
self.camera_fov = camera_fov
|
|
39
|
+
self.sounddevice_index = sounddevice_index
|
|
40
|
+
if self.sounddevice_index is not None:
|
|
41
|
+
self.task_queue = queue.Queue()
|
|
42
|
+
self.sound_receiver = SoundReceiver(sounddevice_index, self.task_queue, wakeword)
|
|
43
|
+
# self.task = ""
|
|
44
|
+
|
|
45
|
+
def capture_image(self):
|
|
46
|
+
self.main_camera.grab() # Clear the buffer
|
|
47
|
+
_, frame = self.main_camera.read()
|
|
48
|
+
frame = horizontal_angle_grid(frame, h_fov=self.camera_fov)
|
|
49
|
+
_, buffer = cv2.imencode('.jpg', frame)
|
|
50
|
+
return buffer.tobytes()
|
|
51
|
+
|
|
52
|
+
def invoke_tool(self, tool_call):
|
|
53
|
+
# convert string to real function
|
|
54
|
+
tool_name_to_tool = {tool.name: tool for tool in self.tools}
|
|
55
|
+
name = tool_call["name"]
|
|
56
|
+
requested_tool = tool_name_to_tool[name]
|
|
57
|
+
args = tool_call["args"]
|
|
58
|
+
tool_output = requested_tool.invoke(args)
|
|
59
|
+
return ToolMessage(tool_output, tool_call_id=tool_call["id"])
|
|
60
|
+
|
|
61
|
+
def cut_off_context(self, nr_of_loops):
|
|
62
|
+
"""
|
|
63
|
+
Trims the message history in the state to keep only the most recent context for the agent.
|
|
64
|
+
"""
|
|
65
|
+
ai_indices = [i for i, msg in enumerate(self.message_history) if msg.type == "human"]
|
|
66
|
+
if len(ai_indices) >= nr_of_loops:
|
|
67
|
+
start_index = ai_indices[-nr_of_loops]
|
|
68
|
+
self.message_history = [self.system_message] + self.message_history[start_index:]
|
|
69
|
+
|
|
70
|
+
def check_for_new_task(self):
|
|
71
|
+
"""Non-blockingly checks the queue for a new task."""
|
|
72
|
+
if not self.task_queue.empty():
|
|
73
|
+
self.task = self.task_queue.get()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def go(self):
|
|
77
|
+
while True:
|
|
78
|
+
if self.main_camera:
|
|
79
|
+
image_bytes = self.capture_image()
|
|
80
|
+
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
|
|
81
|
+
|
|
82
|
+
message = HumanMessage(
|
|
83
|
+
content=[
|
|
84
|
+
{"type": "text", "text": "Here is the current view from your main camera. Use it to understand your current status."},
|
|
85
|
+
{
|
|
86
|
+
"type": "image_url",
|
|
87
|
+
"image_url": {"url": f"data:image/jpeg;base64,{image_base64}"}
|
|
88
|
+
},
|
|
89
|
+
{"type": "text", "text": f"Your task is: '{self.task}'"}
|
|
90
|
+
]
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
message = HumanMessage(content=f"Your task is: '{self.task}'")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
self.message_history.append(message)
|
|
98
|
+
response = self.llm.invoke(self.message_history)
|
|
99
|
+
print(response.content)
|
|
100
|
+
print(response.tool_calls)
|
|
101
|
+
self.message_history.append(response)
|
|
102
|
+
if self.hitory_len:
|
|
103
|
+
self.cut_off_context(self.hitory_len)
|
|
104
|
+
|
|
105
|
+
# execute tool
|
|
106
|
+
for tool_call in response.tool_calls:
|
|
107
|
+
tool_response = self.invoke_tool(tool_call)
|
|
108
|
+
self.message_history.append(tool_response)
|
|
109
|
+
if tool_call["name"] == "finish_task":
|
|
110
|
+
return "Task finished, going idle."
|
|
111
|
+
|
|
112
|
+
if self.sounddevice_index:
|
|
113
|
+
self.check_for_new_task()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
|
|
118
|
+
@tool
|
|
119
|
+
def do_nothing() -> str:
|
|
120
|
+
"""does nothing at all"""
|
|
121
|
+
print("Doing nothing...")
|
|
122
|
+
return "Doing nothing."
|
|
123
|
+
|
|
124
|
+
agent = LLMAgent(
|
|
125
|
+
model="google_genai:gemini-robotics-er-1.5-preview",
|
|
126
|
+
tools=[
|
|
127
|
+
do_nothing,
|
|
128
|
+
],
|
|
129
|
+
)
|
|
130
|
+
result = agent.go()
|
|
131
|
+
print(result)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import pyaudio
|
|
2
|
+
import wave
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
import audioop
|
|
6
|
+
import time
|
|
7
|
+
from openai import OpenAI
|
|
8
|
+
import io
|
|
9
|
+
from dotenv import find_dotenv, load_dotenv
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
load_dotenv(find_dotenv())
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SoundReceiver:
|
|
16
|
+
def __init__(self, sounddevice_index, task_queue=None, wakeword="robot"):
|
|
17
|
+
self.FORMAT = pyaudio.paInt16
|
|
18
|
+
self.CHANNELS = 1
|
|
19
|
+
self.RATE = 48000
|
|
20
|
+
self.BUFFER_SECONDS = 2
|
|
21
|
+
self.frames_per_buffer = 2048
|
|
22
|
+
self.recording_loop_delay = 0.2
|
|
23
|
+
# parse DEVICE_INDEX env var into an int if present, else None
|
|
24
|
+
self.DEVICE_INDEX = sounddevice_index
|
|
25
|
+
self.wakeword = wakeword
|
|
26
|
+
|
|
27
|
+
self._p = pyaudio.PyAudio()
|
|
28
|
+
self._sample_width = self._p.get_sample_size(self.FORMAT)
|
|
29
|
+
self._bytes_per_second = int(self.RATE * self.CHANNELS * self._sample_width)
|
|
30
|
+
self._buffer_capacity_bytes = int(self._bytes_per_second * self.BUFFER_SECONDS)
|
|
31
|
+
self.task_queue = task_queue
|
|
32
|
+
|
|
33
|
+
self._buffer = bytearray(self._buffer_capacity_bytes)
|
|
34
|
+
self._write_pos = 0
|
|
35
|
+
self._has_wrapped = False
|
|
36
|
+
self._lock = threading.RLock()
|
|
37
|
+
|
|
38
|
+
self._stream = None
|
|
39
|
+
self._listening = False
|
|
40
|
+
self._recording = False
|
|
41
|
+
self.RMS_THRESHOLD = 400.0
|
|
42
|
+
self.reciver_thread = threading.Thread(target=self._recorder_loop)
|
|
43
|
+
self.reciver_thread.daemon = True
|
|
44
|
+
self.recorded_frames = []
|
|
45
|
+
self.first_timestamp_below_threshold = None
|
|
46
|
+
self.num_recorded_buffers = 0
|
|
47
|
+
self.openai_client = OpenAI()
|
|
48
|
+
self.start_listening()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _write_to_buffer(self, data: bytes):
|
|
52
|
+
with self._lock:
|
|
53
|
+
n = len(data)
|
|
54
|
+
if n == 0:
|
|
55
|
+
return
|
|
56
|
+
end_space = self._buffer_capacity_bytes - self._write_pos
|
|
57
|
+
if n <= end_space:
|
|
58
|
+
self._buffer[self._write_pos:self._write_pos + n] = data
|
|
59
|
+
self._write_pos += n
|
|
60
|
+
if self._write_pos == self._buffer_capacity_bytes:
|
|
61
|
+
self._write_pos = 0
|
|
62
|
+
self._has_wrapped = True
|
|
63
|
+
else:
|
|
64
|
+
self._buffer[self._write_pos:] = data[:end_space]
|
|
65
|
+
rest = n - end_space
|
|
66
|
+
self._buffer[0:rest] = data[end_space:]
|
|
67
|
+
self._write_pos = rest
|
|
68
|
+
self._has_wrapped = True
|
|
69
|
+
|
|
70
|
+
def _buffer_write_callback(self, in_data, frame_count, time_info, status):
|
|
71
|
+
if in_data:
|
|
72
|
+
self._write_to_buffer(in_data)
|
|
73
|
+
if self._recording:
|
|
74
|
+
with self._lock:
|
|
75
|
+
self.recorded_frames.append(in_data)
|
|
76
|
+
self.num_recorded_buffers = self.num_recorded_buffers+1
|
|
77
|
+
return (None, pyaudio.paContinue)
|
|
78
|
+
|
|
79
|
+
def _recorder_loop(self):
|
|
80
|
+
while self._listening:
|
|
81
|
+
loop_start_time = time.perf_counter()
|
|
82
|
+
# print(f"rms: {self.get_rms()}")
|
|
83
|
+
if not self._recording:
|
|
84
|
+
if self.get_rms() > self.RMS_THRESHOLD:
|
|
85
|
+
self._recording = True
|
|
86
|
+
pre_roll_data = self.get_last_recorded_bytes(2.0)
|
|
87
|
+
with self._lock:
|
|
88
|
+
self.recorded_frames = [pre_roll_data]
|
|
89
|
+
else:
|
|
90
|
+
|
|
91
|
+
if self.get_rms() < self.RMS_THRESHOLD:
|
|
92
|
+
if self.first_timestamp_below_threshold is None:
|
|
93
|
+
self.first_timestamp_below_threshold = time.time()
|
|
94
|
+
elif time.time() - self.first_timestamp_below_threshold > 2.0:
|
|
95
|
+
self._recording = False
|
|
96
|
+
self.first_timestamp_below_threshold = None
|
|
97
|
+
# TUTAJ WHISPER BIERZE RECORDED FRAMES
|
|
98
|
+
with self._lock:
|
|
99
|
+
audio_data = b''.join(self.recorded_frames)
|
|
100
|
+
self.recorded_frames = []
|
|
101
|
+
|
|
102
|
+
print("Transcribing recorded audio...")
|
|
103
|
+
threading.Thread(
|
|
104
|
+
target=self._transcribe_audio,
|
|
105
|
+
args=(audio_data,)
|
|
106
|
+
).start()
|
|
107
|
+
else:
|
|
108
|
+
self.first_timestamp_below_threshold = None
|
|
109
|
+
|
|
110
|
+
loop_execution_time = time.perf_counter() - loop_start_time
|
|
111
|
+
time.sleep(self.recording_loop_delay - loop_execution_time)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _transcribe_audio(self, audio_data: bytes) -> str:
|
|
115
|
+
print(f"Buffer counter: {self.num_recorded_buffers}")
|
|
116
|
+
# ONLY FOR NOW - TO AVOID SHORT WHEEL NOISES
|
|
117
|
+
if self.num_recorded_buffers < 200: # Check for minimum audio length
|
|
118
|
+
print("Audio data too short to transcribe.")
|
|
119
|
+
return
|
|
120
|
+
self.num_recorded_buffers = 0
|
|
121
|
+
ram_buffer = io.BytesIO()
|
|
122
|
+
ram_buffer.name = "recorded.wav"
|
|
123
|
+
with wave.open(ram_buffer, "wb") as wf:
|
|
124
|
+
wf.setnchannels(self.CHANNELS)
|
|
125
|
+
wf.setsampwidth(self._sample_width)
|
|
126
|
+
wf.setframerate(self.RATE)
|
|
127
|
+
wf.writeframes(audio_data)
|
|
128
|
+
ram_buffer.seek(0)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
transcription = self.openai_client.audio.transcriptions.create(
|
|
132
|
+
model="gpt-4o-transcribe",
|
|
133
|
+
file=ram_buffer
|
|
134
|
+
)
|
|
135
|
+
if transcription.text: # If transcription is not ""
|
|
136
|
+
print(f"transcription: {transcription.text}")
|
|
137
|
+
if self.wakeword in transcription.text.lower():
|
|
138
|
+
self.task_queue.put(transcription.text)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def start_listening(self):
|
|
142
|
+
print(f"Starting SoundReceiver on device index {self.DEVICE_INDEX}")
|
|
143
|
+
if self._listening:
|
|
144
|
+
return
|
|
145
|
+
try:
|
|
146
|
+
self._stream = self._p.open(format=self.FORMAT,
|
|
147
|
+
channels=self.CHANNELS,
|
|
148
|
+
rate=self.RATE,
|
|
149
|
+
input=True,
|
|
150
|
+
input_device_index=self.DEVICE_INDEX,
|
|
151
|
+
frames_per_buffer=self.frames_per_buffer,
|
|
152
|
+
stream_callback=self._buffer_write_callback)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
raise RuntimeError(f"Failed to open input stream: {e}")
|
|
155
|
+
self._stream.start_stream()
|
|
156
|
+
self._listening = True
|
|
157
|
+
self.reciver_thread.start()
|
|
158
|
+
|
|
159
|
+
def stop(self):
|
|
160
|
+
if not self._listening:
|
|
161
|
+
return
|
|
162
|
+
try:
|
|
163
|
+
if self._stream is not None:
|
|
164
|
+
self._stream.stop_stream()
|
|
165
|
+
self._stream.close()
|
|
166
|
+
self._stream = None
|
|
167
|
+
finally:
|
|
168
|
+
try:
|
|
169
|
+
self._p.terminate()
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
self._listening = False
|
|
173
|
+
|
|
174
|
+
def is_listening(self):
|
|
175
|
+
return self._listening
|
|
176
|
+
|
|
177
|
+
def get_buffer_bytes(self) -> bytes:
|
|
178
|
+
with self._lock:
|
|
179
|
+
if not self._has_wrapped:
|
|
180
|
+
return bytes(self._buffer[:self._write_pos])
|
|
181
|
+
return bytes(self._buffer[self._write_pos:] + self._buffer[:self._write_pos])
|
|
182
|
+
|
|
183
|
+
def get_last_recorded_bytes(self, seconds: float) -> bytes:
|
|
184
|
+
bytes_needed = int(min(seconds * self._bytes_per_second, self._buffer_capacity_bytes))
|
|
185
|
+
data = self.get_buffer_bytes()
|
|
186
|
+
if len(data) <= bytes_needed:
|
|
187
|
+
return data
|
|
188
|
+
return data[-bytes_needed:]
|
|
189
|
+
|
|
190
|
+
# RMS helpers
|
|
191
|
+
def get_rms(self) -> float:
|
|
192
|
+
"""Return RMS level for raw PCM bytes (paInt16 width expected)."""
|
|
193
|
+
return float(audioop.rms(self.get_last_recorded_bytes(seconds=0.2), self._sample_width))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# Minimal CLI demo
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
rec = SoundReceiver()
|
|
199
|
+
try:
|
|
200
|
+
# optionally set DEVICE_INDEX env var before running, or pass device_index to start_listening
|
|
201
|
+
rec.start_listening() # non-blocking
|
|
202
|
+
input("Recording... press Enter to stop and save buffer to recent_from_buffer.wav\n")
|
|
203
|
+
print("Saving last 5 seconds to recent_from_buffer.wav")
|
|
204
|
+
|
|
205
|
+
finally:
|
|
206
|
+
rec.stop()
|
robocrew/core/tools.py
ADDED
robocrew/core/utils.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import math
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def horizontal_angle_grid(image, h_fov=120, center_angle=0):
|
|
6
|
+
"""Draw horizontal angle markers on the bottom of the image."""
|
|
7
|
+
height, width = image.shape[:2]
|
|
8
|
+
yellow = (0, 255, 255)
|
|
9
|
+
orange = (0, 100, 255)
|
|
10
|
+
y_pos = 25
|
|
11
|
+
mark_len_angle = 10
|
|
12
|
+
|
|
13
|
+
# Draw baseline
|
|
14
|
+
cv2.line(image, (0, y_pos), (width, y_pos), yellow, 2)
|
|
15
|
+
|
|
16
|
+
# Generate markers every 5° within visible range
|
|
17
|
+
nr_of_marks = int((h_fov / 2) // mark_len_angle * 2 + 1)
|
|
18
|
+
pixels_per_mark = width / h_fov * mark_len_angle
|
|
19
|
+
start_pixel = (width - (nr_of_marks - 1) * pixels_per_mark) / 2
|
|
20
|
+
start_angle = (-h_fov / 2 + center_angle)
|
|
21
|
+
start_angle = mark_len_angle * math.trunc(start_angle / mark_len_angle)
|
|
22
|
+
|
|
23
|
+
for mark_number in range(nr_of_marks):
|
|
24
|
+
x = int(start_pixel + mark_number * pixels_per_mark)
|
|
25
|
+
angle = start_angle + mark_number * mark_len_angle
|
|
26
|
+
cv2.line(image, (x, y_pos - 10), (x, y_pos + 10), yellow, 2)
|
|
27
|
+
cv2.putText(image, f"{angle}", (x - 15, y_pos + 25),
|
|
28
|
+
cv2.FONT_HERSHEY_SIMPLEX, 0.6, yellow, 2)
|
|
29
|
+
|
|
30
|
+
# put right/left text
|
|
31
|
+
cv2.putText(image, "<=LEFT", (10, height - 10), cv2.FONT_HERSHEY_SIMPLEX, 1, orange, 2)
|
|
32
|
+
cv2.putText(image, "RIGHT=>", (width - 145, height - 10), cv2.FONT_HERSHEY_SIMPLEX, 1, yellow, 2)
|
|
33
|
+
return image
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
# Test the function with a sample image
|
|
37
|
+
img = cv2.imread("img.png")
|
|
38
|
+
img_with_grid = horizontal_angle_grid(img, h_fov=118)
|
|
39
|
+
# write to file
|
|
40
|
+
cv2.imwrite("img_with_grid.jpg", img_with_grid)
|
|
File without changes
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
import serial # type: ignore[import]
|
|
5
|
+
import serial.tools.list_ports # type: ignore[import]
|
|
6
|
+
|
|
7
|
+
# Servo protocol constants ----------------------------------------------------
|
|
8
|
+
|
|
9
|
+
BROADCAST_ID = 0xFE
|
|
10
|
+
|
|
11
|
+
INST_WRITE = 3
|
|
12
|
+
INST_SYNC_WRITE = 0x83
|
|
13
|
+
|
|
14
|
+
# Comm results
|
|
15
|
+
COMM_SUCCESS = 0
|
|
16
|
+
COMM_PORT_BUSY = -1
|
|
17
|
+
COMM_TX_FAIL = -2
|
|
18
|
+
COMM_RX_FAIL = -3
|
|
19
|
+
COMM_TX_ERROR = -4
|
|
20
|
+
COMM_RX_WAITING = -5
|
|
21
|
+
COMM_RX_TIMEOUT = -6
|
|
22
|
+
COMM_RX_CORRUPT = -7
|
|
23
|
+
COMM_NOT_AVAILABLE = -9
|
|
24
|
+
|
|
25
|
+
TXPACKET_MAX_LEN = 250
|
|
26
|
+
RXPACKET_MAX_LEN = 250
|
|
27
|
+
|
|
28
|
+
# Packet positions
|
|
29
|
+
PKT_HEADER0 = 0
|
|
30
|
+
PKT_HEADER1 = 1
|
|
31
|
+
PKT_ID = 2
|
|
32
|
+
PKT_LENGTH = 3
|
|
33
|
+
PKT_INSTRUCTION = 4
|
|
34
|
+
PKT_ERROR = 4
|
|
35
|
+
PKT_PARAMETER0 = 5
|
|
36
|
+
|
|
37
|
+
# Control table addresses
|
|
38
|
+
ADDR_SCS_GOAL_SPEED = 46
|
|
39
|
+
ADDR_SCS_MODE = 33
|
|
40
|
+
ADDR_SCS_LOCK = 55
|
|
41
|
+
|
|
42
|
+
DEFAULT_BAUDRATE = 1_000_000
|
|
43
|
+
LATENCY_TIMER = 16
|
|
44
|
+
|
|
45
|
+
# Endianness flag like JS (STS/SMS=0, SCS=1). Most STS/SMS use 0.
|
|
46
|
+
SCS_END = 0
|
|
47
|
+
|
|
48
|
+
def SCS_LOBYTE(w: int) -> int:
|
|
49
|
+
return (w & 0xFF) if SCS_END == 0 else ((w >> 8) & 0xFF)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def SCS_HIBYTE(w: int) -> int:
|
|
53
|
+
return ((w >> 8) & 0xFF) if SCS_END == 0 else (w & 0xFF)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PortHandler:
|
|
57
|
+
def __init__(self, port: Optional[str] = None, baudrate: int = DEFAULT_BAUDRATE):
|
|
58
|
+
self.port_name = port
|
|
59
|
+
self.baudrate = baudrate
|
|
60
|
+
self.ser: Optional[serial.Serial] = None
|
|
61
|
+
self.is_open = False
|
|
62
|
+
self.is_using = False
|
|
63
|
+
self.packet_start_time = 0.0
|
|
64
|
+
self.packet_timeout = 0.0
|
|
65
|
+
self.tx_time_per_byte = 0.0
|
|
66
|
+
|
|
67
|
+
def set_port(self, port: str):
|
|
68
|
+
self.port_name = port
|
|
69
|
+
|
|
70
|
+
def set_baudrate(self, baudrate: int):
|
|
71
|
+
self.baudrate = baudrate
|
|
72
|
+
self.tx_time_per_byte = (1000.0 / self.baudrate) * 10.0
|
|
73
|
+
|
|
74
|
+
def open_port(self) -> bool:
|
|
75
|
+
if not self.port_name:
|
|
76
|
+
return False
|
|
77
|
+
try:
|
|
78
|
+
self.ser = serial.Serial(self.port_name, self.baudrate, timeout=0, write_timeout=1)
|
|
79
|
+
self.is_open = True
|
|
80
|
+
self.tx_time_per_byte = (1000.0 / self.baudrate) * 10.0
|
|
81
|
+
return True
|
|
82
|
+
except Exception:
|
|
83
|
+
self.ser = None
|
|
84
|
+
self.is_open = False
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def close_port(self):
|
|
88
|
+
try:
|
|
89
|
+
if self.ser and self.ser.is_open:
|
|
90
|
+
self.ser.close()
|
|
91
|
+
finally:
|
|
92
|
+
self.is_open = False
|
|
93
|
+
self.ser = None
|
|
94
|
+
|
|
95
|
+
def clear_port(self):
|
|
96
|
+
if self.ser:
|
|
97
|
+
self.ser.reset_input_buffer()
|
|
98
|
+
self.ser.reset_output_buffer()
|
|
99
|
+
|
|
100
|
+
def write_port(self, data: List[int]) -> int:
|
|
101
|
+
if not (self.is_open and self.ser):
|
|
102
|
+
return 0
|
|
103
|
+
try:
|
|
104
|
+
out = bytes(byte & 0xFF for byte in data)
|
|
105
|
+
return self.ser.write(out)
|
|
106
|
+
except Exception:
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
def read_port(self, length: int) -> List[int]:
|
|
110
|
+
if not (self.is_open and self.ser):
|
|
111
|
+
return []
|
|
112
|
+
result: List[int] = []
|
|
113
|
+
start = time.time()
|
|
114
|
+
total_timeout = 0.5 # seconds
|
|
115
|
+
while len(result) < length:
|
|
116
|
+
if (time.time() - start) > total_timeout:
|
|
117
|
+
break
|
|
118
|
+
try:
|
|
119
|
+
chunk = self.ser.read(length - len(result))
|
|
120
|
+
if chunk:
|
|
121
|
+
result.extend(chunk)
|
|
122
|
+
else:
|
|
123
|
+
time.sleep(0.01)
|
|
124
|
+
except Exception:
|
|
125
|
+
break
|
|
126
|
+
return list(result)
|
|
127
|
+
|
|
128
|
+
def set_packet_timeout(self, packet_length: int):
|
|
129
|
+
self.packet_start_time = time.time()
|
|
130
|
+
self.packet_timeout = (self.tx_time_per_byte * packet_length + LATENCY_TIMER * 2.0 + 2.0) / 1000.0
|
|
131
|
+
|
|
132
|
+
def set_packet_timeout_millis(self, msec: float):
|
|
133
|
+
self.packet_start_time = time.time()
|
|
134
|
+
self.packet_timeout = msec / 1000.0
|
|
135
|
+
|
|
136
|
+
def is_packet_timeout(self) -> bool:
|
|
137
|
+
if (time.time() - self.packet_start_time) > self.packet_timeout:
|
|
138
|
+
self.packet_timeout = 0.0
|
|
139
|
+
return True
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class PacketHandler:
|
|
144
|
+
def __init__(self, protocol_end: int = 0):
|
|
145
|
+
global SCS_END
|
|
146
|
+
SCS_END = protocol_end
|
|
147
|
+
|
|
148
|
+
def get_tx_rx_result(self, result: int) -> str:
|
|
149
|
+
mapping = {
|
|
150
|
+
COMM_SUCCESS: "Communication success",
|
|
151
|
+
COMM_PORT_BUSY: "Port in use",
|
|
152
|
+
COMM_TX_FAIL: "TX failed",
|
|
153
|
+
COMM_RX_FAIL: "RX failed",
|
|
154
|
+
COMM_TX_ERROR: "TX packet error",
|
|
155
|
+
COMM_RX_WAITING: "RX waiting",
|
|
156
|
+
COMM_RX_TIMEOUT: "RX timeout",
|
|
157
|
+
COMM_RX_CORRUPT: "RX corrupt",
|
|
158
|
+
COMM_NOT_AVAILABLE: "Not available",
|
|
159
|
+
}
|
|
160
|
+
return mapping.get(result, str(result))
|
|
161
|
+
|
|
162
|
+
def tx_packet(self, port: PortHandler, txpacket: List[int]) -> int:
|
|
163
|
+
checksum = 0
|
|
164
|
+
total_len = txpacket[PKT_LENGTH] + 4
|
|
165
|
+
if port.is_using:
|
|
166
|
+
return COMM_PORT_BUSY
|
|
167
|
+
port.is_using = True
|
|
168
|
+
|
|
169
|
+
if total_len > TXPACKET_MAX_LEN:
|
|
170
|
+
port.is_using = False
|
|
171
|
+
return COMM_TX_ERROR
|
|
172
|
+
|
|
173
|
+
txpacket[PKT_HEADER0] = 0xFF
|
|
174
|
+
txpacket[PKT_HEADER1] = 0xFF
|
|
175
|
+
|
|
176
|
+
for idx in range(2, total_len - 1):
|
|
177
|
+
checksum += txpacket[idx]
|
|
178
|
+
txpacket[total_len - 1] = (~checksum) & 0xFF
|
|
179
|
+
|
|
180
|
+
port.clear_port()
|
|
181
|
+
written = port.write_port(txpacket)
|
|
182
|
+
if written != total_len:
|
|
183
|
+
port.is_using = False
|
|
184
|
+
return COMM_TX_FAIL
|
|
185
|
+
return COMM_SUCCESS
|
|
186
|
+
|
|
187
|
+
def rx_packet(self, port: PortHandler):
|
|
188
|
+
rxpacket: List[int] = []
|
|
189
|
+
result = COMM_RX_FAIL
|
|
190
|
+
wait_length = 6
|
|
191
|
+
|
|
192
|
+
while True:
|
|
193
|
+
data = port.read_port(wait_length - len(rxpacket))
|
|
194
|
+
rxpacket.extend(data)
|
|
195
|
+
|
|
196
|
+
if len(rxpacket) >= wait_length:
|
|
197
|
+
header_index = -1
|
|
198
|
+
for i in range(0, len(rxpacket) - 1):
|
|
199
|
+
if rxpacket[i] == 0xFF and rxpacket[i + 1] == 0xFF:
|
|
200
|
+
header_index = i
|
|
201
|
+
break
|
|
202
|
+
if header_index == 0:
|
|
203
|
+
if rxpacket[PKT_ID] > 0xFD or rxpacket[PKT_LENGTH] > RXPACKET_MAX_LEN:
|
|
204
|
+
rxpacket.pop(0)
|
|
205
|
+
continue
|
|
206
|
+
if wait_length != (rxpacket[PKT_LENGTH] + PKT_LENGTH + 1):
|
|
207
|
+
wait_length = rxpacket[PKT_LENGTH] + PKT_LENGTH + 1
|
|
208
|
+
continue
|
|
209
|
+
if len(rxpacket) < wait_length:
|
|
210
|
+
if port.is_packet_timeout():
|
|
211
|
+
result = COMM_RX_TIMEOUT if len(rxpacket) == 0 else COMM_RX_CORRUPT
|
|
212
|
+
break
|
|
213
|
+
continue
|
|
214
|
+
checksum = 0
|
|
215
|
+
for i in range(2, wait_length - 1):
|
|
216
|
+
checksum += rxpacket[i]
|
|
217
|
+
checksum = (~checksum) & 0xFF
|
|
218
|
+
result = COMM_SUCCESS if rxpacket[wait_length - 1] == checksum else COMM_RX_CORRUPT
|
|
219
|
+
break
|
|
220
|
+
elif header_index > 0:
|
|
221
|
+
rxpacket = rxpacket[header_index:]
|
|
222
|
+
continue
|
|
223
|
+
if port.is_packet_timeout():
|
|
224
|
+
result = COMM_RX_TIMEOUT if len(rxpacket) == 0 else COMM_RX_CORRUPT
|
|
225
|
+
break
|
|
226
|
+
return rxpacket, result
|
|
227
|
+
|
|
228
|
+
def tx_rx_packet(self, port: PortHandler, txpacket: List[int]):
|
|
229
|
+
rxpacket: Optional[List[int]] = None
|
|
230
|
+
error = 0
|
|
231
|
+
result = self.tx_packet(port, txpacket)
|
|
232
|
+
if result != COMM_SUCCESS:
|
|
233
|
+
port.is_using = False
|
|
234
|
+
return rxpacket, result, error
|
|
235
|
+
|
|
236
|
+
if txpacket[PKT_ID] == BROADCAST_ID:
|
|
237
|
+
port.is_using = False
|
|
238
|
+
return rxpacket, result, error
|
|
239
|
+
|
|
240
|
+
port.set_packet_timeout(10)
|
|
241
|
+
|
|
242
|
+
port.clear_port()
|
|
243
|
+
rxpacket, rx_res = self.rx_packet(port)
|
|
244
|
+
|
|
245
|
+
if rx_res != COMM_SUCCESS or not rxpacket:
|
|
246
|
+
port.is_using = False
|
|
247
|
+
return rxpacket, rx_res, error
|
|
248
|
+
|
|
249
|
+
if len(rxpacket) < 6 or rxpacket[PKT_ID] != txpacket[PKT_ID]:
|
|
250
|
+
port.is_using = False
|
|
251
|
+
return rxpacket, COMM_RX_CORRUPT, error
|
|
252
|
+
|
|
253
|
+
error = rxpacket[PKT_ERROR]
|
|
254
|
+
port.is_using = False
|
|
255
|
+
return rxpacket, rx_res, error
|
|
256
|
+
|
|
257
|
+
# Write helpers
|
|
258
|
+
def write_tx_rx(self, port: PortHandler, scs_id: int, address: int, data: List[int]):
|
|
259
|
+
if scs_id >= BROADCAST_ID:
|
|
260
|
+
return COMM_NOT_AVAILABLE, 0
|
|
261
|
+
txpacket = [0] * (len(data) + 7)
|
|
262
|
+
txpacket[PKT_ID] = scs_id
|
|
263
|
+
txpacket[PKT_LENGTH] = len(data) + 3
|
|
264
|
+
txpacket[PKT_INSTRUCTION] = INST_WRITE
|
|
265
|
+
txpacket[PKT_PARAMETER0] = address
|
|
266
|
+
for i, value in enumerate(data):
|
|
267
|
+
txpacket[PKT_PARAMETER0 + 1 + i] = value & 0xFF
|
|
268
|
+
rxpacket, result, error = self.tx_rx_packet(port, txpacket)
|
|
269
|
+
return result, error
|
|
270
|
+
|
|
271
|
+
def write1(self, port: PortHandler, scs_id: int, address: int, data: int):
|
|
272
|
+
return self.write_tx_rx(port, scs_id, address, [data & 0xFF])
|
|
273
|
+
|
|
274
|
+
def write2(self, port: PortHandler, scs_id: int, address: int, data: int):
|
|
275
|
+
arr = [SCS_LOBYTE(data), SCS_HIBYTE(data)]
|
|
276
|
+
return self.write_tx_rx(port, scs_id, address, arr)
|
|
277
|
+
|
|
278
|
+
# Sync ops (TX only)
|
|
279
|
+
def sync_write_tx_only(self, port: PortHandler, start_address: int, data_len: int, param: List[int]) -> int:
|
|
280
|
+
txpacket = [0] * (len(param) + 8)
|
|
281
|
+
txpacket[PKT_ID] = BROADCAST_ID
|
|
282
|
+
txpacket[PKT_LENGTH] = len(param) + 4
|
|
283
|
+
txpacket[PKT_INSTRUCTION] = INST_SYNC_WRITE
|
|
284
|
+
txpacket[PKT_PARAMETER0] = start_address
|
|
285
|
+
txpacket[PKT_PARAMETER0 + 1] = data_len
|
|
286
|
+
for i, b in enumerate(param):
|
|
287
|
+
txpacket[PKT_PARAMETER0 + 2 + i] = b & 0xFF
|
|
288
|
+
# build checksum and send
|
|
289
|
+
res = self.tx_packet(port, txpacket)
|
|
290
|
+
port.is_using = False
|
|
291
|
+
return res
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class GroupSyncWrite:
|
|
295
|
+
def __init__(self, port: PortHandler, ph: PacketHandler, start_address: int, data_length: int):
|
|
296
|
+
self.port = port
|
|
297
|
+
self.ph = ph
|
|
298
|
+
self.start_address = start_address
|
|
299
|
+
self.data_length = data_length
|
|
300
|
+
self.ids: List[int] = []
|
|
301
|
+
self.data: Dict[int, List[int]] = {}
|
|
302
|
+
|
|
303
|
+
def add_param(self, scs_id: int, data: List[int]) -> bool:
|
|
304
|
+
if scs_id in self.ids:
|
|
305
|
+
return False
|
|
306
|
+
if len(data) != self.data_length:
|
|
307
|
+
return False
|
|
308
|
+
self.ids.append(scs_id)
|
|
309
|
+
self.data[scs_id] = list(data)
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
def clear_param(self):
|
|
313
|
+
self.ids.clear()
|
|
314
|
+
self.data.clear()
|
|
315
|
+
|
|
316
|
+
def make_param(self) -> List[int]:
|
|
317
|
+
param: List[int] = []
|
|
318
|
+
for sid in self.ids:
|
|
319
|
+
param.append(sid)
|
|
320
|
+
param.extend(self.data[sid])
|
|
321
|
+
return param
|
|
322
|
+
|
|
323
|
+
def tx_packet(self) -> int:
|
|
324
|
+
if not self.ids:
|
|
325
|
+
return COMM_NOT_AVAILABLE
|
|
326
|
+
param = self.make_param()
|
|
327
|
+
return self.ph.sync_write_tx_only(self.port, self.start_address, self.data_length, param)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class ScsServoSDK:
|
|
331
|
+
"""Thin convenience wrapper for the handful of operations we need."""
|
|
332
|
+
|
|
333
|
+
def __init__(self):
|
|
334
|
+
self.port = PortHandler()
|
|
335
|
+
self.ph = PacketHandler(0)
|
|
336
|
+
|
|
337
|
+
@staticmethod
|
|
338
|
+
def list_ports() -> List[str]:
|
|
339
|
+
return [p.device for p in serial.tools.list_ports.comports()]
|
|
340
|
+
|
|
341
|
+
def connect(self, port: str, baudrate: int = DEFAULT_BAUDRATE, protocol_end: int = 0) -> bool:
|
|
342
|
+
self.port.set_port(port)
|
|
343
|
+
self.port.set_baudrate(baudrate)
|
|
344
|
+
self.ph = PacketHandler(protocol_end)
|
|
345
|
+
return self.port.open_port()
|
|
346
|
+
|
|
347
|
+
def disconnect(self):
|
|
348
|
+
self.port.close_port()
|
|
349
|
+
|
|
350
|
+
# ------------------------------------------------------------------
|
|
351
|
+
# Basic write helpers
|
|
352
|
+
# ------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
def _write_byte(self, servo_id: int, address: int, value: int):
|
|
355
|
+
res, err = self.ph.write1(self.port, servo_id, address, value)
|
|
356
|
+
if res != COMM_SUCCESS:
|
|
357
|
+
raise RuntimeError(f"write_byte failed: {self.ph.get_tx_rx_result(res)} err={err}")
|
|
358
|
+
|
|
359
|
+
def _write_word(self, servo_id: int, address: int, value: int):
|
|
360
|
+
res, err = self.ph.write2(self.port, servo_id, address, value)
|
|
361
|
+
if res != COMM_SUCCESS:
|
|
362
|
+
raise RuntimeError(f"write_word failed: {self.ph.get_tx_rx_result(res)} err={err}")
|
|
363
|
+
|
|
364
|
+
# ------------------------------------------------------------------
|
|
365
|
+
# Wheel helpers
|
|
366
|
+
# ------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
def set_wheel_mode(self, servo_id: int) -> str:
|
|
369
|
+
self._unlock(servo_id)
|
|
370
|
+
try:
|
|
371
|
+
self._write_byte(servo_id, ADDR_SCS_MODE, 1)
|
|
372
|
+
finally:
|
|
373
|
+
self._lock(servo_id)
|
|
374
|
+
return "success"
|
|
375
|
+
|
|
376
|
+
def write_wheel_speed(self, servo_id: int, speed: int) -> str:
|
|
377
|
+
speed = max(-10000, min(10000, int(speed)))
|
|
378
|
+
value = abs(speed) & 0x7FFF
|
|
379
|
+
if speed < 0:
|
|
380
|
+
value |= 0x8000
|
|
381
|
+
self._write_word(servo_id, ADDR_SCS_GOAL_SPEED, value)
|
|
382
|
+
return "success"
|
|
383
|
+
|
|
384
|
+
def sync_write_wheel_speeds(self, servo_speeds: Dict[int, int]) -> str:
|
|
385
|
+
if not servo_speeds:
|
|
386
|
+
return "success"
|
|
387
|
+
group = GroupSyncWrite(self.port, self.ph, ADDR_SCS_GOAL_SPEED, 2)
|
|
388
|
+
added = False
|
|
389
|
+
for sid, speed in servo_speeds.items():
|
|
390
|
+
speed = max(-10000, min(10000, int(speed)))
|
|
391
|
+
value = abs(speed) & 0x7FFF
|
|
392
|
+
if speed < 0:
|
|
393
|
+
value |= 0x8000
|
|
394
|
+
added = group.add_param(int(sid), [SCS_LOBYTE(value), SCS_HIBYTE(value)]) or added
|
|
395
|
+
if not added:
|
|
396
|
+
return "success"
|
|
397
|
+
result = group.tx_packet()
|
|
398
|
+
if result != COMM_SUCCESS:
|
|
399
|
+
raise RuntimeError(f"sync_write_wheel_speeds failed: {self.ph.get_tx_rx_result(result)}")
|
|
400
|
+
return "success"
|
|
401
|
+
|
|
402
|
+
# ------------------------------------------------------------------
|
|
403
|
+
# Internal helpers
|
|
404
|
+
# ------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
def _unlock(self, servo_id: int):
|
|
407
|
+
self._write_byte(servo_id, ADDR_SCS_LOCK, 0)
|
|
408
|
+
|
|
409
|
+
def _lock(self, servo_id: int):
|
|
410
|
+
self._write_byte(servo_id, ADDR_SCS_LOCK, 1)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from langchain_core.tools import tool # type: ignore[import]
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def create_move_forward(wheel_controller):
|
|
8
|
+
@tool
|
|
9
|
+
def move_forward(distance_meters: float) -> str:
|
|
10
|
+
"""Drives the robot forward (or backward) for a specific distance."""
|
|
11
|
+
|
|
12
|
+
distance = float(distance_meters)
|
|
13
|
+
if distance >= 0:
|
|
14
|
+
wheel_controller.go_forward(distance)
|
|
15
|
+
else:
|
|
16
|
+
wheel_controller.go_backward(-distance)
|
|
17
|
+
return f"Moved {'forward' if distance >= 0 else 'backward'} {abs(distance):.2f} meters."
|
|
18
|
+
|
|
19
|
+
return move_forward
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_turn_right(wheel_controller):
|
|
23
|
+
@tool
|
|
24
|
+
def turn_right(angle_degrees: float) -> str:
|
|
25
|
+
"""Turns the robot right by angle in degrees."""
|
|
26
|
+
angle = float(angle_degrees)
|
|
27
|
+
wheel_controller.turn_right(angle)
|
|
28
|
+
time.sleep(0.4) # wait a bit after turn for stabilization
|
|
29
|
+
return f"Turned right by {angle} degrees."
|
|
30
|
+
|
|
31
|
+
return turn_right
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_turn_left(wheel_controller):
|
|
35
|
+
@tool
|
|
36
|
+
def turn_left(angle_degrees: float) -> str:
|
|
37
|
+
"""Turns the robot left by angle in degrees."""
|
|
38
|
+
angle = float(angle_degrees)
|
|
39
|
+
wheel_controller.turn_left(angle)
|
|
40
|
+
time.sleep(0.4) # wait a bit after turn for stabilization
|
|
41
|
+
return f"Turned left by {angle} degrees."
|
|
42
|
+
|
|
43
|
+
return turn_left
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
|
|
2
|
+
"""High-level wheel helpers for the XLe robot.
|
|
3
|
+
|
|
4
|
+
The original setup pulled its configuration from ``dual_mapper_config.json``.
|
|
5
|
+
To simplify deployment the relevant wheel parameters now live directly in this
|
|
6
|
+
module so there is no external configuration file dependency.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Dict, List, Mapping, Optional, Sequence, Tuple, Union
|
|
14
|
+
|
|
15
|
+
from robocrew.robots.XLeRobot.sdk import DEFAULT_BAUDRATE, ScsServoSDK
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Default wheel configuration (was ``portB.wheel`` in dual_mapper_config.json)
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class WheelCalibration:
|
|
25
|
+
"""Direction multipliers applied to the configured base speed."""
|
|
26
|
+
|
|
27
|
+
up: int = 0
|
|
28
|
+
down: int = 0
|
|
29
|
+
left: int = 0
|
|
30
|
+
right: int = 0
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_mapping(cls, data: Mapping[str, int]) -> "WheelCalibration":
|
|
34
|
+
return cls(
|
|
35
|
+
up=int(data.get("Up", 0)),
|
|
36
|
+
down=int(data.get("Down", 0)),
|
|
37
|
+
left=int(data.get("Left", 0)),
|
|
38
|
+
right=int(data.get("Right", 0)),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def value_for(self, action: str) -> int:
|
|
42
|
+
lookup = {
|
|
43
|
+
"up": self.up,
|
|
44
|
+
"down": self.down,
|
|
45
|
+
"left": self.left,
|
|
46
|
+
"right": self.right,
|
|
47
|
+
}
|
|
48
|
+
return lookup.get(action.lower(), 0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class WheelSpec:
|
|
53
|
+
"""Single wheel definition with its role and calibration."""
|
|
54
|
+
|
|
55
|
+
id: int
|
|
56
|
+
role: str = "both"
|
|
57
|
+
calibration: WheelCalibration = field(default_factory=WheelCalibration)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_mapping(cls, data: Mapping[str, object]) -> Optional["WheelSpec"]:
|
|
61
|
+
wid = int(data.get("id", 0))
|
|
62
|
+
if wid <= 0:
|
|
63
|
+
return None
|
|
64
|
+
role = str(data.get("role", "both")).lower()
|
|
65
|
+
raw_cal = data.get("calibration", {})
|
|
66
|
+
if isinstance(raw_cal, Mapping):
|
|
67
|
+
calibration = WheelCalibration.from_mapping(raw_cal)
|
|
68
|
+
else:
|
|
69
|
+
calibration = WheelCalibration()
|
|
70
|
+
return cls(id=wid, role=role, calibration=calibration)
|
|
71
|
+
|
|
72
|
+
def allows(self, action: str) -> bool:
|
|
73
|
+
action = action.lower()
|
|
74
|
+
if self.role == "both":
|
|
75
|
+
return action in ("up", "down", "left", "right")
|
|
76
|
+
if self.role == "drive":
|
|
77
|
+
return action in ("up", "down")
|
|
78
|
+
if self.role == "steer":
|
|
79
|
+
return action in ("left", "right")
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
def speed_for(self, action: str, base_speed: int) -> int:
|
|
83
|
+
if not self.allows(action):
|
|
84
|
+
return 0
|
|
85
|
+
return self.calibration.value_for(action) * base_speed
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class WheelConfig:
|
|
90
|
+
"""Full wheel configuration shared by all actions."""
|
|
91
|
+
|
|
92
|
+
speed: int
|
|
93
|
+
meters_per_second: float
|
|
94
|
+
degrees_per_second: float
|
|
95
|
+
wheels: Tuple[WheelSpec, ...] = field(default_factory=tuple)
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_mapping(cls, data: Mapping[str, object]) -> "WheelConfig":
|
|
99
|
+
speed = int(data.get("speed", DEFAULT_WHEEL_CONFIG.speed))
|
|
100
|
+
meters_per_second = float(data.get("meters_per_second", DEFAULT_WHEEL_CONFIG.meters_per_second))
|
|
101
|
+
degrees_per_second = float(data.get("degrees_per_second", DEFAULT_WHEEL_CONFIG.degrees_per_second))
|
|
102
|
+
wheels: List[WheelSpec] = []
|
|
103
|
+
for entry in data.get("wheels", []):
|
|
104
|
+
if isinstance(entry, Mapping):
|
|
105
|
+
spec = WheelSpec.from_mapping(entry)
|
|
106
|
+
if spec:
|
|
107
|
+
wheels.append(spec)
|
|
108
|
+
return cls(
|
|
109
|
+
speed=speed,
|
|
110
|
+
meters_per_second=meters_per_second,
|
|
111
|
+
degrees_per_second=degrees_per_second,
|
|
112
|
+
wheels=tuple(wheels),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
DEFAULT_WHEEL_CONFIG = WheelConfig(
|
|
117
|
+
speed=10000,
|
|
118
|
+
meters_per_second=0.25,
|
|
119
|
+
degrees_per_second=100.0,
|
|
120
|
+
wheels=(
|
|
121
|
+
WheelSpec(
|
|
122
|
+
id=7,
|
|
123
|
+
role="both",
|
|
124
|
+
calibration=WheelCalibration(up=1, down=-1, left=-1, right=1),
|
|
125
|
+
),
|
|
126
|
+
WheelSpec(
|
|
127
|
+
id=8,
|
|
128
|
+
role="steer",
|
|
129
|
+
calibration=WheelCalibration(up=0, down=0, left=-1, right=1),
|
|
130
|
+
),
|
|
131
|
+
WheelSpec(
|
|
132
|
+
id=9,
|
|
133
|
+
role="both",
|
|
134
|
+
calibration=WheelCalibration(up=-1, down=1, left=-1, right=1),
|
|
135
|
+
),
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_wheel_config(
|
|
141
|
+
*,
|
|
142
|
+
speed: Optional[int] = None,
|
|
143
|
+
meters_per_second: Optional[float] = None,
|
|
144
|
+
degrees_per_second: Optional[float] = None,
|
|
145
|
+
wheels: Optional[Sequence[Mapping[str, object]]] = None,
|
|
146
|
+
) -> WheelConfig:
|
|
147
|
+
"""Helper to create a ``WheelConfig`` from simple primitives."""
|
|
148
|
+
|
|
149
|
+
base = DEFAULT_WHEEL_CONFIG
|
|
150
|
+
resolved_speed = base.speed if speed is None else int(speed)
|
|
151
|
+
resolved_mps = base.meters_per_second if meters_per_second is None else float(meters_per_second)
|
|
152
|
+
resolved_dps = base.degrees_per_second if degrees_per_second is None else float(degrees_per_second)
|
|
153
|
+
|
|
154
|
+
if wheels is None:
|
|
155
|
+
resolved_wheels = base.wheels
|
|
156
|
+
else:
|
|
157
|
+
collected: List[WheelSpec] = []
|
|
158
|
+
for entry in wheels:
|
|
159
|
+
spec = WheelSpec.from_mapping(entry)
|
|
160
|
+
if spec:
|
|
161
|
+
collected.append(spec)
|
|
162
|
+
resolved_wheels = tuple(collected)
|
|
163
|
+
|
|
164
|
+
return WheelConfig(
|
|
165
|
+
speed=resolved_speed,
|
|
166
|
+
meters_per_second=resolved_mps,
|
|
167
|
+
degrees_per_second=resolved_dps,
|
|
168
|
+
wheels=resolved_wheels,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# Controller
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class XLeRobotWheels:
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
sdk: ScsServoSDK,
|
|
181
|
+
wheel_cfg: Optional[Union[WheelConfig, Mapping[str, object]]] = None,
|
|
182
|
+
):
|
|
183
|
+
self.sdk = sdk
|
|
184
|
+
if wheel_cfg is None:
|
|
185
|
+
self.config = DEFAULT_WHEEL_CONFIG
|
|
186
|
+
elif isinstance(wheel_cfg, WheelConfig):
|
|
187
|
+
self.config = wheel_cfg
|
|
188
|
+
elif isinstance(wheel_cfg, Mapping):
|
|
189
|
+
self.config = WheelConfig.from_mapping(wheel_cfg)
|
|
190
|
+
else:
|
|
191
|
+
raise TypeError("wheel_cfg must be a WheelConfig or mapping")
|
|
192
|
+
if not self.config.wheels:
|
|
193
|
+
raise ValueError("Wheel configuration must define at least one wheel.")
|
|
194
|
+
self.linear_mps = max(1e-6, float(self.config.meters_per_second))
|
|
195
|
+
self.angular_dps = max(1e-6, float(self.config.degrees_per_second))
|
|
196
|
+
|
|
197
|
+
def _apply_action(self, action: str) -> Dict[int, int]:
|
|
198
|
+
payload = {wheel.id: wheel.speed_for(action, self.config.speed) for wheel in self.config.wheels}
|
|
199
|
+
if payload:
|
|
200
|
+
self.sdk.sync_write_wheel_speeds(payload)
|
|
201
|
+
return payload
|
|
202
|
+
|
|
203
|
+
def _stop_all(self) -> Dict[int, int]:
|
|
204
|
+
payload = {wheel.id: 0 for wheel in self.config.wheels}
|
|
205
|
+
if payload:
|
|
206
|
+
self.sdk.sync_write_wheel_speeds(payload)
|
|
207
|
+
return payload
|
|
208
|
+
|
|
209
|
+
def _run_for(self, action: str, duration_s: float) -> Dict[int, int]:
|
|
210
|
+
duration = max(0.0, float(duration_s))
|
|
211
|
+
self._apply_action(action)
|
|
212
|
+
time.sleep(duration)
|
|
213
|
+
self._stop_all()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _distance_to_duration(self, distance_m: float) -> float:
|
|
217
|
+
return abs(float(distance_m)) / self.linear_mps
|
|
218
|
+
|
|
219
|
+
def _angle_to_duration(self, degrees: float) -> float:
|
|
220
|
+
return abs(float(degrees)) / self.angular_dps
|
|
221
|
+
|
|
222
|
+
def go_forward(self, meters: float) -> Dict[int, int]:
|
|
223
|
+
"""Drive forward for the requested distance in meters."""
|
|
224
|
+
|
|
225
|
+
distance = float(meters)
|
|
226
|
+
if distance < 0:
|
|
227
|
+
return self.go_backward(-distance)
|
|
228
|
+
if distance == 0:
|
|
229
|
+
return {}
|
|
230
|
+
return self._run_for("Up", self._distance_to_duration(distance))
|
|
231
|
+
|
|
232
|
+
def go_backward(self, meters: float) -> Dict[int, int]:
|
|
233
|
+
"""Drive backward for the requested distance in meters."""
|
|
234
|
+
|
|
235
|
+
distance = float(meters)
|
|
236
|
+
if distance < 0:
|
|
237
|
+
return self.go_forward(-distance)
|
|
238
|
+
if distance == 0:
|
|
239
|
+
return {}
|
|
240
|
+
return self._run_for("Down", self._distance_to_duration(distance))
|
|
241
|
+
|
|
242
|
+
def turn_left(self, degrees: float) -> Dict[int, int]:
|
|
243
|
+
"""Turn left by the requested angle in degrees."""
|
|
244
|
+
|
|
245
|
+
angle = float(degrees)
|
|
246
|
+
if angle < 0:
|
|
247
|
+
return self.turn_right(-angle)
|
|
248
|
+
if angle == 0:
|
|
249
|
+
return {}
|
|
250
|
+
return self._run_for("Left", self._angle_to_duration(angle))
|
|
251
|
+
|
|
252
|
+
def turn_right(self, degrees: float) -> Dict[int, int]:
|
|
253
|
+
"""Turn right by the requested angle in degrees."""
|
|
254
|
+
|
|
255
|
+
angle = float(degrees)
|
|
256
|
+
if angle < 0:
|
|
257
|
+
return self.turn_left(-angle)
|
|
258
|
+
if angle == 0:
|
|
259
|
+
return {}
|
|
260
|
+
return self._run_for("Right", self._angle_to_duration(angle))
|
|
261
|
+
|
|
262
|
+
def apply_wheel_modes(self) -> List[int]:
|
|
263
|
+
"""Switch all configured motors to wheel mode."""
|
|
264
|
+
|
|
265
|
+
applied: List[int] = []
|
|
266
|
+
for wheel in self.config.wheels:
|
|
267
|
+
self.sdk.set_wheel_mode(wheel.id)
|
|
268
|
+
applied.append(wheel.id)
|
|
269
|
+
return applied
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def connect_serial(
|
|
273
|
+
port: str,
|
|
274
|
+
baudrate: int = DEFAULT_BAUDRATE,
|
|
275
|
+
protocol_end: int = 0,
|
|
276
|
+
) -> ScsServoSDK:
|
|
277
|
+
"""Create and connect an ``ScsServoSDK`` instance."""
|
|
278
|
+
|
|
279
|
+
sdk = ScsServoSDK()
|
|
280
|
+
if not sdk.connect(port, baudrate, protocol_end):
|
|
281
|
+
raise RuntimeError(f"Failed to open serial port {port} @ {baudrate}")
|
|
282
|
+
return sdk
|
|
283
|
+
|
|
284
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: robocrew
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: 🦾Set up your embodied LLM agent same easiness as you set up normal agents in CrewAI or Autogen
|
|
5
|
+
Project-URL: Homepage, https://github.com/Grigorij-Dudnik/RoboCrew
|
|
6
|
+
Author: Konrad Suchodolski
|
|
7
|
+
Author-email: Grigorij Dudnik <dudnikgrv@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: langchain-core>=0.3.79
|
|
13
|
+
Requires-Dist: langchain-google-genai
|
|
14
|
+
Requires-Dist: langchain>=0.3.27
|
|
15
|
+
Requires-Dist: openai>=2.3.0
|
|
16
|
+
Requires-Dist: opencv-python
|
|
17
|
+
Requires-Dist: pyaudio
|
|
18
|
+
Requires-Dist: pyserial>=3.5
|
|
19
|
+
Requires-Dist: python-dotenv>=1.1.1
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
robocrew/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
robocrew/core/LLMAgent.py,sha256=oHhsPJlH4yhdHC4qe9Jy0SNxxaXgxeB1ezPI3ss0QdE,5633
|
|
3
|
+
robocrew/core/sound_receiver.py,sha256=Mu_6McbyyqYiclJwcMCJGuJHZX3Pe5YLw9zBA2m9ULY,8102
|
|
4
|
+
robocrew/core/tools.py,sha256=nXkXln1d7BMhuU1125T_Iz1zqXnWhLmqp5P4PvTEzGg,227
|
|
5
|
+
robocrew/core/utils.py,sha256=8s5vdP5IfUVtL9PZPCSWjDDSdBTsk1Y6rtMlOqYvNQ8,1598
|
|
6
|
+
robocrew/robots/XLeRobot/dual_mapper_config.json,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
robocrew/robots/XLeRobot/sdk.py,sha256=SiNLLQA-iGQY69z18OhpMNMdWRmLv4zEpZpbrGIXsa8,14217
|
|
8
|
+
robocrew/robots/XLeRobot/tools.py,sha256=waV4uqPodwneICYd2WB0DrzFovj8YTPU2VUG8wGGino,1400
|
|
9
|
+
robocrew/robots/XLeRobot/wheel_controls.py,sha256=2MHm_zCj5nUpWXE5IiqvHN34d_G7pOvFrEsitPcpUBg,9690
|
|
10
|
+
robocrew-0.0.1.dist-info/METADATA,sha256=U1Unk7Yat6hVUs3UHZ8tMYsn6MwcerkEDiDKfik_TVA,703
|
|
11
|
+
robocrew-0.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
12
|
+
robocrew-0.0.1.dist-info/RECORD,,
|