Skip to content

Commit 560644a

Browse files
author
Saumya Saksena
committed
Add tello controller
1 parent 2ada0da commit 560644a

File tree

1 file changed

+358
-0
lines changed

1 file changed

+358
-0
lines changed

tello_vision/tello_controller.py

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
"""Modern DJI Tello drone controller using djitellopy.
2+
3+
Handles video streaming, keyboard controls, and flight commands.
4+
"""
5+
6+
import threading
7+
import time
8+
from typing import Callable, Optional
9+
10+
import cv2
11+
import numpy as np
12+
from djitellopy import Tello
13+
from pynput import keyboard
14+
15+
16+
class TelloController:
17+
"""Controller for DJI Tello drone with video streaming."""
18+
19+
def __init__(self, config: dict):
20+
"""Initialize Tello controller.
21+
22+
Args:
23+
config: Configuration dictionary
24+
"""
25+
self.config = config
26+
self.drone = Tello()
27+
28+
# State
29+
self.is_flying = False
30+
self.is_recording = False
31+
self.video_writer: Optional[cv2.VideoWriter] = None
32+
self.frame_callback: Optional[Callable[[np.ndarray], None]] = None
33+
34+
# Stats
35+
self.battery = 0
36+
self.temperature = 0
37+
self.flight_time = 0
38+
self.height = 0
39+
40+
# Control settings
41+
self.speed = config.get("speed", 50)
42+
43+
# Keyboard listener
44+
self.listener: Optional[keyboard.Listener] = None
45+
self.active_keys = set()
46+
47+
def connect(self) -> bool:
48+
"""Connect to the Tello drone.
49+
50+
Returns:
51+
True if connection successful
52+
"""
53+
try:
54+
print("Connecting to Tello...")
55+
self.drone.connect()
56+
57+
# Get initial state
58+
self.battery = self.drone.get_battery()
59+
self.temperature = self.drone.get_temperature()
60+
61+
print(f"Connected! Battery: {self.battery}%, Temp: {self.temperature}°C")
62+
63+
# Start video stream
64+
self.drone.streamon()
65+
print("Video stream started")
66+
67+
return True
68+
69+
except Exception as e:
70+
print(f"Connection failed: {e}")
71+
return False
72+
73+
def disconnect(self) -> None:
74+
"""Disconnect from drone and cleanup."""
75+
print("Disconnecting...")
76+
77+
if self.is_flying:
78+
self.land()
79+
80+
if self.is_recording:
81+
self.stop_recording()
82+
83+
try:
84+
self.drone.streamoff()
85+
except:
86+
pass
87+
88+
try:
89+
self.drone.end()
90+
except:
91+
pass
92+
93+
if self.listener:
94+
self.listener.stop()
95+
96+
print("Disconnected")
97+
98+
def get_frame(self) -> Optional[np.ndarray]:
99+
"""Get current video frame from drone.
100+
101+
Returns:
102+
Frame as numpy array (BGR) or None if unavailable
103+
"""
104+
try:
105+
frame = self.drone.get_frame_read().frame
106+
return frame
107+
except Exception as e:
108+
print(f"Error getting frame: {e}")
109+
return None
110+
111+
def start_video_stream(
112+
self, callback: Optional[Callable[[np.ndarray], None]] = None
113+
) -> None:
114+
"""Start processing video stream.
115+
116+
Args:
117+
callback: Optional callback function to process each frame
118+
"""
119+
self.frame_callback = callback
120+
121+
def stream_loop():
122+
while True:
123+
frame = self.get_frame()
124+
if frame is not None and self.frame_callback:
125+
self.frame_callback(frame)
126+
time.sleep(0.01) # Small delay to prevent CPU hogging
127+
128+
stream_thread = threading.Thread(target=stream_loop, daemon=True)
129+
stream_thread.start()
130+
131+
def takeoff(self) -> None:
132+
"""Take off the drone."""
133+
if not self.is_flying:
134+
print("Taking off...")
135+
self.drone.takeoff()
136+
self.is_flying = True
137+
print("Airborne!")
138+
139+
def land(self) -> None:
140+
"""Land the drone."""
141+
if self.is_flying:
142+
print("Landing...")
143+
self.drone.land()
144+
self.is_flying = False
145+
print("Landed")
146+
147+
def emergency(self) -> None:
148+
"""Emergency stop - cuts motors immediately."""
149+
print("EMERGENCY STOP!")
150+
self.drone.emergency()
151+
self.is_flying = False
152+
153+
# Movement commands
154+
def move_forward(self, distance: int = 20) -> None:
155+
"""Move forward (cm)."""
156+
if self.is_flying:
157+
self.drone.move_forward(distance)
158+
159+
def move_back(self, distance: int = 20) -> None:
160+
"""Move backward (cm)."""
161+
if self.is_flying:
162+
self.drone.move_back(distance)
163+
164+
def move_left(self, distance: int = 20) -> None:
165+
"""Move left (cm)."""
166+
if self.is_flying:
167+
self.drone.move_left(distance)
168+
169+
def move_right(self, distance: int = 20) -> None:
170+
"""Move right (cm)."""
171+
if self.is_flying:
172+
self.drone.move_right(distance)
173+
174+
def move_up(self, distance: int = 20) -> None:
175+
"""Move up (cm)."""
176+
if self.is_flying:
177+
self.drone.move_up(distance)
178+
179+
def move_down(self, distance: int = 20) -> None:
180+
"""Move down (cm)."""
181+
if self.is_flying:
182+
self.drone.move_down(distance)
183+
184+
def rotate_clockwise(self, degrees: int = 30) -> None:
185+
"""Rotate clockwise (degrees)."""
186+
if self.is_flying:
187+
self.drone.rotate_clockwise(degrees)
188+
189+
def rotate_counter_clockwise(self, degrees: int = 30) -> None:
190+
"""Rotate counter-clockwise (degrees)."""
191+
if self.is_flying:
192+
self.drone.rotate_counter_clockwise(degrees)
193+
194+
# Continuous control (for smoother movement)
195+
def send_rc_control(
196+
self,
197+
left_right: int = 0,
198+
forward_backward: int = 0,
199+
up_down: int = 0,
200+
yaw: int = 0,
201+
) -> None:
202+
"""Send RC control command for smooth movement.
203+
204+
Args:
205+
left_right: -100 to 100 (left to right)
206+
forward_backward: -100 to 100 (backward to forward)
207+
up_down: -100 to 100 (down to up)
208+
yaw: -100 to 100 (CCW to CW)
209+
"""
210+
if self.is_flying:
211+
self.drone.send_rc_control(left_right, forward_backward, up_down, yaw)
212+
213+
def update_stats(self) -> None:
214+
"""Update drone telemetry stats."""
215+
try:
216+
self.battery = self.drone.get_battery()
217+
self.temperature = self.drone.get_temperature()
218+
self.flight_time = self.drone.get_flight_time()
219+
self.height = self.drone.get_height()
220+
except:
221+
pass
222+
223+
def get_stats_text(self) -> list:
224+
"""Get formatted stats text for display.
225+
226+
Returns:
227+
List of stat strings
228+
"""
229+
return [
230+
f"Battery: {self.battery}%",
231+
f"Temp: {self.temperature}°C",
232+
f"Height: {self.height}cm",
233+
f"Flight Time: {self.flight_time}s",
234+
f"Flying: {self.is_flying}",
235+
f"Recording: {self.is_recording}",
236+
]
237+
238+
def start_recording(
239+
self, output_path: str, fps: int = 30, resolution: tuple = (960, 720)
240+
) -> None:
241+
"""Start recording video.
242+
243+
Args:
244+
output_path: Output file path
245+
fps: Frames per second
246+
resolution: Video resolution (width, height)
247+
"""
248+
if not self.is_recording:
249+
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
250+
self.video_writer = cv2.VideoWriter(output_path, fourcc, fps, resolution)
251+
self.is_recording = True
252+
print(f"Recording started: {output_path}")
253+
254+
def stop_recording(self) -> None:
255+
"""Stop recording video."""
256+
if self.is_recording and self.video_writer:
257+
self.video_writer.release()
258+
self.video_writer = None
259+
self.is_recording = False
260+
print("Recording stopped")
261+
262+
def write_frame(self, frame: np.ndarray) -> None:
263+
"""Write frame to video file if recording."""
264+
if self.is_recording and self.video_writer:
265+
self.video_writer.write(frame)
266+
267+
def setup_keyboard_controls(self, controls: dict) -> None:
268+
"""Setup keyboard controls.
269+
270+
Args:
271+
controls: Dictionary mapping actions to keys
272+
"""
273+
274+
def on_press(key):
275+
try:
276+
k = key.char if hasattr(key, "char") else key.name
277+
278+
if k == controls.get("takeoff"):
279+
self.takeoff()
280+
elif k == controls.get("land"):
281+
self.land()
282+
elif k == controls.get("emergency"):
283+
self.emergency()
284+
elif k == controls.get("forward"):
285+
self.active_keys.add("forward")
286+
elif k == controls.get("backward"):
287+
self.active_keys.add("backward")
288+
elif k == controls.get("left"):
289+
self.active_keys.add("left")
290+
elif k == controls.get("right"):
291+
self.active_keys.add("right")
292+
elif k == controls.get("up"):
293+
self.active_keys.add("up")
294+
elif k == controls.get("down"):
295+
self.active_keys.add("down")
296+
elif k == controls.get("yaw_left"):
297+
self.active_keys.add("yaw_left")
298+
elif k == controls.get("yaw_right"):
299+
self.active_keys.add("yaw_right")
300+
301+
except AttributeError:
302+
pass
303+
304+
def on_release(key):
305+
try:
306+
k = key.char if hasattr(key, "char") else key.name
307+
308+
# Remove from active keys
309+
for action in [
310+
"forward",
311+
"backward",
312+
"left",
313+
"right",
314+
"up",
315+
"down",
316+
"yaw_left",
317+
"yaw_right",
318+
]:
319+
if k == controls.get(action):
320+
self.active_keys.discard(action)
321+
322+
except AttributeError:
323+
pass
324+
325+
self.listener = keyboard.Listener(on_press=on_press, on_release=on_release)
326+
self.listener.start()
327+
328+
# Start control loop for continuous movement
329+
def control_loop():
330+
while True:
331+
lr = fb = ud = yaw = 0
332+
333+
if "forward" in self.active_keys:
334+
fb = self.speed
335+
if "backward" in self.active_keys:
336+
fb = -self.speed
337+
if "left" in self.active_keys:
338+
lr = -self.speed
339+
if "right" in self.active_keys:
340+
lr = self.speed
341+
if "up" in self.active_keys:
342+
ud = self.speed
343+
if "down" in self.active_keys:
344+
ud = -self.speed
345+
if "yaw_left" in self.active_keys:
346+
yaw = -self.speed
347+
if "yaw_right" in self.active_keys:
348+
yaw = self.speed
349+
350+
if any([lr, fb, ud, yaw]):
351+
self.send_rc_control(lr, fb, ud, yaw)
352+
else:
353+
self.send_rc_control(0, 0, 0, 0)
354+
355+
time.sleep(0.05)
356+
357+
control_thread = threading.Thread(target=control_loop, daemon=True)
358+
control_thread.start()

0 commit comments

Comments
 (0)