Compare commits
2 Commits
2148c92a92
...
973b530593
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
973b530593 | ||
|
|
d621032fef |
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
123
CLAUDE.md
Normal file
123
CLAUDE.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Tello Commander is a FastAPI-based control system for DJI Tello drones. The system provides a web UI for controlling the drone and manages WiFi connectivity, video streaming, and flight telemetry. It runs on Raspberry Pi and Linux systems with supervisor for process management.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Running the Server
|
||||||
|
|
||||||
|
**Local development:**
|
||||||
|
```bash
|
||||||
|
export $(grep -v '^#' env | xargs) && python src/server/server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production (via supervisor on Raspberry Pi):**
|
||||||
|
```bash
|
||||||
|
# Manage remote server
|
||||||
|
./scripts/manage_server.sh {start|stop|restart|status|update|logs}
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
./scripts/manage_server.sh logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
**Pull latest changes:**
|
||||||
|
```bash
|
||||||
|
./scripts/pull_from_origin.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update supervisor service:**
|
||||||
|
```bash
|
||||||
|
./scripts/manage_server.sh update
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
**Server Entry Point (`src/server/server.py`):**
|
||||||
|
- FastAPI application with lifespan management
|
||||||
|
- Initializes shared state with Tello instance and connection status
|
||||||
|
- Spawns background workers: `maintain_connection_to_tello` and `collect_flight_stats`
|
||||||
|
- Serves static files from `src/client/static` and templates from `src/client/templates`
|
||||||
|
- Runs on port 8001 with reload enabled
|
||||||
|
|
||||||
|
**Background Workers (`src/server/workers.py`):**
|
||||||
|
- `maintain_connection_to_tello`: Continuously manages WiFi connection to Tello via dhclient (wlan1 interface). Sets `state["connection"]` to "OK" or "NOK"
|
||||||
|
- `collect_flight_stats`: Polls battery, temperature, and barometer data. Enters command mode when connected. Updates `state["stats"]`
|
||||||
|
- `forward_video_stream`: FFmpeg-based video forwarding from Tello (UDP 11111) to client
|
||||||
|
- Uses ThreadPoolExecutor for blocking I/O operations
|
||||||
|
|
||||||
|
**Routers:**
|
||||||
|
- `router_main.py`: Root endpoint, UI endpoint (`/ui`), status endpoint (`/status`), reconnect trigger
|
||||||
|
- `router_command.py`: Flight commands (`/takeoff`, `/land`, `/turn`, `/move`, `/emergency`, `/end`)
|
||||||
|
- `router_stream.py`: Video streaming control (`/streamon`)
|
||||||
|
- All routers access shared state via `request.app.state.shared_state`
|
||||||
|
|
||||||
|
**Connection Management (`src/server/services/connections.py`):**
|
||||||
|
- `release_and_renew`: Uses dhclient to release/renew IP on wlan1 interface
|
||||||
|
- `check_dhcp_ip`: Validates DHCP assignment matches expected Tello IP (from env `TELLO_STATION_IP`)
|
||||||
|
- Designed for Linux network management via subprocess calls
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
Shared state dictionary stored in `app.state.shared_state`:
|
||||||
|
- `connection`: "genesis" | "OK" | "NOK" - WiFi connection status to Tello
|
||||||
|
- `tello`: DJI Tello instance (djitellopy)
|
||||||
|
- `command`: Boolean - Whether Tello is in command mode
|
||||||
|
- `stats`: Dict with `bat`, `temp`, `baro` keys
|
||||||
|
- `streamon`: Boolean - Video stream status
|
||||||
|
|
||||||
|
### Network Configuration
|
||||||
|
|
||||||
|
Environment variables (defined in `env` file):
|
||||||
|
- `TELLO_SSID_NAME`: Tello WiFi network name
|
||||||
|
- `TELLO_SSID_PASS`: Tello WiFi password
|
||||||
|
- `TELLO_STATION_IP`: Expected IP address for this station on Tello network (e.g., 192.168.10.4)
|
||||||
|
- `DONGLE_IFNAME`: WiFi interface name (wlan1)
|
||||||
|
|
||||||
|
### Deployment Details
|
||||||
|
|
||||||
|
**Supervisor Configuration (`src/server/supervisor/tello-server.conf`):**
|
||||||
|
- Service runs as user `uad`
|
||||||
|
- Working directory: `/home/uad/tello-commander`
|
||||||
|
- Uses CPU affinity (taskset) to distribute load
|
||||||
|
- Auto-restart enabled
|
||||||
|
- Logs written to supervisor directory
|
||||||
|
|
||||||
|
**Remote Access:**
|
||||||
|
- Default server IP: `192.168.1.219:8001`
|
||||||
|
- Web UI: `http://192.168.1.219:8001/ui`
|
||||||
|
- Managed via SSH (`uad@192.168.1.219`)
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
**Router Pattern:**
|
||||||
|
All routers are registered through `routers/base.py` which creates a single `api_router`. This router is imported in `server.py` during lifespan startup to avoid circular imports.
|
||||||
|
|
||||||
|
**Connection Recovery:**
|
||||||
|
When `state["connection"]` becomes "NOK", the maintain_connection worker automatically attempts reconnection via dhclient release/renew cycles. Manual reconnection can be triggered via `/reconnect` endpoint.
|
||||||
|
|
||||||
|
**Barometer Calibration:**
|
||||||
|
Barometer readings are calibrated in `workers.py` using `get_calibrated_altitude` with a hardcoded offset (39 meters for "tuncel yerde").
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Key packages (from `requirements-dev.txt`):
|
||||||
|
- `fastapi==0.112.0` - Web framework
|
||||||
|
- `uvicorn==0.30.5` - ASGI server
|
||||||
|
- `djitellopy==2.5.0` - Tello SDK wrapper
|
||||||
|
- `loguru==0.7.2` - Logging
|
||||||
|
- `nmcli==1.3.0` - NetworkManager CLI wrapper
|
||||||
|
- `opencv-python==4.10.0.84` - Video processing
|
||||||
|
- `av==12.3.0` - Video codec bindings
|
||||||
|
- `ffmpegio` - FFmpeg wrapper
|
||||||
|
|
||||||
|
## Current Branch
|
||||||
|
|
||||||
|
Development occurs on `refactor/v2` branch.
|
||||||
0
pipwheels/.gitkeep
Normal file → Executable file
0
pipwheels/.gitkeep
Normal file → Executable file
0
requirements-dev.txt
Normal file → Executable file
0
requirements-dev.txt
Normal file → Executable file
0
scripts/open_webui.sh.save
Normal file → Executable file
0
scripts/open_webui.sh.save
Normal file → Executable file
0
src/client/__init__.py
Normal file → Executable file
0
src/client/__init__.py
Normal file → Executable file
36
src/client/static/app.js
Normal file → Executable file
36
src/client/static/app.js
Normal file → Executable file
@@ -1,6 +1,7 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Base API URL
|
// Base API URL
|
||||||
const API_BASE_URL = 'http://192.168.1.219:8001';
|
const API_BASE_URL = 'http://192.168.1.219:8001';
|
||||||
|
const WS_BASE_URL = 'ws://192.168.1.219:8001';
|
||||||
|
|
||||||
// Control Buttons
|
// Control Buttons
|
||||||
const reconnectBtn = document.getElementById('reconnect-btn');
|
const reconnectBtn = document.getElementById('reconnect-btn');
|
||||||
@@ -19,6 +20,41 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const baroElem = document.getElementById('baro');
|
const baroElem = document.getElementById('baro');
|
||||||
const tempElem = document.getElementById('temp');
|
const tempElem = document.getElementById('temp');
|
||||||
|
|
||||||
|
// Video Canvas
|
||||||
|
const videoCanvas = document.getElementById('videoCanvas');
|
||||||
|
const ctx = videoCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// WebSocket for Video Streaming
|
||||||
|
let videoWs = null;
|
||||||
|
const connectVideoWebSocket = () => {
|
||||||
|
videoWs = new WebSocket(`${WS_BASE_URL}/stream/ws/video`);
|
||||||
|
|
||||||
|
videoWs.onopen = () => {
|
||||||
|
console.log('Video WebSocket connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
videoWs.onmessage = (event) => {
|
||||||
|
// Decode base64 JPEG and draw to canvas
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
ctx.drawImage(img, 0, 0, videoCanvas.width, videoCanvas.height);
|
||||||
|
};
|
||||||
|
img.src = 'data:image/jpeg;base64,' + event.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
videoWs.onerror = (error) => {
|
||||||
|
console.error('Video WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
videoWs.onclose = () => {
|
||||||
|
console.log('Video WebSocket closed, reconnecting in 3s...');
|
||||||
|
setTimeout(connectVideoWebSocket, 3000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connect to video stream
|
||||||
|
connectVideoWebSocket();
|
||||||
|
|
||||||
// Command Functions
|
// Command Functions
|
||||||
const sendCommand = async (endpoint) => {
|
const sendCommand = async (endpoint) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
0
src/client/static/style.css
Normal file → Executable file
0
src/client/static/style.css
Normal file → Executable file
6
src/client/templates/index.html
Normal file → Executable file
6
src/client/templates/index.html
Normal file → Executable file
@@ -18,9 +18,9 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<!-- Placeholder for Video Stream or Additional Content -->
|
<!-- Video Stream Canvas -->
|
||||||
<div class="video-container">
|
<div class="video-container mb-3">
|
||||||
<!--<img src="/streaming/video_feed" alt="Drone Video Stream" class="img-fluid">-->
|
<canvas id="videoCanvas" width="960" height="720" style="width: 100%; max-width: 960px; border: 2px solid #333;"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status Indicators -->
|
<!-- Status Indicators -->
|
||||||
|
|||||||
0
src/server/__init__.py
Normal file → Executable file
0
src/server/__init__.py
Normal file → Executable file
0
src/server/routers/__init__.py
Normal file → Executable file
0
src/server/routers/__init__.py
Normal file → Executable file
0
src/server/routers/base.py
Normal file → Executable file
0
src/server/routers/base.py
Normal file → Executable file
0
src/server/routers/router_command.py
Normal file → Executable file
0
src/server/routers/router_command.py
Normal file → Executable file
0
src/server/routers/router_main.py
Normal file → Executable file
0
src/server/routers/router_main.py
Normal file → Executable file
28
src/server/routers/router_stream.py
Normal file → Executable file
28
src/server/routers/router_stream.py
Normal file → Executable file
@@ -1,4 +1,6 @@
|
|||||||
from fastapi import APIRouter, Request
|
import asyncio
|
||||||
|
import base64
|
||||||
|
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -13,3 +15,27 @@ def start_video_stream(request: Request):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"failed to start stream - {e}")
|
logger.error(f"failed to start stream - {e}")
|
||||||
return {"msg": "error", "reason": f"failed to start stream - {e}"}
|
return {"msg": "error", "reason": f"failed to start stream - {e}"}
|
||||||
|
|
||||||
|
@router.websocket("/ws/video")
|
||||||
|
async def websocket_video_endpoint(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
logger.info("Video WebSocket client connected")
|
||||||
|
|
||||||
|
try:
|
||||||
|
shared_state = websocket.app.state.shared_state
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Wait for a new frame to be available
|
||||||
|
if "video_frame" in shared_state and shared_state["video_frame"] is not None:
|
||||||
|
frame_data = shared_state["video_frame"]
|
||||||
|
|
||||||
|
# Send base64 encoded JPEG to client
|
||||||
|
await websocket.send_text(frame_data)
|
||||||
|
|
||||||
|
# Small delay to avoid overwhelming the connection
|
||||||
|
await asyncio.sleep(0.033) # ~30 FPS max
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info("Video WebSocket client disconnected")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Video WebSocket error: {e}")
|
||||||
|
|||||||
1
src/server/server.py
Normal file → Executable file
1
src/server/server.py
Normal file → Executable file
@@ -20,6 +20,7 @@ async def lifespan(app: FastAPI):
|
|||||||
app.state.shared_state = state
|
app.state.shared_state = state
|
||||||
asyncio.create_task(workers.maintain_connection_to_tello(state))
|
asyncio.create_task(workers.maintain_connection_to_tello(state))
|
||||||
asyncio.create_task(workers.collect_flight_stats(state))
|
asyncio.create_task(workers.collect_flight_stats(state))
|
||||||
|
asyncio.create_task(workers.capture_video_frames(state))
|
||||||
from routers.base import api_router # Import api_router here to avoid circular import
|
from routers.base import api_router # Import api_router here to avoid circular import
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
yield
|
yield
|
||||||
|
|||||||
0
src/server/services/__init__.py
Normal file → Executable file
0
src/server/services/__init__.py
Normal file → Executable file
0
src/server/services/connections.py
Normal file → Executable file
0
src/server/services/connections.py
Normal file → Executable file
0
src/server/supervisor/puller.conf
Normal file → Executable file
0
src/server/supervisor/puller.conf
Normal file → Executable file
0
src/server/supervisor/tello-server.conf
Normal file → Executable file
0
src/server/supervisor/tello-server.conf
Normal file → Executable file
97
src/server/workers.py
Normal file → Executable file
97
src/server/workers.py
Normal file → Executable file
@@ -1,8 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import base64
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
import cv2
|
||||||
import nmcli
|
import nmcli
|
||||||
import ffmpegio
|
import ffmpegio
|
||||||
from djitellopy import TelloException
|
from djitellopy import TelloException
|
||||||
@@ -80,41 +82,74 @@ async def collect_flight_stats(state):
|
|||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
async def forward_video_stream(state):
|
async def capture_video_frames(state):
|
||||||
loop = asyncio.get_event_loop()
|
"""
|
||||||
|
Captures video frames from Tello's UDP stream and stores them in shared state
|
||||||
|
for WebSocket streaming to clients.
|
||||||
|
"""
|
||||||
tello = state["tello"]
|
tello = state["tello"]
|
||||||
state["streamon"] = False
|
state["streamon"] = False
|
||||||
|
state["video_frame"] = None
|
||||||
|
|
||||||
# tek seferlik 'streamon' komutu göndererek telloyu moda sok
|
logger.info("Video capture worker started")
|
||||||
if (state["connection"] == "OK" and state["command"] == True and state["streamon"] == False):
|
|
||||||
await loop.run_in_executor(executor, tello.send_command_with_return, "streamon")
|
|
||||||
state["streamon"] = True
|
|
||||||
try:
|
|
||||||
state_ffmpeg = ffmpegio.run(
|
|
||||||
ffmpeg_args=[
|
|
||||||
"-fflags", "nobuffer",
|
|
||||||
"-flags", "low_delay",
|
|
||||||
"-strict", "experimental",
|
|
||||||
"-analyzeduration", "0",
|
|
||||||
"-probesize", "32",
|
|
||||||
"-i", "udp://192.168.10.1:11111",
|
|
||||||
"-c", "copy",
|
|
||||||
"-f", "mpegts",
|
|
||||||
"-flush_packets", "1",
|
|
||||||
"-max_delay", "0",
|
|
||||||
"-f", "mpegts",
|
|
||||||
"udp://192.168.1.210:11111"
|
|
||||||
],
|
|
||||||
overwrite=True,
|
|
||||||
capture_log=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if state_ffmpeg.returncode == 0:
|
while True:
|
||||||
print("FFmpeg stream completed successfully.")
|
# Wait for connection and command mode
|
||||||
else:
|
if state["connection"] != "OK" or not state["command"]:
|
||||||
print(f"FFmpeg exited with return code {state_ffmpeg.returncode}.")
|
await asyncio.sleep(1)
|
||||||
except Exception as e:
|
continue
|
||||||
print(f"Unexpected error: {e}")
|
|
||||||
|
# Send streamon command once
|
||||||
|
if not state["streamon"]:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(tello.send_command_with_return, "streamon")
|
||||||
|
state["streamon"] = True
|
||||||
|
logger.success("Tello stream activated")
|
||||||
|
await asyncio.sleep(2) # Give stream time to start
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start Tello stream: {e}")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Capture frames from UDP stream
|
||||||
|
try:
|
||||||
|
cap = cv2.VideoCapture("udp://192.168.10.1:11111", cv2.CAP_FFMPEG)
|
||||||
|
|
||||||
|
if not cap.isOpened():
|
||||||
|
logger.warning("Failed to open video stream, retrying...")
|
||||||
|
state["streamon"] = False
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info("Video stream opened successfully")
|
||||||
|
|
||||||
|
while state["connection"] == "OK" and state["streamon"]:
|
||||||
|
ret, frame = await asyncio.to_thread(cap.read)
|
||||||
|
|
||||||
|
if not ret:
|
||||||
|
logger.warning("Failed to read frame from stream")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Encode frame as JPEG
|
||||||
|
ret, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
||||||
|
if not ret:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert to base64 for WebSocket transmission
|
||||||
|
jpg_as_text = base64.b64encode(buffer).decode('utf-8')
|
||||||
|
state["video_frame"] = jpg_as_text
|
||||||
|
|
||||||
|
# Small delay to control frame rate
|
||||||
|
await asyncio.sleep(0.033) # ~30 FPS
|
||||||
|
|
||||||
|
cap.release()
|
||||||
|
logger.warning("Video capture stopped")
|
||||||
|
state["streamon"] = False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Video capture error: {e}")
|
||||||
|
state["streamon"] = False
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
async def get_calibrated_altitude(offset, measure):
|
async def get_calibrated_altitude(offset, measure):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user