pyaidrone 2.7__tar.gz → 2.8__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyaidrone
3
- Version: 2.7
3
+ Version: 2.8
4
4
  Summary: Library for AIDrone Products
5
5
  Home-page: http://www.ir-brain.com
6
6
  Author: IR-Brain
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyaidrone
3
- Version: 2.7
3
+ Version: 2.8
4
4
  Summary: Library for AIDrone Products
5
5
  Home-page: http://www.ir-brain.com
6
6
  Author: IR-Brain
@@ -2,10 +2,8 @@ README.md
2
2
  setup.cfg
3
3
  setup.py
4
4
  pyaidrone/__init__.py
5
- pyaidrone/aiDrone.py
6
5
  pyaidrone/aidrone_mavlink_bridge.py
7
6
  pyaidrone/deflib.py
8
- pyaidrone/drone.py
9
7
  pyaidrone/edu.py
10
8
  pyaidrone/ikeyevent.py
11
9
  pyaidrone/packet.py
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="pyaidrone",
5
- version="2.7",
5
+ version="2.8",
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 형식임을 지정
@@ -1,659 +0,0 @@
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 move(self, dir=FRONT, dist=100):
350
- """이륙 지점 기준 누적 절대 위치로 이동한다 (cm)."""
351
- if dir > LEFT or dir < FRONT:
352
- return
353
- dist = int(dist)
354
- if dir == BACK or dir == LEFT:
355
- dist = -dist
356
- if dir == FRONT or dir == BACK:
357
- self.posX = DefLib.constrain(self.posX + dist, POS_MAX, POS_MIN)
358
- else:
359
- self.posY = DefLib.constrain(self.posY + dist, POS_MAX, POS_MIN)
360
- self.makepkt.setField(Packet.IDX_PITCH, self.posX)
361
- self.makepkt.setField(Packet.IDX_ROLL, self.posY)
362
- self.setOption(OPT_FLIGHT_POS)
363
-
364
- def moveTo(self, x=0, y=0):
365
- """이륙 지점 기준 절대 좌표로 직접 이동한다 (누적 아님)."""
366
- self.posX = DefLib.constrain(int(x), POS_MAX, POS_MIN)
367
- self.posY = DefLib.constrain(int(y), POS_MAX, POS_MIN)
368
- self.makepkt.setField(Packet.IDX_PITCH, self.posX)
369
- self.makepkt.setField(Packet.IDX_ROLL, self.posY)
370
- self.setOption(OPT_FLIGHT_POS)
371
-
372
- def rotation(self, rot=90):
373
- """rot(도)만큼 회전한다. 양수는 시계방향. 내부적으로 절대각을 누적한다."""
374
- self.rot = (int(self.rot + rot) + 180) % 360 - 180
375
- self.makepkt.setField(Packet.IDX_YAW, self.rot)
376
-
377
- def rotationTo(self, angle=0):
378
- """이륙 시점 기준 절대 각도로 회전한다 (누적 아님)."""
379
- self.rot = (int(angle) + 180) % 360 - 180
380
- self.makepkt.setField(Packet.IDX_YAW, self.rot)
381
-
382
- def stop(self):
383
- """수평 이동을 정지하고 제자리 호버링한다."""
384
- self.makepkt.setField(Packet.IDX_ROLL, 0)
385
- self.makepkt.setField(Packet.IDX_PITCH, 0)
386
- self.posX = self.posY = 0
387
- self.setOption(OPT_FLIGHT_VEL)
388
-
389
- hovering = stop
390
-
391
- def setSpeed(self, posvel=100, yawvel=100):
392
- """이동 속도(cm/s)와 회전 각속도(도/s) 기본값을 설정한다."""
393
- self.makepkt.setField(Packet.IDX_POSVEL,
394
- DefLib.constrain(int(posvel), POSVEL_MAX, 0), signed=False)
395
- self.makepkt.setField(Packet.IDX_YAWVEL,
396
- DefLib.constrain(int(yawvel), YAWVEL_MAX, 0), signed=False)
397
-
398
- def motor(self, what, speed):
399
- """개별 모터 직접 구동 (테스트용). **반드시 프로펠러를 제거하고 사용할 것.**"""
400
- if what < 0 or what > 3:
401
- return
402
- speed = DefLib.constrain(int(speed), 100, 0)
403
- self.makepkt.setField(what * 2 + 6, speed)
404
- self.setOption(OPT_MOTORTEST)
405
-
406
- def emergency(self):
407
- """ARM 비트를 내려 모터를 즉시 정지한다. 공중에서 호출하면 추락한다."""
408
- self._resetAxes()
409
- self.makepkt.setField(Packet.IDX_THR, 0, signed=False)
410
- self.setOption(OPT_STOP)
411
- # [v1.1 수정] 간격 없이 연속 write 하면 드라이버 버퍼에 뭉쳐 나간다.
412
- for i in range(3):
413
- self._write(self.makepkt.getPacket())
414
- if i < 2:
415
- sleep(0.02)
416
-
417
- # ==================================================================
418
- # Command Packet (설정 / 요청)
419
- # ==================================================================
420
- def _sendCommand(self, header4, type_no, payload=b"", repeat=None, gap=None):
421
- """Command Packet을 구성해 전송한다. LEN/CS는 자동 계산.
422
-
423
- [v1.1 수정] 송신 스레드를 잠시 멈추고 여러 번 반복 전송한다.
424
- 이전 버전은 1회만 보내 제어 스트림 사이에서 유실되는 일이 잦았다.
425
- """
426
- repeat = self.CMD_REPEAT if repeat is None else max(1, int(repeat))
427
- gap = self.CMD_GAP if gap is None else float(gap)
428
-
429
- length = 7 + len(payload)
430
- pkt = bytearray([0x26, 0xA8, 0x14, header4, length, 0x00, type_no]) + bytes(payload)
431
- pkt[5] = DefLib.checksum(pkt)
432
-
433
- ok = False
434
- with self._txPaused():
435
- for i in range(repeat):
436
- ok = self._write(pkt) or ok
437
- if i < repeat - 1:
438
- sleep(gap)
439
- return ok
440
-
441
- # ---- 센서 / 정보 요청 -------------------------------------------
442
- def _trackSensor(self, data_type, on):
443
- """출력 요청 중인 TYPE 목록을 갱신한다 (Close() 자동 정지에 사용).
444
-
445
- __init__ 을 거치지 않은 객체(단위 테스트)에서도 안전하도록 방어한다.
446
- """
447
- types = getattr(self, '_sensorTypes', None)
448
- if types is None:
449
- return
450
- types.add(data_type) if on else types.discard(data_type)
451
-
452
- def requestSensorData(self, data_type, sig_mode=None):
453
- """센서 데이터 출력을 요청한다.
454
-
455
- data_type 9 : 비행정보 (roll/pitch/height/pos_x/pos_y), sig_mode 기본 1
456
- data_type 10 : 센서·제어 신호, sig_mode 기본 0x06(POS_SIGNAL)
457
- """
458
- if data_type == TYPE_FLIGHTINFO:
459
- mode = 1 if sig_mode is None else (int(sig_mode) & 0xFF)
460
- ok = self._sendCommand(0xB2, TYPE_FLIGHTINFO, bytes([mode]))
461
- self._trackSensor(TYPE_FLIGHTINFO, ok and bool(mode))
462
- return ok
463
- elif data_type == TYPE_SIGNAL:
464
- sig = SIG_POS if sig_mode is None else (int(sig_mode) & 0xFF)
465
- ok = self._sendCommand(0xB1, TYPE_SIGNAL, bytes([sig]))
466
- self._trackSensor(TYPE_SIGNAL, ok and sig != SIG_STOP)
467
- return ok
468
- return False
469
-
470
- def stopSensorData(self, data_type):
471
- """센서 데이터 출력을 중지한다."""
472
- if data_type == TYPE_FLIGHTINFO:
473
- ok = self._sendCommand(0xB2, TYPE_FLIGHTINFO, bytes([0x00]))
474
- self._trackSensor(TYPE_FLIGHTINFO, False)
475
- return ok
476
- elif data_type == TYPE_SIGNAL:
477
- ok = self._sendCommand(0xB1, TYPE_SIGNAL, bytes([SIG_STOP]))
478
- self._trackSensor(TYPE_SIGNAL, False)
479
- return ok
480
- return False
481
-
482
- def startSensor(self, flight=True, signal=False, timeout=3.0):
483
- """[v1.1 추가] 센서 출력을 시작하고 실제로 들어오는지 확인한다.
484
-
485
- 내장 센서(sensor=True)로 생성한 경우에만 도착 확인이 가능하며,
486
- 확인에 성공하면 True를 반환한다.
487
- """
488
- base = self.sensor.info_count if self.sensor is not None else 0
489
- if flight:
490
- self.requestSensorData(TYPE_FLIGHTINFO)
491
- if signal:
492
- self.requestSensorData(TYPE_SIGNAL)
493
- if self.sensor is None:
494
- return True # 확인할 방법이 없으므로 전송 성공으로 간주
495
-
496
- deadline = time() + timeout
497
- while time() < deadline:
498
- if self.sensor.info_count > base:
499
- return True
500
- sleep(0.05)
501
- return False
502
-
503
- def stopSensor(self):
504
- """[v1.1 추가] 요청 중인 센서 출력을 모두 중지한다."""
505
- for t in list(getattr(self, '_sensorTypes', ())):
506
- self.stopSensorData(t)
507
- return True
508
-
509
- def requestDeviceInfo(self):
510
- """TYPE(8) 디바이스 정보(버전/시리얼/MAC/센서 탑재) 출력 요청."""
511
- return self._sendCommand(0xB2, TYPE_DEVINFO)
512
-
513
- # ---- 내장 센서 접근 ---------------------------------------------
514
- def getState(self):
515
- """[v1.1 추가] 내장 센서의 상태 딕셔너리. sensor=False 면 None."""
516
- return None if self.sensor is None else self.sensor.state
517
-
518
- def getBattery(self):
519
- """[v1.1 추가] 배터리 잔량(%). 요청 없이도 모든 출력 패킷에 들어 있다."""
520
- return 0 if self.sensor is None else self.sensor.state['battery']
521
-
522
- def getAttitude(self):
523
- """[v1.1 추가] (roll, pitch, height). startSensor() 이후에만 갱신된다."""
524
- if self.sensor is None:
525
- return (0, 0, 0)
526
- s = self.sensor.state
527
- return s['roll'], s['pitch'], s['height']
528
-
529
- def isReady(self):
530
- """[v1.1 추가] OPT1 bit1 — 드론 비행 준비 상태."""
531
- return False if self.sensor is None else self.sensor.state['ready']
532
-
533
- def waitReady(self, timeout=5.0):
534
- """[v1.1 추가] 비행 준비 상태가 될 때까지 기다린다."""
535
- if self.sensor is None:
536
- return False
537
- deadline = time() + timeout
538
- while time() < deadline:
539
- if self.sensor.state['ready']:
540
- return True
541
- sleep(0.05)
542
- return False
543
-
544
- # ---- 보정 / 트림 / 기타 설정 ------------------------------------
545
- def calibration(self):
546
- """TYPE(6) 자이로·가속 센서 보정. 드론을 평평한 곳에 놓고 실행할 것."""
547
- return self._sendCommand(0xB1, TYPE_CALIBRATION)
548
-
549
- def setTrim(self, roll=READ_TRIM, pitch=READ_TRIM):
550
- """TYPE(5) TRIM 설정. 인자 없이 호출하면 현재 설정값 읽기 요청."""
551
- payload = int(roll).to_bytes(2, "little", signed=True) + \
552
- int(pitch).to_bytes(2, "little", signed=True)
553
- return self._sendCommand(0xB1, TYPE_TRIM, payload)
554
-
555
- def setMisc(self, rst=0, wc=READ_MISC, af=READ_MISC, pd=READ_MISC):
556
- """TYPE(7) 기타 설정.
557
-
558
- rst : 0x94 이면 설정값 공장 초기화 (펌웨어는 변화 없음)
559
- wc : Weight Coefficient. 기체가 무거울수록 크게. 255이면 현재값 읽기
560
- af : AltFree. 0=고도 제한(0~150cm), 1=제한 없음, 255=읽기
561
- pd : Propeller Direction. 255=읽기
562
- """
563
- payload = bytes([int(rst) & 0xFF, int(wc) & 0xFF, int(af) & 0xFF, int(pd) & 0xFF])
564
- return self._sendCommand(0xB1, TYPE_MISC, payload)
565
-
566
- def setWeightCoefficient(self, wc):
567
- """ALTHOLD=1에서 기체 무게가 바뀌어 고도가 불안정할 때 조정한다."""
568
- return self.setMisc(rst=0, wc=wc, af=READ_MISC, pd=READ_MISC)
569
-
570
- def factoryReset(self):
571
- """설정값을 공장 초기화한다."""
572
- return self.setMisc(rst=FACTORY_RESET)
573
-
574
- # ---- PID 설정 ---------------------------------------------------
575
- def _setPID(self, type_no, p, i, d):
576
- payload = int(p).to_bytes(2, "little", signed=True) + \
577
- int(i).to_bytes(2, "little", signed=True) + \
578
- int(d).to_bytes(2, "little", signed=True)
579
- return self._sendCommand(0xB1, type_no, payload)
580
-
581
- def setFlightPID(self, p=READ_PID, i=READ_PID, d=READ_PID):
582
- """TYPE(2) 비행 PID. 인자 없이 호출하면 현재값 읽기 요청."""
583
- return self._setPID(TYPE_PID_FLIGHT, p, i, d)
584
-
585
- def setAltPID(self, p=READ_PID, i=READ_PID, d=READ_PID):
586
- """TYPE(3) 높이 PID."""
587
- return self._setPID(TYPE_PID_ALT, p, i, d)
588
-
589
- def setPosPID(self, p=READ_PID, i=READ_PID, d=READ_PID):
590
- """TYPE(4) 위치 PID."""
591
- return self._setPID(TYPE_PID_POS, p, i, d)
592
-
593
- # ==================================================================
594
- # 영상 스트리밍
595
- # ==================================================================
596
- def setStreamAddress(self, host, port=80, path="/?action=stream"):
597
- if not isinstance(host, str) or len(host.strip()) == 0:
598
- raise ValueError("host가 올바르지 않습니다.")
599
- self.stream_host = host.strip()
600
- self.stream_port = int(port)
601
- if not path.startswith("/"):
602
- path = "/" + path
603
- self.stream_path = path
604
- return self._build_stream_url()
605
-
606
- def _build_stream_url(self):
607
- if self.stream_port in (80, None):
608
- return f"http://{self.stream_host}{self.stream_path}"
609
- return f"http://{self.stream_host}:{self.stream_port}{self.stream_path}"
610
-
611
- def streamon(self, host=None, port=None, path=None, return_url=False):
612
- """MJPG-Streamer 스트림을 열고 cv2.VideoCapture 객체를 반환한다."""
613
- if host is not None or port is not None or path is not None:
614
- self.setStreamAddress(host or self.stream_host,
615
- self.stream_port if port is None else port,
616
- self.stream_path if path is None else path)
617
- url = self._build_stream_url()
618
- if return_url:
619
- return url
620
-
621
- try:
622
- import cv2 as cv
623
- except Exception as e:
624
- raise RuntimeError(f"OpenCV(cv2) 임포트 실패: {e}")
625
-
626
- self.streamoff()
627
- self._cap = cv.VideoCapture(url)
628
- if not self._cap.isOpened():
629
- self._cap.release()
630
- self._cap = None
631
- raise RuntimeError(f"스트림 열기 실패: {url}")
632
- return self._cap
633
-
634
- def streamoff(self):
635
- if self._cap is not None:
636
- try:
637
- self._cap.release()
638
- except Exception:
639
- pass
640
- self._cap = None
641
-
642
- def getFrame(self):
643
- """열려 있는 스트림에서 프레임 1장을 읽어 반환한다. 실패 시 None."""
644
- if self._cap is None:
645
- return None
646
- ret, frame = self._cap.read()
647
- if not ret:
648
- return None
649
- return frame
650
-
651
- # ==================================================================
652
- # with 문 지원
653
- # ==================================================================
654
- def __enter__(self):
655
- return self
656
-
657
- def __exit__(self, exc_type, exc, tb):
658
- self.Close()
659
- return False
@@ -1,201 +0,0 @@
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
- # 좌우/전후는 velocity 로, 상하/회전은 별도 처리
175
- if abs(fb) >= abs(lr):
176
- self.drone.velocity(FRONT if fb >= 0 else BACK, abs(fb))
177
- else:
178
- self.drone.velocity(RIGHT if lr >= 0 else LEFT, abs(lr))
179
- if yaw:
180
- self.drone.rotation(int(yaw) // 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
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