Compare commits

...

93 Commits

Author SHA1 Message Date
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
1bc5b75dba format 2025-10-13 00:39:34 +02:00
e37f8e3dc8 a little better sound 2025-10-13 00:39:26 +02:00
df7c4ff3b9 even better sound 2025-10-13 00:27:45 +02:00
07186b4b73 sound upgrade 2025-10-13 00:17:16 +02:00
6a1f7d48ce simple music 2025-10-13 00:05:56 +02:00
031ff1952b get rid of powerhelper 2025-10-12 22:54:18 +02:00
df55b8f2e1 hang fix fix 2025-10-12 20:43:13 +02:00
ed1cee82d2 fix hanging 2 2025-10-12 20:37:39 +02:00
7474f65aaa fix hanging 2025-10-12 20:37:30 +02:00
088c6e47bd better timers 2025-10-12 20:34:05 +02:00
d91b7540fc fix intr 2025-10-12 17:44:19 +02:00
d5506b9455 fix gameboy autostart 2025-10-12 17:32:33 +02:00
db88e16aaa better controls 2025-10-12 17:29:15 +02:00
a6713859b2 better statusbar 2025-10-12 17:16:07 +02:00
aaac0514c0 more settings 2025-10-12 17:11:22 +02:00
1b6e9a0f78 settings app 2025-10-12 16:57:40 +02:00
c64f03a09f status bar 2 2025-10-12 15:18:42 +02:00
5ab8662332 statusbar 2025-10-12 15:03:34 +02:00
6d8834d9b2 nicer tetris 2025-10-12 14:04:06 +02:00
83ba775971 some stuff 2025-10-12 13:51:50 +02:00
df57e55171 better default scale mode 2025-10-12 00:52:58 +02:00
a3b837f329 a bit more speedup 2025-10-12 00:33:48 +02:00
fc9e85aea0 fast wide scale 2025-10-12 00:20:22 +02:00
b55feb68f8 some opt 2025-10-12 00:03:01 +02:00
f04b026d46 a little faster gameboy 2025-10-11 22:21:31 +02:00
e18278e130 disable analyzer 2025-10-11 20:59:29 +02:00
9a392d6aec check macro 2025-10-11 20:36:43 +02:00
961453e28a 8bit draw 2025-10-11 20:03:00 +02:00
a4c2719077 text 2025-10-11 16:54:41 +02:00
f721ebcb4c some refactoring 2025-10-11 16:44:48 +02:00
e9a05259c5 more cleanup 2025-10-11 15:15:25 +02:00
23400d817b more cleanup 2025-10-11 15:04:28 +02:00
fa2715a60a cleaner backend 2025-10-11 14:26:42 +02:00
899bfeef41 checkpoint 2025-10-11 12:54:46 +02:00
535b0078e5 some cleanup 2025-10-11 11:15:39 +02:00
5b75ff28e0 independnet gameboy 2025-10-10 17:11:49 +02:00
e9e371739b kinda sdk 2025-10-10 16:03:23 +02:00
28411535bb tetris high score 2025-10-10 11:18:04 +02:00
54d5f85538 nice dithering 2025-10-09 23:41:51 +02:00
c3295b9b01 fix rotated text 2025-10-09 23:19:05 +02:00
7fc48e5e93 fixes 2025-10-09 22:56:46 +02:00
afff3d0e02 display fix 2025-10-09 22:25:51 +02:00
0660c40ec4 sdkconfig 2025-10-09 18:57:35 +02:00
8520ef556b better gameboy timings 2025-10-09 18:14:17 +02:00
4e78618556 event loop 2025-10-09 17:12:17 +02:00
49455d1b36 move peanutgb to repo 2025-10-09 16:28:35 +02:00
ddf5a47c33 faster 2025-10-09 09:26:34 +02:00
13cdcb01dd gameboy stats 2025-10-09 00:16:25 +02:00
4c0fd5243f fps counter 2025-10-08 22:41:48 +02:00
429d704c8c vibecoded gb emulator 2025-10-08 22:19:21 +02:00
9a9e25e124 app menu 2025-10-08 21:21:33 +02:00
ecf6d09651 buzzer 2025-10-07 22:50:45 +02:00
413e021e49 async display 2025-10-07 14:57:15 +02:00
c9f0f59630 dump 2025-10-07 10:11:39 +02:00
9420887392 more cleanup 2025-10-07 01:26:39 +02:00
7df84f1e81 better gpt tetris 4 2025-10-07 01:14:38 +02:00
4861d26d8a better 2025-10-07 00:29:04 +02:00
e389a776be broken game over 2025-10-06 09:47:50 +02:00
8b8d9d3a55 pause 2025-10-06 09:34:33 +02:00
126d377836 re-enable auto sleep 2025-10-06 09:16:19 +02:00
3f8d90c18a bat stats 2025-10-06 09:14:25 +02:00
c439aecd03 craptrix 3 2025-10-06 08:50:02 +02:00
cd72c2d7df craptrix 2 2025-10-05 23:23:24 +02:00
589c598b01 craptrix 2025-10-05 22:25:19 +02:00
95a946e47f less crap spi 2025-07-31 17:00:47 +02:00
48d2089b69 get rid of window refresh 2025-07-31 16:13:54 +02:00
3e9b7b4326 less template pain 2025-07-31 16:12:35 +02:00
e1004ff196 x11 thread workaround 2025-07-31 14:36:21 +02:00
24df0fc825 add some missed includes 2025-07-31 14:35:58 +02:00
ab32731f4d dump 2025-07-28 09:39:13 +02:00
474a0b2a43 buttons interrupt 2025-07-26 15:34:20 +02:00
35219c353c set correct charge termination current 2025-07-26 12:43:20 +02:00
8180abed4c shutdown on 3v 2025-07-26 12:18:52 +02:00
12d634ecc9 set VEmpty to 3v 2025-07-26 12:07:53 +02:00
6a8f74384e some firmware updates (fuel gauge and port extender) 2025-07-26 11:45:47 +02:00
126 changed files with 20824 additions and 1082 deletions

3
Firmware/.gitignore vendored
View File

@@ -1,3 +1,6 @@
build
cmake-build*
.idea
.cache
managed_components
*.gb

23
Firmware/.vscode/c_cpp_properties.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"configurations": [
{
"name": "ESP-IDF",
"compilerPath": "${config:idf.toolsPath}/tools/riscv32-esp-elf/esp-14.2.0_20241119/riscv32-esp-elf/bin/riscv32-esp-elf-gcc",
"compileCommands": "${config:idf.buildPath}/compile_commands.json",
"includePath": [
"${config:idf.espIdfPath}/components/**",
"${config:idf.espIdfPathWin}/components/**",
"${workspaceFolder}/**"
],
"browse": {
"path": [
"${config:idf.espIdfPath}/components",
"${config:idf.espIdfPathWin}/components",
"${workspaceFolder}"
],
"limitSymbolsToIncludedHeaders": true
}
}
],
"version": 4
}

15
Firmware/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "gdbtarget",
"request": "attach",
"name": "Eclipse CDT GDB Adapter"
},
{
"type": "espidf",
"name": "Launch",
"request": "launch"
}
]
}

85
Firmware/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,85 @@
{
"idf.flashType": "JTAG",
"idf.port": "/dev/tty.usbmodem12401",
"C_Cpp.intelliSenseEngine": "default",
"files.associations": {
"bitset": "cpp",
"chrono": "cpp",
"algorithm": "cpp",
"random": "cpp",
"fstream": "cpp",
"streambuf": "cpp",
"regex": "cpp",
"*.inc": "cpp",
"vector": "cpp",
"esp_partition.h": "c",
"cstring": "cpp",
"array": "cpp",
"string_view": "cpp",
"any": "cpp",
"atomic": "cpp",
"barrier": "cpp",
"bit": "cpp",
"cctype": "cpp",
"charconv": "cpp",
"clocale": "cpp",
"cmath": "cpp",
"codecvt": "cpp",
"compare": "cpp",
"complex": "cpp",
"concepts": "cpp",
"condition_variable": "cpp",
"cstdarg": "cpp",
"cstddef": "cpp",
"cstdint": "cpp",
"cstdio": "cpp",
"cstdlib": "cpp",
"ctime": "cpp",
"cwchar": "cpp",
"cwctype": "cpp",
"deque": "cpp",
"forward_list": "cpp",
"list": "cpp",
"map": "cpp",
"set": "cpp",
"string": "cpp",
"unordered_map": "cpp",
"unordered_set": "cpp",
"exception": "cpp",
"functional": "cpp",
"iterator": "cpp",
"memory": "cpp",
"memory_resource": "cpp",
"netfwd": "cpp",
"numeric": "cpp",
"optional": "cpp",
"ratio": "cpp",
"system_error": "cpp",
"tuple": "cpp",
"type_traits": "cpp",
"utility": "cpp",
"format": "cpp",
"future": "cpp",
"initializer_list": "cpp",
"iomanip": "cpp",
"iosfwd": "cpp",
"iostream": "cpp",
"istream": "cpp",
"latch": "cpp",
"limits": "cpp",
"mutex": "cpp",
"new": "cpp",
"numbers": "cpp",
"ostream": "cpp",
"semaphore": "cpp",
"span": "cpp",
"sstream": "cpp",
"stdexcept": "cpp",
"stop_token": "cpp",
"text_encoding": "cpp",
"thread": "cpp",
"cinttypes": "cpp",
"typeinfo": "cpp",
"variant": "cpp"
}
}

4
Firmware/AGENTS.md Normal file
View File

@@ -0,0 +1,4 @@
To build:
(in zsh)
. "$HOME/esp/esp-idf/export.sh"
idf.py build

View File

@@ -2,5 +2,7 @@
# CMakeLists in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.16)
add_compile_options(-Ofast)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(hello_world)

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,38 @@
{
"images" : [
{
"filename" : "cardboy-icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "cardboy-icon-dark.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "cardboy-icon-tinted.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 41 KiB

View File

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

View File

@@ -0,0 +1,543 @@
import Combine
import SwiftUI
import UIKit
import UniformTypeIdentifiers
struct ContentView: View {
@EnvironmentObject private var manager: TimeSyncManager
@State private var selectedTab = 0
@State private var shareURL: URL?
@State private var errorWrapper: ErrorWrapper?
var body: some View {
TabView(selection: $selectedTab) {
TimeSyncTabView()
.tabItem { Label("Clock", systemImage: "clock.arrow.circlepath") }
.tag(0)
FileManagerTabView(shareURL: $shareURL, errorWrapper: $errorWrapper)
.tabItem { Label("Files", systemImage: "folder") }
.tag(1)
}
.sheet(item: $shareURL) { url in
ShareSheet(items: [url])
}
.alert(item: $errorWrapper) { wrapper in
Alert(title: Text("Error"), message: Text(wrapper.message), dismissButton: .default(Text("OK")))
}
.onReceive(manager.$fileErrorMessage.compactMap { $0 }) { message in
errorWrapper = ErrorWrapper(message: message)
manager.fileErrorMessage = nil
}
.onReceive(manager.$downloadedFileURL.compactMap { $0 }) { url in
shareURL = url
manager.downloadedFileURL = nil
}
}
}
private struct ErrorWrapper: Identifiable {
let id = UUID()
let message: String
}
private struct TimeSyncTabView: View {
@EnvironmentObject private var manager: TimeSyncManager
private var formattedLastSync: String {
guard let date = manager.lastSyncDate else { return "Never" }
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .medium
return formatter.string(from: date)
}
var body: some View {
VStack(spacing: 24) {
Text("Cardboy Clock Sync")
.font(.title.bold())
VStack(spacing: 12) {
Text("State: \(manager.connectionState.rawValue)")
.font(.headline)
Text(manager.statusMessage)
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
HStack(spacing: 16) {
Button(action: manager.sendCurrentTime) {
Label("Sync Now", systemImage: "clock.arrow.2.circlepath")
}
.buttonStyle(.borderedProminent)
.disabled(manager.connectionState != .ready)
Button(action: manager.forceRescan) {
Label("Rescan", systemImage: "dot.radiowaves.left.and.right")
}
.buttonStyle(.bordered)
}
}
VStack(spacing: 8) {
Text("Last Sync:")
.font(.caption)
.foregroundColor(.secondary)
Text(formattedLastSync)
.font(.body.monospaced())
}
Spacer()
}
.padding()
}
}
private struct FileManagerTabView: View {
@EnvironmentObject private var manager: TimeSyncManager
@Binding var shareURL: URL?
@Binding var errorWrapper: ErrorWrapper?
@State private var navigationPath: [String] = []
var body: some View {
ZStack {
NavigationStack(path: $navigationPath) {
DirectoryView(
path: "/",
navigationPath: $navigationPath,
shareURL: $shareURL,
errorWrapper: $errorWrapper
)
.navigationDestination(for: String.self) { destination in
DirectoryView(
path: destination,
navigationPath: $navigationPath,
shareURL: $shareURL,
errorWrapper: $errorWrapper
)
}
}
if let operation = manager.activeFileOperation {
FileOperationHUD(operation: operation)
}
if manager.connectionState != .ready {
ConnectionOverlay(
state: manager.connectionState,
statusMessage: manager.statusMessage,
retryAction: manager.forceRescan
)
}
}
.onAppear {
if manager.currentDirectory != "/" {
manager.changeDirectory(to: "/")
}
navigationPath = stackForPath(manager.currentDirectory)
}
.onChange(of: manager.currentDirectory) { newValue in
let desired = stackForPath(newValue)
if desired != navigationPath {
navigationPath = desired
}
}
.onChange(of: navigationPath) { newValue in
let target = newValue.last ?? "/"
if target != manager.currentDirectory {
manager.changeDirectory(to: target)
}
}
.onChange(of: manager.connectionState) { newState in
if newState == .ready, !manager.isFileBusy {
manager.refreshDirectory()
}
}
}
}
private struct DirectoryView: View {
let path: String
@Binding var navigationPath: [String]
@Binding var shareURL: URL?
@Binding var errorWrapper: ErrorWrapper?
@EnvironmentObject private var manager: TimeSyncManager
@State private var showingImporter = false
@State private var showingNewFolderSheet = false
@State private var showingRenameSheet = false
@State private var newFolderName = ""
@State private var renameText = ""
@State private var renameTarget: TimeSyncManager.RemoteFileEntry?
private var pathSegments: [(name: String, fullPath: String)] {
var segments: [(String, String)] = []
var current = ""
for component in path.split(separator: "/").map(String.init) {
current += "/" + component
segments.append((component, current))
}
return segments
}
private var displayTitle: String {
pathSegments.last?.name ?? "Files"
}
var body: some View {
ZStack {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Path:")
.font(.headline)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
Button(action: { navigationPath = [] }) {
Text("/")
}
.buttonStyle(.bordered)
ForEach(Array(pathSegments.enumerated()), id: \.element.fullPath) { index, segment in
Button(action: {
navigationPath = Array(pathSegments.prefix(index + 1).map(\.fullPath))
}) {
Text(segment.name)
}
.buttonStyle(.bordered)
}
}
}
}
.padding(.horizontal)
List {
ForEach(manager.directoryEntries) { entry in
if entry.isDirectory {
NavigationLink(value: entry.path) {
FileRow(entry: entry)
}
.contextMenu {
Button("Open") {
navigationPath = stackForPath(entry.path)
}
Button("Rename") {
renameTarget = entry
renameText = entry.name
showingRenameSheet = true
}
Button(role: .destructive) {
manager.delete(entry: entry)
} label: {
Text("Delete")
}
}
} else {
FileRow(entry: entry)
.contextMenu {
Button("Download") {
manager.download(entry: entry) { result in
switch result {
case .success(let url):
shareURL = url
case .failure(let error):
errorWrapper = ErrorWrapper(message: error.localizedDescription)
}
}
}
Button("Rename") {
renameTarget = entry
renameText = entry.name
showingRenameSheet = true
}
Button(role: .destructive) {
manager.delete(entry: entry)
} label: {
Text("Delete")
}
}
}
}
}
.listStyle(.plain)
HStack(spacing: 12) {
Menu {
Button {
showingImporter = true
} label: {
Label("Upload File", systemImage: "square.and.arrow.up")
}
Button {
showingNewFolderSheet = true
newFolderName = ""
} label: {
Label("New Folder", systemImage: "folder.badge.plus")
}
} label: {
Label("Actions", systemImage: "ellipsis.circle")
}
.buttonStyle(.bordered)
.controlSize(.large)
Spacer()
Button {
manager.refreshDirectory()
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.padding([.horizontal, .bottom])
}
}
.navigationTitle(displayTitle)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
if manager.currentDirectory != path {
manager.changeDirectory(to: path)
} else {
manager.refreshDirectory()
}
}
.fileImporter(
isPresented: $showingImporter,
allowedContentTypes: [.data],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
manager.uploadFile(from: url) { uploadResult in
if case let .failure(error) = uploadResult {
errorWrapper = ErrorWrapper(message: error.localizedDescription)
}
}
case .failure(let error):
errorWrapper = ErrorWrapper(message: error.localizedDescription)
}
}
.sheet(isPresented: $showingNewFolderSheet) {
NavigationView {
Form {
Section(header: Text("Folder Name")) {
TextField("Name", text: $newFolderName)
.autocapitalization(.none)
}
}
.navigationTitle("New Folder")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showingNewFolderSheet = false }
}
ToolbarItem(placement: .confirmationAction) {
Button("Create") {
let name = newFolderName.trimmingCharacters(in: .whitespacesAndNewlines)
if !name.isEmpty {
manager.createDirectory(named: name)
}
showingNewFolderSheet = false
}
}
}
}
}
.sheet(isPresented: $showingRenameSheet) {
NavigationView {
Form {
Section(header: Text("New Name")) {
TextField("Name", text: $renameText)
.autocapitalization(.none)
}
}
.navigationTitle("Rename")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showingRenameSheet = false }
}
ToolbarItem(placement: .confirmationAction) {
Button("Rename") {
if let target = renameTarget {
let newName = renameText.trimmingCharacters(in: .whitespacesAndNewlines)
if !newName.isEmpty {
manager.rename(entry: target, to: newName)
}
}
showingRenameSheet = false
}
}
}
}
}
}
}
private func stackForPath(_ path: String) -> [String] {
guard path != "/" else { return [] }
var stack: [String] = []
var current = ""
for component in path.split(separator: "/").map(String.init) {
current += "/" + component
stack.append(current)
}
return stack
}
private struct FileRow: View {
let entry: TimeSyncManager.RemoteFileEntry
private var iconName: String {
entry.isDirectory ? "folder" : "doc.text"
}
private var formattedSize: String {
guard !entry.isDirectory else { return "" }
let byteCountFormatter = ByteCountFormatter()
byteCountFormatter.allowedUnits = [.useKB, .useMB, .useGB]
byteCountFormatter.countStyle = .file
return byteCountFormatter.string(fromByteCount: Int64(entry.size))
}
var body: some View {
HStack {
Image(systemName: iconName)
.foregroundColor(entry.isDirectory ? .accentColor : .primary)
VStack(alignment: .leading) {
Text(entry.name)
.fontWeight(entry.isDirectory ? .semibold : .regular)
if !entry.isDirectory, !formattedSize.isEmpty {
Text(formattedSize)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
.padding(.vertical, 4)
}
}
private struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
private struct FileOperationHUD: View {
let operation: TimeSyncManager.FileOperationProgress
var body: some View {
ZStack {
Color.black.opacity(0.3)
.ignoresSafeArea()
VStack(spacing: 12) {
Text(operation.title)
.font(.headline)
Text(operation.message)
.font(.subheadline)
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
if let progress = operation.progress {
ProgressView(value: progress)
.progressViewStyle(.linear)
.frame(maxWidth: 240)
Text("\(Int(progress * 100))%")
.font(.caption.monospacedDigit())
.foregroundColor(.secondary)
} else {
ProgressView()
.progressViewStyle(.circular)
}
}
.padding(24)
.background(.ultraThinMaterial)
.cornerRadius(16)
.shadow(radius: 10)
.padding()
}
.transition(.opacity)
}
}
private struct ConnectionOverlay: View {
let state: TimeSyncManager.ConnectionState
let statusMessage: String
let retryAction: () -> Void
private var showsSpinner: Bool {
switch state {
case .scanning, .connecting, .discovering:
return true
default:
return false
}
}
private var canRetry: Bool {
switch state {
case .failed, .idle:
return true
default:
return false
}
}
var body: some View {
ZStack {
Color.black.opacity(0.35)
.ignoresSafeArea()
VStack(spacing: 16) {
if showsSpinner {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(1.2)
} else {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 42, weight: .semibold))
.foregroundColor(.yellow)
}
Text(statusMessage)
.multilineTextAlignment(.center)
.font(.headline)
.foregroundColor(.primary)
if canRetry {
Button("Try Again", action: retryAction)
.buttonStyle(.borderedProminent)
}
}
.padding(28)
.frame(maxWidth: 320)
.background(.ultraThinMaterial)
.cornerRadius(20)
.shadow(radius: 12)
}
.transition(.opacity)
}
}
extension URL: Identifiable {
public var id: String { absoluteString }
}
#Preview {
ContentView()
.environmentObject(TimeSyncManager())
}

View File

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

View File

