Compare commits

...

30 Commits

Author SHA1 Message Date
65ee33a141 desktop fix 2025-10-25 18:25:12 +02:00
0e69debf39 reset sdkconfig 2025-10-25 16:01:50 +02:00
9b5521fc28 better lockscreen 2025-10-25 14:28:58 +02:00
278e822600 faster timeout for games 2025-10-25 13:52:50 +02:00
844cf86d8d fixie 2025-10-25 12:51:28 +02:00
f8735d4bce some refactoring 2 2025-10-25 12:34:53 +02:00
1ee132898b some refactoring 2025-10-22 14:46:20 +02:00
5ddd38e5d7 remove notifications 2025-10-21 23:35:11 +02:00
4112efd60b repeat pairing fix 2025-10-21 20:21:12 +02:00
678158c302 more fixes 2025-10-21 00:54:43 +02:00
12e8a0e098 janky notifications 2025-10-21 00:42:35 +02:00
fc633d7c90 a bit higher tx power 2025-10-20 08:48:59 +02:00
e8ae1cbec4 overlay fixes 2025-10-20 00:58:33 +02:00
b72ea4f417 some fixes 2025-10-20 00:50:21 +02:00
bf0ffe8632 rescan fix 2025-10-20 00:38:52 +02:00
96bfaaf64b lower power consumption bluetooth 2025-10-20 00:34:25 +02:00
cf5a848741 app fixes 2025-10-20 00:04:18 +02:00
7c492627f0 lockscreen show progressbar only on hold 2025-10-19 23:48:14 +02:00
be2629a008 connected overlay in file manager 2025-10-19 23:27:27 +02:00
016629eb82 lockscreen app 2025-10-19 23:27:16 +02:00
de1ac0e7a2 fixie 2025-10-19 23:09:55 +02:00
3ab2a7bf26 somewhat working file sync 2025-10-19 23:07:20 +02:00
b4f11851d7 fix 2025-10-19 20:30:11 +02:00
eeedc629d7 background sync 2025-10-19 20:26:40 +02:00
8bb48daf6c some power savings 2025-10-19 20:11:22 +02:00
7c741c42dc time sync 2025-10-19 19:54:24 +02:00
ecbcce12ea fix 2025-10-15 20:51:52 +02:00
f6c800fc63 gameboy save states 2025-10-15 20:46:48 +02:00
5e63875d35 bad sound in correct place 2025-10-13 22:02:55 +02:00
cc805abe80 snake app 2025-10-13 14:36:27 +02:00
57 changed files with 8119 additions and 902 deletions

3
Firmware/.gitignore vendored
View File

@@ -2,4 +2,5 @@ build
cmake-build*
.idea
.cache
managed_components
managed_components
*.gb

View File

@@ -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
View 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

View 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 12byte 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 selfcontained and can be reused.

View File

@@ -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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "cardboy-icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

@@ -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>

View File

@@ -0,0 +1,13 @@
import SwiftUI
@main
struct cardboy_companionApp: App {
@StateObject private var manager = TimeSyncManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(manager)
}
}
}

View File

@@ -4,12 +4,12 @@ idf_component_register(
"src/buttons.cpp"
"src/buzzer.cpp"
"src/esp_backend.cpp"
"src/event_bus.cpp"
"src/display.cpp"
"src/fs_helper.cpp"
"src/i2c_global.cpp"
"src/shutdowner.cpp"
"src/spi_global.cpp"
"src/time_sync_service.cpp"
INCLUDE_DIRS
"include"
PRIV_REQUIRES
@@ -19,6 +19,7 @@ idf_component_register(
esp_driver_spi
littlefs
nvs_flash
bt
)
add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../sdk/utils" cardboy_utils_esp)

View File

@@ -5,9 +5,10 @@
#ifndef BUTTONS_HPP
#define BUTTONS_HPP
#include "cardboy/sdk/event_bus.hpp"
#include <cstdint>
#include "cardboy/sdk/input_state.hpp"
#include "cardboy/sdk/services.hpp"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -24,20 +25,19 @@ typedef enum {
class Buttons {
public:
static Buttons& get();
void pooler(); // FIXME:
uint8_t get_pressed();
void install_isr();
void register_listener(TaskHandle_t task);
void setEventBus(cardboy::sdk::IEventBus* bus);
static Buttons& get();
void pooler(); // FIXME:
uint8_t get_pressed();
cardboy::sdk::InputState get_state();
void install_isr();
void setEventBus(cardboy::sdk::IEventBus* bus);
TaskHandle_t _pooler_task;
private:
Buttons();
uint8_t _previous;
volatile uint8_t _current;
volatile TaskHandle_t _listener = nullptr;
cardboy::sdk::IEventBus* _eventBus = nullptr;
};

View File

@@ -1,27 +0,0 @@
#pragma once
#include <cardboy/sdk/event_bus.hpp>
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "freertos/timers.h"
namespace cardboy::backend::esp {
class EventBus final : public cardboy::sdk::IEventBus {
public:
EventBus();
~EventBus() 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;
private:
EventGroupHandle_t group;
TimerHandle_t timer;
};
} // namespace cardboy::backend::esp

View File

@@ -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

View File

@@ -2,7 +2,6 @@
#include <cardboy/sdk/display_spec.hpp>
#include "cardboy/backend/esp/display.hpp"
#include "cardboy/backend/esp/event_bus.hpp"
#include "cardboy/sdk/platform.hpp"
#include "cardboy/sdk/services.hpp"
@@ -61,17 +60,24 @@ private:
class HighResClockService;
class FilesystemService;
class LoopHooksService;
class NotificationService;
class TimerService;
class EventBus;
class AppScopedServices;
class AppServiceProvider;
std::unique_ptr<BuzzerService> buzzerService;
std::unique_ptr<BatteryService> batteryService;
std::unique_ptr<StorageService> storageService;
std::unique_ptr<RandomService> randomService;
std::unique_ptr<HighResClockService> highResClockService;
std::unique_ptr<FilesystemService> filesystemService;
std::unique_ptr<EventBus> eventBus;
std::unique_ptr<LoopHooksService> loopHooksService;
std::unique_ptr<BuzzerService> _buzzerService;
std::unique_ptr<BatteryService> _batteryService;
std::unique_ptr<StorageService> _storageService;
std::unique_ptr<RandomService> _randomService;
std::unique_ptr<HighResClockService> _highResClockService;
std::unique_ptr<FilesystemService> _filesystemService;
std::unique_ptr<EventBus> _eventBus;
std::unique_ptr<LoopHooksService> _loopHooksService;
std::unique_ptr<NotificationService> _notificationService;
std::unique_ptr<AppServiceProvider> _appServiceProvider;
cardboy::sdk::Services services{};
cardboy::sdk::Services _services{};
};
struct Backend {

View File

@@ -13,7 +13,6 @@
#include "cardboy/backend/esp/config.hpp"
#include "cardboy/backend/esp/i2c_global.hpp"
#include "cardboy/sdk/event_bus.hpp"
static i2c_master_dev_handle_t dev_handle;
static inline i2c_device_config_t dev_cfg = {
@@ -76,6 +75,28 @@ static void delay(unsigned long long loop) {
}
}
static cardboy::sdk::InputState buttons_to_input_state(uint8_t pressed) {
cardboy::sdk::InputState state{};
if (pressed & BTN_UP)
state.up = true;
if (pressed & BTN_LEFT)
state.left = true;
if (pressed & BTN_RIGHT)
state.right = true;
if (pressed & BTN_DOWN)
state.down = true;
if (pressed & BTN_A)
state.a = true;
if (pressed & BTN_B)
state.b = true;
if (pressed & BTN_SELECT)
state.select = true;
if (pressed & BTN_START)
state.start = true;
return state;
}
void Buttons::pooler() {
while (true) {
BaseType_t xResult = xTaskNotifyWait(pdFALSE, ULONG_MAX, nullptr, portMAX_DELAY);
@@ -88,15 +109,27 @@ void Buttons::pooler() {
reg = 1;
ESP_ERROR_CHECK(
i2c_master_transmit_receive(dev_handle, &reg, sizeof(reg), reinterpret_cast<uint8_t*>(&buffer), 1, -1));
if (_listener)
xTaskNotifyGive(_listener);
if (_eventBus)
_eventBus->signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Input));
if (_eventBus) {
cardboy::sdk::AppButtonEvent button{};
button.current = buttons_to_input_state(_current);
button.previous = buttons_to_input_state(_previous);
_previous = _current;
cardboy::sdk::AppEvent evt{};
// TODO: dedup?
TickType_t ticks = xTaskGetTickCount();
auto now = static_cast<std::uint32_t>((static_cast<std::uint64_t>(ticks) * 1000ULL) / configTICK_RATE_HZ);
evt.timestamp_ms = now;
evt.data = button;
_eventBus->post(evt);
}
}
}
uint8_t Buttons::get_pressed() { return _current; }
void Buttons::install_isr() { gpio_isr_handler_add(EXP_INT, wakeup, nullptr); }
void Buttons::register_listener(TaskHandle_t task) { _listener = task; }
void Buttons::setEventBus(cardboy::sdk::IEventBus* bus) { _eventBus = bus; }
uint8_t Buttons::get_pressed() { return _current; }
void Buttons::install_isr() { gpio_isr_handler_add(EXP_INT, wakeup, nullptr); }
cardboy::sdk::InputState Buttons::get_state() { return buttons_to_input_state(get_pressed()); }
void Buttons::setEventBus(cardboy::sdk::IEventBus* bus) { _eventBus = bus; }

View File

