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 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
@@ -0,0 +1,8 @@
1
+ from langchain_core.tools import tool
2
+
3
+
4
+ @tool
5
+ def finish_task():
6
+ """Claim that task is finished and go idle. You need to ensure the task is actually finished before calling this tool."""
7
+ return "Task finished"
8
+
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any