mirror of
https://github.com/usatiuk/cardboy.git
synced 2025-10-28 15:17:48 +01:00
somewhat working file sync
This commit is contained in:
@@ -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:<br> - `uint8 type` (0=file, 1=dir)<br> - `uint8 reserved`<br> - `uint16 name_len`<br> - `uint32 size` (0 for dirs)<br> - `name_len` bytes UTF-8 name<br> 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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 41 KiB |
@@ -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())
|
||||
|
||||
@@ -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, Error>) -> Void
|
||||
}
|
||||
private var uploadState: UploadState?
|
||||
|
||||
private struct DownloadState {
|
||||
let id: UUID
|
||||
var remotePath: String
|
||||
var expectedSize: Int?
|
||||
var data = Data()
|
||||
var completion: (Result<URL, Error>) -> 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, Error>) -> 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<URL, Error>) -> 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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "cardboy/backend/esp/time_sync_service.hpp"
|
||||
|
||||
#include "cardboy/backend/esp/fs_helper.hpp"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#include <sys/time.h>
|
||||
@@ -7,23 +8,33 @@
|
||||
#include <unistd.h>
|
||||
#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 <algorithm>
|
||||
#include <array>
|
||||
#include <cerrno>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <dirent.h>
|
||||
#include <esp_bt.h>
|
||||
#include <esp_err.h>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <sys/stat.h>
|
||||
#include <vector>
|
||||
|
||||
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<uint16_t>(std::min<std::size_t>(length, 0xFFFF));
|
||||
if (msg.length > 0 && data != nullptr) {
|
||||
msg.data = static_cast<uint8_t*>(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<uint8_t>(std::min(err, 0x7F)) | kResponseFlagComplete;
|
||||
if (message && *message != '\0') {
|
||||
const std::size_t len = std::strlen(message);
|
||||
return enqueueFileResponse(opcode, status, reinterpret_cast<const uint8_t*>(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<uint8_t>(FileCommandCode::ListDirectory), EINVAL, "Invalid list payload");
|
||||
return;
|
||||
}
|
||||
const uint16_t pathLen = static_cast<uint16_t>(payload[0] | (payload[1] << 8));
|
||||
if (length < sizeof(uint16_t) + pathLen) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::ListDirectory), EINVAL, "Malformed list payload");
|
||||
return;
|
||||
}
|
||||
std::string relative(reinterpret_cast<const char*>(payload + sizeof(uint16_t)), pathLen);
|
||||
std::string absolute;
|
||||
if (!sanitizePath(relative, absolute)) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::ListDirectory), EINVAL, "Invalid path");
|
||||
return;
|
||||
}
|
||||
|
||||
DIR* dir = opendir(absolute.c_str());
|
||||
if (!dir) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::ListDirectory), errno, "opendir failed");
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> 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<uint8_t>(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<uint32_t>(st.st_size) : 0;
|
||||
|
||||
buffer.push_back(type);
|
||||
buffer.push_back(0);
|
||||
buffer.push_back(static_cast<uint8_t>(nameLen & 0xFF));
|
||||
buffer.push_back(static_cast<uint8_t>((nameLen >> 8) & 0xFF));
|
||||
|
||||
buffer.push_back(static_cast<uint8_t>(size & 0xFF));
|
||||
buffer.push_back(static_cast<uint8_t>((size >> 8) & 0xFF));
|
||||
buffer.push_back(static_cast<uint8_t>((size >> 16) & 0xFF));
|
||||
buffer.push_back(static_cast<uint8_t>((size >> 24) & 0xFF));
|
||||
|
||||
buffer.insert(buffer.end(), name, name + nameLen);
|
||||
}
|
||||
|
||||
closedir(dir);
|
||||
|
||||
const uint8_t opcode = static_cast<uint8_t>(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<uint8_t>(FileCommandCode::UploadBegin), EINVAL, "Invalid upload header");
|
||||
return;
|
||||
}
|
||||
|
||||
const uint16_t pathLen = static_cast<uint16_t>(payload[0] | (payload[1] << 8));
|
||||
if (length < sizeof(uint16_t) + pathLen + sizeof(uint32_t)) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::UploadBegin), EINVAL, "Malformed upload header");
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t* ptr = payload + sizeof(uint16_t);
|
||||
std::string relative(reinterpret_cast<const char*>(ptr), pathLen);
|
||||
ptr += pathLen;
|
||||
|
||||
const uint32_t fileSize = static_cast<uint32_t>(ptr[0] | (ptr[1] << 8) | (ptr[2] << 16) | (ptr[3] << 24));
|
||||
|
||||
std::string absolute;
|
||||
if (!sanitizePath(relative, absolute)) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::UploadBegin), EINVAL, "Invalid path");
|
||||
return;
|
||||
}
|
||||
|
||||
resetUploadContext();
|
||||
|
||||
FILE* file = std::fopen(absolute.c_str(), "wb");
|
||||
if (!file) {
|
||||
sendFileError(static_cast<uint8_t>(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<uint8_t>(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<uint8_t>(FileCommandCode::UploadChunk), EBADF, "No active upload");
|
||||
return;
|
||||
}
|
||||
|
||||
if (length == 0) {
|
||||
if (!sendFileResponse(static_cast<uint8_t>(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<uint8_t>(FileCommandCode::UploadChunk), err, "Write failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sendFileResponse(static_cast<uint8_t>(FileCommandCode::UploadChunk), kResponseFlagComplete, nullptr, 0)) {
|
||||
resetUploadContext();
|
||||
}
|
||||
}
|
||||
|
||||
void handleUploadEnd() {
|
||||
if (!g_uploadCtx.active || g_uploadCtx.file == nullptr) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::UploadEnd), EBADF, "No active upload");
|
||||
return;
|
||||
}
|
||||
|
||||
std::fflush(g_uploadCtx.file);
|
||||
resetUploadContext();
|
||||
sendFileResponse(static_cast<uint8_t>(FileCommandCode::UploadEnd), kResponseFlagComplete, nullptr, 0);
|
||||
}
|
||||
|
||||
void handleDownloadRequest(const uint8_t* payload, std::size_t length) {
|
||||
if (length < sizeof(uint16_t)) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::DownloadRequest), EINVAL, "Invalid download payload");
|
||||
return;
|
||||
}
|
||||
|
||||
const uint16_t pathLen = static_cast<uint16_t>(payload[0] | (payload[1] << 8));
|
||||
if (length < sizeof(uint16_t) + pathLen) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::DownloadRequest), EINVAL, "Malformed path");
|
||||
return;
|
||||
}
|
||||
|
||||
std::string relative(reinterpret_cast<const char*>(payload + sizeof(uint16_t)), pathLen);
|
||||
std::string absolute;
|
||||
if (!sanitizePath(relative, absolute)) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::DownloadRequest), EINVAL, "Invalid path");
|
||||
return;
|
||||
}
|
||||
|
||||
resetDownloadContext();
|
||||
|
||||
FILE* file = std::fopen(absolute.c_str(), "rb");
|
||||
if (!file) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::DownloadRequest), errno, "Failed to open file");
|
||||
return;
|
||||
}
|
||||
|
||||
struct stat st{};
|
||||
if (stat(absolute.c_str(), &st) != 0) {
|
||||
std::fclose(file);
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::DownloadRequest), errno, "stat failed");
|
||||
return;
|
||||
}
|
||||
|
||||
g_downloadCtx.file = file;
|
||||
g_downloadCtx.remaining = static_cast<std::size_t>(st.st_size);
|
||||
g_downloadCtx.active = true;
|
||||
|
||||
uint8_t sizePayload[4];
|
||||
const uint32_t size = static_cast<uint32_t>(st.st_size);
|
||||
sizePayload[0] = static_cast<uint8_t>(size & 0xFF);
|
||||
sizePayload[1] = static_cast<uint8_t>((size >> 8) & 0xFF);
|
||||
sizePayload[2] = static_cast<uint8_t>((size >> 16) & 0xFF);
|
||||
sizePayload[3] = static_cast<uint8_t>((size >> 24) & 0xFF);
|
||||
|
||||
const uint8_t opcode = static_cast<uint8_t>(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<uint8_t, 128> chunk{};
|
||||
while (g_downloadCtx.remaining > 0) {
|
||||
const std::size_t toRead = std::min<std::size_t>(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<uint8_t>(directory ? FileCommandCode::DeleteDirectory : FileCommandCode::DeleteFile),
|
||||
EINVAL, "Invalid payload");
|
||||
return;
|
||||
}
|
||||
const uint16_t pathLen = static_cast<uint16_t>(payload[0] | (payload[1] << 8));
|
||||
if (length < sizeof(uint16_t) + pathLen) {
|
||||
sendFileError(static_cast<uint8_t>(directory ? FileCommandCode::DeleteDirectory : FileCommandCode::DeleteFile),
|
||||
EINVAL, "Malformed path");
|
||||
return;
|
||||
}
|
||||
std::string relative(reinterpret_cast<const char*>(payload + sizeof(uint16_t)), pathLen);
|
||||
std::string absolute;
|
||||
if (!sanitizePath(relative, absolute)) {
|
||||
sendFileError(static_cast<uint8_t>(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<uint8_t>(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<uint8_t>(FileCommandCode::CreateDirectory), EINVAL, "Invalid payload");
|
||||
return;
|
||||
}
|
||||
const uint16_t pathLen = static_cast<uint16_t>(payload[0] | (payload[1] << 8));
|
||||
if (length < sizeof(uint16_t) + pathLen) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::CreateDirectory), EINVAL, "Malformed path");
|
||||
return;
|
||||
}
|
||||
std::string relative(reinterpret_cast<const char*>(payload + sizeof(uint16_t)), pathLen);
|
||||
std::string absolute;
|
||||
if (!sanitizePath(relative, absolute)) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::CreateDirectory), EINVAL, "Invalid path");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mkdir(absolute.c_str(), 0755) != 0) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::CreateDirectory), errno, "mkdir failed");
|
||||
} else {
|
||||
sendFileResponse(static_cast<uint8_t>(FileCommandCode::CreateDirectory), kResponseFlagComplete, nullptr, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void handleRename(const uint8_t* payload, std::size_t length) {
|
||||
if (length < sizeof(uint16_t) * 2) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::RenamePath), EINVAL, "Invalid rename payload");
|
||||
return;
|
||||
}
|
||||
|
||||
const uint16_t srcLen = static_cast<uint16_t>(payload[0] | (payload[1] << 8));
|
||||
if (length < sizeof(uint16_t) + srcLen + sizeof(uint16_t)) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::RenamePath), EINVAL, "Malformed source path");
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t* ptr = payload + sizeof(uint16_t);
|
||||
std::string srcRel(reinterpret_cast<const char*>(ptr), srcLen);
|
||||
ptr += srcLen;
|
||||
|
||||
const uint16_t dstLen = static_cast<uint16_t>(ptr[0] | (ptr[1] << 8));
|
||||
ptr += sizeof(uint16_t);
|
||||
if (length < sizeof(uint16_t) * 2 + srcLen + dstLen) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::RenamePath), EINVAL, "Malformed destination path");
|
||||
return;
|
||||
}
|
||||
|
||||
std::string dstRel(reinterpret_cast<const char*>(ptr), dstLen);
|
||||
|
||||
std::string srcAbs;
|
||||
std::string dstAbs;
|
||||
if (!sanitizePath(srcRel, srcAbs) || !sanitizePath(dstRel, dstAbs)) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::RenamePath), EINVAL, "Invalid path");
|
||||
return;
|
||||
}
|
||||
|
||||
if (std::rename(srcAbs.c_str(), dstAbs.c_str()) != 0) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::RenamePath), errno, "rename failed");
|
||||
} else {
|
||||
sendFileResponse(static_cast<uint8_t>(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<uint8_t>(FileCommandCode::ListDirectory), EINVAL, "Command too short");
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> buffer(incomingLen);
|
||||
const int rc = os_mbuf_copydata(ctxt->om, 0, incomingLen, buffer.data());
|
||||
if (rc != 0) {
|
||||
sendFileError(static_cast<uint8_t>(FileCommandCode::ListDirectory), EIO, "Read failed");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const auto* header = reinterpret_cast<const PacketHeader*>(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<FileCommandCode>(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<uint8_t>(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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user