@@ -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"
@@ -17,14 +18,22 @@
#include "esp_random.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "freertos/timers.h"
#include "nvs.h"
#include "nvs_flash.h"
#include <algorithm>
#include <atomic>
#include <cstdint>
#include <ctime>
#include <deque>
#include <list>
#include <mutex>
#include <string>
#include <string_view>
#include <vector>
namespace cardboy::backend::esp {
@@ -36,6 +45,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,33 +136,396 @@ public:
void onLoopIteration() override { vTaskDelay(1); }
};
class EspRuntime::EventBus final : public cardboy::sdk::IEventBus {
public:
explicit EventBus() {
_queueHandle =
xQueueCreateStatic(_kMaxQueueSize, sizeof(cardboy::sdk::AppEvent), _queueStorage.data(), &_queue);
}
~EventBus() override { vQueueDelete(_queueHandle); }
void post(const sdk::AppEvent& event) override { xQueueSendToBack(_queueHandle, &event, portMAX_DELAY); }
std::optional<sdk::AppEvent> pop(std::optional<std::uint32_t> timeout_ms = std::nullopt) override {
sdk::AppEvent out;
TickType_t ticks = timeout_ms ? pdMS_TO_TICKS(*timeout_ms) : portMAX_DELAY;
if (xQueueReceive(_queueHandle, &out, ticks) == pdTRUE) {
return out;
}
return std::nullopt;
}
private:
static constexpr std::uint32_t _kMaxQueueSize = 32;
StaticQueue_t _queue;
std::array<std::uint8_t, 32 * sizeof(cardboy::sdk::AppEvent)> _queueStorage{};
QueueHandle_t _queueHandle;
};
class EspRuntime::TimerService final : public cardboy::sdk::ITimerService {
public:
explicit TimerService(cardboy::sdk::IEventBus& appBus);
~TimerService() override;
cardboy::sdk::AppTimerHandle scheduleTimer(std::uint32_t delay_ms, bool repeat) override;
void cancelTimer(cardboy::sdk::AppTimerHandle handle) override;
void cancelAllTimers() override;
private:
static inline EspRuntime::TimerService* _current = nullptr;
struct InitializedSemaphore {
InitializedSemaphore() {
_handle = xSemaphoreCreateBinary();
xSemaphoreGive(_handle);
}
~InitializedSemaphore() { vSemaphoreDelete(_handle); }
SemaphoreHandle_t operator*() { return _handle; }
SemaphoreHandle_t operator->() { return _handle; }
private:
SemaphoreHandle_t _handle;
};
static inline InitializedSemaphore _currentSemaphore;
struct TimerRecord {
TimerService* owner = nullptr;
TimerHandle_t timer = nullptr;
cardboy::sdk::AppTimerHandle handle = cardboy::sdk::kInvalidAppTimer;
bool repeat = false;
};
static void timerCallback(TimerHandle_t timer);
void handleTimer(sdk::AppTimerHandle record);
cardboy::sdk::IEventBus& _appEventBus;
SemaphoreHandle_t _mutex;
StaticSemaphore_t _mutexStatic;
std::list<TimerRecord> _timers;
std::atomic<sdk::AppTimerHandle> _nextTimerHandle = 1;
static_assert(std::atomic<sdk::AppTimerHandle>::is_always_lock_free);
};
class EspRuntime::AppScopedServices final : public cardboy::sdk::AppScopedServices {
public:
AppScopedServices(std::unique_ptr<TimerService> timer) : _ownedTimer(std::move(timer)) {
this->timer = _ownedTimer.get();
}
private:
std::unique_ptr<TimerService> _ownedTimer;
};
class EspRuntime::AppServiceProvider final : public cardboy::sdk::IAppServiceProvider {
public:
explicit AppServiceProvider(cardboy::sdk::IEventBus& bus) : eventBus(bus) {}
[[nodiscard]] std::unique_ptr<cardboy::sdk::AppScopedServices>
createScopedServices(std::uint64_t generation) override {
(void) generation;
auto timer = std::make_unique<TimerService>(eventBus);
return std::make_unique<AppScopedServices>(std::move(timer));
}
private:
cardboy::sdk::IEventBus& eventBus;
};
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& appBus) : _appEventBus(appBus) {
xSemaphoreTake(*_currentSemaphore, portMAX_DELAY);
assert(_current == nullptr);
_mutex = xSemaphoreCreateBinaryStatic(&_mutexStatic);
assert(_mutex);
xSemaphoreGive(_mutex);
_current = this;
xSemaphoreGive(*_currentSemaphore);
}
EspRuntime::TimerService::~TimerService() {
xSemaphoreTake(*_currentSemaphore, portMAX_DELAY);
assert(_current == this);
_current = nullptr;
cancelAllTimers();
vSemaphoreDelete(_mutex);
xSemaphoreGive(*_currentSemaphore);
}
cardboy::sdk::AppTimerHandle EspRuntime::TimerService::scheduleTimer(std::uint32_t delay_ms, bool repeat) {
TimerRecord record{};
record.owner = this;
record.repeat = repeat;
cardboy::sdk::AppTimerHandle newHandle = cardboy::sdk::kInvalidAppTimer;
do {
newHandle = _nextTimerHandle++;
} while (newHandle == cardboy::sdk::kInvalidAppTimer);
if (_nextTimerHandle == cardboy::sdk::kInvalidAppTimer)
++_nextTimerHandle;
record.handle = newHandle;
xSemaphoreTake(_mutex, portMAX_DELAY);
TimerRecord* storedRecord = &_timers.emplace_back(record);
xSemaphoreGive(_mutex);
const TickType_t ticks = std::max<TickType_t>(pdMS_TO_TICKS(delay_ms), 1);
static_assert(sizeof(void*) >= sizeof(cardboy::sdk::AppTimerHandle));
TimerHandle_t timerHandle =
xTimerCreate("AppSvcTimer", ticks, repeat ? pdTRUE : pdFALSE, reinterpret_cast<void*>(storedRecord->handle),
&TimerService::timerCallback);
storedRecord->timer = timerHandle;
if (xTimerStart(timerHandle, portMAX_DELAY) != pdPASS) {
assert(false);
}
return newHandle;
}
void EspRuntime::TimerService::cancelTimer(cardboy::sdk::AppTimerHandle handle) {
assert(handle != sdk::kInvalidAppTimer);
TimerHandle_t timerHandle = nullptr;
{
xSemaphoreTake(_mutex, portMAX_DELAY);
for (auto it = _timers.begin(); it != _timers.end(); ++it) {
if (it->handle == handle) {
timerHandle = it->timer;
it = _timers.erase(it);
}
}
xSemaphoreGive(_mutex);
}
if (!timerHandle)
return;
xTimerStop(timerHandle, portMAX_DELAY);
xTimerDelete(timerHandle, portMAX_DELAY);
}
void EspRuntime::TimerService::cancelAllTimers() {
if (!_mutex)
return;
std::vector<TimerHandle_t> handles;
handles.resize(_timers.size());
{
xSemaphoreTake(_mutex, portMAX_DELAY);
size_t i = 0;
for (auto& record: _timers) {
if (record.timer) {
assert(record.timer);
handles[i] = record.timer;
}
i++;
}
_timers.clear();
xSemaphoreGive(_mutex);
}
for (auto timerHandle: handles) {
xTimerStop(timerHandle, portMAX_DELAY);
xTimerDelete(timerHandle, portMAX_DELAY);
}
}
void EspRuntime::TimerService::timerCallback(TimerHandle_t timer) {
auto handle = reinterpret_cast<sdk::AppTimerHandle>(pvTimerGetTimerID(timer));
xSemaphoreTake(*_currentSemaphore, portMAX_DELAY);
if (!_current)
return;
_current->handleTimer(handle);
xSemaphoreGive(*_currentSemaphore);
}
void EspRuntime::TimerService::handleTimer(sdk::AppTimerHandle handle) {
TimerHandle_t timerHandle = nullptr;
bool repeat = false;
{
xSemaphoreTake(_mutex, portMAX_DELAY);
for (auto it = _timers.begin(); it != _timers.end(); ++it) {
if (it->handle == handle) {
timerHandle = it->timer;
if (!it->repeat) {
_timers.erase(it);
} else {
repeat = true;
}
break;
}
}
xSemaphoreGive(_mutex);
}
if (!timerHandle) {
printf("Couldn't find handle for timer %lu\n", handle);
return;
}
if (!repeat && timerHandle)
xTimerDelete(timerHandle, portMAX_DELAY);
cardboy::sdk::AppTimerEvent timerEvent{};
timerEvent.handle = handle;
cardboy::sdk::AppEvent event{};
event.timestamp_ms = static_cast<std::uint32_t>(esp_timer_get_time() / 1000ULL);
event.data = timerEvent;
_appEventBus.post(event);
}
EspRuntime::EspRuntime() : framebuffer(), input(), clock() {
initializeHardware();
buzzerService = std::make_unique<BuzzerService>();
batteryService = std::make_unique<BatteryService>();
storageService = std::make_unique<StorageService>();
randomService = std::make_unique<RandomService>();
highResClockService = std::make_unique<HighResClockService>();
filesystemService = std::make_unique<FilesystemService>();
eventBus = std::make_unique<EventBus>();
loopHooksService = std::make_unique<LoopHooksService>();
_buzzerService = std::make_unique<BuzzerService>();
_batteryService = std::make_unique<BatteryService>();
_storageService = std::make_unique<StorageService>();
_randomService = std::make_unique<RandomService>();
_highResClockService = std::make_unique<HighResClockService>();
_filesystemService = std::make_unique<FilesystemService>();
_eventBus = std::make_unique<EventBus>();
_appServiceProvider = std::make_unique<AppServiceProvider>(*_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.appServices = _appServiceProvider.get();
_services.loopHooks = _loopHooksService.get();
_services.notifications = _notificationService.get();
Buttons::get().setEventBus(eventBus.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; }
cardboy::sdk::Services& EspRuntime::serviceRegistry() { return _services; }
void EspRuntime::initializeHardware() {
static bool initialized = false;
@@ -180,6 +553,8 @@ void EspRuntime::initializeHardware() {
Buzzer::get().init();
FsHelper::get().mount();
ensure_time_sync_service_started();
}
void EspFramebuffer::clear_impl(bool on) {
@@ -194,27 +569,7 @@ void EspFramebuffer::sendFrame_impl(bool clearAfterSend) { SMD::send_frame(clear
bool EspFramebuffer::frameInFlight_impl() const { return SMD::frame_transfer_in_flight(); }
cardboy::sdk::InputState EspInput::readState_impl() {
cardboy::sdk::InputState state{};
const uint8_t pressed = Buttons::get().get_pressed();
if (pressed & BTN_UP)
state.up = true;
if (pressed & BTN_LEFT)
state.left = true;
if (pressed & BTN_RIGHT)
state.right = true;
if (pressed & BTN_DOWN)
state.down = true;
if (pressed & BTN_A)
state.a = true;
if (pressed & BTN_B)
state.b = true;
if (pressed & BTN_SELECT)
state.select = true;
if (pressed & BTN_START)
state.start = true;
return state;
}
cardboy::sdk::InputState EspInput::readState_impl() { return Buttons::get().get_state(); }
std::uint32_t EspClock::millis_impl() {
TickType_t ticks = xTaskGetTickCount();

View File

@@ -1,81 +0,0 @@
#include "cardboy/backend/esp/event_bus.hpp"
#include "cardboy/sdk/event_bus.hpp"
#include "freertos/portmacro.h"
#include <algorithm>
namespace cardboy::backend::esp {
namespace {
[[nodiscard]] TickType_t toTicks(std::uint32_t timeout_ms) {
if (timeout_ms == cardboy::sdk::IEventBus::kWaitForever)
return portMAX_DELAY;
return pdMS_TO_TICKS(timeout_ms);
}
} // 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)) {}
EventBus::~EventBus() {
if (timer)
xTimerDelete(timer, portMAX_DELAY);
if (group)
vEventGroupDelete(group);
}
void EventBus::signal(std::uint32_t bits) {
if (!group || bits == 0)
return;
xEventGroupSetBits(group, bits);
}
void EventBus::signalFromISR(std::uint32_t bits) {
if (!group || bits == 0)
return;
BaseType_t higherPriorityTaskWoken = pdFALSE;
xEventGroupSetBitsFromISR(group, bits, &higherPriorityTaskWoken);
if (higherPriorityTaskWoken == pdTRUE)
portYIELD_FROM_ISR(higherPriorityTaskWoken);
}
std::uint32_t EventBus::wait(std::uint32_t mask, std::uint32_t timeout_ms) {
if (!group || mask == 0)
return 0;
const EventBits_t bits = xEventGroupWaitBits(group, mask, pdTRUE, pdFALSE, toTicks(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)
return;
if (delay_ms == 0) {
signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Timer));
return;
}
const TickType_t ticks = std::max<TickType_t>(pdMS_TO_TICKS(delay_ms), 1);
if (xTimerChangePeriod(timer, ticks, 0) == pdPASS)
xTimerStart(timer, 0);
}
void EventBus::cancelTimerSignal() {
if (!timer)
return;
xTimerStop(timer, 0);
}
} // namespace cardboy::backend::esp

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -4,6 +4,11 @@ project(cardboy_sdk LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED YES)
set(CMAKE_CXX_EXTENSIONS NO)
# add_compile_options(-Werror -O0 -Wall -Wextra -pedantic -Wno-unused-parameter -Wno-unused-variable
# -Wno-error=unused-function
# -Wshadow -Wformat=2 -Wfloat-equal -D_GLIBCXX_DEBUG -Wconversion)
#add_compile_options(-fsanitize=address -fno-sanitize-recover -D_GLIBCXX_DEBUG)
#add_link_options(-fsanitize=address -fno-sanitize-recover -D_GLIBCXX_DEBUG)
add_subdirectory(utils)

View File

@@ -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)

View File

@@ -17,7 +17,10 @@ namespace apps {
namespace {
using cardboy::sdk::AppButtonEvent;
using cardboy::sdk::AppTimeoutEvent;
using cardboy::sdk::AppContext;
using cardboy::sdk::AppTimerEvent;
constexpr const char* kClockAppName = "Clock";
@@ -47,21 +50,20 @@ public:
const auto snap = captureTime();
renderIfNeeded(snap);
lastSnapshot = snap;
refreshTimer = context.scheduleRepeatingTimer(200);
if (auto* timer = context.timer())
refreshTimer = timer->scheduleTimer(200, true);
}
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;
}
std::optional<std::uint32_t> 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)
updateDisplay();
},
[](const AppTimeoutEvent&) { /* ignore */ }));
return std::nullopt;
}
private:
@@ -76,10 +78,11 @@ private:
TimeSnapshot lastSnapshot{};
void cancelRefreshTimer() {
if (refreshTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(refreshTimer);
refreshTimer = cardboy::sdk::kInvalidAppTimer;
}
if (refreshTimer == cardboy::sdk::kInvalidAppTimer)
return;
if (auto* timer = context.timer())
timer->cancelTimer(refreshTimer);
refreshTimer = cardboy::sdk::kInvalidAppTimer;
}
void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) {

View File

@@ -151,9 +151,11 @@ 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::AppTimeoutEvent;
using cardboy::sdk::AppTimerEvent;
using cardboy::sdk::AppTimerHandle;
using cardboy::sdk::InputState;
using cardboy::sdk::kInvalidAppTimer;
@@ -223,7 +225,6 @@ public:
::gAudioWriteThunk = &GameboyApp::audioWriteThunk;
apu.attach(this);
apu.reset();
cancelTick();
frameDelayCarryUs = 0;
GB_PERF_ONLY(perf.resetAll();)
prevInput = context.input.readState();
@@ -232,13 +233,12 @@ public:
scaleMode = ScaleMode::FullHeightWide;
ensureFilesystemReady();
refreshRomList();
mode = Mode::Browse;
browserDirty = true;
scheduleNextTick(0);
mode = Mode::Browse;
browserDirty = true;
nextTimeoutMs = 0;
}
void onStop() override {
cancelTick();
frameDelayCarryUs = 0;
GB_PERF_ONLY(perf.maybePrintAggregate(true);)
unloadRom();
@@ -251,21 +251,22 @@ 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);
return;
}
if (event.type == AppEventType::Button) {
frameDelayCarryUs = 0;
scheduleNextTick(0);
}
std::optional<std::uint32_t> handleEvent(const AppEvent& event) override {
event.visit(cardboy::sdk::overload(
[this](const AppTimeoutEvent&) {
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;
nextTimeoutMs = 0;
},
[](const AppTimerEvent&) { /* ignore */ }));
return nextTimeoutMs;
}
void performStep() {
@@ -299,6 +300,9 @@ public:
break;
}
if (mode != Mode::Running)
break;
GB_PERF_ONLY(perf.geometryUs = 0;)
@@ -306,11 +310,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 +819,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 +948,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(
@@ -1059,9 +1113,9 @@ public:
cardboy::sdk::IFilesystem* filesystem = nullptr;
cardboy::sdk::IHighResClock* highResClock = nullptr;
PerfTracker perf{};
AppTimerHandle tickTimer = kInvalidAppTimer;
int64_t frameDelayCarryUs = 0;
static constexpr uint32_t kTargetFrameUs = 1000000 / 60; // ~16.6 ms
std::optional<std::uint32_t> nextTimeoutMs;
static constexpr uint32_t kTargetFrameUs = 1000000 / 60; // ~16.6 ms
Mode mode = Mode::Browse;
ScaleMode scaleMode = ScaleMode::FullHeightWide;
@@ -1085,24 +1139,16 @@ 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;
uint8_t lastLoud = 0;
uint32_t stableFrames = 0;
void cancelTick() {
if (tickTimer != kInvalidAppTimer) {
context.cancelTimer(tickTimer);
tickTimer = kInvalidAppTimer;
}
}
void scheduleNextTick(uint32_t delayMs) {
cancelTick();
tickTimer = context.scheduleTimer(delayMs, false);
}
uint32_t idleDelayMs() const { return browserDirty ? 50 : 140; }
void scheduleAfterFrame(uint64_t elapsedUs) {
@@ -1111,17 +1157,17 @@ public:
desiredUs += frameDelayCarryUs;
if (desiredUs <= 0) {
frameDelayCarryUs = desiredUs;
scheduleNextTick(0);
nextTimeoutMs = 0;
return;
}
frameDelayCarryUs = desiredUs % 1000;
desiredUs -= frameDelayCarryUs;
uint32_t delayMs = static_cast<uint32_t>(desiredUs / 1000);
scheduleNextTick(delayMs);
nextTimeoutMs = delayMs;
return;
}
frameDelayCarryUs = 0;
scheduleNextTick(idleDelayMs());
nextTimeoutMs = idleDelayMs();
}
bool ensureFilesystemReady() {
@@ -1423,21 +1469,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 +1502,10 @@ public:
if (!fsReady)
statusText.append(" (no save)");
setStatus(std::move(statusText));
if (stateFileExists())
enterPrompt(PromptKind::LoadState);
return true;
}
@@ -1464,6 +1518,10 @@ public:
cartRam.clear();
activeRomName.clear();
activeRomSavePath.clear();
activeRomStatePath.clear();
promptKind = PromptKind::None;
promptSelection = 0;
promptDirty = false;
return;
}
@@ -1477,6 +1535,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 +1553,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 +1585,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 +1756,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 +1987,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 +2189,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) {

View 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
)

