mirror of
https://github.com/usatiuk/cardboy.git
synced 2025-10-28 23:27:49 +01:00
Compare commits
7 Commits
sound
...
eeedc629d7
| Author | SHA1 | Date | |
|---|---|---|---|
| eeedc629d7 | |||
| 8bb48daf6c | |||
| 7c741c42dc | |||
| ecbcce12ea | |||
| f6c800fc63 | |||
| 5e63875d35 | |||
| cc805abe80 |
3
Firmware/.gitignore
vendored
3
Firmware/.gitignore
vendored
@@ -2,4 +2,5 @@ build
|
||||
cmake-build*
|
||||
.idea
|
||||
.cache
|
||||
managed_components
|
||||
managed_components
|
||||
*.gb
|
||||
62
Firmware/cardboy-companion/.gitignore
vendored
Normal file
62
Firmware/cardboy-companion/.gitignore
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
# Xcode
|
||||
#
|
||||
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## Obj-C/Swift specific
|
||||
*.hmap
|
||||
|
||||
## App packaging
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
## Playgrounds
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
# Swift Package Manager
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
|
||||
# Packages/
|
||||
# Package.pins
|
||||
# Package.resolved
|
||||
# *.xcodeproj
|
||||
#
|
||||
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
|
||||
# hence it is not needed unless you have added a package configuration file to your project
|
||||
# .swiftpm
|
||||
|
||||
.build/
|
||||
|
||||
# CocoaPods
|
||||
#
|
||||
# We recommend against adding the Pods directory to your .gitignore. However
|
||||
# you should judge for yourself, the pros and cons are mentioned at:
|
||||
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
|
||||
#
|
||||
# Pods/
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from the Xcode workspace
|
||||
# *.xcworkspace
|
||||
|
||||
# Carthage
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Carthage dependencies.
|
||||
# Carthage/Checkouts
|
||||
|
||||
Carthage/Build/
|
||||
|
||||
# fastlane
|
||||
#
|
||||
# It is recommended to not store the screenshots in the git repo.
|
||||
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
|
||||
# For more information about the recommended setup visit:
|
||||
# https://docs.fastlane.tools/best-practices/source-control/#source-control
|
||||
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
29
Firmware/cardboy-companion/README.md
Normal file
29
Firmware/cardboy-companion/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Cardboy Time Sync Companion
|
||||
|
||||
This SwiftUI app connects to the Cardboy device over Bluetooth Low Energy and updates its wall clock using the custom time sync service exposed by the firmware. The sources live inside the existing `cardboy-companion/cardboy-companion.xcodeproj` so you can open and run them directly in Xcode.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Xcode 15 or newer
|
||||
- iOS 16 or newer deployment target (can be lowered to 15 with minor API tweaks)
|
||||
- A Cardboy running firmware that includes the BLE time sync service
|
||||
|
||||
## How it works
|
||||
|
||||
1. The app scans for peripherals exposing service UUID `00000001-CA7B-4EFD-B5A6-10C3F4D3F230`.
|
||||
2. Once connected it discovers characteristic `00000002-CA7B-4EFD-B5A6-10C3F4D3F231`.
|
||||
3. Tapping **Sync Now** writes a 12‑byte payload containing:
|
||||
- 8 bytes Unix epoch seconds (little endian)
|
||||
- 2 bytes time zone offset in minutes from UTC (little endian)
|
||||
- 1 byte DST flag (`1` if daylight saving is active)
|
||||
- 1 reserved byte (`0`)
|
||||
4. The firmware applies the timestamp with `settimeofday()` and updates the TZ environment variable so the clock app renders local time.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Open `cardboy-companion/cardboy-companion.xcodeproj` in Xcode.
|
||||
2. Ensure the `CoreBluetooth` capability is enabled for the `cardboy-companion` target and keep the *Uses Bluetooth LE accessories* background mode on (preconfigured in this project).
|
||||
3. Build & run on a real device (BLE is not available in the simulator).
|
||||
4. Allow Bluetooth permissions when prompted. The app keeps scanning in the background, so the Cardboy can request a sync even while the companion is not foregrounded. Tap **Sync Now** any time you want to trigger a manual refresh.
|
||||
|
||||
Optionally bundle this code into your existing app—`TimeSyncManager` is self‑contained and can be reused.
|
||||
@@ -0,0 +1,356 @@
|
||||
// !$*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_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_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UIBackgroundModes = "bluetooth-central";
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.usatiuk.cardboy-companion";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
ECAB9A7E2EA550D9004BA9DE /* Build configuration list for PBXProject "cardboy-companion" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
ECAB9A8C2EA550DB004BA9DE /* Debug */,
|
||||
ECAB9A8D2EA550DB004BA9DE /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
ECAB9A8E2EA550DB004BA9DE /* Build configuration list for PBXNativeTarget "cardboy-companion" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
ECAB9A8F2EA550DB004BA9DE /* Debug */,
|
||||
ECAB9A902EA550DB004BA9DE /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = ECAB9A7B2EA550D9004BA9DE /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject private var manager: TimeSyncManager
|
||||
|
||||
private var formattedLastSync: String {
|
||||
guard let date = manager.lastSyncDate else { return "Never" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .medium
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Text("Cardboy Clock Sync")
|
||||
.font(.title.bold())
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text("State: \(manager.connectionState.rawValue)")
|
||||
.font(.headline)
|
||||
|
||||
Text(manager.statusMessage)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
Button(action: manager.sendCurrentTime) {
|
||||
Label("Sync Now", systemImage: "clock.arrow.2.circlepath")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(manager.connectionState != .ready)
|
||||
|
||||
Button(action: manager.forceRescan) {
|
||||
Label("Rescan", systemImage: "dot.radiowaves.left.and.right")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Last Sync:")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(formattedLastSync)
|
||||
.font(.body.monospaced())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.environmentObject(TimeSyncManager())
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,253 @@
|
||||
import Combine
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
final class TimeSyncManager: NSObject, ObservableObject {
|
||||
enum ConnectionState: String {
|
||||
case idle = "Idle"
|
||||
case scanning = "Scanning"
|
||||
case connecting = "Connecting"
|
||||
case discovering = "Discovering Services"
|
||||
case ready = "Ready"
|
||||
case failed = "Failed"
|
||||
}
|
||||
|
||||
@Published private(set) var connectionState: ConnectionState = .idle
|
||||
@Published private(set) var statusMessage: String = "Waiting for Bluetooth…"
|
||||
@Published private(set) var lastSyncDate: Date?
|
||||
|
||||
private let serviceUUID = CBUUID(string: "00000001-CA7B-4EFD-B5A6-10C3F4D3F230")
|
||||
private let characteristicUUID = CBUUID(string: "00000002-CA7B-4EFD-B5A6-10C3F4D3F231")
|
||||
|
||||
private lazy var central: CBCentralManager = CBCentralManager(
|
||||
delegate: self,
|
||||
queue: nil,
|
||||
options: [
|
||||
CBCentralManagerOptionShowPowerAlertKey: true,
|
||||
CBCentralManagerOptionRestoreIdentifierKey: "com.usatiuk.cardboy-companion.central"
|
||||
]
|
||||
)
|
||||
|
||||
private var targetPeripheral: CBPeripheral?
|
||||
private var timeCharacteristic: CBCharacteristic?
|
||||
|
||||
private var retryWorkItem: DispatchWorkItem?
|
||||
private var shouldKeepScanning = true
|
||||
private var isScanning = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
deinit {
|
||||
retryWorkItem?.cancel()
|
||||
}
|
||||
|
||||
func forceRescan() {
|
||||
statusMessage = "Restarting scan…"
|
||||
shouldKeepScanning = true
|
||||
stopScanning()
|
||||
targetPeripheral = nil
|
||||
timeCharacteristic = nil
|
||||
startScanning()
|
||||
}
|
||||
|
||||
func sendCurrentTime() {
|
||||
guard let peripheral = targetPeripheral,
|
||||
let characteristic = timeCharacteristic else {
|
||||
statusMessage = "Device is not ready."
|
||||
return
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
let epochSeconds = UInt64(now.timeIntervalSince1970.rounded())
|
||||
let timezoneOffsetMinutes = Int16(TimeZone.current.secondsFromGMT(for: now) / 60)
|
||||
let isDst = TimeZone.current.isDaylightSavingTime(for: now) ? UInt8(1) : UInt8(0)
|
||||
|
||||
var payload = Data()
|
||||
|
||||
var epochLe = epochSeconds.littleEndian
|
||||
payload.append(UnsafeBufferPointer(start: &epochLe, count: 1))
|
||||
|
||||
var offsetLe = timezoneOffsetMinutes.littleEndian
|
||||
payload.append(UnsafeBufferPointer(start: &offsetLe, count: 1))
|
||||
|
||||
payload.append(isDst)
|
||||
payload.append(UInt8(0)) // Reserved byte
|
||||
|
||||
connectionState = .ready
|
||||
statusMessage = "Sending current time…"
|
||||
peripheral.writeValue(payload, for: characteristic, type: .withResponse)
|
||||
}
|
||||
|
||||
private func startScanning() {
|
||||
guard shouldKeepScanning, central.state == .poweredOn else { return }
|
||||
if isScanning { return }
|
||||
|
||||
central.scanForPeripherals(withServices: [serviceUUID], options: [
|
||||
CBCentralManagerScanOptionAllowDuplicatesKey: false
|
||||
])
|
||||
|
||||
isScanning = true
|
||||
connectionState = .scanning
|
||||
statusMessage = "Scanning for Cardboy…"
|
||||
}
|
||||
|
||||
private func stopScanning() {
|
||||
guard isScanning else { return }
|
||||
central.stopScan()
|
||||
isScanning = false
|
||||
}
|
||||
|
||||
private func scheduleRetry(after delay: TimeInterval = 2.5) {
|
||||
shouldKeepScanning = true
|
||||
retryWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
self?.startScanning()
|
||||
}
|
||||
retryWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
|
||||
}
|
||||
}
|
||||
|
||||
extension TimeSyncManager: CBCentralManagerDelegate {
|
||||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
||||
shouldKeepScanning = true
|
||||
|
||||
if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral],
|
||||
let restored = peripherals.first {
|
||||
statusMessage = "Restoring connection…"
|
||||
connectionState = .connecting
|
||||
targetPeripheral = restored
|
||||
restored.delegate = self
|
||||
if central.state == .poweredOn {
|
||||
central.connect(restored, options: nil)
|
||||
}
|
||||
} else {
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
switch central.state {
|
||||
case .unknown, .resetting:
|
||||
connectionState = .idle
|
||||
statusMessage = "Bluetooth is resetting…"
|
||||
case .unsupported:
|
||||
connectionState = .failed
|
||||
statusMessage = "Bluetooth Low Energy is not supported on this device."
|
||||
case .unauthorized:
|
||||
connectionState = .failed
|
||||
statusMessage = "Bluetooth permissions are missing."
|
||||
case .poweredOff:
|
||||
connectionState = .idle
|
||||
statusMessage = "Turn on Bluetooth to continue."
|
||||
stopScanning()
|
||||
case .poweredOn:
|
||||
startScanning()
|
||||
@unknown default:
|
||||
connectionState = .failed
|
||||
statusMessage = "Unknown Bluetooth state."
|
||||
}
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any],
|
||||
rssi RSSI: NSNumber) {
|
||||
statusMessage = "Found \(peripheral.name ?? "device"), connecting…"
|
||||
connectionState = .connecting
|
||||
shouldKeepScanning = false
|
||||
stopScanning()
|
||||
targetPeripheral = peripheral
|
||||
peripheral.delegate = self
|
||||
central.connect(peripheral, options: nil)
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
statusMessage = "Connected. Discovering services…"
|
||||
connectionState = .discovering
|
||||
peripheral.discoverServices([serviceUUID])
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||
statusMessage = "Failed to connect. \(error?.localizedDescription ?? "")"
|
||||
connectionState = .failed
|
||||
targetPeripheral = nil
|
||||
timeCharacteristic = nil
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||
let reason = error?.localizedDescription ?? "Disconnected."
|
||||
statusMessage = reason
|
||||
connectionState = .idle
|
||||
targetPeripheral = nil
|
||||
timeCharacteristic = nil
|
||||
scheduleRetry()
|
||||
}
|
||||
}
|
||||
|
||||
extension TimeSyncManager: CBPeripheralDelegate {
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
if let error {
|
||||
statusMessage = "Service discovery failed: \(error.localizedDescription)"
|
||||
connectionState = .failed
|
||||
scheduleRetry()
|
||||
return
|
||||
}
|
||||
|
||||
guard let services = peripheral.services else {
|
||||
statusMessage = "No services found on device."
|
||||
connectionState = .failed
|
||||
scheduleRetry()
|
||||
return
|
||||
}
|
||||
|
||||
for service in services where service.uuid == serviceUUID {
|
||||
peripheral.discoverCharacteristics([characteristicUUID], for: service)
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||
if let error {
|
||||
statusMessage = "Characteristic discovery failed: \(error.localizedDescription)"
|
||||
connectionState = .failed
|
||||
scheduleRetry()
|
||||
return
|
||||
}
|
||||
|
||||
guard let characteristics = service.characteristics,
|
||||
let targetCharacteristic = characteristics.first(where: { $0.uuid == characteristicUUID }) else {
|
||||
statusMessage = "Time sync characteristic missing."
|
||||
connectionState = .failed
|
||||
scheduleRetry()
|
||||
return
|
||||
}
|
||||
|
||||
timeCharacteristic = targetCharacteristic
|
||||
connectionState = .ready
|
||||
statusMessage = "Ready to sync time."
|
||||
sendCurrentTime()
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
if let error {
|
||||
statusMessage = "Write failed: \(error.localizedDescription)"
|
||||
connectionState = .failed
|
||||
scheduleRetry()
|
||||
return
|
||||
}
|
||||
|
||||
lastSyncDate = Date()
|
||||
if let date = lastSyncDate {
|
||||
let formatted = DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .medium)
|
||||
statusMessage = "Time synced at \(formatted)."
|
||||
} else {
|
||||
statusMessage = "Time synced."
|
||||
}
|
||||
connectionState = .ready
|
||||
|
||||
shouldKeepScanning = true
|
||||
central.cancelPeripheralConnection(peripheral)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct cardboy_companionApp: App {
|
||||
@StateObject private var manager = TimeSyncManager()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(manager)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ idf_component_register(
|
||||
"src/i2c_global.cpp"
|
||||
"src/shutdowner.cpp"
|
||||
"src/spi_global.cpp"
|
||||
"src/time_sync_service.cpp"
|
||||
INCLUDE_DIRS
|
||||
"include"
|
||||
PRIV_REQUIRES
|
||||
@@ -19,6 +20,7 @@ idf_component_register(
|
||||
esp_driver_spi
|
||||
littlefs
|
||||
nvs_flash
|
||||
bt
|
||||
)
|
||||
|
||||
add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../sdk/utils" cardboy_utils_esp)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
namespace cardboy::backend::esp {
|
||||
|
||||
/**
|
||||
* Ensure the BLE time synchronisation service is running.
|
||||
*
|
||||
* Safe to call multiple times; subsequent calls become no-ops once the
|
||||
* service has been started successfully.
|
||||
*/
|
||||
void ensure_time_sync_service_started();
|
||||
|
||||
/**
|
||||
* Stop the BLE time synchronisation service if it is running.
|
||||
* A no-op on platforms that do not support the BLE implementation.
|
||||
*/
|
||||
void shutdown_time_sync_service();
|
||||
|
||||
} // namespace cardboy::backend::esp
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "cardboy/backend/esp/i2c_global.hpp"
|
||||
#include "cardboy/backend/esp/shutdowner.hpp"
|
||||
#include "cardboy/backend/esp/spi_global.hpp"
|
||||
#include "cardboy/backend/esp/time_sync_service.hpp"
|
||||
|
||||
#include "cardboy/sdk/display_spec.hpp"
|
||||
|
||||
@@ -150,7 +151,7 @@ EspRuntime::EspRuntime() : framebuffer(), input(), clock() {
|
||||
Buttons::get().setEventBus(eventBus.get());
|
||||
}
|
||||
|
||||
EspRuntime::~EspRuntime() = default;
|
||||
EspRuntime::~EspRuntime() { shutdown_time_sync_service(); }
|
||||
|
||||
cardboy::sdk::Services& EspRuntime::serviceRegistry() { return services; }
|
||||
|
||||
@@ -180,6 +181,8 @@ void EspRuntime::initializeHardware() {
|
||||
Buzzer::get().init();
|
||||
|
||||
FsHelper::get().mount();
|
||||
|
||||
ensure_time_sync_service_started();
|
||||
}
|
||||
|
||||
void EspFramebuffer::clear_impl(bool on) {
|
||||
|
||||
321
Firmware/components/backend-esp/src/time_sync_service.cpp
Normal file
321
Firmware/components/backend-esp/src/time_sync_service.cpp
Normal file
@@ -0,0 +1,321 @@
|
||||
#include "cardboy/backend/esp/time_sync_service.hpp"
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#include <sys/time.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "host/ble_gap.h"
|
||||
#include "host/ble_gatt.h"
|
||||
#include "host/ble_hs.h"
|
||||
#include "host/util/util.h"
|
||||
#include "nimble/nimble_port.h"
|
||||
#include "nimble/nimble_port_freertos.h"
|
||||
#include "services/gap/ble_svc_gap.h"
|
||||
#include "services/gatt/ble_svc_gatt.h"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <esp_bt.h>
|
||||
#include <esp_err.h>
|
||||
|
||||
namespace cardboy::backend::esp {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char kLogTag[] = "TimeSyncBLE";
|
||||
constexpr char kDeviceName[] = "Cardboy";
|
||||
|
||||
// 128-bit UUIDs (little-endian order for NimBLE macros)
|
||||
static const ble_uuid128_t kTimeServiceUuid = BLE_UUID128_INIT(0x30, 0xF2, 0xD3, 0xF4, 0xC3, 0x10, 0xA6, 0xB5, 0xFD,
|
||||
0x4E, 0x7B, 0xCA, 0x01, 0x00, 0x00, 0x00);
|
||||
static const ble_uuid128_t kTimeWriteCharUuid = BLE_UUID128_INIT(0x31, 0xF2, 0xD3, 0xF4, 0xC3, 0x10, 0xA6, 0xB5, 0xFD,
|
||||
0x4E, 0x7B, 0xCA, 0x02, 0x00, 0x00, 0x00);
|
||||
|
||||
struct [[gnu::packed]] TimeSyncPayload {
|
||||
std::uint64_t epochSeconds; // Unix time in seconds (UTC)
|
||||
std::int16_t timezoneOffsetMinutes; // Minutes east of UTC
|
||||
std::uint8_t daylightSavingActive; // Non-zero if DST active at source
|
||||
std::uint8_t reserved; // Reserved for alignment / future use
|
||||
};
|
||||
|
||||
static_assert(sizeof(TimeSyncPayload) == 12, "Unexpected payload size");
|
||||
|
||||
static bool g_started = false;
|
||||
static uint8_t g_ownAddrType = BLE_OWN_ADDR_PUBLIC;
|
||||
static TaskHandle_t g_hostTaskHandle = nullptr;
|
||||
|
||||
int gapEventHandler(struct ble_gap_event* event, void* arg);
|
||||
void startAdvertising();
|
||||
|
||||
void setSystemTimeFromPayload(const TimeSyncPayload& payload) {
|
||||
timeval tv{};
|
||||
tv.tv_sec = static_cast<time_t>(payload.epochSeconds);
|
||||
tv.tv_usec = 0;
|
||||
|
||||
if (settimeofday(&tv, nullptr) != 0) {
|
||||
ESP_LOGW(kLogTag, "Failed to set system time (errno=%d)", errno);
|
||||
} else {
|
||||
ESP_LOGI(kLogTag, "Wall time updated: epoch=%llu dst=%u offset=%dmin",
|
||||
static_cast<unsigned long long>(payload.epochSeconds), payload.daylightSavingActive,
|
||||
static_cast<int>(payload.timezoneOffsetMinutes));
|
||||
}
|
||||
|
||||
// Apply timezone offset by updating TZ environment variable.
|
||||
// POSIX TZ strings invert the sign relative to the offset from UTC.
|
||||
const int offsetMin = static_cast<int>(payload.timezoneOffsetMinutes);
|
||||
const int absOffset = std::abs(offsetMin);
|
||||
const int hours = absOffset / 60;
|
||||
const int minutes = absOffset % 60;
|
||||
|
||||
char tzString[16];
|
||||
const char signChar = (offsetMin >= 0) ? '-' : '+';
|
||||
if (minutes == 0) {
|
||||
std::snprintf(tzString, sizeof(tzString), "GMT%c%d", signChar, hours);
|
||||
} else {
|
||||
std::snprintf(tzString, sizeof(tzString), "GMT%c%d:%02d", signChar, hours, minutes);
|
||||
}
|
||||
|
||||
setenv("TZ", tzString, 1);
|
||||
tzset();
|
||||
ESP_LOGI(kLogTag, "Timezone updated to %s", tzString);
|
||||
}
|
||||
|
||||
int timeSyncWriteAccess(uint16_t /*conn_handle*/, uint16_t /*attr_handle*/, ble_gatt_access_ctxt* ctxt, void* /*arg*/) {
|
||||
if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) {
|
||||
return BLE_ATT_ERR_READ_NOT_PERMITTED;
|
||||
}
|
||||
|
||||
const std::uint16_t incomingLen = OS_MBUF_PKTLEN(ctxt->om);
|
||||
if (incomingLen != sizeof(TimeSyncPayload)) {
|
||||
ESP_LOGW(kLogTag, "Invalid payload length: %u", incomingLen);
|
||||
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
|
||||
}
|
||||
|
||||
TimeSyncPayload payload{};
|
||||
const int rc = os_mbuf_copydata(ctxt->om, 0, sizeof(TimeSyncPayload), &payload);
|
||||
if (rc != 0) {
|
||||
ESP_LOGW(kLogTag, "Failed to read payload (rc=%d)", rc);
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
|
||||
setSystemTimeFromPayload(payload);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const ble_gatt_svc_def kGattServices[] = {
|
||||
{
|
||||
.type = BLE_GATT_SVC_TYPE_PRIMARY,
|
||||
.uuid = &kTimeServiceUuid.u,
|
||||
.characteristics =
|
||||
(ble_gatt_chr_def[]) {
|
||||
{
|
||||
.uuid = &kTimeWriteCharUuid.u,
|
||||
.access_cb = timeSyncWriteAccess,
|
||||
.flags = static_cast<uint8_t>(BLE_GATT_CHR_F_WRITE |
|
||||
BLE_GATT_CHR_F_WRITE_NO_RSP),
|
||||
},
|
||||
{
|
||||
0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
0,
|
||||
},
|
||||
};
|
||||
|
||||
void startAdvertising() {
|
||||
ble_hs_adv_fields fields{};
|
||||
std::memset(&fields, 0, sizeof(fields));
|
||||
|
||||
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
|
||||
|
||||
const char* name = ble_svc_gap_device_name();
|
||||
fields.name = reinterpret_cast<const std::uint8_t*>(name);
|
||||
fields.name_len = static_cast<std::uint8_t>(std::strlen(name));
|
||||
fields.name_is_complete = 1;
|
||||
fields.uuids128 = const_cast<ble_uuid128_t*>(&kTimeServiceUuid);
|
||||
fields.num_uuids128 = 1;
|
||||
fields.uuids128_is_complete = 1;
|
||||
|
||||
int rc = ble_gap_adv_set_fields(&fields);
|
||||
if (rc != 0) {
|
||||
ESP_LOGE(kLogTag, "ble_gap_adv_set_fields failed: %d", rc);
|
||||
return;
|
||||
}
|
||||
|
||||
ble_hs_adv_fields rspFields{};
|
||||
std::memset(&rspFields, 0, sizeof(rspFields));
|
||||
rc = ble_gap_adv_rsp_set_fields(&rspFields);
|
||||
if (rc != 0) {
|
||||
ESP_LOGE(kLogTag, "ble_gap_adv_rsp_set_fields failed: %d", rc);
|
||||
return;
|
||||
}
|
||||
|
||||
ble_gap_adv_params advParams{};
|
||||
std::memset(&advParams, 0, sizeof(advParams));
|
||||
advParams.conn_mode = BLE_GAP_CONN_MODE_UND;
|
||||
advParams.disc_mode = BLE_GAP_DISC_MODE_GEN;
|
||||
const uint16_t advIntervalMin = BLE_GAP_ADV_ITVL_MS(1000);
|
||||
advParams.itvl_min = advIntervalMin;
|
||||
advParams.itvl_max = BLE_GAP_ADV_ITVL_MS(1200);
|
||||
|
||||
rc = ble_gap_adv_start(g_ownAddrType, nullptr, BLE_HS_FOREVER, &advParams, gapEventHandler, nullptr);
|
||||
if (rc != 0) {
|
||||
ESP_LOGE(kLogTag, "ble_gap_adv_start failed: %d", rc);
|
||||
} else {
|
||||
ESP_LOGI(kLogTag, "Advertising started");
|
||||
}
|
||||
}
|
||||
|
||||
void onReset(int reason) { ESP_LOGW(kLogTag, "Resetting state; reason=%d", reason); }
|
||||
|
||||
void onSync() {
|
||||
int rc = ble_hs_id_infer_auto(0, &g_ownAddrType);
|
||||
if (rc != 0) {
|
||||
ESP_LOGE(kLogTag, "ble_hs_id_infer_auto failed: %d", rc);
|
||||
return;
|
||||
}
|
||||
|
||||
std::uint8_t addrVal[6];
|
||||
rc = ble_hs_id_copy_addr(g_ownAddrType, addrVal, nullptr);
|
||||
if (rc == 0) {
|
||||
ESP_LOGI(kLogTag, "Device address: %02X:%02X:%02X:%02X:%02X:%02X", addrVal[5], addrVal[4], addrVal[3],
|
||||
addrVal[2], addrVal[1], addrVal[0]);
|
||||
}
|
||||
|
||||
rc = ble_gap_set_prefered_default_le_phy(BLE_HCI_LE_PHY_1M_PREF_MASK, BLE_HCI_LE_PHY_1M_PREF_MASK);
|
||||
if (rc != 0) {
|
||||
ESP_LOGW(kLogTag, "Failed to set preferred PHY (rc=%d)", rc);
|
||||
}
|
||||
|
||||
if (esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_DEFAULT, ESP_PWR_LVL_N24) != ESP_OK) {
|
||||
ESP_LOGW(kLogTag, "Failed to set default TX power level");
|
||||
}
|
||||
if (esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, ESP_PWR_LVL_N24) != ESP_OK) {
|
||||
ESP_LOGW(kLogTag, "Failed to set advertising TX power level");
|
||||
}
|
||||
if (esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_SCAN, ESP_PWR_LVL_N24) != ESP_OK) {
|
||||
ESP_LOGW(kLogTag, "Failed to set scan TX power level");
|
||||
}
|
||||
|
||||
startAdvertising();
|
||||
}
|
||||
|
||||
int gapEventHandler(struct ble_gap_event* event, void* /*arg*/) {
|
||||
switch (event->type) {
|
||||
case BLE_GAP_EVENT_CONNECT:
|
||||
if (event->connect.status == 0) {
|
||||
ESP_LOGI(kLogTag, "Connected; handle=%d", event->connect.conn_handle);
|
||||
} else {
|
||||
ESP_LOGW(kLogTag, "Connection attempt failed; status=%d", event->connect.status);
|
||||
startAdvertising();
|
||||
}
|
||||
break;
|
||||
|
||||
case BLE_GAP_EVENT_DISCONNECT:
|
||||
ESP_LOGI(kLogTag, "Disconnected; reason=%d", event->disconnect.reason);
|
||||
startAdvertising();
|
||||
break;
|
||||
|
||||
case BLE_GAP_EVENT_ADV_COMPLETE:
|
||||
ESP_LOGI(kLogTag, "Advertising complete; restarting");
|
||||
startAdvertising();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void hostTask(void* /*param*/) {
|
||||
g_hostTaskHandle = xTaskGetCurrentTaskHandle();
|
||||
nimble_port_run(); // This call blocks until NimBLE stops
|
||||
nimble_port_freertos_deinit();
|
||||
g_hostTaskHandle = nullptr;
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
void configureGap() {
|
||||
ble_svc_gap_init();
|
||||
ble_svc_gap_device_name_set(kDeviceName);
|
||||
ble_svc_gatt_init();
|
||||
}
|
||||
|
||||
bool initController() {
|
||||
ble_hs_cfg.reset_cb = onReset;
|
||||
ble_hs_cfg.sync_cb = onSync;
|
||||
ble_hs_cfg.gatts_register_cb = nullptr;
|
||||
ble_hs_cfg.store_status_cb = ble_store_util_status_rr;
|
||||
ble_hs_cfg.sm_io_cap = BLE_HS_IO_NO_INPUT_OUTPUT;
|
||||
ble_hs_cfg.sm_bonding = 0;
|
||||
ble_hs_cfg.sm_mitm = 0;
|
||||
ble_hs_cfg.sm_sc = 0;
|
||||
ESP_ERROR_CHECK(nimble_port_init());
|
||||
|
||||
configureGap();
|
||||
|
||||
int gattRc = ble_gatts_count_cfg(kGattServices);
|
||||
if (gattRc != 0) {
|
||||
ESP_LOGE(kLogTag, "ble_gatts_count_cfg failed (rc=%d)", gattRc);
|
||||
return false;
|
||||
}
|
||||
|
||||
gattRc = ble_gatts_add_svcs(kGattServices);
|
||||
if (gattRc != 0) {
|
||||
ESP_LOGE(kLogTag, "ble_gatts_add_svcs failed (rc=%d)", gattRc);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ensure_time_sync_service_started() {
|
||||
if (g_started) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initController()) {
|
||||
ESP_LOGE(kLogTag, "Unable to initialise BLE time sync service");
|
||||
return;
|
||||
}
|
||||
|
||||
nimble_port_freertos_init(hostTask);
|
||||
g_started = true;
|
||||
ESP_LOGI(kLogTag, "BLE time sync service initialised");
|
||||
}
|
||||
|
||||
void shutdown_time_sync_service() {
|
||||
if (!g_started) {
|
||||
return;
|
||||
}
|
||||
|
||||
int rc = nimble_port_stop();
|
||||
if (rc == 0) {
|
||||
// Wait for host task to exit
|
||||
while (g_hostTaskHandle != nullptr) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
}
|
||||
|
||||
nimble_port_deinit();
|
||||
// esp_nimble_hci_and_controller_deinit();
|
||||
esp_bt_controller_disable();
|
||||
esp_bt_controller_deinit();
|
||||
|
||||
g_started = false;
|
||||
ESP_LOGI(kLogTag, "BLE time sync service stopped");
|
||||
}
|
||||
|
||||
} // namespace cardboy::backend::esp
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "cardboy/apps/gameboy_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"
|
||||
@@ -234,6 +235,7 @@ extern "C" void app_main() {
|
||||
system.registerApp(apps::createMenuAppFactory());
|
||||
system.registerApp(apps::createSettingsAppFactory());
|
||||
system.registerApp(apps::createClockAppFactory());
|
||||
system.registerApp(apps::createSnakeAppFactory());
|
||||
system.registerApp(apps::createTetrisAppFactory());
|
||||
system.registerApp(apps::createGameboyAppFactory());
|
||||
|
||||
|
||||
@@ -16,4 +16,5 @@ add_subdirectory(menu)
|
||||
add_subdirectory(clock)
|
||||
add_subdirectory(settings)
|
||||
add_subdirectory(gameboy)
|
||||
add_subdirectory(snake)
|
||||
add_subdirectory(tetris)
|
||||
|
||||
@@ -299,6 +299,9 @@ public:
|
||||
break;
|
||||
}
|
||||
|
||||
if (mode != Mode::Running)
|
||||
break;
|
||||
|
||||
|
||||
GB_PERF_ONLY(perf.geometryUs = 0;)
|
||||
|
||||
@@ -306,11 +309,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 +818,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 +947,19 @@ public:
|
||||
|
||||
void printStep(Mode stepMode, bool gbReady, bool frameDirty, uint32_t fps, const std::string& romName,
|
||||
std::size_t romCount, std::size_t selectedIdx, bool browserDirty) const {
|
||||
auto toMs = [](uint64_t us) { return static_cast<double>(us) / 1000.0; };
|
||||
const char* modeStr = (stepMode == Mode::Running) ? "RUN" : "BROWSE";
|
||||
auto toMs = [](uint64_t us) { return static_cast<double>(us) / 1000.0; };
|
||||
const char* modeStr = "UNKNOWN";
|
||||
switch (stepMode) {
|
||||
case Mode::Running:
|
||||
modeStr = "RUN";
|
||||
break;
|
||||
case Mode::Browse:
|
||||
modeStr = "BROWSE";
|
||||
break;
|
||||
case Mode::Prompt:
|
||||
modeStr = "PROMPT";
|
||||
break;
|
||||
}
|
||||
const char* name = romName.empty() ? "-" : romName.c_str();
|
||||
const std::size_t safeIdx = (romCount == 0) ? 0 : std::min(selectedIdx, romCount - 1);
|
||||
std::printf(
|
||||
@@ -1085,6 +1138,10 @@ public:
|
||||
uint32_t fpsCurrent = 0;
|
||||
std::string activeRomName;
|
||||
std::string activeRomSavePath;
|
||||
std::string activeRomStatePath;
|
||||
PromptKind promptKind = PromptKind::None;
|
||||
int promptSelection = 0; // 0 = Yes, 1 = No
|
||||
bool promptDirty = false;
|
||||
SimpleApu apu{};
|
||||
// Smoothing state for buzzer tone
|
||||
uint32_t lastFreqHz = 0;
|
||||
@@ -1423,21 +1480,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 +1513,10 @@ public:
|
||||
if (!fsReady)
|
||||
statusText.append(" (no save)");
|
||||
setStatus(std::move(statusText));
|
||||
|
||||
if (stateFileExists())
|
||||
enterPrompt(PromptKind::LoadState);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1464,6 +1529,10 @@ public:
|
||||
cartRam.clear();
|
||||
activeRomName.clear();
|
||||
activeRomSavePath.clear();
|
||||
activeRomStatePath.clear();
|
||||
promptKind = PromptKind::None;
|
||||
promptSelection = 0;
|
||||
promptDirty = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1477,6 +1546,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 +1564,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 +1596,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 +1767,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 +1998,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 +2200,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) {
|
||||
|
||||
9
Firmware/sdk/apps/snake/CMakeLists.txt
Normal file
9
Firmware/sdk/apps/snake/CMakeLists.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
target_sources(cardboy_apps
|
||||
PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/snake_app.cpp
|
||||
)
|
||||
|
||||
target_include_directories(cardboy_apps
|
||||
PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
)
|
||||
11
Firmware/sdk/apps/snake/include/cardboy/apps/snake_app.hpp
Normal file
11
Firmware/sdk/apps/snake/include/cardboy/apps/snake_app.hpp
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/sdk/app_framework.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace apps {
|
||||
|
||||
std::unique_ptr<cardboy::sdk::IAppFactory> createSnakeAppFactory();
|
||||
|
||||
} // namespace apps
|
||||
432
Firmware/sdk/apps/snake/src/snake_app.cpp
Normal file
432
Firmware/sdk/apps/snake/src/snake_app.cpp
Normal file
@@ -0,0 +1,432 @@
|
||||
#include "cardboy/apps/snake_app.hpp"
|
||||
|
||||
#include "cardboy/apps/menu_app.hpp"
|
||||
#include "cardboy/gfx/font16x8.hpp"
|
||||
#include "cardboy/sdk/app_framework.hpp"
|
||||
#include "cardboy/sdk/app_system.hpp"
|
||||
#include "cardboy/sdk/display_spec.hpp"
|
||||
#include "cardboy/sdk/input_state.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <deque>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace apps {
|
||||
namespace {
|
||||
|
||||
using cardboy::sdk::AppButtonEvent;
|
||||
using cardboy::sdk::AppContext;
|
||||
using cardboy::sdk::AppEvent;
|
||||
using cardboy::sdk::AppEventType;
|
||||
using cardboy::sdk::AppTimerHandle;
|
||||
using cardboy::sdk::InputState;
|
||||
|
||||
constexpr char kSnakeAppName[] = "Snake";
|
||||
|
||||
constexpr int kBoardWidth = 32;
|
||||
constexpr int kBoardHeight = 20;
|
||||
constexpr int kCellSize = 10;
|
||||
constexpr int kInitialSnakeLength = 5;
|
||||
constexpr int kScorePerFood = 10;
|
||||
constexpr int kMinMoveIntervalMs = 80;
|
||||
constexpr int kBaseMoveIntervalMs = 220;
|
||||
constexpr int kIntervalSpeedupPerSegment = 4;
|
||||
|
||||
struct Point {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
|
||||
bool operator==(const Point& other) const { return x == other.x && y == other.y; }
|
||||
};
|
||||
|
||||
enum class Direction { Up, Down, Left, Right };
|
||||
|
||||
[[nodiscard]] std::uint32_t randomSeed(AppContext& ctx) {
|
||||
if (auto* rnd = ctx.random())
|
||||
return rnd->nextUint32();
|
||||
static std::random_device rd;
|
||||
return rd();
|
||||
}
|
||||
|
||||
class SnakeGame {
|
||||
public:
|
||||
explicit SnakeGame(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer) {
|
||||
rng.seed(randomSeed(context));
|
||||
loadHighScore();
|
||||
reset();
|
||||
}
|
||||
|
||||
void onStart() {
|
||||
scheduleMoveTimer();
|
||||
dirty = true;
|
||||
renderIfNeeded();
|
||||
}
|
||||
|
||||
void onStop() { cancelMoveTimer(); }
|
||||
|
||||
void handleEvent(const AppEvent& event) {
|
||||
switch (event.type) {
|
||||
case AppEventType::Button:
|
||||
handleButtons(event.button);
|
||||
break;
|
||||
case AppEventType::Timer:
|
||||
handleTimer(event.timer.handle);
|
||||
break;
|
||||
}
|
||||
renderIfNeeded();
|
||||
}
|
||||
|
||||
private:
|
||||
AppContext& context;
|
||||
typename AppContext::Framebuffer& framebuffer;
|
||||
|
||||
std::deque<Point> snake;
|
||||
Point food{};
|
||||
Direction direction = Direction::Right;
|
||||
Direction queuedDirection = Direction::Right;
|
||||
bool paused = false;
|
||||
bool gameOver = false;
|
||||
bool dirty = false;
|
||||
int score = 0;
|
||||
int highScore = 0;
|
||||
AppTimerHandle moveTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
std::mt19937 rng;
|
||||
|
||||
void handleButtons(const AppButtonEvent& evt) {
|
||||
const auto& cur = evt.current;
|
||||
const auto& prev = evt.previous;
|
||||
if (cur.b && !prev.b) {
|
||||
context.requestAppSwitchByName(kMenuAppName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cur.select && !prev.select) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cur.start && !prev.start) {
|
||||
if (gameOver)
|
||||
reset();
|
||||
else {
|
||||
paused = !paused;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (gameOver)
|
||||
return;
|
||||
|
||||
if (cur.up && !prev.up)
|
||||
queueDirection(Direction::Up);
|
||||
else if (cur.down && !prev.down)
|
||||
queueDirection(Direction::Down);
|
||||
else if (cur.left && !prev.left)
|
||||
queueDirection(Direction::Left);
|
||||
else if (cur.right && !prev.right)
|
||||
queueDirection(Direction::Right);
|
||||
|
||||
if (cur.a && !prev.a && !paused)
|
||||
advance();
|
||||
}
|
||||
|
||||
void handleTimer(AppTimerHandle handle) {
|
||||
if (handle == moveTimer && !paused && !gameOver)
|
||||
advance();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
cancelMoveTimer();
|
||||
|
||||
snake.clear();
|
||||
const int centerX = kBoardWidth / 2;
|
||||
const int centerY = kBoardHeight / 2;
|
||||
for (int i = 0; i < kInitialSnakeLength; ++i)
|
||||
snake.push_back(Point{centerX - i, centerY});
|
||||
|
||||
direction = Direction::Right;
|
||||
queuedDirection = Direction::Right;
|
||||
paused = false;
|
||||
gameOver = false;
|
||||
score = 0;
|
||||
dirty = true;
|
||||
|
||||
if (!spawnFood())
|
||||
onGameOver();
|
||||
|
||||
scheduleMoveTimer();
|
||||
}
|
||||
|
||||
void advance() {
|
||||
direction = queuedDirection;
|
||||
Point nextHead = snake.front();
|
||||
switch (direction) {
|
||||
case Direction::Up:
|
||||
--nextHead.y;
|
||||
break;
|
||||
case Direction::Down:
|
||||
++nextHead.y;
|
||||
break;
|
||||
case Direction::Left:
|
||||
--nextHead.x;
|
||||
break;
|
||||
case Direction::Right:
|
||||
++nextHead.x;
|
||||
break;
|
||||
}
|
||||
|
||||
if (isCollision(nextHead)) {
|
||||
onGameOver();
|
||||
return;
|
||||
}
|
||||
|
||||
snake.push_front(nextHead);
|
||||
if (nextHead == food) {
|
||||
score += kScorePerFood;
|
||||
updateHighScore();
|
||||
if (!spawnFood()) {
|
||||
onGameOver();
|
||||
return;
|
||||
}
|
||||
scheduleMoveTimer();
|
||||
if (auto* buzzer = context.buzzer())
|
||||
buzzer->beepMove();
|
||||
} else {
|
||||
snake.pop_back();
|
||||
}
|
||||
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool isCollision(const Point& nextHead) const {
|
||||
if (nextHead.x < 0 || nextHead.x >= kBoardWidth || nextHead.y < 0 || nextHead.y >= kBoardHeight)
|
||||
return true;
|
||||
return std::find(snake.begin(), snake.end(), nextHead) != snake.end();
|
||||
}
|
||||
|
||||
void onGameOver() {
|
||||
if (gameOver)
|
||||
return;
|
||||
gameOver = true;
|
||||
cancelMoveTimer();
|
||||
dirty = true;
|
||||
if (auto* buzzer = context.buzzer())
|
||||
buzzer->beepGameOver();
|
||||
}
|
||||
|
||||
void queueDirection(Direction next) {
|
||||
if (isOpposite(direction, next) || isOpposite(queuedDirection, next))
|
||||
return;
|
||||
queuedDirection = next;
|
||||
}
|
||||
|
||||
[[nodiscard]] static bool isOpposite(Direction a, Direction b) {
|
||||
if ((a == Direction::Up && b == Direction::Down) || (a == Direction::Down && b == Direction::Up))
|
||||
return true;
|
||||
if ((a == Direction::Left && b == Direction::Right) || (a == Direction::Right && b == Direction::Left))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool spawnFood() {
|
||||
std::vector<Point> freeCells;
|
||||
freeCells.reserve(kBoardWidth * kBoardHeight - static_cast<int>(snake.size()));
|
||||
for (int y = 0; y < kBoardHeight; ++y) {
|
||||
for (int x = 0; x < kBoardWidth; ++x) {
|
||||
Point p{x, y};
|
||||
if (std::find(snake.begin(), snake.end(), p) == snake.end())
|
||||
freeCells.push_back(p);
|
||||
}
|
||||
}
|
||||
if (freeCells.empty())
|
||||
return false;
|
||||
std::uniform_int_distribution<std::size_t> dist(0, freeCells.size() - 1);
|
||||
food = freeCells[dist(rng)];
|
||||
return true;
|
||||
}
|
||||
|
||||
void scheduleMoveTimer() {
|
||||
cancelMoveTimer();
|
||||
const std::uint32_t interval = currentInterval();
|
||||
moveTimer = context.scheduleRepeatingTimer(interval);
|
||||
}
|
||||
|
||||
void cancelMoveTimer() {
|
||||
if (moveTimer != cardboy::sdk::kInvalidAppTimer) {
|
||||
context.cancelTimer(moveTimer);
|
||||
moveTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] std::uint32_t currentInterval() const {
|
||||
int interval = kBaseMoveIntervalMs - static_cast<int>(snake.size()) * kIntervalSpeedupPerSegment;
|
||||
if (interval < kMinMoveIntervalMs)
|
||||
interval = kMinMoveIntervalMs;
|
||||
return static_cast<std::uint32_t>(interval);
|
||||
}
|
||||
|
||||
void updateHighScore() {
|
||||
if (score <= highScore)
|
||||
return;
|
||||
highScore = score;
|
||||
if (auto* storage = context.storage())
|
||||
storage->writeUint32("snake", "best", static_cast<std::uint32_t>(highScore));
|
||||
}
|
||||
|
||||
void loadHighScore() {
|
||||
if (auto* storage = context.storage()) {
|
||||
std::uint32_t stored = 0;
|
||||
if (storage->readUint32("snake", "best", stored))
|
||||
highScore = static_cast<int>(stored);
|
||||
}
|
||||
}
|
||||
|
||||
void renderIfNeeded() {
|
||||
if (!dirty)
|
||||
return;
|
||||
dirty = false;
|
||||
|
||||
framebuffer.frameReady();
|
||||
framebuffer.clear(false);
|
||||
|
||||
drawBoard();
|
||||
drawFood();
|
||||
drawSnake();
|
||||
drawHud();
|
||||
|
||||
framebuffer.sendFrame();
|
||||
}
|
||||
|
||||
[[nodiscard]] int boardOriginX() const {
|
||||
return (cardboy::sdk::kDisplayWidth - kBoardWidth * kCellSize) / 2;
|
||||
}
|
||||
|
||||
[[nodiscard]] int boardOriginY() const {
|
||||
const int centered = (cardboy::sdk::kDisplayHeight - kBoardHeight * kCellSize) / 2;
|
||||
return std::max(24, centered);
|
||||
}
|
||||
|
||||
void drawBoard() {
|
||||
const int originX = boardOriginX();
|
||||
const int originY = boardOriginY();
|
||||
const int width = kBoardWidth * kCellSize;
|
||||
const int height = kBoardHeight * kCellSize;
|
||||
|
||||
const int x0 = originX;
|
||||
const int y0 = originY;
|
||||
const int x1 = originX + width - 1;
|
||||
const int y1 = originY + height - 1;
|
||||
for (int x = x0; x <= x1; ++x) {
|
||||
framebuffer.drawPixel(x, y0, true);
|
||||
framebuffer.drawPixel(x, y1, true);
|
||||
}
|
||||
for (int y = y0; y <= y1; ++y) {
|
||||
framebuffer.drawPixel(x0, y, true);
|
||||
framebuffer.drawPixel(x1, y, true);
|
||||
}
|
||||
}
|
||||
|
||||
void drawSnake() {
|
||||
if (snake.empty())
|
||||
return;
|
||||
std::size_t index = 0;
|
||||
for (const auto& segment: snake) {
|
||||
drawSnakeSegment(segment, index == 0);
|
||||
++index;
|
||||
}
|
||||
}
|
||||
|
||||
void drawSnakeSegment(const Point& segment, bool head) {
|
||||
const int originX = boardOriginX() + segment.x * kCellSize;
|
||||
const int originY = boardOriginY() + segment.y * kCellSize;
|
||||
for (int dy = 0; dy < kCellSize; ++dy) {
|
||||
for (int dx = 0; dx < kCellSize; ++dx) {
|
||||
const bool border = dx == 0 || dy == 0 || dx == kCellSize - 1 || dy == kCellSize - 1;
|
||||
bool fill = ((dx + dy) & 0x1) == 0;
|
||||
if (head)
|
||||
fill = true;
|
||||
const bool on = border || fill;
|
||||
framebuffer.drawPixel(originX + dx, originY + dy, on);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void drawFood() {
|
||||
const int cx = boardOriginX() + food.x * kCellSize + kCellSize / 2;
|
||||
const int cy = boardOriginY() + food.y * kCellSize + kCellSize / 2;
|
||||
const int r = std::max(2, kCellSize / 2 - 1);
|
||||
for (int dy = -r; dy <= r; ++dy) {
|
||||
for (int dx = -r; dx <= r; ++dx) {
|
||||
if (std::abs(dx) + std::abs(dy) <= r)
|
||||
framebuffer.drawPixel(cx + dx, cy + dy, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void drawHud() {
|
||||
const int margin = 12;
|
||||
const int textY = 8;
|
||||
const std::string scoreStr = "SCORE " + std::to_string(score);
|
||||
const std::string bestStr = "BEST " + std::to_string(highScore);
|
||||
font16x8::drawText(framebuffer, margin, textY, scoreStr, 1, true, 1);
|
||||
const int bestX = cardboy::sdk::kDisplayWidth - font16x8::measureText(bestStr, 1, 1) - margin;
|
||||
font16x8::drawText(framebuffer, bestX, textY, bestStr, 1, true, 1);
|
||||
|
||||
const int footerY = cardboy::sdk::kDisplayHeight - 24;
|
||||
const std::string menuStr = "B MENU";
|
||||
const std::string selectStr = "SELECT RESET";
|
||||
const std::string startStr = "START PAUSE";
|
||||
const int selectX = (cardboy::sdk::kDisplayWidth - font16x8::measureText(selectStr, 1, 1)) / 2;
|
||||
const int startX = cardboy::sdk::kDisplayWidth - font16x8::measureText(startStr, 1, 1) - margin;
|
||||
font16x8::drawText(framebuffer, margin, footerY, menuStr, 1, true, 1);
|
||||
font16x8::drawText(framebuffer, selectX, footerY, selectStr, 1, true, 1);
|
||||
font16x8::drawText(framebuffer, startX, footerY, startStr, 1, true, 1);
|
||||
|
||||
if (paused && !gameOver)
|
||||
drawBanner("PAUSED");
|
||||
else if (gameOver)
|
||||
drawBanner("GAME OVER");
|
||||
}
|
||||
|
||||
void drawBanner(std::string_view text) {
|
||||
const int w = font16x8::measureText(text, 2, 1);
|
||||
const int h = font16x8::kGlyphHeight * 2;
|
||||
const int x = (cardboy::sdk::kDisplayWidth - w) / 2;
|
||||
const int y = boardOriginY() + kBoardHeight * kCellSize / 2 - h / 2;
|
||||
for (int yy = -4; yy < h + 4; ++yy)
|
||||
for (int xx = -6; xx < w + 6; ++xx)
|
||||
framebuffer.drawPixel(x + xx, y + yy, yy == -4 || yy == h + 3 || xx == -6 || xx == w + 5);
|
||||
font16x8::drawText(framebuffer, x, y, text, 2, true, 1);
|
||||
}
|
||||
};
|
||||
|
||||
class SnakeApp final : public cardboy::sdk::IApp {
|
||||
public:
|
||||
explicit SnakeApp(AppContext& ctx) : game(ctx) {}
|
||||
|
||||
void onStart() override { game.onStart(); }
|
||||
void onStop() override { game.onStop(); }
|
||||
void handleEvent(const AppEvent& event) override { game.handleEvent(event); }
|
||||
|
||||
private:
|
||||
SnakeGame game;
|
||||
};
|
||||
|
||||
class SnakeFactory final : public cardboy::sdk::IAppFactory {
|
||||
public:
|
||||
const char* name() const override { return kSnakeAppName; }
|
||||
std::unique_ptr<cardboy::sdk::IApp> create(AppContext& context) override {
|
||||
return std::make_unique<SnakeApp>(context);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<cardboy::sdk::IAppFactory> createSnakeAppFactory() { return std::make_unique<SnakeFactory>(); }
|
||||
|
||||
} // namespace apps
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "cardboy/apps/gameboy_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/desktop_backend.hpp"
|
||||
#include "cardboy/sdk/app_system.hpp"
|
||||
@@ -29,6 +30,7 @@ int main() {
|
||||
system.registerApp(apps::createSettingsAppFactory());
|
||||
system.registerApp(apps::createClockAppFactory());
|
||||
system.registerApp(apps::createGameboyAppFactory());
|
||||
system.registerApp(apps::createSnakeAppFactory());
|
||||
system.registerApp(apps::createTetrisAppFactory());
|
||||
|
||||
system.run();
|
||||
|
||||
Reference in New Issue
Block a user