mirror of
https://github.com/usatiuk/cardboy.git
synced 2025-10-28 23:27:49 +01:00
Compare commits
24 Commits
sound
...
1ee132898b
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ee132898b | |||
| 5ddd38e5d7 | |||
| 4112efd60b | |||
| 678158c302 | |||
| 12e8a0e098 | |||
| fc633d7c90 | |||
| 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
|
||||
@@ -1,4 +1,4 @@
|
||||
To build:
|
||||
(in zsh)
|
||||
(in zsh, bash doesn't work)
|
||||
. "$HOME/esp/esp-idf/export.sh"
|
||||
idf.py build
|
||||
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,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "cardboy-icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
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)
|
||||
}
|
||||
|
||||
Button(action: manager.sendTestNotification) {
|
||||
Label("Send Test Notification", systemImage: "bell.badge.waveform")
|
||||
}
|
||||
.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,12 @@
|
||||
<?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>
|
||||
<key>NSUserNotificationUsageDescription</key>
|
||||
<string>Allow Cardboy Companion to send local notifications for testing.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/event_groups.h"
|
||||
#include "freertos/timers.h"
|
||||
#include "freertos/queue.h"
|
||||
|
||||
namespace cardboy::backend::esp {
|
||||
|
||||
@@ -16,12 +16,12 @@ public:
|
||||
void signal(std::uint32_t bits) override;
|
||||
void signalFromISR(std::uint32_t bits) override;
|
||||
std::uint32_t wait(std::uint32_t mask, std::uint32_t timeout_ms) override;
|
||||
void scheduleTimerSignal(std::uint32_t delay_ms) override;
|
||||
void cancelTimerSignal() override;
|
||||
void postEvent(const cardboy::sdk::AppEvent& event) override;
|
||||
bool popEvent(cardboy::sdk::AppEvent& outEvent) override;
|
||||
|
||||
private:
|
||||
EventGroupHandle_t group;
|
||||
TimerHandle_t timer;
|
||||
QueueHandle_t eventQueue;
|
||||
};
|
||||
|
||||
} // namespace cardboy::backend::esp
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
namespace cardboy::sdk {
|
||||
class INotificationCenter;
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
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();
|
||||
|
||||
/**
|
||||
* Provide a notification sink that receives mirrored notifications from iOS.
|
||||
* Passing nullptr disables mirroring.
|
||||
*/
|
||||
void set_notification_center(cardboy::sdk::INotificationCenter* center);
|
||||
|
||||
} // namespace cardboy::backend::esp
|
||||
@@ -61,6 +61,8 @@ private:
|
||||
class HighResClockService;
|
||||
class FilesystemService;
|
||||
class LoopHooksService;
|
||||
class NotificationService;
|
||||
class TimerService;
|
||||
|
||||
std::unique_ptr<BuzzerService> buzzerService;
|
||||
std::unique_ptr<BatteryService> batteryService;
|
||||
@@ -69,7 +71,9 @@ private:
|
||||
std::unique_ptr<HighResClockService> highResClockService;
|
||||
std::unique_ptr<FilesystemService> filesystemService;
|
||||
std::unique_ptr<EventBus> eventBus;
|
||||
std::unique_ptr<TimerService> timerService;
|
||||
std::unique_ptr<LoopHooksService> loopHooksService;
|
||||
std::unique_ptr<NotificationService> notificationService;
|
||||
|
||||
cardboy::sdk::Services services{};
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -18,13 +19,19 @@
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/timers.h"
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <list>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace cardboy::backend::esp {
|
||||
|
||||
@@ -36,6 +43,7 @@ void ensureNvsInit() {
|
||||
|
||||
esp_err_t err = nvs_flash_init();
|
||||
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||
printf("Erasing flash!\n");
|
||||
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||
err = nvs_flash_init();
|
||||
}
|
||||
@@ -126,6 +134,389 @@ public:
|
||||
void onLoopIteration() override { vTaskDelay(1); }
|
||||
};
|
||||
|
||||
class EspRuntime::TimerService final : public cardboy::sdk::ITimerService {
|
||||
public:
|
||||
explicit TimerService(cardboy::sdk::IEventBus& bus);
|
||||
~TimerService() override;
|
||||
|
||||
cardboy::sdk::TimerClientId acquireClient() override;
|
||||
void releaseClient(cardboy::sdk::TimerClientId clientId) override;
|
||||
cardboy::sdk::AppTimerHandle scheduleTimer(cardboy::sdk::TimerClientId clientId, std::uint32_t delay_ms,
|
||||
bool repeat) override;
|
||||
void cancelTimer(cardboy::sdk::TimerClientId clientId, cardboy::sdk::AppTimerHandle handle) override;
|
||||
void cancelAllTimers(cardboy::sdk::TimerClientId clientId) override;
|
||||
|
||||
private:
|
||||
struct TimerRecord {
|
||||
TimerService* owner = nullptr;
|
||||
TimerHandle_t timer = nullptr;
|
||||
cardboy::sdk::TimerClientId clientId = cardboy::sdk::kInvalidTimerClient;
|
||||
cardboy::sdk::AppTimerHandle handle = cardboy::sdk::kInvalidAppTimer;
|
||||
bool repeat = false;
|
||||
bool active = true;
|
||||
};
|
||||
|
||||
class RecursiveLock {
|
||||
public:
|
||||
explicit RecursiveLock(SemaphoreHandle_t m) : mutex(m) { lock(); }
|
||||
~RecursiveLock() { unlock(); }
|
||||
|
||||
void lock() {
|
||||
if (mutex && !locked) {
|
||||
if (xSemaphoreTakeRecursive(mutex, portMAX_DELAY) == pdTRUE)
|
||||
locked = true;
|
||||
}
|
||||
}
|
||||
|
||||
void unlock() {
|
||||
if (mutex && locked) {
|
||||
xSemaphoreGiveRecursive(mutex);
|
||||
locked = false;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
SemaphoreHandle_t mutex;
|
||||
bool locked = false;
|
||||
};
|
||||
|
||||
static void timerCallback(TimerHandle_t timer);
|
||||
void handleTimer(TimerRecord* record);
|
||||
void shutdown();
|
||||
|
||||
cardboy::sdk::IEventBus& eventBus;
|
||||
SemaphoreHandle_t mutex = nullptr;
|
||||
std::list<TimerRecord> timers;
|
||||
cardboy::sdk::AppTimerHandle nextTimerHandle = 1;
|
||||
cardboy::sdk::TimerClientId nextClientId = 1;
|
||||
};
|
||||
|
||||
class EspRuntime::NotificationService final : public cardboy::sdk::INotificationCenter {
|
||||
public:
|
||||
void pushNotification(Notification notification) override {
|
||||
if (notification.timestamp == 0) {
|
||||
notification.timestamp = static_cast<std::uint64_t>(std::time(nullptr));
|
||||
}
|
||||
|
||||
capLengths(notification);
|
||||
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (notification.externalId != 0) {
|
||||
for (auto it = entries.begin(); it != entries.end();) {
|
||||
if (it->externalId == notification.externalId)
|
||||
it = entries.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
notification.id = nextId++;
|
||||
notification.unread = true;
|
||||
|
||||
entries.push_back(std::move(notification));
|
||||
if (entries.size() > kMaxEntries)
|
||||
entries.erase(entries.begin());
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::uint32_t revision() const override {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
return revisionCounter;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<Notification> recent(std::size_t limit) const override {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
std::vector<Notification> out;
|
||||
const std::size_t count = std::min<std::size_t>(limit, entries.size());
|
||||
out.reserve(count);
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
out.push_back(entries[entries.size() - 1 - i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void markAllRead() override {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
bool changed = false;
|
||||
for (auto& entry: entries) {
|
||||
if (entry.unread) {
|
||||
entry.unread = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed)
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
void clear() override {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (entries.empty())
|
||||
return;
|
||||
entries.clear();
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
void removeById(std::uint64_t id) override {
|
||||
if (id == 0)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
bool removed = false;
|
||||
for (auto it = entries.begin(); it != entries.end();) {
|
||||
if (it->id == id) {
|
||||
it = entries.erase(it);
|
||||
removed = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
if (removed)
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
void removeByExternalId(std::uint64_t externalId) override {
|
||||
if (externalId == 0)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
bool removed = false;
|
||||
for (auto it = entries.begin(); it != entries.end();) {
|
||||
if (it->externalId == externalId) {
|
||||
it = entries.erase(it);
|
||||
removed = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
if (removed)
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr std::size_t kMaxEntries = 8;
|
||||
static constexpr std::size_t kMaxTitleBytes = 96;
|
||||
static constexpr std::size_t kMaxBodyBytes = 256;
|
||||
|
||||
static void capLengths(Notification& notification) {
|
||||
if (notification.title.size() > kMaxTitleBytes)
|
||||
notification.title.resize(kMaxTitleBytes);
|
||||
if (notification.body.size() > kMaxBodyBytes)
|
||||
notification.body.resize(kMaxBodyBytes);
|
||||
}
|
||||
|
||||
mutable std::mutex mutex;
|
||||
std::vector<Notification> entries;
|
||||
std::uint64_t nextId = 1;
|
||||
std::uint32_t revisionCounter = 0;
|
||||
};
|
||||
|
||||
EspRuntime::TimerService::TimerService(cardboy::sdk::IEventBus& bus) : eventBus(bus) {
|
||||
mutex = xSemaphoreCreateRecursiveMutex();
|
||||
}
|
||||
|
||||
EspRuntime::TimerService::~TimerService() {
|
||||
shutdown();
|
||||
if (mutex)
|
||||
vSemaphoreDelete(mutex);
|
||||
}
|
||||
|
||||
cardboy::sdk::TimerClientId EspRuntime::TimerService::acquireClient() {
|
||||
if (!mutex)
|
||||
return cardboy::sdk::kInvalidTimerClient;
|
||||
RecursiveLock lock(mutex);
|
||||
cardboy::sdk::TimerClientId id = cardboy::sdk::kInvalidTimerClient;
|
||||
do {
|
||||
id = nextClientId++;
|
||||
} while (id == cardboy::sdk::kInvalidTimerClient);
|
||||
if (nextClientId == cardboy::sdk::kInvalidTimerClient)
|
||||
++nextClientId;
|
||||
return id;
|
||||
}
|
||||
|
||||
void EspRuntime::TimerService::releaseClient(cardboy::sdk::TimerClientId clientId) { cancelAllTimers(clientId); }
|
||||
|
||||
cardboy::sdk::AppTimerHandle EspRuntime::TimerService::scheduleTimer(cardboy::sdk::TimerClientId clientId,
|
||||
std::uint32_t delay_ms, bool repeat) {
|
||||
if (!mutex || clientId == cardboy::sdk::kInvalidTimerClient)
|
||||
return cardboy::sdk::kInvalidAppTimer;
|
||||
|
||||
TimerRecord* storedRecord = nullptr;
|
||||
{
|
||||
RecursiveLock lock(mutex);
|
||||
TimerRecord record{};
|
||||
record.owner = this;
|
||||
record.clientId = clientId;
|
||||
record.repeat = repeat;
|
||||
record.active = true;
|
||||
cardboy::sdk::AppTimerHandle newHandle = cardboy::sdk::kInvalidAppTimer;
|
||||
do {
|
||||
newHandle = nextTimerHandle++;
|
||||
} while (newHandle == cardboy::sdk::kInvalidAppTimer);
|
||||
if (nextTimerHandle == cardboy::sdk::kInvalidAppTimer)
|
||||
++nextTimerHandle;
|
||||
record.handle = newHandle;
|
||||
timers.emplace_back(record);
|
||||
storedRecord = &timers.back();
|
||||
}
|
||||
|
||||
const std::uint32_t effectiveDelay = std::max<std::uint32_t>(1, delay_ms);
|
||||
const TickType_t ticks = std::max<TickType_t>(pdMS_TO_TICKS(effectiveDelay), 1);
|
||||
|
||||
TimerHandle_t timerHandle =
|
||||
xTimerCreate("AppSvcTimer", ticks, repeat ? pdTRUE : pdFALSE, storedRecord, &TimerService::timerCallback);
|
||||
if (!timerHandle) {
|
||||
RecursiveLock lock(mutex);
|
||||
for (auto it = timers.begin(); it != timers.end(); ++it) {
|
||||
if (&(*it) == storedRecord) {
|
||||
timers.erase(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
|
||||
{
|
||||
RecursiveLock lock(mutex);
|
||||
storedRecord->timer = timerHandle;
|
||||
}
|
||||
|
||||
if (xTimerStart(timerHandle, 0) != pdPASS) {
|
||||
cancelTimer(clientId, storedRecord->handle);
|
||||
return cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
|
||||
return storedRecord->handle;
|
||||
}
|
||||
|
||||
void EspRuntime::TimerService::cancelTimer(cardboy::sdk::TimerClientId clientId,
|
||||
cardboy::sdk::AppTimerHandle handle) {
|
||||
if (!mutex || clientId == cardboy::sdk::kInvalidTimerClient || handle == cardboy::sdk::kInvalidAppTimer)
|
||||
return;
|
||||
|
||||
TimerHandle_t timerHandle = nullptr;
|
||||
{
|
||||
RecursiveLock lock(mutex);
|
||||
for (auto& record: timers) {
|
||||
if (record.clientId == clientId && record.handle == handle) {
|
||||
record.active = false;
|
||||
timerHandle = record.timer;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!timerHandle)
|
||||
return;
|
||||
|
||||
xTimerStop(timerHandle, portMAX_DELAY);
|
||||
xTimerDelete(timerHandle, portMAX_DELAY);
|
||||
|
||||
{
|
||||
RecursiveLock lock(mutex);
|
||||
for (auto it = timers.begin(); it != timers.end();) {
|
||||
if (it->clientId == clientId && it->handle == handle)
|
||||
it = timers.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void EspRuntime::TimerService::cancelAllTimers(cardboy::sdk::TimerClientId clientId) {
|
||||
if (!mutex || clientId == cardboy::sdk::kInvalidTimerClient)
|
||||
return;
|
||||
|
||||
std::vector<TimerHandle_t> handles;
|
||||
{
|
||||
RecursiveLock lock(mutex);
|
||||
for (auto& record: timers) {
|
||||
if (record.clientId == clientId) {
|
||||
record.active = false;
|
||||
if (record.timer)
|
||||
handles.push_back(record.timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto timerHandle: handles) {
|
||||
xTimerStop(timerHandle, portMAX_DELAY);
|
||||
xTimerDelete(timerHandle, portMAX_DELAY);
|
||||
}
|
||||
|
||||
{
|
||||
RecursiveLock lock(mutex);
|
||||
for (auto it = timers.begin(); it != timers.end();) {
|
||||
if (it->clientId == clientId)
|
||||
it = timers.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void EspRuntime::TimerService::timerCallback(TimerHandle_t timer) {
|
||||
auto* record = static_cast<TimerRecord*>(pvTimerGetTimerID(timer));
|
||||
if (!record || !record->owner)
|
||||
return;
|
||||
record->owner->handleTimer(record);
|
||||
}
|
||||
|
||||
void EspRuntime::TimerService::handleTimer(TimerRecord* record) {
|
||||
if (!record || !record->active)
|
||||
return;
|
||||
|
||||
const cardboy::sdk::TimerClientId clientId = record->clientId;
|
||||
const cardboy::sdk::AppTimerHandle handle = record->handle;
|
||||
const bool isRepeating = record->repeat;
|
||||
TimerHandle_t timerHandle = record->timer;
|
||||
|
||||
if (!isRepeating) {
|
||||
record->active = false;
|
||||
{
|
||||
RecursiveLock lock(mutex);
|
||||
for (auto it = timers.begin(); it != timers.end();) {
|
||||
if (&(*it) == record)
|
||||
it = timers.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
if (timerHandle)
|
||||
xTimerDelete(timerHandle, 0);
|
||||
}
|
||||
|
||||
cardboy::sdk::AppTimerEvent timerEvent{};
|
||||
timerEvent.clientId = clientId;
|
||||
timerEvent.handle = handle;
|
||||
|
||||
cardboy::sdk::AppEvent event{};
|
||||
event.timestamp_ms = static_cast<std::uint32_t>(esp_timer_get_time() / 1000ULL);
|
||||
event.data = timerEvent;
|
||||
eventBus.postEvent(event);
|
||||
}
|
||||
|
||||
void EspRuntime::TimerService::shutdown() {
|
||||
if (!mutex)
|
||||
return;
|
||||
|
||||
std::vector<TimerHandle_t> handles;
|
||||
{
|
||||
RecursiveLock lock(mutex);
|
||||
for (auto& record: timers) {
|
||||
record.active = false;
|
||||
if (record.timer)
|
||||
handles.push_back(record.timer);
|
||||
}
|
||||
}
|
||||
|
||||
for (auto timerHandle: handles) {
|
||||
xTimerStop(timerHandle, portMAX_DELAY);
|
||||
xTimerDelete(timerHandle, portMAX_DELAY);
|
||||
}
|
||||
|
||||
{
|
||||
RecursiveLock lock(mutex);
|
||||
timers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
EspRuntime::EspRuntime() : framebuffer(), input(), clock() {
|
||||
initializeHardware();
|
||||
|
||||
@@ -136,21 +527,29 @@ EspRuntime::EspRuntime() : framebuffer(), input(), clock() {
|
||||
highResClockService = std::make_unique<HighResClockService>();
|
||||
filesystemService = std::make_unique<FilesystemService>();
|
||||
eventBus = std::make_unique<EventBus>();
|
||||
timerService = std::make_unique<TimerService>(*eventBus);
|
||||
loopHooksService = std::make_unique<LoopHooksService>();
|
||||
notificationService = std::make_unique<NotificationService>();
|
||||
|
||||
services.buzzer = buzzerService.get();
|
||||
services.battery = batteryService.get();
|
||||
services.storage = storageService.get();
|
||||
services.random = randomService.get();
|
||||
services.highResClock = highResClockService.get();
|
||||
services.filesystem = filesystemService.get();
|
||||
services.eventBus = eventBus.get();
|
||||
services.loopHooks = loopHooksService.get();
|
||||
services.buzzer = buzzerService.get();
|
||||
services.battery = batteryService.get();
|
||||
services.storage = storageService.get();
|
||||
services.random = randomService.get();
|
||||
services.highResClock = highResClockService.get();
|
||||
services.filesystem = filesystemService.get();
|
||||
services.eventBus = eventBus.get();
|
||||
services.timer = timerService.get();
|
||||
services.loopHooks = loopHooksService.get();
|
||||
services.notifications = notificationService.get();
|
||||
|
||||
Buttons::get().setEventBus(eventBus.get());
|
||||
set_notification_center(notificationService.get());
|
||||
}
|
||||
|
||||
EspRuntime::~EspRuntime() = default;
|
||||
EspRuntime::~EspRuntime() {
|
||||
set_notification_center(nullptr);
|
||||
shutdown_time_sync_service();
|
||||
}
|
||||
|
||||
cardboy::sdk::Services& EspRuntime::serviceRegistry() { return services; }
|
||||
|
||||
@@ -180,6 +579,8 @@ void EspRuntime::initializeHardware() {
|
||||
Buzzer::get().init();
|
||||
|
||||
FsHelper::get().mount();
|
||||
|
||||
ensure_time_sync_service_started();
|
||||
}
|
||||
|
||||
void EspFramebuffer::clear_impl(bool on) {
|
||||
|
||||
@@ -14,22 +14,19 @@ namespace {
|
||||
return portMAX_DELAY;
|
||||
return pdMS_TO_TICKS(timeout_ms);
|
||||
}
|
||||
|
||||
constexpr UBaseType_t kEventQueueLength = 16;
|
||||
} // namespace
|
||||
|
||||
static void timerCallback(TimerHandle_t handle) {
|
||||
auto* bus = static_cast<EventBus*>(pvTimerGetTimerID(handle));
|
||||
if (bus)
|
||||
bus->signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Timer));
|
||||
}
|
||||
|
||||
EventBus::EventBus() :
|
||||
group(xEventGroupCreate()), timer(xTimerCreate("EventBusTimer", pdMS_TO_TICKS(1), pdFALSE, this, timerCallback)) {}
|
||||
group(xEventGroupCreate()),
|
||||
eventQueue(xQueueCreate(kEventQueueLength, sizeof(cardboy::sdk::AppEvent))) {}
|
||||
|
||||
EventBus::~EventBus() {
|
||||
if (timer)
|
||||
xTimerDelete(timer, portMAX_DELAY);
|
||||
if (group)
|
||||
vEventGroupDelete(group);
|
||||
if (eventQueue)
|
||||
vQueueDelete(eventQueue);
|
||||
}
|
||||
|
||||
void EventBus::signal(std::uint32_t bits) {
|
||||
@@ -54,28 +51,23 @@ std::uint32_t EventBus::wait(std::uint32_t mask, std::uint32_t timeout_ms) {
|
||||
return static_cast<std::uint32_t>(bits & mask);
|
||||
}
|
||||
|
||||
void EventBus::scheduleTimerSignal(std::uint32_t delay_ms) {
|
||||
if (!timer)
|
||||
return;
|
||||
xTimerStop(timer, 0);
|
||||
|
||||
if (delay_ms == cardboy::sdk::IEventBus::kWaitForever)
|
||||
void EventBus::postEvent(const cardboy::sdk::AppEvent& event) {
|
||||
if (!eventQueue)
|
||||
return;
|
||||
|
||||
if (delay_ms == 0) {
|
||||
signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Timer));
|
||||
return;
|
||||
if (xQueueSend(eventQueue, &event, 0) == errQUEUE_FULL) {
|
||||
cardboy::sdk::AppEvent discarded{};
|
||||
if (xQueueReceive(eventQueue, &discarded, 0) == pdTRUE)
|
||||
xQueueSend(eventQueue, &event, 0);
|
||||
}
|
||||
|
||||
const TickType_t ticks = std::max<TickType_t>(pdMS_TO_TICKS(delay_ms), 1);
|
||||
if (xTimerChangePeriod(timer, ticks, 0) == pdPASS)
|
||||
xTimerStart(timer, 0);
|
||||
signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Timer));
|
||||
}
|
||||
|
||||
void EventBus::cancelTimerSignal() {
|
||||
if (!timer)
|
||||
return;
|
||||
xTimerStop(timer, 0);
|
||||
bool EventBus::popEvent(cardboy::sdk::AppEvent& outEvent) {
|
||||
if (!eventQueue)
|
||||
return false;
|
||||
return xQueueReceive(eventQueue, &outEvent, 0) == pdTRUE;
|
||||
}
|
||||
|
||||
} // namespace cardboy::backend::esp
|
||||
|
||||
1627
Firmware/components/backend-esp/src/time_sync_service.cpp
Normal file
1627
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)
|
||||
|
||||
@@ -17,7 +17,9 @@ namespace apps {
|
||||
|
||||
namespace {
|
||||
|
||||
using cardboy::sdk::AppButtonEvent;
|
||||
using cardboy::sdk::AppContext;
|
||||
using cardboy::sdk::AppTimerEvent;
|
||||
|
||||
constexpr const char* kClockAppName = "Clock";
|
||||
|
||||
@@ -53,15 +55,12 @@ public:
|
||||
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)
|
||||
updateDisplay();
|
||||
break;
|
||||
}
|
||||
event.visit(cardboy::sdk::overload(
|
||||
[this](const AppButtonEvent& button) { handleButtonEvent(button); },
|
||||
[this](const AppTimerEvent& timer) {
|
||||
if (timer.handle == refreshTimer)
|
||||
updateDisplay();
|
||||
}));
|
||||
}
|
||||
|
||||
private:
|
||||
|
||||
@@ -151,9 +151,10 @@ constexpr int kMenuSpacing = font16x8::kGlyphHeight + 6;
|
||||
class GameboyApp;
|
||||
|
||||
|
||||
using cardboy::sdk::AppButtonEvent;
|
||||
using cardboy::sdk::AppContext;
|
||||
using cardboy::sdk::AppEvent;
|
||||
using cardboy::sdk::AppEventType;
|
||||
using cardboy::sdk::AppTimerEvent;
|
||||
using cardboy::sdk::AppTimerHandle;
|
||||
using cardboy::sdk::InputState;
|
||||
using cardboy::sdk::kInvalidAppTimer;
|
||||
@@ -252,20 +253,26 @@ public:
|
||||
}
|
||||
|
||||
void handleEvent(const AppEvent& event) override {
|
||||
if (event.type == AppEventType::Timer && event.timer.handle == tickTimer) {
|
||||
tickTimer = kInvalidAppTimer;
|
||||
const uint64_t frameStartUs = nowMicros();
|
||||
performStep();
|
||||
const uint64_t frameEndUs = nowMicros();
|
||||
const uint64_t elapsedUs = (frameEndUs >= frameStartUs) ? (frameEndUs - frameStartUs) : 0;
|
||||
GB_PERF_ONLY(printf("Step took %" PRIu64 " us\n", elapsedUs));
|
||||
scheduleAfterFrame(elapsedUs);
|
||||
bool handled = false;
|
||||
event.visit(cardboy::sdk::overload(
|
||||
[this, &handled](const AppTimerEvent& timer) {
|
||||
if (timer.handle != tickTimer)
|
||||
return;
|
||||
handled = true;
|
||||
tickTimer = kInvalidAppTimer;
|
||||
const uint64_t frameStartUs = nowMicros();
|
||||
performStep();
|
||||
const uint64_t frameEndUs = nowMicros();
|
||||
const uint64_t elapsedUs = (frameEndUs >= frameStartUs) ? (frameEndUs - frameStartUs) : 0;
|
||||
GB_PERF_ONLY(printf("Step took %" PRIu64 " us\n", elapsedUs));
|
||||
scheduleAfterFrame(elapsedUs);
|
||||
},
|
||||
[this](const AppButtonEvent&) {
|
||||
frameDelayCarryUs = 0;
|
||||
scheduleNextTick(0);
|
||||
}));
|
||||
if (handled)
|
||||
return;
|
||||
}
|
||||
if (event.type == AppEventType::Button) {
|
||||
frameDelayCarryUs = 0;
|
||||
scheduleNextTick(0);
|
||||
}
|
||||
}
|
||||
|
||||
void performStep() {
|
||||
@@ -299,6 +306,9 @@ public:
|
||||
break;
|
||||
}
|
||||
|
||||
if (mode != Mode::Running)
|
||||
break;
|
||||
|
||||
|
||||
GB_PERF_ONLY(perf.geometryUs = 0;)
|
||||
|
||||
@@ -306,11 +316,49 @@ public:
|
||||
gb_run_frame(&gb);
|
||||
GB_PERF_ONLY(perf.runUs = nowMicros() - runStartUs;)
|
||||
|
||||
{
|
||||
uint32_t freqHz = 0;
|
||||
uint8_t loud = 0;
|
||||
if (apu.computeEffectiveTone(freqHz, loud)) {
|
||||
// Basic smoothing: if freq didn't change much, keep it; otherwise snap quickly
|
||||
const uint32_t prev = lastFreqHz;
|
||||
if (prev != 0 && freqHz != 0) {
|
||||
const uint32_t diff = (prev > freqHz) ? (prev - freqHz) : (freqHz - prev);
|
||||
if (diff < 15) {
|
||||
freqHz = prev; // minor jitter suppression
|
||||
++stableFrames;
|
||||
} else {
|
||||
stableFrames = 0;
|
||||
}
|
||||
} else {
|
||||
stableFrames = 0;
|
||||
}
|
||||
lastFreqHz = freqHz;
|
||||
lastLoud = loud;
|
||||
const uint32_t durMs = 16;
|
||||
playTone(freqHz, durMs, 0);
|
||||
} else {
|
||||
lastFreqHz = 0;
|
||||
lastLoud = 0;
|
||||
// Don't enqueue anything; queue naturally drains and buzzer stops
|
||||
}
|
||||
}
|
||||
|
||||
GB_PERF_ONLY(const uint64_t renderStartUs = nowMicros();)
|
||||
renderGameFrame();
|
||||
GB_PERF_ONLY(perf.renderUs = nowMicros() - renderStartUs;)
|
||||
break;
|
||||
}
|
||||
case Mode::Prompt: {
|
||||
GB_PERF_ONLY(const uint64_t handleStartUs = nowMicros();)
|
||||
handlePromptInput(input);
|
||||
GB_PERF_ONLY(perf.handleUs = nowMicros() - handleStartUs;)
|
||||
|
||||
GB_PERF_ONLY(const uint64_t renderStartUs = nowMicros();)
|
||||
renderPrompt();
|
||||
GB_PERF_ONLY(perf.renderUs = nowMicros() - renderStartUs;)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
prevInput = input;
|
||||
@@ -777,8 +825,9 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
enum class Mode { Browse, Running };
|
||||
enum class Mode { Browse, Running, Prompt };
|
||||
enum class ScaleMode { Original, FullHeight, FullHeightWide };
|
||||
enum class PromptKind { None, LoadState, SaveState };
|
||||
|
||||
struct PerfTracker {
|
||||
enum class CallbackKind { RomRead, CartRamRead, CartRamWrite, LcdDraw, Error };
|
||||
@@ -905,8 +954,19 @@ public:
|
||||
|
||||
void printStep(Mode stepMode, bool gbReady, bool frameDirty, uint32_t fps, const std::string& romName,
|
||||
std::size_t romCount, std::size_t selectedIdx, bool browserDirty) const {
|
||||
auto toMs = [](uint64_t us) { return static_cast<double>(us) / 1000.0; };
|
||||
const char* modeStr = (stepMode == Mode::Running) ? "RUN" : "BROWSE";
|
||||
auto toMs = [](uint64_t us) { return static_cast<double>(us) / 1000.0; };
|
||||
const char* modeStr = "UNKNOWN";
|
||||
switch (stepMode) {
|
||||
case Mode::Running:
|
||||
modeStr = "RUN";
|
||||
break;
|
||||
case Mode::Browse:
|
||||
modeStr = "BROWSE";
|
||||
break;
|
||||
case Mode::Prompt:
|
||||
modeStr = "PROMPT";
|
||||
break;
|
||||
}
|
||||
const char* name = romName.empty() ? "-" : romName.c_str();
|
||||
const std::size_t safeIdx = (romCount == 0) ? 0 : std::min(selectedIdx, romCount - 1);
|
||||
std::printf(
|
||||
@@ -1085,6 +1145,10 @@ public:
|
||||
uint32_t fpsCurrent = 0;
|
||||
std::string activeRomName;
|
||||
std::string activeRomSavePath;
|
||||
std::string activeRomStatePath;
|
||||
PromptKind promptKind = PromptKind::None;
|
||||
int promptSelection = 0; // 0 = Yes, 1 = No
|
||||
bool promptDirty = false;
|
||||
SimpleApu apu{};
|
||||
// Smoothing state for buzzer tone
|
||||
uint32_t lastFreqHz = 0;
|
||||
@@ -1423,21 +1487,25 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
gb.direct.priv = this;
|
||||
gb.direct.joypad = 0xFF;
|
||||
|
||||
gb_init_lcd(&gb, &GameboyApp::lcdDrawLine);
|
||||
applyRuntimeBindings();
|
||||
|
||||
gb.direct.joypad = 0xFF;
|
||||
gb.direct.interlace = false;
|
||||
gb.direct.frame_skip = true;
|
||||
|
||||
const uint_fast32_t saveSize = gb_get_save_size(&gb);
|
||||
cartRam.assign(static_cast<std::size_t>(saveSize), 0);
|
||||
std::string savePath;
|
||||
std::string statePath;
|
||||
const bool fsReady = (filesystem && filesystem->isMounted()) || ensureFilesystemReady();
|
||||
if (fsReady)
|
||||
savePath = buildSavePath(rom, romDirectory());
|
||||
activeRomSavePath = savePath;
|
||||
if (fsReady) {
|
||||
const std::string romDir = romDirectory();
|
||||
savePath = buildSavePath(rom, romDir);
|
||||
statePath = buildStatePath(rom, romDir);
|
||||
}
|
||||
activeRomSavePath = savePath;
|
||||
activeRomStatePath = statePath;
|
||||
loadSaveFile();
|
||||
|
||||
resetFpsStats();
|
||||
@@ -1452,6 +1520,10 @@ public:
|
||||
if (!fsReady)
|
||||
statusText.append(" (no save)");
|
||||
setStatus(std::move(statusText));
|
||||
|
||||
if (stateFileExists())
|
||||
enterPrompt(PromptKind::LoadState);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1464,6 +1536,10 @@ public:
|
||||
cartRam.clear();
|
||||
activeRomName.clear();
|
||||
activeRomSavePath.clear();
|
||||
activeRomStatePath.clear();
|
||||
promptKind = PromptKind::None;
|
||||
promptSelection = 0;
|
||||
promptDirty = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1477,6 +1553,10 @@ public:
|
||||
cartRam.clear();
|
||||
activeRomName.clear();
|
||||
activeRomSavePath.clear();
|
||||
activeRomStatePath.clear();
|
||||
promptKind = PromptKind::None;
|
||||
promptSelection = 0;
|
||||
promptDirty = false;
|
||||
std::memset(&gb, 0, sizeof(gb));
|
||||
apu.reset();
|
||||
mode = Mode::Browse;
|
||||
@@ -1491,6 +1571,19 @@ public:
|
||||
if (scaleToggleCombo)
|
||||
toggleScaleMode();
|
||||
|
||||
const bool exitComboPressed = input.start && input.select;
|
||||
const bool exitComboJustPressed = exitComboPressed && !(prevInput.start && prevInput.select);
|
||||
if (exitComboJustPressed) {
|
||||
if (!activeRomStatePath.empty()) {
|
||||
enterPrompt(PromptKind::SaveState);
|
||||
} else {
|
||||
unloadRom();
|
||||
setStatus("Save state unavailable");
|
||||
}
|
||||
gb.direct.joypad = 0xFF;
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t joypad = 0xFF;
|
||||
if (input.a)
|
||||
joypad &= ~JOYPAD_A;
|
||||
@@ -1510,13 +1603,6 @@ public:
|
||||
joypad &= ~JOYPAD_RIGHT;
|
||||
|
||||
gb.direct.joypad = joypad;
|
||||
|
||||
const bool exitComboPressed = input.start && input.select;
|
||||
const bool exitComboJustPressed = exitComboPressed && !(prevInput.start && prevInput.select);
|
||||
if (exitComboJustPressed) {
|
||||
setStatus("Saved " + activeRomName);
|
||||
unloadRom();
|
||||
}
|
||||
}
|
||||
|
||||
void renderGameFrame() {
|
||||
@@ -1688,6 +1774,186 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
void applyRuntimeBindings() {
|
||||
gb.gb_cart_ram_read = &GameboyApp::cartRamRead;
|
||||
gb.gb_cart_ram_write = &GameboyApp::cartRamWrite;
|
||||
gb.gb_error = &GameboyApp::errorCallback;
|
||||
gb.display.lcd_draw_line = &GameboyApp::lcdDrawLine;
|
||||
gb.direct.priv = this;
|
||||
if (romDataView)
|
||||
gb.direct.rom = romDataView;
|
||||
}
|
||||
|
||||
bool saveStateToFile() {
|
||||
if (activeRomStatePath.empty())
|
||||
return false;
|
||||
|
||||
FILE* file = std::fopen(activeRomStatePath.c_str(), "wb");
|
||||
if (!file)
|
||||
return false;
|
||||
|
||||
const size_t written = std::fwrite(&gb, 1, sizeof(gb), file);
|
||||
std::fclose(file);
|
||||
return written == sizeof(gb);
|
||||
}
|
||||
|
||||
bool loadStateFromFile() {
|
||||
if (activeRomStatePath.empty())
|
||||
return false;
|
||||
|
||||
FILE* file = std::fopen(activeRomStatePath.c_str(), "rb");
|
||||
if (!file)
|
||||
return false;
|
||||
|
||||
std::vector<uint8_t> backup(sizeof(gb));
|
||||
std::memcpy(backup.data(), &gb, sizeof(gb));
|
||||
|
||||
const size_t read = std::fread(&gb, 1, sizeof(gb), file);
|
||||
std::fclose(file);
|
||||
if (read != sizeof(gb)) {
|
||||
std::memcpy(&gb, backup.data(), sizeof(gb));
|
||||
return false;
|
||||
}
|
||||
|
||||
applyRuntimeBindings();
|
||||
gb.direct.joypad = 0xFF;
|
||||
frameDirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool stateFileExists() const {
|
||||
if (activeRomStatePath.empty())
|
||||
return false;
|
||||
struct stat st{};
|
||||
return stat(activeRomStatePath.c_str(), &st) == 0 && S_ISREG(st.st_mode);
|
||||
}
|
||||
|
||||
void enterPrompt(PromptKind kind) {
|
||||
if (kind == PromptKind::None)
|
||||
return;
|
||||
promptKind = kind;
|
||||
promptSelection = 0;
|
||||
promptDirty = true;
|
||||
mode = Mode::Prompt;
|
||||
gb.direct.joypad = 0xFF;
|
||||
scheduleNextTick(0);
|
||||
}
|
||||
|
||||
void exitPrompt(Mode nextMode) {
|
||||
promptKind = PromptKind::None;
|
||||
promptSelection = 0;
|
||||
promptDirty = true;
|
||||
mode = nextMode;
|
||||
}
|
||||
|
||||
void handlePromptInput(const InputState& input) {
|
||||
if (promptKind == PromptKind::None)
|
||||
return;
|
||||
|
||||
const bool leftOrUp = (input.left && !prevInput.left) || (input.up && !prevInput.up);
|
||||
const bool rightOrDown = (input.right && !prevInput.right) || (input.down && !prevInput.down);
|
||||
if (leftOrUp && promptSelection != 0) {
|
||||
promptSelection = 0;
|
||||
promptDirty = true;
|
||||
} else if (rightOrDown && promptSelection != 1) {
|
||||
promptSelection = 1;
|
||||
promptDirty = true;
|
||||
}
|
||||
|
||||
const bool confirm = (input.a && !prevInput.a) || (input.start && !prevInput.start);
|
||||
const bool cancel = (input.b && !prevInput.b) || (input.select && !prevInput.select);
|
||||
|
||||
if (confirm) {
|
||||
handlePromptDecision(promptSelection == 0);
|
||||
} else if (cancel) {
|
||||
handlePromptDecision(false);
|
||||
}
|
||||
}
|
||||
|
||||
void handlePromptDecision(bool yesSelected) {
|
||||
const PromptKind kind = promptKind;
|
||||
if (kind == PromptKind::None)
|
||||
return;
|
||||
|
||||
if (kind == PromptKind::LoadState) {
|
||||
exitPrompt(Mode::Running);
|
||||
if (yesSelected) {
|
||||
if (loadStateFromFile())
|
||||
setStatus("Save state loaded");
|
||||
else
|
||||
setStatus("Load state failed");
|
||||
}
|
||||
frameDirty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind == PromptKind::SaveState) {
|
||||
const bool haveStatePath = !activeRomStatePath.empty();
|
||||
bool saved = false;
|
||||
if (yesSelected && haveStatePath)
|
||||
saved = saveStateToFile();
|
||||
|
||||
exitPrompt(Mode::Running);
|
||||
unloadRom();
|
||||
|
||||
if (yesSelected) {
|
||||
if (!haveStatePath)
|
||||
setStatus("Save state unavailable");
|
||||
else if (saved)
|
||||
setStatus("Save state written");
|
||||
else
|
||||
setStatus("Save state failed");
|
||||
} else {
|
||||
setStatus("Exited without state");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void renderPrompt() {
|
||||
if (!promptDirty || promptKind == PromptKind::None)
|
||||
return;
|
||||
promptDirty = false;
|
||||
|
||||
framebuffer.frameReady();
|
||||
framebuffer.clear(false);
|
||||
|
||||
auto drawCentered = [&](int y, std::string_view text, int scale = 1) {
|
||||
const int width = font16x8::measureText(text, scale, 1);
|
||||
const int x = (framebuffer.width() - width) / 2;
|
||||
font16x8::drawText(framebuffer, x, y, text, scale, true, 1);
|
||||
};
|
||||
|
||||
std::string_view headline;
|
||||
std::string_view helper;
|
||||
|
||||
switch (promptKind) {
|
||||
case PromptKind::LoadState:
|
||||
headline = "LOAD SAVE STATE?";
|
||||
helper = "A YES / B NO";
|
||||
break;
|
||||
case PromptKind::SaveState:
|
||||
headline = "SAVE BEFORE EXIT?";
|
||||
helper = "A YES / B NO";
|
||||
break;
|
||||
case PromptKind::None:
|
||||
default:
|
||||
headline = "";
|
||||
helper = "";
|
||||
break;
|
||||
}
|
||||
|
||||
drawCentered(40, headline);
|
||||
drawCentered(72, helper);
|
||||
|
||||
const std::string yesLabel = (promptSelection == 0) ? "> YES" : " YES";
|
||||
const std::string noLabel = (promptSelection == 1) ? "> NO" : " NO";
|
||||
|
||||
drawCentered(104, yesLabel);
|
||||
drawCentered(120, noLabel);
|
||||
|
||||
framebuffer.sendFrame();
|
||||
}
|
||||
|
||||
void playTone(uint32_t freqHz, uint32_t durationMs, uint32_t gapMs) {
|
||||
if (freqHz == 0 || durationMs == 0)
|
||||
return;
|
||||
@@ -1739,6 +2005,21 @@ public:
|
||||
return result;
|
||||
}
|
||||
|
||||
static std::string buildStatePath(const RomEntry& rom, std::string_view romDir) {
|
||||
std::string slug = rom.saveSlug;
|
||||
if (slug.empty())
|
||||
slug = sanitizeSaveSlug(rom.name);
|
||||
if (slug.empty())
|
||||
slug = "rom";
|
||||
|
||||
std::string result(romDir);
|
||||
if (!result.empty() && result.back() != '/')
|
||||
result.push_back('/');
|
||||
result.append(slug);
|
||||
result.append(".state");
|
||||
return result;
|
||||
}
|
||||
|
||||
static GameboyApp* fromGb(struct gb_s* gb) {
|
||||
CARDBOY_CHECK_CODE(if (!gb) return nullptr;);
|
||||
return static_cast<GameboyApp*>(gb->direct.priv);
|
||||
@@ -1926,35 +2207,6 @@ private:
|
||||
drawLineOriginal(*self, pixels, static_cast<int>(line));
|
||||
break;
|
||||
}
|
||||
|
||||
// Simple per-scanline hook: at end of last line, decide tone for the frame.
|
||||
if (line + 1 == LCD_HEIGHT) {
|
||||
uint32_t freqHz = 0;
|
||||
uint8_t loud = 0;
|
||||
if (self->apu.computeEffectiveTone(freqHz, loud)) {
|
||||
// Basic smoothing: if freq didn't change much, keep it; otherwise snap quickly
|
||||
const uint32_t prev = self->lastFreqHz;
|
||||
if (prev != 0 && freqHz != 0) {
|
||||
const uint32_t diff = (prev > freqHz) ? (prev - freqHz) : (freqHz - prev);
|
||||
if (diff < 15) {
|
||||
freqHz = prev; // minor jitter suppression
|
||||
++self->stableFrames;
|
||||
} else {
|
||||
self->stableFrames = 0;
|
||||
}
|
||||
} else {
|
||||
self->stableFrames = 0;
|
||||
}
|
||||
self->lastFreqHz = freqHz;
|
||||
self->lastLoud = loud;
|
||||
const uint32_t durMs = 17;
|
||||
self->playTone(freqHz, durMs, 0);
|
||||
} else {
|
||||
self->lastFreqHz = 0;
|
||||
self->lastLoud = 0;
|
||||
// Don't enqueue anything; queue naturally drains and buzzer stops
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static const char* initErrorToString(enum gb_init_error_e err) {
|
||||
|
||||
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
|
||||
|
||||
596
Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp
Normal file
596
Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp
Normal file
@@ -0,0 +1,596 @@
|
||||
#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 <array>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace apps {
|
||||
|
||||
namespace {
|
||||
|
||||
using cardboy::sdk::AppButtonEvent;
|
||||
using cardboy::sdk::AppContext;
|
||||
using cardboy::sdk::AppTimerEvent;
|
||||
|
||||
constexpr std::uint32_t kRefreshIntervalMs = 100;
|
||||
constexpr std::uint32_t kUnlockHoldMs = 1500;
|
||||
|
||||
using Framebuffer = typename AppContext::Framebuffer;
|
||||
using Clock = typename AppContext::Clock;
|
||||
|
||||
constexpr std::array<std::uint8_t, font16x8::kGlyphHeight> kArrowUpGlyph{0b00011000, 0b00111100, 0b01111110,
|
||||
0b11111111, 0b00011000, 0b00011000,
|
||||
0b00011000, 0b00011000, 0b00011000,
|
||||
0b00011000, 0b00011000, 0b00011000,
|
||||
0b00011000, 0b00011000, 0b00000000,
|
||||
0b00000000};
|
||||
|
||||
constexpr std::array<std::uint8_t, font16x8::kGlyphHeight> kArrowDownGlyph{0b00000000, 0b00000000, 0b00011000,
|
||||
0b00011000, 0b00011000, 0b00011000,
|
||||
0b00011000, 0b00011000, 0b00011000,
|
||||
0b00011000, 0b00011000, 0b11111111,
|
||||
0b01111110, 0b00111100, 0b00011000,
|
||||
0b00000000};
|
||||
|
||||
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), notificationCenter(ctx.notificationCenter()) {}
|
||||
|
||||
void onStart() override {
|
||||
cancelRefreshTimer();
|
||||
lastSnapshot = {};
|
||||
holdActive = false;
|
||||
holdProgressMs = 0;
|
||||
dirty = true;
|
||||
lastNotificationInteractionMs = clock.millis();
|
||||
refreshNotifications();
|
||||
const auto snap = captureTime();
|
||||
renderIfNeeded(snap);
|
||||
lastSnapshot = snap;
|
||||
refreshTimer = context.scheduleRepeatingTimer(kRefreshIntervalMs);
|
||||
}
|
||||
|
||||
void onStop() override { cancelRefreshTimer(); }
|
||||
|
||||
void handleEvent(const cardboy::sdk::AppEvent& event) override {
|
||||
event.visit(cardboy::sdk::overload(
|
||||
[this](const AppButtonEvent& button) { handleButtonEvent(button); },
|
||||
[this](const AppTimerEvent& timer) {
|
||||
if (timer.handle == refreshTimer) {
|
||||
advanceHoldProgress();
|
||||
updateDisplay();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr std::size_t kMaxDisplayedNotifications = 5;
|
||||
static constexpr std::uint32_t kNotificationHideMs = 8000;
|
||||
AppContext& context;
|
||||
Framebuffer& framebuffer;
|
||||
Clock& clock;
|
||||
cardboy::sdk::INotificationCenter* notificationCenter = nullptr;
|
||||
std::uint32_t lastNotificationRevision = 0;
|
||||
std::vector<cardboy::sdk::INotificationCenter::Notification> notifications;
|
||||
std::size_t selectedNotification = 0;
|
||||
|
||||
bool dirty = false;
|
||||
cardboy::sdk::AppTimerHandle refreshTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
TimeSnapshot lastSnapshot{};
|
||||
bool holdActive = false;
|
||||
std::uint32_t holdProgressMs = 0;
|
||||
std::uint32_t lastNotificationInteractionMs = 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 upPressed = button.current.up && !button.previous.up;
|
||||
const bool downPressed = button.current.down && !button.previous.down;
|
||||
bool navPressed = false;
|
||||
|
||||
if (!notifications.empty() && (upPressed || downPressed)) {
|
||||
const std::size_t count = notifications.size();
|
||||
lastNotificationInteractionMs = clock.millis();
|
||||
navPressed = true;
|
||||
if (count > 1) {
|
||||
if (upPressed)
|
||||
selectedNotification = (selectedNotification + count - 1) % count;
|
||||
else if (downPressed)
|
||||
selectedNotification = (selectedNotification + 1) % count;
|
||||
}
|
||||
}
|
||||
|
||||
const bool deletePressed = button.current.b && !button.previous.b;
|
||||
if (deletePressed && notificationCenter && !notifications.empty()) {
|
||||
const std::size_t index = std::min<std::size_t>(selectedNotification, notifications.size() - 1);
|
||||
const auto& note = notifications[index];
|
||||
std::size_t preferredIndex = index;
|
||||
if (index + 1 < notifications.size())
|
||||
preferredIndex = index + 1;
|
||||
else if (index > 0)
|
||||
preferredIndex = index - 1;
|
||||
if (note.externalId != 0)
|
||||
notificationCenter->removeByExternalId(note.externalId);
|
||||
else
|
||||
notificationCenter->removeById(note.id);
|
||||
selectedNotification = preferredIndex;
|
||||
lastNotificationInteractionMs = clock.millis();
|
||||
dirty = true;
|
||||
refreshNotifications();
|
||||
}
|
||||
|
||||
const bool comboNow = comboPressed(button.current);
|
||||
updateHoldState(comboNow);
|
||||
if (navPressed)
|
||||
dirty = true;
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
void refreshNotifications() {
|
||||
if (!notificationCenter) {
|
||||
if (!notifications.empty() || lastNotificationRevision != 0) {
|
||||
notifications.clear();
|
||||
selectedNotification = 0;
|
||||
lastNotificationRevision = 0;
|
||||
dirty = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const std::uint32_t revision = notificationCenter->revision();
|
||||
if (revision == lastNotificationRevision)
|
||||
return;
|
||||
lastNotificationRevision = revision;
|
||||
|
||||
const std::uint64_t previousId =
|
||||
(selectedNotification < notifications.size()) ? notifications[selectedNotification].id : 0;
|
||||
|
||||
auto latest = notificationCenter->recent(kMaxDisplayedNotifications);
|
||||
notifications = std::move(latest);
|
||||
|
||||
if (notifications.empty()) {
|
||||
selectedNotification = 0;
|
||||
} else if (previousId != 0) {
|
||||
auto it = std::find_if(notifications.begin(), notifications.end(),
|
||||
[previousId](const auto& note) { return note.id == previousId; });
|
||||
if (it != notifications.end()) {
|
||||
selectedNotification = static_cast<std::size_t>(std::distance(notifications.begin(), it));
|
||||
} else {
|
||||
selectedNotification = 0;
|
||||
}
|
||||
} else {
|
||||
selectedNotification = 0;
|
||||
}
|
||||
|
||||
lastNotificationInteractionMs = clock.millis();
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
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() {
|
||||
refreshNotifications();
|
||||
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 void drawArrowGlyph(Framebuffer& fb, int x, int y,
|
||||
const std::array<std::uint8_t, font16x8::kGlyphHeight>& glyph, int scale = 1) {
|
||||
if (scale <= 0)
|
||||
return;
|
||||
for (int row = 0; row < font16x8::kGlyphHeight; ++row) {
|
||||
const std::uint8_t rowBits = glyph[row];
|
||||
for (int col = 0; col < font16x8::kGlyphWidth; ++col) {
|
||||
const auto mask = static_cast<std::uint8_t>(1u << (font16x8::kGlyphWidth - 1 - col));
|
||||
if ((rowBits & mask) == 0)
|
||||
continue;
|
||||
for (int sx = 0; sx < scale; ++sx) {
|
||||
for (int sy = 0; sy < scale; ++sy) {
|
||||
fb.drawPixel(x + col * scale + sx, y + row * scale + sy, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void drawArrow(Framebuffer& fb, int x, int y, bool up, int scale = 1) {
|
||||
const auto& glyph = up ? kArrowUpGlyph : kArrowDownGlyph;
|
||||
drawArrowGlyph(fb, x, y, glyph, scale);
|
||||
}
|
||||
|
||||
static std::string truncateWithEllipsis(std::string_view text, int maxWidth, int scale, int letterSpacing) {
|
||||
if (font16x8::measureText(text, scale, letterSpacing) <= maxWidth)
|
||||
return std::string(text);
|
||||
|
||||
std::string result(text.begin(), text.end());
|
||||
const std::string ellipsis = "...";
|
||||
while (!result.empty()) {
|
||||
result.pop_back();
|
||||
std::string candidate = result + ellipsis;
|
||||
if (font16x8::measureText(candidate, scale, letterSpacing) <= maxWidth)
|
||||
return candidate;
|
||||
}
|
||||
return ellipsis;
|
||||
}
|
||||
|
||||
static std::vector<std::string> wrapText(std::string_view text, int maxWidth, int scale, int letterSpacing,
|
||||
int maxLines) {
|
||||
std::vector<std::string> lines;
|
||||
if (text.empty() || maxWidth <= 0 || maxLines <= 0)
|
||||
return lines;
|
||||
|
||||
std::string current;
|
||||
std::string word;
|
||||
bool truncated = false;
|
||||
|
||||
auto flushCurrent = [&]() {
|
||||
if (current.empty())
|
||||
return;
|
||||
if (lines.size() < static_cast<std::size_t>(maxLines)) {
|
||||
lines.push_back(current);
|
||||
} else {
|
||||
truncated = true;
|
||||
}
|
||||
current.clear();
|
||||
};
|
||||
|
||||
for (std::size_t i = 0; i <= text.size(); ++i) {
|
||||
char ch = (i < text.size()) ? text[i] : ' ';
|
||||
const bool isBreak = (ch == ' ' || ch == '\n' || ch == '\r' || i == text.size());
|
||||
if (!isBreak) {
|
||||
word.push_back(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!word.empty()) {
|
||||
std::string candidate = current.empty() ? word : current + " " + word;
|
||||
if (!current.empty() &&
|
||||
font16x8::measureText(candidate, scale, letterSpacing) > maxWidth) {
|
||||
flushCurrent();
|
||||
if (lines.size() >= static_cast<std::size_t>(maxLines)) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
candidate = word;
|
||||
}
|
||||
|
||||
if (font16x8::measureText(candidate, scale, letterSpacing) > maxWidth) {
|
||||
std::string shortened = truncateWithEllipsis(word, maxWidth, scale, letterSpacing);
|
||||
flushCurrent();
|
||||
if (lines.size() < static_cast<std::size_t>(maxLines)) {
|
||||
lines.push_back(shortened);
|
||||
} else {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
current.clear();
|
||||
} else {
|
||||
current = candidate;
|
||||
}
|
||||
word.clear();
|
||||
}
|
||||
|
||||
if (ch == '\n' || ch == '\r') {
|
||||
flushCurrent();
|
||||
if (lines.size() >= static_cast<std::size_t>(maxLines)) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushCurrent();
|
||||
if (lines.size() > static_cast<std::size_t>(maxLines)) {
|
||||
truncated = true;
|
||||
lines.resize(maxLines);
|
||||
}
|
||||
if (truncated && !lines.empty()) {
|
||||
lines.back() = truncateWithEllipsis(lines.back(), maxWidth, scale, letterSpacing);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
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;
|
||||
const int textLineHeight = font16x8::kGlyphHeight * scaleSmall;
|
||||
|
||||
const int cardMarginTop = 4;
|
||||
const int cardMarginSide = 8;
|
||||
const int cardPadding = 6;
|
||||
const int cardLineSpacing = 4;
|
||||
int cardHeight = 0;
|
||||
const int cardWidth = framebuffer.width() - cardMarginSide * 2;
|
||||
|
||||
const std::uint32_t nowMs = clock.millis();
|
||||
const bool hasNotifications = !notifications.empty();
|
||||
const bool showNotificationDetails =
|
||||
hasNotifications && (nowMs - lastNotificationInteractionMs <= kNotificationHideMs);
|
||||
|
||||
if (hasNotifications) {
|
||||
const auto& note = notifications[selectedNotification];
|
||||
if (showNotificationDetails) {
|
||||
std::string title = note.title.empty() ? std::string("Notification") : note.title;
|
||||
title = truncateWithEllipsis(title, cardWidth - cardPadding * 2, scaleSmall, 1);
|
||||
|
||||
auto bodyLines = wrapText(note.body, cardWidth - cardPadding * 2, scaleSmall, 1, 4);
|
||||
|
||||
cardHeight = cardPadding * 2 + textLineHeight;
|
||||
if (!bodyLines.empty()) {
|
||||
cardHeight += cardLineSpacing;
|
||||
cardHeight += static_cast<int>(bodyLines.size()) * textLineHeight;
|
||||
if (bodyLines.size() > 1)
|
||||
cardHeight += (static_cast<int>(bodyLines.size()) - 1) * cardLineSpacing;
|
||||
}
|
||||
|
||||
if (notifications.size() > 1) {
|
||||
cardHeight = std::max(cardHeight, cardPadding * 2 + textLineHeight * 2 + cardLineSpacing + 8);
|
||||
}
|
||||
|
||||
drawRectOutline(framebuffer, cardMarginSide, cardMarginTop, cardWidth, cardHeight);
|
||||
if (cardWidth > 2 && cardHeight > 2)
|
||||
drawRectOutline(framebuffer, cardMarginSide + 1, cardMarginTop + 1, cardWidth - 2, cardHeight - 2);
|
||||
|
||||
font16x8::drawText(framebuffer, cardMarginSide + cardPadding, cardMarginTop + cardPadding, title,
|
||||
scaleSmall, true, 1);
|
||||
|
||||
if (notifications.size() > 1) {
|
||||
char counter[16];
|
||||
std::snprintf(counter, sizeof(counter), "%zu/%zu", selectedNotification + 1, notifications.size());
|
||||
const int counterWidth = font16x8::measureText(counter, scaleSmall, 1);
|
||||
const int counterX = cardMarginSide + cardWidth - cardPadding - counterWidth;
|
||||
font16x8::drawText(framebuffer, counterX, cardMarginTop + cardPadding, counter, scaleSmall, true, 1);
|
||||
const int arrowWidth = font16x8::kGlyphWidth * scaleSmall;
|
||||
const int arrowSpacing = std::max(1, scaleSmall);
|
||||
const int arrowsTotalWide = arrowWidth * 2 + arrowSpacing;
|
||||
const int arrowX = counterX + (counterWidth - arrowsTotalWide) / 2;
|
||||
const int arrowY = cardMarginTop + cardPadding + textLineHeight + 1;
|
||||
drawArrow(framebuffer, arrowX, arrowY, true, scaleSmall);
|
||||
drawArrow(framebuffer, arrowX + arrowWidth + arrowSpacing, arrowY, false, scaleSmall);
|
||||
const int arrowHeight = font16x8::kGlyphHeight * scaleSmall;
|
||||
cardHeight = std::max(cardHeight, arrowY + arrowHeight - cardMarginTop);
|
||||
}
|
||||
|
||||
if (!bodyLines.empty()) {
|
||||
int bodyY = cardMarginTop + cardPadding + textLineHeight + cardLineSpacing;
|
||||
for (const auto& line : bodyLines) {
|
||||
font16x8::drawText(framebuffer, cardMarginSide + cardPadding, bodyY, line, scaleSmall, true, 1);
|
||||
bodyY += textLineHeight + cardLineSpacing;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cardHeight = textLineHeight + cardPadding * 2;
|
||||
char summary[32];
|
||||
if (notifications.size() == 1)
|
||||
std::snprintf(summary, sizeof(summary), "1 NOTIFICATION");
|
||||
else
|
||||
std::snprintf(summary, sizeof(summary), "%zu NOTIFICATIONS", notifications.size());
|
||||
const int summaryWidth = font16x8::measureText(summary, scaleSmall, 1);
|
||||
const int summaryX = (framebuffer.width() - summaryWidth) / 2;
|
||||
const int summaryY = cardMarginTop;
|
||||
font16x8::drawText(framebuffer, summaryX, summaryY, summary, scaleSmall, true, 1);
|
||||
|
||||
if (notifications.size() > 1) {
|
||||
const int arrowWidth = font16x8::kGlyphWidth * scaleSmall;
|
||||
const int arrowSpacing = std::max(1, scaleSmall);
|
||||
const int arrowsTotalWide = arrowWidth * 2 + arrowSpacing;
|
||||
const int arrowX = (framebuffer.width() - arrowsTotalWide) / 2;
|
||||
const int arrowY = summaryY + textLineHeight + 1;
|
||||
drawArrow(framebuffer, arrowX, arrowY, true, scaleSmall);
|
||||
drawArrow(framebuffer, arrowX + arrowWidth + arrowSpacing, arrowY, false, scaleSmall);
|
||||
const int arrowHeight = font16x8::kGlyphHeight * scaleSmall;
|
||||
cardHeight = std::max(cardHeight, arrowY + arrowHeight - cardMarginTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const int defaultTimeY = (framebuffer.height() - font16x8::kGlyphHeight * scaleTime) / 2 - 8;
|
||||
int timeY = defaultTimeY;
|
||||
if (cardHeight > 0)
|
||||
timeY = cardMarginTop + cardHeight + 16;
|
||||
const int minTimeY = (cardHeight > 0) ? (cardMarginTop + cardHeight + 12) : 16;
|
||||
const int maxTimeY =
|
||||
std::max(minTimeY, framebuffer.height() - font16x8::kGlyphHeight * scaleTime - 48);
|
||||
timeY = std::clamp(timeY, minTimeY, maxTimeY);
|
||||
|
||||
char hoursMinutes[6];
|
||||
std::snprintf(hoursMinutes, sizeof(hoursMinutes), "%02d:%02d", snap.hour24, snap.minute);
|
||||
const int mainW = font16x8::measureText(hoursMinutes, scaleTime, 0);
|
||||
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 + 16, dateLine, scaleSmall, 1);
|
||||
|
||||
const std::string instruction = holdActive ? "KEEP HOLDING A+SELECT" : "HOLD A+SELECT";
|
||||
const int instructionWidth = font16x8::measureText(instruction, scaleSmall, 1);
|
||||
const int barHeight = 14;
|
||||
const int barY = framebuffer.height() - 24;
|
||||
const int textY = barY + (barHeight - textLineHeight) / 2;
|
||||
const int textX = 8;
|
||||
font16x8::drawText(framebuffer, textX, textY, instruction, scaleSmall, true, 1);
|
||||
|
||||
int barX = textX + instructionWidth + 12;
|
||||
int barWidth = framebuffer.width() - barX - 8;
|
||||
if (barWidth < 40) {
|
||||
barWidth = 40;
|
||||
barX = std::min(barX, framebuffer.width() - barWidth - 8);
|
||||
}
|
||||
|
||||
drawRectOutline(framebuffer, barX, barY, barWidth, barHeight);
|
||||
|
||||
if (holdActive || holdProgressMs > 0) {
|
||||
const int innerWidth = barWidth - 2;
|
||||
const int innerHeight = barHeight - 2;
|
||||
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>
|
||||
@@ -15,10 +17,15 @@ namespace apps {
|
||||
|
||||
namespace {
|
||||
|
||||
using cardboy::sdk::AppButtonEvent;
|
||||
using cardboy::sdk::AppContext;
|
||||
using cardboy::sdk::AppEvent;
|
||||
using cardboy::sdk::AppTimerEvent;
|
||||
|
||||
using Framebuffer = typename AppContext::Framebuffer;
|
||||
|
||||
constexpr std::uint32_t kIdleTimeoutMs = 15000;
|
||||
|
||||
struct MenuEntry {
|
||||
std::string name;
|
||||
std::size_t index = 0;
|
||||
@@ -31,15 +38,43 @@ 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 {
|
||||
event.visit(cardboy::sdk::overload(
|
||||
[this](const AppButtonEvent& button) { handleButtonEvent(button); },
|
||||
[this](const AppTimerEvent& timer) {
|
||||
if (timer.handle == inactivityTimer) {
|
||||
cancelInactivityTimer();
|
||||
context.requestAppSwitchByName(kLockscreenAppName);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -39,11 +39,12 @@ public:
|
||||
}
|
||||
|
||||
void handleEvent(const cardboy::sdk::AppEvent& event) override {
|
||||
if (event.type != cardboy::sdk::AppEventType::Button)
|
||||
const auto* buttonEvent = event.button();
|
||||
if (!buttonEvent)
|
||||
return;
|
||||
|
||||
const auto& current = event.button.current;
|
||||
const auto& previous = event.button.previous;
|
||||
const auto& current = buttonEvent->current;
|
||||
const auto& previous = buttonEvent->previous;
|
||||
|
||||
const bool previousAvailable = buzzerAvailable;
|
||||
syncBuzzerState();
|
||||
|
||||
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
|
||||
427
Firmware/sdk/apps/snake/src/snake_app.cpp
Normal file
427
Firmware/sdk/apps/snake/src/snake_app.cpp
Normal file
@@ -0,0 +1,427 @@
|
||||
#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::AppTimerEvent;
|
||||
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) {
|
||||
event.visit(cardboy::sdk::overload(
|
||||
[this](const AppButtonEvent& button) { handleButtons(button); },
|
||||
[this](const AppTimerEvent& timer) { handleTimer(timer.handle); }));
|
||||
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
|
||||
@@ -22,7 +22,7 @@ namespace {
|
||||
using cardboy::sdk::AppButtonEvent;
|
||||
using cardboy::sdk::AppContext;
|
||||
using cardboy::sdk::AppEvent;
|
||||
using cardboy::sdk::AppEventType;
|
||||
using cardboy::sdk::AppTimerEvent;
|
||||
using cardboy::sdk::AppTimerHandle;
|
||||
using cardboy::sdk::InputState;
|
||||
|
||||
@@ -149,14 +149,9 @@ public:
|
||||
void onStop() { cancelTimers(); }
|
||||
|
||||
void handleEvent(const AppEvent& event) {
|
||||
switch (event.type) {
|
||||
case AppEventType::Button:
|
||||
handleButtons(event.button);
|
||||
break;
|
||||
case AppEventType::Timer:
|
||||
handleTimer(event.timer.handle);
|
||||
break;
|
||||
}
|
||||
event.visit(cardboy::sdk::overload(
|
||||
[this](const AppButtonEvent& button) { handleButtons(button); },
|
||||
[this](const AppTimerEvent& timer) { handleTimer(timer.handle); }));
|
||||
renderIfNeeded();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/sdk/input_state.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
|
||||
namespace cardboy::sdk {
|
||||
|
||||
using AppTimerHandle = std::uint32_t;
|
||||
constexpr AppTimerHandle kInvalidAppTimer = 0;
|
||||
|
||||
using TimerClientId = std::uint32_t;
|
||||
constexpr TimerClientId kInvalidTimerClient = 0;
|
||||
|
||||
struct AppButtonEvent {
|
||||
InputState current{};
|
||||
InputState previous{};
|
||||
};
|
||||
|
||||
struct AppTimerEvent {
|
||||
TimerClientId clientId = kInvalidTimerClient;
|
||||
AppTimerHandle handle = kInvalidAppTimer;
|
||||
};
|
||||
|
||||
struct AppEvent {
|
||||
using Data = std::variant<AppButtonEvent, AppTimerEvent>;
|
||||
|
||||
std::uint32_t timestamp_ms = 0;
|
||||
Data data{AppButtonEvent{}};
|
||||
|
||||
[[nodiscard]] bool isButton() const { return std::holds_alternative<AppButtonEvent>(data); }
|
||||
[[nodiscard]] bool isTimer() const { return std::holds_alternative<AppTimerEvent>(data); }
|
||||
|
||||
[[nodiscard]] const AppButtonEvent* button() const { return std::get_if<AppButtonEvent>(&data); }
|
||||
[[nodiscard]] AppButtonEvent* button() { return std::get_if<AppButtonEvent>(&data); }
|
||||
|
||||
[[nodiscard]] const AppTimerEvent* timer() const { return std::get_if<AppTimerEvent>(&data); }
|
||||
[[nodiscard]] AppTimerEvent* timer() { return std::get_if<AppTimerEvent>(&data); }
|
||||
|
||||
template<typename Visitor>
|
||||
decltype(auto) visit(Visitor&& visitor) {
|
||||
return std::visit(std::forward<Visitor>(visitor), data);
|
||||
}
|
||||
|
||||
template<typename Visitor>
|
||||
decltype(auto) visit(Visitor&& visitor) const {
|
||||
return std::visit(std::forward<Visitor>(visitor), data);
|
||||
}
|
||||
};
|
||||
|
||||
template<typename... Ts>
|
||||
struct Overload : Ts... {
|
||||
using Ts::operator()...;
|
||||
};
|
||||
|
||||
template<typename... Ts>
|
||||
Overload(Ts...) -> Overload<Ts...>;
|
||||
|
||||
template<typename... Ts>
|
||||
constexpr auto overload(Ts&&... ts) {
|
||||
return Overload<std::decay_t<Ts>...>{std::forward<Ts>(ts)...};
|
||||
}
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/sdk/app_events.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace cardboy::sdk {
|
||||
@@ -35,8 +37,8 @@ public:
|
||||
virtual void signal(std::uint32_t bits) = 0;
|
||||
virtual void signalFromISR(std::uint32_t bits) = 0;
|
||||
virtual std::uint32_t wait(std::uint32_t mask, std::uint32_t timeout_ms) = 0;
|
||||
virtual void scheduleTimerSignal(std::uint32_t delay_ms) = 0;
|
||||
virtual void cancelTimerSignal() = 0;
|
||||
virtual void postEvent(const AppEvent& event) = 0;
|
||||
virtual bool popEvent(AppEvent& outEvent) = 0;
|
||||
};
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
#include "cardboy/sdk/event_bus.hpp"
|
||||
#include "cardboy/sdk/loop_hooks.hpp"
|
||||
#include "cardboy/sdk/timer_service.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace cardboy::sdk {
|
||||
|
||||
@@ -69,6 +71,28 @@ public:
|
||||
[[nodiscard]] virtual std::string basePath() const = 0;
|
||||
};
|
||||
|
||||
class INotificationCenter {
|
||||
public:
|
||||
struct Notification {
|
||||
std::uint64_t id = 0;
|
||||
std::uint64_t timestamp = 0;
|
||||
std::uint64_t externalId = 0;
|
||||
std::string title;
|
||||
std::string body;
|
||||
bool unread = true;
|
||||
};
|
||||
|
||||
virtual ~INotificationCenter() = default;
|
||||
|
||||
virtual void pushNotification(Notification notification) = 0;
|
||||
[[nodiscard]] virtual std::uint32_t revision() const = 0;
|
||||
[[nodiscard]] virtual std::vector<Notification> recent(std::size_t limit) const = 0;
|
||||
virtual void markAllRead() = 0;
|
||||
virtual void clear() = 0;
|
||||
virtual void removeById(std::uint64_t id) = 0;
|
||||
virtual void removeByExternalId(std::uint64_t externalId) = 0;
|
||||
};
|
||||
|
||||
struct Services {
|
||||
IBuzzer* buzzer = nullptr;
|
||||
IBatteryMonitor* battery = nullptr;
|
||||
@@ -77,7 +101,9 @@ struct Services {
|
||||
IHighResClock* highResClock = nullptr;
|
||||
IFilesystem* filesystem = nullptr;
|
||||
IEventBus* eventBus = nullptr;
|
||||
ITimerService* timer = nullptr;
|
||||
ILoopHooks* loopHooks = nullptr;
|
||||
INotificationCenter* notifications = nullptr;
|
||||
};
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/sdk/app_events.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace cardboy::sdk {
|
||||
|
||||
class ITimerService {
|
||||
public:
|
||||
virtual ~ITimerService() = default;
|
||||
|
||||
virtual TimerClientId acquireClient() = 0;
|
||||
virtual void releaseClient(TimerClientId clientId) = 0;
|
||||
virtual AppTimerHandle scheduleTimer(TimerClientId clientId, std::uint32_t delay_ms, bool repeat) = 0;
|
||||
virtual void cancelTimer(TimerClientId clientId, AppTimerHandle handle) = 0;
|
||||
virtual void cancelAllTimers(TimerClientId clientId) = 0;
|
||||
};
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <SFML/Window/Keyboard.hpp>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <limits>
|
||||
@@ -86,27 +87,79 @@ private:
|
||||
bool mounted = false;
|
||||
};
|
||||
|
||||
class DesktopNotificationCenter final : public cardboy::sdk::INotificationCenter {
|
||||
public:
|
||||
void pushNotification(Notification notification) override;
|
||||
[[nodiscard]] std::uint32_t revision() const override;
|
||||
[[nodiscard]] std::vector<Notification> recent(std::size_t limit) const override;
|
||||
void markAllRead() override;
|
||||
void clear() override;
|
||||
void removeById(std::uint64_t id) override;
|
||||
void removeByExternalId(std::uint64_t externalId) override;
|
||||
|
||||
private:
|
||||
static constexpr std::size_t kMaxEntries = 8;
|
||||
|
||||
mutable std::mutex mutex;
|
||||
std::vector<Notification> entries;
|
||||
std::uint64_t nextId = 1;
|
||||
std::uint32_t revisionCounter = 0;
|
||||
};
|
||||
|
||||
class DesktopEventBus final : public cardboy::sdk::IEventBus {
|
||||
public:
|
||||
explicit DesktopEventBus(DesktopRuntime& owner);
|
||||
~DesktopEventBus() override;
|
||||
|
||||
void signal(std::uint32_t bits) override;
|
||||
void signalFromISR(std::uint32_t bits) override;
|
||||
std::uint32_t wait(std::uint32_t mask, std::uint32_t timeout_ms) override;
|
||||
void scheduleTimerSignal(std::uint32_t delay_ms) override;
|
||||
void cancelTimerSignal() override;
|
||||
void postEvent(const cardboy::sdk::AppEvent& event) override;
|
||||
bool popEvent(cardboy::sdk::AppEvent& outEvent) override;
|
||||
|
||||
private:
|
||||
DesktopRuntime& runtime;
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::uint32_t pendingBits = 0;
|
||||
std::mutex eventMutex;
|
||||
std::deque<cardboy::sdk::AppEvent> eventQueue;
|
||||
};
|
||||
|
||||
std::mutex timerMutex;
|
||||
std::condition_variable timerCv;
|
||||
std::thread timerThread;
|
||||
bool timerCancel = false;
|
||||
class DesktopTimerService final : public cardboy::sdk::ITimerService {
|
||||
public:
|
||||
DesktopTimerService(DesktopRuntime& owner, cardboy::sdk::IEventBus& bus);
|
||||
~DesktopTimerService() override;
|
||||
|
||||
cardboy::sdk::TimerClientId acquireClient() override;
|
||||
void releaseClient(cardboy::sdk::TimerClientId clientId) override;
|
||||
cardboy::sdk::AppTimerHandle scheduleTimer(cardboy::sdk::TimerClientId clientId, std::uint32_t delay_ms,
|
||||
bool repeat) override;
|
||||
void cancelTimer(cardboy::sdk::TimerClientId clientId, cardboy::sdk::AppTimerHandle handle) override;
|
||||
void cancelAllTimers(cardboy::sdk::TimerClientId clientId) override;
|
||||
|
||||
private:
|
||||
struct TimerRecord {
|
||||
cardboy::sdk::TimerClientId clientId = cardboy::sdk::kInvalidTimerClient;
|
||||
cardboy::sdk::AppTimerHandle handle = cardboy::sdk::kInvalidAppTimer;
|
||||
std::chrono::steady_clock::time_point due;
|
||||
std::chrono::milliseconds interval{0};
|
||||
bool repeat = false;
|
||||
bool active = true;
|
||||
};
|
||||
|
||||
void workerLoop();
|
||||
void wakeWorker();
|
||||
void cleanupInactive();
|
||||
|
||||
DesktopRuntime& runtime;
|
||||
cardboy::sdk::IEventBus& eventBus;
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::vector<TimerRecord> timers;
|
||||
bool stopWorker = false;
|
||||
std::thread worker;
|
||||
cardboy::sdk::AppTimerHandle nextHandle = 1;
|
||||
cardboy::sdk::TimerClientId nextClient = 1;
|
||||
};
|
||||
|
||||
class DesktopFramebuffer final : public cardboy::sdk::FramebufferFacade<DesktopFramebuffer> {
|
||||
@@ -187,6 +240,8 @@ private:
|
||||
DesktopHighResClock highResService;
|
||||
DesktopFilesystem filesystemService;
|
||||
DesktopEventBus eventBusService;
|
||||
DesktopTimerService timerService;
|
||||
DesktopNotificationCenter notificationService;
|
||||
cardboy::sdk::Services services{};
|
||||
};
|
||||
|
||||
|
||||
@@ -8,14 +8,18 @@
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <stdexcept>
|
||||
#include <system_error>
|
||||
#include <utility>
|
||||
|
||||
namespace cardboy::backend::desktop {
|
||||
|
||||
DesktopEventBus::DesktopEventBus(DesktopRuntime& owner) : runtime(owner) {}
|
||||
namespace {
|
||||
constexpr std::size_t kDesktopEventQueueLimit = 64;
|
||||
} // namespace
|
||||
|
||||
DesktopEventBus::~DesktopEventBus() { cancelTimerSignal(); }
|
||||
DesktopEventBus::DesktopEventBus(DesktopRuntime& owner) : runtime(owner) {}
|
||||
|
||||
void DesktopEventBus::signal(std::uint32_t bits) {
|
||||
if (bits == 0)
|
||||
@@ -59,46 +63,161 @@ std::uint32_t DesktopEventBus::wait(std::uint32_t mask, std::uint32_t timeout_ms
|
||||
}
|
||||
}
|
||||
|
||||
void DesktopEventBus::scheduleTimerSignal(std::uint32_t delay_ms) {
|
||||
cancelTimerSignal();
|
||||
|
||||
if (delay_ms == cardboy::sdk::IEventBus::kWaitForever)
|
||||
return;
|
||||
|
||||
if (delay_ms == 0) {
|
||||
signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Timer));
|
||||
return;
|
||||
}
|
||||
|
||||
void DesktopEventBus::postEvent(const cardboy::sdk::AppEvent& event) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(timerMutex);
|
||||
timerCancel = false;
|
||||
std::lock_guard<std::mutex> lock(eventMutex);
|
||||
if (eventQueue.size() >= kDesktopEventQueueLimit)
|
||||
eventQueue.pop_front();
|
||||
eventQueue.push_back(event);
|
||||
}
|
||||
|
||||
timerThread = std::thread([this, delay_ms]() {
|
||||
std::unique_lock<std::mutex> lock(timerMutex);
|
||||
const bool cancelled =
|
||||
timerCv.wait_for(lock, std::chrono::milliseconds(delay_ms), [this] { return timerCancel; });
|
||||
lock.unlock();
|
||||
if (!cancelled)
|
||||
signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Timer));
|
||||
});
|
||||
signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Timer));
|
||||
}
|
||||
|
||||
void DesktopEventBus::cancelTimerSignal() {
|
||||
bool DesktopEventBus::popEvent(cardboy::sdk::AppEvent& outEvent) {
|
||||
std::lock_guard<std::mutex> lock(eventMutex);
|
||||
if (eventQueue.empty())
|
||||
return false;
|
||||
outEvent = eventQueue.front();
|
||||
eventQueue.pop_front();
|
||||
return true;
|
||||
}
|
||||
|
||||
DesktopTimerService::DesktopTimerService(DesktopRuntime& owner, cardboy::sdk::IEventBus& bus) : runtime(owner), eventBus(bus) {
|
||||
worker = std::thread(&DesktopTimerService::workerLoop, this);
|
||||
}
|
||||
|
||||
DesktopTimerService::~DesktopTimerService() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(timerMutex);
|
||||
timerCancel = true;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
stopWorker = true;
|
||||
}
|
||||
timerCv.notify_all();
|
||||
if (timerThread.joinable())
|
||||
timerThread.join();
|
||||
cv.notify_all();
|
||||
if (worker.joinable())
|
||||
worker.join();
|
||||
}
|
||||
|
||||
cardboy::sdk::TimerClientId DesktopTimerService::acquireClient() {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
cardboy::sdk::TimerClientId id = cardboy::sdk::kInvalidTimerClient;
|
||||
do {
|
||||
id = nextClient++;
|
||||
} while (id == cardboy::sdk::kInvalidTimerClient);
|
||||
if (nextClient == cardboy::sdk::kInvalidTimerClient)
|
||||
++nextClient;
|
||||
return id;
|
||||
}
|
||||
|
||||
void DesktopTimerService::releaseClient(cardboy::sdk::TimerClientId clientId) { cancelAllTimers(clientId); }
|
||||
|
||||
cardboy::sdk::AppTimerHandle DesktopTimerService::scheduleTimer(cardboy::sdk::TimerClientId clientId,
|
||||
std::uint32_t delay_ms, bool repeat) {
|
||||
if (clientId == cardboy::sdk::kInvalidTimerClient)
|
||||
return cardboy::sdk::kInvalidAppTimer;
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const auto effectiveDelayMs = std::chrono::milliseconds(delay_ms);
|
||||
const auto dueTime = delay_ms == 0 ? now : now + effectiveDelayMs;
|
||||
const auto interval = std::chrono::milliseconds(std::max<std::uint32_t>(1, repeat ? delay_ms : std::max(delay_ms, 1u)));
|
||||
|
||||
TimerRecord record{};
|
||||
record.clientId = clientId;
|
||||
record.repeat = repeat;
|
||||
record.interval = interval;
|
||||
record.due = dueTime;
|
||||
record.active = true;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(timerMutex);
|
||||
timerCancel = false;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
cardboy::sdk::AppTimerHandle handle = cardboy::sdk::kInvalidAppTimer;
|
||||
do {
|
||||
handle = nextHandle++;
|
||||
} while (handle == cardboy::sdk::kInvalidAppTimer);
|
||||
if (nextHandle == cardboy::sdk::kInvalidAppTimer)
|
||||
++nextHandle;
|
||||
record.handle = handle;
|
||||
timers.push_back(record);
|
||||
wakeWorker();
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
|
||||
void DesktopTimerService::cancelTimer(cardboy::sdk::TimerClientId clientId, cardboy::sdk::AppTimerHandle handle) {
|
||||
if (clientId == cardboy::sdk::kInvalidTimerClient || handle == cardboy::sdk::kInvalidAppTimer)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
for (auto& record: timers) {
|
||||
if (record.clientId == clientId && record.handle == handle)
|
||||
record.active = false;
|
||||
}
|
||||
cleanupInactive();
|
||||
wakeWorker();
|
||||
}
|
||||
|
||||
void DesktopTimerService::cancelAllTimers(cardboy::sdk::TimerClientId clientId) {
|
||||
if (clientId == cardboy::sdk::kInvalidTimerClient)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
for (auto& record: timers) {
|
||||
if (record.clientId == clientId)
|
||||
record.active = false;
|
||||
}
|
||||
cleanupInactive();
|
||||
wakeWorker();
|
||||
}
|
||||
|
||||
void DesktopTimerService::workerLoop() {
|
||||
std::unique_lock<std::mutex> lock(mutex);
|
||||
while (!stopWorker) {
|
||||
cleanupInactive();
|
||||
if (timers.empty()) {
|
||||
cv.wait(lock, [this] { return stopWorker || !timers.empty(); });
|
||||
continue;
|
||||
}
|
||||
|
||||
auto nextIt = std::min_element(timers.begin(), timers.end(), [](const TimerRecord& a, const TimerRecord& b) {
|
||||
return a.due < b.due;
|
||||
});
|
||||
|
||||
if (nextIt == timers.end())
|
||||
continue;
|
||||
|
||||
if (!nextIt->active)
|
||||
continue;
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now >= nextIt->due) {
|
||||
TimerRecord record = *nextIt;
|
||||
if (record.repeat) {
|
||||
nextIt->due = now + record.interval;
|
||||
} else {
|
||||
nextIt->active = false;
|
||||
}
|
||||
lock.unlock();
|
||||
|
||||
cardboy::sdk::AppTimerEvent timerEvent{};
|
||||
timerEvent.clientId = record.clientId;
|
||||
timerEvent.handle = record.handle;
|
||||
|
||||
cardboy::sdk::AppEvent event{};
|
||||
event.timestamp_ms = runtime.clock.millis();
|
||||
event.data = timerEvent;
|
||||
eventBus.postEvent(event);
|
||||
|
||||
lock.lock();
|
||||
continue;
|
||||
}
|
||||
|
||||
cv.wait_until(lock, nextIt->due, [this] { return stopWorker; });
|
||||
}
|
||||
}
|
||||
|
||||
void DesktopTimerService::wakeWorker() { cv.notify_all(); }
|
||||
|
||||
void DesktopTimerService::cleanupInactive() {
|
||||
timers.erase(std::remove_if(timers.begin(), timers.end(), [](const TimerRecord& record) { return !record.active; }),
|
||||
timers.end());
|
||||
}
|
||||
|
||||
bool DesktopStorage::readUint32(std::string_view ns, std::string_view key, std::uint32_t& out) {
|
||||
auto it = data.find(composeKey(ns, key));
|
||||
if (it == data.end())
|
||||
@@ -149,6 +268,101 @@ bool DesktopFilesystem::mount() {
|
||||
return mounted;
|
||||
}
|
||||
|
||||
void DesktopNotificationCenter::pushNotification(Notification notification) {
|
||||
if (notification.timestamp == 0) {
|
||||
notification.timestamp = static_cast<std::uint64_t>(std::time(nullptr));
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (notification.externalId != 0) {
|
||||
for (auto it = entries.begin(); it != entries.end();) {
|
||||
if (it->externalId == notification.externalId)
|
||||
it = entries.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
notification.id = nextId++;
|
||||
notification.unread = true;
|
||||
|
||||
entries.push_back(std::move(notification));
|
||||
while (entries.size() > kMaxEntries)
|
||||
entries.erase(entries.begin());
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
std::uint32_t DesktopNotificationCenter::revision() const {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
return revisionCounter;
|
||||
}
|
||||
|
||||
std::vector<DesktopNotificationCenter::Notification> DesktopNotificationCenter::recent(std::size_t limit) const {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
const std::size_t count = std::min<std::size_t>(limit, entries.size());
|
||||
std::vector<Notification> result;
|
||||
result.reserve(count);
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
result.push_back(entries[entries.size() - 1 - i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void DesktopNotificationCenter::markAllRead() {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
bool changed = false;
|
||||
for (auto& entry : entries) {
|
||||
if (entry.unread) {
|
||||
entry.unread = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed)
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
void DesktopNotificationCenter::clear() {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (entries.empty())
|
||||
return;
|
||||
entries.clear();
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
void DesktopNotificationCenter::removeById(std::uint64_t id) {
|
||||
if (id == 0)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
bool removed = false;
|
||||
for (auto it = entries.begin(); it != entries.end();) {
|
||||
if (it->id == id) {
|
||||
it = entries.erase(it);
|
||||
removed = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
if (removed)
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
void DesktopNotificationCenter::removeByExternalId(std::uint64_t externalId) {
|
||||
if (externalId == 0)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
bool removed = false;
|
||||
for (auto it = entries.begin(); it != entries.end();) {
|
||||
if (it->externalId == externalId) {
|
||||
it = entries.erase(it);
|
||||
removed = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
if (removed)
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
DesktopFramebuffer::DesktopFramebuffer(DesktopRuntime& runtime) : runtime(runtime) {}
|
||||
|
||||
int DesktopFramebuffer::width_impl() const { return cardboy::sdk::kDisplayWidth; }
|
||||
@@ -227,7 +441,7 @@ DesktopRuntime::DesktopRuntime() :
|
||||
"Cardboy Desktop"),
|
||||
texture(), sprite(texture),
|
||||
pixels(static_cast<std::size_t>(cardboy::sdk::kDisplayWidth * cardboy::sdk::kDisplayHeight) * 4, 0),
|
||||
framebuffer(*this), input(*this), clock(*this), eventBusService(*this) {
|
||||
framebuffer(*this), input(*this), clock(*this), eventBusService(*this), timerService(*this, eventBusService) {
|
||||
window.setFramerateLimit(60);
|
||||
if (!texture.resize(sf::Vector2u{cardboy::sdk::kDisplayWidth, cardboy::sdk::kDisplayHeight}))
|
||||
throw std::runtime_error("Failed to allocate texture for desktop framebuffer");
|
||||
@@ -243,7 +457,9 @@ DesktopRuntime::DesktopRuntime() :
|
||||
services.highResClock = &highResService;
|
||||
services.filesystem = &filesystemService;
|
||||
services.eventBus = &eventBusService;
|
||||
services.timer = &timerService;
|
||||
services.loopHooks = nullptr;
|
||||
services.notifications = ¬ificationService;
|
||||
}
|
||||
|
||||
cardboy::sdk::Services& DesktopRuntime::serviceRegistry() { return services; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <cardboy/sdk/app_events.hpp>
|
||||
#include <cardboy/sdk/backend.hpp>
|
||||
#include <cardboy/sdk/platform.hpp>
|
||||
#include <cardboy/sdk/services.hpp>
|
||||
@@ -14,30 +15,6 @@ namespace cardboy::sdk {
|
||||
|
||||
class AppSystem;
|
||||
|
||||
using AppTimerHandle = std::uint32_t;
|
||||
constexpr AppTimerHandle kInvalidAppTimer = 0;
|
||||
|
||||
enum class AppEventType {
|
||||
Button,
|
||||
Timer,
|
||||
};
|
||||
|
||||
struct AppButtonEvent {
|
||||
InputState current{};
|
||||
InputState previous{};
|
||||
};
|
||||
|
||||
struct AppTimerEvent {
|
||||
AppTimerHandle handle = kInvalidAppTimer;
|
||||
};
|
||||
|
||||
struct AppEvent {
|
||||
AppEventType type;
|
||||
std::uint32_t timestamp_ms = 0;
|
||||
AppButtonEvent button{};
|
||||
AppTimerEvent timer{};
|
||||
};
|
||||
|
||||
using ActiveBackend = cardboy::backend::ActiveBackend;
|
||||
|
||||
struct AppContext {
|
||||
@@ -63,7 +40,11 @@ struct AppContext {
|
||||
[[nodiscard]] IHighResClock* highResClock() const { return services ? services->highResClock : nullptr; }
|
||||
[[nodiscard]] IFilesystem* filesystem() const { return services ? services->filesystem : nullptr; }
|
||||
[[nodiscard]] IEventBus* eventBus() const { return services ? services->eventBus : nullptr; }
|
||||
[[nodiscard]] ITimerService* timerService() const { return services ? services->timer : nullptr; }
|
||||
[[nodiscard]] ILoopHooks* loopHooks() const { return services ? services->loopHooks : nullptr; }
|
||||
[[nodiscard]] INotificationCenter* notificationCenter() const {
|
||||
return services ? services->notifications : nullptr;
|
||||
}
|
||||
|
||||
void requestAppSwitchByIndex(std::size_t index) {
|
||||
pendingAppIndex = index;
|
||||
@@ -81,27 +62,28 @@ struct AppContext {
|
||||
[[nodiscard]] bool hasPendingAppSwitch() const { return pendingSwitch; }
|
||||
|
||||
AppTimerHandle scheduleTimer(std::uint32_t delay_ms, bool repeat = false) {
|
||||
if (!system)
|
||||
auto* timer = timerService();
|
||||
if (!timer || timerClientId == kInvalidTimerClient)
|
||||
return kInvalidAppTimer;
|
||||
return scheduleTimerInternal(delay_ms, repeat);
|
||||
return timer->scheduleTimer(timerClientId, delay_ms, repeat);
|
||||
}
|
||||
|
||||
AppTimerHandle scheduleRepeatingTimer(std::uint32_t interval_ms) {
|
||||
if (!system)
|
||||
return kInvalidAppTimer;
|
||||
return scheduleTimerInternal(interval_ms, true);
|
||||
return scheduleTimer(interval_ms, true);
|
||||
}
|
||||
|
||||
void cancelTimer(AppTimerHandle handle) {
|
||||
if (!system)
|
||||
auto* timer = timerService();
|
||||
if (!timer || timerClientId == kInvalidTimerClient)
|
||||
return;
|
||||
cancelTimerInternal(handle);
|
||||
timer->cancelTimer(timerClientId, handle);
|
||||
}
|
||||
|
||||
void cancelAllTimers() {
|
||||
if (!system)
|
||||
auto* timer = timerService();
|
||||
if (!timer || timerClientId == kInvalidTimerClient)
|
||||
return;
|
||||
cancelAllTimersInternal();
|
||||
timer->cancelAllTimers(timerClientId);
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -110,10 +92,7 @@ private:
|
||||
bool pendingSwitchByName = false;
|
||||
std::size_t pendingAppIndex = 0;
|
||||
std::string pendingAppName;
|
||||
|
||||
AppTimerHandle scheduleTimerInternal(std::uint32_t delay_ms, bool repeat);
|
||||
void cancelTimerInternal(AppTimerHandle handle);
|
||||
void cancelAllTimersInternal();
|
||||
TimerClientId timerClientId = kInvalidTimerClient;
|
||||
};
|
||||
|
||||
class IApp {
|
||||
|
||||
@@ -32,52 +32,20 @@ public:
|
||||
private:
|
||||
friend struct AppContext;
|
||||
|
||||
struct TimerRecord {
|
||||
AppTimerHandle id = kInvalidAppTimer;
|
||||
std::uint32_t generation = 0;
|
||||
std::uint32_t due_ms = 0;
|
||||
std::uint32_t interval_ms = 0;
|
||||
bool repeat = false;
|
||||
bool active = false;
|
||||
};
|
||||
|
||||
AppTimerHandle scheduleTimer(std::uint32_t delay_ms, bool repeat);
|
||||
void cancelTimer(AppTimerHandle handle);
|
||||
void cancelAllTimers();
|
||||
|
||||
void dispatchEvent(const AppEvent& event);
|
||||
|
||||
void processDueTimers(std::uint32_t now, std::vector<AppEvent>& outEvents);
|
||||
std::uint32_t nextTimerDueMs(std::uint32_t now) const;
|
||||
void clearTimersForCurrentApp();
|
||||
TimerRecord* findTimer(AppTimerHandle handle);
|
||||
bool handlePendingSwitchRequest();
|
||||
void notifyEventBus(EventBusSignal signal);
|
||||
bool handlePendingSwitchRequest();
|
||||
void attachTimerClient();
|
||||
void detachTimerClient();
|
||||
void processEventBusEvents();
|
||||
|
||||
AppContext context;
|
||||
std::vector<std::unique_ptr<IAppFactory>> factories;
|
||||
std::unique_ptr<IApp> current;
|
||||
IAppFactory* activeFactory = nullptr;
|
||||
std::size_t activeIndex = static_cast<std::size_t>(-1);
|
||||
std::vector<TimerRecord> timers;
|
||||
AppTimerHandle nextTimerId = 1;
|
||||
std::uint32_t currentGeneration = 0;
|
||||
TimerClientId timerClientId = kInvalidTimerClient;
|
||||
InputState lastInputState{};
|
||||
bool suppressInputs = false;
|
||||
};
|
||||
|
||||
inline AppTimerHandle AppContext::scheduleTimerInternal(std::uint32_t delay_ms, bool repeat) {
|
||||
return system ? system->scheduleTimer(delay_ms, repeat) : kInvalidAppTimer;
|
||||
}
|
||||
|
||||
inline void AppContext::cancelTimerInternal(AppTimerHandle handle) {
|
||||
if (system)
|
||||
system->cancelTimer(handle);
|
||||
}
|
||||
|
||||
inline void AppContext::cancelAllTimersInternal() {
|
||||
if (system)
|
||||
system->cancelAllTimers();
|
||||
}
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
#include "cardboy/sdk/status_bar.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
|
||||
namespace cardboy::sdk {
|
||||
@@ -34,7 +33,10 @@ AppSystem::AppSystem(AppContext ctx) : context(std::move(ctx)) {
|
||||
FramebufferHooks::setPreSendHook(&statusBarPreSendHook<FBType>, &statusBar);
|
||||
}
|
||||
|
||||
AppSystem::~AppSystem() { FramebufferHooks::clearPreSendHook(); }
|
||||
AppSystem::~AppSystem() {
|
||||
detachTimerClient();
|
||||
FramebufferHooks::clearPreSendHook();
|
||||
}
|
||||
|
||||
void AppSystem::registerApp(std::unique_ptr<IAppFactory> factory) {
|
||||
if (!factory)
|
||||
@@ -63,6 +65,7 @@ bool AppSystem::startAppByIndex(std::size_t index) {
|
||||
if (current) {
|
||||
current->onStop();
|
||||
current.reset();
|
||||
detachTimerClient();
|
||||
}
|
||||
|
||||
activeFactory = factory.get();
|
||||
@@ -70,8 +73,8 @@ bool AppSystem::startAppByIndex(std::size_t index) {
|
||||
context.pendingSwitch = false;
|
||||
context.pendingSwitchByName = false;
|
||||
context.pendingAppName.clear();
|
||||
clearTimersForCurrentApp();
|
||||
current = std::move(app);
|
||||
attachTimerClient();
|
||||
lastInputState = context.input.readState();
|
||||
suppressInputs = true;
|
||||
StatusBar::instance().setServices(context.services);
|
||||
@@ -86,17 +89,19 @@ void AppSystem::run() {
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<AppEvent> events;
|
||||
events.reserve(4);
|
||||
|
||||
while (true) {
|
||||
auto* eventBus = context.eventBus();
|
||||
if (!eventBus)
|
||||
return;
|
||||
|
||||
if (auto* hooks = context.loopHooks())
|
||||
hooks->onLoopIteration();
|
||||
|
||||
events.clear();
|
||||
const std::uint32_t now = context.clock.millis();
|
||||
processDueTimers(now, events);
|
||||
processEventBusEvents();
|
||||
if (handlePendingSwitchRequest())
|
||||
continue;
|
||||
|
||||
const std::uint32_t now = context.clock.millis();
|
||||
const InputState inputNow = context.input.readState();
|
||||
const bool consumedByStatusToggle = StatusBar::instance().handleToggleInput(inputNow, lastInputState);
|
||||
|
||||
@@ -105,42 +110,25 @@ void AppSystem::run() {
|
||||
if (!anyButtonPressed(inputNow))
|
||||
suppressInputs = false;
|
||||
} else if (!consumedByStatusToggle && inputsDiffer(inputNow, lastInputState)) {
|
||||
AppButtonEvent button{};
|
||||
button.current = inputNow;
|
||||
button.previous = lastInputState;
|
||||
|
||||
AppEvent evt{};
|
||||
evt.type = AppEventType::Button;
|
||||
evt.timestamp_ms = now;
|
||||
evt.button.current = inputNow;
|
||||
evt.button.previous = lastInputState;
|
||||
events.push_back(evt);
|
||||
evt.timestamp_ms = now;
|
||||
evt.data = button;
|
||||
|
||||
lastInputState = inputNow;
|
||||
dispatchEvent(evt);
|
||||
} else if (consumedByStatusToggle) {
|
||||
lastInputState = inputNow;
|
||||
}
|
||||
|
||||
for (const auto& evt: events) {
|
||||
dispatchEvent(evt);
|
||||
if (handlePendingSwitchRequest())
|
||||
break;
|
||||
}
|
||||
|
||||
const std::uint32_t waitBase = context.clock.millis();
|
||||
std::uint32_t waitMs = nextTimerDueMs(waitBase);
|
||||
|
||||
if (waitMs == 0)
|
||||
if (handlePendingSwitchRequest())
|
||||
continue;
|
||||
|
||||
auto* eventBus = context.eventBus();
|
||||
if (!eventBus)
|
||||
return;
|
||||
|
||||
const std::uint32_t mask = to_event_bits(EventBusSignal::Input) | to_event_bits(EventBusSignal::Timer);
|
||||
|
||||
if (waitMs == std::numeric_limits<std::uint32_t>::max()) {
|
||||
eventBus->cancelTimerSignal();
|
||||
eventBus->wait(mask, IEventBus::kWaitForever);
|
||||
} else {
|
||||
eventBus->scheduleTimerSignal(waitMs);
|
||||
eventBus->wait(mask, IEventBus::kWaitForever);
|
||||
}
|
||||
eventBus->wait(mask, IEventBus::kWaitForever);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,105 +148,16 @@ std::size_t AppSystem::indexOfFactory(const IAppFactory* factory) const {
|
||||
return static_cast<std::size_t>(-1);
|
||||
}
|
||||
|
||||
AppTimerHandle AppSystem::scheduleTimer(std::uint32_t delay_ms, bool repeat) {
|
||||
if (!current)
|
||||
return kInvalidAppTimer;
|
||||
TimerRecord record;
|
||||
record.id = nextTimerId++;
|
||||
if (record.id == kInvalidAppTimer)
|
||||
record.id = nextTimerId++;
|
||||
record.generation = currentGeneration;
|
||||
const auto now = context.clock.millis();
|
||||
record.due_ms = now + delay_ms;
|
||||
record.interval_ms = repeat ? std::max<std::uint32_t>(1, delay_ms) : 0;
|
||||
record.repeat = repeat;
|
||||
record.active = true;
|
||||
timers.push_back(record);
|
||||
notifyEventBus(EventBusSignal::Timer);
|
||||
return record.id;
|
||||
}
|
||||
|
||||
void AppSystem::cancelTimer(AppTimerHandle handle) {
|
||||
auto* timer = findTimer(handle);
|
||||
if (!timer)
|
||||
return;
|
||||
timer->active = false;
|
||||
timers.erase(std::remove_if(timers.begin(), timers.end(), [](const TimerRecord& rec) { return !rec.active; }),
|
||||
timers.end());
|
||||
notifyEventBus(EventBusSignal::Timer);
|
||||
}
|
||||
|
||||
void AppSystem::cancelAllTimers() {
|
||||
bool changed = false;
|
||||
for (auto& timer: timers) {
|
||||
if (timer.generation == currentGeneration && timer.active) {
|
||||
timer.active = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
timers.erase(std::remove_if(timers.begin(), timers.end(), [](const TimerRecord& rec) { return !rec.active; }),
|
||||
timers.end());
|
||||
if (changed)
|
||||
notifyEventBus(EventBusSignal::Timer);
|
||||
}
|
||||
|
||||
void AppSystem::dispatchEvent(const AppEvent& event) {
|
||||
if (current)
|
||||
current->handleEvent(event);
|
||||
}
|
||||
if (!current)
|
||||
return;
|
||||
|
||||
void AppSystem::processDueTimers(std::uint32_t now, std::vector<AppEvent>& outEvents) {
|
||||
for (auto& timer: timers) {
|
||||
if (!timer.active || timer.generation != currentGeneration)
|
||||
continue;
|
||||
if (static_cast<std::int32_t>(now - timer.due_ms) >= 0) {
|
||||
AppEvent ev{};
|
||||
ev.type = AppEventType::Timer;
|
||||
ev.timestamp_ms = now;
|
||||
ev.timer.handle = timer.id;
|
||||
outEvents.push_back(ev);
|
||||
if (timer.repeat) {
|
||||
const std::uint32_t interval = timer.interval_ms ? timer.interval_ms : 1;
|
||||
timer.due_ms = now + interval;
|
||||
} else {
|
||||
timer.active = false;
|
||||
}
|
||||
}
|
||||
if (const auto* timer = event.timer()) {
|
||||
if (timer->clientId != timerClientId)
|
||||
return;
|
||||
}
|
||||
timers.erase(std::remove_if(timers.begin(), timers.end(), [](const TimerRecord& rec) { return !rec.active; }),
|
||||
timers.end());
|
||||
}
|
||||
|
||||
std::uint32_t AppSystem::nextTimerDueMs(std::uint32_t now) const {
|
||||
std::uint32_t minWait = std::numeric_limits<std::uint32_t>::max();
|
||||
for (const auto& timer: timers) {
|
||||
if (!timer.active || timer.generation != currentGeneration)
|
||||
continue;
|
||||
if (static_cast<std::int32_t>(now - timer.due_ms) >= 0)
|
||||
return 0;
|
||||
const std::uint32_t delta = timer.due_ms - now;
|
||||
if (delta < minWait)
|
||||
minWait = delta;
|
||||
}
|
||||
return minWait;
|
||||
}
|
||||
|
||||
void AppSystem::clearTimersForCurrentApp() {
|
||||
const bool hadTimers = !timers.empty();
|
||||
++currentGeneration;
|
||||
timers.clear();
|
||||
if (hadTimers)
|
||||
notifyEventBus(EventBusSignal::Timer);
|
||||
}
|
||||
|
||||
AppSystem::TimerRecord* AppSystem::findTimer(AppTimerHandle handle) {
|
||||
for (auto& timer: timers) {
|
||||
if (!timer.active || timer.generation != currentGeneration)
|
||||
continue;
|
||||
if (timer.id == handle)
|
||||
return &timer;
|
||||
}
|
||||
return nullptr;
|
||||
current->handleEvent(event);
|
||||
}
|
||||
|
||||
bool AppSystem::handlePendingSwitchRequest() {
|
||||
@@ -278,11 +177,39 @@ bool AppSystem::handlePendingSwitchRequest() {
|
||||
return switched;
|
||||
}
|
||||
|
||||
void AppSystem::notifyEventBus(EventBusSignal signal) {
|
||||
if (signal == EventBusSignal::None)
|
||||
void AppSystem::attachTimerClient() {
|
||||
auto* timer = context.timerService();
|
||||
if (!timer) {
|
||||
timerClientId = kInvalidTimerClient;
|
||||
context.timerClientId = kInvalidTimerClient;
|
||||
return;
|
||||
if (auto* bus = context.eventBus())
|
||||
bus->signal(to_event_bits(signal));
|
||||
}
|
||||
|
||||
timerClientId = timer->acquireClient();
|
||||
context.timerClientId = timerClientId;
|
||||
}
|
||||
|
||||
void AppSystem::detachTimerClient() {
|
||||
auto* timer = context.timerService();
|
||||
if (!timer || timerClientId == kInvalidTimerClient)
|
||||
return;
|
||||
|
||||
timer->releaseClient(timerClientId);
|
||||
timerClientId = kInvalidTimerClient;
|
||||
context.timerClientId = kInvalidTimerClient;
|
||||
}
|
||||
|
||||
void AppSystem::processEventBusEvents() {
|
||||
auto* eventBus = context.eventBus();
|
||||
if (!eventBus)
|
||||
return;
|
||||
|
||||
AppEvent event{};
|
||||
while (eventBus->popEvent(event)) {
|
||||
dispatchEvent(event);
|
||||
if (context.pendingSwitch)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -695,7 +695,7 @@ CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y
|
||||
CONFIG_BT_NIMBLE_ROLE_OBSERVER=y
|
||||
CONFIG_BT_NIMBLE_GATT_CLIENT=y
|
||||
CONFIG_BT_NIMBLE_GATT_SERVER=y
|
||||
# CONFIG_BT_NIMBLE_NVS_PERSIST is not set
|
||||
CONFIG_BT_NIMBLE_NVS_PERSIST=y
|
||||
# CONFIG_BT_NIMBLE_SMP_ID_RESET is not set
|
||||
CONFIG_BT_NIMBLE_SECURITY_ENABLE=y
|
||||
CONFIG_BT_NIMBLE_SM_LEGACY=y
|
||||
@@ -2497,7 +2497,7 @@ CONFIG_NIMBLE_ROLE_CENTRAL=y
|
||||
CONFIG_NIMBLE_ROLE_PERIPHERAL=y
|
||||
CONFIG_NIMBLE_ROLE_BROADCASTER=y
|
||||
CONFIG_NIMBLE_ROLE_OBSERVER=y
|
||||
# CONFIG_NIMBLE_NVS_PERSIST is not set
|
||||
CONFIG_NIMBLE_NVS_PERSIST=y
|
||||
CONFIG_NIMBLE_SM_LEGACY=y
|
||||
CONFIG_NIMBLE_SM_SC=y
|
||||
# CONFIG_NIMBLE_SM_SC_DEBUG_KEYS is not set
|
||||
|
||||
Reference in New Issue
Block a user