mirror of
https://github.com/usatiuk/cardboy.git
synced 2025-10-29 07:37:48 +01:00
Compare commits
32 Commits
eeedc629d7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bb033a6ca | |||
| 4d0eb3d187 | |||
| 5532055cdc | |||
| 61f05b4e58 | |||
| 961da2ba33 | |||
| 96f5b1f0ee | |||
| f5a780c1c8 | |||
| 5c3cdaaae4 | |||
| f814c45532 | |||
| 65ee33a141 | |||
| 0e69debf39 | |||
| 9b5521fc28 | |||
| 278e822600 | |||
| 844cf86d8d | |||
| f8735d4bce | |||
| 1ee132898b | |||
| 5ddd38e5d7 | |||
| 4112efd60b | |||
| 678158c302 | |||
| 12e8a0e098 | |||
| fc633d7c90 | |||
| e8ae1cbec4 | |||
| b72ea4f417 | |||
| bf0ffe8632 | |||
| 96bfaaf64b | |||
| cf5a848741 | |||
| 7c492627f0 | |||
| be2629a008 | |||
| 016629eb82 | |||
| de1ac0e7a2 | |||
| 3ab2a7bf26 | |||
| b4f11851d7 |
4
Firmware/.vscode/settings.json
vendored
4
Firmware/.vscode/settings.json
vendored
@@ -80,6 +80,8 @@
|
||||
"thread": "cpp",
|
||||
"cinttypes": "cpp",
|
||||
"typeinfo": "cpp",
|
||||
"variant": "cpp"
|
||||
"variant": "cpp",
|
||||
"ranges": "cpp",
|
||||
"shared_mutex": "cpp"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
To build:
|
||||
(in zsh)
|
||||
(in zsh, bash doesn't work)
|
||||
. "$HOME/esp/esp-idf/export.sh"
|
||||
idf.py build
|
||||
@@ -25,5 +25,65 @@ This SwiftUI app connects to the Cardboy device over Bluetooth Low Energy and up
|
||||
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.
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
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;
|
||||
@@ -304,6 +305,7 @@
|
||||
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;
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "cardboy-icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
|
||||
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 |
@@ -1,7 +1,48 @@
|
||||
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" }
|
||||
@@ -36,6 +77,11 @@ struct ContentView: View {
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Button(action: manager.sendTestNotification) {
|
||||
Label("Send Test Notification", systemImage: "bell.badge.waveform")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
@@ -52,6 +98,450 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -6,5 +6,7 @@
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
</array>
|
||||
<key>NSUserNotificationUsageDescription</key>
|
||||
<string>Allow Cardboy Companion to send local notifications for testing.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import Combine
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
import UserNotifications
|
||||
|
||||
final class TimeSyncManager: NSObject, ObservableObject {
|
||||
final class TimeSyncManager: NSObject, ObservableObject, UNUserNotificationCenterDelegate {
|
||||
enum ConnectionState: String {
|
||||
case idle = "Idle"
|
||||
case scanning = "Scanning"
|
||||
@@ -12,12 +14,82 @@ final class TimeSyncManager: NSObject, ObservableObject {
|
||||
case failed = "Failed"
|
||||
}
|
||||
|
||||
struct RemoteFileEntry: Identifiable, Hashable {
|
||||
enum EntryType: Hashable {
|
||||
case file(size: UInt32)
|
||||
case directory
|
||||
}
|
||||
|
||||
let path: String
|
||||
let name: String
|
||||
let type: EntryType
|
||||
|
||||
var id: String { path }
|
||||
|
||||
var isDirectory: Bool {
|
||||
if case .directory = type {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var size: UInt32 {
|
||||
if case let .file(size) = type {
|
||||
return size
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
enum FileServiceError: Error, LocalizedError {
|
||||
case characteristicUnavailable
|
||||
case busy
|
||||
case remoteError(code: Int, message: String?)
|
||||
case invalidResponse
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .characteristicUnavailable:
|
||||
return "File service is not ready yet."
|
||||
case .busy:
|
||||
return "Another file operation is already running."
|
||||
case let .remoteError(code, message):
|
||||
if let message, !message.isEmpty {
|
||||
return "\(message) (code \(code))"
|
||||
}
|
||||
return "Remote error (code \(code))"
|
||||
case .invalidResponse:
|
||||
return "Received an invalid response from the device."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var connectionState: ConnectionState = .idle
|
||||
@Published private(set) var statusMessage: String = "Waiting for Bluetooth…"
|
||||
@Published private(set) var lastSyncDate: Date?
|
||||
|
||||
private let serviceUUID = CBUUID(string: "00000001-CA7B-4EFD-B5A6-10C3F4D3F230")
|
||||
private let characteristicUUID = CBUUID(string: "00000002-CA7B-4EFD-B5A6-10C3F4D3F231")
|
||||
@Published private(set) var currentDirectory: String = "/"
|
||||
@Published private(set) var directoryEntries: [RemoteFileEntry] = []
|
||||
@Published private(set) var isFileBusy: Bool = false
|
||||
@Published var fileErrorMessage: String?
|
||||
@Published var downloadedFileURL: URL?
|
||||
@Published private(set) var activeFileOperation: FileOperationProgress?
|
||||
|
||||
struct FileOperationProgress: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let message: String
|
||||
let progress: Double?
|
||||
let indeterminate: Bool
|
||||
}
|
||||
|
||||
private let timeServiceUUID = CBUUID(string: "00000001-CA7B-4EFD-B5A6-10C3F4D3F230")
|
||||
private let timeCharacteristicUUID = CBUUID(string: "00000002-CA7B-4EFD-B5A6-10C3F4D3F231")
|
||||
|
||||
private let fileServiceUUID = CBUUID(string: "00000010-CA7B-4EFD-B5A6-10C3F4D3F230")
|
||||
private let fileCommandUUID = CBUUID(string: "00000011-CA7B-4EFD-B5A6-10C3F4D3F231")
|
||||
private let fileResponseUUID = CBUUID(string: "00000012-CA7B-4EFD-B5A6-10C3F4D3F232")
|
||||
private let responseFlagComplete: UInt8 = 0x80
|
||||
|
||||
private lazy var central: CBCentralManager = CBCentralManager(
|
||||
delegate: self,
|
||||
@@ -30,25 +102,77 @@ final class TimeSyncManager: NSObject, ObservableObject {
|
||||
|
||||
private var targetPeripheral: CBPeripheral?
|
||||
private var timeCharacteristic: CBCharacteristic?
|
||||
private var fileCommandCharacteristic: CBCharacteristic?
|
||||
private var fileResponseCharacteristic: CBCharacteristic?
|
||||
|
||||
private var retryWorkItem: DispatchWorkItem?
|
||||
private var shouldKeepScanning = true
|
||||
private var isScanning = false
|
||||
|
||||
private var fileCommandQueue: [Data] = []
|
||||
private var isWritingFileCommand = false
|
||||
|
||||
private var pendingListData = Data()
|
||||
private var pendingListPath: String?
|
||||
private var pendingListOperationID: UUID?
|
||||
private var simpleOperationID: UUID?
|
||||
private var pendingDirectoryRequest: (path: String, operationID: UUID)?
|
||||
private let notificationCenter = UNUserNotificationCenter.current()
|
||||
|
||||
private struct UploadState {
|
||||
let id: UUID
|
||||
var remotePath: String
|
||||
var data: Data
|
||||
var offset: Int = 0
|
||||
var awaitingChunkAck: Bool = false
|
||||
var completion: (Result<Void, Error>) -> Void
|
||||
}
|
||||
private var uploadState: UploadState?
|
||||
|
||||
private struct DownloadState {
|
||||
let id: UUID
|
||||
var remotePath: String
|
||||
var expectedSize: Int?
|
||||
var data = Data()
|
||||
var completion: (Result<URL, Error>) -> Void
|
||||
}
|
||||
private var downloadState: DownloadState?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
// Force central manager to initialise immediately so state updates arrive right away.
|
||||
_ = central
|
||||
|
||||
// Ensure we can present notifications while app is foreground
|
||||
notificationCenter.delegate = self
|
||||
|
||||
if central.state == .poweredOn {
|
||||
startScanning()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
retryWorkItem?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Public BLE Controls
|
||||
|
||||
func forceRescan() {
|
||||
statusMessage = "Restarting scan…"
|
||||
shouldKeepScanning = true
|
||||
retryWorkItem?.cancel()
|
||||
|
||||
let existingPeripheral = targetPeripheral
|
||||
stopScanning()
|
||||
if let existingPeripheral {
|
||||
central.cancelPeripheralConnection(existingPeripheral)
|
||||
}
|
||||
|
||||
targetPeripheral = nil
|
||||
timeCharacteristic = nil
|
||||
fileCommandCharacteristic = nil
|
||||
fileResponseCharacteristic = nil
|
||||
resetFileStateOnDisconnect()
|
||||
startScanning()
|
||||
}
|
||||
|
||||
@@ -75,19 +199,231 @@ final class TimeSyncManager: NSObject, ObservableObject {
|
||||
payload.append(isDst)
|
||||
payload.append(UInt8(0)) // Reserved byte
|
||||
|
||||
connectionState = .ready
|
||||
statusMessage = "Sending current time…"
|
||||
peripheral.writeValue(payload, for: characteristic, type: .withResponse)
|
||||
lastSyncDate = now
|
||||
connectionState = .ready
|
||||
let timeString = DateFormatter.localizedString(from: now, dateStyle: .none, timeStyle: .medium)
|
||||
statusMessage = "Time synced at \(timeString)."
|
||||
}
|
||||
|
||||
func sendTestNotification() {
|
||||
func scheduleNotification() {
|
||||
DispatchQueue.main.async {
|
||||
self.statusMessage = "Scheduling test notification…"
|
||||
}
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Cardboy Test Notification"
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .medium
|
||||
content.body = "Triggered at \(formatter.string(from: Date()))"
|
||||
content.sound = UNNotificationSound.default
|
||||
|
||||
// Schedule slightly later so the notification is visible when the app is foreground
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
|
||||
let request = UNNotificationRequest(identifier: "cardboy-test-\(UUID().uuidString)",
|
||||
content: content,
|
||||
trigger: trigger)
|
||||
|
||||
notificationCenter.add(request) { error in
|
||||
DispatchQueue.main.async {
|
||||
if let error {
|
||||
self.statusMessage = "Failed to schedule test notification: \(error.localizedDescription)"
|
||||
} else {
|
||||
self.statusMessage = "Test notification scheduled."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestPermissionAndSchedule() {
|
||||
notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
if let error {
|
||||
DispatchQueue.main.async {
|
||||
self.statusMessage = "Notification permission error: \(error.localizedDescription)"
|
||||
}
|
||||
return
|
||||
}
|
||||
if granted {
|
||||
scheduleNotification()
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.statusMessage = "Enable notifications in Settings to test."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notificationCenter.getNotificationSettings { settings in
|
||||
switch settings.authorizationStatus {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
scheduleNotification()
|
||||
case .notDetermined:
|
||||
requestPermissionAndSchedule()
|
||||
case .denied:
|
||||
DispatchQueue.main.async {
|
||||
self.statusMessage = "Enable notifications in Settings to test."
|
||||
}
|
||||
@unknown default:
|
||||
requestPermissionAndSchedule()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File operations exposed to UI
|
||||
|
||||
func refreshDirectory() {
|
||||
if isFileBusy {
|
||||
pendingDirectoryRequest = (path: currentDirectory, operationID: pendingDirectoryRequest?.operationID ?? UUID())
|
||||
return
|
||||
}
|
||||
requestDirectory(path: currentDirectory)
|
||||
}
|
||||
|
||||
func enter(directory entry: RemoteFileEntry) {
|
||||
guard entry.isDirectory else { return }
|
||||
changeDirectory(to: NSString(string: currentDirectory).appendingPathComponent(entry.name))
|
||||
}
|
||||
|
||||
func navigateUp() {
|
||||
guard currentDirectory != "/" else { return }
|
||||
changeDirectory(to: (currentDirectory as NSString).deletingLastPathComponent)
|
||||
}
|
||||
|
||||
func changeDirectory(to path: String) {
|
||||
let normalized = normalizedPath(path)
|
||||
currentDirectory = normalized
|
||||
requestDirectory(path: normalized)
|
||||
}
|
||||
|
||||
func uploadFile(from url: URL, suggestedName: String? = nil, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard uploadState == nil else {
|
||||
completion(.failure(FileServiceError.busy))
|
||||
return
|
||||
}
|
||||
guard let commandCharacteristic = fileCommandCharacteristic else {
|
||||
completion(.failure(FileServiceError.characteristicUnavailable))
|
||||
return
|
||||
}
|
||||
|
||||
let fileData: Data
|
||||
let shouldStopAccessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if shouldStopAccessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
fileData = try Data(contentsOf: url)
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
let name = suggestedName ?? url.lastPathComponent
|
||||
let remotePath = normalizedPath(NSString(string: currentDirectory).appendingPathComponent(name))
|
||||
|
||||
let opID = UUID()
|
||||
uploadState = UploadState(id: opID, remotePath: remotePath, data: fileData, completion: completion)
|
||||
isFileBusy = true
|
||||
updateOperation(id: opID, title: "Uploading", message: remotePath, progress: 0.0)
|
||||
|
||||
let payload = payloadFor(path: remotePath, extra: UInt32(fileData.count).littleEndianBytes)
|
||||
enqueueFileCommand(.uploadBegin, payload: payload, characteristic: commandCharacteristic)
|
||||
}
|
||||
|
||||
func delete(entry: RemoteFileEntry) {
|
||||
let opcode: FileCommand = entry.isDirectory ? .deleteDirectory : .deleteFile
|
||||
guard let commandCharacteristic = fileCommandCharacteristic else {
|
||||
fileErrorMessage = FileServiceError.characteristicUnavailable.localizedDescription
|
||||
return
|
||||
}
|
||||
let payload = payloadFor(path: entry.path)
|
||||
isFileBusy = true
|
||||
let opID = UUID()
|
||||
let title = entry.isDirectory ? "Deleting Folder" : "Deleting File"
|
||||
updateOperation(id: opID, title: title, message: entry.path, progress: nil, indeterminate: true)
|
||||
simpleOperationID = opID
|
||||
enqueueFileCommand(opcode, payload: payload, characteristic: commandCharacteristic)
|
||||
}
|
||||
|
||||
func createDirectory(named name: String) {
|
||||
guard let commandCharacteristic = fileCommandCharacteristic else {
|
||||
fileErrorMessage = FileServiceError.characteristicUnavailable.localizedDescription
|
||||
return
|
||||
}
|
||||
let remotePath = normalizedPath(NSString(string: currentDirectory).appendingPathComponent(name))
|
||||
let payload = payloadFor(path: remotePath)
|
||||
isFileBusy = true
|
||||
let opID = UUID()
|
||||
updateOperation(id: opID, title: "Creating Folder", message: remotePath, progress: nil, indeterminate: true)
|
||||
simpleOperationID = opID
|
||||
enqueueFileCommand(.createDirectory, payload: payload, characteristic: commandCharacteristic)
|
||||
}
|
||||
|
||||
func rename(entry: RemoteFileEntry, to newName: String) {
|
||||
guard let commandCharacteristic = fileCommandCharacteristic else {
|
||||
fileErrorMessage = FileServiceError.characteristicUnavailable.localizedDescription
|
||||
return
|
||||
}
|
||||
|
||||
let srcPath = entry.path
|
||||
let dstPath = normalizedPath(NSString(string: (srcPath as NSString).deletingLastPathComponent).appendingPathComponent(newName))
|
||||
|
||||
var payload = Data()
|
||||
payload.appendPath(srcPath)
|
||||
payload.appendPath(dstPath)
|
||||
|
||||
isFileBusy = true
|
||||
let opID = UUID()
|
||||
updateOperation(id: opID, title: "Renaming", message: srcPath, progress: nil, indeterminate: true)
|
||||
simpleOperationID = opID
|
||||
enqueueFileCommand(.renamePath, payload: payload, characteristic: commandCharacteristic)
|
||||
}
|
||||
|
||||
func download(entry: RemoteFileEntry, completion: @escaping (Result<URL, Error>) -> Void) {
|
||||
guard !entry.isDirectory else {
|
||||
completion(.failure(FileServiceError.busy))
|
||||
return
|
||||
}
|
||||
guard downloadState == nil else {
|
||||
completion(.failure(FileServiceError.busy))
|
||||
return
|
||||
}
|
||||
guard let commandCharacteristic = fileCommandCharacteristic else {
|
||||
completion(.failure(FileServiceError.characteristicUnavailable))
|
||||
return
|
||||
}
|
||||
|
||||
let opID = UUID()
|
||||
downloadState = DownloadState(id: opID, remotePath: entry.path, expectedSize: nil, data: Data(), completion: completion)
|
||||
isFileBusy = true
|
||||
updateOperation(id: opID, title: "Downloading", message: entry.path, progress: nil, indeterminate: true)
|
||||
let payload = payloadFor(path: entry.path)
|
||||
enqueueFileCommand(.downloadRequest, payload: payload, characteristic: commandCharacteristic)
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private func startScanning() {
|
||||
guard shouldKeepScanning, central.state == .poweredOn else { return }
|
||||
if isScanning { return }
|
||||
|
||||
central.scanForPeripherals(withServices: [serviceUUID], options: [
|
||||
let connected = central.retrieveConnectedPeripherals(withServices: [timeServiceUUID, fileServiceUUID])
|
||||
if let restored = connected.first {
|
||||
statusMessage = "Restoring connection…"
|
||||
connectionState = .connecting
|
||||
targetPeripheral = restored
|
||||
restored.delegate = self
|
||||
central.connect(restored, options: nil)
|
||||
shouldKeepScanning = false
|
||||
return
|
||||
}
|
||||
|
||||
central.scanForPeripherals(withServices: [timeServiceUUID, fileServiceUUID], options: [
|
||||
CBCentralManagerScanOptionAllowDuplicatesKey: false
|
||||
])
|
||||
|
||||
isScanning = true
|
||||
connectionState = .scanning
|
||||
statusMessage = "Scanning for Cardboy…"
|
||||
@@ -108,8 +444,372 @@ final class TimeSyncManager: NSObject, ObservableObject {
|
||||
retryWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
|
||||
}
|
||||
|
||||
private enum FileCommand: UInt8 {
|
||||
case listDirectory = 0x01
|
||||
case uploadBegin = 0x02
|
||||
case uploadChunk = 0x03
|
||||
case uploadEnd = 0x04
|
||||
case downloadRequest = 0x05
|
||||
case deleteFile = 0x06
|
||||
case createDirectory = 0x07
|
||||
case deleteDirectory = 0x08
|
||||
case renamePath = 0x09
|
||||
}
|
||||
|
||||
private func enqueueFileCommand(_ opcode: FileCommand, payload: Data, characteristic: CBCharacteristic) {
|
||||
var packet = Data()
|
||||
packet.append(opcode.rawValue)
|
||||
packet.append(UInt8(0))
|
||||
packet.append(UInt8(payload.count & 0xFF))
|
||||
packet.append(UInt8((payload.count >> 8) & 0xFF))
|
||||
packet.append(payload)
|
||||
|
||||
fileCommandQueue.append(packet)
|
||||
processFileCommandQueue(characteristic: characteristic)
|
||||
}
|
||||
|
||||
private func processFileCommandQueue(characteristic: CBCharacteristic? = nil) {
|
||||
guard !isWritingFileCommand else { return }
|
||||
guard let characteristic = characteristic ?? fileCommandCharacteristic else { return }
|
||||
guard !fileCommandQueue.isEmpty, let peripheral = targetPeripheral else { return }
|
||||
|
||||
let packet = fileCommandQueue.removeFirst()
|
||||
isWritingFileCommand = true
|
||||
peripheral.writeValue(packet, for: characteristic, type: .withResponse)
|
||||
}
|
||||
|
||||
private func resetFileStateOnDisconnect() {
|
||||
fileCommandQueue.removeAll()
|
||||
isWritingFileCommand = false
|
||||
uploadState = nil
|
||||
downloadState = nil
|
||||
pendingListData.removeAll()
|
||||
pendingListPath = nil
|
||||
pendingListOperationID = nil
|
||||
simpleOperationID = nil
|
||||
pendingDirectoryRequest = nil
|
||||
isFileBusy = false
|
||||
currentDirectory = "/"
|
||||
directoryEntries = []
|
||||
clearOperation()
|
||||
}
|
||||
|
||||
private func payloadFor(path: String, extra: [UInt8]? = nil) -> Data {
|
||||
var data = Data()
|
||||
data.appendPath(path)
|
||||
if let extra {
|
||||
data.append(contentsOf: extra)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private func updateOperation(id: UUID, title: String, message: String, progress: Double?, indeterminate: Bool = false) {
|
||||
DispatchQueue.main.async {
|
||||
self.activeFileOperation = FileOperationProgress(id: id, title: title, message: message, progress: progress, indeterminate: indeterminate)
|
||||
}
|
||||
}
|
||||
|
||||
private func clearOperation(ifMatches id: UUID? = nil) {
|
||||
DispatchQueue.main.async {
|
||||
if let id, self.activeFileOperation?.id != id {
|
||||
return
|
||||
}
|
||||
self.activeFileOperation = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizedPath(_ path: String) -> String {
|
||||
var normalized = path
|
||||
if normalized.isEmpty {
|
||||
normalized = "/"
|
||||
}
|
||||
if !normalized.hasPrefix("/") {
|
||||
normalized = "/" + normalized
|
||||
}
|
||||
if normalized.count > 1 && normalized.hasSuffix("/") {
|
||||
normalized.removeLast()
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private func handleFileResponse(_ data: Data) {
|
||||
guard data.count >= 4 else {
|
||||
fileErrorMessage = FileServiceError.invalidResponse.localizedDescription
|
||||
return
|
||||
}
|
||||
|
||||
let opcodeRaw = data[0]
|
||||
let status = data[1]
|
||||
let length = Int(data[2]) | (Int(data[3]) << 8)
|
||||
guard data.count >= 4 + length else {
|
||||
fileErrorMessage = FileServiceError.invalidResponse.localizedDescription
|
||||
return
|
||||
}
|
||||
|
||||
let payload = data.subdata(in: 4 ..< 4 + length)
|
||||
let isComplete = (status & responseFlagComplete) != 0
|
||||
let errorCode = Int(status & ~responseFlagComplete)
|
||||
|
||||
if errorCode != 0 {
|
||||
let message = String(data: payload, encoding: .utf8)
|
||||
handleFileError(opcodeRaw, code: errorCode, message: message)
|
||||
return
|
||||
}
|
||||
|
||||
guard let opcode = FileCommand(rawValue: opcodeRaw) else { return }
|
||||
|
||||
switch opcode {
|
||||
case .listDirectory:
|
||||
pendingListData.append(payload)
|
||||
if isComplete {
|
||||
let opID = pendingListOperationID
|
||||
finalizeDirectoryListing(path: pendingListPath ?? currentDirectory, data: pendingListData)
|
||||
pendingListData.removeAll()
|
||||
pendingListPath = nil
|
||||
isFileBusy = false
|
||||
pendingListOperationID = nil
|
||||
clearOperation(ifMatches: opID)
|
||||
}
|
||||
case .uploadBegin:
|
||||
if isComplete {
|
||||
sendNextUploadChunk()
|
||||
}
|
||||
case .uploadChunk:
|
||||
if isComplete {
|
||||
uploadState?.awaitingChunkAck = false
|
||||
sendNextUploadChunk()
|
||||
}
|
||||
case .uploadEnd:
|
||||
if isComplete {
|
||||
let opID = uploadState?.id
|
||||
let completion = uploadState?.completion
|
||||
uploadState = nil
|
||||
isFileBusy = false
|
||||
completion?(.success(()))
|
||||
refreshDirectory()
|
||||
clearOperation(ifMatches: opID)
|
||||
}
|
||||
case .downloadRequest:
|
||||
handleDownloadResponse(payload: payload, isComplete: isComplete)
|
||||
case .deleteFile, .createDirectory, .deleteDirectory, .renamePath:
|
||||
if isComplete {
|
||||
isFileBusy = false
|
||||
refreshDirectory()
|
||||
let opID = simpleOperationID
|
||||
simpleOperationID = nil
|
||||
clearOperation(ifMatches: opID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFileError(_ opcodeRaw: UInt8, code: Int, message: String?) {
|
||||
var operationID: UUID?
|
||||
defer {
|
||||
isFileBusy = false
|
||||
clearOperation(ifMatches: operationID)
|
||||
}
|
||||
|
||||
let error = FileServiceError.remoteError(code: code, message: message)
|
||||
fileErrorMessage = error.localizedDescription
|
||||
pendingListData.removeAll()
|
||||
pendingListPath = nil
|
||||
pendingListOperationID = nil
|
||||
|
||||
switch FileCommand(rawValue: opcodeRaw) {
|
||||
case .listDirectory:
|
||||
operationID = pendingListOperationID
|
||||
pendingListOperationID = nil
|
||||
case .uploadBegin, .uploadChunk, .uploadEnd:
|
||||
operationID = uploadState?.id
|
||||
if let completion = uploadState?.completion {
|
||||
completion(.failure(error))
|
||||
}
|
||||
uploadState = nil
|
||||
case .downloadRequest:
|
||||
operationID = downloadState?.id
|
||||
if let completion = downloadState?.completion {
|
||||
completion(.failure(error))
|
||||
}
|
||||
downloadState = nil
|
||||
case .deleteFile, .createDirectory, .deleteDirectory, .renamePath:
|
||||
operationID = simpleOperationID
|
||||
simpleOperationID = nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func finalizeDirectoryListing(path: String, data: Data) {
|
||||
var entries: [RemoteFileEntry] = []
|
||||
var index = data.startIndex
|
||||
while index + 8 <= data.endIndex {
|
||||
let typeRaw = data[index]
|
||||
index += 1 // type
|
||||
index += 1 // reserved
|
||||
let nameLen = Int(data[index]) | (Int(data[index + 1]) << 8)
|
||||
index += 2
|
||||
let size = UInt32(data[index]) |
|
||||
(UInt32(data[index + 1]) << 8) |
|
||||
(UInt32(data[index + 2]) << 16) |
|
||||
(UInt32(data[index + 3]) << 24)
|
||||
index += 4
|
||||
guard index + nameLen <= data.endIndex else { break }
|
||||
let nameData = data[index ..< index + nameLen]
|
||||
index += nameLen
|
||||
guard let name = String(data: nameData, encoding: .utf8) else { continue }
|
||||
|
||||
let fullPath = normalizedPath(NSString(string: path).appendingPathComponent(name))
|
||||
let type: RemoteFileEntry.EntryType = (typeRaw == 1) ? .directory : .file(size: size)
|
||||
entries.append(RemoteFileEntry(path: fullPath, name: name, type: type))
|
||||
}
|
||||
|
||||
directoryEntries = entries.sorted {
|
||||
if $0.isDirectory == $1.isDirectory {
|
||||
return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||
}
|
||||
return $0.isDirectory && !$1.isDirectory
|
||||
}
|
||||
}
|
||||
|
||||
private func sendNextUploadChunk() {
|
||||
guard var state = uploadState else { return }
|
||||
guard let commandCharacteristic = fileCommandCharacteristic,
|
||||
targetPeripheral != nil else { return }
|
||||
|
||||
if state.awaitingChunkAck {
|
||||
uploadState = state
|
||||
return
|
||||
}
|
||||
|
||||
if state.offset >= state.data.count {
|
||||
updateOperation(id: state.id, title: "Uploading", message: state.remotePath, progress: 1.0)
|
||||
print("Upload progress \(state.remotePath): 100%")
|
||||
uploadState = state
|
||||
enqueueFileCommand(.uploadEnd, payload: Data(), characteristic: commandCharacteristic)
|
||||
return
|
||||
}
|
||||
|
||||
let chunkSize = min(140, state.data.count - state.offset)
|
||||
let chunk = state.data[state.offset ..< state.offset + chunkSize]
|
||||
state.offset += chunkSize
|
||||
state.awaitingChunkAck = true
|
||||
let totalBytes = max(state.data.count, 1)
|
||||
let progress = Double(state.offset) / Double(totalBytes)
|
||||
updateOperation(id: state.id, title: "Uploading", message: state.remotePath, progress: progress)
|
||||
print("Upload progress \(state.remotePath): \(Int(progress * 100))%")
|
||||
uploadState = state
|
||||
|
||||
enqueueFileCommand(.uploadChunk, payload: Data(chunk), characteristic: commandCharacteristic)
|
||||
processFileCommandQueue(characteristic: commandCharacteristic)
|
||||
}
|
||||
|
||||
private func handleDownloadResponse(payload: Data, isComplete: Bool) {
|
||||
guard var state = downloadState else { return }
|
||||
|
||||
if state.expectedSize == nil && payload.count == 4 {
|
||||
let size = Int(payload[0]) | (Int(payload[1]) << 8) | (Int(payload[2]) << 16) | (Int(payload[3]) << 24)
|
||||
state.expectedSize = size
|
||||
downloadState = state
|
||||
if size == 0 {
|
||||
updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: 1.0)
|
||||
} else {
|
||||
updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: 0.0)
|
||||
}
|
||||
if isComplete && size == 0 {
|
||||
finishDownload(state: state)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
state.data.append(payload)
|
||||
downloadState = state
|
||||
|
||||
if let expected = state.expectedSize, expected > 0 {
|
||||
let progress = min(Double(state.data.count) / Double(expected), 1.0)
|
||||
updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: progress)
|
||||
print("Download progress \(state.remotePath): \(Int(progress * 100))%")
|
||||
} else {
|
||||
updateOperation(id: state.id, title: "Downloading", message: state.remotePath, progress: nil, indeterminate: true)
|
||||
}
|
||||
|
||||
if isComplete {
|
||||
finishDownload(state: state)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishDownload(state: DownloadState) {
|
||||
var completedState = state
|
||||
defer {
|
||||
downloadState = nil
|
||||
isFileBusy = false
|
||||
clearOperation(ifMatches: completedState.id)
|
||||
}
|
||||
|
||||
updateOperation(id: completedState.id, title: "Download Complete", message: completedState.remotePath, progress: 1.0)
|
||||
print("Download progress \(completedState.remotePath): 100%")
|
||||
|
||||
let filename = NSString(string: completedState.remotePath).lastPathComponent
|
||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
||||
|
||||
do {
|
||||
try completedState.data.write(to: tempURL, options: .atomic)
|
||||
completedState.completion(.success(tempURL))
|
||||
downloadedFileURL = tempURL
|
||||
} catch {
|
||||
completedState.completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
private func requestDirectory(path: String) {
|
||||
let normalizedPath = normalizedPath(path)
|
||||
|
||||
if let commandCharacteristic = fileCommandCharacteristic {
|
||||
let opID: UUID
|
||||
if let pending = pendingDirectoryRequest, pending.path == normalizedPath {
|
||||
opID = pending.operationID
|
||||
pendingDirectoryRequest = nil
|
||||
} else {
|
||||
opID = UUID()
|
||||
}
|
||||
startDirectoryRequest(path: normalizedPath, operationID: opID, characteristic: commandCharacteristic)
|
||||
} else {
|
||||
let opID = pendingDirectoryRequest?.operationID ?? UUID()
|
||||
pendingDirectoryRequest = (path: normalizedPath, operationID: opID)
|
||||
|
||||
if connectionState == .ready {
|
||||
pendingListPath = normalizedPath
|
||||
pendingListData.removeAll()
|
||||
isFileBusy = true
|
||||
pendingListOperationID = opID
|
||||
updateOperation(id: opID, title: "Loading", message: normalizedPath, progress: nil, indeterminate: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func flushPendingDirectoryRequest() -> Bool {
|
||||
guard let pending = pendingDirectoryRequest,
|
||||
let commandCharacteristic = fileCommandCharacteristic else { return false }
|
||||
|
||||
pendingDirectoryRequest = nil
|
||||
startDirectoryRequest(path: pending.path, operationID: pending.operationID, characteristic: commandCharacteristic)
|
||||
return true
|
||||
}
|
||||
|
||||
private func startDirectoryRequest(path: String, operationID: UUID, characteristic: CBCharacteristic) {
|
||||
pendingListPath = path
|
||||
pendingListData.removeAll()
|
||||
isFileBusy = true
|
||||
pendingListOperationID = operationID
|
||||
updateOperation(id: operationID, title: "Loading", message: path, progress: nil, indeterminate: true)
|
||||
let payload = payloadFor(path: path)
|
||||
enqueueFileCommand(.listDirectory, payload: payload, characteristic: characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBCentralManagerDelegate
|
||||
|
||||
extension TimeSyncManager: CBCentralManagerDelegate {
|
||||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
||||
shouldKeepScanning = true
|
||||
@@ -144,15 +844,25 @@ extension TimeSyncManager: CBCentralManagerDelegate {
|
||||
statusMessage = "Turn on Bluetooth to continue."
|
||||
stopScanning()
|
||||
case .poweredOn:
|
||||
startScanning()
|
||||
// If there are peripherals already connected that match our services, restore connection.
|
||||
let connected = central.retrieveConnectedPeripherals(withServices: [timeServiceUUID, fileServiceUUID])
|
||||
if let restored = connected.first {
|
||||
statusMessage = "Restoring connection…"
|
||||
connectionState = .connecting
|
||||
targetPeripheral = restored
|
||||
restored.delegate = self
|
||||
central.connect(restored, options: nil)
|
||||
} else {
|
||||
startScanning()
|
||||
}
|
||||
@unknown default:
|
||||
connectionState = .failed
|
||||
statusMessage = "Unknown Bluetooth state."
|
||||
}
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any],
|
||||
rssi RSSI: NSNumber) {
|
||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||
statusMessage = "Found \(peripheral.name ?? "device"), connecting…"
|
||||
connectionState = .connecting
|
||||
shouldKeepScanning = false
|
||||
@@ -165,27 +875,31 @@ extension TimeSyncManager: CBCentralManagerDelegate {
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
statusMessage = "Connected. Discovering services…"
|
||||
connectionState = .discovering
|
||||
peripheral.discoverServices([serviceUUID])
|
||||
peripheral.discoverServices([timeServiceUUID, fileServiceUUID])
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||
statusMessage = "Failed to connect. \(error?.localizedDescription ?? "")"
|
||||
connectionState = .failed
|
||||
targetPeripheral = nil
|
||||
timeCharacteristic = nil
|
||||
resetFileStateOnDisconnect()
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||
let reason = error?.localizedDescription ?? "Disconnected."
|
||||
statusMessage = reason
|
||||
statusMessage = "Disconnected. \(error?.localizedDescription ?? "")"
|
||||
connectionState = .idle
|
||||
targetPeripheral = nil
|
||||
timeCharacteristic = nil
|
||||
fileCommandCharacteristic = nil
|
||||
fileResponseCharacteristic = nil
|
||||
resetFileStateOnDisconnect()
|
||||
scheduleRetry()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBPeripheralDelegate
|
||||
|
||||
extension TimeSyncManager: CBPeripheralDelegate {
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
if let error {
|
||||
@@ -202,8 +916,12 @@ extension TimeSyncManager: CBPeripheralDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
for service in services where service.uuid == serviceUUID {
|
||||
peripheral.discoverCharacteristics([characteristicUUID], for: service)
|
||||
for service in services {
|
||||
if service.uuid == timeServiceUUID {
|
||||
peripheral.discoverCharacteristics([timeCharacteristicUUID], for: service)
|
||||
} else if service.uuid == fileServiceUUID {
|
||||
peripheral.discoverCharacteristics([fileCommandUUID, fileResponseUUID], for: service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,39 +933,105 @@ extension TimeSyncManager: CBPeripheralDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
guard let characteristics = service.characteristics,
|
||||
let targetCharacteristic = characteristics.first(where: { $0.uuid == characteristicUUID }) else {
|
||||
statusMessage = "Time sync characteristic missing."
|
||||
connectionState = .failed
|
||||
scheduleRetry()
|
||||
guard let characteristics = service.characteristics else { return }
|
||||
|
||||
for characteristic in characteristics {
|
||||
if characteristic.uuid == timeCharacteristicUUID {
|
||||
timeCharacteristic = characteristic
|
||||
sendCurrentTime()
|
||||
} else if characteristic.uuid == fileCommandUUID {
|
||||
fileCommandCharacteristic = characteristic
|
||||
processFileCommandQueue(characteristic: characteristic)
|
||||
} else if characteristic.uuid == fileResponseUUID {
|
||||
fileResponseCharacteristic = characteristic
|
||||
peripheral.setNotifyValue(true, for: characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
if timeCharacteristic != nil && fileResponseCharacteristic != nil && connectionState == .discovering {
|
||||
connectionState = .ready
|
||||
statusMessage = "Connected to Cardboy."
|
||||
}
|
||||
|
||||
if fileResponseCharacteristic?.isNotifying == true {
|
||||
_ = flushPendingDirectoryRequest()
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
||||
if let error {
|
||||
fileErrorMessage = error.localizedDescription
|
||||
return
|
||||
}
|
||||
|
||||
timeCharacteristic = targetCharacteristic
|
||||
connectionState = .ready
|
||||
statusMessage = "Ready to sync time."
|
||||
sendCurrentTime()
|
||||
if characteristic.uuid == fileResponseUUID, characteristic.isNotifying {
|
||||
if !flushPendingDirectoryRequest() {
|
||||
refreshDirectory()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
if let error {
|
||||
fileErrorMessage = error.localizedDescription
|
||||
isFileBusy = false
|
||||
return
|
||||
}
|
||||
|
||||
guard let value = characteristic.value else { return }
|
||||
|
||||
if characteristic.uuid == fileResponseUUID {
|
||||
handleFileResponse(value)
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
if let error {
|
||||
statusMessage = "Write failed: \(error.localizedDescription)"
|
||||
connectionState = .failed
|
||||
scheduleRetry()
|
||||
return
|
||||
if characteristic.uuid == fileCommandUUID {
|
||||
if let error {
|
||||
fileErrorMessage = error.localizedDescription
|
||||
}
|
||||
isWritingFileCommand = false
|
||||
processFileCommandQueue(characteristic: characteristic)
|
||||
}
|
||||
|
||||
lastSyncDate = Date()
|
||||
if let date = lastSyncDate {
|
||||
let formatted = DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .medium)
|
||||
statusMessage = "Time synced at \(formatted)."
|
||||
} else {
|
||||
statusMessage = "Time synced."
|
||||
}
|
||||
connectionState = .ready
|
||||
|
||||
shouldKeepScanning = true
|
||||
central.cancelPeripheralConnection(peripheral)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data helpers
|
||||
|
||||
private extension Data {
|
||||
mutating func appendPath(_ path: String) {
|
||||
let normalized: String
|
||||
if path.isEmpty {
|
||||
normalized = "/"
|
||||
} else if path.hasPrefix("/") {
|
||||
normalized = path
|
||||
} else {
|
||||
normalized = "/" + path
|
||||
}
|
||||
let pathData = normalized.data(using: .utf8) ?? Data()
|
||||
append(contentsOf: UInt16(pathData.count).littleEndianBytes)
|
||||
append(pathData)
|
||||
}
|
||||
}
|
||||
|
||||
private extension UInt16 {
|
||||
var littleEndianBytes: [UInt8] {
|
||||
let value = self.littleEndian
|
||||
return [
|
||||
UInt8(value & 0xFF),
|
||||
UInt8((value >> 8) & 0xFF),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private extension UInt32 {
|
||||
var littleEndianBytes: [UInt8] {
|
||||
let value = self.littleEndian
|
||||
return [
|
||||
UInt8(value & 0xFF),
|
||||
UInt8((value >> 8) & 0xFF),
|
||||
UInt8((value >> 16) & 0xFF),
|
||||
UInt8((value >> 24) & 0xFF),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ idf_component_register(
|
||||
"src/buttons.cpp"
|
||||
"src/buzzer.cpp"
|
||||
"src/esp_backend.cpp"
|
||||
"src/event_bus.cpp"
|
||||
"src/display.cpp"
|
||||
"src/fs_helper.cpp"
|
||||
"src/i2c_global.cpp"
|
||||
|
||||
@@ -18,6 +18,7 @@ public:
|
||||
float get_voltage() const;
|
||||
float get_charge() const;
|
||||
float get_current() const;
|
||||
float get_percentage() const;
|
||||
|
||||
void pooler(); // FIXME:
|
||||
private:
|
||||
@@ -33,6 +34,7 @@ private:
|
||||
volatile float _voltage;
|
||||
volatile float _current;
|
||||
volatile float _charge;
|
||||
volatile float _percentage;
|
||||
|
||||
TaskHandle_t _pooler_task;
|
||||
};
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
#ifndef BUTTONS_HPP
|
||||
#define BUTTONS_HPP
|
||||
|
||||
#include "cardboy/sdk/event_bus.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "cardboy/sdk/input_state.hpp"
|
||||
#include "cardboy/sdk/services.hpp"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
@@ -24,20 +25,19 @@ typedef enum {
|
||||
|
||||
class Buttons {
|
||||
public:
|
||||
static Buttons& get();
|
||||
void pooler(); // FIXME:
|
||||
uint8_t get_pressed();
|
||||
void install_isr();
|
||||
void register_listener(TaskHandle_t task);
|
||||
void setEventBus(cardboy::sdk::IEventBus* bus);
|
||||
static Buttons& get();
|
||||
void pooler(); // FIXME:
|
||||
uint8_t get_pressed();
|
||||
cardboy::sdk::InputState get_state();
|
||||
void install_isr();
|
||||
void setEventBus(cardboy::sdk::IEventBus* bus);
|
||||
|
||||
TaskHandle_t _pooler_task;
|
||||
|
||||
private:
|
||||
Buttons();
|
||||
|
||||
uint8_t _previous;
|
||||
volatile uint8_t _current;
|
||||
volatile TaskHandle_t _listener = nullptr;
|
||||
cardboy::sdk::IEventBus* _eventBus = nullptr;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cardboy/sdk/event_bus.hpp>
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/event_groups.h"
|
||||
#include "freertos/timers.h"
|
||||
|
||||
namespace cardboy::backend::esp {
|
||||
|
||||
class EventBus final : public cardboy::sdk::IEventBus {
|
||||
public:
|
||||
EventBus();
|
||||
~EventBus() override;
|
||||
|
||||
void signal(std::uint32_t bits) override;
|
||||
void signalFromISR(std::uint32_t bits) override;
|
||||
std::uint32_t wait(std::uint32_t mask, std::uint32_t timeout_ms) override;
|
||||
void scheduleTimerSignal(std::uint32_t delay_ms) override;
|
||||
void cancelTimerSignal() override;
|
||||
|
||||
private:
|
||||
EventGroupHandle_t group;
|
||||
TimerHandle_t timer;
|
||||
};
|
||||
|
||||
} // namespace cardboy::backend::esp
|
||||
@@ -1,5 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
namespace cardboy::sdk {
|
||||
class INotificationCenter;
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
namespace cardboy::backend::esp {
|
||||
|
||||
/**
|
||||
@@ -16,5 +20,10 @@ void ensure_time_sync_service_started();
|
||||
*/
|
||||
void shutdown_time_sync_service();
|
||||
|
||||
} // namespace cardboy::backend::esp
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include <cardboy/sdk/display_spec.hpp>
|
||||
#include "cardboy/backend/esp/display.hpp"
|
||||
#include "cardboy/backend/esp/event_bus.hpp"
|
||||
#include "cardboy/sdk/platform.hpp"
|
||||
#include "cardboy/sdk/services.hpp"
|
||||
|
||||
@@ -61,17 +60,24 @@ private:
|
||||
class HighResClockService;
|
||||
class FilesystemService;
|
||||
class LoopHooksService;
|
||||
class NotificationService;
|
||||
class TimerService;
|
||||
class EventBus;
|
||||
class AppScopedServices;
|
||||
class AppServiceProvider;
|
||||
|
||||
std::unique_ptr<BuzzerService> buzzerService;
|
||||
std::unique_ptr<BatteryService> batteryService;
|
||||
std::unique_ptr<StorageService> storageService;
|
||||
std::unique_ptr<RandomService> randomService;
|
||||
std::unique_ptr<HighResClockService> highResClockService;
|
||||
std::unique_ptr<FilesystemService> filesystemService;
|
||||
std::unique_ptr<EventBus> eventBus;
|
||||
std::unique_ptr<LoopHooksService> loopHooksService;
|
||||
std::unique_ptr<BuzzerService> _buzzerService;
|
||||
std::unique_ptr<BatteryService> _batteryService;
|
||||
std::unique_ptr<StorageService> _storageService;
|
||||
std::unique_ptr<RandomService> _randomService;
|
||||
std::unique_ptr<HighResClockService> _highResClockService;
|
||||
std::unique_ptr<FilesystemService> _filesystemService;
|
||||
std::unique_ptr<EventBus> _eventBus;
|
||||
std::unique_ptr<LoopHooksService> _loopHooksService;
|
||||
std::unique_ptr<NotificationService> _notificationService;
|
||||
std::unique_ptr<AppServiceProvider> _appServiceProvider;
|
||||
|
||||
cardboy::sdk::Services services{};
|
||||
cardboy::sdk::Services _services{};
|
||||
};
|
||||
|
||||
struct Backend {
|
||||
|
||||
@@ -48,6 +48,8 @@ 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; }
|
||||
// lsb is 1/256%
|
||||
constexpr float regToPercent(uint16_t reg) { return static_cast<float>(reg) / 256.0f; }
|
||||
constexpr float regToCurrent(uint16_t reg) {
|
||||
return static_cast<float>(static_cast<int16_t>(reg)) * 0.0015625f / RSense; // Convert to mA
|
||||
}
|
||||
@@ -103,6 +105,7 @@ void BatMon::pooler() {
|
||||
_charge = capToMah(ReadRegister(0x05));
|
||||
_current = regToCurrent(ReadRegister(0x0B));
|
||||
_voltage = regToVoltage(ReadRegister(0x09));
|
||||
_percentage = regToPercent(ReadRegister(0x06));
|
||||
vTaskDelay(pdMS_TO_TICKS(10000));
|
||||
if (_voltage < 3.0f) {
|
||||
Shutdowner::get().shutdown();
|
||||
@@ -113,3 +116,4 @@ void BatMon::pooler() {
|
||||
float BatMon::get_voltage() const { return _voltage; }
|
||||
float BatMon::get_charge() const { return _charge; }
|
||||
float BatMon::get_current() const { return _current; }
|
||||
float BatMon::get_percentage() const { return _percentage; }
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
|
||||
#include "cardboy/backend/esp/config.hpp"
|
||||
#include "cardboy/backend/esp/i2c_global.hpp"
|
||||
#include "cardboy/sdk/event_bus.hpp"
|
||||
|
||||
static i2c_master_dev_handle_t dev_handle;
|
||||
static inline i2c_device_config_t dev_cfg = {
|
||||
@@ -76,6 +75,28 @@ static void delay(unsigned long long loop) {
|
||||
}
|
||||
}
|
||||
|
||||
static cardboy::sdk::InputState buttons_to_input_state(uint8_t pressed) {
|
||||
cardboy::sdk::InputState state{};
|
||||
if (pressed & BTN_UP)
|
||||
state.up = true;
|
||||
if (pressed & BTN_LEFT)
|
||||
state.left = true;
|
||||
if (pressed & BTN_RIGHT)
|
||||
state.right = true;
|
||||
if (pressed & BTN_DOWN)
|
||||
state.down = true;
|
||||
if (pressed & BTN_A)
|
||||
state.a = true;
|
||||
if (pressed & BTN_B)
|
||||
state.b = true;
|
||||
if (pressed & BTN_SELECT)
|
||||
state.select = true;
|
||||
if (pressed & BTN_START)
|
||||
state.start = true;
|
||||
return state;
|
||||
}
|
||||
|
||||
|
||||
void Buttons::pooler() {
|
||||
while (true) {
|
||||
BaseType_t xResult = xTaskNotifyWait(pdFALSE, ULONG_MAX, nullptr, portMAX_DELAY);
|
||||
@@ -88,15 +109,27 @@ void Buttons::pooler() {
|
||||
reg = 1;
|
||||
ESP_ERROR_CHECK(
|
||||
i2c_master_transmit_receive(dev_handle, ®, sizeof(reg), reinterpret_cast<uint8_t*>(&buffer), 1, -1));
|
||||
if (_listener)
|
||||
xTaskNotifyGive(_listener);
|
||||
if (_eventBus)
|
||||
_eventBus->signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Input));
|
||||
|
||||
|
||||
if (_eventBus) {
|
||||
cardboy::sdk::AppButtonEvent button{};
|
||||
button.current = buttons_to_input_state(_current);
|
||||
button.previous = buttons_to_input_state(_previous);
|
||||
_previous = _current;
|
||||
|
||||
cardboy::sdk::AppEvent evt{};
|
||||
|
||||
// TODO: dedup?
|
||||
TickType_t ticks = xTaskGetTickCount();
|
||||
auto now = static_cast<std::uint32_t>((static_cast<std::uint64_t>(ticks) * 1000ULL) / configTICK_RATE_HZ);
|
||||
|
||||
evt.timestamp_ms = now;
|
||||
evt.data = button;
|
||||
_eventBus->post(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
uint8_t Buttons::get_pressed() { return _current; }
|
||||
void Buttons::install_isr() { gpio_isr_handler_add(EXP_INT, wakeup, nullptr); }
|
||||
|
||||
void Buttons::register_listener(TaskHandle_t task) { _listener = task; }
|
||||
|
||||
void Buttons::setEventBus(cardboy::sdk::IEventBus* bus) { _eventBus = bus; }
|
||||
uint8_t Buttons::get_pressed() { return _current; }
|
||||
void Buttons::install_isr() { gpio_isr_handler_add(EXP_INT, wakeup, nullptr); }
|
||||
cardboy::sdk::InputState Buttons::get_state() { return buttons_to_input_state(get_pressed()); }
|
||||
void Buttons::setEventBus(cardboy::sdk::IEventBus* bus) { _eventBus = bus; }
|
||||
|
||||
@@ -18,14 +18,22 @@
|
||||
#include "esp_random.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/timers.h"
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <deque>
|
||||
#include <list>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace cardboy::backend::esp {
|
||||
|
||||
@@ -37,6 +45,7 @@ void ensureNvsInit() {
|
||||
|
||||
esp_err_t err = nvs_flash_init();
|
||||
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
|
||||
printf("Erasing flash!\n");
|
||||
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||
err = nvs_flash_init();
|
||||
}
|
||||
@@ -69,6 +78,7 @@ public:
|
||||
[[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(); }
|
||||
[[nodiscard]] float percentage() const override { return BatMon::get().get_percentage(); }
|
||||
};
|
||||
|
||||
class EspRuntime::StorageService final : public cardboy::sdk::IStorage {
|
||||
@@ -127,33 +137,396 @@ public:
|
||||
void onLoopIteration() override { vTaskDelay(1); }
|
||||
};
|
||||
|
||||
class EspRuntime::EventBus final : public cardboy::sdk::IEventBus {
|
||||
public:
|
||||
explicit EventBus() {
|
||||
_queueHandle =
|
||||
xQueueCreateStatic(_kMaxQueueSize, sizeof(cardboy::sdk::AppEvent), _queueStorage.data(), &_queue);
|
||||
}
|
||||
|
||||
~EventBus() override { vQueueDelete(_queueHandle); }
|
||||
void post(const sdk::AppEvent& event) override { xQueueSendToBack(_queueHandle, &event, portMAX_DELAY); }
|
||||
std::optional<sdk::AppEvent> pop(std::optional<std::uint32_t> timeout_ms = std::nullopt) override {
|
||||
sdk::AppEvent out;
|
||||
TickType_t ticks = timeout_ms ? pdMS_TO_TICKS(*timeout_ms) : portMAX_DELAY;
|
||||
if (xQueueReceive(_queueHandle, &out, ticks) == pdTRUE) {
|
||||
return out;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr std::uint32_t _kMaxQueueSize = 32;
|
||||
StaticQueue_t _queue;
|
||||
std::array<std::uint8_t, 32 * sizeof(cardboy::sdk::AppEvent)> _queueStorage{};
|
||||
QueueHandle_t _queueHandle;
|
||||
};
|
||||
|
||||
|
||||
class EspRuntime::TimerService final : public cardboy::sdk::ITimerService {
|
||||
public:
|
||||
explicit TimerService(cardboy::sdk::IEventBus& appBus);
|
||||
~TimerService() override;
|
||||
|
||||
cardboy::sdk::AppTimerHandle scheduleTimer(std::uint32_t delay_ms, bool repeat) override;
|
||||
void cancelTimer(cardboy::sdk::AppTimerHandle handle) override;
|
||||
void cancelAllTimers() override;
|
||||
|
||||
private:
|
||||
static inline EspRuntime::TimerService* _current = nullptr;
|
||||
struct InitializedSemaphore {
|
||||
InitializedSemaphore() {
|
||||
_handle = xSemaphoreCreateBinary();
|
||||
xSemaphoreGive(_handle);
|
||||
}
|
||||
~InitializedSemaphore() { vSemaphoreDelete(_handle); }
|
||||
|
||||
SemaphoreHandle_t operator*() { return _handle; }
|
||||
SemaphoreHandle_t operator->() { return _handle; }
|
||||
|
||||
private:
|
||||
SemaphoreHandle_t _handle;
|
||||
};
|
||||
static inline InitializedSemaphore _currentSemaphore;
|
||||
|
||||
struct TimerRecord {
|
||||
TimerService* owner = nullptr;
|
||||
TimerHandle_t timer = nullptr;
|
||||
cardboy::sdk::AppTimerHandle handle = cardboy::sdk::kInvalidAppTimer;
|
||||
bool repeat = false;
|
||||
};
|
||||
|
||||
static void timerCallback(TimerHandle_t timer);
|
||||
void handleTimer(sdk::AppTimerHandle record);
|
||||
|
||||
cardboy::sdk::IEventBus& _appEventBus;
|
||||
SemaphoreHandle_t _mutex;
|
||||
StaticSemaphore_t _mutexStatic;
|
||||
std::list<TimerRecord> _timers;
|
||||
std::atomic<sdk::AppTimerHandle> _nextTimerHandle = 1;
|
||||
static_assert(std::atomic<sdk::AppTimerHandle>::is_always_lock_free);
|
||||
};
|
||||
|
||||
class EspRuntime::AppScopedServices final : public cardboy::sdk::AppScopedServices {
|
||||
public:
|
||||
AppScopedServices(std::unique_ptr<TimerService> timer) : _ownedTimer(std::move(timer)) {
|
||||
this->timer = _ownedTimer.get();
|
||||
}
|
||||
|
||||
private:
|
||||
std::unique_ptr<TimerService> _ownedTimer;
|
||||
};
|
||||
|
||||
class EspRuntime::AppServiceProvider final : public cardboy::sdk::IAppServiceProvider {
|
||||
public:
|
||||
explicit AppServiceProvider(cardboy::sdk::IEventBus& bus) : eventBus(bus) {}
|
||||
|
||||
[[nodiscard]] std::unique_ptr<cardboy::sdk::AppScopedServices>
|
||||
createScopedServices(std::uint64_t generation) override {
|
||||
(void) generation;
|
||||
auto timer = std::make_unique<TimerService>(eventBus);
|
||||
return std::make_unique<AppScopedServices>(std::move(timer));
|
||||
}
|
||||
|
||||
private:
|
||||
cardboy::sdk::IEventBus& eventBus;
|
||||
};
|
||||
|
||||
class EspRuntime::NotificationService final : public cardboy::sdk::INotificationCenter {
|
||||
public:
|
||||
void pushNotification(Notification notification) override {
|
||||
if (notification.timestamp == 0) {
|
||||
notification.timestamp = static_cast<std::uint64_t>(std::time(nullptr));
|
||||
}
|
||||
|
||||
capLengths(notification);
|
||||
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (notification.externalId != 0) {
|
||||
for (auto it = entries.begin(); it != entries.end();) {
|
||||
if (it->externalId == notification.externalId)
|
||||
it = entries.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
notification.id = nextId++;
|
||||
notification.unread = true;
|
||||
|
||||
entries.push_back(std::move(notification));
|
||||
if (entries.size() > kMaxEntries)
|
||||
entries.erase(entries.begin());
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::uint32_t revision() const override {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
return revisionCounter;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<Notification> recent(std::size_t limit) const override {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
std::vector<Notification> out;
|
||||
const std::size_t count = std::min<std::size_t>(limit, entries.size());
|
||||
out.reserve(count);
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
out.push_back(entries[entries.size() - 1 - i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void markAllRead() override {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
bool changed = false;
|
||||
for (auto& entry: entries) {
|
||||
if (entry.unread) {
|
||||
entry.unread = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed)
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
void clear() override {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (entries.empty())
|
||||
return;
|
||||
entries.clear();
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
void removeById(std::uint64_t id) override {
|
||||
if (id == 0)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
bool removed = false;
|
||||
for (auto it = entries.begin(); it != entries.end();) {
|
||||
if (it->id == id) {
|
||||
it = entries.erase(it);
|
||||
removed = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
if (removed)
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
void removeByExternalId(std::uint64_t externalId) override {
|
||||
if (externalId == 0)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
bool removed = false;
|
||||
for (auto it = entries.begin(); it != entries.end();) {
|
||||
if (it->externalId == externalId) {
|
||||
it = entries.erase(it);
|
||||
removed = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
if (removed)
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr std::size_t kMaxEntries = 8;
|
||||
static constexpr std::size_t kMaxTitleBytes = 96;
|
||||
static constexpr std::size_t kMaxBodyBytes = 256;
|
||||
|
||||
static void capLengths(Notification& notification) {
|
||||
if (notification.title.size() > kMaxTitleBytes)
|
||||
notification.title.resize(kMaxTitleBytes);
|
||||
if (notification.body.size() > kMaxBodyBytes)
|
||||
notification.body.resize(kMaxBodyBytes);
|
||||
}
|
||||
|
||||
mutable std::mutex mutex;
|
||||
std::vector<Notification> entries;
|
||||
std::uint64_t nextId = 1;
|
||||
std::uint32_t revisionCounter = 0;
|
||||
};
|
||||
|
||||
EspRuntime::TimerService::TimerService(cardboy::sdk::IEventBus& appBus) : _appEventBus(appBus) {
|
||||
xSemaphoreTake(*_currentSemaphore, portMAX_DELAY);
|
||||
assert(_current == nullptr);
|
||||
_mutex = xSemaphoreCreateBinaryStatic(&_mutexStatic);
|
||||
assert(_mutex);
|
||||
xSemaphoreGive(_mutex);
|
||||
_current = this;
|
||||
xSemaphoreGive(*_currentSemaphore);
|
||||
}
|
||||
|
||||
EspRuntime::TimerService::~TimerService() {
|
||||
xSemaphoreTake(*_currentSemaphore, portMAX_DELAY);
|
||||
assert(_current == this);
|
||||
_current = nullptr;
|
||||
cancelAllTimers();
|
||||
vSemaphoreDelete(_mutex);
|
||||
xSemaphoreGive(*_currentSemaphore);
|
||||
}
|
||||
|
||||
cardboy::sdk::AppTimerHandle EspRuntime::TimerService::scheduleTimer(std::uint32_t delay_ms, bool repeat) {
|
||||
TimerRecord record{};
|
||||
record.owner = this;
|
||||
record.repeat = repeat;
|
||||
|
||||
cardboy::sdk::AppTimerHandle newHandle = cardboy::sdk::kInvalidAppTimer;
|
||||
do {
|
||||
newHandle = _nextTimerHandle++;
|
||||
} while (newHandle == cardboy::sdk::kInvalidAppTimer);
|
||||
if (_nextTimerHandle == cardboy::sdk::kInvalidAppTimer)
|
||||
++_nextTimerHandle;
|
||||
record.handle = newHandle;
|
||||
xSemaphoreTake(_mutex, portMAX_DELAY);
|
||||
TimerRecord* storedRecord = &_timers.emplace_back(record);
|
||||
xSemaphoreGive(_mutex);
|
||||
|
||||
const TickType_t ticks = std::max<TickType_t>(pdMS_TO_TICKS(delay_ms), 1);
|
||||
|
||||
static_assert(sizeof(void*) >= sizeof(cardboy::sdk::AppTimerHandle));
|
||||
|
||||
TimerHandle_t timerHandle =
|
||||
xTimerCreate("AppSvcTimer", ticks, repeat ? pdTRUE : pdFALSE, reinterpret_cast<void*>(storedRecord->handle),
|
||||
&TimerService::timerCallback);
|
||||
storedRecord->timer = timerHandle;
|
||||
|
||||
if (xTimerStart(timerHandle, portMAX_DELAY) != pdPASS) {
|
||||
assert(false);
|
||||
}
|
||||
|
||||
return newHandle;
|
||||
}
|
||||
|
||||
void EspRuntime::TimerService::cancelTimer(cardboy::sdk::AppTimerHandle handle) {
|
||||
assert(handle != sdk::kInvalidAppTimer);
|
||||
|
||||
TimerHandle_t timerHandle = nullptr;
|
||||
{
|
||||
xSemaphoreTake(_mutex, portMAX_DELAY);
|
||||
for (auto it = _timers.begin(); it != _timers.end(); ++it) {
|
||||
if (it->handle == handle) {
|
||||
timerHandle = it->timer;
|
||||
it = _timers.erase(it);
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(_mutex);
|
||||
}
|
||||
|
||||
if (!timerHandle)
|
||||
return;
|
||||
|
||||
xTimerStop(timerHandle, portMAX_DELAY);
|
||||
xTimerDelete(timerHandle, portMAX_DELAY);
|
||||
}
|
||||
|
||||
void EspRuntime::TimerService::cancelAllTimers() {
|
||||
if (!_mutex)
|
||||
return;
|
||||
|
||||
std::vector<TimerHandle_t> handles;
|
||||
handles.resize(_timers.size());
|
||||
{
|
||||
xSemaphoreTake(_mutex, portMAX_DELAY);
|
||||
size_t i = 0;
|
||||
for (auto& record: _timers) {
|
||||
if (record.timer) {
|
||||
assert(record.timer);
|
||||
handles[i] = record.timer;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
_timers.clear();
|
||||
xSemaphoreGive(_mutex);
|
||||
}
|
||||
|
||||
for (auto timerHandle: handles) {
|
||||
xTimerStop(timerHandle, portMAX_DELAY);
|
||||
xTimerDelete(timerHandle, portMAX_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
void EspRuntime::TimerService::timerCallback(TimerHandle_t timer) {
|
||||
auto handle = reinterpret_cast<sdk::AppTimerHandle>(pvTimerGetTimerID(timer));
|
||||
xSemaphoreTake(*_currentSemaphore, portMAX_DELAY);
|
||||
if (!_current)
|
||||
return;
|
||||
_current->handleTimer(handle);
|
||||
xSemaphoreGive(*_currentSemaphore);
|
||||
}
|
||||
|
||||
void EspRuntime::TimerService::handleTimer(sdk::AppTimerHandle handle) {
|
||||
TimerHandle_t timerHandle = nullptr;
|
||||
bool repeat = false;
|
||||
|
||||
{
|
||||
xSemaphoreTake(_mutex, portMAX_DELAY);
|
||||
for (auto it = _timers.begin(); it != _timers.end(); ++it) {
|
||||
if (it->handle == handle) {
|
||||
timerHandle = it->timer;
|
||||
if (!it->repeat) {
|
||||
_timers.erase(it);
|
||||
} else {
|
||||
repeat = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(_mutex);
|
||||
}
|
||||
|
||||
if (!timerHandle) {
|
||||
printf("Couldn't find handle for timer %lu\n", handle);
|
||||
return;
|
||||
}
|
||||
if (!repeat && timerHandle)
|
||||
xTimerDelete(timerHandle, portMAX_DELAY);
|
||||
|
||||
cardboy::sdk::AppTimerEvent timerEvent{};
|
||||
timerEvent.handle = handle;
|
||||
|
||||
cardboy::sdk::AppEvent event{};
|
||||
event.timestamp_ms = static_cast<std::uint32_t>(esp_timer_get_time() / 1000ULL);
|
||||
event.data = timerEvent;
|
||||
_appEventBus.post(event);
|
||||
}
|
||||
|
||||
EspRuntime::EspRuntime() : framebuffer(), input(), clock() {
|
||||
initializeHardware();
|
||||
|
||||
buzzerService = std::make_unique<BuzzerService>();
|
||||
batteryService = std::make_unique<BatteryService>();
|
||||
storageService = std::make_unique<StorageService>();
|
||||
randomService = std::make_unique<RandomService>();
|
||||
highResClockService = std::make_unique<HighResClockService>();
|
||||
filesystemService = std::make_unique<FilesystemService>();
|
||||
eventBus = std::make_unique<EventBus>();
|
||||
loopHooksService = std::make_unique<LoopHooksService>();
|
||||
_buzzerService = std::make_unique<BuzzerService>();
|
||||
_batteryService = std::make_unique<BatteryService>();
|
||||
_storageService = std::make_unique<StorageService>();
|
||||
_randomService = std::make_unique<RandomService>();
|
||||
_highResClockService = std::make_unique<HighResClockService>();
|
||||
_filesystemService = std::make_unique<FilesystemService>();
|
||||
_eventBus = std::make_unique<EventBus>();
|
||||
_appServiceProvider = std::make_unique<AppServiceProvider>(*_eventBus);
|
||||
_loopHooksService = std::make_unique<LoopHooksService>();
|
||||
_notificationService = std::make_unique<NotificationService>();
|
||||
|
||||
services.buzzer = buzzerService.get();
|
||||
services.battery = batteryService.get();
|
||||
services.storage = storageService.get();
|
||||
services.random = randomService.get();
|
||||
services.highResClock = highResClockService.get();
|
||||
services.filesystem = filesystemService.get();
|
||||
services.eventBus = eventBus.get();
|
||||
services.loopHooks = loopHooksService.get();
|
||||
_services.buzzer = _buzzerService.get();
|
||||
_services.battery = _batteryService.get();
|
||||
_services.storage = _storageService.get();
|
||||
_services.random = _randomService.get();
|
||||
_services.highResClock = _highResClockService.get();
|
||||
_services.filesystem = _filesystemService.get();
|
||||
_services.eventBus = _eventBus.get();
|
||||
_services.appServices = _appServiceProvider.get();
|
||||
_services.loopHooks = _loopHooksService.get();
|
||||
_services.notifications = _notificationService.get();
|
||||
|
||||
Buttons::get().setEventBus(eventBus.get());
|
||||
Buttons::get().setEventBus(_eventBus.get());
|
||||
set_notification_center(_notificationService.get());
|
||||
}
|
||||
|
||||
EspRuntime::~EspRuntime() { shutdown_time_sync_service(); }
|
||||
EspRuntime::~EspRuntime() {
|
||||
set_notification_center(nullptr);
|
||||
shutdown_time_sync_service();
|
||||
}
|
||||
|
||||
cardboy::sdk::Services& EspRuntime::serviceRegistry() { return services; }
|
||||
cardboy::sdk::Services& EspRuntime::serviceRegistry() { return _services; }
|
||||
|
||||
void EspRuntime::initializeHardware() {
|
||||
static bool initialized = false;
|
||||
@@ -197,27 +570,7 @@ void EspFramebuffer::sendFrame_impl(bool clearAfterSend) { SMD::send_frame(clear
|
||||
|
||||
bool EspFramebuffer::frameInFlight_impl() const { return SMD::frame_transfer_in_flight(); }
|
||||
|
||||
cardboy::sdk::InputState EspInput::readState_impl() {
|
||||
cardboy::sdk::InputState state{};
|
||||
const uint8_t pressed = Buttons::get().get_pressed();
|
||||
if (pressed & BTN_UP)
|
||||
state.up = true;
|
||||
if (pressed & BTN_LEFT)
|
||||
state.left = true;
|
||||
if (pressed & BTN_RIGHT)
|
||||
state.right = true;
|
||||
if (pressed & BTN_DOWN)
|
||||
state.down = true;
|
||||
if (pressed & BTN_A)
|
||||
state.a = true;
|
||||
if (pressed & BTN_B)
|
||||
state.b = true;
|
||||
if (pressed & BTN_SELECT)
|
||||
state.select = true;
|
||||
if (pressed & BTN_START)
|
||||
state.start = true;
|
||||
return state;
|
||||
}
|
||||
cardboy::sdk::InputState EspInput::readState_impl() { return Buttons::get().get_state(); }
|
||||
|
||||
std::uint32_t EspClock::millis_impl() {
|
||||
TickType_t ticks = xTaskGetTickCount();
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
#include "cardboy/backend/esp/event_bus.hpp"
|
||||
|
||||
#include "cardboy/sdk/event_bus.hpp"
|
||||
|
||||
#include "freertos/portmacro.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace cardboy::backend::esp {
|
||||
|
||||
namespace {
|
||||
[[nodiscard]] TickType_t toTicks(std::uint32_t timeout_ms) {
|
||||
if (timeout_ms == cardboy::sdk::IEventBus::kWaitForever)
|
||||
return portMAX_DELAY;
|
||||
return pdMS_TO_TICKS(timeout_ms);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
static void timerCallback(TimerHandle_t handle) {
|
||||
auto* bus = static_cast<EventBus*>(pvTimerGetTimerID(handle));
|
||||
if (bus)
|
||||
bus->signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Timer));
|
||||
}
|
||||
|
||||
EventBus::EventBus() :
|
||||
group(xEventGroupCreate()), timer(xTimerCreate("EventBusTimer", pdMS_TO_TICKS(1), pdFALSE, this, timerCallback)) {}
|
||||
|
||||
EventBus::~EventBus() {
|
||||
if (timer)
|
||||
xTimerDelete(timer, portMAX_DELAY);
|
||||
if (group)
|
||||
vEventGroupDelete(group);
|
||||
}
|
||||
|
||||
void EventBus::signal(std::uint32_t bits) {
|
||||
if (!group || bits == 0)
|
||||
return;
|
||||
xEventGroupSetBits(group, bits);
|
||||
}
|
||||
|
||||
void EventBus::signalFromISR(std::uint32_t bits) {
|
||||
if (!group || bits == 0)
|
||||
return;
|
||||
BaseType_t higherPriorityTaskWoken = pdFALSE;
|
||||
xEventGroupSetBitsFromISR(group, bits, &higherPriorityTaskWoken);
|
||||
if (higherPriorityTaskWoken == pdTRUE)
|
||||
portYIELD_FROM_ISR(higherPriorityTaskWoken);
|
||||
}
|
||||
|
||||
std::uint32_t EventBus::wait(std::uint32_t mask, std::uint32_t timeout_ms) {
|
||||
if (!group || mask == 0)
|
||||
return 0;
|
||||
const EventBits_t bits = xEventGroupWaitBits(group, mask, pdTRUE, pdFALSE, toTicks(timeout_ms));
|
||||
return static_cast<std::uint32_t>(bits & mask);
|
||||
}
|
||||
|
||||
void EventBus::scheduleTimerSignal(std::uint32_t delay_ms) {
|
||||
if (!timer)
|
||||
return;
|
||||
xTimerStop(timer, 0);
|
||||
|
||||
if (delay_ms == cardboy::sdk::IEventBus::kWaitForever)
|
||||
return;
|
||||
|
||||
if (delay_ms == 0) {
|
||||
signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Timer));
|
||||
return;
|
||||
}
|
||||
|
||||
const TickType_t ticks = std::max<TickType_t>(pdMS_TO_TICKS(delay_ms), 1);
|
||||
if (xTimerChangePeriod(timer, ticks, 0) == pdPASS)
|
||||
xTimerStart(timer, 0);
|
||||
}
|
||||
|
||||
void EventBus::cancelTimerSignal() {
|
||||
if (!timer)
|
||||
return;
|
||||
xTimerStop(timer, 0);
|
||||
}
|
||||
|
||||
} // namespace cardboy::backend::esp
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
|
||||
#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"
|
||||
@@ -233,6 +234,7 @@ extern "C" void app_main() {
|
||||
#endif
|
||||
|
||||
system.registerApp(apps::createMenuAppFactory());
|
||||
system.registerApp(apps::createLockscreenAppFactory());
|
||||
system.registerApp(apps::createSettingsAppFactory());
|
||||
system.registerApp(apps::createClockAppFactory());
|
||||
system.registerApp(apps::createSnakeAppFactory());
|
||||
|
||||
@@ -4,6 +4,11 @@ project(cardboy_sdk LANGUAGES CXX)
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED YES)
|
||||
set(CMAKE_CXX_EXTENSIONS NO)
|
||||
# add_compile_options(-Werror -O0 -Wall -Wextra -pedantic -Wno-unused-parameter -Wno-unused-variable
|
||||
# -Wno-error=unused-function
|
||||
# -Wshadow -Wformat=2 -Wfloat-equal -D_GLIBCXX_DEBUG -Wconversion)
|
||||
#add_compile_options(-fsanitize=address -fno-sanitize-recover -D_GLIBCXX_DEBUG)
|
||||
#add_link_options(-fsanitize=address -fno-sanitize-recover -D_GLIBCXX_DEBUG)
|
||||
|
||||
add_subdirectory(utils)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ target_link_libraries(cardboy_apps
|
||||
target_compile_features(cardboy_apps PUBLIC cxx_std_20)
|
||||
|
||||
add_subdirectory(menu)
|
||||
add_subdirectory(lockscreen)
|
||||
add_subdirectory(clock)
|
||||
add_subdirectory(settings)
|
||||
add_subdirectory(gameboy)
|
||||
|
||||
@@ -17,7 +17,10 @@ namespace apps {
|
||||
|
||||
namespace {
|
||||
|
||||
using cardboy::sdk::AppButtonEvent;
|
||||
using cardboy::sdk::AppTimeoutEvent;
|
||||
using cardboy::sdk::AppContext;
|
||||
using cardboy::sdk::AppTimerEvent;
|
||||
|
||||
constexpr const char* kClockAppName = "Clock";
|
||||
|
||||
@@ -47,21 +50,20 @@ public:
|
||||
const auto snap = captureTime();
|
||||
renderIfNeeded(snap);
|
||||
lastSnapshot = snap;
|
||||
refreshTimer = context.scheduleRepeatingTimer(200);
|
||||
if (auto* timer = context.timer())
|
||||
refreshTimer = timer->scheduleTimer(200, true);
|
||||
}
|
||||
|
||||
void onStop() override { cancelRefreshTimer(); }
|
||||
|
||||
void handleEvent(const cardboy::sdk::AppEvent& event) override {
|
||||
switch (event.type) {
|
||||
case cardboy::sdk::AppEventType::Button:
|
||||
handleButtonEvent(event.button);
|
||||
break;
|
||||
case cardboy::sdk::AppEventType::Timer:
|
||||
if (event.timer.handle == refreshTimer)
|
||||
updateDisplay();
|
||||
break;
|
||||
}
|
||||
std::optional<std::uint32_t> handleEvent(const cardboy::sdk::AppEvent& event) override {
|
||||
event.visit(cardboy::sdk::overload([this](const AppButtonEvent& button) { handleButtonEvent(button); },
|
||||
[this](const AppTimerEvent& timer) {
|
||||
if (timer.handle == refreshTimer)
|
||||
updateDisplay();
|
||||
},
|
||||
[](const AppTimeoutEvent&) { /* ignore */ }));
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -76,10 +78,11 @@ private:
|
||||
TimeSnapshot lastSnapshot{};
|
||||
|
||||
void cancelRefreshTimer() {
|
||||
if (refreshTimer != cardboy::sdk::kInvalidAppTimer) {
|
||||
context.cancelTimer(refreshTimer);
|
||||
refreshTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
if (refreshTimer == cardboy::sdk::kInvalidAppTimer)
|
||||
return;
|
||||
if (auto* timer = context.timer())
|
||||
timer->cancelTimer(refreshTimer);
|
||||
refreshTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
|
||||
void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) {
|
||||
|
||||
@@ -151,9 +151,11 @@ constexpr int kMenuSpacing = font16x8::kGlyphHeight + 6;
|
||||
class GameboyApp;
|
||||
|
||||
|
||||
using cardboy::sdk::AppButtonEvent;
|
||||
using cardboy::sdk::AppContext;
|
||||
using cardboy::sdk::AppEvent;
|
||||
using cardboy::sdk::AppEventType;
|
||||
using cardboy::sdk::AppTimeoutEvent;
|
||||
using cardboy::sdk::AppTimerEvent;
|
||||
using cardboy::sdk::AppTimerHandle;
|
||||
using cardboy::sdk::InputState;
|
||||
using cardboy::sdk::kInvalidAppTimer;
|
||||
@@ -223,7 +225,6 @@ public:
|
||||
::gAudioWriteThunk = &GameboyApp::audioWriteThunk;
|
||||
apu.attach(this);
|
||||
apu.reset();
|
||||
cancelTick();
|
||||
frameDelayCarryUs = 0;
|
||||
GB_PERF_ONLY(perf.resetAll();)
|
||||
prevInput = context.input.readState();
|
||||
@@ -232,13 +233,12 @@ public:
|
||||
scaleMode = ScaleMode::FullHeightWide;
|
||||
ensureFilesystemReady();
|
||||
refreshRomList();
|
||||
mode = Mode::Browse;
|
||||
browserDirty = true;
|
||||
scheduleNextTick(0);
|
||||
mode = Mode::Browse;
|
||||
browserDirty = true;
|
||||
nextTimeoutMs = 0;
|
||||
}
|
||||
|
||||
void onStop() override {
|
||||
cancelTick();
|
||||
frameDelayCarryUs = 0;
|
||||
GB_PERF_ONLY(perf.maybePrintAggregate(true);)
|
||||
unloadRom();
|
||||
@@ -251,21 +251,22 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
void handleEvent(const AppEvent& event) override {
|
||||
if (event.type == AppEventType::Timer && event.timer.handle == tickTimer) {
|
||||
tickTimer = kInvalidAppTimer;
|
||||
const uint64_t frameStartUs = nowMicros();
|
||||
performStep();
|
||||
const uint64_t frameEndUs = nowMicros();
|
||||
const uint64_t elapsedUs = (frameEndUs >= frameStartUs) ? (frameEndUs - frameStartUs) : 0;
|
||||
GB_PERF_ONLY(printf("Step took %" PRIu64 " us\n", elapsedUs));
|
||||
scheduleAfterFrame(elapsedUs);
|
||||
return;
|
||||
}
|
||||
if (event.type == AppEventType::Button) {
|
||||
frameDelayCarryUs = 0;
|
||||
scheduleNextTick(0);
|
||||
}
|
||||
std::optional<std::uint32_t> handleEvent(const AppEvent& event) override {
|
||||
event.visit(cardboy::sdk::overload(
|
||||
[this](const AppTimeoutEvent&) {
|
||||
const uint64_t frameStartUs = nowMicros();
|
||||
performStep();
|
||||
const uint64_t frameEndUs = nowMicros();
|
||||
const uint64_t elapsedUs = (frameEndUs >= frameStartUs) ? (frameEndUs - frameStartUs) : 0;
|
||||
GB_PERF_ONLY(printf("Step took %" PRIu64 " us\n", elapsedUs));
|
||||
scheduleAfterFrame(elapsedUs);
|
||||
},
|
||||
[this](const AppButtonEvent&) {
|
||||
frameDelayCarryUs = 0;
|
||||
nextTimeoutMs = 0;
|
||||
},
|
||||
[](const AppTimerEvent&) { /* ignore */ }));
|
||||
return nextTimeoutMs;
|
||||
}
|
||||
|
||||
void performStep() {
|
||||
@@ -1112,9 +1113,9 @@ public:
|
||||
cardboy::sdk::IFilesystem* filesystem = nullptr;
|
||||
cardboy::sdk::IHighResClock* highResClock = nullptr;
|
||||
PerfTracker perf{};
|
||||
AppTimerHandle tickTimer = kInvalidAppTimer;
|
||||
int64_t frameDelayCarryUs = 0;
|
||||
static constexpr uint32_t kTargetFrameUs = 1000000 / 60; // ~16.6 ms
|
||||
std::optional<std::uint32_t> nextTimeoutMs;
|
||||
static constexpr uint32_t kTargetFrameUs = 1000000 / 60; // ~16.6 ms
|
||||
|
||||
Mode mode = Mode::Browse;
|
||||
ScaleMode scaleMode = ScaleMode::FullHeightWide;
|
||||
@@ -1148,18 +1149,6 @@ public:
|
||||
uint8_t lastLoud = 0;
|
||||
uint32_t stableFrames = 0;
|
||||
|
||||
void cancelTick() {
|
||||
if (tickTimer != kInvalidAppTimer) {
|
||||
context.cancelTimer(tickTimer);
|
||||
tickTimer = kInvalidAppTimer;
|
||||
}
|
||||
}
|
||||
|
||||
void scheduleNextTick(uint32_t delayMs) {
|
||||
cancelTick();
|
||||
tickTimer = context.scheduleTimer(delayMs, false);
|
||||
}
|
||||
|
||||
uint32_t idleDelayMs() const { return browserDirty ? 50 : 140; }
|
||||
|
||||
void scheduleAfterFrame(uint64_t elapsedUs) {
|
||||
@@ -1168,17 +1157,17 @@ public:
|
||||
desiredUs += frameDelayCarryUs;
|
||||
if (desiredUs <= 0) {
|
||||
frameDelayCarryUs = desiredUs;
|
||||
scheduleNextTick(0);
|
||||
nextTimeoutMs = 0;
|
||||
return;
|
||||
}
|
||||
frameDelayCarryUs = desiredUs % 1000;
|
||||
desiredUs -= frameDelayCarryUs;
|
||||
uint32_t delayMs = static_cast<uint32_t>(desiredUs / 1000);
|
||||
scheduleNextTick(delayMs);
|
||||
nextTimeoutMs = delayMs;
|
||||
return;
|
||||
}
|
||||
frameDelayCarryUs = 0;
|
||||
scheduleNextTick(idleDelayMs());
|
||||
nextTimeoutMs = idleDelayMs();
|
||||
}
|
||||
|
||||
bool ensureFilesystemReady() {
|
||||
@@ -1829,7 +1818,7 @@ public:
|
||||
promptDirty = true;
|
||||
mode = Mode::Prompt;
|
||||
gb.direct.joypad = 0xFF;
|
||||
scheduleNextTick(0);
|
||||
// scheduleNextTick(0);
|
||||
}
|
||||
|
||||
void exitPrompt(Mode nextMode) {
|
||||
|
||||
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
|
||||
|
||||
608
Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp
Normal file
608
Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp
Normal file
@@ -0,0 +1,608 @@
|
||||
#include "cardboy/apps/lockscreen_app.hpp"
|
||||
|
||||
#include "cardboy/apps/menu_app.hpp"
|
||||
#include "cardboy/gfx/font16x8.hpp"
|
||||
#include "cardboy/sdk/app_framework.hpp"
|
||||
#include "cardboy/sdk/app_system.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace apps {
|
||||
|
||||
namespace {
|
||||
|
||||
using cardboy::sdk::AppButtonEvent;
|
||||
using cardboy::sdk::AppContext;
|
||||
using cardboy::sdk::AppTimeoutEvent;
|
||||
using cardboy::sdk::AppTimerEvent;
|
||||
|
||||
constexpr std::uint32_t kRefreshIntervalMs = 100;
|
||||
constexpr std::uint32_t kSlowRefreshMs = 1000;
|
||||
constexpr std::uint32_t kFastRefreshMs = 20;
|
||||
constexpr std::uint32_t kUnlockHoldMs = 1500;
|
||||
|
||||
using Framebuffer = typename AppContext::Framebuffer;
|
||||
using Clock = typename AppContext::Clock;
|
||||
|
||||
constexpr std::array<std::uint8_t, font16x8::kGlyphHeight> kArrowUpGlyph{
|
||||
0b00010000, 0b00111000, 0b01111100, 0b11111110, 0b00010000, 0b00010000, 0b00010000, 0b00010000,
|
||||
0b00010000, 0b00010000, 0b00010000, 0b00010000, 0b00010000, 0b00010000, 0b00000000, 0b00000000};
|
||||
|
||||
constexpr std::array<std::uint8_t, font16x8::kGlyphHeight> kArrowDownGlyph{
|
||||
0b00000000, 0b00000000, 0b00010000, 0b00010000, 0b00010000, 0b00010000, 0b00010000, 0b00010000,
|
||||
0b00010000, 0b00010000, 0b00010000, 0b11111110, 0b01111100, 0b00111000, 0b00010000, 0b00000000};
|
||||
|
||||
struct TimeSnapshot {
|
||||
bool hasWallTime = false;
|
||||
int hour24 = 0;
|
||||
int minute = 0;
|
||||
int second = 0;
|
||||
int year = 0;
|
||||
int month = 0;
|
||||
int day = 0;
|
||||
int weekday = 0;
|
||||
std::uint64_t uptimeSeconds = 0;
|
||||
};
|
||||
|
||||
class LockscreenApp final : public cardboy::sdk::IApp {
|
||||
public:
|
||||
explicit LockscreenApp(AppContext& ctx) :
|
||||
context(ctx), framebuffer(ctx.framebuffer), clock(ctx.clock), notificationCenter(ctx.notificationCenter()) {}
|
||||
|
||||
void onStart() override {
|
||||
cancelRefreshTimer();
|
||||
lastSnapshot = {};
|
||||
holdActive = false;
|
||||
holdProgressMs = 0;
|
||||
dirty = true;
|
||||
lastNotificationInteractionMs = clock.millis();
|
||||
lastRefreshMs = clock.millis();
|
||||
refreshNotifications();
|
||||
const auto snap = captureTime();
|
||||
renderIfNeeded(snap);
|
||||
lastSnapshot = snap;
|
||||
rescheduleRefreshTimer(kSlowRefreshMs);
|
||||
}
|
||||
|
||||
void onStop() override { cancelRefreshTimer(); }
|
||||
|
||||
std::optional<std::uint32_t> handleEvent(const cardboy::sdk::AppEvent& event) override {
|
||||
event.visit(cardboy::sdk::overload([this](const AppButtonEvent& button) { handleButtonEvent(button); },
|
||||
[this](const AppTimerEvent& timer) {
|
||||
if (timer.handle == refreshTimer) {
|
||||
const std::uint32_t now = clock.millis();
|
||||
const std::uint32_t elapsed = now - lastRefreshMs;
|
||||
lastRefreshMs = now;
|
||||
advanceHoldProgress(elapsed);
|
||||
updateDisplay();
|
||||
}
|
||||
},
|
||||
[](const AppTimeoutEvent&) { /* ignore */ }));
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr std::size_t kMaxDisplayedNotifications = 5;
|
||||
static constexpr std::uint32_t kNotificationHideMs = 8000;
|
||||
AppContext& context;
|
||||
Framebuffer& framebuffer;
|
||||
Clock& clock;
|
||||
cardboy::sdk::INotificationCenter* notificationCenter = nullptr;
|
||||
std::uint32_t lastNotificationRevision = 0;
|
||||
std::vector<cardboy::sdk::INotificationCenter::Notification> notifications;
|
||||
std::size_t selectedNotification = 0;
|
||||
|
||||
bool dirty = false;
|
||||
cardboy::sdk::AppTimerHandle refreshTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
TimeSnapshot lastSnapshot{};
|
||||
bool holdActive = false;
|
||||
std::uint32_t holdProgressMs = 0;
|
||||
std::uint32_t lastNotificationInteractionMs = 0;
|
||||
std::uint32_t lastRefreshMs = 0;
|
||||
|
||||
void cancelRefreshTimer() {
|
||||
if (refreshTimer == cardboy::sdk::kInvalidAppTimer)
|
||||
return;
|
||||
if (auto* timer = context.timer())
|
||||
timer->cancelTimer(refreshTimer);
|
||||
refreshTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
|
||||
void rescheduleRefreshTimer(std::uint32_t intervalMs) {
|
||||
cancelRefreshTimer();
|
||||
if (auto* timer = context.timer())
|
||||
refreshTimer = timer->scheduleTimer(intervalMs, true);
|
||||
}
|
||||
|
||||
static bool comboPressed(const cardboy::sdk::InputState& state) { return state.a && state.select; }
|
||||
|
||||
void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) {
|
||||
const bool upPressed = button.current.up && !button.previous.up;
|
||||
const bool downPressed = button.current.down && !button.previous.down;
|
||||
bool navPressed = false;
|
||||
|
||||
if (!notifications.empty() && (upPressed || downPressed)) {
|
||||
const std::size_t count = notifications.size();
|
||||
lastNotificationInteractionMs = clock.millis();
|
||||
if (count > 1) {
|
||||
if (upPressed && selectedNotification > 0) {
|
||||
selectedNotification--;
|
||||
navPressed = true;
|
||||
} else if (downPressed && selectedNotification < count - 1) {
|
||||
selectedNotification++;
|
||||
navPressed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bool deletePressed = button.current.b && !button.previous.b;
|
||||
if (deletePressed && notificationCenter && !notifications.empty()) {
|
||||
const std::size_t index = std::min<std::size_t>(selectedNotification, notifications.size() - 1);
|
||||
const auto& note = notifications[index];
|
||||
std::size_t preferredIndex = index;
|
||||
if (index + 1 < notifications.size())
|
||||
preferredIndex = index + 1;
|
||||
else if (index > 0)
|
||||
preferredIndex = index - 1;
|
||||
if (note.externalId != 0)
|
||||
notificationCenter->removeByExternalId(note.externalId);
|
||||
else
|
||||
notificationCenter->removeById(note.id);
|
||||
selectedNotification = preferredIndex;
|
||||
lastNotificationInteractionMs = clock.millis();
|
||||
dirty = true;
|
||||
refreshNotifications();
|
||||
}
|
||||
|
||||
const bool comboNow = comboPressed(button.current);
|
||||
updateHoldState(comboNow);
|
||||
if (navPressed)
|
||||
dirty = true;
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
void refreshNotifications() {
|
||||
if (!notificationCenter) {
|
||||
if (!notifications.empty() || lastNotificationRevision != 0) {
|
||||
notifications.clear();
|
||||
selectedNotification = 0;
|
||||
lastNotificationRevision = 0;
|
||||
dirty = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const std::uint32_t revision = notificationCenter->revision();
|
||||
if (revision == lastNotificationRevision)
|
||||
return;
|
||||
lastNotificationRevision = revision;
|
||||
|
||||
const std::uint64_t previousId =
|
||||
(selectedNotification < notifications.size()) ? notifications[selectedNotification].id : 0;
|
||||
|
||||
auto latest = notificationCenter->recent(kMaxDisplayedNotifications);
|
||||
notifications = std::move(latest);
|
||||
|
||||
if (notifications.empty()) {
|
||||
selectedNotification = 0;
|
||||
} else if (previousId != 0) {
|
||||
auto it = std::find_if(notifications.begin(), notifications.end(),
|
||||
[previousId](const auto& note) { return note.id == previousId; });
|
||||
if (it != notifications.end()) {
|
||||
selectedNotification = static_cast<std::size_t>(std::distance(notifications.begin(), it));
|
||||
} else {
|
||||
selectedNotification = 0;
|
||||
}
|
||||
} else {
|
||||
selectedNotification = 0;
|
||||
}
|
||||
|
||||
lastNotificationInteractionMs = clock.millis();
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void updateHoldState(bool comboNow) {
|
||||
const bool wasActive = holdActive;
|
||||
if (comboNow) {
|
||||
if (!holdActive) {
|
||||
holdActive = true;
|
||||
holdProgressMs = 0;
|
||||
lastRefreshMs = clock.millis();
|
||||
dirty = true;
|
||||
}
|
||||
} else {
|
||||
if (holdActive || holdProgressMs != 0) {
|
||||
holdActive = false;
|
||||
holdProgressMs = 0;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
if (wasActive != holdActive) {
|
||||
rescheduleRefreshTimer(holdActive ? kFastRefreshMs : kSlowRefreshMs);
|
||||
}
|
||||
}
|
||||
|
||||
void advanceHoldProgress(std::uint32_t elapsedMs) {
|
||||
if (holdActive) {
|
||||
const std::uint32_t next = std::min<std::uint32_t>(holdProgressMs + elapsedMs, kUnlockHoldMs);
|
||||
if (next != holdProgressMs) {
|
||||
holdProgressMs = next;
|
||||
dirty = true;
|
||||
}
|
||||
if (holdProgressMs >= kUnlockHoldMs) {
|
||||
holdActive = false;
|
||||
context.requestAppSwitchByName(kMenuAppName);
|
||||
}
|
||||
} else if (holdProgressMs != 0) {
|
||||
holdProgressMs = 0;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
void updateDisplay() {
|
||||
refreshNotifications();
|
||||
const auto snap = captureTime();
|
||||
if (!sameSnapshot(snap, lastSnapshot))
|
||||
dirty = true;
|
||||
renderIfNeeded(snap);
|
||||
lastSnapshot = snap;
|
||||
}
|
||||
|
||||
static bool sameSnapshot(const TimeSnapshot& a, const TimeSnapshot& b) {
|
||||
return a.hasWallTime == b.hasWallTime && a.hour24 == b.hour24 && a.minute == b.minute && a.second == b.second &&
|
||||
a.day == b.day && a.month == b.month && a.year == b.year;
|
||||
}
|
||||
|
||||
TimeSnapshot captureTime() const {
|
||||
TimeSnapshot snap{};
|
||||
snap.uptimeSeconds = clock.millis() / 1000ULL;
|
||||
|
||||
std::time_t raw = 0;
|
||||
if (std::time(&raw) != static_cast<std::time_t>(-1) && raw > 0) {
|
||||
std::tm tm{};
|
||||
if (localtime_r(&raw, &tm) != nullptr) {
|
||||
snap.hasWallTime = true;
|
||||
snap.hour24 = tm.tm_hour;
|
||||
snap.minute = tm.tm_min;
|
||||
snap.second = tm.tm_sec;
|
||||
snap.year = tm.tm_year + 1900;
|
||||
snap.month = tm.tm_mon + 1;
|
||||
snap.day = tm.tm_mday;
|
||||
snap.weekday = tm.tm_wday;
|
||||
return snap;
|
||||
}
|
||||
}
|
||||
|
||||
snap.hasWallTime = false;
|
||||
snap.hour24 = static_cast<int>((snap.uptimeSeconds / 3600ULL) % 24ULL);
|
||||
snap.minute = static_cast<int>((snap.uptimeSeconds / 60ULL) % 60ULL);
|
||||
snap.second = static_cast<int>(snap.uptimeSeconds % 60ULL);
|
||||
return snap;
|
||||
}
|
||||
|
||||
static void drawCenteredText(Framebuffer& fb, int y, std::string_view text, int scale, int letterSpacing = 0) {
|
||||
const int width = font16x8::measureText(text, scale, letterSpacing);
|
||||
const int x = (fb.width() - width) / 2;
|
||||
font16x8::drawText(fb, x, y, text, scale, true, letterSpacing);
|
||||
}
|
||||
|
||||
static void drawRectOutline(Framebuffer& fb, int x, int y, int w, int h) {
|
||||
if (w <= 0 || h <= 0)
|
||||
return;
|
||||
for (int dx = 0; dx < w; ++dx) {
|
||||
fb.drawPixel(x + dx, y, true);
|
||||
fb.drawPixel(x + dx, y + h - 1, true);
|
||||
}
|
||||
for (int dy = 0; dy < h; ++dy) {
|
||||
fb.drawPixel(x, y + dy, true);
|
||||
fb.drawPixel(x + w - 1, y + dy, true);
|
||||
}
|
||||
}
|
||||
|
||||
static void fillRect(Framebuffer& fb, int x, int y, int w, int h) {
|
||||
if (w <= 0 || h <= 0)
|
||||
return;
|
||||
for (int dy = 0; dy < h; ++dy) {
|
||||
for (int dx = 0; dx < w; ++dx) {
|
||||
fb.drawPixel(x + dx, y + dy, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void drawArrowGlyph(Framebuffer& fb, int x, int y,
|
||||
const std::array<std::uint8_t, font16x8::kGlyphHeight>& glyph, int scale = 1) {
|
||||
if (scale <= 0)
|
||||
return;
|
||||
for (int row = 0; row < font16x8::kGlyphHeight; ++row) {
|
||||
const std::uint8_t rowBits = glyph[row];
|
||||
for (int col = 0; col < font16x8::kGlyphWidth; ++col) {
|
||||
const auto mask = static_cast<std::uint8_t>(1u << (font16x8::kGlyphWidth - 1 - col));
|
||||
if ((rowBits & mask) == 0)
|
||||
continue;
|
||||
for (int sx = 0; sx < scale; ++sx) {
|
||||
for (int sy = 0; sy < scale; ++sy) {
|
||||
fb.drawPixel(x + col * scale + sx, y + row * scale + sy, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void drawArrow(Framebuffer& fb, int x, int y, bool up, int scale = 1) {
|
||||
const auto& glyph = up ? kArrowUpGlyph : kArrowDownGlyph;
|
||||
drawArrowGlyph(fb, x, y, glyph, scale);
|
||||
}
|
||||
|
||||
static std::string truncateWithEllipsis(std::string_view text, int maxWidth, int scale, int letterSpacing) {
|
||||
if (font16x8::measureText(text, scale, letterSpacing) <= maxWidth)
|
||||
return std::string(text);
|
||||
|
||||
std::string result(text.begin(), text.end());
|
||||
const std::string ellipsis = "...";
|
||||
while (!result.empty()) {
|
||||
result.pop_back();
|
||||
std::string candidate = result + ellipsis;
|
||||
if (font16x8::measureText(candidate, scale, letterSpacing) <= maxWidth)
|
||||
return candidate;
|
||||
}
|
||||
return ellipsis;
|
||||
}
|
||||
|
||||
static std::vector<std::string> wrapText(std::string_view text, int maxWidth, int scale, int letterSpacing,
|
||||
int maxLines) {
|
||||
std::vector<std::string> lines;
|
||||
if (text.empty() || maxWidth <= 0 || maxLines <= 0)
|
||||
return lines;
|
||||
|
||||
std::string current;
|
||||
std::string word;
|
||||
bool truncated = false;
|
||||
|
||||
auto flushCurrent = [&]() {
|
||||
if (current.empty())
|
||||
return;
|
||||
if (lines.size() < static_cast<std::size_t>(maxLines)) {
|
||||
lines.push_back(current);
|
||||
} else {
|
||||
truncated = true;
|
||||
}
|
||||
current.clear();
|
||||
};
|
||||
|
||||
for (std::size_t i = 0; i <= text.size(); ++i) {
|
||||
char ch = (i < text.size()) ? text[i] : ' ';
|
||||
const bool isBreak = (ch == ' ' || ch == '\n' || ch == '\r' || i == text.size());
|
||||
if (!isBreak) {
|
||||
word.push_back(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!word.empty()) {
|
||||
std::string candidate = current.empty() ? word : current + " " + word;
|
||||
if (!current.empty() && font16x8::measureText(candidate, scale, letterSpacing) > maxWidth) {
|
||||
flushCurrent();
|
||||
if (lines.size() >= static_cast<std::size_t>(maxLines)) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
candidate = word;
|
||||
}
|
||||
|
||||
if (font16x8::measureText(candidate, scale, letterSpacing) > maxWidth) {
|
||||
std::string shortened = truncateWithEllipsis(word, maxWidth, scale, letterSpacing);
|
||||
flushCurrent();
|
||||
if (lines.size() < static_cast<std::size_t>(maxLines)) {
|
||||
lines.push_back(shortened);
|
||||
} else {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
current.clear();
|
||||
} else {
|
||||
current = candidate;
|
||||
}
|
||||
word.clear();
|
||||
}
|
||||
|
||||
if (ch == '\n' || ch == '\r') {
|
||||
flushCurrent();
|
||||
if (lines.size() >= static_cast<std::size_t>(maxLines)) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushCurrent();
|
||||
if (lines.size() > static_cast<std::size_t>(maxLines)) {
|
||||
truncated = true;
|
||||
lines.resize(maxLines);
|
||||
}
|
||||
if (truncated && !lines.empty()) {
|
||||
lines.back() = truncateWithEllipsis(lines.back(), maxWidth, scale, letterSpacing);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
static std::string formatDate(const TimeSnapshot& snap) {
|
||||
static const char* kWeekdays[] = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"};
|
||||
if (!snap.hasWallTime)
|
||||
return "UPTIME MODE";
|
||||
const char* weekday = (snap.weekday >= 0 && snap.weekday < 7) ? kWeekdays[snap.weekday] : "";
|
||||
char buffer[32];
|
||||
std::snprintf(buffer, sizeof(buffer), "%s %04d-%02d-%02d", weekday, snap.year, snap.month, snap.day);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
void renderIfNeeded(const TimeSnapshot& snap) {
|
||||
if (!dirty)
|
||||
return;
|
||||
dirty = false;
|
||||
|
||||
framebuffer.frameReady();
|
||||
|
||||
const std::uint32_t nowMs = clock.millis();
|
||||
const bool hasNotifications = !notifications.empty();
|
||||
const bool showNotificationDetails =
|
||||
hasNotifications && (nowMs - lastNotificationInteractionMs <= kNotificationHideMs);
|
||||
|
||||
if (!showNotificationDetails) {
|
||||
if (auto* battery = context.battery(); battery && battery->hasData()) {
|
||||
const float percentage = battery->percentage();
|
||||
if (std::isfinite(percentage) && percentage >= 0.0f) {
|
||||
char pct[8];
|
||||
std::snprintf(pct, sizeof(pct), "%.0f%%", static_cast<double>(percentage));
|
||||
const int pctWidth = font16x8::measureText(pct, 1, 1);
|
||||
const int pctX = framebuffer.width() - pctWidth - 4;
|
||||
const int pctY = 4;
|
||||
font16x8::drawText(framebuffer, pctX, pctY, pct, 1, true, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
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;
|
||||
if (selectedNotification > 0)
|
||||
drawArrow(framebuffer, arrowX, arrowY, true, scaleSmall);
|
||||
if (selectedNotification < notifications.size() - 1)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const int defaultTimeY = (framebuffer.height() - font16x8::kGlyphHeight * scaleTime) / 2 - 8;
|
||||
const int minTimeY = (cardHeight > 0) ? (cardMarginTop + cardHeight + 12) : 16;
|
||||
const int maxTimeY = std::max(minTimeY, framebuffer.height() - font16x8::kGlyphHeight * scaleTime - 48);
|
||||
const int timeY = std::clamp(defaultTimeY, 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 = "HOLD A+SELECT TO UNLOCK";
|
||||
const int instructionWidth = font16x8::measureText(instruction, scaleSmall, 1);
|
||||
const int barHeight = 18;
|
||||
const int barY = framebuffer.height() - 30;
|
||||
const int textY = barY + (barHeight - textLineHeight) / 2 + 1;
|
||||
drawCenteredText(framebuffer, textY, instruction, scaleSmall, 1);
|
||||
|
||||
const int barWidth = framebuffer.width() - 64;
|
||||
const int barX = 32;
|
||||
|
||||
if (holdActive || holdProgressMs > 0) {
|
||||
drawRectOutline(framebuffer, barX, barY, barWidth, barHeight);
|
||||
const int innerWidth = barWidth - 2;
|
||||
const int innerHeight = barHeight - 2;
|
||||
const float ratio = std::clamp(holdProgressMs / static_cast<float>(kUnlockHoldMs), 0.0f, 1.0f);
|
||||
const int fillWidth = static_cast<int>(ratio * innerWidth + 0.5f);
|
||||
if (fillWidth > 0)
|
||||
fillRect(framebuffer, barX + 1, barY + 1, fillWidth, innerHeight);
|
||||
}
|
||||
|
||||
framebuffer.sendFrame();
|
||||
}
|
||||
};
|
||||
|
||||
class LockscreenAppFactory final : public cardboy::sdk::IAppFactory {
|
||||
public:
|
||||
const char* name() const override { return kLockscreenAppName; }
|
||||
std::unique_ptr<cardboy::sdk::IApp> create(cardboy::sdk::AppContext& context) override {
|
||||
return std::make_unique<LockscreenApp>(context);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<cardboy::sdk::IAppFactory> createLockscreenAppFactory() {
|
||||
return std::make_unique<LockscreenAppFactory>();
|
||||
}
|
||||
|
||||
} // namespace apps
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "cardboy/apps/menu_app.hpp"
|
||||
#include "cardboy/apps/lockscreen_app.hpp"
|
||||
|
||||
#include "cardboy/sdk/app_framework.hpp"
|
||||
#include "cardboy/sdk/app_system.hpp"
|
||||
@@ -6,6 +7,7 @@
|
||||
#include "cardboy/gfx/font16x8.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
@@ -15,10 +17,16 @@ namespace apps {
|
||||
|
||||
namespace {
|
||||
|
||||
using cardboy::sdk::AppButtonEvent;
|
||||
using cardboy::sdk::AppContext;
|
||||
using cardboy::sdk::AppEvent;
|
||||
using cardboy::sdk::AppTimeoutEvent;
|
||||
using cardboy::sdk::AppTimerEvent;
|
||||
|
||||
using Framebuffer = typename AppContext::Framebuffer;
|
||||
|
||||
constexpr std::uint32_t kIdleTimeoutMs = 15000;
|
||||
|
||||
struct MenuEntry {
|
||||
std::string name;
|
||||
std::size_t index = 0;
|
||||
@@ -31,15 +39,44 @@ public:
|
||||
void onStart() override {
|
||||
refreshEntries();
|
||||
dirty = true;
|
||||
resetInactivityTimer();
|
||||
renderIfNeeded();
|
||||
}
|
||||
|
||||
void handleEvent(const cardboy::sdk::AppEvent& event) override {
|
||||
if (event.type != cardboy::sdk::AppEventType::Button)
|
||||
return;
|
||||
void onStop() override { cancelInactivityTimer(); }
|
||||
|
||||
const auto& current = event.button.current;
|
||||
const auto& previous = event.button.previous;
|
||||
std::optional<std::uint32_t> handleEvent(const cardboy::sdk::AppEvent& event) override {
|
||||
event.visit(cardboy::sdk::overload([this](const AppButtonEvent& button) { handleButtonEvent(button); },
|
||||
[this](const AppTimerEvent& timer) {
|
||||
if (timer.handle == inactivityTimer) {
|
||||
cancelInactivityTimer();
|
||||
context.requestAppSwitchByName(kLockscreenAppName);
|
||||
}
|
||||
},
|
||||
[](const AppTimeoutEvent&) { /* ignore */ }));
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
private:
|
||||
AppContext& context;
|
||||
Framebuffer& framebuffer;
|
||||
std::vector<MenuEntry> entries;
|
||||
std::size_t selected = 0;
|
||||
|
||||
bool dirty = false;
|
||||
|
||||
cardboy::sdk::AppTimerHandle inactivityTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
|
||||
void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) {
|
||||
resetInactivityTimer();
|
||||
|
||||
const auto& current = button.current;
|
||||
const auto& previous = button.previous;
|
||||
|
||||
if (current.b && !previous.b) {
|
||||
context.requestAppSwitchByName(kLockscreenAppName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.left && !previous.left) {
|
||||
moveSelection(-1);
|
||||
@@ -54,14 +91,6 @@ public:
|
||||
renderIfNeeded();
|
||||
}
|
||||
|
||||
private:
|
||||
AppContext& context;
|
||||
Framebuffer& framebuffer;
|
||||
std::vector<MenuEntry> entries;
|
||||
std::size_t selected = 0;
|
||||
|
||||
bool dirty = false;
|
||||
|
||||
void moveSelection(int step) {
|
||||
if (entries.empty())
|
||||
return;
|
||||
@@ -93,7 +122,8 @@ private:
|
||||
const char* name = factory->name();
|
||||
if (!name)
|
||||
continue;
|
||||
if (std::string_view(name) == kMenuAppNameView)
|
||||
const std::string_view appName(name);
|
||||
if (appName == kMenuAppNameView || appName == kLockscreenAppNameView)
|
||||
continue;
|
||||
entries.push_back(MenuEntry{std::string(name), i});
|
||||
}
|
||||
@@ -159,6 +189,20 @@ private:
|
||||
|
||||
framebuffer.sendFrame();
|
||||
}
|
||||
|
||||
void cancelInactivityTimer() {
|
||||
if (inactivityTimer == cardboy::sdk::kInvalidAppTimer)
|
||||
return;
|
||||
if (auto* timer = context.timer())
|
||||
timer->cancelTimer(inactivityTimer);
|
||||
inactivityTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
|
||||
void resetInactivityTimer() {
|
||||
cancelInactivityTimer();
|
||||
if (auto* timer = context.timer())
|
||||
inactivityTimer = timer->scheduleTimer(kIdleTimeoutMs, false);
|
||||
}
|
||||
};
|
||||
|
||||
class MenuAppFactory final : public cardboy::sdk::IAppFactory {
|
||||
|
||||
@@ -38,41 +38,44 @@ public:
|
||||
renderIfNeeded();
|
||||
}
|
||||
|
||||
void handleEvent(const cardboy::sdk::AppEvent& event) override {
|
||||
if (event.type != cardboy::sdk::AppEventType::Button)
|
||||
return;
|
||||
std::optional<std::uint32_t> handleEvent(const cardboy::sdk::AppEvent& event) override {
|
||||
event.visit(cardboy::sdk::overload(
|
||||
[this](const cardboy::sdk::AppButtonEvent& button) {
|
||||
const auto& current = button.current;
|
||||
const auto& previous = button.previous;
|
||||
|
||||
const auto& current = event.button.current;
|
||||
const auto& previous = event.button.previous;
|
||||
const bool previousAvailable = buzzerAvailable;
|
||||
syncBuzzerState();
|
||||
if (previousAvailable != buzzerAvailable)
|
||||
dirty = true;
|
||||
|
||||
const bool previousAvailable = buzzerAvailable;
|
||||
syncBuzzerState();
|
||||
if (previousAvailable != buzzerAvailable)
|
||||
dirty = true;
|
||||
if (current.b && !previous.b) {
|
||||
context.requestAppSwitchByName(kMenuAppName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.b && !previous.b) {
|
||||
context.requestAppSwitchByName(kMenuAppName);
|
||||
return;
|
||||
}
|
||||
bool moved = false;
|
||||
if (current.down && !previous.down) {
|
||||
moveSelection(+1);
|
||||
moved = true;
|
||||
} else if (current.up && !previous.up) {
|
||||
moveSelection(-1);
|
||||
moved = true;
|
||||
}
|
||||
|
||||
bool moved = false;
|
||||
if (current.down && !previous.down) {
|
||||
moveSelection(+1);
|
||||
moved = true;
|
||||
} else if (current.up && !previous.up) {
|
||||
moveSelection(-1);
|
||||
moved = true;
|
||||
}
|
||||
const bool togglePressed = (current.a && !previous.a) || (current.start && !previous.start) ||
|
||||
(current.select && !previous.select);
|
||||
if (togglePressed)
|
||||
handleToggle();
|
||||
|
||||
const bool togglePressed = (current.a && !previous.a) || (current.start && !previous.start) ||
|
||||
(current.select && !previous.select);
|
||||
if (togglePressed)
|
||||
handleToggle();
|
||||
if (moved)
|
||||
dirty = true;
|
||||
|
||||
if (moved)
|
||||
dirty = true;
|
||||
|
||||
renderIfNeeded();
|
||||
renderIfNeeded();
|
||||
},
|
||||
[](const cardboy::sdk::AppTimerEvent&) { /* ignore */ },
|
||||
[](const cardboy::sdk::AppTimeoutEvent&) { /* ignore */ }));
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
private:
|
||||
|
||||
@@ -20,21 +20,22 @@ namespace apps {
|
||||
namespace {
|
||||
|
||||
using cardboy::sdk::AppButtonEvent;
|
||||
using cardboy::sdk::AppTimeoutEvent;
|
||||
using cardboy::sdk::AppContext;
|
||||
using cardboy::sdk::AppEvent;
|
||||
using cardboy::sdk::AppEventType;
|
||||
using cardboy::sdk::AppTimerEvent;
|
||||
using cardboy::sdk::AppTimerHandle;
|
||||
using cardboy::sdk::InputState;
|
||||
|
||||
constexpr char kSnakeAppName[] = "Snake";
|
||||
|
||||
constexpr int kBoardWidth = 32;
|
||||
constexpr int kBoardHeight = 20;
|
||||
constexpr int kCellSize = 10;
|
||||
constexpr int kInitialSnakeLength = 5;
|
||||
constexpr int kScorePerFood = 10;
|
||||
constexpr int kMinMoveIntervalMs = 80;
|
||||
constexpr int kBaseMoveIntervalMs = 220;
|
||||
constexpr int kBoardWidth = 32;
|
||||
constexpr int kBoardHeight = 18;
|
||||
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 {
|
||||
@@ -69,16 +70,12 @@ public:
|
||||
|
||||
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;
|
||||
}
|
||||
std::optional<std::uint32_t> handleEvent(const AppEvent& event) {
|
||||
event.visit(cardboy::sdk::overload([this](const AppButtonEvent& button) { handleButtons(button); },
|
||||
[this](const AppTimerEvent& timer) { handleTimer(timer.handle); },
|
||||
[](const AppTimeoutEvent&) { /* ignore */ }));
|
||||
renderIfNeeded();
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -94,8 +91,8 @@ private:
|
||||
bool dirty = false;
|
||||
int score = 0;
|
||||
int highScore = 0;
|
||||
AppTimerHandle moveTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
std::mt19937 rng;
|
||||
AppTimerHandle moveTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
std::mt19937 rng;
|
||||
|
||||
void handleButtons(const AppButtonEvent& evt) {
|
||||
const auto& cur = evt.current;
|
||||
@@ -163,7 +160,7 @@ private:
|
||||
}
|
||||
|
||||
void advance() {
|
||||
direction = queuedDirection;
|
||||
direction = queuedDirection;
|
||||
Point nextHead = snake.front();
|
||||
switch (direction) {
|
||||
case Direction::Up:
|
||||
@@ -253,14 +250,16 @@ private:
|
||||
void scheduleMoveTimer() {
|
||||
cancelMoveTimer();
|
||||
const std::uint32_t interval = currentInterval();
|
||||
moveTimer = context.scheduleRepeatingTimer(interval);
|
||||
if (auto* timer = context.timer())
|
||||
moveTimer = timer->scheduleTimer(interval, true);
|
||||
}
|
||||
|
||||
void cancelMoveTimer() {
|
||||
if (moveTimer != cardboy::sdk::kInvalidAppTimer) {
|
||||
context.cancelTimer(moveTimer);
|
||||
moveTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
if (moveTimer == cardboy::sdk::kInvalidAppTimer)
|
||||
return;
|
||||
if (auto* timer = context.timer())
|
||||
timer->cancelTimer(moveTimer);
|
||||
moveTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::uint32_t currentInterval() const {
|
||||
@@ -302,9 +301,7 @@ private:
|
||||
framebuffer.sendFrame();
|
||||
}
|
||||
|
||||
[[nodiscard]] int boardOriginX() const {
|
||||
return (cardboy::sdk::kDisplayWidth - kBoardWidth * kCellSize) / 2;
|
||||
}
|
||||
[[nodiscard]] int boardOriginX() const { return (cardboy::sdk::kDisplayWidth - kBoardWidth * kCellSize) / 2; }
|
||||
|
||||
[[nodiscard]] int boardOriginY() const {
|
||||
const int centered = (cardboy::sdk::kDisplayHeight - kBoardHeight * kCellSize) / 2;
|
||||
@@ -409,9 +406,9 @@ 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); }
|
||||
void onStart() override { game.onStart(); }
|
||||
void onStop() override { game.onStop(); }
|
||||
std::optional<std::uint32_t> handleEvent(const AppEvent& event) override { return game.handleEvent(event); }
|
||||
|
||||
private:
|
||||
SnakeGame game;
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace {
|
||||
using cardboy::sdk::AppButtonEvent;
|
||||
using cardboy::sdk::AppContext;
|
||||
using cardboy::sdk::AppEvent;
|
||||
using cardboy::sdk::AppEventType;
|
||||
using cardboy::sdk::AppTimerEvent;
|
||||
using cardboy::sdk::AppTimerHandle;
|
||||
using cardboy::sdk::InputState;
|
||||
|
||||
@@ -148,16 +148,12 @@ public:
|
||||
|
||||
void onStop() { cancelTimers(); }
|
||||
|
||||
void handleEvent(const AppEvent& event) {
|
||||
switch (event.type) {
|
||||
case AppEventType::Button:
|
||||
handleButtons(event.button);
|
||||
break;
|
||||
case AppEventType::Timer:
|
||||
handleTimer(event.timer.handle);
|
||||
break;
|
||||
}
|
||||
std::optional<std::uint32_t> handleEvent(const AppEvent& event) {
|
||||
event.visit(cardboy::sdk::overload([this](const AppButtonEvent& button) { handleButtons(button); },
|
||||
[this](const AppTimerEvent& timer) { handleTimer(timer.handle); },
|
||||
[](const cardboy::sdk::AppTimeoutEvent&) { /* ignore */ }));
|
||||
renderIfNeeded();
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -240,35 +236,40 @@ private:
|
||||
|
||||
void cancelTimers() {
|
||||
if (dropTimer != cardboy::sdk::kInvalidAppTimer) {
|
||||
context.cancelTimer(dropTimer);
|
||||
if (auto* timer = context.timer())
|
||||
timer->cancelTimer(dropTimer);
|
||||
dropTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
cancelSoftDropTimer();
|
||||
}
|
||||
|
||||
void cancelSoftDropTimer() {
|
||||
if (softTimer != cardboy::sdk::kInvalidAppTimer) {
|
||||
context.cancelTimer(softTimer);
|
||||
softTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
if (softTimer == cardboy::sdk::kInvalidAppTimer)
|
||||
return;
|
||||
if (auto* timer = context.timer())
|
||||
timer->cancelTimer(softTimer);
|
||||
softTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
|
||||
void scheduleDropTimer() {
|
||||
cancelDropTimer();
|
||||
const std::uint32_t interval = dropIntervalMs();
|
||||
dropTimer = context.scheduleRepeatingTimer(interval);
|
||||
if (auto* timer = context.timer())
|
||||
dropTimer = timer->scheduleTimer(interval, true);
|
||||
}
|
||||
|
||||
void cancelDropTimer() {
|
||||
if (dropTimer != cardboy::sdk::kInvalidAppTimer) {
|
||||
context.cancelTimer(dropTimer);
|
||||
dropTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
if (dropTimer == cardboy::sdk::kInvalidAppTimer)
|
||||
return;
|
||||
if (auto* timer = context.timer())
|
||||
timer->cancelTimer(dropTimer);
|
||||
dropTimer = cardboy::sdk::kInvalidAppTimer;
|
||||
}
|
||||
|
||||
void scheduleSoftDropTimer() {
|
||||
cancelSoftDropTimer();
|
||||
softTimer = context.scheduleRepeatingTimer(60);
|
||||
if (auto* timer = context.timer())
|
||||
softTimer = timer->scheduleTimer(60, true);
|
||||
}
|
||||
|
||||
[[nodiscard]] std::uint32_t dropIntervalMs() const {
|
||||
@@ -634,9 +635,9 @@ class TetrisApp final : public cardboy::sdk::IApp {
|
||||
public:
|
||||
explicit TetrisApp(AppContext& ctx) : game(ctx) {}
|
||||
|
||||
void onStart() override { game.onStart(); }
|
||||
void onStop() override { game.onStop(); }
|
||||
void handleEvent(const AppEvent& event) override { game.handleEvent(event); }
|
||||
void onStart() override { game.onStart(); }
|
||||
void onStop() override { game.onStop(); }
|
||||
std::optional<std::uint32_t> handleEvent(const AppEvent& event) override { return game.handleEvent(event); }
|
||||
|
||||
private:
|
||||
TetrisGame game;
|
||||
|
||||
@@ -16,7 +16,6 @@ target_sources(cardboy_backend_interface
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/backend/backend_interface.hpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/backend.hpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/display_spec.hpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/event_bus.hpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/loop_hooks.hpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/input_state.hpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/platform.hpp
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/sdk/input_state.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
|
||||
namespace cardboy::sdk {
|
||||
|
||||
using AppTimerHandle = std::uint32_t;
|
||||
constexpr AppTimerHandle kInvalidAppTimer = 0;
|
||||
|
||||
struct AppButtonEvent {
|
||||
InputState current{};
|
||||
InputState previous{};
|
||||
};
|
||||
|
||||
struct AppTimerEvent {
|
||||
AppTimerHandle handle = kInvalidAppTimer;
|
||||
};
|
||||
|
||||
struct AppTimeoutEvent {};
|
||||
|
||||
struct AppEvent {
|
||||
using Data = std::variant<AppButtonEvent, AppTimerEvent, AppTimeoutEvent>;
|
||||
|
||||
std::uint32_t timestamp_ms = 0;
|
||||
Data data{AppButtonEvent{}};
|
||||
|
||||
[[nodiscard]] bool isButton() const { return std::holds_alternative<AppButtonEvent>(data); }
|
||||
[[nodiscard]] bool isTimer() const { return std::holds_alternative<AppTimerEvent>(data); }
|
||||
[[nodiscard]] bool isTimeout() const { return std::holds_alternative<AppTimeoutEvent>(data); }
|
||||
|
||||
[[nodiscard]] const AppButtonEvent* button() const { return std::get_if<AppButtonEvent>(&data); }
|
||||
[[nodiscard]] AppButtonEvent* button() { return std::get_if<AppButtonEvent>(&data); }
|
||||
|
||||
[[nodiscard]] const AppTimerEvent* timer() const { return std::get_if<AppTimerEvent>(&data); }
|
||||
[[nodiscard]] AppTimerEvent* timer() { return std::get_if<AppTimerEvent>(&data); }
|
||||
|
||||
[[nodiscard]] const AppTimeoutEvent* timeout() const { return std::get_if<AppTimeoutEvent>(&data); }
|
||||
[[nodiscard]] AppTimeoutEvent* timeout() { return std::get_if<AppTimeoutEvent>(&data); }
|
||||
|
||||
template<typename Visitor>
|
||||
decltype(auto) visit(Visitor&& visitor) {
|
||||
return std::visit(std::forward<Visitor>(visitor), data);
|
||||
}
|
||||
|
||||
template<typename Visitor>
|
||||
decltype(auto) visit(Visitor&& visitor) const {
|
||||
return std::visit(std::forward<Visitor>(visitor), data);
|
||||
}
|
||||
};
|
||||
|
||||
static_assert(std::is_trivially_copyable_v<AppEvent>);
|
||||
|
||||
template<typename... Ts>
|
||||
struct Overload : Ts... {
|
||||
using Ts::operator()...;
|
||||
};
|
||||
|
||||
template<typename... Ts>
|
||||
Overload(Ts...) -> Overload<Ts...>;
|
||||
|
||||
template<typename... Ts>
|
||||
constexpr auto overload(Ts&&... ts) {
|
||||
return Overload<std::decay_t<Ts>...>{std::forward<Ts>(ts)...};
|
||||
}
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
@@ -1,42 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace cardboy::sdk {
|
||||
|
||||
enum class EventBusSignal : std::uint32_t {
|
||||
None = 0,
|
||||
Input = 1u << 0,
|
||||
Timer = 1u << 1,
|
||||
External = 1u << 2,
|
||||
};
|
||||
|
||||
inline EventBusSignal operator|(EventBusSignal lhs, EventBusSignal rhs) {
|
||||
return static_cast<EventBusSignal>(static_cast<std::uint32_t>(lhs) | static_cast<std::uint32_t>(rhs));
|
||||
}
|
||||
|
||||
inline EventBusSignal& operator|=(EventBusSignal& lhs, EventBusSignal rhs) {
|
||||
lhs = lhs | rhs;
|
||||
return lhs;
|
||||
}
|
||||
|
||||
inline EventBusSignal operator&(EventBusSignal lhs, EventBusSignal rhs) {
|
||||
return static_cast<EventBusSignal>(static_cast<std::uint32_t>(lhs) & static_cast<std::uint32_t>(rhs));
|
||||
}
|
||||
|
||||
inline std::uint32_t to_event_bits(EventBusSignal signal) { return static_cast<std::uint32_t>(signal); }
|
||||
|
||||
class IEventBus {
|
||||
public:
|
||||
static constexpr std::uint32_t kWaitForever = 0xFFFFFFFFu;
|
||||
|
||||
virtual ~IEventBus() = default;
|
||||
|
||||
virtual void signal(std::uint32_t bits) = 0;
|
||||
virtual void signalFromISR(std::uint32_t bits) = 0;
|
||||
virtual std::uint32_t wait(std::uint32_t mask, std::uint32_t timeout_ms) = 0;
|
||||
virtual void scheduleTimerSignal(std::uint32_t delay_ms) = 0;
|
||||
virtual void cancelTimerSignal() = 0;
|
||||
};
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
@@ -11,8 +11,8 @@ public:
|
||||
static void invokePreSend(void* framebuffer);
|
||||
|
||||
private:
|
||||
static PreSendHook hook_;
|
||||
static void* userData_;
|
||||
static PreSendHook _hook;
|
||||
static void* _userData;
|
||||
};
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
@@ -11,6 +11,13 @@ struct InputState {
|
||||
bool b = false;
|
||||
bool select = false;
|
||||
bool start = false;
|
||||
|
||||
bool operator==(const InputState& other) const {
|
||||
return up == other.up && left == other.left && right == other.right && down == other.down && a == other.a &&
|
||||
b == other.b && select == other.select && start == other.start;
|
||||
}
|
||||
|
||||
bool operator!=(const InputState& other) const { return !(*this == other); }
|
||||
};
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/sdk/event_bus.hpp"
|
||||
#include "cardboy/sdk/loop_hooks.hpp"
|
||||
#include "cardboy/sdk/timer_service.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace cardboy::sdk {
|
||||
|
||||
@@ -36,6 +39,7 @@ public:
|
||||
[[nodiscard]] virtual float voltage() const { return 0.0f; }
|
||||
[[nodiscard]] virtual float charge() const { return 0.0f; }
|
||||
[[nodiscard]] virtual float current() const { return 0.0f; }
|
||||
[[nodiscard]] virtual float percentage() const { return 0.0f; }
|
||||
};
|
||||
|
||||
class IStorage {
|
||||
@@ -69,15 +73,59 @@ public:
|
||||
[[nodiscard]] virtual std::string basePath() const = 0;
|
||||
};
|
||||
|
||||
class INotificationCenter {
|
||||
public:
|
||||
struct Notification {
|
||||
std::uint64_t id = 0;
|
||||
std::uint64_t timestamp = 0;
|
||||
std::uint64_t externalId = 0;
|
||||
std::string title;
|
||||
std::string body;
|
||||
bool unread = true;
|
||||
};
|
||||
|
||||
virtual ~INotificationCenter() = default;
|
||||
|
||||
virtual void pushNotification(Notification notification) = 0;
|
||||
[[nodiscard]] virtual std::uint32_t revision() const = 0;
|
||||
[[nodiscard]] virtual std::vector<Notification> recent(std::size_t limit) const = 0;
|
||||
virtual void markAllRead() = 0;
|
||||
virtual void clear() = 0;
|
||||
virtual void removeById(std::uint64_t id) = 0;
|
||||
virtual void removeByExternalId(std::uint64_t externalId) = 0;
|
||||
};
|
||||
|
||||
class IEventBus {
|
||||
public:
|
||||
virtual ~IEventBus() = default;
|
||||
|
||||
virtual void post(const AppEvent& event) = 0;
|
||||
virtual std::optional<AppEvent> pop(std::optional<std::uint32_t> timeout_ms = std::nullopt) = 0;
|
||||
};
|
||||
|
||||
struct AppScopedServices {
|
||||
ITimerService* timer = nullptr;
|
||||
virtual ~AppScopedServices() = default;
|
||||
};
|
||||
|
||||
class IAppServiceProvider {
|
||||
public:
|
||||
virtual ~IAppServiceProvider() = default;
|
||||
|
||||
[[nodiscard]] virtual std::unique_ptr<AppScopedServices> createScopedServices(std::uint64_t generation) = 0;
|
||||
};
|
||||
|
||||
struct Services {
|
||||
IBuzzer* buzzer = nullptr;
|
||||
IBatteryMonitor* battery = nullptr;
|
||||
IStorage* storage = nullptr;
|
||||
IRandom* random = nullptr;
|
||||
IHighResClock* highResClock = nullptr;
|
||||
IFilesystem* filesystem = nullptr;
|
||||
IEventBus* eventBus = nullptr;
|
||||
ILoopHooks* loopHooks = nullptr;
|
||||
IBuzzer* buzzer = nullptr;
|
||||
IBatteryMonitor* battery = nullptr;
|
||||
IStorage* storage = nullptr;
|
||||
IRandom* random = nullptr;
|
||||
IHighResClock* highResClock = nullptr;
|
||||
IFilesystem* filesystem = nullptr;
|
||||
IEventBus* eventBus = nullptr;
|
||||
ILoopHooks* loopHooks = nullptr;
|
||||
INotificationCenter* notifications = nullptr;
|
||||
IAppServiceProvider* appServices = nullptr;
|
||||
};
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/sdk/app_events.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace cardboy::sdk {
|
||||
|
||||
class ITimerService {
|
||||
public:
|
||||
virtual ~ITimerService() = default;
|
||||
|
||||
virtual AppTimerHandle scheduleTimer(std::uint32_t delay_ms, bool repeat) = 0;
|
||||
virtual void cancelTimer(AppTimerHandle handle) = 0;
|
||||
virtual void cancelAllTimers() = 0;
|
||||
};
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
@@ -1,6 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/sdk/event_bus.hpp"
|
||||
#include "cardboy/sdk/platform.hpp"
|
||||
#include "cardboy/sdk/services.hpp"
|
||||
|
||||
@@ -9,8 +8,10 @@
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <filesystem>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#include <string>
|
||||
@@ -40,7 +41,6 @@ class DesktopBattery final : public cardboy::sdk::IBatteryMonitor {
|
||||
public:
|
||||
[[nodiscard]] bool hasData() const override { return false; }
|
||||
};
|
||||
|
||||
class DesktopStorage final : public cardboy::sdk::IStorage {
|
||||
public:
|
||||
[[nodiscard]] bool readUint32(std::string_view ns, std::string_view key, std::uint32_t& out) override;
|
||||
@@ -86,27 +86,92 @@ private:
|
||||
bool mounted = false;
|
||||
};
|
||||
|
||||
class DesktopEventBus final : public cardboy::sdk::IEventBus {
|
||||
class DesktopNotificationCenter final : public cardboy::sdk::INotificationCenter {
|
||||
public:
|
||||
explicit DesktopEventBus(DesktopRuntime& owner);
|
||||
~DesktopEventBus() override;
|
||||
|
||||
void signal(std::uint32_t bits) override;
|
||||
void signalFromISR(std::uint32_t bits) override;
|
||||
std::uint32_t wait(std::uint32_t mask, std::uint32_t timeout_ms) override;
|
||||
void scheduleTimerSignal(std::uint32_t delay_ms) override;
|
||||
void cancelTimerSignal() override;
|
||||
void pushNotification(Notification notification) override;
|
||||
[[nodiscard]] std::uint32_t revision() const override;
|
||||
[[nodiscard]] std::vector<Notification> recent(std::size_t limit) const override;
|
||||
void markAllRead() override;
|
||||
void clear() override;
|
||||
void removeById(std::uint64_t id) override;
|
||||
void removeByExternalId(std::uint64_t externalId) override;
|
||||
|
||||
private:
|
||||
DesktopRuntime& runtime;
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::uint32_t pendingBits = 0;
|
||||
static constexpr std::size_t kMaxEntries = 8;
|
||||
|
||||
std::mutex timerMutex;
|
||||
std::condition_variable timerCv;
|
||||
std::thread timerThread;
|
||||
bool timerCancel = false;
|
||||
mutable std::mutex mutex;
|
||||
std::vector<Notification> entries;
|
||||
std::uint64_t nextId = 1;
|
||||
std::uint32_t revisionCounter = 0;
|
||||
};
|
||||
|
||||
class DesktopLoopHooks final : public cardboy::sdk::ILoopHooks {
|
||||
public:
|
||||
explicit DesktopLoopHooks(DesktopRuntime& owner);
|
||||
|
||||
void onLoopIteration() override;
|
||||
|
||||
private:
|
||||
DesktopRuntime& runtime;
|
||||
};
|
||||
|
||||
class DesktopEventBus final : public cardboy::sdk::IEventBus {
|
||||
public:
|
||||
void post(const cardboy::sdk::AppEvent& event) override;
|
||||
std::optional<cardboy::sdk::AppEvent> pop(std::optional<std::uint32_t> timeout_ms = std::nullopt) override;
|
||||
|
||||
private:
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::deque<cardboy::sdk::AppEvent> queue;
|
||||
};
|
||||
|
||||
class DesktopTimerService final : public cardboy::sdk::ITimerService {
|
||||
public:
|
||||
DesktopTimerService(DesktopRuntime& owner, cardboy::sdk::IEventBus& eventBus);
|
||||
~DesktopTimerService() override;
|
||||
|
||||
cardboy::sdk::AppTimerHandle scheduleTimer(std::uint32_t delay_ms, bool repeat) override;
|
||||
void cancelTimer(cardboy::sdk::AppTimerHandle handle) override;
|
||||
void cancelAllTimers() override;
|
||||
|
||||
private:
|
||||
struct TimerRecord {
|
||||
cardboy::sdk::AppTimerHandle handle = cardboy::sdk::kInvalidAppTimer;
|
||||
std::chrono::steady_clock::time_point due;
|
||||
std::chrono::milliseconds interval{0};
|
||||
bool repeat = false;
|
||||
bool active = true;
|
||||
};
|
||||
|
||||
void workerLoop();
|
||||
void wakeWorker();
|
||||
void cleanupInactive();
|
||||
|
||||
DesktopRuntime& runtime;
|
||||
cardboy::sdk::IEventBus& eventBus;
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::vector<TimerRecord> timers;
|
||||
bool stopWorker = false;
|
||||
std::thread worker;
|
||||
cardboy::sdk::AppTimerHandle nextHandle = 1;
|
||||
};
|
||||
|
||||
class DesktopAppServiceProvider final : public cardboy::sdk::IAppServiceProvider {
|
||||
public:
|
||||
DesktopAppServiceProvider(DesktopRuntime& owner, cardboy::sdk::IEventBus& bus);
|
||||
|
||||
[[nodiscard]] std::unique_ptr<cardboy::sdk::AppScopedServices>
|
||||
createScopedServices(std::uint64_t generation) override;
|
||||
|
||||
private:
|
||||
struct ScopedServices final : cardboy::sdk::AppScopedServices {
|
||||
std::unique_ptr<DesktopTimerService> ownedTimer;
|
||||
};
|
||||
|
||||
DesktopRuntime& runtime;
|
||||
cardboy::sdk::IEventBus& eventBus;
|
||||
};
|
||||
|
||||
class DesktopFramebuffer final : public cardboy::sdk::FramebufferFacade<DesktopFramebuffer> {
|
||||
@@ -180,14 +245,17 @@ private:
|
||||
bool running = true;
|
||||
bool clearNextFrame = true;
|
||||
|
||||
DesktopBuzzer buzzerService;
|
||||
DesktopBattery batteryService;
|
||||
DesktopStorage storageService;
|
||||
DesktopRandom randomService;
|
||||
DesktopHighResClock highResService;
|
||||
DesktopFilesystem filesystemService;
|
||||
DesktopEventBus eventBusService;
|
||||
cardboy::sdk::Services services{};
|
||||
DesktopBuzzer buzzerService;
|
||||
DesktopBattery batteryService;
|
||||
DesktopStorage storageService;
|
||||
DesktopRandom randomService;
|
||||
DesktopHighResClock highResService;
|
||||
DesktopFilesystem filesystemService;
|
||||
DesktopEventBus eventBusService;
|
||||
DesktopAppServiceProvider appServiceProvider;
|
||||
DesktopNotificationCenter notificationService;
|
||||
DesktopLoopHooks loopHooksService;
|
||||
cardboy::sdk::Services services{};
|
||||
};
|
||||
|
||||
struct Backend {
|
||||
@@ -196,7 +264,7 @@ struct Backend {
|
||||
using Clock = DesktopClock;
|
||||
};
|
||||
|
||||
} // namespace cardboy::backend::desktop
|
||||
}; // namespace cardboy::backend::desktop
|
||||
|
||||
namespace cardboy::backend {
|
||||
using DesktopBackend = desktop::Backend;
|
||||
|
||||
@@ -8,95 +8,167 @@
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <system_error>
|
||||
#include <utility>
|
||||
|
||||
namespace cardboy::backend::desktop {
|
||||
|
||||
DesktopEventBus::DesktopEventBus(DesktopRuntime& owner) : runtime(owner) {}
|
||||
namespace {
|
||||
constexpr std::size_t kDesktopEventQueueLimit = 64;
|
||||
} // namespace
|
||||
|
||||
DesktopEventBus::~DesktopEventBus() { cancelTimerSignal(); }
|
||||
void DesktopEventBus::post(const cardboy::sdk::AppEvent& event) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
queue.push_back(event);
|
||||
cv.notify_one();
|
||||
}
|
||||
|
||||
void DesktopEventBus::signal(std::uint32_t bits) {
|
||||
if (bits == 0)
|
||||
return;
|
||||
std::optional<cardboy::sdk::AppEvent> DesktopEventBus::pop(std::optional<std::uint32_t> timeout_ms) {
|
||||
std::unique_lock<std::mutex> lock(mutex);
|
||||
if (queue.empty()) {
|
||||
if (!timeout_ms) {
|
||||
return std::nullopt;
|
||||
}
|
||||
auto timeout = std::chrono::milliseconds(*timeout_ms);
|
||||
cv.wait_for(lock, timeout, [this] { return !queue.empty(); });
|
||||
if (queue.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
auto event = queue.front();
|
||||
queue.pop_front();
|
||||
return event;
|
||||
}
|
||||
|
||||
DesktopTimerService::DesktopTimerService(DesktopRuntime& owner, cardboy::sdk::IEventBus& eventBus) :
|
||||
runtime(owner), eventBus(eventBus) {
|
||||
worker = std::thread(&DesktopTimerService::workerLoop, this);
|
||||
}
|
||||
|
||||
DesktopTimerService::~DesktopTimerService() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
pendingBits |= bits;
|
||||
stopWorker = true;
|
||||
}
|
||||
cv.notify_all();
|
||||
if (worker.joinable())
|
||||
worker.join();
|
||||
cancelAllTimers();
|
||||
}
|
||||
|
||||
void DesktopEventBus::signalFromISR(std::uint32_t bits) { signal(bits); }
|
||||
cardboy::sdk::AppTimerHandle DesktopTimerService::scheduleTimer(std::uint32_t delay_ms, bool repeat) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const auto effectiveDelayMs = std::chrono::milliseconds(delay_ms);
|
||||
const auto dueTime = delay_ms == 0 ? now : now + effectiveDelayMs;
|
||||
const auto interval = std::chrono::milliseconds(
|
||||
std::max<std::uint32_t>(1, repeat ? std::max(delay_ms, 1u) : std::max(delay_ms, 1u)));
|
||||
|
||||
std::uint32_t DesktopEventBus::wait(std::uint32_t mask, std::uint32_t timeout_ms) {
|
||||
if (mask == 0)
|
||||
return 0;
|
||||
TimerRecord record{};
|
||||
record.repeat = repeat;
|
||||
record.interval = interval;
|
||||
record.due = dueTime;
|
||||
record.active = true;
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
const bool infinite = timeout_ms == cardboy::sdk::IEventBus::kWaitForever;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
cardboy::sdk::AppTimerHandle handle = cardboy::sdk::kInvalidAppTimer;
|
||||
do {
|
||||
handle = nextHandle++;
|
||||
} while (handle == cardboy::sdk::kInvalidAppTimer);
|
||||
if (nextHandle == cardboy::sdk::kInvalidAppTimer)
|
||||
++nextHandle;
|
||||
record.handle = handle;
|
||||
timers.push_back(record);
|
||||
wakeWorker();
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
const std::uint32_t ready = pendingBits & mask;
|
||||
if (ready != 0) {
|
||||
pendingBits &= ~mask;
|
||||
return ready;
|
||||
void DesktopTimerService::cancelTimer(cardboy::sdk::AppTimerHandle handle) {
|
||||
if (handle == cardboy::sdk::kInvalidAppTimer)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
for (auto& record: timers) {
|
||||
if (record.handle == handle)
|
||||
record.active = false;
|
||||
}
|
||||
cleanupInactive();
|
||||
wakeWorker();
|
||||
}
|
||||
|
||||
void DesktopTimerService::cancelAllTimers() {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
for (auto& record: timers) {
|
||||
record.active = false;
|
||||
}
|
||||
cleanupInactive();
|
||||
wakeWorker();
|
||||
}
|
||||
|
||||
void DesktopTimerService::workerLoop() {
|
||||
std::unique_lock<std::mutex> lock(mutex);
|
||||
while (!stopWorker) {
|
||||
cleanupInactive();
|
||||
if (timers.empty()) {
|
||||
cv.wait(lock, [this] { return stopWorker || !timers.empty(); });
|
||||
continue;
|
||||
}
|
||||
|
||||
auto nextIt = std::min_element(timers.begin(), timers.end(),
|
||||
[](const TimerRecord& a, const TimerRecord& b) { return a.due < b.due; });
|
||||
|
||||
if (nextIt == timers.end())
|
||||
continue;
|
||||
|
||||
if (!nextIt->active)
|
||||
continue;
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now >= nextIt->due) {
|
||||
TimerRecord record = *nextIt;
|
||||
if (record.repeat) {
|
||||
nextIt->due = now + record.interval;
|
||||
} else {
|
||||
nextIt->active = false;
|
||||
}
|
||||
lock.unlock();
|
||||
|
||||
cardboy::sdk::AppTimerEvent timerEvent{};
|
||||
timerEvent.handle = record.handle;
|
||||
|
||||
cardboy::sdk::AppEvent event{};
|
||||
event.timestamp_ms = runtime.clock.millis();
|
||||
event.data = timerEvent;
|
||||
eventBus.post(event);
|
||||
|
||||
lock.lock();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!infinite) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(now - start).count();
|
||||
if (elapsedMs >= static_cast<std::int64_t>(timeout_ms))
|
||||
return 0;
|
||||
const auto remaining = timeout_ms - static_cast<std::uint32_t>(elapsedMs);
|
||||
runtime.sleepFor(std::min<std::uint32_t>(remaining, 8));
|
||||
} else {
|
||||
runtime.sleepFor(8);
|
||||
}
|
||||
cv.wait_until(lock, nextIt->due, [this] { return stopWorker; });
|
||||
}
|
||||
}
|
||||
|
||||
void DesktopEventBus::scheduleTimerSignal(std::uint32_t delay_ms) {
|
||||
cancelTimerSignal();
|
||||
void DesktopTimerService::wakeWorker() { cv.notify_all(); }
|
||||
|
||||
if (delay_ms == cardboy::sdk::IEventBus::kWaitForever)
|
||||
return;
|
||||
|
||||
if (delay_ms == 0) {
|
||||
signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Timer));
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(timerMutex);
|
||||
timerCancel = false;
|
||||
}
|
||||
|
||||
timerThread = std::thread([this, delay_ms]() {
|
||||
std::unique_lock<std::mutex> lock(timerMutex);
|
||||
const bool cancelled =
|
||||
timerCv.wait_for(lock, std::chrono::milliseconds(delay_ms), [this] { return timerCancel; });
|
||||
lock.unlock();
|
||||
if (!cancelled)
|
||||
signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Timer));
|
||||
});
|
||||
void DesktopTimerService::cleanupInactive() {
|
||||
timers.erase(std::remove_if(timers.begin(), timers.end(), [](const TimerRecord& record) { return !record.active; }),
|
||||
timers.end());
|
||||
}
|
||||
|
||||
void DesktopEventBus::cancelTimerSignal() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(timerMutex);
|
||||
timerCancel = true;
|
||||
}
|
||||
timerCv.notify_all();
|
||||
if (timerThread.joinable())
|
||||
timerThread.join();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(timerMutex);
|
||||
timerCancel = false;
|
||||
}
|
||||
DesktopAppServiceProvider::DesktopAppServiceProvider(DesktopRuntime& owner, cardboy::sdk::IEventBus& bus) :
|
||||
runtime(owner), eventBus(bus) {}
|
||||
|
||||
std::unique_ptr<cardboy::sdk::AppScopedServices>
|
||||
DesktopAppServiceProvider::createScopedServices(std::uint64_t generation) {
|
||||
(void) generation;
|
||||
auto scoped = std::make_unique<ScopedServices>();
|
||||
scoped->ownedTimer = std::make_unique<DesktopTimerService>(runtime, eventBus);
|
||||
scoped->timer = scoped->ownedTimer.get();
|
||||
return scoped;
|
||||
}
|
||||
|
||||
bool DesktopStorage::readUint32(std::string_view ns, std::string_view key, std::uint32_t& out) {
|
||||
@@ -149,6 +221,108 @@ bool DesktopFilesystem::mount() {
|
||||
return mounted;
|
||||
}
|
||||
|
||||
void DesktopNotificationCenter::pushNotification(Notification notification) {
|
||||
if (notification.timestamp == 0) {
|
||||
notification.timestamp = static_cast<std::uint64_t>(std::time(nullptr));
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (notification.externalId != 0) {
|
||||
for (auto it = entries.begin(); it != entries.end();) {
|
||||
if (it->externalId == notification.externalId)
|
||||
it = entries.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
notification.id = nextId++;
|
||||
notification.unread = true;
|
||||
|
||||
entries.push_back(std::move(notification));
|
||||
while (entries.size() > kMaxEntries)
|
||||
entries.erase(entries.begin());
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
std::uint32_t DesktopNotificationCenter::revision() const {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
return revisionCounter;
|
||||
}
|
||||
|
||||
std::vector<DesktopNotificationCenter::Notification> DesktopNotificationCenter::recent(std::size_t limit) const {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
const std::size_t count = std::min<std::size_t>(limit, entries.size());
|
||||
std::vector<Notification> result;
|
||||
result.reserve(count);
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
result.push_back(entries[entries.size() - 1 - i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void DesktopNotificationCenter::markAllRead() {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
bool changed = false;
|
||||
for (auto& entry: entries) {
|
||||
if (entry.unread) {
|
||||
entry.unread = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed)
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
void DesktopNotificationCenter::clear() {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (entries.empty())
|
||||
return;
|
||||
entries.clear();
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
void DesktopNotificationCenter::removeById(std::uint64_t id) {
|
||||
if (id == 0)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
bool removed = false;
|
||||
for (auto it = entries.begin(); it != entries.end();) {
|
||||
if (it->id == id) {
|
||||
it = entries.erase(it);
|
||||
removed = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
if (removed)
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
void DesktopNotificationCenter::removeByExternalId(std::uint64_t externalId) {
|
||||
if (externalId == 0)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
bool removed = false;
|
||||
for (auto it = entries.begin(); it != entries.end();) {
|
||||
if (it->externalId == externalId) {
|
||||
it = entries.erase(it);
|
||||
removed = true;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
if (removed)
|
||||
++revisionCounter;
|
||||
}
|
||||
|
||||
DesktopLoopHooks::DesktopLoopHooks(DesktopRuntime& owner) : runtime(owner) {}
|
||||
|
||||
void DesktopLoopHooks::onLoopIteration() {
|
||||
runtime.processEvents();
|
||||
runtime.presentIfNeeded();
|
||||
}
|
||||
|
||||
DesktopFramebuffer::DesktopFramebuffer(DesktopRuntime& runtime) : runtime(runtime) {}
|
||||
|
||||
int DesktopFramebuffer::width_impl() const { return cardboy::sdk::kDisplayWidth; }
|
||||
@@ -176,26 +350,29 @@ DesktopInput::DesktopInput(DesktopRuntime& runtime) : runtime(runtime) {}
|
||||
cardboy::sdk::InputState DesktopInput::readState_impl() { return state; }
|
||||
|
||||
void DesktopInput::handleKey(sf::Keyboard::Key key, bool pressed) {
|
||||
bool handled = true;
|
||||
const auto oldState = state;
|
||||
bool handled = true;
|
||||
switch (key) {
|
||||
case sf::Keyboard::Key::Up:
|
||||
case sf::Keyboard::Key::W:
|
||||
state.up = pressed;
|
||||
break;
|
||||
case sf::Keyboard::Key::Down:
|
||||
case sf::Keyboard::Key::S:
|
||||
state.down = pressed;
|
||||
break;
|
||||
case sf::Keyboard::Key::Left:
|
||||
case sf::Keyboard::Key::A:
|
||||
state.left = pressed;
|
||||
break;
|
||||
case sf::Keyboard::Key::Right:
|
||||
case sf::Keyboard::Key::D:
|
||||
state.right = pressed;
|
||||
break;
|
||||
case sf::Keyboard::Key::Z:
|
||||
case sf::Keyboard::Key::A:
|
||||
state.a = pressed;
|
||||
break;
|
||||
case sf::Keyboard::Key::X:
|
||||
case sf::Keyboard::Key::S:
|
||||
state.b = pressed;
|
||||
break;
|
||||
case sf::Keyboard::Key::Space:
|
||||
@@ -208,8 +385,11 @@ void DesktopInput::handleKey(sf::Keyboard::Key key, bool pressed) {
|
||||
handled = false;
|
||||
break;
|
||||
}
|
||||
if (handled)
|
||||
runtime.eventBusService.signal(cardboy::sdk::to_event_bits(cardboy::sdk::EventBusSignal::Input));
|
||||
if (handled && oldState != state) {
|
||||
cardboy::sdk::AppButtonEvent btnEvent{oldState, state};
|
||||
cardboy::sdk::AppEvent event{runtime.clock.millis(), btnEvent};
|
||||
runtime.eventBusService.post(event);
|
||||
}
|
||||
}
|
||||
|
||||
DesktopClock::DesktopClock(DesktopRuntime& runtime) : runtime(runtime), start(std::chrono::steady_clock::now()) {}
|
||||
@@ -227,23 +407,29 @@ DesktopRuntime::DesktopRuntime() :
|
||||
"Cardboy Desktop"),
|
||||
texture(), sprite(texture),
|
||||
pixels(static_cast<std::size_t>(cardboy::sdk::kDisplayWidth * cardboy::sdk::kDisplayHeight) * 4, 0),
|
||||
framebuffer(*this), input(*this), clock(*this), eventBusService(*this) {
|
||||
framebuffer(*this), input(*this), clock(*this), eventBusService(), appServiceProvider(*this, eventBusService),
|
||||
loopHooksService(*this) {
|
||||
window.setFramerateLimit(60);
|
||||
if (!texture.resize(sf::Vector2u{cardboy::sdk::kDisplayWidth, cardboy::sdk::kDisplayHeight}))
|
||||
throw std::runtime_error("Failed to allocate texture for desktop framebuffer");
|
||||
sprite.setTexture(texture, true);
|
||||
sprite.setScale(sf::Vector2f{static_cast<float>(kPixelScale), static_cast<float>(kPixelScale)});
|
||||
clearPixels(true);
|
||||
clearPixels(false);
|
||||
presentIfNeeded();
|
||||
window.requestFocus();
|
||||
|
||||
services.buzzer = &buzzerService;
|
||||
services.battery = &batteryService;
|
||||
services.storage = &storageService;
|
||||
services.random = &randomService;
|
||||
services.highResClock = &highResService;
|
||||
services.filesystem = &filesystemService;
|
||||
services.eventBus = &eventBusService;
|
||||
services.loopHooks = nullptr;
|
||||
std::cout << "Desktop window initialized and presented." << std::endl;
|
||||
|
||||
services.buzzer = &buzzerService;
|
||||
services.battery = &batteryService;
|
||||
services.storage = &storageService;
|
||||
services.random = &randomService;
|
||||
services.highResClock = &highResService;
|
||||
services.filesystem = &filesystemService;
|
||||
services.eventBus = &eventBusService;
|
||||
services.appServices = &appServiceProvider;
|
||||
services.loopHooks = &loopHooksService;
|
||||
services.notifications = ¬ificationService;
|
||||
}
|
||||
|
||||
cardboy::sdk::Services& DesktopRuntime::serviceRegistry() { return services; }
|
||||
|
||||
@@ -1,25 +1,67 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
include(FetchContent)
|
||||
|
||||
FetchContent_Declare(uni_algo
|
||||
URL https://github.com/uni-algo/uni-algo/archive/refs/tags/v1.2.0.tar.gz
|
||||
URL_HASH SHA256=f2a1539cd8635bc6088d05144a73ecfe7b4d74ee0361fabed6f87f9f19e74ca9
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(uni_algo)
|
||||
|
||||
add_library(cardboy_sdk STATIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/app_system.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/status_bar.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/framebuffer_hooks.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/persistent_settings.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/font_repository.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/psf_font.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src/builtin_fonts.cpp
|
||||
)
|
||||
|
||||
set_target_properties(cardboy_sdk PROPERTIES
|
||||
EXPORT_NAME sdk
|
||||
)
|
||||
|
||||
target_include_directories(cardboy_sdk
|
||||
PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
)
|
||||
|
||||
target_compile_features(cardboy_sdk PUBLIC cxx_std_20)
|
||||
|
||||
target_link_libraries(cardboy_sdk
|
||||
PUBLIC
|
||||
cardboy_backend_interface
|
||||
${CARDBOY_SDK_BACKEND_LIBRARY}
|
||||
uni-algo::uni-algo
|
||||
)
|
||||
|
||||
target_include_directories(cardboy_sdk
|
||||
PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
)
|
||||
|
||||
set(PSF_FONT_FILES
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/spleen8x16.psf
|
||||
)
|
||||
|
||||
find_program(XXD_EXECUTABLE xxd)
|
||||
if (NOT XXD_EXECUTABLE)
|
||||
message(FATAL_ERROR "xxd not found; required to embed PSF fonts")
|
||||
endif()
|
||||
|
||||
set(_cardboy_font_generated_sources "")
|
||||
foreach(psf_file IN LISTS PSF_FONT_FILES)
|
||||
get_filename_component(psf_name "${psf_file}" NAME)
|
||||
get_filename_component(psf_stem "${psf_file}" NAME_WE)
|
||||
set(psf_symbol "${psf_stem}_psf")
|
||||
set(out_cpp "${CMAKE_CURRENT_BINARY_DIR}/${psf_name}.cpp")
|
||||
add_custom_command(
|
||||
OUTPUT "${out_cpp}"
|
||||
COMMAND ${XXD_EXECUTABLE} -i -n ${psf_symbol} "${psf_file}" "${out_cpp}"
|
||||
DEPENDS "${psf_file}"
|
||||
COMMENT "Embedding PSF font ${psf_name}"
|
||||
)
|
||||
set_source_files_properties("${out_cpp}" PROPERTIES GENERATED TRUE)
|
||||
list(APPEND _cardboy_font_generated_sources "${out_cpp}")
|
||||
endforeach()
|
||||
|
||||
if (_cardboy_font_generated_sources)
|
||||
target_sources(cardboy_sdk PRIVATE ${_cardboy_font_generated_sources})
|
||||
endif()
|
||||
|
||||
BIN
Firmware/sdk/core/assets/fonts/spleen8x16.psf
Normal file
BIN
Firmware/sdk/core/assets/fonts/spleen8x16.psf
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
8
Firmware/sdk/core/include/cardboy/gfx/builtin_fonts.hpp
Normal file
8
Firmware/sdk/core/include/cardboy/gfx/builtin_fonts.hpp
Normal file
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
namespace cardboy::gfx::builtin {
|
||||
|
||||
void ensureRegistered();
|
||||
|
||||
} // namespace cardboy::gfx::builtin
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/gfx/Fonts.hpp"
|
||||
#include "cardboy/gfx/builtin_fonts.hpp"
|
||||
#include "cardboy/gfx/font_repository.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
|
||||
#include <uni_algo/ranges.h>
|
||||
|
||||
#include "uni_algo/ranges_conv.h"
|
||||
|
||||
namespace font16x8 {
|
||||
|
||||
constexpr int kGlyphWidth = 8;
|
||||
constexpr int kGlyphHeight = 16;
|
||||
constexpr unsigned char kFallbackChar = '?';
|
||||
|
||||
inline unsigned char normalizeChar(char ch) {
|
||||
unsigned char uc = static_cast<unsigned char>(ch);
|
||||
if (uc >= 'a' && uc <= 'z')
|
||||
uc = static_cast<unsigned char>(std::toupper(static_cast<unsigned char>(uc)));
|
||||
if (!std::isprint(static_cast<unsigned char>(uc)))
|
||||
return kFallbackChar;
|
||||
return uc;
|
||||
}
|
||||
|
||||
inline const std::array<uint8_t, kGlyphHeight>& glyphBitmap(char ch) {
|
||||
unsigned char uc = normalizeChar(ch);
|
||||
return fonts_Terminess_Powerline[uc];
|
||||
}
|
||||
constexpr int kGlyphWidth = 8;
|
||||
constexpr int kGlyphHeight = 16;
|
||||
constexpr std::uint32_t kFallbackCodepoint = static_cast<std::uint32_t>('?');
|
||||
|
||||
enum class Rotation {
|
||||
None,
|
||||
@@ -37,22 +28,86 @@ struct TextBounds {
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
|
||||
inline const cardboy::gfx::FontRecord* defaultFontRecord() {
|
||||
using cardboy::gfx::FontRepository;
|
||||
using cardboy::gfx::builtin::ensureRegistered;
|
||||
static const cardboy::gfx::FontRecord* record = []() -> const cardboy::gfx::FontRecord* {
|
||||
ensureRegistered();
|
||||
auto& repo = FontRepository::instance();
|
||||
if (const auto* spleen = repo.getFont("Spleen 8x16"))
|
||||
return spleen;
|
||||
return repo.getFont("Terminess 8x16");
|
||||
}();
|
||||
return record;
|
||||
}
|
||||
|
||||
inline const cardboy::gfx::ParsedPsfFont* defaultFont() {
|
||||
const auto* record = defaultFontRecord();
|
||||
if (!record)
|
||||
return nullptr;
|
||||
if (!record->font.valid)
|
||||
return nullptr;
|
||||
return &record->font;
|
||||
}
|
||||
|
||||
inline std::uint32_t bytesPerRow(const cardboy::gfx::ParsedPsfFont& font) { return (font.view.width + 7u) / 8u; }
|
||||
|
||||
inline std::size_t countCodepoints(std::string_view text) {
|
||||
std::size_t count = 0;
|
||||
for (auto cp: una::ranges::utf8_view{text}) {
|
||||
(void) cp;
|
||||
++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
inline int fontGlyphWidth() {
|
||||
const auto* font = defaultFont();
|
||||
if (!font || font->view.width == 0)
|
||||
return kGlyphWidth;
|
||||
return static_cast<int>(font->view.width);
|
||||
}
|
||||
|
||||
inline int fontGlyphHeight() {
|
||||
const auto* font = defaultFont();
|
||||
if (!font || font->view.height == 0)
|
||||
return kGlyphHeight;
|
||||
return static_cast<int>(font->view.height);
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
template<typename Framebuffer>
|
||||
inline void drawGlyph(Framebuffer& fb, int x, int y, char ch, int scale = 1, bool on = true,
|
||||
Rotation rotation = Rotation::None) {
|
||||
const auto& rows = glyphBitmap(ch);
|
||||
if (rotation == Rotation::None && scale == 1 && on && ((x % 8) == 0)) {
|
||||
for (int row = 0; row < kGlyphHeight; ++row) {
|
||||
const uint8_t rowBits = rows[row];
|
||||
inline void drawGlyphCodepoint(Framebuffer& fb, int x, int y, std::uint32_t codepoint, int scale = 1, bool on = true,
|
||||
Rotation rotation = Rotation::None) {
|
||||
const auto* font = detail::defaultFont();
|
||||
if (!font)
|
||||
return;
|
||||
const auto glyphIndex = cardboy::gfx::glyphIndexForCodepoint(*font, codepoint);
|
||||
const auto* glyph = font->view.glyphPointer(glyphIndex);
|
||||
if (!glyph)
|
||||
return;
|
||||
|
||||
const int width = detail::fontGlyphWidth();
|
||||
const int height = detail::fontGlyphHeight();
|
||||
const std::uint32_t rowStride = detail::bytesPerRow(*font);
|
||||
|
||||
if (rotation == Rotation::None && scale == 1 && on && rowStride == 1 && ((x % 8) == 0)) {
|
||||
for (int row = 0; row < height; ++row) {
|
||||
const std::uint8_t rowBits = glyph[row];
|
||||
fb.drawBits8(x, y + row, rowBits);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (int row = 0; row < kGlyphHeight; ++row) {
|
||||
const uint8_t rowBits = rows[row];
|
||||
for (int col = 0; col < kGlyphWidth; ++col) {
|
||||
const uint8_t mask = static_cast<uint8_t>(1u << (kGlyphWidth - 1 - col));
|
||||
if ((rowBits & mask) == 0)
|
||||
|
||||
for (int row = 0; row < height; ++row) {
|
||||
const std::uint8_t* rowData = glyph + row * rowStride;
|
||||
for (int col = 0; col < width; ++col) {
|
||||
const std::uint8_t byte = rowData[col / 8];
|
||||
const std::uint8_t mask = static_cast<std::uint8_t>(0x80u >> (col % 8));
|
||||
if ((byte & mask) == 0)
|
||||
continue;
|
||||
for (int sx = 0; sx < scale; ++sx) {
|
||||
for (int sy = 0; sy < scale; ++sy) {
|
||||
@@ -65,10 +120,10 @@ inline void drawGlyph(Framebuffer& fb, int x, int y, char ch, int scale = 1, boo
|
||||
break;
|
||||
case Rotation::Clockwise90:
|
||||
dstX += row * scale + sx;
|
||||
dstY += (kGlyphWidth - 1 - col) * scale + sy;
|
||||
dstY += (width - 1 - col) * scale + sy;
|
||||
break;
|
||||
case Rotation::CounterClockwise90:
|
||||
dstX += (kGlyphHeight - 1 - row) * scale + sx;
|
||||
dstX += (height - 1 - row) * scale + sx;
|
||||
dstY += col * scale + sy;
|
||||
break;
|
||||
}
|
||||
@@ -79,13 +134,25 @@ inline void drawGlyph(Framebuffer& fb, int x, int y, char ch, int scale = 1, boo
|
||||
}
|
||||
}
|
||||
|
||||
template<typename Framebuffer>
|
||||
inline void drawGlyph(Framebuffer& fb, int x, int y, char ch, int scale = 1, bool on = true,
|
||||
Rotation rotation = Rotation::None) {
|
||||
const auto codepoint = static_cast<std::uint32_t>(static_cast<unsigned char>(ch));
|
||||
drawGlyphCodepoint(fb, x, y, codepoint, scale, on, rotation);
|
||||
}
|
||||
|
||||
inline TextBounds measureTextBounds(std::string_view text, int scale = 1, int letterSpacing = 1,
|
||||
Rotation rotation = Rotation::None) {
|
||||
if (text.empty())
|
||||
return {};
|
||||
const int advance = (kGlyphWidth + letterSpacing) * scale;
|
||||
const int extent = static_cast<int>(text.size()) * advance - letterSpacing * scale;
|
||||
const int height = kGlyphHeight * scale;
|
||||
const std::size_t glyphCount = detail::countCodepoints(text);
|
||||
if (glyphCount == 0)
|
||||
return {};
|
||||
const int glyphWidth = detail::fontGlyphWidth();
|
||||
const int glyphHeight = detail::fontGlyphHeight();
|
||||
const int advance = (glyphWidth + letterSpacing) * scale;
|
||||
const int extent = static_cast<int>(glyphCount) * advance - letterSpacing * scale;
|
||||
const int height = glyphHeight * scale;
|
||||
switch (rotation) {
|
||||
case Rotation::None:
|
||||
return {extent, height};
|
||||
@@ -99,8 +166,12 @@ inline TextBounds measureTextBounds(std::string_view text, int scale = 1, int le
|
||||
inline int measureText(std::string_view text, int scale = 1, int letterSpacing = 1) {
|
||||
if (text.empty())
|
||||
return 0;
|
||||
const int advance = (kGlyphWidth + letterSpacing) * scale;
|
||||
return static_cast<int>(text.size()) * advance - letterSpacing * scale;
|
||||
const std::size_t glyphCount = detail::countCodepoints(text);
|
||||
if (glyphCount == 0)
|
||||
return 0;
|
||||
const int glyphWidth = detail::fontGlyphWidth();
|
||||
const int advance = (glyphWidth + letterSpacing) * scale;
|
||||
return static_cast<int>(glyphCount) * advance - letterSpacing * scale;
|
||||
}
|
||||
|
||||
template<typename Framebuffer>
|
||||
@@ -108,17 +179,21 @@ inline void drawText(Framebuffer& fb, int x, int y, std::string_view text, int s
|
||||
int letterSpacing = 1, Rotation rotation = Rotation::None) {
|
||||
if (text.empty())
|
||||
return;
|
||||
const int advance = (kGlyphWidth + letterSpacing) * scale;
|
||||
const int glyphWidth = detail::fontGlyphWidth();
|
||||
const int advance = (glyphWidth + letterSpacing) * scale;
|
||||
|
||||
if (rotation == Rotation::None) {
|
||||
int cursor = x;
|
||||
for (char ch: text) {
|
||||
drawGlyph(fb, cursor, y, ch, scale, on, rotation);
|
||||
for (auto cp: una::ranges::utf8_view{text}) {
|
||||
const auto codepoint = static_cast<std::uint32_t>(cp);
|
||||
drawGlyphCodepoint(fb, cursor, y, codepoint, scale, on, rotation);
|
||||
cursor += advance;
|
||||
}
|
||||
} else {
|
||||
int cursor = y;
|
||||
for (char ch: text) {
|
||||
drawGlyph(fb, x, cursor, ch, scale, on, rotation);
|
||||
for (auto cp: una::ranges::utf8_view{text}) {
|
||||
const auto codepoint = static_cast<std::uint32_t>(cp);
|
||||
drawGlyphCodepoint(fb, x, cursor, codepoint, scale, on, rotation);
|
||||
cursor += advance;
|
||||
}
|
||||
}
|
||||
|
||||
50
Firmware/sdk/core/include/cardboy/gfx/font_repository.hpp
Normal file
50
Firmware/sdk/core/include/cardboy/gfx/font_repository.hpp
Normal file
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include "cardboy/gfx/psf_font.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
#include <span>
|
||||
|
||||
namespace cardboy::gfx {
|
||||
|
||||
struct FontInfo {
|
||||
std::string_view name;
|
||||
const std::uint8_t* data;
|
||||
std::size_t size;
|
||||
};
|
||||
|
||||
struct FontRecord {
|
||||
std::string name;
|
||||
const std::uint8_t* data = nullptr;
|
||||
std::size_t size = 0;
|
||||
ParsedPsfFont font;
|
||||
};
|
||||
|
||||
class FontRepository {
|
||||
public:
|
||||
static FontRepository& instance();
|
||||
|
||||
const FontRecord* addFont(std::string_view name, const std::uint8_t* data, std::size_t size);
|
||||
const FontRecord* getFont(std::string_view name) const;
|
||||
std::vector<FontInfo> listFonts() const;
|
||||
|
||||
private:
|
||||
FontRepository() = default;
|
||||
~FontRepository() = default;
|
||||
|
||||
FontRepository(const FontRepository&) = delete;
|
||||
FontRepository& operator=(const FontRepository&) = delete;
|
||||
|
||||
const FontRecord* addOrUpdateLocked(std::string_view name, const std::uint8_t* data, std::size_t size);
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
std::vector<std::unique_ptr<FontRecord>> fonts_;
|
||||
};
|
||||
|
||||
} // namespace cardboy::gfx
|
||||
48
Firmware/sdk/core/include/cardboy/gfx/psf_font.hpp
Normal file
48
Firmware/sdk/core/include/cardboy/gfx/psf_font.hpp
Normal file
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace cardboy::gfx {
|
||||
|
||||
enum class PsfFormat { Unknown, Psf1, Psf2 };
|
||||
|
||||
struct PsfFontView {
|
||||
PsfFormat format = PsfFormat::Unknown;
|
||||
std::span<const std::uint8_t> blob = {};
|
||||
std::span<const std::uint8_t> glyphs = {};
|
||||
std::span<const std::uint8_t> unicode = {};
|
||||
std::uint32_t glyphCount = 0;
|
||||
std::uint32_t bytesPerGlyph = 0;
|
||||
std::uint32_t width = 0;
|
||||
std::uint32_t height = 0;
|
||||
bool hasUnicode = false;
|
||||
|
||||
const std::uint8_t* glyphPointer(std::uint32_t index) const;
|
||||
};
|
||||
|
||||
struct PsfUnicodeEntry {
|
||||
std::uint32_t codepoint = 0;
|
||||
std::uint32_t glyphIndex = 0;
|
||||
};
|
||||
|
||||
struct ParsedPsfFont {
|
||||
PsfFontView view;
|
||||
std::vector<PsfUnicodeEntry> unicodeEntries;
|
||||
std::unordered_map<std::uint32_t, std::uint32_t> unicodeToGlyph;
|
||||
std::uint32_t defaultGlyphIndex = 0;
|
||||
bool valid = false;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
ParsedPsfFont parsePsfFont(std::span<const std::uint8_t> blob);
|
||||
|
||||
std::uint32_t glyphIndexForCodepoint(const ParsedPsfFont& font, std::uint32_t codepoint);
|
||||
|
||||
} // namespace cardboy::gfx
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <cardboy/sdk/app_events.hpp>
|
||||
#include <cardboy/sdk/backend.hpp>
|
||||
#include <cardboy/sdk/platform.hpp>
|
||||
#include <cardboy/sdk/services.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
@@ -14,30 +16,6 @@ namespace cardboy::sdk {
|
||||
|
||||
class AppSystem;
|
||||
|
||||
using AppTimerHandle = std::uint32_t;
|
||||
constexpr AppTimerHandle kInvalidAppTimer = 0;
|
||||
|
||||
enum class AppEventType {
|
||||
Button,
|
||||
Timer,
|
||||
};
|
||||
|
||||
struct AppButtonEvent {
|
||||
InputState current{};
|
||||
InputState previous{};
|
||||
};
|
||||
|
||||
struct AppTimerEvent {
|
||||
AppTimerHandle handle = kInvalidAppTimer;
|
||||
};
|
||||
|
||||
struct AppEvent {
|
||||
AppEventType type;
|
||||
std::uint32_t timestamp_ms = 0;
|
||||
AppButtonEvent button{};
|
||||
AppTimerEvent timer{};
|
||||
};
|
||||
|
||||
using ActiveBackend = cardboy::backend::ActiveBackend;
|
||||
|
||||
struct AppContext {
|
||||
@@ -56,72 +34,52 @@ struct AppContext {
|
||||
|
||||
[[nodiscard]] Services* getServices() const { return services; }
|
||||
|
||||
[[nodiscard]] IBuzzer* buzzer() const { return services ? services->buzzer : nullptr; }
|
||||
[[nodiscard]] IBatteryMonitor* battery() const { return services ? services->battery : nullptr; }
|
||||
[[nodiscard]] IStorage* storage() const { return services ? services->storage : nullptr; }
|
||||
[[nodiscard]] IRandom* random() const { return services ? services->random : nullptr; }
|
||||
[[nodiscard]] IHighResClock* highResClock() const { return services ? services->highResClock : nullptr; }
|
||||
[[nodiscard]] IFilesystem* filesystem() const { return services ? services->filesystem : nullptr; }
|
||||
[[nodiscard]] IEventBus* eventBus() const { return services ? services->eventBus : nullptr; }
|
||||
[[nodiscard]] ILoopHooks* loopHooks() const { return services ? services->loopHooks : nullptr; }
|
||||
[[nodiscard]] IBuzzer* buzzer() const { return services ? services->buzzer : nullptr; }
|
||||
[[nodiscard]] IBatteryMonitor* battery() const { return services ? services->battery : nullptr; }
|
||||
[[nodiscard]] IStorage* storage() const { return services ? services->storage : nullptr; }
|
||||
[[nodiscard]] IRandom* random() const { return services ? services->random : nullptr; }
|
||||
[[nodiscard]] IHighResClock* highResClock() const { return services ? services->highResClock : nullptr; }
|
||||
[[nodiscard]] IFilesystem* filesystem() const { return services ? services->filesystem : nullptr; }
|
||||
[[nodiscard]] AppScopedServices* appServices() const { return _scopedServices; }
|
||||
[[nodiscard]] IEventBus* eventBus() const { return services ? services->eventBus : nullptr; }
|
||||
[[nodiscard]] ITimerService* timer() const { return _scopedServices ? _scopedServices->timer : nullptr; }
|
||||
[[nodiscard]] ILoopHooks* loopHooks() const { return services ? services->loopHooks : nullptr; }
|
||||
[[nodiscard]] INotificationCenter* notificationCenter() const {
|
||||
return services ? services->notifications : nullptr;
|
||||
}
|
||||
|
||||
void requestAppSwitchByIndex(std::size_t index) {
|
||||
pendingAppIndex = index;
|
||||
pendingAppName.clear();
|
||||
pendingSwitchByName = false;
|
||||
pendingSwitch = true;
|
||||
_pendingAppIndex = index;
|
||||
_pendingAppName.clear();
|
||||
_pendingSwitchByName = false;
|
||||
_pendingSwitch = true;
|
||||
}
|
||||
|
||||
void requestAppSwitchByName(std::string_view name) {
|
||||
pendingAppName.assign(name.begin(), name.end());
|
||||
pendingSwitchByName = true;
|
||||
pendingSwitch = true;
|
||||
_pendingAppName.assign(name.begin(), name.end());
|
||||
_pendingSwitchByName = true;
|
||||
_pendingSwitch = true;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool hasPendingAppSwitch() const { return pendingSwitch; }
|
||||
|
||||
AppTimerHandle scheduleTimer(std::uint32_t delay_ms, bool repeat = false) {
|
||||
if (!system)
|
||||
return kInvalidAppTimer;
|
||||
return scheduleTimerInternal(delay_ms, repeat);
|
||||
}
|
||||
|
||||
AppTimerHandle scheduleRepeatingTimer(std::uint32_t interval_ms) {
|
||||
if (!system)
|
||||
return kInvalidAppTimer;
|
||||
return scheduleTimerInternal(interval_ms, true);
|
||||
}
|
||||
|
||||
void cancelTimer(AppTimerHandle handle) {
|
||||
if (!system)
|
||||
return;
|
||||
cancelTimerInternal(handle);
|
||||
}
|
||||
|
||||
void cancelAllTimers() {
|
||||
if (!system)
|
||||
return;
|
||||
cancelAllTimersInternal();
|
||||
}
|
||||
[[nodiscard]] bool hasPendingAppSwitch() const { return _pendingSwitch; }
|
||||
|
||||
private:
|
||||
friend class AppSystem;
|
||||
bool pendingSwitch = false;
|
||||
bool pendingSwitchByName = false;
|
||||
std::size_t pendingAppIndex = 0;
|
||||
std::string pendingAppName;
|
||||
bool _pendingSwitch = false;
|
||||
bool _pendingSwitchByName = false;
|
||||
std::size_t _pendingAppIndex = 0;
|
||||
std::string _pendingAppName;
|
||||
AppScopedServices* _scopedServices = nullptr;
|
||||
|
||||
AppTimerHandle scheduleTimerInternal(std::uint32_t delay_ms, bool repeat);
|
||||
void cancelTimerInternal(AppTimerHandle handle);
|
||||
void cancelAllTimersInternal();
|
||||
void setScopedServices(AppScopedServices* services) { _scopedServices = services; }
|
||||
};
|
||||
|
||||
class IApp {
|
||||
public:
|
||||
virtual ~IApp() = default;
|
||||
virtual void onStart() {}
|
||||
virtual void onStop() {}
|
||||
virtual void handleEvent(const AppEvent& event) = 0;
|
||||
virtual void onStart() {}
|
||||
virtual void onStop() {}
|
||||
virtual std::optional<std::uint32_t> handleEvent(const AppEvent& event) = 0;
|
||||
};
|
||||
|
||||
class IAppFactory {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <cardboy/sdk/event_bus.hpp>
|
||||
#include "app_framework.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -16,68 +15,31 @@ public:
|
||||
~AppSystem();
|
||||
|
||||
void registerApp(std::unique_ptr<IAppFactory> factory);
|
||||
bool startApp(const std::string& name);
|
||||
bool startAppByIndex(std::size_t index);
|
||||
void startApp(const std::string& name);
|
||||
void startAppByIndex(std::size_t index);
|
||||
|
||||
void run();
|
||||
|
||||
[[nodiscard]] std::size_t appCount() const { return factories.size(); }
|
||||
[[nodiscard]] std::size_t appCount() const { return _factories.size(); }
|
||||
[[nodiscard]] const IAppFactory* factoryAt(std::size_t index) const;
|
||||
[[nodiscard]] std::size_t indexOfFactory(const IAppFactory* factory) const;
|
||||
[[nodiscard]] std::size_t currentFactoryIndex() const { return activeIndex; }
|
||||
[[nodiscard]] std::size_t currentFactoryIndex() const { return _activeIndex; }
|
||||
|
||||
[[nodiscard]] const IApp* currentApp() const { return current.get(); }
|
||||
[[nodiscard]] const IAppFactory* currentFactory() const { return activeFactory; }
|
||||
[[nodiscard]] const IApp* currentApp() const { return _current.get(); }
|
||||
[[nodiscard]] const IAppFactory* currentFactory() const { return _activeFactory; }
|
||||
|
||||
private:
|
||||
friend struct AppContext;
|
||||
void handlePendingSwitchRequest();
|
||||
std::unique_ptr<AppScopedServices> createAppScopedServices(std::uint64_t generation);
|
||||
|
||||
struct TimerRecord {
|
||||
AppTimerHandle id = kInvalidAppTimer;
|
||||
std::uint32_t generation = 0;
|
||||
std::uint32_t due_ms = 0;
|
||||
std::uint32_t interval_ms = 0;
|
||||
bool repeat = false;
|
||||
bool active = false;
|
||||
};
|
||||
|
||||
AppTimerHandle scheduleTimer(std::uint32_t delay_ms, bool repeat);
|
||||
void cancelTimer(AppTimerHandle handle);
|
||||
void cancelAllTimers();
|
||||
|
||||
void dispatchEvent(const AppEvent& event);
|
||||
|
||||
void processDueTimers(std::uint32_t now, std::vector<AppEvent>& outEvents);
|
||||
std::uint32_t nextTimerDueMs(std::uint32_t now) const;
|
||||
void clearTimersForCurrentApp();
|
||||
TimerRecord* findTimer(AppTimerHandle handle);
|
||||
bool handlePendingSwitchRequest();
|
||||
void notifyEventBus(EventBusSignal signal);
|
||||
|
||||
AppContext context;
|
||||
std::vector<std::unique_ptr<IAppFactory>> factories;
|
||||
std::unique_ptr<IApp> current;
|
||||
IAppFactory* activeFactory = nullptr;
|
||||
std::size_t activeIndex = static_cast<std::size_t>(-1);
|
||||
std::vector<TimerRecord> timers;
|
||||
AppTimerHandle nextTimerId = 1;
|
||||
std::uint32_t currentGeneration = 0;
|
||||
InputState lastInputState{};
|
||||
bool suppressInputs = false;
|
||||
AppContext _context;
|
||||
std::vector<std::unique_ptr<IAppFactory>> _factories;
|
||||
std::unique_ptr<IApp> _current;
|
||||
IAppFactory* _activeFactory = nullptr;
|
||||
std::size_t _activeIndex = static_cast<std::size_t>(-1);
|
||||
std::unique_ptr<AppScopedServices> _scopedServices;
|
||||
std::uint64_t _nextScopedGeneration = 1;
|
||||
std::optional<std::uint32_t> _currentTimeout;
|
||||
};
|
||||
|
||||
inline AppTimerHandle AppContext::scheduleTimerInternal(std::uint32_t delay_ms, bool repeat) {
|
||||
return system ? system->scheduleTimer(delay_ms, repeat) : kInvalidAppTimer;
|
||||
}
|
||||
|
||||
inline void AppContext::cancelTimerInternal(AppTimerHandle handle) {
|
||||
if (system)
|
||||
system->cancelTimer(handle);
|
||||
}
|
||||
|
||||
inline void AppContext::cancelAllTimersInternal() {
|
||||
if (system)
|
||||
system->cancelAllTimers();
|
||||
}
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
@@ -13,8 +13,8 @@ public:
|
||||
static void invokePreSend(void* framebuffer);
|
||||
|
||||
private:
|
||||
static PreSendHook hook_;
|
||||
static void* userData_;
|
||||
static PreSendHook _hook;
|
||||
static void* _userData;
|
||||
};
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
@@ -17,11 +17,11 @@ class StatusBar {
|
||||
public:
|
||||
static StatusBar& instance();
|
||||
|
||||
void setServices(Services* services) { services_ = services; }
|
||||
void setServices(Services* services) { _services = services; }
|
||||
|
||||
void setEnabled(bool value);
|
||||
void toggle();
|
||||
[[nodiscard]] bool isEnabled() const { return enabled_; }
|
||||
[[nodiscard]] bool isEnabled() const { return _enabled; }
|
||||
|
||||
void setCurrentAppName(std::string_view name);
|
||||
|
||||
@@ -29,7 +29,7 @@ public:
|
||||
|
||||
template<typename Framebuffer>
|
||||
void renderIfEnabled(Framebuffer& fb) {
|
||||
if (!enabled_)
|
||||
if (!_enabled)
|
||||
return;
|
||||
renderBar(fb);
|
||||
}
|
||||
@@ -56,9 +56,6 @@ private:
|
||||
fb.drawPixel(x, y, true);
|
||||
}
|
||||
|
||||
for (int x = 0; x < width; ++x)
|
||||
fb.drawPixel(x, 0, false);
|
||||
|
||||
const int textY = 1;
|
||||
const int bottomSeparatorY = textY + font16x8::kGlyphHeight + 1;
|
||||
if (bottomSeparatorY < fillHeight) {
|
||||
@@ -84,9 +81,9 @@ private:
|
||||
[[nodiscard]] std::string prepareLeftText(int displayWidth) const;
|
||||
[[nodiscard]] std::string prepareRightText() const;
|
||||
|
||||
bool enabled_ = false;
|
||||
Services* services_ = nullptr;
|
||||
std::string appName_{};
|
||||
bool _enabled = false;
|
||||
Services* _services = nullptr;
|
||||
std::string _appName{};
|
||||
};
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
@@ -3,19 +3,11 @@
|
||||
#include "cardboy/sdk/status_bar.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
|
||||
namespace cardboy::sdk {
|
||||
namespace {
|
||||
[[nodiscard]] bool inputsDiffer(const InputState& a, const InputState& b) {
|
||||
return a.up != b.up || a.down != b.down || a.left != b.left || a.right != b.right || a.a != b.a || a.b != b.b ||
|
||||
a.select != b.select || a.start != b.start;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool anyButtonPressed(const InputState& state) {
|
||||
return state.up || state.down || state.left || state.right || state.a || state.b || state.select || state.start;
|
||||
}
|
||||
|
||||
template<typename Framebuffer>
|
||||
void statusBarPreSendHook(void* framebuffer, void* userData) {
|
||||
@@ -26,263 +18,128 @@ void statusBarPreSendHook(void* framebuffer, void* userData) {
|
||||
}
|
||||
} // namespace
|
||||
|
||||
AppSystem::AppSystem(AppContext ctx) : context(std::move(ctx)) {
|
||||
context.system = this;
|
||||
AppSystem::AppSystem(AppContext ctx) : _context(std::move(ctx)) {
|
||||
_context.system = this;
|
||||
auto& statusBar = StatusBar::instance();
|
||||
statusBar.setServices(context.services);
|
||||
using FBType = typename AppContext::Framebuffer;
|
||||
FramebufferHooks::setPreSendHook(&statusBarPreSendHook<FBType>, &statusBar);
|
||||
statusBar.setServices(_context.services);
|
||||
FramebufferHooks::setPreSendHook(&statusBarPreSendHook<AppContext::Framebuffer>, &statusBar);
|
||||
}
|
||||
|
||||
AppSystem::~AppSystem() { FramebufferHooks::clearPreSendHook(); }
|
||||
|
||||
void AppSystem::registerApp(std::unique_ptr<IAppFactory> factory) {
|
||||
if (!factory)
|
||||
return;
|
||||
factories.emplace_back(std::move(factory));
|
||||
assert(factory);
|
||||
_factories.emplace_back(std::move(factory));
|
||||
}
|
||||
|
||||
bool AppSystem::startApp(const std::string& name) {
|
||||
for (std::size_t i = 0; i < factories.size(); ++i) {
|
||||
if (factories[i]->name() == name)
|
||||
return startAppByIndex(i);
|
||||
void AppSystem::startApp(const std::string& name) {
|
||||
for (std::size_t i = 0; i < _factories.size(); ++i) {
|
||||
if (_factories[i]->name() == name)
|
||||
startAppByIndex(i);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool AppSystem::startAppByIndex(std::size_t index) {
|
||||
if (index >= factories.size())
|
||||
return false;
|
||||
void AppSystem::startAppByIndex(std::size_t index) {
|
||||
assert(index < _factories.size());
|
||||
|
||||
context.system = this;
|
||||
auto& factory = factories[index];
|
||||
auto app = factory->create(context);
|
||||
if (!app)
|
||||
return false;
|
||||
|
||||
if (current) {
|
||||
current->onStop();
|
||||
current.reset();
|
||||
if (_current) {
|
||||
_current->onStop();
|
||||
_current.reset();
|
||||
}
|
||||
|
||||
activeFactory = factory.get();
|
||||
activeIndex = index;
|
||||
context.pendingSwitch = false;
|
||||
context.pendingSwitchByName = false;
|
||||
context.pendingAppName.clear();
|
||||
clearTimersForCurrentApp();
|
||||
current = std::move(app);
|
||||
lastInputState = context.input.readState();
|
||||
suppressInputs = true;
|
||||
StatusBar::instance().setServices(context.services);
|
||||
StatusBar::instance().setCurrentAppName(activeFactory ? activeFactory->name() : "");
|
||||
current->onStart();
|
||||
return true;
|
||||
_context.system = this;
|
||||
|
||||
auto& factory = _factories[index];
|
||||
|
||||
const std::uint64_t newGeneration = _nextScopedGeneration++;
|
||||
|
||||
auto app = factory->create(_context);
|
||||
assert(app);
|
||||
|
||||
_scopedServices.reset();
|
||||
auto scoped = createAppScopedServices(newGeneration);
|
||||
_scopedServices = std::move(scoped);
|
||||
_context.setScopedServices(_scopedServices.get());
|
||||
|
||||
_activeFactory = factory.get();
|
||||
_activeIndex = index;
|
||||
_context._pendingSwitch = false;
|
||||
_context._pendingSwitchByName = false;
|
||||
_context._pendingAppName.clear();
|
||||
_current = std::move(app);
|
||||
_currentTimeout = std::nullopt;
|
||||
StatusBar::instance().setServices(_context.services);
|
||||
StatusBar::instance().setCurrentAppName(_activeFactory ? _activeFactory->name() : "");
|
||||
_current->onStart();
|
||||
}
|
||||
|
||||
void AppSystem::run() {
|
||||
if (!current) {
|
||||
if (factories.empty() || !startAppByIndex(0))
|
||||
return;
|
||||
if (!_current) {
|
||||
assert(!_factories.empty());
|
||||
startAppByIndex(0);
|
||||
}
|
||||
|
||||
std::vector<AppEvent> events;
|
||||
events.reserve(4);
|
||||
|
||||
while (true) {
|
||||
if (auto* hooks = context.loopHooks())
|
||||
if (auto* hooks = _context.loopHooks())
|
||||
hooks->onLoopIteration();
|
||||
|
||||
events.clear();
|
||||
const std::uint32_t now = context.clock.millis();
|
||||
processDueTimers(now, events);
|
||||
|
||||
const InputState inputNow = context.input.readState();
|
||||
const bool consumedByStatusToggle = StatusBar::instance().handleToggleInput(inputNow, lastInputState);
|
||||
|
||||
if (suppressInputs) {
|
||||
lastInputState = inputNow;
|
||||
if (!anyButtonPressed(inputNow))
|
||||
suppressInputs = false;
|
||||
} else if (!consumedByStatusToggle && inputsDiffer(inputNow, lastInputState)) {
|
||||
AppEvent evt{};
|
||||
evt.type = AppEventType::Button;
|
||||
evt.timestamp_ms = now;
|
||||
evt.button.current = inputNow;
|
||||
evt.button.previous = lastInputState;
|
||||
events.push_back(evt);
|
||||
lastInputState = inputNow;
|
||||
} else if (consumedByStatusToggle) {
|
||||
lastInputState = inputNow;
|
||||
}
|
||||
|
||||
for (const auto& evt: events) {
|
||||
dispatchEvent(evt);
|
||||
if (handlePendingSwitchRequest())
|
||||
break;
|
||||
}
|
||||
|
||||
const std::uint32_t waitBase = context.clock.millis();
|
||||
std::uint32_t waitMs = nextTimerDueMs(waitBase);
|
||||
|
||||
if (waitMs == 0)
|
||||
continue;
|
||||
|
||||
auto* eventBus = context.eventBus();
|
||||
if (!eventBus)
|
||||
return;
|
||||
|
||||
const std::uint32_t mask = to_event_bits(EventBusSignal::Input) | to_event_bits(EventBusSignal::Timer);
|
||||
|
||||
if (waitMs == std::numeric_limits<std::uint32_t>::max()) {
|
||||
eventBus->cancelTimerSignal();
|
||||
eventBus->wait(mask, IEventBus::kWaitForever);
|
||||
AppEvent event;
|
||||
auto event_opt = _context.eventBus()->pop(_currentTimeout);
|
||||
if (!event_opt) {
|
||||
event = AppEvent{_context.clock.millis(), AppTimeoutEvent{}};
|
||||
} else {
|
||||
eventBus->scheduleTimerSignal(waitMs);
|
||||
eventBus->wait(mask, IEventBus::kWaitForever);
|
||||
event = *event_opt;
|
||||
}
|
||||
|
||||
if (const auto* btn = event.button()) {
|
||||
const bool consumedByStatusToggle = StatusBar::instance().handleToggleInput(btn->current, btn->previous);
|
||||
if (consumedByStatusToggle) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_currentTimeout = _current->handleEvent(event);
|
||||
if (_context._pendingSwitch) {
|
||||
handlePendingSwitchRequest();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const IAppFactory* AppSystem::factoryAt(std::size_t index) const {
|
||||
if (index >= factories.size())
|
||||
if (index >= _factories.size())
|
||||
return nullptr;
|
||||
return factories[index].get();
|
||||
return _factories[index].get();
|
||||
}
|
||||
|
||||
std::size_t AppSystem::indexOfFactory(const IAppFactory* factory) const {
|
||||
if (!factory)
|
||||
return static_cast<std::size_t>(-1);
|
||||
for (std::size_t i = 0; i < factories.size(); ++i) {
|
||||
if (factories[i].get() == factory)
|
||||
for (std::size_t i = 0; i < _factories.size(); ++i) {
|
||||
if (_factories[i].get() == factory)
|
||||
return i;
|
||||
}
|
||||
return static_cast<std::size_t>(-1);
|
||||
}
|
||||
|
||||
AppTimerHandle AppSystem::scheduleTimer(std::uint32_t delay_ms, bool repeat) {
|
||||
if (!current)
|
||||
return kInvalidAppTimer;
|
||||
TimerRecord record;
|
||||
record.id = nextTimerId++;
|
||||
if (record.id == kInvalidAppTimer)
|
||||
record.id = nextTimerId++;
|
||||
record.generation = currentGeneration;
|
||||
const auto now = context.clock.millis();
|
||||
record.due_ms = now + delay_ms;
|
||||
record.interval_ms = repeat ? std::max<std::uint32_t>(1, delay_ms) : 0;
|
||||
record.repeat = repeat;
|
||||
record.active = true;
|
||||
timers.push_back(record);
|
||||
notifyEventBus(EventBusSignal::Timer);
|
||||
return record.id;
|
||||
}
|
||||
|
||||
void AppSystem::cancelTimer(AppTimerHandle handle) {
|
||||
auto* timer = findTimer(handle);
|
||||
if (!timer)
|
||||
return;
|
||||
timer->active = false;
|
||||
timers.erase(std::remove_if(timers.begin(), timers.end(), [](const TimerRecord& rec) { return !rec.active; }),
|
||||
timers.end());
|
||||
notifyEventBus(EventBusSignal::Timer);
|
||||
}
|
||||
|
||||
void AppSystem::cancelAllTimers() {
|
||||
bool changed = false;
|
||||
for (auto& timer: timers) {
|
||||
if (timer.generation == currentGeneration && timer.active) {
|
||||
timer.active = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
timers.erase(std::remove_if(timers.begin(), timers.end(), [](const TimerRecord& rec) { return !rec.active; }),
|
||||
timers.end());
|
||||
if (changed)
|
||||
notifyEventBus(EventBusSignal::Timer);
|
||||
}
|
||||
|
||||
void AppSystem::dispatchEvent(const AppEvent& event) {
|
||||
if (current)
|
||||
current->handleEvent(event);
|
||||
}
|
||||
|
||||
void AppSystem::processDueTimers(std::uint32_t now, std::vector<AppEvent>& outEvents) {
|
||||
for (auto& timer: timers) {
|
||||
if (!timer.active || timer.generation != currentGeneration)
|
||||
continue;
|
||||
if (static_cast<std::int32_t>(now - timer.due_ms) >= 0) {
|
||||
AppEvent ev{};
|
||||
ev.type = AppEventType::Timer;
|
||||
ev.timestamp_ms = now;
|
||||
ev.timer.handle = timer.id;
|
||||
outEvents.push_back(ev);
|
||||
if (timer.repeat) {
|
||||
const std::uint32_t interval = timer.interval_ms ? timer.interval_ms : 1;
|
||||
timer.due_ms = now + interval;
|
||||
} else {
|
||||
timer.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
timers.erase(std::remove_if(timers.begin(), timers.end(), [](const TimerRecord& rec) { return !rec.active; }),
|
||||
timers.end());
|
||||
}
|
||||
|
||||
std::uint32_t AppSystem::nextTimerDueMs(std::uint32_t now) const {
|
||||
std::uint32_t minWait = std::numeric_limits<std::uint32_t>::max();
|
||||
for (const auto& timer: timers) {
|
||||
if (!timer.active || timer.generation != currentGeneration)
|
||||
continue;
|
||||
if (static_cast<std::int32_t>(now - timer.due_ms) >= 0)
|
||||
return 0;
|
||||
const std::uint32_t delta = timer.due_ms - now;
|
||||
if (delta < minWait)
|
||||
minWait = delta;
|
||||
}
|
||||
return minWait;
|
||||
}
|
||||
|
||||
void AppSystem::clearTimersForCurrentApp() {
|
||||
const bool hadTimers = !timers.empty();
|
||||
++currentGeneration;
|
||||
timers.clear();
|
||||
if (hadTimers)
|
||||
notifyEventBus(EventBusSignal::Timer);
|
||||
}
|
||||
|
||||
AppSystem::TimerRecord* AppSystem::findTimer(AppTimerHandle handle) {
|
||||
for (auto& timer: timers) {
|
||||
if (!timer.active || timer.generation != currentGeneration)
|
||||
continue;
|
||||
if (timer.id == handle)
|
||||
return &timer;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool AppSystem::handlePendingSwitchRequest() {
|
||||
if (!context.pendingSwitch)
|
||||
return false;
|
||||
const bool byName = context.pendingSwitchByName;
|
||||
const std::size_t reqIndex = context.pendingAppIndex;
|
||||
const std::string reqName = context.pendingAppName;
|
||||
context.pendingSwitch = false;
|
||||
context.pendingSwitchByName = false;
|
||||
context.pendingAppName.clear();
|
||||
void AppSystem::handlePendingSwitchRequest() {
|
||||
assert(_context._pendingSwitch);
|
||||
const bool byName = _context._pendingSwitchByName;
|
||||
const std::size_t reqIndex = _context._pendingAppIndex;
|
||||
const std::string reqName = _context._pendingAppName;
|
||||
_context._pendingSwitch = false;
|
||||
_context._pendingSwitchByName = false;
|
||||
_context._pendingAppName.clear();
|
||||
bool switched = false;
|
||||
if (byName)
|
||||
switched = startApp(reqName);
|
||||
startApp(reqName);
|
||||
else
|
||||
switched = startAppByIndex(reqIndex);
|
||||
return switched;
|
||||
startAppByIndex(reqIndex);
|
||||
}
|
||||
|
||||
void AppSystem::notifyEventBus(EventBusSignal signal) {
|
||||
if (signal == EventBusSignal::None)
|
||||
return;
|
||||
if (auto* bus = context.eventBus())
|
||||
bus->signal(to_event_bits(signal));
|
||||
std::unique_ptr<AppScopedServices> AppSystem::createAppScopedServices(std::uint64_t generation) {
|
||||
if (!_context.services || !_context.services->appServices)
|
||||
return nullptr;
|
||||
return _context.services->appServices->createScopedServices(generation);
|
||||
}
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
31
Firmware/sdk/core/src/builtin_fonts.cpp
Normal file
31
Firmware/sdk/core/src/builtin_fonts.cpp
Normal file
@@ -0,0 +1,31 @@
|
||||
#include "cardboy/gfx/builtin_fonts.hpp"
|
||||
|
||||
#include "cardboy/gfx/font_repository.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
extern unsigned char spleen8x16_psf[];
|
||||
extern unsigned int spleen8x16_psf_len;
|
||||
|
||||
namespace cardboy::gfx::builtin {
|
||||
|
||||
namespace {
|
||||
|
||||
void registerFonts() {
|
||||
static bool registered = false;
|
||||
if (registered)
|
||||
return;
|
||||
registered = true;
|
||||
const auto* spleenData = reinterpret_cast<const std::uint8_t*>(spleen8x16_psf);
|
||||
const std::size_t spleenSize = static_cast<std::size_t>(spleen8x16_psf_len);
|
||||
FontRepository::instance().addFont("Spleen 8x16", spleenData, spleenSize);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ensureRegistered() {
|
||||
registerFonts();
|
||||
}
|
||||
|
||||
} // namespace cardboy::gfx::builtin
|
||||
66
Firmware/sdk/core/src/font_repository.cpp
Normal file
66
Firmware/sdk/core/src/font_repository.cpp
Normal file
@@ -0,0 +1,66 @@
|
||||
#include "cardboy/gfx/font_repository.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace cardboy::gfx {
|
||||
|
||||
FontRepository& FontRepository::instance() {
|
||||
static FontRepository repository;
|
||||
return repository;
|
||||
}
|
||||
|
||||
const FontRecord* FontRepository::addFont(std::string_view name, const std::uint8_t* data, std::size_t size) {
|
||||
if (name.empty() || data == nullptr || size == 0)
|
||||
return nullptr;
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return addOrUpdateLocked(name, data, size);
|
||||
}
|
||||
|
||||
const FontRecord* FontRepository::getFont(std::string_view name) const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
const auto it = std::find_if(fonts_.begin(), fonts_.end(),
|
||||
[&](const std::unique_ptr<FontRecord>& record) { return record->name == name; });
|
||||
if (it == fonts_.end())
|
||||
return nullptr;
|
||||
return it->get();
|
||||
}
|
||||
|
||||
std::vector<FontInfo> FontRepository::listFonts() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
std::vector<FontInfo> result;
|
||||
result.reserve(fonts_.size());
|
||||
for (const auto& record: fonts_) {
|
||||
result.push_back(FontInfo{
|
||||
.name = record->name,
|
||||
.data = record->data,
|
||||
.size = record->size,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const FontRecord* FontRepository::addOrUpdateLocked(std::string_view name,
|
||||
const std::uint8_t* data,
|
||||
std::size_t size) {
|
||||
auto it = std::find_if(fonts_.begin(), fonts_.end(),
|
||||
[&](const std::unique_ptr<FontRecord>& record) { return record->name == name; });
|
||||
if (it == fonts_.end()) {
|
||||
auto record = std::make_unique<FontRecord>();
|
||||
record->name = std::string{name};
|
||||
record->data = data;
|
||||
record->size = size;
|
||||
record->font = parsePsfFont(std::span<const std::uint8_t>(data, size));
|
||||
fonts_.push_back(std::move(record));
|
||||
it = std::prev(fonts_.end());
|
||||
} else {
|
||||
(*it)->data = data;
|
||||
(*it)->size = size;
|
||||
(*it)->font = parsePsfFont(std::span<const std::uint8_t>(data, size));
|
||||
}
|
||||
if (!(*it)->font.valid) {
|
||||
(*it)->font.error = "Failed to parse PSF font";
|
||||
}
|
||||
return it->get();
|
||||
}
|
||||
|
||||
} // namespace cardboy::gfx
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
namespace cardboy::sdk {
|
||||
|
||||
FramebufferHooks::PreSendHook FramebufferHooks::hook_ = nullptr;
|
||||
void* FramebufferHooks::userData_ = nullptr;
|
||||
FramebufferHooks::PreSendHook FramebufferHooks::_hook = nullptr;
|
||||
void* FramebufferHooks::_userData = nullptr;
|
||||
|
||||
void FramebufferHooks::setPreSendHook(PreSendHook hook, void* userData) {
|
||||
hook_ = hook;
|
||||
userData_ = userData;
|
||||
_hook = hook;
|
||||
_userData = userData;
|
||||
}
|
||||
|
||||
void FramebufferHooks::clearPreSendHook() {
|
||||
hook_ = nullptr;
|
||||
userData_ = nullptr;
|
||||
_hook = nullptr;
|
||||
_userData = nullptr;
|
||||
}
|
||||
|
||||
void FramebufferHooks::invokePreSend(void* framebuffer) {
|
||||
if (hook_)
|
||||
hook_(framebuffer, userData_);
|
||||
if (_hook)
|
||||
_hook(framebuffer, _userData);
|
||||
}
|
||||
|
||||
} // namespace cardboy::sdk
|
||||
|
||||
211
Firmware/sdk/core/src/psf_font.cpp
Normal file
211
Firmware/sdk/core/src/psf_font.cpp
Normal file
@@ -0,0 +1,211 @@
|
||||
#include "cardboy/gfx/psf_font.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <iterator>
|
||||
|
||||
namespace cardboy::gfx {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::uint8_t kPsf1Magic0 = 0x36;
|
||||
constexpr std::uint8_t kPsf1Magic1 = 0x04;
|
||||
|
||||
constexpr std::uint8_t kPsf1Mode512 = 0x01;
|
||||
constexpr std::uint8_t kPsf1ModeHasTab = 0x02;
|
||||
|
||||
constexpr std::uint16_t kPsf1Separator = 0xFFFF;
|
||||
constexpr std::uint16_t kPsf1StartSeq = 0xFFFE;
|
||||
|
||||
constexpr std::uint32_t kPsf2Magic = 0x864ab572;
|
||||
constexpr std::uint8_t kPsf2Separator = 0xFF;
|
||||
constexpr std::uint8_t kPsf2StartSeq = 0xFE;
|
||||
|
||||
struct Psf1Header {
|
||||
std::uint8_t magic[2];
|
||||
std::uint8_t mode;
|
||||
std::uint8_t charsize;
|
||||
};
|
||||
|
||||
struct Psf2Header {
|
||||
std::uint32_t magic;
|
||||
std::uint32_t version;
|
||||
std::uint32_t headerSize;
|
||||
std::uint32_t flags;
|
||||
std::uint32_t length;
|
||||
std::uint32_t charSize;
|
||||
std::uint32_t height;
|
||||
std::uint32_t width;
|
||||
};
|
||||
|
||||
template<typename It>
|
||||
std::uint32_t decodeUtf8(It& it, const It end) {
|
||||
if (it == end)
|
||||
return 0xFFFFFFFFu;
|
||||
const std::uint8_t first = *it++;
|
||||
if ((first & 0x80u) == 0)
|
||||
return first;
|
||||
if ((first & 0xE0u) == 0xC0u) {
|
||||
if (it == end)
|
||||
return 0xFFFFFFFFu;
|
||||
const std::uint8_t b1 = *it++;
|
||||
return ((first & 0x1Fu) << 6u) | (b1 & 0x3Fu);
|
||||
}
|
||||
if ((first & 0xF0u) == 0xE0u) {
|
||||
if (std::distance(it, end) < 2)
|
||||
return 0xFFFFFFFFu;
|
||||
const std::uint8_t b1 = *it++;
|
||||
const std::uint8_t b2 = *it++;
|
||||
return ((first & 0x0Fu) << 12u) | ((b1 & 0x3Fu) << 6u) | (b2 & 0x3Fu);
|
||||
}
|
||||
if ((first & 0xF8u) == 0xF0u) {
|
||||
if (std::distance(it, end) < 3)
|
||||
return 0xFFFFFFFFu;
|
||||
const std::uint8_t b1 = *it++;
|
||||
const std::uint8_t b2 = *it++;
|
||||
const std::uint8_t b3 = *it++;
|
||||
return ((first & 0x07u) << 18u) | ((b1 & 0x3Fu) << 12u) | ((b2 & 0x3Fu) << 6u) | (b3 & 0x3Fu);
|
||||
}
|
||||
return 0xFFFFFFFFu;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const std::uint8_t* PsfFontView::glyphPointer(std::uint32_t index) const {
|
||||
if (glyphs.empty() || index >= glyphCount)
|
||||
return nullptr;
|
||||
const std::size_t offset = static_cast<std::size_t>(index) * bytesPerGlyph;
|
||||
if (offset + bytesPerGlyph > glyphs.size())
|
||||
return nullptr;
|
||||
return glyphs.data() + offset;
|
||||
}
|
||||
|
||||
ParsedPsfFont parsePsfFont(std::span<const std::uint8_t> blob) {
|
||||
ParsedPsfFont parsed;
|
||||
parsed.view.blob = blob;
|
||||
if (blob.size() < sizeof(Psf1Header))
|
||||
return parsed;
|
||||
|
||||
const auto* psf1 = reinterpret_cast<const Psf1Header*>(blob.data());
|
||||
if (psf1->magic[0] == kPsf1Magic0 && psf1->magic[1] == kPsf1Magic1) {
|
||||
parsed.view.format = PsfFormat::Psf1;
|
||||
const std::uint32_t count = (psf1->mode & kPsf1Mode512) ? 512u : 256u;
|
||||
const std::uint32_t glyphBytes = static_cast<std::uint32_t>(psf1->charsize);
|
||||
if (blob.size() < sizeof(Psf1Header) + static_cast<std::size_t>(count) * glyphBytes)
|
||||
return parsed;
|
||||
parsed.view.glyphCount = count;
|
||||
parsed.view.bytesPerGlyph = glyphBytes;
|
||||
parsed.view.width = 8;
|
||||
parsed.view.height = glyphBytes;
|
||||
parsed.view.glyphs = blob.subspan(sizeof(Psf1Header), static_cast<std::size_t>(count) * glyphBytes);
|
||||
parsed.view.hasUnicode = (psf1->mode & kPsf1ModeHasTab) != 0;
|
||||
if (parsed.view.hasUnicode && parsed.view.glyphs.size() < blob.size())
|
||||
parsed.view.unicode = blob.subspan(sizeof(Psf1Header) + parsed.view.glyphs.size());
|
||||
} else if (blob.size() >= sizeof(Psf2Header)) {
|
||||
const auto* psf2 = reinterpret_cast<const Psf2Header*>(blob.data());
|
||||
if (psf2->magic != kPsf2Magic)
|
||||
return parsed;
|
||||
parsed.view.format = PsfFormat::Psf2;
|
||||
parsed.view.glyphCount = psf2->length;
|
||||
parsed.view.bytesPerGlyph = psf2->charSize;
|
||||
parsed.view.width = psf2->width;
|
||||
parsed.view.height = psf2->height;
|
||||
const std::size_t glyphOffset = psf2->headerSize;
|
||||
const std::size_t glyphBytes = static_cast<std::size_t>(psf2->length) * psf2->charSize;
|
||||
if (blob.size() < glyphOffset + glyphBytes)
|
||||
return parsed;
|
||||
parsed.view.glyphs = blob.subspan(glyphOffset, glyphBytes);
|
||||
parsed.view.hasUnicode = (psf2->flags & 0x01u) != 0;
|
||||
if (parsed.view.hasUnicode && glyphOffset + glyphBytes < blob.size())
|
||||
parsed.view.unicode = blob.subspan(glyphOffset + glyphBytes);
|
||||
} else {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
parsed.valid = true;
|
||||
|
||||
// Build Unicode mapping if available.
|
||||
if (parsed.view.hasUnicode && !parsed.view.unicode.empty()) {
|
||||
parsed.unicodeEntries.reserve(parsed.view.glyphCount);
|
||||
parsed.unicodeToGlyph.reserve(parsed.view.glyphCount);
|
||||
|
||||
if (parsed.view.format == PsfFormat::Psf1) {
|
||||
const auto* data = parsed.view.unicode.data();
|
||||
std::size_t remaining = parsed.view.unicode.size();
|
||||
for (std::uint32_t glyph = 0; glyph < parsed.view.glyphCount && remaining >= sizeof(std::uint16_t); ++glyph) {
|
||||
bool inSequence = false;
|
||||
while (remaining >= sizeof(std::uint16_t)) {
|
||||
const std::uint16_t low = static_cast<std::uint16_t>(data[0]);
|
||||
const std::uint16_t high = static_cast<std::uint16_t>(data[1]) << 8u;
|
||||
const std::uint16_t value = static_cast<std::uint16_t>(low | high);
|
||||
data += sizeof(std::uint16_t);
|
||||
remaining -= sizeof(std::uint16_t);
|
||||
if (value == kPsf1Separator) {
|
||||
break;
|
||||
}
|
||||
if (value == kPsf1StartSeq) {
|
||||
inSequence = true;
|
||||
continue;
|
||||
}
|
||||
if (!inSequence) {
|
||||
parsed.unicodeEntries.push_back({value, glyph});
|
||||
parsed.unicodeToGlyph.emplace(value, glyph);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (parsed.view.format == PsfFormat::Psf2) {
|
||||
auto begin = parsed.view.unicode.begin();
|
||||
auto end = parsed.view.unicode.end();
|
||||
for (std::uint32_t glyph = 0; glyph < parsed.view.glyphCount && begin != end; ++glyph) {
|
||||
bool inSequence = false;
|
||||
while (begin != end) {
|
||||
const std::uint8_t byte = *begin;
|
||||
if (byte == kPsf2Separator) {
|
||||
++begin;
|
||||
break;
|
||||
}
|
||||
if (byte == kPsf2StartSeq) {
|
||||
inSequence = true;
|
||||
++begin;
|
||||
continue;
|
||||
}
|
||||
auto it = begin;
|
||||
const std::uint32_t codepoint = decodeUtf8(it, end);
|
||||
if (codepoint == 0xFFFFFFFFu) {
|
||||
begin = end;
|
||||
break;
|
||||
}
|
||||
begin = it;
|
||||
if (!inSequence) {
|
||||
parsed.unicodeEntries.push_back({codepoint, glyph});
|
||||
parsed.unicodeToGlyph.emplace(codepoint, glyph);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.unicodeToGlyph.empty()) {
|
||||
for (std::uint32_t glyph = 0; glyph < parsed.view.glyphCount; ++glyph) {
|
||||
parsed.unicodeToGlyph.emplace(glyph, glyph);
|
||||
}
|
||||
}
|
||||
|
||||
// Choose '?' as fallback if present.
|
||||
const auto it = parsed.unicodeToGlyph.find(static_cast<std::uint32_t>('?'));
|
||||
if (it != parsed.unicodeToGlyph.end())
|
||||
parsed.defaultGlyphIndex = it->second;
|
||||
else
|
||||
parsed.defaultGlyphIndex = 0;
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
std::uint32_t glyphIndexForCodepoint(const ParsedPsfFont& font, std::uint32_t codepoint) {
|
||||
const auto it = font.unicodeToGlyph.find(codepoint);
|
||||
if (it != font.unicodeToGlyph.end())
|
||||
return it->second;
|
||||
return font.defaultGlyphIndex;
|
||||
}
|
||||
|
||||
} // namespace cardboy::gfx
|
||||
@@ -12,17 +12,17 @@ StatusBar& StatusBar::instance() {
|
||||
return bar;
|
||||
}
|
||||
|
||||
void StatusBar::setEnabled(bool value) { enabled_ = value; }
|
||||
void StatusBar::setEnabled(bool value) { _enabled = value; }
|
||||
|
||||
void StatusBar::toggle() {
|
||||
enabled_ = !enabled_;
|
||||
if (services_ && services_->buzzer)
|
||||
services_->buzzer->beepMove();
|
||||
_enabled = !_enabled;
|
||||
if (_services && _services->buzzer)
|
||||
_services->buzzer->beepMove();
|
||||
}
|
||||
|
||||
void StatusBar::setCurrentAppName(std::string_view name) {
|
||||
appName_.assign(name.begin(), name.end());
|
||||
std::transform(appName_.begin(), appName_.end(), appName_.begin(),
|
||||
_appName.assign(name.begin(), name.end());
|
||||
std::transform(_appName.begin(), _appName.end(), _appName.begin(),
|
||||
[](unsigned char ch) { return static_cast<char>(std::toupper(ch)); });
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ bool StatusBar::handleToggleInput(const InputState& current, const InputState& p
|
||||
}
|
||||
|
||||
std::string StatusBar::prepareLeftText(int displayWidth) const {
|
||||
std::string text = appName_.empty() ? std::string("CARDBOY") : appName_;
|
||||
std::string text = _appName.empty() ? std::string("CARDBOY") : _appName;
|
||||
int maxWidth = std::max(0, displayWidth - 32);
|
||||
while (!text.empty() && font16x8::measureText(text, 1, 1) > maxWidth)
|
||||
text.pop_back();
|
||||
@@ -45,25 +45,21 @@ std::string StatusBar::prepareLeftText(int displayWidth) const {
|
||||
}
|
||||
|
||||
std::string StatusBar::prepareRightText() const {
|
||||
if (!services_)
|
||||
if (!_services)
|
||||
return {};
|
||||
|
||||
std::string right;
|
||||
if (services_->battery && services_->battery->hasData()) {
|
||||
const float current = services_->battery->current();
|
||||
const float chargeMah = services_->battery->charge();
|
||||
const float fallbackV = services_->battery->voltage();
|
||||
if (_services->battery && _services->battery->hasData()) {
|
||||
const float current = _services->battery->current();
|
||||
const float chargeMah = _services->battery->charge();
|
||||
const float percentage = _services->battery->percentage();
|
||||
char buf[64];
|
||||
if (std::isfinite(current) && std::isfinite(chargeMah)) {
|
||||
std::snprintf(buf, sizeof(buf), "cur %.2fmA chr %.2fmAh", static_cast<double>(current),
|
||||
static_cast<double>(chargeMah));
|
||||
} else {
|
||||
std::snprintf(buf, sizeof(buf), "vol %.2fV", static_cast<double>(fallbackV));
|
||||
}
|
||||
std::snprintf(buf, sizeof(buf), "%.2fmA %.2fmAh %.0f%%", static_cast<double>(current),
|
||||
static_cast<double>(chargeMah), static_cast<double>(percentage));
|
||||
right.assign(buf);
|
||||
}
|
||||
|
||||
if (services_->buzzer && services_->buzzer->isMuted()) {
|
||||
if (_services->buzzer && _services->buzzer->isMuted()) {
|
||||
if (!right.empty())
|
||||
right.append(" ");
|
||||
right.append("MUTE");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "cardboy/apps/clock_app.hpp"
|
||||
#include "cardboy/apps/gameboy_app.hpp"
|
||||
#include "cardboy/apps/menu_app.hpp"
|
||||
#include "cardboy/apps/lockscreen_app.hpp"
|
||||
#include "cardboy/apps/settings_app.hpp"
|
||||
#include "cardboy/apps/snake_app.hpp"
|
||||
#include "cardboy/apps/tetris_app.hpp"
|
||||
@@ -27,6 +28,7 @@ int main() {
|
||||
buzzer->setMuted(persistentSettings.mute);
|
||||
|
||||
system.registerApp(apps::createMenuAppFactory());
|
||||
system.registerApp(apps::createLockscreenAppFactory());
|
||||
system.registerApp(apps::createSettingsAppFactory());
|
||||
system.registerApp(apps::createClockAppFactory());
|
||||
system.registerApp(apps::createGameboyAppFactory());
|
||||
|
||||
@@ -567,9 +567,9 @@ CONFIG_SECURE_TEE_LOG_LEVEL=0
|
||||
# Serial flasher config
|
||||
#
|
||||
# CONFIG_ESPTOOLPY_NO_STUB is not set
|
||||
# CONFIG_ESPTOOLPY_FLASHMODE_QIO is not set
|
||||
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
|
||||
# CONFIG_ESPTOOLPY_FLASHMODE_QOUT is not set
|
||||
CONFIG_ESPTOOLPY_FLASHMODE_DIO=y
|
||||
# CONFIG_ESPTOOLPY_FLASHMODE_DIO is not set
|
||||
# CONFIG_ESPTOOLPY_FLASHMODE_DOUT is not set
|
||||
CONFIG_ESPTOOLPY_FLASH_SAMPLE_MODE_STR=y
|
||||
CONFIG_ESPTOOLPY_FLASHMODE="dio"
|
||||
@@ -695,7 +695,7 @@ CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y
|
||||
CONFIG_BT_NIMBLE_ROLE_OBSERVER=y
|
||||
CONFIG_BT_NIMBLE_GATT_CLIENT=y
|
||||
CONFIG_BT_NIMBLE_GATT_SERVER=y
|
||||
# CONFIG_BT_NIMBLE_NVS_PERSIST is not set
|
||||
CONFIG_BT_NIMBLE_NVS_PERSIST=y
|
||||
# CONFIG_BT_NIMBLE_SMP_ID_RESET is not set
|
||||
CONFIG_BT_NIMBLE_SECURITY_ENABLE=y
|
||||
CONFIG_BT_NIMBLE_SM_LEGACY=y
|
||||
@@ -1217,7 +1217,7 @@ CONFIG_SPI_SLAVE_ISR_IN_IRAM=y
|
||||
# ESP-Driver:USB Serial/JTAG Configuration
|
||||
#
|
||||
CONFIG_USJ_ENABLE_USB_SERIAL_JTAG=y
|
||||
# CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION is not set
|
||||
CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION=y
|
||||
# end of ESP-Driver:USB Serial/JTAG Configuration
|
||||
|
||||
#
|
||||
@@ -2458,9 +2458,9 @@ CONFIG_LOG_BOOTLOADER_LEVEL_INFO=y
|
||||
# CONFIG_LOG_BOOTLOADER_LEVEL_VERBOSE is not set
|
||||
CONFIG_LOG_BOOTLOADER_LEVEL=3
|
||||
# CONFIG_FLASH_ENCRYPTION_ENABLED is not set
|
||||
# CONFIG_FLASHMODE_QIO is not set
|
||||
CONFIG_FLASHMODE_QIO=y
|
||||
# CONFIG_FLASHMODE_QOUT is not set
|
||||
CONFIG_FLASHMODE_DIO=y
|
||||
# CONFIG_FLASHMODE_DIO is not set
|
||||
# CONFIG_FLASHMODE_DOUT is not set
|
||||
CONFIG_MONITOR_BAUD=115200
|
||||
# CONFIG_OPTIMIZATION_LEVEL_DEBUG is not set
|
||||
@@ -2497,7 +2497,7 @@ CONFIG_NIMBLE_ROLE_CENTRAL=y
|
||||
CONFIG_NIMBLE_ROLE_PERIPHERAL=y
|
||||
CONFIG_NIMBLE_ROLE_BROADCASTER=y
|
||||
CONFIG_NIMBLE_ROLE_OBSERVER=y
|
||||
# CONFIG_NIMBLE_NVS_PERSIST is not set
|
||||
CONFIG_NIMBLE_NVS_PERSIST=y
|
||||
CONFIG_NIMBLE_SM_LEGACY=y
|
||||
CONFIG_NIMBLE_SM_SC=y
|
||||
# CONFIG_NIMBLE_SM_SC_DEBUG_KEYS is not set
|
||||
|
||||
@@ -599,13 +599,13 @@ CONFIG_ESPTOOLPY_MONITOR_BAUD=115200
|
||||
#
|
||||
# Partition Table
|
||||
#
|
||||
CONFIG_PARTITION_TABLE_SINGLE_APP=y
|
||||
# CONFIG_PARTITION_TABLE_SINGLE_APP is not set
|
||||
# CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE is not set
|
||||
# CONFIG_PARTITION_TABLE_TWO_OTA is not set
|
||||
# CONFIG_PARTITION_TABLE_TWO_OTA_LARGE is not set
|
||||
# CONFIG_PARTITION_TABLE_CUSTOM is not set
|
||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
|
||||
CONFIG_PARTITION_TABLE_FILENAME="partitions_singleapp.csv"
|
||||
CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"
|
||||
CONFIG_PARTITION_TABLE_OFFSET=0x8000
|
||||
CONFIG_PARTITION_TABLE_MD5=y
|
||||
# end of Partition Table
|
||||
@@ -644,7 +644,7 @@ CONFIG_COMPILER_RT_LIB_GCCLIB=y
|
||||
CONFIG_COMPILER_RT_LIB_NAME="gcc"
|
||||
CONFIG_COMPILER_ORPHAN_SECTIONS_WARNING=y
|
||||
# CONFIG_COMPILER_ORPHAN_SECTIONS_PLACE is not set
|
||||
CONFIG_COMPILER_STATIC_ANALYZER=y
|
||||
# CONFIG_COMPILER_STATIC_ANALYZER is not set
|
||||
# end of Compiler options
|
||||
|
||||
#
|
||||
@@ -695,7 +695,7 @@ CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y
|
||||
CONFIG_BT_NIMBLE_ROLE_OBSERVER=y
|
||||
CONFIG_BT_NIMBLE_GATT_CLIENT=y
|
||||
CONFIG_BT_NIMBLE_GATT_SERVER=y
|
||||
# CONFIG_BT_NIMBLE_NVS_PERSIST is not set
|
||||
CONFIG_BT_NIMBLE_NVS_PERSIST=y
|
||||
# CONFIG_BT_NIMBLE_SMP_ID_RESET is not set
|
||||
CONFIG_BT_NIMBLE_SECURITY_ENABLE=y
|
||||
CONFIG_BT_NIMBLE_SM_LEGACY=y
|
||||
@@ -1699,9 +1699,13 @@ CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=2048
|
||||
CONFIG_FREERTOS_TIMER_QUEUE_LENGTH=10
|
||||
CONFIG_FREERTOS_QUEUE_REGISTRY_SIZE=0
|
||||
CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=1
|
||||
# CONFIG_FREERTOS_USE_TRACE_FACILITY is not set
|
||||
CONFIG_FREERTOS_USE_TRACE_FACILITY=y
|
||||
CONFIG_FREERTOS_USE_STATS_FORMATTING_FUNCTIONS=y
|
||||
# CONFIG_FREERTOS_USE_LIST_DATA_INTEGRITY_CHECK_BYTES is not set
|
||||
# CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS is not set
|
||||
# CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID is not set
|
||||
CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y
|
||||
CONFIG_FREERTOS_RUN_TIME_COUNTER_TYPE_U32=y
|
||||
# CONFIG_FREERTOS_RUN_TIME_COUNTER_TYPE_U64 is not set
|
||||
CONFIG_FREERTOS_USE_TICKLESS_IDLE=y
|
||||
CONFIG_FREERTOS_IDLE_TIME_BEFORE_SLEEP=3
|
||||
# CONFIG_FREERTOS_USE_APPLICATION_TASK_TAG is not set
|
||||
@@ -1722,6 +1726,7 @@ CONFIG_FREERTOS_TICK_SUPPORT_SYSTIMER=y
|
||||
CONFIG_FREERTOS_CORETIMER_SYSTIMER_LVL1=y
|
||||
# CONFIG_FREERTOS_CORETIMER_SYSTIMER_LVL3 is not set
|
||||
CONFIG_FREERTOS_SYSTICK_USES_SYSTIMER=y
|
||||
CONFIG_FREERTOS_RUN_TIME_STATS_USING_ESP_TIMER=y
|
||||
# CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH is not set
|
||||
# CONFIG_FREERTOS_CHECK_PORT_CRITICAL_COMPLIANCE is not set
|
||||
# end of Port
|
||||
@@ -1757,16 +1762,16 @@ CONFIG_HAL_WDT_USE_ROM_IMPL=y
|
||||
#
|
||||
# Heap memory debugging
|
||||
#
|
||||
CONFIG_HEAP_POISONING_DISABLED=y
|
||||
# CONFIG_HEAP_POISONING_DISABLED is not set
|
||||
# CONFIG_HEAP_POISONING_LIGHT is not set
|
||||
# CONFIG_HEAP_POISONING_COMPREHENSIVE is not set
|
||||
CONFIG_HEAP_POISONING_COMPREHENSIVE=y
|
||||
CONFIG_HEAP_TRACING_OFF=y
|
||||
# CONFIG_HEAP_TRACING_STANDALONE is not set
|
||||
# CONFIG_HEAP_TRACING_TOHOST is not set
|
||||
# CONFIG_HEAP_USE_HOOKS is not set
|
||||
# CONFIG_HEAP_TASK_TRACKING is not set
|
||||
# CONFIG_HEAP_ABORT_WHEN_ALLOCATION_FAILS is not set
|
||||
CONFIG_HEAP_TLSF_USE_ROM_IMPL=y
|
||||
# CONFIG_HEAP_TLSF_USE_ROM_IMPL is not set
|
||||
# CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH is not set
|
||||
# end of Heap memory debugging
|
||||
|
||||
@@ -2410,6 +2415,211 @@ CONFIG_WL_SECTOR_SIZE=4096
|
||||
CONFIG_WIFI_PROV_SCAN_MAX_ENTRIES=16
|
||||
CONFIG_WIFI_PROV_AUTOSTOP_TIMEOUT=30
|
||||
CONFIG_WIFI_PROV_BLE_SEC_CONN=y
|
||||
|
||||
#
|
||||
# LittleFS
|
||||
#
|
||||
# CONFIG_LITTLEFS_SDMMC_SUPPORT is not set
|
||||
CONFIG_LITTLEFS_MAX_PARTITIONS=3
|
||||
CONFIG_LITTLEFS_PAGE_SIZE=256
|
||||
CONFIG_LITTLEFS_OBJ_NAME_LEN=64
|
||||
CONFIG_LITTLEFS_READ_SIZE=128
|
||||
CONFIG_LITTLEFS_WRITE_SIZE=128
|
||||
CONFIG_LITTLEFS_LOOKAHEAD_SIZE=128
|
||||
CONFIG_LITTLEFS_CACHE_SIZE=512
|
||||
CONFIG_LITTLEFS_BLOCK_CYCLES=512
|
||||
CONFIG_LITTLEFS_USE_MTIME=y
|
||||
# CONFIG_LITTLEFS_USE_ONLY_HASH is not set
|
||||
# CONFIG_LITTLEFS_HUMAN_READABLE is not set
|
||||
CONFIG_LITTLEFS_MTIME_USE_SECONDS=y
|
||||
# CONFIG_LITTLEFS_MTIME_USE_NONCE is not set
|
||||
# CONFIG_LITTLEFS_SPIFFS_COMPAT is not set
|
||||
# CONFIG_LITTLEFS_FLUSH_FILE_EVERY_WRITE is not set
|
||||
# CONFIG_LITTLEFS_FCNTL_GET_PATH is not set
|
||||
# CONFIG_LITTLEFS_MULTIVERSION is not set
|
||||
# CONFIG_LITTLEFS_MALLOC_STRATEGY_DISABLE is not set
|
||||
CONFIG_LITTLEFS_MALLOC_STRATEGY_DEFAULT=y
|
||||
# CONFIG_LITTLEFS_MALLOC_STRATEGY_INTERNAL is not set
|
||||
CONFIG_LITTLEFS_ASSERTS=y
|
||||
# CONFIG_LITTLEFS_MMAP_PARTITION is not set
|
||||
# end of LittleFS
|
||||
# end of Component config
|
||||
|
||||
# CONFIG_IDF_EXPERIMENTAL_FEATURES is not set
|
||||
|
||||
# Deprecated options for backward compatibility
|
||||
# CONFIG_APP_BUILD_TYPE_ELF_RAM is not set
|
||||
# CONFIG_NO_BLOBS is not set
|
||||
# CONFIG_APP_ROLLBACK_ENABLE is not set
|
||||
# CONFIG_LOG_BOOTLOADER_LEVEL_NONE is not set
|
||||
# CONFIG_LOG_BOOTLOADER_LEVEL_ERROR is not set
|
||||
# CONFIG_LOG_BOOTLOADER_LEVEL_WARN is not set
|
||||
CONFIG_LOG_BOOTLOADER_LEVEL_INFO=y
|
||||
# CONFIG_LOG_BOOTLOADER_LEVEL_DEBUG is not set
|
||||
# CONFIG_LOG_BOOTLOADER_LEVEL_VERBOSE is not set
|
||||
CONFIG_LOG_BOOTLOADER_LEVEL=3
|
||||
# CONFIG_FLASH_ENCRYPTION_ENABLED is not set
|
||||
# CONFIG_FLASHMODE_QIO is not set
|
||||
# CONFIG_FLASHMODE_QOUT is not set
|
||||
CONFIG_FLASHMODE_DIO=y
|
||||
# CONFIG_FLASHMODE_DOUT is not set
|
||||
CONFIG_MONITOR_BAUD=115200
|
||||
CONFIG_OPTIMIZATION_LEVEL_DEBUG=y
|
||||
CONFIG_COMPILER_OPTIMIZATION_LEVEL_DEBUG=y
|
||||
CONFIG_COMPILER_OPTIMIZATION_DEFAULT=y
|
||||
# CONFIG_OPTIMIZATION_LEVEL_RELEASE is not set
|
||||
# CONFIG_COMPILER_OPTIMIZATION_LEVEL_RELEASE is not set
|
||||
CONFIG_OPTIMIZATION_ASSERTIONS_ENABLED=y
|
||||
# CONFIG_OPTIMIZATION_ASSERTIONS_SILENT is not set
|
||||
# CONFIG_OPTIMIZATION_ASSERTIONS_DISABLED is not set
|
||||
CONFIG_OPTIMIZATION_ASSERTION_LEVEL=2
|
||||
# CONFIG_CXX_EXCEPTIONS is not set
|
||||
# CONFIG_STACK_CHECK_NONE is not set
|
||||
# CONFIG_STACK_CHECK_NORM is not set
|
||||
CONFIG_STACK_CHECK_STRONG=y
|
||||
# CONFIG_STACK_CHECK_ALL is not set
|
||||
CONFIG_STACK_CHECK=y
|
||||
CONFIG_WARN_WRITE_STRINGS=y
|
||||
# CONFIG_ESP32_APPTRACE_DEST_TRAX is not set
|
||||
CONFIG_ESP32_APPTRACE_DEST_NONE=y
|
||||
CONFIG_ESP32_APPTRACE_LOCK_ENABLE=y
|
||||
# CONFIG_BLUEDROID_ENABLED is not set
|
||||
CONFIG_NIMBLE_ENABLED=y
|
||||
CONFIG_NIMBLE_MEM_ALLOC_MODE_INTERNAL=y
|
||||
# CONFIG_NIMBLE_MEM_ALLOC_MODE_DEFAULT is not set
|
||||
CONFIG_NIMBLE_MAX_CONNECTIONS=3
|
||||
CONFIG_NIMBLE_MAX_BONDS=3
|
||||
CONFIG_NIMBLE_MAX_CCCDS=8
|
||||
CONFIG_NIMBLE_L2CAP_COC_MAX_NUM=0
|
||||
CONFIG_NIMBLE_PINNED_TO_CORE=0
|
||||
CONFIG_NIMBLE_TASK_STACK_SIZE=4096
|
||||
CONFIG_BT_NIMBLE_TASK_STACK_SIZE=4096
|
||||
CONFIG_NIMBLE_ROLE_CENTRAL=y
|
||||
CONFIG_NIMBLE_ROLE_PERIPHERAL=y
|
||||
CONFIG_NIMBLE_ROLE_BROADCASTER=y
|
||||
CONFIG_NIMBLE_ROLE_OBSERVER=y
|
||||
CONFIG_NIMBLE_NVS_PERSIST=y
|
||||
CONFIG_NIMBLE_SM_LEGACY=y
|
||||
CONFIG_NIMBLE_SM_SC=y
|
||||
# CONFIG_NIMBLE_SM_SC_DEBUG_KEYS is not set
|
||||
CONFIG_BT_NIMBLE_SM_SC_LVL=0
|
||||
# CONFIG_NIMBLE_DEBUG is not set
|
||||
CONFIG_NIMBLE_SVC_GAP_DEVICE_NAME="nimble"
|
||||
CONFIG_NIMBLE_GAP_DEVICE_NAME_MAX_LEN=31
|
||||
CONFIG_NIMBLE_ATT_PREFERRED_MTU=256
|
||||
CONFIG_NIMBLE_SVC_GAP_APPEARANCE=0
|
||||
CONFIG_BT_NIMBLE_MSYS1_BLOCK_COUNT=24
|
||||
CONFIG_BT_NIMBLE_ACL_BUF_COUNT=24
|
||||
CONFIG_BT_NIMBLE_ACL_BUF_SIZE=255
|
||||
CONFIG_BT_NIMBLE_HCI_EVT_BUF_SIZE=70
|
||||
CONFIG_BT_NIMBLE_HCI_EVT_HI_BUF_COUNT=30
|
||||
CONFIG_BT_NIMBLE_HCI_EVT_LO_BUF_COUNT=8
|
||||
CONFIG_NIMBLE_RPA_TIMEOUT=900
|
||||
# CONFIG_NIMBLE_MESH is not set
|
||||
CONFIG_NIMBLE_CRYPTO_STACK_MBEDTLS=y
|
||||
# CONFIG_BT_NIMBLE_COEX_PHY_CODED_TX_RX_TLIM_EN is not set
|
||||
CONFIG_BT_NIMBLE_COEX_PHY_CODED_TX_RX_TLIM_DIS=y
|
||||
CONFIG_SW_COEXIST_ENABLE=y
|
||||
CONFIG_ESP32_WIFI_SW_COEXIST_ENABLE=y
|
||||
CONFIG_ESP_WIFI_SW_COEXIST_ENABLE=y
|
||||
# CONFIG_ANA_CMPR_ISR_IRAM_SAFE is not set
|
||||
# CONFIG_GPTIMER_ISR_IRAM_SAFE is not set
|
||||
# CONFIG_MCPWM_ISR_IRAM_SAFE is not set
|
||||
# CONFIG_EVENT_LOOP_PROFILING is not set
|
||||
CONFIG_POST_EVENTS_FROM_ISR=y
|
||||
CONFIG_POST_EVENTS_FROM_IRAM_ISR=y
|
||||
CONFIG_GDBSTUB_SUPPORT_TASKS=y
|
||||
CONFIG_GDBSTUB_MAX_TASKS=32
|
||||
# CONFIG_OTA_ALLOW_HTTP is not set
|
||||
CONFIG_ESP_SYSTEM_PD_FLASH=y
|
||||
CONFIG_PERIPH_CTRL_FUNC_IN_IRAM=y
|
||||
CONFIG_BROWNOUT_DET=y
|
||||
# CONFIG_BROWNOUT_DET_LVL_SEL_7 is not set
|
||||
# CONFIG_BROWNOUT_DET_LVL_SEL_6 is not set
|
||||
# CONFIG_BROWNOUT_DET_LVL_SEL_5 is not set
|
||||
# CONFIG_BROWNOUT_DET_LVL_SEL_4 is not set
|
||||
CONFIG_BROWNOUT_DET_LVL_SEL_3=y
|
||||
# CONFIG_BROWNOUT_DET_LVL_SEL_2 is not set
|
||||
# CONFIG_BROWNOUT_DET_LVL_SEL_1 is not set
|
||||
# CONFIG_BROWNOUT_DET_LVL_SEL_0 is not set
|
||||
CONFIG_BROWNOUT_DET_LVL=3
|
||||
CONFIG_ESP_SYSTEM_BROWNOUT_INTR=y
|
||||
CONFIG_ESP32_PHY_CALIBRATION_AND_DATA_STORAGE=y
|
||||
CONFIG_ESP32_PHY_MAX_WIFI_TX_POWER=20
|
||||
CONFIG_ESP32_PHY_MAX_TX_POWER=20
|
||||
# CONFIG_REDUCE_PHY_TX_POWER is not set
|
||||
# CONFIG_ESP32_REDUCE_PHY_TX_POWER is not set
|
||||
CONFIG_ESP_SYSTEM_PM_POWER_DOWN_CPU=y
|
||||
CONFIG_ESP32_RTC_XTAL_BOOTSTRAP_CYCLES=0
|
||||
CONFIG_SYSTEM_EVENT_QUEUE_SIZE=32
|
||||
CONFIG_SYSTEM_EVENT_TASK_STACK_SIZE=2304
|
||||
CONFIG_MAIN_TASK_STACK_SIZE=3584
|
||||
CONFIG_CONSOLE_UART_DEFAULT=y
|
||||
# CONFIG_CONSOLE_UART_CUSTOM is not set
|
||||
# CONFIG_CONSOLE_UART_NONE is not set
|
||||
# CONFIG_ESP_CONSOLE_UART_NONE is not set
|
||||
CONFIG_CONSOLE_UART=y
|
||||
CONFIG_CONSOLE_UART_NUM=0
|
||||
CONFIG_CONSOLE_UART_BAUDRATE=115200
|
||||
CONFIG_INT_WDT=y
|
||||
CONFIG_INT_WDT_TIMEOUT_MS=300
|
||||
CONFIG_TASK_WDT=y
|
||||
CONFIG_ESP_TASK_WDT=y
|
||||
# CONFIG_TASK_WDT_PANIC is not set
|
||||
CONFIG_TASK_WDT_TIMEOUT_S=5
|
||||
CONFIG_TASK_WDT_CHECK_IDLE_TASK_CPU0=y
|
||||
# CONFIG_ESP32_DEBUG_STUBS_ENABLE is not set
|
||||
CONFIG_IPC_TASK_STACK_SIZE=1024
|
||||
CONFIG_TIMER_TASK_STACK_SIZE=3584
|
||||
# CONFIG_ESP32_ENABLE_COREDUMP_TO_FLASH is not set
|
||||
# CONFIG_ESP32_ENABLE_COREDUMP_TO_UART is not set
|
||||
CONFIG_ESP32_ENABLE_COREDUMP_TO_NONE=y
|
||||
CONFIG_TIMER_TASK_PRIORITY=1
|
||||
CONFIG_TIMER_TASK_STACK_DEPTH=2048
|
||||
CONFIG_TIMER_QUEUE_LENGTH=10
|
||||
# CONFIG_ENABLE_STATIC_TASK_CLEAN_UP_HOOK is not set
|
||||
# CONFIG_HAL_ASSERTION_SILIENT is not set
|
||||
# CONFIG_L2_TO_L3_COPY is not set
|
||||
CONFIG_ESP_GRATUITOUS_ARP=y
|
||||
CONFIG_GARP_TMR_INTERVAL=60
|
||||
CONFIG_TCPIP_RECVMBOX_SIZE=32
|
||||
CONFIG_TCP_MAXRTX=12
|
||||
CONFIG_TCP_SYNMAXRTX=12
|
||||
CONFIG_TCP_MSS=1440
|
||||
CONFIG_TCP_MSL=60000
|
||||
CONFIG_TCP_SND_BUF_DEFAULT=5760
|
||||
CONFIG_TCP_WND_DEFAULT=5760
|
||||
CONFIG_TCP_RECVMBOX_SIZE=6
|
||||
CONFIG_TCP_QUEUE_OOSEQ=y
|
||||
CONFIG_TCP_OVERSIZE_MSS=y
|
||||
# CONFIG_TCP_OVERSIZE_QUARTER_MSS is not set
|
||||
# CONFIG_TCP_OVERSIZE_DISABLE is not set
|
||||
CONFIG_UDP_RECVMBOX_SIZE=6
|
||||
CONFIG_TCPIP_TASK_STACK_SIZE=3072
|
||||
CONFIG_TCPIP_TASK_AFFINITY_NO_AFFINITY=y
|
||||
# CONFIG_TCPIP_TASK_AFFINITY_CPU0 is not set
|
||||
CONFIG_TCPIP_TASK_AFFINITY=0x7FFFFFFF
|
||||
# CONFIG_PPP_SUPPORT is not set
|
||||
CONFIG_NEWLIB_STDOUT_LINE_ENDING_CRLF=y
|
||||
# CONFIG_NEWLIB_STDOUT_LINE_ENDING_LF is not set
|
||||
# CONFIG_NEWLIB_STDOUT_LINE_ENDING_CR is not set
|
||||
# CONFIG_NEWLIB_STDIN_LINE_ENDING_CRLF is not set
|
||||
# CONFIG_NEWLIB_STDIN_LINE_ENDING_LF is not set
|
||||
CONFIG_NEWLIB_STDIN_LINE_ENDING_CR=y
|
||||
# CONFIG_NEWLIB_NANO_FORMAT is not set
|
||||
CONFIG_NEWLIB_TIME_SYSCALL_USE_RTC_HRT=y
|
||||
# CONFIG_NEWLIB_TIME_SYSCALL_USE_RTC is not set
|
||||
# CONFIG_NEWLIB_TIME_SYSCALL_USE_HRT is not set
|
||||
# CONFIG_NEWLIB_TIME_SYSCALL_USE_NONE is not set
|
||||
CONFIG_ESP32_PTHREAD_TASK_PRIO_DEFAULT=5
|
||||
CONFIG_ESP32_PTHREAD_TASK_STACK_SIZE_DEFAULT=3072
|
||||
CONFIG_ESP32_PTHREAD_STACK_MIN=768
|
||||
CONFIG_ESP32_PTHREAD_TASK_CORE_DEFAULT=-1
|
||||
CONFIG_ESP32_PTHREAD_TASK_NAME_DEFAULT="pthread"
|
||||
CONFIG_SPI_FLASH_WRITING_DANGEROUS_REGIONS_ABORTS=y
|
||||
# CONFIG_SPI_FLASH_WRITING_DANGEROUS_REGIONS_FAILS is not set
|
||||
# CONFIG_SPI_FLASH_WRITING_DANGEROUS_REGIONS_ALLOWED is not set
|
||||
CONFIG_SUPPRESS_SELECT_DEBUG_OUTPUT=y
|
||||
CONFIG_SUPPORT_TERMIOS=y
|
||||
CONFIG_SEMIHOSTFS_MAX_MOUNT_POINTS=1
|
||||
# End of deprecated options
|
||||
|
||||
Reference in New Issue
Block a user