View File

@@ -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

View File

@@ -0,0 +1,609 @@
#include "cardboy/apps/lockscreen_app.hpp"
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/gfx/font16x8.hpp"
#include "cardboy/sdk/app_framework.hpp"
#include "cardboy/sdk/app_system.hpp"
#include <algorithm>
#include <array>
#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::AppTimeoutEvent;
using cardboy::sdk::AppTimerEvent;
constexpr std::uint32_t kRefreshIntervalMs = 100;
constexpr std::uint32_t kSlowRefreshMs = 1000;
constexpr std::uint32_t kFastRefreshMs = 20;
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();
lastRefreshMs = clock.millis();
refreshNotifications();
const auto snap = captureTime();
renderIfNeeded(snap);
lastSnapshot = snap;
rescheduleRefreshTimer(kSlowRefreshMs);
}
void onStop() override { cancelRefreshTimer(); }
std::optional<std::uint32_t> 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) {
const std::uint32_t now = clock.millis();
const std::uint32_t elapsed = now - lastRefreshMs;
lastRefreshMs = now;
advanceHoldProgress(elapsed);
updateDisplay();
}
},
[](const AppTimeoutEvent&) { /* ignore */ }));
return std::nullopt;
}
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;
std::uint32_t lastRefreshMs = 0;
void cancelRefreshTimer() {
if (refreshTimer == cardboy::sdk::kInvalidAppTimer)
return;
if (auto* timer = context.timer())
timer->cancelTimer(refreshTimer);
refreshTimer = cardboy::sdk::kInvalidAppTimer;
}
void rescheduleRefreshTimer(std::uint32_t intervalMs) {
cancelRefreshTimer();
if (auto* timer = context.timer())
refreshTimer = timer->scheduleTimer(intervalMs, true);
}
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) {
const bool wasActive = holdActive;
if (comboNow) {
if (!holdActive) {
holdActive = true;
holdProgressMs = 0;
lastRefreshMs = clock.millis();
dirty = true;
}
} else {
if (holdActive || holdProgressMs != 0) {
holdActive = false;
holdProgressMs = 0;
dirty = true;
}
}
if (wasActive != holdActive) {
rescheduleRefreshTimer(holdActive ? kFastRefreshMs : kSlowRefreshMs);
}
}
void advanceHoldProgress(std::uint32_t elapsedMs) {
if (holdActive) {
const std::uint32_t next = std::min<std::uint32_t>(holdProgressMs + elapsedMs, 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

View File

@@ -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,16 @@ namespace apps {
namespace {
using cardboy::sdk::AppButtonEvent;
using cardboy::sdk::AppTimeoutEvent;
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 +39,44 @@ 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;
std::optional<std::uint32_t> 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);
}
},
[](const AppTimeoutEvent&) { /* ignore */ }));
return std::nullopt;
}
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 +91,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 +122,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 +189,20 @@ private:
framebuffer.sendFrame();
}
void cancelInactivityTimer() {
if (inactivityTimer == cardboy::sdk::kInvalidAppTimer)
return;
if (auto* timer = context.timer())
timer->cancelTimer(inactivityTimer);
inactivityTimer = cardboy::sdk::kInvalidAppTimer;
}
void resetInactivityTimer() {
cancelInactivityTimer();
if (auto* timer = context.timer())
inactivityTimer = timer->scheduleTimer(kIdleTimeoutMs, false);
}
};
class MenuAppFactory final : public cardboy::sdk::IAppFactory {

View File

@@ -38,41 +38,44 @@ public:
renderIfNeeded();
}
void handleEvent(const cardboy::sdk::AppEvent& event) override {
if (event.type != cardboy::sdk::AppEventType::Button)
return;
std::optional<std::uint32_t> handleEvent(const cardboy::sdk::AppEvent& event) override {
event.visit(cardboy::sdk::overload(
[this](const cardboy::sdk::AppButtonEvent& button) {
const auto& current = button.current;
const auto& previous = button.previous;
const auto& current = event.button.current;
const auto& previous = event.button.previous;
const bool previousAvailable = buzzerAvailable;
syncBuzzerState();
if (previousAvailable != buzzerAvailable)
dirty = true;
const bool previousAvailable = buzzerAvailable;
syncBuzzerState();
if (previousAvailable != buzzerAvailable)
dirty = true;
if (current.b && !previous.b) {
context.requestAppSwitchByName(kMenuAppName);
return;
}
if (current.b && !previous.b) {
context.requestAppSwitchByName(kMenuAppName);
return;
}
bool moved = false;
if (current.down && !previous.down) {
moveSelection(+1);
moved = true;
} else if (current.up && !previous.up) {
moveSelection(-1);
moved = true;
}
bool moved = false;
if (current.down && !previous.down) {
moveSelection(+1);
moved = true;
} else if (current.up && !previous.up) {
moveSelection(-1);
moved = true;
}
const bool togglePressed = (current.a && !previous.a) || (current.start && !previous.start) ||
(current.select && !previous.select);
if (togglePressed)
handleToggle();
const bool togglePressed = (current.a && !previous.a) || (current.start && !previous.start) ||
(current.select && !previous.select);
if (togglePressed)
handleToggle();
if (moved)
dirty = true;
if (moved)
dirty = true;
renderIfNeeded();
renderIfNeeded();
},
[](const cardboy::sdk::AppTimerEvent&) { /* ignore */ },
[](const cardboy::sdk::AppTimeoutEvent&) { /* ignore */ }));
return std::nullopt;
}
private:

View 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
)

View 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

View File