@@ -0,0 +1,946 @@
import Combine
import CoreBluetooth
import Foundation
import UniformTypeIdentifiers
final class TimeSyncManager: NSObject, ObservableObject {
enum ConnectionState: String {
case idle = "Idle"
case scanning = "Scanning"
case connecting = "Connecting"
case discovering = "Discovering Services"
case ready = "Ready"
case failed = "Failed"
}
struct RemoteFileEntry: Identifiable, Hashable {
enum EntryType: Hashable {
case file(size: UInt32)
case directory
}
let path: String
let name: String
let type: EntryType
var id: String { path }
var isDirectory: Bool {
if case .directory = type {
return true
}
return false
}
var size: UInt32 {
if case let .file(size) = type {
return size
}
return 0
}
}
enum FileServiceError: Error, LocalizedError {
case characteristicUnavailable
case busy
case remoteError(code: Int, message: String?)
case invalidResponse
var errorDescription: String? {
switch self {
case .characteristicUnavailable:
return "File service is not ready yet."
case .busy:
return "Another file operation is already running."
case let .remoteError(code, message):
if let message, !message.isEmpty {
return "\(message) (code \(code))"
}
return "Remote error (code \(code))"
case .invalidResponse:
return "Received an invalid response from the device."
}
}
}
@Published private(set) var connectionState: ConnectionState = .idle
@Published private(set) var statusMessage: String = "Waiting for Bluetooth…"
@Published private(set) var lastSyncDate: Date?
@Published private(set) var currentDirectory: String = "/"
@Published private(set) var directoryEntries: [RemoteFileEntry] = []
@Published private(set) var isFileBusy: Bool = false
@Published var fileErrorMessage: String?
@Published var downloadedFileURL: URL?
@Published private(set) var activeFileOperation: FileOperationProgress?
struct FileOperationProgress: Identifiable, Equatable {
let id: UUID
let title: String
let message: String
let progress: Double?
let indeterminate: Bool
}
private let timeServiceUUID = CBUUID(string: "00000001-CA7B-4EFD-B5A6-10C3F4D3F230")
private let timeCharacteristicUUID = CBUUID(string: "00000002-CA7B-4EFD-B5A6-10C3F4D3F231")
private let fileServiceUUID = CBUUID(string: "00000010-CA7B-4EFD-B5A6-10C3F4D3F230")
private let fileCommandUUID = CBUUID(string: "00000011-CA7B-4EFD-B5A6-10C3F4D3F231")
private let fileResponseUUID = CBUUID(string: "00000012-CA7B-4EFD-B5A6-10C3F4D3F232")
private let responseFlagComplete: UInt8 = 0x80
private lazy var central: CBCentralManager = CBCentralManager(
delegate: self,
queue: nil,
options: [
CBCentralManagerOptionShowPowerAlertKey: true,
CBCentralManagerOptionRestoreIdentifierKey: "com.usatiuk.cardboy-companion.central"
]
)
private var targetPeripheral: CBPeripheral?
private var timeCharacteristic: CBCharacteristic?
private var fileCommandCharacteristic: CBCharacteristic?
private var fileResponseCharacteristic: CBCharacteristic?
private var retryWorkItem: DispatchWorkItem?
private var shouldKeepScanning = true
private var isScanning = false
private var fileCommandQueue: [Data] = []
private var isWritingFileCommand = false
private var pendingListData = Data()
private var pendingListPath: String?
private var pendingListOperationID: UUID?
private var simpleOperationID: UUID?
private var pendingDirectoryRequest: (path: String, operationID: UUID)?
private struct UploadState {
let id: UUID
var remotePath: String
var data: Data
var offset: Int = 0
var awaitingChunkAck: Bool = false
var completion: (Result<Void, Error>) -> Void
}
private var uploadState: UploadState?
private struct DownloadState {
let id: UUID
var remotePath: String
var expectedSize: Int?
var data = Data()
var completion: (Result<URL, Error>) -> Void
}
private var downloadState: DownloadState?
override init() {
super.init()
// Force central manager to initialise immediately so state updates arrive right away.
_ = central
if central.state == .poweredOn {
startScanning()
}
}
deinit {
retryWorkItem?.cancel()
}
// MARK: - Public BLE Controls
func forceRescan() {
statusMessage = "Restarting scan…"
shouldKeepScanning = true
retryWorkItem?.cancel()
let existingPeripheral = targetPeripheral
stopScanning()
if let existingPeripheral {
central.cancelPeripheralConnection(existingPeripheral)
}
targetPeripheral = nil
timeCharacteristic = nil
fileCommandCharacteristic = nil
fileResponseCharacteristic = nil
resetFileStateOnDisconnect()
startScanning()
}
func sendCurrentTime() {
guard let peripheral = targetPeripheral,
let characteristic = timeCharacteristic else {
statusMessage = "Device is not ready."
return
}
let now = Date()
let epochSeconds = UInt64(now.timeIntervalSince1970.rounded())
let timezoneOffsetMinutes = Int16(TimeZone.current.secondsFromGMT(for: now) / 60)
let isDst = TimeZone.current.isDaylightSavingTime(for: now) ? UInt8(1) : UInt8(0)
var payload = Data()
var epochLe = epochSeconds.littleEndian
payload.append(UnsafeBufferPointer(start: &epochLe, count: 1))
var offsetLe = timezoneOffsetMinutes.littleEndian
payload.append(UnsafeBufferPointer(start: &offsetLe, count: 1))
payload.append(isDst)
payload.append(UInt8(0)) // Reserved byte
peripheral.writeValue(payload, for: characteristic, type: .withResponse)
lastSyncDate = now
connectionState = .ready
let timeString = DateFormatter.localizedString(from: now, dateStyle: .none, timeStyle: .medium)
statusMessage = "Time synced at \(timeString)."
}
// MARK: - File operations exposed to UI
func refreshDirectory() {
if isFileBusy {
pendingDirectoryRequest = (path: currentDirectory, operationID: pendingDirectoryRequest?.operationID ?? UUID())
return
}
requestDirectory(path: currentDirectory)
}
func enter(directory entry: RemoteFileEntry) {
guard entry.isDirectory else { return }
changeDirectory(to: NSString(string: currentDirectory).appendingPathComponent(entry.name))
}
func navigateUp() {
guard currentDirectory != "/" else { return }
changeDirectory(to: (currentDirectory as NSString).deletingLastPathComponent)
}
func changeDirectory(to path: String) {
let normalized = normalizedPath(path)
currentDirectory = normalized
requestDirectory(path: normalized)
}
func uploadFile(from url: URL, suggestedName: String? = nil, completion: @escaping (Result<Void, Error>) -> Void) {
guard uploadState == nil else {
completion(.failure(FileServiceError.busy))
return
}
guard let commandCharacteristic = fileCommandCharacteristic else {
completion(.failure(FileServiceError.characteristicUnavailable))
return
}
let fileData: Data
let shouldStopAccessing = url.startAccessingSecurityScopedResource()
defer {
if shouldStopAccessing {
url.stopAccessingSecurityScopedResource()
}
}
do {
fileData = try Data(contentsOf: url)
} catch {
completion(.failure(error))
return
}
let name = suggestedName ?? url.lastPathComponent
let remotePath = normalizedPath(NSString(string: currentDirectory).appendingPathComponent(name))
let opID = UUID()
uploadState = UploadState(id: opID, remotePath: remotePath, data: fileData, completion: completion)
isFileBusy = true
updateOperation(id: opID, title: "Uploading", message: remotePath, progress: 0.0)
let payload = payloadFor(path: remotePath, extra: UInt32(fileData.count).littleEndianBytes)
enqueueFileCommand(.uploadBegin, payload: payload, characteristic: commandCharacteristic)
}
func delete(entry: RemoteFileEntry) {
let opcode: FileCommand = entry.isDirectory ? .deleteDirectory : .deleteFile
guard let commandCharacteristic = fileCommandCharacteristic else {
fileErrorMessage = FileServiceError.characteristicUnavailable.localizedDescription
return
}
let payload = payloadFor(path: entry.path)
isFileBusy = true
let opID = UUID()
let title = entry.isDirectory ? "Deleting Folder" : "Deleting File"
updateOperation(id: opID, title: title, message: entry.path, progress: nil, indeterminate: true)
simpleOperationID = opID
enqueueFileCommand(opcode, payload: payload, characteristic: commandCharacteristic)
}
func createDirectory(named name: String) {
guard let commandCharacteristic = fileCommandCharacteristic else {
fileErrorMessage = FileServiceError.characteristicUnavailable.localizedDescription
return
}
let remotePath = normalizedPath(NSString(string: currentDirectory).appendingPathComponent(name))
let payload = payloadFor(path: remotePath)
isFileBusy = true
let opID = UUID()
updateOperation(id: opID, title: "Creating Folder", message: remotePath, progress: nil, indeterminate: true)
simpleOperationID = opID
enqueueFileCommand(.createDirectory, payload: payload, characteristic: commandCharacteristic)
}
func rename(entry: RemoteFileEntry, to newName: String) {
guard let commandCharacteristic = fileCommandCharacteristic else {
fileErrorMessage = FileServiceError.characteristicUnavailable.localizedDescription
return
}
let srcPath = entry.path
let dstPath = normalizedPath(NSString(string: (srcPath as NSString).deletingLastPathComponent).appendingPathComponent(newName))
var payload = Data()
payload.appendPath(srcPath)
payload.appendPath(dstPath)
isFileBusy = true
let opID = UUID()
updateOperation(id: opID, title: "Renaming", message: srcPath, progress: nil, indeterminate: true)
simpleOperationID = opID
enqueueFileCommand(.renamePath, payload: payload, characteristic: commandCharacteristic)
}
func download(entry: RemoteFileEntry, completion: @escaping (Result<URL, Error>) -> Void) {
guard !entry.isDirectory else {
completion(.failure(FileServiceError.busy))
return
}
guard downloadState == nil else {
completion(.failure(FileServiceError.busy))
return
}
guard let commandCharacteristic = fileCommandCharacteristic else {
completion(.failure(FileServiceError.characteristicUnavailable))
return
}
let opID = UUID()
downloadState = DownloadState(id: opID, remotePath: entry.path, expectedSize: nil, data: Data(), completion: completion)
isFileBusy = true
updateOperation(id: opID, title: "Downloading", message: entry.path, progress: nil, indeterminate: true)
let payload = payloadFor(path: entry.path)
enqueueFileCommand(.downloadRequest, payload: payload, characteristic: commandCharacteristic)
}
// MARK: - Private helpers
private func startScanning() {
guard shouldKeepScanning, central.state == .poweredOn else { return }
if isScanning { return }
central.scanForPeripherals(withServices: [timeServiceUUID, fileServiceUUID], options: [
CBCentralManagerScanOptionAllowDuplicatesKey: false
])
isScanning = true
connectionState = .scanning
statusMessage = "Scanning for Cardboy…"
}
private func stopScanning() {
guard isScanning else { return }
central.stopScan()
isScanning = false
}
private func scheduleRetry(after delay: TimeInterval = 2.5) {
shouldKeepScanning = true
retryWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
self?.startScanning()
}
retryWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}
private enum FileCommand: UInt8 {
case listDirectory = 0x01
case uploadBegin = 0x02
case uploadChunk = 0x03
case uploadEnd = 0x04
case downloadRequest = 0x05
case deleteFile = 0x06
case createDirectory = 0x07
case deleteDirectory = 0x08
case renamePath = 0x09
}
private func enqueueFileCommand(_ opcode: FileCommand, payload: Data, characteristic: CBCharacteristic) {
var packet = Data()
packet.append(opcode.rawValue)
packet.append(UInt8(0))
packet.append(UInt8(payload.count & 0xFF))
packet.append(UInt8((payload.count >> 8) & 0xFF))
packet.append(payload)
fileCommandQueue.append(packet)
processFileCommandQueue(characteristic: characteristic)
}
private func processFileCommandQueue(characteristic: CBCharacteristic? = nil) {
guard !isWritingFileCommand else { return }
guard let characteristic = characteristic ?? fileCommandCharacteristic else { return }
guard !fileCommandQueue.isEmpty, let peripheral = targetPeripheral else { return }
let packet = fileCommandQueue.removeFirst()
isWritingFileCommand = true
peripheral.writeValue(packet, for: characteristic, type: .withResponse)
}
private func resetFileStateOnDisconnect() {
fileCommandQueue.removeAll()
isWritingFileCommand = false
uploadState = nil
downloadState = nil
pendingListData.removeAll()
pendingListPath = nil
pendingListOperationID = nil
simpleOperationID = nil
pendingDirectoryRequest = nil
isFileBusy = false
currentDirectory = "/"
directoryEntries = []
clearOperation()
}
private func payloadFor(path: String, extra: [UInt8]? = nil) -> Data {
var data = Data()
data.appendPath(path)
if let extra {
data.append(contentsOf: extra)
}
return data
}
private func updateOperation(id: UUID, title: String, message: String, progress: Double?, indeterminate: Bool = false) {
DispatchQueue.main.async {
self.activeFileOperation = FileOperationProgress(id: id, title: title, message: message, progress: progress, indeterminate: indeterminate)
}
}
private func clearOperation(ifMatches id: UUID? = nil) {
DispatchQueue.main.async {
if let id, self.activeFileOperation?.id != id {
return
}
self.activeFileOperation = nil
}
}
private func normalizedPath(_ path: String) -> String {
var normalized = path
if normalized.isEmpty {
normalized = "/"
}
if !normalized.hasPrefix("/") {
normalized = "/" + normalized
}
if normalized.count > 1 && normalized.hasSuffix("/") {
normalized.removeLast()
}
return normalized
}
private func handleFileResponse(_ data: Data) {
guard data.count >= 4 else {
fileErrorMessage = FileServiceError.invalidResponse.localizedDescription
return
}
let opcodeRaw = data[0]
let status = data[1]
let length = Int(data[2]) | (Int(data[3]) << 8)
guard data.count >= 4 + length else {
fileErrorMessage = FileServiceError.invalidResponse.localizedDescription
return
}
let payload = data.subdata(in: 4 ..< 4 + length)
let isComplete = (status & responseFlagComplete) != 0
let errorCode = Int(status & ~responseFlagComplete)
if errorCode != 0 {
let message = String(data: payload, encoding: .utf8)
handleFileError(opcodeRaw, code: errorCode, message: message)
return
}
guard let opcode = FileCommand(rawValue: opcodeRaw) else { return }
switch opcode {
case .listDirectory:
pendingListData.append(payload)
if isComplete {
let opID = pendingListOperationID
finalizeDirectoryListing(path: pendingListPath ?? currentDirectory, data: pendingListData)
pendingListData.removeAll()
pendingListPath = nil
isFileBusy = false
pendingListOperationID = nil
clearOperation(ifMatches: opID)
}
case .uploadBegin:
if isComplete {
sendNextUploadChunk()
}
case .uploadChunk:
if isComplete {
uploadState?.awaitingChunkAck = false
sendNextUploadChunk()
}
case .uploadEnd:
if isComplete {
let opID = uploadState?.id
let completion = uploadState?.completion
uploadState = nil
isFileBusy = false
completion?(.success(()))
refreshDirectory()
clearOperation(ifMatches: opID)
}
case .downloadRequest:
handleDownloadResponse(payload: payload, isComplete: isComplete)
case .deleteFile, .createDirectory, .deleteDirectory, .renamePath:
if isComplete {
isFileBusy = false
refreshDirectory()
let opID = simpleOperationID
simpleOperationID = nil
clearOperation(ifMatches: opID)
}
}
}
private func handleFileError(_ opcodeRaw: UInt8, code: Int, message: String?) {
var operationID: UUID?
defer {
isFileBusy = false
clearOperation(ifMatches: operationID)
}
let error = FileServiceError.remoteError(code: code, message: message)
fileErrorMessage = error.localizedDescription
pendingListData.removeAll()
pendingListPath = nil
pendingListOperationID = nil
switch FileCommand(rawValue: opcodeRaw) {
case .listDirectory:
operationID = pendingListOperationID
pendingListOperationID = nil
case .uploadBegin, .uploadChunk, .uploadEnd:
operationID = uploadState?.id
if let completion = uploadState?.completion {
completion(.failure(error))
}
uploadState = nil
case .downloadRequest:
operationID = downloadState?.id
if let completion = downloadState?.completion {
completion(.failure(error))
}
downloadState = nil
case .deleteFile, .createDirectory, .deleteDirectory, .renamePath:
operationID = simpleOperationID
simpleOperationID = nil
default:
break
}
}
private func finalizeDirectoryListing(path: String, data: Data) {
var entries: [RemoteFileEntry] = []
var index = data.startIndex
while index + 8 <= data.endIndex {
let typeRaw = data[index]
index += 1 // type
index += 1 // reserved
let nameLen = Int(data[index]) | (Int(data[index + 1]) << 8)
index += 2
let size = UInt32(data[index]) |
(UInt32(data[index + 1]) << 8) |
(UInt32(data[index + 2]) << 16) |
(UInt32(data[index + 3]) << 24)
index += 4
guard index + nameLen <= data.endIndex else { break }
let nameData = data[index ..< index + nameLen]
index += nameLen
guard let name = String(data: nameData, encoding: .utf8) else { continue }
let fullPath = normalizedPath(NSString(string: path).appendingPathComponent(name))
let type: RemoteFileEntry.EntryType = (typeRaw == 1) ? .directory : .file(size: size)
entries.append(RemoteFileEntry(path: fullPath, name: name, type: type))
}
directoryEntries = entries.sorted {
if $0.isDirectory == $1.isDirectory {
return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
return $0.isDirectory && !$1.isDirectory
}
}
private func sendNextUploadChunk() {
guard var state = uploadState else { return }
guard let commandCharacteristic = fileCommandCharacteristic,
targetPeripheral != nil else { return }
if state.awaitingChunkAck {
uploadState = state
return
}
if state.offset >= state.data.count {
updateOperation(id: state.id, title: "Uploading", message: state.remotePath, progress: 1.0)
print("Upload progress \(state.remotePath): 100%")
uploadState = state
enqueueFileCommand(.uploadEnd, payload: Data(), characteristic: commandCharacteristic)
return
}
let chunkSize = min(140, state.data.count - state.offset)
let chunk = state.data[state.offset ..< state.offset + chunkSize]
state.offset += chunkSize
state.awaitingChunkAck = true
let totalBytes = max(state.data.count, 1)
let progress = Double(state.offset) / Double(totalBytes)
updateOperation(id: state.id, title: "Uploading", message: state.remotePath, progress: progress)
print("Upload progress \(state.remotePath): \(Int(progress * 100))%")
uploadState = state
enqueueFileCommand(.uploadChunk, payload: Data(chunk), characteristic: commandCharacteristic)
processFileCommandQueue(characteristic: commandCharacteristic)
}
private func handleDownloadResponse(payload: Data, isComplete: Bool) {
guard var state = downloadState else { return }
if state.expectedSize == nil && payload.count == 4 {
let size = Int(payload[0]) | (Int(payload[1]) << 8) | (Int(payload[2]) << 16) | (Int(payload[3]) << 24)
state.expectedSize = size
downloadState = state
if size == 0 {
updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: 1.0)
} else {
updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: 0.0)
}
if isComplete && size == 0 {
finishDownload(state: state)
}
return
}
state.data.append(payload)
downloadState = state
if let expected = state.expectedSize, expected > 0 {
let progress = min(Double(state.data.count) / Double(expected), 1.0)
updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: progress)
print("Download progress \(state.remotePath): \(Int(progress * 100))%")
} else {
updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: nil, indeterminate: true)
}
if isComplete {
finishDownload(state: state)
}
}
private func finishDownload(state: DownloadState) {
var completedState = state
defer {
downloadState = nil
isFileBusy = false
clearOperation(ifMatches: completedState.id)
}
updateOperation(id: completedState.id, title: "Download Complete", message: completedState.remotePath, progress: 1.0)
print("Download progress \(completedState.remotePath): 100%")
let filename = NSString(string: completedState.remotePath).lastPathComponent
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
do {
try completedState.data.write(to: tempURL, options: .atomic)
completedState.completion(.success(tempURL))
downloadedFileURL = tempURL
} catch {
completedState.completion(.failure(error))
}
}
private func requestDirectory(path: String) {
let normalizedPath = normalizedPath(path)
if let commandCharacteristic = fileCommandCharacteristic {
let opID: UUID
if let pending = pendingDirectoryRequest, pending.path == normalizedPath {
opID = pending.operationID
pendingDirectoryRequest = nil
} else {
opID = UUID()
}
startDirectoryRequest(path: normalizedPath, operationID: opID, characteristic: commandCharacteristic)
} else {
let opID = pendingDirectoryRequest?.operationID ?? UUID()
pendingDirectoryRequest = (path: normalizedPath, operationID: opID)
if connectionState == .ready {
pendingListPath = normalizedPath
pendingListData.removeAll()
isFileBusy = true
pendingListOperationID = opID
updateOperation(id: opID, title: "Loading", message: normalizedPath, progress: nil, indeterminate: true)
}
}
}
@discardableResult
private func flushPendingDirectoryRequest() -> Bool {
guard let pending = pendingDirectoryRequest,
let commandCharacteristic = fileCommandCharacteristic else { return false }
pendingDirectoryRequest = nil
startDirectoryRequest(path: pending.path, operationID: pending.operationID, characteristic: commandCharacteristic)
return true
}
private func startDirectoryRequest(path: String, operationID: UUID, characteristic: CBCharacteristic) {
pendingListPath = path
pendingListData.removeAll()
isFileBusy = true
pendingListOperationID = operationID
updateOperation(id: operationID, title: "Loading", message: path, progress: nil, indeterminate: true)
let payload = payloadFor(path: path)
enqueueFileCommand(.listDirectory, payload: payload, characteristic: characteristic)
}
}
// MARK: - CBCentralManagerDelegate
extension TimeSyncManager: CBCentralManagerDelegate {
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
shouldKeepScanning = true
if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral],
let restored = peripherals.first {
statusMessage = "Restoring connection…"
connectionState = .connecting
targetPeripheral = restored
restored.delegate = self
if central.state == .poweredOn {
central.connect(restored, options: nil)
}
} else {
startScanning()
}
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
switch central.state {
case .unknown, .resetting:
connectionState = .idle
statusMessage = "Bluetooth is resetting…"
case .unsupported:
connectionState = .failed
statusMessage = "Bluetooth Low Energy is not supported on this device."
case .unauthorized:
connectionState = .failed
statusMessage = "Bluetooth permissions are missing."
case .poweredOff:
connectionState = .idle
statusMessage = "Turn on Bluetooth to continue."
stopScanning()
case .poweredOn:
startScanning()
@unknown default:
connectionState = .failed
statusMessage = "Unknown Bluetooth state."
}
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
statusMessage = "Found \(peripheral.name ?? "device"), connecting…"
connectionState = .connecting
shouldKeepScanning = false
stopScanning()
targetPeripheral = peripheral
peripheral.delegate = self
central.connect(peripheral, options: nil)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
statusMessage = "Connected. Discovering services…"
connectionState = .discovering
peripheral.discoverServices([timeServiceUUID, fileServiceUUID])
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
statusMessage = "Failed to connect. \(error?.localizedDescription ?? "")"
connectionState = .failed
targetPeripheral = nil
resetFileStateOnDisconnect()
scheduleRetry()
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
statusMessage = "Disconnected. \(error?.localizedDescription ?? "")"
connectionState = .idle
targetPeripheral = nil
timeCharacteristic = nil
fileCommandCharacteristic = nil
fileResponseCharacteristic = nil
resetFileStateOnDisconnect()
scheduleRetry()
}
}
// MARK: - CBPeripheralDelegate
extension TimeSyncManager: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error {
statusMessage = "Service discovery failed: \(error.localizedDescription)"
connectionState = .failed
scheduleRetry()
return
}
guard let services = peripheral.services else {
statusMessage = "No services found on device."
connectionState = .failed
scheduleRetry()
return
}
for service in services {
if service.uuid == timeServiceUUID {
peripheral.discoverCharacteristics([timeCharacteristicUUID], for: service)
} else if service.uuid == fileServiceUUID {
peripheral.discoverCharacteristics([fileCommandUUID, fileResponseUUID], for: service)
}
}
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
if let error {
statusMessage = "Characteristic discovery failed: \(error.localizedDescription)"
connectionState = .failed
scheduleRetry()
return
}
guard let characteristics = service.characteristics else { return }
for characteristic in characteristics {
if characteristic.uuid == timeCharacteristicUUID {
timeCharacteristic = characteristic
sendCurrentTime()
} else if characteristic.uuid == fileCommandUUID {
fileCommandCharacteristic = characteristic
processFileCommandQueue(characteristic: characteristic)
} else if characteristic.uuid == fileResponseUUID {
fileResponseCharacteristic = characteristic
peripheral.setNotifyValue(true, for: characteristic)
}
}
if timeCharacteristic != nil && fileResponseCharacteristic != nil && connectionState == .discovering {
connectionState = .ready
statusMessage = "Connected to Cardboy."
}
if fileResponseCharacteristic?.isNotifying == true {
_ = flushPendingDirectoryRequest()
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
if let error {
fileErrorMessage = error.localizedDescription
return
}
if characteristic.uuid == fileResponseUUID, characteristic.isNotifying {
if !flushPendingDirectoryRequest() {
refreshDirectory()
}
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
if let error {
fileErrorMessage = error.localizedDescription
isFileBusy = false
return
}
guard let value = characteristic.value else { return }
if characteristic.uuid == fileResponseUUID {
handleFileResponse(value)
}
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
if characteristic.uuid == fileCommandUUID {
if let error {
fileErrorMessage = error.localizedDescription
}
isWritingFileCommand = false
processFileCommandQueue(characteristic: characteristic)
}
}
}
// MARK: - Data helpers
private extension Data {
mutating func appendPath(_ path: String) {
let normalized: String
if path.isEmpty {
normalized = "/"
} else if path.hasPrefix("/") {
normalized = path
} else {
normalized = "/" + path
}
let pathData = normalized.data(using: .utf8) ?? Data()
append(contentsOf: UInt16(pathData.count).littleEndianBytes)
append(pathData)
}
}
private extension UInt16 {
var littleEndianBytes: [UInt8] {
let value = self.littleEndian
return [
UInt8(value & 0xFF),
UInt8((value >> 8) & 0xFF),
]
}
}
private extension UInt32 {
var littleEndianBytes: [UInt8] {
let value = self.littleEndian
return [
UInt8(value & 0xFF),
UInt8((value >> 8) & 0xFF),
UInt8((value >> 16) & 0xFF),
UInt8((value >> 24) & 0xFF),
]
}
}

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

