diff --git a/Firmware/cardboy-companion/README.md b/Firmware/cardboy-companion/README.md index 4a84078..8f31a96 100644 --- a/Firmware/cardboy-companion/README.md +++ b/Firmware/cardboy-companion/README.md @@ -25,5 +25,65 @@ This SwiftUI app connects to the Cardboy device over Bluetooth Low Energy and up 2. Ensure the `CoreBluetooth` capability is enabled for the `cardboy-companion` target and keep the *Uses Bluetooth LE accessories* background mode on (preconfigured in this project). 3. Build & run on a real device (BLE is not available in the simulator). 4. Allow Bluetooth permissions when prompted. The app keeps scanning in the background, so the Cardboy can request a sync even while the companion is not foregrounded. Tap **Sync Now** any time you want to trigger a manual refresh. +5. Switch to the **Files** tab to browse the LittleFS volume on the Cardboy: you can upload ROMs from the Files picker, create/remove folders, rename entries, delete files, and download items back to the phone for sharing. + +## BLE File Service Protocol + +The ESP firmware exposes a custom GATT service (UUID `00000010-CA7B-4EFD-B5A6-10C3F4D3F230`) with two characteristics: + +| Characteristic | UUID | Properties | Direction | Description | +| --- | --- | --- | --- | --- | +| File Command | `00000011-CA7B-4EFD-B5A6-10C3F4D3F231` | Write / Write Without Response | iOS → ESP | Sends file management requests | +| File Response | `00000012-CA7B-4EFD-B5A6-10C3F4D3F232` | Notify | ESP → iOS | Streams command results (responses or data) | + +All payloads share the same framing. Commands written to the File Command characteristic use: + +``` +Offset | Size | Description +-------+------+------------ +0 | 1 | Opcode (see table below) +1 | 1 | Reserved (set to 0) +2 | 2 | Little-endian payload length in bytes (N) +4 | N | Command payload +``` + +Notifications from the File Response characteristic use: + +``` +Offset | Size | Description +-------+------+------------ +0 | 1 | Opcode (echoed from command) +1 | 1 | Status byte (bit 7 = completion flag; lower 7 bits = error code) +2 | 2 | Little-endian payload length (N) +4 | N | Response payload (command-specific) +``` + +Status byte semantics: +- Bit 7 (0x80) set → final packet for the current command (no further fragments). +- Lower 7 bits = error code (`0` = success, otherwise `errno`-style code echoed back). + - On error the response payload may contain a UTF-8 message. + +### Opcodes and Payloads + +| Opcode | Name | Command Payload | Response Payload | +| --- | --- | --- | --- | +| `0x01` | List Directory | `uint16 path_len` + UTF-8 path | One or more fragments, each entry encoded as:
- `uint8 type` (0=file, 1=dir)
- `uint8 reserved`
- `uint16 name_len`
- `uint32 size` (0 for dirs)
- `name_len` bytes UTF-8 name
Final notification has completion bit set. | +| `0x02` | Upload Begin | `uint16 path_len` + UTF-8 path + `uint32 file_size` | Empty payload on success. Starts upload session (expects `UploadChunk` packets). | +| `0x03` | Upload Chunk | Raw file bytes | Empty payload ack for each chunk. | +| `0x04` | Upload End | No payload | Empty payload confirming completion. | +| `0x05` | Download Request | `uint16 path_len` + UTF-8 path | First notification: 4-byte little-endian total file size; subsequent notifications stream raw file data fragments. Completion bit marks the final chunk. | +| `0x06` | Delete File | `uint16 path_len` + UTF-8 path | Empty payload on success. | +| `0x07` | Create Directory | `uint16 path_len` + UTF-8 path | Empty payload on success. | +| `0x08` | Delete Directory | `uint16 path_len` + UTF-8 path | Empty payload on success. | +| `0x09` | Rename Path | `uint16 src_len` + UTF-8 source path + `uint16 dst_len` + UTF-8 destination path | Empty payload on success. | + +### Notes + +- Paths are absolute within the LittleFS volume; the firmware normalizes them and rejects entries containing `..`. +- Large responses (directory lists, downloads) may arrive in multiple notifications; the iOS client aggregates fragments until it sees the completion flag. +- Uploads are initiated with `Upload Begin` (including total size), followed by one or more `Upload Chunk` writes, and `Upload End` when done. +- Errors from the firmware propagate via the status byte; when `status & 0x7F != 0`, the notification payload typically includes a UTF-8 error message (e.g., `"stat failed"`). + +This protocol mirrors the implementation in `components/backend-esp/src/time_sync_service.cpp` and the Swift client in `TimeSyncManager.swift`. Update both sides if new commands are added. Optionally bundle this code into your existing app—`TimeSyncManager` is self‑contained and can be reused. diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/Contents.json b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..b6e521d 100644 --- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "cardboy-icon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -12,6 +13,7 @@ "value" : "dark" } ], + "filename" : "cardboy-icon-dark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -23,6 +25,7 @@ "value" : "tinted" } ], + "filename" : "cardboy-icon-tinted.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.png b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.png new file mode 100644 index 0000000..10137e1 Binary files /dev/null and b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.png differ diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.svg b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.svg new file mode 100644 index 0000000..a724a49 --- /dev/null +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.svg @@ -0,0 +1,1356 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.png b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.png new file mode 100644 index 0000000..3aec464 Binary files /dev/null and b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.png differ diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.svg b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.svg new file mode 100644 index 0000000..3928af1 --- /dev/null +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.svg @@ -0,0 +1,1356 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.png b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.png new file mode 100644 index 0000000..21122ad Binary files /dev/null and b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.png differ diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.svg b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.svg new file mode 100644 index 0000000..e9ab20a --- /dev/null +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.svg @@ -0,0 +1,1356 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift index b2afa73..da11b6f 100644 --- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift @@ -1,7 +1,48 @@ +import Combine import SwiftUI +import UIKit +import UniformTypeIdentifiers struct ContentView: View { @EnvironmentObject private var manager: TimeSyncManager + @State private var selectedTab = 0 + @State private var shareURL: URL? + @State private var errorWrapper: ErrorWrapper? + + var body: some View { + TabView(selection: $selectedTab) { + TimeSyncTabView() + .tabItem { Label("Clock", systemImage: "clock.arrow.circlepath") } + .tag(0) + + FileManagerTabView(shareURL: $shareURL, errorWrapper: $errorWrapper) + .tabItem { Label("Files", systemImage: "folder") } + .tag(1) + } + .sheet(item: $shareURL) { url in + ShareSheet(items: [url]) + } + .alert(item: $errorWrapper) { wrapper in + Alert(title: Text("Error"), message: Text(wrapper.message), dismissButton: .default(Text("OK"))) + } + .onReceive(manager.$fileErrorMessage.compactMap { $0 }) { message in + errorWrapper = ErrorWrapper(message: message) + manager.fileErrorMessage = nil + } + .onReceive(manager.$downloadedFileURL.compactMap { $0 }) { url in + shareURL = url + manager.downloadedFileURL = nil + } + } +} + +private struct ErrorWrapper: Identifiable { + let id = UUID() + let message: String +} + +private struct TimeSyncTabView: View { + @EnvironmentObject private var manager: TimeSyncManager private var formattedLastSync: String { guard let date = manager.lastSyncDate else { return "Never" } @@ -52,6 +93,288 @@ struct ContentView: View { } } +private struct FileManagerTabView: View { + @EnvironmentObject private var manager: TimeSyncManager + @Binding var shareURL: URL? + @Binding var errorWrapper: ErrorWrapper? + + @State private var showingImporter = false + @State private var showingNewFolderSheet = false + @State private var showingRenameSheet = false + @State private var newFolderName = "" + @State private var renameText = "" + @State private var renameTarget: TimeSyncManager.RemoteFileEntry? + + private var pathComponents: [String] { + manager.currentDirectory.split(separator: "/").map(String.init) + } + + var body: some View { + ZStack { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Path:") + .font(.headline) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + Button(action: { manager.changeDirectory(to: "/") }) { + Text("/") + } + .buttonStyle(.bordered) + + ForEach(pathComponents.indices, id: \.self) { index in + let component = pathComponents[index] + Button(action: { + let path = "/" + pathComponents.prefix(index + 1).joined(separator: "/") + manager.changeDirectory(to: path) + }) { + Text(component) + } + .buttonStyle(.bordered) + } + } + } + } + .padding(.horizontal) + + List { + ForEach(manager.directoryEntries) { entry in + FileRow(entry: entry) + .contentShape(Rectangle()) + .onTapGesture { + if entry.isDirectory { + manager.enter(directory: entry) + } + } + .contextMenu { + if entry.isDirectory { + Button("Open", action: { manager.enter(directory: entry) }) + } else { + Button("Download") { + manager.download(entry: entry) { result in + switch result { + case .success(let url): + shareURL = url + case .failure(let error): + errorWrapper = ErrorWrapper(message: error.localizedDescription) + } + } + } + } + + Button("Rename") { + renameTarget = entry + renameText = entry.name + showingRenameSheet = true + } + + Button(role: .destructive) { + manager.delete(entry: entry) + } label: { + Text("Delete") + } + } + } + } + .listStyle(.plain) + + HStack { + Button { + showingImporter = true + } label: { + Label("Upload", systemImage: "square.and.arrow.up") + } + .buttonStyle(.bordered) + + Button { + showingNewFolderSheet = true + newFolderName = "" + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + .buttonStyle(.bordered) + + Button { + manager.navigateUp() + } label: { + Label("Up", systemImage: "arrow.up") + } + .buttonStyle(.bordered) + + Spacer() + + Button { + manager.refreshDirectory() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + } + .padding([.horizontal, .bottom]) + } + + if let operation = manager.activeFileOperation { + FileOperationHUD(operation: operation) + } + } + .fileImporter( + isPresented: $showingImporter, + allowedContentTypes: [.data], + allowsMultipleSelection: false + ) { result in + switch result { + case .success(let urls): + guard let url = urls.first else { return } + manager.uploadFile(from: url) { uploadResult in + if case let .failure(error) = uploadResult { + errorWrapper = ErrorWrapper(message: error.localizedDescription) + } + } + case .failure(let error): + errorWrapper = ErrorWrapper(message: error.localizedDescription) + } + } + .sheet(isPresented: $showingNewFolderSheet) { + NavigationView { + Form { + Section(header: Text("Folder Name")) { + TextField("Name", text: $newFolderName) + .autocapitalization(.none) + } + } + .navigationTitle("New Folder") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showingNewFolderSheet = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("Create") { + let name = newFolderName.trimmingCharacters(in: .whitespacesAndNewlines) + if !name.isEmpty { + manager.createDirectory(named: name) + } + showingNewFolderSheet = false + } + } + } + } + } + .sheet(isPresented: $showingRenameSheet) { + NavigationView { + Form { + Section(header: Text("New Name")) { + TextField("Name", text: $renameText) + .autocapitalization(.none) + } + } + .navigationTitle("Rename") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showingRenameSheet = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("Rename") { + if let target = renameTarget { + let newName = renameText.trimmingCharacters(in: .whitespacesAndNewlines) + if !newName.isEmpty { + manager.rename(entry: target, to: newName) + } + } + showingRenameSheet = false + } + } + } + } + } + } +} + +private struct FileRow: View { + let entry: TimeSyncManager.RemoteFileEntry + + private var iconName: String { + entry.isDirectory ? "folder" : "doc.text" + } + + private var formattedSize: String { + guard !entry.isDirectory else { return "" } + let byteCountFormatter = ByteCountFormatter() + byteCountFormatter.allowedUnits = [.useKB, .useMB, .useGB] + byteCountFormatter.countStyle = .file + return byteCountFormatter.string(fromByteCount: Int64(entry.size)) + } + + var body: some View { + HStack { + Image(systemName: iconName) + .foregroundColor(entry.isDirectory ? .accentColor : .primary) + VStack(alignment: .leading) { + Text(entry.name) + .fontWeight(entry.isDirectory ? .semibold : .regular) + if !entry.isDirectory, !formattedSize.isEmpty { + Text(formattedSize) + .font(.caption) + .foregroundColor(.secondary) + } + } + Spacer() + } + .padding(.vertical, 4) + } +} + +private struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + +private struct FileOperationHUD: View { + let operation: TimeSyncManager.FileOperationProgress + + var body: some View { + ZStack { + Color.black.opacity(0.3) + .ignoresSafeArea() + + VStack(spacing: 12) { + Text(operation.title) + .font(.headline) + Text(operation.message) + .font(.subheadline) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + + if let progress = operation.progress { + ProgressView(value: progress) + .progressViewStyle(.linear) + .frame(maxWidth: 240) + Text("\(Int(progress * 100))%") + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + } else { + ProgressView() + .progressViewStyle(.circular) + } + } + .padding(24) + .background(.ultraThinMaterial) + .cornerRadius(16) + .shadow(radius: 10) + .padding() + } + .transition(.opacity) + } +} + +extension URL: Identifiable { + public var id: String { absoluteString } +} + #Preview { ContentView() .environmentObject(TimeSyncManager()) diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift index bb6641f..a5b3c06 100644 --- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift +++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift @@ -1,6 +1,7 @@ import Combine import CoreBluetooth import Foundation +import UniformTypeIdentifiers final class TimeSyncManager: NSObject, ObservableObject { enum ConnectionState: String { @@ -12,12 +13,82 @@ final class TimeSyncManager: NSObject, ObservableObject { case failed = "Failed" } + struct RemoteFileEntry: Identifiable, Hashable { + enum EntryType: Hashable { + case file(size: UInt32) + case directory + } + + let path: String + let name: String + let type: EntryType + + var id: String { path } + + var isDirectory: Bool { + if case .directory = type { + return true + } + return false + } + + var size: UInt32 { + if case let .file(size) = type { + return size + } + return 0 + } + } + + enum FileServiceError: Error, LocalizedError { + case characteristicUnavailable + case busy + case remoteError(code: Int, message: String?) + case invalidResponse + + var errorDescription: String? { + switch self { + case .characteristicUnavailable: + return "File service is not ready yet." + case .busy: + return "Another file operation is already running." + case let .remoteError(code, message): + if let message, !message.isEmpty { + return "\(message) (code \(code))" + } + return "Remote error (code \(code))" + case .invalidResponse: + return "Received an invalid response from the device." + } + } + } + @Published private(set) var connectionState: ConnectionState = .idle @Published private(set) var statusMessage: String = "Waiting for Bluetooth…" @Published private(set) var lastSyncDate: Date? - private let serviceUUID = CBUUID(string: "00000001-CA7B-4EFD-B5A6-10C3F4D3F230") - private let characteristicUUID = CBUUID(string: "00000002-CA7B-4EFD-B5A6-10C3F4D3F231") + @Published private(set) var currentDirectory: String = "/" + @Published private(set) var directoryEntries: [RemoteFileEntry] = [] + @Published private(set) var isFileBusy: Bool = false + @Published var fileErrorMessage: String? + @Published var downloadedFileURL: URL? + @Published private(set) var activeFileOperation: FileOperationProgress? + + struct FileOperationProgress: Identifiable, Equatable { + let id: UUID + let title: String + let message: String + let progress: Double? + let indeterminate: Bool + } + + private let timeServiceUUID = CBUUID(string: "00000001-CA7B-4EFD-B5A6-10C3F4D3F230") + private let timeCharacteristicUUID = CBUUID(string: "00000002-CA7B-4EFD-B5A6-10C3F4D3F231") + + private let fileServiceUUID = CBUUID(string: "00000010-CA7B-4EFD-B5A6-10C3F4D3F230") + private let fileCommandUUID = CBUUID(string: "00000011-CA7B-4EFD-B5A6-10C3F4D3F231") + private let fileResponseUUID = CBUUID(string: "00000012-CA7B-4EFD-B5A6-10C3F4D3F232") + private let responseFlagComplete: UInt8 = 0x80 private lazy var central: CBCentralManager = CBCentralManager( delegate: self, @@ -30,15 +101,43 @@ final class TimeSyncManager: NSObject, ObservableObject { private var targetPeripheral: CBPeripheral? private var timeCharacteristic: CBCharacteristic? + private var fileCommandCharacteristic: CBCharacteristic? + private var fileResponseCharacteristic: CBCharacteristic? private var retryWorkItem: DispatchWorkItem? private var shouldKeepScanning = true private var isScanning = false + private var fileCommandQueue: [Data] = [] + private var isWritingFileCommand = false + + private var pendingListData = Data() + private var pendingListPath: String? + private var pendingListOperationID: UUID? + private var simpleOperationID: UUID? + + private struct UploadState { + let id: UUID + var remotePath: String + var data: Data + var offset: Int = 0 + var awaitingChunkAck: Bool = false + var completion: (Result) -> Void + } + private var uploadState: UploadState? + + private struct DownloadState { + let id: UUID + var remotePath: String + var expectedSize: Int? + var data = Data() + var completion: (Result) -> Void + } + private var downloadState: DownloadState? + override init() { super.init() - // Force the central manager to be created immediately so CoreBluetooth - // can begin delivering state updates without waiting for a manual action. + // Force central manager to initialise immediately so state updates arrive right away. _ = central if central.state == .poweredOn { @@ -50,12 +149,22 @@ final class TimeSyncManager: NSObject, ObservableObject { retryWorkItem?.cancel() } + // MARK: - Public BLE Controls + func forceRescan() { statusMessage = "Restarting scan…" shouldKeepScanning = true stopScanning() targetPeripheral = nil timeCharacteristic = nil + fileCommandCharacteristic = nil + fileResponseCharacteristic = nil + currentDirectory = "/" + directoryEntries = [] + isFileBusy = false + pendingListOperationID = nil + simpleOperationID = nil + clearOperation() startScanning() } @@ -87,14 +196,144 @@ final class TimeSyncManager: NSObject, ObservableObject { peripheral.writeValue(payload, for: characteristic, type: .withResponse) } + // MARK: - File operations exposed to UI + + func refreshDirectory() { + requestDirectory(path: currentDirectory) + } + + func enter(directory entry: RemoteFileEntry) { + guard entry.isDirectory else { return } + changeDirectory(to: NSString(string: currentDirectory).appendingPathComponent(entry.name)) + } + + func navigateUp() { + guard currentDirectory != "/" else { return } + changeDirectory(to: (currentDirectory as NSString).deletingLastPathComponent) + } + + func changeDirectory(to path: String) { + let normalized = normalizedPath(path) + currentDirectory = normalized + requestDirectory(path: normalized) + } + + func uploadFile(from url: URL, suggestedName: String? = nil, completion: @escaping (Result) -> Void) { + guard uploadState == nil else { + completion(.failure(FileServiceError.busy)) + return + } + guard let commandCharacteristic = fileCommandCharacteristic else { + completion(.failure(FileServiceError.characteristicUnavailable)) + return + } + + let fileData: Data + let shouldStopAccessing = url.startAccessingSecurityScopedResource() + defer { + if shouldStopAccessing { + url.stopAccessingSecurityScopedResource() + } + } + + do { + fileData = try Data(contentsOf: url) + } catch { + completion(.failure(error)) + return + } + + let name = suggestedName ?? url.lastPathComponent + let remotePath = normalizedPath(NSString(string: currentDirectory).appendingPathComponent(name)) + + let opID = UUID() + uploadState = UploadState(id: opID, remotePath: remotePath, data: fileData, completion: completion) + isFileBusy = true + updateOperation(id: opID, title: "Uploading", message: remotePath, progress: 0.0) + + let payload = payloadFor(path: remotePath, extra: UInt32(fileData.count).littleEndianBytes) + enqueueFileCommand(.uploadBegin, payload: payload, characteristic: commandCharacteristic) + } + + func delete(entry: RemoteFileEntry) { + let opcode: FileCommand = entry.isDirectory ? .deleteDirectory : .deleteFile + guard let commandCharacteristic = fileCommandCharacteristic else { + fileErrorMessage = FileServiceError.characteristicUnavailable.localizedDescription + return + } + let payload = payloadFor(path: entry.path) + isFileBusy = true + let opID = UUID() + let title = entry.isDirectory ? "Deleting Folder" : "Deleting File" + updateOperation(id: opID, title: title, message: entry.path, progress: nil, indeterminate: true) + simpleOperationID = opID + enqueueFileCommand(opcode, payload: payload, characteristic: commandCharacteristic) + } + + func createDirectory(named name: String) { + guard let commandCharacteristic = fileCommandCharacteristic else { + fileErrorMessage = FileServiceError.characteristicUnavailable.localizedDescription + return + } + let remotePath = normalizedPath(NSString(string: currentDirectory).appendingPathComponent(name)) + let payload = payloadFor(path: remotePath) + isFileBusy = true + let opID = UUID() + updateOperation(id: opID, title: "Creating Folder", message: remotePath, progress: nil, indeterminate: true) + simpleOperationID = opID + enqueueFileCommand(.createDirectory, payload: payload, characteristic: commandCharacteristic) + } + + func rename(entry: RemoteFileEntry, to newName: String) { + guard let commandCharacteristic = fileCommandCharacteristic else { + fileErrorMessage = FileServiceError.characteristicUnavailable.localizedDescription + return + } + + let srcPath = entry.path + let dstPath = normalizedPath(NSString(string: (srcPath as NSString).deletingLastPathComponent).appendingPathComponent(newName)) + + var payload = Data() + payload.appendPath(srcPath) + payload.appendPath(dstPath) + + isFileBusy = true + let opID = UUID() + updateOperation(id: opID, title: "Renaming", message: srcPath, progress: nil, indeterminate: true) + simpleOperationID = opID + enqueueFileCommand(.renamePath, payload: payload, characteristic: commandCharacteristic) + } + + func download(entry: RemoteFileEntry, completion: @escaping (Result) -> Void) { + guard !entry.isDirectory else { + completion(.failure(FileServiceError.busy)) + return + } + guard downloadState == nil else { + completion(.failure(FileServiceError.busy)) + return + } + guard let commandCharacteristic = fileCommandCharacteristic else { + completion(.failure(FileServiceError.characteristicUnavailable)) + return + } + + let opID = UUID() + downloadState = DownloadState(id: opID, remotePath: entry.path, expectedSize: nil, data: Data(), completion: completion) + isFileBusy = true + updateOperation(id: opID, title: "Downloading", message: entry.path, progress: nil, indeterminate: true) + let payload = payloadFor(path: entry.path) + enqueueFileCommand(.downloadRequest, payload: payload, characteristic: commandCharacteristic) + } + + // MARK: - Private helpers + private func startScanning() { guard shouldKeepScanning, central.state == .poweredOn else { return } if isScanning { return } - - central.scanForPeripherals(withServices: [serviceUUID], options: [ + central.scanForPeripherals(withServices: [timeServiceUUID, fileServiceUUID], options: [ CBCentralManagerScanOptionAllowDuplicatesKey: false ]) - isScanning = true connectionState = .scanning statusMessage = "Scanning for Cardboy…" @@ -115,8 +354,340 @@ final class TimeSyncManager: NSObject, ObservableObject { retryWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } + + private enum FileCommand: UInt8 { + case listDirectory = 0x01 + case uploadBegin = 0x02 + case uploadChunk = 0x03 + case uploadEnd = 0x04 + case downloadRequest = 0x05 + case deleteFile = 0x06 + case createDirectory = 0x07 + case deleteDirectory = 0x08 + case renamePath = 0x09 + } + + private func enqueueFileCommand(_ opcode: FileCommand, payload: Data, characteristic: CBCharacteristic) { + var packet = Data() + packet.append(opcode.rawValue) + packet.append(UInt8(0)) + packet.append(UInt8(payload.count & 0xFF)) + packet.append(UInt8((payload.count >> 8) & 0xFF)) + packet.append(payload) + + fileCommandQueue.append(packet) + processFileCommandQueue(characteristic: characteristic) + } + + private func processFileCommandQueue(characteristic: CBCharacteristic? = nil) { + guard !isWritingFileCommand else { return } + guard let characteristic = characteristic ?? fileCommandCharacteristic else { return } + guard !fileCommandQueue.isEmpty, let peripheral = targetPeripheral else { return } + + let packet = fileCommandQueue.removeFirst() + isWritingFileCommand = true + peripheral.writeValue(packet, for: characteristic, type: .withResponse) + } + + private func resetFileStateOnDisconnect() { + fileCommandQueue.removeAll() + isWritingFileCommand = false + uploadState = nil + downloadState = nil + pendingListData.removeAll() + pendingListPath = nil + pendingListOperationID = nil + simpleOperationID = nil + isFileBusy = false + currentDirectory = "/" + directoryEntries = [] + clearOperation() + } + + private func payloadFor(path: String, extra: [UInt8]? = nil) -> Data { + var data = Data() + data.appendPath(path) + if let extra { + data.append(contentsOf: extra) + } + return data + } + + private func updateOperation(id: UUID, title: String, message: String, progress: Double?, indeterminate: Bool = false) { + DispatchQueue.main.async { + self.activeFileOperation = FileOperationProgress(id: id, title: title, message: message, progress: progress, indeterminate: indeterminate) + } + } + + private func clearOperation(ifMatches id: UUID? = nil) { + DispatchQueue.main.async { + if let id, self.activeFileOperation?.id != id { + return + } + self.activeFileOperation = nil + } + } + + private func normalizedPath(_ path: String) -> String { + var normalized = path + if normalized.isEmpty { + normalized = "/" + } + if !normalized.hasPrefix("/") { + normalized = "/" + normalized + } + if normalized.count > 1 && normalized.hasSuffix("/") { + normalized.removeLast() + } + return normalized + } + + private func handleFileResponse(_ data: Data) { + guard data.count >= 4 else { + fileErrorMessage = FileServiceError.invalidResponse.localizedDescription + return + } + + let opcodeRaw = data[0] + let status = data[1] + let length = Int(data[2]) | (Int(data[3]) << 8) + guard data.count >= 4 + length else { + fileErrorMessage = FileServiceError.invalidResponse.localizedDescription + return + } + + let payload = data.subdata(in: 4 ..< 4 + length) + let isComplete = (status & responseFlagComplete) != 0 + let errorCode = Int(status & ~responseFlagComplete) + + if errorCode != 0 { + let message = String(data: payload, encoding: .utf8) + handleFileError(opcodeRaw, code: errorCode, message: message) + return + } + + guard let opcode = FileCommand(rawValue: opcodeRaw) else { return } + + switch opcode { + case .listDirectory: + pendingListData.append(payload) + if isComplete { + let opID = pendingListOperationID + finalizeDirectoryListing(path: pendingListPath ?? currentDirectory, data: pendingListData) + pendingListData.removeAll() + pendingListPath = nil + isFileBusy = false + pendingListOperationID = nil + clearOperation(ifMatches: opID) + } + case .uploadBegin: + if isComplete { + sendNextUploadChunk() + } + case .uploadChunk: + if isComplete { + uploadState?.awaitingChunkAck = false + sendNextUploadChunk() + } + case .uploadEnd: + if isComplete { + let opID = uploadState?.id + let completion = uploadState?.completion + uploadState = nil + isFileBusy = false + completion?(.success(())) + refreshDirectory() + clearOperation(ifMatches: opID) + } + case .downloadRequest: + handleDownloadResponse(payload: payload, isComplete: isComplete) + case .deleteFile, .createDirectory, .deleteDirectory, .renamePath: + if isComplete { + isFileBusy = false + refreshDirectory() + let opID = simpleOperationID + simpleOperationID = nil + clearOperation(ifMatches: opID) + } + } + } + + private func handleFileError(_ opcodeRaw: UInt8, code: Int, message: String?) { + var operationID: UUID? + defer { + isFileBusy = false + clearOperation(ifMatches: operationID) + } + + let error = FileServiceError.remoteError(code: code, message: message) + fileErrorMessage = error.localizedDescription + pendingListData.removeAll() + pendingListPath = nil + pendingListOperationID = nil + + switch FileCommand(rawValue: opcodeRaw) { + case .listDirectory: + operationID = pendingListOperationID + pendingListOperationID = nil + case .uploadBegin, .uploadChunk, .uploadEnd: + operationID = uploadState?.id + if let completion = uploadState?.completion { + completion(.failure(error)) + } + uploadState = nil + case .downloadRequest: + operationID = downloadState?.id + if let completion = downloadState?.completion { + completion(.failure(error)) + } + downloadState = nil + case .deleteFile, .createDirectory, .deleteDirectory, .renamePath: + operationID = simpleOperationID + simpleOperationID = nil + default: + break + } + } + + private func finalizeDirectoryListing(path: String, data: Data) { + var entries: [RemoteFileEntry] = [] + var index = data.startIndex + while index + 8 <= data.endIndex { + let typeRaw = data[index] + index += 1 // type + index += 1 // reserved + let nameLen = Int(data[index]) | (Int(data[index + 1]) << 8) + index += 2 + let size = UInt32(data[index]) | + (UInt32(data[index + 1]) << 8) | + (UInt32(data[index + 2]) << 16) | + (UInt32(data[index + 3]) << 24) + index += 4 + guard index + nameLen <= data.endIndex else { break } + let nameData = data[index ..< index + nameLen] + index += nameLen + guard let name = String(data: nameData, encoding: .utf8) else { continue } + + let fullPath = normalizedPath(NSString(string: path).appendingPathComponent(name)) + let type: RemoteFileEntry.EntryType = (typeRaw == 1) ? .directory : .file(size: size) + entries.append(RemoteFileEntry(path: fullPath, name: name, type: type)) + } + + directoryEntries = entries.sorted { + if $0.isDirectory == $1.isDirectory { + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + return $0.isDirectory && !$1.isDirectory + } + } + + private func sendNextUploadChunk() { + guard var state = uploadState else { return } + guard let commandCharacteristic = fileCommandCharacteristic, + targetPeripheral != nil else { return } + + if state.awaitingChunkAck { + uploadState = state + return + } + + if state.offset >= state.data.count { + updateOperation(id: state.id, title: "Uploading", message: state.remotePath, progress: 1.0) + print("Upload progress \(state.remotePath): 100%") + uploadState = state + enqueueFileCommand(.uploadEnd, payload: Data(), characteristic: commandCharacteristic) + return + } + + let chunkSize = min(140, state.data.count - state.offset) + let chunk = state.data[state.offset ..< state.offset + chunkSize] + state.offset += chunkSize + state.awaitingChunkAck = true + let totalBytes = max(state.data.count, 1) + let progress = Double(state.offset) / Double(totalBytes) + updateOperation(id: state.id, title: "Uploading", message: state.remotePath, progress: progress) + print("Upload progress \(state.remotePath): \(Int(progress * 100))%") + uploadState = state + + enqueueFileCommand(.uploadChunk, payload: Data(chunk), characteristic: commandCharacteristic) + processFileCommandQueue(characteristic: commandCharacteristic) + } + + private func handleDownloadResponse(payload: Data, isComplete: Bool) { + guard var state = downloadState else { return } + + if state.expectedSize == nil && payload.count == 4 { + let size = Int(payload[0]) | (Int(payload[1]) << 8) | (Int(payload[2]) << 16) | (Int(payload[3]) << 24) + state.expectedSize = size + downloadState = state + if size == 0 { + updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: 1.0) + } else { + updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: 0.0) + } + if isComplete && size == 0 { + finishDownload(state: state) + } + return + } + + state.data.append(payload) + downloadState = state + + if let expected = state.expectedSize, expected > 0 { + let progress = min(Double(state.data.count) / Double(expected), 1.0) + updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: progress) + print("Download progress \(state.remotePath): \(Int(progress * 100))%") + } else { + updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: nil, indeterminate: true) + } + + if isComplete { + finishDownload(state: state) + } + } + + private func finishDownload(state: DownloadState) { + var completedState = state + defer { + downloadState = nil + isFileBusy = false + clearOperation(ifMatches: completedState.id) + } + + updateOperation(id: completedState.id, title: "Download Complete", message: completedState.remotePath, progress: 1.0) + print("Download progress \(completedState.remotePath): 100%") + + let filename = NSString(string: completedState.remotePath).lastPathComponent + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) + + do { + try completedState.data.write(to: tempURL, options: .atomic) + completedState.completion(.success(tempURL)) + downloadedFileURL = tempURL + } catch { + completedState.completion(.failure(error)) + } + } + + private func requestDirectory(path: String) { + guard let commandCharacteristic = fileCommandCharacteristic else { + fileErrorMessage = FileServiceError.characteristicUnavailable.localizedDescription + return + } + pendingListPath = path + pendingListData.removeAll() + isFileBusy = true + let opID = UUID() + pendingListOperationID = opID + updateOperation(id: opID, title: "Loading", message: path, progress: nil, indeterminate: true) + let payload = payloadFor(path: path) + enqueueFileCommand(.listDirectory, payload: payload, characteristic: commandCharacteristic) + } } +// MARK: - CBCentralManagerDelegate + extension TimeSyncManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { shouldKeepScanning = true @@ -158,8 +729,8 @@ extension TimeSyncManager: CBCentralManagerDelegate { } } - func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], - rssi RSSI: NSNumber) { + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], rssi RSSI: NSNumber) { statusMessage = "Found \(peripheral.name ?? "device"), connecting…" connectionState = .connecting shouldKeepScanning = false @@ -172,27 +743,31 @@ extension TimeSyncManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { statusMessage = "Connected. Discovering services…" connectionState = .discovering - peripheral.discoverServices([serviceUUID]) + peripheral.discoverServices([timeServiceUUID, fileServiceUUID]) } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { statusMessage = "Failed to connect. \(error?.localizedDescription ?? "")" connectionState = .failed targetPeripheral = nil - timeCharacteristic = nil + resetFileStateOnDisconnect() scheduleRetry() } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - let reason = error?.localizedDescription ?? "Disconnected." - statusMessage = reason + statusMessage = "Disconnected. \(error?.localizedDescription ?? "")" connectionState = .idle targetPeripheral = nil timeCharacteristic = nil + fileCommandCharacteristic = nil + fileResponseCharacteristic = nil + resetFileStateOnDisconnect() scheduleRetry() } } +// MARK: - CBPeripheralDelegate + extension TimeSyncManager: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error { @@ -209,8 +784,12 @@ extension TimeSyncManager: CBPeripheralDelegate { return } - for service in services where service.uuid == serviceUUID { - peripheral.discoverCharacteristics([characteristicUUID], for: service) + for service in services { + if service.uuid == timeServiceUUID { + peripheral.discoverCharacteristics([timeCharacteristicUUID], for: service) + } else if service.uuid == fileServiceUUID { + peripheral.discoverCharacteristics([fileCommandUUID, fileResponseUUID], for: service) + } } } @@ -222,38 +801,99 @@ extension TimeSyncManager: CBPeripheralDelegate { return } - guard let characteristics = service.characteristics, - let targetCharacteristic = characteristics.first(where: { $0.uuid == characteristicUUID }) else { - statusMessage = "Time sync characteristic missing." - connectionState = .failed - scheduleRetry() + guard let characteristics = service.characteristics else { return } + + for characteristic in characteristics { + if characteristic.uuid == timeCharacteristicUUID { + timeCharacteristic = characteristic + sendCurrentTime() + } else if characteristic.uuid == fileCommandUUID { + fileCommandCharacteristic = characteristic + processFileCommandQueue(characteristic: characteristic) + } else if characteristic.uuid == fileResponseUUID { + fileResponseCharacteristic = characteristic + peripheral.setNotifyValue(true, for: characteristic) + } + } + + if timeCharacteristic != nil && fileResponseCharacteristic != nil && connectionState == .discovering { + connectionState = .ready + statusMessage = "Connected to Cardboy." + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + if let error { + fileErrorMessage = error.localizedDescription return } - timeCharacteristic = targetCharacteristic - connectionState = .ready - statusMessage = "Ready to sync time." - sendCurrentTime() + if characteristic.uuid == fileResponseUUID, characteristic.isNotifying { + refreshDirectory() + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + if let error { + fileErrorMessage = error.localizedDescription + isFileBusy = false + return + } + + guard let value = characteristic.value else { return } + + if characteristic.uuid == fileResponseUUID { + handleFileResponse(value) + } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { - if let error { - statusMessage = "Write failed: \(error.localizedDescription)" - connectionState = .failed - scheduleRetry() - return + if characteristic.uuid == fileCommandUUID { + if let error { + fileErrorMessage = error.localizedDescription + } + isWritingFileCommand = false + processFileCommandQueue(characteristic: characteristic) } - - lastSyncDate = Date() - if let date = lastSyncDate { - let formatted = DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .medium) - statusMessage = "Time synced at \(formatted)." - } else { - statusMessage = "Time synced." - } - connectionState = .ready - - shouldKeepScanning = true - central.cancelPeripheralConnection(peripheral) + } +} + +// MARK: - Data helpers + +private extension Data { + mutating func appendPath(_ path: String) { + let normalized: String + if path.isEmpty { + normalized = "/" + } else if path.hasPrefix("/") { + normalized = path + } else { + normalized = "/" + path + } + let pathData = normalized.data(using: .utf8) ?? Data() + append(contentsOf: UInt16(pathData.count).littleEndianBytes) + append(pathData) + } +} + +private extension UInt16 { + var littleEndianBytes: [UInt8] { + let value = self.littleEndian + return [ + UInt8(value & 0xFF), + UInt8((value >> 8) & 0xFF), + ] + } +} + +private extension UInt32 { + var littleEndianBytes: [UInt8] { + let value = self.littleEndian + return [ + UInt8(value & 0xFF), + UInt8((value >> 8) & 0xFF), + UInt8((value >> 16) & 0xFF), + UInt8((value >> 24) & 0xFF), + ] } } diff --git a/Firmware/components/backend-esp/src/time_sync_service.cpp b/Firmware/components/backend-esp/src/time_sync_service.cpp index 296fd60..c9bb258 100644 --- a/Firmware/components/backend-esp/src/time_sync_service.cpp +++ b/Firmware/components/backend-esp/src/time_sync_service.cpp @@ -1,5 +1,6 @@ #include "cardboy/backend/esp/time_sync_service.hpp" +#include "cardboy/backend/esp/fs_helper.hpp" #include "sdkconfig.h" #include @@ -7,23 +8,33 @@ #include #include "esp_log.h" #include "freertos/FreeRTOS.h" +#include "freertos/queue.h" #include "freertos/task.h" #include "host/ble_gap.h" #include "host/ble_gatt.h" #include "host/ble_hs.h" +#include "host/ble_hs_mbuf.h" #include "host/util/util.h" #include "nimble/nimble_port.h" #include "nimble/nimble_port_freertos.h" +#include "os/os_mbuf.h" #include "services/gap/ble_svc_gap.h" #include "services/gatt/ble_svc_gatt.h" +#include +#include #include #include #include #include #include +#include #include #include +#include +#include +#include +#include namespace cardboy::backend::esp { @@ -38,6 +49,13 @@ static const ble_uuid128_t kTimeServiceUuid = BLE_UUID128_INIT(0x30, 0xF2, 0xD static const ble_uuid128_t kTimeWriteCharUuid = BLE_UUID128_INIT(0x31, 0xF2, 0xD3, 0xF4, 0xC3, 0x10, 0xA6, 0xB5, 0xFD, 0x4E, 0x7B, 0xCA, 0x02, 0x00, 0x00, 0x00); +static const ble_uuid128_t kFileServiceUuid = BLE_UUID128_INIT(0x30, 0xF2, 0xD3, 0xF4, 0xC3, 0x10, 0xA6, 0xB5, 0xFD, + 0x4E, 0x7B, 0xCA, 0x10, 0x00, 0x00, 0x00); +static const ble_uuid128_t kFileCommandCharUuid = BLE_UUID128_INIT(0x31, 0xF2, 0xD3, 0xF4, 0xC3, 0x10, 0xA6, 0xB5, 0xFD, + 0x4E, 0x7B, 0xCA, 0x11, 0x00, 0x00, 0x00); +static const ble_uuid128_t kFileResponseCharUuid = BLE_UUID128_INIT(0x32, 0xF2, 0xD3, 0xF4, 0xC3, 0x10, 0xA6, 0xB5, + 0xFD, 0x4E, 0x7B, 0xCA, 0x12, 0x00, 0x00, 0x00); + struct [[gnu::packed]] TimeSyncPayload { std::uint64_t epochSeconds; // Unix time in seconds (UTC) std::int16_t timezoneOffsetMinutes; // Minutes east of UTC @@ -47,12 +65,143 @@ struct [[gnu::packed]] TimeSyncPayload { static_assert(sizeof(TimeSyncPayload) == 12, "Unexpected payload size"); -static bool g_started = false; -static uint8_t g_ownAddrType = BLE_OWN_ADDR_PUBLIC; -static TaskHandle_t g_hostTaskHandle = nullptr; +static bool g_started = false; +static uint8_t g_ownAddrType = BLE_OWN_ADDR_PUBLIC; +static TaskHandle_t g_hostTaskHandle = nullptr; +static uint16_t g_activeConnHandle = BLE_HS_CONN_HANDLE_NONE; + +struct ResponseMessage { + uint8_t opcode; + uint8_t status; + uint16_t length; + uint8_t* data; +}; + +static QueueHandle_t g_responseQueue = nullptr; +static TaskHandle_t g_notifyTaskHandle = nullptr; +constexpr uint8_t kResponseOpcodeShutdown = 0xFF; + +static uint16_t g_fileCommandValueHandle = 0; +static uint16_t g_fileResponseValueHandle = 0; + +struct FileUploadContext { + FILE* file = nullptr; + std::string path; + std::size_t remaining = 0; + bool active = false; +}; + +struct FileDownloadContext { + FILE* file = nullptr; + std::size_t remaining = 0; + bool active = false; +}; + +static FileUploadContext g_uploadCtx{}; +static FileDownloadContext g_downloadCtx{}; + +enum class FileCommandCode : uint8_t { + ListDirectory = 0x01, + UploadBegin = 0x02, + UploadChunk = 0x03, + UploadEnd = 0x04, + DownloadRequest = 0x05, + DeleteFile = 0x06, + CreateDirectory = 0x07, + DeleteDirectory = 0x08, + RenamePath = 0x09, +}; + +constexpr uint8_t kResponseFlagComplete = 0x80; + +struct PacketHeader { + uint8_t opcode; + uint8_t status; + uint16_t length; +} __attribute__((packed)); int gapEventHandler(struct ble_gap_event* event, void* arg); void startAdvertising(); +int timeSyncWriteAccess(uint16_t connHandle, uint16_t attrHandle, ble_gatt_access_ctxt* ctxt, void* arg); +int fileCommandAccess(uint16_t connHandle, uint16_t attrHandle, ble_gatt_access_ctxt* ctxt, void* arg); +int fileResponseAccess(uint16_t connHandle, uint16_t attrHandle, ble_gatt_access_ctxt* ctxt, void* arg); +void handleGattsRegister(ble_gatt_register_ctxt* ctxt, void* arg); +bool sendFileResponse(uint8_t opcode, uint8_t status, const uint8_t* data, std::size_t length); +bool sendFileError(uint8_t opcode, int err, const char* message = nullptr); +bool sanitizePath(std::string_view input, std::string& absoluteOut); +void resetUploadContext(); +void resetDownloadContext(); +void handleListDirectory(const uint8_t* payload, std::size_t length); +void handleUploadBegin(const uint8_t* payload, std::size_t length); +void handleUploadChunk(const uint8_t* payload, std::size_t length); +void handleUploadEnd(); +void handleDownloadRequest(const uint8_t* payload, std::size_t length); +void handleDeletePath(const uint8_t* payload, std::size_t length, bool directory); +void handleCreateDirectory(const uint8_t* payload, std::size_t length); +void handleRename(const uint8_t* payload, std::size_t length); +bool enqueueFileResponse(uint8_t opcode, uint8_t status, const uint8_t* data, std::size_t length); +bool sendFileResponseNow(const ResponseMessage& msg); +void notificationTask(void* param); + +static const ble_gatt_chr_def kTimeServiceCharacteristics[] = { + { + .uuid = &kTimeWriteCharUuid.u, + .access_cb = timeSyncWriteAccess, + .arg = nullptr, + .descriptors = nullptr, + .flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_NO_RSP, + .min_key_size = 0, + .val_handle = nullptr, + .cpfd = nullptr, + }, + { + 0, + }, +}; + +static const ble_gatt_chr_def kFileServiceCharacteristics[] = { + { + .uuid = &kFileCommandCharUuid.u, + .access_cb = fileCommandAccess, + .arg = nullptr, + .descriptors = nullptr, + .flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_NO_RSP, + .min_key_size = 0, + .val_handle = &g_fileCommandValueHandle, + .cpfd = nullptr, + }, + { + .uuid = &kFileResponseCharUuid.u, + .access_cb = fileResponseAccess, + .arg = nullptr, + .descriptors = nullptr, + .flags = BLE_GATT_CHR_F_NOTIFY, + .min_key_size = 0, + .val_handle = &g_fileResponseValueHandle, + .cpfd = nullptr, + }, + { + 0, + }, +}; + +static const ble_gatt_svc_def kGattServices[] = { + { + .type = BLE_GATT_SVC_TYPE_PRIMARY, + .uuid = &kTimeServiceUuid.u, + .includes = nullptr, + .characteristics = kTimeServiceCharacteristics, + }, + { + .type = BLE_GATT_SVC_TYPE_PRIMARY, + .uuid = &kFileServiceUuid.u, + .includes = nullptr, + .characteristics = kFileServiceCharacteristics, + }, + { + 0, + }, +}; void setSystemTimeFromPayload(const TimeSyncPayload& payload) { timeval tv{}; @@ -87,6 +236,574 @@ void setSystemTimeFromPayload(const TimeSyncPayload& payload) { ESP_LOGI(kLogTag, "Timezone updated to %s", tzString); } +bool sanitizePath(std::string_view input, std::string& absoluteOut) { + std::string path(input); + if (path.empty()) + path = "/"; + + if (path.front() != '/') + path.insert(path.begin(), '/'); + + // Collapse multiple slashes + std::string cleaned; + cleaned.reserve(path.size()); + char prev = '\0'; + for (char ch: path) { + if (ch == '/' && prev == '/') + continue; + cleaned.push_back(ch); + prev = ch; + } + + if (cleaned.size() > 1 && cleaned.back() == '/') + cleaned.pop_back(); + + if (cleaned.find("..") != std::string::npos) + return false; + + absoluteOut.assign(FsHelper::get().basePath()); + absoluteOut.append(cleaned); + return true; +} + +bool enqueueFileResponse(uint8_t opcode, uint8_t status, const uint8_t* data, std::size_t length) { + if (!g_responseQueue || g_fileResponseValueHandle == 0 || g_activeConnHandle == BLE_HS_CONN_HANDLE_NONE) + return false; + + ResponseMessage msg{}; + msg.opcode = opcode; + msg.status = status; + msg.length = static_cast(std::min(length, 0xFFFF)); + if (msg.length > 0 && data != nullptr) { + msg.data = static_cast(pvPortMalloc(msg.length)); + if (!msg.data) { + ESP_LOGW(kLogTag, "Failed to allocate buffer for queued response (len=%u)", msg.length); + return false; + } + std::memcpy(msg.data, data, msg.length); + } else { + msg.data = nullptr; + } + + if (xQueueSend(g_responseQueue, &msg, pdMS_TO_TICKS(20)) != pdPASS) { + ESP_LOGW(kLogTag, "Response queue full; dropping packet opcode=0x%02x", opcode); + if (msg.data) + vPortFree(msg.data); + return false; + } + + return true; +} + +bool sendFileResponseNow(const ResponseMessage& msg) { + if (g_fileResponseValueHandle == 0 || g_activeConnHandle == BLE_HS_CONN_HANDLE_NONE) + return false; + + const std::size_t totalLength = sizeof(PacketHeader) + msg.length; + if (totalLength > 0xFFFF + sizeof(PacketHeader)) { + ESP_LOGW(kLogTag, "File response payload too large (%zu bytes)", totalLength); + return false; + } + + PacketHeader header{.opcode = msg.opcode, .status = msg.status, .length = msg.length}; + + constexpr int kMaxAttempts = 20; + for (int attempt = 0; attempt < kMaxAttempts; ++attempt) { + os_mbuf* om = os_msys_get_pkthdr(totalLength, 0); + if (om == nullptr) { + vTaskDelay(pdMS_TO_TICKS(5)); + continue; + } + + if (os_mbuf_append(om, &header, sizeof(header)) != 0 || + (msg.length > 0 && msg.data != nullptr && os_mbuf_append(om, msg.data, msg.length) != 0)) { + ESP_LOGW(kLogTag, "Failed to populate mbuf for file response"); + os_mbuf_free_chain(om); + return false; + } + + int rc = ble_gatts_notify_custom(g_activeConnHandle, g_fileResponseValueHandle, om); + if (rc == 0) { + return true; + } + + os_mbuf_free_chain(om); + + if (rc != BLE_HS_ENOMEM && rc != BLE_HS_EBUSY) { + ESP_LOGW(kLogTag, "ble_gatts_notify_custom failed: %d", rc); + return false; + } + + vTaskDelay(pdMS_TO_TICKS(5)); + } + + ESP_LOGW(kLogTag, "Failed to send file response opcode=0x%02x after %d attempts", msg.opcode, kMaxAttempts); + return false; +} + +bool sendFileResponse(uint8_t opcode, uint8_t status, const uint8_t* data, std::size_t length) { + return enqueueFileResponse(opcode, status, data, length); +} + +bool sendFileError(uint8_t opcode, int err, const char* message) { + const uint8_t status = static_cast(std::min(err, 0x7F)) | kResponseFlagComplete; + if (message && *message != '\0') { + const std::size_t len = std::strlen(message); + return enqueueFileResponse(opcode, status, reinterpret_cast(message), len); + } + return enqueueFileResponse(opcode, status, nullptr, 0); +} + +void resetUploadContext() { + if (g_uploadCtx.file) { + std::fclose(g_uploadCtx.file); + g_uploadCtx.file = nullptr; + } + g_uploadCtx.path.clear(); + g_uploadCtx.remaining = 0; + g_uploadCtx.active = false; +} + +void resetDownloadContext() { + if (g_downloadCtx.file) { + std::fclose(g_downloadCtx.file); + g_downloadCtx.file = nullptr; + } + g_downloadCtx.remaining = 0; + g_downloadCtx.active = false; +} + +void handleListDirectory(const uint8_t* payload, std::size_t length) { + if (length < sizeof(uint16_t)) { + sendFileError(static_cast(FileCommandCode::ListDirectory), EINVAL, "Invalid list payload"); + return; + } + const uint16_t pathLen = static_cast(payload[0] | (payload[1] << 8)); + if (length < sizeof(uint16_t) + pathLen) { + sendFileError(static_cast(FileCommandCode::ListDirectory), EINVAL, "Malformed list payload"); + return; + } + std::string relative(reinterpret_cast(payload + sizeof(uint16_t)), pathLen); + std::string absolute; + if (!sanitizePath(relative, absolute)) { + sendFileError(static_cast(FileCommandCode::ListDirectory), EINVAL, "Invalid path"); + return; + } + + DIR* dir = opendir(absolute.c_str()); + if (!dir) { + sendFileError(static_cast(FileCommandCode::ListDirectory), errno, "opendir failed"); + return; + } + + std::vector buffer; + buffer.reserve(196); + struct dirent* entry = nullptr; + + while ((entry = readdir(dir)) != nullptr) { + const char* name = entry->d_name; + if (std::strcmp(name, ".") == 0 || std::strcmp(name, "..") == 0) + continue; + + std::string fullPath = absolute; + fullPath.push_back('/'); + fullPath.append(name); + + struct stat st{}; + if (stat(fullPath.c_str(), &st) != 0) + continue; + + const std::size_t nameLen = std::strlen(name); + if (nameLen > 0xFFFF) + continue; + + const std::size_t recordSize = sizeof(uint8_t) * 2 + sizeof(uint16_t) + sizeof(uint32_t) + nameLen; + if (buffer.size() + recordSize > 180) { + if (!sendFileResponse(static_cast(FileCommandCode::ListDirectory), 0, buffer.data(), + buffer.size())) { + closedir(dir); + return; + } + vTaskDelay(pdMS_TO_TICKS(5)); + buffer.clear(); + } + + const uint8_t type = S_ISDIR(st.st_mode) ? 1 : (S_ISREG(st.st_mode) ? 0 : 2); + const uint32_t size = S_ISREG(st.st_mode) ? static_cast(st.st_size) : 0; + + buffer.push_back(type); + buffer.push_back(0); + buffer.push_back(static_cast(nameLen & 0xFF)); + buffer.push_back(static_cast((nameLen >> 8) & 0xFF)); + + buffer.push_back(static_cast(size & 0xFF)); + buffer.push_back(static_cast((size >> 8) & 0xFF)); + buffer.push_back(static_cast((size >> 16) & 0xFF)); + buffer.push_back(static_cast((size >> 24) & 0xFF)); + + buffer.insert(buffer.end(), name, name + nameLen); + } + + closedir(dir); + + const uint8_t opcode = static_cast(FileCommandCode::ListDirectory); + if (!buffer.empty()) { + if (!sendFileResponse(opcode, kResponseFlagComplete, buffer.data(), buffer.size())) + return; + vTaskDelay(pdMS_TO_TICKS(5)); + } else { + sendFileResponse(opcode, kResponseFlagComplete, nullptr, 0); + } +} + +void handleUploadBegin(const uint8_t* payload, std::size_t length) { + if (length < sizeof(uint16_t) + sizeof(uint32_t)) { + sendFileError(static_cast(FileCommandCode::UploadBegin), EINVAL, "Invalid upload header"); + return; + } + + const uint16_t pathLen = static_cast(payload[0] | (payload[1] << 8)); + if (length < sizeof(uint16_t) + pathLen + sizeof(uint32_t)) { + sendFileError(static_cast(FileCommandCode::UploadBegin), EINVAL, "Malformed upload header"); + return; + } + + const uint8_t* ptr = payload + sizeof(uint16_t); + std::string relative(reinterpret_cast(ptr), pathLen); + ptr += pathLen; + + const uint32_t fileSize = static_cast(ptr[0] | (ptr[1] << 8) | (ptr[2] << 16) | (ptr[3] << 24)); + + std::string absolute; + if (!sanitizePath(relative, absolute)) { + sendFileError(static_cast(FileCommandCode::UploadBegin), EINVAL, "Invalid path"); + return; + } + + resetUploadContext(); + + FILE* file = std::fopen(absolute.c_str(), "wb"); + if (!file) { + sendFileError(static_cast(FileCommandCode::UploadBegin), errno, "Failed to open file"); + return; + } + + g_uploadCtx.file = file; + g_uploadCtx.path = std::move(absolute); + g_uploadCtx.remaining = fileSize; + g_uploadCtx.active = true; + + if (!sendFileResponse(static_cast(FileCommandCode::UploadBegin), kResponseFlagComplete, nullptr, 0)) { + resetUploadContext(); + } +} + +void handleUploadChunk(const uint8_t* payload, std::size_t length) { + if (!g_uploadCtx.active || g_uploadCtx.file == nullptr) { + sendFileError(static_cast(FileCommandCode::UploadChunk), EBADF, "No active upload"); + return; + } + + if (length == 0) { + if (!sendFileResponse(static_cast(FileCommandCode::UploadChunk), kResponseFlagComplete, nullptr, 0)) { + resetUploadContext(); + return; + } + return; + } + + if (g_uploadCtx.remaining >= length) { + g_uploadCtx.remaining -= length; + } + + const size_t written = std::fwrite(payload, 1, length, g_uploadCtx.file); + if (written != length) { + const int err = ferror(g_uploadCtx.file) ? errno : EIO; + resetUploadContext(); + sendFileError(static_cast(FileCommandCode::UploadChunk), err, "Write failed"); + return; + } + + if (!sendFileResponse(static_cast(FileCommandCode::UploadChunk), kResponseFlagComplete, nullptr, 0)) { + resetUploadContext(); + } +} + +void handleUploadEnd() { + if (!g_uploadCtx.active || g_uploadCtx.file == nullptr) { + sendFileError(static_cast(FileCommandCode::UploadEnd), EBADF, "No active upload"); + return; + } + + std::fflush(g_uploadCtx.file); + resetUploadContext(); + sendFileResponse(static_cast(FileCommandCode::UploadEnd), kResponseFlagComplete, nullptr, 0); +} + +void handleDownloadRequest(const uint8_t* payload, std::size_t length) { + if (length < sizeof(uint16_t)) { + sendFileError(static_cast(FileCommandCode::DownloadRequest), EINVAL, "Invalid download payload"); + return; + } + + const uint16_t pathLen = static_cast(payload[0] | (payload[1] << 8)); + if (length < sizeof(uint16_t) + pathLen) { + sendFileError(static_cast(FileCommandCode::DownloadRequest), EINVAL, "Malformed path"); + return; + } + + std::string relative(reinterpret_cast(payload + sizeof(uint16_t)), pathLen); + std::string absolute; + if (!sanitizePath(relative, absolute)) { + sendFileError(static_cast(FileCommandCode::DownloadRequest), EINVAL, "Invalid path"); + return; + } + + resetDownloadContext(); + + FILE* file = std::fopen(absolute.c_str(), "rb"); + if (!file) { + sendFileError(static_cast(FileCommandCode::DownloadRequest), errno, "Failed to open file"); + return; + } + + struct stat st{}; + if (stat(absolute.c_str(), &st) != 0) { + std::fclose(file); + sendFileError(static_cast(FileCommandCode::DownloadRequest), errno, "stat failed"); + return; + } + + g_downloadCtx.file = file; + g_downloadCtx.remaining = static_cast(st.st_size); + g_downloadCtx.active = true; + + uint8_t sizePayload[4]; + const uint32_t size = static_cast(st.st_size); + sizePayload[0] = static_cast(size & 0xFF); + sizePayload[1] = static_cast((size >> 8) & 0xFF); + sizePayload[2] = static_cast((size >> 16) & 0xFF); + sizePayload[3] = static_cast((size >> 24) & 0xFF); + + const uint8_t opcode = static_cast(FileCommandCode::DownloadRequest); + if (!sendFileResponse(opcode, 0, sizePayload, sizeof(sizePayload))) { + resetDownloadContext(); + return; + } + + if (g_downloadCtx.remaining == 0) { + sendFileResponse(opcode, kResponseFlagComplete, nullptr, 0); + resetDownloadContext(); + return; + } + + std::array chunk{}; + while (g_downloadCtx.remaining > 0) { + const std::size_t toRead = std::min(chunk.size(), g_downloadCtx.remaining); + const std::size_t read = std::fread(chunk.data(), 1, toRead, g_downloadCtx.file); + if (read == 0) { + const int err = ferror(g_downloadCtx.file) ? errno : EIO; + resetDownloadContext(); + sendFileError(opcode, err, "Read failed"); + return; + } + + g_downloadCtx.remaining -= read; + const uint8_t status = (g_downloadCtx.remaining == 0) ? kResponseFlagComplete : 0; + if (!sendFileResponse(opcode, status, chunk.data(), read)) { + resetDownloadContext(); + return; + } + if (g_downloadCtx.remaining > 0) + vTaskDelay(pdMS_TO_TICKS(5)); + } + + resetDownloadContext(); +} + +void handleDeletePath(const uint8_t* payload, std::size_t length, bool directory) { + if (length < sizeof(uint16_t)) { + sendFileError(static_cast(directory ? FileCommandCode::DeleteDirectory : FileCommandCode::DeleteFile), + EINVAL, "Invalid payload"); + return; + } + const uint16_t pathLen = static_cast(payload[0] | (payload[1] << 8)); + if (length < sizeof(uint16_t) + pathLen) { + sendFileError(static_cast(directory ? FileCommandCode::DeleteDirectory : FileCommandCode::DeleteFile), + EINVAL, "Malformed path"); + return; + } + std::string relative(reinterpret_cast(payload + sizeof(uint16_t)), pathLen); + std::string absolute; + if (!sanitizePath(relative, absolute)) { + sendFileError(static_cast(directory ? FileCommandCode::DeleteDirectory : FileCommandCode::DeleteFile), + EINVAL, "Invalid path"); + return; + } + + int result = 0; + if (directory) { + result = rmdir(absolute.c_str()); + } else { + result = std::remove(absolute.c_str()); + } + + const uint8_t opcode = + static_cast(directory ? FileCommandCode::DeleteDirectory : FileCommandCode::DeleteFile); + if (result != 0) { + sendFileError(opcode, errno, "Remove failed"); + } else { + sendFileResponse(opcode, kResponseFlagComplete, nullptr, 0); + } +} + +void handleCreateDirectory(const uint8_t* payload, std::size_t length) { + if (length < sizeof(uint16_t)) { + sendFileError(static_cast(FileCommandCode::CreateDirectory), EINVAL, "Invalid payload"); + return; + } + const uint16_t pathLen = static_cast(payload[0] | (payload[1] << 8)); + if (length < sizeof(uint16_t) + pathLen) { + sendFileError(static_cast(FileCommandCode::CreateDirectory), EINVAL, "Malformed path"); + return; + } + std::string relative(reinterpret_cast(payload + sizeof(uint16_t)), pathLen); + std::string absolute; + if (!sanitizePath(relative, absolute)) { + sendFileError(static_cast(FileCommandCode::CreateDirectory), EINVAL, "Invalid path"); + return; + } + + if (mkdir(absolute.c_str(), 0755) != 0) { + sendFileError(static_cast(FileCommandCode::CreateDirectory), errno, "mkdir failed"); + } else { + sendFileResponse(static_cast(FileCommandCode::CreateDirectory), kResponseFlagComplete, nullptr, 0); + } +} + +void handleRename(const uint8_t* payload, std::size_t length) { + if (length < sizeof(uint16_t) * 2) { + sendFileError(static_cast(FileCommandCode::RenamePath), EINVAL, "Invalid rename payload"); + return; + } + + const uint16_t srcLen = static_cast(payload[0] | (payload[1] << 8)); + if (length < sizeof(uint16_t) + srcLen + sizeof(uint16_t)) { + sendFileError(static_cast(FileCommandCode::RenamePath), EINVAL, "Malformed source path"); + return; + } + + const uint8_t* ptr = payload + sizeof(uint16_t); + std::string srcRel(reinterpret_cast(ptr), srcLen); + ptr += srcLen; + + const uint16_t dstLen = static_cast(ptr[0] | (ptr[1] << 8)); + ptr += sizeof(uint16_t); + if (length < sizeof(uint16_t) * 2 + srcLen + dstLen) { + sendFileError(static_cast(FileCommandCode::RenamePath), EINVAL, "Malformed destination path"); + return; + } + + std::string dstRel(reinterpret_cast(ptr), dstLen); + + std::string srcAbs; + std::string dstAbs; + if (!sanitizePath(srcRel, srcAbs) || !sanitizePath(dstRel, dstAbs)) { + sendFileError(static_cast(FileCommandCode::RenamePath), EINVAL, "Invalid path"); + return; + } + + if (std::rename(srcAbs.c_str(), dstAbs.c_str()) != 0) { + sendFileError(static_cast(FileCommandCode::RenamePath), errno, "rename failed"); + } else { + sendFileResponse(static_cast(FileCommandCode::RenamePath), kResponseFlagComplete, nullptr, 0); + } +} + +void handleGattsRegister(ble_gatt_register_ctxt* ctxt, void* /*arg*/) { + if (ctxt->op == BLE_GATT_REGISTER_OP_CHR) { + if (ble_uuid_cmp(ctxt->chr.chr_def->uuid, &kFileCommandCharUuid.u) == 0) { + g_fileCommandValueHandle = ctxt->chr.val_handle; + ESP_LOGI(kLogTag, "File command characteristic handle=%u", g_fileCommandValueHandle); + } else if (ble_uuid_cmp(ctxt->chr.chr_def->uuid, &kFileResponseCharUuid.u) == 0) { + g_fileResponseValueHandle = ctxt->chr.val_handle; + ESP_LOGI(kLogTag, "File response characteristic handle=%u", g_fileResponseValueHandle); + } + } +} + +int fileCommandAccess(uint16_t connHandle, uint16_t /*attrHandle*/, ble_gatt_access_ctxt* ctxt, void* /*arg*/) { + if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) { + return BLE_ATT_ERR_READ_NOT_PERMITTED; + } + + const uint16_t incomingLen = OS_MBUF_PKTLEN(ctxt->om); + if (incomingLen < sizeof(PacketHeader)) { + sendFileError(static_cast(FileCommandCode::ListDirectory), EINVAL, "Command too short"); + return 0; + } + + std::vector buffer(incomingLen); + const int rc = os_mbuf_copydata(ctxt->om, 0, incomingLen, buffer.data()); + if (rc != 0) { + sendFileError(static_cast(FileCommandCode::ListDirectory), EIO, "Read failed"); + return 0; + } + + const auto* header = reinterpret_cast(buffer.data()); + const uint16_t payloadLen = header->length; + if (payloadLen + sizeof(PacketHeader) != incomingLen) { + sendFileError(header->opcode, EINVAL, "Length mismatch"); + return 0; + } + + const uint8_t* payload = buffer.data() + sizeof(PacketHeader); + + g_activeConnHandle = connHandle; + + switch (static_cast(header->opcode)) { + case FileCommandCode::ListDirectory: + handleListDirectory(payload, payloadLen); + break; + case FileCommandCode::UploadBegin: + handleUploadBegin(payload, payloadLen); + break; + case FileCommandCode::UploadChunk: + handleUploadChunk(payload, payloadLen); + break; + case FileCommandCode::UploadEnd: + handleUploadEnd(); + break; + case FileCommandCode::DownloadRequest: + handleDownloadRequest(payload, payloadLen); + break; + case FileCommandCode::DeleteFile: + handleDeletePath(payload, payloadLen, false); + break; + case FileCommandCode::CreateDirectory: + handleCreateDirectory(payload, payloadLen); + break; + case FileCommandCode::DeleteDirectory: + handleDeletePath(payload, payloadLen, true); + break; + case FileCommandCode::RenamePath: + handleRename(payload, payloadLen); + break; + default: + sendFileError(header->opcode, EINVAL, "Unknown opcode"); + break; + } + + return 0; +} + +int fileResponseAccess(uint16_t /*connHandle*/, uint16_t /*attrHandle*/, ble_gatt_access_ctxt* ctxt, void* /*arg*/) { + if (ctxt->op == BLE_GATT_ACCESS_OP_WRITE_CHR) { + return BLE_ATT_ERR_WRITE_NOT_PERMITTED; + } + return BLE_ATT_ERR_READ_NOT_PERMITTED; +} + int timeSyncWriteAccess(uint16_t /*conn_handle*/, uint16_t /*attr_handle*/, ble_gatt_access_ctxt* ctxt, void* /*arg*/) { if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) { return BLE_ATT_ERR_READ_NOT_PERMITTED; @@ -109,27 +826,35 @@ int timeSyncWriteAccess(uint16_t /*conn_handle*/, uint16_t /*attr_handle*/, ble_ return 0; } -const ble_gatt_svc_def kGattServices[] = { - { - .type = BLE_GATT_SVC_TYPE_PRIMARY, - .uuid = &kTimeServiceUuid.u, - .characteristics = - (ble_gatt_chr_def[]) { - { - .uuid = &kTimeWriteCharUuid.u, - .access_cb = timeSyncWriteAccess, - .flags = static_cast(BLE_GATT_CHR_F_WRITE | - BLE_GATT_CHR_F_WRITE_NO_RSP), - }, - { - 0, - }, - }, - }, - { - 0, - }, -}; +void notificationTask(void* /*param*/) { + ResponseMessage msg{}; + while (xQueueReceive(g_responseQueue, &msg, portMAX_DELAY) == pdTRUE) { + if (msg.opcode == kResponseOpcodeShutdown && msg.length == 0) { + if (msg.data) + vPortFree(msg.data); + break; + } + + bool sent = false; + for (uint8_t attempt = 0; attempt < 20; ++attempt) { + if (sendFileResponseNow(msg)) { + sent = true; + break; + } + vTaskDelay(pdMS_TO_TICKS(5)); + } + + if (!sent) { + ESP_LOGW(kLogTag, "Notification delivery failed for opcode=0x%02x", msg.opcode); + } + + if (msg.data) + vPortFree(msg.data); + } + + g_notifyTaskHandle = nullptr; + vTaskDelete(nullptr); +} void startAdvertising() { ble_hs_adv_fields fields{}; @@ -214,6 +939,7 @@ int gapEventHandler(struct ble_gap_event* event, void* /*arg*/) { case BLE_GAP_EVENT_CONNECT: if (event->connect.status == 0) { ESP_LOGI(kLogTag, "Connected; handle=%d", event->connect.conn_handle); + g_activeConnHandle = event->connect.conn_handle; } else { ESP_LOGW(kLogTag, "Connection attempt failed; status=%d", event->connect.status); startAdvertising(); @@ -222,6 +948,11 @@ int gapEventHandler(struct ble_gap_event* event, void* /*arg*/) { case BLE_GAP_EVENT_DISCONNECT: ESP_LOGI(kLogTag, "Disconnected; reason=%d", event->disconnect.reason); + g_activeConnHandle = BLE_HS_CONN_HANDLE_NONE; + resetUploadContext(); + resetDownloadContext(); + if (g_responseQueue) + xQueueReset(g_responseQueue); startAdvertising(); break; @@ -254,7 +985,7 @@ void configureGap() { bool initController() { ble_hs_cfg.reset_cb = onReset; ble_hs_cfg.sync_cb = onSync; - ble_hs_cfg.gatts_register_cb = nullptr; + ble_hs_cfg.gatts_register_cb = handleGattsRegister; ble_hs_cfg.store_status_cb = ble_store_util_status_rr; ble_hs_cfg.sm_io_cap = BLE_HS_IO_NO_INPUT_OUTPUT; ble_hs_cfg.sm_bonding = 0; @@ -291,6 +1022,31 @@ void ensure_time_sync_service_started() { return; } + if (!g_responseQueue) { + g_responseQueue = xQueueCreate(256, sizeof(ResponseMessage)); + if (!g_responseQueue) { + ESP_LOGE(kLogTag, "Failed to create response queue"); + nimble_port_deinit(); + esp_bt_controller_disable(); + esp_bt_controller_deinit(); + return; + } + } else { + xQueueReset(g_responseQueue); + } + + if (!g_notifyTaskHandle) { + if (xTaskCreate(notificationTask, "BleNotify", 4096, nullptr, 5, &g_notifyTaskHandle) != pdPASS) { + ESP_LOGE(kLogTag, "Failed to start notification task"); + vQueueDelete(g_responseQueue); + g_responseQueue = nullptr; + nimble_port_deinit(); + esp_bt_controller_disable(); + esp_bt_controller_deinit(); + return; + } + } + nimble_port_freertos_init(hostTask); g_started = true; ESP_LOGI(kLogTag, "BLE time sync service initialised"); @@ -316,6 +1072,23 @@ void shutdown_time_sync_service() { g_started = false; ESP_LOGI(kLogTag, "BLE time sync service stopped"); + + if (g_responseQueue) { + xQueueReset(g_responseQueue); + ResponseMessage stop{}; + stop.opcode = kResponseOpcodeShutdown; + stop.status = 0; + stop.length = 0; + stop.data = nullptr; + xQueueSend(g_responseQueue, &stop, pdMS_TO_TICKS(20)); + + while (g_notifyTaskHandle != nullptr) { + vTaskDelay(pdMS_TO_TICKS(5)); + } + + vQueueDelete(g_responseQueue); + g_responseQueue = nullptr; + } } } // namespace cardboy::backend::esp diff --git a/Firmware/main/src/app_main.cpp b/Firmware/main/src/app_main.cpp index c671a43..633f75f 100644 --- a/Firmware/main/src/app_main.cpp +++ b/Firmware/main/src/app_main.cpp @@ -239,7 +239,7 @@ extern "C" void app_main() { system.registerApp(apps::createTetrisAppFactory()); system.registerApp(apps::createGameboyAppFactory()); - // start_task_usage_monitor(); + start_task_usage_monitor(); system.run(); }