@@ -0,0 +1,429 @@
#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::AppTimeoutEvent;
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(); }
std::optional<std::uint32_t> handleEvent(const AppEvent& event) {
event.visit(cardboy::sdk::overload([this](const AppButtonEvent& button) { handleButtons(button); },
[this](const AppTimerEvent& timer) { handleTimer(timer.handle); },
[](const AppTimeoutEvent&) { /* ignore */ }));
renderIfNeeded();
return std::nullopt;
}
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();
if (auto* timer = context.timer())
moveTimer = timer->scheduleTimer(interval, true);
}
void cancelMoveTimer() {
if (moveTimer == cardboy::sdk::kInvalidAppTimer)
return;
if (auto* timer = context.timer())
timer->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(); }
std::optional<std::uint32_t> handleEvent(const AppEvent& event) override { return 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

View File

@@ -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;
@@ -148,16 +148,12 @@ 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;
}
std::optional<std::uint32_t> handleEvent(const AppEvent& event) {
event.visit(cardboy::sdk::overload([this](const AppButtonEvent& button) { handleButtons(button); },
[this](const AppTimerEvent& timer) { handleTimer(timer.handle); },
[](const cardboy::sdk::AppTimeoutEvent&) { /* ignore */ }));
renderIfNeeded();
return std::nullopt;
}
private:
@@ -240,35 +236,40 @@ private:
void cancelTimers() {
if (dropTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(dropTimer);
if (auto* timer = context.timer())
timer->cancelTimer(dropTimer);
dropTimer = cardboy::sdk::kInvalidAppTimer;
}
cancelSoftDropTimer();
}
void cancelSoftDropTimer() {
if (softTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(softTimer);
softTimer = cardboy::sdk::kInvalidAppTimer;
}
if (softTimer == cardboy::sdk::kInvalidAppTimer)
return;
if (auto* timer = context.timer())
timer->cancelTimer(softTimer);
softTimer = cardboy::sdk::kInvalidAppTimer;
}
void scheduleDropTimer() {
cancelDropTimer();
const std::uint32_t interval = dropIntervalMs();
dropTimer = context.scheduleRepeatingTimer(interval);
if (auto* timer = context.timer())
dropTimer = timer->scheduleTimer(interval, true);
}
void cancelDropTimer() {
if (dropTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(dropTimer);
dropTimer = cardboy::sdk::kInvalidAppTimer;
}
if (dropTimer == cardboy::sdk::kInvalidAppTimer)
return;
if (auto* timer = context.timer())
timer->cancelTimer(dropTimer);
dropTimer = cardboy::sdk::kInvalidAppTimer;
}
void scheduleSoftDropTimer() {
cancelSoftDropTimer();
softTimer = context.scheduleRepeatingTimer(60);
if (auto* timer = context.timer())
softTimer = timer->scheduleTimer(60, true);
}
[[nodiscard]] std::uint32_t dropIntervalMs() const {
@@ -634,9 +635,9 @@ class TetrisApp final : public cardboy::sdk::IApp {
public:
explicit TetrisApp(AppContext& ctx) : game(ctx) {}
void onStart() override { game.onStart(); }
void onStop() override { game.onStop(); }
void handleEvent(const AppEvent& event) override { game.handleEvent(event); }
void onStart() override { game.onStart(); }
void onStop() override { game.onStop(); }
std::optional<std::uint32_t> handleEvent(const AppEvent& event) override { return game.handleEvent(event); }
private:
TetrisGame game;

View File

@@ -16,7 +16,6 @@ target_sources(cardboy_backend_interface
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/backend/backend_interface.hpp
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/backend.hpp
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/display_spec.hpp
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/event_bus.hpp
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/loop_hooks.hpp
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/input_state.hpp
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/platform.hpp

View File

@@ -0,0 +1,71 @@
#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;
struct AppButtonEvent {
InputState current{};
InputState previous{};
};
struct AppTimerEvent {
AppTimerHandle handle = kInvalidAppTimer;
};
struct AppTimeoutEvent {};
struct AppEvent {
using Data = std::variant<AppButtonEvent, AppTimerEvent, AppTimeoutEvent>;
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]] bool isTimeout() const { return std::holds_alternative<AppTimeoutEvent>(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); }
[[nodiscard]] const AppTimeoutEvent* timeout() const { return std::get_if<AppTimeoutEvent>(&data); }
[[nodiscard]] AppTimeoutEvent* timeout() { return std::get_if<AppTimeoutEvent>(&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);
}
};
static_assert(std::is_trivially_copyable_v<AppEvent>);
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

View File

@@ -1,42 +0,0 @@
#pragma once
#include <cstdint>
namespace cardboy::sdk {
enum class EventBusSignal : std::uint32_t {
None = 0,
Input = 1u << 0,
Timer = 1u << 1,
External = 1u << 2,
};
inline EventBusSignal operator|(EventBusSignal lhs, EventBusSignal rhs) {
return static_cast<EventBusSignal>(static_cast<std::uint32_t>(lhs) | static_cast<std::uint32_t>(rhs));
}
inline EventBusSignal& operator|=(EventBusSignal& lhs, EventBusSignal rhs) {
lhs = lhs | rhs;
return lhs;
}
inline EventBusSignal operator&(EventBusSignal lhs, EventBusSignal rhs) {
return static_cast<EventBusSignal>(static_cast<std::uint32_t>(lhs) & static_cast<std::uint32_t>(rhs));
}
inline std::uint32_t to_event_bits(EventBusSignal signal) { return static_cast<std::uint32_t>(signal); }
class IEventBus {
public:
static constexpr std::uint32_t kWaitForever = 0xFFFFFFFFu;
virtual ~IEventBus() = default;
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;
};
} // namespace cardboy::sdk

View File

@@ -11,8 +11,8 @@ public:
static void invokePreSend(void* framebuffer);
private:
static PreSendHook hook_;
static void* userData_;
static PreSendHook _hook;
static void* _userData;
};
} // namespace cardboy::sdk

View File

@@ -11,6 +11,13 @@ struct InputState {
bool b = false;
bool select = false;
bool start = false;
bool operator==(const InputState& other) const {
return up == other.up && left == other.left && right == other.right && down == other.down && a == other.a &&
b == other.b && select == other.select && start == other.start;
}
bool operator!=(const InputState& other) const { return !(*this == other); }
};
} // namespace cardboy::sdk

View File

@@ -1,11 +1,14 @@
#pragma once
#include "cardboy/sdk/event_bus.hpp"
#include "cardboy/sdk/loop_hooks.hpp"
#include "cardboy/sdk/timer_service.hpp"
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace cardboy::sdk {
@@ -69,15 +72,59 @@ 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;
};
class IEventBus {
public:
virtual ~IEventBus() = default;
virtual void post(const AppEvent& event) = 0;
virtual std::optional<AppEvent> pop(std::optional<std::uint32_t> timeout_ms = std::nullopt) = 0;
};
struct AppScopedServices {
ITimerService* timer = nullptr;
virtual ~AppScopedServices() = default;
};
class IAppServiceProvider {
public:
virtual ~IAppServiceProvider() = default;
[[nodiscard]] virtual std::unique_ptr<AppScopedServices> createScopedServices(std::uint64_t generation) = 0;
};
struct Services {
IBuzzer* buzzer = nullptr;
IBatteryMonitor* battery = nullptr;
IStorage* storage = nullptr;
IRandom* random = nullptr;
IHighResClock* highResClock = nullptr;
IFilesystem* filesystem = nullptr;
IEventBus* eventBus = nullptr;
ILoopHooks* loopHooks = nullptr;
IBuzzer* buzzer = nullptr;
IBatteryMonitor* battery = nullptr;
IStorage* storage = nullptr;
IRandom* random = nullptr;
IHighResClock* highResClock = nullptr;
IFilesystem* filesystem = nullptr;
IEventBus* eventBus = nullptr;
ILoopHooks* loopHooks = nullptr;
INotificationCenter* notifications = nullptr;
IAppServiceProvider* appServices = nullptr;
};
} // namespace cardboy::sdk

View File

@@ -0,0 +1,18 @@
#pragma once
#include "cardboy/sdk/app_events.hpp"
#include <cstdint>
namespace cardboy::sdk {
class ITimerService {
public:
virtual ~ITimerService() = default;
virtual AppTimerHandle scheduleTimer(std::uint32_t delay_ms, bool repeat) = 0;
virtual void cancelTimer(AppTimerHandle handle) = 0;
virtual void cancelAllTimers() = 0;
};
} // namespace cardboy::sdk

View File