@@ -0,0 +1,38 @@
idf_component_register(
SRCS
"src/bat_mon.cpp"
"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
driver
esp_timer
esp_driver_i2c
esp_driver_spi
littlefs
nvs_flash
bt
)
add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../sdk/utils" cardboy_utils_esp)
add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../sdk/backend_interface" backend_interface_from_backend_esp)
add_library(cardboy_backend_esp INTERFACE)
target_link_libraries(cardboy_backend_esp
INTERFACE
${COMPONENT_LIB}
)
target_link_libraries(${COMPONENT_LIB}
PUBLIC
cardboy_backend_interface
)

View File

@@ -0,0 +1,8 @@
#pragma once
#include "cardboy/backend/esp_backend.hpp"
namespace cardboy::backend {
using ActiveBackend = EspBackend;
}

View File

@@ -5,10 +5,12 @@
#ifndef CB_BAT_MON_HPP
#define CB_BAT_MON_HPP
#include "config.hpp"
#include "cardboy/backend/esp/config.hpp"
#include "driver/i2c_master.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <cstdint>
class BatMon {
public:
@@ -20,9 +22,10 @@ public:
void pooler(); // FIXME:
private:
static inline i2c_device_config_t _dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = 0x70,
.scl_speed_hz = 100000,
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = 0x36,
.scl_speed_hz = 100000,
.flags = 0,
};
BatMon();

View File

@@ -0,0 +1,45 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#ifndef BUTTONS_HPP
#define BUTTONS_HPP
#include "cardboy/sdk/event_bus.hpp"
#include <cstdint>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
typedef enum {
BTN_START = 1 << 1,
BTN_DOWN = 1 << 6,
BTN_SELECT = 1 << 0,
BTN_LEFT = 1 << 7,
BTN_UP = 1 << 5,
BTN_B = 1 << 2,
BTN_RIGHT = 1 << 4,
BTN_A = 1 << 3,
} btn_num;
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);
TaskHandle_t _pooler_task;
private:
Buttons();
volatile uint8_t _current;
volatile TaskHandle_t _listener = nullptr;
cardboy::sdk::IEventBus* _eventBus = nullptr;
};
#endif // BUTTONS_HPP

View File

@@ -0,0 +1,54 @@
// Simple piezo buzzer helper using LEDC (PWM) for square wave tones.
// Provides a tiny queued pattern player for short game SFX without blocking.
#pragma once
#include <cstdint>
class Buzzer {
public:
static Buzzer& get();
void init(); // call once from app_main
// Queue a tone. freq=0 => silence. gap_ms is silence after tone before next.
void tone(uint32_t freq, uint32_t duration_ms, uint32_t gap_ms = 0);
// Convenience SFX
void beepRotate();
void beepMove();
void beepLock();
void beepLines(int lines); // 1..4 lines
void beepLevelUp(int level); // after increment
void beepGameOver();
// Mute controls
void setMuted(bool m);
void toggleMuted();
bool isMuted() const { return _muted; }
private:
struct Step {
uint32_t freq;
uint32_t dur_ms;
uint32_t gap_ms;
};
static constexpr int MAX_QUEUE = 16;
Step _queue[MAX_QUEUE]{};
int _q_head = 0; // inclusive
int _q_tail = 0; // exclusive
bool _running = false;
bool _in_gap = false;
void* _timer = nullptr; // esp_timer_handle_t (opaque here)
bool _muted = false;
Buzzer() = default;
void enqueue(const Step& s);
bool empty() const { return _q_head == _q_tail; }
Step& front() { return _queue[_q_head]; }
void popFront();
void startNext();
void schedule(uint32_t ms, bool gapPhase);
void applyFreq(uint32_t freq);
static void timerCb(void* arg);
void clearQueue() { _q_head = _q_tail = 0; }
};

View File

@@ -0,0 +1,30 @@
#ifndef CB_CONFIG_HPP
#define CB_CONFIG_HPP
#include "hal/spi_types.h"
#include "soc/gpio_num.h"
#define I2C_SCL GPIO_NUM_8
#define I2C_SDA GPIO_NUM_9
#define SPI_MOSI GPIO_NUM_5
#define SPI_MISO GPIO_NUM_0
#define SPI_SCK GPIO_NUM_4
#define SPI_DISP_CS GPIO_NUM_24
#define SPI_DISP_DISP GPIO_NUM_11
#define SPI_BUS SPI2_HOST
#include "cardboy/sdk/display_spec.hpp"
#define DISP_WIDTH cardboy::sdk::kDisplayWidth
#define DISP_HEIGHT cardboy::sdk::kDisplayHeight
#define BUZZER_PIN GPIO_NUM_25
#define PWR_INT GPIO_NUM_10
#define PWR_KILL GPIO_NUM_12
#define EXP_INT GPIO_NUM_1
#endif

View File

@@ -0,0 +1,72 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#ifndef CB_DISPLAY_HPP
#define CB_DISPLAY_HPP
#include "cardboy/backend/esp/config.hpp"
#include "cardboy/utils/utils.hpp"
#include "driver/spi_master.h"
// (Async memcpy removed for debugging simplification)
#include <array>
#include <bitset>
#include <cassert>
#include <cstdint>
namespace SMD {
static constexpr size_t kLineBytes = DISP_WIDTH / 8;
static constexpr size_t kLineMultiSingle = (kLineBytes + 2);
static constexpr size_t kLineDataBytes = kLineMultiSingle * DISP_HEIGHT + 2;
extern uint8_t* dma_buf;
void init();
// Double-buffered asynchronous frame pipeline:
// Usage pattern each frame:
// SMD::frame_ready(); // (start of frame) waits for previous transfer & ensures draw buffer is ready/synced
// ... write pixels into dma_buf via set_pixel / surface ...
// SMD::send_frame(); // (end of frame) queues SPI DMA of current framebuffer; once SPI finishes the sent buffer
// // is optionally cleared so the alternate buffer is ready for the next frame
void send_frame(bool clear_after_send = true);
void frame_ready();
bool frame_transfer_in_flight(); // optional diagnostic: is a frame transfer still in flight?
__attribute__((always_inline)) static void set_pixel(int x, int y, bool value) {
CARDBOY_CHECK(x >= 0 && x < DISP_WIDTH && y >= 0 && y < DISP_HEIGHT);
unsigned lineIdx = 2 + kLineMultiSingle * y + (x / 8);
unsigned bitIdx = 1 << (7 - (x % 8)) % 8;
if (value) {
dma_buf[lineIdx] &= ~bitIdx;
} else {
dma_buf[lineIdx] |= bitIdx;
}
}
__attribute__((always_inline)) static void set_pixel_8(int x, int y, std::uint8_t value) {
CARDBOY_CHECK(x >= 0 && x < DISP_WIDTH && y >= 0 && y < DISP_HEIGHT);
CARDBOY_CHECK((x % 8) == 0);
unsigned lineIdx = 2 + kLineMultiSingle * y + (x / 8);
dma_buf[lineIdx] = ~value;
}
extern "C" void s_spi_post_cb(spi_transaction_t* trans);
static inline spi_device_interface_config_t _devcfg = {
.mode = 0, // SPI mode 0
.clock_speed_hz = 10 * 1000 * 1000, // Clock out at 10 MHz
.spics_io_num = SPI_DISP_CS, // CS pin
.flags = SPI_DEVICE_POSITIVE_CS,
.queue_size = 1,
.pre_cb = nullptr,
.post_cb = s_spi_post_cb,
};
extern spi_device_handle_t _spi;
}; // namespace SMD
#endif // DISPLAY_HPP

View File

@@ -0,0 +1,27 @@
#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,26 @@
#pragma once
#include <esp_err.h>
#include <string_view>
class FsHelper {
public:
static FsHelper& get();
esp_err_t mount();
void unmount();
bool isMounted() const { return mounted; }
const char* basePath() const { return kBasePath; }
const char* partitionLabel() const { return kPartitionLabel; }
private:
FsHelper() = default;
static constexpr const char* kBasePath = "/lfs";
static constexpr const char* kPartitionLabel = "littlefs";
static constexpr const bool kFormatOnFailure = true;
bool mounted = false;
};

View File

@@ -5,7 +5,7 @@
#ifndef CB_I2C_GLOBAL_HPP
#define CB_I2C_GLOBAL_HPP
#include "config.hpp"
#include "cardboy/backend/esp/config.hpp"
#include "driver/i2c_master.h"
@@ -22,7 +22,8 @@ private:
.scl_io_num = I2C_SCL,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.flags = {.enable_internal_pullup = false, .allow_pd = true},
.intr_priority = 2,
.flags = {.enable_internal_pullup = true, .allow_pd = true},
};
i2c_master_bus_handle_t _bus_handle;
}; // namespace i2c_global

View File

@@ -10,6 +10,7 @@ class Shutdowner {
public:
static Shutdowner& get();
void install_isr();
void shutdown();
private:
Shutdowner();
};

View File

@@ -5,7 +5,7 @@
#ifndef CB_SPI_GLOBAL_HPP
#define CB_SPI_GLOBAL_HPP
#include "config.hpp"
#include "cardboy/backend/esp/config.hpp"
#include "driver/spi_master.h"
@@ -20,7 +20,7 @@ private:
.sclk_io_num = SPI_SCK,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = 400 * 240 * 2};
.max_transfer_sz = 12482U};
}; // namespace spi_global

View File

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

View File

@@ -0,0 +1,87 @@
#pragma once
#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"
#include <cstdint>
#include <memory>
namespace cardboy::backend::esp {
class EspRuntime;
class EspFramebuffer final : public cardboy::sdk::FramebufferFacade<EspFramebuffer> {
public:
EspFramebuffer() = default;
[[nodiscard]] int width_impl() const { return cardboy::sdk::kDisplayWidth; }
[[nodiscard]] int height_impl() const { return cardboy::sdk::kDisplayHeight; }
void __attribute__((always_inline)) drawPixel_impl(int x, int y, bool on) { SMD::set_pixel(x, y, on); }
void __attribute__((always_inline)) drawBits8_impl(int x, int y, std::uint8_t bits) {
SMD::set_pixel_8(x, y, bits);
}
void clear_impl(bool on);
void frameReady_impl();
void sendFrame_impl(bool clearAfterSend);
[[nodiscard]] bool frameInFlight_impl() const;
};
class EspInput final : public cardboy::sdk::InputFacade<EspInput> {
public:
cardboy::sdk::InputState readState_impl();
};
class EspClock final : public cardboy::sdk::ClockFacade<EspClock> {
public:
std::uint32_t millis_impl();
void sleep_ms_impl(std::uint32_t ms);
};
class EspRuntime {
public:
EspRuntime();
~EspRuntime();
cardboy::sdk::Services& serviceRegistry();
EspFramebuffer framebuffer;
EspInput input;
EspClock clock;
private:
void initializeHardware();
class BuzzerService;
class BatteryService;
class StorageService;
class RandomService;
class HighResClockService;
class FilesystemService;
class 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;
cardboy::sdk::Services services{};
};
struct Backend {
using Framebuffer = EspFramebuffer;
using Input = EspInput;
using Clock = EspClock;
};
} // namespace cardboy::backend::esp
namespace cardboy::backend {
using EspBackend = esp::Backend;
} // namespace cardboy::backend

View File

@@ -0,0 +1,115 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "cardboy/backend/esp/bat_mon.hpp"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "cardboy/backend/esp/i2c_global.hpp"
#include "cardboy/backend/esp/shutdowner.hpp"
static i2c_master_dev_handle_t dev_handle;
BatMon& BatMon::get() {
static BatMon bat_mon;
return bat_mon;
}
static void start_pooler(void* arg) { static_cast<BatMon*>(arg)->pooler(); }
void WriteRegister(uint8_t reg, uint16_t value) {
uint8_t buf2[3];
buf2[0] = reg;
buf2[1] = value & 0xFF;
buf2[2] = value >> 8;
ESP_ERROR_CHECK(i2c_master_transmit(dev_handle, buf2, sizeof(buf2), -1));
}
uint16_t ReadRegister(uint8_t reg) {
uint16_t buffer;
ESP_ERROR_CHECK(
i2c_master_transmit_receive(dev_handle, &reg, sizeof(reg), reinterpret_cast<uint8_t*>(&buffer), 2, -1));
return buffer;
}
void WriteAndVerifyRegister(char RegisterAddress, int RegisterValueToWrite) {
int attempt = 0;
uint16_t RegisterValueRead;
do {
WriteRegister(RegisterAddress, RegisterValueToWrite);
vTaskDelay(1 / portTICK_PERIOD_MS);
RegisterValueRead = ReadRegister(RegisterAddress);
} while (RegisterValueToWrite != RegisterValueRead && attempt++ < 3);
}
static constexpr float RSense = 0.1; // 100mOhm
static constexpr uint16_t DesignCapMah = 180; // 100mOhm
constexpr float mahToCap(float mah) { return mah * (1000.0 / 5.0) * RSense; }
constexpr float capToMah(uint16_t cap) { return cap * (5.0 / 1000.0) / RSense; }
constexpr float regToCurrent(uint16_t reg) {
return static_cast<float>(static_cast<int16_t>(reg)) * 0.0015625f / RSense; // Convert to mA
}
constexpr uint16_t currentToReg(float current) { return static_cast<uint16_t>(current * RSense / 0.0015625f); }
constexpr float regToVoltage(uint16_t reg) {
return reg * 0.078125f * 0.001f; // Convert to volts
}
constexpr uint16_t voltageToReg(float voltage) {
return static_cast<uint16_t>(voltage / (0.078125f * 0.001f)); // Convert to register value
}
static constexpr uint16_t DesignCap = mahToCap(DesignCapMah);
static constexpr uint16_t IchgTerm = currentToReg(10);
static constexpr uint16_t VEmpty = 0b1001011001100001; // (3V/3.88V)
static constexpr uint16_t dQAcc = (DesignCap / 32);
BatMon::BatMon() {
ESP_ERROR_CHECK(i2c_master_bus_add_device(I2cGlobal::get().get_bus_handle(), &_dev_cfg, &dev_handle));
bool StatusPOR = ReadRegister(0x00) & 0x0002;
if (StatusPOR) // POR reset
{
printf("Gas gauge reset!\n");
while (ReadRegister(0x3D) & 1)
vTaskDelay(10 / portTICK_PERIOD_MS);
uint16_t HibCFG = ReadRegister(0xBA); // Store original HibCFG value
WriteRegister(0x60, 0x90); // Exit Hibernate Mode step 1
WriteRegister(0xBA, 0x0); // Exit Hibernate Mode step 2
WriteRegister(0x60, 0x0); // Exit Hibernate Mode step 3
WriteRegister(0x18, DesignCap); // Write DesignCap
WriteRegister(0x45, DesignCap / 32); // Write dQAcc
WriteRegister(0x1E, IchgTerm); // Write IchgTerm
WriteRegister(0x3A, VEmpty); // Write VEmpty
WriteRegister(0x46, dQAcc * 44138 / DesignCap); // Write dPAcc
WriteRegister(0xDB, 0x8000); // Write ModelCFG
// Poll ModelCFG.Refresh(highest bit), proceed to Step 4 when ModelCFG.Refresh = 0.
while (ReadRegister(0xDB) & 0x8000)
vTaskDelay(10 / portTICK_PERIOD_MS); // 10ms wait loop. Do not continue until ModelCFG.Refresh == 0.
WriteRegister(0xBA, HibCFG); // Restore Original HibCFG value
uint16_t Status = ReadRegister(0x00); // Read Status
WriteAndVerifyRegister(0x00, Status & 0xFFFD); // Write and Verify Status with POR bit cleared
}
xTaskCreate(&start_pooler, "BatMon", 2048, this, tskIDLE_PRIORITY, &_pooler_task);
}
void BatMon::pooler() {
while (true) {
uint8_t reg = 8;
uint16_t buffer;
_charge = capToMah(ReadRegister(0x05));
_current = regToCurrent(ReadRegister(0x0B));
_voltage = regToVoltage(ReadRegister(0x09));
vTaskDelay(pdMS_TO_TICKS(10000));
if (_voltage < 3.0f) {
Shutdowner::get().shutdown();
}
}
}
float BatMon::get_voltage() const { return _voltage; }
float BatMon::get_charge() const { return _charge; }
float BatMon::get_current() const { return _current; }

View File

@@ -0,0 +1,102 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "cardboy/backend/esp/buttons.hpp"
#include <driver/gpio.h>
#include <esp_err.h>
#include <rom/ets_sys.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#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 = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = 0x20,
.scl_speed_hz = 50000,
};
Buttons& Buttons::get() {
static Buttons buttons;
return buttons;
}
static void start_pooler(void* arg) { static_cast<Buttons*>(arg)->pooler(); }
static bool is_on_low;
static void wakeup(void* arg) {
if (is_on_low) {
ESP_ERROR_CHECK(gpio_set_intr_type(EXP_INT, GPIO_INTR_HIGH_LEVEL));
ESP_ERROR_CHECK(gpio_wakeup_enable(EXP_INT, GPIO_INTR_HIGH_LEVEL));
is_on_low = false;
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xTaskNotifyFromISR(Buttons::get()._pooler_task, 0, eNoAction, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
} else {
ESP_ERROR_CHECK(gpio_set_intr_type(EXP_INT, GPIO_INTR_LOW_LEVEL));
ESP_ERROR_CHECK(gpio_wakeup_enable(EXP_INT, GPIO_INTR_LOW_LEVEL));
is_on_low = true;
}
}
Buttons::Buttons() {
ESP_ERROR_CHECK(i2c_master_bus_add_device(I2cGlobal::get().get_bus_handle(), &dev_cfg, &dev_handle));
uint8_t buf2[2];
// Config
buf2[0] = 6;
buf2[1] = 0xFF;
ESP_ERROR_CHECK(i2c_master_transmit(dev_handle, buf2, sizeof(buf2), -1));
buf2[0] = 7;
buf2[1] = 0x80;
ESP_ERROR_CHECK(i2c_master_transmit(dev_handle, buf2, sizeof(buf2), -1));
xTaskCreate(&start_pooler, "ButtonsPooler", 2048, this, 2, &_pooler_task);
ESP_ERROR_CHECK(gpio_reset_pin(EXP_INT));
ESP_ERROR_CHECK(gpio_set_direction(EXP_INT, GPIO_MODE_INPUT));
ESP_ERROR_CHECK(gpio_set_pull_mode(EXP_INT, GPIO_FLOATING));
ESP_ERROR_CHECK(gpio_set_intr_type(EXP_INT, GPIO_INTR_LOW_LEVEL));
ESP_ERROR_CHECK(gpio_wakeup_enable(EXP_INT, GPIO_INTR_LOW_LEVEL));
is_on_low = true;
}
static void delay(unsigned long long loop) {
for (unsigned long long i = 0; i < loop; i++) {
asm volatile("nop");
}
}
void Buttons::pooler() {
while (true) {
BaseType_t xResult = xTaskNotifyWait(pdFALSE, ULONG_MAX, nullptr, portMAX_DELAY);
uint8_t reg = 0;
uint8_t buffer;
ESP_ERROR_CHECK(
i2c_master_transmit_receive(dev_handle, &reg, sizeof(reg), reinterpret_cast<uint8_t*>(&buffer), 1, -1));
_current = buffer;
// read second port too to clear the interrupt
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));
}
}
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; }

View File

