mirror of
https://github.com/usatiuk/cardboy.git
synced 2025-10-28 15:17:48 +01:00
Compare commits
96 Commits
v2-board
...
678158c302
| Author | SHA1 | Date | |
|---|---|---|---|
| 678158c302 | |||
| 12e8a0e098 | |||
| fc633d7c90 | |||
| e8ae1cbec4 | |||
| b72ea4f417 | |||
| bf0ffe8632 | |||
| 96bfaaf64b | |||
| cf5a848741 | |||
| 7c492627f0 | |||
| be2629a008 | |||
| 016629eb82 | |||
| de1ac0e7a2 | |||
| 3ab2a7bf26 | |||
| b4f11851d7 | |||
| eeedc629d7 | |||
| 8bb48daf6c | |||
| 7c741c42dc | |||
| ecbcce12ea | |||
| f6c800fc63 | |||
| 5e63875d35 | |||
| cc805abe80 | |||
| 1bc5b75dba | |||
| e37f8e3dc8 | |||
| df7c4ff3b9 | |||
| 07186b4b73 | |||
| 6a1f7d48ce | |||
| 031ff1952b | |||
| df55b8f2e1 | |||
| ed1cee82d2 | |||
| 7474f65aaa | |||
| 088c6e47bd | |||
| d91b7540fc | |||
| d5506b9455 | |||
| db88e16aaa | |||
| a6713859b2 | |||
| aaac0514c0 | |||
| 1b6e9a0f78 | |||
| c64f03a09f | |||
| 5ab8662332 | |||
| 6d8834d9b2 | |||
| 83ba775971 | |||
| df57e55171 | |||
| a3b837f329 | |||
| fc9e85aea0 | |||
| b55feb68f8 | |||
| f04b026d46 | |||
| e18278e130 | |||
| 9a392d6aec | |||
| 961453e28a | |||
| a4c2719077 | |||
| f721ebcb4c | |||
| e9a05259c5 | |||
| 23400d817b | |||
| fa2715a60a | |||
| 899bfeef41 | |||
| 535b0078e5 | |||
| 5b75ff28e0 | |||
| e9e371739b | |||
| 28411535bb | |||
| 54d5f85538 | |||
| c3295b9b01 | |||
| 7fc48e5e93 | |||
| afff3d0e02 | |||
| 0660c40ec4 | |||
| 8520ef556b | |||
| 4e78618556 | |||
| 49455d1b36 | |||
| ddf5a47c33 | |||
| 13cdcb01dd | |||
| 4c0fd5243f | |||
| 429d704c8c | |||
| 9a9e25e124 | |||
| ecf6d09651 | |||
| 413e021e49 | |||
| c9f0f59630 | |||
| 9420887392 | |||
| 7df84f1e81 | |||
| 4861d26d8a | |||
| e389a776be | |||
| 8b8d9d3a55 | |||
| 126d377836 | |||
| 3f8d90c18a | |||
| c439aecd03 | |||
| cd72c2d7df | |||
| 589c598b01 | |||
| 95a946e47f | |||
| 48d2089b69 | |||
| 3e9b7b4326 | |||
| e1004ff196 | |||
| 24df0fc825 | |||
| ab32731f4d | |||
| 474a0b2a43 | |||
| 35219c353c | |||
| 8180abed4c | |||
| 12d634ecc9 | |||
| 6a8f74384e |
3
Firmware/.gitignore
vendored
3
Firmware/.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
build
|
||||
cmake-build*
|
||||
.idea
|
||||
.cache
|
||||
managed_components
|
||||
*.gb
|
||||
23
Firmware/.vscode/c_cpp_properties.json
vendored
Normal file
23
Firmware/.vscode/c_cpp_properties.json
vendored
Normal 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
15
Firmware/.vscode/launch.json
vendored
Normal 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
85
Firmware/.vscode/settings.json
vendored
Normal 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
4
Firmware/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
||||
To build:
|
||||
(in zsh)
|
||||
. "$HOME/esp/esp-idf/export.sh"
|
||||
idf.py build
|
||||
@@ -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
62
Firmware/cardboy-companion/.gitignore
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
# Xcode
|
||||
#
|
||||
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## Obj-C/Swift specific
|
||||
*.hmap
|
||||
|
||||
## App packaging
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
## Playgrounds
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
# Swift Package Manager
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
|
||||
# Packages/
|
||||
# Package.pins
|
||||
# Package.resolved
|
||||
# *.xcodeproj
|
||||
#
|
||||
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
|
||||
# hence it is not needed unless you have added a package configuration file to your project
|
||||
# .swiftpm
|
||||
|
||||
.build/
|
||||
|
||||
# CocoaPods
|
||||
#
|
||||
# We recommend against adding the Pods directory to your .gitignore. However
|
||||
# you should judge for yourself, the pros and cons are mentioned at:
|
||||
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
|
||||
#
|
||||
# Pods/
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from the Xcode workspace
|
||||
# *.xcworkspace
|
||||
|
||||
# Carthage
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Carthage dependencies.
|
||||
# Carthage/Checkouts
|
||||
|
||||
Carthage/Build/
|
||||
|
||||
# fastlane
|
||||
#
|
||||
# It is recommended to not store the screenshots in the git repo.
|
||||
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
|
||||
# For more information about the recommended setup visit:
|
||||
# https://docs.fastlane.tools/best-practices/source-control/#source-control
|
||||
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
89
Firmware/cardboy-companion/README.md
Normal file
89
Firmware/cardboy-companion/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Cardboy Time Sync Companion
|
||||
|
||||
This SwiftUI app connects to the Cardboy device over Bluetooth Low Energy and updates its wall clock using the custom time sync service exposed by the firmware. The sources live inside the existing `cardboy-companion/cardboy-companion.xcodeproj` so you can open and run them directly in Xcode.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Xcode 15 or newer
|
||||
- iOS 16 or newer deployment target (can be lowered to 15 with minor API tweaks)
|
||||
- A Cardboy running firmware that includes the BLE time sync service
|
||||
|
||||
## How it works
|
||||
|
||||
1. The app scans for peripherals exposing service UUID `00000001-CA7B-4EFD-B5A6-10C3F4D3F230`.
|
||||
2. Once connected it discovers characteristic `00000002-CA7B-4EFD-B5A6-10C3F4D3F231`.
|
||||
3. Tapping **Sync Now** writes a 12‑byte payload containing:
|
||||
- 8 bytes Unix epoch seconds (little endian)
|
||||
- 2 bytes time zone offset in minutes from UTC (little endian)
|
||||
- 1 byte DST flag (`1` if daylight saving is active)
|
||||
- 1 reserved byte (`0`)
|
||||
4. The firmware applies the timestamp with `settimeofday()` and updates the TZ environment variable so the clock app renders local time.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Open `cardboy-companion/cardboy-companion.xcodeproj` in Xcode.
|
||||
2. Ensure the `CoreBluetooth` capability is enabled for the `cardboy-companion` target and keep the *Uses Bluetooth LE accessories* background mode on (preconfigured in this project).
|
||||
3. Build & run on a real device (BLE is not available in the simulator).
|
||||
4. Allow Bluetooth permissions when prompted. The app keeps scanning in the background, so the Cardboy can request a sync even while the companion is not foregrounded. Tap **Sync Now** any time you want to trigger a manual refresh.
|
||||
5. Switch to the **Files** tab to browse the LittleFS volume on the Cardboy: you can upload ROMs from the Files picker, create/remove folders, rename entries, delete files, and download items back to the phone for sharing.
|
||||
|
||||
## BLE File Service Protocol
|
||||
|
||||
The ESP firmware exposes a custom GATT service (UUID `00000010-CA7B-4EFD-B5A6-10C3F4D3F230`) with two characteristics:
|
||||
|
||||
| Characteristic | UUID | Properties | Direction | Description |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| File Command | `00000011-CA7B-4EFD-B5A6-10C3F4D3F231` | Write / Write Without Response | iOS → ESP | Sends file management requests |
|
||||
| File Response | `00000012-CA7B-4EFD-B5A6-10C3F4D3F232` | Notify | ESP → iOS | Streams command results (responses or data) |
|
||||
|
||||
All payloads share the same framing. Commands written to the File Command characteristic use:
|
||||
|
||||
```
|
||||
Offset | Size | Description
|
||||
-------+------+------------
|
||||
0 | 1 | Opcode (see table below)
|
||||
1 | 1 | Reserved (set to 0)
|
||||
2 | 2 | Little-endian payload length in bytes (N)
|
||||
4 | N | Command payload
|
||||
```
|
||||
|
||||
Notifications from the File Response characteristic use:
|
||||
|
||||
```
|
||||
Offset | Size | Description
|
||||
-------+------+------------
|
||||
0 | 1 | Opcode (echoed from command)
|
||||
1 | 1 | Status byte (bit 7 = completion flag; lower 7 bits = error code)
|
||||
2 | 2 | Little-endian payload length (N)
|
||||
4 | N | Response payload (command-specific)
|
||||
```
|
||||
|
||||
Status byte semantics:
|
||||
- Bit 7 (0x80) set → final packet for the current command (no further fragments).
|
||||
- Lower 7 bits = error code (`0` = success, otherwise `errno`-style code echoed back).
|
||||
- On error the response payload may contain a UTF-8 message.
|
||||
|
||||
### Opcodes and Payloads
|
||||
|
||||
| Opcode | Name | Command Payload | Response Payload |
|
||||
| --- | --- | --- | --- |
|
||||
| `0x01` | List Directory | `uint16 path_len` + UTF-8 path | One or more fragments, each entry encoded as:<br> - `uint8 type` (0=file, 1=dir)<br> - `uint8 reserved`<br> - `uint16 name_len`<br> - `uint32 size` (0 for dirs)<br> - `name_len` bytes UTF-8 name<br> Final notification has completion bit set. |
|
||||
| `0x02` | Upload Begin | `uint16 path_len` + UTF-8 path + `uint32 file_size` | Empty payload on success. Starts upload session (expects `UploadChunk` packets). |
|
||||
| `0x03` | Upload Chunk | Raw file bytes | Empty payload ack for each chunk. |
|
||||
| `0x04` | Upload End | No payload | Empty payload confirming completion. |
|
||||
| `0x05` | Download Request | `uint16 path_len` + UTF-8 path | First notification: 4-byte little-endian total file size; subsequent notifications stream raw file data fragments. Completion bit marks the final chunk. |
|
||||
| `0x06` | Delete File | `uint16 path_len` + UTF-8 path | Empty payload on success. |
|
||||
| `0x07` | Create Directory | `uint16 path_len` + UTF-8 path | Empty payload on success. |
|
||||
| `0x08` | Delete Directory | `uint16 path_len` + UTF-8 path | Empty payload on success. |
|
||||
| `0x09` | Rename Path | `uint16 src_len` + UTF-8 source path + `uint16 dst_len` + UTF-8 destination path | Empty payload on success. |
|
||||
|
||||
### Notes
|
||||
|
||||
- Paths are absolute within the LittleFS volume; the firmware normalizes them and rejects entries containing `..`.
|
||||
- Large responses (directory lists, downloads) may arrive in multiple notifications; the iOS client aggregates fragments until it sees the completion flag.
|
||||
- Uploads are initiated with `Upload Begin` (including total size), followed by one or more `Upload Chunk` writes, and `Upload End` when done.
|
||||
- Errors from the firmware propagate via the status byte; when `status & 0x7F != 0`, the notification payload typically includes a UTF-8 error message (e.g., `"stat failed"`).
|
||||
|
||||
This protocol mirrors the implementation in `components/backend-esp/src/time_sync_service.cpp` and the Swift client in `TimeSyncManager.swift`. Update both sides if new commands are added.
|
||||
|
||||
Optionally bundle this code into your existing app—`TimeSyncManager` is self‑contained and can be reused.
|
||||
@@ -0,0 +1,358 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
ECAB9A832EA550D9004BA9DE /* cardboy-companion.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "cardboy-companion.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
ECAB9ABA2EA562CD004BA9DE /* Exceptions for "cardboy-companion" folder in "cardboy-companion" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = ECAB9A822EA550D9004BA9DE /* cardboy-companion */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
ECAB9A852EA550D9004BA9DE /* cardboy-companion */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
ECAB9ABA2EA562CD004BA9DE /* Exceptions for "cardboy-companion" folder in "cardboy-companion" target */,
|
||||
);
|
||||
path = "cardboy-companion";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
ECAB9A802EA550D9004BA9DE /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
ECAB9A7A2EA550D9004BA9DE = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ECAB9A852EA550D9004BA9DE /* cardboy-companion */,
|
||||
ECAB9A842EA550D9004BA9DE /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
ECAB9A842EA550D9004BA9DE /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ECAB9A832EA550D9004BA9DE /* cardboy-companion.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
ECAB9A822EA550D9004BA9DE /* cardboy-companion */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = ECAB9A8E2EA550DB004BA9DE /* Build configuration list for PBXNativeTarget "cardboy-companion" */;
|
||||
buildPhases = (
|
||||
ECAB9A7F2EA550D9004BA9DE /* Sources */,
|
||||
ECAB9A802EA550D9004BA9DE /* Frameworks */,
|
||||
ECAB9A812EA550D9004BA9DE /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
ECAB9A852EA550D9004BA9DE /* cardboy-companion */,
|
||||
);
|
||||
name = "cardboy-companion";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "cardboy-companion";
|
||||
productReference = ECAB9A832EA550D9004BA9DE /* cardboy-companion.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
ECAB9A7B2EA550D9004BA9DE /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2600;
|
||||
LastUpgradeCheck = 2600;
|
||||
TargetAttributes = {
|
||||
ECAB9A822EA550D9004BA9DE = {
|
||||
CreatedOnToolsVersion = 26.0.1;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = ECAB9A7E2EA550D9004BA9DE /* Build configuration list for PBXProject "cardboy-companion" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = ECAB9A7A2EA550D9004BA9DE;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = ECAB9A842EA550D9004BA9DE /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
ECAB9A822EA550D9004BA9DE /* cardboy-companion */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
ECAB9A812EA550D9004BA9DE /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
ECAB9A7F2EA550D9004BA9DE /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
ECAB9A8C2EA550DB004BA9DE /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
ECAB9A8D2EA550DB004BA9DE /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
ECAB9A8F2EA550DB004BA9DE /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = WX524QS7SH;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "cardboy-companion/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Cardboy;
|
||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UIBackgroundModes = "bluetooth-central";
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.usatiuk.cardboy-companion";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
ECAB9A902EA550DB004BA9DE /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = WX524QS7SH;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "cardboy-companion/Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Cardboy;
|
||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UIBackgroundModes = "bluetooth-central";
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.usatiuk.cardboy-companion";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
ECAB9A7E2EA550D9004BA9DE /* Build configuration list for PBXProject "cardboy-companion" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
ECAB9A8C2EA550DB004BA9DE /* Debug */,
|
||||
ECAB9A8D2EA550DB004BA9DE /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
ECAB9A8E2EA550DB004BA9DE /* Build configuration list for PBXNativeTarget "cardboy-companion" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
ECAB9A8F2EA550DB004BA9DE /* Debug */,
|
||||
ECAB9A902EA550DB004BA9DE /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = ECAB9A7B2EA550D9004BA9DE /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "cardboy-icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject private var manager: TimeSyncManager
|
||||
@State private var selectedTab = 0
|
||||
@State private var shareURL: URL?
|
||||
@State private var errorWrapper: ErrorWrapper?
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
TimeSyncTabView()
|
||||
.tabItem { Label("Clock", systemImage: "clock.arrow.circlepath") }
|
||||
.tag(0)
|
||||
|
||||
FileManagerTabView(shareURL: $shareURL, errorWrapper: $errorWrapper)
|
||||
.tabItem { Label("Files", systemImage: "folder") }
|
||||
.tag(1)
|
||||
}
|
||||
.sheet(item: $shareURL) { url in
|
||||
ShareSheet(items: [url])
|
||||
}
|
||||
.alert(item: $errorWrapper) { wrapper in
|
||||
Alert(title: Text("Error"), message: Text(wrapper.message), dismissButton: .default(Text("OK")))
|
||||
}
|
||||
.onReceive(manager.$fileErrorMessage.compactMap { $0 }) { message in
|
||||
errorWrapper = ErrorWrapper(message: message)
|
||||
manager.fileErrorMessage = nil
|
||||
}
|
||||
.onReceive(manager.$downloadedFileURL.compactMap { $0 }) { url in
|
||||
shareURL = url
|
||||
manager.downloadedFileURL = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ErrorWrapper: Identifiable {
|
||||
let id = UUID()
|
||||
let message: String
|
||||
}
|
||||
|
||||
private struct TimeSyncTabView: View {
|
||||
@EnvironmentObject private var manager: TimeSyncManager
|
||||
|
||||
private var formattedLastSync: String {
|
||||
guard let date = manager.lastSyncDate else { return "Never" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .medium
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Text("Cardboy Clock Sync")
|
||||
.font(.title.bold())
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text("State: \(manager.connectionState.rawValue)")
|
||||
.font(.headline)
|
||||
|
||||
Text(manager.statusMessage)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
Button(action: manager.sendCurrentTime) {
|
||||
Label("Sync Now", systemImage: "clock.arrow.2.circlepath")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(manager.connectionState != .ready)
|
||||
|
||||
Button(action: manager.forceRescan) {
|
||||
Label("Rescan", systemImage: "dot.radiowaves.left.and.right")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Button(action: manager.sendTestNotification) {
|
||||
Label("Send Test Notification", systemImage: "bell.badge.waveform")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Last Sync:")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(formattedLastSync)
|
||||
.font(.body.monospaced())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private struct FileManagerTabView: View {
|
||||
@EnvironmentObject private var manager: TimeSyncManager
|
||||
@Binding var shareURL: URL?
|
||||
@Binding var errorWrapper: ErrorWrapper?
|
||||
|
||||
@State private var navigationPath: [String] = []
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
NavigationStack(path: $navigationPath) {
|
||||
DirectoryView(
|
||||
path: "/",
|
||||
navigationPath: $navigationPath,
|
||||
shareURL: $shareURL,
|
||||
errorWrapper: $errorWrapper
|
||||
)
|
||||
.navigationDestination(for: String.self) { destination in
|
||||
DirectoryView(
|
||||
path: destination,
|
||||
navigationPath: $navigationPath,
|
||||
shareURL: $shareURL,
|
||||
errorWrapper: $errorWrapper
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let operation = manager.activeFileOperation {
|
||||
FileOperationHUD(operation: operation)
|
||||
}
|
||||
|
||||
if manager.connectionState != .ready {
|
||||
ConnectionOverlay(
|
||||
state: manager.connectionState,
|
||||
statusMessage: manager.statusMessage,
|
||||
retryAction: manager.forceRescan
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if manager.currentDirectory != "/" {
|
||||
manager.changeDirectory(to: "/")
|
||||
}
|
||||
navigationPath = stackForPath(manager.currentDirectory)
|
||||
}
|
||||
.onChange(of: manager.currentDirectory) { newValue in
|
||||
let desired = stackForPath(newValue)
|
||||
if desired != navigationPath {
|
||||
navigationPath = desired
|
||||
}
|
||||
}
|
||||
.onChange(of: navigationPath) { newValue in
|
||||
let target = newValue.last ?? "/"
|
||||
if target != manager.currentDirectory {
|
||||
manager.changeDirectory(to: target)
|
||||
}
|
||||
}
|
||||
.onChange(of: manager.connectionState) { newState in
|
||||
if newState == .ready, !manager.isFileBusy {
|
||||
manager.refreshDirectory()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DirectoryView: View {
|
||||
let path: String
|
||||
@Binding var navigationPath: [String]
|
||||
@Binding var shareURL: URL?
|
||||
@Binding var errorWrapper: ErrorWrapper?
|
||||
|
||||
@EnvironmentObject private var manager: TimeSyncManager
|
||||
|
||||
@State private var showingImporter = false
|
||||
@State private var showingNewFolderSheet = false
|
||||
@State private var showingRenameSheet = false
|
||||
@State private var newFolderName = ""
|
||||
@State private var renameText = ""
|
||||
@State private var renameTarget: TimeSyncManager.RemoteFileEntry?
|
||||
|
||||
private var pathSegments: [(name: String, fullPath: String)] {
|
||||
var segments: [(String, String)] = []
|
||||
var current = ""
|
||||
for component in path.split(separator: "/").map(String.init) {
|
||||
current += "/" + component
|
||||
segments.append((component, current))
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
private var displayTitle: String {
|
||||
pathSegments.last?.name ?? "Files"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Path:")
|
||||
.font(.headline)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 4) {
|
||||
Button(action: { navigationPath = [] }) {
|
||||
Text("/")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
ForEach(Array(pathSegments.enumerated()), id: \.element.fullPath) { index, segment in
|
||||
Button(action: {
|
||||
navigationPath = Array(pathSegments.prefix(index + 1).map(\.fullPath))
|
||||
}) {
|
||||
Text(segment.name)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
List {
|
||||
ForEach(manager.directoryEntries) { entry in
|
||||
if entry.isDirectory {
|
||||
NavigationLink(value: entry.path) {
|
||||
FileRow(entry: entry)
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Open") {
|
||||
navigationPath = stackForPath(entry.path)
|
||||
}
|
||||
|
||||
Button("Rename") {
|
||||
renameTarget = entry
|
||||
renameText = entry.name
|
||||
showingRenameSheet = true
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
manager.delete(entry: entry)
|
||||
} label: {
|
||||
Text("Delete")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FileRow(entry: entry)
|
||||
.contextMenu {
|
||||
Button("Download") {
|
||||
manager.download(entry: entry) { result in
|
||||
switch result {
|
||||
case .success(let url):
|
||||
shareURL = url
|
||||
case .failure(let error):
|
||||
errorWrapper = ErrorWrapper(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("Rename") {
|
||||
renameTarget = entry
|
||||
renameText = entry.name
|
||||
showingRenameSheet = true
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
manager.delete(entry: entry)
|
||||
} label: {
|
||||
Text("Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Menu {
|
||||
Button {
|
||||
showingImporter = true
|
||||
} label: {
|
||||
Label("Upload File", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Button {
|
||||
showingNewFolderSheet = true
|
||||
newFolderName = ""
|
||||
} label: {
|
||||
Label("New Folder", systemImage: "folder.badge.plus")
|
||||
}
|
||||
} label: {
|
||||
Label("Actions", systemImage: "ellipsis.circle")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
manager.refreshDirectory()
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding([.horizontal, .bottom])
|
||||
}
|
||||
|
||||
}
|
||||
.navigationTitle(displayTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
if manager.currentDirectory != path {
|
||||
manager.changeDirectory(to: path)
|
||||
} else {
|
||||
manager.refreshDirectory()
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showingImporter,
|
||||
allowedContentTypes: [.data],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
guard let url = urls.first else { return }
|
||||
manager.uploadFile(from: url) { uploadResult in
|
||||
if case let .failure(error) = uploadResult {
|
||||
errorWrapper = ErrorWrapper(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
errorWrapper = ErrorWrapper(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingNewFolderSheet) {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section(header: Text("Folder Name")) {
|
||||
TextField("Name", text: $newFolderName)
|
||||
.autocapitalization(.none)
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Folder")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { showingNewFolderSheet = false }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Create") {
|
||||
let name = newFolderName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !name.isEmpty {
|
||||
manager.createDirectory(named: name)
|
||||
}
|
||||
showingNewFolderSheet = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingRenameSheet) {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section(header: Text("New Name")) {
|
||||
TextField("Name", text: $renameText)
|
||||
.autocapitalization(.none)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Rename")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { showingRenameSheet = false }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Rename") {
|
||||
if let target = renameTarget {
|
||||
let newName = renameText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !newName.isEmpty {
|
||||
manager.rename(entry: target, to: newName)
|
||||
}
|
||||
}
|
||||
showingRenameSheet = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stackForPath(_ path: String) -> [String] {
|
||||
guard path != "/" else { return [] }
|
||||
var stack: [String] = []
|
||||
var current = ""
|
||||
for component in path.split(separator: "/").map(String.init) {
|
||||
current += "/" + component
|
||||
stack.append(current)
|
||||
}
|
||||
return stack
|
||||
}
|
||||
|
||||
private struct FileRow: View {
|
||||
let entry: TimeSyncManager.RemoteFileEntry
|
||||
|
||||
private var iconName: String {
|
||||
entry.isDirectory ? "folder" : "doc.text"
|
||||
}
|
||||
|
||||
private var formattedSize: String {
|
||||
guard !entry.isDirectory else { return "" }
|
||||
let byteCountFormatter = ByteCountFormatter()
|
||||
byteCountFormatter.allowedUnits = [.useKB, .useMB, .useGB]
|
||||
byteCountFormatter.countStyle = .file
|
||||
return byteCountFormatter.string(fromByteCount: Int64(entry.size))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: iconName)
|
||||
.foregroundColor(entry.isDirectory ? .accentColor : .primary)
|
||||
VStack(alignment: .leading) {
|
||||
Text(entry.name)
|
||||
.fontWeight(entry.isDirectory ? .semibold : .regular)
|
||||
if !entry.isDirectory, !formattedSize.isEmpty {
|
||||
Text(formattedSize)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShareSheet: UIViewControllerRepresentable {
|
||||
let items: [Any]
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
||||
private struct FileOperationHUD: View {
|
||||
let operation: TimeSyncManager.FileOperationProgress
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.3)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text(operation.title)
|
||||
.font(.headline)
|
||||
Text(operation.message)
|
||||
.font(.subheadline)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if let progress = operation.progress {
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.linear)
|
||||
.frame(maxWidth: 240)
|
||||
Text("\(Int(progress * 100))%")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(.ultraThinMaterial)
|
||||
.cornerRadius(16)
|
||||
.shadow(radius: 10)
|
||||
.padding()
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectionOverlay: View {
|
||||
let state: TimeSyncManager.ConnectionState
|
||||
let statusMessage: String
|
||||
let retryAction: () -> Void
|
||||
|
||||
private var showsSpinner: Bool {
|
||||
switch state {
|
||||
case .scanning, .connecting, .discovering:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var canRetry: Bool {
|
||||
switch state {
|
||||
case .failed, .idle:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.35)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
if showsSpinner {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.scaleEffect(1.2)
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 42, weight: .semibold))
|
||||
.foregroundColor(.yellow)
|
||||
}
|
||||
|
||||
Text(statusMessage)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
if canRetry {
|
||||
Button("Try Again", action: retryAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 320)
|
||||
.background(.ultraThinMaterial)
|
||||
.cornerRadius(20)
|
||||
.shadow(radius: 12)
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
extension URL: Identifiable {
|
||||
public var id: String { absoluteString }
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.environmentObject(TimeSyncManager())
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
</array>
|
||||
<key>NSUserNotificationUsageDescription</key>
|
||||
<string>Allow Cardboy Companion to send local notifications for testing.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct cardboy_companionApp: App {
|
||||
@StateObject private var manager = TimeSyncManager()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(manager)
|
||||
}
|
||||
}
|
||||
}
|
||||
38
Firmware/components/backend-esp/CMakeLists.txt
Normal file
38
Firmware/components/backend-esp/CMakeLists.txt
Normal 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
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/backend/esp_backend.hpp"
|
||||
|
||||
namespace cardboy::backend {
|
||||
using ActiveBackend = EspBackend;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
@@ -10,6 +10,7 @@ class Shutdowner {
|
||||
public:
|
||||
static Shutdowner& get();
|
||||
void install_isr();
|
||||
void shutdown();
|
||||
private:
|
||||
Shutdowner();
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
namespace cardboy::sdk {
|
||||
class INotificationCenter;
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
namespace cardboy::backend::esp {
|
||||
|
||||
/**
|
||||
* Ensure the BLE time synchronisation service is running.
|
||||
*
|
||||
* Safe to call multiple times; subsequent calls become no-ops once the
|
||||
* service has been started successfully.
|
||||
*/
|
||||
void ensure_time_sync_service_started();
|
||||
|
||||
/**
|
||||
* Stop the BLE time synchronisation service if it is running.
|
||||
* A no-op on platforms that do not support the BLE implementation.
|
||||
*/
|
||||
void shutdown_time_sync_service();
|
||||
|
||||
/**
|
||||
* Provide a notification sink that receives mirrored notifications from iOS.
|
||||
* Passing nullptr disables mirroring.
|
||||
*/
|
||||
void set_notification_center(cardboy::sdk::INotificationCenter* center);
|
||||
|
||||
} // namespace cardboy::backend::esp
|
||||
@@ -0,0 +1,89 @@
|
||||
#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;
|
||||
class NotificationService;
|
||||
|
||||
std::unique_ptr<BuzzerService> buzzerService;
|
||||
std::unique_ptr<BatteryService> batteryService;
|
||||
std::unique_ptr<StorageService> storageService;
|
||||
std::unique_ptr<RandomService> randomService;
|
||||
std::unique_ptr<HighResClockService> highResClockService;
|
||||
std::unique_ptr<FilesystemService> filesystemService;
|
||||
std::unique_ptr<EventBus> eventBus;
|
||||
std::unique_ptr<LoopHooksService> loopHooksService;
|
||||
std::unique_ptr<NotificationService> notificationService;
|
||||
|
||||
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
|
||||
115
Firmware/components/backend-esp/src/bat_mon.cpp
Normal file
115
Firmware/components/backend-esp/src/bat_mon.cpp
Normal 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, ®, 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; }
|
||||
102
Firmware/components/backend-esp/src/buttons.cpp
Normal file
102
Firmware/components/backend-esp/src/buttons.cpp
Normal 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, ®, 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, ®, 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; }
|
||||
160
Firmware/components/backend-esp/src/buzzer.cpp
Normal file
160
Firmware/components/backend-esp/src/buzzer.cpp
Normal 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); }
|
||||
155
Firmware/components/backend-esp/src/display.cpp
Normal file
155
Firmware/components/backend-esp/src/display.cpp
Normal 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);
|
||||
}
|
||||
342
Firmware/components/backend-esp/src/esp_backend.cpp
Normal file
342
Firmware/components/backend-esp/src/esp_backend.cpp
Normal file
@@ -0,0 +1,342 @@
|
||||
#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 <ctime>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
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) {
|
||||
printf("Erasing flash!\n");
|
||||
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); }
|
||||
};
|
||||
|
||||
class EspRuntime::NotificationService final : public cardboy::sdk::INotificationCenter {
|
||||
public:
|
||||
void pushNotification(Notification notification) override {
|
||||
if (notification.timestamp == 0) {
|
||||
notification.timestamp = static_cast<std::uint64_t>(std::time(nullptr));
|
||||
}
|
||||
|
||||
capLengths(notification);
|
||||
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (notification.externalId != 0) {
|
||||
for (auto it = entries.begin(); it != entries.end();) {
|
||||
if (it->externalId == notification.externalId)
|
||||
it = entries.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
notification.id = nextId++;
|
||||
notification.unread = true;
|
||||
|
||||
entries.push_back(std::move(notification));
|
||||
if (entries.size() > kMaxEntries)
|
||||
entries.erase(entries.begin());
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::uint32_t revision() const override {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
return revisionCounter;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<Notification> recent(std::size_t limit) const override {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
std::vector<Notification> out;
|
||||
const std::size_t count = std::min<std::size_t>(limit, entries.size());
|
||||
out.reserve(count);
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
out.push_back(entries[entries.size() - 1 - i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void markAllRead() override {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
bool changed = false;
|
||||
for (auto& entry: entries) {
|
||||
if (entry.unread) {
|
||||
entry.unread = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed)
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
void clear() override {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (entries.empty())
|
||||
return;
|
||||
entries.clear();
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
void removeByExternalId(std::uint64_t externalId) override {
|
||||
if (externalId == 0)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
bool removed = false;
|
||||
for (auto it = entries.begin(); it != entries.end();) {
|
||||
if (it->externalId == externalId) {
|
||||
it = entries.erase(it);
|
||||
removed = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
if (removed)
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr std::size_t kMaxEntries = 8;
|
||||
static constexpr std::size_t kMaxTitleBytes = 96;
|
||||
static constexpr std::size_t kMaxBodyBytes = 256;
|
||||
|
||||
static void capLengths(Notification& notification) {
|
||||
if (notification.title.size() > kMaxTitleBytes)
|
||||
notification.title.resize(kMaxTitleBytes);
|
||||
if (notification.body.size() > kMaxBodyBytes)
|
||||
notification.body.resize(kMaxBodyBytes);
|
||||
}
|
||||
|
||||
mutable std::mutex mutex;
|
||||
std::vector<Notification> entries;
|
||||
std::uint64_t nextId = 1;
|
||||
std::uint32_t revisionCounter = 0;
|
||||
};
|
||||
|
||||
EspRuntime::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>();
|
||||
notificationService = std::make_unique<NotificationService>();
|
||||
|
||||
services.buzzer = buzzerService.get();
|
||||
services.battery = batteryService.get();
|
||||
services.storage = storageService.get();
|
||||
services.random = randomService.get();
|
||||
services.highResClock = highResClockService.get();
|
||||
services.filesystem = filesystemService.get();
|
||||
services.eventBus = eventBus.get();
|
||||
services.loopHooks = loopHooksService.get();
|
||||
services.notifications = notificationService.get();
|
||||
|
||||
Buttons::get().setEventBus(eventBus.get());
|
||||
set_notification_center(notificationService.get());
|
||||
}
|
||||
|
||||
EspRuntime::~EspRuntime() {
|
||||
set_notification_center(nullptr);
|
||||
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
|
||||
81
Firmware/components/backend-esp/src/event_bus.cpp
Normal file
81
Firmware/components/backend-esp/src/event_bus.cpp
Normal 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
|
||||
68
Firmware/components/backend-esp/src/fs_helper.cpp
Normal file
68
Firmware/components/backend-esp/src/fs_helper.cpp
Normal 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);
|
||||
}
|
||||
@@ -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() {
|
||||
@@ -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); }
|
||||
@@ -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;
|
||||
1611
Firmware/components/backend-esp/src/time_sync_service.cpp
Normal file
1611
Firmware/components/backend-esp/src/time_sync_service.cpp
Normal file
File diff suppressed because it is too large
Load Diff
31
Firmware/components/sdk-esp/CMakeLists.txt
Normal file
31
Firmware/components/sdk-esp/CMakeLists.txt
Normal 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
|
||||
)
|
||||
21
Firmware/dependencies.lock
Normal file
21
Firmware/dependencies.lock
Normal 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
248
Firmware/ghettoprof.sh
Executable 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
|
||||
@@ -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)
|
||||
|
||||
17
Firmware/main/idf_component.yml
Normal file
17
Firmware/main/idf_component.yml
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
7
Firmware/main/roms/README.md
Normal file
7
Firmware/main/roms/README.md
Normal 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.
|
||||
247
Firmware/main/src/app_main.cpp
Normal file
247
Firmware/main/src/app_main.cpp
Normal 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();
|
||||
}
|
||||
@@ -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, ®, 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, ®, 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, ®, 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, ®, 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; }
|
||||
@@ -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; }
|
||||
@@ -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); }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
6
Firmware/partitions.csv
Normal 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,
|
||||
|
76
Firmware/sdk/.clang-format
Normal file
76
Firmware/sdk/.clang-format
Normal 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
|
||||
...
|
||||
37
Firmware/sdk/CMakeLists.txt
Normal file
37
Firmware/sdk/CMakeLists.txt
Normal 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 ()
|
||||
21
Firmware/sdk/apps/CMakeLists.txt
Normal file
21
Firmware/sdk/apps/CMakeLists.txt
Normal 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)
|
||||
9
Firmware/sdk/apps/clock/CMakeLists.txt
Normal file
9
Firmware/sdk/apps/clock/CMakeLists.txt
Normal 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
|
||||
)
|
||||
12
Firmware/sdk/apps/clock/include/cardboy/apps/clock_app.hpp
Normal file
12
Firmware/sdk/apps/clock/include/cardboy/apps/clock_app.hpp
Normal 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
|
||||
|
||||
238
Firmware/sdk/apps/clock/src/clock_app.cpp
Normal file
238
Firmware/sdk/apps/clock/src/clock_app.cpp
Normal 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
|
||||
10
Firmware/sdk/apps/gameboy/CMakeLists.txt
Normal file
10
Firmware/sdk/apps/gameboy/CMakeLists.txt
Normal 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
|
||||
)
|
||||
@@ -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
|
||||
4036
Firmware/sdk/apps/gameboy/include/cardboy/apps/peanut_gb.h
Normal file
4036
Firmware/sdk/apps/gameboy/include/cardboy/apps/peanut_gb.h
Normal file
File diff suppressed because it is too large
Load Diff
2252
Firmware/sdk/apps/gameboy/src/gameboy_app.cpp
Normal file
2252
Firmware/sdk/apps/gameboy/src/gameboy_app.cpp
Normal file
File diff suppressed because it is too large
Load Diff
10
Firmware/sdk/apps/lockscreen/CMakeLists.txt
Normal file
10
Firmware/sdk/apps/lockscreen/CMakeLists.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
target_sources(cardboy_apps
|
||||
PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/lockscreen_app.cpp
|
||||
)
|
||||
|
||||
target_include_directories(cardboy_apps
|
||||
PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/sdk/app_framework.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <string_view>
|
||||
|
||||
namespace apps {
|
||||
|
||||
inline constexpr char kLockscreenAppName[] = "Lockscreen";
|
||||
inline constexpr std::string_view kLockscreenAppNameView = kLockscreenAppName;
|
||||
|
||||
std::unique_ptr<cardboy::sdk::IAppFactory> createLockscreenAppFactory();
|
||||
|
||||
} // namespace apps
|
||||
|
||||
578
Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp
Normal file
578
Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp
Normal file
@@ -0,0 +1,578 @@
|
||||
#include "cardboy/apps/lockscreen_app.hpp"
|
||||
|
||||
#include "cardboy/apps/menu_app.hpp"
|
||||
#include "cardboy/gfx/font16x8.hpp"
|
||||
#include "cardboy/sdk/app_framework.hpp"
|
||||
#include "cardboy/sdk/app_system.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace apps {
|
||||
|
||||
namespace {
|
||||
|
||||
using cardboy::sdk::AppContext;
|
||||
|
||||
constexpr std::uint32_t kRefreshIntervalMs = 100;
|
||||
constexpr std::uint32_t kUnlockHoldMs = 1500;
|
||||
|
||||
using Framebuffer = typename AppContext::Framebuffer;
|
||||
using Clock = typename AppContext::Clock;
|
||||
|
||||
constexpr std::array<std::uint8_t, font16x8::kGlyphHeight> kArrowUpGlyph{0b00011000, 0b00111100, 0b01111110,
|
||||
0b11111111, 0b00011000, 0b00011000,
|
||||
0b00011000, 0b00011000, 0b00011000,
|
||||
0b00011000, 0b00011000, 0b00011000,
|
||||
0b00011000, 0b00011000, 0b00000000,
|
||||
0b00000000};
|
||||
|
||||
constexpr std::array<std::uint8_t, font16x8::kGlyphHeight> kArrowDownGlyph{0b00000000, 0b00000000, 0b00011000,
|
||||
0b00011000, 0b00011000, 0b00011000,
|
||||
0b00011000, 0b00011000, 0b00011000,
|
||||
0b00011000, 0b00011000, 0b11111111,
|
||||
0b01111110, 0b00111100, 0b00011000,
|
||||
0b00000000};
|
||||
|
||||
struct TimeSnapshot {
|
||||
bool hasWallTime = false;
|
||||
int hour24 = 0;
|
||||
int minute = 0;
|
||||
int second = 0;
|
||||
int year = 0;
|
||||
int month = 0;
|
||||
int day = 0;
|
||||
int weekday = 0;
|
||||
std::uint64_t uptimeSeconds = 0;
|
||||
};
|
||||
|
||||
class LockscreenApp final : public cardboy::sdk::IApp {
|
||||
public:
|
||||
explicit LockscreenApp(AppContext& ctx) :
|
||||
context(ctx), framebuffer(ctx.framebuffer), clock(ctx.clock), notificationCenter(ctx.notificationCenter()) {}
|
||||
|
||||
void onStart() override {
|
||||
cancelRefreshTimer();
|
||||
lastSnapshot = {};
|
||||
holdActive = false;
|
||||
holdProgressMs = 0;
|
||||
dirty = true;
|
||||
lastNotificationInteractionMs = clock.millis();
|
||||
refreshNotifications();
|
||||
const auto snap = captureTime();
|
||||
renderIfNeeded(snap);
|
||||
lastSnapshot = snap;
|
||||
refreshTimer = context.scheduleRepeatingTimer(kRefreshIntervalMs);
|
||||
}
|
||||
|
||||
void onStop() override { cancelRefreshTimer(); }
|
||||
|
||||
void handleEvent(const cardboy::sdk::AppEvent& event) override {
|
||||
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:
|
||||
static constexpr std::size_t kMaxDisplayedNotifications = 5;
|
||||
static constexpr std::uint32_t kNotificationHideMs = 8000;
|
||||
AppContext& context;
|
||||
Framebuffer& framebuffer;
|
||||
Clock& clock;
|
||||
cardboy::sdk::INotificationCenter* notificationCenter = nullptr;
|
||||
std::uint32_t lastNotificationRevision = 0;
|
||||
std::vector<cardboy::sdk::INotificationCenter::Notification> notifications;
|
||||
std::size_t selectedNotification = 0;
|
||||
|
||||
bool dirty = false;
|
||||
cardboy::sdk::AppTimerHandle refreshTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
TimeSnapshot lastSnapshot{};
|
||||
bool holdActive = false;
|
||||
std::uint32_t holdProgressMs = 0;
|
||||
std::uint32_t lastNotificationInteractionMs = 0;
|
||||
|
||||
void cancelRefreshTimer() {
|
||||
if (refreshTimer != cardboy::sdk::kInvalidAppTimer) {
|
||||
context.cancelTimer(refreshTimer);
|
||||
refreshTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
}
|
||||
|
||||
static bool comboPressed(const cardboy::sdk::InputState& state) { return state.a && state.select; }
|
||||
|
||||
void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) {
|
||||
const bool upPressed = button.current.up && !button.previous.up;
|
||||
const bool downPressed = button.current.down && !button.previous.down;
|
||||
bool navPressed = false;
|
||||
|
||||
if (!notifications.empty() && (upPressed || downPressed)) {
|
||||
const std::size_t count = notifications.size();
|
||||
lastNotificationInteractionMs = clock.millis();
|
||||
navPressed = true;
|
||||
if (count > 1) {
|
||||
if (upPressed)
|
||||
selectedNotification = (selectedNotification + count - 1) % count;
|
||||
else if (downPressed)
|
||||
selectedNotification = (selectedNotification + 1) % count;
|
||||
}
|
||||
}
|
||||
|
||||
const bool comboNow = comboPressed(button.current);
|
||||
updateHoldState(comboNow);
|
||||
if (navPressed)
|
||||
dirty = true;
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
void refreshNotifications() {
|
||||
if (!notificationCenter) {
|
||||
if (!notifications.empty() || lastNotificationRevision != 0) {
|
||||
notifications.clear();
|
||||
selectedNotification = 0;
|
||||
lastNotificationRevision = 0;
|
||||
dirty = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const std::uint32_t revision = notificationCenter->revision();
|
||||
if (revision == lastNotificationRevision)
|
||||
return;
|
||||
lastNotificationRevision = revision;
|
||||
|
||||
const std::uint64_t previousId =
|
||||
(selectedNotification < notifications.size()) ? notifications[selectedNotification].id : 0;
|
||||
|
||||
auto latest = notificationCenter->recent(kMaxDisplayedNotifications);
|
||||
notifications = std::move(latest);
|
||||
|
||||
if (notifications.empty()) {
|
||||
selectedNotification = 0;
|
||||
} else if (previousId != 0) {
|
||||
auto it = std::find_if(notifications.begin(), notifications.end(),
|
||||
[previousId](const auto& note) { return note.id == previousId; });
|
||||
if (it != notifications.end()) {
|
||||
selectedNotification = static_cast<std::size_t>(std::distance(notifications.begin(), it));
|
||||
} else {
|
||||
selectedNotification = 0;
|
||||
}
|
||||
} else {
|
||||
selectedNotification = 0;
|
||||
}
|
||||
|
||||
lastNotificationInteractionMs = clock.millis();
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void updateHoldState(bool comboNow) {
|
||||
if (comboNow) {
|
||||
if (!holdActive) {
|
||||
holdActive = true;
|
||||
dirty = true;
|
||||
}
|
||||
} else {
|
||||
if (holdActive || holdProgressMs != 0) {
|
||||
holdActive = false;
|
||||
holdProgressMs = 0;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void advanceHoldProgress() {
|
||||
if (holdActive) {
|
||||
const std::uint32_t next =
|
||||
std::min<std::uint32_t>(holdProgressMs + kRefreshIntervalMs, kUnlockHoldMs);
|
||||
if (next != holdProgressMs) {
|
||||
holdProgressMs = next;
|
||||
dirty = true;
|
||||
}
|
||||
if (holdProgressMs >= kUnlockHoldMs) {
|
||||
holdActive = false;
|
||||
context.requestAppSwitchByName(kMenuAppName);
|
||||
}
|
||||
} else if (holdProgressMs != 0) {
|
||||
holdProgressMs = 0;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
void updateDisplay() {
|
||||
refreshNotifications();
|
||||
const auto snap = captureTime();
|
||||
if (!sameSnapshot(snap, lastSnapshot))
|
||||
dirty = true;
|
||||
renderIfNeeded(snap);
|
||||
lastSnapshot = snap;
|
||||
}
|
||||
|
||||
static bool sameSnapshot(const TimeSnapshot& a, const TimeSnapshot& b) {
|
||||
return a.hasWallTime == b.hasWallTime && a.hour24 == b.hour24 && a.minute == b.minute &&
|
||||
a.second == b.second && a.day == b.day && a.month == b.month && a.year == b.year;
|
||||
}
|
||||
|
||||
TimeSnapshot captureTime() const {
|
||||
TimeSnapshot snap{};
|
||||
snap.uptimeSeconds = clock.millis() / 1000ULL;
|
||||
|
||||
std::time_t raw = 0;
|
||||
if (std::time(&raw) != static_cast<std::time_t>(-1) && raw > 0) {
|
||||
std::tm tm{};
|
||||
if (localtime_r(&raw, &tm) != nullptr) {
|
||||
snap.hasWallTime = true;
|
||||
snap.hour24 = tm.tm_hour;
|
||||
snap.minute = tm.tm_min;
|
||||
snap.second = tm.tm_sec;
|
||||
snap.year = tm.tm_year + 1900;
|
||||
snap.month = tm.tm_mon + 1;
|
||||
snap.day = tm.tm_mday;
|
||||
snap.weekday = tm.tm_wday;
|
||||
return snap;
|
||||
}
|
||||
}
|
||||
|
||||
snap.hasWallTime = false;
|
||||
snap.hour24 = static_cast<int>((snap.uptimeSeconds / 3600ULL) % 24ULL);
|
||||
snap.minute = static_cast<int>((snap.uptimeSeconds / 60ULL) % 60ULL);
|
||||
snap.second = static_cast<int>(snap.uptimeSeconds % 60ULL);
|
||||
return snap;
|
||||
}
|
||||
|
||||
static void drawCenteredText(Framebuffer& fb, int y, std::string_view text, int scale, int letterSpacing = 0) {
|
||||
const int width = font16x8::measureText(text, scale, letterSpacing);
|
||||
const int x = (fb.width() - width) / 2;
|
||||
font16x8::drawText(fb, x, y, text, scale, true, letterSpacing);
|
||||
}
|
||||
|
||||
static void drawRectOutline(Framebuffer& fb, int x, int y, int w, int h) {
|
||||
if (w <= 0 || h <= 0)
|
||||
return;
|
||||
for (int dx = 0; dx < w; ++dx) {
|
||||
fb.drawPixel(x + dx, y, true);
|
||||
fb.drawPixel(x + dx, y + h - 1, true);
|
||||
}
|
||||
for (int dy = 0; dy < h; ++dy) {
|
||||
fb.drawPixel(x, y + dy, true);
|
||||
fb.drawPixel(x + w - 1, y + dy, true);
|
||||
}
|
||||
}
|
||||
|
||||
static void fillRect(Framebuffer& fb, int x, int y, int w, int h) {
|
||||
if (w <= 0 || h <= 0)
|
||||
return;
|
||||
for (int dy = 0; dy < h; ++dy) {
|
||||
for (int dx = 0; dx < w; ++dx) {
|
||||
fb.drawPixel(x + dx, y + dy, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void drawArrowGlyph(Framebuffer& fb, int x, int y,
|
||||
const std::array<std::uint8_t, font16x8::kGlyphHeight>& glyph, int scale = 1) {
|
||||
if (scale <= 0)
|
||||
return;
|
||||
for (int row = 0; row < font16x8::kGlyphHeight; ++row) {
|
||||
const std::uint8_t rowBits = glyph[row];
|
||||
for (int col = 0; col < font16x8::kGlyphWidth; ++col) {
|
||||
const auto mask = static_cast<std::uint8_t>(1u << (font16x8::kGlyphWidth - 1 - col));
|
||||
if ((rowBits & mask) == 0)
|
||||
continue;
|
||||
for (int sx = 0; sx < scale; ++sx) {
|
||||
for (int sy = 0; sy < scale; ++sy) {
|
||||
fb.drawPixel(x + col * scale + sx, y + row * scale + sy, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void drawArrow(Framebuffer& fb, int x, int y, bool up, int scale = 1) {
|
||||
const auto& glyph = up ? kArrowUpGlyph : kArrowDownGlyph;
|
||||
drawArrowGlyph(fb, x, y, glyph, scale);
|
||||
}
|
||||
|
||||
static std::string truncateWithEllipsis(std::string_view text, int maxWidth, int scale, int letterSpacing) {
|
||||
if (font16x8::measureText(text, scale, letterSpacing) <= maxWidth)
|
||||
return std::string(text);
|
||||
|
||||
std::string result(text.begin(), text.end());
|
||||
const std::string ellipsis = "...";
|
||||
while (!result.empty()) {
|
||||
result.pop_back();
|
||||
std::string candidate = result + ellipsis;
|
||||
if (font16x8::measureText(candidate, scale, letterSpacing) <= maxWidth)
|
||||
return candidate;
|
||||
}
|
||||
return ellipsis;
|
||||
}
|
||||
|
||||
static std::vector<std::string> wrapText(std::string_view text, int maxWidth, int scale, int letterSpacing,
|
||||
int maxLines) {
|
||||
std::vector<std::string> lines;
|
||||
if (text.empty() || maxWidth <= 0 || maxLines <= 0)
|
||||
return lines;
|
||||
|
||||
std::string current;
|
||||
std::string word;
|
||||
bool truncated = false;
|
||||
|
||||
auto flushCurrent = [&]() {
|
||||
if (current.empty())
|
||||
return;
|
||||
if (lines.size() < static_cast<std::size_t>(maxLines)) {
|
||||
lines.push_back(current);
|
||||
} else {
|
||||
truncated = true;
|
||||
}
|
||||
current.clear();
|
||||
};
|
||||
|
||||
for (std::size_t i = 0; i <= text.size(); ++i) {
|
||||
char ch = (i < text.size()) ? text[i] : ' ';
|
||||
const bool isBreak = (ch == ' ' || ch == '\n' || ch == '\r' || i == text.size());
|
||||
if (!isBreak) {
|
||||
word.push_back(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!word.empty()) {
|
||||
std::string candidate = current.empty() ? word : current + " " + word;
|
||||
if (!current.empty() &&
|
||||
font16x8::measureText(candidate, scale, letterSpacing) > maxWidth) {
|
||||
flushCurrent();
|
||||
if (lines.size() >= static_cast<std::size_t>(maxLines)) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
candidate = word;
|
||||
}
|
||||
|
||||
if (font16x8::measureText(candidate, scale, letterSpacing) > maxWidth) {
|
||||
std::string shortened = truncateWithEllipsis(word, maxWidth, scale, letterSpacing);
|
||||
flushCurrent();
|
||||
if (lines.size() < static_cast<std::size_t>(maxLines)) {
|
||||
lines.push_back(shortened);
|
||||
} else {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
current.clear();
|
||||
} else {
|
||||
current = candidate;
|
||||
}
|
||||
word.clear();
|
||||
}
|
||||
|
||||
if (ch == '\n' || ch == '\r') {
|
||||
flushCurrent();
|
||||
if (lines.size() >= static_cast<std::size_t>(maxLines)) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushCurrent();
|
||||
if (lines.size() > static_cast<std::size_t>(maxLines)) {
|
||||
truncated = true;
|
||||
lines.resize(maxLines);
|
||||
}
|
||||
if (truncated && !lines.empty()) {
|
||||
lines.back() = truncateWithEllipsis(lines.back(), maxWidth, scale, letterSpacing);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
static std::string formatDate(const TimeSnapshot& snap) {
|
||||
static const char* kWeekdays[] = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"};
|
||||
if (!snap.hasWallTime)
|
||||
return "UPTIME MODE";
|
||||
const char* weekday = (snap.weekday >= 0 && snap.weekday < 7) ? kWeekdays[snap.weekday] : "";
|
||||
char buffer[32];
|
||||
std::snprintf(buffer, sizeof(buffer), "%s %04d-%02d-%02d", weekday, snap.year, snap.month, snap.day);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
void renderIfNeeded(const TimeSnapshot& snap) {
|
||||
if (!dirty)
|
||||
return;
|
||||
dirty = false;
|
||||
|
||||
framebuffer.frameReady();
|
||||
|
||||
const int scaleTime = 4;
|
||||
const int scaleSeconds = 2;
|
||||
const int scaleSmall = 1;
|
||||
const int textLineHeight = font16x8::kGlyphHeight * scaleSmall;
|
||||
|
||||
const int cardMarginTop = 4;
|
||||
const int cardMarginSide = 8;
|
||||
const int cardPadding = 6;
|
||||
const int cardLineSpacing = 4;
|
||||
int cardHeight = 0;
|
||||
const int cardWidth = framebuffer.width() - cardMarginSide * 2;
|
||||
|
||||
const std::uint32_t nowMs = clock.millis();
|
||||
const bool hasNotifications = !notifications.empty();
|
||||
const bool showNotificationDetails =
|
||||
hasNotifications && (nowMs - lastNotificationInteractionMs <= kNotificationHideMs);
|
||||
|
||||
if (hasNotifications) {
|
||||
const auto& note = notifications[selectedNotification];
|
||||
if (showNotificationDetails) {
|
||||
std::string title = note.title.empty() ? std::string("Notification") : note.title;
|
||||
title = truncateWithEllipsis(title, cardWidth - cardPadding * 2, scaleSmall, 1);
|
||||
|
||||
auto bodyLines = wrapText(note.body, cardWidth - cardPadding * 2, scaleSmall, 1, 4);
|
||||
|
||||
cardHeight = cardPadding * 2 + textLineHeight;
|
||||
if (!bodyLines.empty()) {
|
||||
cardHeight += cardLineSpacing;
|
||||
cardHeight += static_cast<int>(bodyLines.size()) * textLineHeight;
|
||||
if (bodyLines.size() > 1)
|
||||
cardHeight += (static_cast<int>(bodyLines.size()) - 1) * cardLineSpacing;
|
||||
}
|
||||
|
||||
if (notifications.size() > 1) {
|
||||
cardHeight = std::max(cardHeight, cardPadding * 2 + textLineHeight * 2 + cardLineSpacing + 8);
|
||||
}
|
||||
|
||||
drawRectOutline(framebuffer, cardMarginSide, cardMarginTop, cardWidth, cardHeight);
|
||||
if (cardWidth > 2 && cardHeight > 2)
|
||||
drawRectOutline(framebuffer, cardMarginSide + 1, cardMarginTop + 1, cardWidth - 2, cardHeight - 2);
|
||||
|
||||
font16x8::drawText(framebuffer, cardMarginSide + cardPadding, cardMarginTop + cardPadding, title,
|
||||
scaleSmall, true, 1);
|
||||
|
||||
if (notifications.size() > 1) {
|
||||
char counter[16];
|
||||
std::snprintf(counter, sizeof(counter), "%zu/%zu", selectedNotification + 1, notifications.size());
|
||||
const int counterWidth = font16x8::measureText(counter, scaleSmall, 1);
|
||||
const int counterX = cardMarginSide + cardWidth - cardPadding - counterWidth;
|
||||
font16x8::drawText(framebuffer, counterX, cardMarginTop + cardPadding, counter, scaleSmall, true, 1);
|
||||
const int arrowWidth = font16x8::kGlyphWidth * scaleSmall;
|
||||
const int arrowSpacing = std::max(1, scaleSmall);
|
||||
const int arrowsTotalWide = arrowWidth * 2 + arrowSpacing;
|
||||
const int arrowX = counterX + (counterWidth - arrowsTotalWide) / 2;
|
||||
const int arrowY = cardMarginTop + cardPadding + textLineHeight + 1;
|
||||
drawArrow(framebuffer, arrowX, arrowY, true, scaleSmall);
|
||||
drawArrow(framebuffer, arrowX + arrowWidth + arrowSpacing, arrowY, false, scaleSmall);
|
||||
const int arrowHeight = font16x8::kGlyphHeight * scaleSmall;
|
||||
cardHeight = std::max(cardHeight, arrowY + arrowHeight - cardMarginTop);
|
||||
}
|
||||
|
||||
if (!bodyLines.empty()) {
|
||||
int bodyY = cardMarginTop + cardPadding + textLineHeight + cardLineSpacing;
|
||||
for (const auto& line : bodyLines) {
|
||||
font16x8::drawText(framebuffer, cardMarginSide + cardPadding, bodyY, line, scaleSmall, true, 1);
|
||||
bodyY += textLineHeight + cardLineSpacing;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cardHeight = textLineHeight + cardPadding * 2;
|
||||
char summary[32];
|
||||
if (notifications.size() == 1)
|
||||
std::snprintf(summary, sizeof(summary), "1 NOTIFICATION");
|
||||
else
|
||||
std::snprintf(summary, sizeof(summary), "%zu NOTIFICATIONS", notifications.size());
|
||||
const int summaryWidth = font16x8::measureText(summary, scaleSmall, 1);
|
||||
const int summaryX = (framebuffer.width() - summaryWidth) / 2;
|
||||
const int summaryY = cardMarginTop;
|
||||
font16x8::drawText(framebuffer, summaryX, summaryY, summary, scaleSmall, true, 1);
|
||||
|
||||
if (notifications.size() > 1) {
|
||||
const int arrowWidth = font16x8::kGlyphWidth * scaleSmall;
|
||||
const int arrowSpacing = std::max(1, scaleSmall);
|
||||
const int arrowsTotalWide = arrowWidth * 2 + arrowSpacing;
|
||||
const int arrowX = (framebuffer.width() - arrowsTotalWide) / 2;
|
||||
const int arrowY = summaryY + textLineHeight + 1;
|
||||
drawArrow(framebuffer, arrowX, arrowY, true, scaleSmall);
|
||||
drawArrow(framebuffer, arrowX + arrowWidth + arrowSpacing, arrowY, false, scaleSmall);
|
||||
const int arrowHeight = font16x8::kGlyphHeight * scaleSmall;
|
||||
cardHeight = std::max(cardHeight, arrowY + arrowHeight - cardMarginTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const int defaultTimeY = (framebuffer.height() - font16x8::kGlyphHeight * scaleTime) / 2 - 8;
|
||||
int timeY = defaultTimeY;
|
||||
if (cardHeight > 0)
|
||||
timeY = cardMarginTop + cardHeight + 16;
|
||||
const int minTimeY = (cardHeight > 0) ? (cardMarginTop + cardHeight + 12) : 16;
|
||||
const int maxTimeY =
|
||||
std::max(minTimeY, framebuffer.height() - font16x8::kGlyphHeight * scaleTime - 48);
|
||||
timeY = std::clamp(timeY, minTimeY, maxTimeY);
|
||||
|
||||
char hoursMinutes[6];
|
||||
std::snprintf(hoursMinutes, sizeof(hoursMinutes), "%02d:%02d", snap.hour24, snap.minute);
|
||||
const int mainW = font16x8::measureText(hoursMinutes, scaleTime, 0);
|
||||
const int timeX = (framebuffer.width() - mainW) / 2;
|
||||
const int secX = timeX + mainW + 12;
|
||||
const int secY = timeY + font16x8::kGlyphHeight * scaleTime - font16x8::kGlyphHeight * scaleSeconds;
|
||||
char secs[3];
|
||||
std::snprintf(secs, sizeof(secs), "%02d", snap.second);
|
||||
|
||||
font16x8::drawText(framebuffer, timeX, timeY, hoursMinutes, scaleTime, true, 0);
|
||||
font16x8::drawText(framebuffer, secX, secY, secs, scaleSeconds, true, 0);
|
||||
|
||||
const std::string dateLine = formatDate(snap);
|
||||
drawCenteredText(framebuffer, timeY + font16x8::kGlyphHeight * scaleTime + 16, dateLine, scaleSmall, 1);
|
||||
|
||||
const std::string instruction = holdActive ? "KEEP HOLDING A+SELECT" : "HOLD A+SELECT";
|
||||
const int instructionWidth = font16x8::measureText(instruction, scaleSmall, 1);
|
||||
const int barHeight = 14;
|
||||
const int barY = framebuffer.height() - 24;
|
||||
const int textY = barY + (barHeight - textLineHeight) / 2;
|
||||
const int textX = 8;
|
||||
font16x8::drawText(framebuffer, textX, textY, instruction, scaleSmall, true, 1);
|
||||
|
||||
int barX = textX + instructionWidth + 12;
|
||||
int barWidth = framebuffer.width() - barX - 8;
|
||||
if (barWidth < 40) {
|
||||
barWidth = 40;
|
||||
barX = std::min(barX, framebuffer.width() - barWidth - 8);
|
||||
}
|
||||
|
||||
drawRectOutline(framebuffer, barX, barY, barWidth, barHeight);
|
||||
|
||||
if (holdActive || holdProgressMs > 0) {
|
||||
const int innerWidth = barWidth - 2;
|
||||
const int innerHeight = barHeight - 2;
|
||||
const float ratio =
|
||||
std::clamp(holdProgressMs / static_cast<float>(kUnlockHoldMs), 0.0f, 1.0f);
|
||||
const int fillWidth = static_cast<int>(ratio * innerWidth + 0.5f);
|
||||
if (fillWidth > 0)
|
||||
fillRect(framebuffer, barX + 1, barY + 1, fillWidth, innerHeight);
|
||||
}
|
||||
|
||||
framebuffer.sendFrame();
|
||||
}
|
||||
};
|
||||
|
||||
class LockscreenAppFactory final : public cardboy::sdk::IAppFactory {
|
||||
public:
|
||||
const char* name() const override { return kLockscreenAppName; }
|
||||
std::unique_ptr<cardboy::sdk::IApp> create(cardboy::sdk::AppContext& context) override {
|
||||
return std::make_unique<LockscreenApp>(context);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<cardboy::sdk::IAppFactory> createLockscreenAppFactory() {
|
||||
return std::make_unique<LockscreenAppFactory>();
|
||||
}
|
||||
|
||||
} // namespace apps
|
||||
9
Firmware/sdk/apps/menu/CMakeLists.txt
Normal file
9
Firmware/sdk/apps/menu/CMakeLists.txt
Normal 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
|
||||
)
|
||||
16
Firmware/sdk/apps/menu/include/cardboy/apps/menu_app.hpp
Normal file
16
Firmware/sdk/apps/menu/include/cardboy/apps/menu_app.hpp
Normal 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
|
||||
|
||||
216
Firmware/sdk/apps/menu/src/menu_app.cpp
Normal file
216
Firmware/sdk/apps/menu/src/menu_app.cpp
Normal 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
|
||||
9
Firmware/sdk/apps/settings/CMakeLists.txt
Normal file
9
Firmware/sdk/apps/settings/CMakeLists.txt
Normal 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
|
||||
)
|
||||
@@ -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
|
||||
198
Firmware/sdk/apps/settings/src/settings_app.cpp
Normal file
198
Firmware/sdk/apps/settings/src/settings_app.cpp
Normal 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
|
||||
9
Firmware/sdk/apps/snake/CMakeLists.txt
Normal file
9
Firmware/sdk/apps/snake/CMakeLists.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
target_sources(cardboy_apps
|
||||
PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/snake_app.cpp
|
||||
)
|
||||
|
||||
target_include_directories(cardboy_apps
|
||||
PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
)
|
||||
11
Firmware/sdk/apps/snake/include/cardboy/apps/snake_app.hpp
Normal file
11
Firmware/sdk/apps/snake/include/cardboy/apps/snake_app.hpp
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/sdk/app_framework.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace apps {
|
||||
|
||||
std::unique_ptr<cardboy::sdk::IAppFactory> createSnakeAppFactory();
|
||||
|
||||
} // namespace apps
|
||||
432
Firmware/sdk/apps/snake/src/snake_app.cpp
Normal file
432
Firmware/sdk/apps/snake/src/snake_app.cpp
Normal file
@@ -0,0 +1,432 @@
|
||||
#include "cardboy/apps/snake_app.hpp"
|
||||
|
||||
#include "cardboy/apps/menu_app.hpp"
|
||||
#include "cardboy/gfx/font16x8.hpp"
|
||||
#include "cardboy/sdk/app_framework.hpp"
|
||||
#include "cardboy/sdk/app_system.hpp"
|
||||
#include "cardboy/sdk/display_spec.hpp"
|
||||
#include "cardboy/sdk/input_state.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <deque>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace apps {
|
||||
namespace {
|
||||
|
||||
using cardboy::sdk::AppButtonEvent;
|
||||
using cardboy::sdk::AppContext;
|
||||
using cardboy::sdk::AppEvent;
|
||||
using cardboy::sdk::AppEventType;
|
||||
using cardboy::sdk::AppTimerHandle;
|
||||
using cardboy::sdk::InputState;
|
||||
|
||||
constexpr char kSnakeAppName[] = "Snake";
|
||||
|
||||
constexpr int kBoardWidth = 32;
|
||||
constexpr int kBoardHeight = 20;
|
||||
constexpr int kCellSize = 10;
|
||||
constexpr int kInitialSnakeLength = 5;
|
||||
constexpr int kScorePerFood = 10;
|
||||
constexpr int kMinMoveIntervalMs = 80;
|
||||
constexpr int kBaseMoveIntervalMs = 220;
|
||||
constexpr int kIntervalSpeedupPerSegment = 4;
|
||||
|
||||
struct Point {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
|
||||
bool operator==(const Point& other) const { return x == other.x && y == other.y; }
|
||||
};
|
||||
|
||||
enum class Direction { Up, Down, Left, Right };
|
||||
|
||||
[[nodiscard]] std::uint32_t randomSeed(AppContext& ctx) {
|
||||
if (auto* rnd = ctx.random())
|
||||
return rnd->nextUint32();
|
||||
static std::random_device rd;
|
||||
return rd();
|
||||
}
|
||||
|
||||
class SnakeGame {
|
||||
public:
|
||||
explicit SnakeGame(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer) {
|
||||
rng.seed(randomSeed(context));
|
||||
loadHighScore();
|
||||
reset();
|
||||
}
|
||||
|
||||
void onStart() {
|
||||
scheduleMoveTimer();
|
||||
dirty = true;
|
||||
renderIfNeeded();
|
||||
}
|
||||
|
||||
void onStop() { cancelMoveTimer(); }
|
||||
|
||||
void handleEvent(const AppEvent& event) {
|
||||
switch (event.type) {
|
||||
case AppEventType::Button:
|
||||
handleButtons(event.button);
|
||||
break;
|
||||
case AppEventType::Timer:
|
||||
handleTimer(event.timer.handle);
|
||||
break;
|
||||
}
|
||||
renderIfNeeded();
|
||||
}
|
||||
|
||||
private:
|
||||
AppContext& context;
|
||||
typename AppContext::Framebuffer& framebuffer;
|
||||
|
||||
std::deque<Point> snake;
|
||||
Point food{};
|
||||
Direction direction = Direction::Right;
|
||||
Direction queuedDirection = Direction::Right;
|
||||
bool paused = false;
|
||||
bool gameOver = false;
|
||||
bool dirty = false;
|
||||
int score = 0;
|
||||
int highScore = 0;
|
||||
AppTimerHandle moveTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
std::mt19937 rng;
|
||||
|
||||
void handleButtons(const AppButtonEvent& evt) {
|
||||
const auto& cur = evt.current;
|
||||
const auto& prev = evt.previous;
|
||||
if (cur.b && !prev.b) {
|
||||
context.requestAppSwitchByName(kMenuAppName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cur.select && !prev.select) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cur.start && !prev.start) {
|
||||
if (gameOver)
|
||||
reset();
|
||||
else {
|
||||
paused = !paused;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (gameOver)
|
||||
return;
|
||||
|
||||
if (cur.up && !prev.up)
|
||||
queueDirection(Direction::Up);
|
||||
else if (cur.down && !prev.down)
|
||||
queueDirection(Direction::Down);
|
||||
else if (cur.left && !prev.left)
|
||||
queueDirection(Direction::Left);
|
||||
else if (cur.right && !prev.right)
|
||||
queueDirection(Direction::Right);
|
||||
|
||||
if (cur.a && !prev.a && !paused)
|
||||
advance();
|
||||
}
|
||||
|
||||
void handleTimer(AppTimerHandle handle) {
|
||||
if (handle == moveTimer && !paused && !gameOver)
|
||||
advance();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
cancelMoveTimer();
|
||||
|
||||
snake.clear();
|
||||
const int centerX = kBoardWidth / 2;
|
||||
const int centerY = kBoardHeight / 2;
|
||||
for (int i = 0; i < kInitialSnakeLength; ++i)
|
||||
snake.push_back(Point{centerX - i, centerY});
|
||||
|
||||
direction = Direction::Right;
|
||||
queuedDirection = Direction::Right;
|
||||
paused = false;
|
||||
gameOver = false;
|
||||
score = 0;
|
||||
dirty = true;
|
||||
|
||||
if (!spawnFood())
|
||||
onGameOver();
|
||||
|
||||
scheduleMoveTimer();
|
||||
}
|
||||
|
||||
void advance() {
|
||||
direction = queuedDirection;
|
||||
Point nextHead = snake.front();
|
||||
switch (direction) {
|
||||
case Direction::Up:
|
||||
--nextHead.y;
|
||||
break;
|
||||
case Direction::Down:
|
||||
++nextHead.y;
|
||||
break;
|
||||
case Direction::Left:
|
||||
--nextHead.x;
|
||||
break;
|
||||
case Direction::Right:
|
||||
++nextHead.x;
|
||||
break;
|
||||
}
|
||||
|
||||
if (isCollision(nextHead)) {
|
||||
onGameOver();
|
||||
return;
|
||||
}
|
||||
|
||||
snake.push_front(nextHead);
|
||||
if (nextHead == food) {
|
||||
score += kScorePerFood;
|
||||
updateHighScore();
|
||||
if (!spawnFood()) {
|
||||
onGameOver();
|
||||
return;
|
||||
}
|
||||
scheduleMoveTimer();
|
||||
if (auto* buzzer = context.buzzer())
|
||||
buzzer->beepMove();
|
||||
} else {
|
||||
snake.pop_back();
|
||||
}
|
||||
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool isCollision(const Point& nextHead) const {
|
||||
if (nextHead.x < 0 || nextHead.x >= kBoardWidth || nextHead.y < 0 || nextHead.y >= kBoardHeight)
|
||||
return true;
|
||||
return std::find(snake.begin(), snake.end(), nextHead) != snake.end();
|
||||
}
|
||||
|
||||
void onGameOver() {
|
||||
if (gameOver)
|
||||
return;
|
||||
gameOver = true;
|
||||
cancelMoveTimer();
|
||||
dirty = true;
|
||||
if (auto* buzzer = context.buzzer())
|
||||
buzzer->beepGameOver();
|
||||
}
|
||||
|
||||
void queueDirection(Direction next) {
|
||||
if (isOpposite(direction, next) || isOpposite(queuedDirection, next))
|
||||
return;
|
||||
queuedDirection = next;
|
||||
}
|
||||
|
||||
[[nodiscard]] static bool isOpposite(Direction a, Direction b) {
|
||||
if ((a == Direction::Up && b == Direction::Down) || (a == Direction::Down && b == Direction::Up))
|
||||
return true;
|
||||
if ((a == Direction::Left && b == Direction::Right) || (a == Direction::Right && b == Direction::Left))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool spawnFood() {
|
||||
std::vector<Point> freeCells;
|
||||
freeCells.reserve(kBoardWidth * kBoardHeight - static_cast<int>(snake.size()));
|
||||
for (int y = 0; y < kBoardHeight; ++y) {
|
||||
for (int x = 0; x < kBoardWidth; ++x) {
|
||||
Point p{x, y};
|
||||
if (std::find(snake.begin(), snake.end(), p) == snake.end())
|
||||
freeCells.push_back(p);
|
||||
}
|
||||
}
|
||||
if (freeCells.empty())
|
||||
return false;
|
||||
std::uniform_int_distribution<std::size_t> dist(0, freeCells.size() - 1);
|
||||
food = freeCells[dist(rng)];
|
||||
return true;
|
||||
}
|
||||
|
||||
void scheduleMoveTimer() {
|
||||
cancelMoveTimer();
|
||||
const std::uint32_t interval = currentInterval();
|
||||
moveTimer = context.scheduleRepeatingTimer(interval);
|
||||
}
|
||||
|
||||
void cancelMoveTimer() {
|
||||
if (moveTimer != cardboy::sdk::kInvalidAppTimer) {
|
||||
context.cancelTimer(moveTimer);
|
||||
moveTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] std::uint32_t currentInterval() const {
|
||||
int interval = kBaseMoveIntervalMs - static_cast<int>(snake.size()) * kIntervalSpeedupPerSegment;
|
||||
if (interval < kMinMoveIntervalMs)
|
||||
interval = kMinMoveIntervalMs;
|
||||
return static_cast<std::uint32_t>(interval);
|
||||
}
|
||||
|
||||
void updateHighScore() {
|
||||
if (score <= highScore)
|
||||
return;
|
||||
highScore = score;
|
||||
if (auto* storage = context.storage())
|
||||
storage->writeUint32("snake", "best", static_cast<std::uint32_t>(highScore));
|
||||
}
|
||||
|
||||
void loadHighScore() {
|
||||
if (auto* storage = context.storage()) {
|
||||
std::uint32_t stored = 0;
|
||||
if (storage->readUint32("snake", "best", stored))
|
||||
highScore = static_cast<int>(stored);
|
||||
}
|
||||
}
|
||||
|
||||
void renderIfNeeded() {
|
||||
if (!dirty)
|
||||
return;
|
||||
dirty = false;
|
||||
|
||||
framebuffer.frameReady();
|
||||
framebuffer.clear(false);
|
||||
|
||||
drawBoard();
|
||||
drawFood();
|
||||
drawSnake();
|
||||
drawHud();
|
||||
|
||||
framebuffer.sendFrame();
|
||||
}
|
||||
|
||||
[[nodiscard]] int boardOriginX() const {
|
||||
return (cardboy::sdk::kDisplayWidth - kBoardWidth * kCellSize) / 2;
|
||||
}
|
||||
|
||||
[[nodiscard]] int boardOriginY() const {
|
||||
const int centered = (cardboy::sdk::kDisplayHeight - kBoardHeight * kCellSize) / 2;
|
||||
return std::max(24, centered);
|
||||
}
|
||||
|
||||
void drawBoard() {
|
||||
const int originX = boardOriginX();
|
||||
const int originY = boardOriginY();
|
||||
const int width = kBoardWidth * kCellSize;
|
||||
const int height = kBoardHeight * kCellSize;
|
||||
|
||||
const int x0 = originX;
|
||||
const int y0 = originY;
|
||||
const int x1 = originX + width - 1;
|
||||
const int y1 = originY + height - 1;
|
||||
for (int x = x0; x <= x1; ++x) {
|
||||
framebuffer.drawPixel(x, y0, true);
|
||||
framebuffer.drawPixel(x, y1, true);
|
||||
}
|
||||
for (int y = y0; y <= y1; ++y) {
|
||||
framebuffer.drawPixel(x0, y, true);
|
||||
framebuffer.drawPixel(x1, y, true);
|
||||
}
|
||||
}
|
||||
|
||||
void drawSnake() {
|
||||
if (snake.empty())
|
||||
return;
|
||||
std::size_t index = 0;
|
||||
for (const auto& segment: snake) {
|
||||
drawSnakeSegment(segment, index == 0);
|
||||
++index;
|
||||
}
|
||||
}
|
||||
|
||||
void drawSnakeSegment(const Point& segment, bool head) {
|
||||
const int originX = boardOriginX() + segment.x * kCellSize;
|
||||
const int originY = boardOriginY() + segment.y * kCellSize;
|
||||
for (int dy = 0; dy < kCellSize; ++dy) {
|
||||
for (int dx = 0; dx < kCellSize; ++dx) {
|
||||
const bool border = dx == 0 || dy == 0 || dx == kCellSize - 1 || dy == kCellSize - 1;
|
||||
bool fill = ((dx + dy) & 0x1) == 0;
|
||||
if (head)
|
||||
fill = true;
|
||||
const bool on = border || fill;
|
||||
framebuffer.drawPixel(originX + dx, originY + dy, on);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void drawFood() {
|
||||
const int cx = boardOriginX() + food.x * kCellSize + kCellSize / 2;
|
||||
const int cy = boardOriginY() + food.y * kCellSize + kCellSize / 2;
|
||||
const int r = std::max(2, kCellSize / 2 - 1);
|
||||
for (int dy = -r; dy <= r; ++dy) {
|
||||
for (int dx = -r; dx <= r; ++dx) {
|
||||
if (std::abs(dx) + std::abs(dy) <= r)
|
||||
framebuffer.drawPixel(cx + dx, cy + dy, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void drawHud() {
|
||||
const int margin = 12;
|
||||
const int textY = 8;
|
||||
const std::string scoreStr = "SCORE " + std::to_string(score);
|
||||
const std::string bestStr = "BEST " + std::to_string(highScore);
|
||||
font16x8::drawText(framebuffer, margin, textY, scoreStr, 1, true, 1);
|
||||
const int bestX = cardboy::sdk::kDisplayWidth - font16x8::measureText(bestStr, 1, 1) - margin;
|
||||
font16x8::drawText(framebuffer, bestX, textY, bestStr, 1, true, 1);
|
||||
|
||||
const int footerY = cardboy::sdk::kDisplayHeight - 24;
|
||||
const std::string menuStr = "B MENU";
|
||||
const std::string selectStr = "SELECT RESET";
|
||||
const std::string startStr = "START PAUSE";
|
||||
const int selectX = (cardboy::sdk::kDisplayWidth - font16x8::measureText(selectStr, 1, 1)) / 2;
|
||||
const int startX = cardboy::sdk::kDisplayWidth - font16x8::measureText(startStr, 1, 1) - margin;
|
||||
font16x8::drawText(framebuffer, margin, footerY, menuStr, 1, true, 1);
|
||||
font16x8::drawText(framebuffer, selectX, footerY, selectStr, 1, true, 1);
|
||||
font16x8::drawText(framebuffer, startX, footerY, startStr, 1, true, 1);
|
||||
|
||||
if (paused && !gameOver)
|
||||
drawBanner("PAUSED");
|
||||
else if (gameOver)
|
||||
drawBanner("GAME OVER");
|
||||
}
|
||||
|
||||
void drawBanner(std::string_view text) {
|
||||
const int w = font16x8::measureText(text, 2, 1);
|
||||
const int h = font16x8::kGlyphHeight * 2;
|
||||
const int x = (cardboy::sdk::kDisplayWidth - w) / 2;
|
||||
const int y = boardOriginY() + kBoardHeight * kCellSize / 2 - h / 2;
|
||||
for (int yy = -4; yy < h + 4; ++yy)
|
||||
for (int xx = -6; xx < w + 6; ++xx)
|
||||
framebuffer.drawPixel(x + xx, y + yy, yy == -4 || yy == h + 3 || xx == -6 || xx == w + 5);
|
||||
font16x8::drawText(framebuffer, x, y, text, 2, true, 1);
|
||||
}
|
||||
};
|
||||
|
||||
class SnakeApp final : public cardboy::sdk::IApp {
|
||||
public:
|
||||
explicit SnakeApp(AppContext& ctx) : game(ctx) {}
|
||||
|
||||
void onStart() override { game.onStart(); }
|
||||
void onStop() override { game.onStop(); }
|
||||
void handleEvent(const AppEvent& event) override { game.handleEvent(event); }
|
||||
|
||||
private:
|
||||
SnakeGame game;
|
||||
};
|
||||
|
||||
class SnakeFactory final : public cardboy::sdk::IAppFactory {
|
||||
public:
|
||||
const char* name() const override { return kSnakeAppName; }
|
||||
std::unique_ptr<cardboy::sdk::IApp> create(AppContext& context) override {
|
||||
return std::make_unique<SnakeApp>(context);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<cardboy::sdk::IAppFactory> createSnakeAppFactory() { return std::make_unique<SnakeFactory>(); }
|
||||
|
||||
} // namespace apps
|
||||
9
Firmware/sdk/apps/tetris/CMakeLists.txt
Normal file
9
Firmware/sdk/apps/tetris/CMakeLists.txt
Normal 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
|
||||
)
|
||||
12
Firmware/sdk/apps/tetris/include/cardboy/apps/tetris_app.hpp
Normal file
12
Firmware/sdk/apps/tetris/include/cardboy/apps/tetris_app.hpp
Normal 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
|
||||
|
||||
657
Firmware/sdk/apps/tetris/src/tetris_app.cpp
Normal file
657
Firmware/sdk/apps/tetris/src/tetris_app.cpp
Normal 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
|
||||
24
Firmware/sdk/backend_interface/CMakeLists.txt
Normal file
24
Firmware/sdk/backend_interface/CMakeLists.txt
Normal 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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
namespace cardboy::sdk {
|
||||
|
||||
inline constexpr int kDisplayWidth = 400;
|
||||
inline constexpr int kDisplayHeight = 240;
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
namespace cardboy::sdk {
|
||||
|
||||
struct InputState {
|
||||
bool up = false;
|
||||
bool left = false;
|
||||
bool right = false;
|
||||
bool down = false;
|
||||
bool a = false;
|
||||
bool b = false;
|
||||
bool select = false;
|
||||
bool start = false;
|
||||
};
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
namespace cardboy::sdk {
|
||||
|
||||
class ILoopHooks {
|
||||
public:
|
||||
virtual ~ILoopHooks() = default;
|
||||
virtual void onLoopIteration() = 0;
|
||||
};
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
137
Firmware/sdk/backend_interface/include/cardboy/sdk/platform.hpp
Normal file
137
Firmware/sdk/backend_interface/include/cardboy/sdk/platform.hpp
Normal file
@@ -0,0 +1,137 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/sdk/framebuffer_hooks.hpp"
|
||||
#include "input_state.hpp"
|
||||
|
||||
#include <concepts>
|
||||
#include <cstdint>
|
||||
|
||||
namespace cardboy::sdk {
|
||||
|
||||
namespace detail {
|
||||
template<typename Impl>
|
||||
concept HasClearImpl = requires(Impl& impl, bool value) {
|
||||
{ impl.clear_impl(value) };
|
||||
};
|
||||
|
||||
template<typename Impl>
|
||||
concept HasFrameReadyImpl = requires(Impl& impl) {
|
||||
{ impl.frameReady_impl() };
|
||||
};
|
||||
|
||||
template<typename Impl>
|
||||
concept HasSendFrameImpl = requires(Impl& impl, bool flag) {
|
||||
{ impl.sendFrame_impl(flag) };
|
||||
};
|
||||
|
||||
template<typename Impl>
|
||||
concept HasDrawBits8Impl = requires(Impl& impl, int x, int y, std::uint8_t bits) { impl.drawBits8_impl(x, y, bits); };
|
||||
|
||||
template<typename Impl>
|
||||
concept HasFrameInFlightImpl = requires(const Impl& impl) {
|
||||
{ impl.frameInFlight_impl() } -> std::convertible_to<bool>;
|
||||
};
|
||||
|
||||
template<typename Impl>
|
||||
concept HasSleepMsImpl = requires(Impl& impl, std::uint32_t value) {
|
||||
{ impl.sleep_ms_impl(value) };
|
||||
};
|
||||
} // namespace detail
|
||||
|
||||
template<typename Impl>
|
||||
class FramebufferFacade {
|
||||
public:
|
||||
[[nodiscard]] __attribute__((always_inline)) int width() const { return impl().width_impl(); }
|
||||
[[nodiscard]] __attribute__((always_inline)) int height() const { return impl().height_impl(); }
|
||||
|
||||
__attribute__((always_inline)) void drawPixel(int x, int y, bool on) { impl().drawPixel_impl(x, y, on); }
|
||||
|
||||
__attribute__((always_inline)) void drawBits8(int x, int y, std::uint8_t bits) {
|
||||
if constexpr (detail::HasDrawBits8Impl<Impl>) {
|
||||
impl().drawBits8_impl(x, y, bits);
|
||||
} else {
|
||||
defaultDrawBits8(x, y, bits);
|
||||
}
|
||||
}
|
||||
|
||||
void clear(bool on) {
|
||||
if constexpr (detail::HasClearImpl<Impl>) {
|
||||
impl().clear_impl(on);
|
||||
} else {
|
||||
defaultClear(on);
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((always_inline)) void frameReady() {
|
||||
if constexpr (detail::HasFrameReadyImpl<Impl>)
|
||||
impl().frameReady_impl();
|
||||
}
|
||||
|
||||
__attribute__((always_inline)) void sendFrame(bool clearDrawBuffer = true) {
|
||||
if constexpr (detail::HasSendFrameImpl<Impl>) {
|
||||
FramebufferHooks::invokePreSend(&impl());
|
||||
impl().sendFrame_impl(clearDrawBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] __attribute__((always_inline)) bool isFrameInFlight() const {
|
||||
if constexpr (detail::HasFrameInFlightImpl<Impl>)
|
||||
return impl().frameInFlight_impl();
|
||||
return false;
|
||||
}
|
||||
|
||||
protected:
|
||||
FramebufferFacade() = default;
|
||||
~FramebufferFacade() = default;
|
||||
|
||||
private:
|
||||
[[nodiscard]] __attribute__((always_inline)) Impl& impl() { return static_cast<Impl&>(*this); }
|
||||
[[nodiscard]] __attribute__((always_inline)) const Impl& impl() const { return static_cast<const Impl&>(*this); }
|
||||
|
||||
void defaultClear(bool on) {
|
||||
for (int y = 0; y < height(); ++y)
|
||||
for (int x = 0; x < width(); ++x)
|
||||
drawPixel(x, y, on);
|
||||
}
|
||||
|
||||
void defaultDrawBits8(int x, int y, std::uint8_t bits) {
|
||||
for (int col = 0; col < 8; ++col) {
|
||||
const std::uint8_t mask = static_cast<std::uint8_t>(1u << (7 - col));
|
||||
const bool pixelOn = (bits & mask) != 0;
|
||||
drawPixel(x + col, y, pixelOn);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
template<typename Impl>
|
||||
class InputFacade {
|
||||
public:
|
||||
InputState readState() { return impl().readState_impl(); }
|
||||
|
||||
protected:
|
||||
InputFacade() = default;
|
||||
~InputFacade() = default;
|
||||
|
||||
private:
|
||||
[[nodiscard]] Impl& impl() { return static_cast<Impl&>(*this); }
|
||||
};
|
||||
|
||||
template<typename Impl>
|
||||
class ClockFacade {
|
||||
public:
|
||||
std::uint32_t millis() { return impl().millis_impl(); }
|
||||
|
||||
void sleep_ms(std::uint32_t ms) {
|
||||
if constexpr (detail::HasSleepMsImpl<Impl>)
|
||||
impl().sleep_ms_impl(ms);
|
||||
}
|
||||
|
||||
protected:
|
||||
ClockFacade() = default;
|
||||
~ClockFacade() = default;
|
||||
|
||||
private:
|
||||
[[nodiscard]] Impl& impl() { return static_cast<Impl&>(*this); }
|
||||
};
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
106
Firmware/sdk/backend_interface/include/cardboy/sdk/services.hpp
Normal file
106
Firmware/sdk/backend_interface/include/cardboy/sdk/services.hpp
Normal file
@@ -0,0 +1,106 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/sdk/event_bus.hpp"
|
||||
#include "cardboy/sdk/loop_hooks.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace cardboy::sdk {
|
||||
|
||||
class IBuzzer {
|
||||
public:
|
||||
virtual ~IBuzzer() = default;
|
||||
|
||||
virtual void init() {}
|
||||
virtual void tone(std::uint32_t freq, std::uint32_t duration_ms, std::uint32_t gap_ms = 0) = 0;
|
||||
|
||||
virtual void beepRotate() {}
|
||||
virtual void beepMove() {}
|
||||
virtual void beepLock() {}
|
||||
virtual void beepLines(int /*lines*/) {}
|
||||
virtual void beepLevelUp(int /*level*/) {}
|
||||
virtual void beepGameOver() {}
|
||||
|
||||
virtual void setMuted(bool /*muted*/) {}
|
||||
virtual void toggleMuted() {}
|
||||
[[nodiscard]] virtual bool isMuted() const { return false; }
|
||||
};
|
||||
|
||||
class IBatteryMonitor {
|
||||
public:
|
||||
virtual ~IBatteryMonitor() = default;
|
||||
|
||||
[[nodiscard]] virtual bool hasData() const { return false; }
|
||||
[[nodiscard]] virtual float voltage() const { return 0.0f; }
|
||||
[[nodiscard]] virtual float charge() const { return 0.0f; }
|
||||
[[nodiscard]] virtual float current() const { return 0.0f; }
|
||||
};
|
||||
|
||||
class IStorage {
|
||||
public:
|
||||
virtual ~IStorage() = default;
|
||||
|
||||
[[nodiscard]] virtual bool readUint32(std::string_view ns, std::string_view key, std::uint32_t& out) = 0;
|
||||
virtual void writeUint32(std::string_view ns, std::string_view key, std::uint32_t value) = 0;
|
||||
};
|
||||
|
||||
class IRandom {
|
||||
public:
|
||||
virtual ~IRandom() = default;
|
||||
|
||||
[[nodiscard]] virtual std::uint32_t nextUint32() = 0;
|
||||
};
|
||||
|
||||
class IHighResClock {
|
||||
public:
|
||||
virtual ~IHighResClock() = default;
|
||||
|
||||
[[nodiscard]] virtual std::uint64_t micros() = 0;
|
||||
};
|
||||
|
||||
class IFilesystem {
|
||||
public:
|
||||
virtual ~IFilesystem() = default;
|
||||
|
||||
virtual bool mount() = 0;
|
||||
[[nodiscard]] virtual bool isMounted() const = 0;
|
||||
[[nodiscard]] virtual std::string basePath() const = 0;
|
||||
};
|
||||
|
||||
class INotificationCenter {
|
||||
public:
|
||||
struct Notification {
|
||||
std::uint64_t id = 0;
|
||||
std::uint64_t timestamp = 0;
|
||||
std::uint64_t externalId = 0;
|
||||
std::string title;
|
||||
std::string body;
|
||||
bool unread = true;
|
||||
};
|
||||
|
||||
virtual ~INotificationCenter() = default;
|
||||
|
||||
virtual void pushNotification(Notification notification) = 0;
|
||||
[[nodiscard]] virtual std::uint32_t revision() const = 0;
|
||||
[[nodiscard]] virtual std::vector<Notification> recent(std::size_t limit) const = 0;
|
||||
virtual void markAllRead() = 0;
|
||||
virtual void clear() = 0;
|
||||
virtual void removeByExternalId(std::uint64_t externalId) = 0;
|
||||
};
|
||||
|
||||
struct Services {
|
||||
IBuzzer* buzzer = nullptr;
|
||||
IBatteryMonitor* battery = nullptr;
|
||||
IStorage* storage = nullptr;
|
||||
IRandom* random = nullptr;
|
||||
IHighResClock* highResClock = nullptr;
|
||||
IFilesystem* filesystem = nullptr;
|
||||
IEventBus* eventBus = nullptr;
|
||||
ILoopHooks* loopHooks = nullptr;
|
||||
INotificationCenter* notifications = nullptr;
|
||||
};
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user