@@ -1,6 +1,5 @@
#pragma once
#include "cardboy/sdk/event_bus.hpp"
#include "cardboy/sdk/platform.hpp"
#include "cardboy/sdk/services.hpp"
@@ -9,8 +8,10 @@
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <filesystem>
#include <limits>
#include <memory>
#include <mutex>
#include <random>
#include <string>
@@ -86,27 +87,92 @@ private:
bool mounted = false;
};
class DesktopEventBus final : public cardboy::sdk::IEventBus {
class DesktopNotificationCenter final : public cardboy::sdk::INotificationCenter {
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 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:
DesktopRuntime& runtime;
std::mutex mutex;
std::condition_variable cv;
std::uint32_t pendingBits = 0;
static constexpr std::size_t kMaxEntries = 8;
std::mutex timerMutex;
std::condition_variable timerCv;
std::thread timerThread;
bool timerCancel = false;
mutable std::mutex mutex;
std::vector<Notification> entries;
std::uint64_t nextId = 1;
std::uint32_t revisionCounter = 0;
};
class DesktopLoopHooks final : public cardboy::sdk::ILoopHooks {
public:
explicit DesktopLoopHooks(DesktopRuntime& owner);
void onLoopIteration() override;
private:
DesktopRuntime& runtime;
};
class DesktopEventBus final : public cardboy::sdk::IEventBus {
public:
void post(const cardboy::sdk::AppEvent& event) override;
std::optional<cardboy::sdk::AppEvent> pop(std::optional<std::uint32_t> timeout_ms = std::nullopt) override;
private:
std::mutex mutex;
std::condition_variable cv;
std::deque<cardboy::sdk::AppEvent> queue;
};
class DesktopTimerService final : public cardboy::sdk::ITimerService {
public:
DesktopTimerService(DesktopRuntime& owner, cardboy::sdk::IEventBus& eventBus);
~DesktopTimerService() override;
cardboy::sdk::AppTimerHandle scheduleTimer(std::uint32_t delay_ms, bool repeat) override;
void cancelTimer(cardboy::sdk::AppTimerHandle handle) override;
void cancelAllTimers() override;
private:
struct TimerRecord {
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;
};
class DesktopAppServiceProvider final : public cardboy::sdk::IAppServiceProvider {
public:
DesktopAppServiceProvider(DesktopRuntime& owner, cardboy::sdk::IEventBus& bus);
[[nodiscard]] std::unique_ptr<cardboy::sdk::AppScopedServices>
createScopedServices(std::uint64_t generation) override;
private:
struct ScopedServices final : cardboy::sdk::AppScopedServices {
std::unique_ptr<DesktopTimerService> ownedTimer;
};
DesktopRuntime& runtime;
cardboy::sdk::IEventBus& eventBus;
};
class DesktopFramebuffer final : public cardboy::sdk::FramebufferFacade<DesktopFramebuffer> {
@@ -180,14 +246,17 @@ private:
bool running = true;
bool clearNextFrame = true;
DesktopBuzzer buzzerService;
DesktopBattery batteryService;
DesktopStorage storageService;
DesktopRandom randomService;
DesktopHighResClock highResService;
DesktopFilesystem filesystemService;
DesktopEventBus eventBusService;
cardboy::sdk::Services services{};
DesktopBuzzer buzzerService;
DesktopBattery batteryService;
DesktopStorage storageService;
DesktopRandom randomService;
DesktopHighResClock highResService;
DesktopFilesystem filesystemService;
DesktopEventBus eventBusService;
DesktopAppServiceProvider appServiceProvider;
DesktopNotificationCenter notificationService;
DesktopLoopHooks loopHooksService;
cardboy::sdk::Services services{};
};
struct Backend {

View File

@@ -8,95 +8,167 @@
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <ctime>
#include <iostream>
#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(); }
void DesktopEventBus::post(const cardboy::sdk::AppEvent& event) {
std::lock_guard<std::mutex> lock(mutex);
queue.push_back(event);
cv.notify_one();
}
void DesktopEventBus::signal(std::uint32_t bits) {
if (bits == 0)
return;
std::optional<cardboy::sdk::AppEvent> DesktopEventBus::pop(std::optional<std::uint32_t> timeout_ms) {
std::unique_lock<std::mutex> lock(mutex);
if (queue.empty()) {
if (!timeout_ms) {
return std::nullopt;
}
auto timeout = std::chrono::milliseconds(*timeout_ms);
cv.wait_for(lock, timeout, [this] { return !queue.empty(); });
if (queue.empty()) {
return std::nullopt;
}
}
auto event = queue.front();
queue.pop_front();
return event;
}
DesktopTimerService::DesktopTimerService(DesktopRuntime& owner, cardboy::sdk::IEventBus& eventBus) :
runtime(owner), eventBus(eventBus) {
worker = std::thread(&DesktopTimerService::workerLoop, this);
}
DesktopTimerService::~DesktopTimerService() {
{
std::lock_guard<std::mutex> lock(mutex);
pendingBits |= bits;
stopWorker = true;
}
cv.notify_all();
if (worker.joinable())
worker.join();
cancelAllTimers();
}
void DesktopEventBus::signalFromISR(std::uint32_t bits) { signal(bits); }
cardboy::sdk::AppTimerHandle DesktopTimerService::scheduleTimer(std::uint32_t delay_ms, bool repeat) {
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 ? std::max(delay_ms, 1u) : std::max(delay_ms, 1u)));
std::uint32_t DesktopEventBus::wait(std::uint32_t mask, std::uint32_t timeout_ms) {
if (mask == 0)
return 0;
TimerRecord record{};
record.repeat = repeat;
record.interval = interval;
record.due = dueTime;
record.active = true;
const auto start = std::chrono::steady_clock::now();
const bool infinite = timeout_ms == cardboy::sdk::IEventBus::kWaitForever;
{
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;
}
}
while (true) {
{
std::lock_guard<std::mutex> lock(mutex);
const std::uint32_t ready = pendingBits & mask;
if (ready != 0) {
pendingBits &= ~mask;
return ready;
void DesktopTimerService::cancelTimer(cardboy::sdk::AppTimerHandle handle) {
if (handle == cardboy::sdk::kInvalidAppTimer)
return;
std::lock_guard<std::mutex> lock(mutex);
for (auto& record: timers) {
if (record.handle == handle)
record.active = false;
}
cleanupInactive();
wakeWorker();
}
void DesktopTimerService::cancelAllTimers() {
std::lock_guard<std::mutex> lock(mutex);
for (auto& record: timers) {
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.handle = record.handle;
cardboy::sdk::AppEvent event{};
event.timestamp_ms = runtime.clock.millis();
event.data = timerEvent;
eventBus.post(event);
lock.lock();
continue;
}
if (!infinite) {
const auto now = std::chrono::steady_clock::now();
const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(now - start).count();
if (elapsedMs >= static_cast<std::int64_t>(timeout_ms))
return 0;
const auto remaining = timeout_ms - static_cast<std::uint32_t>(elapsedMs);
runtime.sleepFor(std::min<std::uint32_t>(remaining, 8));
} else {
runtime.sleepFor(8);
}
cv.wait_until(lock, nextIt->due, [this] { return stopWorker; });
}
}
void DesktopEventBus::scheduleTimerSignal(std::uint32_t delay_ms) {
cancelTimerSignal();
void DesktopTimerService::wakeWorker() { cv.notify_all(); }
if (delay_ms == cardboy::sdk::IEventBus::kWaitForever)
return;
if (delay_ms == 0) {
signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Timer));
return;
}
{
std::lock_guard<std::mutex> lock(timerMutex);
timerCancel = false;
}
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));
});
void DesktopTimerService::cleanupInactive() {
timers.erase(std::remove_if(timers.begin(), timers.end(), [](const TimerRecord& record) { return !record.active; }),
timers.end());
}
void DesktopEventBus::cancelTimerSignal() {
{
std::lock_guard<std::mutex> lock(timerMutex);
timerCancel = true;
}
timerCv.notify_all();
if (timerThread.joinable())
timerThread.join();
{
std::lock_guard<std::mutex> lock(timerMutex);
timerCancel = false;
}
DesktopAppServiceProvider::DesktopAppServiceProvider(DesktopRuntime& owner, cardboy::sdk::IEventBus& bus) :
runtime(owner), eventBus(bus) {}
std::unique_ptr<cardboy::sdk::AppScopedServices>
DesktopAppServiceProvider::createScopedServices(std::uint64_t generation) {
(void) generation;
auto scoped = std::make_unique<ScopedServices>();
scoped->ownedTimer = std::make_unique<DesktopTimerService>(runtime, eventBus);
scoped->timer = scoped->ownedTimer.get();
return scoped;
}
bool DesktopStorage::readUint32(std::string_view ns, std::string_view key, std::uint32_t& out) {
@@ -149,6 +221,108 @@ 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;
}
DesktopLoopHooks::DesktopLoopHooks(DesktopRuntime& owner) : runtime(owner) {}
void DesktopLoopHooks::onLoopIteration() {
runtime.processEvents();
runtime.presentIfNeeded();
}
DesktopFramebuffer::DesktopFramebuffer(DesktopRuntime& runtime) : runtime(runtime) {}
int DesktopFramebuffer::width_impl() const { return cardboy::sdk::kDisplayWidth; }
@@ -176,26 +350,29 @@ DesktopInput::DesktopInput(DesktopRuntime& runtime) : runtime(runtime) {}
cardboy::sdk::InputState DesktopInput::readState_impl() { return state; }
void DesktopInput::handleKey(sf::Keyboard::Key key, bool pressed) {
bool handled = true;
const auto oldState = state;
bool handled = true;
switch (key) {
case sf::Keyboard::Key::Up:
case sf::Keyboard::Key::W:
state.up = pressed;
break;
case sf::Keyboard::Key::Down:
case sf::Keyboard::Key::S:
state.down = pressed;
break;
case sf::Keyboard::Key::Left:
case sf::Keyboard::Key::A:
state.left = pressed;
break;
case sf::Keyboard::Key::Right:
case sf::Keyboard::Key::D:
state.right = pressed;
break;
case sf::Keyboard::Key::Z:
case sf::Keyboard::Key::A:
state.a = pressed;
break;
case sf::Keyboard::Key::X:
case sf::Keyboard::Key::S:
state.b = pressed;
break;
case sf::Keyboard::Key::Space:
@@ -208,8 +385,11 @@ void DesktopInput::handleKey(sf::Keyboard::Key key, bool pressed) {
handled = false;
break;
}
if (handled)
runtime.eventBusService.signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Input));
if (handled && oldState != state) {
cardboy::sdk::AppButtonEvent btnEvent{oldState, state};
cardboy::sdk::AppEvent event{runtime.clock.millis(), btnEvent};
runtime.eventBusService.post(event);
}
}
DesktopClock::DesktopClock(DesktopRuntime& runtime) : runtime(runtime), start(std::chrono::steady_clock::now()) {}
@@ -227,23 +407,29 @@ 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(), appServiceProvider(*this, eventBusService),
loopHooksService(*this) {
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");
sprite.setTexture(texture, true);
sprite.setScale(sf::Vector2f{static_cast<float>(kPixelScale), static_cast<float>(kPixelScale)});
clearPixels(true);
clearPixels(false);
presentIfNeeded();
window.requestFocus();
services.buzzer = &buzzerService;
services.battery = &batteryService;
services.storage = &storageService;
services.random = &randomService;
services.highResClock = &highResService;
services.filesystem = &filesystemService;
services.eventBus = &eventBusService;
services.loopHooks = nullptr;
std::cout << "Desktop window initialized and presented." << std::endl;
services.buzzer = &buzzerService;
services.battery = &batteryService;
services.storage = &storageService;
services.random = &randomService;
services.highResClock = &highResService;
services.filesystem = &filesystemService;
services.eventBus = &eventBusService;
services.appServices = &appServiceProvider;
services.loopHooks = &loopHooksService;
services.notifications = &notificationService;
}
cardboy::sdk::Services& DesktopRuntime::serviceRegistry() { return services; }

View File

@@ -1,11 +1,13 @@
#pragma once
#include <cardboy/sdk/app_events.hpp>
#include <cardboy/sdk/backend.hpp>
#include <cardboy/sdk/platform.hpp>
#include <cardboy/sdk/services.hpp>
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
@@ -14,30 +16,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 {
@@ -56,72 +34,52 @@ struct AppContext {
[[nodiscard]] Services* getServices() const { return services; }
[[nodiscard]] IBuzzer* buzzer() const { return services ? services->buzzer : nullptr; }
[[nodiscard]] IBatteryMonitor* battery() const { return services ? services->battery : nullptr; }
[[nodiscard]] IStorage* storage() const { return services ? services->storage : nullptr; }
[[nodiscard]] IRandom* random() const { return services ? services->random : nullptr; }
[[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]] ILoopHooks* loopHooks() const { return services ? services->loopHooks : nullptr; }
[[nodiscard]] IBuzzer* buzzer() const { return services ? services->buzzer : nullptr; }
[[nodiscard]] IBatteryMonitor* battery() const { return services ? services->battery : nullptr; }
[[nodiscard]] IStorage* storage() const { return services ? services->storage : nullptr; }
[[nodiscard]] IRandom* random() const { return services ? services->random : nullptr; }
[[nodiscard]] IHighResClock* highResClock() const { return services ? services->highResClock : nullptr; }
[[nodiscard]] IFilesystem* filesystem() const { return services ? services->filesystem : nullptr; }
[[nodiscard]] AppScopedServices* appServices() const { return _scopedServices; }
[[nodiscard]] IEventBus* eventBus() const { return services ? services->eventBus : nullptr; }
[[nodiscard]] ITimerService* timer() const { return _scopedServices ? _scopedServices->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;
pendingAppName.clear();
pendingSwitchByName = false;
pendingSwitch = true;
_pendingAppIndex = index;
_pendingAppName.clear();
_pendingSwitchByName = false;
_pendingSwitch = true;
}
void requestAppSwitchByName(std::string_view name) {
pendingAppName.assign(name.begin(), name.end());
pendingSwitchByName = true;
pendingSwitch = true;
_pendingAppName.assign(name.begin(), name.end());
_pendingSwitchByName = true;
_pendingSwitch = true;
}
[[nodiscard]] bool hasPendingAppSwitch() const { return pendingSwitch; }
AppTimerHandle scheduleTimer(std::uint32_t delay_ms, bool repeat = false) {
if (!system)
return kInvalidAppTimer;
return scheduleTimerInternal(delay_ms, repeat);
}
AppTimerHandle scheduleRepeatingTimer(std::uint32_t interval_ms) {
if (!system)
return kInvalidAppTimer;
return scheduleTimerInternal(interval_ms, true);
}
void cancelTimer(AppTimerHandle handle) {
if (!system)
return;
cancelTimerInternal(handle);
}
void cancelAllTimers() {
if (!system)
return;
cancelAllTimersInternal();
}
[[nodiscard]] bool hasPendingAppSwitch() const { return _pendingSwitch; }
private:
friend class AppSystem;
bool pendingSwitch = false;
bool pendingSwitchByName = false;
std::size_t pendingAppIndex = 0;
std::string pendingAppName;
bool _pendingSwitch = false;
bool _pendingSwitchByName = false;
std::size_t _pendingAppIndex = 0;
std::string _pendingAppName;
AppScopedServices* _scopedServices = nullptr;
AppTimerHandle scheduleTimerInternal(std::uint32_t delay_ms, bool repeat);
void cancelTimerInternal(AppTimerHandle handle);
void cancelAllTimersInternal();
void setScopedServices(AppScopedServices* services) { _scopedServices = services; }
};
class IApp {
public:
virtual ~IApp() = default;
virtual void onStart() {}
virtual void onStop() {}
virtual void handleEvent(const AppEvent& event) = 0;
virtual void onStart() {}
virtual void onStop() {}
virtual std::optional<std::uint32_t> handleEvent(const AppEvent& event) = 0;
};
class IAppFactory {

View File

@@ -1,6 +1,5 @@
#pragma once
#include <cardboy/sdk/event_bus.hpp>
#include "app_framework.hpp"
#include <cstdint>
@@ -16,68 +15,31 @@ public:
~AppSystem();
void registerApp(std::unique_ptr<IAppFactory> factory);
bool startApp(const std::string& name);
bool startAppByIndex(std::size_t index);
void startApp(const std::string& name);
void startAppByIndex(std::size_t index);
void run();
[[nodiscard]] std::size_t appCount() const { return factories.size(); }
[[nodiscard]] std::size_t appCount() const { return _factories.size(); }
[[nodiscard]] const IAppFactory* factoryAt(std::size_t index) const;
[[nodiscard]] std::size_t indexOfFactory(const IAppFactory* factory) const;
[[nodiscard]] std::size_t currentFactoryIndex() const { return activeIndex; }
[[nodiscard]] std::size_t currentFactoryIndex() const { return _activeIndex; }
[[nodiscard]] const IApp* currentApp() const { return current.get(); }
[[nodiscard]] const IAppFactory* currentFactory() const { return activeFactory; }
[[nodiscard]] const IApp* currentApp() const { return _current.get(); }
[[nodiscard]] const IAppFactory* currentFactory() const { return _activeFactory; }
private:
friend struct AppContext;
void handlePendingSwitchRequest();
std::unique_ptr<AppScopedServices> createAppScopedServices(std::uint64_t generation);
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);
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;
InputState lastInputState{};
bool suppressInputs = false;
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::unique_ptr<AppScopedServices> _scopedServices;
std::uint64_t _nextScopedGeneration = 1;
std::optional<std::uint32_t> _currentTimeout;
};
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

