pyaidrone 2.8__tar.gz → 2.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyaidrone-2.8 → pyaidrone-2.9}/PKG-INFO +1 -1
- pyaidrone-2.9/pyaidrone/aiDrone.py +685 -0
- pyaidrone-2.9/pyaidrone/drone.py +201 -0
- {pyaidrone-2.8 → pyaidrone-2.9}/pyaidrone.egg-info/PKG-INFO +1 -1
- {pyaidrone-2.8 → pyaidrone-2.9}/pyaidrone.egg-info/SOURCES.txt +2 -0
- {pyaidrone-2.8 → pyaidrone-2.9}/setup.py +1 -1
- {pyaidrone-2.8 → pyaidrone-2.9}/README.md +0 -0
- {pyaidrone-2.8 → pyaidrone-2.9}/pyaidrone/__init__.py +0 -0
- {pyaidrone-2.8 → pyaidrone-2.9}/pyaidrone/aidrone_mavlink_bridge.py +0 -0
- {pyaidrone-2.8 → pyaidrone-2.9}/pyaidrone/deflib.py +0 -0
- {pyaidrone-2.8 → pyaidrone-2.9}/pyaidrone/edu.py +0 -0
- {pyaidrone-2.8 → pyaidrone-2.9}/pyaidrone/ikeyevent.py +0 -0
- {pyaidrone-2.8 → pyaidrone-2.9}/pyaidrone/packet.py +0 -0
- {pyaidrone-2.8 → pyaidrone-2.9}/pyaidrone/parse.py +0 -0
- {pyaidrone-2.8 → pyaidrone-2.9}/pyaidrone/sensor.py +0 -0
- {pyaidrone-2.8 → pyaidrone-2.9}/pyaidrone/vision_ai.py +0 -0
- {pyaidrone-2.8 → pyaidrone-2.9}/pyaidrone.egg-info/dependency_links.txt +0 -0
- {pyaidrone-2.8 → pyaidrone-2.9}/pyaidrone.egg-info/requires.txt +0 -0
- {pyaidrone-2.8 → pyaidrone-2.9}/pyaidrone.egg-info/top_level.txt +0 -0
- {pyaidrone-2.8 → pyaidrone-2.9}/setup.cfg +0 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
pyaidrone.aiDrone — AIDrone 제어 클래스
|
|
4
|
+
|
|
5
|
+
수정 이력 (v1.0)
|
|
6
|
+
[치명] takeoff()가 posX/posY/rot 및 ROLL/PITCH/YAW 필드를 초기화하지 않아
|
|
7
|
+
"이륙 -> 이동 -> 착륙 -> 재이륙" 시 이전 좌표로 즉시 돌진하던 문제 수정.
|
|
8
|
+
[치명] velocity()와 move()가 서로 반대 축을 갱신하지 않아, POSTYPE 비트가 바뀌는
|
|
9
|
+
순간 잔류값이 거리<->속도로 오해석되던 문제 수정.
|
|
10
|
+
[중요] 송신을 수신 루프에 종속시키던 구조를 분리. 전용 송신 스레드(기본 20Hz).
|
|
11
|
+
[중요] rotation() 각도 정규화가 1회 가감뿐이라 범위를 벗어나던 문제 -> 모듈러 연산.
|
|
12
|
+
[중요] altitude()/move()/velocity() 범위 제한 추가 (OverflowError 방지).
|
|
13
|
+
[중요] emergency()의 serial None 검사 추가.
|
|
14
|
+
[개선] class AIDrone(Parse, Packet) 다중 상속 제거. 합성만 사용.
|
|
15
|
+
[개선] Open() 자동 탐색에서 첫 번째 CH340 포트를 선택하도록 break 추가.
|
|
16
|
+
[추가] TYPE 5/6/7/8 및 PID 설정 API.
|
|
17
|
+
[추가] stop(), hovering(), getFrame() 편의 메서드.
|
|
18
|
+
|
|
19
|
+
수정 이력 (v1.0 → v1.1)
|
|
20
|
+
[치명] _sendCommand()가 명령을 1회만 전송했다. 송신 스레드가 20Hz로 제어 패킷을
|
|
21
|
+
계속 흘리는 중이라 명령이 그 사이에 끼어 유실되는 일이 잦았다.
|
|
22
|
+
(재현: 20Hz 스트림 중 TYPE9 요청이 CTRL 사이에 1개만 삽입됨)
|
|
23
|
+
-> 명령 전송 동안 송신 스레드를 잠시 멈추고(_txPaused), 30ms 간격으로
|
|
24
|
+
3회 반복 전송한다. 이제 비행 중에도 txHz를 낮출 필요가 없다.
|
|
25
|
+
[치명] Close()가 센서 출력을 멈추지 않아, 프로그램을 끝내도 드론이 계속 출력
|
|
26
|
+
패킷을 쏟아냈다. 다음 실행 때 이전 스트림이 남아 진단을 어렵게 했다.
|
|
27
|
+
-> Close()에서 stopSensor()를 먼저 호출한다.
|
|
28
|
+
[중요] Open()을 두 번 호출하면 스레드가 중복 생성되고 이전 포트가 누수됐다.
|
|
29
|
+
-> 이미 열려 있으면 경고 후 무시. 열 때 입력 버퍼를 비운다.
|
|
30
|
+
[중요] Close() 후에도 수신 스레드가 self.serial=None 을 참조해 예외 메시지를
|
|
31
|
+
뿜던 문제 -> 루프 진입 시 연결 상태를 검사하고, 스레드를 join 한다.
|
|
32
|
+
[중요] emergency()가 간격 없이 3회를 연속 write 해 드라이버 버퍼에 뭉쳐 나갔다.
|
|
33
|
+
-> 짧은 간격을 둔다.
|
|
34
|
+
[추가] 내장 센서. AIDrone(sensor=True) 로 생성하면 콜백을 직접 쓰지 않아도
|
|
35
|
+
getBattery() / getAttitude() / getState() 로 값을 읽을 수 있다.
|
|
36
|
+
(수업에서 스레드·콜백 개념 없이 센서 실습을 시키기 위한 것)
|
|
37
|
+
[추가] startSensor() / stopSensor() — 요청 후 실제로 데이터가 오는지 확인한다.
|
|
38
|
+
[추가] disarm() — ARM 해제 수단이 emergency() 뿐이라 수업에서 쓰기 부담스러웠다.
|
|
39
|
+
[추가] waitReady() — OPT1 bit1(비행 준비) 폴링.
|
|
40
|
+
[추가] setTxRate() — 송신 주기 변경.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import serial
|
|
44
|
+
from time import sleep, time
|
|
45
|
+
from threading import Thread, Lock, Event
|
|
46
|
+
from contextlib import contextmanager
|
|
47
|
+
from serial.tools.list_ports import comports
|
|
48
|
+
|
|
49
|
+
from pyaidrone.parse import Parse
|
|
50
|
+
from pyaidrone.packet import Packet
|
|
51
|
+
from pyaidrone.sensor import AIDroneSensor
|
|
52
|
+
from pyaidrone.deflib import *
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AIDrone:
|
|
56
|
+
"""AIDrone 제어 객체.
|
|
57
|
+
|
|
58
|
+
사용 흐름 (기존):
|
|
59
|
+
aidrone = AIDrone(receiveCallback)
|
|
60
|
+
aidrone.Open("COM3")
|
|
61
|
+
...
|
|
62
|
+
aidrone.Close()
|
|
63
|
+
|
|
64
|
+
사용 흐름 (v1.1 내장 센서):
|
|
65
|
+
with AIDrone(sensor=True) as aidrone:
|
|
66
|
+
aidrone.Open("COM3")
|
|
67
|
+
aidrone.startSensor()
|
|
68
|
+
print(aidrone.getBattery(), aidrone.getAttitude())
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
CMD_REPEAT = 3 # 명령 패킷 반복 전송 횟수
|
|
72
|
+
CMD_GAP = 0.03 # 반복 간격(초)
|
|
73
|
+
|
|
74
|
+
def __init__(self, receiveCallback=None, txHz=20, sensor=False):
|
|
75
|
+
self.serial = None
|
|
76
|
+
self.isThreadRun = False
|
|
77
|
+
self.parse = Parse(AIDRONE)
|
|
78
|
+
self.makepkt = Packet(AIDRONE)
|
|
79
|
+
self.receiveCallback = receiveCallback
|
|
80
|
+
|
|
81
|
+
# [v1.1 추가] 내장 센서. sensor=True 면 콜백 없이도 값을 읽을 수 있다.
|
|
82
|
+
self.sensor = AIDroneSensor() if sensor else None
|
|
83
|
+
self._sensorTypes = set() # 현재 출력 요청 중인 TYPE 목록
|
|
84
|
+
|
|
85
|
+
self._lock = Lock()
|
|
86
|
+
self._txPeriod = 1.0 / max(1.0, float(txHz))
|
|
87
|
+
self._txEnabled = Event()
|
|
88
|
+
self._txEnabled.set()
|
|
89
|
+
self._rxThread = None
|
|
90
|
+
self._txThread = None
|
|
91
|
+
|
|
92
|
+
# 내부 좌표 (이륙 지점 기준 절대 위치)
|
|
93
|
+
self.posX = 0
|
|
94
|
+
self.posY = 0
|
|
95
|
+
self.rot = 0
|
|
96
|
+
|
|
97
|
+
# 영상 스트리밍 기본값
|
|
98
|
+
self.stream_host = "192.168.4.1"
|
|
99
|
+
self.stream_port = 80
|
|
100
|
+
self.stream_path = "/?action=stream"
|
|
101
|
+
self._cap = None
|
|
102
|
+
|
|
103
|
+
# ==================================================================
|
|
104
|
+
# 연결 / 해제
|
|
105
|
+
# ==================================================================
|
|
106
|
+
def Open(self, portName=None):
|
|
107
|
+
"""시리얼 포트를 19200bps로 열고 송·수신 스레드를 시작한다."""
|
|
108
|
+
# [v1.1 수정] 중복 Open 방지. 이전에는 스레드가 중복 생성되고 포트가 누수됐다.
|
|
109
|
+
if self.isOpen():
|
|
110
|
+
print("이미 연결되어 있습니다. Close() 후 다시 시도하세요.")
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
if portName is None:
|
|
114
|
+
for node in comports():
|
|
115
|
+
if node.description and "CH340" in node.description:
|
|
116
|
+
portName = node.device
|
|
117
|
+
break
|
|
118
|
+
if portName is None:
|
|
119
|
+
print("Can't find Serial Port")
|
|
120
|
+
raise ConnectionError("CH340 시리얼 포트를 찾을 수 없습니다.")
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
self.serial = serial.Serial(port=portName, baudrate=19200, timeout=0.05)
|
|
124
|
+
except serial.SerialException as e:
|
|
125
|
+
print(f"시리얼 포트 오류: {e}")
|
|
126
|
+
raise
|
|
127
|
+
except Exception as e:
|
|
128
|
+
print(f"Can't open {portName}: {e}")
|
|
129
|
+
raise ConnectionError(f"포트 연결 실패: {portName}") from e
|
|
130
|
+
|
|
131
|
+
if not self.serial.isOpen():
|
|
132
|
+
raise ConnectionError(f"포트를 열 수 없습니다: {portName}")
|
|
133
|
+
|
|
134
|
+
# [v1.1 추가] 이전 세션의 잔여 바이트를 버린다. 파서가 헤더를 오인하지 않도록.
|
|
135
|
+
try:
|
|
136
|
+
self.serial.reset_input_buffer()
|
|
137
|
+
self.serial.reset_output_buffer()
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
# 안전한 중립 상태(OPTION=0)에서 시작
|
|
142
|
+
self.makepkt.clearPacket()
|
|
143
|
+
self.parse.reset()
|
|
144
|
+
self.posX = self.posY = self.rot = 0
|
|
145
|
+
self._sensorTypes.clear()
|
|
146
|
+
|
|
147
|
+
self.isThreadRun = True
|
|
148
|
+
self._txEnabled.set()
|
|
149
|
+
self._rxThread = Thread(target=self._receiveHandler, daemon=True, name="aidrone-rx")
|
|
150
|
+
self._txThread = Thread(target=self._transmitHandler, daemon=True, name="aidrone-tx")
|
|
151
|
+
self._rxThread.start()
|
|
152
|
+
self._txThread.start()
|
|
153
|
+
print("Connected to", portName)
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
def Close(self):
|
|
157
|
+
"""센서 출력을 멈추고 중립 패킷을 보낸 뒤 포트를 닫는다."""
|
|
158
|
+
if self.serial is None:
|
|
159
|
+
return
|
|
160
|
+
try:
|
|
161
|
+
if self.serial.isOpen():
|
|
162
|
+
# [v1.1 추가] 센서 출력을 먼저 멈춘다. 이걸 빠뜨리면 프로그램을
|
|
163
|
+
# 끝내도 드론이 계속 패킷을 쏟아낸다.
|
|
164
|
+
self.stopSensor()
|
|
165
|
+
|
|
166
|
+
# 모터 테스트 모드였다면 속도 0으로 먼저 정지시킨다
|
|
167
|
+
if (self.makepkt.getField(Packet.IDX_OPTION, signed=False) & 0x8000) == 0x8000:
|
|
168
|
+
self.makepkt.clearPacket()
|
|
169
|
+
self.setOption(OPT_MOTORTEST)
|
|
170
|
+
self._write(self.makepkt.getPacket())
|
|
171
|
+
sleep(0.2)
|
|
172
|
+
# 중립(ARM 해제) 패킷을 여러 번 보내 유실에 대비
|
|
173
|
+
self.makepkt.clearPacket()
|
|
174
|
+
for _ in range(5):
|
|
175
|
+
self._write(self.makepkt.getPacket())
|
|
176
|
+
sleep(0.05)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
print(f"[Close] 정지 패킷 전송 실패: {e}")
|
|
179
|
+
finally:
|
|
180
|
+
self.isThreadRun = False
|
|
181
|
+
self._txEnabled.set() # 일시 정지 중이어도 스레드가 빠져나오도록
|
|
182
|
+
# [v1.1 추가] 스레드가 끝난 뒤에 포트를 닫는다.
|
|
183
|
+
for th in (self._txThread, self._rxThread):
|
|
184
|
+
if th is not None and th.is_alive():
|
|
185
|
+
th.join(timeout=1.0)
|
|
186
|
+
self._txThread = self._rxThread = None
|
|
187
|
+
self.streamoff()
|
|
188
|
+
try:
|
|
189
|
+
if self.serial is not None and self.serial.isOpen():
|
|
190
|
+
self.serial.close()
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
self.serial = None
|
|
194
|
+
|
|
195
|
+
def isOpen(self):
|
|
196
|
+
return self.serial is not None and self.serial.isOpen()
|
|
197
|
+
|
|
198
|
+
# ==================================================================
|
|
199
|
+
# 스레드
|
|
200
|
+
# ==================================================================
|
|
201
|
+
def _write(self, data):
|
|
202
|
+
"""모든 송신은 이 메서드를 통한다 (Lock 보호 + 연결 검사)."""
|
|
203
|
+
if self.serial is None or not self.serial.isOpen():
|
|
204
|
+
return False
|
|
205
|
+
try:
|
|
206
|
+
with self._lock:
|
|
207
|
+
self.serial.write(data)
|
|
208
|
+
return True
|
|
209
|
+
except serial.SerialException as e:
|
|
210
|
+
print(f"[_write] 시리얼 오류: {e}")
|
|
211
|
+
self.isThreadRun = False
|
|
212
|
+
return False
|
|
213
|
+
except Exception as e:
|
|
214
|
+
print(f"[_write] 송신 오류: {e}")
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
def _receiveHandler(self):
|
|
218
|
+
"""수신 전용 스레드."""
|
|
219
|
+
while self.isThreadRun:
|
|
220
|
+
# [v1.1 수정] Close() 도중 self.serial 이 None 이 되어 예외 메시지를
|
|
221
|
+
# 뿜던 문제. 루프 진입 시 연결 상태를 먼저 확인한다.
|
|
222
|
+
port = self.serial
|
|
223
|
+
if port is None or not port.isOpen():
|
|
224
|
+
break
|
|
225
|
+
try:
|
|
226
|
+
n = port.in_waiting
|
|
227
|
+
readData = port.read(n if n else 1)
|
|
228
|
+
if not readData:
|
|
229
|
+
continue
|
|
230
|
+
for packet in self.parse.feed(readData):
|
|
231
|
+
if self.sensor is not None:
|
|
232
|
+
try:
|
|
233
|
+
self.sensor.parse(packet)
|
|
234
|
+
except Exception as e:
|
|
235
|
+
print(f"[sensor] 파싱 오류: {e}")
|
|
236
|
+
if self.receiveCallback is not None:
|
|
237
|
+
try:
|
|
238
|
+
self.receiveCallback(packet)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
print(f"[receiveCallback] 사용자 콜백 오류: {e}")
|
|
241
|
+
except serial.SerialException as e:
|
|
242
|
+
print(f"[receiveHandler] 시리얼 오류: {e}")
|
|
243
|
+
self.isThreadRun = False
|
|
244
|
+
break
|
|
245
|
+
except Exception as e:
|
|
246
|
+
if not self.isThreadRun:
|
|
247
|
+
break
|
|
248
|
+
print(f"[receiveHandler] 일시 오류 (계속 실행): {e}")
|
|
249
|
+
sleep(0.01)
|
|
250
|
+
|
|
251
|
+
def _transmitHandler(self):
|
|
252
|
+
"""송신 전용 스레드. 제어 패킷을 일정 주기로 반복 전송한다.
|
|
253
|
+
|
|
254
|
+
[v1.1 수정] _txEnabled 가 내려가 있으면 송신하지 않는다.
|
|
255
|
+
명령 패킷을 보낼 때 제어 스트림이 잠시 비켜 주기 위한 장치.
|
|
256
|
+
"""
|
|
257
|
+
while self.isThreadRun:
|
|
258
|
+
if not self._txEnabled.wait(timeout=0.1):
|
|
259
|
+
continue
|
|
260
|
+
if not self.isThreadRun:
|
|
261
|
+
break
|
|
262
|
+
self._write(self.makepkt.getPacket())
|
|
263
|
+
sleep(self._txPeriod)
|
|
264
|
+
|
|
265
|
+
def setTxRate(self, txHz):
|
|
266
|
+
"""제어 패킷 송신 주기를 바꾼다. 비행 중에는 20Hz 이상을 권장한다."""
|
|
267
|
+
self._txPeriod = 1.0 / max(1.0, float(txHz))
|
|
268
|
+
return self._txPeriod
|
|
269
|
+
|
|
270
|
+
@contextmanager
|
|
271
|
+
def _txPaused(self, settle=0.02):
|
|
272
|
+
"""명령 전송 동안 제어 스트림을 잠시 멈춘다.
|
|
273
|
+
|
|
274
|
+
[v1.1 추가] 이 장치가 없으면 명령 패킷이 20Hz 제어 패킷 사이에 끼어
|
|
275
|
+
드론 쪽 수신 버퍼에서 밀린다. 예제에서 txHz를 2로 낮춰 우회하던 문제를
|
|
276
|
+
라이브러리에서 해결한다.
|
|
277
|
+
"""
|
|
278
|
+
ev = getattr(self, '_txEnabled', None)
|
|
279
|
+
if ev is None: # 패킷 로직만 검사하는 단위 테스트 경로
|
|
280
|
+
yield
|
|
281
|
+
return
|
|
282
|
+
ev.clear()
|
|
283
|
+
sleep(settle)
|
|
284
|
+
try:
|
|
285
|
+
yield
|
|
286
|
+
finally:
|
|
287
|
+
ev.set()
|
|
288
|
+
|
|
289
|
+
# ==================================================================
|
|
290
|
+
# 비행 제어
|
|
291
|
+
# ==================================================================
|
|
292
|
+
def setOption(self, option):
|
|
293
|
+
"""OPTION 필드를 직접 설정한다."""
|
|
294
|
+
self.makepkt.setField(Packet.IDX_OPTION, int(option) & 0xFFFF, signed=False)
|
|
295
|
+
|
|
296
|
+
def _resetAxes(self):
|
|
297
|
+
"""ROLL/PITCH/YAW 필드와 내부 좌표를 0으로 되돌린다."""
|
|
298
|
+
self.posX = self.posY = self.rot = 0
|
|
299
|
+
self.makepkt.setField(Packet.IDX_ROLL, 0)
|
|
300
|
+
self.makepkt.setField(Packet.IDX_PITCH, 0)
|
|
301
|
+
self.makepkt.setField(Packet.IDX_YAW, 0)
|
|
302
|
+
|
|
303
|
+
def takeoff(self, alt=70):
|
|
304
|
+
"""목표 고도(기본 70cm)로 이륙한다."""
|
|
305
|
+
alt = DefLib.constrain(int(alt), ALT_MAX, 1)
|
|
306
|
+
self._resetAxes()
|
|
307
|
+
self.makepkt.setField(Packet.IDX_THR, alt, signed=False)
|
|
308
|
+
self.setOption(OPT_FLIGHT_POS)
|
|
309
|
+
|
|
310
|
+
def landing(self):
|
|
311
|
+
"""목표 고도를 0으로 설정해 착륙한다.
|
|
312
|
+
|
|
313
|
+
주의: ARM 비트는 그대로 유지된다. 착륙이 끝난 뒤 모터를 완전히 내리려면
|
|
314
|
+
disarm() 을 호출한다.
|
|
315
|
+
"""
|
|
316
|
+
self.makepkt.setField(Packet.IDX_THR, 0, signed=False)
|
|
317
|
+
|
|
318
|
+
def disarm(self):
|
|
319
|
+
"""[v1.1 추가] ARM 비트를 내려 모터를 정지한다.
|
|
320
|
+
|
|
321
|
+
emergency() 와 동작은 같지만, 착륙이 끝난 뒤 정상 종료 용도로 쓴다.
|
|
322
|
+
**공중에서 호출하면 추락한다.**
|
|
323
|
+
"""
|
|
324
|
+
self._resetAxes()
|
|
325
|
+
self.makepkt.setField(Packet.IDX_THR, 0, signed=False)
|
|
326
|
+
self.setOption(OPT_STOP)
|
|
327
|
+
|
|
328
|
+
def altitude(self, alt):
|
|
329
|
+
"""목표 고도를 cm 단위로 설정한다 (0~150)."""
|
|
330
|
+
alt = DefLib.constrain(int(alt), ALT_MAX, ALT_MIN)
|
|
331
|
+
self.makepkt.setField(Packet.IDX_THR, alt, signed=False)
|
|
332
|
+
|
|
333
|
+
def velocity(self, dir=FRONT, vel=100):
|
|
334
|
+
"""지정 방향으로 속도 제어(cm/s). 누적되지 않는다."""
|
|
335
|
+
if dir > LEFT or dir < FRONT:
|
|
336
|
+
return
|
|
337
|
+
vel = DefLib.constrain(int(vel), VEL_MAX, VEL_MIN)
|
|
338
|
+
if dir == BACK or dir == LEFT:
|
|
339
|
+
vel = -vel
|
|
340
|
+
if dir == FRONT or dir == BACK:
|
|
341
|
+
self.makepkt.setField(Packet.IDX_PITCH, vel)
|
|
342
|
+
self.makepkt.setField(Packet.IDX_ROLL, 0)
|
|
343
|
+
else:
|
|
344
|
+
self.makepkt.setField(Packet.IDX_ROLL, vel)
|
|
345
|
+
self.makepkt.setField(Packet.IDX_PITCH, 0)
|
|
346
|
+
self.posX = self.posY = 0
|
|
347
|
+
self.setOption(OPT_FLIGHT_VEL)
|
|
348
|
+
|
|
349
|
+
def rc_control(self, roll=0, pitch=0, yaw=0, throttle=None):
|
|
350
|
+
"""roll·pitch·yaw 를 한 번에 준다 (실시간 수동 조종용).
|
|
351
|
+
|
|
352
|
+
velocity() 를 앞뒤·좌우로 따로 부르면 서로 필드를 0으로 덮어써서
|
|
353
|
+
한 방향만 먹는다. 대각선 이동이나 방향키 동시 입력에는 이 메서드를 쓴다.
|
|
354
|
+
|
|
355
|
+
roll : 좌우 속도 (오른쪽 +, 왼쪽 -) -VEL_MAX ~ VEL_MAX
|
|
356
|
+
pitch : 전후 속도 (앞 +, 뒤 -)
|
|
357
|
+
yaw : 회전 속도 (오른쪽 +, 왼쪽 -)
|
|
358
|
+
throttle : 고도(cm). None 이면 기존 고도 유지
|
|
359
|
+
|
|
360
|
+
Tello 의 send_rc_control(lr, fb, ud, yaw) 에 대응한다.
|
|
361
|
+
"""
|
|
362
|
+
roll = DefLib.constrain(int(roll), VEL_MAX, VEL_MIN)
|
|
363
|
+
pitch = DefLib.constrain(int(pitch), VEL_MAX, VEL_MIN)
|
|
364
|
+
yaw = DefLib.constrain(int(yaw), YAWVEL_MAX, -YAWVEL_MAX)
|
|
365
|
+
self.makepkt.setField(Packet.IDX_ROLL, roll)
|
|
366
|
+
self.makepkt.setField(Packet.IDX_PITCH, pitch)
|
|
367
|
+
self.makepkt.setField(Packet.IDX_YAW, yaw)
|
|
368
|
+
if throttle is not None:
|
|
369
|
+
self.makepkt.setField(Packet.IDX_THR,
|
|
370
|
+
DefLib.constrain(int(throttle), ALT_MAX, ALT_MIN),
|
|
371
|
+
signed=False)
|
|
372
|
+
self.posX = self.posY = 0
|
|
373
|
+
self.setOption(OPT_FLIGHT_VEL)
|
|
374
|
+
|
|
375
|
+
def move(self, dir=FRONT, dist=100):
|
|
376
|
+
"""이륙 지점 기준 누적 절대 위치로 이동한다 (cm)."""
|
|
377
|
+
if dir > LEFT or dir < FRONT:
|
|
378
|
+
return
|
|
379
|
+
dist = int(dist)
|
|
380
|
+
if dir == BACK or dir == LEFT:
|
|
381
|
+
dist = -dist
|
|
382
|
+
if dir == FRONT or dir == BACK:
|
|
383
|
+
self.posX = DefLib.constrain(self.posX + dist, POS_MAX, POS_MIN)
|
|
384
|
+
else:
|
|
385
|
+
self.posY = DefLib.constrain(self.posY + dist, POS_MAX, POS_MIN)
|
|
386
|
+
self.makepkt.setField(Packet.IDX_PITCH, self.posX)
|
|
387
|
+
self.makepkt.setField(Packet.IDX_ROLL, self.posY)
|
|
388
|
+
self.setOption(OPT_FLIGHT_POS)
|
|
389
|
+
|
|
390
|
+
def moveTo(self, x=0, y=0):
|
|
391
|
+
"""이륙 지점 기준 절대 좌표로 직접 이동한다 (누적 아님)."""
|
|
392
|
+
self.posX = DefLib.constrain(int(x), POS_MAX, POS_MIN)
|
|
393
|
+
self.posY = DefLib.constrain(int(y), POS_MAX, POS_MIN)
|
|
394
|
+
self.makepkt.setField(Packet.IDX_PITCH, self.posX)
|
|
395
|
+
self.makepkt.setField(Packet.IDX_ROLL, self.posY)
|
|
396
|
+
self.setOption(OPT_FLIGHT_POS)
|
|
397
|
+
|
|
398
|
+
def rotation(self, rot=90):
|
|
399
|
+
"""rot(도)만큼 회전한다. 양수는 시계방향. 내부적으로 절대각을 누적한다."""
|
|
400
|
+
self.rot = (int(self.rot + rot) + 180) % 360 - 180
|
|
401
|
+
self.makepkt.setField(Packet.IDX_YAW, self.rot)
|
|
402
|
+
|
|
403
|
+
def rotationTo(self, angle=0):
|
|
404
|
+
"""이륙 시점 기준 절대 각도로 회전한다 (누적 아님)."""
|
|
405
|
+
self.rot = (int(angle) + 180) % 360 - 180
|
|
406
|
+
self.makepkt.setField(Packet.IDX_YAW, self.rot)
|
|
407
|
+
|
|
408
|
+
def stop(self):
|
|
409
|
+
"""수평 이동을 정지하고 제자리 호버링한다."""
|
|
410
|
+
self.makepkt.setField(Packet.IDX_ROLL, 0)
|
|
411
|
+
self.makepkt.setField(Packet.IDX_PITCH, 0)
|
|
412
|
+
self.posX = self.posY = 0
|
|
413
|
+
self.setOption(OPT_FLIGHT_VEL)
|
|
414
|
+
|
|
415
|
+
hovering = stop
|
|
416
|
+
|
|
417
|
+
def setSpeed(self, posvel=100, yawvel=100):
|
|
418
|
+
"""이동 속도(cm/s)와 회전 각속도(도/s) 기본값을 설정한다."""
|
|
419
|
+
self.makepkt.setField(Packet.IDX_POSVEL,
|
|
420
|
+
DefLib.constrain(int(posvel), POSVEL_MAX, 0), signed=False)
|
|
421
|
+
self.makepkt.setField(Packet.IDX_YAWVEL,
|
|
422
|
+
DefLib.constrain(int(yawvel), YAWVEL_MAX, 0), signed=False)
|
|
423
|
+
|
|
424
|
+
def motor(self, what, speed):
|
|
425
|
+
"""개별 모터 직접 구동 (테스트용). **반드시 프로펠러를 제거하고 사용할 것.**"""
|
|
426
|
+
if what < 0 or what > 3:
|
|
427
|
+
return
|
|
428
|
+
speed = DefLib.constrain(int(speed), 100, 0)
|
|
429
|
+
self.makepkt.setField(what * 2 + 6, speed)
|
|
430
|
+
self.setOption(OPT_MOTORTEST)
|
|
431
|
+
|
|
432
|
+
def emergency(self):
|
|
433
|
+
"""ARM 비트를 내려 모터를 즉시 정지한다. 공중에서 호출하면 추락한다."""
|
|
434
|
+
self._resetAxes()
|
|
435
|
+
self.makepkt.setField(Packet.IDX_THR, 0, signed=False)
|
|
436
|
+
self.setOption(OPT_STOP)
|
|
437
|
+
# [v1.1 수정] 간격 없이 연속 write 하면 드라이버 버퍼에 뭉쳐 나간다.
|
|
438
|
+
for i in range(3):
|
|
439
|
+
self._write(self.makepkt.getPacket())
|
|
440
|
+
if i < 2:
|
|
441
|
+
sleep(0.02)
|
|
442
|
+
|
|
443
|
+
# ==================================================================
|
|
444
|
+
# Command Packet (설정 / 요청)
|
|
445
|
+
# ==================================================================
|
|
446
|
+
def _sendCommand(self, header4, type_no, payload=b"", repeat=None, gap=None):
|
|
447
|
+
"""Command Packet을 구성해 전송한다. LEN/CS는 자동 계산.
|
|
448
|
+
|
|
449
|
+
[v1.1 수정] 송신 스레드를 잠시 멈추고 여러 번 반복 전송한다.
|
|
450
|
+
이전 버전은 1회만 보내 제어 스트림 사이에서 유실되는 일이 잦았다.
|
|
451
|
+
"""
|
|
452
|
+
repeat = self.CMD_REPEAT if repeat is None else max(1, int(repeat))
|
|
453
|
+
gap = self.CMD_GAP if gap is None else float(gap)
|
|
454
|
+
|
|
455
|
+
length = 7 + len(payload)
|
|
456
|
+
pkt = bytearray([0x26, 0xA8, 0x14, header4, length, 0x00, type_no]) + bytes(payload)
|
|
457
|
+
pkt[5] = DefLib.checksum(pkt)
|
|
458
|
+
|
|
459
|
+
ok = False
|
|
460
|
+
with self._txPaused():
|
|
461
|
+
for i in range(repeat):
|
|
462
|
+
ok = self._write(pkt) or ok
|
|
463
|
+
if i < repeat - 1:
|
|
464
|
+
sleep(gap)
|
|
465
|
+
return ok
|
|
466
|
+
|
|
467
|
+
# ---- 센서 / 정보 요청 -------------------------------------------
|
|
468
|
+
def _trackSensor(self, data_type, on):
|
|
469
|
+
"""출력 요청 중인 TYPE 목록을 갱신한다 (Close() 자동 정지에 사용).
|
|
470
|
+
|
|
471
|
+
__init__ 을 거치지 않은 객체(단위 테스트)에서도 안전하도록 방어한다.
|
|
472
|
+
"""
|
|
473
|
+
types = getattr(self, '_sensorTypes', None)
|
|
474
|
+
if types is None:
|
|
475
|
+
return
|
|
476
|
+
types.add(data_type) if on else types.discard(data_type)
|
|
477
|
+
|
|
478
|
+
def requestSensorData(self, data_type, sig_mode=None):
|
|
479
|
+
"""센서 데이터 출력을 요청한다.
|
|
480
|
+
|
|
481
|
+
data_type 9 : 비행정보 (roll/pitch/height/pos_x/pos_y), sig_mode 기본 1
|
|
482
|
+
data_type 10 : 센서·제어 신호, sig_mode 기본 0x06(POS_SIGNAL)
|
|
483
|
+
"""
|
|
484
|
+
if data_type == TYPE_FLIGHTINFO:
|
|
485
|
+
mode = 1 if sig_mode is None else (int(sig_mode) & 0xFF)
|
|
486
|
+
ok = self._sendCommand(0xB2, TYPE_FLIGHTINFO, bytes([mode]))
|
|
487
|
+
self._trackSensor(TYPE_FLIGHTINFO, ok and bool(mode))
|
|
488
|
+
return ok
|
|
489
|
+
elif data_type == TYPE_SIGNAL:
|
|
490
|
+
sig = SIG_POS if sig_mode is None else (int(sig_mode) & 0xFF)
|
|
491
|
+
ok = self._sendCommand(0xB1, TYPE_SIGNAL, bytes([sig]))
|
|
492
|
+
self._trackSensor(TYPE_SIGNAL, ok and sig != SIG_STOP)
|
|
493
|
+
return ok
|
|
494
|
+
return False
|
|
495
|
+
|
|
496
|
+
def stopSensorData(self, data_type):
|
|
497
|
+
"""센서 데이터 출력을 중지한다."""
|
|
498
|
+
if data_type == TYPE_FLIGHTINFO:
|
|
499
|
+
ok = self._sendCommand(0xB2, TYPE_FLIGHTINFO, bytes([0x00]))
|
|
500
|
+
self._trackSensor(TYPE_FLIGHTINFO, False)
|
|
501
|
+
return ok
|
|
502
|
+
elif data_type == TYPE_SIGNAL:
|
|
503
|
+
ok = self._sendCommand(0xB1, TYPE_SIGNAL, bytes([SIG_STOP]))
|
|
504
|
+
self._trackSensor(TYPE_SIGNAL, False)
|
|
505
|
+
return ok
|
|
506
|
+
return False
|
|
507
|
+
|
|
508
|
+
def startSensor(self, flight=True, signal=False, timeout=3.0):
|
|
509
|
+
"""[v1.1 추가] 센서 출력을 시작하고 실제로 들어오는지 확인한다.
|
|
510
|
+
|
|
511
|
+
내장 센서(sensor=True)로 생성한 경우에만 도착 확인이 가능하며,
|
|
512
|
+
확인에 성공하면 True를 반환한다.
|
|
513
|
+
"""
|
|
514
|
+
base = self.sensor.info_count if self.sensor is not None else 0
|
|
515
|
+
if flight:
|
|
516
|
+
self.requestSensorData(TYPE_FLIGHTINFO)
|
|
517
|
+
if signal:
|
|
518
|
+
self.requestSensorData(TYPE_SIGNAL)
|
|
519
|
+
if self.sensor is None:
|
|
520
|
+
return True # 확인할 방법이 없으므로 전송 성공으로 간주
|
|
521
|
+
|
|
522
|
+
deadline = time() + timeout
|
|
523
|
+
while time() < deadline:
|
|
524
|
+
if self.sensor.info_count > base:
|
|
525
|
+
return True
|
|
526
|
+
sleep(0.05)
|
|
527
|
+
return False
|
|
528
|
+
|
|
529
|
+
def stopSensor(self):
|
|
530
|
+
"""[v1.1 추가] 요청 중인 센서 출력을 모두 중지한다."""
|
|
531
|
+
for t in list(getattr(self, '_sensorTypes', ())):
|
|
532
|
+
self.stopSensorData(t)
|
|
533
|
+
return True
|
|
534
|
+
|
|
535
|
+
def requestDeviceInfo(self):
|
|
536
|
+
"""TYPE(8) 디바이스 정보(버전/시리얼/MAC/센서 탑재) 출력 요청."""
|
|
537
|
+
return self._sendCommand(0xB2, TYPE_DEVINFO)
|
|
538
|
+
|
|
539
|
+
# ---- 내장 센서 접근 ---------------------------------------------
|
|
540
|
+
def getState(self):
|
|
541
|
+
"""[v1.1 추가] 내장 센서의 상태 딕셔너리. sensor=False 면 None."""
|
|
542
|
+
return None if self.sensor is None else self.sensor.state
|
|
543
|
+
|
|
544
|
+
def getBattery(self):
|
|
545
|
+
"""[v1.1 추가] 배터리 잔량(%). 요청 없이도 모든 출력 패킷에 들어 있다."""
|
|
546
|
+
return 0 if self.sensor is None else self.sensor.state['battery']
|
|
547
|
+
|
|
548
|
+
def getAttitude(self):
|
|
549
|
+
"""[v1.1 추가] (roll, pitch, height). startSensor() 이후에만 갱신된다."""
|
|
550
|
+
if self.sensor is None:
|
|
551
|
+
return (0, 0, 0)
|
|
552
|
+
s = self.sensor.state
|
|
553
|
+
return s['roll'], s['pitch'], s['height']
|
|
554
|
+
|
|
555
|
+
def isReady(self):
|
|
556
|
+
"""[v1.1 추가] OPT1 bit1 — 드론 비행 준비 상태."""
|
|
557
|
+
return False if self.sensor is None else self.sensor.state['ready']
|
|
558
|
+
|
|
559
|
+
def waitReady(self, timeout=5.0):
|
|
560
|
+
"""[v1.1 추가] 비행 준비 상태가 될 때까지 기다린다."""
|
|
561
|
+
if self.sensor is None:
|
|
562
|
+
return False
|
|
563
|
+
deadline = time() + timeout
|
|
564
|
+
while time() < deadline:
|
|
565
|
+
if self.sensor.state['ready']:
|
|
566
|
+
return True
|
|
567
|
+
sleep(0.05)
|
|
568
|
+
return False
|
|
569
|
+
|
|
570
|
+
# ---- 보정 / 트림 / 기타 설정 ------------------------------------
|
|
571
|
+
def calibration(self):
|
|
572
|
+
"""TYPE(6) 자이로·가속 센서 보정. 드론을 평평한 곳에 놓고 실행할 것."""
|
|
573
|
+
return self._sendCommand(0xB1, TYPE_CALIBRATION)
|
|
574
|
+
|
|
575
|
+
def setTrim(self, roll=READ_TRIM, pitch=READ_TRIM):
|
|
576
|
+
"""TYPE(5) TRIM 설정. 인자 없이 호출하면 현재 설정값 읽기 요청."""
|
|
577
|
+
payload = int(roll).to_bytes(2, "little", signed=True) + \
|
|
578
|
+
int(pitch).to_bytes(2, "little", signed=True)
|
|
579
|
+
return self._sendCommand(0xB1, TYPE_TRIM, payload)
|
|
580
|
+
|
|
581
|
+
def setMisc(self, rst=0, wc=READ_MISC, af=READ_MISC, pd=READ_MISC):
|
|
582
|
+
"""TYPE(7) 기타 설정.
|
|
583
|
+
|
|
584
|
+
rst : 0x94 이면 설정값 공장 초기화 (펌웨어는 변화 없음)
|
|
585
|
+
wc : Weight Coefficient. 기체가 무거울수록 크게. 255이면 현재값 읽기
|
|
586
|
+
af : AltFree. 0=고도 제한(0~150cm), 1=제한 없음, 255=읽기
|
|
587
|
+
pd : Propeller Direction. 255=읽기
|
|
588
|
+
"""
|
|
589
|
+
payload = bytes([int(rst) & 0xFF, int(wc) & 0xFF, int(af) & 0xFF, int(pd) & 0xFF])
|
|
590
|
+
return self._sendCommand(0xB1, TYPE_MISC, payload)
|
|
591
|
+
|
|
592
|
+
def setWeightCoefficient(self, wc):
|
|
593
|
+
"""ALTHOLD=1에서 기체 무게가 바뀌어 고도가 불안정할 때 조정한다."""
|
|
594
|
+
return self.setMisc(rst=0, wc=wc, af=READ_MISC, pd=READ_MISC)
|
|
595
|
+
|
|
596
|
+
def factoryReset(self):
|
|
597
|
+
"""설정값을 공장 초기화한다."""
|
|
598
|
+
return self.setMisc(rst=FACTORY_RESET)
|
|
599
|
+
|
|
600
|
+
# ---- PID 설정 ---------------------------------------------------
|
|
601
|
+
def _setPID(self, type_no, p, i, d):
|
|
602
|
+
payload = int(p).to_bytes(2, "little", signed=True) + \
|
|
603
|
+
int(i).to_bytes(2, "little", signed=True) + \
|
|
604
|
+
int(d).to_bytes(2, "little", signed=True)
|
|
605
|
+
return self._sendCommand(0xB1, type_no, payload)
|
|
606
|
+
|
|
607
|
+
def setFlightPID(self, p=READ_PID, i=READ_PID, d=READ_PID):
|
|
608
|
+
"""TYPE(2) 비행 PID. 인자 없이 호출하면 현재값 읽기 요청."""
|
|
609
|
+
return self._setPID(TYPE_PID_FLIGHT, p, i, d)
|
|
610
|
+
|
|
611
|
+
def setAltPID(self, p=READ_PID, i=READ_PID, d=READ_PID):
|
|
612
|
+
"""TYPE(3) 높이 PID."""
|
|
613
|
+
return self._setPID(TYPE_PID_ALT, p, i, d)
|
|
614
|
+
|
|
615
|
+
def setPosPID(self, p=READ_PID, i=READ_PID, d=READ_PID):
|
|
616
|
+
"""TYPE(4) 위치 PID."""
|
|
617
|
+
return self._setPID(TYPE_PID_POS, p, i, d)
|
|
618
|
+
|
|
619
|
+
# ==================================================================
|
|
620
|
+
# 영상 스트리밍
|
|
621
|
+
# ==================================================================
|
|
622
|
+
def setStreamAddress(self, host, port=80, path="/?action=stream"):
|
|
623
|
+
if not isinstance(host, str) or len(host.strip()) == 0:
|
|
624
|
+
raise ValueError("host가 올바르지 않습니다.")
|
|
625
|
+
self.stream_host = host.strip()
|
|
626
|
+
self.stream_port = int(port)
|
|
627
|
+
if not path.startswith("/"):
|
|
628
|
+
path = "/" + path
|
|
629
|
+
self.stream_path = path
|
|
630
|
+
return self._build_stream_url()
|
|
631
|
+
|
|
632
|
+
def _build_stream_url(self):
|
|
633
|
+
if self.stream_port in (80, None):
|
|
634
|
+
return f"http://{self.stream_host}{self.stream_path}"
|
|
635
|
+
return f"http://{self.stream_host}:{self.stream_port}{self.stream_path}"
|
|
636
|
+
|
|
637
|
+
def streamon(self, host=None, port=None, path=None, return_url=False):
|
|
638
|
+
"""MJPG-Streamer 스트림을 열고 cv2.VideoCapture 객체를 반환한다."""
|
|
639
|
+
if host is not None or port is not None or path is not None:
|
|
640
|
+
self.setStreamAddress(host or self.stream_host,
|
|
641
|
+
self.stream_port if port is None else port,
|
|
642
|
+
self.stream_path if path is None else path)
|
|
643
|
+
url = self._build_stream_url()
|
|
644
|
+
if return_url:
|
|
645
|
+
return url
|
|
646
|
+
|
|
647
|
+
try:
|
|
648
|
+
import cv2 as cv
|
|
649
|
+
except Exception as e:
|
|
650
|
+
raise RuntimeError(f"OpenCV(cv2) 임포트 실패: {e}")
|
|
651
|
+
|
|
652
|
+
self.streamoff()
|
|
653
|
+
self._cap = cv.VideoCapture(url)
|
|
654
|
+
if not self._cap.isOpened():
|
|
655
|
+
self._cap.release()
|
|
656
|
+
self._cap = None
|
|
657
|
+
raise RuntimeError(f"스트림 열기 실패: {url}")
|
|
658
|
+
return self._cap
|
|
659
|
+
|
|
660
|
+
def streamoff(self):
|
|
661
|
+
if self._cap is not None:
|
|
662
|
+
try:
|
|
663
|
+
self._cap.release()
|
|
664
|
+
except Exception:
|
|
665
|
+
pass
|
|
666
|
+
self._cap = None
|
|
667
|
+
|
|
668
|
+
def getFrame(self):
|
|
669
|
+
"""열려 있는 스트림에서 프레임 1장을 읽어 반환한다. 실패 시 None."""
|
|
670
|
+
if self._cap is None:
|
|
671
|
+
return None
|
|
672
|
+
ret, frame = self._cap.read()
|
|
673
|
+
if not ret:
|
|
674
|
+
return None
|
|
675
|
+
return frame
|
|
676
|
+
|
|
677
|
+
# ==================================================================
|
|
678
|
+
# with 문 지원
|
|
679
|
+
# ==================================================================
|
|
680
|
+
def __enter__(self):
|
|
681
|
+
return self
|
|
682
|
+
|
|
683
|
+
def __exit__(self, exc_type, exc, tb):
|
|
684
|
+
self.Close()
|
|
685
|
+
return False
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
pyaidrone.drone — 쉬운 고수준 드론 제어 클래스 (Tello 스타일 API)
|
|
4
|
+
|
|
5
|
+
djitellopy(Tello) 예제를 거의 그대로 쓸 수 있게, 같은 이름의 메서드를 제공한다.
|
|
6
|
+
학생이 인터넷의 수많은 Tello 코드를 pyaidrone 으로 바로 가져다 쓸 수 있다.
|
|
7
|
+
|
|
8
|
+
Tello 코드:
|
|
9
|
+
from djitellopy import Tello
|
|
10
|
+
aidrone = Tello() # (참고: 실제로는 Drone 을 씀)
|
|
11
|
+
aidrone.connect()
|
|
12
|
+
aidrone.takeoff()
|
|
13
|
+
aidrone.move_forward(100)
|
|
14
|
+
aidrone.rotate_clockwise(90)
|
|
15
|
+
aidrone.land()
|
|
16
|
+
|
|
17
|
+
같은 동작 (pyaidrone):
|
|
18
|
+
from pyaidrone.drone import Drone
|
|
19
|
+
aidrone = Drone("COM3")
|
|
20
|
+
aidrone.connect()
|
|
21
|
+
aidrone.takeoff()
|
|
22
|
+
aidrone.move_forward(100)
|
|
23
|
+
aidrone.rotate_clockwise(90)
|
|
24
|
+
aidrone.land()
|
|
25
|
+
|
|
26
|
+
핵심 차이(내부적으로 흡수함):
|
|
27
|
+
- Tello 의 move_xxx(cm) 는 '거리를 다 갈 때까지 기다렸다가' 리턴한다(블로킹).
|
|
28
|
+
이 드론은 속도 제어 기반이므로, 거리/속도로 이동 시간을 계산해 그만큼 이동 후
|
|
29
|
+
자동 정지한다. 학생은 Tello 와 똑같이 거리만 지정하면 된다.
|
|
30
|
+
- 이 드론 펌웨어는 '준비 비트'를 쓰지 않으므로, connect() 는 통신 여부로 판단한다.
|
|
31
|
+
|
|
32
|
+
주의: 실제 비행 거리 정확도는 기체 상태(광학흐름/바닥 무늬/배터리)에 따라 달라진다.
|
|
33
|
+
DEFAULT_SPEED 를 조정해 교실 환경에 맞게 보정할 수 있다.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import time
|
|
37
|
+
from pyaidrone.aiDrone import AIDrone
|
|
38
|
+
from pyaidrone.deflib import FRONT, BACK, LEFT, RIGHT
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Drone:
|
|
42
|
+
"""pyaidrone 을 Tello(djitellopy) 처럼 쓰게 해주는 래퍼."""
|
|
43
|
+
|
|
44
|
+
DEFAULT_SPEED = 30 # 이동 속도(cm/s). move_xxx 의 이동 시간 계산 기준
|
|
45
|
+
DEFAULT_HEIGHT = 80 # 이륙 목표 고도(cm)
|
|
46
|
+
TAKEOFF_WAIT = 5 # 이륙 안정화 대기(초)
|
|
47
|
+
ROTATE_WAIT = 3 # 회전 대기(초)
|
|
48
|
+
|
|
49
|
+
def __init__(self, port="COM3"):
|
|
50
|
+
self.port = port
|
|
51
|
+
self.drone = AIDrone(sensor=True)
|
|
52
|
+
self._flying = False
|
|
53
|
+
self._cap = None
|
|
54
|
+
self.speed = self.DEFAULT_SPEED
|
|
55
|
+
|
|
56
|
+
# ==================================================================
|
|
57
|
+
# 연결
|
|
58
|
+
# ==================================================================
|
|
59
|
+
def connect(self, wait=0.5):
|
|
60
|
+
"""드론에 연결하고 센서 출력을 시작한다. (Tello.connect 대응)"""
|
|
61
|
+
self.drone.Open(self.port)
|
|
62
|
+
time.sleep(wait)
|
|
63
|
+
self.drone.startSensor(flight=True)
|
|
64
|
+
if self.drone.sensor.count == 0:
|
|
65
|
+
raise ConnectionError("드론에서 신호가 오지 않습니다. 전원/페어링을 확인하세요.")
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
def end(self):
|
|
69
|
+
"""연결을 종료한다. (Tello.end 대응)"""
|
|
70
|
+
try:
|
|
71
|
+
if self._flying:
|
|
72
|
+
self.land()
|
|
73
|
+
finally:
|
|
74
|
+
self.streamoff()
|
|
75
|
+
self.drone.Close()
|
|
76
|
+
|
|
77
|
+
# ==================================================================
|
|
78
|
+
# 상태 조회 (Tello get_xxx 대응)
|
|
79
|
+
# ==================================================================
|
|
80
|
+
def get_battery(self):
|
|
81
|
+
"""배터리 잔량(%)."""
|
|
82
|
+
return self.drone.getBattery()
|
|
83
|
+
|
|
84
|
+
def get_height(self):
|
|
85
|
+
"""현재 높이(cm)."""
|
|
86
|
+
return self.drone.getAttitude()[2]
|
|
87
|
+
|
|
88
|
+
def get_roll(self):
|
|
89
|
+
return self.drone.getAttitude()[0]
|
|
90
|
+
|
|
91
|
+
def get_pitch(self):
|
|
92
|
+
return self.drone.getAttitude()[1]
|
|
93
|
+
|
|
94
|
+
def get_state(self):
|
|
95
|
+
"""전체 센서 상태(딕셔너리)."""
|
|
96
|
+
return self.drone.getState()
|
|
97
|
+
|
|
98
|
+
def set_speed(self, cm_s):
|
|
99
|
+
"""이동 속도(cm/s)를 설정한다. move_xxx 의 이동 시간에 반영된다."""
|
|
100
|
+
self.speed = max(10, min(100, int(cm_s)))
|
|
101
|
+
|
|
102
|
+
# ==================================================================
|
|
103
|
+
# 이·착륙 (Tello 대응)
|
|
104
|
+
# ==================================================================
|
|
105
|
+
def takeoff(self):
|
|
106
|
+
"""이륙 후 안정될 때까지 기다린다. (블로킹)"""
|
|
107
|
+
self.drone.takeoff(self.DEFAULT_HEIGHT)
|
|
108
|
+
self._flying = True
|
|
109
|
+
time.sleep(self.TAKEOFF_WAIT)
|
|
110
|
+
|
|
111
|
+
def land(self):
|
|
112
|
+
"""착륙 후 모터를 정지한다. (블로킹)"""
|
|
113
|
+
self.drone.landing()
|
|
114
|
+
time.sleep(3)
|
|
115
|
+
self.drone.disarm()
|
|
116
|
+
self._flying = False
|
|
117
|
+
|
|
118
|
+
# ==================================================================
|
|
119
|
+
# 거리 이동 — Tello move_xxx(cm) 대응 (블로킹)
|
|
120
|
+
# ==================================================================
|
|
121
|
+
def _move(self, direction, cm):
|
|
122
|
+
"""지정 방향으로 cm 만큼 이동 후 정지. 거리/속도로 시간을 계산한다."""
|
|
123
|
+
cm = abs(int(cm))
|
|
124
|
+
duration = cm / float(self.speed)
|
|
125
|
+
self.drone.velocity(direction, self.speed)
|
|
126
|
+
time.sleep(duration)
|
|
127
|
+
self.drone.velocity(direction, 0) # 정지
|
|
128
|
+
time.sleep(0.5) # 정지 안정화
|
|
129
|
+
|
|
130
|
+
def move_forward(self, cm):
|
|
131
|
+
self._move(FRONT, cm)
|
|
132
|
+
|
|
133
|
+
def move_back(self, cm):
|
|
134
|
+
self._move(BACK, cm)
|
|
135
|
+
|
|
136
|
+
def move_left(self, cm):
|
|
137
|
+
self._move(LEFT, cm)
|
|
138
|
+
|
|
139
|
+
def move_right(self, cm):
|
|
140
|
+
self._move(RIGHT, cm)
|
|
141
|
+
|
|
142
|
+
def move_up(self, cm):
|
|
143
|
+
"""위로 cm 만큼 상승 (절대 고도 기준으로 목표를 올림)."""
|
|
144
|
+
h = self.get_height()
|
|
145
|
+
target = min(150, h + abs(int(cm)))
|
|
146
|
+
self.drone.altitude(target)
|
|
147
|
+
time.sleep(2)
|
|
148
|
+
|
|
149
|
+
def move_down(self, cm):
|
|
150
|
+
h = self.get_height()
|
|
151
|
+
target = max(0, h - abs(int(cm)))
|
|
152
|
+
self.drone.altitude(target)
|
|
153
|
+
time.sleep(2)
|
|
154
|
+
|
|
155
|
+
# ==================================================================
|
|
156
|
+
# 회전 — Tello rotate_xxx(deg) 대응 (블로킹)
|
|
157
|
+
# ==================================================================
|
|
158
|
+
def rotate_clockwise(self, deg):
|
|
159
|
+
"""시계 방향(오른쪽) 회전."""
|
|
160
|
+
self.drone.rotation(abs(int(deg)))
|
|
161
|
+
time.sleep(self.ROTATE_WAIT)
|
|
162
|
+
|
|
163
|
+
def rotate_counter_clockwise(self, deg):
|
|
164
|
+
"""반시계 방향(왼쪽) 회전."""
|
|
165
|
+
self.drone.rotation(-abs(int(deg)))
|
|
166
|
+
time.sleep(self.ROTATE_WAIT)
|
|
167
|
+
|
|
168
|
+
# ==================================================================
|
|
169
|
+
# 실시간 속도 제어 — Tello send_rc_control 대응
|
|
170
|
+
# ==================================================================
|
|
171
|
+
def send_rc_control(self, lr, fb, ud, yaw):
|
|
172
|
+
"""네 방향 속도를 한 번에 준다. (-100~100)
|
|
173
|
+
lr: 좌우, fb: 전후, ud: 상하, yaw: 회전. Tello 와 동일한 순서.
|
|
174
|
+
|
|
175
|
+
좌우(roll)와 전후(pitch)를 동시에 보내므로 대각선 이동도 된다."""
|
|
176
|
+
self.drone.rc_control(roll=lr, pitch=fb, yaw=yaw)
|
|
177
|
+
if ud:
|
|
178
|
+
# 상하는 목표 고도를 조금씩 올리고 내린다
|
|
179
|
+
h = self.get_height()
|
|
180
|
+
self.drone.altitude(max(0, min(150, h + int(ud) // 10)))
|
|
181
|
+
|
|
182
|
+
# ==================================================================
|
|
183
|
+
# 영상 — Tello streamon / get_frame_read 대응
|
|
184
|
+
# ==================================================================
|
|
185
|
+
def streamon(self, url="http://192.168.4.1/?action=stream"):
|
|
186
|
+
"""영상 스트림을 켠다."""
|
|
187
|
+
import cv2
|
|
188
|
+
self._cap = cv2.VideoCapture(url)
|
|
189
|
+
return self._cap.isOpened()
|
|
190
|
+
|
|
191
|
+
def streamoff(self):
|
|
192
|
+
if self._cap is not None:
|
|
193
|
+
self._cap.release()
|
|
194
|
+
self._cap = None
|
|
195
|
+
|
|
196
|
+
def get_frame(self):
|
|
197
|
+
"""현재 프레임 1장을 반환한다. 실패 시 None."""
|
|
198
|
+
if self._cap is None:
|
|
199
|
+
return None
|
|
200
|
+
ok, frame = self._cap.read()
|
|
201
|
+
return frame if ok else None
|
|
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="pyaidrone",
|
|
5
|
-
version="2.
|
|
5
|
+
version="2.9",
|
|
6
6
|
description="Library for AIDrone Products",
|
|
7
7
|
long_description=open("README.md").read(), # README.md 내용을 long_description으로 사용
|
|
8
8
|
long_description_content_type="text/markdown", # README 파일이 markdown 형식임을 지정
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|