@@ -0,0 +1,160 @@
// Buzzer implementation
#include "cardboy/backend/esp/buzzer.hpp"
#include "cardboy/backend/esp/config.hpp"
#include <driver/ledc.h>
#include <esp_err.h>
#include <esp_timer.h>
static constexpr ledc_mode_t LEDC_MODE = LEDC_LOW_SPEED_MODE; // low speed is fine
static constexpr ledc_timer_t LEDC_TIMER = LEDC_TIMER_0;
static constexpr ledc_channel_t LEDC_CH = LEDC_CHANNEL_0;
static constexpr ledc_timer_bit_t LEDC_BITS = LEDC_TIMER_10_BIT;
Buzzer& Buzzer::get() {
static Buzzer b;
return b;
}
void Buzzer::init() {
ledc_timer_config_t tcfg{};
tcfg.speed_mode = LEDC_MODE;
tcfg.timer_num = LEDC_TIMER;
tcfg.duty_resolution = LEDC_BITS;
tcfg.freq_hz = 1000; // placeholder, changed per tone
tcfg.clk_cfg = LEDC_AUTO_CLK;
ESP_ERROR_CHECK(ledc_timer_config(&tcfg));
ledc_channel_config_t ccfg{};
ccfg.speed_mode = LEDC_MODE;
ccfg.channel = LEDC_CH;
ccfg.timer_sel = LEDC_TIMER;
ccfg.gpio_num = static_cast<int>(BUZZER_PIN);
ccfg.duty = 0; // start silent
ccfg.hpoint = 0;
ccfg.intr_type = LEDC_INTR_DISABLE;
ESP_ERROR_CHECK(ledc_channel_config(&ccfg));
esp_timer_create_args_t args{};
args.callback = &Buzzer::timerCb;
args.arg = this;
args.name = "buzz";
ESP_ERROR_CHECK(esp_timer_create(&args, reinterpret_cast<esp_timer_handle_t*>(&_timer)));
}
void Buzzer::applyFreq(uint32_t freq) {
if (freq == 0) {
ledc_stop(LEDC_MODE, LEDC_CH, 0);
return;
}
ledc_set_freq(LEDC_MODE, LEDC_TIMER, freq);
ledc_set_duty(LEDC_MODE, LEDC_CH, (1 << LEDC_BITS) / 2);
ledc_update_duty(LEDC_MODE, LEDC_CH);
}
void Buzzer::enqueue(const Step& s) {
int nextTail = (_q_tail + 1) % MAX_QUEUE;
if (nextTail == _q_head) { // full, drop oldest
_q_head = (_q_head + 1) % MAX_QUEUE;
}
_queue[_q_tail] = s;
_q_tail = nextTail;
}
void Buzzer::popFront() {
if (!empty())
_q_head = (_q_head + 1) % MAX_QUEUE;
}
void Buzzer::startNext() {
if (empty()) {
_running = false;
applyFreq(0);
return;
}
_running = true;
_in_gap = false;
Step& s = front();
applyFreq(s.freq);
schedule(s.dur_ms, false);
}
void Buzzer::schedule(uint32_t ms, bool gapPhase) {
if (!_timer)
return;
_in_gap = gapPhase;
esp_timer_stop(reinterpret_cast<esp_timer_handle_t>(_timer));
esp_timer_start_once(reinterpret_cast<esp_timer_handle_t>(_timer), (uint64_t) ms * 1000ULL);
}
void Buzzer::timerCb(void* arg) {
auto* self = static_cast<Buzzer*>(arg);
if (!self)
return;
if (self->_in_gap) {
self->popFront();
self->startNext();
return;
}
// Tone finished
if (!self->empty()) {
auto& s = self->front();
if (s.gap_ms) {
self->applyFreq(0);
self->schedule(s.gap_ms, true);
return;
}
self->popFront();
self->startNext();
}
}
void Buzzer::tone(uint32_t freq, uint32_t duration_ms, uint32_t gap_ms) {
if (_muted)
return; // ignore while muted
Step s{freq, duration_ms, gap_ms};
enqueue(s);
if (!_running)
startNext();
}
// ---- Game SFX ----
void Buzzer::beepRotate() { tone(1800, 25); }
void Buzzer::beepMove() { tone(1200, 12); }
void Buzzer::beepLock() { tone(900, 25); }
void Buzzer::beepLines(int lines) {
static const uint32_t base = 1100;
for (int i = 0; i < lines; ++i) {
tone(base + i * 190, 40, 12);
}
}
void Buzzer::beepLevelUp(int) {
tone(1600, 70, 25);
tone(2000, 90, 0);
}
void Buzzer::beepGameOver() {
tone(1000, 140, 40);
tone(700, 140, 40);
tone(400, 260, 0);
}
void Buzzer::setMuted(bool m) {
if (m == _muted)
return;
_muted = m;
if (_muted) {
clearQueue();
applyFreq(0);
if (_timer) {
esp_timer_stop(reinterpret_cast<esp_timer_handle_t>(_timer));
}
_running = false;
_in_gap = false;
} else {
// confirmation chirp
tone(1500, 40, 10);
tone(1900, 60, 0);
}
}
void Buzzer::toggleMuted() { setMuted(!_muted); }

View File

@@ -0,0 +1,155 @@
// Double-buffered display implementation with async memcpy ---------------------------------
#include "cardboy/backend/esp/display.hpp"
#include <cassert>
#include <cstring>
#include <driver/gpio.h>
#include "driver/spi_master.h"
#include "esp_async_memcpy.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
DMA_ATTR static uint8_t s_dma_buffer0[SMD::kLineDataBytes]{};
DMA_ATTR static uint8_t s_dma_buffer1[SMD::kLineDataBytes]{};
static uint8_t* s_dma_buffers[2] = {s_dma_buffer0, s_dma_buffer1};
DMA_ATTR static uint8_t dma_buf_template[SMD::kLineDataBytes]{};
uint8_t* SMD::dma_buf = s_dma_buffers[0];
spi_device_handle_t SMD::_spi;
static spi_transaction_t _tx{};
static SemaphoreHandle_t _txSem = nullptr;
static bool _vcom = false;
static TaskHandle_t s_clearTaskHandle = nullptr;
static SemaphoreHandle_t s_clearReqSem = nullptr;
static SemaphoreHandle_t s_bufferSem[2] = {nullptr, nullptr};
static bool s_clearPending[2] = {true, true};
static async_memcpy_config_t config = ASYNC_MEMCPY_DEFAULT_CONFIG();
// update the maximum data stream supported by underlying DMA engine
static async_memcpy_handle_t driver = nullptr;
static volatile int s_drawBufIdx = 0;
static unsigned char reverse_bits3(unsigned char b) { return (b * 0x0202020202ULL & 0x010884422010ULL) % 0x3ff; }
static bool IRAM_ATTR my_async_memcpy_cb(async_memcpy_handle_t /*mcp_hdl*/, async_memcpy_event_t* /*event*/,
void* cb_args) {
BaseType_t high_task_wakeup = pdFALSE;
auto sem = static_cast<SemaphoreHandle_t>(cb_args);
xSemaphoreGiveFromISR(sem, &high_task_wakeup);
return high_task_wakeup == pdTRUE;
}
extern "C" void IRAM_ATTR s_spi_post_cb(spi_transaction_t* /*t*/) {
BaseType_t hpw = pdFALSE;
xSemaphoreGiveFromISR(s_clearReqSem, &hpw);
if (hpw)
portYIELD_FROM_ISR();
}
static void clear_task(void*) {
for (;;) {
if (xSemaphoreTake(s_clearReqSem, portMAX_DELAY) == pdTRUE) {
spi_transaction_t* r = nullptr;
ESP_ERROR_CHECK(spi_device_get_trans_result(SMD::_spi, &r, 0));
int bufIdx = (int) r->user;
xSemaphoreGive(_txSem);
const bool shouldClear = s_clearPending[bufIdx];
s_clearPending[bufIdx] = true;
if (shouldClear) {
constexpr unsigned alignedSize = SMD::kLineDataBytes - (SMD::kLineDataBytes % 4);
static_assert(SMD::kLineDataBytes - alignedSize < 8); // Last byte is zero anyway
ESP_ERROR_CHECK(esp_async_memcpy(driver, s_dma_buffers[bufIdx], dma_buf_template, alignedSize,
my_async_memcpy_cb, static_cast<void*>(s_bufferSem[bufIdx])));
} else {
if (!xSemaphoreGive(s_bufferSem[bufIdx]))
assert(false);
}
}
}
}
void SMD::init() {
spi_bus_add_device(SPI_BUS, &_devcfg, &_spi);
ESP_ERROR_CHECK(gpio_reset_pin(SPI_DISP_DISP));
ESP_ERROR_CHECK(gpio_set_direction(SPI_DISP_DISP, GPIO_MODE_OUTPUT));
ESP_ERROR_CHECK(gpio_set_level(SPI_DISP_DISP, 1));
ESP_ERROR_CHECK(gpio_hold_en(SPI_DISP_DISP));
for (int buf = 0; buf < 2; ++buf) {
auto* fb = s_dma_buffers[buf];
for (uint8_t i = 0; i < DISP_HEIGHT; ++i) {
fb[kLineMultiSingle * i + 1] = reverse_bits3(i + 1);
fb[2 + kLineMultiSingle * i + kLineBytes] = 0;
}
fb[kLineDataBytes - 1] = 0;
}
s_drawBufIdx = 0;
dma_buf = s_dma_buffers[s_drawBufIdx];
for (int y = 0; y < DISP_HEIGHT; ++y)
for (int x = 0; x < DISP_WIDTH; ++x)
set_pixel(x, y, false);
std::memcpy(dma_buf_template, dma_buf, sizeof(dma_buf_template));
std::memcpy(s_dma_buffers[1], dma_buf_template, sizeof(dma_buf_template));
ESP_ERROR_CHECK(esp_async_memcpy_install(&config, &driver)); // install driver with default DMA engine
s_clearReqSem = xSemaphoreCreateBinary();
for (int i = 0; i < 2; ++i) {
s_bufferSem[i] = xSemaphoreCreateBinary();
xSemaphoreGive(s_bufferSem[i]);
}
_txSem = xSemaphoreCreateBinary();
xSemaphoreGive(_txSem);
xTaskCreate(clear_task, "fbclr", 1536, nullptr, tskIDLE_PRIORITY + 1, &s_clearTaskHandle);
}
bool SMD::frame_transfer_in_flight() { return uxSemaphoreGetCount(s_bufferSem[s_drawBufIdx]) == 0; }
void SMD::send_frame(bool clear_after_send) {
assert(driver != nullptr);
if (!xSemaphoreTake(_txSem, portMAX_DELAY))
assert(false);
const int sendIdx = s_drawBufIdx;
assert(sendIdx >= 0 && sendIdx < 2);
SemaphoreHandle_t sem = s_bufferSem[sendIdx];
if (!xSemaphoreTake(sem, 0))
assert(false);
const int nextDrawIdx = sendIdx ^ 1;
s_clearPending[sendIdx] = clear_after_send;
_vcom = !_vcom;
_tx = {};
_tx.tx_buffer = s_dma_buffers[sendIdx];
_tx.length = SMD::kLineDataBytes * 8;
_tx.user = (void*) (sendIdx);
s_dma_buffers[sendIdx][0] = 0b10000000 | (_vcom << 6);
ESP_ERROR_CHECK(spi_device_queue_trans(_spi, &_tx, 0));
s_drawBufIdx = nextDrawIdx;
dma_buf = s_dma_buffers[nextDrawIdx];
}
void SMD::frame_ready() {
SemaphoreHandle_t sem = s_bufferSem[s_drawBufIdx];
// uint64_t waitedUs = 0;
if (!uxSemaphoreGetCount(sem)) {
// uint64_t start = esp_timer_get_time();
if (!xSemaphoreTake(sem, portMAX_DELAY))
assert(false);
if (!xSemaphoreGive(sem))
assert(false);
// waitedUs = esp_timer_get_time() - start;
}
// if (waitedUs)
// printf("Waited %" PRIu64 " us\n", waitedUs);
}

View File

@@ -0,0 +1,233 @@
#include "cardboy/backend/esp_backend.hpp"
#include "cardboy/backend/esp/bat_mon.hpp"
#include "cardboy/backend/esp/buttons.hpp"
#include "cardboy/backend/esp/buzzer.hpp"
#include "cardboy/backend/esp/config.hpp"
#include "cardboy/backend/esp/display.hpp"
#include "cardboy/backend/esp/fs_helper.hpp"
#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"
#include "driver/gpio.h"
#include "esp_err.h"
#include "esp_random.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs.h"
#include "nvs_flash.h"
#include <algorithm>
#include <cstdint>
#include <string>
#include <string_view>
namespace cardboy::backend::esp {
namespace {
void ensureNvsInit() {
static bool nvsReady = false;
if (nvsReady)
return;
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
nvsReady = true;
}
} // namespace
class EspRuntime::BuzzerService final : public cardboy::sdk::IBuzzer {
public:
void tone(std::uint32_t freq, std::uint32_t duration_ms, std::uint32_t gap_ms = 0) override {
Buzzer::get().tone(freq, duration_ms, gap_ms);
}
void beepRotate() override { Buzzer::get().beepRotate(); }
void beepMove() override { Buzzer::get().beepMove(); }
void beepLock() override { Buzzer::get().beepLock(); }
void beepLines(int lines) override { Buzzer::get().beepLines(lines); }
void beepLevelUp(int level) override { Buzzer::get().beepLevelUp(level); }
void beepGameOver() override { Buzzer::get().beepGameOver(); }
void setMuted(bool muted) override { Buzzer::get().setMuted(muted); }
void toggleMuted() override { Buzzer::get().toggleMuted(); }
[[nodiscard]] bool isMuted() const override { return Buzzer::get().isMuted(); }
};
class EspRuntime::BatteryService final : public cardboy::sdk::IBatteryMonitor {
public:
[[nodiscard]] bool hasData() const override { return true; }
[[nodiscard]] float voltage() const override { return BatMon::get().get_voltage(); }
[[nodiscard]] float charge() const override { return BatMon::get().get_charge(); }
[[nodiscard]] float current() const override { return BatMon::get().get_current(); }
};
class EspRuntime::StorageService final : public cardboy::sdk::IStorage {
public:
[[nodiscard]] bool readUint32(std::string_view ns, std::string_view key, std::uint32_t& out) override {
ensureNvsInit();
nvs_handle_t handle;
std::string nsStr(ns);
std::string keyStr(key);
if (nvs_open(nsStr.c_str(), NVS_READONLY, &handle) != ESP_OK)
return false;
std::uint32_t value = 0;
esp_err_t err = nvs_get_u32(handle, keyStr.c_str(), &value);
nvs_close(handle);
if (err != ESP_OK)
return false;
out = value;
return true;
}
void writeUint32(std::string_view ns, std::string_view key, std::uint32_t value) override {
ensureNvsInit();
nvs_handle_t handle;
std::string nsStr(ns);
std::string keyStr(key);
if (nvs_open(nsStr.c_str(), NVS_READWRITE, &handle) != ESP_OK)
return;
nvs_set_u32(handle, keyStr.c_str(), value);
nvs_commit(handle);
nvs_close(handle);
}
};
class EspRuntime::RandomService final : public cardboy::sdk::IRandom {
public:
[[nodiscard]] std::uint32_t nextUint32() override { return esp_random(); }
};
class EspRuntime::HighResClockService final : public cardboy::sdk::IHighResClock {
public:
[[nodiscard]] std::uint64_t micros() override { return static_cast<std::uint64_t>(esp_timer_get_time()); }
};
class EspRuntime::FilesystemService final : public cardboy::sdk::IFilesystem {
public:
bool mount() override { return FsHelper::get().mount() == ESP_OK; }
[[nodiscard]] bool isMounted() const override { return FsHelper::get().isMounted(); }
[[nodiscard]] std::string basePath() const override {
const char* path = FsHelper::get().basePath();
return path ? std::string(path) : std::string{};
}
};
class EspRuntime::LoopHooksService final : public cardboy::sdk::ILoopHooks {
public:
void onLoopIteration() override { vTaskDelay(1); }
};
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>();
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();
Buttons::get().setEventBus(eventBus.get());
}
EspRuntime::~EspRuntime() { shutdown_time_sync_service(); }
cardboy::sdk::Services& EspRuntime::serviceRegistry() { return services; }
void EspRuntime::initializeHardware() {
static bool initialized = false;
if (initialized)
return;
initialized = true;
ensureNvsInit();
Shutdowner::get();
Buttons::get();
esp_err_t isrErr = gpio_install_isr_service(0);
if (isrErr != ESP_OK && isrErr != ESP_ERR_INVALID_STATE) {
ESP_ERROR_CHECK(isrErr);
}
Shutdowner::get().install_isr();
Buttons::get().install_isr();
I2cGlobal::get();
BatMon::get();
SpiGlobal::get();
SMD::init();
Buzzer::get().init();
FsHelper::get().mount();
ensure_time_sync_service_started();
}
void EspFramebuffer::clear_impl(bool on) {
for (int y = 0; y < height_impl(); ++y)
for (int x = 0; x < width_impl(); ++x)
SMD::set_pixel(x, y, on);
}
void EspFramebuffer::frameReady_impl() { SMD::frame_ready(); }
void EspFramebuffer::sendFrame_impl(bool clearAfterSend) { SMD::send_frame(clearAfterSend); }
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;
}
std::uint32_t EspClock::millis_impl() {
TickType_t ticks = xTaskGetTickCount();
return static_cast<std::uint32_t>((static_cast<std::uint64_t>(ticks) * 1000ULL) / configTICK_RATE_HZ);
}
void EspClock::sleep_ms_impl(std::uint32_t ms) {
if (ms == 0)
return;
vTaskDelay(pdMS_TO_TICKS(ms));
}
} // namespace cardboy::backend::esp

View File

@@ -0,0 +1,81 @@
#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

View File

@@ -0,0 +1,68 @@
#include "cardboy/backend/esp/fs_helper.hpp"
#include <esp_idf_version.h>
#include <esp_littlefs.h>
#include <esp_log.h>
#include <cstring>
namespace {
constexpr const char* kTag = "FsHelper";
} // namespace
FsHelper& FsHelper::get() {
static FsHelper instance;
return instance;
}
esp_err_t FsHelper::mount() {
if (mounted)
return ESP_OK;
esp_vfs_littlefs_conf_t conf{};
conf.base_path = kBasePath;
conf.partition_label = kPartitionLabel;
conf.format_if_mount_failed = kFormatOnFailure;
conf.dont_mount = false;
#if ESP_IDF_VERSION_MAJOR >= 5
conf.read_only = false;
#endif
const esp_err_t err = esp_vfs_littlefs_register(&conf);
if (err != ESP_OK) {
if (err == ESP_ERR_NOT_FOUND) {
ESP_LOGE(kTag, "Failed to find LittleFS partition '%s'", kPartitionLabel);
} else if (err == ESP_FAIL) {
ESP_LOGE(kTag, "Failed to mount LittleFS at %s (consider enabling format)",
kBasePath);
} else {
ESP_LOGE(kTag, "esp_vfs_littlefs_register failed: %s", esp_err_to_name(err));
}
return err;
}
mounted = true;
size_t total = 0;
size_t used = 0;
const esp_err_t infoErr = esp_littlefs_info(kPartitionLabel, &total, &used);
if (infoErr == ESP_OK) {
ESP_LOGI(kTag, "LittleFS mounted at %s (%zu / %zu bytes used)", kBasePath, used, total);
} else {
ESP_LOGW(kTag, "LittleFS mounted but failed to query usage: %s", esp_err_to_name(infoErr));
}
return ESP_OK;
}
void FsHelper::unmount() {
if (!mounted)
return;
const esp_err_t err = esp_vfs_littlefs_unregister(kPartitionLabel);
if (err != ESP_OK) {
ESP_LOGW(kTag, "Failed to unmount LittleFS (%s)", esp_err_to_name(err));
return;
}
mounted = false;
ESP_LOGI(kTag, "LittleFS unmounted from %s", kBasePath);
}

View File