View File

@@ -13,8 +13,8 @@ public:
static void invokePreSend(void* framebuffer);
private:
static PreSendHook hook_;
static void* userData_;
static PreSendHook _hook;
static void* _userData;
};
} // namespace cardboy::sdk

View File

@@ -17,11 +17,11 @@ class StatusBar {
public:
static StatusBar& instance();
void setServices(Services* services) { services_ = services; }
void setServices(Services* services) { _services = services; }
void setEnabled(bool value);
void toggle();
[[nodiscard]] bool isEnabled() const { return enabled_; }
[[nodiscard]] bool isEnabled() const { return _enabled; }
void setCurrentAppName(std::string_view name);
@@ -29,7 +29,7 @@ public:
template<typename Framebuffer>
void renderIfEnabled(Framebuffer& fb) {
if (!enabled_)
if (!_enabled)
return;
renderBar(fb);
}
@@ -84,9 +84,9 @@ private:
[[nodiscard]] std::string prepareLeftText(int displayWidth) const;
[[nodiscard]] std::string prepareRightText() const;
bool enabled_ = false;
Services* services_ = nullptr;
std::string appName_{};
bool _enabled = false;
Services* _services = nullptr;
std::string _appName{};
};
} // namespace cardboy::sdk

View File

@@ -3,19 +3,11 @@
#include "cardboy/sdk/status_bar.hpp"
#include <algorithm>
#include <limits>
#include <optional>
#include <utility>
namespace cardboy::sdk {
namespace {
[[nodiscard]] bool inputsDiffer(const InputState& a, const InputState& b) {
return a.up != b.up || a.down != b.down || a.left != b.left || a.right != b.right || a.a != b.a || a.b != b.b ||
a.select != b.select || a.start != b.start;
}
[[nodiscard]] bool anyButtonPressed(const InputState& state) {
return state.up || state.down || state.left || state.right || state.a || state.b || state.select || state.start;
}
template<typename Framebuffer>
void statusBarPreSendHook(void* framebuffer, void* userData) {
@@ -26,263 +18,128 @@ void statusBarPreSendHook(void* framebuffer, void* userData) {
}
} // namespace
AppSystem::AppSystem(AppContext ctx) : context(std::move(ctx)) {
context.system = this;
AppSystem::AppSystem(AppContext ctx) : _context(std::move(ctx)) {
_context.system = this;
auto& statusBar = StatusBar::instance();
statusBar.setServices(context.services);
using FBType = typename AppContext::Framebuffer;
FramebufferHooks::setPreSendHook(&statusBarPreSendHook<FBType>, &statusBar);
statusBar.setServices(_context.services);
FramebufferHooks::setPreSendHook(&statusBarPreSendHook<AppContext::Framebuffer>, &statusBar);
}
AppSystem::~AppSystem() { FramebufferHooks::clearPreSendHook(); }
void AppSystem::registerApp(std::unique_ptr<IAppFactory> factory) {
if (!factory)
return;
factories.emplace_back(std::move(factory));
assert(factory);
_factories.emplace_back(std::move(factory));
}
bool AppSystem::startApp(const std::string& name) {
for (std::size_t i = 0; i < factories.size(); ++i) {
if (factories[i]->name() == name)
return startAppByIndex(i);
void AppSystem::startApp(const std::string& name) {
for (std::size_t i = 0; i < _factories.size(); ++i) {
if (_factories[i]->name() == name)
startAppByIndex(i);
}
return false;
}
bool AppSystem::startAppByIndex(std::size_t index) {
if (index >= factories.size())
return false;
void AppSystem::startAppByIndex(std::size_t index) {
assert(index < _factories.size());
context.system = this;
auto& factory = factories[index];
auto app = factory->create(context);
if (!app)
return false;
if (current) {
current->onStop();
current.reset();
if (_current) {
_current->onStop();
_current.reset();
}
activeFactory = factory.get();
activeIndex = index;
context.pendingSwitch = false;
context.pendingSwitchByName = false;
context.pendingAppName.clear();
clearTimersForCurrentApp();
current = std::move(app);
lastInputState = context.input.readState();
suppressInputs = true;
StatusBar::instance().setServices(context.services);
StatusBar::instance().setCurrentAppName(activeFactory ? activeFactory->name() : "");
current->onStart();
return true;
_context.system = this;
auto& factory = _factories[index];
const std::uint64_t newGeneration = _nextScopedGeneration++;
auto app = factory->create(_context);
assert(app);
_scopedServices.reset();
auto scoped = createAppScopedServices(newGeneration);
_scopedServices = std::move(scoped);
_context.setScopedServices(_scopedServices.get());
_activeFactory = factory.get();
_activeIndex = index;
_context._pendingSwitch = false;
_context._pendingSwitchByName = false;
_context._pendingAppName.clear();
_current = std::move(app);
_currentTimeout = std::nullopt;
StatusBar::instance().setServices(_context.services);
StatusBar::instance().setCurrentAppName(_activeFactory ? _activeFactory->name() : "");
_current->onStart();
}
void AppSystem::run() {
if (!current) {
if (factories.empty() || !startAppByIndex(0))
return;
if (!_current) {
assert(!_factories.empty());
startAppByIndex(0);
}
std::vector<AppEvent> events;
events.reserve(4);
while (true) {
if (auto* hooks = context.loopHooks())
if (auto* hooks = _context.loopHooks())
hooks->onLoopIteration();
events.clear();
const std::uint32_t now = context.clock.millis();
processDueTimers(now, events);
const InputState inputNow = context.input.readState();
const bool consumedByStatusToggle = StatusBar::instance().handleToggleInput(inputNow, lastInputState);
if (suppressInputs) {
lastInputState = inputNow;
if (!anyButtonPressed(inputNow))
suppressInputs = false;
} else if (!consumedByStatusToggle && inputsDiffer(inputNow, lastInputState)) {
AppEvent evt{};
evt.type = AppEventType::Button;
evt.timestamp_ms = now;
evt.button.current = inputNow;
evt.button.previous = lastInputState;
events.push_back(evt);
lastInputState = inputNow;
} 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)
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);
AppEvent event;
auto event_opt = _context.eventBus()->pop(_currentTimeout);
if (!event_opt) {
event = AppEvent{_context.clock.millis(), AppTimeoutEvent{}};
} else {
eventBus->scheduleTimerSignal(waitMs);
eventBus->wait(mask, IEventBus::kWaitForever);
event = *event_opt;
}
if (const auto* btn = event.button()) {
const bool consumedByStatusToggle = StatusBar::instance().handleToggleInput(btn->current, btn->previous);
if (consumedByStatusToggle) {
continue;
}
}
_currentTimeout = _current->handleEvent(event);
if (_context._pendingSwitch) {
handlePendingSwitchRequest();
continue;
}
}
}
const IAppFactory* AppSystem::factoryAt(std::size_t index) const {
if (index >= factories.size())
if (index >= _factories.size())
return nullptr;
return factories[index].get();
return _factories[index].get();
}
std::size_t AppSystem::indexOfFactory(const IAppFactory* factory) const {
if (!factory)
return static_cast<std::size_t>(-1);
for (std::size_t i = 0; i < factories.size(); ++i) {
if (factories[i].get() == factory)
for (std::size_t i = 0; i < _factories.size(); ++i) {
if (_factories[i].get() == factory)
return i;
}
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);
}
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;
}
}
}
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;
}
bool AppSystem::handlePendingSwitchRequest() {
if (!context.pendingSwitch)
return false;
const bool byName = context.pendingSwitchByName;
const std::size_t reqIndex = context.pendingAppIndex;
const std::string reqName = context.pendingAppName;
context.pendingSwitch = false;
context.pendingSwitchByName = false;
context.pendingAppName.clear();
void AppSystem::handlePendingSwitchRequest() {
assert(_context._pendingSwitch);
const bool byName = _context._pendingSwitchByName;
const std::size_t reqIndex = _context._pendingAppIndex;
const std::string reqName = _context._pendingAppName;
_context._pendingSwitch = false;
_context._pendingSwitchByName = false;
_context._pendingAppName.clear();
bool switched = false;
if (byName)
switched = startApp(reqName);
startApp(reqName);
else
switched = startAppByIndex(reqIndex);
return switched;
startAppByIndex(reqIndex);
}
void AppSystem::notifyEventBus(EventBusSignal signal) {
if (signal == EventBusSignal::None)
return;
if (auto* bus = context.eventBus())
bus->signal(to_event_bits(signal));
std::unique_ptr<AppScopedServices> AppSystem::createAppScopedServices(std::uint64_t generation) {
if (!_context.services || !_context.services->appServices)
return nullptr;
return _context.services->appServices->createScopedServices(generation);
}
} // namespace cardboy::sdk

View File

@@ -2,22 +2,22 @@
namespace cardboy::sdk {
FramebufferHooks::PreSendHook FramebufferHooks::hook_ = nullptr;
void* FramebufferHooks::userData_ = nullptr;
FramebufferHooks::PreSendHook FramebufferHooks::_hook = nullptr;
void* FramebufferHooks::_userData = nullptr;
void FramebufferHooks::setPreSendHook(PreSendHook hook, void* userData) {
hook_ = hook;
userData_ = userData;
_hook = hook;
_userData = userData;
}
void FramebufferHooks::clearPreSendHook() {
hook_ = nullptr;
userData_ = nullptr;
_hook = nullptr;
_userData = nullptr;
}
void FramebufferHooks::invokePreSend(void* framebuffer) {
if (hook_)
hook_(framebuffer, userData_);
if (_hook)
_hook(framebuffer, _userData);
}
} // namespace cardboy::sdk

View File

@@ -12,17 +12,17 @@ StatusBar& StatusBar::instance() {
return bar;
}
void StatusBar::setEnabled(bool value) { enabled_ = value; }
void StatusBar::setEnabled(bool value) { _enabled = value; }
void StatusBar::toggle() {
enabled_ = !enabled_;
if (services_ && services_->buzzer)
services_->buzzer->beepMove();
_enabled = !_enabled;
if (_services && _services->buzzer)
_services->buzzer->beepMove();
}
void StatusBar::setCurrentAppName(std::string_view name) {
appName_.assign(name.begin(), name.end());
std::transform(appName_.begin(), appName_.end(), appName_.begin(),
_appName.assign(name.begin(), name.end());
std::transform(_appName.begin(), _appName.end(), _appName.begin(),
[](unsigned char ch) { return static_cast<char>(std::toupper(ch)); });
}
@@ -37,7 +37,7 @@ bool StatusBar::handleToggleInput(const InputState& current, const InputState& p
}
std::string StatusBar::prepareLeftText(int displayWidth) const {
std::string text = appName_.empty() ? std::string("CARDBOY") : appName_;
std::string text = _appName.empty() ? std::string("CARDBOY") : _appName;
int maxWidth = std::max(0, displayWidth - 32);
while (!text.empty() && font16x8::measureText(text, 1, 1) > maxWidth)
text.pop_back();
@@ -45,14 +45,14 @@ std::string StatusBar::prepareLeftText(int displayWidth) const {
}
std::string StatusBar::prepareRightText() const {
if (!services_)
if (!_services)
return {};
std::string right;
if (services_->battery && services_->battery->hasData()) {
const float current = services_->battery->current();
const float chargeMah = services_->battery->charge();
const float fallbackV = services_->battery->voltage();
if (_services->battery && _services->battery->hasData()) {
const float current = _services->battery->current();
const float chargeMah = _services->battery->charge();
const float fallbackV = _services->battery->voltage();
char buf[64];
if (std::isfinite(current) && std::isfinite(chargeMah)) {
std::snprintf(buf, sizeof(buf), "cur %.2fmA chr %.2fmAh", static_cast<double>(current),
@@ -63,7 +63,7 @@ std::string StatusBar::prepareRightText() const {
right.assign(buf);
}
if (services_->buzzer && services_->buzzer->isMuted()) {
if (_services->buzzer && _services->buzzer->isMuted()) {
if (!right.empty())
right.append(" ");
right.append("MUTE");

View File

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

View File

@@ -567,9 +567,9 @@ CONFIG_SECURE_TEE_LOG_LEVEL=0
# Serial flasher config
#
# CONFIG_ESPTOOLPY_NO_STUB is not set
# CONFIG_ESPTOOLPY_FLASHMODE_QIO is not set
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
# CONFIG_ESPTOOLPY_FLASHMODE_QOUT is not set
CONFIG_ESPTOOLPY_FLASHMODE_DIO=y
# CONFIG_ESPTOOLPY_FLASHMODE_DIO is not set
# CONFIG_ESPTOOLPY_FLASHMODE_DOUT is not set
CONFIG_ESPTOOLPY_FLASH_SAMPLE_MODE_STR=y
CONFIG_ESPTOOLPY_FLASHMODE="dio"
@@ -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
@@ -1217,7 +1217,7 @@ CONFIG_SPI_SLAVE_ISR_IN_IRAM=y
# ESP-Driver:USB Serial/JTAG Configuration
#
CONFIG_USJ_ENABLE_USB_SERIAL_JTAG=y
# CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION is not set
CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION=y
# end of ESP-Driver:USB Serial/JTAG Configuration
#
@@ -2458,9 +2458,9 @@ CONFIG_LOG_BOOTLOADER_LEVEL_INFO=y
# CONFIG_LOG_BOOTLOADER_LEVEL_VERBOSE is not set
CONFIG_LOG_BOOTLOADER_LEVEL=3
# CONFIG_FLASH_ENCRYPTION_ENABLED is not set
# CONFIG_FLASHMODE_QIO is not set
CONFIG_FLASHMODE_QIO=y
# CONFIG_FLASHMODE_QOUT is not set
CONFIG_FLASHMODE_DIO=y
# CONFIG_FLASHMODE_DIO is not set
# CONFIG_FLASHMODE_DOUT is not set
CONFIG_MONITOR_BAUD=115200
# CONFIG_OPTIMIZATION_LEVEL_DEBUG is not set
@@ -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

View File

@@ -599,13 +599,13 @@ CONFIG_ESPTOOLPY_MONITOR_BAUD=115200
#
# Partition Table
#
CONFIG_PARTITION_TABLE_SINGLE_APP=y
# CONFIG_PARTITION_TABLE_SINGLE_APP is not set
# CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE is not set
# CONFIG_PARTITION_TABLE_TWO_OTA is not set
# CONFIG_PARTITION_TABLE_TWO_OTA_LARGE is not set
# CONFIG_PARTITION_TABLE_CUSTOM is not set
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_PARTITION_TABLE_FILENAME="partitions_singleapp.csv"
CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"
CONFIG_PARTITION_TABLE_OFFSET=0x8000
CONFIG_PARTITION_TABLE_MD5=y
# end of Partition Table
@@ -644,7 +644,7 @@ CONFIG_COMPILER_RT_LIB_GCCLIB=y
CONFIG_COMPILER_RT_LIB_NAME="gcc"
CONFIG_COMPILER_ORPHAN_SECTIONS_WARNING=y
# CONFIG_COMPILER_ORPHAN_SECTIONS_PLACE is not set
CONFIG_COMPILER_STATIC_ANALYZER=y
# CONFIG_COMPILER_STATIC_ANALYZER is not set
# end of Compiler options
#
@@ -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
@@ -1699,9 +1699,13 @@ CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=2048
CONFIG_FREERTOS_TIMER_QUEUE_LENGTH=10
CONFIG_FREERTOS_QUEUE_REGISTRY_SIZE=0
CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=1
# CONFIG_FREERTOS_USE_TRACE_FACILITY is not set
CONFIG_FREERTOS_USE_TRACE_FACILITY=y
CONFIG_FREERTOS_USE_STATS_FORMATTING_FUNCTIONS=y
# CONFIG_FREERTOS_USE_LIST_DATA_INTEGRITY_CHECK_BYTES is not set
# CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS is not set
# CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID is not set
CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y
CONFIG_FREERTOS_RUN_TIME_COUNTER_TYPE_U32=y
# CONFIG_FREERTOS_RUN_TIME_COUNTER_TYPE_U64 is not set
CONFIG_FREERTOS_USE_TICKLESS_IDLE=y
CONFIG_FREERTOS_IDLE_TIME_BEFORE_SLEEP=3
# CONFIG_FREERTOS_USE_APPLICATION_TASK_TAG is not set
@@ -1722,6 +1726,7 @@ CONFIG_FREERTOS_TICK_SUPPORT_SYSTIMER=y
CONFIG_FREERTOS_CORETIMER_SYSTIMER_LVL1=y
# CONFIG_FREERTOS_CORETIMER_SYSTIMER_LVL3 is not set
CONFIG_FREERTOS_SYSTICK_USES_SYSTIMER=y
CONFIG_FREERTOS_RUN_TIME_STATS_USING_ESP_TIMER=y
# CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH is not set
# CONFIG_FREERTOS_CHECK_PORT_CRITICAL_COMPLIANCE is not set
# end of Port
@@ -1757,16 +1762,16 @@ CONFIG_HAL_WDT_USE_ROM_IMPL=y
#
# Heap memory debugging
#
CONFIG_HEAP_POISONING_DISABLED=y
# CONFIG_HEAP_POISONING_DISABLED is not set
# CONFIG_HEAP_POISONING_LIGHT is not set
# CONFIG_HEAP_POISONING_COMPREHENSIVE is not set
CONFIG_HEAP_POISONING_COMPREHENSIVE=y
CONFIG_HEAP_TRACING_OFF=y
# CONFIG_HEAP_TRACING_STANDALONE is not set
# CONFIG_HEAP_TRACING_TOHOST is not set
# CONFIG_HEAP_USE_HOOKS is not set
# CONFIG_HEAP_TASK_TRACKING is not set
# CONFIG_HEAP_ABORT_WHEN_ALLOCATION_FAILS is not set
CONFIG_HEAP_TLSF_USE_ROM_IMPL=y
# CONFIG_HEAP_TLSF_USE_ROM_IMPL is not set
# CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH is not set
# end of Heap memory debugging
@@ -2410,6 +2415,211 @@ CONFIG_WL_SECTOR_SIZE=4096
CONFIG_WIFI_PROV_SCAN_MAX_ENTRIES=16
CONFIG_WIFI_PROV_AUTOSTOP_TIMEOUT=30
CONFIG_WIFI_PROV_BLE_SEC_CONN=y
#
# LittleFS
#
# CONFIG_LITTLEFS_SDMMC_SUPPORT is not set
CONFIG_LITTLEFS_MAX_PARTITIONS=3
CONFIG_LITTLEFS_PAGE_SIZE=256
CONFIG_LITTLEFS_OBJ_NAME_LEN=64
CONFIG_LITTLEFS_READ_SIZE=128
CONFIG_LITTLEFS_WRITE_SIZE=128
CONFIG_LITTLEFS_LOOKAHEAD_SIZE=128
CONFIG_LITTLEFS_CACHE_SIZE=512
CONFIG_LITTLEFS_BLOCK_CYCLES=512
CONFIG_LITTLEFS_USE_MTIME=y
# CONFIG_LITTLEFS_USE_ONLY_HASH is not set
# CONFIG_LITTLEFS_HUMAN_READABLE is not set
CONFIG_LITTLEFS_MTIME_USE_SECONDS=y
# CONFIG_LITTLEFS_MTIME_USE_NONCE is not set
# CONFIG_LITTLEFS_SPIFFS_COMPAT is not set
# CONFIG_LITTLEFS_FLUSH_FILE_EVERY_WRITE is not set
# CONFIG_LITTLEFS_FCNTL_GET_PATH is not set
# CONFIG_LITTLEFS_MULTIVERSION is not set
# CONFIG_LITTLEFS_MALLOC_STRATEGY_DISABLE is not set
CONFIG_LITTLEFS_MALLOC_STRATEGY_DEFAULT=y
# CONFIG_LITTLEFS_MALLOC_STRATEGY_INTERNAL is not set
CONFIG_LITTLEFS_ASSERTS=y
# CONFIG_LITTLEFS_MMAP_PARTITION is not set
# end of LittleFS
# end of Component config
# CONFIG_IDF_EXPERIMENTAL_FEATURES is not set
# Deprecated options for backward compatibility
# CONFIG_APP_BUILD_TYPE_ELF_RAM is not set
# CONFIG_NO_BLOBS is not set
# CONFIG_APP_ROLLBACK_ENABLE is not set
# CONFIG_LOG_BOOTLOADER_LEVEL_NONE is not set
# CONFIG_LOG_BOOTLOADER_LEVEL_ERROR is not set
# CONFIG_LOG_BOOTLOADER_LEVEL_WARN is not set
CONFIG_LOG_BOOTLOADER_LEVEL_INFO=y
# CONFIG_LOG_BOOTLOADER_LEVEL_DEBUG is not set
# CONFIG_LOG_BOOTLOADER_LEVEL_VERBOSE is not set
CONFIG_LOG_BOOTLOADER_LEVEL=3
# CONFIG_FLASH_ENCRYPTION_ENABLED is not set
# CONFIG_FLASHMODE_QIO is not set
# CONFIG_FLASHMODE_QOUT is not set
CONFIG_FLASHMODE_DIO=y
# CONFIG_FLASHMODE_DOUT is not set
CONFIG_MONITOR_BAUD=115200
CONFIG_OPTIMIZATION_LEVEL_DEBUG=y
CONFIG_COMPILER_OPTIMIZATION_LEVEL_DEBUG=y
CONFIG_COMPILER_OPTIMIZATION_DEFAULT=y
# CONFIG_OPTIMIZATION_LEVEL_RELEASE is not set
# CONFIG_COMPILER_OPTIMIZATION_LEVEL_RELEASE is not set
CONFIG_OPTIMIZATION_ASSERTIONS_ENABLED=y
# CONFIG_OPTIMIZATION_ASSERTIONS_SILENT is not set
# CONFIG_OPTIMIZATION_ASSERTIONS_DISABLED is not set
CONFIG_OPTIMIZATION_ASSERTION_LEVEL=2
# CONFIG_CXX_EXCEPTIONS is not set
# CONFIG_STACK_CHECK_NONE is not set
# CONFIG_STACK_CHECK_NORM is not set
CONFIG_STACK_CHECK_STRONG=y
# CONFIG_STACK_CHECK_ALL is not set
CONFIG_STACK_CHECK=y
CONFIG_WARN_WRITE_STRINGS=y
# CONFIG_ESP32_APPTRACE_DEST_TRAX is not set
CONFIG_ESP32_APPTRACE_DEST_NONE=y
CONFIG_ESP32_APPTRACE_LOCK_ENABLE=y
# CONFIG_BLUEDROID_ENABLED is not set
CONFIG_NIMBLE_ENABLED=y
CONFIG_NIMBLE_MEM_ALLOC_MODE_INTERNAL=y
# CONFIG_NIMBLE_MEM_ALLOC_MODE_DEFAULT is not set
CONFIG_NIMBLE_MAX_CONNECTIONS=3
CONFIG_NIMBLE_MAX_BONDS=3
CONFIG_NIMBLE_MAX_CCCDS=8
CONFIG_NIMBLE_L2CAP_COC_MAX_NUM=0
CONFIG_NIMBLE_PINNED_TO_CORE=0
CONFIG_NIMBLE_TASK_STACK_SIZE=4096
CONFIG_BT_NIMBLE_TASK_STACK_SIZE=4096
CONFIG_NIMBLE_ROLE_CENTRAL=y
CONFIG_NIMBLE_ROLE_PERIPHERAL=y
CONFIG_NIMBLE_ROLE_BROADCASTER=y
CONFIG_NIMBLE_ROLE_OBSERVER=y
CONFIG_NIMBLE_NVS_PERSIST=y
CONFIG_NIMBLE_SM_LEGACY=y
CONFIG_NIMBLE_SM_SC=y
# CONFIG_NIMBLE_SM_SC_DEBUG_KEYS is not set
CONFIG_BT_NIMBLE_SM_SC_LVL=0
# CONFIG_NIMBLE_DEBUG is not set
CONFIG_NIMBLE_SVC_GAP_DEVICE_NAME="nimble"
CONFIG_NIMBLE_GAP_DEVICE_NAME_MAX_LEN=31
CONFIG_NIMBLE_ATT_PREFERRED_MTU=256
CONFIG_NIMBLE_SVC_GAP_APPEARANCE=0
CONFIG_BT_NIMBLE_MSYS1_BLOCK_COUNT=24
CONFIG_BT_NIMBLE_ACL_BUF_COUNT=24
CONFIG_BT_NIMBLE_ACL_BUF_SIZE=255
CONFIG_BT_NIMBLE_HCI_EVT_BUF_SIZE=70
CONFIG_BT_NIMBLE_HCI_EVT_HI_BUF_COUNT=30
CONFIG_BT_NIMBLE_HCI_EVT_LO_BUF_COUNT=8
CONFIG_NIMBLE_RPA_TIMEOUT=900
# CONFIG_NIMBLE_MESH is not set
CONFIG_NIMBLE_CRYPTO_STACK_MBEDTLS=y
# CONFIG_BT_NIMBLE_COEX_PHY_CODED_TX_RX_TLIM_EN is not set
CONFIG_BT_NIMBLE_COEX_PHY_CODED_TX_RX_TLIM_DIS=y
CONFIG_SW_COEXIST_ENABLE=y
CONFIG_ESP32_WIFI_SW_COEXIST_ENABLE=y
CONFIG_ESP_WIFI_SW_COEXIST_ENABLE=y
# CONFIG_ANA_CMPR_ISR_IRAM_SAFE is not set
# CONFIG_GPTIMER_ISR_IRAM_SAFE is not set
# CONFIG_MCPWM_ISR_IRAM_SAFE is not set
# CONFIG_EVENT_LOOP_PROFILING is not set
CONFIG_POST_EVENTS_FROM_ISR=y
CONFIG_POST_EVENTS_FROM_IRAM_ISR=y
CONFIG_GDBSTUB_SUPPORT_TASKS=y
CONFIG_GDBSTUB_MAX_TASKS=32
# CONFIG_OTA_ALLOW_HTTP is not set
CONFIG_ESP_SYSTEM_PD_FLASH=y
CONFIG_PERIPH_CTRL_FUNC_IN_IRAM=y
CONFIG_BROWNOUT_DET=y
# CONFIG_BROWNOUT_DET_LVL_SEL_7 is not set
# CONFIG_BROWNOUT_DET_LVL_SEL_6 is not set
# CONFIG_BROWNOUT_DET_LVL_SEL_5 is not set
# CONFIG_BROWNOUT_DET_LVL_SEL_4 is not set
CONFIG_BROWNOUT_DET_LVL_SEL_3=y
# CONFIG_BROWNOUT_DET_LVL_SEL_2 is not set
# CONFIG_BROWNOUT_DET_LVL_SEL_1 is not set
# CONFIG_BROWNOUT_DET_LVL_SEL_0 is not set
CONFIG_BROWNOUT_DET_LVL=3
CONFIG_ESP_SYSTEM_BROWNOUT_INTR=y
CONFIG_ESP32_PHY_CALIBRATION_AND_DATA_STORAGE=y
CONFIG_ESP32_PHY_MAX_WIFI_TX_POWER=20
CONFIG_ESP32_PHY_MAX_TX_POWER=20
# CONFIG_REDUCE_PHY_TX_POWER is not set
# CONFIG_ESP32_REDUCE_PHY_TX_POWER is not set
CONFIG_ESP_SYSTEM_PM_POWER_DOWN_CPU=y
CONFIG_ESP32_RTC_XTAL_BOOTSTRAP_CYCLES=0
CONFIG_SYSTEM_EVENT_QUEUE_SIZE=32
CONFIG_SYSTEM_EVENT_TASK_STACK_SIZE=2304
CONFIG_MAIN_TASK_STACK_SIZE=3584
CONFIG_CONSOLE_UART_DEFAULT=y
# CONFIG_CONSOLE_UART_CUSTOM is not set
# CONFIG_CONSOLE_UART_NONE is not set
# CONFIG_ESP_CONSOLE_UART_NONE is not set
CONFIG_CONSOLE_UART=y
CONFIG_CONSOLE_UART_NUM=0
CONFIG_CONSOLE_UART_BAUDRATE=115200
CONFIG_INT_WDT=y
CONFIG_INT_WDT_TIMEOUT_MS=300
CONFIG_TASK_WDT=y
CONFIG_ESP_TASK_WDT=y
# CONFIG_TASK_WDT_PANIC is not set
CONFIG_TASK_WDT_TIMEOUT_S=5
CONFIG_TASK_WDT_CHECK_IDLE_TASK_CPU0=y
# CONFIG_ESP32_DEBUG_STUBS_ENABLE is not set
CONFIG_IPC_TASK_STACK_SIZE=1024
CONFIG_TIMER_TASK_STACK_SIZE=3584
# CONFIG_ESP32_ENABLE_COREDUMP_TO_FLASH is not set
# CONFIG_ESP32_ENABLE_COREDUMP_TO_UART is not set
CONFIG_ESP32_ENABLE_COREDUMP_TO_NONE=y
CONFIG_TIMER_TASK_PRIORITY=1
CONFIG_TIMER_TASK_STACK_DEPTH=2048
CONFIG_TIMER_QUEUE_LENGTH=10
# CONFIG_ENABLE_STATIC_TASK_CLEAN_UP_HOOK is not set
# CONFIG_HAL_ASSERTION_SILIENT is not set
# CONFIG_L2_TO_L3_COPY is not set
CONFIG_ESP_GRATUITOUS_ARP=y
CONFIG_GARP_TMR_INTERVAL=60
CONFIG_TCPIP_RECVMBOX_SIZE=32
CONFIG_TCP_MAXRTX=12
CONFIG_TCP_SYNMAXRTX=12
CONFIG_TCP_MSS=1440
CONFIG_TCP_MSL=60000
CONFIG_TCP_SND_BUF_DEFAULT=5760
CONFIG_TCP_WND_DEFAULT=5760
CONFIG_TCP_RECVMBOX_SIZE=6
CONFIG_TCP_QUEUE_OOSEQ=y
CONFIG_TCP_OVERSIZE_MSS=y
# CONFIG_TCP_OVERSIZE_QUARTER_MSS is not set
# CONFIG_TCP_OVERSIZE_DISABLE is not set
CONFIG_UDP_RECVMBOX_SIZE=6
CONFIG_TCPIP_TASK_STACK_SIZE=3072
CONFIG_TCPIP_TASK_AFFINITY_NO_AFFINITY=y
# CONFIG_TCPIP_TASK_AFFINITY_CPU0 is not set
CONFIG_TCPIP_TASK_AFFINITY=0x7FFFFFFF
# CONFIG_PPP_SUPPORT is not set
CONFIG_NEWLIB_STDOUT_LINE_ENDING_CRLF=y
# CONFIG_NEWLIB_STDOUT_LINE_ENDING_LF is not set
# CONFIG_NEWLIB_STDOUT_LINE_ENDING_CR is not set
# CONFIG_NEWLIB_STDIN_LINE_ENDING_CRLF is not set
# CONFIG_NEWLIB_STDIN_LINE_ENDING_LF is not set
CONFIG_NEWLIB_STDIN_LINE_ENDING_CR=y
# CONFIG_NEWLIB_NANO_FORMAT is not set
CONFIG_NEWLIB_TIME_SYSCALL_USE_RTC_HRT=y
# CONFIG_NEWLIB_TIME_SYSCALL_USE_RTC is not set
# CONFIG_NEWLIB_TIME_SYSCALL_USE_HRT is not set
# CONFIG_NEWLIB_TIME_SYSCALL_USE_NONE is not set
CONFIG_ESP32_PTHREAD_TASK_PRIO_DEFAULT=5
CONFIG_ESP32_PTHREAD_TASK_STACK_SIZE_DEFAULT=3072
CONFIG_ESP32_PTHREAD_STACK_MIN=768
CONFIG_ESP32_PTHREAD_TASK_CORE_DEFAULT=-1
CONFIG_ESP32_PTHREAD_TASK_NAME_DEFAULT="pthread"
CONFIG_SPI_FLASH_WRITING_DANGEROUS_REGIONS_ABORTS=y
# CONFIG_SPI_FLASH_WRITING_DANGEROUS_REGIONS_FAILS is not set
# CONFIG_SPI_FLASH_WRITING_DANGEROUS_REGIONS_ALLOWED is not set
CONFIG_SUPPRESS_SELECT_DEBUG_OUTPUT=y
CONFIG_SUPPORT_TERMIOS=y
CONFIG_SEMIHOSTFS_MAX_MOUNT_POINTS=1
# End of deprecated options