mirror of
https://github.com/usatiuk/cardboy.git
synced 2025-10-29 07:37:48 +01:00
Compare commits
18 Commits
sound
...
e8ae1cbec4
| Author | SHA1 | Date | |
|---|---|---|---|
| e8ae1cbec4 | |||
| b72ea4f417 | |||
| bf0ffe8632 | |||
| 96bfaaf64b | |||
| cf5a848741 | |||
| 7c492627f0 | |||
| be2629a008 | |||
| 016629eb82 | |||
| de1ac0e7a2 | |||
| 3ab2a7bf26 | |||
| b4f11851d7 | |||
| eeedc629d7 | |||
| 8bb48daf6c | |||
| 7c741c42dc | |||
| ecbcce12ea | |||
| f6c800fc63 | |||
| 5e63875d35 | |||
| cc805abe80 |
3
Firmware/.gitignore
vendored
3
Firmware/.gitignore
vendored
@@ -2,4 +2,5 @@ build
|
||||
cmake-build*
|
||||
.idea
|
||||
.cache
|
||||
managed_components
|
||||
managed_components
|
||||
*.gb
|
||||
62
Firmware/cardboy-companion/.gitignore
vendored
Normal file
62
Firmware/cardboy-companion/.gitignore
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
# Xcode
|
||||
#
|
||||
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## Obj-C/Swift specific
|
||||
*.hmap
|
||||
|
||||
## App packaging
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
## Playgrounds
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
# Swift Package Manager
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
|
||||
# Packages/
|
||||
# Package.pins
|
||||
# Package.resolved
|
||||
# *.xcodeproj
|
||||
#
|
||||
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
|
||||
# hence it is not needed unless you have added a package configuration file to your project
|
||||
# .swiftpm
|
||||
|
||||
.build/
|
||||
|
||||
# CocoaPods
|
||||
#
|
||||
# We recommend against adding the Pods directory to your .gitignore. However
|
||||
# you should judge for yourself, the pros and cons are mentioned at:
|
||||
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
|
||||
#
|
||||
# Pods/
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from the Xcode workspace
|
||||
# *.xcworkspace
|
||||
|
||||
# Carthage
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Carthage dependencies.
|
||||
# Carthage/Checkouts
|
||||
|
||||
Carthage/Build/
|
||||
|
||||
# fastlane
|
||||
#
|
||||
# It is recommended to not store the screenshots in the git repo.
|
||||
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
|
||||
# For more information about the recommended setup visit:
|
||||
# https://docs.fastlane.tools/best-practices/source-control/#source-control
|
||||
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
89
Firmware/cardboy-companion/README.md
Normal file
89
Firmware/cardboy-companion/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Cardboy Time Sync Companion
|
||||
|
||||
This SwiftUI app connects to the Cardboy device over Bluetooth Low Energy and updates its wall clock using the custom time sync service exposed by the firmware. The sources live inside the existing `cardboy-companion/cardboy-companion.xcodeproj` so you can open and run them directly in Xcode.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Xcode 15 or newer
|
||||
- iOS 16 or newer deployment target (can be lowered to 15 with minor API tweaks)
|
||||
- A Cardboy running firmware that includes the BLE time sync service
|
||||
|
||||
## How it works
|
||||
|
||||
1. The app scans for peripherals exposing service UUID `00000001-CA7B-4EFD-B5A6-10C3F4D3F230`.
|
||||
2. Once connected it discovers characteristic `00000002-CA7B-4EFD-B5A6-10C3F4D3F231`.
|
||||
3. Tapping **Sync Now** writes a 12‑byte payload containing:
|
||||
- 8 bytes Unix epoch seconds (little endian)
|
||||
- 2 bytes time zone offset in minutes from UTC (little endian)
|
||||
- 1 byte DST flag (`1` if daylight saving is active)
|
||||
- 1 reserved byte (`0`)
|
||||
4. The firmware applies the timestamp with `settimeofday()` and updates the TZ environment variable so the clock app renders local time.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Open `cardboy-companion/cardboy-companion.xcodeproj` in Xcode.
|
||||
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.
|
||||
@@ -0,0 +1,358 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
ECAB9A832EA550D9004BA9DE /* cardboy-companion.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "cardboy-companion.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
ECAB9ABA2EA562CD004BA9DE /* Exceptions for "cardboy-companion" folder in "cardboy-companion" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = ECAB9A822EA550D9004BA9DE /* cardboy-companion */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
ECAB9A852EA550D9004BA9DE /* cardboy-companion */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
ECAB9ABA2EA562CD004BA9DE /* Exceptions for "cardboy-companion" folder in "cardboy-companion" target */,
|
||||
);
|
||||
path = "cardboy-companion";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
ECAB9A802EA550D9004BA9DE /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
ECAB9A7A2EA550D9004BA9DE = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ECAB9A852EA550D9004BA9DE /* cardboy-companion */,
|
||||
ECAB9A842EA550D9004BA9DE /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
ECAB9A842EA550D9004BA9DE /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ECAB9A832EA550D9004BA9DE /* cardboy-companion.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
ECAB9A822EA550D9004BA9DE /* cardboy-companion */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = ECAB9A8E2EA550DB004BA9DE /* Build configuration list for PBXNativeTarget "cardboy-companion" */;
|
||||
buildPhases = (
|
||||
ECAB9A7F2EA550D9004BA9DE /* Sources */,
|
||||
ECAB9A802EA550D9004BA9DE /* Frameworks */,
|
||||
ECAB9A812EA550D9004BA9DE /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
ECAB9A852EA550D9004BA9DE /* cardboy-companion */,
|
||||
);
|
||||
name = "cardboy-companion";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "cardboy-companion";
|
||||
productReference = ECAB9A832EA550D9004BA9DE /* cardboy-companion.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
ECAB9A7B2EA550D9004BA9DE /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2600;
|
||||
LastUpgradeCheck = 2600;
|
||||
TargetAttributes = {
|
||||
ECAB9A822EA550D9004BA9DE = {
|
||||
CreatedOnToolsVersion = 26.0.1;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = ECAB9A7E2EA550D9004BA9DE /* Build configuration list for PBXProject "cardboy-companion" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = ECAB9A7A2EA550D9004BA9DE;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = ECAB9A842EA550D9004BA9DE /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
ECAB9A822EA550D9004BA9DE /* cardboy-companion */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
ECAB9A812EA550D9004BA9DE /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
ECAB9A7F2EA550D9004BA9DE /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
ECAB9A8C2EA550DB004BA9DE /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
ECAB9A8D2EA550DB004BA9DE /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
ECAB9A8F2EA550DB004BA9DE /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = WX524QS7SH;
|
||||
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;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UIBackgroundModes = "bluetooth-central";
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.usatiuk.cardboy-companion";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
ECAB9A902EA550DB004BA9DE /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = WX524QS7SH;
|
||||
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;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UIBackgroundModes = "bluetooth-central";
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.usatiuk.cardboy-companion";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
ECAB9A7E2EA550D9004BA9DE /* Build configuration list for PBXProject "cardboy-companion" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
ECAB9A8C2EA550DB004BA9DE /* Debug */,
|
||||
ECAB9A8D2EA550DB004BA9DE /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
ECAB9A8E2EA550DB004BA9DE /* Build configuration list for PBXNativeTarget "cardboy-companion" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
ECAB9A8F2EA550DB004BA9DE /* Debug */,
|
||||
ECAB9A902EA550DB004BA9DE /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = ECAB9A7B2EA550D9004BA9DE /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "cardboy-icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "cardboy-icon-dark.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "cardboy-icon-tinted.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
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 |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
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" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .medium
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Text("Cardboy Clock Sync")
|
||||
.font(.title.bold())
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text("State: \(manager.connectionState.rawValue)")
|
||||
.font(.headline)
|
||||
|
||||
Text(manager.statusMessage)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
Button(action: manager.sendCurrentTime) {
|
||||
Label("Sync Now", systemImage: "clock.arrow.2.circlepath")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(manager.connectionState != .ready)
|
||||
|
||||
Button(action: manager.forceRescan) {
|
||||
Label("Rescan", systemImage: "dot.radiowaves.left.and.right")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Last Sync:")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(formattedLastSync)
|
||||
.font(.body.monospaced())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,946 @@
|
||||
import Combine
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
final class TimeSyncManager: NSObject, ObservableObject {
|
||||
enum ConnectionState: String {
|
||||
case idle = "Idle"
|
||||
case scanning = "Scanning"
|
||||
case connecting = "Connecting"
|
||||
case discovering = "Discovering Services"
|
||||
case ready = "Ready"
|
||||
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?
|
||||
|
||||
@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,
|
||||
queue: nil,
|
||||
options: [
|
||||
CBCentralManagerOptionShowPowerAlertKey: true,
|
||||
CBCentralManagerOptionRestoreIdentifierKey: "com.usatiuk.cardboy-companion.central"
|
||||
]
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func sendCurrentTime() {
|
||||
guard let peripheral = targetPeripheral,
|
||||
let characteristic = timeCharacteristic else {
|
||||
statusMessage = "Device is not ready."
|
||||
return
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
let epochSeconds = UInt64(now.timeIntervalSince1970.rounded())
|
||||
let timezoneOffsetMinutes = Int16(TimeZone.current.secondsFromGMT(for: now) / 60)
|
||||
let isDst = TimeZone.current.isDaylightSavingTime(for: now) ? UInt8(1) : UInt8(0)
|
||||
|
||||
var payload = Data()
|
||||
|
||||
var epochLe = epochSeconds.littleEndian
|
||||
payload.append(UnsafeBufferPointer(start: &epochLe, count: 1))
|
||||
|
||||
var offsetLe = timezoneOffsetMinutes.littleEndian
|
||||
payload.append(UnsafeBufferPointer(start: &offsetLe, count: 1))
|
||||
|
||||
payload.append(isDst)
|
||||
payload.append(UInt8(0)) // Reserved byte
|
||||
|
||||
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: [timeServiceUUID, fileServiceUUID], options: [
|
||||
CBCentralManagerScanOptionAllowDuplicatesKey: false
|
||||
])
|
||||
isScanning = true
|
||||
connectionState = .scanning
|
||||
statusMessage = "Scanning for Cardboy…"
|
||||
}
|
||||
|
||||
private func stopScanning() {
|
||||
guard isScanning else { return }
|
||||
central.stopScan()
|
||||
isScanning = false
|
||||
}
|
||||
|
||||
private func scheduleRetry(after delay: TimeInterval = 2.5) {
|
||||
shouldKeepScanning = true
|
||||
retryWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
self?.startScanning()
|
||||
}
|
||||
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
|
||||
|
||||
if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral],
|
||||
let restored = peripherals.first {
|
||||
statusMessage = "Restoring connection…"
|
||||
connectionState = .connecting
|
||||
targetPeripheral = restored
|
||||
restored.delegate = self
|
||||
if central.state == .poweredOn {
|
||||
central.connect(restored, options: nil)
|
||||
}
|
||||
} else {
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
switch central.state {
|
||||
case .unknown, .resetting:
|
||||
connectionState = .idle
|
||||
statusMessage = "Bluetooth is resetting…"
|
||||
case .unsupported:
|
||||
connectionState = .failed
|
||||
statusMessage = "Bluetooth Low Energy is not supported on this device."
|
||||
case .unauthorized:
|
||||
connectionState = .failed
|
||||
statusMessage = "Bluetooth permissions are missing."
|
||||
case .poweredOff:
|
||||
connectionState = .idle
|
||||
statusMessage = "Turn on Bluetooth to continue."
|
||||
stopScanning()
|
||||
case .poweredOn:
|
||||
startScanning()
|
||||
@unknown default:
|
||||
connectionState = .failed
|
||||
statusMessage = "Unknown Bluetooth state."
|
||||
}
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||
statusMessage = "Found \(peripheral.name ?? "device"), connecting…"
|
||||
connectionState = .connecting
|
||||
shouldKeepScanning = false
|
||||
stopScanning()
|
||||
targetPeripheral = peripheral
|
||||
peripheral.delegate = self
|
||||
central.connect(peripheral, options: nil)
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
statusMessage = "Connected. Discovering services…"
|
||||
connectionState = .discovering
|
||||
peripheral.discoverServices([timeServiceUUID, fileServiceUUID])
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||
statusMessage = "Failed to connect. \(error?.localizedDescription ?? "")"
|
||||
connectionState = .failed
|
||||
targetPeripheral = nil
|
||||
resetFileStateOnDisconnect()
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||
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 {
|
||||
statusMessage = "Service discovery failed: \(error.localizedDescription)"
|
||||
connectionState = .failed
|
||||
scheduleRetry()
|
||||
return
|
||||
}
|
||||
|
||||
guard let services = peripheral.services else {
|
||||
statusMessage = "No services found on device."
|
||||
connectionState = .failed
|
||||
scheduleRetry()
|
||||
return
|
||||
}
|
||||
|
||||
for service in services {
|
||||
if service.uuid == timeServiceUUID {
|
||||
peripheral.discoverCharacteristics([timeCharacteristicUUID], for: service)
|
||||
} else if service.uuid == fileServiceUUID {
|
||||
peripheral.discoverCharacteristics([fileCommandUUID, fileResponseUUID], for: service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||
if let error {
|
||||
statusMessage = "Characteristic discovery failed: \(error.localizedDescription)"
|
||||
connectionState = .failed
|
||||
scheduleRetry()
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 characteristic.uuid == fileCommandUUID {
|
||||
if let error {
|
||||
fileErrorMessage = error.localizedDescription
|
||||
}
|
||||
isWritingFileCommand = false
|
||||
processFileCommandQueue(characteristic: characteristic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct cardboy_companionApp: App {
|
||||
@StateObject private var manager = TimeSyncManager()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(manager)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ idf_component_register(
|
||||
"src/i2c_global.cpp"
|
||||
"src/shutdowner.cpp"
|
||||
"src/spi_global.cpp"
|
||||
"src/time_sync_service.cpp"
|
||||
INCLUDE_DIRS
|
||||
"include"
|
||||
PRIV_REQUIRES
|
||||
@@ -19,6 +20,7 @@ idf_component_register(
|
||||
esp_driver_spi
|
||||
littlefs
|
||||
nvs_flash
|
||||
bt
|
||||
)
|
||||
|
||||
add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../sdk/utils" cardboy_utils_esp)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
#define DISP_WIDTH cardboy::sdk::kDisplayWidth
|
||||
#define DISP_HEIGHT cardboy::sdk::kDisplayHeight
|
||||
|
||||
#define BUZZER_PIN GPIO_NUM_22
|
||||
#define BUZZER_PIN GPIO_NUM_25
|
||||
|
||||
#define PWR_INT GPIO_NUM_10
|
||||
#define PWR_KILL GPIO_NUM_12
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
namespace cardboy::backend::esp {
|
||||
|
||||
/**
|
||||
* Ensure the BLE time synchronisation service is running.
|
||||
*
|
||||
* Safe to call multiple times; subsequent calls become no-ops once the
|
||||
* service has been started successfully.
|
||||
*/
|
||||
void ensure_time_sync_service_started();
|
||||
|
||||
/**
|
||||
* Stop the BLE time synchronisation service if it is running.
|
||||
* A no-op on platforms that do not support the BLE implementation.
|
||||
*/
|
||||
void shutdown_time_sync_service();
|
||||
|
||||
} // namespace cardboy::backend::esp
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "cardboy/backend/esp/i2c_global.hpp"
|
||||
#include "cardboy/backend/esp/shutdowner.hpp"
|
||||
#include "cardboy/backend/esp/spi_global.hpp"
|
||||
#include "cardboy/backend/esp/time_sync_service.hpp"
|
||||
|
||||
#include "cardboy/sdk/display_spec.hpp"
|
||||
|
||||
@@ -150,7 +151,7 @@ EspRuntime::EspRuntime() : framebuffer(), input(), clock() {
|
||||
Buttons::get().setEventBus(eventBus.get());
|
||||
}
|
||||
|
||||
EspRuntime::~EspRuntime() = default;
|
||||
EspRuntime::~EspRuntime() { shutdown_time_sync_service(); }
|
||||
|
||||
cardboy::sdk::Services& EspRuntime::serviceRegistry() { return services; }
|
||||
|
||||
@@ -180,6 +181,8 @@ void EspRuntime::initializeHardware() {
|
||||
Buzzer::get().init();
|
||||
|
||||
FsHelper::get().mount();
|
||||
|
||||
ensure_time_sync_service_started();
|
||||
}
|
||||
|
||||
void EspFramebuffer::clear_impl(bool on) {
|
||||
|
||||
1275
Firmware/components/backend-esp/src/time_sync_service.cpp
Normal file
1275
Firmware/components/backend-esp/src/time_sync_service.cpp
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,10 @@
|
||||
|
||||
#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"
|
||||
#include "cardboy/apps/tetris_app.hpp"
|
||||
#include "cardboy/backend/esp_backend.hpp"
|
||||
#include "cardboy/sdk/app_system.hpp"
|
||||
@@ -232,8 +234,10 @@ 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());
|
||||
system.registerApp(apps::createTetrisAppFactory());
|
||||
system.registerApp(apps::createGameboyAppFactory());
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ 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)
|
||||
add_subdirectory(snake)
|
||||
add_subdirectory(tetris)
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
target_sources(cardboy_apps
|
||||
PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/gameboy_app.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/minigb_apu/minigb_apu.c
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/apps/peanut_gb.h
|
||||
)
|
||||
|
||||
target_include_directories(cardboy_apps
|
||||
PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
)
|
||||
|
||||
target_compile_definitions(cardboy_apps
|
||||
PRIVATE
|
||||
MINIGB_APU_AUDIO_FORMAT_S16SYS=1
|
||||
)
|
||||
@@ -1,542 +0,0 @@
|
||||
/**
|
||||
* Game Boy APU emulator.
|
||||
* Copyright (c) 2019 Mahyar Koshkouei <mk@deltabeard.com>
|
||||
* Copyright (c) 2017 Alex Baines <alex@abaines.me.uk>
|
||||
* minigb_apu is released under the terms of the MIT license.
|
||||
*
|
||||
* minigb_apu emulates the audio processing unit (APU) of the Game Boy. This
|
||||
* project is based on MiniGBS by Alex Baines: https://github.com/baines/MiniGBS
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "minigb_apu.h"
|
||||
|
||||
#define DMG_CLOCK_FREQ_U ((unsigned)DMG_CLOCK_FREQ)
|
||||
#define AUDIO_NSAMPLES (AUDIO_SAMPLES_TOTAL)
|
||||
|
||||
#define MAX(a, b) ( a > b ? a : b )
|
||||
#define MIN(a, b) ( a <= b ? a : b )
|
||||
|
||||
/* Factor in which values are multiplied to compensate for fixed-point
|
||||
* arithmetic. Some hard-coded values in this project must be recreated. */
|
||||
#ifndef FREQ_INC_MULT
|
||||
# define FREQ_INC_MULT 105
|
||||
#endif
|
||||
/* Handles time keeping for sound generation.
|
||||
* FREQ_INC_REF must be equal to, or larger than AUDIO_SAMPLE_RATE in order
|
||||
* to avoid a division by zero error.
|
||||
* Using a square of 2 simplifies calculations. */
|
||||
#define FREQ_INC_REF (AUDIO_SAMPLE_RATE * FREQ_INC_MULT)
|
||||
|
||||
#define MAX_CHAN_VOLUME 15
|
||||
|
||||
static void set_note_freq(struct chan *c)
|
||||
{
|
||||
/* Lowest expected value of freq is 64. */
|
||||
uint32_t freq = (DMG_CLOCK_FREQ_U / 4) / (2048 - c->freq);
|
||||
c->freq_inc = freq * (uint32_t)(FREQ_INC_REF / AUDIO_SAMPLE_RATE);
|
||||
}
|
||||
|
||||
static void chan_enable(struct minigb_apu_ctx *ctx,
|
||||
const uint_fast8_t i, const bool enable)
|
||||
{
|
||||
uint8_t val;
|
||||
|
||||
ctx->chans[i].enabled = enable;
|
||||
val = (ctx->audio_mem[0xFF26 - AUDIO_ADDR_COMPENSATION] & 0x80) |
|
||||
(ctx->chans[3].enabled << 3) | (ctx->chans[2].enabled << 2) |
|
||||
(ctx->chans[1].enabled << 1) | (ctx->chans[0].enabled << 0);
|
||||
|
||||
ctx->audio_mem[0xFF26 - AUDIO_ADDR_COMPENSATION] = val;
|
||||
}
|
||||
|
||||
static void update_env(struct chan *c)
|
||||
{
|
||||
c->env.counter += c->env.inc;
|
||||
|
||||
while (c->env.counter > FREQ_INC_REF) {
|
||||
if (c->env.step) {
|
||||
c->volume += c->env.up ? 1 : -1;
|
||||
if (c->volume == 0 || c->volume == MAX_CHAN_VOLUME) {
|
||||
c->env.inc = 0;
|
||||
}
|
||||
c->volume = MAX(0, MIN(MAX_CHAN_VOLUME, c->volume));
|
||||
}
|
||||
c->env.counter -= FREQ_INC_REF;
|
||||
}
|
||||
}
|
||||
|
||||
static void update_len(struct minigb_apu_ctx *ctx, struct chan *c)
|
||||
{
|
||||
if (!c->len.enabled)
|
||||
return;
|
||||
|
||||
c->len.counter += c->len.inc;
|
||||
if (c->len.counter > FREQ_INC_REF) {
|
||||
chan_enable(ctx, c - ctx->chans, 0);
|
||||
c->len.counter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
static bool update_freq(struct chan *c, uint32_t *pos)
|
||||
{
|
||||
uint32_t inc = c->freq_inc - *pos;
|
||||
c->freq_counter += inc;
|
||||
|
||||
if (c->freq_counter > FREQ_INC_REF) {
|
||||
*pos = c->freq_inc - (c->freq_counter - FREQ_INC_REF);
|
||||
c->freq_counter = 0;
|
||||
return true;
|
||||
} else {
|
||||
*pos = c->freq_inc;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static void update_sweep(struct chan *c)
|
||||
{
|
||||
c->sweep.counter += c->sweep.inc;
|
||||
|
||||
while (c->sweep.counter > FREQ_INC_REF) {
|
||||
if (c->sweep.shift) {
|
||||
uint16_t inc = (c->sweep.freq >> c->sweep.shift);
|
||||
if (c->sweep.down)
|
||||
inc *= -1;
|
||||
|
||||
c->freq = c->sweep.freq + inc;
|
||||
if (c->freq > 2047) {
|
||||
c->enabled = 0;
|
||||
} else {
|
||||
set_note_freq(c);
|
||||
c->sweep.freq = c->freq;
|
||||
}
|
||||
} else if (c->sweep.rate) {
|
||||
c->enabled = 0;
|
||||
}
|
||||
c->sweep.counter -= FREQ_INC_REF;
|
||||
}
|
||||
}
|
||||
|
||||
static void update_square(struct minigb_apu_ctx *ctx, audio_sample_t *samples,
|
||||
const bool ch2)
|
||||
{
|
||||
struct chan *c = &ctx->chans[ch2];
|
||||
|
||||
if (!c->powered || !c->enabled)
|
||||
return;
|
||||
|
||||
set_note_freq(c);
|
||||
|
||||
for (uint_fast16_t i = 0; i < AUDIO_NSAMPLES; i += 2) {
|
||||
update_len(ctx, c);
|
||||
if (!c->enabled)
|
||||
return;
|
||||
|
||||
update_env(c);
|
||||
if (!c->volume)
|
||||
continue;
|
||||
|
||||
if (!ch2)
|
||||
update_sweep(c);
|
||||
|
||||
uint32_t pos = 0;
|
||||
uint32_t prev_pos = 0;
|
||||
int32_t sample = 0;
|
||||
|
||||
while (update_freq(c, &pos)) {
|
||||
c->square.duty_counter = (c->square.duty_counter + 1) & 7;
|
||||
sample += ((pos - prev_pos) / c->freq_inc) * c->val;
|
||||
c->val = (c->square.duty & (1 << c->square.duty_counter)) ?
|
||||
VOL_INIT_MAX / MAX_CHAN_VOLUME :
|
||||
VOL_INIT_MIN / MAX_CHAN_VOLUME;
|
||||
prev_pos = pos;
|
||||
}
|
||||
|
||||
sample += c->val;
|
||||
sample *= c->volume;
|
||||
sample /= 4;
|
||||
|
||||
samples[i + 0] += sample * c->on_left * ctx->vol_l;
|
||||
samples[i + 1] += sample * c->on_right * ctx->vol_r;
|
||||
}
|
||||
}
|
||||
|
||||
static uint8_t wave_sample(struct minigb_apu_ctx *ctx,
|
||||
const unsigned int pos, const unsigned int volume)
|
||||
{
|
||||
uint8_t sample;
|
||||
|
||||
sample = ctx->audio_mem[(0xFF30 + pos / 2) - AUDIO_ADDR_COMPENSATION];
|
||||
if (pos & 1) {
|
||||
sample &= 0xF;
|
||||
} else {
|
||||
sample >>= 4;
|
||||
}
|
||||
return volume ? (sample >> (volume - 1)) : 0;
|
||||
}
|
||||
|
||||
static void update_wave(struct minigb_apu_ctx *ctx, audio_sample_t *samples)
|
||||
{
|
||||
struct chan *c = &ctx->chans[2];
|
||||
|
||||
if (!c->powered || !c->enabled || !c->volume)
|
||||
return;
|
||||
|
||||
set_note_freq(c);
|
||||
c->freq_inc *= 2;
|
||||
|
||||
for (uint_fast16_t i = 0; i < AUDIO_NSAMPLES; i += 2) {
|
||||
update_len(ctx, c);
|
||||
if (!c->enabled)
|
||||
return;
|
||||
|
||||
uint32_t pos = 0;
|
||||
uint32_t prev_pos = 0;
|
||||
audio_sample_t sample = 0;
|
||||
|
||||
c->wave.sample = wave_sample(ctx, c->val, c->volume);
|
||||
|
||||
while (update_freq(c, &pos)) {
|
||||
c->val = (c->val + 1) & 31;
|
||||
sample += ((pos - prev_pos) / c->freq_inc) *
|
||||
((audio_sample_t)c->wave.sample - 8) *
|
||||
(AUDIO_SAMPLE_MAX/64);
|
||||
c->wave.sample = wave_sample(ctx, c->val, c->volume);
|
||||
prev_pos = pos;
|
||||
}
|
||||
|
||||
sample += ((audio_sample_t)c->wave.sample - 8) *
|
||||
(audio_sample_t)(AUDIO_SAMPLE_MAX/64);
|
||||
{
|
||||
/* First element is unused. */
|
||||
audio_sample_t div[] = { AUDIO_SAMPLE_MAX, 1, 2, 4 };
|
||||
sample = sample / (div[c->volume]);
|
||||
}
|
||||
|
||||
sample /= 4;
|
||||
samples[i + 0] += sample * c->on_left * ctx->vol_l;
|
||||
samples[i + 1] += sample * c->on_right * ctx->vol_r;
|
||||
}
|
||||
}
|
||||
|
||||
static void update_noise(struct minigb_apu_ctx *ctx, audio_sample_t *samples)
|
||||
{
|
||||
struct chan *c = &ctx->chans[3];
|
||||
|
||||
if (c->freq >= 14)
|
||||
c->enabled = 0;
|
||||
|
||||
if (!c->powered || !c->enabled)
|
||||
return;
|
||||
|
||||
{
|
||||
const uint32_t lfsr_div_lut[] = {
|
||||
8, 16, 32, 48, 64, 80, 96, 112
|
||||
};
|
||||
uint32_t freq;
|
||||
|
||||
freq = DMG_CLOCK_FREQ_U / (lfsr_div_lut[c->noise.lfsr_div] << c->freq);
|
||||
c->freq_inc = freq * (uint32_t)(FREQ_INC_REF / AUDIO_SAMPLE_RATE);
|
||||
}
|
||||
|
||||
for (uint_fast16_t i = 0; i < AUDIO_NSAMPLES; i += 2) {
|
||||
update_len(ctx, c);
|
||||
if (!c->enabled)
|
||||
return;
|
||||
|
||||
update_env(c);
|
||||
if (!c->volume)
|
||||
continue;
|
||||
|
||||
uint32_t pos = 0;
|
||||
uint32_t prev_pos = 0;
|
||||
int32_t sample = 0;
|
||||
|
||||
while (update_freq(c, &pos)) {
|
||||
c->noise.lfsr_reg = (c->noise.lfsr_reg << 1) |
|
||||
(c->val >= VOL_INIT_MAX/MAX_CHAN_VOLUME);
|
||||
|
||||
if (c->noise.lfsr_wide) {
|
||||
c->val = !(((c->noise.lfsr_reg >> 14) & 1) ^
|
||||
((c->noise.lfsr_reg >> 13) & 1)) ?
|
||||
VOL_INIT_MAX / MAX_CHAN_VOLUME :
|
||||
VOL_INIT_MIN / MAX_CHAN_VOLUME;
|
||||
} else {
|
||||
c->val = !(((c->noise.lfsr_reg >> 6) & 1) ^
|
||||
((c->noise.lfsr_reg >> 5) & 1)) ?
|
||||
VOL_INIT_MAX / MAX_CHAN_VOLUME :
|
||||
VOL_INIT_MIN / MAX_CHAN_VOLUME;
|
||||
}
|
||||
|
||||
sample += ((pos - prev_pos) / c->freq_inc) * c->val;
|
||||
prev_pos = pos;
|
||||
}
|
||||
|
||||
sample += c->val;
|
||||
sample *= c->volume;
|
||||
sample /= 4;
|
||||
|
||||
samples[i + 0] += sample * c->on_left * ctx->vol_l;
|
||||
samples[i + 1] += sample * c->on_right * ctx->vol_r;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SDL2 style audio callback function.
|
||||
*/
|
||||
void minigb_apu_audio_callback(struct minigb_apu_ctx *ctx,
|
||||
audio_sample_t *stream)
|
||||
{
|
||||
memset(stream, 0, AUDIO_SAMPLES_TOTAL * sizeof(audio_sample_t));
|
||||
update_square(ctx, stream, 0);
|
||||
update_square(ctx, stream, 1);
|
||||
update_wave(ctx, stream);
|
||||
update_noise(ctx, stream);
|
||||
}
|
||||
|
||||
static void chan_trigger(struct minigb_apu_ctx *ctx, uint_fast8_t i)
|
||||
{
|
||||
struct chan *c = &ctx->chans[i];
|
||||
|
||||
chan_enable(ctx, i, 1);
|
||||
c->volume = c->volume_init;
|
||||
|
||||
// volume envelope
|
||||
{
|
||||
/* LUT created in Julia with:
|
||||
* `(FREQ_INC_MULT * 64)./vcat(8, 1:7)`
|
||||
* Must be recreated when FREQ_INC_MULT modified.
|
||||
*/
|
||||
const uint32_t inc_lut[8] = {
|
||||
#if FREQ_INC_MULT == 16
|
||||
128, 1024, 512, 341,
|
||||
256, 205, 171, 146
|
||||
#elif FREQ_INC_MULT == 64
|
||||
512, 4096, 2048, 1365,
|
||||
1024, 819, 683, 585
|
||||
#elif FREQ_INC_MULT == 105
|
||||
/* Multiples of 105 provide integer values. */
|
||||
840, 6720, 3360, 2240,
|
||||
1680, 1344, 1120, 960
|
||||
#else
|
||||
#error "LUT not calculated for this value of FREQ_INC_MULT"
|
||||
#endif
|
||||
};
|
||||
uint8_t val;
|
||||
|
||||
val = ctx->audio_mem[(0xFF12 + (i * 5)) - AUDIO_ADDR_COMPENSATION];
|
||||
|
||||
c->env.step = val & 0x7;
|
||||
c->env.up = val & 0x8;
|
||||
c->env.inc = inc_lut[c->env.step];
|
||||
c->env.counter = 0;
|
||||
}
|
||||
|
||||
// freq sweep
|
||||
if (i == 0) {
|
||||
uint8_t val = ctx->audio_mem[0xFF10 - AUDIO_ADDR_COMPENSATION];
|
||||
|
||||
c->sweep.freq = c->freq;
|
||||
c->sweep.rate = (val >> 4) & 0x07;
|
||||
c->sweep.down = (val & 0x08);
|
||||
c->sweep.shift = (val & 0x07);
|
||||
c->sweep.inc = c->sweep.rate ?
|
||||
((128u * FREQ_INC_REF) / (c->sweep.rate * AUDIO_SAMPLE_RATE)) : 0;
|
||||
c->sweep.counter = FREQ_INC_REF;
|
||||
}
|
||||
|
||||
int len_max = 64;
|
||||
|
||||
if (i == 2) { // wave
|
||||
len_max = 256;
|
||||
c->val = 0;
|
||||
} else if (i == 3) { // noise
|
||||
c->noise.lfsr_reg = 0xFFFF;
|
||||
c->val = VOL_INIT_MIN / MAX_CHAN_VOLUME;
|
||||
}
|
||||
|
||||
c->len.inc = (256u * FREQ_INC_REF) / (AUDIO_SAMPLE_RATE * (len_max - c->len.load));
|
||||
c->len.counter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read audio register.
|
||||
* \param addr Address of audio register. Must be 0xFF10 <= addr <= 0xFF3F.
|
||||
* This is not checked in this function.
|
||||
* \return Byte at address.
|
||||
*/
|
||||
uint8_t minigb_apu_audio_read(struct minigb_apu_ctx *ctx, const uint16_t addr)
|
||||
{
|
||||
static const uint8_t ortab[] = {
|
||||
0x80, 0x3f, 0x00, 0xff, 0xbf,
|
||||
0xff, 0x3f, 0x00, 0xff, 0xbf,
|
||||
0x7f, 0xff, 0x9f, 0xff, 0xbf,
|
||||
0xff, 0xff, 0x00, 0x00, 0xbf,
|
||||
0x00, 0x00, 0x70,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
};
|
||||
|
||||
return ctx->audio_mem[addr - AUDIO_ADDR_COMPENSATION] |
|
||||
ortab[addr - AUDIO_ADDR_COMPENSATION];
|
||||
}
|
||||
|
||||
/**
|
||||
* Write audio register.
|
||||
* \param addr Address of audio register. Must be 0xFF10 <= addr <= 0xFF3F.
|
||||
* This is not checked in this function.
|
||||
* \param val Byte to write at address.
|
||||
*/
|
||||
void minigb_apu_audio_write(struct minigb_apu_ctx *ctx,
|
||||
const uint16_t addr, const uint8_t val)
|
||||
{
|
||||
/* Find sound channel corresponding to register address. */
|
||||
uint_fast8_t i;
|
||||
|
||||
if(addr == 0xFF26)
|
||||
{
|
||||
ctx->audio_mem[addr - AUDIO_ADDR_COMPENSATION] = val & 0x80;
|
||||
/* On APU power off, clear all registers apart from wave
|
||||
* RAM. */
|
||||
if((val & 0x80) == 0)
|
||||
{
|
||||
memset(ctx->audio_mem,
|
||||
0x00, 0xFF26 - AUDIO_ADDR_COMPENSATION);
|
||||
ctx->chans[0].enabled = false;
|
||||
ctx->chans[1].enabled = false;
|
||||
ctx->chans[2].enabled = false;
|
||||
ctx->chans[3].enabled = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/* Ignore register writes if APU powered off. */
|
||||
if(ctx->audio_mem[0xFF26 - AUDIO_ADDR_COMPENSATION] == 0x00)
|
||||
return;
|
||||
|
||||
ctx->audio_mem[addr - AUDIO_ADDR_COMPENSATION] = val;
|
||||
i = (addr - AUDIO_ADDR_COMPENSATION) / 5;
|
||||
|
||||
switch (addr) {
|
||||
case 0xFF12:
|
||||
case 0xFF17:
|
||||
case 0xFF21: {
|
||||
ctx->chans[i].volume_init = val >> 4;
|
||||
ctx->chans[i].powered = (val >> 3) != 0;
|
||||
|
||||
// "zombie mode" stuff, needed for Prehistorik Man and probably
|
||||
// others
|
||||
if (ctx->chans[i].powered && ctx->chans[i].enabled) {
|
||||
if ((ctx->chans[i].env.step == 0 && ctx->chans[i].env.inc != 0)) {
|
||||
if (val & 0x08) {
|
||||
ctx->chans[i].volume++;
|
||||
} else {
|
||||
ctx->chans[i].volume += 2;
|
||||
}
|
||||
} else {
|
||||
ctx->chans[i].volume = 16 - ctx->chans[i].volume;
|
||||
}
|
||||
|
||||
ctx->chans[i].volume &= 0x0F;
|
||||
ctx->chans[i].env.step = val & 0x07;
|
||||
}
|
||||
} break;
|
||||
|
||||
case 0xFF1C:
|
||||
ctx->chans[i].volume = ctx->chans[i].volume_init = (val >> 5) & 0x03;
|
||||
break;
|
||||
|
||||
case 0xFF11:
|
||||
case 0xFF16:
|
||||
case 0xFF20: {
|
||||
const uint8_t duty_lookup[] = { 0x10, 0x30, 0x3C, 0xCF };
|
||||
ctx->chans[i].len.load = val & 0x3f;
|
||||
ctx->chans[i].square.duty = duty_lookup[val >> 6];
|
||||
break;
|
||||
}
|
||||
|
||||
case 0xFF1B:
|
||||
ctx->chans[i].len.load = val;
|
||||
break;
|
||||
|
||||
case 0xFF13:
|
||||
case 0xFF18:
|
||||
case 0xFF1D:
|
||||
ctx->chans[i].freq &= 0xFF00;
|
||||
ctx->chans[i].freq |= val;
|
||||
break;
|
||||
|
||||
case 0xFF1A:
|
||||
ctx->chans[i].powered = (val & 0x80) != 0;
|
||||
chan_enable(ctx, i, val & 0x80);
|
||||
break;
|
||||
|
||||
case 0xFF14:
|
||||
case 0xFF19:
|
||||
case 0xFF1E:
|
||||
ctx->chans[i].freq &= 0x00FF;
|
||||
ctx->chans[i].freq |= ((val & 0x07) << 8);
|
||||
/* Intentional fall-through. */
|
||||
case 0xFF23:
|
||||
ctx->chans[i].len.enabled = val & 0x40;
|
||||
if (val & 0x80)
|
||||
chan_trigger(ctx, i);
|
||||
|
||||
break;
|
||||
|
||||
case 0xFF22:
|
||||
ctx->chans[3].freq = val >> 4;
|
||||
ctx->chans[3].noise.lfsr_wide = !(val & 0x08);
|
||||
ctx->chans[3].noise.lfsr_div = val & 0x07;
|
||||
break;
|
||||
|
||||
case 0xFF24:
|
||||
{
|
||||
ctx->vol_l = ((val >> 4) & 0x07);
|
||||
ctx->vol_r = (val & 0x07);
|
||||
break;
|
||||
}
|
||||
|
||||
case 0xFF25:
|
||||
for (uint_fast8_t j = 0; j < 4; j++) {
|
||||
ctx->chans[j].on_left = (val >> (4 + j)) & 1;
|
||||
ctx->chans[j].on_right = (val >> j) & 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void minigb_apu_audio_init(struct minigb_apu_ctx *ctx)
|
||||
{
|
||||
/* Initialise channels and samples. */
|
||||
memset(ctx->chans, 0, sizeof(ctx->chans));
|
||||
ctx->chans[0].val = ctx->chans[1].val = -1;
|
||||
|
||||
/* Initialise IO registers. */
|
||||
{
|
||||
const uint8_t regs_init[] = { 0x80, 0xBF, 0xF3, 0xFF, 0x3F,
|
||||
0xFF, 0x3F, 0x00, 0xFF, 0x3F,
|
||||
0x7F, 0xFF, 0x9F, 0xFF, 0x3F,
|
||||
0xFF, 0xFF, 0x00, 0x00, 0x3F,
|
||||
0x77, 0xF3, 0xF1 };
|
||||
|
||||
for(uint_fast8_t i = 0; i < sizeof(regs_init); ++i)
|
||||
minigb_apu_audio_write(ctx, 0xFF10 + i, regs_init[i]);
|
||||
}
|
||||
|
||||
/* Initialise Wave Pattern RAM. */
|
||||
{
|
||||
const uint8_t wave_init[] = { 0xac, 0xdd, 0xda, 0x48,
|
||||
0x36, 0x02, 0xcf, 0x16,
|
||||
0x2c, 0x04, 0xe5, 0x2c,
|
||||
0xac, 0xdd, 0xda, 0x48 };
|
||||
|
||||
for(uint_fast8_t i = 0; i < sizeof(wave_init); ++i)
|
||||
minigb_apu_audio_write(ctx, 0xFF30 + i, wave_init[i]);
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* minigb_apu is released under the terms listed within the LICENSE file.
|
||||
*
|
||||
* minigb_apu emulates the audio processing unit (APU) of the Game Boy. This
|
||||
* project is based on MiniGBS by Alex Baines: https://github.com/baines/MiniGBS
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifndef AUDIO_SAMPLE_RATE
|
||||
# define AUDIO_SAMPLE_RATE 20000
|
||||
#endif
|
||||
|
||||
/* The audio output format is in platform native endian. */
|
||||
#if defined(MINIGB_APU_AUDIO_FORMAT_S16SYS)
|
||||
typedef int16_t audio_sample_t;
|
||||
# define AUDIO_SAMPLE_MAX INT16_MAX
|
||||
# define AUDIO_SAMPLE_MIN INT16_MIN
|
||||
# define VOL_INIT_MAX (AUDIO_SAMPLE_MAX/8)
|
||||
# define VOL_INIT_MIN (AUDIO_SAMPLE_MIN/8)
|
||||
#elif defined(MINIGB_APU_AUDIO_FORMAT_S32SYS)
|
||||
typedef int32_t audio_sample_t;
|
||||
# define AUDIO_SAMPLE_MAX INT32_MAX
|
||||
# define AUDIO_SAMPLE_MIN INT32_MIN
|
||||
# define VOL_INIT_MAX (INT32_MAX/8)
|
||||
# define VOL_INIT_MIN (INT32_MIN/8)
|
||||
#else
|
||||
#error MiniGB APU: Invalid or unsupported audio format selected
|
||||
#endif
|
||||
|
||||
#define DMG_CLOCK_FREQ 4194304.0
|
||||
#define SCREEN_REFRESH_CYCLES 70224.0
|
||||
#define VERTICAL_SYNC (DMG_CLOCK_FREQ/SCREEN_REFRESH_CYCLES)
|
||||
|
||||
/* Number of audio samples in each channel. */
|
||||
#define AUDIO_SAMPLES ((unsigned)(AUDIO_SAMPLE_RATE / VERTICAL_SYNC))
|
||||
/* Number of audio channels. The audio output is in interleaved stereo format.*/
|
||||
#define AUDIO_CHANNELS 2
|
||||
/* Number of audio samples output in each audio_callback call. */
|
||||
#define AUDIO_SAMPLES_TOTAL (AUDIO_SAMPLES * 2)
|
||||
|
||||
#define AUDIO_MEM_SIZE (0xFF3F - 0xFF10 + 1)
|
||||
#define AUDIO_ADDR_COMPENSATION 0xFF10
|
||||
|
||||
struct chan_len_ctr {
|
||||
uint8_t load;
|
||||
uint8_t enabled;
|
||||
uint32_t counter;
|
||||
uint32_t inc;
|
||||
};
|
||||
|
||||
struct chan_vol_env {
|
||||
uint8_t step;
|
||||
uint8_t up;
|
||||
uint32_t counter;
|
||||
uint32_t inc;
|
||||
};
|
||||
|
||||
struct chan_freq_sweep {
|
||||
uint8_t rate;
|
||||
uint8_t shift;
|
||||
uint8_t down;
|
||||
uint16_t freq;
|
||||
uint32_t counter;
|
||||
uint32_t inc;
|
||||
};
|
||||
|
||||
struct chan {
|
||||
uint8_t enabled;
|
||||
uint8_t powered;
|
||||
uint8_t on_left;
|
||||
uint8_t on_right;
|
||||
|
||||
uint8_t volume;
|
||||
uint8_t volume_init;
|
||||
|
||||
uint16_t freq;
|
||||
uint32_t freq_counter;
|
||||
uint32_t freq_inc;
|
||||
|
||||
int32_t val;
|
||||
|
||||
struct chan_len_ctr len;
|
||||
struct chan_vol_env env;
|
||||
struct chan_freq_sweep sweep;
|
||||
|
||||
union {
|
||||
struct {
|
||||
uint8_t duty;
|
||||
uint8_t duty_counter;
|
||||
} square;
|
||||
struct {
|
||||
uint16_t lfsr_reg;
|
||||
uint8_t lfsr_wide;
|
||||
uint8_t lfsr_div;
|
||||
} noise;
|
||||
struct {
|
||||
uint8_t sample;
|
||||
} wave;
|
||||
};
|
||||
};
|
||||
|
||||
struct minigb_apu_ctx {
|
||||
struct chan chans[4];
|
||||
int32_t vol_l, vol_r;
|
||||
|
||||
/**
|
||||
* Memory holding audio registers between 0xFF10 and 0xFF3F inclusive.
|
||||
*/
|
||||
uint8_t audio_mem[AUDIO_MEM_SIZE];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fill allocated buffer "stream" with AUDIO_SAMPLES_TOTAL number of 16-bit
|
||||
* signed samples (native endian order) in stereo interleaved format.
|
||||
* Each call corresponds to the time taken for each VSYNC in the Game Boy.
|
||||
*
|
||||
* \param ctx Library context. Must be initialised with audio_init().
|
||||
* \param stream Allocated pointer to store audio samples. Must be at least
|
||||
* AUDIO_SAMPLES_TOTAL in size.
|
||||
*/
|
||||
void minigb_apu_audio_callback(struct minigb_apu_ctx *ctx,
|
||||
audio_sample_t *stream);
|
||||
|
||||
/**
|
||||
* Read audio register at given address "addr".
|
||||
* \param ctx Library context. Must be initialised with audio_init().
|
||||
* \param addr Address of registers to read. Must be within 0xFF10 and 0xFF3F,
|
||||
* inclusive.
|
||||
*/
|
||||
uint8_t minigb_apu_audio_read(struct minigb_apu_ctx *ctx, const uint16_t addr);
|
||||
|
||||
/**
|
||||
* Write "val" to audio register at given address "addr".
|
||||
* \param ctx Library context. Must be initialised with audio_init().
|
||||
* \param addr Address of registers to read. Must be within 0xFF10 and 0xFF3F,
|
||||
* inclusive.
|
||||
* \param val Value to write to address.
|
||||
*/
|
||||
void minigb_apu_audio_write(struct minigb_apu_ctx *ctx,
|
||||
const uint16_t addr, const uint8_t val);
|
||||
|
||||
/**
|
||||
* Initialise audio driver.
|
||||
* \param ctx Library context.
|
||||
*/
|
||||
void minigb_apu_audio_init(struct minigb_apu_ctx *ctx);
|
||||
File diff suppressed because it is too large
Load Diff
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 {
|
||||
|
||||
9
Firmware/sdk/apps/snake/CMakeLists.txt
Normal file
9
Firmware/sdk/apps/snake/CMakeLists.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
target_sources(cardboy_apps
|
||||
PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/snake_app.cpp
|
||||
)
|
||||
|
||||
target_include_directories(cardboy_apps
|
||||
PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
)
|
||||
11
Firmware/sdk/apps/snake/include/cardboy/apps/snake_app.hpp
Normal file
11
Firmware/sdk/apps/snake/include/cardboy/apps/snake_app.hpp
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/sdk/app_framework.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace apps {
|
||||
|
||||
std::unique_ptr<cardboy::sdk::IAppFactory> createSnakeAppFactory();
|
||||
|
||||
} // namespace apps
|
||||
432
Firmware/sdk/apps/snake/src/snake_app.cpp
Normal file
432
Firmware/sdk/apps/snake/src/snake_app.cpp
Normal file
@@ -0,0 +1,432 @@
|
||||
#include "cardboy/apps/snake_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 "cardboy/sdk/display_spec.hpp"
|
||||
#include "cardboy/sdk/input_state.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <deque>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace apps {
|
||||
namespace {
|
||||
|
||||
using cardboy::sdk::AppButtonEvent;
|
||||
using cardboy::sdk::AppContext;
|
||||
using cardboy::sdk::AppEvent;
|
||||
using cardboy::sdk::AppEventType;
|
||||
using cardboy::sdk::AppTimerHandle;
|
||||
using cardboy::sdk::InputState;
|
||||
|
||||
constexpr char kSnakeAppName[] = "Snake";
|
||||
|
||||
constexpr int kBoardWidth = 32;
|
||||
constexpr int kBoardHeight = 20;
|
||||
constexpr int kCellSize = 10;
|
||||
constexpr int kInitialSnakeLength = 5;
|
||||
constexpr int kScorePerFood = 10;
|
||||
constexpr int kMinMoveIntervalMs = 80;
|
||||
constexpr int kBaseMoveIntervalMs = 220;
|
||||
constexpr int kIntervalSpeedupPerSegment = 4;
|
||||
|
||||
struct Point {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
|
||||
bool operator==(const Point& other) const { return x == other.x && y == other.y; }
|
||||
};
|
||||
|
||||
enum class Direction { Up, Down, Left, Right };
|
||||
|
||||
[[nodiscard]] std::uint32_t randomSeed(AppContext& ctx) {
|
||||
if (auto* rnd = ctx.random())
|
||||
return rnd->nextUint32();
|
||||
static std::random_device rd;
|
||||
return rd();
|
||||
}
|
||||
|
||||
class SnakeGame {
|
||||
public:
|
||||
explicit SnakeGame(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer) {
|
||||
rng.seed(randomSeed(context));
|
||||
loadHighScore();
|
||||
reset();
|
||||
}
|
||||
|
||||
void onStart() {
|
||||
scheduleMoveTimer();
|
||||
dirty = true;
|
||||
renderIfNeeded();
|
||||
}
|
||||
|
||||
void onStop() { cancelMoveTimer(); }
|
||||
|
||||
void handleEvent(const AppEvent& event) {
|
||||
switch (event.type) {
|
||||
case AppEventType::Button:
|
||||
handleButtons(event.button);
|
||||
break;
|
||||
case AppEventType::Timer:
|
||||
handleTimer(event.timer.handle);
|
||||
break;
|
||||
}
|
||||
renderIfNeeded();
|
||||
}
|
||||
|
||||
private:
|
||||
AppContext& context;
|
||||
typename AppContext::Framebuffer& framebuffer;
|
||||
|
||||
std::deque<Point> snake;
|
||||
Point food{};
|
||||
Direction direction = Direction::Right;
|
||||
Direction queuedDirection = Direction::Right;
|
||||
bool paused = false;
|
||||
bool gameOver = false;
|
||||
bool dirty = false;
|
||||
int score = 0;
|
||||
int highScore = 0;
|
||||
AppTimerHandle moveTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
std::mt19937 rng;
|
||||
|
||||
void handleButtons(const AppButtonEvent& evt) {
|
||||
const auto& cur = evt.current;
|
||||
const auto& prev = evt.previous;
|
||||
if (cur.b && !prev.b) {
|
||||
context.requestAppSwitchByName(kMenuAppName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cur.select && !prev.select) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cur.start && !prev.start) {
|
||||
if (gameOver)
|
||||
reset();
|
||||
else {
|
||||
paused = !paused;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (gameOver)
|
||||
return;
|
||||
|
||||
if (cur.up && !prev.up)
|
||||
queueDirection(Direction::Up);
|
||||
else if (cur.down && !prev.down)
|
||||
queueDirection(Direction::Down);
|
||||
else if (cur.left && !prev.left)
|
||||
queueDirection(Direction::Left);
|
||||
else if (cur.right && !prev.right)
|
||||
queueDirection(Direction::Right);
|
||||
|
||||
if (cur.a && !prev.a && !paused)
|
||||
advance();
|
||||
}
|
||||
|
||||
void handleTimer(AppTimerHandle handle) {
|
||||
if (handle == moveTimer && !paused && !gameOver)
|
||||
advance();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
cancelMoveTimer();
|
||||
|
||||
snake.clear();
|
||||
const int centerX = kBoardWidth / 2;
|
||||
const int centerY = kBoardHeight / 2;
|
||||
for (int i = 0; i < kInitialSnakeLength; ++i)
|
||||
snake.push_back(Point{centerX - i, centerY});
|
||||
|
||||
direction = Direction::Right;
|
||||
queuedDirection = Direction::Right;
|
||||
paused = false;
|
||||
gameOver = false;
|
||||
score = 0;
|
||||
dirty = true;
|
||||
|
||||
if (!spawnFood())
|
||||
onGameOver();
|
||||
|
||||
scheduleMoveTimer();
|
||||
}
|
||||
|
||||
void advance() {
|
||||
direction = queuedDirection;
|
||||
Point nextHead = snake.front();
|
||||
switch (direction) {
|
||||
case Direction::Up:
|
||||
--nextHead.y;
|
||||
break;
|
||||
case Direction::Down:
|
||||
++nextHead.y;
|
||||
break;
|
||||
case Direction::Left:
|
||||
--nextHead.x;
|
||||
break;
|
||||
case Direction::Right:
|
||||
++nextHead.x;
|
||||
break;
|
||||
}
|
||||
|
||||
if (isCollision(nextHead)) {
|
||||
onGameOver();
|
||||
return;
|
||||
}
|
||||
|
||||
snake.push_front(nextHead);
|
||||
if (nextHead == food) {
|
||||
score += kScorePerFood;
|
||||
updateHighScore();
|
||||
if (!spawnFood()) {
|
||||
onGameOver();
|
||||
return;
|
||||
}
|
||||
scheduleMoveTimer();
|
||||
if (auto* buzzer = context.buzzer())
|
||||
buzzer->beepMove();
|
||||
} else {
|
||||
snake.pop_back();
|
||||
}
|
||||
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool isCollision(const Point& nextHead) const {
|
||||
if (nextHead.x < 0 || nextHead.x >= kBoardWidth || nextHead.y < 0 || nextHead.y >= kBoardHeight)
|
||||
return true;
|
||||
return std::find(snake.begin(), snake.end(), nextHead) != snake.end();
|
||||
}
|
||||
|
||||
void onGameOver() {
|
||||
if (gameOver)
|
||||
return;
|
||||
gameOver = true;
|
||||
cancelMoveTimer();
|
||||
dirty = true;
|
||||
if (auto* buzzer = context.buzzer())
|
||||
buzzer->beepGameOver();
|
||||
}
|
||||
|
||||
void queueDirection(Direction next) {
|
||||
if (isOpposite(direction, next) || isOpposite(queuedDirection, next))
|
||||
return;
|
||||
queuedDirection = next;
|
||||
}
|
||||
|
||||
[[nodiscard]] static bool isOpposite(Direction a, Direction b) {
|
||||
if ((a == Direction::Up && b == Direction::Down) || (a == Direction::Down && b == Direction::Up))
|
||||
return true;
|
||||
if ((a == Direction::Left && b == Direction::Right) || (a == Direction::Right && b == Direction::Left))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool spawnFood() {
|
||||
std::vector<Point> freeCells;
|
||||
freeCells.reserve(kBoardWidth * kBoardHeight - static_cast<int>(snake.size()));
|
||||
for (int y = 0; y < kBoardHeight; ++y) {
|
||||
for (int x = 0; x < kBoardWidth; ++x) {
|
||||
Point p{x, y};
|
||||
if (std::find(snake.begin(), snake.end(), p) == snake.end())
|
||||
freeCells.push_back(p);
|
||||
}
|
||||
}
|
||||
if (freeCells.empty())
|
||||
return false;
|
||||
std::uniform_int_distribution<std::size_t> dist(0, freeCells.size() - 1);
|
||||
food = freeCells[dist(rng)];
|
||||
return true;
|
||||
}
|
||||
|
||||
void scheduleMoveTimer() {
|
||||
cancelMoveTimer();
|
||||
const std::uint32_t interval = currentInterval();
|
||||
moveTimer = context.scheduleRepeatingTimer(interval);
|
||||
}
|
||||
|
||||
void cancelMoveTimer() {
|
||||
if (moveTimer != cardboy::sdk::kInvalidAppTimer) {
|
||||
context.cancelTimer(moveTimer);
|
||||
moveTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] std::uint32_t currentInterval() const {
|
||||
int interval = kBaseMoveIntervalMs - static_cast<int>(snake.size()) * kIntervalSpeedupPerSegment;
|
||||
if (interval < kMinMoveIntervalMs)
|
||||
interval = kMinMoveIntervalMs;
|
||||
return static_cast<std::uint32_t>(interval);
|
||||
}
|
||||
|
||||
void updateHighScore() {
|
||||
if (score <= highScore)
|
||||
return;
|
||||
highScore = score;
|
||||
if (auto* storage = context.storage())
|
||||
storage->writeUint32("snake", "best", static_cast<std::uint32_t>(highScore));
|
||||
}
|
||||
|
||||
void loadHighScore() {
|
||||
if (auto* storage = context.storage()) {
|
||||
std::uint32_t stored = 0;
|
||||
if (storage->readUint32("snake", "best", stored))
|
||||
highScore = static_cast<int>(stored);
|
||||
}
|
||||
}
|
||||
|
||||
void renderIfNeeded() {
|
||||
if (!dirty)
|
||||
return;
|
||||
dirty = false;
|
||||
|
||||
framebuffer.frameReady();
|
||||
framebuffer.clear(false);
|
||||
|
||||
drawBoard();
|
||||
drawFood();
|
||||
drawSnake();
|
||||
drawHud();
|
||||
|
||||
framebuffer.sendFrame();
|
||||
}
|
||||
|
||||
[[nodiscard]] int boardOriginX() const {
|
||||
return (cardboy::sdk::kDisplayWidth - kBoardWidth * kCellSize) / 2;
|
||||
}
|
||||
|
||||
[[nodiscard]] int boardOriginY() const {
|
||||
const int centered = (cardboy::sdk::kDisplayHeight - kBoardHeight * kCellSize) / 2;
|
||||
return std::max(24, centered);
|
||||
}
|
||||
|
||||
void drawBoard() {
|
||||
const int originX = boardOriginX();
|
||||
const int originY = boardOriginY();
|
||||
const int width = kBoardWidth * kCellSize;
|
||||
const int height = kBoardHeight * kCellSize;
|
||||
|
||||
const int x0 = originX;
|
||||
const int y0 = originY;
|
||||
const int x1 = originX + width - 1;
|
||||
const int y1 = originY + height - 1;
|
||||
for (int x = x0; x <= x1; ++x) {
|
||||
framebuffer.drawPixel(x, y0, true);
|
||||
framebuffer.drawPixel(x, y1, true);
|
||||
}
|
||||
for (int y = y0; y <= y1; ++y) {
|
||||
framebuffer.drawPixel(x0, y, true);
|
||||
framebuffer.drawPixel(x1, y, true);
|
||||
}
|
||||
}
|
||||
|
||||
void drawSnake() {
|
||||
if (snake.empty())
|
||||
return;
|
||||
std::size_t index = 0;
|
||||
for (const auto& segment: snake) {
|
||||
drawSnakeSegment(segment, index == 0);
|
||||
++index;
|
||||
}
|
||||
}
|
||||
|
||||
void drawSnakeSegment(const Point& segment, bool head) {
|
||||
const int originX = boardOriginX() + segment.x * kCellSize;
|
||||
const int originY = boardOriginY() + segment.y * kCellSize;
|
||||
for (int dy = 0; dy < kCellSize; ++dy) {
|
||||
for (int dx = 0; dx < kCellSize; ++dx) {
|
||||
const bool border = dx == 0 || dy == 0 || dx == kCellSize - 1 || dy == kCellSize - 1;
|
||||
bool fill = ((dx + dy) & 0x1) == 0;
|
||||
if (head)
|
||||
fill = true;
|
||||
const bool on = border || fill;
|
||||
framebuffer.drawPixel(originX + dx, originY + dy, on);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void drawFood() {
|
||||
const int cx = boardOriginX() + food.x * kCellSize + kCellSize / 2;
|
||||
const int cy = boardOriginY() + food.y * kCellSize + kCellSize / 2;
|
||||
const int r = std::max(2, kCellSize / 2 - 1);
|
||||
for (int dy = -r; dy <= r; ++dy) {
|
||||
for (int dx = -r; dx <= r; ++dx) {
|
||||
if (std::abs(dx) + std::abs(dy) <= r)
|
||||
framebuffer.drawPixel(cx + dx, cy + dy, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void drawHud() {
|
||||
const int margin = 12;
|
||||
const int textY = 8;
|
||||
const std::string scoreStr = "SCORE " + std::to_string(score);
|
||||
const std::string bestStr = "BEST " + std::to_string(highScore);
|
||||
font16x8::drawText(framebuffer, margin, textY, scoreStr, 1, true, 1);
|
||||
const int bestX = cardboy::sdk::kDisplayWidth - font16x8::measureText(bestStr, 1, 1) - margin;
|
||||
font16x8::drawText(framebuffer, bestX, textY, bestStr, 1, true, 1);
|
||||
|
||||
const int footerY = cardboy::sdk::kDisplayHeight - 24;
|
||||
const std::string menuStr = "B MENU";
|
||||
const std::string selectStr = "SELECT RESET";
|
||||
const std::string startStr = "START PAUSE";
|
||||
const int selectX = (cardboy::sdk::kDisplayWidth - font16x8::measureText(selectStr, 1, 1)) / 2;
|
||||
const int startX = cardboy::sdk::kDisplayWidth - font16x8::measureText(startStr, 1, 1) - margin;
|
||||
font16x8::drawText(framebuffer, margin, footerY, menuStr, 1, true, 1);
|
||||
font16x8::drawText(framebuffer, selectX, footerY, selectStr, 1, true, 1);
|
||||
font16x8::drawText(framebuffer, startX, footerY, startStr, 1, true, 1);
|
||||
|
||||
if (paused && !gameOver)
|
||||
drawBanner("PAUSED");
|
||||
else if (gameOver)
|
||||
drawBanner("GAME OVER");
|
||||
}
|
||||
|
||||
void drawBanner(std::string_view text) {
|
||||
const int w = font16x8::measureText(text, 2, 1);
|
||||
const int h = font16x8::kGlyphHeight * 2;
|
||||
const int x = (cardboy::sdk::kDisplayWidth - w) / 2;
|
||||
const int y = boardOriginY() + kBoardHeight * kCellSize / 2 - h / 2;
|
||||
for (int yy = -4; yy < h + 4; ++yy)
|
||||
for (int xx = -6; xx < w + 6; ++xx)
|
||||
framebuffer.drawPixel(x + xx, y + yy, yy == -4 || yy == h + 3 || xx == -6 || xx == w + 5);
|
||||
font16x8::drawText(framebuffer, x, y, text, 2, true, 1);
|
||||
}
|
||||
};
|
||||
|
||||
class SnakeApp final : public cardboy::sdk::IApp {
|
||||
public:
|
||||
explicit SnakeApp(AppContext& ctx) : game(ctx) {}
|
||||
|
||||
void onStart() override { game.onStart(); }
|
||||
void onStop() override { game.onStop(); }
|
||||
void handleEvent(const AppEvent& event) override { game.handleEvent(event); }
|
||||
|
||||
private:
|
||||
SnakeGame game;
|
||||
};
|
||||
|
||||
class SnakeFactory final : public cardboy::sdk::IAppFactory {
|
||||
public:
|
||||
const char* name() const override { return kSnakeAppName; }
|
||||
std::unique_ptr<cardboy::sdk::IApp> create(AppContext& context) override {
|
||||
return std::make_unique<SnakeApp>(context);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<cardboy::sdk::IAppFactory> createSnakeAppFactory() { return std::make_unique<SnakeFactory>(); }
|
||||
|
||||
} // namespace apps
|
||||
@@ -1,7 +1,9 @@
|
||||
#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"
|
||||
#include "cardboy/backend/desktop_backend.hpp"
|
||||
#include "cardboy/sdk/app_system.hpp"
|
||||
@@ -26,9 +28,11 @@ 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());
|
||||
system.registerApp(apps::createSnakeAppFactory());
|
||||
system.registerApp(apps::createTetrisAppFactory());
|
||||
|
||||
system.run();
|
||||
|
||||
Reference in New Issue
Block a user