mirror of
https://github.com/usatiuk/cardboy.git
synced 2025-10-28 23:27:49 +01:00
Compare commits
11 Commits
eeedc629d7
...
e8ae1cbec4
| Author | SHA1 | Date | |
|---|---|---|---|
| e8ae1cbec4 | |||
| b72ea4f417 | |||
| bf0ffe8632 | |||
| 96bfaaf64b | |||
| cf5a848741 | |||
| 7c492627f0 | |||
| be2629a008 | |||
| 016629eb82 | |||
| de1ac0e7a2 | |||
| 3ab2a7bf26 | |||
| b4f11851d7 |
@@ -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.
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "cardboy-companion/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Cardboy;
|
||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -304,6 +305,7 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "cardboy-companion/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Cardboy;
|
||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
|
||||
@@ -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,450 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct FileManagerTabView: View {
|
||||
@EnvironmentObject private var manager: TimeSyncManager
|
||||
@Binding var shareURL: URL?
|
||||
@Binding var errorWrapper: ErrorWrapper?
|
||||
|
||||
@State private var navigationPath: [String] = []
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
NavigationStack(path: $navigationPath) {
|
||||
DirectoryView(
|
||||
path: "/",
|
||||
navigationPath: $navigationPath,
|
||||
shareURL: $shareURL,
|
||||
errorWrapper: $errorWrapper
|
||||
)
|
||||
.navigationDestination(for: String.self) { destination in
|
||||
DirectoryView(
|
||||
path: destination,
|
||||
navigationPath: $navigationPath,
|
||||
shareURL: $shareURL,
|
||||
errorWrapper: $errorWrapper
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let operation = manager.activeFileOperation {
|
||||
FileOperationHUD(operation: operation)
|
||||
}
|
||||
|
||||
if manager.connectionState != .ready {
|
||||
ConnectionOverlay(
|
||||
state: manager.connectionState,
|
||||
statusMessage: manager.statusMessage,
|
||||
retryAction: manager.forceRescan
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if manager.currentDirectory != "/" {
|
||||
manager.changeDirectory(to: "/")
|
||||
}
|
||||
navigationPath = stackForPath(manager.currentDirectory)
|
||||
}
|
||||
.onChange(of: manager.currentDirectory) { newValue in
|
||||
let desired = stackForPath(newValue)
|
||||
if desired != navigationPath {
|
||||
navigationPath = desired
|
||||
}
|
||||
}
|
||||
.onChange(of: navigationPath) { newValue in
|
||||
let target = newValue.last ?? "/"
|
||||
if target != manager.currentDirectory {
|
||||
manager.changeDirectory(to: target)
|
||||
}
|
||||
}
|
||||
.onChange(of: manager.connectionState) { newState in
|
||||
if newState == .ready, !manager.isFileBusy {
|
||||
manager.refreshDirectory()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DirectoryView: View {
|
||||
let path: String
|
||||
@Binding var navigationPath: [String]
|
||||
@Binding var shareURL: URL?
|
||||
@Binding var errorWrapper: ErrorWrapper?
|
||||
|
||||
@EnvironmentObject private var manager: TimeSyncManager
|
||||
|
||||
@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 pathSegments: [(name: String, fullPath: String)] {
|
||||
var segments: [(String, String)] = []
|
||||
var current = ""
|
||||
for component in path.split(separator: "/").map(String.init) {
|
||||
current += "/" + component
|
||||
segments.append((component, current))
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
private var displayTitle: String {
|
||||
pathSegments.last?.name ?? "Files"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Path:")
|
||||
.font(.headline)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 4) {
|
||||
Button(action: { navigationPath = [] }) {
|
||||
Text("/")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
ForEach(Array(pathSegments.enumerated()), id: \.element.fullPath) { index, segment in
|
||||
Button(action: {
|
||||
navigationPath = Array(pathSegments.prefix(index + 1).map(\.fullPath))
|
||||
}) {
|
||||
Text(segment.name)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
List {
|
||||
ForEach(manager.directoryEntries) { entry in
|
||||
if entry.isDirectory {
|
||||
NavigationLink(value: entry.path) {
|
||||
FileRow(entry: entry)
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Open") {
|
||||
navigationPath = stackForPath(entry.path)
|
||||
}
|
||||
|
||||
Button("Rename") {
|
||||
renameTarget = entry
|
||||
renameText = entry.name
|
||||
showingRenameSheet = true
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
manager.delete(entry: entry)
|
||||
} label: {
|
||||
Text("Delete")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FileRow(entry: entry)
|
||||
.contextMenu {
|
||||
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(spacing: 12) {
|
||||
Menu {
|
||||
Button {
|
||||
showingImporter = true
|
||||
} label: {
|
||||
Label("Upload File", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Button {
|
||||
showingNewFolderSheet = true
|
||||
newFolderName = ""
|
||||
} label: {
|
||||
Label("New Folder", systemImage: "folder.badge.plus")
|
||||
}
|
||||
} label: {
|
||||
Label("Actions", systemImage: "ellipsis.circle")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
manager.refreshDirectory()
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding([.horizontal, .bottom])
|
||||
}
|
||||
|
||||
}
|
||||
.navigationTitle(displayTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
if manager.currentDirectory != path {
|
||||
manager.changeDirectory(to: path)
|
||||
} else {
|
||||
manager.refreshDirectory()
|
||||
}
|
||||
}
|
||||
.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 func stackForPath(_ path: String) -> [String] {
|
||||
guard path != "/" else { return [] }
|
||||
var stack: [String] = []
|
||||
var current = ""
|
||||
for component in path.split(separator: "/").map(String.init) {
|
||||
current += "/" + component
|
||||
stack.append(current)
|
||||
}
|
||||
return stack
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectionOverlay: View {
|
||||
let state: TimeSyncManager.ConnectionState
|
||||
let statusMessage: String
|
||||
let retryAction: () -> Void
|
||||
|
||||
private var showsSpinner: Bool {
|
||||
switch state {
|
||||
case .scanning, .connecting, .discovering:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var canRetry: Bool {
|
||||
switch state {
|
||||
case .failed, .idle:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.35)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
if showsSpinner {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.scaleEffect(1.2)
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 42, weight: .semibold))
|
||||
.foregroundColor(.yellow)
|
||||
}
|
||||
|
||||
Text(statusMessage)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
if canRetry {
|
||||
Button("Try Again", action: retryAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 320)
|
||||
.background(.ultraThinMaterial)
|
||||
.cornerRadius(20)
|
||||
.shadow(radius: 12)
|
||||
}
|
||||
.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,25 +101,73 @@ 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 var pendingDirectoryRequest: (path: String, operationID: 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 central manager to initialise immediately so state updates arrive right away.
|
||||
_ = central
|
||||
|
||||
if central.state == .poweredOn {
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
retryWorkItem?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Public BLE Controls
|
||||
|
||||
func forceRescan() {
|
||||
statusMessage = "Restarting scan…"
|
||||
shouldKeepScanning = true
|
||||
retryWorkItem?.cancel()
|
||||
|
||||
let existingPeripheral = targetPeripheral
|
||||
stopScanning()
|
||||
if let existingPeripheral {
|
||||
central.cancelPeripheralConnection(existingPeripheral)
|
||||
}
|
||||
|
||||
targetPeripheral = nil
|
||||
timeCharacteristic = nil
|
||||
fileCommandCharacteristic = nil
|
||||
fileResponseCharacteristic = nil
|
||||
resetFileStateOnDisconnect()
|
||||
startScanning()
|
||||
}
|
||||
|
||||
@@ -75,19 +194,155 @@ final class TimeSyncManager: NSObject, ObservableObject {
|
||||
payload.append(isDst)
|
||||
payload.append(UInt8(0)) // Reserved byte
|
||||
|
||||
connectionState = .ready
|
||||
statusMessage = "Sending current time…"
|
||||
peripheral.writeValue(payload, for: characteristic, type: .withResponse)
|
||||
lastSyncDate = now
|
||||
connectionState = .ready
|
||||
let timeString = DateFormatter.localizedString(from: now, dateStyle: .none, timeStyle: .medium)
|
||||
statusMessage = "Time synced at \(timeString)."
|
||||
}
|
||||
|
||||
// MARK: - File operations exposed to UI
|
||||
|
||||
func refreshDirectory() {
|
||||
if isFileBusy {
|
||||
pendingDirectoryRequest = (path: currentDirectory, operationID: pendingDirectoryRequest?.operationID ?? UUID())
|
||||
return
|
||||
}
|
||||
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…"
|
||||
@@ -108,8 +363,372 @@ 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
|
||||
pendingDirectoryRequest = 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) {
|
||||
let normalizedPath = normalizedPath(path)
|
||||
|
||||
if let commandCharacteristic = fileCommandCharacteristic {
|
||||
let opID: UUID
|
||||
if let pending = pendingDirectoryRequest, pending.path == normalizedPath {
|
||||
opID = pending.operationID
|
||||
pendingDirectoryRequest = nil
|
||||
} else {
|
||||
opID = UUID()
|
||||
}
|
||||
startDirectoryRequest(path: normalizedPath, operationID: opID, characteristic: commandCharacteristic)
|
||||
} else {
|
||||
let opID = pendingDirectoryRequest?.operationID ?? UUID()
|
||||
pendingDirectoryRequest = (path: normalizedPath, operationID: opID)
|
||||
|
||||
if connectionState == .ready {
|
||||
pendingListPath = normalizedPath
|
||||
pendingListData.removeAll()
|
||||
isFileBusy = true
|
||||
pendingListOperationID = opID
|
||||
updateOperation(id: opID, title: "Loading", message: normalizedPath, progress: nil, indeterminate: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func flushPendingDirectoryRequest() -> Bool {
|
||||
guard let pending = pendingDirectoryRequest,
|
||||
let commandCharacteristic = fileCommandCharacteristic else { return false }
|
||||
|
||||
pendingDirectoryRequest = nil
|
||||
startDirectoryRequest(path: pending.path, operationID: pending.operationID, characteristic: commandCharacteristic)
|
||||
return true
|
||||
}
|
||||
|
||||
private func startDirectoryRequest(path: String, operationID: UUID, characteristic: CBCharacteristic) {
|
||||
pendingListPath = path
|
||||
pendingListData.removeAll()
|
||||
isFileBusy = true
|
||||
pendingListOperationID = operationID
|
||||
updateOperation(id: operationID, title: "Loading", message: path, progress: nil, indeterminate: true)
|
||||
let payload = payloadFor(path: path)
|
||||
enqueueFileCommand(.listDirectory, payload: payload, characteristic: characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBCentralManagerDelegate
|
||||
|
||||
extension TimeSyncManager: CBCentralManagerDelegate {
|
||||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
||||
shouldKeepScanning = true
|
||||
@@ -151,8 +770,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
|
||||
@@ -165,27 +784,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 {
|
||||
@@ -202,8 +825,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,39 +842,105 @@ 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."
|
||||
}
|
||||
|
||||
if fileResponseCharacteristic?.isNotifying == true {
|
||||
_ = flushPendingDirectoryRequest()
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if !flushPendingDirectoryRequest() {
|
||||
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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "cardboy/apps/clock_app.hpp"
|
||||
#include "cardboy/apps/gameboy_app.hpp"
|
||||
#include "cardboy/apps/lockscreen_app.hpp"
|
||||
#include "cardboy/apps/menu_app.hpp"
|
||||
#include "cardboy/apps/settings_app.hpp"
|
||||
#include "cardboy/apps/snake_app.hpp"
|
||||
@@ -233,6 +234,7 @@ extern "C" void app_main() {
|
||||
#endif
|
||||
|
||||
system.registerApp(apps::createMenuAppFactory());
|
||||
system.registerApp(apps::createLockscreenAppFactory());
|
||||
system.registerApp(apps::createSettingsAppFactory());
|
||||
system.registerApp(apps::createClockAppFactory());
|
||||
system.registerApp(apps::createSnakeAppFactory());
|
||||
|
||||
@@ -13,6 +13,7 @@ target_link_libraries(cardboy_apps
|
||||
target_compile_features(cardboy_apps PUBLIC cxx_std_20)
|
||||
|
||||
add_subdirectory(menu)
|
||||
add_subdirectory(lockscreen)
|
||||
add_subdirectory(clock)
|
||||
add_subdirectory(settings)
|
||||
add_subdirectory(gameboy)
|
||||
|
||||
10
Firmware/sdk/apps/lockscreen/CMakeLists.txt
Normal file
10
Firmware/sdk/apps/lockscreen/CMakeLists.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
target_sources(cardboy_apps
|
||||
PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/lockscreen_app.cpp
|
||||
)
|
||||
|
||||
target_include_directories(cardboy_apps
|
||||
PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/sdk/app_framework.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <string_view>
|
||||
|
||||
namespace apps {
|
||||
|
||||
inline constexpr char kLockscreenAppName[] = "Lockscreen";
|
||||
inline constexpr std::string_view kLockscreenAppNameView = kLockscreenAppName;
|
||||
|
||||
std::unique_ptr<cardboy::sdk::IAppFactory> createLockscreenAppFactory();
|
||||
|
||||
} // namespace apps
|
||||
|
||||
271
Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp
Normal file
271
Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp
Normal file
@@ -0,0 +1,271 @@
|
||||
#include "cardboy/apps/lockscreen_app.hpp"
|
||||
|
||||
#include "cardboy/apps/menu_app.hpp"
|
||||
#include "cardboy/gfx/font16x8.hpp"
|
||||
#include "cardboy/sdk/app_framework.hpp"
|
||||
#include "cardboy/sdk/app_system.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace apps {
|
||||
|
||||
namespace {
|
||||
|
||||
using cardboy::sdk::AppContext;
|
||||
|
||||
constexpr std::uint32_t kRefreshIntervalMs = 100;
|
||||
constexpr std::uint32_t kUnlockHoldMs = 1500;
|
||||
|
||||
using Framebuffer = typename AppContext::Framebuffer;
|
||||
using Clock = typename AppContext::Clock;
|
||||
|
||||
struct TimeSnapshot {
|
||||
bool hasWallTime = false;
|
||||
int hour24 = 0;
|
||||
int minute = 0;
|
||||
int second = 0;
|
||||
int year = 0;
|
||||
int month = 0;
|
||||
int day = 0;
|
||||
int weekday = 0;
|
||||
std::uint64_t uptimeSeconds = 0;
|
||||
};
|
||||
|
||||
class LockscreenApp final : public cardboy::sdk::IApp {
|
||||
public:
|
||||
explicit LockscreenApp(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer), clock(ctx.clock) {}
|
||||
|
||||
void onStart() override {
|
||||
cancelRefreshTimer();
|
||||
lastSnapshot = {};
|
||||
holdActive = false;
|
||||
holdProgressMs = 0;
|
||||
dirty = true;
|
||||
const auto snap = captureTime();
|
||||
renderIfNeeded(snap);
|
||||
lastSnapshot = snap;
|
||||
refreshTimer = context.scheduleRepeatingTimer(kRefreshIntervalMs);
|
||||
}
|
||||
|
||||
void onStop() override { cancelRefreshTimer(); }
|
||||
|
||||
void handleEvent(const cardboy::sdk::AppEvent& event) override {
|
||||
switch (event.type) {
|
||||
case cardboy::sdk::AppEventType::Button:
|
||||
handleButtonEvent(event.button);
|
||||
break;
|
||||
case cardboy::sdk::AppEventType::Timer:
|
||||
if (event.timer.handle == refreshTimer) {
|
||||
advanceHoldProgress();
|
||||
updateDisplay();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
AppContext& context;
|
||||
Framebuffer& framebuffer;
|
||||
Clock& clock;
|
||||
|
||||
bool dirty = false;
|
||||
cardboy::sdk::AppTimerHandle refreshTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
TimeSnapshot lastSnapshot{};
|
||||
bool holdActive = false;
|
||||
std::uint32_t holdProgressMs = 0;
|
||||
|
||||
void cancelRefreshTimer() {
|
||||
if (refreshTimer != cardboy::sdk::kInvalidAppTimer) {
|
||||
context.cancelTimer(refreshTimer);
|
||||
refreshTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
}
|
||||
|
||||
static bool comboPressed(const cardboy::sdk::InputState& state) { return state.a && state.select; }
|
||||
|
||||
void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) {
|
||||
const bool comboNow = comboPressed(button.current);
|
||||
updateHoldState(comboNow);
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
void updateHoldState(bool comboNow) {
|
||||
if (comboNow) {
|
||||
if (!holdActive) {
|
||||
holdActive = true;
|
||||
dirty = true;
|
||||
}
|
||||
} else {
|
||||
if (holdActive || holdProgressMs != 0) {
|
||||
holdActive = false;
|
||||
holdProgressMs = 0;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void advanceHoldProgress() {
|
||||
if (holdActive) {
|
||||
const std::uint32_t next =
|
||||
std::min<std::uint32_t>(holdProgressMs + kRefreshIntervalMs, kUnlockHoldMs);
|
||||
if (next != holdProgressMs) {
|
||||
holdProgressMs = next;
|
||||
dirty = true;
|
||||
}
|
||||
if (holdProgressMs >= kUnlockHoldMs) {
|
||||
holdActive = false;
|
||||
context.requestAppSwitchByName(kMenuAppName);
|
||||
}
|
||||
} else if (holdProgressMs != 0) {
|
||||
holdProgressMs = 0;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
void updateDisplay() {
|
||||
const auto snap = captureTime();
|
||||
if (!sameSnapshot(snap, lastSnapshot))
|
||||
dirty = true;
|
||||
renderIfNeeded(snap);
|
||||
lastSnapshot = snap;
|
||||
}
|
||||
|
||||
static bool sameSnapshot(const TimeSnapshot& a, const TimeSnapshot& b) {
|
||||
return a.hasWallTime == b.hasWallTime && a.hour24 == b.hour24 && a.minute == b.minute &&
|
||||
a.second == b.second && a.day == b.day && a.month == b.month && a.year == b.year;
|
||||
}
|
||||
|
||||
TimeSnapshot captureTime() const {
|
||||
TimeSnapshot snap{};
|
||||
snap.uptimeSeconds = clock.millis() / 1000ULL;
|
||||
|
||||
std::time_t raw = 0;
|
||||
if (std::time(&raw) != static_cast<std::time_t>(-1) && raw > 0) {
|
||||
std::tm tm{};
|
||||
if (localtime_r(&raw, &tm) != nullptr) {
|
||||
snap.hasWallTime = true;
|
||||
snap.hour24 = tm.tm_hour;
|
||||
snap.minute = tm.tm_min;
|
||||
snap.second = tm.tm_sec;
|
||||
snap.year = tm.tm_year + 1900;
|
||||
snap.month = tm.tm_mon + 1;
|
||||
snap.day = tm.tm_mday;
|
||||
snap.weekday = tm.tm_wday;
|
||||
return snap;
|
||||
}
|
||||
}
|
||||
|
||||
snap.hasWallTime = false;
|
||||
snap.hour24 = static_cast<int>((snap.uptimeSeconds / 3600ULL) % 24ULL);
|
||||
snap.minute = static_cast<int>((snap.uptimeSeconds / 60ULL) % 60ULL);
|
||||
snap.second = static_cast<int>(snap.uptimeSeconds % 60ULL);
|
||||
return snap;
|
||||
}
|
||||
|
||||
static void drawCenteredText(Framebuffer& fb, int y, std::string_view text, int scale, int letterSpacing = 0) {
|
||||
const int width = font16x8::measureText(text, scale, letterSpacing);
|
||||
const int x = (fb.width() - width) / 2;
|
||||
font16x8::drawText(fb, x, y, text, scale, true, letterSpacing);
|
||||
}
|
||||
|
||||
static void drawRectOutline(Framebuffer& fb, int x, int y, int w, int h) {
|
||||
if (w <= 0 || h <= 0)
|
||||
return;
|
||||
for (int dx = 0; dx < w; ++dx) {
|
||||
fb.drawPixel(x + dx, y, true);
|
||||
fb.drawPixel(x + dx, y + h - 1, true);
|
||||
}
|
||||
for (int dy = 0; dy < h; ++dy) {
|
||||
fb.drawPixel(x, y + dy, true);
|
||||
fb.drawPixel(x + w - 1, y + dy, true);
|
||||
}
|
||||
}
|
||||
|
||||
static void fillRect(Framebuffer& fb, int x, int y, int w, int h) {
|
||||
if (w <= 0 || h <= 0)
|
||||
return;
|
||||
for (int dy = 0; dy < h; ++dy) {
|
||||
for (int dx = 0; dx < w; ++dx) {
|
||||
fb.drawPixel(x + dx, y + dy, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static std::string formatDate(const TimeSnapshot& snap) {
|
||||
static const char* kWeekdays[] = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"};
|
||||
if (!snap.hasWallTime)
|
||||
return "UPTIME MODE";
|
||||
const char* weekday = (snap.weekday >= 0 && snap.weekday < 7) ? kWeekdays[snap.weekday] : "";
|
||||
char buffer[32];
|
||||
std::snprintf(buffer, sizeof(buffer), "%s %04d-%02d-%02d", weekday, snap.year, snap.month, snap.day);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
void renderIfNeeded(const TimeSnapshot& snap) {
|
||||
if (!dirty)
|
||||
return;
|
||||
dirty = false;
|
||||
|
||||
framebuffer.frameReady();
|
||||
|
||||
const int scaleTime = 4;
|
||||
const int scaleSeconds = 2;
|
||||
const int scaleSmall = 1;
|
||||
|
||||
char hoursMinutes[6];
|
||||
std::snprintf(hoursMinutes, sizeof(hoursMinutes), "%02d:%02d", snap.hour24, snap.minute);
|
||||
const int mainW = font16x8::measureText(hoursMinutes, scaleTime, 0);
|
||||
const int timeY = (framebuffer.height() - font16x8::kGlyphHeight * scaleTime) / 2 - 8;
|
||||
const int timeX = (framebuffer.width() - mainW) / 2;
|
||||
const int secX = timeX + mainW + 12;
|
||||
const int secY = timeY + font16x8::kGlyphHeight * scaleTime - font16x8::kGlyphHeight * scaleSeconds;
|
||||
char secs[3];
|
||||
std::snprintf(secs, sizeof(secs), "%02d", snap.second);
|
||||
|
||||
font16x8::drawText(framebuffer, timeX, timeY, hoursMinutes, scaleTime, true, 0);
|
||||
font16x8::drawText(framebuffer, secX, secY, secs, scaleSeconds, true, 0);
|
||||
|
||||
const std::string dateLine = formatDate(snap);
|
||||
drawCenteredText(framebuffer, timeY + font16x8::kGlyphHeight * scaleTime + 24, dateLine, scaleSmall, 1);
|
||||
const char* instruction = holdActive ? "KEEP HOLDING A+SELECT" : "HOLD A+SELECT";
|
||||
drawCenteredText(framebuffer, framebuffer.height() - 52, instruction, scaleSmall, 1);
|
||||
|
||||
if (holdActive || holdProgressMs > 0) {
|
||||
const int barWidth = framebuffer.width() - 64;
|
||||
const int barHeight = 14;
|
||||
const int barX = (framebuffer.width() - barWidth) / 2;
|
||||
const int barY = framebuffer.height() - 32;
|
||||
const int innerWidth = barWidth - 2;
|
||||
const int innerHeight = barHeight - 2;
|
||||
drawRectOutline(framebuffer, barX, barY, barWidth, barHeight);
|
||||
|
||||
const float ratio =
|
||||
std::clamp(holdProgressMs / static_cast<float>(kUnlockHoldMs), 0.0f, 1.0f);
|
||||
const int fillWidth = static_cast<int>(ratio * innerWidth + 0.5f);
|
||||
if (fillWidth > 0)
|
||||
fillRect(framebuffer, barX + 1, barY + 1, fillWidth, innerHeight);
|
||||
}
|
||||
|
||||
framebuffer.sendFrame();
|
||||
}
|
||||
};
|
||||
|
||||
class LockscreenAppFactory final : public cardboy::sdk::IAppFactory {
|
||||
public:
|
||||
const char* name() const override { return kLockscreenAppName; }
|
||||
std::unique_ptr<cardboy::sdk::IApp> create(cardboy::sdk::AppContext& context) override {
|
||||
return std::make_unique<LockscreenApp>(context);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<cardboy::sdk::IAppFactory> createLockscreenAppFactory() {
|
||||
return std::make_unique<LockscreenAppFactory>();
|
||||
}
|
||||
|
||||
} // namespace apps
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "cardboy/apps/menu_app.hpp"
|
||||
#include "cardboy/apps/lockscreen_app.hpp"
|
||||
|
||||
#include "cardboy/sdk/app_framework.hpp"
|
||||
#include "cardboy/sdk/app_system.hpp"
|
||||
@@ -6,6 +7,7 @@
|
||||
#include "cardboy/gfx/font16x8.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
@@ -19,6 +21,8 @@ using cardboy::sdk::AppContext;
|
||||
|
||||
using Framebuffer = typename AppContext::Framebuffer;
|
||||
|
||||
constexpr std::uint32_t kIdleTimeoutMs = 15000;
|
||||
|
||||
struct MenuEntry {
|
||||
std::string name;
|
||||
std::size_t index = 0;
|
||||
@@ -31,15 +35,46 @@ public:
|
||||
void onStart() override {
|
||||
refreshEntries();
|
||||
dirty = true;
|
||||
resetInactivityTimer();
|
||||
renderIfNeeded();
|
||||
}
|
||||
|
||||
void handleEvent(const cardboy::sdk::AppEvent& event) override {
|
||||
if (event.type != cardboy::sdk::AppEventType::Button)
|
||||
return;
|
||||
void onStop() override { cancelInactivityTimer(); }
|
||||
|
||||
const auto& current = event.button.current;
|
||||
const auto& previous = event.button.previous;
|
||||
void handleEvent(const cardboy::sdk::AppEvent& event) override {
|
||||
switch (event.type) {
|
||||
case cardboy::sdk::AppEventType::Button:
|
||||
handleButtonEvent(event.button);
|
||||
break;
|
||||
case cardboy::sdk::AppEventType::Timer:
|
||||
if (event.timer.handle == inactivityTimer) {
|
||||
cancelInactivityTimer();
|
||||
context.requestAppSwitchByName(kLockscreenAppName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
AppContext& context;
|
||||
Framebuffer& framebuffer;
|
||||
std::vector<MenuEntry> entries;
|
||||
std::size_t selected = 0;
|
||||
|
||||
bool dirty = false;
|
||||
|
||||
cardboy::sdk::AppTimerHandle inactivityTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
|
||||
void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) {
|
||||
resetInactivityTimer();
|
||||
|
||||
const auto& current = button.current;
|
||||
const auto& previous = button.previous;
|
||||
|
||||
if (current.b && !previous.b) {
|
||||
context.requestAppSwitchByName(kLockscreenAppName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.left && !previous.left) {
|
||||
moveSelection(-1);
|
||||
@@ -54,14 +89,6 @@ public:
|
||||
renderIfNeeded();
|
||||
}
|
||||
|
||||
private:
|
||||
AppContext& context;
|
||||
Framebuffer& framebuffer;
|
||||
std::vector<MenuEntry> entries;
|
||||
std::size_t selected = 0;
|
||||
|
||||
bool dirty = false;
|
||||
|
||||
void moveSelection(int step) {
|
||||
if (entries.empty())
|
||||
return;
|
||||
@@ -93,7 +120,8 @@ private:
|
||||
const char* name = factory->name();
|
||||
if (!name)
|
||||
continue;
|
||||
if (std::string_view(name) == kMenuAppNameView)
|
||||
const std::string_view appName(name);
|
||||
if (appName == kMenuAppNameView || appName == kLockscreenAppNameView)
|
||||
continue;
|
||||
entries.push_back(MenuEntry{std::string(name), i});
|
||||
}
|
||||
@@ -159,6 +187,18 @@ private:
|
||||
|
||||
framebuffer.sendFrame();
|
||||
}
|
||||
|
||||
void cancelInactivityTimer() {
|
||||
if (inactivityTimer != cardboy::sdk::kInvalidAppTimer) {
|
||||
context.cancelTimer(inactivityTimer);
|
||||
inactivityTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
}
|
||||
|
||||
void resetInactivityTimer() {
|
||||
cancelInactivityTimer();
|
||||
inactivityTimer = context.scheduleTimer(kIdleTimeoutMs);
|
||||
}
|
||||
};
|
||||
|
||||
class MenuAppFactory final : public cardboy::sdk::IAppFactory {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "cardboy/apps/clock_app.hpp"
|
||||
#include "cardboy/apps/gameboy_app.hpp"
|
||||
#include "cardboy/apps/menu_app.hpp"
|
||||
#include "cardboy/apps/lockscreen_app.hpp"
|
||||
#include "cardboy/apps/settings_app.hpp"
|
||||
#include "cardboy/apps/snake_app.hpp"
|
||||
#include "cardboy/apps/tetris_app.hpp"
|
||||
@@ -27,6 +28,7 @@ int main() {
|
||||
buzzer->setMuted(persistentSettings.mute);
|
||||
|
||||
system.registerApp(apps::createMenuAppFactory());
|
||||
system.registerApp(apps::createLockscreenAppFactory());
|
||||
system.registerApp(apps::createSettingsAppFactory());
|
||||
system.registerApp(apps::createClockAppFactory());
|
||||
system.registerApp(apps::createGameboyAppFactory());
|
||||
|
||||
Reference in New Issue
Block a user