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();
}