@@ -2,7 +2,7 @@
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "i2c_global.hpp"
#include "cardboy/backend/esp/i2c_global.hpp"
I2cGlobal& I2cGlobal::get() {

View File

@@ -2,38 +2,43 @@
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "shutdowner.hpp"
#include "cardboy/backend/esp/shutdowner.hpp"
#include <driver/gpio.h>
#include <esp_sleep.h>
#include "config.hpp"
#include "cardboy/backend/esp/config.hpp"
Shutdowner& Shutdowner::get() {
static Shutdowner instance;
return instance;
}
static void IRAM_ATTR shutdown(void* arg) {
static void IRAM_ATTR int_shutdown(void* arg) {
// printf("Shutting down...\n");
ESP_ERROR_CHECK(gpio_hold_dis(PWR_KILL));
ESP_ERROR_CHECK(gpio_set_level(PWR_KILL, 0));
}
Shutdowner::Shutdowner() {
ESP_ERROR_CHECK(gpio_reset_pin(PWR_INT));
ESP_ERROR_CHECK(gpio_reset_pin(PWR_KILL));
void Shutdowner::shutdown() {
ESP_ERROR_CHECK(gpio_hold_dis(PWR_KILL));
ESP_ERROR_CHECK(gpio_set_level(PWR_KILL, 0));
}
ESP_ERROR_CHECK(gpio_set_direction(PWR_INT, GPIO_MODE_INPUT));
Shutdowner::Shutdowner() {
ESP_ERROR_CHECK(gpio_reset_pin(PWR_KILL));
ESP_ERROR_CHECK(gpio_set_direction(PWR_KILL, GPIO_MODE_OUTPUT));
ESP_ERROR_CHECK(gpio_set_level(PWR_KILL, 1));
ESP_ERROR_CHECK(gpio_hold_en(PWR_KILL));
ESP_ERROR_CHECK(gpio_reset_pin(PWR_INT));
ESP_ERROR_CHECK(gpio_set_direction(PWR_INT, GPIO_MODE_INPUT));
ESP_ERROR_CHECK(gpio_set_pull_mode(PWR_INT, GPIO_FLOATING));
ESP_ERROR_CHECK(gpio_set_intr_type(PWR_INT, GPIO_INTR_LOW_LEVEL));
// ESP_ERROR_CHECK(esp_sleep_enable_gpio_wakeup());
ESP_ERROR_CHECK(gpio_wakeup_enable(PWR_INT, GPIO_INTR_LOW_LEVEL));
// ESP_ERROR_CHECK(gpio_install_isr_service(0));
ESP_ERROR_CHECK(gpio_hold_en(PWR_KILL));
// gpio_isr_handler_add(PWR_INT, shutdown, nullptr);
}
void Shutdowner::install_isr() { gpio_isr_handler_add(PWR_INT, shutdown, nullptr); }
void Shutdowner::install_isr() { gpio_isr_handler_add(PWR_INT, int_shutdown, nullptr); }

View File

@@ -2,7 +2,7 @@
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "spi_global.hpp"
#include "cardboy/backend/esp/spi_global.hpp"
SpiGlobal& SpiGlobal::get() {
static SpiGlobal SpiGlobal;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
idf_component_register(
INCLUDE_DIRS ""
REQUIRES backend-esp
)
set(CARDBOY_BUILD_SFML OFF CACHE BOOL "Disable desktop backend build" FORCE)
set(CARDBOY_SDK_BACKEND_LIBRARY cardboy_backend_esp CACHE STRING "Cardboy backend implementation" FORCE)
add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../sdk/core" cb-sdk-build-core)
add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../sdk/apps" cb-sdk-build-apps)
target_compile_options(cardboy_backend_esp
INTERFACE
-fjump-tables
-ftree-switch-conversion
)
target_link_libraries(cardboy_backend_esp
INTERFACE
idf::driver
idf::esp_timer
idf::esp_driver_spi
idf::freertos
)
target_link_libraries(${COMPONENT_LIB}
INTERFACE
cardboy_backend_esp
cardboy_sdk
cardboy_apps
)

View File

@@ -0,0 +1,21 @@
dependencies:
idf:
source:
type: idf
version: 5.5.1
joltwallet/littlefs:
component_hash: 8e12955f47e27e6070b76715a96d6c75fc2b44f069e8c33679332d9bdd3120c4
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.20.1
direct_dependencies:
- idf
- joltwallet/littlefs
manifest_hash: 261ed140a57f28f061ce29bcf3ae4833c35c16fa5e15670490bf2aacedefa622
target: esp32h2
version: 2.0.0

248
Firmware/ghettoprof.sh Executable file
View File

@@ -0,0 +1,248 @@
#!/usr/bin/env bash
# parallel-pc-profile.sh — parallel symbol resolver + optional annotated disassembly
# Supports C++ demangling, LLVM disassembler, and optional no-inlines aggregation (symbol-table based).
#
# Usage:
# ./parallel-pc-profile.sh [-j jobs] [--annotate] [--no-inlines] firmware.elf pcs.txt
set -euo pipefail
usage() {
echo "Usage: $0 [-j jobs] [--annotate] [--no-inlines] firmware.elf pcs.txt"
exit 1
}
ANNOTATE=0
JOBS=""
NO_INLINES=0
# ---- args ----
while [[ $# -gt 0 ]]; do
case "$1" in
-j) JOBS="$2"; shift 2 ;;
--annotate) ANNOTATE=1; shift ;;
--no-inlines) NO_INLINES=1; shift ;;
-h|--help) usage ;;
*) break ;;
esac
done
[[ $# -lt 2 ]] && usage
ELF="$1"
PCS_IN="$2"
[[ ! -f "$ELF" ]] && { echo "ELF not found: $ELF" >&2; exit 2; }
[[ ! -f "$PCS_IN" ]] && { echo "PC log not found: $PCS_IN" >&2; exit 3; }
# ---- tools ----
ADDR2LINE=""
for t in llvm-addr2line eu-addr2line riscv32-esp-elf-addr2line xtensa-esp32-elf-addr2line addr2line; do
if command -v "$t" >/dev/null 2>&1; then ADDR2LINE="$t"; break; fi
done
[[ -z "$ADDR2LINE" ]] && { echo "No addr2line found"; exit 4; }
if command -v llvm-objdump >/dev/null 2>&1; then
OBJDUMP="llvm-objdump"
else
for t in riscv32-esp-elf-objdump xtensa-esp32-elf-objdump objdump; do
if command -v "$t" >/dev/null 2>&1; then OBJDUMP="$t"; break; fi
done
fi
[[ -z "${OBJDUMP:-}" ]] && { echo "No objdump found"; exit 5; }
if command -v llvm-nm >/dev/null 2>&1; then
NM="llvm-nm"
elif command -v nm >/dev/null 2>&1; then
NM="nm"
else
NM=""
fi
if command -v c++filt >/dev/null 2>&1; then
CPPFILT="c++filt"
elif command -v llvm-cxxfilt >/dev/null 2>&1; then
CPPFILT="llvm-cxxfilt"
else
CPPFILT=""
fi
# ---- cores ----
if [[ -z "$JOBS" ]]; then
if command -v nproc >/dev/null 2>&1; then JOBS=$(nproc)
elif [[ "$OSTYPE" == "darwin"* ]]; then JOBS=$(sysctl -n hw.ncpu 2>/dev/null || echo 4)
else JOBS=$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)
fi
fi
(( JOBS = JOBS > 1 ? JOBS - 1 : 1 ))
echo ">> Using $JOBS parallel jobs"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
# ---- extract PCs ----
grep -aoE '0x[0-9a-fA-F]+' "$PCS_IN" | tr 'A-F' 'a-f' | sort | uniq -c >"$TMP/pc_counts.txt" || true
awk '{print $2}' "$TMP/pc_counts.txt" >"$TMP/addrs.txt"
[[ ! -s "$TMP/addrs.txt" ]] && { echo "No addresses found"; exit 5; }
# ---- parallel addr2line (live PC -> function to stderr) ----
CHUNK=400
split -l "$CHUNK" "$TMP/addrs.txt" "$TMP/chunk."
find "$TMP" -name 'chunk.*' -type f -print0 \
| xargs -0 -I{} -n1 -P "$JOBS" bash -c '
set -euo pipefail
ADDR2LINE="$1"; ELF="$2"; CHUNK="$3"; CPP="$4"
OUT="${CHUNK}.sym"
"$ADDR2LINE" -a -f -e "$ELF" $(cat "$CHUNK") \
| tee "$OUT" \
| awk '"'"'NR%3==1{a=$0;next} NR%3==2{f=$0; printf "%s\t%s\n",a,f; next} NR%3==0{next}'"'"' \
| { if [[ -n "$CPP" ]]; then "$CPP"; else cat; fi; } 1>&2
' _ "$ADDR2LINE" "$ELF" {} "$CPPFILT"
# Collate triplets
cat "$TMP"/chunk.*.sym > "$TMP/symbols.raw"
# ---- parse 3-line addr/func/file:line ----
# Normalize leading zeros in addresses so joins match grep-extracted PCs
awk 'NR%3==1{a=$0; sub(/^0x0+/, "0x", a); next} NR%3==2{f=$0; next} NR%3==0{print a "\t" f "\t" $0}' \
"$TMP/symbols.raw" >"$TMP/map.tsv"
# ---- counts: addr -> samplecount ----
awk '{printf "%s\t%s\n",$2,$1}' "$TMP/pc_counts.txt" | sort -k1,1 >"$TMP/counts.tsv"
# ---- choose mapping: default (addr2line; may show inlined names) vs --no-inlines (symbol-table) ----
DEFAULT_ADDR_FUNC="$TMP/addr_func.tsv"
cut -f1,2 "$TMP/map.tsv" | sort -k1,1 >"$DEFAULT_ADDR_FUNC"
if [[ "$NO_INLINES" == "1" ]]; then
if [[ -z "$NM" ]]; then
echo "WARNING: nm/llvm-nm not found; falling back to inline-aware mapping." >&2
ADDR_FUNC_FILE="$DEFAULT_ADDR_FUNC"
else
echo ">> Building symbol table for no-inlines mapping..."
# Create sorted function symbols: hexaddr\tname (demangled if possible afterwards)
# Try llvm-nm format first; fall back to generic nm.
if [[ "$NM" == "llvm-nm" ]]; then
# llvm-nm -n --defined-only emits: ADDRESS TYPE NAME
"$NM" -n --defined-only "$ELF" \
| awk '/ [Tt] /{print $1 "\t" $3}' > "$TMP/syms.raw"
else
# generic nm -n emits: ADDRESS TYPE NAME (varies a bit across platforms)
"$NM" -n --defined-only "$ELF" 2>/dev/null \
| awk '/ [Tt] /{print $1 "\t" $3}' > "$TMP/syms.raw" || true
# macOS nm might output different columns; handle common alt layout:
if [[ ! -s "$TMP/syms.raw" ]]; then
"$NM" -n "$ELF" 2>/dev/null | awk '/ [Tt] /{print $1 "\t" $3}' > "$TMP/syms.raw" || true
fi
fi
if [[ -n "$CPPFILT" && -s "$TMP/syms.raw" ]]; then
"$CPPFILT" < "$TMP/syms.raw" > "$TMP/syms.dem.raw" || cp "$TMP/syms.raw" "$TMP/syms.dem.raw"
else
cp "$TMP/syms.raw" "$TMP/syms.dem.raw"
fi
# Normalize addresses and sort ascending
awk '{addr=$1; sub(/^0x0+/, "0x", addr); print addr "\t" $2}' "$TMP/syms.dem.raw" \
| awk 'NF' \
| sort -k1,1 > "$TMP/syms.tsv"
if [[ ! -s "$TMP/syms.tsv" ]]; then
echo "WARNING: no text symbols found; falling back to inline-aware mapping." >&2
ADDR_FUNC_FILE="$DEFAULT_ADDR_FUNC"
else
# Map each PC to the *containing* function: last symbol with addr <= PC.
# Both syms.tsv and addrs.txt are sorted asc → single pass merge.
awk '
function hex2num(h, x, n,i,c) {
gsub(/^0x/,"",h); n=0
for(i=1;i<=length(h);i++){ c=substr(h,i,1)
x = index("0123456789abcdef", tolower(c)) - 1
if (x<0) x = index("0123456789ABCDEF", c) - 1
n = n*16 + x
}
return n
}
BEGIN {
# preload symbols
while ((getline < ARGV[1]) > 0) {
saddr[NSYM]=$1; sname[NSYM]=$2; NSYM++
}
# load PCs
while ((getline < ARGV[2]) > 0) {
pc[NPC]=$0; NPC++
}
# pointers
si=0
for (i=0; i<NPC; i++) {
p=pc[i]; pn=hex2num(p)
# advance symbol index while next symbol start <= pc
while (si+1<NSYM && hex2num(saddr[si+1]) <= pn) si++
# output mapping: p -> sname[si] (if any)
if (si<NSYM && hex2num(saddr[si]) <= pn)
printf "%s\t%s\n", p, sname[si]
else
printf "%s\t<unknown>\n", p
}
exit 0
}
' "$TMP/syms.tsv" "$TMP/addrs.txt" \
| sort -k1,1 > "$TMP/addr_func.noinline.tsv"
ADDR_FUNC_FILE="$TMP/addr_func.noinline.tsv"
fi
fi
else
ADDR_FUNC_FILE="$DEFAULT_ADDR_FUNC"
fi
# ---- aggregate to hot functions ----
join -t $'\t' -a1 -e "<unknown>" -o 1.2,2.2 "$TMP/counts.tsv" "$ADDR_FUNC_FILE" \
| awk -F'\t' '{s[$2]+=$1} END{for(k in s) printf "%8d %s\n",s[k],k}' \
| sort -nr > "$TMP/hot.txt"
# ---- demangle final hot list (if available) ----
if [[ -n "$CPPFILT" ]]; then
"$CPPFILT" < "$TMP/hot.txt" > hot_functions.txt
else
cp "$TMP/hot.txt" hot_functions.txt
fi
echo "=== Top 50 hot functions ==="
head -50 hot_functions.txt
echo "Full list in: hot_functions.txt"
# ---- annotated source+assembly (optional) ----
if (( ANNOTATE )); then
echo ">> Generating annotated source+assembly..."
awk '{printf "%s %s\n",$2,$1}' "$TMP/pc_counts.txt" >"$TMP/count.map"
if [[ "$OBJDUMP" == "llvm-objdump" ]]; then
# Portable across llvm-objdump versions
"$OBJDUMP" --source -l --demangle -d "$ELF" >"$TMP/disasm.txt"
else
"$OBJDUMP" -S -C -l -d "$ELF" >"$TMP/disasm.txt"
fi
# Overlay hit counts onto the disassembly
awk -v counts="$TMP/count.map" '
BEGIN {
while ((getline < counts) > 0) {
addr=$1; cnt=$2
gsub(/^0x/,"",addr)
map[addr]=cnt
}
}
/^[[:space:]]*[0-9a-f]+:/ {
split($1,a,":"); key=a[1]
if (key in map)
printf("%-12s %6d | %s\n", $1, map[key], substr($0, index($0,$2)))
else
print $0
next
}
{ print }
' "$TMP/disasm.txt" > annotated.S
echo "Annotated source + assembly written to: annotated.S"
echo "Tip: less -R annotated.S"
fi

View File

@@ -1,13 +1,16 @@
idf_component_register(SRCS
src/hello_world_main.cpp
src/display.cpp
src/bat_mon.cpp
src/spi_global.cpp
src/i2c_global.cpp
src/disp_tools.cpp
src/disp_tty.cpp
src/shutdowner.cpp
src/buttons.cpp
src/power_helper.cpp
PRIV_REQUIRES spi_flash esp_driver_i2c driver
INCLUDE_DIRS "include")
idf_component_register(
SRCS
"src/app_main.cpp"
PRIV_REQUIRES
sdk-esp
littlefs
REQUIRES
backend-esp
EMBED_FILES
"roms/builtin_demo1.gb"
"roms/builtin_demo2.gb"
INCLUDE_DIRS
""
)
littlefs_create_partition_image(littlefs ../flash_data FLASH_IN_PROJECT)

View File

@@ -0,0 +1,17 @@
## IDF Component Manager Manifest File
dependencies:
## Required IDF version
idf:
version: '>=4.1.0'
# # Put list of dependencies here
# # For components maintained by Espressif:
# component: "~1.0.0"
# # For 3rd party components:
# username/component: ">=1.0.0,<2.0.0"
# username2/component2:
# version: "~1.0.0"
# # For transient dependencies `public` flag can be set.
# # `public` flag doesn't have an effect dependencies of the `main` component.
# # All dependencies of `main` are public by default.
# public: true
joltwallet/littlefs: ^1.20

View File

@@ -1,36 +0,0 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#ifndef BUTTONS_HPP
#define BUTTONS_HPP
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
typedef enum {
L1 = 1 << 1,
L2 = 1 << 6,
L3 = 1 << 0,
L4 = 1 << 7,
R1 = 1 << 5,
R2 = 1 << 2,
R3 = 1 << 4,
R4 = 1 << 3,
} btn_num;
class Buttons {
public:
static Buttons& get();
void pooler(); // FIXME:
uint8_t get_pressed();
private:
Buttons();
volatile uint8_t _current;
TaskHandle_t _pooler_task;
};
#endif // BUTTONS_HPP

View File

@@ -1,29 +0,0 @@
#ifndef CB_CONFIG_HPP
#define CB_CONFIG_HPP
#include "hal/spi_types.h"
#include "soc/gpio_num.h"
#define I2C_SCL GPIO_NUM_8
#define I2C_SDA GPIO_NUM_9
#define SPI_MOSI GPIO_NUM_5
#define SPI_MISO GPIO_NUM_0
#define SPI_SCK GPIO_NUM_4
#define SPI_DISP_CS GPIO_NUM_11
#define SPI_BUS SPI2_HOST
#define DISP_WIDTH 400
#define DISP_HEIGHT 240
#define PWR_INT GPIO_NUM_10
#define PWR_KILL GPIO_NUM_12
#define SHR_OUT GPIO_NUM_23
#define SHR_CLK GPIO_NUM_3
#define SHR_SH GPIO_NUM_2
#define DIRECT_BTN GPIO_NUM_1
#endif

View File

@@ -1,28 +0,0 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#ifndef CB_DISP_TOOLS_HPP
#define CB_DISP_TOOLS_HPP
#include <display.hpp>
class DispTools {
public:
static DispTools& get();
void clear();
bool get_pixel(int x, int y);
void set_pixel(int x, int y);
void reset_pixel(int x, int y);
void draw_rectangle(int x1, int y1, int x2, int y2);
void draw_line(int x1, int y1, int x2, int y2);
void draw_circle(int x, int y, int r);
void draw_to_display();
private:
SMD::disp_frame_t disp_frame;
};
#endif // DISP_TOOLS_HPP

View File

@@ -1,42 +0,0 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#ifndef DISP_TTY_HPP
#define DISP_TTY_HPP
#include <array>
#include <cstddef>
#include "config.hpp"
#include <format>
class FbTty {
public:
void putchar(char c);
void putstr(const char* str);
void reset();
template<typename... Args>
auto fmt(std::format_string<Args...> fmt, Args&&... args) {
auto str = std::format(fmt, std::forward<Args>(args)...);
putstr(str.c_str());
}
private:
void draw_char(int col, int row);
int _cur_col = 0;
int _cur_row = 0;
static constexpr size_t _max_col = DISP_WIDTH / 8;
static constexpr size_t _max_row = DISP_HEIGHT / 16;
std::array<std::array<char, _max_row>, _max_col> _buf = {};
void next_col();
void next_row();
};
#endif // DISP_TTY_HPP

View File

@@ -1,44 +0,0 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#ifndef CB_DISPLAY_HPP
#define CB_DISPLAY_HPP
#include "config.hpp"
#include "driver/spi_master.h"
#include <array>
#include <bitset>
class SMD {
public:
using disp_line_t = std::bitset<400>;
using disp_frame_t = std::array<disp_line_t, 240>;
static SMD& get();
void clear();
void draw(const disp_frame_t& frame);
private:
SMD();
static inline spi_device_interface_config_t _devcfg = {
.mode = 0, // SPI mode 0
.clock_speed_hz = 2 * 1000 * 1000, // Clock out at 10 MHz
.spics_io_num = SPI_DISP_CS, // CS pin
.flags = SPI_DEVICE_POSITIVE_CS,
.queue_size = 3,
// .pre_cb = lcd_spi_pre_transfer_callback, //Specify pre-transfer callback to handle D/C line
};
static constexpr size_t kLineBytes = DISP_WIDTH / 8;
spi_device_handle_t _spi;
bool _vcom = false;
static constexpr size_t kLineData = (kLineBytes + 4);
std::array<uint8_t, kLineData> buf{};
std::array<uint8_t, kLineBytes> prep_line(const SMD::disp_line_t& line);
};
#endif // DISPLAY_HPP

View File

@@ -1,29 +0,0 @@
//
// Created by Stepan Usatiuk on 03.03.2025.
//
#ifndef POWER_HELPER_HPP
#define POWER_HELPER_HPP
#include "freertos/FreeRTOS.h"
class PowerHelper {
public:
static PowerHelper& get();
bool is_slow() const;
void set_slow(bool slow);
void reset_slow_isr(); // FIXME:
void delay(int slow_ms, int normal_ms);
void install_isr();
private:
PowerHelper();
bool _slow = false;
EventGroupHandle_t _event_group;
};
#endif // POWER_HELPER_HPP

View File

@@ -0,0 +1,7 @@
# Built-in ROM placeholders
This directory holds Game Boy ROM images that get embedded into the firmware via `EMBED_FILES`.
The repository includes two small placeholder files (`builtin_demo1.gb` and `builtin_demo2.gb`) so
that the build system always has something to embed, but they are not valid games. Replace them
with legally distributable ROMs to ship useful built-in titles. Filenames are used to derive the
save-game slot name.

View File

@@ -0,0 +1,247 @@
// Cardboy firmware entry point: boot platform services and run the modular app system.
#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"
#include "cardboy/sdk/persistent_settings.hpp"
#include "esp_err.h"
#include "esp_pm.h"
#include "esp_sleep.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "sdkconfig.h"
#include <algorithm>
#include <cinttypes>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <span>
#include <string>
#include <string_view>
#include <vector>
namespace {
extern "C" {
extern const uint8_t _binary_builtin_demo1_gb_start[];
extern const uint8_t _binary_builtin_demo1_gb_end[];
extern const uint8_t _binary_builtin_demo2_gb_start[];
extern const uint8_t _binary_builtin_demo2_gb_end[];
}
constexpr apps::EmbeddedRomDescriptor kEmbeddedRoms[] = {
{
.name = "Builtin Demo 1",
.saveSlug = "builtin_demo1",
.start = _binary_builtin_demo1_gb_start,
.end = _binary_builtin_demo1_gb_end,
},
{
.name = "Builtin Demo 2",
.saveSlug = "builtin_demo2",
.start = _binary_builtin_demo2_gb_start,
.end = _binary_builtin_demo2_gb_end,
},
};
} // namespace
#if CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS && CONFIG_FREERTOS_USE_TRACE_FACILITY
namespace {
constexpr TickType_t kStatsTaskDelayTicks = pdMS_TO_TICKS(5000);
constexpr TickType_t kStatsWarmupDelay = pdMS_TO_TICKS(2000);
constexpr UBaseType_t kStatsTaskPriority = tskIDLE_PRIORITY + 1;
constexpr uint32_t kStatsTaskStack = 4096;
constexpr char kStatsTaskName[] = "TaskStats";
struct TaskRuntimeSample {
TaskHandle_t handle;
uint32_t runtime;
};
struct TaskUsageRow {
std::string name;
uint64_t delta;
UBaseType_t priority;
uint32_t stackHighWaterBytes;
bool isIdle;
};
[[nodiscard]] uint64_t deltaWithWrap(uint32_t current, uint32_t previous) {
if (current >= previous)
return static_cast<uint64_t>(current - previous);
return static_cast<uint64_t>(current) + (static_cast<uint64_t>(UINT32_MAX) - previous) + 1ULL;
}
void task_usage_monitor(void*) {
static constexpr char tag[] = "TaskUsage";
std::vector<TaskRuntimeSample> lastSamples;
uint32_t lastTotal = 0;
vTaskDelay(kStatsWarmupDelay);
while (true) {
vTaskDelay(kStatsTaskDelayTicks);
const UBaseType_t taskCount = uxTaskGetNumberOfTasks();
if (taskCount == 0)
continue;
std::vector<TaskStatus_t> statusBuffer(taskCount);
uint32_t totalRuntime = 0;
const UBaseType_t captured = uxTaskGetSystemState(statusBuffer.data(), statusBuffer.size(), &totalRuntime);
if (captured == 0)
continue;
statusBuffer.resize(captured);
std::vector<TaskRuntimeSample> currentSamples;
currentSamples.reserve(statusBuffer.size());
if (lastTotal == 0) {
for (const auto& status: statusBuffer) {
currentSamples.push_back({status.xHandle, status.ulRunTimeCounter});
}
lastSamples = std::move(currentSamples);
lastTotal = totalRuntime;
continue;
}
const uint64_t totalDelta = deltaWithWrap(totalRuntime, lastTotal);
if (totalDelta == 0)
continue;
std::vector<TaskUsageRow> rows;
rows.reserve(statusBuffer.size());
uint64_t idleDelta = 0;
uint64_t activeDelta = 0;
uint64_t accountedDelta = 0;
for (const auto& status: statusBuffer) {
const auto it = std::find_if(lastSamples.begin(), lastSamples.end(), [&](const TaskRuntimeSample& entry) {
return entry.handle == status.xHandle;
});
const uint32_t previousRuntime = (it != lastSamples.end()) ? it->runtime : status.ulRunTimeCounter;
const uint64_t taskDelta =
(it != lastSamples.end()) ? deltaWithWrap(status.ulRunTimeCounter, previousRuntime) : 0ULL;
currentSamples.push_back({status.xHandle, status.ulRunTimeCounter});
TaskUsageRow row{.name = std::string(status.pcTaskName ? status.pcTaskName : ""),
.delta = taskDelta,
.priority = status.uxCurrentPriority,
.stackHighWaterBytes =
static_cast<uint32_t>(status.usStackHighWaterMark) * sizeof(StackType_t),
.isIdle = status.uxCurrentPriority == tskIDLE_PRIORITY ||
(status.pcTaskName && std::strncmp(status.pcTaskName, "IDLE", 4) == 0)};
rows.push_back(std::move(row));
accountedDelta += taskDelta;
if (rows.back().isIdle)
idleDelta += taskDelta;
else
activeDelta += taskDelta;
}
lastSamples = std::move(currentSamples);
lastTotal = totalRuntime;
if (rows.empty())
continue;
std::sort(rows.begin(), rows.end(),
[](const TaskUsageRow& a, const TaskUsageRow& b) { return a.delta > b.delta; });
const double windowMs = static_cast<double>(totalDelta) / 1000.0;
std::printf("\n[%s] CPU usage over %.1f ms window\n", tag, windowMs);
for (const auto& row: rows) {
if (row.delta == 0 || row.isIdle)
continue;
const double pct = (static_cast<double>(row.delta) * 100.0) / static_cast<double>(totalDelta);
std::printf(" %-16s %6.2f%% (prio=%u stack_free=%lu B)\n", row.name.c_str(), pct, row.priority,
static_cast<unsigned long>(row.stackHighWaterBytes));
}
const double idlePct = (idleDelta * 100.0) / static_cast<double>(totalDelta);
std::printf(" %-16s %6.2f%% (aggregated idle)\n", "<idle>", idlePct);
const uint64_t residual = (accountedDelta >= totalDelta) ? 0ULL : (totalDelta - accountedDelta);
if (residual > 0) {
const double residualPct = (static_cast<double>(residual) * 100.0) / static_cast<double>(totalDelta);
std::printf(" %-16s %6.2f%% (ISRs / scheduler)\n", "<isr>", residualPct);
}
std::printf("[%s] Active %.2f%% | Idle %.2f%%\n", tag, (activeDelta * 100.0) / static_cast<double>(totalDelta),
idlePct);
const uint32_t heapFree = esp_get_free_heap_size();
const uint32_t heapMinimum = esp_get_minimum_free_heap_size();
std::printf("[%s] Heap free %lu B | Min free %lu B\n", tag, static_cast<unsigned long>(heapFree),
static_cast<unsigned long>(heapMinimum));
std::fflush(stdout);
}
}
void start_task_usage_monitor() {
xTaskCreatePinnedToCore(task_usage_monitor, kStatsTaskName, kStatsTaskStack, nullptr, kStatsTaskPriority, nullptr,
0);
}
} // namespace
#else
inline void start_task_usage_monitor() {}
#endif
extern "C" void app_main() {
apps::setGameboyEmbeddedRoms(std::span<const apps::EmbeddedRomDescriptor>(kEmbeddedRoms));
static cardboy::backend::esp::EspRuntime runtime;
cardboy::sdk::AppContext context(runtime.framebuffer, runtime.input, runtime.clock);
context.services = &runtime.serviceRegistry();
cardboy::sdk::AppSystem system(context);
context.system = &system;
const cardboy::sdk::PersistentSettings persistentSettings =
cardboy::sdk::loadPersistentSettings(context.getServices());
if (auto* buzzer = context.buzzer())
buzzer->setMuted(persistentSettings.mute);
#ifdef CONFIG_PM_ENABLE
if (persistentSettings.autoLightSleep) {
const esp_pm_config_t pm_config = {
.max_freq_mhz = CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ,
.min_freq_mhz = 16,
.light_sleep_enable = true,
};
ESP_ERROR_CHECK(esp_pm_configure(&pm_config));
}
ESP_ERROR_CHECK(esp_sleep_enable_gpio_wakeup());
#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());
// start_task_usage_monitor();
system.run();
}

View File

@@ -1,78 +0,0 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "bat_mon.hpp"
#include <power_helper.hpp>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "i2c_global.hpp"
static i2c_master_dev_handle_t dev_handle;
BatMon& BatMon::get() {
static BatMon bat_mon;
return bat_mon;
}
static void start_pooler(void* arg) { static_cast<BatMon*>(arg)->pooler(); }
BatMon::BatMon() {
ESP_ERROR_CHECK(i2c_master_bus_add_device(I2cGlobal::get().get_bus_handle(), &_dev_cfg, &dev_handle));
uint8_t reg = 1;
uint8_t buffer;
uint8_t buf2[2];
ESP_ERROR_CHECK(
i2c_master_transmit_receive(dev_handle, &reg, sizeof(reg), reinterpret_cast<uint8_t*>(&buffer), 1, -1));
if (buffer & (1 << 4)) // POR reset
{
printf("Gas gauge reset!\n");
buf2[0] = 1;
buf2[1] = 0 << 4;
ESP_ERROR_CHECK(i2c_master_transmit(dev_handle, buf2, sizeof(buf2), -1));
buf2[0] = 0;
buf2[1] = 1 << 4 | 1 << 2; // 10 bit adc
ESP_ERROR_CHECK(i2c_master_transmit(dev_handle, buf2, sizeof(buf2), -1));
}
xTaskCreate(&start_pooler, "BatMon", 2048, this, tskIDLE_PRIORITY, &_pooler_task);
}
void BatMon::pooler() {
while (true) {
uint8_t reg = 8;
uint16_t buffer;
ESP_ERROR_CHECK(
i2c_master_transmit_receive(dev_handle, &reg, sizeof(reg), reinterpret_cast<uint8_t*>(&buffer), 2, -1));
float voltage = buffer;
voltage *= 2.44f;
voltage /= 1000;
_voltage = voltage;
reg = 2;
ESP_ERROR_CHECK(
i2c_master_transmit_receive(dev_handle, &reg, sizeof(reg), reinterpret_cast<uint8_t*>(&buffer), 2, -1));
float charge = *reinterpret_cast<int16_t*>(&buffer);
charge *= 6.70f;
charge /= 50;
_charge = charge;
reg = 6;
ESP_ERROR_CHECK(
i2c_master_transmit_receive(dev_handle, &reg, sizeof(reg), reinterpret_cast<uint8_t*>(&buffer), 2, -1));
float current = static_cast<int16_t>(buffer << 2);
current *= 11.77f;
current /= 50;
current /= 4;
_current = current;
PowerHelper::get().delay(10000, 1000);
}
}
float BatMon::get_voltage() const { return _voltage; }
float BatMon::get_charge() const { return _charge; }
float BatMon::get_current() const { return _current; }

View File

@@ -1,59 +0,0 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "buttons.hpp"
#include <driver/gpio.h>
#include <esp_err.h>
#include <power_helper.hpp>
#include <rom/ets_sys.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "config.hpp"
Buttons& Buttons::get() {
static Buttons buttons;
return buttons;
}
static void start_pooler(void* arg) { static_cast<Buttons*>(arg)->pooler(); }
Buttons::Buttons() {
ESP_ERROR_CHECK(gpio_reset_pin(SHR_OUT));
ESP_ERROR_CHECK(gpio_reset_pin(SHR_CLK));
ESP_ERROR_CHECK(gpio_reset_pin(SHR_SH));
ESP_ERROR_CHECK(gpio_set_direction(SHR_OUT, GPIO_MODE_INPUT));
ESP_ERROR_CHECK(gpio_set_pull_mode(SHR_OUT, GPIO_FLOATING));
ESP_ERROR_CHECK(gpio_set_direction(SHR_SH, GPIO_MODE_OUTPUT));
ESP_ERROR_CHECK(gpio_set_direction(SHR_CLK, GPIO_MODE_OUTPUT));
xTaskCreate(&start_pooler, "ButtonsPooler", 2048, this, 1, &_pooler_task);
}
static void delay(unsigned long long loop) {
for (unsigned long long i = 0; i < loop; i++) {
asm volatile("nop");
}
}
void Buttons::pooler() {
while (true) {
ESP_ERROR_CHECK(gpio_set_level(SHR_SH, 0));
ESP_ERROR_CHECK(gpio_set_level(SHR_SH, 1));
uint8_t new_val = 0;
for (int i = 0; i < 8; i++) {
ESP_ERROR_CHECK(gpio_set_level(SHR_CLK, 0));
new_val |= gpio_get_level(SHR_OUT) << i;
ESP_ERROR_CHECK(gpio_set_level(SHR_CLK, 1));
}
_current = new_val;
PowerHelper::get().delay(10000, 100);
}
}
uint8_t Buttons::get_pressed() { return _current; }

View File

@@ -1,75 +0,0 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "disp_tools.hpp"
#include <cmath>
#include <display.hpp>
DispTools& DispTools::get() {
static DispTools disp_tools;
return disp_tools;
}
void DispTools::clear() {
for (int y = 0; y < DISP_HEIGHT; y++) {
for (int x = 0; x < DISP_WIDTH; x++) {
disp_frame[y][x] = 1;
}
}
}
bool DispTools::get_pixel(int x, int y) { return disp_frame[y][x]; }
void DispTools::reset_pixel(int x, int y) { disp_frame[y][x] = true; }
void DispTools::set_pixel(int x, int y) { disp_frame[y][x] = false; }
void DispTools::draw_rectangle(int x1, int y1, int x2, int y2) {
int dy = y2 - y1;
while (std::abs(dy) > 0) {
draw_line(x1, y1 + dy, x2, y1 + dy);
dy += (dy > 0) ? -1 : 1;
}
}
void DispTools::draw_line(int x1, int y1, int x2, int y2) {
int dx = x2 - x1;
int dy = y2 - y1;
int a = 0, b = 0, diff = 0;
if (dx == 0) {
while (dy != 0) {
set_pixel(x1, y1 + dy);
dy += (dy > 0) ? -1 : 1;
}
return;
}
if (dy == 0) {
while (dx != 0) {
set_pixel(x1 + dx, y1);
dx += (dx > 0) ? -1 : 1;
}
return;
}
while (std::abs(a) <= std::abs(dx) && std::abs(b) <= std::abs(dy)) {
set_pixel(x1 + a, y1 + b);
if (diff < 0) {
a += (dx > 0) ? 1 : -1;
diff += std::abs(dy);
} else {
b += (dy > 0) ? 1 : -1;
diff -= std::abs(dx);
}
}
}
void DispTools::draw_circle(int x, int y, int r) {
if (r > 181)
return;
int dy = -r;
while (dy <= r) {
int dx = static_cast<int>(std::sqrt(r * r - dy * dy));
draw_line(x - dx, y + dy, x + dx, y + dy);
dy++;
}
}
void DispTools::draw_to_display() { SMD::get().draw(disp_frame); }

View File

@@ -1,62 +0,0 @@
//
// Created by Stepan Usatiuk on 26.04.2024.
//
#include "disp_tty.hpp"
#include <disp_tools.hpp>
#include "Fonts.hpp"
void FbTty::draw_char(int col, int row) {
for (int x = 0; x < 8; x++) {
for (int y = 0; y < 16; y++) {
bool color = fonts_Terminess_Powerline[_buf[col][row]][y] & (1 << (8 - x));
if (color)
DispTools::get().set_pixel(col * 8 + x, row * 16 + y);
else
DispTools::get().reset_pixel(col * 8 + x, row * 16 + y);
}
}
}
void FbTty::reset() {
_cur_col = 0;
_cur_row = 0;
}
void FbTty::putchar(char c) {
if (c == '\n') {
next_row();
return;
}
_buf[_cur_col][_cur_row] = c;
draw_char(_cur_col, _cur_row);
next_col();
}
void FbTty::putstr(const char* str) {
while (*str != 0) {
putchar(*str);
str++;
}
}
void FbTty::next_col() {
_cur_col++;
_cur_col = _cur_col % _max_col;
if (_cur_col == 0) {
next_row();
} else {
_buf[_cur_col][_cur_row] = ' ';
draw_char(_cur_col, _cur_row);
}
}
void FbTty::next_row() {
_cur_col = 0;
_cur_row++;
_cur_row = _cur_row % _max_row;
for (int i = 0; i < _max_col; i++) {
_buf[i][_cur_row] = ' ';
draw_char(i, _cur_row);
}
}

View File

@@ -1,62 +0,0 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "display.hpp"
#include <cstring>
#include "driver/spi_master.h"
// This solution is attributed to Rich Schroeppel in the Programming Hacks section
// TODO: Why does the device flag not work?
unsigned char reverse_bits3(unsigned char b) { return (b * 0x0202020202ULL & 0x010884422010ULL) % 0x3ff; }
std::array<uint8_t, SMD::kLineBytes> SMD::prep_line(const SMD::disp_line_t& line) {
std::array<uint8_t, kLineBytes> data{};
for (int i = 0; i < DISP_WIDTH; i++) {
data[i / 8] = data[i / 8] | (line[i] << (i % 8));
}
for (int i = 0; i < kLineBytes; i++) {
data[i] = reverse_bits3(data[i]);
}
return data;
}
SMD& SMD::get() {
static SMD smd;
return smd;
}
SMD::SMD() { spi_bus_add_device(SPI_BUS, &_devcfg, &_spi); }
void SMD::clear() {
std::array<uint8_t, 2> buf{};
buf[0] = 0b00100000;
spi_transaction_t t{};
t.tx_buffer = buf.data();
t.length = buf.size() * 8;
ESP_ERROR_CHECK(spi_device_transmit(_spi, &t));
}
void SMD::draw(const disp_frame_t& frame) {
_vcom = !_vcom;
for (uint8_t i = 0; i < DISP_HEIGHT; i++) {
spi_transaction_t t{};
t.tx_buffer = buf.data();
t.length = buf.size() * 8;
buf[0] = 0b10000000 | (_vcom << 6);
buf[1] = reverse_bits3(i + 1);
auto prepared = prep_line(frame.at(i));
memcpy(buf.data() + 2, prepared.data(), kLineBytes);
buf[2 + kLineBytes] = 0;
buf[2 + kLineBytes + 1] = 0;
ESP_ERROR_CHECK(spi_device_transmit(_spi, &t));
}
}

View File

@@ -1,119 +0,0 @@
/*
* SPDX-FileCopyrightText: 2010-2022 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: CC0-1.0
*/
#include <buttons.hpp>
#include <cstdint>
#include <disp_tools.hpp>
#include <disp_tty.hpp>
#include <esp_pm.h>
#include <inttypes.h>
#include <stdio.h>
#include "esp_chip_info.h"
#include "esp_flash.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "sdkconfig.h"
#include "display.hpp"
#include "bat_mon.hpp"
#include "driver/i2c_master.h"
#include "driver/spi_master.h"
#include "i2c_global.hpp"
#include <driver/gpio.h>
#include <esp_sleep.h>
#include <memory>
#include <power_helper.hpp>
#include <shutdowner.hpp>
#include <spi_global.hpp>
#include <string>
FbTty tty;
extern "C" void app_main() {
esp_pm_config_t pm_config = {
.max_freq_mhz = CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ, .min_freq_mhz = 16, .light_sleep_enable = true};
ESP_ERROR_CHECK(esp_pm_configure(&pm_config));
printf("Hello world!\n");
// TODO: Where to put that?
ESP_ERROR_CHECK(esp_sleep_enable_gpio_wakeup());
// For some reason, calling it here hangs on startup, sometimes
// ESP_ERROR_CHECK(gpio_install_isr_service(0));
PowerHelper::get();
Shutdowner::get();
ESP_ERROR_CHECK(gpio_install_isr_service(0));
Shutdowner::get().install_isr();
PowerHelper::get().install_isr();
Buttons::get();
I2cGlobal::get();
BatMon::get();
SpiGlobal::get();
SMD::get();
SMD::get().clear();
DispTools::get().clear();
DispTools::get().draw_line(0, 0, 400, 240);
DispTools::get().draw_circle(100, 100, 20);
DispTools::get().draw_to_display();
tty.putstr("Hello\nworld!");
DispTools::get().draw_to_display();
int rx = 30, ry = 30;
int lastmove = 0;
while (true) {
// SMD::clear();
// printf("Voltage: %f\n", BatMon::get_voltage());
DispTools::get().clear();
tty.reset();
uint8_t pressed = Buttons::get().get_pressed();
if (pressed & L3)
rx -= 5;
if (pressed & L4)
ry += 5;
if (pressed & R3)
ry -= 5;
if (pressed & R4)
rx += 5;
if (pressed == 0 && !PowerHelper::get().is_slow())
lastmove++;
else if (pressed != 0) {
lastmove = 0;
PowerHelper::get().set_slow(false);
}
if (lastmove > 20) {
lastmove = 0;
PowerHelper::get().set_slow(true);
}
bool slow = PowerHelper::get().is_slow();
tty.fmt("{:.1f}mA {:.1f}V {:.1f}mAh {}", BatMon::get().get_current(), BatMon::get().get_voltage(),
BatMon::get().get_charge(), slow ? "S" : "");
if (rx < 30)
rx = 30;
if (rx > 370)
rx = 370;
if (ry < 30)
ry = 30;
if (ry > 210)
ry = 210;
// tty.fmt("Button: {}", pressed);
DispTools::get().draw_circle(rx, ry, 20);
// printf("Restarting in %d seconds...\n", i);
DispTools::get().draw_to_display();
PowerHelper::get().delay(10000, 30);
}
// printf("Restarting now.\n");
// fflush(stdout);
// esp_restart();
}

View File

@@ -1,70 +0,0 @@
//
// Created by Stepan Usatiuk on 03.03.2025.
//
#include "power_helper.hpp"
#include <config.hpp>
#include <driver/gpio.h>
#include <esp_sleep.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
PowerHelper& PowerHelper::get() {
static PowerHelper powerHelper;
return powerHelper;
}
bool PowerHelper::is_slow() const { return _slow; }
void PowerHelper::set_slow(bool slow) {
_slow = slow;
if (_slow) {
xEventGroupClearBits(_event_group, 1);
} else {
xEventGroupSetBits(_event_group, 1);
}
}
void PowerHelper::reset_slow_isr() {
BaseType_t xHigherPriorityTaskWoken, xResult;
xHigherPriorityTaskWoken = pdFALSE;
_slow = false;
xResult = xEventGroupSetBitsFromISR(_event_group, 1, &xHigherPriorityTaskWoken);
if (xResult != pdFAIL) {
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
}
static void wakeup(void* arg) { static_cast<PowerHelper*>(arg)->reset_slow_isr(); }
PowerHelper::PowerHelper() : _event_group(xEventGroupCreate()) {
ESP_ERROR_CHECK(gpio_reset_pin(DIRECT_BTN));
ESP_ERROR_CHECK(gpio_set_direction(DIRECT_BTN, GPIO_MODE_INPUT));
ESP_ERROR_CHECK(gpio_set_pull_mode(DIRECT_BTN, GPIO_FLOATING));
ESP_ERROR_CHECK(gpio_set_intr_type(DIRECT_BTN, GPIO_INTR_HIGH_LEVEL));
ESP_ERROR_CHECK(gpio_wakeup_enable(DIRECT_BTN, GPIO_INTR_HIGH_LEVEL));
// ESP_ERROR_CHECK(gpio_install_isr_service(0));
// gpio_isr_handler_add(DIRECT_BTN, wakeup, this);
set_slow(false);
}
void PowerHelper::delay(int slow_ms, int normal_ms) {
if (is_slow()) {
auto cur_ticks = xTaskGetTickCount();
TickType_t to_wait = slow_ms / portTICK_PERIOD_MS;
TickType_t to_wait_normal = normal_ms / portTICK_PERIOD_MS;
auto expected_ticks = cur_ticks + to_wait_normal;
xEventGroupWaitBits(_event_group, 1, pdFALSE, pdTRUE, to_wait);
auto realTicks = xTaskGetTickCount();
if (realTicks < expected_ticks) {
vTaskDelay(expected_ticks - realTicks);
}
} else {
vTaskDelay(normal_ms / portTICK_PERIOD_MS);
}
}
void PowerHelper::install_isr() { gpio_isr_handler_add(DIRECT_BTN, wakeup, this); }

6
Firmware/partitions.csv Normal file
View File

@@ -0,0 +1,6 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
phy_init, data, phy, 0x10000, 0x1000,
factory, app, factory, 0x20000, 0x250000,
littlefs, data, littlefs,, 0x190000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 otadata data ota 0xe000 0x2000
4 phy_init data phy 0x10000 0x1000
5 factory app factory 0x20000 0x250000
6 littlefs data littlefs 0x190000

View File

@@ -0,0 +1,76 @@
# Generated from CLion C/C++ Code Style settings
---
Language: Cpp
BasedOnStyle: LLVM
AccessModifierOffset: -4
AlignAfterOpenBracket: Align
AlignConsecutiveAssignments:
Enabled: true
AcrossEmptyLines: false
AcrossComments: false
AlignConsecutiveBitFields:
Enabled: true
AcrossEmptyLines: false
AcrossComments: false
AlignConsecutiveDeclarations:
Enabled: true
AcrossEmptyLines: false
AcrossComments: false
AlignConsecutiveMacros:
Enabled: true
AcrossEmptyLines: false
AcrossComments: false
AlignTrailingComments:
Kind: Always
OverEmptyLines: 2
SpacesBeforeTrailingComments: 1
AlignOperands: Align
AlignEscapedNewlines: Right
AlwaysBreakTemplateDeclarations: Yes
BraceWrapping:
AfterCaseLabel: false
AfterClass: false
AfterControlStatement: false
AfterEnum: false
AfterFunction: false
AfterNamespace: false
AfterStruct: false
AfterUnion: false
AfterExternBlock: false
BeforeCatch: false
BeforeElse: false
BeforeLambdaBody: false
BeforeWhile: false
SplitEmptyFunction: true
SplitEmptyRecord: true
SplitEmptyNamespace: true
BreakBeforeBraces: Custom
BreakConstructorInitializers: AfterColon
BreakConstructorInitializersBeforeComma: false
ColumnLimit: 120
ConstructorInitializerAllOnOneLineOrOnePerLine: false
ContinuationIndentWidth: 8
IncludeCategories:
- Regex: '^<.*'
Priority: 1
- Regex: '^".*'
Priority: 2
- Regex: '.*'
Priority: 3
IncludeIsMainRegex: '([-_](test|unittest))?$'
IndentCaseLabels: true
IndentWidth: 4
InsertNewlineAtEOF: true
MacroBlockBegin: ''
MacroBlockEnd: ''
MaxEmptyLinesToKeep: 2
PointerAlignment: Left
SpaceAfterCStyleCast: true
SpaceAfterTemplateKeyword: false
SpaceBeforeRangeBasedForLoopColon: false
SpaceInEmptyParentheses: false
SpacesInAngles: false
SpacesInConditionalStatement: false
SpacesInCStyleCastParentheses: false
SpacesInParentheses: false
...

View File

@@ -0,0 +1,37 @@
cmake_minimum_required(VERSION 3.16)
project(cardboy_sdk LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED YES)
set(CMAKE_CXX_EXTENSIONS NO)
add_subdirectory(utils)
add_subdirectory(backend_interface)
set(CARDBOY_SDK_BACKEND_LIBRARY "" CACHE STRING "Backend implementation library for Cardboy SDK")
set(_cardboy_backend_default "${CARDBOY_SDK_BACKEND_LIBRARY}")
option(CARDBOY_BUILD_SFML "Build desktop SFML backend and launcher" ON)
if (CARDBOY_BUILD_SFML)
add_subdirectory(backends/desktop)
if (DEFINED CARDBOY_DESKTOP_BACKEND_TARGET AND NOT CARDBOY_DESKTOP_BACKEND_TARGET STREQUAL "")
set(_cardboy_backend_default "${CARDBOY_DESKTOP_BACKEND_TARGET}")
endif ()
endif ()
if (_cardboy_backend_default STREQUAL "")
message(FATAL_ERROR "CARDBOY_SDK_BACKEND_LIBRARY is not set. Provide a backend implementation library or enable one of the available backends.")
endif ()
set(CARDBOY_SDK_BACKEND_LIBRARY "${_cardboy_backend_default}" CACHE STRING "Backend implementation library for Cardboy SDK" FORCE)
add_subdirectory(core)
add_subdirectory(apps)
if (CARDBOY_BUILD_SFML)
add_subdirectory(launchers/desktop)
endif ()

View File

@@ -0,0 +1,21 @@
add_library(cardboy_apps STATIC)
set_target_properties(cardboy_apps PROPERTIES
EXPORT_NAME apps
)
target_link_libraries(cardboy_apps
PUBLIC
cardboy_sdk
${CARDBOY_SDK_BACKEND_LIBRARY}
)
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

@@ -0,0 +1,9 @@
target_sources(cardboy_apps
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src/clock_app.cpp
)
target_include_directories(cardboy_apps
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)

View File

@@ -0,0 +1,12 @@
#pragma once
#include "cardboy/sdk/app_framework.hpp"
#include <memory>
namespace apps {
std::unique_ptr<cardboy::sdk::IAppFactory> createClockAppFactory();
} // namespace apps

View File

@@ -0,0 +1,238 @@
#include "cardboy/apps/clock_app.hpp"
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/sdk/app_framework.hpp"
#include "cardboy/sdk/app_system.hpp"
#include "cardboy/gfx/font16x8.hpp"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <ctime>
#include <string>
#include <string_view>
namespace apps {
namespace {
using cardboy::sdk::AppContext;
constexpr const char* kClockAppName = "Clock";
using Framebuffer = typename AppContext::Framebuffer;
using Clock = typename AppContext::Clock;
struct TimeSnapshot {
bool hasWallTime = false;
int hour24 = 0;
int minute = 0;
int second = 0;
int year = 0;
int month = 0;
int day = 0;
int weekday = 0;
std::uint64_t uptimeSeconds = 0;
};
class ClockApp final : public cardboy::sdk::IApp {
public:
explicit ClockApp(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer), clock(ctx.clock) {}
void onStart() override {
cancelRefreshTimer();
lastSnapshot = {};
dirty = true;
const auto snap = captureTime();
renderIfNeeded(snap);
lastSnapshot = snap;
refreshTimer = context.scheduleRepeatingTimer(200);
}
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;
}
}
private:
AppContext& context;
Framebuffer& framebuffer;
Clock& clock;
bool use24Hour = true;
bool dirty = false;
cardboy::sdk::AppTimerHandle refreshTimer = cardboy::sdk::kInvalidAppTimer;
TimeSnapshot lastSnapshot{};
void cancelRefreshTimer() {
if (refreshTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(refreshTimer);
refreshTimer = cardboy::sdk::kInvalidAppTimer;
}
}
void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) {
const auto& current = button.current;
const auto& previous = button.previous;
if (current.b && !previous.b) {
context.requestAppSwitchByName(kMenuAppName);
return;
}
if (current.select && !previous.select) {
use24Hour = !use24Hour;
dirty = true;
}
updateDisplay();
}
void updateDisplay() {
const auto snap = captureTime();
if (!sameSnapshot(snap, lastSnapshot))
dirty = true;
renderIfNeeded(snap);
lastSnapshot = snap;
}
static bool sameSnapshot(const TimeSnapshot& a, const TimeSnapshot& b) {
return a.hasWallTime == b.hasWallTime && a.hour24 == b.hour24 && a.minute == b.minute && a.second == b.second;
}
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;
}
}
// Fallback to uptime-derived clock
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 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 scaleLarge = 3;
const int scaleSeconds = 2;
const int scaleSmall = 1;
int hourDisplay = snap.hour24;
bool isPm = false;
if (!use24Hour) {
isPm = hourDisplay >= 12;
int h12 = hourDisplay % 12;
if (h12 == 0)
h12 = 12;
hourDisplay = h12;
}
char mainLine[6];
std::snprintf(mainLine, sizeof(mainLine), "%02d:%02d", hourDisplay, snap.minute);
const int mainW = font16x8::measureText(mainLine, scaleLarge, 0);
const int timeY = (framebuffer.height() - font16x8::kGlyphHeight * scaleLarge) / 2 - 12;
const int timeX = (framebuffer.width() - mainW) / 2;
font16x8::drawText(framebuffer, timeX, timeY, mainLine, scaleLarge, true, 0);
char secondsLine[3];
std::snprintf(secondsLine, sizeof(secondsLine), "%02d", snap.second);
const int secondsX = timeX + mainW + 12;
const int secondsY = timeY + font16x8::kGlyphHeight * scaleLarge - font16x8::kGlyphHeight * scaleSeconds;
font16x8::drawText(framebuffer, secondsX, secondsY, secondsLine, scaleSeconds, true, 0);
if (!use24Hour) {
font16x8::drawText(framebuffer, timeX + mainW + 12, timeY, isPm ? "PM" : "AM", scaleSmall, true, 0);
} else {
font16x8::drawText(framebuffer, timeX + mainW + 12, timeY, "24H", scaleSmall, true, 0);
}
const std::string dateLine = formatDate(snap);
drawCenteredText(framebuffer, timeY + font16x8::kGlyphHeight * scaleLarge + 28, dateLine, scaleSmall, 1);
if (!snap.hasWallTime) {
char uptimeLine[32];
const std::uint64_t days = snap.uptimeSeconds / 86400ULL;
const std::uint64_t hrs = (snap.uptimeSeconds / 3600ULL) % 24ULL;
const std::uint64_t mins = (snap.uptimeSeconds / 60ULL) % 60ULL;
const std::uint64_t secs = snap.uptimeSeconds % 60ULL;
if (days > 0) {
std::snprintf(uptimeLine, sizeof(uptimeLine), "%llud %02llu:%02llu:%02llu UP",
static_cast<unsigned long long>(days), static_cast<unsigned long long>(hrs),
static_cast<unsigned long long>(mins), static_cast<unsigned long long>(secs));
} else {
std::snprintf(uptimeLine, sizeof(uptimeLine), "%02llu:%02llu:%02llu UP",
static_cast<unsigned long long>(hrs), static_cast<unsigned long long>(mins),
static_cast<unsigned long long>(secs));
}
drawCenteredText(framebuffer, framebuffer.height() - 68, uptimeLine, scaleSmall, 1);
}
drawCenteredText(framebuffer, framebuffer.height() - 36, "SELECT TOGGLE 12/24H", 1, 1);
drawCenteredText(framebuffer, framebuffer.height() - 18, "B BACK", 1, 1);
framebuffer.sendFrame();
}
};
class ClockAppFactory final : public cardboy::sdk::IAppFactory {
public:
const char* name() const override { return kClockAppName; }
std::unique_ptr<cardboy::sdk::IApp> create(cardboy::sdk::AppContext& context) override {
return std::make_unique<ClockApp>(context);
}
};
} // namespace
std::unique_ptr<cardboy::sdk::IAppFactory> createClockAppFactory() { return std::make_unique<ClockAppFactory>(); }
} // namespace apps

