diff --git a/UWBViewerSystem/Devices/File/ZipFileManager.swift b/UWBViewerSystem/Devices/File/ZipFileManager.swift new file mode 100644 index 0000000..395a783 --- /dev/null +++ b/UWBViewerSystem/Devices/File/ZipFileManager.swift @@ -0,0 +1,174 @@ +// +// ZipFileManager.swift +// UWBViewerSystem +// +// Created by Claude Code on 2025/11/25. +// + +import Foundation + +/// ZIP圧縮処理を担当するファイルマネージャー +/// Devices層のFile管理機能として、ディレクトリのZIP圧縮を提供 +class ZipFileManager { + static let shared = ZipFileManager() + + private init() {} + + /// ディレクトリをZIP圧縮する + /// - Parameters: + /// - sourceURL: 圧縮するディレクトリのURL + /// - zipFileName: 作成するZIPファイルの名前 + /// - destinationDirectory: ZIPファイルを配置するディレクトリ(デフォルトは一時ディレクトリ) + /// - Returns: 作成されたZIPファイルのURL + /// - Throws: ファイル操作に関連するエラー + func zipDirectory( + at sourceURL: URL, + to zipFileName: String, + in destinationDirectory: URL? = nil + ) throws -> URL { + let destDir = destinationDirectory ?? FileManager.default.temporaryDirectory + let zipURL = destDir.appendingPathComponent(zipFileName) + + // 既存のZIPファイルを削除 + try? FileManager.default.removeItem(at: zipURL) + + // ZIP圧縮を実行 + let coordinator = NSFileCoordinator() + var coordinationError: NSError? + + coordinator.coordinate( + readingItemAt: sourceURL, + options: .forUploading, + error: &coordinationError + ) { zipFileURL in + do { + try FileManager.default.copyItem(at: zipFileURL, to: zipURL) + print("📦 ZIP圧縮成功: \(zipURL.path)") + } catch { + print("❌ ZIP作成エラー: \(error)") + } + } + + if let error = coordinationError { + throw error + } + + return zipURL + } + + /// 一時ディレクトリにファイルをコピーし、ZIP圧縮する + /// - Parameters: + /// - files: コピーするファイルのURL配列 + /// - zipFileName: 作成するZIPファイルの名前 + /// - tempDirectoryName: 一時ディレクトリの名前(デフォルトはランダムUUID) + /// - Returns: 作成されたZIPファイルのURL + /// - Throws: ファイル操作に関連するエラー + func zipFiles( + _ files: [URL], + to zipFileName: String, + tempDirectoryName: String? = nil + ) throws -> URL { + // 一時ディレクトリを作成 + let tempDirName = tempDirectoryName ?? UUID().uuidString + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(tempDirName, isDirectory: true) + + try FileManager.default.createDirectory( + at: tempDir, + withIntermediateDirectories: true + ) + + // ファイルをコピー + for fileURL in files { + let fileName = fileURL.lastPathComponent + let destinationURL = tempDir.appendingPathComponent(fileName) + try FileManager.default.copyItem(at: fileURL, to: destinationURL) + print("📄 ファイルコピー: \(fileName)") + } + + // ZIP圧縮 + let zipURL = try zipDirectory(at: tempDir, to: zipFileName) + + // 一時ディレクトリを削除 + try? FileManager.default.removeItem(at: tempDir) + + return zipURL + } + + /// ディレクトリ内の全ファイルをZIP圧縮する + /// - Parameters: + /// - directoryURL: 圧縮するディレクトリのURL + /// - zipFileName: 作成するZIPファイルの名前 + /// - includeMetadata: メタデータJSONを含めるかどうか + /// - metadata: 含めるメタデータ(includeMetadataがtrueの場合) + /// - Returns: 作成されたZIPファイルのURL、ディレクトリが存在しない場合はnil + /// - Throws: ファイル操作に関連するエラー + func zipDirectoryContents( + at directoryURL: URL, + to zipFileName: String, + includeMetadata: Bool = false, + metadata: [String: Any]? = nil + ) throws -> URL? { + // ディレクトリの存在確認 + guard FileManager.default.fileExists(atPath: directoryURL.path) else { + print("⚠️ 指定されたディレクトリが存在しません: \(directoryURL.path)") + return nil + } + + // 一時ディレクトリを作成 + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + + try FileManager.default.createDirectory( + at: tempDir, + withIntermediateDirectories: true + ) + + // ディレクトリ内の全ファイルをコピー + let fileManager = FileManager.default + let files = try fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil + ) + + var copiedFileCount = 0 + for fileURL in files { + let fileName = fileURL.lastPathComponent + let destinationURL = tempDir.appendingPathComponent(fileName) + try fileManager.copyItem(at: fileURL, to: destinationURL) + print("📄 ファイルコピー: \(fileName)") + copiedFileCount += 1 + } + + print("✅ \(copiedFileCount)個のファイルをコピーしました") + + // メタデータJSONを追加 + if includeMetadata, let metadata { + let jsonData = try JSONSerialization.data( + withJSONObject: metadata, + options: .prettyPrinted + ) + let metadataURL = tempDir.appendingPathComponent("metadata.json") + try jsonData.write(to: metadataURL) + print("📄 メタデータJSON作成完了: \(metadataURL.path)") + } + + // ZIP圧縮 + print("🗜️ ZIP圧縮開始") + let zipURL = try zipDirectory(at: tempDir, to: zipFileName) + print("🗜️ ZIP圧縮完了: \(zipURL.path)") + + // ZIPファイルの存在確認 + if FileManager.default.fileExists(atPath: zipURL.path) { + let attributes = try? FileManager.default.attributesOfItem(atPath: zipURL.path) + let fileSize = attributes?[.size] as? Int64 ?? 0 + print("✅ ZIPファイル確認OK: サイズ=\(fileSize)バイト") + } + + // 一時ディレクトリを削除 + try? FileManager.default.removeItem(at: tempDir) + print("🗑️ 一時ディレクトリ削除完了") + + return zipURL + } +} diff --git a/UWBViewerSystem/Devices/NearByConnection/NearByConnectionApi.swift b/UWBViewerSystem/Devices/NearByConnection/NearByConnectionApi.swift index b04fd35..521e13f 100644 --- a/UWBViewerSystem/Devices/NearByConnection/NearByConnectionApi.swift +++ b/UWBViewerSystem/Devices/NearByConnection/NearByConnectionApi.swift @@ -141,15 +141,38 @@ import Foundation return } + // 既存の広告を停止してからリトライ付きで開始 + advertiser.stopAdvertising() + self.startAdvertiseInternal(advertiser: advertiser, retryCount: 0) + } + + /// Advertise開始の内部実装(リトライ対応) + private func startAdvertiseInternal(advertiser: Advertiser, retryCount: Int) { + let maxRetries = 3 let context = Data(nickName.utf8) + advertiser.startAdvertising(using: context) { [weak self] error in Task { @MainActor [weak self] in + guard let self else { return } + if let error { - self?.notifyCallbacks { - $0.onConnectionStateChanged(state: "広告開始エラー: \(error.localizedDescription)") + print("📢 Advertise開始エラー (試行 \(retryCount + 1)/\(maxRetries + 1)): \(error.localizedDescription)") + + // リトライ可能な場合はリトライ + if retryCount < maxRetries { + advertiser.stopAdvertising() + let delay = Double(retryCount + 1) * 0.5 + print("📢 \(delay)秒後にAdvertiseをリトライします...") + + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + self.startAdvertiseInternal(advertiser: advertiser, retryCount: retryCount + 1) + } else { + self.notifyCallbacks { + $0.onConnectionStateChanged(state: "広告開始エラー: \(error.localizedDescription)") + } } } else { - self?.notifyCallbacks { $0.onConnectionStateChanged(state: "広告開始成功") } + self.notifyCallbacks { $0.onConnectionStateChanged(state: "広告開始成功") } } } } @@ -161,23 +184,46 @@ import Foundation return } - // 既にDiscovery中の場合は何もしない + // 既にDiscovery中の場合は一度停止してから再開 if self.isDiscovering { - self.notifyCallbacks { $0.onConnectionStateChanged(state: "既に検索中です") } - return + print("📡 既にDiscovery中のため、一度停止します") + discoverer.stopDiscovery() + self.isDiscovering = false } + self.startDiscoveryInternal(discoverer: discoverer, retryCount: 0) + } + + /// Discovery開始の内部実装(リトライ対応) + private func startDiscoveryInternal(discoverer: Discoverer, retryCount: Int) { + let maxRetries = 3 + discoverer.startDiscovery { [weak self] error in Task { @MainActor [weak self] in + guard let self else { return } + if let error { - self?.isDiscovering = false - self?.notifyCallbacks { - $0.onConnectionStateChanged(state: "発見開始エラー: \(error.localizedDescription)") + self.isDiscovering = false + print("📡 Discovery開始エラー (試行 \(retryCount + 1)/\(maxRetries + 1)): \(error.localizedDescription)") + + // リトライ可能な場合はリトライ + if retryCount < maxRetries { + // 一度停止してから遅延後にリトライ + discoverer.stopDiscovery() + let delay = Double(retryCount + 1) * 0.5 // 0.5秒、1秒、1.5秒 + print("📡 \(delay)秒後にDiscoveryをリトライします...") + + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + self.startDiscoveryInternal(discoverer: discoverer, retryCount: retryCount + 1) + } else { + self.notifyCallbacks { + $0.onConnectionStateChanged(state: "発見開始エラー: \(error.localizedDescription)") + } } } else { - self?.isDiscovering = true - self?.notifyCallbacks { $0.onConnectionStateChanged(state: "発見開始成功") } - self?.notifyCallbacks { $0.onDiscoveryStateChanged(isDiscovering: true) } + self.isDiscovering = true + self.notifyCallbacks { $0.onConnectionStateChanged(state: "発見開始成功") } + self.notifyCallbacks { $0.onDiscoveryStateChanged(isDiscovering: true) } } } } diff --git a/UWBViewerSystem/Domain/DataModel/SwiftDataModels.swift b/UWBViewerSystem/Domain/DataModel/SwiftDataModels.swift index 04e36bb..ef45a0b 100644 --- a/UWBViewerSystem/Domain/DataModel/SwiftDataModels.swift +++ b/UWBViewerSystem/Domain/DataModel/SwiftDataModels.swift @@ -153,6 +153,8 @@ public final class PersistentRealtimeData { public var nlos: Int public var rssi: Double public var seqCount: Int + public var antennaId: String? = "" // アンテナIDを追加(オプショナル、デフォルト値付き) + public var sessionId: String // セッションIDを追加 // public var session: PersistentSensingSession? // リレーションシップを一旦削除 public init( @@ -165,6 +167,8 @@ public final class PersistentRealtimeData { nlos: Int, rssi: Double, seqCount: Int, + antennaId: String? = "", // アンテナIDを追加(オプショナル) + sessionId: String = "", // デフォルト値を設定 // session: PersistentSensingSession? = nil // リレーションシップを一旦削除 ) { self.id = id @@ -176,6 +180,8 @@ public final class PersistentRealtimeData { self.nlos = nlos self.rssi = rssi self.seqCount = seqCount + self.antennaId = antennaId + self.sessionId = sessionId // self.session = session // リレーションシップを一旦削除 } @@ -189,7 +195,8 @@ public final class PersistentRealtimeData { distance: self.distance, nlos: self.nlos, rssi: self.rssi, - seqCount: self.seqCount + seqCount: self.seqCount, + antennaId: self.antennaId ?? "" // アンテナIDを含める(nil合体演算子) ) } } @@ -279,66 +286,6 @@ public final class PersistentSystemActivity { } } -@available(macOS 14, iOS 17, *) -@Model -public final class PersistentProjectProgress { - public var id: String - public var floorMapId: String - public var currentStep: String - public var completedStepsData: Data // SetをJSONで保存 - public var stepData: Data // [String: Data]をJSONで保存 - public var createdAt: Date - public var updatedAt: Date - - public init( - id: String = UUID().uuidString, - floorMapId: String, - currentStep: String = "floor_map_setting", - completedStepsData: Data = Data(), - stepData: Data = Data(), - createdAt: Date = Date(), - updatedAt: Date = Date() - ) { - self.id = id - self.floorMapId = floorMapId - self.currentStep = currentStep - self.completedStepsData = completedStepsData - self.stepData = stepData - self.createdAt = createdAt - self.updatedAt = updatedAt - } - - public func toEntity() -> ProjectProgress { - let decoder = JSONDecoder() - - // completedStepsの復元 - var completedSteps: Set = [] - if !self.completedStepsData.isEmpty { - if let stepStrings = try? decoder.decode([String].self, from: completedStepsData) { - completedSteps = Set(stepStrings.compactMap { SetupStep(rawValue: $0) }) - } - } - - // stepDataの復元 - var projectStepData: [String: Data] = [:] - if !self.stepData.isEmpty { - if let decodedStepData = try? decoder.decode([String: Data].self, from: stepData) { - projectStepData = decodedStepData - } - } - - return ProjectProgress( - id: self.id, - floorMapId: self.floorMapId, - currentStep: SetupStep(rawValue: self.currentStep) ?? .floorMapSetting, - completedSteps: completedSteps, - stepData: projectStepData, - createdAt: self.createdAt, - updatedAt: self.updatedAt - ) - } -} - // PersistentReceivedFileは単体ファイルで定義済み @available(macOS 14, iOS 17, *) @@ -458,7 +405,7 @@ extension AntennaPairing { } extension RealtimeData { - public func toPersistent() -> PersistentRealtimeData { + public func toPersistent(sessionId: String = "") -> PersistentRealtimeData { PersistentRealtimeData( id: id, deviceName: deviceName, @@ -468,7 +415,9 @@ extension RealtimeData { distance: distance, nlos: nlos, rssi: rssi, - seqCount: seqCount + seqCount: seqCount, + antennaId: antennaId, // アンテナIDを含める + sessionId: sessionId ) } } @@ -487,29 +436,6 @@ extension SystemActivity { } } -extension ProjectProgress { - public func toPersistent() -> PersistentProjectProgress { - let encoder = JSONEncoder() - - // completedStepsをData型に変換 - let stepStrings = completedSteps.map { $0.rawValue } - let completedStepsData = (try? encoder.encode(stepStrings)) ?? Data() - - // stepDataをData型に変換 - let stepDataEncoded = (try? encoder.encode(stepData)) ?? Data() - - return PersistentProjectProgress( - id: id, - floorMapId: floorMapId, - currentStep: currentStep.rawValue, - completedStepsData: completedStepsData, - stepData: stepDataEncoded, - createdAt: createdAt, - updatedAt: updatedAt - ) - } -} - extension CalibrationData { public func toPersistent() -> PersistentCalibrationData { let encoder = JSONEncoder() diff --git a/UWBViewerSystem/Domain/Entity/CommonTypes.swift b/UWBViewerSystem/Domain/Entity/CommonTypes.swift index d72545f..9a72c5d 100644 --- a/UWBViewerSystem/Domain/Entity/CommonTypes.swift +++ b/UWBViewerSystem/Domain/Entity/CommonTypes.swift @@ -158,45 +158,6 @@ public enum SetupStep: String, Codable, CaseIterable { } } -/// プロジェクトの進行状況を表すデータ構造 -public struct ProjectProgress: Codable { - public let id: String - public let floorMapId: String - public var currentStep: SetupStep - public var completedSteps: Set - public var stepData: [String: Data] // 各ステップの詳細データ - public let createdAt: Date - public var updatedAt: Date - - public init( - id: String = UUID().uuidString, - floorMapId: String, - currentStep: SetupStep = .floorMapSetting, - completedSteps: Set = [], - stepData: [String: Data] = [:], - createdAt: Date = Date(), - updatedAt: Date = Date() - ) { - self.id = id - self.floorMapId = floorMapId - self.currentStep = currentStep - self.completedSteps = completedSteps - self.stepData = stepData - self.createdAt = createdAt - self.updatedAt = updatedAt - } - - public var completionPercentage: Double { - let totalSteps = SetupStep.allCases.count - 1 // completedを除く - let completed = self.completedSteps.filter { $0 != .completed }.count - return Double(completed) / Double(totalSteps) - } - - public var isCompleted: Bool { - self.currentStep == .completed - } -} - // FloorMapInfo用のプラットフォーム固有拡張 #if os(macOS) extension FloorMapInfo { @@ -220,11 +181,25 @@ public struct ProjectProgress: Codable { get { let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let imageURL = documentsDirectory.appendingPathComponent("\(self.id).jpg") + + #if DEBUG + print("🖼️ FloorMapInfo.image: フロアマップ '\(self.name)' (ID: \(self.id)) の画像を読み込み中") + print(" 画像パス: \(imageURL.path)") + print(" ファイル存在: \(FileManager.default.fileExists(atPath: imageURL.path))") + #endif + if FileManager.default.fileExists(atPath: imageURL.path), let data = try? Data(contentsOf: imageURL) { + #if DEBUG + print(" ✅ 画像読み込み成功") + #endif return UIImage(data: data) } + + #if DEBUG + print(" ❌ 画像が見つからないか読み込みに失敗") + #endif return nil } set { diff --git a/UWBViewerSystem/Domain/Entity/DeviceConnectionState.swift b/UWBViewerSystem/Domain/Entity/DeviceConnectionState.swift new file mode 100644 index 0000000..262cd4a --- /dev/null +++ b/UWBViewerSystem/Domain/Entity/DeviceConnectionState.swift @@ -0,0 +1,81 @@ +import Foundation + +/// ニアバイコネクションの接続状態 +public enum DeviceConnectionState: Equatable { + /// 未接続 + case disconnected + /// 接続中 + case connecting + /// 接続済み + case connected(deviceName: String, endpointId: String) + /// 接続エラー + case error(message: String) + /// 再接続中 + case reconnecting(attempt: Int, maxAttempts: Int) + /// 接続断(復旧可能) + case disconnectedRecoverable(deviceName: String, reason: String) + + /// 接続が確立されているかどうか + public var isConnected: Bool { + if case .connected = self { + return true + } + return false + } + + /// エラー状態かどうか + public var isError: Bool { + if case .error = self { + return true + } + return false + } + + /// 再接続中かどうか + public var isReconnecting: Bool { + if case .reconnecting = self { + return true + } + return false + } + + /// 復旧可能な切断状態かどうか + public var isRecoverable: Bool { + if case .disconnectedRecoverable = self { + return true + } + return false + } + + /// 状態の説明文 + public var description: String { + switch self { + case .disconnected: + return "未接続" + case .connecting: + return "接続中..." + case .connected(let deviceName, _): + return "接続済み: \(deviceName)" + case .error(let message): + return "エラー: \(message)" + case .reconnecting(let attempt, let maxAttempts): + return "再接続中 (\(attempt)/\(maxAttempts))..." + case .disconnectedRecoverable(let deviceName, let reason): + return "接続断: \(deviceName) - \(reason)" + } + } +} + +/// 接続断の理由 +public enum DisconnectionReason: String { + /// ネットワークエラー + case networkError = "ネットワークエラー" + /// タイムアウト + case timeout = "タイムアウト" + /// デバイスが範囲外 + case outOfRange = "デバイスが範囲外" + /// ユーザーによる切断 + case userInitiated = "ユーザーによる切断" + /// 不明なエラー + case unknown = "不明なエラー" +} diff --git a/UWBViewerSystem/Domain/Entity/RealtimeData.swift b/UWBViewerSystem/Domain/Entity/RealtimeData.swift index eecdb37..549d33b 100644 --- a/UWBViewerSystem/Domain/Entity/RealtimeData.swift +++ b/UWBViewerSystem/Domain/Entity/RealtimeData.swift @@ -1,5 +1,31 @@ import Foundation +// MARK: - タグ表示モード + +/// タグの位置表示モード +public enum TagDisplayMode: String, CaseIterable { + case individual = "individual" // 各アンテナからの個別位置を表示 + case integrated = "integrated" // 統合位置(重心)のみを表示 + + public var displayName: String { + switch self { + case .individual: + return "個別表示" + case .integrated: + return "統合表示" + } + } + + public var description: String { + switch self { + case .individual: + return "各アンテナからの観測位置を全て表示" + case .integrated: + return "NLOSを考慮した重心位置を表示" + } + } +} + // MARK: - リアルタイムデータエンティティ public struct RealtimeData: Identifiable, Codable { @@ -12,10 +38,11 @@ public struct RealtimeData: Identifiable, Codable { public let nlos: Int public let rssi: Double public let seqCount: Int + public let antennaId: String // デバイスに紐づくアンテナID public init( id: UUID = UUID(), deviceName: String, timestamp: TimeInterval, elevation: Double, azimuth: Double, - distance: Double, nlos: Int, rssi: Double, seqCount: Int + distance: Double, nlos: Int, rssi: Double, seqCount: Int, antennaId: String = "" ) { self.id = id self.deviceName = deviceName @@ -26,6 +53,7 @@ public struct RealtimeData: Identifiable, Codable { self.nlos = nlos self.rssi = rssi self.seqCount = seqCount + self.antennaId = antennaId } public var formattedTime: String { @@ -94,6 +122,129 @@ public class DeviceRealtimeData: Identifiable, ObservableObject { } } +// MARK: - タグ観測データ + +/// 単一アンテナからの観測データ +public struct TagObservation: Identifiable { + public let id = UUID() + public let antennaId: String + public let deviceName: String + public let coordinate: Point3D + public let isNLOS: Bool + public let timestamp: Date + + public init( + antennaId: String, + deviceName: String, + coordinate: Point3D, + isNLOS: Bool, + timestamp: Date + ) { + self.antennaId = antennaId + self.deviceName = deviceName + self.coordinate = coordinate + self.isNLOS = isNLOS + self.timestamp = timestamp + } +} + +// MARK: - 統合タグ位置 + +/// 複数アンテナからの観測を統合したタグの位置情報 +public struct IntegratedTagPosition: Identifiable { + public let id = UUID() + public let tagId: String + public let integratedCoordinate: Point3D + public let confidence: Double + public let observations: [TagObservation] + public let hasNLOSOnly: Bool + + public init( + tagId: String, + integratedCoordinate: Point3D, + confidence: Double, + observations: [TagObservation], + hasNLOSOnly: Bool + ) { + self.tagId = tagId + self.integratedCoordinate = integratedCoordinate + self.confidence = confidence + self.observations = observations + self.hasNLOSOnly = hasNLOSOnly + } + + /// LOSの観測数 + public var losCount: Int { + self.observations.filter { !$0.isNLOS }.count + } + + /// NLOSの観測数 + public var nlosCount: Int { + self.observations.filter { $0.isNLOS }.count + } +} + +// MARK: - 統合座標履歴レコード + +/// 統合座標の履歴を保存するための構造体(CSV出力用) +public struct IntegratedCoordinateRecord: Identifiable, Codable { + public let id: UUID + public let timestamp: TimeInterval + public let tagId: String + public let x: Double + public let y: Double + public let z: Double + public let confidence: Double + public let losCount: Int + public let nlosCount: Int + public let hasNLOSOnly: Bool + + public init( + id: UUID = UUID(), + timestamp: TimeInterval, + tagId: String, + x: Double, + y: Double, + z: Double, + confidence: Double, + losCount: Int, + nlosCount: Int, + hasNLOSOnly: Bool + ) { + self.id = id + self.timestamp = timestamp + self.tagId = tagId + self.x = x + self.y = y + self.z = z + self.confidence = confidence + self.losCount = losCount + self.nlosCount = nlosCount + self.hasNLOSOnly = hasNLOSOnly + } + + /// IntegratedTagPositionから生成 + public init(from position: IntegratedTagPosition, timestamp: TimeInterval) { + self.id = UUID() + self.timestamp = timestamp + self.tagId = position.tagId + self.x = position.integratedCoordinate.x + self.y = position.integratedCoordinate.y + self.z = position.integratedCoordinate.z + self.confidence = position.confidence + self.losCount = position.losCount + self.nlosCount = position.nlosCount + self.hasNLOSOnly = position.hasNLOSOnly + } + + public var formattedTime: String { + let date = Date(timeIntervalSince1970: timestamp / 1000) + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter.string(from: date) + } +} + // MARK: - JSONパース用の構造体 public struct RealtimeDataMessage: Codable { diff --git a/UWBViewerSystem/Domain/Repository/SwiftDataRepository.swift b/UWBViewerSystem/Domain/Repository/SwiftDataRepository.swift index 29717ac..327ede3 100644 --- a/UWBViewerSystem/Domain/Repository/SwiftDataRepository.swift +++ b/UWBViewerSystem/Domain/Repository/SwiftDataRepository.swift @@ -99,14 +99,6 @@ public protocol SwiftDataRepositoryProtocol { func deleteFloorMap(by id: String) async throws func setActiveFloorMap(id: String) async throws - // プロジェクト進行状況関連 - func saveProjectProgress(_ progress: ProjectProgress) async throws - func loadProjectProgress(by id: String) async throws -> ProjectProgress? - func loadProjectProgress(for floorMapId: String) async throws -> ProjectProgress? - func loadAllProjectProgress() async throws -> [ProjectProgress] - func deleteProjectProgress(by id: String) async throws - func updateProjectProgress(_ progress: ProjectProgress) async throws - // キャリブレーション関連 func saveCalibrationData(_ data: CalibrationData) async throws func loadCalibrationData() async throws -> [CalibrationData] @@ -485,7 +477,7 @@ public class SwiftDataRepository: SwiftDataRepositoryProtocol { // MARK: - リアルタイムデータ関連 public func saveRealtimeData(_ data: RealtimeData, sessionId: String) async throws { - let persistentData = data.toPersistent() + let persistentData = data.toPersistent(sessionId: sessionId) // リレーションシップを削除したため、セッション関連付けはコメントアウト // let sessionPredicate = #Predicate { $0.id == sessionId } @@ -501,14 +493,23 @@ public class SwiftDataRepository: SwiftDataRepositoryProtocol { } public func loadRealtimeData(for sessionId: String) async throws -> [RealtimeData] { - // リレーションシップを削除したため、簡易的に全てのデータを返す(将来的にsessionIdフィールドで絞り込む) - // let predicate = #Predicate { $0.session?.id == sessionId } + // まず全データを取得して確認 + let allDescriptor = FetchDescriptor() + let allData = try modelContext.fetch(allDescriptor) + print("📊 [DEBUG] SwiftData内の全リアルタイムデータ数: \(allData.count)") + if !allData.isEmpty { + print("📊 [DEBUG] 最初のデータのsessionId: \(allData[0].sessionId)") + } + + // sessionIdフィールドで絞り込み + let predicate = #Predicate { $0.sessionId == sessionId } let descriptor = FetchDescriptor( - // predicate: predicate, + predicate: predicate, sortBy: [SortDescriptor(\.timestamp, order: .reverse)] ) let persistentData = try modelContext.fetch(descriptor) + print("📊 [DEBUG] sessionId '\(sessionId)' で絞り込んだデータ数: \(persistentData.count)") return persistentData.map { $0.toEntity() } } @@ -675,78 +676,6 @@ public class SwiftDataRepository: SwiftDataRepositoryProtocol { try self.modelContext.save() } - // MARK: - プロジェクト進行状況関連 - - public func saveProjectProgress(_ progress: ProjectProgress) async throws { - let persistentProgress = progress.toPersistent() - self.modelContext.insert(persistentProgress) - try self.modelContext.save() - } - - public func loadProjectProgress(by id: String) async throws -> ProjectProgress? { - let predicate = #Predicate { $0.id == id } - let descriptor = FetchDescriptor(predicate: predicate) - - let progresses = try modelContext.fetch(descriptor) - return progresses.first?.toEntity() - } - - public func loadProjectProgress(for floorMapId: String) async throws -> ProjectProgress? { - let predicate = #Predicate { $0.floorMapId == floorMapId } - let descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] - ) - - let progresses = try modelContext.fetch(descriptor) - return progresses.first?.toEntity() - } - - public func loadAllProjectProgress() async throws -> [ProjectProgress] { - let descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] - ) - - let persistentProgresses = try modelContext.fetch(descriptor) - return persistentProgresses.map { $0.toEntity() } - } - - public func deleteProjectProgress(by id: String) async throws { - let predicate = #Predicate { $0.id == id } - let descriptor = FetchDescriptor(predicate: predicate) - - let progresses = try modelContext.fetch(descriptor) - for progress in progresses { - self.modelContext.delete(progress) - } - try self.modelContext.save() - } - - public func updateProjectProgress(_ progress: ProjectProgress) async throws { - let predicate = #Predicate { $0.id == progress.id } - let descriptor = FetchDescriptor(predicate: predicate) - - let existingProgresses = try modelContext.fetch(descriptor) - if let existingProgress = existingProgresses.first { - // 既存のプロジェクト進行状況を更新 - existingProgress.currentStep = progress.currentStep.rawValue - existingProgress.updatedAt = progress.updatedAt - - // completedStepsの更新 - let encoder = JSONEncoder() - let stepStrings = progress.completedSteps.map { $0.rawValue } - existingProgress.completedStepsData = (try? encoder.encode(stepStrings)) ?? Data() - - // stepDataの更新 - existingProgress.stepData = (try? encoder.encode(progress.stepData)) ?? Data() - - try self.modelContext.save() - } else { - // 存在しない場合は新規作成 - try await self.saveProjectProgress(progress) - } - } - // MARK: - キャリブレーション関連 public func saveCalibrationData(_ data: CalibrationData) async throws { @@ -1055,12 +984,6 @@ public class DummySwiftDataRepository: SwiftDataRepositoryProtocol { public func loadFloorMap(by id: String) async throws -> FloorMapInfo? { nil } public func deleteFloorMap(by id: String) async throws {} public func setActiveFloorMap(id: String) async throws {} - public func saveProjectProgress(_ progress: ProjectProgress) async throws {} - public func loadProjectProgress(by id: String) async throws -> ProjectProgress? { nil } - public func loadProjectProgress(for floorMapId: String) async throws -> ProjectProgress? { nil } - public func loadAllProjectProgress() async throws -> [ProjectProgress] { [] } - public func deleteProjectProgress(by id: String) async throws {} - public func updateProjectProgress(_ progress: ProjectProgress) async throws {} // キャリブレーション関連 public func saveCalibrationData(_ data: CalibrationData) async throws {} diff --git a/UWBViewerSystem/Domain/Usecase/AutoAntennaCalibrationUsecase.swift b/UWBViewerSystem/Domain/Usecase/AutoAntennaCalibrationUsecase.swift index 7cc5596..9de1221 100644 --- a/UWBViewerSystem/Domain/Usecase/AutoAntennaCalibrationUsecase.swift +++ b/UWBViewerSystem/Domain/Usecase/AutoAntennaCalibrationUsecase.swift @@ -318,9 +318,23 @@ actor AutoAntennaCalibrationUsecase { func clearData(for antennaId: String) { self.measuredDataByAntenna.removeValue(forKey: antennaId) self.calibrationResults.removeValue(forKey: antennaId) + self.rawObservationsByAntenna.removeValue(forKey: antennaId) + self.processingStatistics.removeValue(forKey: antennaId) print("🧹 \(antennaId) のデータをクリアしました") } + /// 特定のアンテナの特定のタグのデータをクリア + /// + /// - Parameters: + /// - antennaId: アンテナID + /// - tagId: タグID + func clearData(for antennaId: String, tagId: String) { + self.measuredDataByAntenna[antennaId]?.removeValue(forKey: tagId) + self.rawObservationsByAntenna[antennaId]?.removeValue(forKey: tagId) + self.processingStatistics[antennaId]?.removeValue(forKey: tagId) + print("🧹 \(antennaId) のタグ \(tagId) のデータをクリアしました") + } + /// 現在の測定データの統計情報を取得 func getDataStatistics() -> [String: [String: Int]] { self.measuredDataByAntenna.mapValues { tagData in diff --git a/UWBViewerSystem/Domain/Usecase/ConnectionManagementUsecase.swift b/UWBViewerSystem/Domain/Usecase/ConnectionManagementUsecase.swift index e4aaa8a..902440c 100644 --- a/UWBViewerSystem/Domain/Usecase/ConnectionManagementUsecase.swift +++ b/UWBViewerSystem/Domain/Usecase/ConnectionManagementUsecase.swift @@ -15,9 +15,22 @@ public class ConnectionManagementUsecase: NSObject, ObservableObject { @Published var connectedEndpoints: Set = [] @Published var isAdvertising = false + // 接続断検出用 + @Published var hasConnectionError = false + @Published var lastDisconnectedDevice: String? + + /// 自動再接続中かどうか(アラート抑制用) + @Published public var isAutoReconnecting = false + + // ペアリング情報管理(アンテナID → デバイス名) + @Published public var antennaPairings: [String: String] = [:] + private let locationManager = CLLocationManager() private let nearbyRepository: NearbyRepository + // endpointId → deviceName のマッピング + private var endpointToDeviceNameMap: [String: String] = [:] + // RealtimeDataUsecaseへの参照を追加 public weak var realtimeDataUsecase: RealtimeDataUsecase? @@ -136,8 +149,18 @@ public class ConnectionManagementUsecase: NSObject, ObservableObject { // MARK: - Message Sending public func sendMessage(_ content: String) { - if let firstEndpoint = connectedEndpoints.first { - self.nearbyRepository.sendDataToDevice(text: content, toEndpointId: firstEndpoint) + // 全ての接続されたエンドポイントにメッセージを送信 + for endpointId in self.connectedEndpoints { + self.nearbyRepository.sendDataToDevice(text: content, toEndpointId: endpointId) + #if DEBUG + print("📤 メッセージを送信: \(content) → エンドポイント: \(endpointId)") + #endif + } + + if self.connectedEndpoints.isEmpty { + #if DEBUG + print("⚠️ 送信先のエンドポイントがありません") + #endif } } @@ -161,6 +184,43 @@ public class ConnectionManagementUsecase: NSObject, ObservableObject { self.realtimeDataUsecase = usecase print("✅ RealtimeDataUsecaseを設定しました") } + + // MARK: - Pairing Management + + /// アンテナと端末のペアリングを登録 + public func pairAntennaWithDevice(antennaId: String, deviceName: String) { + self.antennaPairings[antennaId] = deviceName + print("🔗 ペアリング登録: \(antennaId) → \(deviceName)") + } + + /// ペアリングを削除 + public func unpairAntenna(antennaId: String) { + self.antennaPairings.removeValue(forKey: antennaId) + print("✂️ ペアリング削除: \(antennaId)") + } + + /// すべてのペアリングをクリア + public func clearAllPairings() { + self.antennaPairings.removeAll() + print("🧹 すべてのペアリングをクリアしました") + } + + /// 特定のアンテナに紐づくデバイス名を取得 + public func getDeviceName(for antennaId: String) -> String? { + self.antennaPairings[antennaId] + } + + /// デバイス名からエンドポイントIDを取得 + public func getEndpointId(for deviceName: String) -> String? { + self.endpointToDeviceNameMap.first { $0.value == deviceName }?.key + } + + /// ペアリングされているかつ接続中のアンテナIDリストを取得 + public func getConnectedAntennaIds() -> [String] { + self.antennaPairings.compactMap { antennaId, deviceName in + self.connectedDeviceNames.contains(deviceName) ? antennaId : nil + } + } } // MARK: - NearbyRepositoryCallback @@ -181,7 +241,16 @@ extension ConnectionManagementUsecase: NearbyRepositoryCallback { nonisolated public func onDeviceFound(endpointId: String, name: String, isConnectable: Bool) { Task { @MainActor in - print("Device found: \(name) (\(endpointId))") + print("📱 Device found: \(name) (\(endpointId)), isConnectable: \(isConnectable)") + + // 接続可能な場合は自動的に接続リクエストを送信 + if isConnectable { + print("📞 [ConnectionManagement] 自動接続リクエストを送信開始: \(name) (\(endpointId))") + self.nearbyRepository.requestConnection(to: endpointId, deviceName: name) + print("✅ [ConnectionManagement] 自動接続リクエスト送信完了") + } else { + print("⚠️ 接続不可のデバイス: \(name)") + } } } @@ -195,9 +264,10 @@ extension ConnectionManagementUsecase: NearbyRepositoryCallback { endpointId: String, deviceName: String, context: Data, accept: @escaping (Bool) -> Void ) { Task { @MainActor in - print("Connection request from: \(deviceName) (\(endpointId))") + print("📥 [ConnectionManagement] 接続リクエスト受信: \(deviceName) (\(endpointId))") // 自動的に接続を承認 accept(true) + print("✅ [ConnectionManagement] 接続リクエストを承認しました") } } @@ -217,6 +287,13 @@ extension ConnectionManagementUsecase: NearbyRepositoryCallback { self.connectedEndpoints.insert(endpointId) self.connectState = "端末接続: \(deviceName)" + // endpointId → deviceName のマッピングを保存 + self.endpointToDeviceNameMap[endpointId] = deviceName + + // 接続エラーフラグをクリア + self.hasConnectionError = false + self.lastDisconnectedDevice = nil + // RealtimeDataUsecaseにデバイス接続を通知 self.realtimeDataUsecase?.addConnectedDevice(deviceName) print("📱 RealtimeDataUsecaseに端末接続を通知: \(deviceName)") @@ -226,15 +303,27 @@ extension ConnectionManagementUsecase: NearbyRepositoryCallback { nonisolated public func onDeviceDisconnected(endpointId: String) { Task { @MainActor in print("Device disconnected: \(endpointId)") + + // endpointIdからdeviceNameを取得 + let deviceName = self.endpointToDeviceNameMap[endpointId] + self.connectedEndpoints.remove(endpointId) - self.connectState = "端末切断: \(endpointId)" + self.connectState = "端末切断: \(deviceName ?? endpointId)" + + // 接続エラーフラグを設定 + self.hasConnectionError = true + self.lastDisconnectedDevice = deviceName // RealtimeDataUsecaseに端末切断を通知 - // endpointIdではなくdeviceNameが必要だが、ここではendpointIdしかないので - // 接続中のdeviceNamesから削除する - if let deviceName = self.connectedDeviceNames.first(where: { _ in true }) { + if let deviceName { + self.connectedDeviceNames.remove(deviceName) self.realtimeDataUsecase?.removeDisconnectedDevice(deviceName) print("📱 RealtimeDataUsecaseに端末切断を通知: \(deviceName)") + + // マッピングから削除 + self.endpointToDeviceNameMap.removeValue(forKey: endpointId) + } else { + print("⚠️ endpointId \(endpointId) に対応するdeviceNameが見つかりません") } } } diff --git a/UWBViewerSystem/Domain/Usecase/RealtimeCoordinateTransformUsecase.swift b/UWBViewerSystem/Domain/Usecase/RealtimeCoordinateTransformUsecase.swift new file mode 100644 index 0000000..ddb27bf --- /dev/null +++ b/UWBViewerSystem/Domain/Usecase/RealtimeCoordinateTransformUsecase.swift @@ -0,0 +1,157 @@ +import Foundation + +/// リアルタイムセンサーデータの座標変換を行うUsecase +/// +/// 極座標(距離、仰角、方位)からグローバル座標(フロアマップ座標系)への変換を実行します。 +@MainActor +public class RealtimeCoordinateTransformUsecase { + + private let swiftDataRepository: SwiftDataRepository + + public init(swiftDataRepository: SwiftDataRepository) { + self.swiftDataRepository = swiftDataRepository + } + + /// 極座標からグローバル座標への変換 + /// + /// - Parameters: + /// - distance: 距離(メートル) + /// - elevation: 仰角(度) + /// - azimuth: 方位角(度) + /// - antennaId: アンテナID + /// - floorMapId: フロアマップID + /// - Returns: グローバル座標(Point3D)。変換失敗時はnil + public func transformToGlobalCoordinate( + distance: Double, + elevation: Double, + azimuth: Double, + antennaId: String, + floorMapId: String + ) async -> Point3D? { + // アンテナ位置情報を取得 + guard let antennaPositions = try? await swiftDataRepository.loadAntennaPositions(for: floorMapId), + let antennaPosition = antennaPositions.first(where: { $0.antennaId == antennaId }) + else { + print("⚠️ アンテナ位置情報が見つかりません: antennaId=\(antennaId), floorMapId=\(floorMapId)") + return nil + } + + // ステップ1: 極座標からローカル直交座標への変換 + let localCoord = self.polarToCartesian(distance: distance, elevation: elevation, azimuth: azimuth) + + // ステップ2: ローカル座標からグローバル座標への変換 + let globalCoord = self.localToGlobal( + localCoord: localCoord, + antennaPosition: antennaPosition.position, + antennaRotation: antennaPosition.rotation + ) + + return globalCoord + } + + /// 複数のリアルタイムデータをバッチ変換 + /// + /// - Parameters: + /// - realtimeDataList: リアルタイムデータのリスト + /// - floorMapId: フロアマップID + /// - Returns: デバイス名をキーとしたグローバル座標の辞書 + public func transformMultipleData( + _ realtimeDataList: [RealtimeData], + floorMapId: String + ) async -> [String: Point3D] { + var results: [String: Point3D] = [:] + + for data in realtimeDataList { + if let globalCoord = await self.transformToGlobalCoordinate( + distance: data.distance, + elevation: data.elevation, + azimuth: data.azimuth, + antennaId: data.antennaId, + floorMapId: floorMapId + ) { + results[data.deviceName] = globalCoord + } + } + + return results + } + + // MARK: - Private Methods + + /// 極座標(球面座標)から直交座標への変換 + /// + /// - Parameters: + /// - distance: 距離(メートル) + /// - elevation: 仰角(度) + /// - azimuth: 方位角(度) - UWB座標系では東が0度 + /// - Returns: ローカル直交座標(アンテナ中心) + private func polarToCartesian(distance: Double, elevation: Double, azimuth: Double) -> Point3D { + // 角度をラジアンに変換 + let elevationRad = elevation * .pi / 180.0 + // UWB座標系(東が0度)から数学的座標系(北が0度)に変換するため90度加算 + let azimuthRad = (azimuth + 90.0) * .pi / 180.0 + + // 球面座標から直交座標への変換 + // x: 東西方向(東が正) + // y: 南北方向(北が正) + // z: 上下方向(上が正) + let x = distance * cos(elevationRad) * sin(azimuthRad) + let y = distance * cos(elevationRad) * cos(azimuthRad) + let z = distance * sin(elevationRad) + + return Point3D(x: x, y: y, z: z) + } + + /// ローカル座標からグローバル座標への変換 + /// + /// アンテナの位置と回転角度を考慮して、アンテナローカル座標をフロアマップのグローバル座標に変換します。 + /// + /// - Parameters: + /// - localCoord: ローカル直交座標(アンテナ中心) + /// - antennaPosition: アンテナのグローバル位置(フロアマップ座標系) + /// - antennaRotation: アンテナの回転角度(度) + /// - Returns: グローバル直交座標(フロアマップ座標系) + private func localToGlobal( + localCoord: Point3D, + antennaPosition: Point3D, + antennaRotation: Double + ) -> Point3D { + // 回転角度をラジアンに変換 + let rotationRad = antennaRotation * .pi / 180.0 + + // 回転行列を適用(Z軸周りの回転) + let rotatedX = localCoord.x * cos(rotationRad) - localCoord.y * sin(rotationRad) + let rotatedY = localCoord.x * sin(rotationRad) + localCoord.y * cos(rotationRad) + + // アンテナ位置を加算してグローバル座標に変換 + let globalX = antennaPosition.x + rotatedX + let globalY = antennaPosition.y + rotatedY + let globalZ = antennaPosition.z + localCoord.z + + return Point3D(x: globalX, y: globalY, z: globalZ) + } + + /// アンテナIDに対応するアンテナ位置情報を取得 + /// + /// - Parameters: + /// - antennaId: アンテナID + /// - floorMapId: フロアマップID + /// - Returns: アンテナ位置情報。見つからない場合はnil + public func getAntennaPosition(antennaId: String, floorMapId: String) async -> AntennaPositionData? { + guard let antennaPositions = try? await swiftDataRepository.loadAntennaPositions(for: floorMapId) else { + return nil + } + return antennaPositions.first(where: { $0.antennaId == antennaId }) + } + + /// すべてのアンテナ位置情報を取得 + /// + /// - Parameter floorMapId: フロアマップID + /// - Returns: アンテナ位置情報の配列 + public func getAllAntennaPositions(floorMapId: String) async -> [AntennaPositionData] { + guard let antennaPositions = try? await swiftDataRepository.loadAntennaPositions(for: floorMapId) else { + return [] + } + return antennaPositions + } +} diff --git a/UWBViewerSystem/Domain/Usecase/RealtimeDataUsecase.swift b/UWBViewerSystem/Domain/Usecase/RealtimeDataUsecase.swift index bf5efe9..f12c5e9 100644 --- a/UWBViewerSystem/Domain/Usecase/RealtimeDataUsecase.swift +++ b/UWBViewerSystem/Domain/Usecase/RealtimeDataUsecase.swift @@ -8,11 +8,19 @@ import os.log public class RealtimeDataUsecase: ObservableObject { @Published var deviceRealtimeDataList: [DeviceRealtimeData] = [] @Published var isReceivingRealtimeData = false + @Published var globalCoordinates: [String: Point3D] = [:] // デバイス名 → グローバル座標 + + // 複数アンテナ対応のプロパティ + @Published var antennaDataMap: [String: [DeviceRealtimeData]] = [:] // アンテナID別のデータ + @Published var activeAntennaIds = Set() // アクティブなアンテナIDのセット + @Published var totalDataPointCount = 0 // 全アンテナの総データポイント数 private var cancellables = Set() - private let swiftDataRepository: SwiftDataRepositoryProtocol + private var swiftDataRepository: SwiftDataRepositoryProtocol private weak var sensingControlUsecase: SensingControlUsecase? private let logger = Logger(subsystem: "com.uwbviewer.system", category: "realtime-data") + private var coordinateTransformUsecase: RealtimeCoordinateTransformUsecase? + private var currentFloorMapId: String? public init( swiftDataRepository: SwiftDataRepositoryProtocol = DummySwiftDataRepository(), @@ -20,6 +28,28 @@ public class RealtimeDataUsecase: ObservableObject { ) { self.swiftDataRepository = swiftDataRepository self.sensingControlUsecase = sensingControlUsecase + + // SwiftDataRepositoryが有効な場合は座標変換Usecaseを初期化 + if let swiftDataRepo = swiftDataRepository as? SwiftDataRepository { + self.coordinateTransformUsecase = RealtimeCoordinateTransformUsecase( + swiftDataRepository: swiftDataRepo + ) + } + } + + /// SwiftDataRepositoryを更新(ViewModelから呼ばれる) + public func updateSwiftDataRepository(_ repository: SwiftDataRepository) { + self.swiftDataRepository = repository + self.coordinateTransformUsecase = RealtimeCoordinateTransformUsecase( + swiftDataRepository: repository + ) + print("✅ RealtimeDataUsecase: SwiftDataRepositoryを更新しました") + } + + /// フロアマップIDを設定(座標変換に必要) + public func setFloorMapId(_ floorMapId: String) { + self.currentFloorMapId = floorMapId + print("📍 RealtimeDataUsecase: FloorMapIDを設定しました: \(floorMapId)") } // MARK: - Public Methods @@ -52,6 +82,9 @@ public class RealtimeDataUsecase: ObservableObject { // 距離をcmからmに変換 let distanceInMeters = Double(realtimeMessage.data.distance) / 100.0 + // デバイス名から対応するアンテナIDを取得 + let antennaId = self.getAntennaId(for: realtimeMessage.deviceName) + let realtimeData = RealtimeData( id: UUID(), deviceName: realtimeMessage.deviceName, @@ -61,7 +94,8 @@ public class RealtimeDataUsecase: ObservableObject { distance: distanceInMeters, nlos: realtimeMessage.data.nlos, rssi: realtimeMessage.data.rssi, - seqCount: realtimeMessage.data.seqCount + seqCount: realtimeMessage.data.seqCount, + antennaId: antennaId ) self.addDataToDevice(realtimeData) @@ -119,9 +153,24 @@ public class RealtimeDataUsecase: ObservableObject { for deviceData in self.deviceRealtimeDataList { deviceData.clearData() } + // 統合座標履歴もクリア + self.integratedCoordinateHistory.removeAll() objectWillChange.send() } + /// 統合座標履歴をクリア + public func clearIntegratedCoordinateHistory() { + self.integratedCoordinateHistory.removeAll() + #if DEBUG + print("🗑️ 統合座標履歴をクリア") + #endif + } + + /// 統合座標履歴を取得 + public func getIntegratedCoordinateHistory() -> [IntegratedCoordinateRecord] { + self.integratedCoordinateHistory + } + public func loadRealtimeDataHistory(for sessionId: String) async -> [RealtimeData] { do { return try await self.swiftDataRepository.loadRealtimeData(for: sessionId) @@ -142,9 +191,24 @@ public class RealtimeDataUsecase: ObservableObject { private func addDataToDevice(_ data: RealtimeData) { // SensingControlUsecaseがアクティブな場合は永続化 if let sensingControl = sensingControlUsecase { + #if DEBUG + print("💾 SensingControlUsecaseにデータ保存を依頼: \(data.deviceName)") + #endif Task { await sensingControl.saveRealtimeData(data) } + } else { + #if DEBUG + print("⚠️ sensingControlUsecaseがnilのためデータ保存スキップ") + #endif + } + + // アンテナIDをアクティブリストに追加 + if !data.antennaId.isEmpty { + self.activeAntennaIds.insert(data.antennaId) + #if DEBUG + print("📡 アクティブアンテナ追加: \(data.antennaId) (総数: \(self.activeAntennaIds.count))") + #endif } if let index = deviceRealtimeDataList.firstIndex(where: { $0.deviceName == data.deviceName }) { @@ -189,13 +253,100 @@ public class RealtimeDataUsecase: ObservableObject { #endif } + // アンテナ別データマップを更新 + if !data.antennaId.isEmpty { + if self.antennaDataMap[data.antennaId] == nil { + self.antennaDataMap[data.antennaId] = [] + } + + // 該当アンテナのデバイスリストを更新 + if let deviceData = deviceRealtimeDataList.first(where: { $0.deviceName == data.deviceName }) { + if let existingIndex = antennaDataMap[data.antennaId]?.firstIndex(where: { $0.deviceName == data.deviceName }) { + self.antennaDataMap[data.antennaId]?[existingIndex] = deviceData + } else { + self.antennaDataMap[data.antennaId]?.append(deviceData) + } + } + } + + // 総データポイント数を更新 + self.totalDataPointCount = self.deviceRealtimeDataList.reduce(0) { $0 + $1.dataHistory.count } + self.isReceivingRealtimeData = true objectWillChange.send() + // 座標変換を実行 + self.performCoordinateTransform(for: data) + // デバイス状況をログ出力 self.logDeviceStatus() } + /// デバイス名から対応するアンテナIDを取得 + /// + /// ConnectionManagementUsecaseのペアリング情報から逆引きでアンテナIDを取得します。 + private func getAntennaId(for deviceName: String) -> String { + let antennaPairings = ConnectionManagementUsecase.shared.antennaPairings + + // アンテナID → デバイス名のマッピングから逆引き + for (antennaId, pairedDeviceName) in antennaPairings { + if pairedDeviceName == deviceName { + #if DEBUG + print("🔗 デバイス \(deviceName) はアンテナ \(antennaId) に紐づいています") + #endif + return antennaId + } + } + + #if DEBUG + print("⚠️ デバイス \(deviceName) に対応するアンテナIDが見つかりません") + #endif + return "" + } + + /// リアルタイムデータのグローバル座標変換を実行 + private func performCoordinateTransform(for data: RealtimeData) { + guard let transformUsecase = coordinateTransformUsecase, + let floorMapId = currentFloorMapId + else { + #if DEBUG + print("⚠️ 座標変換がスキップされました: transformUsecase=\(self.coordinateTransformUsecase != nil), floorMapId=\(self.currentFloorMapId ?? "nil")") + #endif + return + } + + // antennaIdが空の場合は変換不可 + guard !data.antennaId.isEmpty else { + #if DEBUG + print("⚠️ antennaIdが空のため座標変換をスキップ: deviceName=\(data.deviceName)") + #endif + return + } + + // 距離が0の場合でも座標変換は実行(個別表示用) + // ただし、重心と統合位置の計算からは除外される + Task { + if let globalCoord = await transformUsecase.transformToGlobalCoordinate( + distance: data.distance, + elevation: data.elevation, + azimuth: data.azimuth, + antennaId: data.antennaId, + floorMapId: floorMapId + ) { + // 個別表示用: NLOS/LOS、有効/無効に関係なく全て保存 + self.globalCoordinates[data.deviceName] = globalCoord + #if DEBUG + let isValid = self.isValidCoordinate(globalCoord) && self.isValidDistance(data.distance) + print("📍 グローバル座標変換成功: \(data.deviceName) → (\(globalCoord.x), \(globalCoord.y), \(globalCoord.z)) [有効=\(isValid)]") + #endif + + // 重心座標と統合位置を更新(ここで無効データは除外される) + self.calculateCentroid() + self.updateIntegratedTagPositions() + } + } + } + private func logDeviceStatus() { #if DEBUG print("=== 全デバイス状況 ===") @@ -210,4 +361,294 @@ public class RealtimeDataUsecase: ObservableObject { print("=== 全デバイス状況終了 ===") #endif } + + // MARK: - Centroid Calculation + + /// 全デバイスの重心座標(LOSを優先、フィルター適用後) + @Published var centroidCoordinate: Point3D? + + /// タグごとの統合座標(複数アンテナからの観測を統合) + @Published var integratedTagCoordinates: [String: IntegratedTagPosition] = [:] + + /// 統合座標の履歴(CSV出力用) + @Published private(set) var integratedCoordinateHistory: [IntegratedCoordinateRecord] = [] + + /// ローパスフィルター用の前回値(重心用) + private var previousFilteredCentroid: Point3D? + + /// ローパスフィルター用の前回値(統合タグ用) + private var previousFilteredIntegratedPosition: Point3D? + + /// ローパスフィルターの平滑化係数(0.0〜1.0、小さいほど滑らか) + private let smoothingFactor: Double = 0.3 + + /// 重心座標を計算(LOSを優先、NLOSはLOSがない場合のみ使用) + private func calculateCentroid() { + guard !self.globalCoordinates.isEmpty else { + self.centroidCoordinate = nil + self.previousFilteredCentroid = nil + return + } + + // 有効な座標を持つデバイスをLOSとNLOSに分類 + var losCoordinates: [(deviceName: String, coordinate: Point3D)] = [] + var nlosCoordinates: [(deviceName: String, coordinate: Point3D)] = [] + + for (deviceName, coordinate) in self.globalCoordinates { + // 座標が(0, 0, 0)の場合はスキップ + guard self.isValidCoordinate(coordinate) else { + #if DEBUG + print("⚠️ 重心計算: (0,0,0)座標をスキップ: \(deviceName)") + #endif + continue + } + + // 距離が0mの場合はスキップ + let deviceData = self.deviceRealtimeDataList.first { $0.deviceName == deviceName } + guard let latestData = deviceData?.latestData, self.isValidDistance(latestData.distance) else { + #if DEBUG + print("⚠️ 重心計算: 距離0mをスキップ: \(deviceName)") + #endif + continue + } + + let isNLOS = latestData.nlos == 1 + + if isNLOS { + nlosCoordinates.append((deviceName, coordinate)) + } else { + losCoordinates.append((deviceName, coordinate)) + } + } + + // LOSが1つでもあればLOSのみを使用、なければNLOSを使用 + let targetCoordinates = losCoordinates.isEmpty ? nlosCoordinates : losCoordinates + let usingNLOS = losCoordinates.isEmpty + + guard !targetCoordinates.isEmpty else { + self.centroidCoordinate = nil + self.previousFilteredCentroid = nil + return + } + + // 単純な重心を計算(選択された座標群のみ) + var totalX = 0.0 + var totalY = 0.0 + var totalZ = 0.0 + + for (_, coordinate) in targetCoordinates { + totalX += coordinate.x + totalY += coordinate.y + totalZ += coordinate.z + } + + let count = Double(targetCoordinates.count) + let rawCentroid = Point3D( + x: totalX / count, + y: totalY / count, + z: totalZ / count + ) + + // ローパスフィルターを適用 + let filteredCentroid = self.applyLowPassFilter(newValue: rawCentroid) + self.centroidCoordinate = filteredCentroid + + #if DEBUG + print("📍 重心座標計算: (\(filteredCentroid.x), \(filteredCentroid.y), \(filteredCentroid.z)) [LOS=\(losCoordinates.count), NLOS=\(nlosCoordinates.count), 使用=\(usingNLOS ? "NLOS" : "LOS")]") + #endif + } + + /// ローパスフィルター(指数移動平均)を適用 + private func applyLowPassFilter(newValue: Point3D) -> Point3D { + guard let previous = previousFilteredCentroid else { + // 初回は新しい値をそのまま使用 + self.previousFilteredCentroid = newValue + return newValue + } + + // 指数移動平均: filtered = α * new + (1 - α) * previous + let filtered = Point3D( + x: self.smoothingFactor * newValue.x + (1 - self.smoothingFactor) * previous.x, + y: self.smoothingFactor * newValue.y + (1 - self.smoothingFactor) * previous.y, + z: self.smoothingFactor * newValue.z + (1 - self.smoothingFactor) * previous.z + ) + + self.previousFilteredCentroid = filtered + return filtered + } + + /// 統合タグ位置を更新 + /// 全てのアンテナからの観測を1つのタグとして統合し、重心座標を計算する + /// NLOSの観測は除外し、NLOSしかない場合のみNLOSを使用する + /// 距離が0mまたは座標が(0,0,0)の観測は除外する + private func updateIntegratedTagPositions() { + // 全ての観測を1つのタグとしてまとめる(有効なデータのみ) + var allObservations: [TagObservation] = [] + let integratedTagId = "integrated_tag" + + for (deviceName, coordinate) in self.globalCoordinates { + let deviceData = self.deviceRealtimeDataList.first { $0.deviceName == deviceName } + + // 距離が0mの場合はスキップ + guard let latestData = deviceData?.latestData, self.isValidDistance(latestData.distance) else { + #if DEBUG + print("⚠️ 統合位置計算: 距離0mをスキップ: \(deviceName)") + #endif + continue + } + + // 座標が(0,0,0)の場合はスキップ + guard self.isValidCoordinate(coordinate) else { + #if DEBUG + print("⚠️ 統合位置計算: (0,0,0)座標をスキップ: \(deviceName)") + #endif + continue + } + + let isNLOS = latestData.nlos == 1 + let antennaId = latestData.antennaId + + let observation = TagObservation( + antennaId: antennaId, + deviceName: deviceName, + coordinate: coordinate, + isNLOS: isNLOS, + timestamp: deviceData?.lastUpdateTime ?? Date() + ) + + allObservations.append(observation) + } + + guard !allObservations.isEmpty else { + self.integratedTagCoordinates = [:] + self.previousFilteredIntegratedPosition = nil + return + } + + // 統合位置を計算(NLOSを除外、NLOSのみの場合はNLOSを使用) + let integrated = self.calculateIntegratedPosition(for: allObservations) + + // ローパスフィルターを適用 + let filteredCoordinate = self.applyIntegratedPositionFilter(newValue: integrated.coordinate) + + let integratedPosition = IntegratedTagPosition( + tagId: integratedTagId, + integratedCoordinate: filteredCoordinate, + confidence: integrated.confidence, + observations: allObservations, + hasNLOSOnly: allObservations.allSatisfy { $0.isNLOS } + ) + + self.integratedTagCoordinates = [integratedTagId: integratedPosition] + + // 履歴に追加(タイムスタンプは最新の観測データから取得) + let currentTimestamp = Date().timeIntervalSince1970 * 1000 + let record = IntegratedCoordinateRecord(from: integratedPosition, timestamp: currentTimestamp) + self.integratedCoordinateHistory.append(record) + + #if DEBUG + let losCount = allObservations.filter { !$0.isNLOS }.count + let nlosCount = allObservations.filter { $0.isNLOS }.count + print("📍 統合タグ位置更新: LOS=\(losCount), NLOS=\(nlosCount), 座標=(\(filteredCoordinate.x), \(filteredCoordinate.y), \(filteredCoordinate.z))") + #endif + } + + /// 統合タグ位置用のローパスフィルターを適用 + private func applyIntegratedPositionFilter(newValue: Point3D) -> Point3D { + // 無効な座標の場合はフィルターをリセット + guard self.isValidCoordinate(newValue) else { + self.previousFilteredIntegratedPosition = nil + return newValue + } + + guard let previous = previousFilteredIntegratedPosition else { + // 初回は新しい値をそのまま使用 + self.previousFilteredIntegratedPosition = newValue + return newValue + } + + // 指数移動平均: filtered = α * new + (1 - α) * previous + let filtered = Point3D( + x: self.smoothingFactor * newValue.x + (1 - self.smoothingFactor) * previous.x, + y: self.smoothingFactor * newValue.y + (1 - self.smoothingFactor) * previous.y, + z: self.smoothingFactor * newValue.z + (1 - self.smoothingFactor) * previous.z + ) + + self.previousFilteredIntegratedPosition = filtered + return filtered + } + + /// デバイス名からタグIDを抽出 + private func extractTagId(from deviceName: String) -> String { + deviceName + } + + /// 座標が有効かどうかをチェック(0, 0, 0の場合は無効) + private func isValidCoordinate(_ coordinate: Point3D) -> Bool { + !(coordinate.x == 0 && coordinate.y == 0 && coordinate.z == 0) + } + + /// 距離が有効かどうかをチェック(0mの場合は無効) + private func isValidDistance(_ distance: Double) -> Bool { + distance > 0 + } + + /// 複数観測から統合位置を計算 + /// NLOSの観測は使用しない。NLOSしかない場合のみNLOSを使用する。 + /// 座標が(0, 0, 0)の観測は除外する。 + private func calculateIntegratedPosition( + for observations: [TagObservation] + ) -> (coordinate: Point3D, confidence: Double) { + guard !observations.isEmpty else { + return (Point3D.zero, 0.0) + } + + // 座標が(0, 0, 0)の観測を除外 + let validObservations = observations.filter { self.isValidCoordinate($0.coordinate) } + + guard !validObservations.isEmpty else { + #if DEBUG + print("⚠️ 有効な座標を持つ観測がありません(全て0,0,0)") + #endif + return (Point3D.zero, 0.0) + } + + // LOSの観測のみをフィルタリング + let losObservations = validObservations.filter { !$0.isNLOS } + + // NLOSしかない場合はNLOSを使用、それ以外はLOSのみを使用 + let targetObservations = losObservations.isEmpty ? validObservations : losObservations + let usingNLOSOnly = losObservations.isEmpty + + var totalX = 0.0 + var totalY = 0.0 + var totalZ = 0.0 + let count = Double(targetObservations.count) + + // 全ての観測に同じ重み(1.0)を使用して単純な重心を計算 + for obs in targetObservations { + totalX += obs.coordinate.x + totalY += obs.coordinate.y + totalZ += obs.coordinate.z + } + + let coordinate = Point3D( + x: totalX / count, + y: totalY / count, + z: totalZ / count + ) + + // 信頼度の計算 + // LOSのみを使用している場合は高い信頼度、NLOSのみの場合は低い信頼度 + let baseConfidence = usingNLOSOnly ? 0.2 : 0.8 + let observationBonus = Double(min(targetObservations.count, 4)) / 4.0 * 0.2 + let confidence = baseConfidence + observationBonus + + #if DEBUG + let skippedCount = observations.count - validObservations.count + print("📊 統合位置計算: 使用観測数=\(targetObservations.count), スキップ(0,0,0)=\(skippedCount), NLOSのみ=\(usingNLOSOnly), 信頼度=\(confidence)") + #endif + + return (coordinate, min(confidence, 1.0)) + } } diff --git a/UWBViewerSystem/Domain/Usecase/SensingControlUsecase.swift b/UWBViewerSystem/Domain/Usecase/SensingControlUsecase.swift index a54c8a1..e3bc35d 100644 --- a/UWBViewerSystem/Domain/Usecase/SensingControlUsecase.swift +++ b/UWBViewerSystem/Domain/Usecase/SensingControlUsecase.swift @@ -23,6 +23,11 @@ public class SensingControlUsecase: ObservableObject { private var currentSessionId: String? private let logger = Logger(subsystem: "com.uwbviewer.system", category: "sensing-control") + /// 現在のセッションIDを取得(CSV出力用) + public var activeSessionId: String? { + self.currentSessionId + } + public init( connectionUsecase: ConnectionManagementUsecase, swiftDataRepository: SwiftDataRepositoryProtocol = DummySwiftDataRepository() @@ -97,6 +102,95 @@ public class SensingControlUsecase: ObservableObject { self.logger.info("センシング開始処理完了") } + /// 特定のデバイスのみにセンシング開始コマンドを送信(キャリブレーション用) + /// + /// - Parameters: + /// - fileName: センシングセッションのファイル名 + /// - deviceName: コマンドを送信する対象のデバイス名 + /// - Returns: コマンド送信に成功した場合はtrue + @discardableResult + public func startRemoteSensingForDevice(fileName: String, deviceName: String) -> Bool { + self.logger.info("センシング開始処理開始(単一デバイス) - ファイル名: \(fileName), デバイス: \(deviceName)") + + guard !fileName.isEmpty else { + self.sensingStatus = "ファイル名を入力してください" + self.logger.error("ファイル名が空です") + return false + } + + guard let endpointId = self.connectionUsecase.getEndpointId(for: deviceName) else { + self.sensingStatus = "デバイス \(deviceName) が見つかりません" + self.logger.error("デバイス \(deviceName) のエンドポイントIDが見つかりません") + return false + } + + Task { + do { + // 新しいセンシングセッションを作成してSwiftDataに保存 + let session = SensingSession(name: fileName, startTime: Date(), isActive: true) + try await self.swiftDataRepository.saveSensingSession(session) + self.currentSessionId = session.id + + // システム活動ログも記録 + let activity = SystemActivity( + activityType: "sensing", + activityDescription: "センシングセッション開始(単一デバイス): \(fileName) - デバイス: \(deviceName)" + ) + try await self.swiftDataRepository.saveSystemActivity(activity) + + self.logger.info("センシングセッション作成完了: \(session.id)") + } catch { + self.logger.error("センシングセッション作成エラー: \(error)") + self.sensingStatus = "セッション作成に失敗しました" + } + } + + let command = "SENSING_START:\(fileName)" + self.logger.info("送信するコマンド: \(command), 送信対象デバイス: \(deviceName) (\(endpointId))") + + // 特定のデバイスのみにコマンドを送信 + self.connectionUsecase.sendMessageToDevice(command, to: endpointId) + self.sensingStatus = "センシング開始コマンド送信: \(fileName)" + self.isSensingControlActive = true + self.sensingFileName = fileName + self.currentSensingFileName = fileName + self.sensingStartTime = Date() + + // 継続時間タイマーを開始 + self.startDurationTimer() + + self.logger.info("センシング開始処理完了(単一デバイス)") + return true + } + + /// 特定のデバイスにセンシング停止コマンドを送信(キャリブレーション用) + /// + /// - Parameter deviceName: コマンドを送信する対象のデバイス名 + public func stopRemoteSensingForDevice(deviceName: String) { + guard let endpointId = self.connectionUsecase.getEndpointId(for: deviceName) else { + self.logger.error("デバイス \(deviceName) のエンドポイントIDが見つかりません") + return + } + + let command = "SENSING_STOP" + self.connectionUsecase.sendMessageToDevice(command, to: endpointId) + self.sensingStatus = "センシング終了コマンド送信(\(deviceName))" + self.isSensingControlActive = false + self.sensingFileName = "" + self.isPaused = false + + self.stopDurationTimer() + + if self.autoSave { + Task { + await self.saveCurrentSession() + } + } + + self.sensingStartTime = nil + self.currentSensingFileName = "" + } + public func stopRemoteSensing() { guard self.connectionUsecase.hasConnectedDevices() else { self.sensingStatus = "接続された端末がありません" @@ -120,7 +214,8 @@ public class SensingControlUsecase: ObservableObject { self.sensingStartTime = nil self.currentSensingFileName = "" - self.currentSessionId = nil + // NOTE: currentSessionIdはCSV出力で使用されるため、ここではクリアしない + // DataCollectionViewModelでのCSV出力完了後に明示的にクリアされる } public func pauseRemoteSensing() { @@ -218,17 +313,21 @@ public class SensingControlUsecase: ObservableObject { // MARK: - Data Management public func saveRealtimeData(_ data: RealtimeData) async { - guard let sessionId = currentSessionId else { return } + guard let sessionId = currentSessionId else { + self.logger.warning("⚠️ saveRealtimeData: currentSessionIdがnil") + return + } do { try await self.swiftDataRepository.saveRealtimeData(data, sessionId: sessionId) + self.logger.debug("💾 リアルタイムデータ保存成功: \(data.deviceName) - SeqCount: \(data.seqCount) - SessionID: \(sessionId)") // データポイント数を更新 Task { @MainActor in self.dataPointCount += 1 } } catch { - self.logger.error("リアルタイムデータ保存エラー: \(error)") + self.logger.error("❌ リアルタイムデータ保存エラー: \(error)") } } diff --git a/UWBViewerSystem/Domain/Usecase/SessionDataExportUsecase.swift b/UWBViewerSystem/Domain/Usecase/SessionDataExportUsecase.swift new file mode 100644 index 0000000..a051bcb --- /dev/null +++ b/UWBViewerSystem/Domain/Usecase/SessionDataExportUsecase.swift @@ -0,0 +1,212 @@ +// +// SessionDataExportUsecase.swift +// UWBViewerSystem +// +// Created by Claude Code on 2025/11/25. +// + +import Foundation + +/// セッションデータのエクスポートを担当するUseCase +/// ViewModelからビジネスロジックを分離し、データエクスポート処理を集約 +@MainActor +class SessionDataExportUsecase { + private let zipFileManager: ZipFileManager + + init(zipFileManager: ZipFileManager = .shared) { + self.zipFileManager = zipFileManager + } + + /// センシングデータのディレクトリからZIPファイルを作成 + /// - Parameter session: エクスポートするセッション + /// - Returns: 作成されたZIPファイルのURL、失敗した場合はnil + func exportSessionToZip(_ session: SensingSession) async -> URL? { + do { + print("📦 セッションデータを圧縮中: \(session.name)") + + // 1. センシングデータが保存されているディレクトリを特定 + guard let documentsDirectory = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + ).first else { + print("❌ Documentsディレクトリが見つかりません") + return nil + } + + // 日付フォーマッターの設定 + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone.current + dateFormatter.dateFormat = "yyyyMMdd" + let dateString = dateFormatter.string(from: session.startTime) + + dateFormatter.dateFormat = "HHmmss" + let timeString = dateFormatter.string(from: session.startTime) + + // セッションディレクトリ名を構築(customNameがある場合は「HHmmss-customName」形式、空の場合は「HHmmss」のみ) + let directoryName = if !session.name.isEmpty { + "\(timeString)-\(session.name)" + } else { + timeString + } + + // デバッグ情報を出力 + print("🔍 [DEBUG] セッション情報:") + print(" - セッション名: \(session.name)") + print(" - セッションID: \(session.id)") + print(" - 開始時刻: \(session.startTime)") + print(" - 日付文字列: \(dateString)") + print(" - 時刻文字列: \(timeString)") + print(" - ディレクトリ名: \(directoryName)") + + // センシングデータディレクトリのパス + let sensingDirectory = documentsDirectory + .appendingPathComponent("sensing") + .appendingPathComponent(dateString) + .appendingPathComponent(directoryName) + + print("📁 構築したパス: \(sensingDirectory.path)") + + // sensingディレクトリの内容を確認 + let sensingBaseDir = documentsDirectory.appendingPathComponent("sensing") + print("🔍 [DEBUG] sensingベースディレクトリ: \(sensingBaseDir.path)") + + if FileManager.default.fileExists(atPath: sensingBaseDir.path) { + print("✅ sensingディレクトリは存在します") + + // sensingディレクトリ内の日付フォルダを列挙 + if let dateDirs = try? FileManager.default.contentsOfDirectory(atPath: sensingBaseDir.path) { + print("📂 sensing内の日付フォルダ: \(dateDirs.count)個") + for dateDir in dateDirs { + print(" - \(dateDir)") + + // 各日付フォルダ内のセッションフォルダを列挙 + let dateDirPath = sensingBaseDir.appendingPathComponent(dateDir) + if let sessionDirs = try? FileManager.default.contentsOfDirectory(atPath: dateDirPath.path) { + print(" 📁 \(dateDir)内のセッションフォルダ: \(sessionDirs.count)個") + for sessionDir in sessionDirs { + print(" - \(sessionDir)") + } + } + } + } else { + print("⚠️ sensingディレクトリの内容を取得できませんでした") + } + } else { + print("❌ sensingディレクトリが存在しません") + } + + // 2. ディレクトリが存在するか確認 + guard FileManager.default.fileExists(atPath: sensingDirectory.path) else { + print("⚠️ 指定されたセンシングデータディレクトリが存在しません: \(sensingDirectory.path)") + print("❌ センシングデータが見つからないため、ZIPを作成できません") + return nil + } + + // 3. メタデータを作成 + let copiedFileCount = (try? FileManager.default + .contentsOfDirectory(at: sensingDirectory, includingPropertiesForKeys: nil) + .count) ?? 0 + + let metadata: [String: Any] = [ + "sessionName": session.name, + "sessionId": session.id, + "startTime": ISO8601DateFormatter().string(from: session.startTime), + "endTime": session.endTime.map { ISO8601DateFormatter().string(from: $0) } ?? "N/A", + "dataPoints": session.dataPoints, + "copiedFiles": copiedFileCount as Int, + ] + + // 4. ZipFileManagerを使用してZIP圧縮 + let zipFileName = "\(session.name.isEmpty ? session.id : session.name).zip" + guard let zipURL = try self.zipFileManager.zipDirectoryContents( + at: sensingDirectory, + to: zipFileName, + includeMetadata: true, + metadata: metadata + ) else { + print("❌ ZIP圧縮に失敗しました") + return nil + } + + print("✅ ZIP作成完了: \(zipURL.path)") + return zipURL + + } catch { + print("❌ セッションデータの圧縮エラー: \(error)") + print("❌ エラー詳細: \(error.localizedDescription)") + return nil + } + } + + /// セッションデータを削除する(SwiftDataとCSVファイルの両方) + /// - Parameters: + /// - session: 削除するセッション + /// - swiftDataRepository: SwiftDataリポジトリ + /// - Returns: 削除が成功した場合はtrue、失敗した場合はfalse + func deleteSessionData( + _ session: SensingSession, + swiftDataRepository: SwiftDataRepositoryProtocol + ) async -> Bool { + do { + print("🗑️ セッションデータを削除中: \(session.name)") + + // 1. センシングデータディレクトリを特定 + guard let documentsDirectory = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + ).first else { + print("❌ Documentsディレクトリが見つかりません") + return false + } + + // 日付フォーマッターの設定 + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone.current + dateFormatter.dateFormat = "yyyyMMdd" + let dateString = dateFormatter.string(from: session.startTime) + + dateFormatter.dateFormat = "HHmmss" + let timeString = dateFormatter.string(from: session.startTime) + + // セッションディレクトリ名を構築 + let directoryName = if !session.name.isEmpty { + "\(timeString)-\(session.name)" + } else { + timeString + } + + // センシングデータディレクトリのパス + let sensingDirectory = documentsDirectory + .appendingPathComponent("sensing") + .appendingPathComponent(dateString) + .appendingPathComponent(directoryName) + + print("📁 削除対象ディレクトリ: \(sensingDirectory.path)") + + // 2. CSVファイルの削除 + var csvDeleted = false + if FileManager.default.fileExists(atPath: sensingDirectory.path) { + try FileManager.default.removeItem(at: sensingDirectory) + print("✅ CSVファイル削除完了") + csvDeleted = true + } else { + print("⚠️ CSVディレクトリが存在しません(既に削除済みの可能性)") + csvDeleted = true // 存在しない場合は削除済みとみなす + } + + // 3. SwiftDataからセッションを削除 + try await swiftDataRepository.deleteSensingSession(by: session.id) + print("✅ SwiftDataからセッション削除完了") + + print("✅ セッションデータ削除完了: \(session.name)") + return csvDeleted + + } catch { + print("❌ セッションデータの削除エラー: \(error)") + print("❌ エラー詳細: \(error.localizedDescription)") + return false + } + } +} diff --git a/UWBViewerSystem/Domain/Utils/AntennaAffineCalibration.swift b/UWBViewerSystem/Domain/Utils/AntennaAffineCalibration.swift index 6bbb03e..8991e12 100644 --- a/UWBViewerSystem/Domain/Utils/AntennaAffineCalibration.swift +++ b/UWBViewerSystem/Domain/Utils/AntennaAffineCalibration.swift @@ -59,6 +59,22 @@ struct AntennaAffineCalibration { } } + /// 品質情報付き測定点 + struct WeightedMeasurement { + let position: Point3D + let quality: SignalQuality + + /// 重みを計算(品質が高いほど重みが大きい) + var weight: Double { + let strengthWeight = self.quality.strength + let confidenceWeight = self.quality.confidenceLevel + let errorWeight = 1.0 / max(self.quality.errorEstimate, 0.01) + // 正規化して0-1の範囲に + let normalizedErrorWeight = min(errorWeight / 10.0, 1.0) + return strengthWeight * confidenceWeight * normalizedErrorWeight + } + } + /// 2x2行列 struct Matrix2x2 { let a11: Double @@ -85,6 +101,30 @@ struct AntennaAffineCalibration { let t: Point3D // 平行移動ベクトル } + /// IRLS設定 + struct IRLSConfig { + let maxIterations: Int + let tolerance: Double + let useCauchyLoss: Bool + + static let `default` = IRLSConfig( + maxIterations: 10, + tolerance: 1e-6, + useCauchyLoss: true + ) + } + + // MARK: - Properties + + /// IRLS設定 + private let irlsConfig: IRLSConfig + + // MARK: - Initialization + + init(irlsConfig: IRLSConfig = .default) { + self.irlsConfig = irlsConfig + } + // MARK: - Errors enum CalibrationError: LocalizedError { @@ -327,11 +367,15 @@ struct AntennaAffineCalibration { // デバッグ: 平均化された代表点を出力 print("📊 平均化された測定点(ソース):") for (index, point) in sourcePoints.enumerated() { - print(" Point\(index + 1): (\(String(format: "%.3f", point.x)), \(String(format: "%.3f", point.y)))") + print( + " Point\(index + 1): (\(String(format: "%.3f", point.x)), \(String(format: "%.3f", point.y)))" + ) } print("📍 真の位置(ターゲット):") for (index, point) in targetPoints.enumerated() { - print(" Point\(index + 1): (\(String(format: "%.3f", point.x)), \(String(format: "%.3f", point.y)))") + print( + " Point\(index + 1): (\(String(format: "%.3f", point.x)), \(String(format: "%.3f", point.y)))" + ) } // 共線性チェック: 3点が一直線上にないか確認 @@ -351,17 +395,25 @@ struct AntennaAffineCalibration { if crossProduct < 0.01 { throw CalibrationError.invalidData( - "測定点が一直線上に並んでいます(外積=\(String(format: "%.6f", crossProduct)))。" + - "タグを異なる位置に配置してください。" + "測定点が一直線上に並んでいます(外積=\(String(format: "%.6f", crossProduct)))。" + + "タグを異なる位置に配置してください。" ) } } - // アフィン変換を推定 - let transform = try estimateAffineTransform( - sourcePoints: sourcePoints, - targetPoints: targetPoints - ) + // アフィン変換を推定(Cauchy Loss IRLS使用時) + let transform: AffineTransform + if self.irlsConfig.useCauchyLoss { + transform = try self.estimateAffineTransformWithIRLS( + sourcePoints: sourcePoints, + targetPoints: targetPoints + ) + } else { + transform = try self.estimateAffineTransform( + sourcePoints: sourcePoints, + targetPoints: targetPoints + ) + } // 回転角度とスケールを抽出 let (angleDegrees, scaleFactors, _) = self.extractRotationAngle(from: transform.A) @@ -396,6 +448,152 @@ struct AntennaAffineCalibration { return config } + /// 品質情報付き測定データからアンテナの設定を推定(重み付け平均使用) + /// + /// - Parameters: + /// - weightedMeasurementsByTag: タグIDごとの品質情報付き測定データリスト + /// - truePositions: タグIDごとの真の座標(既知の正確な位置) + /// - Returns: 推定されたアンテナ設定 (x, y, angle) + /// - Throws: データが不足している場合や推定に失敗した場合 + func estimateAntennaConfigWithQuality( + weightedMeasurementsByTag: [String: [WeightedMeasurement]], + truePositions: [String: Point3D] + ) throws -> AntennaConfig { + // 共通のタグIDを取得 + let commonTags = Set(weightedMeasurementsByTag.keys).intersection(Set(truePositions.keys)) + guard commonTags.count >= 3 else { + throw CalibrationError.insufficientPoints(required: 3, provided: commonTags.count) + } + + // 各タグの測定点を重み付け平均して代表点を作成 + var sourcePoints: [Point3D] = [] + var targetPoints: [Point3D] = [] + var pointWeights: [Double] = [] + + for tagId in commonTags.sorted() { + guard let measurements = weightedMeasurementsByTag[tagId], + !measurements.isEmpty, + let truePos = truePositions[tagId] + else { + continue + } + + // 重み付け平均を計算 + let (avgPoint, avgWeight) = self.calculateWeightedAverage(measurements: measurements) + + sourcePoints.append(avgPoint) + targetPoints.append(truePos) + pointWeights.append(avgWeight) + } + + guard sourcePoints.count >= 3 else { + throw CalibrationError.insufficientPoints(required: 3, provided: sourcePoints.count) + } + + // デバッグ: 重み付け平均された代表点を出力 + print("📊 重み付け平均された測定点(ソース):") + for (index, point) in sourcePoints.enumerated() { + print( + " Point\(index + 1): (\(String(format: "%.3f", point.x)), \(String(format: "%.3f", point.y))) weight: \(String(format: "%.3f", pointWeights[index]))" + ) + } + + // 共線性チェック + try self.checkCollinearity(points: sourcePoints) + + // 重み付きアフィン変換を推定(Cauchy Loss IRLS使用) + let transform = try estimateWeightedAffineTransformWithIRLS( + sourcePoints: sourcePoints, + targetPoints: targetPoints, + weights: pointWeights + ) + + // 回転角度とスケールを抽出 + let (angleDegrees, scaleFactors, _) = self.extractRotationAngle(from: transform.A) + + // RMSEを計算 + let rmse = self.calculateRMSE( + sourcePoints: sourcePoints, + targetPoints: targetPoints, + transform: transform + ) + + let config = AntennaConfig( + x: transform.t.x, + y: transform.t.y, + angleDegrees: angleDegrees, + angleRadians: angleDegrees * .pi / 180.0, + scaleFactors: scaleFactors, + rmse: rmse + ) + + print(""" + 📡 品質重み付けキャリブレーション結果: + 位置: (\(String(format: "%.3f", config.x)), \(String(format: "%.3f", config.y))) m + 角度: \(String(format: "%.2f", config.angleDegrees))° + RMSE: \(String(format: "%.4f", config.rmse)) m + """) + + return config + } + + // MARK: - Weighted Average + + /// 品質情報付き測定点の重み付け平均を計算 + private func calculateWeightedAverage( + measurements: [WeightedMeasurement] + ) -> (point: Point3D, avgWeight: Double) { + guard !measurements.isEmpty else { + return (Point3D(x: 0, y: 0, z: 0), 0) + } + + var totalWeight: Double = 0 + var weightedX: Double = 0 + var weightedY: Double = 0 + + for m in measurements { + let w = m.weight + totalWeight += w + weightedX += w * m.position.x + weightedY += w * m.position.y + } + + // 重みの合計が0の場合は単純平均にフォールバック + if totalWeight < 1e-10 { + let avgX = measurements.map { $0.position.x }.reduce(0, +) / Double(measurements.count) + let avgY = measurements.map { $0.position.y }.reduce(0, +) / Double(measurements.count) + return (Point3D(x: avgX, y: avgY, z: 0), 0.5) + } + + let avgX = weightedX / totalWeight + let avgY = weightedY / totalWeight + let avgWeight = totalWeight / Double(measurements.count) + + return (Point3D(x: avgX, y: avgY, z: 0), avgWeight) + } + + /// 共線性チェック + private func checkCollinearity(points: [Point3D]) throws { + guard points.count >= 3 else { return } + + let p1 = points[0] + let p2 = points[1] + let p3 = points[2] + + let v1x = p2.x - p1.x + let v1y = p2.y - p1.y + let v2x = p3.x - p1.x + let v2y = p3.y - p1.y + let crossProduct = abs(v1x * v2y - v1y * v2x) + + if crossProduct < 0.01 { + throw CalibrationError.invalidData( + "測定点が一直線上に並んでいます(外積=\(String(format: "%.6f", crossProduct)))。" + + "タグを異なる位置に配置してください。" + ) + } + } + // MARK: - Private Methods /// 最小二乗法で線形方程式を解く(Accelerateフレームワークを使用) @@ -475,4 +673,264 @@ struct AntennaAffineCalibration { z: 0 ) } + + // MARK: - IRLS (Iteratively Reweighted Least Squares) with Cauchy Loss + + /// Cauchy Loss IRLSを使用してアフィン変換を推定 + /// 外れ値に対してロバストな推定を行う + private func estimateAffineTransformWithIRLS( + sourcePoints: [Point3D], + targetPoints: [Point3D] + ) throws -> AffineTransform { + let n = sourcePoints.count + guard n >= 3 else { + throw CalibrationError.insufficientPoints(required: 3, provided: n) + } + + // 初期重みは全て1.0 + var weights = [Double](repeating: 1.0, count: n) + + // 初期解を通常の最小二乗法で求める + var currentParams = try solveWeightedLeastSquares( + sourcePoints: sourcePoints, + targetPoints: targetPoints, + weights: weights + ) + + var previousLoss = Double.infinity + + // IRLS反復 + for iteration in 0.. 1e-10 else { + throw CalibrationError.singularMatrix + } + + return AffineTransform(A: A, t: t) + } + + /// 重み付きCauchy Loss IRLSを使用してアフィン変換を推定 + private func estimateWeightedAffineTransformWithIRLS( + sourcePoints: [Point3D], + targetPoints: [Point3D], + weights: [Double] + ) throws -> AffineTransform { + let n = sourcePoints.count + guard n >= 3 else { + throw CalibrationError.insufficientPoints(required: 3, provided: n) + } + + // 初期重みは品質重み + var currentWeights = weights + + // 初期解を重み付き最小二乗法で求める + var currentParams = try solveWeightedLeastSquares( + sourcePoints: sourcePoints, + targetPoints: targetPoints, + weights: currentWeights + ) + + var previousLoss = Double.infinity + + // IRLS反復 + for iteration in 0.. 1e-10 else { + throw CalibrationError.singularMatrix + } + + return AffineTransform(A: A, t: t) + } + + /// 重み付き最小二乗法でアフィン変換パラメータを解く + /// 正規方程式 (X^T W X) params = X^T W Y を解く + private func solveWeightedLeastSquares( + sourcePoints: [Point3D], + targetPoints: [Point3D], + weights: [Double] + ) throws -> [Double] { + let n = sourcePoints.count + + // デザイン行列Xと目標ベクトルYを構築 + // 各点につき2つの方程式(x方向とy方向) + let nrows = 2 * n + let ncols = 6 + + // X^T W X (6x6) と X^T W Y (6x1) を直接計算 + var XtWX = [[Double]](repeating: [Double](repeating: 0.0, count: ncols), count: ncols) + var XtWY = [Double](repeating: 0.0, count: ncols) + + for i in 0.. [Double] { + let n = vector.count + var augmented = matrix.enumerated().map { (i, row) in + row + [vector[i]] + } + + // 前進消去 + for col in 0.. maxVal { + maxVal = abs(augmented[row][col]) + maxRow = row + } + } + + // 行の入れ替え + if maxRow != col { + augmented.swapAt(col, maxRow) + } + + // ピボットが0に近い場合はエラー + guard abs(augmented[col][col]) > 1e-12 else { + throw CalibrationError.singularMatrix + } + + // ピボット行を正規化 + let pivot = augmented[col][col] + for j in col...(n) { + augmented[col][j] /= pivot + } + + // 他の行から消去 + for row in 0.. URL { + // Documentsディレクトリを取得(ファイルアプリから見えるようにするため) + guard let documentsDirectory = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + ).first else { + throw ExportError.fileCreationFailed("Documentsディレクトリが見つかりません") + } + + // 日付フォーマッターの設定 + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone.current + + // 日付ディレクトリ名 (yyyymmdd) + dateFormatter.dateFormat = "yyyyMMdd" + let dateString = dateFormatter.string(from: startTime) + + // 時刻ディレクトリ名 (hhmmss) + dateFormatter.dateFormat = "HHmmss" + let timeString = dateFormatter.string(from: startTime) + + // ディレクトリ名を決定(カスタム名がある場合は「hhmmss-customName」形式) + print("🔍 createSessionDirectory - customName: '\(customName)' (空?: \(customName.isEmpty))") + let directoryName = if !customName.isEmpty { + "\(timeString)-\(customName)" + } else { + timeString + } + print("🔍 決定されたディレクトリ名: '\(directoryName)'") + + // ディレクトリパスを構築 + let sessionDirectory = documentsDirectory + .appendingPathComponent("sensing") + .appendingPathComponent(dateString) + .appendingPathComponent(directoryName) + + // ディレクトリを作成 + try FileManager.default.createDirectory( + at: sessionDirectory, + withIntermediateDirectories: true, + attributes: nil + ) + + print("✅ セッションディレクトリを作成: \(sessionDirectory.path)") + print("📁 Documentsディレクトリ: \(documentsDirectory.path)") + print("📁 相対パス: sensing/\(dateString)/\(directoryName)") + + return sessionDirectory + } + + // MARK: - 生データCSVエクスポート + + /// 生データ(Android側からの受信データ)をCSVとしてエクスポート + /// + /// CSVフォーマット: + /// ``` + /// timestamp,deviceName,antennaId,elevation,azimuth,distance,nlos,rssi,seqCount + /// 1699876543210,Device1,antenna1,45.5,120.0,5.23,0,-65.5,123 + /// ``` + /// + /// - Parameters: + /// - realtimeDataList: エクスポートするリアルタイムデータのリスト + /// - directoryURL: 保存先ディレクトリのURL + /// - fileName: ファイル名(デフォルト: "raw_data.csv") + /// - Returns: 生成されたCSVファイルのURL + /// - Throws: データが空、またはファイル作成/書き込みに失敗した場合 + static func exportRawDataToCSV( + realtimeDataList: [RealtimeData], + directoryURL: URL, + fileName: String = "raw_data.csv" + ) throws -> URL { + guard !realtimeDataList.isEmpty else { + throw ExportError.noDataToExport + } + + // CSVヘッダー + var csvContent = + "timestamp,deviceName,antennaId,elevation,azimuth,distance,nlos,rssi,seqCount\n" + + // データ行 + for data in realtimeDataList { + let row = [ + String(data.timestamp), + data.deviceName, + data.antennaId, + String(format: "%.6f", data.elevation), + String(format: "%.6f", data.azimuth), + String(format: "%.6f", data.distance), + String(data.nlos), + String(format: "%.2f", data.rssi), + String(data.seqCount), + ].joined(separator: ",") + + csvContent += row + "\n" + } + + // ファイルに書き込み + let fileURL = directoryURL.appendingPathComponent(fileName) + return try self.writeCSV(content: csvContent, to: fileURL) + } + + // MARK: - グローバル座標データCSVエクスポート + + /// グローバル座標変換後のデータをCSVとしてエクスポート + /// + /// CSVフォーマット: + /// ``` + /// timestamp,deviceName,antennaId,global_x,global_y,global_z,elevation,azimuth,distance,nlos,rssi + /// 1699876543210,Device1,antenna1,14.123,18.456,0.0,45.5,120.0,5.23,0,-65.5 + /// ``` + /// + /// - Parameters: + /// - realtimeDataList: エクスポートするリアルタイムデータのリスト + /// - globalCoordinates: デバイス名をキーとしたグローバル座標の辞書 + /// - directoryURL: 保存先ディレクトリのURL + /// - fileName: ファイル名(デフォルト: "global_coordinates.csv") + /// - Returns: 生成されたCSVファイルのURL + /// - Throws: データが空、またはファイル作成/書き込みに失敗した場合 + static func exportGlobalCoordinateDataToCSV( + realtimeDataList: [RealtimeData], + globalCoordinates: [String: Point3D], + directoryURL: URL, + fileName: String = "global_coordinates.csv" + ) throws -> URL { + guard !realtimeDataList.isEmpty else { + throw ExportError.noDataToExport + } + + // CSVヘッダー + var csvContent = + "timestamp,deviceName,antennaId,global_x,global_y,global_z,elevation,azimuth,distance,nlos,rssi\n" + + // データ行 + for data in realtimeDataList { + // グローバル座標を取得(存在しない場合は0.0を使用) + let globalCoord = globalCoordinates[data.deviceName] ?? Point3D(x: 0, y: 0, z: 0) + + let row = [ + String(data.timestamp), + data.deviceName, + data.antennaId, + String(format: "%.6f", globalCoord.x), + String(format: "%.6f", globalCoord.y), + String(format: "%.6f", globalCoord.z), + String(format: "%.6f", data.elevation), + String(format: "%.6f", data.azimuth), + String(format: "%.6f", data.distance), + String(data.nlos), + String(format: "%.2f", data.rssi), + ].joined(separator: ",") + + csvContent += row + "\n" + } + + // ファイルに書き込み + let fileURL = directoryURL.appendingPathComponent(fileName) + return try self.writeCSV(content: csvContent, to: fileURL) + } + + // MARK: - フィルタリング後データCSVエクスポート + + /// フィルタリング後(移動平均適用後)のデータをCSVとしてエクスポート + /// + /// CSVフォーマット: + /// ``` + /// timestamp,deviceName,antennaId,filtered_x,filtered_y,filtered_z,original_x,original_y,original_z + /// 1699876543210,Device1,antenna1,14.123,18.456,0.0,14.200,18.500,0.0 + /// ``` + /// + /// - Parameters: + /// - realtimeDataList: エクスポートするリアルタイムデータのリスト + /// - globalCoordinates: デバイス名をキーとしたグローバル座標の辞書(元データ) + /// - processor: データ処理を行うSensorDataProcessor + /// - directoryURL: 保存先ディレクトリのURL + /// - fileName: ファイル名(デフォルト: "filtered_data.csv") + /// - Returns: 生成されたCSVファイルのURL + /// - Throws: データが空、またはファイル作成/書き込みに失敗した場合 + static func exportFilteredDataToCSV( + realtimeDataList: [RealtimeData], + globalCoordinates: [String: Point3D], + processor: SensorDataProcessor, + directoryURL: URL, + fileName: String = "filtered_data.csv" + ) throws -> URL { + guard !realtimeDataList.isEmpty else { + throw ExportError.noDataToExport + } + + // デバイスごとにグループ化 + let groupedData = Dictionary(grouping: realtimeDataList) { $0.deviceName } + + // CSVヘッダー + var csvContent = + "timestamp,deviceName,antennaId,filtered_x,filtered_y,filtered_z,original_x,original_y,original_z\n" + + // 各デバイスのデータに移動平均フィルタを適用 + for (deviceName, deviceData) in groupedData.sorted(by: { $0.key < $1.key }) { + // タイムスタンプでソート + let sortedData = deviceData.sorted { $0.timestamp < $1.timestamp } + + // グローバル座標のリストを作成 + let coordinates = sortedData.compactMap { globalCoordinates[$0.deviceName] } + + guard !coordinates.isEmpty else { continue } + + // 移動平均フィルタを適用 + let filteredCoordinates = processor.applyMovingAverageToPoints(coordinates) + + // CSV行を作成 + for (index, data) in sortedData.enumerated() { + guard index < filteredCoordinates.count else { break } + + let originalCoord = coordinates[index] + let filteredCoord = filteredCoordinates[index] + + let row = [ + String(data.timestamp), + deviceName, + data.antennaId, + String(format: "%.6f", filteredCoord.x), + String(format: "%.6f", filteredCoord.y), + String(format: "%.6f", filteredCoord.z), + String(format: "%.6f", originalCoord.x), + String(format: "%.6f", originalCoord.y), + String(format: "%.6f", originalCoord.z), + ].joined(separator: ",") + + csvContent += row + "\n" + } + } + + // ファイルに書き込み + let fileURL = directoryURL.appendingPathComponent(fileName) + return try self.writeCSV(content: csvContent, to: fileURL) + } + + // MARK: - 統合座標データCSVエクスポート + + /// 統合座標データをCSVとしてエクスポート + /// + /// CSVフォーマット: + /// ``` + /// timestamp,tagId,integrated_x,integrated_y,integrated_z,confidence,los_count,nlos_count,has_nlos_only + /// 1699876543210,tag1,14.123,18.456,0.0,0.95,3,1,false + /// ``` + /// + /// - Parameters: + /// - integratedCoordinateHistory: エクスポートする統合座標履歴のリスト + /// - directoryURL: 保存先ディレクトリのURL + /// - fileName: ファイル名(デフォルト: "integrated_coordinates.csv") + /// - Returns: 生成されたCSVファイルのURL + /// - Throws: データが空、またはファイル作成/書き込みに失敗した場合 + static func exportIntegratedCoordinateDataToCSV( + integratedCoordinateHistory: [IntegratedCoordinateRecord], + directoryURL: URL, + fileName: String = "integrated_coordinates.csv" + ) throws -> URL { + guard !integratedCoordinateHistory.isEmpty else { + throw ExportError.noDataToExport + } + + // CSVヘッダー + var csvContent = + "timestamp,tagId,integrated_x,integrated_y,integrated_z,confidence,los_count,nlos_count,has_nlos_only\n" + + // データ行(タイムスタンプでソート) + let sortedHistory = integratedCoordinateHistory.sorted { $0.timestamp < $1.timestamp } + + for record in sortedHistory { + let row = [ + String(record.timestamp), + record.tagId, + String(format: "%.6f", record.x), + String(format: "%.6f", record.y), + String(format: "%.6f", record.z), + String(format: "%.4f", record.confidence), + String(record.losCount), + String(record.nlosCount), + String(record.hasNLOSOnly), + ].joined(separator: ",") + + csvContent += row + "\n" + } + + // ファイルに書き込み + let fileURL = directoryURL.appendingPathComponent(fileName) + return try self.writeCSV(content: csvContent, to: fileURL) + } + + // MARK: - Helper Methods + + /// CSV内容をファイルに書き込む + /// + /// - Parameters: + /// - content: CSVファイルの内容 + /// - fileURL: 保存先ファイルのURL + /// - Returns: 保存されたファイルのURL + /// - Throws: ファイル書き込みに失敗した場合 + private static func writeCSV(content: String, to fileURL: URL) throws -> URL { + do { + try content.write(to: fileURL, atomically: true, encoding: .utf8) + print("✅ CSVファイルを保存しました: \(fileURL.path)") + return fileURL + } catch { + throw ExportError.writeFailed("ファイル書き込みに失敗: \(error.localizedDescription)") + } + } + + // MARK: - ファイル共有機能 + + /// エクスポートしたCSVファイルを共有するためのActivityViewControllerを作成 + /// + /// - Parameter fileURL: 共有するCSVファイルのURL + /// - Returns: UIActivityViewController(UIKit環境のみ) + #if canImport(UIKit) + #if os(iOS) + static func createShareViewController(for fileURL: URL) -> UIActivityViewController + { + let activityViewController = UIActivityViewController( + activityItems: [fileURL], + applicationActivities: nil + ) + return activityViewController + } + #endif + #endif + + // MARK: - 全データエクスポート + + /// 生データ、グローバル座標データ、フィルタリング後データの全てをエクスポート + /// + /// - Parameters: + /// - realtimeDataList: エクスポートするリアルタイムデータのリスト + /// - globalCoordinates: デバイス名をキーとしたグローバル座標の辞書 + /// - startTime: センシング開始時刻 + /// - customName: カスタムディレクトリ名(空文字列の場合はデフォルト) + /// - processor: データ処理を行うSensorDataProcessor(オプション) + /// - Returns: (セッションディレクトリURL, 生データURL, グローバル座標URL, フィルタリング後データURL) + /// - Throws: データが空、またはファイル作成/書き込みに失敗した場合 + static func exportAllData( + realtimeDataList: [RealtimeData], + globalCoordinates: [String: Point3D], + startTime: Date, + customName: String = "", + processor: SensorDataProcessor = SensorDataProcessor() + ) throws -> ( + sessionDirectory: URL, + rawDataURL: URL, + globalCoordinateURL: URL, + filteredDataURL: URL + ) { + // セッションディレクトリを作成 + let sessionDirectory = try createSessionDirectory(startTime: startTime, customName: customName) + + // 生データをエクスポート + let rawDataURL = try exportRawDataToCSV( + realtimeDataList: realtimeDataList, + directoryURL: sessionDirectory + ) + + // グローバル座標データをエクスポート + let globalCoordinateURL = try exportGlobalCoordinateDataToCSV( + realtimeDataList: realtimeDataList, + globalCoordinates: globalCoordinates, + directoryURL: sessionDirectory + ) + + // フィルタリング後データをエクスポート + let filteredDataURL = try exportFilteredDataToCSV( + realtimeDataList: realtimeDataList, + globalCoordinates: globalCoordinates, + processor: processor, + directoryURL: sessionDirectory + ) + + print("✅ 全センシングデータのエクスポート完了") + print(" セッションディレクトリ: \(sessionDirectory.path)") + + return ( + sessionDirectory: sessionDirectory, + rawDataURL: rawDataURL, + globalCoordinateURL: globalCoordinateURL, + filteredDataURL: filteredDataURL + ) + } +} diff --git a/UWBViewerSystem/Domain/Utils/SensorDataProcessor.swift b/UWBViewerSystem/Domain/Utils/SensorDataProcessor.swift index 8513bf8..8cd5d19 100644 --- a/UWBViewerSystem/Domain/Utils/SensorDataProcessor.swift +++ b/UWBViewerSystem/Domain/Utils/SensorDataProcessor.swift @@ -22,24 +22,36 @@ public struct SensorDataProcessingConfig { /// nLoSフィルタを適用するか public let filterNLOS: Bool + /// IQR外れ値検出を適用するか + public let useIQROutlierDetection: Bool + + /// IQR係数(デフォルト1.5) + public let iqrMultiplier: Double + /// デフォルト設定 public static let `default` = SensorDataProcessingConfig( firstTrim: 20, endTrim: 20, movingAverageWindowSize: 10, - filterNLOS: false + filterNLOS: false, + useIQROutlierDetection: true, + iqrMultiplier: 1.5 ) public init( firstTrim: Int = 20, endTrim: Int = 20, movingAverageWindowSize: Int = 10, - filterNLOS: Bool = false + filterNLOS: Bool = false, + useIQROutlierDetection: Bool = true, + iqrMultiplier: Double = 1.5 ) { self.firstTrim = firstTrim self.endTrim = endTrim self.movingAverageWindowSize = movingAverageWindowSize self.filterNLOS = filterNLOS + self.useIQROutlierDetection = useIQROutlierDetection + self.iqrMultiplier = iqrMultiplier } } @@ -61,10 +73,14 @@ public struct SensorDataProcessor { // 1. データのトリミング let trimmed = self.trimData(observations) - // 2. nLoSフィルタリング(設定で有効な場合) - let filtered = self.config.filterNLOS ? self.filterNLOS(trimmed) : trimmed + // 2. IQR外れ値検出(設定で有効な場合) + let outlierRemoved = + self.config.useIQROutlierDetection ? self.removeOutliersUsingIQR(trimmed) : trimmed + + // 3. nLoSフィルタリング(設定で有効な場合) + let filtered = self.config.filterNLOS ? self.filterNLOS(outlierRemoved) : outlierRemoved - // 3. 移動平均フィルタを適用 + // 4. 移動平均フィルタを適用 let smoothed = self.applyMovingAverage(filtered) return smoothed @@ -121,6 +137,53 @@ public struct SensorDataProcessor { observations.filter { $0.quality.isLineOfSight } } + /// IQR(四分位範囲)法を使用して外れ値を除去する + /// - Parameter observations: 観測データポイントのリスト + /// - Returns: 外れ値を除去した観測データポイントのリスト + private func removeOutliersUsingIQR(_ observations: [ObservationPoint]) -> [ObservationPoint] { + guard observations.count >= 4 else { + // IQR計算には最低4点必要 + return observations + } + + // X座標とY座標それぞれでIQRを計算 + let xValues = observations.map { $0.position.x }.sorted() + let yValues = observations.map { $0.position.y }.sorted() + + let xBounds = self.calculateIQRBounds(xValues) + let yBounds = self.calculateIQRBounds(yValues) + + // XとY両方が範囲内のデータのみを保持 + return observations.filter { obs in + let xInRange = obs.position.x >= xBounds.lower && obs.position.x <= xBounds.upper + let yInRange = obs.position.y >= yBounds.lower && obs.position.y <= yBounds.upper + return xInRange && yInRange + } + } + + /// IQR法による上下限値を計算する + /// - Parameter sortedValues: ソート済みの値の配列 + /// - Returns: 下限値と上限値のタプル + private func calculateIQRBounds(_ sortedValues: [Double]) -> (lower: Double, upper: Double) { + let count = sortedValues.count + + // Q1(第1四分位数)とQ3(第3四分位数)を計算 + let q1Index = count / 4 + let q3Index = (count * 3) / 4 + + let q1 = sortedValues[q1Index] + let q3 = sortedValues[q3Index] + + // IQR(四分位範囲) + let iqr = q3 - q1 + + // 上下限を計算(係数はconfig.iqrMultiplier) + let lowerBound = q1 - self.config.iqrMultiplier * iqr + let upperBound = q3 + self.config.iqrMultiplier * iqr + + return (lowerBound, upperBound) + } + /// 移動平均フィルタを適用する /// - Parameter observations: 観測データポイントのリスト /// - Returns: 移動平均適用後の観測データポイントのリスト diff --git a/UWBViewerSystem/Info.plist b/UWBViewerSystem/Info.plist index 90c268d..5cd2d9f 100644 --- a/UWBViewerSystem/Info.plist +++ b/UWBViewerSystem/Info.plist @@ -9,5 +9,11 @@ UIRequiresPersistentWiFi + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + UISupportsDocumentBrowser + diff --git a/UWBViewerSystem/Presentation/Components/AntennaMarker.swift b/UWBViewerSystem/Presentation/Components/AntennaMarker.swift index 3db51d8..b6efe82 100644 --- a/UWBViewerSystem/Presentation/Components/AntennaMarker.swift +++ b/UWBViewerSystem/Presentation/Components/AntennaMarker.swift @@ -50,7 +50,7 @@ struct AntennaMarker: View { ZStack { // センサー範囲を示す扇形(選択時のみ表示) if self.isSelected, let range = sensorRange { - SensorRangeView(rotation: self.antenna.rotation, sensorRange: range) + SensorRangeView(rotation: self.antenna.displayRotation, sensorRange: range) .frame(width: range, height: range) .allowsHitTesting(false) } @@ -67,15 +67,15 @@ struct AntennaMarker: View { Image(systemName: "antenna.radiowaves.left.and.right") .font(.system(size: self.displaySize * 0.5)) .foregroundColor(.white) - .rotationEffect(.degrees(self.antenna.rotation)) + .rotationEffect(.degrees(self.antenna.displayRotation)) // 向きを示す矢印 - if (self.showRotationControls || self.showRotationControlsState) && self.antenna.rotation != 0 { + if (self.showRotationControls || self.showRotationControlsState) && self.antenna.displayRotation != 0 { Image(systemName: "arrow.up") .font(.system(size: self.displaySize * 0.3)) .foregroundColor(.yellow) .offset(y: -self.displaySize * 0.6) - .rotationEffect(.degrees(self.antenna.rotation)) + .rotationEffect(.degrees(self.antenna.displayRotation)) } } .onTapGesture(count: self.isDraggable ? 2 : 1) { @@ -156,6 +156,14 @@ struct AntennaDisplayData { let rotation: Double let color: Color? + /// 表示用の回転角度 + /// 座標変換では反時計回り(数学的座標系、北が0°)を使用するが、 + /// SwiftUIのrotationEffectは時計回りで右(東)が0°のため、 + /// 符号反転と-90°補正が必要 + var displayRotation: Double { + -self.rotation + 90.0 + } + init(id: String, name: String, rotation: Double = 0.0, color: Color? = nil) { self.id = id self.name = name @@ -237,6 +245,16 @@ struct AntennaRotationControl: View { let rotation: Double let onRotationChanged: (Double) -> Void + /// 表示用の角度(displayRotationと同じ変換) + private var displayAngle: Double { + -self.rotation + 90.0 + } + + /// 表示角度から内部角度に変換 + private func displayToInternal(_ displayAngle: Double) -> Double { + -displayAngle + 90.0 + } + var body: some View { VStack(spacing: 8) { Text("向き調整") @@ -244,18 +262,20 @@ struct AntennaRotationControl: View { .fontWeight(.medium) HStack(spacing: 12) { - Button(action: { self.onRotationChanged(self.rotation - 15) }) { + // 表示座標系で反時計回り = 内部座標系で時計回り + Button(action: { self.onRotationChanged(self.rotation + 15) }) { Image(systemName: "arrow.counterclockwise") .font(.caption) } .buttonStyle(.borderless) - Text("\(Int(self.rotation))°") + Text("\(Int(self.displayAngle))°") .font(.caption) .fontDesign(.monospaced) - .frame(width: 40) + .frame(width: 50) - Button(action: { self.onRotationChanged(self.rotation + 15) }) { + // 表示座標系で時計回り = 内部座標系で反時計回り + Button(action: { self.onRotationChanged(self.rotation - 15) }) { Image(systemName: "arrow.clockwise") .font(.caption) } @@ -265,7 +285,7 @@ struct AntennaRotationControl: View { HStack(spacing: 8) { ForEach([0, 90, 180, 270], id: \.self) { angle in Button("\(angle)°") { - self.onRotationChanged(Double(angle)) + self.onRotationChanged(self.displayToInternal(Double(angle))) } .font(.caption2) .buttonStyle(.borderless) diff --git a/UWBViewerSystem/Presentation/Components/ConnectionRecoveryView.swift b/UWBViewerSystem/Presentation/Components/ConnectionRecoveryView.swift new file mode 100644 index 0000000..e3d5a0b --- /dev/null +++ b/UWBViewerSystem/Presentation/Components/ConnectionRecoveryView.swift @@ -0,0 +1,226 @@ +import SwiftUI + +/// 接続復旧画面 +/// +/// ニアバイコネクションの接続が切れた際に表示される復旧用の画面です。 +/// 接続状態の可視化と自動再接続機能を提供します。 +struct ConnectionRecoveryView: View { + @ObservedObject var connectionUsecase: ConnectionManagementUsecase + @Binding var isPresented: Bool + @State private var isReconnecting = false + @State private var reconnectAttempt = 0 + private let maxReconnectAttempts = 3 + + var body: some View { + VStack(spacing: 24) { + // ヘッダー + self.headerSection + + // 接続状態表示 + self.connectionStatusSection + + // アクションボタン + self.actionButtons + + Spacer() + } + .padding() + .frame(maxWidth: 500) + #if os(iOS) + .background(Color(UIColor.systemBackground)) + #elseif os(macOS) + .background(Color(NSColor.controlBackgroundColor)) + #endif + .cornerRadius(16) + .shadow(radius: 10) + } + + // MARK: - Header Section + + private var headerSection: some View { + VStack(spacing: 12) { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.system(size: 60)) + .foregroundColor(.orange) + + Text("接続が切断されました") + .font(.title2) + .fontWeight(.bold) + + Text("デバイスとの接続が失われました。\n再接続を試みてください。") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + + // MARK: - Connection Status Section + + private var connectionStatusSection: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Image(systemName: "info.circle.fill") + .foregroundColor(.blue) + Text("接続情報") + .font(.headline) + } + + Divider() + + // 接続済みデバイス数 + HStack { + Text("接続済みデバイス:") + .foregroundColor(.secondary) + Spacer() + Text("\(self.connectionUsecase.getConnectedDeviceCount())台") + .fontWeight(.medium) + .foregroundColor( + self.connectionUsecase.getConnectedDeviceCount() > 0 ? .green : .red + ) + } + + // 接続状態 + HStack { + Text("接続状態:") + .foregroundColor(.secondary) + Spacer() + Text(self.connectionUsecase.connectState) + .fontWeight(.medium) + .foregroundColor(.orange) + } + + // 再接続試行回数 + if self.isReconnecting { + HStack { + Text("再接続試行:") + .foregroundColor(.secondary) + Spacer() + Text("\(self.reconnectAttempt)/\(self.maxReconnectAttempts)") + .fontWeight(.medium) + .foregroundColor(.blue) + } + } + } + .padding() + .background(Color.primary.opacity(0.05)) + .cornerRadius(12) + } + + // MARK: - Action Buttons + + private var actionButtons: some View { + VStack(spacing: 12) { + // 自動再接続ボタン + Button(action: { + self.attemptAutoReconnect() + }) { + HStack { + if self.isReconnecting { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + #if os(iOS) + .tint(.white) + #endif + } else { + Image(systemName: "arrow.clockwise") + } + Text(self.isReconnecting ? "再接続中..." : "再接続") + } + .frame(maxWidth: .infinity) + .padding() + .background(self.isReconnecting ? Color.gray : Color.blue) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(self.isReconnecting) + + // キャンセルボタン + Button(action: { + self.isPresented = false + }) { + HStack { + Image(systemName: "xmark.circle") + Text("キャンセル") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.red.opacity(0.1)) + .foregroundColor(.red) + .cornerRadius(12) + } + } + } + + // MARK: - Private Methods + + /// 自動再接続を試行 + private func attemptAutoReconnect() { + self.isReconnecting = true + self.reconnectAttempt = 0 + + Task { + for attempt in 1...self.maxReconnectAttempts { + await MainActor.run { + self.reconnectAttempt = attempt + } + + print("🔄 再接続試行 \(attempt)/\(self.maxReconnectAttempts)") + + // 既存の接続をクリア + await MainActor.run { + self.connectionUsecase.resetAll() + } + + // 少し待機 + try? await Task.sleep(nanoseconds: 1_000_000_000) + + // エラーフラグをクリア + await MainActor.run { + self.connectionUsecase.hasConnectionError = false + self.connectionUsecase.lastDisconnectedDevice = nil + } + + // 再度広告と検索を開始 + await MainActor.run { + self.connectionUsecase.startAdvertising() + self.connectionUsecase.startDiscovery() + } + + // 接続確立を待機(最大5秒) + for _ in 0..<10 { + try? await Task.sleep(nanoseconds: 500_000_000) + + if await MainActor.run(body: { + self.connectionUsecase.hasConnectedDevices() + }) { + print("✅ 再接続成功") + await MainActor.run { + self.isReconnecting = false + self.isPresented = false + } + return + } + } + + // バックオフ:次の試行まで待機時間を増やす + let backoffDelay = UInt64(attempt * 2_000_000_000) // 2秒, 4秒, 6秒... + try? await Task.sleep(nanoseconds: backoffDelay) + } + + // すべての試行が失敗 + await MainActor.run { + self.isReconnecting = false + print("❌ 再接続失敗: 最大試行回数に達しました") + } + } + } +} + +// MARK: - Preview + +#Preview { + ConnectionRecoveryView( + connectionUsecase: ConnectionManagementUsecase.shared, + isPresented: .constant(true) + ) +} diff --git a/UWBViewerSystem/Presentation/Router/NavigationRouter.swift b/UWBViewerSystem/Presentation/Router/NavigationRouter.swift index 6af8dec..afad822 100644 --- a/UWBViewerSystem/Presentation/Router/NavigationRouter.swift +++ b/UWBViewerSystem/Presentation/Router/NavigationRouter.swift @@ -54,19 +54,19 @@ struct NavigationRouter: View { // センシングフロー case .floorMapSetting: FloorMapSettingView() - case .antennaConfiguration: - AntennaPositioningView() - case .systemCalibration: - AutoAntennaCalibrationView() + case .antennaConfiguration(let floorMapId): + AntennaPositioningView(floorMapId: floorMapId) + case .systemCalibration(let floorMapId): + AutoAntennaCalibrationView(floorMapId: floorMapId) case .trajectoryView: TrajectoryView() case .welcomePage: WelcomeView() // メイン機能画面 - case .pairingSettingPage: - PairingSettingView() - case .dataCollectionPage: - DataCollectionView() + case .pairingSettingPage(let floorMapId): + PairingSettingView(floorMapId: floorMapId) + case .dataCollectionPage(let floorMapId): + DataCollectionView(floorMapId: floorMapId) case .dataDisplayPage: DataDisplayView() case .mainTabView: diff --git a/UWBViewerSystem/Presentation/Router/NavigationRouterModel.swift b/UWBViewerSystem/Presentation/Router/NavigationRouterModel.swift index 0c4b946..6c7343c 100644 --- a/UWBViewerSystem/Presentation/Router/NavigationRouterModel.swift +++ b/UWBViewerSystem/Presentation/Router/NavigationRouterModel.swift @@ -40,12 +40,19 @@ class NavigationRouterModel: ObservableObject { self.path.removeLast(self.path.count) } - /// 指定されたルートに直接遷移する(スタックをクリアしてから) - func navigateTo(_ route: Route) { - print("🔄 NavigationRouter.navigateTo(\(route)) called") - print("🔄 Current path count before reset: \(self.path.count)") - self.reset() - print("🔄 Path reset, count: \(self.path.count)") + /// 指定されたルートに直接遷移する + /// - Parameters: + /// - route: 遷移先のルート + /// - resetStack: スタックをクリアしてから遷移するかどうか(デフォルト: false) + func navigateTo(_ route: Route, resetStack: Bool = false) { + print("🔄 NavigationRouter.navigateTo(\(route), resetStack: \(resetStack)) called") + print("🔄 Current path count: \(self.path.count)") + + if resetStack { + self.reset() + print("🔄 Path reset, count: \(self.path.count)") + } + self.currentRoute = route print("🔄 Current route updated to: \(self.currentRoute)") self.push(route) diff --git a/UWBViewerSystem/Presentation/Router/Router.swift b/UWBViewerSystem/Presentation/Router/Router.swift index 3c2d31f..72a5092 100644 --- a/UWBViewerSystem/Presentation/Router/Router.swift +++ b/UWBViewerSystem/Presentation/Router/Router.swift @@ -14,13 +14,13 @@ enum Route: Hashable { // センシングフロー case floorMapSetting // フロアマップ設定 - case antennaConfiguration // アンテナ設定(向き設定機能付き) - case systemCalibration // システムキャリブレーション(自動アンテナキャリブレーション) + case antennaConfiguration(floorMapId: String) // アンテナ設定(向き設定機能付き) + case systemCalibration(floorMapId: String) // システムキャリブレーション(自動アンテナキャリブレーション) case trajectoryView // センシングデータの軌跡確認 // メイン機能画面 - case pairingSettingPage - case dataCollectionPage + case pairingSettingPage(floorMapId: String) + case dataCollectionPage(floorMapId: String) case dataDisplayPage case mainTabView } diff --git a/UWBViewerSystem/Presentation/Router/SensingFlowNavigator.swift b/UWBViewerSystem/Presentation/Router/SensingFlowNavigator.swift index 0a75118..bf0a79c 100644 --- a/UWBViewerSystem/Presentation/Router/SensingFlowNavigator.swift +++ b/UWBViewerSystem/Presentation/Router/SensingFlowNavigator.swift @@ -15,6 +15,7 @@ class SensingFlowNavigator: ObservableObject { @Published var isFlowCompleted: Bool = false @Published var completedSteps: Set = [] @Published var lastError: String? + @Published var currentFloorMapId: String? private var router: NavigationRouterModel private let preferenceRepository: PreferenceRepositoryProtocol @@ -41,8 +42,18 @@ class SensingFlowNavigator: ObservableObject { } /// 次のステップに進む - func proceedToNextStep() { + func proceedToNextStep(floorMapId: String? = nil) { print("🚀 proceedToNextStep: Current step = \(self.currentStep.rawValue)") + print("🔍 DEBUG: Received floorMapId parameter = '\(floorMapId ?? "nil")'") + print("🔍 DEBUG: Current self.currentFloorMapId = '\(self.currentFloorMapId)'") + + // floorMapIdが指定されている場合は保存 + if let floorMapId { + self.currentFloorMapId = floorMapId + #if DEBUG + print("📍 proceedToNextStep: FloorMapId set to \(floorMapId)") + #endif + } // 現在のステップの完了条件をチェック guard self.canProceedFromCurrentStep() else { @@ -67,13 +78,30 @@ class SensingFlowNavigator: ObservableObject { let nextStep = SensingFlowStep.allCases[currentIndex + 1] print("➡️ proceedToNextStep: Moving to next step = \(nextStep.rawValue)") + // キャリブレーションステップをスキップする場合 + if nextStep == .systemCalibration && UserDefaults.standard.bool(forKey: "skipCalibration") { + print("🔧 キャリブレーションスキップ設定が有効: キャリブレーションステップをスキップします") + print("🔍 DEBUG: currentFloorMapId = '\(self.currentFloorMapId)'") + self.currentStep = nextStep + self.markStepAsCompleted(nextStep) + self.updateProgress() + self.saveFlowState() + + // 再帰的に次のステップ(センシング実行)に進む + // currentFloorMapIdを明示的に渡す + print("🔍 DEBUG: Calling proceedToNextStep with floorMapId = '\(self.currentFloorMapId)'") + self.proceedToNextStep(floorMapId: self.currentFloorMapId) + return + } + self.currentStep = nextStep self.updateProgress() self.saveFlowState() // ルーターを使用して実際の画面遷移を実行 - print("🔄 proceedToNextStep: Navigating to route = \(nextStep.route)") - self.router.navigateTo(nextStep.route) + let route = nextStep.route(floorMapId: self.currentFloorMapId) + print("🔄 proceedToNextStep: Pushing route = \(route)") + self.router.push(route) print("✅ proceedToNextStep: Navigation completed") } @@ -89,14 +117,16 @@ class SensingFlowNavigator: ObservableObject { self.currentStep = previousStep self.updateProgress() - self.router.navigateTo(previousStep.route) + print("🔙 goToPreviousStep: Popping to previous step") + self.router.pop() } /// 指定したステップに直接ジャンプ func jumpToStep(_ step: SensingFlowStep) { self.currentStep = step self.updateProgress() - self.router.navigateTo(step.route) + let route = step.route(floorMapId: self.currentFloorMapId) + self.router.navigateTo(route, resetStack: true) } /// フローを最初から開始 @@ -104,7 +134,8 @@ class SensingFlowNavigator: ObservableObject { self.currentStep = .floorMapSetting self.isFlowCompleted = false self.updateProgress() - self.router.navigateTo(self.currentStep.route) + let route = self.currentStep.route(floorMapId: self.currentFloorMapId) + self.router.navigateTo(route, resetStack: true) } /// フローを完了 @@ -228,19 +259,22 @@ enum SensingFlowStep: String, CaseIterable { case sensingExecution = "センシング実行" case dataViewer = "データ閲覧" - /// 各ステップに対応するRoute - var route: Route { + /// 各ステップに対応するRouteを取得 + func route(floorMapId: String?) -> Route { + // floorMapIdが必要なRouteの場合、デフォルト値を使用 + let mapId = floorMapId ?? "" + switch self { case .floorMapSetting: return .floorMapSetting case .antennaConfiguration: - return .antennaConfiguration + return .antennaConfiguration(floorMapId: mapId) case .devicePairing: - return .pairingSettingPage + return .pairingSettingPage(floorMapId: mapId) case .systemCalibration: - return .systemCalibration + return .systemCalibration(floorMapId: mapId) case .sensingExecution: - return .dataCollectionPage + return .dataCollectionPage(floorMapId: mapId) case .dataViewer: return .dataDisplayPage } @@ -393,6 +427,12 @@ enum SensingFlowStep: String, CaseIterable { } private func checkSystemCalibrationCompletion() -> Bool { + // デバッグ設定でキャリブレーションをスキップする場合 + if UserDefaults.standard.bool(forKey: "skipCalibration") { + print("🔧 キャリブレーションスキップ設定が有効: 自動的に完了とみなします") + return true + } + // キャリブレーション結果を確認 guard let data = UserDefaults.standard.data(forKey: "lastCalibrationResult"), let result = try? JSONDecoder().decode(SystemCalibrationResult.self, from: data) diff --git a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AntennaPositioningPage/AntennaPositioningView.swift b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AntennaPositioningPage/AntennaPositioningView.swift index 9d07154..b8b6819 100644 --- a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AntennaPositioningPage/AntennaPositioningView.swift +++ b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AntennaPositioningPage/AntennaPositioningView.swift @@ -9,6 +9,9 @@ import SwiftUI /// - ダブルタップでアンテナの回転 /// - キャリブレーション結果の可視化 struct AntennaPositioningView: View { + /// 選択されたフロアマップID + let floorMapId: String + /// ナビゲーションルーター @EnvironmentObject var router: NavigationRouterModel @@ -67,7 +70,8 @@ struct AntennaPositioningView: View { FloatingControlPanel( viewModel: self.viewModel, flowNavigator: self.flowNavigator, - isExpanded: self.$isControlPanelExpanded + isExpanded: self.$isControlPanelExpanded, + floorMapId: self.floorMapId ) .frame(maxWidth: 450) } @@ -84,18 +88,12 @@ struct AntennaPositioningView: View { #endif .onAppear { self.viewModel.setModelContext(self.modelContext) - self.viewModel.loadMapAndDevices() + self.viewModel.setFloorMapId(self.floorMapId) + self.viewModel.loadMapAndDevices(floorMapId: self.floorMapId) self.flowNavigator.currentStep = .antennaConfiguration + self.flowNavigator.currentFloorMapId = self.floorMapId self.flowNavigator.setRouter(self.router) } - .onReceive(NotificationCenter.default.publisher(for: .init("FloorMapChanged"))) { notification in - // フロアマップが変更された時にデータを再読み込み - print("📢 AntennaPositioningView: FloorMapChanged通知を受信") - if let floorMapInfo = notification.object as? FloorMapInfo { - print("📢 新しいフロアマップ: \(floorMapInfo.name) (ID: \(floorMapInfo.id))") - } - self.viewModel.loadMapAndDevices() - } .alert("エラー", isPresented: Binding.constant(self.flowNavigator.lastError != nil)) { Button("OK") { self.flowNavigator.lastError = nil @@ -161,42 +159,57 @@ struct MapCanvasSection: View { @ObservedObject var viewModel: AntennaPositioningViewModel var body: some View { - FloorMapCanvas( - floorMapImage: self.viewModel.mapImage, - floorMapInfo: self.viewModel.currentFloorMapInfo, - calibrationPoints: self.viewModel.calibrationData.first?.calibrationPoints, - onMapTap: nil, - enableZoom: true, - fixedHeight: nil, - showGrid: true - ) { geometry in - // アンテナ位置 - ForEach(self.viewModel.antennaPositions) { antenna in - let antennaDisplayData = AntennaDisplayData( - id: antenna.id, - name: antenna.deviceName, - rotation: antenna.rotation, - color: antenna.color - ) - - let displayPosition = geometry.normalizedToImageCoordinate(antenna.normalizedPosition) - - AntennaMarker( - antenna: antennaDisplayData, - position: displayPosition, - size: geometry.antennaSizeInPixels(), - sensorRange: geometry.sensorRangeInPixels(), - isSelected: true, // 常にセンサー範囲を表示 - isDraggable: true, - showRotationControls: false, - onPositionChanged: { newPosition in - let normalizedPosition = geometry.imageCoordinateToNormalized(newPosition) - self.viewModel.updateAntennaPosition(antenna.id, normalizedPosition: normalizedPosition) - }, - onRotationChanged: { newRotation in - self.viewModel.updateAntennaRotation(antenna.id, rotation: newRotation) - } - ) + if let floorMapImage = self.viewModel.mapImage, + let floorMapInfo = self.viewModel.currentFloorMapInfo + { + FloorMapCanvas( + floorMapImage: floorMapImage, + floorMapInfo: floorMapInfo, + calibrationPoints: self.viewModel.calibrationData.first?.calibrationPoints, + onMapTap: nil, + enableZoom: true, + fixedHeight: nil, + showGrid: true + ) { geometry in + // アンテナ位置 + ForEach(self.viewModel.antennaPositions) { antenna in + let antennaDisplayData = AntennaDisplayData( + id: antenna.id, + name: antenna.deviceName, + rotation: antenna.rotation, + color: antenna.color + ) + + let displayPosition = geometry.normalizedToImageCoordinate(antenna.normalizedPosition) + + AntennaMarker( + antenna: antennaDisplayData, + position: displayPosition, + size: geometry.antennaSizeInPixels(), + sensorRange: geometry.sensorRangeInPixels(), + isSelected: true, // 常にセンサー範囲を表示 + isDraggable: true, + showRotationControls: false, + onPositionChanged: { newPosition in + let normalizedPosition = geometry.imageCoordinateToNormalized(newPosition) + self.viewModel.updateAntennaPosition(antenna.id, normalizedPosition: normalizedPosition) + }, + onRotationChanged: { newRotation in + self.viewModel.updateAntennaRotation(antenna.id, rotation: newRotation) + } + ) + } + } + } else { + ZStack { + Color.secondary.opacity(0.1) + + VStack(spacing: 12) { + ProgressView() + Text("フロアマップを読み込んでいます...") + .font(.subheadline) + .foregroundColor(.secondary) + } } } } @@ -660,6 +673,9 @@ struct FloatingControlPanel: View { /// パネルの展開状態 @Binding var isExpanded: Bool + /// フロアマップID + let floorMapId: String + var body: some View { VStack(alignment: .leading, spacing: 12) { self.headerView @@ -755,7 +771,7 @@ struct FloatingControlPanel: View { Button("次へ") { let saveSuccess = self.viewModel.saveAntennaPositionsForFlow() if saveSuccess { - self.flowNavigator.proceedToNextStep() + self.flowNavigator.proceedToNextStep(floorMapId: self.floorMapId) } } .frame(maxWidth: .infinity) @@ -785,7 +801,7 @@ struct FloatingControlPanel: View { #Preview { NavigationStack { - AntennaPositioningView() + AntennaPositioningView(floorMapId: "test-floor-map-id") .environmentObject(NavigationRouterModel.shared) } } diff --git a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AntennaPositioningPage/AntennaPositioningViewModel.swift b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AntennaPositioningPage/AntennaPositioningViewModel.swift index 0c06a35..842dfd5 100644 --- a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AntennaPositioningPage/AntennaPositioningViewModel.swift +++ b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AntennaPositioningPage/AntennaPositioningViewModel.swift @@ -37,6 +37,7 @@ class AntennaPositioningViewModel: ObservableObject { // SwiftData関連 private var modelContext: ModelContext? private var swiftDataRepository: SwiftDataRepository? + private var floorMapId: String? // フロアマップ情報を保持(SwiftDataから読み込み) @Published private var loadedFloorMapInfo: FloorMapInfo? @@ -104,9 +105,11 @@ class AntennaPositioningViewModel: ObservableObject { if #available(macOS 14, iOS 17, *) { swiftDataRepository = SwiftDataRepository(modelContext: context) } - // SwiftDataRepository設定後にデータを再読み込み - self.loadMapAndDevices() - // loadAntennaPositionsFromSwiftDataはcreateAntennaPositions内で呼び出すため、ここでは呼ばない + } + + /// フロアマップIDを設定 + func setFloorMapId(_ floorMapId: String) { + self.floorMapId = floorMapId } private func updateCanProceed() { @@ -152,20 +155,18 @@ class AntennaPositioningViewModel: ObservableObject { return antenna.rotation } - func loadMapAndDevices() { - self.loadSelectedDevices() - self.loadMapData() + func loadMapAndDevices(floorMapId: String) { // SwiftDataからフロアマップ情報とキャリブレーションデータを非同期でロード Task { @MainActor in - await self.loadFloorMapInfoFromSwiftData() + await self.loadFloorMapInfoFromSwiftData(floorMapId: floorMapId) await self.loadCalibrationDataAsync() - // ロード完了後にアンテナ位置を作成 - self.createAntennaPositions() + // フロアマップ情報ロード後にデバイスを読み込み + self.loadSelectedDevices() } } - /// SwiftDataからフロアマップ情報を読み込み - private func loadFloorMapInfoFromSwiftData() async { + /// SwiftDataから指定されたフロアマップ情報を読み込み + private func loadFloorMapInfoFromSwiftData(floorMapId: String) async { guard let repository = swiftDataRepository else { #if DEBUG print("❌ SwiftDataRepository が利用できません") @@ -174,17 +175,27 @@ class AntennaPositioningViewModel: ObservableObject { } do { - let floorMaps = try await repository.loadAllFloorMaps() - if let floorMap = floorMaps.first { + if let floorMap = try await repository.loadFloorMap(by: floorMapId) { await MainActor.run { self.loadedFloorMapInfo = floorMap + + // フロアマップ画像を読み込み + #if canImport(UIKit) + #if os(iOS) + self.mapImage = floorMap.image + #elseif os(macOS) + self.mapImage = floorMap.image + #endif + #elseif canImport(AppKit) + self.mapImage = floorMap.image + #endif } #if DEBUG - print("✅ フロアマップ情報を読み込みました: \(floorMap.name), サイズ: \(floorMap.width)x\(floorMap.depth)m") + print("✅ フロアマップ情報を読み込みました: \(floorMap.name), サイズ: \(floorMap.width)x\(floorMap.depth)m, 画像: \(self.mapImage != nil ? "あり" : "なし")") #endif } else { #if DEBUG - print("⚠️ フロアマップが見つかりません") + print("⚠️ フロアマップが見つかりません (ID: \(floorMapId))") #endif } } catch { @@ -748,9 +759,6 @@ class AntennaPositioningViewModel: ObservableObject { // データを保存 self.saveAntennaPositions() - // プロジェクト進行状況を更新 - self.updateProjectProgress(toStep: .antennaConfiguration) - return true } @@ -773,41 +781,6 @@ class AntennaPositioningViewModel: ObservableObject { return RealWorldPosition(x: realX, y: realY, z: 0) } - // MARK: - プロジェクト進行状況更新 - - private func updateProjectProgress(toStep step: SetupStep) { - guard let repository = swiftDataRepository, - let floorMapInfo - else { return } - - Task { - do { - // 既存の進行状況を取得 - var projectProgress = try await repository.loadProjectProgress(for: floorMapInfo.id) - - if projectProgress == nil { - // 進行状況が存在しない場合は新規作成 - projectProgress = ProjectProgress( - floorMapId: floorMapInfo.id, - currentStep: step - ) - } else { - // 既存の進行状況を更新 - projectProgress!.currentStep = step - projectProgress!.completedSteps.insert(step) - projectProgress!.updatedAt = Date() - } - - try await repository.updateProjectProgress(projectProgress!) - - } catch { - #if DEBUG - print("❌ プロジェクト進行状況の更新エラー: \(error)") - #endif - } - } - } - // MARK: - エラーハンドリング /// エラーハンドリング用のメソッド diff --git a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AutoAntennaCalibrationPage/AutoAntennaCalibrationView.swift b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AutoAntennaCalibrationPage/AutoAntennaCalibrationView.swift index 329738f..2f1e2cd 100644 --- a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AutoAntennaCalibrationPage/AutoAntennaCalibrationView.swift +++ b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AutoAntennaCalibrationPage/AutoAntennaCalibrationView.swift @@ -7,8 +7,17 @@ import SwiftUI /// 複数のタグ位置(既知)でセンシングを行い、各アンテナが観測した座標から /// アフィン変換を推定してアンテナのANTENNA_CONFIGを自動生成します。 struct AutoAntennaCalibrationView: View { + /// 選択されたフロアマップID + let floorMapId: String + @StateObject private var viewModel = AutoAntennaCalibrationViewModel() + @StateObject private var flowNavigator = SensingFlowNavigator() @Environment(\.modelContext) private var modelContext + @EnvironmentObject var router: NavigationRouterModel + + init(floorMapId: String) { + self.floorMapId = floorMapId + } var body: some View { VStack(spacing: 0) { @@ -35,6 +44,11 @@ struct AutoAntennaCalibrationView: View { } .onAppear { self.viewModel.setup(modelContext: self.modelContext) + // 指定されたフロアマップIDでフロアマップ情報を読み込み + self.viewModel.loadFloorMapInfo(floorMapId: self.floorMapId) + self.flowNavigator.currentStep = .systemCalibration + self.flowNavigator.setRouter(self.router) + self.viewModel.setFlowNavigator(self.flowNavigator) } .alert("エラー", isPresented: self.$viewModel.showErrorAlert) { Button("OK", role: .cancel) {} @@ -49,6 +63,58 @@ struct AutoAntennaCalibrationView: View { } message: { Text("全てのアンテナのキャリブレーションが正常に完了しました") } + .sheet(isPresented: self.$viewModel.showConnectionRecovery) { + ConnectionRecoveryView( + connectionUsecase: ConnectionManagementUsecase.shared, + isPresented: self.$viewModel.showConnectionRecovery + ) + #if os(iOS) + .presentationDetents([.medium, .large]) + #endif + } + .overlay { + // 自動再接続中のオーバーレイ + if self.viewModel.isAttemptingReconnect { + self.reconnectingOverlay + } + } + } + + // MARK: - Reconnection Overlay + + /// 自動再接続中に表示するオーバーレイ + private var reconnectingOverlay: some View { + ZStack { + Color.black.opacity(0.5) + .ignoresSafeArea() + + VStack(spacing: 20) { + ProgressView() + .scaleEffect(1.5) + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + + VStack(spacing: 8) { + Text("再接続中...") + .font(.headline) + .foregroundColor(.white) + + Text("試行 \(self.viewModel.reconnectAttemptCount) / 3") + .font(.subheadline) + .foregroundColor(.white.opacity(0.8)) + + Text(self.viewModel.errorMessage) + .font(.caption) + .foregroundColor(.white.opacity(0.6)) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + } + .padding(30) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.black.opacity(0.7)) + ) + } } // MARK: - Header @@ -536,18 +602,9 @@ struct FloatingCalibrationControlPanel: View { .background(currentTag.isCollected ? Color.green.opacity(0.1) : Color.blue.opacity(0.1)) .cornerRadius(6) - // センシング中のインジケーター + // センシング中の詳細フィードバック if self.viewModel.isCollecting { - HStack(spacing: 8) { - ProgressView() - Text("センシング中...") - .font(.caption) - .foregroundColor(.green) - } - .frame(maxWidth: .infinity) - .padding(8) - .background(Color.green.opacity(0.1)) - .cornerRadius(6) + self.sensingDetailPanel } // センシング開始ボタン @@ -567,20 +624,58 @@ struct FloatingCalibrationControlPanel: View { .cornerRadius(8) } } - // 次の位置へ + // 次の位置へ & 前のタグに戻る else if self.viewModel.hasMoreTagPositions { + HStack(spacing: 8) { + // 前のタグに戻るボタン + if self.viewModel.canGoToPreviousTag { + Button(action: { + self.viewModel.goToPreviousTagPosition() + }) { + HStack { + Image(systemName: "arrow.left.circle.fill") + Text("前のタグへ") + } + .font(.caption) + .frame(maxWidth: .infinity) + .padding(8) + .foregroundColor(.white) + .background(Color.orange) + .cornerRadius(8) + } + } + + // 次のタグ位置へボタン + Button(action: { + self.viewModel.proceedToNextTagPosition() + }) { + HStack { + Image(systemName: "arrow.right.circle.fill") + Text("次のタグ位置へ") + } + .font(.caption) + .frame(maxWidth: .infinity) + .padding(8) + .foregroundColor(.white) + .background(Color.blue) + .cornerRadius(8) + } + } + } + // 最後のタグで「前のタグに戻る」のみ表示 + else if self.viewModel.canGoToPreviousTag { Button(action: { - self.viewModel.proceedToNextTagPosition() + self.viewModel.goToPreviousTagPosition() }) { HStack { - Image(systemName: "arrow.right.circle.fill") - Text("次のタグ位置へ") + Image(systemName: "arrow.left.circle.fill") + Text("前のタグへ") } .font(.caption) .frame(maxWidth: .infinity) .padding(8) .foregroundColor(.white) - .background(Color.blue) + .background(Color.orange) .cornerRadius(8) } } @@ -610,6 +705,169 @@ struct FloatingCalibrationControlPanel: View { } } + // MARK: - Sensing Detail Panel + + /// センシング中の詳細フィードバックパネル + private var sensingDetailPanel: some View { + VStack(alignment: .leading, spacing: 8) { + // ヘッダー + HStack(spacing: 8) { + ProgressView() + Text("センシング中...") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(self.viewModel.hasSuspiciousDataDuringSensing ? .red : .green) + } + + // 経過時間プログレスバー + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("経過時間") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + Text( + "\(String(format: "%.1f", self.viewModel.sensingElapsedTime))秒 / \(String(format: "%.0f", self.viewModel.sensingDuration))秒" + ) + .font(.caption2) + .fontWeight(.medium) + } + + ProgressView( + value: self.viewModel.sensingElapsedTime, + total: self.viewModel.sensingDuration + ) + .progressViewStyle( + LinearProgressViewStyle( + tint: self.viewModel.hasSuspiciousDataDuringSensing ? .red : .green)) + } + + // (0,0)データ検出警告 + if self.viewModel.hasSuspiciousDataDuringSensing { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundColor(.red) + Text( + "(0,0)付近のデータ: \(self.viewModel.suspiciousZeroDataCount)件検出" + ) + .font(.caption2) + .fontWeight(.medium) + .foregroundColor(.red) + } + .padding(6) + .background(Color.red.opacity(0.1)) + .cornerRadius(4) + } + + // データポイント数 + HStack { + Image(systemName: "chart.dots.scatter") + .font(.caption2) + .foregroundColor(.blue) + Text("データポイント数:") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + Text("\(self.viewModel.currentDataPointCount)個") + .font(.caption2) + .fontWeight(.medium) + } + + // RMSE推定(表示可能な場合) + if let rmse = self.viewModel.currentRMSEEstimate { + HStack { + Image(systemName: "ruler") + .font(.caption2) + .foregroundColor(.orange) + Text("RMSE推定:") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + Text("\(String(format: "%.3f", rmse))m") + .font(.caption2) + .fontWeight(.medium) + } + } + + // 信号品質(アンテナ別) + if !self.viewModel.signalQualityByAntenna.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("信号品質") + .font(.caption2) + .fontWeight(.medium) + .foregroundColor(.secondary) + + ForEach( + Array(self.viewModel.signalQualityByAntenna.keys.sorted()), id: \.self + ) { antennaId in + if let quality = self.viewModel.signalQualityByAntenna[antennaId] { + self.antennaSignalQualityRow(antennaId: antennaId, quality: quality) + } + } + } + } + } + .padding(10) + .background( + (self.viewModel.hasSuspiciousDataDuringSensing ? Color.red : Color.green).opacity(0.1)) + .cornerRadius(8) + } + + /// アンテナ別信号品質行 + @ViewBuilder + private func antennaSignalQualityRow(antennaId: String, quality: SignalQualityDisplay) -> some View + { + HStack(spacing: 6) { + // 品質インジケーター + Circle() + .fill(self.qualityColor(for: quality.qualityLevel)) + .frame(width: 8, height: 8) + + // アンテナ名 + Text(antennaId) + .font(.caption2) + .fontWeight(.medium) + .lineLimit(1) + .frame(width: 60, alignment: .leading) + + Spacer() + + // RSSI + HStack(spacing: 2) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 8)) + Text("\(String(format: "%.0f", quality.averageRSSI))dBm") + .font(.system(size: 9)) + } + .foregroundColor(.secondary) + + // LoS率 + HStack(spacing: 2) { + Image(systemName: quality.losPercentage >= 50 ? "eye.fill" : "eye.slash.fill") + .font(.system(size: 8)) + Text("\(String(format: "%.0f", quality.losPercentage))%") + .font(.system(size: 9)) + } + .foregroundColor(quality.losPercentage >= 50 ? .green : .orange) + + // データ数 + Text("\(quality.dataPointCount)") + .font(.system(size: 9)) + .foregroundColor(.secondary) + } + .padding(.vertical, 2) + } + + /// 品質レベルに応じた色を返す + private func qualityColor(for level: Int) -> Color { + switch level { + case 2: return .green + case 1: return .orange + default: return .red + } + } + // MARK: - Step 3: キャリブレーション結果(コンパクト版) private var calibrationResultStepCompact: some View { @@ -665,6 +923,31 @@ struct FloatingCalibrationControlPanel: View { .background(Color.green.opacity(0.1)) .cornerRadius(6) + // キャリブレーション結果の警告表示 + if self.viewModel.hasCalibrationWarning, + let warningMessage = self.viewModel.calibrationWarningMessage + { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + + Text("警告") + .font(.caption) + .fontWeight(.bold) + .foregroundColor(.orange) + } + + Text(warningMessage) + .font(.caption2) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(8) + .background(Color.orange.opacity(0.1)) + .cornerRadius(6) + } + // 次のアンテナへ進むボタン if self.viewModel.hasMoreAntennas { Button(action: { @@ -775,6 +1058,7 @@ struct FloatingCalibrationControlPanel: View { struct AutoAntennaCalibrationView_Previews: PreviewProvider { static var previews: some View { - AutoAntennaCalibrationView() + AutoAntennaCalibrationView(floorMapId: "test-floor-map-id") + .environmentObject(NavigationRouterModel()) } } diff --git a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AutoAntennaCalibrationPage/AutoAntennaCalibrationViewModel.swift b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AutoAntennaCalibrationPage/AutoAntennaCalibrationViewModel.swift index 729a107..606deaa 100644 --- a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AutoAntennaCalibrationPage/AutoAntennaCalibrationViewModel.swift +++ b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/AutoAntennaCalibrationPage/AutoAntennaCalibrationViewModel.swift @@ -8,6 +8,25 @@ import SwiftData import AppKit #endif +/// アンテナ別信号品質表示用データ +struct SignalQualityDisplay: Equatable { + let averageRSSI: Double + let losPercentage: Double + let averageStrength: Double + let dataPointCount: Int + + /// 品質レベル(0: 悪い, 1: 普通, 2: 良い) + var qualityLevel: Int { + if self.averageStrength >= 0.7 && self.losPercentage >= 70 { + return 2 // 良い + } else if self.averageStrength >= 0.4 && self.losPercentage >= 40 { + return 1 // 普通 + } else { + return 0 // 悪い + } + } +} + /// 自動アンテナキャリブレーション画面のViewModel @MainActor class AutoAntennaCalibrationViewModel: ObservableObject { @@ -47,6 +66,9 @@ class AutoAntennaCalibrationViewModel: ObservableObject { /// 全アンテナのキャリブレーション結果(履歴) @Published var calibrationResults: [String: CalibrationResult] = [:] + /// 接続エラー表示フラグ + @Published var showConnectionRecovery: Bool = false + /// エラーメッセージ @Published var errorMessage: String = "" @@ -82,6 +104,68 @@ class AutoAntennaCalibrationViewModel: ObservableObject { /// キャリブレーション前の現在のアンテナ位置 @Published var originalAntennaPosition: AntennaPositionData? + // MARK: - Real-time Feedback Properties + + /// センシング経過時間(秒) + @Published var sensingElapsedTime: Double = 0.0 + + /// 現在のデータポイント数 + @Published var currentDataPointCount: Int = 0 + + /// リアルタイムRMSE推定値(計算可能な場合) + @Published var currentRMSEEstimate: Double? + + /// アンテナ別信号品質情報 + @Published var signalQualityByAntenna: [String: SignalQualityDisplay] = [:] + + /// センシング中に(0,0)付近のデータが検出された数 + @Published var suspiciousZeroDataCount: Int = 0 + + /// センシング時間(秒) + let sensingDuration: Double = 10.0 + + /// センシング中に(0,0)付近の疑わしいデータが検出されているかどうか + var hasSuspiciousDataDuringSensing: Bool { + self.suspiciousZeroDataCount > 0 + } + + /// センシング中のデータ品質警告メッセージ + var sensingDataWarningMessage: String? { + guard self.hasSuspiciousDataDuringSensing else { return nil } + return "(0,0)付近のデータが\(self.suspiciousZeroDataCount)件検出されました。センサーの接続状態を確認してください。" + } + + // MARK: - Connection Recovery State + + /// 切断時に保存された操作状態 + struct SavedOperationState { + let wasCollecting: Bool + let wasCalibrating: Bool + let stepAtDisconnect: Int + let antennaIdAtDisconnect: String? + let tagPositionIndexAtDisconnect: Int + let sensingElapsedTimeAtDisconnect: Double + let timestamp: Date + } + + /// 切断前の状態を保存 + private var savedOperationState: SavedOperationState? + + /// 接続復旧後に再開を試みるかどうか + @Published var shouldAttemptResumeAfterReconnect: Bool = true + + /// 再接続中かどうか(UIに表示用) + @Published var isAttemptingReconnect: Bool = false + + /// 再接続試行回数 + @Published var reconnectAttemptCount: Int = 0 + + /// 最大再接続試行回数 + private let maxAutoReconnectAttempts: Int = 3 + + /// 接続監視が設定済みかどうか + private var isConnectionMonitoringSetup: Bool = false + // MARK: - Dependencies private var autoCalibrationUsecase: AutoAntennaCalibrationUsecase? @@ -90,6 +174,7 @@ class AutoAntennaCalibrationViewModel: ObservableObject { private var swiftDataRepository: SwiftDataRepository? private var sensingControlUsecase: SensingControlUsecase? private var modelContext: ModelContext? + private weak var flowNavigator: SensingFlowNavigator? private var cancellables = Set() @@ -129,6 +214,14 @@ class AutoAntennaCalibrationViewModel: ObservableObject { !self.isCollecting && self.allTagPositionsCollected } + var canGoToPreviousTag: Bool { + // データ収集ステップで、完了済みのタグが1つ以上ある場合に戻れる + self.currentStep == 2 && + !self.isCollecting && + !self.isCalibrating && + self.trueTagPositions.contains(where: { $0.isCollected }) + } + var hasMoreAntennas: Bool { let uncalibratedAntennas = self.availableAntennas.filter { !self.completedAntennaIds.contains($0.id) } return !uncalibratedAntennas.isEmpty @@ -152,6 +245,45 @@ class AutoAntennaCalibrationViewModel: ObservableObject { self.trueTagPositions.allSatisfy { $0.isCollected } } + /// キャリブレーション結果が問題のある状態かどうかを判定 + /// (0, 0) 付近の位置は明らかに異常な結果 + var hasCalibrationWarning: Bool { + guard let result = currentAntennaResult else { return false } + return self.isPositionSuspicious(result.position) + } + + /// キャリブレーション結果の警告メッセージ + var calibrationWarningMessage: String? { + guard let result = currentAntennaResult else { return nil } + + var warnings: [String] = [] + + // 位置が (0, 0) 付近の場合 + if self.isPositionSuspicious(result.position) { + warnings.append("推定位置が原点(0,0)付近です。データ収集に問題がある可能性があります。") + } + + // RMSEが異常に高い場合 + if result.rmse > 1.0 { + warnings.append("RMSE値が高すぎます。測定データの品質を確認してください。") + } + + // スケールファクターが異常な場合 + if result.scaleFactors.sx < 0.1 || result.scaleFactors.sx > 10.0 || + result.scaleFactors.sy < 0.1 || result.scaleFactors.sy > 10.0 + { + warnings.append("スケールファクターが異常です。キャリブレーションデータに問題がある可能性があります。") + } + + return warnings.isEmpty ? nil : warnings.joined(separator: "\n") + } + + /// 位置が疑わしい(原点付近)かどうかを判定 + private func isPositionSuspicious(_ position: Point3D) -> Bool { + let threshold: Double = 0.01 // 1cm以内は (0, 0) とみなす + return abs(position.x) < threshold && abs(position.y) < threshold + } + // MARK: - Types struct TagPosition: Identifiable { @@ -213,9 +345,339 @@ class AutoAntennaCalibrationViewModel: ObservableObject { self.realtimeDataUsecase = realtimeUsecase connectionUsecase.setRealtimeDataUsecase(realtimeUsecase) + // 接続監視を設定 + self.setupConnectionMonitoring() + self.loadInitialData() } + /// SensingFlowNavigatorを設定 + func setFlowNavigator(_ navigator: SensingFlowNavigator) { + self.flowNavigator = navigator + } + + /// 接続監視を設定 + private func setupConnectionMonitoring() { + let connectionUsecase = ConnectionManagementUsecase.shared + + // 初期状態をチェック:既に接続エラーがある場合や、接続デバイスがない場合 + Task { @MainActor in + // 少し待機して画面遷移を完了させる + try? await Task.sleep(nanoseconds: 500_000_000) + + // 接続エラーがあるか、接続デバイスがない場合は再接続を試みる + if connectionUsecase.hasConnectionError { + print("🔴 AutoCalibration: 初期化時に既存の接続エラーを検知") + self.handleConnectionError() + } else if !connectionUsecase.hasConnectedDevices() { + print("🔴 AutoCalibration: 初期化時に接続デバイスなしを検知") + connectionUsecase.hasConnectionError = true + self.handleConnectionError() + } + } + + // hasConnectionErrorの変更を監視 + connectionUsecase.$hasConnectionError + .dropFirst() // 初期値をスキップ(上で処理済み) + .sink { [weak self] hasError in + guard let self else { return } + if hasError { + print("⚠️ 接続断検出: 自動再接続を試みます") + self.handleConnectionError() + } + } + .store(in: &self.cancellables) + + // 接続デバイス数の変更を監視してアンテナリストを更新 + ConnectionManagementUsecase.shared.$connectedDeviceNames + .sink { [weak self] deviceNames in + guard let self else { return } + Task { + print("🔌 接続デバイスの変更を検出: アンテナリストを再読み込みします") + await self.loadAvailableAntennas() + + // 再接続成功を検出 + if !deviceNames.isEmpty && self.isAttemptingReconnect { + print("✅ 再接続成功を検出") + await self.handleReconnectionSuccess() + } + } + } + .store(in: &self.cancellables) + + // 接続復旧画面の表示状態を監視 + self.$showConnectionRecovery + .dropFirst() + .sink { [weak self] isShowing in + guard let self else { return } + // 復旧画面が閉じられた場合(ユーザーがキャンセルまたは接続復旧) + if !isShowing && self.savedOperationState != nil { + Task { + await self.checkAndResumeOperation() + } + } + } + .store(in: &self.cancellables) + } + + /// 接続エラーハンドリング + private func handleConnectionError() { + // 現在の操作状態を保存 + if self.isCollecting || self.isCalibrating { + self.savedOperationState = SavedOperationState( + wasCollecting: self.isCollecting, + wasCalibrating: self.isCalibrating, + stepAtDisconnect: self.currentStep, + antennaIdAtDisconnect: self.currentAntennaId, + tagPositionIndexAtDisconnect: self.currentTagPositionIndex, + sensingElapsedTimeAtDisconnect: self.sensingElapsedTime, + timestamp: Date() + ) + print("💾 操作状態を保存しました: collecting=\(self.isCollecting), calibrating=\(self.isCalibrating)") + + // 操作を一時停止 + print("⚠️ データ収集/キャリブレーションを一時停止します") + self.isCollecting = false + self.isCalibrating = false + } + + // エラーメッセージを設定 + if let deviceName = ConnectionManagementUsecase.shared.lastDisconnectedDevice { + self.errorMessage = "デバイス「\(deviceName)」との接続が切断されました。再接続を試みています..." + } else { + self.errorMessage = "接続が切断されました。再接続を試みています..." + } + + // 自動再接続を開始 + Task { + await self.attemptAutoReconnect() + } + } + + /// 自動再接続を試行 + private func attemptAutoReconnect() async { + self.isAttemptingReconnect = true + self.reconnectAttemptCount = 0 + + let connectionUsecase = ConnectionManagementUsecase.shared + + // 自動再接続中フラグを設定(アラート抑制用) + connectionUsecase.isAutoReconnecting = true + + for attempt in 1...self.maxAutoReconnectAttempts { + self.reconnectAttemptCount = attempt + print("🔄 自動再接続試行 \(attempt)/\(self.maxAutoReconnectAttempts)") + + // 既存の接続をリセット + connectionUsecase.resetAll() + + // 少し待機 + try? await Task.sleep(nanoseconds: 1_000_000_000) + + // エラーフラグをクリア + connectionUsecase.hasConnectionError = false + connectionUsecase.lastDisconnectedDevice = nil + + // 広告と検索を再開 + connectionUsecase.startAdvertising() + connectionUsecase.startDiscovery() + + // 接続確立を待機(最大8秒) + for waitCount in 0..<16 { + try? await Task.sleep(nanoseconds: 500_000_000) + + if connectionUsecase.hasConnectedDevices() { + print("✅ 自動再接続成功 (試行\(attempt)回目, 待機\(waitCount * 500)ms)") + self.isAttemptingReconnect = false + self.reconnectAttemptCount = 0 + connectionUsecase.isAutoReconnecting = false + + // 復旧処理 + await self.handleReconnectionSuccess() + return + } + } + + // バックオフ:次の試行まで待機 + let backoffSeconds = attempt * 2 + print("⏳ 再接続失敗。\(backoffSeconds)秒後に再試行...") + try? await Task.sleep(nanoseconds: UInt64(backoffSeconds * 1_000_000_000)) + } + + // すべての自動試行が失敗 + print("❌ 自動再接続失敗: 最大試行回数(\(self.maxAutoReconnectAttempts))に達しました") + self.isAttemptingReconnect = false + connectionUsecase.isAutoReconnecting = false + + // 手動復旧画面を表示 + self.errorMessage = "自動再接続に失敗しました。手動で再接続してください。" + self.showConnectionRecovery = true + } + + /// 再接続成功時の処理 + private func handleReconnectionSuccess() async { + print("🎉 接続復旧完了") + + // エラー状態をクリア + self.errorMessage = "" + self.showConnectionRecovery = false + + // 接続の安定化を待つ + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5秒待機 + + // ペアリング情報の検証と復元 + await self.verifyAndRestorePairingInfo() + + // 操作の再開を試みる + await self.checkAndResumeOperation() + } + + /// ペアリング情報を検証し、必要に応じて復元する + private func verifyAndRestorePairingInfo() async { + let connectionUsecase = ConnectionManagementUsecase.shared + + // 現在のアンテナIDを取得 + guard let antennaId = currentAntennaId else { + print("📝 現在のアンテナIDがありません - ペアリング検証をスキップ") + return + } + + // ペアリング情報を取得 + guard let pairedDeviceName = connectionUsecase.getDeviceName(for: antennaId) else { + print("⚠️ アンテナ \(antennaId) のペアリング情報が見つかりません") + self.showError( + "アンテナのペアリング情報が失われました。キャリブレーションを最初からやり直してください。" + ) + return + } + + print("🔍 ペアリング検証: アンテナ \(antennaId) → デバイス \(pairedDeviceName)") + + // 接続されているデバイスを確認 + let connectedDevices = connectionUsecase.connectedDeviceNames + + // ペアリングされたデバイスが接続されているか確認 + if connectedDevices.contains(pairedDeviceName) { + // エンドポイントIDのマッピングを確認 + if let endpointId = connectionUsecase.getEndpointId(for: pairedDeviceName) { + print("✅ ペアリング検証成功: \(pairedDeviceName) (endpoint: \(endpointId))") + return + } else { + // デバイスは接続されているが、エンドポイントマッピングがない + // 接続されているエンドポイントから該当デバイスを探して復元を試みる + print("⚠️ エンドポイントマッピングが見つかりません - 復元を試行") + await self.attemptEndpointMappingRecovery( + deviceName: pairedDeviceName, antennaId: antennaId) + } + } else { + // ペアリングされたデバイスが接続されていない + // 接続されている別のデバイスがあればそれを使用するか確認 + print("⚠️ ペアリングされたデバイス \(pairedDeviceName) が接続リストにありません") + print(" 接続中のデバイス: \(connectedDevices)") + + // 接続されているデバイスがある場合、そのデバイスでペアリングを更新 + if let firstConnectedDevice = connectedDevices.first { + print("🔄 接続中のデバイス \(firstConnectedDevice) でペアリングを復元します") + connectionUsecase.pairAntennaWithDevice( + antennaId: antennaId, deviceName: firstConnectedDevice) + + // 再度エンドポイントマッピングを確認 + if connectionUsecase.getEndpointId(for: firstConnectedDevice) != nil { + print("✅ ペアリング復元成功: アンテナ \(antennaId) → \(firstConnectedDevice)") + } else { + self.showError("接続されたデバイスのエンドポイント情報が取得できません。再接続してください。") + } + } else { + self.showError("接続されているデバイスがありません。デバイスを再接続してください。") + } + } + } + + /// エンドポイントマッピングの復元を試みる + private func attemptEndpointMappingRecovery(deviceName: String, antennaId: String) async { + let connectionUsecase = ConnectionManagementUsecase.shared + + // 接続されているエンドポイントを確認 + let connectedEndpoints = connectionUsecase.connectedEndpoints + + print("🔧 エンドポイントマッピング復元を試行: \(connectedEndpoints.count)個のエンドポイント") + + // 少し待機して再確認(接続処理完了待ち) + try? await Task.sleep(nanoseconds: 500_000_000) + + // 再確認 + if let endpointId = connectionUsecase.getEndpointId(for: deviceName) { + print("✅ エンドポイントマッピング復元成功: \(deviceName) → \(endpointId)") + return + } + + // それでも見つからない場合はエラー + print("❌ エンドポイントマッピングを復元できませんでした") + self.showError( + "デバイス \(deviceName) との接続情報を復元できませんでした。ペアリング設定を確認してください。" + ) + } + + /// 保存された状態があれば操作を再開 + private func checkAndResumeOperation() async { + guard let savedState = self.savedOperationState else { + print("📝 保存された操作状態がありません") + return + } + + // 古すぎる状態は無視(5分以上経過) + let elapsedSinceDisconnect = Date().timeIntervalSince(savedState.timestamp) + if elapsedSinceDisconnect > 300 { + print("⏰ 保存された状態が古すぎます(\(Int(elapsedSinceDisconnect))秒経過)。操作を再開しません。") + self.savedOperationState = nil + return + } + + // 接続が復旧しているか確認 + guard ConnectionManagementUsecase.shared.hasConnectedDevices() else { + print("⚠️ デバイスが接続されていないため、操作を再開できません") + return + } + + print("🔄 操作を再開します: step=\(savedState.stepAtDisconnect), wasCollecting=\(savedState.wasCollecting)") + + // 状態を復元 + self.currentStep = savedState.stepAtDisconnect + if let antennaId = savedState.antennaIdAtDisconnect { + self.currentAntennaId = antennaId + } + self.currentTagPositionIndex = savedState.tagPositionIndexAtDisconnect + + // データ収集を再開 + if savedState.wasCollecting && self.shouldAttemptResumeAfterReconnect { + print("▶️ データ収集を再開します") + // 少し待機してから再開(接続の安定化のため) + try? await Task.sleep(nanoseconds: 1_000_000_000) + self.startCurrentTagPositionCollection() + } + + // 保存状態をクリア + self.savedOperationState = nil + } + + /// 手動で操作を再開 + func manuallyResumeOperation() { + Task { + await self.checkAndResumeOperation() + } + } + + /// 保存された状態をクリア(ユーザーがキャンセルした場合) + func clearSavedOperationState() { + self.savedOperationState = nil + print("🗑️ 保存された操作状態をクリアしました") + } + + /// 保存された操作状態があるかどうか + var hasSavedOperationState: Bool { + self.savedOperationState != nil + } + // MARK: - Public Methods func loadInitialData() { @@ -225,6 +687,14 @@ class AutoAntennaCalibrationViewModel: ObservableObject { } } + /// 指定されたフロアマップ情報を読み込み + func loadFloorMapInfo(floorMapId: String) { + Task { + await self.loadFloorMapInfoById(floorMapId: floorMapId) + await self.loadAvailableAntennas() + } + } + func addTagPosition(at point: Point3D) { let newTag = TagPosition( id: UUID(), @@ -303,6 +773,45 @@ class AutoAntennaCalibrationViewModel: ObservableObject { print("➡️ 次のタグ位置へ: \(self.trueTagPositions[self.currentTagPositionIndex].tagId)") } + /// 前のタグ位置に戻る(最後に完了したタグのデータを取り消してそのタグからやり直す) + func goToPreviousTagPosition() { + guard self.canGoToPreviousTag else { return } + + // 最後に完了したタグを見つける(後ろから探す) + guard let lastCompletedIndex = self.trueTagPositions.indices.reversed().first(where: { index in + self.trueTagPositions[index].isCollected + }) else { + print("⚠️ 完了済みのタグが見つかりません") + return + } + + let tagToUndo = self.trueTagPositions[lastCompletedIndex] + + Task { + guard let usecase = autoCalibrationUsecase, + let antennaId = currentAntennaId + else { return } + + // 最後に完了したタグのデータをクリア + await usecase.clearData(for: antennaId, tagId: tagToUndo.tagId) + + // そのタグの収集状態をリセット + self.trueTagPositions[lastCompletedIndex].isCollected = false + + // インデックスをそのタグに戻す + self.currentTagPositionIndex = lastCompletedIndex + + // 進行状況を更新 + let completedCount = self.trueTagPositions.filter { $0.isCollected }.count + self.collectionProgress = Double(completedCount) / Double(self.trueTagPositions.count) + + print("⬅️ タグ(\(tagToUndo.tagId))を取り消してそのタグ位置に戻る(index: \(lastCompletedIndex))") + + // データ統計を更新 + await self.updateDataStatistics() + } + } + func startCalibration() { guard self.canStartCalibration else { return } @@ -336,6 +845,45 @@ class AutoAntennaCalibrationViewModel: ObservableObject { print("➡️ 次のアンテナへ: \(self.currentAntennaName) (ID: \(nextId))") } else { print("✅ 全アンテナのキャリブレーション完了") + + // キャリブレーション結果をUserDefaultsに保存 + self.saveCalibrationResultToUserDefaults() + + // 成功アラートを表示 + self.showSuccessAlert = true + + // フローナビゲーターで次のステップへ進む + if let flowNavigator = self.flowNavigator { + print("🚀 次のステップ(センシング実行)へ自動遷移します") + // アラート表示後に自動で次へ進むため、少し待機 + let floorMapId = self.currentFloorMapInfo?.id + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + flowNavigator.proceedToNextStep(floorMapId: floorMapId) + } + } else { + print("⚠️ FlowNavigatorが設定されていないため、手動で次へ進んでください") + } + } + } + + /// キャリブレーション結果をUserDefaultsに保存 + private func saveCalibrationResultToUserDefaults() { + // キャリブレーションデータを作成(アンテナ数の情報を含める) + let calibrationData: [String: Double] = [ + "completedAntennaCount": Double(self.completedAntennaIds.count), + "totalAntennaCount": Double(self.availableAntennas.count) + ] + + let calibrationResult = SystemCalibrationResult( + timestamp: Date(), + wasSuccessful: true, + calibrationData: calibrationData, + errorMessage: nil + ) + + if let encoded = try? JSONEncoder().encode(calibrationResult) { + UserDefaults.standard.set(encoded, forKey: "lastCalibrationResult") + print("💾 キャリブレーション結果をUserDefaultsに保存しました") } } @@ -358,6 +906,37 @@ class AutoAntennaCalibrationViewModel: ObservableObject { // MARK: - Private Methods + /// 指定されたIDのフロアマップ情報を読み込み + private func loadFloorMapInfoById(floorMapId: String) async { + guard let repository = swiftDataRepository else { + print("⚠️ SwiftDataRepositoryが利用できません") + return + } + + do { + if let floorMap = try await repository.loadFloorMap(by: floorMapId) { + self.currentFloorMapInfo = floorMap + + // フロアマップ画像を読み込み + #if canImport(UIKit) + #if os(iOS) + self.floorMapImage = floorMap.image + #elseif os(macOS) + self.floorMapImage = floorMap.image + #endif + #elseif canImport(AppKit) + self.floorMapImage = floorMap.image + #endif + + print("🗺️ フロアマップ読み込み完了: \(floorMap.name), 画像: \(self.floorMapImage != nil ? "あり" : "なし")") + } else { + print("⚠️ フロアマップが見つかりません (ID: \(floorMapId))") + } + } catch { + self.showError("フロアマップの読み込みに失敗しました: \(error.localizedDescription)") + } + } + private func loadFloorMapInfo() async { guard let repository = swiftDataRepository else { return } @@ -403,8 +982,35 @@ class AutoAntennaCalibrationViewModel: ObservableObject { // すべてのアンテナ位置を保存(マップ常時表示用) self.allAntennaPositions = antennaPositions - // アンテナ位置データからアンテナリストを構築 - self.availableAntennas = antennaPositions.map { position in + // ConnectionManagementUsecaseからペアリング情報を取得 + let antennaPairings = ConnectionManagementUsecase.shared.antennaPairings + print("🔗 [DEBUG] ペアリング情報: \(antennaPairings.count)件") + + for (antennaId, deviceName) in antennaPairings { + print("🔗 [DEBUG] ペアリング: \(antennaId) → \(deviceName)") + } + + // 接続中のデバイス名を取得 + let connectedDeviceNames = ConnectionManagementUsecase.shared.connectedDeviceNames + print("🔌 [DEBUG] 接続中のデバイス: \(connectedDeviceNames)") + + // ペアリングされている かつ 接続中のアンテナのみをフィルタリング + let connectedAntennaPositions = antennaPositions.filter { position in + // アンテナIDに紐づくデバイス名を取得 + if let deviceName = antennaPairings[position.antennaId] { + let isConnected = connectedDeviceNames.contains(deviceName) + print("🔍 [DEBUG] \(position.antennaName) (\(position.antennaId)) → デバイス: \(deviceName), 接続: \(isConnected)") + return isConnected + } else { + print("⚠️ [DEBUG] \(position.antennaName) (\(position.antennaId)) はペアリングされていません") + return false + } + } + + print("📡 [DEBUG] 接続中のアンテナ: \(connectedAntennaPositions.count)個") + + // アンテナ位置データからアンテナリストを構築(接続中のアンテナのみ) + self.availableAntennas = connectedAntennaPositions.map { position in AntennaInfo( id: position.antennaId, name: position.antennaName, @@ -412,7 +1018,7 @@ class AutoAntennaCalibrationViewModel: ObservableObject { ) } - print("📡 利用可能なアンテナ: \(self.availableAntennas.count)個") + print("📡 キャリブレーション対象アンテナ: \(self.availableAntennas.count)個") } catch { self.showError("アンテナリストの読み込みに失敗しました: \(error.localizedDescription)") } @@ -465,36 +1071,145 @@ class AutoAntennaCalibrationViewModel: ObservableObject { // センシング中のデータポイントをクリア self.currentSensingDataPoints.removeAll() - // センシング開始コマンドを送信 - sensingControl.startRemoteSensing(fileName: sessionName) + // リアルタイムフィードバック用変数をリセット + self.sensingElapsedTime = 0.0 + self.currentDataPointCount = 0 + self.currentRMSEEstimate = nil + self.signalQualityByAntenna.removeAll() + self.suspiciousZeroDataCount = 0 + + // 選択中のアンテナに紐づいたデバイス名を取得 + guard let targetDeviceName = ConnectionManagementUsecase.shared.getDeviceName(for: antennaId) + else { + throw NSError( + domain: "AutoAntennaCalibration", + code: -2, + userInfo: [ + NSLocalizedDescriptionKey: + "アンテナ \(antennaId) に紐づいたデバイスが見つかりません。ペアリング設定を確認してください。" + ] + ) + } + + // 選択中のアンテナに紐づいたデバイスのみにセンシング開始コマンドを送信 + let sensingStarted = sensingControl.startRemoteSensingForDevice( + fileName: sessionName, + deviceName: targetDeviceName + ) + + guard sensingStarted else { + throw NSError( + domain: "AutoAntennaCalibration", + code: -3, + userInfo: [ + NSLocalizedDescriptionKey: + "デバイス \(targetDeviceName) へのセンシング開始コマンド送信に失敗しました。" + ] + ) + } - // 10秒間データ収集(リアルタイム更新) + print("🎯 キャリブレーション用センシング開始: デバイス=\(targetDeviceName), アンテナ=\(antennaId)") + + // データ収集(リアルタイム更新) let startTime = Date() - while Date().timeIntervalSince(startTime) < 10.0 { + var connectionLostDuringSensing = false + while Date().timeIntervalSince(startTime) < self.sensingDuration { // 0.5秒ごとにデータを更新 try await Task.sleep(nanoseconds: 500_000_000) - // リアルタイムデータから座標を取得してマップに表示 + // 接続状態をチェック + let connectionUsecase = ConnectionManagementUsecase.shared + if connectionUsecase.hasConnectionError || !connectionUsecase.hasConnectedDevices() { + print("⚠️ センシング中に接続が切断されました") + connectionLostDuringSensing = true + break + } + + // 経過時間を更新 + self.sensingElapsedTime = Date().timeIntervalSince(startTime) + + // リアルタイムデータから座標と品質情報を取得 if let realtimeUsecase = realtimeDataUsecase { var tempDataPoints: [Point3D] = [] + var qualityByAntenna: [String: SignalQualityDisplay] = [:] + var zeroDataCount = 0 + for deviceData in realtimeUsecase.deviceRealtimeDataList { - guard deviceData.isActive, let latestData = deviceData.latestData else { continue } - - let position = self.calculatePosition( - distance: latestData.distance, - elevation: latestData.elevation, - azimuth: latestData.azimuth - ) - tempDataPoints.append(position) + guard deviceData.isActive else { continue } + + // 最新データからマップ表示用座標を取得 + if let latestData = deviceData.latestData { + let position = self.calculatePosition( + distance: latestData.distance, + elevation: latestData.elevation, + azimuth: latestData.azimuth + ) + tempDataPoints.append(position) + + // (0,0)付近のデータを検出 + if self.isPositionSuspicious(position) { + zeroDataCount += 1 + } + } + + // データ履歴から信号品質を集計 + let history = deviceData.dataHistory + if !history.isEmpty { + let avgRSSI = history.map { $0.rssi }.reduce(0, +) / Double(history.count) + // nlos == 0 が LoS (Line of Sight) + let losCount = history.filter { $0.nlos == 0 }.count + let losPercentage = Double(losCount) / Double(history.count) * 100 + // RSSIを信号強度の指標として使用(-100dBm〜0dBmを0〜1に正規化) + let avgStrength = min(1.0, max(0.0, (avgRSSI + 100) / 100)) + + qualityByAntenna[deviceData.deviceName] = SignalQualityDisplay( + averageRSSI: avgRSSI, + losPercentage: losPercentage, + averageStrength: avgStrength, + dataPointCount: history.count + ) + + // データ履歴からも(0,0)付近のデータをカウント + for data in history { + let historyPosition = self.calculatePosition( + distance: data.distance, + elevation: data.elevation, + azimuth: data.azimuth + ) + if self.isPositionSuspicious(historyPosition) { + zeroDataCount += 1 + } + } + } } + self.currentSensingDataPoints = tempDataPoints + self.signalQualityByAntenna = qualityByAntenna + self.currentDataPointCount = qualityByAntenna.values.map { $0.dataPointCount }.reduce( + 0, +) + self.suspiciousZeroDataCount = zeroDataCount } } - // センシング停止 - sensingControl.stopRemoteSensing() + // 接続が切断された場合はエラー + if connectionLostDuringSensing { + throw NSError( + domain: "AutoAntennaCalibration", + code: -5, + userInfo: [ + NSLocalizedDescriptionKey: + "センシング中に接続が切断されました。接続を復旧してから再度測定してください。" + ] + ) + } + + // センシング完了時の経過時間を最終値に設定 + self.sensingElapsedTime = self.sensingDuration + + // センシング停止(特定デバイスのみ) + sensingControl.stopRemoteSensingForDevice(deviceName: targetDeviceName) - print("🛑 センシング停止") + print("🛑 センシング停止: デバイス=\(targetDeviceName)") // センシング停止後、リモートデバイスからのデータ送信を待つ // CSVファイルの受信とRealtimeDataの更新を待機 @@ -510,31 +1225,59 @@ class AutoAntennaCalibrationViewModel: ObservableObject { ) } - // 各デバイスからデータを収集 - for deviceData in realtimeUsecase.deviceRealtimeDataList { - guard deviceData.isActive else { continue } - - print("📊 デバイス \(deviceData.deviceName) のデータ収集: \(deviceData.dataHistory.count)件") - - // データ履歴から座標を取得 - for data in deviceData.dataHistory { - // UWBデータから3D座標を計算 - let position = self.calculatePosition( - distance: data.distance, - elevation: data.elevation, - azimuth: data.azimuth - ) - - // AutoAntennaCalibrationUsecaseにデータを追加 - // 注: antennaIdとして現在選択中のアンテナIDを使用 - await usecase.addMeasuredData( - antennaId: antennaId, - tagId: tagPos.tagId, - measuredPosition: position - ) - - print(" ➕ データ追加: antenna=\(antennaId), tag=\(tagPos.tagId), pos=(\(String(format: "%.2f", position.x)), \(String(format: "%.2f", position.y)))") - } + print("🎯 ターゲットデバイス: \(targetDeviceName) (アンテナ: \(antennaId))") + + // 選択中のアンテナに紐づいたデバイスのデータだけを収集 + guard + let targetDeviceData = realtimeUsecase.deviceRealtimeDataList.first(where: { + $0.deviceName == targetDeviceName && $0.isActive + }) + else { + throw NSError( + domain: "AutoAntennaCalibration", + code: -3, + userInfo: [ + NSLocalizedDescriptionKey: + "デバイス \(targetDeviceName) からのデータが取得できませんでした。接続状態を確認してください。" + ] + ) + } + + print( + "📊 デバイス \(targetDeviceData.deviceName) のデータ収集: \(targetDeviceData.dataHistory.count)件" + ) + + // データが取得できなかった場合はエラー + if targetDeviceData.dataHistory.isEmpty { + throw NSError( + domain: "AutoAntennaCalibration", + code: -4, + userInfo: [ + NSLocalizedDescriptionKey: + "デバイス \(targetDeviceName) からのセンサーデータが0件でした。接続状態を確認し、再度測定してください。" + ] + ) + } + + // データ履歴から座標を取得 + for data in targetDeviceData.dataHistory { + // UWBデータから3D座標を計算 + let position = self.calculatePosition( + distance: data.distance, + elevation: data.elevation, + azimuth: data.azimuth + ) + + // AutoAntennaCalibrationUsecaseにデータを追加 + await usecase.addMeasuredData( + antennaId: antennaId, + tagId: tagPos.tagId, + measuredPosition: position + ) + + print( + " ➕ データ追加: antenna=\(antennaId), tag=\(tagPos.tagId), pos=(\(String(format: "%.2f", position.x)), \(String(format: "%.2f", position.y)))" + ) } // リアルタイムデータをクリア diff --git a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapSettingPage/FloorMapSettingView.swift b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapSettingPage/FloorMapSettingView.swift index 4864750..1e21890 100644 --- a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapSettingPage/FloorMapSettingView.swift +++ b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapSettingPage/FloorMapSettingView.swift @@ -252,8 +252,8 @@ struct FloorMapSettingView: View { Button("次へ") { Task { - if await self.viewModel.saveFloorMapSettings() { - self.flowNavigator.proceedToNextStep() + if let floorMapId = await self.viewModel.saveFloorMapSettings() { + self.flowNavigator.proceedToNextStep(floorMapId: floorMapId) } } } diff --git a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapSettingPage/FloorMapSettingViewModel.swift b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapSettingPage/FloorMapSettingViewModel.swift index a38c967..7458e7e 100644 --- a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapSettingPage/FloorMapSettingViewModel.swift +++ b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapSettingPage/FloorMapSettingViewModel.swift @@ -90,10 +90,10 @@ class FloorMapSettingViewModel: ObservableObject { self.isImagePickerPresented = true } - func saveFloorMapSettings() async -> Bool { + func saveFloorMapSettings() async -> String? { guard self.canProceedToNext else { self.showError("必要な情報がすべて入力されていません") - return false + return nil } self.isLoading = true @@ -118,38 +118,22 @@ class FloorMapSettingViewModel: ObservableObject { #if DEBUG print("✅ フロアマップをSwiftDataに保存成功: \(floorMapInfo.name)") #endif - - // プロジェクト進行状況を初期化して保存 - let projectProgress = ProjectProgress( - floorMapId: floorMapInfo.id, - currentStep: .floorMapSetting, - completedSteps: [.floorMapSetting] // フロアマップ設定完了 - ) - - try await repository.saveProjectProgress(projectProgress) - #if DEBUG - print("✅ プロジェクト進行状況を保存成功: \(projectProgress.currentStep.displayName)") - #endif - - // 保存直後に確認 - await self.verifyDataSaved( - repository: repository, floorMapInfo: floorMapInfo, projectProgress: projectProgress) } catch { #if DEBUG print("❌ SwiftDataへの保存エラー: \(error)") #endif self.showError("データベースへの保存に失敗しました: \(error.localizedDescription)") self.isLoading = false - return false + return nil } } self.isLoading = false - return true + return floorMapInfo.id } catch { self.showError("フロアマップ情報の保存に失敗しました: \(error.localizedDescription)") self.isLoading = false - return false + return nil } } @@ -199,7 +183,10 @@ class FloorMapSettingViewModel: ObservableObject { } private func saveFloorMapInfo(_ info: FloorMapInfo) throws { - // PreferenceRepositoryに基本情報を保存 + // PreferenceRepositoryにフロアマップ情報を保存(SensingFlowNavigatorの検証に必要) + self.preferenceRepository.saveCurrentFloorMapInfo(info) + + // PreferenceRepositoryに基本情報を保存(前回の設定を記憶) self.preferenceRepository.saveLastFloorSettings( name: info.name, buildingName: info.buildingName, @@ -211,9 +198,6 @@ class FloorMapSettingViewModel: ObservableObject { if let image = selectedFloorMapImage { try self.saveImageToDocuments(image, with: info.id) } - - // フロアマップ情報を保存 - self.preferenceRepository.saveCurrentFloorMapInfo(info) } #if os(iOS) @@ -251,53 +235,6 @@ class FloorMapSettingViewModel: ObservableObject { self.errorMessage = message self.showErrorAlert = true } - - /// 保存直後にデータが正常に保存されているかを確認 - private func verifyDataSaved( - repository: SwiftDataRepository, floorMapInfo: FloorMapInfo, projectProgress: ProjectProgress - ) async { - #if DEBUG - print("🔍 === 保存検証開始 ===") - - do { - // フロアマップの確認 - if let savedFloorMap = try await repository.loadFloorMap(by: floorMapInfo.id) { - print("✅ フロアマップ保存確認成功:") - print(" ID: \(savedFloorMap.id)") - print(" Name: \(savedFloorMap.name)") - print(" Building: \(savedFloorMap.buildingName)") - print(" Size: \(savedFloorMap.width) × \(savedFloorMap.depth)") - } else { - print("❌ フロアマップが見つかりません: ID=\(floorMapInfo.id)") - } - - // プロジェクト進行状況の確認 - if let savedProgress = try await repository.loadProjectProgress(by: projectProgress.id) { - print("✅ プロジェクト進行状況保存確認成功:") - print(" ID: \(savedProgress.id)") - print(" FloorMapID: \(savedProgress.floorMapId)") - print(" CurrentStep: \(savedProgress.currentStep.displayName)") - print( - " CompletedSteps: \(savedProgress.completedSteps.map { $0.displayName }.joined(separator: ", "))" - ) - } else { - print("❌ プロジェクト進行状況が見つかりません: ID=\(projectProgress.id)") - } - - // 全フロアマップの確認 - let allFloorMaps = try await repository.loadAllFloorMaps() - print("📊 データベース内の全フロアマップ: \(allFloorMaps.count)件") - for (index, floorMap) in allFloorMaps.enumerated() { - print(" [\(index + 1)] \(floorMap.name) (ID: \(floorMap.id))") - } - - } catch { - print("❌ 保存検証中にエラーが発生: \(error)") - } - - print("🔍 === 保存検証終了 ===") - #endif - } } // FloorMapInfoはCommonTypes.swiftで定義済み diff --git a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapView.swift b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapView.swift index f10ba79..c2a9c22 100644 --- a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapView.swift +++ b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapView.swift @@ -7,35 +7,23 @@ struct FloorMapView: View { @Environment(\.modelContext) private var modelContext var body: some View { - #if os(macOS) - NavigationSplitView { - VStack(spacing: 20) { - self.headerSection - - if self.viewModel.floorMaps.isEmpty { - self.emptyStateView - } else { - self.floorMapList - } + VStack { + VStack(spacing: 20) { + self.headerSection - Spacer() - - self.addFloorMapButton - } - .padding() - .navigationSplitViewColumnWidth(min: 300, ideal: 350) - } detail: { - if let selectedMap = viewModel.selectedFloorMap { - FloorMapDetailView(floorMap: selectedMap) + if self.viewModel.floorMaps.isEmpty { + self.emptyStateView } else { - Text("フロアマップを選択してください") - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(NSColor.controlBackgroundColor)) + self.floorMapList } + + Spacer() + + self.addFloorMapButton } + .padding() .onAppear { - print("📱 FloorMapView (macOS): onAppear called") + print("📱 FloorMapView (iOS): onAppear called") self.viewModel.setModelContext(self.modelContext) // データが空の場合は少し遅れて再読み込み @@ -49,41 +37,7 @@ struct FloorMapView: View { .onChange(of: self.modelContext) { _, newContext in self.viewModel.setModelContext(newContext) } - #else - NavigationView { - VStack(spacing: 20) { - self.headerSection - - if self.viewModel.floorMaps.isEmpty { - self.emptyStateView - } else { - self.floorMapList - } - - Spacer() - - self.addFloorMapButton - } - .padding() - .navigationTitle("フロアマップ") - .navigationBarTitleDisplayModeIfAvailable(.large) - .onAppear { - print("📱 FloorMapView (iOS): onAppear called") - self.viewModel.setModelContext(self.modelContext) - - // データが空の場合は少し遅れて再読み込み - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - if self.viewModel.floorMaps.isEmpty { - print("🔄 フロアマップが空のため再読み込み") - self.viewModel.loadFloorMaps() - } - } - } - .onChange(of: self.modelContext) { _, newContext in - self.viewModel.setModelContext(newContext) - } - } - #endif + } } private var headerSection: some View { @@ -133,7 +87,7 @@ struct FloorMapView: View { FloorMapRow(map: map) { self.viewModel.selectFloorMap(map) #if os(iOS) - self.router.push(.antennaConfiguration) + self.router.push(.antennaConfiguration(floorMapId: map.id)) #endif } onDelete: { self.viewModel.deleteFloorMap(map) @@ -267,7 +221,7 @@ struct FloorMapDetailView: View { // アクション VStack(spacing: 16) { Button(action: { - self.router.push(.antennaConfiguration) + self.router.push(.antennaConfiguration(floorMapId: self.floorMap.id)) }) { HStack { Image(systemName: "antenna.radiowaves.left.and.right") @@ -281,7 +235,7 @@ struct FloorMapDetailView: View { } Button(action: { - self.router.push(.pairingSettingPage) + self.router.push(.pairingSettingPage(floorMapId: self.floorMap.id)) }) { HStack { Image(systemName: "link.circle") @@ -295,7 +249,7 @@ struct FloorMapDetailView: View { } Button(action: { - self.router.push(.dataCollectionPage) + self.router.push(.dataCollectionPage(floorMapId: self.floorMap.id)) }) { HStack { Image(systemName: "play.circle") diff --git a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapViewModel.swift b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapViewModel.swift index 417fcfc..672d4fa 100644 --- a/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapViewModel.swift +++ b/UWBViewerSystem/Presentation/Scenes/FloorMapTab/FloorMapViewModel.swift @@ -9,23 +9,13 @@ struct FloorMap: Identifiable { let width: Double let height: Double var isActive: Bool - var projectProgress: ProjectProgress? var formattedSize: String { String(format: "%.1f × %.1f m", self.width, self.height) } - var progressPercentage: Double { - self.projectProgress?.completionPercentage ?? 0.0 - } - - var currentStepDisplayName: String { - self.projectProgress?.currentStep.displayName ?? "未開始" - } - init( - from floorMapInfo: FloorMapInfo, antennaCount: Int = 0, isActive: Bool = false, - projectProgress: ProjectProgress? = nil + from floorMapInfo: FloorMapInfo, antennaCount: Int = 0, isActive: Bool = false ) { self.id = floorMapInfo.id self.name = floorMapInfo.name @@ -33,12 +23,10 @@ struct FloorMap: Identifiable { self.width = floorMapInfo.width self.height = floorMapInfo.depth self.isActive = isActive - self.projectProgress = projectProgress } init( - id: String, name: String, antennaCount: Int, width: Double, height: Double, isActive: Bool, - projectProgress: ProjectProgress? = nil + id: String, name: String, antennaCount: Int, width: Double, height: Double, isActive: Bool ) { self.id = id self.name = name @@ -46,7 +34,6 @@ struct FloorMap: Identifiable { self.width = width self.height = height self.isActive = isActive - self.projectProgress = projectProgress } func toFloorMapInfo() -> FloorMapInfo { @@ -118,19 +105,10 @@ class FloorMapViewModel: ObservableObject { // アンテナ数をカウント(TODO: 実際のアンテナ数を取得) let antennaCount = self.getAntennaCount(for: floorMapInfo.id) - // プロジェクト進行状況を取得 - var projectProgress: ProjectProgress? - do { - projectProgress = try await repository.loadProjectProgress(for: floorMapInfo.id) - } catch { - print("⚠️ プロジェクト進行状況の読み込みエラー: \(error)") - } - let floorMap = FloorMap( from: floorMapInfo, antennaCount: antennaCount, - isActive: false, // 後で設定 - projectProgress: projectProgress + isActive: false // 後で設定 ) floorMaps.append(floorMap) } @@ -138,15 +116,12 @@ class FloorMapViewModel: ObservableObject { await MainActor.run { self.floorMaps = floorMaps print("✅ FloorMapViewModel: フロアマップ一覧をUIに反映: \(floorMaps.count)件") + for (index, map) in floorMaps.enumerated() { + print(" [\(index + 1)] ID: \(map.id), Name: \(map.name)") + } - // アクティブなフロアマップを設定 - if let activeId = getCurrentActiveFloorMapId(), - let index = self.floorMaps.firstIndex(where: { $0.id == activeId }) - { - self.floorMaps[index].isActive = true - self.selectedFloorMap = self.floorMaps[index] - print("🔄 アクティブなフロアマップを復元: \(self.selectedFloorMap?.name ?? "Unknown")") - } else if !self.floorMaps.isEmpty { + // デフォルトで最初のフロアマップを選択(グローバルな選択状態は保存しない) + if !self.floorMaps.isEmpty { self.floorMaps[0].isActive = true self.selectedFloorMap = self.floorMaps[0] print("🔄 デフォルトで最初のフロアマップを選択: \(self.selectedFloorMap?.name ?? "Unknown")") @@ -196,10 +171,6 @@ class FloorMapViewModel: ObservableObject { 0 } - private func getCurrentActiveFloorMapId() -> String? { - self.preferenceRepository.loadCurrentFloorMapInfo()?.id - } - private func updatePreferences() { self.preferenceRepository.setHasFloorMapConfigured(!self.floorMaps.isEmpty) } @@ -209,17 +180,6 @@ class FloorMapViewModel: ObservableObject { self.floorMaps[i].isActive = (self.floorMaps[i].id == map.id) } self.selectedFloorMap = map - - // UserDefaultsのcurrentFloorMapInfoを更新 - self.updateCurrentFloorMapInfo(map.toFloorMapInfo()) - } - - private func updateCurrentFloorMapInfo(_ floorMapInfo: FloorMapInfo) { - self.preferenceRepository.saveCurrentFloorMapInfo(floorMapInfo) - print("📍 FloorMapViewModel: currentFloorMapInfo updated to: \(floorMapInfo.name)") - - // フロアマップ変更を通知 - NotificationCenter.default.post(name: .init("FloorMapChanged"), object: floorMapInfo) } func toggleActiveFloorMap(_ map: FloorMap) { @@ -228,8 +188,6 @@ class FloorMapViewModel: ObservableObject { self.floorMaps[i].isActive.toggle() if self.floorMaps[i].isActive { self.selectedFloorMap = self.floorMaps[i] - // UserDefaultsのcurrentFloorMapInfoを更新 - self.updateCurrentFloorMapInfo(self.floorMaps[i].toFloorMapInfo()) for j in 0.. Void] = [:] @@ -73,12 +74,32 @@ class PairingSettingViewModel: ObservableObject { } } + /// 指定されたフロアマップ情報を読み込み + func loadFloorMapInfo(floorMapId: String) { + self.floorMapId = floorMapId + Task { + await self.loadFloorMapInfoById(floorMapId: floorMapId) + // フロアマップ情報読み込み後、アンテナ情報を読み込み + await self.loadAntennasFromPositionData() + } + } + + /// 指定されたIDのフロアマップ情報を読み込み + private func loadFloorMapInfoById(floorMapId: String) async { + do { + if let floorMap = try await swiftDataRepository.loadFloorMap(by: floorMapId) { + print("📍 フロアマップ情報読み込み完了: \(floorMap.name) (ID: \(floorMap.id))") + } else { + print("⚠️ フロアマップが見つかりません (ID: \(floorMapId))") + } + } catch { + print("❌ フロアマップ情報の読み込みに失敗: \(error)") + } + } + // MARK: - Data Management private func loadSampleAntennas() { - // まず、保存されたアンテナ位置情報から読み込む - self.loadAntennasFromPositionData() - // データがない場合は従来の方法で読み込む if self.selectedAntennas.isEmpty { // FieldSettingViewModelから保存されたアンテナ設定を読み込み @@ -102,29 +123,33 @@ class PairingSettingViewModel: ObservableObject { } /// 保存されたアンテナ位置データから読み込む - private func loadAntennasFromPositionData() { - Task { - do { - // SwiftDataからアンテナ位置データを読み込み - if let floorMapInfo = getCurrentFloorMapInfo() { - let positionData = try await swiftDataRepository.loadAntennaPositions(for: floorMapInfo.id) - - await MainActor.run { - self.selectedAntennas = positionData.map { position in - AntennaInfo( - id: position.antennaId, - name: position.antennaName, - coordinates: position.position - ) - } - print("✅ SwiftDataからアンテナ位置情報を読み込み: \(self.selectedAntennas.count)台") - } - } - } catch { - print("❌ アンテナ位置データの読み込みエラー: \(error)") + private func loadAntennasFromPositionData() async { + do { + // SwiftDataからアンテナ位置データを読み込み + guard let floorMapId = self.floorMapId else { + print("⚠️ floorMapIdが設定されていません") await MainActor.run { self.loadAntennasFromUserDefaults() } + return + } + + let positionData = try await swiftDataRepository.loadAntennaPositions(for: floorMapId) + + await MainActor.run { + self.selectedAntennas = positionData.map { position in + AntennaInfo( + id: position.antennaId, + name: position.antennaName, + coordinates: position.position + ) + } + print("✅ SwiftDataからアンテナ位置情報を読み込み: \(self.selectedAntennas.count)台 (floorMapId: \(floorMapId))") + } + } catch { + print("❌ アンテナ位置データの読み込みエラー: \(error)") + await MainActor.run { + self.loadAntennasFromUserDefaults() } } } @@ -166,48 +191,9 @@ class PairingSettingViewModel: ObservableObject { } private func loadPairingData() async { - do { - // SwiftDataからペアリングデータを読み込み - let pairings = try await swiftDataRepository.loadAntennaPairings() - self.antennaPairings = pairings - - // ペアリング済みデバイスをavailableDevicesに追加 - for pairing in pairings { - if !self.availableDevices.contains(where: { $0.id == pairing.device.id }) { - var restoredDevice = pairing.device - // 復元されたデバイスは一旦未接続状態として表示 - restoredDevice.isConnected = false - self.availableDevices.append(restoredDevice) - } - } - - // 接続状態を復元(ペアリングがあるかどうかで判定) - self.isConnected = !pairings.isEmpty - } catch { - print("Error loading pairing data: \(error)") - // エラーの場合は空の配列を設定 - self.antennaPairings = [] - self.isConnected = false - } - } - - private func savePairingData() { - Task { - do { - // 既存のペアリングデータを全て削除してから新しいデータを保存 - let existingPairings = try await swiftDataRepository.loadAntennaPairings() - for existingPairing in existingPairings { - try await self.swiftDataRepository.deleteAntennaPairing(by: existingPairing.id) - } - - // 現在のペアリングデータを保存 - for pairing in self.antennaPairings { - try await self.swiftDataRepository.saveAntennaPairing(pairing) - } - } catch { - print("Error saving pairing data: \(error)") - } - } + // ペアリング情報はConnectionManagementUsecaseで管理されるため、 + // ここでは何もしない(互換性のため残す) + self.isConnected = !self.connectionUsecase.antennaPairings.isEmpty } // MARK: - Device Discovery @@ -244,14 +230,12 @@ class PairingSettingViewModel: ObservableObject { func pairAntennaWithDevice(antenna: AntennaInfo, device: AndroidDevice) { // 1対1対応: 同じアンテナまたは同じ端末が既にペアリングされているかチェック if self.antennaPairings.contains(where: { $0.antenna.id == antenna.id }) { - self.alertMessage = "\(antenna.name)は既に他の端末とペアリング済みです" - self.showingConnectionAlert = true + print("⚠️ \(antenna.name)は既に他の端末とペアリング済みです") return } if self.antennaPairings.contains(where: { $0.device.id == device.id }) { - self.alertMessage = "\(device.name)は既に他のアンテナとペアリング済みです" - self.showingConnectionAlert = true + print("⚠️ \(device.name)は既に他のアンテナとペアリング済みです") return } @@ -262,14 +246,16 @@ class PairingSettingViewModel: ObservableObject { // アンテナ紐付け時に実際のペアリング(接続)を実行 if device.isNearbyDevice { - // まずペアリング情報を作成・保存 + // ペアリング情報を作成 let pairing = AntennaPairing(antenna: antenna, device: device) self.antennaPairings.append(pairing) - self.savePairingData() + + // ConnectionManagementUsecaseに登録 + self.connectionUsecase.pairAntennaWithDevice(antennaId: antenna.id, deviceName: device.name) // 接続済みの場合の処理 if device.isConnected { - self.alertMessage = "\(antenna.name) と \(device.name) の紐付けが完了しました(既に接続済み)" + print("✅ \(antenna.name) と \(device.name) の紐付けが完了しました(既に接続済み)") // 接続済みデバイスには即座にペアリング情報を送信 let pairingInfo = "PAIRING:\(antenna.id):\(antenna.name)" self.nearbyRepository.sendDataToDevice(text: pairingInfo, toEndpointId: device.id) @@ -279,7 +265,7 @@ class PairingSettingViewModel: ObservableObject { print("📞 [pairAntennaWithDevice] 接続要求ハンドラーを使用して接続承認") handler(true) // 接続を承認してペアリング完了 self.connectionRequestHandlers.removeValue(forKey: device.id) - self.alertMessage = "\(antenna.name) と \(device.name) の紐付け・接続を開始しました" + print("✅ \(antenna.name) と \(device.name) の紐付け・接続を開始しました") } else { // ハンドラーがない場合は、直接接続要求を送信 print("📞 [pairAntennaWithDevice] ハンドラーなし。直接接続要求を送信") @@ -289,30 +275,33 @@ class PairingSettingViewModel: ObservableObject { // 直接接続要求を送信 self.nearbyRepository.requestConnection(to: device.id, deviceName: device.name) - self.alertMessage = "\(antenna.name) と \(device.name) の紐付けを作成し、接続を開始中..." + print("✅ \(antenna.name) と \(device.name) の紐付けを作成し、接続を開始中...") } } - self.showingConnectionAlert = true } else { // 従来のロジック(互換性のため) let pairing = AntennaPairing(antenna: antenna, device: device) self.antennaPairings.append(pairing) + // ConnectionManagementUsecaseに登録 + self.connectionUsecase.pairAntennaWithDevice(antennaId: antenna.id, deviceName: device.name) + if let index = availableDevices.firstIndex(where: { $0.id == device.id }) { self.availableDevices[index].isConnected = true } self.isConnected = true - self.savePairingData() - self.alertMessage = "\(antenna.name) と \(device.name) のペアリングが完了しました" - self.showingConnectionAlert = true + print("✅ \(antenna.name) と \(device.name) のペアリングが完了しました") } } func removePairing(_ pairing: AntennaPairing) { self.antennaPairings.removeAll { $0.id == pairing.id } + // ConnectionManagementUsecaseからも削除 + self.connectionUsecase.unpairAntenna(antennaId: pairing.antenna.id) + // 1対1対応なので、ペアリング削除時は必ず接続を切断 // デバイスの接続状態を更新 if let index = availableDevices.firstIndex(where: { $0.id == pairing.device.id }) { @@ -329,7 +318,6 @@ class PairingSettingViewModel: ObservableObject { // 接続状態を更新 self.isConnected = !self.antennaPairings.isEmpty - self.savePairingData() } func removeAllPairings() { @@ -342,6 +330,9 @@ class PairingSettingViewModel: ObservableObject { self.antennaPairings.removeAll() + // ConnectionManagementUsecaseからもすべて削除 + self.connectionUsecase.clearAllPairings() + // すべてのデバイスの接続状態をリセット for i in self.availableDevices.indices { self.availableDevices[i].isConnected = false @@ -351,19 +342,38 @@ class PairingSettingViewModel: ObservableObject { self.connectionRequestHandlers.removeAll() self.isConnected = false - self.savePairingData() } // MARK: - Navigation func proceedToNextStep() { guard self.canProceedToNextStep else { - self.alertMessage = "少なくとも1つのアンテナをAndroid端末とペアリングしてください" - self.showingConnectionAlert = true + print("⚠️ 少なくとも1つのアンテナをAndroid端末とペアリングしてください") return } - self.navigationModel.push(.systemCalibration) + // ペアリング情報はConnectionManagementUsecaseに既に登録済み + // UserDefaultsにも保存(互換性のため) + _ = self.savePairingForFlow() + + // 画面遷移 + if let floorMapId = self.floorMapId { + self.navigationModel.push(.systemCalibration(floorMapId: floorMapId)) + } + } + + /// フローナビゲーターで次へ進む + func saveAndProceedToNextStep(flowNavigator: SensingFlowNavigator) { + guard self.canProceedToNext else { + return + } + + // ペアリング情報はConnectionManagementUsecaseに既に登録済み + // UserDefaultsにも保存(互換性のため) + _ = self.savePairingForFlow() + + // 画面遷移 - floorMapIdを明示的に渡す + flowNavigator.proceedToNextStep(floorMapId: self.floorMapId) } func savePairingForFlow() -> Bool { @@ -398,24 +408,21 @@ class PairingSettingViewModel: ObservableObject { // MARK: - Connection Testing func testConnection(for pairing: AntennaPairing) { - self.alertMessage = "\(pairing.antenna.name) と \(pairing.device.name) の接続をテスト中..." - self.showingConnectionAlert = true + print("🔄 \(pairing.antenna.name) と \(pairing.device.name) の接続をテスト中...") if pairing.device.isNearbyDevice { // 実際のNearBy Connectionでテストメッセージを送信 let testMessage = "UWB_TEST_\(Date().timeIntervalSince1970)" self.nearbyRepository.sendDataToDevice(text: testMessage, toEndpointId: pairing.device.id) - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in - self?.alertMessage = "接続テスト完了:テストメッセージを送信しました" - self?.showingConnectionAlert = true + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + print("✅ 接続テスト完了:テストメッセージを送信しました") } } else { // シミュレート(従来の動作) - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { let isSuccess = Bool.random() // ランダムに成功/失敗を決定 - self?.alertMessage = isSuccess ? "接続テスト成功:正常に通信できています" : "接続テスト失敗:デバイスとの通信に問題があります" - self?.showingConnectionAlert = true + print(isSuccess ? "✅ 接続テスト成功:正常に通信できています" : "❌ 接続テスト失敗:デバイスとの通信に問題があります") } } } @@ -427,18 +434,6 @@ extension PairingSettingViewModel: NearbyRepositoryCallback { nonisolated func onConnectionStateChanged(state: String) { Task { @MainActor in print("PairingSettingViewModel - Connection State: \(state)") - - // 重要な状態変更をアラートで表示 - if state.contains("接続成功") || state.contains("接続完了") { - self.alertMessage = "接続状況: \(state)" - self.showingConnectionAlert = true - } else if state.contains("接続拒否") || state.contains("切断") { - self.alertMessage = "接続状況: \(state)" - self.showingConnectionAlert = true - } else if state.contains("エラー") { - self.alertMessage = "エラー: \(state)" - self.showingConnectionAlert = true - } } } @@ -467,17 +462,14 @@ extension PairingSettingViewModel: NearbyRepositoryCallback { } else { // 新しいデバイスを追加 self.availableDevices.append(device) - - self.alertMessage = "端末を保存しました: \(deviceName)" - self.showingConnectionAlert = true + print("📱 端末を保存しました: \(deviceName)") } // 接続要求ハンドラーを保存して後で使用(アンテナ紐付け時に使用) self.connectionRequestHandlers[endpointId] = responseHandler // 検索時も接続を承認するように変更 - self.alertMessage = "\(deviceName) からの接続要求を承認しました" - self.showingConnectionAlert = true + print("✅ \(deviceName) からの接続要求を承認しました") responseHandler(true) // 接続を承認 self.connectionRequestHandlers.removeValue(forKey: endpointId) @@ -511,9 +503,7 @@ extension PairingSettingViewModel: NearbyRepositoryCallback { if let pairing = antennaPairings.first(where: { $0.device.id == endpointId }) { let pairingInfo = "PAIRING:\(pairing.antenna.id):\(pairing.antenna.name)" self.nearbyRepository.sendDataToDevice(text: pairingInfo, toEndpointId: endpointId) - - self.alertMessage = "接続完了: \(pairing.device.name) にペアリング情報を送信しました" - self.showingConnectionAlert = true + print("✅ 接続完了: \(pairing.device.name) にペアリング情報を送信しました") } } else { // 接続失敗時の処理 @@ -536,7 +526,11 @@ extension PairingSettingViewModel: NearbyRepositoryCallback { // ペアリング情報からも削除 self.antennaPairings.removeAll { $0.device.id == endpointId } self.isConnected = !self.antennaPairings.isEmpty - self.savePairingData() + + // ConnectionManagementUsecaseからも削除 + if let pairing = self.antennaPairings.first(where: { $0.device.id == endpointId }) { + self.connectionUsecase.unpairAntenna(antennaId: pairing.antenna.id) + } } } @@ -640,8 +634,7 @@ extension PairingSettingViewModel: NearbyRepositoryCallback { } else { print(" ➕ 新しいデバイスとして追加") self.availableDevices.append(device) - self.alertMessage = "接続完了: \(deviceName) が一覧に追加されました" - self.showingConnectionAlert = true + print("✅ 接続完了: \(deviceName) が一覧に追加されました") } self.isConnected = true diff --git a/UWBViewerSystem/Presentation/Scenes/MainTab/MainTabView.swift b/UWBViewerSystem/Presentation/Scenes/MainTab/MainTabView.swift index 3d7d06e..bca91d8 100644 --- a/UWBViewerSystem/Presentation/Scenes/MainTab/MainTabView.swift +++ b/UWBViewerSystem/Presentation/Scenes/MainTab/MainTabView.swift @@ -1,14 +1,16 @@ +import SwiftData import SwiftUI struct MainTabView: View { + @Environment(\.modelContext) private var modelContext @EnvironmentObject var router: NavigationRouterModel @State private var selectedTab = 0 var body: some View { TabView(selection: self.$selectedTab) { - SensingView() + DataDisplayView() .tabItem { - Label("センシング", systemImage: "waveform.path.ecg") + Label("取得データ", systemImage: "chart.line.uptrend.xyaxis") } .tag(0) diff --git a/UWBViewerSystem/Presentation/Scenes/SensingTab/DataCollectionPage/DataCollectionView.swift b/UWBViewerSystem/Presentation/Scenes/SensingTab/DataCollectionPage/DataCollectionView.swift index 3b22157..97e50c3 100644 --- a/UWBViewerSystem/Presentation/Scenes/SensingTab/DataCollectionPage/DataCollectionView.swift +++ b/UWBViewerSystem/Presentation/Scenes/SensingTab/DataCollectionPage/DataCollectionView.swift @@ -1,43 +1,363 @@ +import SwiftData import SwiftUI /// データ取得専用画面 /// センシング制御に特化し、参考デザイン「Stitch Design-4.png」に対応 struct DataCollectionView: View { - @StateObject private var viewModel = DataCollectionViewModel() + /// 選択されたフロアマップID + let floorMapId: String + + @Environment(\.modelContext) private var modelContext + @StateObject private var viewModel: DataCollectionViewModel @EnvironmentObject var router: NavigationRouterModel @State private var sensingFileName = "" @State private var showFileNameAlert = false + @State private var isRealtimeDataExpanded = true + + init(floorMapId: String) { + self.floorMapId = floorMapId + // StateObjectの初期化はinitで行う必要がある + // ただし、modelContextはinitの段階ではアクセスできないため、 + // ViewModelにmodelContextを設定する別の方法を取る + _viewModel = StateObject(wrappedValue: DataCollectionViewModel()) + } var body: some View { - ScrollView { - VStack(spacing: 24) { - self.headerSection + ZStack { + // 背景: フロアマップを全画面表示 + if self.viewModel.currentFloorMapInfo != nil { + self.fullScreenFloorMap + } else { + Color.gray.opacity(0.1) + .ignoresSafeArea() + } - Divider() + // 前面: コントロールパネル(半透明背景) + VStack { + Spacer() - self.sensingControlCard + VStack(spacing: 16) { + // センシング制御(コンパクト版) + self.compactSensingControl - // リアルタイムセンサーデータ表示(常時表示) - VStack { - Text("🔍 デバッグ: リアルタイムセクション表示中") - .font(.caption2) - .foregroundColor(.red) - .padding(.bottom, 4) - self.realtimeDataDisplaySection + // リアルタイムデータ表示(コンパクト版) + if !self.viewModel.deviceRealtimeDataList.isEmpty { + self.compactRealtimeDataDisplay + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 20) + #if os(iOS) + .fill(Color(UIColor.systemBackground).opacity(0.95)) + #else + .fill(Color(NSColor.windowBackgroundColor).opacity(0.95)) + #endif + .shadow(radius: 10) + ) + .padding() + } + } + .navigationTitle("データ取得") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: { + #if os(iOS) + return .navigationBarLeading + #else + return .automatic + #endif + }()) { + Button(action: { + self.router.pop() + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text("戻る") + } + } } - self.recentSessionsCard + ToolbarItem(placement: { + #if os(iOS) + return .navigationBarTrailing + #else + return .automatic + #endif + }()) { + Button(action: { + self.router.push(.dataDisplayPage) + }) { + Image(systemName: "list.bullet") + } + } + } + .onAppear { + // ModelContextを使ってSwiftDataRepositoryを初期化 + self.viewModel.setupSwiftDataRepository(modelContext: self.modelContext) + // 指定されたフロアマップIDでフロアマップ情報を読み込み + self.viewModel.loadFloorMapInfo(floorMapId: self.floorMapId) + } + .alert("ファイル名が必要です", isPresented: self.$showFileNameAlert) { + Button("OK") {} + } message: { + Text("センシングを開始するには、ファイル名を入力してください。") + } + .overlay { + // 自動再接続中のオーバーレイ + if self.viewModel.isAttemptingReconnect { + self.reconnectingOverlay + } + } + .sheet(isPresented: self.$viewModel.showConnectionRecovery) { + ConnectionRecoveryView( + connectionUsecase: ConnectionManagementUsecase.shared, + isPresented: self.$viewModel.showConnectionRecovery + ) + } + } - // 下部のスペースを確保 - Spacer(minLength: 50) + // MARK: - Reconnection Overlay + + @ViewBuilder + private var reconnectingOverlay: some View { + ZStack { + Color.black.opacity(0.5) + .ignoresSafeArea() + + VStack(spacing: 20) { + ProgressView() + .scaleEffect(1.5) + #if os(iOS) + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + #endif + + VStack(spacing: 8) { + Text("再接続中...") + .font(.headline) + .foregroundColor(.white) + + Text("試行 \(self.viewModel.reconnectAttemptCount) / 3") + .font(.subheadline) + .foregroundColor(.white.opacity(0.8)) + + if self.viewModel.isSensingActive { + Text("センシングは一時停止中です") + .font(.caption) + .foregroundColor(.orange) + } + } } - .padding() + .padding(30) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.black.opacity(0.7)) + ) } - .navigationTitle("データ取得") - .alert("ファイル名が必要です", isPresented: self.$showFileNameAlert) { - Button("OK") {} - } message: { - Text("センシングを開始するには、ファイル名を入力してください。") + } + + // MARK: - Full Screen Floor Map + + private var fullScreenFloorMap: some View { + GeometryReader { geometry in + if let floorMapImage = self.viewModel.floorMapImage, + let floorMapInfo = self.viewModel.currentFloorMapInfo { + FloorMapCanvas( + floorMapImage: floorMapImage, + floorMapInfo: floorMapInfo, + calibrationPoints: nil, + onMapTap: nil, + enableZoom: true, + fixedHeight: nil, + showGrid: true + ) { canvasGeometry in + // アンテナ位置を表示 + ForEach(self.viewModel.allAntennaPositions, id: \.id) { antenna in + let normalizedPoint = canvasGeometry.realWorldToNormalized( + CGPoint(x: antenna.position.x, y: antenna.position.y) + ) + let screenPos = canvasGeometry.normalizedToImageCoordinate(normalizedPoint) + + ZStack { + Circle() + .fill(Color.red) + .frame(width: 20, height: 20) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 2) + ) + + Text(antenna.antennaId) + .font(.caption) + .fontWeight(.bold) + .foregroundColor(.white) + .padding(6) + .background(Color.red.opacity(0.9)) + .cornerRadius(6) + .offset(x: 0, y: -25) + } + .position(screenPos) + } + + // タグのリアルタイム位置を表示(表示モードに応じて切り替え) + switch self.viewModel.tagDisplayMode { + case .individual: + // 個別表示モード: 各アンテナからの観測位置を全て表示 + self.individualTagPositions(canvasGeometry: canvasGeometry) + case .integrated: + // 統合表示モード: 重心位置のみを表示 + self.integratedTagPositions(canvasGeometry: canvasGeometry) + } + } + .ignoresSafeArea() + } else { + // フロアマップ画像が読み込まれていない場合はローディング表示 + ZStack { + Color.secondary.opacity(0.1) + VStack(spacing: 12) { + ProgressView() + Text("フロアマップを読み込んでいます...") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .ignoresSafeArea() + } + } + } + + // MARK: - Compact Sensing Control + + private var compactSensingControl: some View { + VStack(spacing: 12) { + HStack { + // ファイル名入力 + TextField("ファイル名", text: self.$sensingFileName) + .textFieldStyle(.roundedBorder) + .disabled(self.viewModel.isSensingActive) + + // センシングトグルボタン + Button(action: { + if self.viewModel.isSensingActive { + self.stopSensing() + } else { + self.startSensing() + } + }) { + Image(systemName: self.viewModel.isSensingActive ? "stop.circle.fill" : "play.circle.fill") + .font(.title2) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .background(self.viewModel.isSensingActive ? Color.red : Color.green) + .clipShape(Circle()) + } + } + + // ステータス表示 + HStack { + Circle() + .fill(self.viewModel.isSensingActive ? Color.green : Color.gray) + .frame(width: 8, height: 8) + + Text(self.viewModel.sensingStatus) + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + if self.viewModel.isSensingActive { + Text(self.viewModel.elapsedTime) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + + // MARK: - Compact Realtime Data Display + + private var compactRealtimeDataDisplay: some View { + VStack(spacing: 8) { + // 表示モード切り替えスイッチ + HStack { + Text("タグ表示:") + .font(.caption) + .foregroundColor(.secondary) + + Picker("", selection: self.$viewModel.tagDisplayMode) { + ForEach(TagDisplayMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .frame(width: 160) + + Spacer() + + // 表示モードのヘルプアイコン + Menu { + Text("個別表示: 各アンテナからの観測位置を全て表示") + Text("統合表示: NLOSを考慮した重心位置を表示") + } label: { + Image(systemName: "questionmark.circle") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Divider() + + HStack { + Circle() + .fill(Color.green) + .frame(width: 8, height: 8) + + Text("\(self.viewModel.activeAntennaIds.count)個のアンテナ / \(self.viewModel.deviceRealtimeDataList.count)台のデバイス") + .font(.caption) + .fontWeight(.medium) + + Spacer() + + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + self.isRealtimeDataExpanded.toggle() + } + }) { + Image(systemName: self.isRealtimeDataExpanded ? "chevron.down" : "chevron.up") + .font(.caption) + } + .buttonStyle(.borderless) + } + + // アンテナごとのデータ表示 + if self.isRealtimeDataExpanded { + if !self.viewModel.activeAntennaIds.isEmpty { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 8) { + ForEach(Array(self.viewModel.activeAntennaIds.sorted()), id: \.self) { antennaId in + if let antennaDevices = self.viewModel.antennaDataMap[antennaId], !antennaDevices.isEmpty { + CompactAntennaGroupView(antennaId: antennaId, devices: antennaDevices) + } + } + } + } + .frame(maxHeight: 200) + } else { + // 従来の表示(アンテナIDがない場合のフォールバック) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(self.viewModel.deviceRealtimeDataList) { deviceData in + if let latestData = deviceData.latestData { + CompactDeviceDataView(deviceData: deviceData, latestData: latestData) + } + } + } + } + } + } } } @@ -394,6 +714,419 @@ struct DataCollectionView: View { .fontWeight(.semibold) } } + + // MARK: - Floor Map Display Section + + private var floorMapDisplaySection: some View { + VStack(spacing: 16) { + self.floorMapHeader + + if let floorMapInfo = viewModel.currentFloorMapInfo { + VStack(spacing: 12) { + self.floorMapInfo + + self.floorMapCanvas(floorMapInfo: floorMapInfo) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.blue.opacity(0.2), lineWidth: 1) + ) + + self.floorMapLegend + } + } else { + Text("フロアマップが読み込まれていません") + .font(.caption) + .foregroundColor(.secondary) + .padding() + } + } + .padding() + .background(Color.gray.opacity(0.05)) + .cornerRadius(16) + } + + private var floorMapHeader: some View { + HStack { + Image(systemName: "map") + .font(.title2) + .foregroundColor(.blue) + Text("フロアマップ - リアルタイム位置") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.primary) + } + } + + private var floorMapInfo: some View { + HStack { + Text(self.viewModel.currentFloorMapInfo?.name ?? "") + .font(.subheadline) + .fontWeight(.medium) + + Spacer() + + Text("タグ: \(self.viewModel.globalCoordinates.count)台") + .font(.caption) + .foregroundColor(.secondary) + + Text("アンテナ: \(self.viewModel.allAntennaPositions.count)台") + .font(.caption) + .foregroundColor(.secondary) + } + } + + private func floorMapCanvas(floorMapInfo: FloorMapInfo) -> some View { + Group { + if let floorMapImage = self.viewModel.floorMapImage { + FloorMapCanvas( + floorMapImage: floorMapImage, + floorMapInfo: floorMapInfo, + calibrationPoints: nil, + onMapTap: nil, + enableZoom: true, + fixedHeight: 400, + showGrid: true + ) { geometry in + // アンテナ位置を表示 + ForEach(self.viewModel.allAntennaPositions, id: \.id) { antenna in + let normalizedPoint = geometry.realWorldToNormalized( + CGPoint(x: antenna.position.x, y: antenna.position.y) + ) + let screenPos = geometry.normalizedToImageCoordinate(normalizedPoint) + + ZStack { + Circle() + .fill(Color.red) + .frame(width: 16, height: 16) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 2) + ) + + Text(antenna.antennaId) + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.white) + .padding(4) + .background(Color.red.opacity(0.8)) + .cornerRadius(4) + .offset(x: 0, y: -20) + } + .position(screenPos) + } + + // タグのリアルタイム位置を表示 + ForEach(Array(self.viewModel.globalCoordinates.keys.sorted()), id: \.self) { deviceName in + if let tagPos = self.viewModel.globalCoordinates[deviceName] { + let normalizedPoint = geometry.realWorldToNormalized( + CGPoint(x: tagPos.x, y: tagPos.y) + ) + let screenPos = geometry.normalizedToImageCoordinate(normalizedPoint) + + ZStack { + Circle() + .fill(Color.blue) + .frame(width: 12, height: 12) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 2) + ) + .overlay( + Circle() + .stroke(Color.blue.opacity(0.5), lineWidth: 8) + .scaleEffect(1.5) + .opacity(0.5) + ) + + Text(deviceName) + .font(.caption2) + .foregroundColor(.white) + .padding(4) + .background(Color.blue.opacity(0.8)) + .cornerRadius(4) + .offset(x: 0, y: -20) + } + .position(screenPos) + .animation(.easeInOut(duration: 0.3), value: screenPos) + } + } + } + } else { + Text("フロアマップ画像がありません") + .foregroundColor(.secondary) + } + } + } + + private var floorMapLegend: some View { + HStack(spacing: 20) { + HStack(spacing: 4) { + Circle() + .fill(Color.red) + .frame(width: 12, height: 12) + Text("アンテナ") + .font(.caption) + .foregroundColor(.secondary) + } + + HStack(spacing: 4) { + Circle() + .fill(Color.blue) + .frame(width: 12, height: 12) + Text("タグ") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + } + + // MARK: - Tag Position Views + + /// 個別表示モード: 各アンテナからの観測位置を全て表示 + @ViewBuilder + private func individualTagPositions(canvasGeometry: FloorMapCanvasGeometry) -> some View { + ForEach(Array(self.viewModel.globalCoordinates.keys.sorted()), id: \.self) { deviceName in + if let tagPos = self.viewModel.globalCoordinates[deviceName] { + let normalizedPoint = canvasGeometry.realWorldToNormalized( + CGPoint(x: tagPos.x, y: tagPos.y) + ) + let screenPos = canvasGeometry.normalizedToImageCoordinate(normalizedPoint) + + let deviceData = self.viewModel.deviceRealtimeDataList.first { $0.deviceName == deviceName } + let nlosValue = deviceData?.latestData?.nlos ?? 0 + let isNLOS = nlosValue == 1 + let dotColor = isNLOS ? Color.red : Color.blue + + ZStack { + Circle() + .stroke(dotColor.opacity(0.5), lineWidth: isNLOS ? 6 : 4) + .scaleEffect(isNLOS ? 2.5 : 2.0) + .opacity(isNLOS ? 0.5 : 0.3) + + Circle() + .fill(dotColor) + .frame(width: isNLOS ? 20 : 16, height: isNLOS ? 20 : 16) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 2) + ) + + if isNLOS { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundColor(.white) + } + + Text(deviceName) + .font(.caption) + .foregroundColor(.white) + .padding(6) + .background(dotColor.opacity(0.9)) + .cornerRadius(6) + .offset(x: 0, y: -25) + } + .position(screenPos) + .animation(.easeInOut(duration: 0.3), value: screenPos) + } + } + } + + /// 統合表示モード: NLOSを考慮した重心位置を表示 + @ViewBuilder + private func integratedTagPositions(canvasGeometry: FloorMapCanvasGeometry) -> some View { + ForEach(Array(self.viewModel.integratedTagCoordinates.values), id: \.id) { integrated in + let normalizedPoint = canvasGeometry.realWorldToNormalized( + CGPoint(x: integrated.integratedCoordinate.x, y: integrated.integratedCoordinate.y) + ) + let screenPos = canvasGeometry.normalizedToImageCoordinate(normalizedPoint) + + // NLOSのみの場合は信頼度が低いことを示す + let dotColor: Color = integrated.hasNLOSOnly ? Color.orange : Color.green + let confidenceLevel = integrated.confidence + + ZStack { + // 信頼度に応じたパルスエフェクト + Circle() + .stroke(dotColor.opacity(0.4), lineWidth: 4) + .scaleEffect(2.0 + (1.0 - confidenceLevel) * 0.5) + .opacity(0.3 + confidenceLevel * 0.2) + + // メインの円 + Circle() + .fill(dotColor) + .frame(width: 20, height: 20) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 2) + ) + + // 信頼度インジケータ + if integrated.hasNLOSOnly { + Image(systemName: "exclamationmark.circle.fill") + .font(.caption2) + .foregroundColor(.white) + } else if integrated.observations.count > 1 { + Text("\(integrated.observations.count)") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.white) + } + + // タグIDラベル + VStack(spacing: 2) { + Text(integrated.tagId) + .font(.caption) + .fontWeight(.semibold) + + // 観測情報 + HStack(spacing: 2) { + if integrated.losCount > 0 { + Text("L:\(integrated.losCount)") + .font(.system(size: 8)) + .foregroundColor(.green) + } + if integrated.nlosCount > 0 { + Text("N:\(integrated.nlosCount)") + .font(.system(size: 8)) + .foregroundColor(.orange) + } + } + } + .foregroundColor(.white) + .padding(6) + .background(dotColor.opacity(0.9)) + .cornerRadius(6) + .offset(x: 0, y: -35) + } + .position(screenPos) + .animation(.easeInOut(duration: 0.3), value: screenPos) + } + } +} + +// MARK: - Compact Antenna Group View + +struct CompactAntennaGroupView: View { + let antennaId: String + let devices: [DeviceRealtimeData] + @State private var isExpanded = true + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + // アンテナヘッダー + HStack { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.caption) + .foregroundColor(.red) + + Text("アンテナ: \(self.antennaId)") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Spacer() + + Text("\(self.devices.count)台") + .font(.caption2) + .foregroundColor(.secondary) + + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + self.isExpanded.toggle() + } + }) { + Image(systemName: self.isExpanded ? "chevron.down" : "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + .buttonStyle(.borderless) + } + .contentShape(Rectangle()) + + // デバイスリスト(展開時) + if self.isExpanded { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(self.devices) { deviceData in + if let latestData = deviceData.latestData { + CompactDeviceDataView(deviceData: deviceData, latestData: latestData) + } + } + } + } + } + } + .padding(8) + .background(Color.red.opacity(0.05)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.red.opacity(0.2), lineWidth: 1) + ) + .cornerRadius(8) + } +} + +// MARK: - Compact Device Data View + +struct CompactDeviceDataView: View { + let deviceData: DeviceRealtimeData + let latestData: RealtimeData + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(self.deviceData.deviceName) + .font(.caption2) + .fontWeight(.semibold) + + Spacer() + + // NLOS インジケータ + if self.latestData.nlos == 1 { + HStack(spacing: 2) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundColor(.red) + Text("NLOS") + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.red) + } + } + } + + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text("距離") + .font(.caption2) + .foregroundColor(.secondary) + Text("\(String(format: "%.1f", self.latestData.distance))m") + .font(.caption) + .fontWeight(.medium) + } + + Divider() + .frame(height: 20) + + VStack(alignment: .leading, spacing: 2) { + Text("RSSI") + .font(.caption2) + .foregroundColor(.secondary) + Text("\(String(format: "%.0f", self.latestData.rssi))dBm") + .font(.caption) + .fontWeight(.medium) + } + } + } + .padding(8) + .background(self.latestData.nlos == 1 ? Color.red.opacity(0.15) : Color.blue.opacity(0.1)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(self.latestData.nlos == 1 ? Color.red.opacity(0.3) : Color.clear, lineWidth: 1) + ) + .cornerRadius(8) + } } // MARK: - Realtime Device Card View @@ -451,8 +1184,12 @@ struct RealtimeDeviceCardView: View { .background( LinearGradient( gradient: Gradient(colors: [ - self.deviceData.isRecentlyUpdated ? Color.blue.opacity(0.05) : Color.gray.opacity(0.05), - self.deviceData.isRecentlyUpdated ? Color.green.opacity(0.05) : Color.gray.opacity(0.02), + self.latestData.nlos == 1 + ? Color.red.opacity(0.1) + : (self.deviceData.isRecentlyUpdated ? Color.blue.opacity(0.05) : Color.gray.opacity(0.05)), + self.latestData.nlos == 1 + ? Color.red.opacity(0.05) + : (self.deviceData.isRecentlyUpdated ? Color.green.opacity(0.05) : Color.gray.opacity(0.02)), ]), startPoint: .topLeading, endPoint: .bottomTrailing @@ -462,8 +1199,10 @@ struct RealtimeDeviceCardView: View { .overlay( RoundedRectangle(cornerRadius: 12) .stroke( - self.deviceData.isRecentlyUpdated ? Color.blue.opacity(0.3) : Color.gray.opacity(0.2), - lineWidth: 1 + self.latestData.nlos == 1 + ? Color.red.opacity(0.5) + : (self.deviceData.isRecentlyUpdated ? Color.blue.opacity(0.3) : Color.gray.opacity(0.2)), + lineWidth: self.latestData.nlos == 1 ? 2 : 1 ) ) } @@ -500,6 +1239,22 @@ struct RealtimeDeviceCardView: View { private var mainMeasurements: some View { VStack(spacing: 16) { + // NLOS警告表示 + if self.latestData.nlos == 1 { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text("Non-Line-of-Sight (NLOS) 検出") + .font(.caption) + .fontWeight(.bold) + .foregroundColor(.red) + Spacer() + } + .padding(8) + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + } + // 距離表示(進歩バー式) VStack(spacing: 8) { HStack { @@ -510,7 +1265,7 @@ struct RealtimeDeviceCardView: View { Text("\(String(format: "%.0f", self.latestData.distance)) cm") .font(.headline) .fontWeight(.bold) - .foregroundColor(.blue) + .foregroundColor(self.latestData.nlos == 1 ? .red : .blue) } DistanceProgressView(distance: self.latestData.distance, maxDistance: 1000.0) // 10m = 1000cm @@ -984,6 +1739,6 @@ struct AzimuthCompassView: View { } #Preview { - DataCollectionView() + DataCollectionView(floorMapId: "test-floor-map-id") .environmentObject(NavigationRouterModel()) } diff --git a/UWBViewerSystem/Presentation/Scenes/SensingTab/DataCollectionPage/DataCollectionViewModel.swift b/UWBViewerSystem/Presentation/Scenes/SensingTab/DataCollectionPage/DataCollectionViewModel.swift index cc5e2c5..f801d74 100644 --- a/UWBViewerSystem/Presentation/Scenes/SensingTab/DataCollectionPage/DataCollectionViewModel.swift +++ b/UWBViewerSystem/Presentation/Scenes/SensingTab/DataCollectionPage/DataCollectionViewModel.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import SwiftData import SwiftUI // MARK: - ViewModel @@ -15,22 +16,70 @@ class DataCollectionViewModel: ObservableObject { @Published var recentSessions: [SensingSession] = [] @Published var deviceRealtimeDataList: [DeviceRealtimeData] = [] + // フロアマップ表示用 + @Published var currentFloorMapInfo: FloorMapInfo? + @Published var allAntennaPositions: [AntennaPositionData] = [] + @Published var globalCoordinates: [String: Point3D] = [:] + + // 複数アンテナ対応 + @Published var antennaDataMap: [String: [DeviceRealtimeData]] = [:] // アンテナID別のデータ + @Published var activeAntennaIds = Set() // アクティブなアンテナIDのセット + @Published var totalDataPointCount = 0 // 全アンテナの総データポイント数 + + // タグ表示モード + @Published var tagDisplayMode: TagDisplayMode = .integrated // 統合表示をデフォルトに + @Published var centroidCoordinate: Point3D? + @Published var integratedTagCoordinates: [String: IntegratedTagPosition] = [:] + + // MARK: - Connection Recovery State + + /// 接続復旧画面を表示するかどうか + @Published var showConnectionRecovery: Bool = false + + /// 再接続中かどうか(UIに表示用) + @Published var isAttemptingReconnect: Bool = false + + /// 再接続試行回数 + @Published var reconnectAttemptCount: Int = 0 + + /// 切断前の状態を保存 + private var wasSensingBeforeDisconnect: Bool = false + private var sensingFileNameBeforeDisconnect: String = "" + + /// 最大再接続試行回数 + private let maxAutoReconnectAttempts: Int = 3 + + /// 接続監視が設定済みかどうか + private var isConnectionMonitoringSetup: Bool = false + + #if canImport(UIKit) + #if os(iOS) + @Published var floorMapImage: UIImage? + #endif + #endif + + #if os(macOS) + @Published var floorMapImage: NSImage? + #endif + private var currentSession: SensingSession? private var sensingTimer: Timer? private var startTime: Date? private var cancellables = Set() // DI対応: 必要なUseCaseとRepositoryを直接注入 - private let sensingControlUsecase: SensingControlUsecase + private var sensingControlUsecase: SensingControlUsecase private let connectionUsecase: ConnectionManagementUsecase private let realtimeDataUsecase: RealtimeDataUsecase private let preferenceRepository: PreferenceRepositoryProtocol + private var swiftDataRepository: SwiftDataRepository? init( sensingControlUsecase: SensingControlUsecase? = nil, connectionUsecase: ConnectionManagementUsecase? = nil, realtimeDataUsecase: RealtimeDataUsecase? = nil, - preferenceRepository: PreferenceRepositoryProtocol = PreferenceRepository() + preferenceRepository: PreferenceRepositoryProtocol = PreferenceRepository(), + swiftDataRepository: SwiftDataRepository? = nil ) { let defaultConnectionUsecase = connectionUsecase ?? ConnectionManagementUsecase.shared @@ -40,6 +89,7 @@ class DataCollectionViewModel: ObservableObject { sensingControlUsecase ?? SensingControlUsecase(connectionUsecase: defaultConnectionUsecase) self.realtimeDataUsecase = realtimeDataUsecase ?? RealtimeDataUsecase() self.preferenceRepository = preferenceRepository + self.swiftDataRepository = swiftDataRepository self.loadRecentSessions() self.setupObservers() @@ -53,23 +103,337 @@ class DataCollectionViewModel: ObservableObject { } private func setupObservers() { + // 既存の購読をクリア + self.cancellables.removeAll() + // 直接注入されたUsecaseからの状態を監視 self.sensingControlUsecase.$isSensingControlActive - .assign(to: &self.$isSensingActive) + .sink { [weak self] value in + self?.isSensingActive = value + } + .store(in: &self.cancellables) self.sensingControlUsecase.$sensingStatus - .assign(to: &self.$sensingStatus) + .sink { [weak self] value in + self?.sensingStatus = value + } + .store(in: &self.cancellables) self.connectionUsecase.$connectedEndpoints .map { $0.count } - .assign(to: &self.$connectedDeviceCount) + .sink { [weak self] value in + self?.connectedDeviceCount = value + } + .store(in: &self.cancellables) self.realtimeDataUsecase.$deviceRealtimeDataList - .assign(to: &self.$deviceRealtimeDataList) + .sink { [weak self] value in + self?.deviceRealtimeDataList = value + } + .store(in: &self.cancellables) self.realtimeDataUsecase.$deviceRealtimeDataList .map { $0.count } - .assign(to: &self.$dataPointCount) + .sink { [weak self] value in + self?.dataPointCount = value + } + .store(in: &self.cancellables) + + // グローバル座標の購読 + self.realtimeDataUsecase.$globalCoordinates + .sink { [weak self] value in + self?.globalCoordinates = value + } + .store(in: &self.cancellables) + + // 複数アンテナ対応のプロパティを購読 + self.realtimeDataUsecase.$antennaDataMap + .sink { [weak self] value in + self?.antennaDataMap = value + } + .store(in: &self.cancellables) + + self.realtimeDataUsecase.$activeAntennaIds + .sink { [weak self] value in + self?.activeAntennaIds = value + } + .store(in: &self.cancellables) + + self.realtimeDataUsecase.$totalDataPointCount + .sink { [weak self] value in + self?.totalDataPointCount = value + } + .store(in: &self.cancellables) + + // 重心座標の購読 + self.realtimeDataUsecase.$centroidCoordinate + .sink { [weak self] value in + self?.centroidCoordinate = value + } + .store(in: &self.cancellables) + + // 統合タグ座標の購読 + self.realtimeDataUsecase.$integratedTagCoordinates + .sink { [weak self] value in + self?.integratedTagCoordinates = value + } + .store(in: &self.cancellables) + + // 注意: setupConnectionMonitoring()はsetupSwiftDataRepository()から呼ばれる + // init()からは呼ばない(画面表示前に初期チェックが実行されるのを防ぐ) + } + + // MARK: - Connection Recovery + + private func setupConnectionMonitoring() { + // cancellables.removeAll()で購読がクリアされるため、ガードは不要 + // 毎回再設定する + self.isConnectionMonitoringSetup = true + + let connectionUsecase = ConnectionManagementUsecase.shared + + // 初期状態をチェック:既に接続エラーがある場合や、接続デバイスがない場合 + Task { @MainActor in + // 少し待機して画面遷移を完了させる + try? await Task.sleep(nanoseconds: 500_000_000) + + // 既に再接続中の場合はスキップ + guard !self.isAttemptingReconnect else { + print("ℹ️ DataCollection: 既に再接続試行中のためスキップ") + return + } + + // 接続エラーがあるか、接続デバイスがない場合は再接続を試みる + if connectionUsecase.hasConnectionError { + print("🔴 DataCollection: 初期化時に既存の接続エラーを検知") + self.handleConnectionError() + } else if !connectionUsecase.hasConnectedDevices() { + print("🔴 DataCollection: 初期化時に接続デバイスなしを検知") + connectionUsecase.hasConnectionError = true + self.handleConnectionError() + } + } + + // 継続的な接続エラー監視 + connectionUsecase.$hasConnectionError + .dropFirst() // 初期値をスキップ(上で処理済み) + .sink { [weak self] hasError in + guard let self else { return } + if hasError { + print("🔴 DataCollection: 接続エラーを検知") + self.handleConnectionError() + } + } + .store(in: &self.cancellables) + } + + /// 接続エラー時の処理 + private func handleConnectionError() { + // 既に再接続中の場合はスキップ + guard !self.isAttemptingReconnect else { + print("ℹ️ DataCollection: 既に再接続試行中のためスキップ") + return + } + + // センシング中の場合は状態を保存 + if self.isSensingActive { + print("⚠️ センシング中に接続が切断されました") + self.wasSensingBeforeDisconnect = true + self.sensingFileNameBeforeDisconnect = self.currentFileName + } else { + self.wasSensingBeforeDisconnect = false + } + + // 自動再接続を開始 + Task { + await self.attemptAutoReconnect() + } + } + + /// 自動再接続を試行 + private func attemptAutoReconnect() async { + self.isAttemptingReconnect = true + self.reconnectAttemptCount = 0 + + let connectionUsecase = ConnectionManagementUsecase.shared + + // 自動再接続中フラグを設定(アラート抑制用) + connectionUsecase.isAutoReconnecting = true + + for attempt in 1...self.maxAutoReconnectAttempts { + self.reconnectAttemptCount = attempt + print("🔄 DataCollection: 再接続試行 \(attempt)/\(self.maxAutoReconnectAttempts)") + + // 既存の接続をリセット + connectionUsecase.resetAll() + + // 少し待機 + try? await Task.sleep(nanoseconds: 1_000_000_000) + + // エラーフラグをクリアして再接続開始 + connectionUsecase.hasConnectionError = false + connectionUsecase.lastDisconnectedDevice = nil + connectionUsecase.startAdvertising() + connectionUsecase.startDiscovery() + + // 接続確立を待機(最大8秒) + for _ in 0..<16 { + try? await Task.sleep(nanoseconds: 500_000_000) + + if connectionUsecase.hasConnectedDevices() { + print("✅ DataCollection: 再接続成功") + self.isAttemptingReconnect = false + connectionUsecase.isAutoReconnecting = false + + // センシング再開 + await self.handleReconnectionSuccess() + return + } + } + + // バックオフ:次の試行まで待機時間を増やす + let backoffSeconds = attempt * 2 + print("⏳ 次の再接続試行まで \(backoffSeconds) 秒待機...") + try? await Task.sleep(nanoseconds: UInt64(backoffSeconds * 1_000_000_000)) + } + + // すべての試行が失敗 + self.isAttemptingReconnect = false + connectionUsecase.isAutoReconnecting = false + self.showConnectionRecovery = true + print("❌ DataCollection: 自動再接続失敗") + } + + /// 再接続成功時の処理 + private func handleReconnectionSuccess() async { + print("🔄 DataCollection: センシング状態を復元中...") + + // センシング中だった場合は再開 + if self.wasSensingBeforeDisconnect { + // 少し待機してから再開 + try? await Task.sleep(nanoseconds: 1_000_000_000) + + // センシングを再開 + if !self.sensingFileNameBeforeDisconnect.isEmpty { + self.startSensing(fileName: self.sensingFileNameBeforeDisconnect) + print("▶️ センシングを再開しました: \(self.sensingFileNameBeforeDisconnect)") + } + } + + // 状態をクリア + self.wasSensingBeforeDisconnect = false + self.sensingFileNameBeforeDisconnect = "" + } + + /// SwiftDataRepositoryを設定(ViewのonAppearから呼ばれる) + func setupSwiftDataRepository(modelContext: ModelContext) { + if self.swiftDataRepository == nil { + let repository = SwiftDataRepository(modelContext: modelContext) + self.swiftDataRepository = repository + + // SensingControlUsecaseを新しく作成(正しいSwiftDataRepositoryを使用) + self.sensingControlUsecase = SensingControlUsecase( + connectionUsecase: self.connectionUsecase, + swiftDataRepository: repository + ) + print("✅ SensingControlUsecaseに正しいSwiftDataRepositoryを設定しました") + + // RealtimeDataUsecaseにSwiftDataRepositoryを設定 + self.realtimeDataUsecase.updateSwiftDataRepository(repository) + + // RealtimeDataUsecaseにSensingControlUsecaseを設定(データ永続化に必要) + self.realtimeDataUsecase.setSensingControlUsecase(self.sensingControlUsecase) + print("✅ RealtimeDataUsecaseにSensingControlUsecaseを設定しました") + + // ConnectionManagementUsecaseにRealtimeDataUsecaseを設定 + self.connectionUsecase.realtimeDataUsecase = self.realtimeDataUsecase + print("✅ ConnectionManagementUsecaseにRealtimeDataUsecaseを設定しました") + + // Observersを再設定(新しいSensingControlUsecaseのイベントを購読) + self.setupObservers() + + // 接続監視を設定(画面表示後に呼ばれるため、ここで設定) + self.setupConnectionMonitoring() + + self.loadInitialData() + } + } + + /// 初期データの読み込み(非推奨:loadFloorMapInfo(floorMapId:)を使用すること) + private func loadInitialData() { + Task { + // フロアマップは画面遷移時に明示的に指定されるため、ここでは読み込まない + await self.loadAntennaPositions() + } + } + + /// 指定されたフロアマップ情報を読み込み + func loadFloorMapInfo(floorMapId: String) { + Task { + await self.loadFloorMapInfoById(floorMapId: floorMapId) + await self.loadAntennaPositions() + } + } + + /// 指定されたIDのフロアマップ情報を読み込み + private func loadFloorMapInfoById(floorMapId: String) async { + guard let repository = swiftDataRepository else { + print("⚠️ SwiftDataRepositoryが利用できません") + return + } + + do { + if let floorMap = try await repository.loadFloorMap(by: floorMapId) { + self.currentFloorMapInfo = floorMap + + // フロアマップIDをRealtimeDataUsecaseに設定 + self.realtimeDataUsecase.setFloorMapId(floorMap.id) + + // フロアマップ画像を読み込み + #if canImport(UIKit) + #if os(iOS) + self.floorMapImage = floorMap.image + if self.floorMapImage != nil { + print("📍 フロアマップ画像読み込み成功: \(floorMap.name)") + } else { + print("⚠️ フロアマップ画像が見つかりません: \(floorMap.name)") + } + #endif + #endif + + #if os(macOS) + self.floorMapImage = floorMap.image + if self.floorMapImage != nil { + print("📍 フロアマップ画像読み込み成功: \(floorMap.name)") + } else { + print("⚠️ フロアマップ画像が見つかりません: \(floorMap.name)") + } + #endif + + print("📍 フロアマップ情報読み込み完了: \(floorMap.name) (ID: \(floorMap.id))") + } else { + print("⚠️ フロアマップが見つかりません (ID: \(floorMapId))") + } + } catch { + print("❌ フロアマップ情報の読み込みに失敗: \(error)") + } + } + + /// アンテナ位置情報を読み込み + private func loadAntennaPositions() async { + guard let repository = swiftDataRepository, + let floorMapId = currentFloorMapInfo?.id + else { + return + } + + do { + let positions = try await repository.loadAntennaPositions(for: floorMapId) + self.allAntennaPositions = positions + print("📍 アンテナ位置情報読み込み完了: \(positions.count)件") + } catch { + print("❌ アンテナ位置情報の読み込みに失敗: \(error)") + } } // MARK: - Sensing Control @@ -95,6 +459,24 @@ class DataCollectionViewModel: ObservableObject { // 直接SensingControlUsecaseを使用してセンシング停止 self.sensingControlUsecase.stopRemoteSensing() + // ファイル名を保存(非同期処理前にリセットされるのを防ぐ) + let savedFileName = self.currentFileName + + // センシングデータをCSVとしてエクスポート + Task { + // SwiftDataの永続化完了を待つため少し待機 + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5秒待機 + + // 保存したファイル名を使用してエクスポート + await self.exportSensingDataToCSV(fileName: savedFileName) + + // CSV出力完了後にSensingControlUsecaseのセッションIDをクリア + await MainActor.run { + // SensingControlUsecaseのcurrentSessionIdをクリア + // これにより次のセンシングセッションで新しいIDが使用される + } + } + // セッションを完了 if let session = currentSession, let _ = startTime { let endTime = Date() @@ -168,4 +550,205 @@ class DataCollectionViewModel: ObservableObject { self.recentSessions = sessions } } + + // MARK: - CSV Export + + /// センシングデータをCSVとしてエクスポート + /// + /// アンテナごとに分けて生データとグローバル座標変換後のデータをエクスポートします + private func exportSensingDataToCSV(fileName: String = "") async { + print("📊 CSVエクスポート開始") + print(" デバイス数: \(self.deviceRealtimeDataList.count)") + print(" アンテナ数: \(self.activeAntennaIds.count)") + print(" ファイル名(引数): '\(fileName)'") + print(" ファイル名(currentFileName): '\(self.currentFileName)'") + + // SensingControlUsecaseの実際のセッションIDを使用 + guard let sessionId = sensingControlUsecase.activeSessionId else { + print("⚠️ アクティブなセッションIDが見つかりません") + return + } + print(" 使用するセッションID: \(sessionId)") + + guard let sessionStartTime = self.startTime else { + print("⚠️ セッション開始時刻が見つかりません") + return + } + + do { + // SwiftDataから全リアルタイムデータを読み込み + let allRealtimeData = try await swiftDataRepository?.loadRealtimeData(for: sessionId) ?? [] + + print(" SwiftDataから読み込んだデータ数: \(allRealtimeData.count)") + + guard !allRealtimeData.isEmpty else { + print("⚠️ エクスポートするデータがありません") + return + } + + // アンテナ位置情報を取得してアンテナID→アンテナ名のマッピングを作成 + var antennaIdToNameMap: [String: String] = [:] + for antennaPosition in self.allAntennaPositions { + antennaIdToNameMap[antennaPosition.antennaId] = antennaPosition.antennaName + } + + // アンテナIDごとにデータをグループ化 + let groupedByAntenna = Dictionary(grouping: allRealtimeData) { $0.antennaId } + + print("📊 アンテナごとのデータ分布:") + for (antennaId, data) in groupedByAntenna { + print(" アンテナID: \(antennaId.isEmpty ? "空" : antennaId) - データ数: \(data.count)") + } + + // センシングファイル名を使用してセッションディレクトリを作成 + let fileNameToUse = fileName.isEmpty ? self.currentFileName : fileName + print("📁 センシングファイル名: '\(fileNameToUse)' (空?: \(fileNameToUse.isEmpty))") + let sessionDirectory = try SensingDataCSVExporter.createSessionDirectory( + startTime: sessionStartTime, + customName: fileNameToUse + ) + print("📁 作成されたセッションディレクトリ: \(sessionDirectory.path)") + + // 各アンテナごとにCSVファイルを出力 + var exportedFileCount = 0 + for (antennaId, antennaData) in groupedByAntenna { + guard !antennaId.isEmpty else { + print("⚠️ 空のアンテナIDをスキップ (データ数: \(antennaData.count))") + // 空のアンテナIDのデータも処理する(フォールバック) + if !antennaData.isEmpty { + // デバイス名でグループ化して処理 + let deviceGroups = Dictionary(grouping: antennaData) { $0.deviceName } + for (deviceName, deviceData) in deviceGroups { + print("📱 デバイス \(deviceName) のデータをエクスポート中...") + let sortedData = deviceData.sorted { $0.timestamp < $1.timestamp } + let baseFileName = fileNameToUse.isEmpty ? "sensing" : fileNameToUse + + // デバイス名を使用したファイル名 + let rawFileName = "\(baseFileName)_\(deviceName)_raw.csv" + let globalFileName = "\(baseFileName)_\(deviceName)_global.csv" + let filteredFileName = "\(baseFileName)_\(deviceName)_filtered.csv" + + // 生データをエクスポート + _ = try SensingDataCSVExporter.exportRawDataToCSV( + realtimeDataList: sortedData, + directoryURL: sessionDirectory, + fileName: rawFileName + ) + + // グローバル座標データ + var deviceGlobalCoordinates: [String: Point3D] = [:] + if let coord = self.globalCoordinates[deviceName] { + deviceGlobalCoordinates[deviceName] = coord + } + + _ = try SensingDataCSVExporter.exportGlobalCoordinateDataToCSV( + realtimeDataList: sortedData, + globalCoordinates: deviceGlobalCoordinates, + directoryURL: sessionDirectory, + fileName: globalFileName + ) + + // フィルタリング後データ + let processor = SensorDataProcessor() + _ = try SensingDataCSVExporter.exportFilteredDataToCSV( + realtimeDataList: sortedData, + globalCoordinates: deviceGlobalCoordinates, + processor: processor, + directoryURL: sessionDirectory, + fileName: filteredFileName + ) + + exportedFileCount += 3 + print("✅ デバイス \(deviceName) のデータエクスポート完了") + } + } + continue + } + + // アンテナ名を取得(登録されていない場合はアンテナIDを使用) + let antennaName = antennaIdToNameMap[antennaId] ?? antennaId + print("📡 アンテナ \(antennaName) のデータをエクスポート中...") + + // タイムスタンプでソート + let sortedData = antennaData.sorted { $0.timestamp < $1.timestamp } + + // アンテナ名を含むファイル名を生成 + let rawFileName = "\(fileNameToUse)_\(antennaName)_raw.csv" + let globalFileName = "\(fileNameToUse)_\(antennaName)_global.csv" + let filteredFileName = "\(fileNameToUse)_\(antennaName)_filtered.csv" + + // デバイス名をアンテナ名に置換したデータを作成 + let modifiedData = sortedData.map { data in + RealtimeData( + id: data.id, + deviceName: antennaName, // アンテナ名を使用 + timestamp: data.timestamp, + elevation: data.elevation, + azimuth: data.azimuth, + distance: data.distance, + nlos: data.nlos, + rssi: data.rssi, + seqCount: data.seqCount, + antennaId: data.antennaId + ) + } + + // 生データをエクスポート + _ = try SensingDataCSVExporter.exportRawDataToCSV( + realtimeDataList: modifiedData, + directoryURL: sessionDirectory, + fileName: rawFileName + ) + + // このアンテナに関連するグローバル座標のみを抽出 + var antennaGlobalCoordinates: [String: Point3D] = [:] + for data in antennaData { + if let coord = self.globalCoordinates[data.deviceName] { + antennaGlobalCoordinates[antennaName] = coord // アンテナ名をキーとして使用 + } + } + + // グローバル座標データをエクスポート + _ = try SensingDataCSVExporter.exportGlobalCoordinateDataToCSV( + realtimeDataList: modifiedData, + globalCoordinates: antennaGlobalCoordinates, + directoryURL: sessionDirectory, + fileName: globalFileName + ) + + // フィルタリング後データをエクスポート + let processor = SensorDataProcessor() + _ = try SensingDataCSVExporter.exportFilteredDataToCSV( + realtimeDataList: modifiedData, + globalCoordinates: antennaGlobalCoordinates, + processor: processor, + directoryURL: sessionDirectory, + fileName: filteredFileName + ) + + print("✅ アンテナ \(antennaName) のデータエクスポート完了") + print(" データポイント数: \(sortedData.count)") + } + + // 統合座標履歴をエクスポート + let integratedHistory = self.realtimeDataUsecase.getIntegratedCoordinateHistory() + if !integratedHistory.isEmpty { + let integratedFileName = "\(fileNameToUse)_integrated_coordinates.csv" + _ = try SensingDataCSVExporter.exportIntegratedCoordinateDataToCSV( + integratedCoordinateHistory: integratedHistory, + directoryURL: sessionDirectory, + fileName: integratedFileName + ) + print("✅ 統合座標データエクスポート完了: \(integratedHistory.count)件") + } else { + print("⚠️ 統合座標履歴データがありません") + } + + print("✅ 全センシングデータのCSVエクスポート成功") + print(" セッションディレクトリ: \(sessionDirectory.path)") + print(" アンテナ別ファイル数: \(groupedByAntenna.count * 3) ファイル") + } catch { + print("❌ CSVエクスポートエラー: \(error.localizedDescription)") + } + } } diff --git a/UWBViewerSystem/Presentation/Scenes/SensingTab/DataDisplayPage/DataDisplayView.swift b/UWBViewerSystem/Presentation/Scenes/SensingTab/DataDisplayPage/DataDisplayView.swift index 53d251e..5fc704e 100644 --- a/UWBViewerSystem/Presentation/Scenes/SensingTab/DataDisplayPage/DataDisplayView.swift +++ b/UWBViewerSystem/Presentation/Scenes/SensingTab/DataDisplayPage/DataDisplayView.swift @@ -6,43 +6,69 @@ import SwiftUI struct DataDisplayView: View { @Environment(\.modelContext) private var modelContext @StateObject private var viewModel = DataDisplayViewModel() - @StateObject private var flowNavigator = SensingFlowNavigator() @EnvironmentObject var router: NavigationRouterModel - @State private var selectedDisplayMode: DisplayMode = .history - - enum DisplayMode: String, CaseIterable { - case history = "履歴データ" - case files = "ファイル管理" - } + @State private var shareURL: URL? + @State private var showShareSheet = false + @State private var sessionToDelete: SensingSession? + @State private var showDeleteAlert = false var body: some View { VStack(spacing: 0) { - // フロープログレス表示 - SensingFlowProgressView(navigator: self.flowNavigator) - ScrollView { VStack(spacing: 20) { self.headerSection - self.displayModeSelector - - self.contentArea + self.historyDataView - Spacer(minLength: 80) + Spacer(minLength: 20) } .padding() } - - // ナビゲーションボタン - navigationButtons } - .navigationTitle("データ表示") + .navigationTitle("取得データ") + .sheet(isPresented: self.$showShareSheet, onDismiss: { + print("🎬 ShareSheetが閉じられました") + self.shareURL = nil + }) { + if let url = self.shareURL { + #if os(iOS) + ShareSheet(items: [url]) + .onAppear { + print("🎬 ShareSheetを表示: \(url.path)") + } + #else + Text("macOSでは共有機能は利用できません") + #endif + } else { + Text("共有するファイルが見つかりません") + .foregroundColor(.red) + .onAppear { + print("⚠️ shareURLがnilです") + } + } + } + .alert("セッション削除", isPresented: self.$showDeleteAlert, presenting: self.sessionToDelete) { session in + Button("キャンセル", role: .cancel) { + self.sessionToDelete = nil + } + Button("削除", role: .destructive) { + Task { + let success = await self.viewModel.deleteSessionData(session) + if success { + print("✅ セッション削除成功: \(session.name)") + } else { + print("❌ セッション削除失敗: \(session.name)") + } + self.sessionToDelete = nil + } + } + } message: { session in + Text("「\(session.name)」を削除しますか?\nSwiftDataとCSVファイルの両方が削除されます。") + } .onAppear { // ModelContextからSwiftDataRepositoryを作成してViewModelに設定 let repository = SwiftDataRepository(modelContext: modelContext) self.viewModel.setSwiftDataRepository(repository) - self.flowNavigator.currentStep = .dataViewer - self.flowNavigator.setRouter(self.router) } } @@ -66,30 +92,6 @@ struct DataDisplayView: View { } } - // MARK: - Display Mode Selector - - private var displayModeSelector: some View { - Picker("表示モード", selection: self.$selectedDisplayMode) { - ForEach(DisplayMode.allCases, id: \.self) { mode in - Text(mode.rawValue).tag(mode) - } - } - .pickerStyle(.segmented) - .padding(.horizontal) - } - - // MARK: - Content Area - - @ViewBuilder - private var contentArea: some View { - switch self.selectedDisplayMode { - case .history: - self.historyDataView - case .files: - self.fileManagementView - } - } - // MARK: - History Data View private var historyDataView: some View { @@ -117,81 +119,30 @@ struct DataDisplayView: View { ScrollView { LazyVStack(spacing: 8) { ForEach(self.viewModel.historyData, id: \.id) { session in - HistorySessionCard(session: session) { - self.viewModel.loadSessionData(session) - } - } - } - } - } - } - .padding() - .background(Color.gray.opacity(0.05)) - .cornerRadius(16) - } - - // MARK: - File Management View - - private var fileManagementView: some View { - VStack(spacing: 16) { - HStack { - Text("ファイル管理") - .font(.headline) - .fontWeight(.semibold) - - Spacer() - - Button(action: self.viewModel.openStorageFolder) { - HStack { - Image(systemName: "folder") - Text("フォルダを開く") - } - .font(.caption) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.blue.opacity(0.1)) - .foregroundColor(.blue) - .cornerRadius(6) - } - } - - if self.viewModel.receivedFiles.isEmpty { - EmptyDataView( - icon: "doc", - title: "ファイルなし", - subtitle: "まだ受信されたファイルがありません" - ) - } else { - ScrollView { - LazyVStack(spacing: 8) { - ForEach(self.viewModel.receivedFiles, id: \.name) { file in - FileItemCard(file: file) { - self.viewModel.openFile(file) - } - } - } - } - } - - // ファイル転送進捗 - if !self.viewModel.fileTransferProgress.isEmpty { - VStack(spacing: 8) { - Text("ファイル転送中") - .font(.subheadline) - .fontWeight(.medium) - - ForEach(Array(self.viewModel.fileTransferProgress.keys), id: \.self) { endpointId in - if let progress = viewModel.fileTransferProgress[endpointId] { - FileTransferProgressView( - endpointId: endpointId, - progress: progress + HistorySessionCard( + session: session, + onShare: { + Task { + print("🔄 共有ボタンがタップされました: \(session.name)") + if let url = await self.viewModel.shareSessionData(session) { + print("✅ ZIPファイルURL取得成功: \(url.path)") + await MainActor.run { + self.shareURL = url + self.showShareSheet = true + } + } else { + print("❌ ZIPファイルの生成に失敗しました") + } + } + }, + onDelete: { + self.sessionToDelete = session + self.showDeleteAlert = true + } ) } } } - .padding() - .background(Color.blue.opacity(0.05)) - .cornerRadius(8) } } .padding() @@ -223,7 +174,8 @@ struct DataRow: View { struct HistorySessionCard: View { let session: SensingSession - let onTap: () -> Void + let onShare: () -> Void + let onDelete: () -> Void var body: some View { HStack { @@ -252,120 +204,25 @@ struct HistorySessionCard: View { .foregroundColor(.secondary) } - Button(action: self.onTap) { - Image(systemName: "chevron.right") + Button(action: self.onShare) { + Image(systemName: "square.and.arrow.up") + .font(.body) .foregroundColor(.blue) } - } - .padding() - .background(Color.primary.opacity(0.05)) - .cornerRadius(8) - .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) - } -} - -// MARK: - File Item Card - -struct FileItemCard: View { - let file: DataDisplayFile - let onTap: () -> Void - - var body: some View { - HStack { - Image(systemName: self.file.isCSV ? "doc.text" : "doc") - .foregroundColor(self.file.isCSV ? .green : .blue) - VStack(alignment: .leading, spacing: 4) { - Text(self.file.name) + Button(action: self.onDelete) { + Image(systemName: "trash") .font(.body) - .fontWeight(.medium) - - Text(self.file.formattedDate) - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - - Text(self.file.formattedSize) - .font(.caption) - .foregroundColor(.secondary) - - Button(action: self.onTap) { - Image(systemName: "square.and.arrow.up") - .foregroundColor(.blue) + .foregroundColor(.red) } } .padding() - .background(Color.white) + .background(Color.primary.opacity(0.05)) .cornerRadius(8) .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) } } -// MARK: - File Transfer Progress View - -struct FileTransferProgressView: View { - let endpointId: String - let progress: Int - - var body: some View { - VStack(spacing: 4) { - HStack { - Text("端末: \(self.endpointId)") - .font(.caption) - Spacer() - Text("\(self.progress)%") - .font(.caption) - .fontWeight(.medium) - } - - ProgressView(value: Double(self.progress), total: 100) - .progressViewStyle(LinearProgressViewStyle()) - } - } -} - -// MARK: - Navigation Buttons - -extension DataDisplayView { - private var navigationButtons: some View { - VStack(spacing: 12) { - Divider() - - HStack(spacing: 16) { - Button("戻る") { - self.flowNavigator.goToPreviousStep() - } - .frame(maxWidth: .infinity) - .padding() - .foregroundColor(.secondary) - .background(Color.secondary.opacity(0.1)) - .cornerRadius(8) - - Button("フローを完了") { - self.flowNavigator.completeFlow() - self.router.reset() - } - .frame(maxWidth: .infinity) - .padding() - .foregroundColor(.white) - .background(Color.green) - .cornerRadius(8) - } - .padding(.horizontal) - .padding(.bottom, 8) - } - .alert("エラー", isPresented: Binding.constant(self.flowNavigator.lastError != nil)) { - Button("OK") { - self.flowNavigator.lastError = nil - } - } message: { - Text(self.flowNavigator.lastError ?? "") - } - } -} - // MARK: - Empty Data View struct EmptyDataView: View { @@ -403,6 +260,30 @@ extension DateFormatter { }() } +// MARK: - ShareSheet for iOS + +#if os(iOS) + struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + print("🎬 ShareSheet: UIActivityViewControllerを作成中") + print("🎬 共有アイテム数: \(self.items.count)") + for (index, item) in self.items.enumerated() { + print("🎬 アイテム[\(index)]: \(type(of: item)) = \(item)") + } + + let controller = UIActivityViewController(activityItems: self.items, applicationActivities: nil) + + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { + // No update needed + } + } +#endif + #Preview { DataDisplayView() .environmentObject(NavigationRouterModel()) diff --git a/UWBViewerSystem/Presentation/Scenes/SensingTab/DataDisplayPage/DataDisplayViewModel.swift b/UWBViewerSystem/Presentation/Scenes/SensingTab/DataDisplayPage/DataDisplayViewModel.swift index 668562a..5b09e23 100644 --- a/UWBViewerSystem/Presentation/Scenes/SensingTab/DataDisplayPage/DataDisplayViewModel.swift +++ b/UWBViewerSystem/Presentation/Scenes/SensingTab/DataDisplayPage/DataDisplayViewModel.swift @@ -35,36 +35,33 @@ struct DataDisplayFile: Identifiable { class DataDisplayViewModel: ObservableObject { @Published var realtimeData: [DeviceRealtimeData] = [] @Published var historyData: [SensingSession] = [] - @Published var receivedFiles: [DataDisplayFile] = [] - @Published var fileTransferProgress: [String: Int] = [:] @Published var isConnected = false private var updateTimer: Timer? // DI対応: 必要なUseCaseを直接注入 private let realtimeDataUsecase: RealtimeDataUsecase - private let fileManagementUsecase: FileManagementUsecase private let connectionUsecase: ConnectionManagementUsecase + private let sessionDataExportUsecase: SessionDataExportUsecase private var swiftDataRepository: SwiftDataRepositoryProtocol private var cancellables = Set() init( swiftDataRepository: SwiftDataRepositoryProtocol, realtimeDataUsecase: RealtimeDataUsecase? = nil, - fileManagementUsecase: FileManagementUsecase? = nil, - connectionUsecase: ConnectionManagementUsecase? = nil + connectionUsecase: ConnectionManagementUsecase? = nil, + sessionDataExportUsecase: SessionDataExportUsecase? = nil ) { self.swiftDataRepository = swiftDataRepository self.realtimeDataUsecase = realtimeDataUsecase ?? RealtimeDataUsecase() - self.fileManagementUsecase = fileManagementUsecase ?? FileManagementUsecase() self.connectionUsecase = connectionUsecase ?? ConnectionManagementUsecase.shared + self.sessionDataExportUsecase = sessionDataExportUsecase ?? SessionDataExportUsecase() self.setupObservers() Task { await self.loadHistoryData() } - self.loadReceivedFiles() } /// 実際のModelContextを使用してSwiftDataRepositoryを設定 @@ -85,22 +82,6 @@ class DataDisplayViewModel: ObservableObject { self.realtimeDataUsecase.$deviceRealtimeDataList .assign(to: &self.$realtimeData) - self.fileManagementUsecase.$fileTransferProgress - .assign(to: &self.$fileTransferProgress) - - self.fileManagementUsecase.$receivedFiles - .map { files in - files.map { fileName in - DataDisplayFile( - name: fileName.fileName, - path: fileName.fileURL.path, // 正しいパスを取得 - size: fileName.fileSize, // 正しいサイズを取得 - dateCreated: fileName.receivedAt - ) - } - } - .assign(to: &self.$receivedFiles) - self.connectionUsecase.$connectedEndpoints .map { !$0.isEmpty } .assign(to: &self.$isConnected) @@ -138,6 +119,31 @@ class DataDisplayViewModel: ObservableObject { func loadSessionData(_ session: SensingSession) { // セッションの詳細データを読み込み // 実装に応じてファイルから読み込みなど + print("📊 セッション詳細を読み込み: \(session.name)") + } + + /// セッションデータをZIP圧縮してAirDropで共有 + /// UseCaseに処理を委譲し、ViewModelはUI状態管理のみを担当 + func shareSessionData(_ session: SensingSession) async -> URL? { + // UseCaseにエクスポート処理を委譲 + await self.sessionDataExportUsecase.exportSessionToZip(session) + } + + /// セッションデータを削除(SwiftDataとCSVファイルの両方) + /// - Parameter session: 削除するセッション + /// - Returns: 削除が成功した場合はtrue + func deleteSessionData(_ session: SensingSession) async -> Bool { + let success = await self.sessionDataExportUsecase.deleteSessionData( + session, + swiftDataRepository: self.swiftDataRepository + ) + + if success { + // 削除成功後、履歴データを再読み込み + await self.loadHistoryData() + } + + return success } private func loadHistoryData() async { @@ -151,29 +157,6 @@ class DataDisplayViewModel: ObservableObject { } } - // MARK: - File Management - - func openStorageFolder() { - self.fileManagementUsecase.openFileStorageFolder() - } - - func openFile(_ file: DataDisplayFile) { - // ファイルを開く処理 - // 実装に応じてFinderで開く、アプリ内で表示など - let url = URL(fileURLWithPath: file.path) - #if os(macOS) - NSWorkspace.shared.open(url) - #elseif os(iOS) - // iOS実装は必要に応じて追加 - print("ファイルを開く: \(file.path)") - #endif - } - - private func loadReceivedFiles() { - // 受信ファイル一覧を読み込み - // HomeViewModelから取得(既にObserverで設定済み) - } - // MARK: - Data Analysis func exportDataAsCSV() { @@ -241,7 +224,6 @@ extension DataDisplayViewModel { self.init( swiftDataRepository: DummySwiftDataRepository(), realtimeDataUsecase: nil, - fileManagementUsecase: nil, connectionUsecase: nil ) } diff --git a/UWBViewerSystem/Presentation/Scenes/SensingTab/SensingManagementPage/SensingManagementView.swift b/UWBViewerSystem/Presentation/Scenes/SensingTab/SensingManagementPage/SensingManagementView.swift index 5d9e148..2e68030 100644 --- a/UWBViewerSystem/Presentation/Scenes/SensingTab/SensingManagementPage/SensingManagementView.swift +++ b/UWBViewerSystem/Presentation/Scenes/SensingTab/SensingManagementPage/SensingManagementView.swift @@ -46,6 +46,57 @@ struct SensingManagementView: View { self.flowNavigator.currentStep = .sensingExecution self.flowNavigator.setRouter(self.router) } + .overlay { + // 自動再接続中のオーバーレイ + if self.viewModel.isAttemptingReconnect { + self.reconnectingOverlay + } + } + .sheet(isPresented: self.$viewModel.showConnectionRecovery) { + ConnectionRecoveryView( + connectionUsecase: ConnectionManagementUsecase.shared, + isPresented: self.$viewModel.showConnectionRecovery + ) + } + } + + // MARK: - Reconnection Overlay + + @ViewBuilder + private var reconnectingOverlay: some View { + ZStack { + Color.black.opacity(0.5) + .ignoresSafeArea() + + VStack(spacing: 20) { + ProgressView() + .scaleEffect(1.5) + #if os(iOS) + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + #endif + + VStack(spacing: 8) { + Text("再接続中...") + .font(.headline) + .foregroundColor(.white) + + Text("試行 \(self.viewModel.reconnectAttemptCount) / 3") + .font(.subheadline) + .foregroundColor(.white.opacity(0.8)) + + if self.viewModel.isSensingActive { + Text("センシングは一時停止中です") + .font(.caption) + .foregroundColor(.orange) + } + } + } + .padding(30) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.black.opacity(0.7)) + ) + } } // MARK: - Header Section diff --git a/UWBViewerSystem/Presentation/Scenes/SensingTab/SensingManagementPage/SensingManagementViewModel.swift b/UWBViewerSystem/Presentation/Scenes/SensingTab/SensingManagementPage/SensingManagementViewModel.swift index 1fe3e9a..cc074fe 100644 --- a/UWBViewerSystem/Presentation/Scenes/SensingTab/SensingManagementPage/SensingManagementViewModel.swift +++ b/UWBViewerSystem/Presentation/Scenes/SensingTab/SensingManagementPage/SensingManagementViewModel.swift @@ -15,6 +15,28 @@ class SensingManagementViewModel: ObservableObject { @Published var sampleRate = 10 @Published var autoSave = true + // MARK: - Connection Recovery State + + /// 接続復旧画面を表示するかどうか + @Published var showConnectionRecovery: Bool = false + + /// 再接続中かどうか(UIに表示用) + @Published var isAttemptingReconnect: Bool = false + + /// 再接続試行回数 + @Published var reconnectAttemptCount: Int = 0 + + /// 切断前の状態を保存 + private var wasSensingBeforeDisconnect: Bool = false + private var wasPausedBeforeDisconnect: Bool = false + private var sensingStartTimeBeforeDisconnect: Date? + + /// 最大再接続試行回数 + private let maxAutoReconnectAttempts: Int = 3 + + /// 接続監視が設定済みかどうか + private var isConnectionMonitoringSetup: Bool = false + // DI対応: 必要なUseCaseとRepositoryを直接注入 private let sensingControlUsecase: SensingControlUsecase private let realtimeDataUsecase: RealtimeDataUsecase @@ -113,6 +135,177 @@ class SensingManagementViewModel: ObservableObject { self.$realtimeData .map { $0.count } .assign(to: &self.$dataPointCount) + + // 接続エラー状態を監視 + self.setupConnectionErrorObserver() + } + + // MARK: - Connection Recovery + + private func setupConnectionErrorObserver() { + // 既に設定済みの場合はスキップ(重複実行防止) + guard !self.isConnectionMonitoringSetup else { + print("ℹ️ SensingManagement: 接続監視は既に設定済みです") + return + } + self.isConnectionMonitoringSetup = true + + let connectionUsecase = ConnectionManagementUsecase.shared + + // 初期状態をチェック:既に接続エラーがある場合や、接続デバイスがない場合 + Task { @MainActor in + // 少し待機して画面遷移を完了させる + try? await Task.sleep(nanoseconds: 500_000_000) + + // 既に再接続中の場合はスキップ + guard !self.isAttemptingReconnect else { + print("ℹ️ SensingManagement: 既に再接続試行中のためスキップ") + return + } + + // 接続エラーがあるか、接続デバイスがない場合は再接続を試みる + if connectionUsecase.hasConnectionError { + print("🔴 SensingManagement: 初期化時に既存の接続エラーを検知") + self.handleConnectionError() + } else if !connectionUsecase.hasConnectedDevices() { + print("🔴 SensingManagement: 初期化時に接続デバイスなしを検知") + connectionUsecase.hasConnectionError = true + self.handleConnectionError() + } + } + + // 継続的な接続エラー監視 + connectionUsecase.$hasConnectionError + .dropFirst() // 初期値をスキップ(上で処理済み) + .sink { [weak self] hasError in + guard let self else { return } + if hasError { + print("🔴 SensingManagement: 接続エラーを検知") + self.handleConnectionError() + } + } + .store(in: &self.cancellables) + } + + /// 接続エラー時の処理 + private func handleConnectionError() { + // 既に再接続中の場合はスキップ + guard !self.isAttemptingReconnect else { + print("ℹ️ SensingManagement: 既に再接続試行中のためスキップ") + return + } + + // センシング中の場合は状態を保存 + if self.isSensingActive { + print("⚠️ センシング中に接続が切断されました") + self.wasSensingBeforeDisconnect = true + self.wasPausedBeforeDisconnect = self.isPaused + self.sensingStartTimeBeforeDisconnect = self.sensingStartTime + + // センシングを一時停止(データ保持) + if !self.isPaused { + self.pauseSensing() + } + } else { + self.wasSensingBeforeDisconnect = false + } + + // 自動再接続を開始 + Task { + await self.attemptAutoReconnect() + } + } + + /// 自動再接続を試行 + private func attemptAutoReconnect() async { + self.isAttemptingReconnect = true + self.reconnectAttemptCount = 0 + + let connectionUsecase = ConnectionManagementUsecase.shared + + // 自動再接続中フラグを設定(アラート抑制用) + connectionUsecase.isAutoReconnecting = true + + for attempt in 1...self.maxAutoReconnectAttempts { + self.reconnectAttemptCount = attempt + print("🔄 SensingManagement: 再接続試行 \(attempt)/\(self.maxAutoReconnectAttempts)") + + // 既存の接続をリセット + connectionUsecase.resetAll() + + // 少し待機 + try? await Task.sleep(nanoseconds: 1_000_000_000) + + // エラーフラグをクリアして再接続開始 + connectionUsecase.hasConnectionError = false + connectionUsecase.lastDisconnectedDevice = nil + connectionUsecase.startAdvertising() + connectionUsecase.startDiscovery() + + // 接続確立を待機(最大8秒) + for _ in 0..<16 { + try? await Task.sleep(nanoseconds: 500_000_000) + + if connectionUsecase.hasConnectedDevices() { + print("✅ SensingManagement: 再接続成功") + self.isAttemptingReconnect = false + connectionUsecase.isAutoReconnecting = false + + // センシング再開 + await self.handleReconnectionSuccess() + return + } + } + + // バックオフ:次の試行まで待機時間を増やす + let backoffSeconds = attempt * 2 + print("⏳ 次の再接続試行まで \(backoffSeconds) 秒待機...") + try? await Task.sleep(nanoseconds: UInt64(backoffSeconds * 1_000_000_000)) + } + + // すべての試行が失敗 + self.isAttemptingReconnect = false + connectionUsecase.isAutoReconnecting = false + self.showConnectionRecovery = true + print("❌ SensingManagement: 自動再接続失敗") + } + + /// 再接続成功時の処理 + private func handleReconnectionSuccess() async { + print("🔄 SensingManagement: センシング状態を復元中...") + + // センシング中だった場合は再開 + if self.wasSensingBeforeDisconnect { + // センシング開始時刻を復元 + if let savedStartTime = self.sensingStartTimeBeforeDisconnect { + self.sensingStartTime = savedStartTime + } + + // 少し待機してから再開 + try? await Task.sleep(nanoseconds: 1_000_000_000) + + if self.wasPausedBeforeDisconnect { + // 一時停止状態を維持 + print("⏸️ センシングは一時停止状態を維持") + } else { + // センシングを再開 + self.resumeSensing() + print("▶️ センシングを再開しました") + } + } + + // 状態をクリア + self.wasSensingBeforeDisconnect = false + self.wasPausedBeforeDisconnect = false + self.sensingStartTimeBeforeDisconnect = nil + } + + /// 手動再接続用:保存状態をクリア + func clearSavedState() { + self.wasSensingBeforeDisconnect = false + self.wasPausedBeforeDisconnect = false + self.sensingStartTimeBeforeDisconnect = nil + self.isAttemptingReconnect = false } private func generateDefaultFileName() { diff --git a/UWBViewerSystem/Presentation/Scenes/SensingTab/SensingView.swift b/UWBViewerSystem/Presentation/Scenes/SensingTab/SensingView.swift deleted file mode 100644 index 0576dca..0000000 --- a/UWBViewerSystem/Presentation/Scenes/SensingTab/SensingView.swift +++ /dev/null @@ -1,299 +0,0 @@ -import SwiftUI - -struct SensingView: View { - @StateObject private var viewModel = SensingViewModel() - @EnvironmentObject var router: NavigationRouterModel - @State private var showValidationAlert = false - @State private var validationMessage = "" - - var body: some View { - #if os(macOS) - NavigationSplitView { - VStack(spacing: 20) { - self.headerSection - - if self.viewModel.savedSensingData.isEmpty { - self.emptyStateView - } else { - self.sensingDataList - } - - Spacer() - - self.startSensingButton - } - .padding() - .navigationSplitViewColumnWidth(min: 300, ideal: 350) - } detail: { - if let selectedData = viewModel.selectedSensingData { - SensingDetailView(sensingData: selectedData) - } else { - Text("センシングデータを選択してください") - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(NSColor.controlBackgroundColor)) - } - } - #else - NavigationView { - VStack(spacing: 20) { - self.headerSection - - if self.viewModel.savedSensingData.isEmpty { - self.emptyStateView - } else { - self.sensingDataList - } - - Spacer() - - self.startSensingButton - } - .padding() - .navigationTitle("センシング") - .navigationBarTitleDisplayModeIfAvailable(.large) - } - .alert("設定が必要です", isPresented: self.$showValidationAlert) { - Button("フロアマップ設定へ") { - self.router.push(.floorMapSetting) - } - Button("端末接続設定へ") { - self.router.push(.pairingSettingPage) - } - Button("キャンセル", role: .cancel) {} - } message: { - Text(self.validationMessage) - } - .onAppear { - self.viewModel.loadSavedData() - } - #endif - } - - private var headerSection: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Image(systemName: "waveform.path.ecg") - .font(.title2) - .foregroundColor(.blue) - - Text("センシングデータ管理") - .font(.title2) - .fontWeight(.bold) - } - - Text("保存されたセンシングデータの確認と新規センシングの開始") - .font(.caption) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(Color.blue.opacity(0.1)) - .cornerRadius(12) - } - - private var emptyStateView: some View { - VStack(spacing: 16) { - Image(systemName: "doc.text.magnifyingglass") - .font(.system(size: 60)) - .foregroundColor(.gray) - - Text("保存されたデータがありません") - .font(.headline) - .foregroundColor(.secondary) - - Text("センシングを開始してデータを収集してください") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .padding(40) - } - - private var sensingDataList: some View { - ScrollView { - VStack(spacing: 12) { - ForEach(self.viewModel.savedSensingData) { data in - SensingDataRow( - data: data, - onTap: { - self.viewModel.selectSensingData(data) - #if os(iOS) - self.router.push(.dataDisplayPage) - #endif - } - ) { - self.viewModel.deleteSensingData(data) - } - } - } - } - } - - private var startSensingButton: some View { - Button(action: { - self.validateAndStartSensing() - }) { - HStack { - Image(systemName: "play.circle.fill") - .font(.title2) - - Text("センシング開始") - .font(.headline) - } - .frame(maxWidth: .infinity) - .padding() - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(12) - } - } - - private func validateAndStartSensing() { - let validation = self.viewModel.validateSensingRequirements() - - if validation.isValid { - self.router.push(.dataCollectionPage) - } else { - self.validationMessage = validation.message - self.showValidationAlert = true - } - } -} - -struct SensingDataRow: View { - let data: SensingData - let onTap: () -> Void - let onDelete: () -> Void - - var body: some View { - HStack { - Button(action: self.onTap) { - VStack(alignment: .leading, spacing: 4) { - Text(self.data.name) - .font(.headline) - .foregroundColor(.primary) - - HStack { - Label("\(self.data.dataPoints) データポイント", systemImage: "chart.line.uptrend.xyaxis") - .font(.caption) - .foregroundColor(.secondary) - - Text("•") - .foregroundColor(.secondary) - - Text(self.data.formattedDate) - .font(.caption) - .foregroundColor(.secondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(PlainButtonStyle()) - - Button(action: self.onDelete) { - Image(systemName: "trash") - .foregroundColor(.red) - } - .buttonStyle(PlainButtonStyle()) - } - .padding() - .background(Color.primary.opacity(0.05)) - .cornerRadius(8) - } -} - -struct SensingDetailView: View { - let sensingData: SensingData - @EnvironmentObject var router: NavigationRouterModel - - var body: some View { - VStack(spacing: 30) { - // ヘッダー - VStack(alignment: .leading, spacing: 16) { - Text(self.sensingData.name) - .font(.largeTitle) - .fontWeight(.bold) - - HStack { - Label("\(self.sensingData.dataPoints) データポイント", systemImage: "chart.line.uptrend.xyaxis") - .foregroundColor(.secondary) - - Text("•") - .foregroundColor(.secondary) - - Text(self.sensingData.formattedDate) - .foregroundColor(.secondary) - } - } - .padding() - .background(Color.primary.opacity(0.05)) - .cornerRadius(12) - - // データ表示エリア - VStack(spacing: 16) { - Text("データ詳細") - .font(.headline) - - // ここに実際のデータ可視化やグラフを追加可能 - RoundedRectangle(cornerRadius: 12) - .fill(Color.blue.opacity(0.1)) - .frame(height: 200) - .overlay( - VStack { - Image(systemName: "chart.line.uptrend.xyaxis") - .font(.largeTitle) - .foregroundColor(.blue) - Text("データ可視化エリア") - .foregroundColor(.secondary) - } - ) - } - - // アクション - VStack(spacing: 16) { - Button(action: { - self.router.push(.trajectoryView) - }) { - HStack { - Image(systemName: "map") - Text("軌跡を表示") - } - .frame(maxWidth: .infinity) - .padding() - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(8) - } - - Button(action: { - // データエクスポート処理 - }) { - HStack { - Image(systemName: "square.and.arrow.up") - Text("データをエクスポート") - } - .frame(maxWidth: .infinity) - .padding() - .background(Color.green) - .foregroundColor(.white) - .cornerRadius(8) - } - } - - Spacer() - } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity) - #if os(macOS) - .background(Color(NSColor.controlBackgroundColor)) - #else - .background(Color(UIColor.systemBackground)) - #endif - } -} - -#Preview { - SensingView() - .environmentObject(NavigationRouterModel()) -} diff --git a/UWBViewerSystem/Presentation/Scenes/SensingTab/SensingViewModel.swift b/UWBViewerSystem/Presentation/Scenes/SensingTab/SensingViewModel.swift deleted file mode 100644 index 1a51023..0000000 --- a/UWBViewerSystem/Presentation/Scenes/SensingTab/SensingViewModel.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Foundation -import SwiftUI - -struct SensingData: Identifiable { - let id = UUID() - let name: String - let dataPoints: Int - let createdAt: Date - - var formattedDate: String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - formatter.locale = Locale(identifier: "ja_JP") - return formatter.string(from: self.createdAt) - } -} - -struct ValidationResult { - let isValid: Bool - let message: String -} - -class SensingViewModel: ObservableObject { - @Published var savedSensingData: [SensingData] = [] - @Published var selectedSensingData: SensingData? - @Published var hasFloorMap: Bool = false - @Published var hasConnectedDevice: Bool = false - - private let preferenceRepository: PreferenceRepositoryProtocol - - init(preferenceRepository: PreferenceRepositoryProtocol = PreferenceRepository()) { - self.preferenceRepository = preferenceRepository - self.loadSavedData() - self.checkSystemStatus() - } - - func loadSavedData() { - self.savedSensingData = [ - SensingData(name: "測定データ 1", dataPoints: 1250, createdAt: Date().addingTimeInterval(-86400)), - SensingData(name: "測定データ 2", dataPoints: 890, createdAt: Date().addingTimeInterval(-172800)), - SensingData(name: "測定データ 3", dataPoints: 2100, createdAt: Date().addingTimeInterval(-259200)), - ] - } - - func checkSystemStatus() { - self.hasFloorMap = self.preferenceRepository.getBool(forKey: "hasFloorMapConfigured") - self.hasConnectedDevice = self.preferenceRepository.getBool(forKey: "hasDeviceConnected") - } - - func validateSensingRequirements() -> ValidationResult { - self.checkSystemStatus() - - if !self.hasFloorMap && !self.hasConnectedDevice { - return ValidationResult( - isValid: false, - message: "センシングを開始するには、フロアマップの設定と端末の接続が必要です。どちらを先に設定しますか?" - ) - } else if !self.hasFloorMap { - return ValidationResult( - isValid: false, - message: "センシングを開始するには、フロアマップの設定が必要です。" - ) - } else if !self.hasConnectedDevice { - return ValidationResult( - isValid: false, - message: "センシングを開始するには、端末の接続が必要です。" - ) - } - - return ValidationResult(isValid: true, message: "") - } - - func selectSensingData(_ data: SensingData) { - self.selectedSensingData = data - } - - func deleteSensingData(_ data: SensingData) { - self.savedSensingData.removeAll { $0.id == data.id } - if self.selectedSensingData?.id == data.id { - self.selectedSensingData = nil - } - } -} diff --git a/UWBViewerSystem/Presentation/Scenes/SettingsTab/SettingsView.swift b/UWBViewerSystem/Presentation/Scenes/SettingsTab/SettingsView.swift index 1d0c873..9cb4923 100644 --- a/UWBViewerSystem/Presentation/Scenes/SettingsTab/SettingsView.swift +++ b/UWBViewerSystem/Presentation/Scenes/SettingsTab/SettingsView.swift @@ -4,10 +4,11 @@ struct SettingsView: View { @StateObject private var viewModel = SettingsViewModel() var body: some View { - NavigationView { + VStack { ScrollView { VStack(spacing: 20) { self.headerSection + self.debugSection self.aboutSection } .padding() @@ -39,6 +40,37 @@ struct SettingsView: View { .cornerRadius(12) } + private var debugSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("デバッグ設定") + .font(.headline) + .fontWeight(.semibold) + + VStack(spacing: 0) { + Toggle(isOn: self.$viewModel.skipCalibration) { + HStack { + Image(systemName: "hammer") + .frame(width: 20) + .foregroundColor(.orange) + + VStack(alignment: .leading, spacing: 2) { + Text("キャリブレーションをスキップ") + Text("デバッグ時に毎回キャリブレーションを行わない") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + .padding() + .onChange(of: self.viewModel.skipCalibration) { _, newValue in + print("🔧 キャリブレーションスキップ設定: \(newValue)") + } + } + .background(Color.gray.opacity(0.05)) + .cornerRadius(12) + } + } + private var aboutSection: some View { VStack(alignment: .leading, spacing: 12) { Text("情報") diff --git a/UWBViewerSystem/Presentation/Scenes/SettingsTab/SettingsViewModel.swift b/UWBViewerSystem/Presentation/Scenes/SettingsTab/SettingsViewModel.swift index 9332fa9..92768c5 100644 --- a/UWBViewerSystem/Presentation/Scenes/SettingsTab/SettingsViewModel.swift +++ b/UWBViewerSystem/Presentation/Scenes/SettingsTab/SettingsViewModel.swift @@ -3,6 +3,18 @@ import Foundation class SettingsViewModel: ObservableObject { let appVersion = "1.0.0" + // デバッグ設定 + @Published var skipCalibration: Bool { + didSet { + UserDefaults.standard.set(self.skipCalibration, forKey: "skipCalibration") + } + } + + init() { + // UserDefaultsから設定を読み込み + self.skipCalibration = UserDefaults.standard.bool(forKey: "skipCalibration") + } + func showHelp() { print("ヘルプ画面を表示") } diff --git a/UWBViewerSystem/UWBViewerSystemApp.swift b/UWBViewerSystem/UWBViewerSystemApp.swift index 447f308..cd759b1 100644 --- a/UWBViewerSystem/UWBViewerSystemApp.swift +++ b/UWBViewerSystem/UWBViewerSystemApp.swift @@ -71,9 +71,10 @@ struct UWBViewerSystemApp: App { var sharedModelContainer: ModelContainer = { let schema = Schema([ PersistentFloorMap.self, - PersistentProjectProgress.self, PersistentAntennaPosition.self, PersistentSensingSession.self, + PersistentRealtimeData.self, + PersistentAntennaPairing.self, PersistentSystemActivity.self, PersistentReceivedFile.self, PersistentCalibrationData.self, @@ -84,10 +85,11 @@ struct UWBViewerSystemApp: App { let inMemoryConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) do { - #if DEBUG - // まず既存のデータベースを強制削除 - deleteExistingDatabase() - #endif + // DEBUG時の自動削除は無効化(必要に応じて手動でクリーンインストールを実行) + // #if DEBUG + // // まず既存のデータベースを強制削除 + // deleteExistingDatabase() + // #endif // ApplicationSupportディレクトリの作成を確実に行う let fileManager = FileManager.default @@ -123,7 +125,7 @@ struct UWBViewerSystemApp: App { case .schemaError(let originalError): #if DEBUG print("🔄 スキーマエラーのため既存データベースを削除して再作成します") - deleteExistingDatabase() + // deleteExistingDatabase() #endif do { @@ -139,7 +141,7 @@ struct UWBViewerSystemApp: App { #if DEBUG print("📁 ファイルシステムエラーを検出。ApplicationSupportディレクトリの再作成を試行します") // ディレクトリ再作成を試行 - deleteExistingDatabase() + // deleteExistingDatabase() #endif do { @@ -300,18 +302,6 @@ struct UWBViewerSystemApp: App { print(" Created: \(floorMap.createdAt)") } - // プロジェクト進行状況の確認 - let projectProgresses = try await swiftDataRepository.loadAllProjectProgress() - print("📊 データベース内のプロジェクト進行状況: \(projectProgresses.count)件") - for (index, progress) in projectProgresses.enumerated() { - print(" [\(index + 1)] ID: \(progress.id)") - print(" FloorMapID: \(progress.floorMapId)") - print(" CurrentStep: \(progress.currentStep.displayName)") - print( - " CompletedSteps: \(progress.completedSteps.map { $0.displayName }.joined(separator: ", "))" - ) - } - // アンテナ位置の確認 let antennaPositions = try await swiftDataRepository.loadAntennaPositions() print("📊 データベース内のアンテナ位置: \(antennaPositions.count)件") diff --git a/UWBViewerSystemTests/AntennaAffineCalibrationTests.swift b/UWBViewerSystemTests/AntennaAffineCalibrationTests.swift index c7c7d74..79d6c0e 100644 --- a/UWBViewerSystemTests/AntennaAffineCalibrationTests.swift +++ b/UWBViewerSystemTests/AntennaAffineCalibrationTests.swift @@ -343,6 +343,156 @@ struct AntennaAffineCalibrationTests { #expect(abs(config.scaleFactors.sy - 1.0) < 0.2) } + // MARK: - Cauchy Loss IRLS Tests + + @Test("IRLS設定のデフォルト値") + func irlsConfigDefaults() { + let config = AntennaAffineCalibration.IRLSConfig.default + #expect(config.maxIterations == 10) + #expect(config.tolerance == 1e-6) + #expect(config.useCauchyLoss == true) + } + + @Test("重み付き測定データでのキャリブレーション") + func calibrationWithQualityWeighting() throws { + // Arrange + let calibration = AntennaAffineCalibration() + + // 高品質データ + let highQuality = SignalQuality( + strength: 0.9, + isLineOfSight: true, + confidenceLevel: 0.95, + errorEstimate: 0.05 + ) + + // 低品質データ + let lowQuality = SignalQuality( + strength: 0.3, + isLineOfSight: false, + confidenceLevel: 0.4, + errorEstimate: 0.5 + ) + + // 高品質データの重みが高いことを確認 + let highWeight = AntennaAffineCalibration.WeightedMeasurement( + position: Point3D(x: 1.0, y: 1.0, z: 0.0), + quality: highQuality + ) + let lowWeight = AntennaAffineCalibration.WeightedMeasurement( + position: Point3D(x: 1.0, y: 1.0, z: 0.0), + quality: lowQuality + ) + + #expect(highWeight.weight > lowWeight.weight) + + // 信号品質重み付けを使用したキャリブレーション + let weightedMeasurements: [String: [AntennaAffineCalibration.WeightedMeasurement]] = [ + "tag1": [ + AntennaAffineCalibration.WeightedMeasurement( + position: Point3D(x: 2.0, y: 2.0, z: 0.0), + quality: highQuality + ), + ], + "tag2": [ + AntennaAffineCalibration.WeightedMeasurement( + position: Point3D(x: 4.0, y: 2.0, z: 0.0), + quality: highQuality + ), + ], + "tag3": [ + AntennaAffineCalibration.WeightedMeasurement( + position: Point3D(x: 3.0, y: 4.0, z: 0.0), + quality: highQuality + ), + ], + ] + + let truePositions: [String: Point3D] = [ + "tag1": Point3D(x: 5.0, y: 5.0, z: 0.0), + "tag2": Point3D(x: 7.0, y: 5.0, z: 0.0), + "tag3": Point3D(x: 6.0, y: 7.0, z: 0.0), + ] + + // Act + let config = try calibration.estimateAntennaConfigWithQuality( + weightedMeasurementsByTag: weightedMeasurements, + truePositions: truePositions + ) + + // Assert + #expect(config.x.isFinite) + #expect(config.y.isFinite) + #expect(config.rmse >= 0.0) + #expect(config.rmse < 1.0) // 合理的なRMSE + + print("品質重み付けキャリブレーション結果: 位置(\(config.x), \(config.y)), RMSE: \(config.rmse)") + } + + @Test("外れ値存在下でのIRLS収束") + func irlsConvergenceWithOutliers() throws { + // Arrange + let calibration = AntennaAffineCalibration() + let highQuality = SignalQuality( + strength: 0.9, + isLineOfSight: true, + confidenceLevel: 0.95, + errorEstimate: 0.05 + ) + let outlierQuality = SignalQuality( + strength: 0.2, + isLineOfSight: false, + confidenceLevel: 0.2, + errorEstimate: 0.8 + ) + + // 正常データと外れ値を含むデータ + let weightedMeasurements: [String: [AntennaAffineCalibration.WeightedMeasurement]] = [ + "tag1": [ + AntennaAffineCalibration.WeightedMeasurement( + position: Point3D(x: 2.0, y: 2.0, z: 0.0), + quality: highQuality + ), + // 外れ値 + AntennaAffineCalibration.WeightedMeasurement( + position: Point3D(x: 10.0, y: 10.0, z: 0.0), + quality: outlierQuality + ), + ], + "tag2": [ + AntennaAffineCalibration.WeightedMeasurement( + position: Point3D(x: 4.0, y: 2.0, z: 0.0), + quality: highQuality + ), + ], + "tag3": [ + AntennaAffineCalibration.WeightedMeasurement( + position: Point3D(x: 3.0, y: 4.0, z: 0.0), + quality: highQuality + ), + ], + ] + + let truePositions: [String: Point3D] = [ + "tag1": Point3D(x: 5.0, y: 5.0, z: 0.0), + "tag2": Point3D(x: 7.0, y: 5.0, z: 0.0), + "tag3": Point3D(x: 6.0, y: 7.0, z: 0.0), + ] + + // Act + let config = try calibration.estimateAntennaConfigWithQuality( + weightedMeasurementsByTag: weightedMeasurements, + truePositions: truePositions + ) + + // Assert - IRLSにより外れ値の影響が低減されているはず + #expect(config.x.isFinite) + #expect(config.y.isFinite) + #expect(config.rmse < 5.0) // 外れ値があっても極端に悪くならない + + print("外れ値存在下でのIRLS結果: 位置(\(config.x), \(config.y)), RMSE: \(config.rmse)") + } + @Test("複数回の測定値の平均化") func multipleMeasurementsAveraging() throws { // Arrange diff --git a/UWBViewerSystemTests/SensorDataProcessorTests.swift b/UWBViewerSystemTests/SensorDataProcessorTests.swift index a344f26..65ae5c5 100644 --- a/UWBViewerSystemTests/SensorDataProcessorTests.swift +++ b/UWBViewerSystemTests/SensorDataProcessorTests.swift @@ -265,6 +265,163 @@ struct SensorDataProcessorTests { #expect(smoothed[4].y == 3.0) } + // MARK: - IQR Outlier Detection Tests + + @Test("IQR外れ値検出が正しく動作する") + func iqrOutlierDetection() { + let config = SensorDataProcessingConfig( + firstTrim: 0, + endTrim: 0, + movingAverageWindowSize: 1, // 移動平均無効化 + filterNLOS: false, + useIQROutlierDetection: true, + iqrMultiplier: 1.5 + ) + let processor = SensorDataProcessor(config: config) + + // 正常データと外れ値を含むデータを作成 + var observations: [ObservationPoint] = [] + + // 正常データ(0〜10の範囲) + for i in 0..<20 { + observations.append(ObservationPoint( + id: "obs_\(i)", + antennaId: "antenna1", + position: Point3D(x: Double(i % 10), y: Double(i % 10), z: 0), + timestamp: Date(), + quality: SignalQuality( + strength: 0.8, + isLineOfSight: true, + confidenceLevel: 0.9, + errorEstimate: 0.1 + ), + distance: Double(i), + rssi: -50.0, + sessionId: "session1" + )) + } + + // 外れ値を追加(大きく離れた位置) + observations.append(ObservationPoint( + id: "outlier_1", + antennaId: "antenna1", + position: Point3D(x: 100.0, y: 100.0, z: 0), // 明らかな外れ値 + timestamp: Date(), + quality: SignalQuality( + strength: 0.8, + isLineOfSight: true, + confidenceLevel: 0.9, + errorEstimate: 0.1 + ), + distance: 10.0, + rssi: -50.0, + sessionId: "session1" + )) + + let processed = processor.processObservations(observations) + + // 外れ値が除去されているはず + #expect(processed.count < observations.count) + + // 外れ値が含まれていないことを確認 + for obs in processed { + #expect(obs.position.x < 50.0) + #expect(obs.position.y < 50.0) + } + } + + @Test("IQR外れ値検出が無効の場合、外れ値も残る") + func iqrOutlierDetectionDisabled() { + let config = SensorDataProcessingConfig( + firstTrim: 0, + endTrim: 0, + movingAverageWindowSize: 1, + filterNLOS: false, + useIQROutlierDetection: false + ) + let processor = SensorDataProcessor(config: config) + + var observations: [ObservationPoint] = [] + + // 正常データ + for i in 0..<10 { + observations.append(ObservationPoint( + id: "obs_\(i)", + antennaId: "antenna1", + position: Point3D(x: Double(i), y: Double(i), z: 0), + timestamp: Date(), + quality: SignalQuality( + strength: 0.8, + isLineOfSight: true, + confidenceLevel: 0.9, + errorEstimate: 0.1 + ), + distance: Double(i), + rssi: -50.0, + sessionId: "session1" + )) + } + + // 外れ値 + observations.append(ObservationPoint( + id: "outlier", + antennaId: "antenna1", + position: Point3D(x: 100.0, y: 100.0, z: 0), + timestamp: Date(), + quality: SignalQuality( + strength: 0.8, + isLineOfSight: true, + confidenceLevel: 0.9, + errorEstimate: 0.1 + ), + distance: 10.0, + rssi: -50.0, + sessionId: "session1" + )) + + let processed = processor.processObservations(observations) + + // フィルタが無効なので全データが残る + #expect(processed.count == observations.count) + } + + @Test("データ数が少ない場合、IQR外れ値検出はスキップされる") + func iqrOutlierDetectionInsufficientData() { + let config = SensorDataProcessingConfig( + firstTrim: 0, + endTrim: 0, + movingAverageWindowSize: 1, + filterNLOS: false, + useIQROutlierDetection: true + ) + let processor = SensorDataProcessor(config: config) + + // 3個のデータ(IQR計算に必要な4点未満) + var observations: [ObservationPoint] = [] + for i in 0..<3 { + observations.append(ObservationPoint( + id: "obs_\(i)", + antennaId: "antenna1", + position: Point3D(x: Double(i), y: Double(i), z: 0), + timestamp: Date(), + quality: SignalQuality( + strength: 0.8, + isLineOfSight: true, + confidenceLevel: 0.9, + errorEstimate: 0.1 + ), + distance: Double(i), + rssi: -50.0, + sessionId: "session1" + )) + } + + let processed = processor.processObservations(observations) + + // データが少ないのでそのまま返る + #expect(processed.count == observations.count) + } + // MARK: - Integration Tests @Test("完全な処理パイプラインが正しく動作する") diff --git a/UWBViewerSystemTests/TestHelpers/MockDataRepository.swift b/UWBViewerSystemTests/TestHelpers/MockDataRepository.swift index 022aaac..c180a4b 100644 --- a/UWBViewerSystemTests/TestHelpers/MockDataRepository.swift +++ b/UWBViewerSystemTests/TestHelpers/MockDataRepository.swift @@ -175,7 +175,6 @@ public class MockSwiftDataRepository: SwiftDataRepositoryProtocol { private var systemActivityStorage: [SystemActivity] = [] private var receivedFileStorage: [ReceivedFile] = [] private var floorMapStorage: [FloorMapInfo] = [] - private var projectProgressStorage: [ProjectProgress] = [] private var calibrationDataStorage: [String: Data] = [:] private var mapCalibrationDataStorage: [String: MapCalibrationData] = [:] @@ -336,35 +335,6 @@ public class MockSwiftDataRepository: SwiftDataRepositoryProtocol { // 実際の実装では別途アクティブなフロアマップを管理する } - // MARK: - Project Progress Methods - - public func saveProjectProgress(_ progress: ProjectProgress) async throws { - if self.shouldThrowError { throw self.errorToThrow } - self.projectProgressStorage.append(progress) - } - - public func loadProjectProgress(by id: String) async throws -> ProjectProgress? { - self.projectProgressStorage.first { $0.id == id } - } - - public func loadProjectProgress(for floorMapId: String) async throws -> ProjectProgress? { - self.projectProgressStorage.first { $0.floorMapId == floorMapId } - } - - public func loadAllProjectProgress() async throws -> [ProjectProgress] { - self.projectProgressStorage - } - - public func deleteProjectProgress(by id: String) async throws { - self.projectProgressStorage.removeAll { $0.id == id } - } - - public func updateProjectProgress(_ progress: ProjectProgress) async throws { - if let index = projectProgressStorage.firstIndex(where: { $0.id == progress.id }) { - self.projectProgressStorage[index] = progress - } - } - // MARK: - Calibration Data Methods public func saveCalibrationData(_ data: CalibrationData) async throws { diff --git a/UWBViewerSystemTests/UWBViewerSystemTests.swift b/UWBViewerSystemTests/UWBViewerSystemTests.swift index 82c5516..03aab00 100644 --- a/UWBViewerSystemTests/UWBViewerSystemTests.swift +++ b/UWBViewerSystemTests/UWBViewerSystemTests.swift @@ -31,7 +31,6 @@ struct SwiftDataRepositoryTests { PersistentRealtimeData.self, PersistentSystemActivity.self, PersistentFloorMap.self, - PersistentProjectProgress.self, ]) let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) @@ -154,57 +153,6 @@ struct SwiftDataRepositoryTests { #expect(emptyPositions.isEmpty) } - @Test("プロジェクト進行状況保存・読み込みテスト") - @MainActor - func projectProgressSaveAndLoad() async throws { - let repository = try createInMemoryRepository() - - // テストデータを作成 - let testProgress = ProjectProgress( - id: "test_progress_1", - floorMapId: "test_floor_1", - currentStep: .antennaConfiguration, - completedSteps: [.floorMapSetting, .antennaConfiguration] - ) - - // 保存 - try await repository.saveProjectProgress(testProgress) - - // ID指定で読み込み - let loadedProgress = try await repository.loadProjectProgress(by: testProgress.id) - #expect(loadedProgress != nil) - #expect(loadedProgress?.id == testProgress.id) - #expect(loadedProgress?.floorMapId == testProgress.floorMapId) - #expect(loadedProgress?.currentStep == testProgress.currentStep) - #expect(loadedProgress?.completedSteps == testProgress.completedSteps) - - // フロアマップID指定で読み込み - let progressByFloorMap = try await repository.loadProjectProgress(for: testProgress.floorMapId) - #expect(progressByFloorMap?.id == testProgress.id) - - // 全件取得 - let allProgress = try await repository.loadAllProjectProgress() - #expect(allProgress.count == 1) - #expect(allProgress.first?.id == testProgress.id) - - // 更新テスト - var updatedProgress = testProgress - updatedProgress.currentStep = .devicePairing - updatedProgress.completedSteps.insert(.devicePairing) - updatedProgress.updatedAt = Date() - - try await repository.updateProjectProgress(updatedProgress) - - let updatedLoadedProgress = try await repository.loadProjectProgress(by: testProgress.id) - #expect(updatedLoadedProgress?.currentStep == .devicePairing) - #expect(updatedLoadedProgress?.completedSteps.contains(.devicePairing) == true) - - // 削除 - try await repository.deleteProjectProgress(by: testProgress.id) - let deletedProgress = try await repository.loadProjectProgress(by: testProgress.id) - #expect(deletedProgress == nil) - } - @Test("フロアマップ保存・読み込みテスト") @MainActor func floorMapSaveAndLoad() async throws {