import SwiftUI import SceneKit import Network // MARK: - 1. Tello UDP通信 & iPad単体最適化コントローラー(Concurreny対応版) @MainActor class TelloController: ObservableObject { private var connection: NWConnection? private var listener: NWListener? @Published var statusMessage: String = "未接続(TelloのWi-Fiに接続してください)" @Published var isConnected: Bool = false @Published var isFlying: Bool = false @Published var isSequenceRunning: Bool = false @Published var isSimulationMode: Bool = false @Published var currentStep: Int = 0 @Published var isMovingForward: Bool = false @Published var targetHeight: Float = 1.0 @Published var currentMoveDuration: Double = 3.0 @Published var currentMoveDistance: Float = 1.0 @Published var resetID: Int = 0 private let telloIP = "192.168.10.1" private let telloPort: UInt16 = 8889 private var sequenceTask: Task? func resetDrone() { stopSequence() isFlying = false targetHeight = 1.0 currentStep = 0 resetID += 1 statusMessage = "🔄 位置を原点 (0,0,0) にリセットしました" } // MARK: - iPad単体用 接続・初期化処理 func connect() { if isSimulationMode { isConnected = true statusMessage = "🎮 シミュレーションモードで接続中" return } statusMessage = "🔄 ネットワーク権限の起動&ソケット生成中..." // 1. 既存接続のクリーンアップ connection?.cancel() connection = nil listener?.cancel() listener = nil // 2. iPadOSのローカルネットワーク許可ポップアップを確実に呼ぶダミーリスナー起動 startDummyListener() // 3. UDPパラメーターの設定(ポートの再利用を許可) let host = NWEndpoint.Host(telloIP) guard let port = NWEndpoint.Port(rawValue: telloPort) else { return } let params = NWParameters.udp params.allowLocalEndpointReuse = true let newConnection = NWConnection(host: host, port: port, using: params) self.connection = newConnection newConnection.stateUpdateHandler = { [weak self] state in Task { @MainActor in guard let self = self else { return } switch state { case .ready: self.statusMessage = "📡 ソケット準備完了。『command』送信中..." self.receiveResponses() self.sendCommand("command") case .waiting(let error): self.statusMessage = "⏳ 接続待機中(Wi-Fiを確認してください): \(error.localizedDescription)" self.isConnected = false case .failed(let error): self.statusMessage = "❌ 接続失敗: \(error.localizedDescription)" self.isConnected = false default: break } } } newConnection.start(queue: .global()) } // iPadOSの権限ポップアップ用 private func startDummyListener() { do { let dummyListener = try NWListener(using: .udp) self.listener = dummyListener dummyListener.stateUpdateHandler = { [weak dummyListener] state in if case .ready = state { dummyListener?.cancel() } } dummyListener.start(queue: .global()) } catch { // エラー無視 } } // Telloからの "ok" 返信の継続受信処理 private func receiveResponses() { connection?.receiveMessage { [weak self] content, context, isComplete, error in if let data = content, let response = String(data: data, encoding: .utf8) { Task { @MainActor in let cleanResponse = response.trimmingCharacters(in: .whitespacesAndNewlines) self?.statusMessage = "📥 Tello返信: [ \(cleanResponse) ]" if cleanResponse == "ok" { self?.isConnected = true } } } if error == nil { Task { @MainActor in self?.receiveResponses() } } } } // MARK: - コマンド送信処理 func sendCommand(_ command: String) { if isSimulationMode { statusMessage = "🎮 [シミュレーション] 送信: \(command)" return } guard let data = command.data(using: .utf8) else { return } connection?.send(content: data, completion: .contentProcessed { [weak self] error in Task { @MainActor in guard let self = self else { return } if let error = error { self.statusMessage = "❌ 送信エラー: \(error.localizedDescription)" self.isConnected = false } else { if !self.isConnected { self.statusMessage = "📤 コマンド [ \(command) ] 送信完了。返事待機中..." } } } }) } func takeoff() { sendCommand("takeoff") if isSimulationMode { isFlying = true targetHeight = 1.0 statusMessage = "🎮 [シミュレーション] 離陸しました (高度1.0m)" } } func startSequence() { isSequenceRunning = true sequenceTask = Task { @MainActor in self.currentStep = 0 let repeatCount = 5 for step in 1...repeatCount { guard self.isSequenceRunning else { break } self.currentStep = step let startHeight: Float = 1.0 let stepUpHeight: Float = 0.5 let calculatedHeight = startHeight + Float(step - 1) * stepUpHeight self.targetHeight = calculatedHeight self.sendCommand("takeoff") if self.isSimulationMode { self.isFlying = true } if step > 1 { let upCm = Int(stepUpHeight * 100) self.sendCommand("up \(upCm)") } self.statusMessage = "[ステップ \(step)] 離陸指示送信完了..." try? await Task.sleep(nanoseconds: 2_500_000_000) guard self.isSequenceRunning else { break } let forwardSpeed = 30 let moveDurationSec: Double = 3.0 let simulatedDistance = Float(forwardSpeed) * 0.011 * Float(moveDurationSec) self.currentMoveDuration = moveDurationSec self.currentMoveDistance = simulatedDistance if self.isSimulationMode { self.isMovingForward = true } self.sendCommand("rc 0 \(forwardSpeed) 0 0") let durationNanoseconds = UInt64(moveDurationSec * 1_000_000_000) try? await Task.sleep(nanoseconds: durationNanoseconds) self.sendCommand("rc 0 0 0 0") self.isMovingForward = false guard self.isSequenceRunning else { break } self.sendCommand("land") if self.isSimulationMode { self.isFlying = false } try? await Task.sleep(nanoseconds: 2_500_000_000) } self.statusMessage = "🎉 ゴールに到着しました!" self.isSequenceRunning = false } } func stopSequence() { isSequenceRunning = false sequenceTask?.cancel() sequenceTask = nil isMovingForward = false sendCommand("rc 0 0 0 0") if isFlying { land() } else { statusMessage = "⏹ シーケンスを停止しました" } } func land() { sendCommand("rc 0 0 0 0") sendCommand("land") if isSimulationMode { isFlying = false isMovingForward = false statusMessage = "🎮 [シミュレーション] 着陸しました" } } func emergencyStop() { stopSequence() sendCommand("emergency") isFlying = false statusMessage = "🚨 緊急停止を実行しました" } } // MARK: - 2. 3Dシミュレーションビュー (SceneKit) struct Drone3DSceneView: UIViewRepresentable { @Binding var isFlying: Bool @Binding var isSequenceRunning: Bool @Binding var isMovingForward: Bool @Binding var targetHeight: Float @Binding var moveDuration: Double @Binding var moveDistance: Float @Binding var resetID: Int func makeUIView(context: Context) -> SCNView { let scnView = SCNView() let scene = SCNScene() let cameraNode = SCNNode() cameraNode.camera = SCNCamera() cameraNode.position = SCNVector3(x: 5.0, y: 4.5, z: -1.5) cameraNode.eulerAngles = SCNVector3(x: -0.4, y: Float.pi - 0.7, z: 0) scene.rootNode.addChildNode(cameraNode) let lightNode = SCNNode() lightNode.light = SCNLight() lightNode.light?.type = .omni lightNode.position = SCNVector3(x: 2, y: 10, z: 5) scene.rootNode.addChildNode(lightNode) let ambientLightNode = SCNNode() ambientLightNode.light = SCNLight() ambientLightNode.light?.type = .ambient ambientLightNode.light?.color = UIColor.gray scene.rootNode.addChildNode(ambientLightNode) let gridNode = createXYZGrid(spacing: 0.5, gridCount: 14, axisLength: 7.0) scene.rootNode.addChildNode(gridNode) let areaWidth: CGFloat = 1.0 let areaHeight: CGFloat = 2.0 let areaLength: CGFloat = 5.0 let flightAreaNode = createFixedFlightArea(width: areaWidth, height: areaHeight, length: areaLength) scene.rootNode.addChildNode(flightAreaNode) let scaleMarksNode = createMeterScaleMarkers(maxMeters: Int(areaLength)) scene.rootNode.addChildNode(scaleMarksNode) let droneGroup = SCNNode() droneGroup.name = "telloModel" let droneBox = SCNBox(width: 0.3, height: 0.08, length: 0.3, chamferRadius: 0.01) droneBox.firstMaterial?.diffuse.contents = UIColor.systemRed let bodyNode = SCNNode(geometry: droneBox) droneGroup.addChildNode(bodyNode) let frontMarker = SCNSphere(radius: 0.04) frontMarker.firstMaterial?.diffuse.contents = UIColor.systemBlue let frontNode = SCNNode(geometry: frontMarker) frontNode.position = SCNVector3(0, 0, 0.18) droneGroup.addChildNode(frontNode) droneGroup.position = SCNVector3(0, 0, 0) scene.rootNode.addChildNode(droneGroup) scnView.scene = scene scnView.allowsCameraControl = true scnView.backgroundColor = UIColor.white return scnView } func updateUIView(_ uiView: SCNView, context: Context) { guard let droneNode = uiView.scene?.rootNode.childNode(withName: "telloModel", recursively: true) else { return } if resetID != context.coordinator.lastResetID { context.coordinator.lastResetID = resetID droneNode.removeAllActions() let resetAction = SCNAction.move(to: SCNVector3(0, 0, 0), duration: 0.5) droneNode.runAction(resetAction) return } if isFlying && droneNode.position.y < targetHeight && !isMovingForward { let targetPos = SCNVector3(droneNode.position.x, targetHeight, droneNode.position.z) let takeoffAction = SCNAction.move(to: targetPos, duration: 1.5) droneNode.runAction(takeoffAction, forKey: "flightAction") } if isMovingForward { if droneNode.action(forKey: "forwardAction") == nil { let moveForward = SCNAction.moveBy(x: 0, y: 0, z: CGFloat(moveDistance), duration: moveDuration) droneNode.runAction(moveForward, forKey: "forwardAction") } } else { droneNode.removeAction(forKey: "forwardAction") } if !isFlying && droneNode.position.y > 0 { droneNode.removeAllActions() let landPos = SCNVector3(droneNode.position.x, 0, droneNode.position.z) let landAction = SCNAction.move(to: landPos, duration: 1.5) droneNode.runAction(landAction) } } func makeCoordinator() -> Coordinator { Coordinator() } class Coordinator { var lastResetID: Int = 0 } private func createFixedFlightArea(width: CGFloat, height: CGFloat, length: CGFloat) -> SCNNode { let box = SCNBox(width: width, height: height, length: length, chamferRadius: 0) let material = SCNMaterial() material.diffuse.contents = UIColor.systemRed.withAlphaComponent(0.25) material.isDoubleSided = true box.materials = [material] let areaNode = SCNNode(geometry: box) areaNode.position = SCNVector3(0, Float(height / 2.0), Float(length / 2.0)) return areaNode } private func createMeterScaleMarkers(maxMeters: Int) -> SCNNode { let parentNode = SCNNode() for i in 1...maxMeters { let zPos = Float(i) let markLine = SCNBox(width: 1.0, height: 0.01, length: 0.02, chamferRadius: 0) markLine.firstMaterial?.diffuse.contents = UIColor.black let lineNode = SCNNode(geometry: markLine) lineNode.position = SCNVector3(0, 0.005, zPos) parentNode.addChildNode(lineNode) let textGeometry = SCNText(string: "\(i)m", extrusionDepth: 0.02) textGeometry.font = UIFont.systemFont(ofSize: 0.25, weight: .bold) textGeometry.firstMaterial?.diffuse.contents = UIColor.black let textNode = SCNNode(geometry: textGeometry) let (min, max) = textGeometry.boundingBox let dx = min.x + (max.x - min.x) / 2 let dy = min.y + (max.y - min.y) / 2 textNode.pivot = SCNMatrix4MakeTranslation(dx, dy, 0) textNode.position = SCNVector3(0.8, 0.15, zPos) textNode.eulerAngles = SCNVector3(0, Float.pi * 3.0 / 4.0, 0) parentNode.addChildNode(textNode) } return parentNode } private func createXYZGrid(spacing: Float, gridCount: Int, axisLength: Float) -> SCNNode { let gridGroup = SCNNode() let lineThickness: CGFloat = 0.005 let xAxis = SCNBox(width: CGFloat(axisLength * 2), height: lineThickness * 3, length: lineThickness * 3, chamferRadius: 0) xAxis.firstMaterial?.diffuse.contents = UIColor.systemRed gridGroup.addChildNode(SCNNode(geometry: xAxis)) let yAxis = SCNBox(width: lineThickness * 3, height: CGFloat(axisLength * 2), length: lineThickness * 3, chamferRadius: 0) yAxis.firstMaterial?.diffuse.contents = UIColor.systemGreen gridGroup.addChildNode(SCNNode(geometry: yAxis)) let zAxis = SCNBox(width: lineThickness * 3, height: lineThickness * 3, length: CGFloat(axisLength * 2), chamferRadius: 0) zAxis.firstMaterial?.diffuse.contents = UIColor.systemBlue gridGroup.addChildNode(SCNNode(geometry: zAxis)) let gridColor = UIColor.gray.withAlphaComponent(0.4) for i in -gridCount...gridCount { if i == 0 { continue } let pos = Float(i) * spacing let lineX = SCNBox(width: CGFloat(axisLength * 2), height: lineThickness, length: lineThickness, chamferRadius: 0) lineX.firstMaterial?.diffuse.contents = gridColor let nodeX = SCNNode(geometry: lineX) nodeX.position = SCNVector3(0, 0, pos) gridGroup.addChildNode(nodeX) let lineZ = SCNBox(width: lineThickness, height: lineThickness, length: CGFloat(axisLength * 2), chamferRadius: 0) lineZ.firstMaterial?.diffuse.contents = gridColor let nodeZ = SCNNode(geometry: lineZ) nodeZ.position = SCNVector3(pos, 0, 0) gridGroup.addChildNode(nodeZ) let lineY = SCNBox(width: CGFloat(axisLength * 2), height: lineThickness, length: lineThickness, chamferRadius: 0) lineY.firstMaterial?.diffuse.contents = UIColor.systemBlue.withAlphaComponent(0.15) let nodeY = SCNNode(geometry: lineY) nodeY.position = SCNVector3(0, abs(pos), 0) gridGroup.addChildNode(nodeY) } return gridGroup } } // MARK: - 3. メイン操作UI画面 struct ContentView: View { @StateObject private var tello = TelloController() var body: some View { VStack(spacing: 0) { Drone3DSceneView( isFlying: $tello.isFlying, isSequenceRunning: $tello.isSequenceRunning, isMovingForward: $tello.isMovingForward, targetHeight: $tello.targetHeight, moveDuration: $tello.currentMoveDuration, moveDistance: $tello.currentMoveDistance, resetID: $tello.resetID ) .frame(maxHeight: .infinity) VStack(spacing: 12) { Toggle(isOn: $tello.isSimulationMode) { HStack { Image(systemName: "desktopcomputer") Text("実機なしシミュレーションモード") .font(.subheadline) .bold() } } .padding(.horizontal) .onChange(of: tello.isSimulationMode) { _, newValue in tello.isConnected = false tello.isFlying = false tello.stopSequence() tello.statusMessage = newValue ? "🎮 シミュレーションモードに切り替えました" : "未接続(TelloのWi-Fiに接続してください)" } HStack { Image(systemName: "info.circle.fill") Text(tello.statusMessage) .font(.subheadline) .bold() } .foregroundColor(tello.isConnected ? .green : .orange) Button(action: { tello.connect() }) { Label(tello.isSimulationMode ? "1. 仮想接続スタート" : "1. Tello Wi-Fi 接続・初期化", systemImage: tello.isSimulationMode ? "play.circle.fill" : "wifi") .font(.headline) .frame(maxWidth: .infinity) .padding(.vertical, 4) } .buttonStyle(.borderedProminent) .tint(tello.isSimulationMode ? .indigo : .blue) HStack(spacing: 8) { Button(action: { tello.resetDrone() }) { VStack { Image(systemName: "arrow.counterclockwise.circle.fill").font(.title2) Text("リセット") } .frame(maxWidth: .infinity) .padding(.vertical, 6) } .buttonStyle(.borderedProminent) .tint(.orange) Button(action: { tello.takeoff() }) { VStack { Image(systemName: "arrow.up.circle.fill").font(.title2) Text("2. 離陸") } .frame(maxWidth: .infinity) .padding(.vertical, 6) } .buttonStyle(.borderedProminent) .tint(.green) .disabled(!tello.isConnected || tello.isFlying) Button(action: { if tello.isSequenceRunning { tello.stopSequence() } else { tello.startSequence() } }) { VStack { Image(systemName: "play.triangle.spiral.circle.fill").font(.title2) Text(tello.isSequenceRunning ? "停止" : "3. 飛行スタート") } .frame(maxWidth: .infinity) .padding(.vertical, 6) } .buttonStyle(.borderedProminent) .tint(.purple) .disabled(!tello.isConnected) Button(action: { tello.land() }) { VStack { Image(systemName: "arrow.down.circle.fill").font(.title2) Text("着陸") } .frame(maxWidth: .infinity) .padding(.vertical, 6) } .buttonStyle(.borderedProminent) .tint(.gray) .disabled(!tello.isFlying) } Divider() Button(action: { tello.emergencyStop() }) { HStack { Image(systemName: "exclamationmark.triangle.fill") Text("🚨 緊急停止 (EMERGENCY)") .font(.title3) .bold() } .foregroundColor(.white) .frame(maxWidth: .infinity) .padding(.vertical, 8) .background(Color.red) .cornerRadius(10) } } .padding() .background(Color(UIColor.systemGroupedBackground)) } } } // MARK: - 4. アプリ起動エントリーポイント @main struct MyApp: App { var body: some Scene { WindowGroup { ContentView() } } }