View File

@@ -0,0 +1,10 @@
target_sources(cardboy_apps
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src/gameboy_app.cpp
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/apps/peanut_gb.h
)
target_include_directories(cardboy_apps
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)

View File

@@ -0,0 +1,26 @@
#pragma once
#include "cardboy/sdk/app_framework.hpp"
#include <memory>
#include <string_view>
#include <span>
#include <cstdint>
namespace apps {
inline constexpr char kGameboyAppName[] = "Game Boy";
inline constexpr std::string_view kGameboyAppNameView = kGameboyAppName;
struct EmbeddedRomDescriptor {
std::string_view name;
std::string_view saveSlug;
const std::uint8_t* start = nullptr;
const std::uint8_t* end = nullptr;
};
void setGameboyEmbeddedRoms(std::span<const EmbeddedRomDescriptor> descriptors);
std::unique_ptr<cardboy::sdk::IAppFactory> createGameboyAppFactory();
} // namespace apps

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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,271 @@
#include "cardboy/apps/lockscreen_app.hpp"
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/gfx/font16x8.hpp"
#include "cardboy/sdk/app_framework.hpp"
#include "cardboy/sdk/app_system.hpp"
#include <algorithm>
#include <cstdio>
#include <ctime>
#include <string>
#include <string_view>
namespace apps {
namespace {
using cardboy::sdk::AppContext;
constexpr std::uint32_t kRefreshIntervalMs = 100;
constexpr std::uint32_t kUnlockHoldMs = 1500;
using Framebuffer = typename AppContext::Framebuffer;
using Clock = typename AppContext::Clock;
struct TimeSnapshot {
bool hasWallTime = false;
int hour24 = 0;
int minute = 0;
int second = 0;
int year = 0;
int month = 0;
int day = 0;
int weekday = 0;
std::uint64_t uptimeSeconds = 0;
};
class LockscreenApp final : public cardboy::sdk::IApp {
public:
explicit LockscreenApp(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer), clock(ctx.clock) {}
void onStart() override {
cancelRefreshTimer();
lastSnapshot = {};
holdActive = false;
holdProgressMs = 0;
dirty = true;
const auto snap = captureTime();
renderIfNeeded(snap);
lastSnapshot = snap;
refreshTimer = context.scheduleRepeatingTimer(kRefreshIntervalMs);
}
void onStop() override { cancelRefreshTimer(); }
void handleEvent(const cardboy::sdk::AppEvent& event) override {
switch (event.type) {
case cardboy::sdk::AppEventType::Button:
handleButtonEvent(event.button);
break;
case cardboy::sdk::AppEventType::Timer:
if (event.timer.handle == refreshTimer) {
advanceHoldProgress();
updateDisplay();
}
break;
}
}
private:
AppContext& context;
Framebuffer& framebuffer;
Clock& clock;
bool dirty = false;
cardboy::sdk::AppTimerHandle refreshTimer = cardboy::sdk::kInvalidAppTimer;
TimeSnapshot lastSnapshot{};
bool holdActive = false;
std::uint32_t holdProgressMs = 0;
void cancelRefreshTimer() {
if (refreshTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(refreshTimer);
refreshTimer = cardboy::sdk::kInvalidAppTimer;
}
}
static bool comboPressed(const cardboy::sdk::InputState& state) { return state.a && state.select; }
void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) {
const bool comboNow = comboPressed(button.current);
updateHoldState(comboNow);
updateDisplay();
}
void updateHoldState(bool comboNow) {
if (comboNow) {
if (!holdActive) {
holdActive = true;
dirty = true;
}
} else {
if (holdActive || holdProgressMs != 0) {
holdActive = false;
holdProgressMs = 0;
dirty = true;
}
}
}
void advanceHoldProgress() {
if (holdActive) {
const std::uint32_t next =
std::min<std::uint32_t>(holdProgressMs + kRefreshIntervalMs, kUnlockHoldMs);
if (next != holdProgressMs) {
holdProgressMs = next;
dirty = true;
}
if (holdProgressMs >= kUnlockHoldMs) {
holdActive = false;
context.requestAppSwitchByName(kMenuAppName);
}
} else if (holdProgressMs != 0) {
holdProgressMs = 0;
dirty = true;
}
}
void updateDisplay() {
const auto snap = captureTime();
if (!sameSnapshot(snap, lastSnapshot))
dirty = true;
renderIfNeeded(snap);
lastSnapshot = snap;
}
static bool sameSnapshot(const TimeSnapshot& a, const TimeSnapshot& b) {
return a.hasWallTime == b.hasWallTime && a.hour24 == b.hour24 && a.minute == b.minute &&
a.second == b.second && a.day == b.day && a.month == b.month && a.year == b.year;
}
TimeSnapshot captureTime() const {
TimeSnapshot snap{};
snap.uptimeSeconds = clock.millis() / 1000ULL;
std::time_t raw = 0;
if (std::time(&raw) != static_cast<std::time_t>(-1) && raw > 0) {
std::tm tm{};
if (localtime_r(&raw, &tm) != nullptr) {
snap.hasWallTime = true;
snap.hour24 = tm.tm_hour;
snap.minute = tm.tm_min;
snap.second = tm.tm_sec;
snap.year = tm.tm_year + 1900;
snap.month = tm.tm_mon + 1;
snap.day = tm.tm_mday;
snap.weekday = tm.tm_wday;
return snap;
}
}
snap.hasWallTime = false;
snap.hour24 = static_cast<int>((snap.uptimeSeconds / 3600ULL) % 24ULL);
snap.minute = static_cast<int>((snap.uptimeSeconds / 60ULL) % 60ULL);
snap.second = static_cast<int>(snap.uptimeSeconds % 60ULL);
return snap;
}
static void drawCenteredText(Framebuffer& fb, int y, std::string_view text, int scale, int letterSpacing = 0) {
const int width = font16x8::measureText(text, scale, letterSpacing);
const int x = (fb.width() - width) / 2;
font16x8::drawText(fb, x, y, text, scale, true, letterSpacing);
}
static void drawRectOutline(Framebuffer& fb, int x, int y, int w, int h) {
if (w <= 0 || h <= 0)
return;
for (int dx = 0; dx < w; ++dx) {
fb.drawPixel(x + dx, y, true);
fb.drawPixel(x + dx, y + h - 1, true);
}
for (int dy = 0; dy < h; ++dy) {
fb.drawPixel(x, y + dy, true);
fb.drawPixel(x + w - 1, y + dy, true);
}
}
static void fillRect(Framebuffer& fb, int x, int y, int w, int h) {
if (w <= 0 || h <= 0)
return;
for (int dy = 0; dy < h; ++dy) {
for (int dx = 0; dx < w; ++dx) {
fb.drawPixel(x + dx, y + dy, true);
}
}
}
static std::string formatDate(const TimeSnapshot& snap) {
static const char* kWeekdays[] = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"};
if (!snap.hasWallTime)
return "UPTIME MODE";
const char* weekday = (snap.weekday >= 0 && snap.weekday < 7) ? kWeekdays[snap.weekday] : "";
char buffer[32];
std::snprintf(buffer, sizeof(buffer), "%s %04d-%02d-%02d", weekday, snap.year, snap.month, snap.day);
return buffer;
}
void renderIfNeeded(const TimeSnapshot& snap) {
if (!dirty)
return;
dirty = false;
framebuffer.frameReady();
const int scaleTime = 4;
const int scaleSeconds = 2;
const int scaleSmall = 1;
char hoursMinutes[6];
std::snprintf(hoursMinutes, sizeof(hoursMinutes), "%02d:%02d", snap.hour24, snap.minute);
const int mainW = font16x8::measureText(hoursMinutes, scaleTime, 0);
const int timeY = (framebuffer.height() - font16x8::kGlyphHeight * scaleTime) / 2 - 8;
const int timeX = (framebuffer.width() - mainW) / 2;
const int secX = timeX + mainW + 12;
const int secY = timeY + font16x8::kGlyphHeight * scaleTime - font16x8::kGlyphHeight * scaleSeconds;
char secs[3];
std::snprintf(secs, sizeof(secs), "%02d", snap.second);
font16x8::drawText(framebuffer, timeX, timeY, hoursMinutes, scaleTime, true, 0);
font16x8::drawText(framebuffer, secX, secY, secs, scaleSeconds, true, 0);
const std::string dateLine = formatDate(snap);
drawCenteredText(framebuffer, timeY + font16x8::kGlyphHeight * scaleTime + 24, dateLine, scaleSmall, 1);
const char* instruction = holdActive ? "KEEP HOLDING A+SELECT" : "HOLD A+SELECT";
drawCenteredText(framebuffer, framebuffer.height() - 52, instruction, scaleSmall, 1);
if (holdActive || holdProgressMs > 0) {
const int barWidth = framebuffer.width() - 64;
const int barHeight = 14;
const int barX = (framebuffer.width() - barWidth) / 2;
const int barY = framebuffer.height() - 32;
const int innerWidth = barWidth - 2;
const int innerHeight = barHeight - 2;
drawRectOutline(framebuffer, barX, barY, barWidth, barHeight);
const float ratio =
std::clamp(holdProgressMs / static_cast<float>(kUnlockHoldMs), 0.0f, 1.0f);
const int fillWidth = static_cast<int>(ratio * innerWidth + 0.5f);
if (fillWidth > 0)
fillRect(framebuffer, barX + 1, barY + 1, fillWidth, innerHeight);
}
framebuffer.sendFrame();
}
};
class LockscreenAppFactory final : public cardboy::sdk::IAppFactory {
public:
const char* name() const override { return kLockscreenAppName; }
std::unique_ptr<cardboy::sdk::IApp> create(cardboy::sdk::AppContext& context) override {
return std::make_unique<LockscreenApp>(context);
}
};
} // namespace
std::unique_ptr<cardboy::sdk::IAppFactory> createLockscreenAppFactory() {
return std::make_unique<LockscreenAppFactory>();
}
} // namespace apps

View File

@@ -0,0 +1,9 @@
target_sources(cardboy_apps
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src/menu_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 kMenuAppName[] = "Menu";
inline constexpr std::string_view kMenuAppNameView = kMenuAppName;
std::unique_ptr<cardboy::sdk::IAppFactory> createMenuAppFactory();
} // namespace apps

View File

@@ -0,0 +1,216 @@
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/apps/lockscreen_app.hpp"
#include "cardboy/sdk/app_framework.hpp"
#include "cardboy/sdk/app_system.hpp"
#include "cardboy/gfx/font16x8.hpp"
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <string>
#include <string_view>
#include <vector>
namespace apps {
namespace {
using cardboy::sdk::AppContext;
using Framebuffer = typename AppContext::Framebuffer;
constexpr std::uint32_t kIdleTimeoutMs = 15000;
struct MenuEntry {
std::string name;
std::size_t index = 0;
};
class MenuApp final : public cardboy::sdk::IApp {
public:
explicit MenuApp(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer) { refreshEntries(); }
void onStart() override {
refreshEntries();
dirty = true;
resetInactivityTimer();
renderIfNeeded();
}
void onStop() override { cancelInactivityTimer(); }
void handleEvent(const cardboy::sdk::AppEvent& event) override {
switch (event.type) {
case cardboy::sdk::AppEventType::Button:
handleButtonEvent(event.button);
break;
case cardboy::sdk::AppEventType::Timer:
if (event.timer.handle == inactivityTimer) {
cancelInactivityTimer();
context.requestAppSwitchByName(kLockscreenAppName);
}
break;
}
}
private:
AppContext& context;
Framebuffer& framebuffer;
std::vector<MenuEntry> entries;
std::size_t selected = 0;
bool dirty = false;
cardboy::sdk::AppTimerHandle inactivityTimer = cardboy::sdk::kInvalidAppTimer;
void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) {
resetInactivityTimer();
const auto& current = button.current;
const auto& previous = button.previous;
if (current.b && !previous.b) {
context.requestAppSwitchByName(kLockscreenAppName);
return;
}
if (current.left && !previous.left) {
moveSelection(-1);
} else if (current.right && !previous.right) {
moveSelection(+1);
}
const bool launch = (current.a && !previous.a) || (current.start && !previous.start);
if (launch)
launchSelected();
renderIfNeeded();
}
void moveSelection(int step) {
if (entries.empty())
return;
const int count = static_cast<int>(entries.size());
int next = static_cast<int>(selected) + step;
next = (next % count + count) % count;
selected = static_cast<std::size_t>(next);
dirty = true;
}
void launchSelected() {
if (entries.empty())
return;
const auto target = entries[selected].index;
if (context.system && context.system->currentFactoryIndex() == target)
return;
context.requestAppSwitchByIndex(target);
}
void refreshEntries() {
entries.clear();
if (!context.system)
return;
const std::size_t total = context.system->appCount();
for (std::size_t i = 0; i < total; ++i) {
const cardboy::sdk::IAppFactory* factory = context.system->factoryAt(i);
if (!factory)
continue;
const char* name = factory->name();
if (!name)
continue;
const std::string_view appName(name);
if (appName == kMenuAppNameView || appName == kLockscreenAppNameView)
continue;
entries.push_back(MenuEntry{std::string(name), i});
}
if (selected >= entries.size())
selected = entries.empty() ? 0 : entries.size() - 1;
dirty = true;
}
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);
}
void drawPagerDots() {
if (entries.size() <= 1)
return;
const int count = static_cast<int>(entries.size());
const int spacing = 20;
const int dotSize = 7;
const int totalW = spacing * (count - 1);
const int startX = (framebuffer.width() - totalW) / 2;
const int baseline = framebuffer.height() - (font16x8::kGlyphHeight + 48);
for (int i = 0; i < count; ++i) {
const int cx = startX + i * spacing;
for (int dx = -dotSize / 2; dx <= dotSize / 2; ++dx) {
for (int dy = -dotSize / 2; dy <= dotSize / 2; ++dy) {
const bool isSelected = (static_cast<std::size_t>(i) == selected);
const bool on = isSelected || std::abs(dx) == dotSize / 2 || std::abs(dy) == dotSize / 2;
if (on)
framebuffer.drawPixel(cx + dx, baseline + dy, true);
}
}
}
}
void renderIfNeeded() {
if (!dirty)
return;
dirty = false;
framebuffer.frameReady();
drawCenteredText(framebuffer, 24, "APPS", 1, 1);
if (entries.empty()) {
drawCenteredText(framebuffer, framebuffer.height() / 2 - 18, "NO OTHER APPS", 2, 1);
drawCenteredText(framebuffer, framebuffer.height() - 72, "ADD MORE IN FIRMWARE", 1, 1);
} else {
const std::string& name = entries[selected].name;
const int titleScale = 2;
const int centerY = framebuffer.height() / 2 - (font16x8::kGlyphHeight * titleScale) / 2;
drawCenteredText(framebuffer, centerY, name, titleScale, 0);
const std::string indexLabel = std::to_string(selected + 1) + "/" + std::to_string(entries.size());
const int topRightX = framebuffer.width() - font16x8::measureText(indexLabel, 1, 0) - 16;
font16x8::drawText(framebuffer, topRightX, 20, indexLabel, 1, true, 0);
drawPagerDots();
drawCenteredText(framebuffer, framebuffer.height() - 48, "A START APP", 1, 1);
drawCenteredText(framebuffer, framebuffer.height() - 28, "L/R CHOOSE", 1, 1);
}
framebuffer.sendFrame();
}
void cancelInactivityTimer() {
if (inactivityTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(inactivityTimer);
inactivityTimer = cardboy::sdk::kInvalidAppTimer;
}
}
void resetInactivityTimer() {
cancelInactivityTimer();
inactivityTimer = context.scheduleTimer(kIdleTimeoutMs);
}
};
class MenuAppFactory final : public cardboy::sdk::IAppFactory {
public:
const char* name() const override { return kMenuAppName; }
std::unique_ptr<cardboy::sdk::IApp> create(cardboy::sdk::AppContext& context) override {
return std::make_unique<MenuApp>(context);
}
};
} // namespace
std::unique_ptr<cardboy::sdk::IAppFactory> createMenuAppFactory() { return std::make_unique<MenuAppFactory>(); }
} // namespace apps

View File

@@ -0,0 +1,9 @@
target_sources(cardboy_apps
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src/settings_app.cpp
)
target_include_directories(cardboy_apps
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)

View File

@@ -0,0 +1,13 @@
#pragma once
#include "cardboy/sdk/app_framework.hpp"
#include <memory>
namespace apps {
inline constexpr char kSettingsAppName[] = "Settings";
std::unique_ptr<cardboy::sdk::IAppFactory> createSettingsAppFactory();
} // namespace apps

View File

@@ -0,0 +1,198 @@
#include "cardboy/apps/settings_app.hpp"
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/gfx/font16x8.hpp"
#include "cardboy/sdk/app_framework.hpp"
#include "cardboy/sdk/persistent_settings.hpp"
#include <array>
#include <cstddef>
#include <memory>
#include <string>
#include <string_view>
namespace apps {
namespace {
using cardboy::sdk::AppContext;
using Framebuffer = typename AppContext::Framebuffer;
enum class SettingOption {
Sound,
AutoLightSleep,
};
constexpr std::array<SettingOption, 2> kOptions = {
SettingOption::Sound,
SettingOption::AutoLightSleep,
};
class SettingsApp final : public cardboy::sdk::IApp {
public:
explicit SettingsApp(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer) {}
void onStart() override {
loadSettings();
dirty = true;
renderIfNeeded();
}
void handleEvent(const cardboy::sdk::AppEvent& event) override {
if (event.type != cardboy::sdk::AppEventType::Button)
return;
const auto& current = event.button.current;
const auto& previous = event.button.previous;
const bool previousAvailable = buzzerAvailable;
syncBuzzerState();
if (previousAvailable != buzzerAvailable)
dirty = true;
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;
}
const bool togglePressed = (current.a && !previous.a) || (current.start && !previous.start) ||
(current.select && !previous.select);
if (togglePressed)
handleToggle();
if (moved)
dirty = true;
renderIfNeeded();
}
private:
AppContext& context;
Framebuffer& framebuffer;
bool buzzerAvailable = false;
cardboy::sdk::PersistentSettings settings{};
std::size_t selectedIndex = 0;
bool dirty = false;
void loadSettings() {
settings = cardboy::sdk::loadPersistentSettings(context.getServices());
syncBuzzerState();
}
void syncBuzzerState() {
auto* buzzer = context.buzzer();
buzzerAvailable = (buzzer != nullptr);
if (!buzzer)
return;
if (buzzer->isMuted() != settings.mute)
buzzer->setMuted(settings.mute);
}
void moveSelection(int delta) {
const int count = static_cast<int>(kOptions.size());
if (count == 0)
return;
const int current = static_cast<int>(selectedIndex);
int next = (current + delta) % count;
if (next < 0)
next += count;
selectedIndex = static_cast<std::size_t>(next);
}
void handleToggle() {
switch (kOptions[selectedIndex]) {
case SettingOption::Sound:
toggleSound();
break;
case SettingOption::AutoLightSleep:
toggleAutoLightSleep();
break;
}
}
void toggleSound() {
if (!buzzerAvailable)
return;
settings.mute = !settings.mute;
cardboy::sdk::savePersistentSettings(context.getServices(), settings);
syncBuzzerState();
if (!settings.mute) {
if (auto* buzzer = context.buzzer())
buzzer->beepMove();
}
dirty = true;
}
void toggleAutoLightSleep() {
settings.autoLightSleep = !settings.autoLightSleep;
cardboy::sdk::savePersistentSettings(context.getServices(), settings);
dirty = true;
}
static void drawCenteredText(Framebuffer& fb, int y, std::string_view text, int scale, int letterSpacing = 1) {
const int width = font16x8::measureText(text, scale, letterSpacing);
const int x = (fb.width() - width) / 2;
font16x8::drawText(fb, x, y, text, scale, true, letterSpacing);
}
void drawOptionRow(int row, std::string_view label, std::string_view value, bool selected) {
std::string prefix = selected ? "> " : " ";
std::string line = prefix;
line.append(label);
line.append(": ");
line.append(value);
const int x = 24;
const int y = 56 + row * 24;
font16x8::drawText(framebuffer, x, y, line, 1, true, 1);
}
void renderIfNeeded() {
if (!dirty)
return;
dirty = false;
framebuffer.frameReady();
framebuffer.clear(false);
drawCenteredText(framebuffer, 24, "SETTINGS", 1, 1);
const std::string soundValue = buzzerAvailable ? (settings.mute ? "OFF" : "ON") : "N/A";
drawOptionRow(0, "SOUND", soundValue, selectedIndex == 0);
const std::string lightSleepValue = settings.autoLightSleep ? "ON" : "OFF";
drawOptionRow(1, "AUTO LIGHT SLEEP", lightSleepValue, selectedIndex == 1);
if (!buzzerAvailable)
drawCenteredText(framebuffer, 120, "SOUND CONTROL UNAVAILABLE", 1, 1);
drawCenteredText(framebuffer, framebuffer.height() - 54, "UP/DOWN MOVE", 1, 1);
drawCenteredText(framebuffer, framebuffer.height() - 36, "A/START/SELECT TOGGLE", 1, 1);
drawCenteredText(framebuffer, framebuffer.height() - 18, "B BACK | LIGHT SLEEP AFTER RESET", 1, 1);
framebuffer.sendFrame();
}
};
class SettingsAppFactory final : public cardboy::sdk::IAppFactory {
public:
const char* name() const override { return kSettingsAppName; }
std::unique_ptr<cardboy::sdk::IApp> create(cardboy::sdk::AppContext& context) override {
return std::make_unique<SettingsApp>(context);
}
};
} // namespace
std::unique_ptr<cardboy::sdk::IAppFactory> createSettingsAppFactory() { return std::make_unique<SettingsAppFactory>(); }
} // namespace apps

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

View File

@@ -0,0 +1,9 @@
target_sources(cardboy_apps
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src/tetris_app.cpp
)
target_include_directories(cardboy_apps
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)

View File

@@ -0,0 +1,12 @@
#pragma once
#include "cardboy/sdk/app_framework.hpp"
#include <memory>
namespace apps {
std::unique_ptr<cardboy::sdk::IAppFactory> createTetrisAppFactory();
} // namespace apps

View File

@@ -0,0 +1,657 @@
#include "cardboy/apps/tetris_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 <array>
#include <cstdint>
#include <cstdio>
#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 kTetrisAppName[] = "Tetris";
constexpr int kBoardWidth = 10;
constexpr int kBoardHeight = 20;
constexpr int kCellSize = 10;
constexpr std::array<int, 5> kLineScores = {0, 40, 100, 300, 1200};
struct BlockOffset {
int x = 0;
int y = 0;
};
struct Tetromino {
std::array<std::array<BlockOffset, 4>, 4> rotations{};
};
constexpr std::array<BlockOffset, 4> makeOffsets(std::initializer_list<BlockOffset> blocks) {
std::array<BlockOffset, 4> out{};
std::size_t idx = 0;
for (const auto& b: blocks) {
out[idx++] = b;
}
return out;
}
constexpr std::array<BlockOffset, 4> rotate(const std::array<BlockOffset, 4>& src) {
std::array<BlockOffset, 4> out{};
for (std::size_t i = 0; i < src.size(); ++i) {
out[i].x = -src[i].y;
out[i].y = src[i].x;
}
return out;
}
constexpr Tetromino makeTetromino(std::initializer_list<BlockOffset> baseBlocks) {
Tetromino tet{};
tet.rotations[0] = makeOffsets(baseBlocks);
for (int r = 1; r < 4; ++r)
tet.rotations[r] = rotate(tet.rotations[r - 1]);
return tet;
}
constexpr std::array<Tetromino, 7> kPieces = {{
makeTetromino({{-1, 0}, {0, 0}, {1, 0}, {2, 0}}), // I
makeTetromino({{-1, 0}, {0, 0}, {1, 0}, {1, 1}}), // J
makeTetromino({{-1, 1}, {-1, 0}, {0, 0}, {1, 0}}), // L
makeTetromino({{0, 0}, {1, 0}, {0, 1}, {1, 1}}), // O
makeTetromino({{-1, 0}, {0, 0}, {0, 1}, {1, 1}}), // S
makeTetromino({{-1, 0}, {0, 0}, {1, 0}, {0, 1}}), // T
makeTetromino({{-1, 1}, {0, 1}, {0, 0}, {1, 0}}), // Z
}};
class RandomBag {
public:
RandomBag() { refill(); }
void seed(std::uint32_t value) { rng.seed(value); }
int next() {
if (bag.empty())
refill();
int val = bag.back();
bag.pop_back();
return val;
}
private:
std::vector<int> bag;
std::mt19937 rng{std::random_device{}()};
void refill() {
bag.clear();
bag.reserve(7);
for (int i = 0; i < 7; ++i)
bag.push_back(i);
std::shuffle(bag.begin(), bag.end(), rng);
}
};
struct ActivePiece {
int type = 0;
int rotation = 0;
int x = 0;
int y = 0;
};
struct GameState {
std::array<int, kBoardWidth * kBoardHeight> board{};
ActivePiece current{};
int nextPiece = 0;
int level = 1;
int linesCleared = 0;
int score = 0;
int highScore = 0;
bool paused = false;
bool gameOver = false;
};
[[nodiscard]] std::uint32_t randomSeed(AppContext& ctx) {
if (auto* rnd = ctx.random())
return rnd->nextUint32();
static std::random_device rd;
return rd();
}
class TetrisGame {
public:
explicit TetrisGame(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer) {
bag.seed(randomSeed(context));
loadHighScore();
reset();
}
void onStart() {
scheduleDropTimer();
dirty = true;
renderIfNeeded();
}
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;
}
renderIfNeeded();
}
private:
AppContext& context;
typename AppContext::Framebuffer& framebuffer;
GameState state;
RandomBag bag;
InputState lastInput{};
bool dirty = false;
AppTimerHandle dropTimer = cardboy::sdk::kInvalidAppTimer;
AppTimerHandle softTimer = cardboy::sdk::kInvalidAppTimer;
void reset() {
cancelTimers();
int oldHigh = state.highScore;
state = {};
state.highScore = oldHigh;
state.current.type = bag.next();
state.nextPiece = bag.next();
state.current.x = kBoardWidth / 2;
state.current.y = 0;
state.level = 1;
state.gameOver = false;
state.paused = false;
dirty = true;
scheduleDropTimer();
}
void handleButtons(const AppButtonEvent& evt) {
const auto& cur = evt.current;
const auto& prev = evt.previous;
lastInput = cur;
if (cur.b && !prev.b) {
context.requestAppSwitchByName(kMenuAppName);
return;
}
if (cur.start && !prev.start) {
if (state.gameOver) {
reset();
} else {
state.paused = !state.paused;
}
dirty = true;
}
if (state.paused || state.gameOver)
return;
if (cur.left && !prev.left)
tryMove(-1, 0);
if (cur.right && !prev.right)
tryMove(1, 0);
if (cur.a && !prev.a)
rotate(1);
if (cur.select && !prev.select)
hardDrop();
if (cur.down && !prev.down) {
softDropStep();
scheduleSoftDropTimer();
} else if (!cur.down && prev.down) {
cancelSoftDropTimer();
}
}
void handleTimer(AppTimerHandle handle) {
if (handle == dropTimer) {
if (!state.paused && !state.gameOver)
gravityStep();
} else if (handle == softTimer) {
if (lastInput.down && !state.paused && !state.gameOver)
softDropStep();
else
cancelSoftDropTimer();
}
}
void cancelTimers() {
if (dropTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(dropTimer);
dropTimer = cardboy::sdk::kInvalidAppTimer;
}
cancelSoftDropTimer();
}
void cancelSoftDropTimer() {
if (softTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(softTimer);
softTimer = cardboy::sdk::kInvalidAppTimer;
}
}
void scheduleDropTimer() {
cancelDropTimer();
const std::uint32_t interval = dropIntervalMs();
dropTimer = context.scheduleRepeatingTimer(interval);
}
void cancelDropTimer() {
if (dropTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(dropTimer);
dropTimer = cardboy::sdk::kInvalidAppTimer;
}
}
void scheduleSoftDropTimer() {
cancelSoftDropTimer();
softTimer = context.scheduleRepeatingTimer(60);
}
[[nodiscard]] std::uint32_t dropIntervalMs() const {
const int base = 700;
const int step = 50;
int interval = base - (state.level - 1) * step;
if (interval < 120)
interval = 120;
return static_cast<std::uint32_t>(interval);
}
[[nodiscard]] const Tetromino& currentPiece() const { return kPieces[state.current.type]; }
bool canPlace(int nx, int ny, int rot) const {
const auto& piece = kPieces[state.current.type];
rot = ((rot % 4) + 4) % 4;
for (const auto& block: piece.rotations[rot]) {
int gx = nx + block.x;
int gy = ny + block.y;
if (gx < 0 || gx >= kBoardWidth)
return false;
if (gy >= kBoardHeight)
return false;
if (gy >= 0 && cellAt(gx, gy) != 0)
return false;
}
return true;
}
[[nodiscard]] int cellAt(int x, int y) const { return state.board[y * kBoardWidth + x]; }
void setCell(int x, int y, int value) { state.board[y * kBoardWidth + x] = value; }
void tryMove(int dx, int dy) {
int nx = state.current.x + dx;
int ny = state.current.y + dy;
if (canPlace(nx, ny, state.current.rotation)) {
state.current.x = nx;
state.current.y = ny;
dirty = true;
if (dx != 0) {
if (auto* buzzer = context.buzzer())
buzzer->beepMove();
}
}
}
void rotate(int direction) {
int nextRot = state.current.rotation + (direction >= 0 ? 1 : -1);
nextRot = ((nextRot % 4) + 4) % 4;
if (canPlace(state.current.x, state.current.y, nextRot)) {
state.current.rotation = nextRot;
dirty = true;
if (auto* buzzer = context.buzzer())
buzzer->beepRotate();
}
}
void gravityStep() {
if (!canPlace(state.current.x, state.current.y + 1, state.current.rotation)) {
lockPiece();
} else {
state.current.y++;
dirty = true;
}
}
void softDropStep() {
if (canPlace(state.current.x, state.current.y + 1, state.current.rotation)) {
state.current.y++;
state.score += 1;
updateHighScore();
dirty = true;
if (auto* buzzer = context.buzzer())
buzzer->beepMove();
} else {
lockPiece();
}
}
void hardDrop() {
int distance = 0;
while (canPlace(state.current.x, state.current.y + distance + 1, state.current.rotation))
++distance;
if (distance > 0) {
state.current.y += distance;
state.score += distance * 2;
updateHighScore();
dirty = true;
if (auto* buzzer = context.buzzer())
buzzer->beepMove();
}
lockPiece();
}
void lockPiece() {
for (const auto& block: currentPiece().rotations[state.current.rotation]) {
int gx = state.current.x + block.x;
int gy = state.current.y + block.y;
if (gy >= 0 && gy < kBoardHeight && gx >= 0 && gx < kBoardWidth)
setCell(gx, gy, state.current.type + 1);
if (gy < 0)
state.gameOver = true;
}
handleLineClear();
spawnNext();
dirty = true;
if (state.gameOver) {
cancelSoftDropTimer();
cancelDropTimer();
if (auto* buzzer = context.buzzer())
buzzer->beepGameOver();
} else {
if (auto* buzzer = context.buzzer())
buzzer->beepLock();
}
}
void handleLineClear() {
int cleared = 0;
for (int y = kBoardHeight - 1; y >= 0; --y) {
bool full = true;
for (int x = 0; x < kBoardWidth; ++x) {
if (cellAt(x, y) == 0) {
full = false;
break;
}
}
if (full) {
++cleared;
for (int pull = y; pull > 0; --pull)
for (int x = 0; x < kBoardWidth; ++x)
setCell(x, pull, cellAt(x, pull - 1));
for (int x = 0; x < kBoardWidth; ++x)
setCell(x, 0, 0);
++y; // re-check same row after collapse
}
}
if (cleared > 0) {
state.linesCleared += cleared;
if (cleared < static_cast<int>(kLineScores.size()))
state.score += kLineScores[cleared] * state.level;
else
state.score += kLineScores.back() * state.level;
int newLevel = 1 + state.linesCleared / 10;
if (newLevel != state.level) {
state.level = newLevel;
scheduleDropTimer();
if (auto* buzzer = context.buzzer())
buzzer->beepLevelUp(state.level);
}
updateHighScore();
if (auto* buzzer = context.buzzer())
buzzer->beepLines(cleared);
}
}
void spawnNext() {
state.current.type = state.nextPiece;
state.current.rotation = 0;
state.current.x = kBoardWidth / 2;
state.current.y = 0;
state.nextPiece = bag.next();
if (!canPlace(state.current.x, state.current.y, state.current.rotation))
state.gameOver = true;
}
void updateHighScore() {
if (state.score > state.highScore) {
state.highScore = state.score;
if (auto* storage = context.storage())
storage->writeUint32("tetris", "best", static_cast<std::uint32_t>(state.highScore));
}
}
void loadHighScore() {
if (auto* storage = context.storage()) {
std::uint32_t stored = 0;
if (storage->readUint32("tetris", "best", stored))
state.highScore = static_cast<int>(stored);
}
}
void renderIfNeeded() {
if (!dirty)
return;
dirty = false;
framebuffer.frameReady();
drawBoard();
drawActivePiece();
drawNextPreview();
drawHUD();
framebuffer.sendFrame();
}
void drawBoard() {
const int originX = (cardboy::sdk::kDisplayWidth - kBoardWidth * kCellSize) / 2;
const int originY = (cardboy::sdk::kDisplayHeight - kBoardHeight * kCellSize) / 2;
for (int y = 0; y < kBoardHeight; ++y) {
for (int x = 0; x < kBoardWidth; ++x) {
if (int value = cellAt(x, y); value != 0)
drawCell(originX, originY, x, y, value - 1, true);
}
}
drawBoardFrame(originX, originY);
}
void drawActivePiece() {
if (state.gameOver)
return;
const int originX = (cardboy::sdk::kDisplayWidth - kBoardWidth * kCellSize) / 2;
const int originY = (cardboy::sdk::kDisplayHeight - kBoardHeight * kCellSize) / 2;
for (const auto& block: currentPiece().rotations[state.current.rotation]) {
int gx = state.current.x + block.x;
int gy = state.current.y + block.y;
if (gy < 0)
continue;
drawCell(originX, originY, gx, gy, state.current.type, false);
}
}
static bool patternPixel(int pieceIndex, int dx, int dy) {
const int idx = std::clamp(pieceIndex, 0, static_cast<int>(kPieces.size()) - 1);
const int mx = dx & 0x3;
const int my = dy & 0x3;
switch (idx) {
case 0: // I - vertical stripes
return mx < 2;
case 1: // J - horizontal stripes
return my < 2;
case 2: { // L - forward diagonal
const int sum = (mx + my) & 0x3;
return sum < 2;
}
case 3: // O - diamond centerpiece
return (mx == 1 && my == 1) || (mx == 2 && my == 1) || (mx == 1 && my == 2) || (mx == 2 && my == 2);
case 4: // S - checkerboard
return ((mx ^ my) & 0x1) == 0;
case 5: { // T - cross
return (mx == 0) || (my == 0);
}
case 6: { // Z - backward diagonal
int diff = mx - my;
if (diff < 0)
diff += 4;
diff &= 0x3;
return diff < 2;
}
}
return true;
}
void drawPatternBlock(int x0, int y0, int size, int pieceIndex, bool locked) {
const int idx = std::clamp(pieceIndex, 0, static_cast<int>(kPieces.size()) - 1);
for (int dy = 0; dy < size; ++dy) {
for (int dx = 0; dx < size; ++dx) {
const bool border = dx == 0 || dx == size - 1 || dy == 0 || dy == size - 1;
bool fill = patternPixel(idx, dx, dy);
if (!locked && !border)
fill = fill && (((dx + dy) & 0x1) == 0);
const bool on = border || fill;
framebuffer.drawPixel(x0 + dx, y0 + dy, on);
}
}
}
void drawCell(int originX, int originY, int cx, int cy, int pieceIndex, bool locked) {
const int x0 = originX + cx * kCellSize;
const int y0 = originY + cy * kCellSize;
drawPatternBlock(x0, y0, kCellSize, pieceIndex, locked);
}
void drawBoardFrame(int originX, int originY) {
const int widthPixels = kBoardWidth * kCellSize;
const int heightPixels = kBoardHeight * kCellSize;
const int x0 = originX;
const int y0 = originY;
const int x1 = originX + widthPixels - 1;
const int y1 = originY + heightPixels - 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 drawNextPreview() {
const int blockSize = kCellSize;
const int boxSize = blockSize * 4;
const int originX = (cardboy::sdk::kDisplayWidth + kBoardWidth * kCellSize) / 2 + 24;
const int originY = (cardboy::sdk::kDisplayHeight - boxSize) / 2;
for (int dy = 0; dy < boxSize; ++dy)
for (int dx = 0; dx < boxSize; ++dx)
framebuffer.drawPixel(originX + dx, originY + dy,
(dy == 0 || dy == boxSize - 1 || dx == 0 || dx == boxSize - 1));
const auto& piece = kPieces[state.nextPiece];
for (const auto& block: piece.rotations[0]) {
const int px = originX + (block.x + 1) * blockSize + 1;
const int py = originY + (block.y + 1) * blockSize + 1;
drawPatternBlock(px, py, blockSize - 2, state.nextPiece, true);
}
}
void drawLabel(int x, int y, std::string_view text, int scale = 1) {
font16x8::drawText(framebuffer, x, y, text, scale, true, 1);
}
void drawHUD() {
const int margin = 16;
drawLabel(margin, margin, "SCORE", 1);
drawLabel(margin, margin + 16, std::to_string(state.score), 1);
drawLabel(margin, margin + 40, "BEST", 1);
drawLabel(margin, margin + 56, std::to_string(state.highScore), 1);
drawLabel(margin, margin + 80, "LEVEL", 1);
drawLabel(margin, margin + 96, std::to_string(state.level), 1);
if (auto* battery = context.battery(); battery && battery->hasData()) {
char line[32];
std::snprintf(line, sizeof(line), "BAT %.2fV", battery->voltage());
drawLabel(margin, margin + 120, line, 1);
}
drawLabel(margin, cardboy::sdk::kDisplayHeight - 48, "A ROTATE", 1);
drawLabel(margin, cardboy::sdk::kDisplayHeight - 32, "DOWN DROP", 1);
drawLabel(margin, cardboy::sdk::kDisplayHeight - 16, "B MENU", 1);
if (state.paused)
drawCenteredBanner("PAUSED");
else if (state.gameOver)
drawCenteredBanner("GAME OVER");
}
void drawCenteredBanner(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 = (cardboy::sdk::kDisplayHeight - 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 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); }
private:
TetrisGame game;
};
class TetrisFactory final : public cardboy::sdk::IAppFactory {
public:
const char* name() const override { return kTetrisAppName; }
std::unique_ptr<cardboy::sdk::IApp> create(AppContext& context) override {
return std::make_unique<TetrisApp>(context);
}
};
} // namespace
std::unique_ptr<cardboy::sdk::IAppFactory> createTetrisAppFactory() { return std::make_unique<TetrisFactory>(); }
} // namespace apps

View File

@@ -0,0 +1,24 @@
add_library(cardboy_backend_interface INTERFACE)
target_link_libraries(cardboy_backend_interface INTERFACE cardboy_utils)
set_target_properties(cardboy_backend_interface PROPERTIES
EXPORT_NAME backend_interface
)
target_include_directories(cardboy_backend_interface
INTERFACE
${CMAKE_CURRENT_SOURCE_DIR}/include
)
target_sources(cardboy_backend_interface
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
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/services.hpp
)

View File

@@ -0,0 +1,14 @@
#pragma once
#include <concepts>
namespace cardboy::backend {
template<typename Backend>
concept BackendInterface = requires {
typename Backend::Framebuffer;
typename Backend::Input;
typename Backend::Clock;
};
} // namespace cardboy::backend

View File

@@ -0,0 +1,10 @@
#pragma once
#include <cardboy/sdk/platform.hpp>
#include <cardboy/backend/backend_impl.hpp>
#include <cardboy/backend/backend_interface.hpp>
namespace cardboy::backend {
static_assert(BackendInterface<ActiveBackend>, "ActiveBackend must provide Framebuffer, Input, Clock types");
} // namespace cardboy::backend

View File

@@ -0,0 +1,8 @@
#pragma once
namespace cardboy::sdk {
inline constexpr int kDisplayWidth = 400;
inline constexpr int kDisplayHeight = 240;
} // namespace cardboy::sdk

View File

@@ -0,0 +1,42 @@
#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

@@ -0,0 +1,18 @@
#pragma once
namespace cardboy::sdk {
class FramebufferHooks {
public:
using PreSendHook = void (*)(void* framebuffer, void* userData);
static void setPreSendHook(PreSendHook hook, void* userData);
static void clearPreSendHook();
static void invokePreSend(void* framebuffer);
private:
static PreSendHook hook_;
static void* userData_;
};
} // namespace cardboy::sdk

Some files were not shown because too many files have changed in this diff Show More