diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/Contents.json b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/Contents.json
index b6e521d..449cc11 100644
--- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -6,30 +6,6 @@
"platform" : "ios",
"size" : "1024x1024"
},
- {
- "appearances" : [
- {
- "appearance" : "luminosity",
- "value" : "dark"
- }
- ],
- "filename" : "cardboy-icon-dark.png",
- "idiom" : "universal",
- "platform" : "ios",
- "size" : "1024x1024"
- },
- {
- "appearances" : [
- {
- "appearance" : "luminosity",
- "value" : "tinted"
- }
- ],
- "filename" : "cardboy-icon-tinted.png",
- "idiom" : "universal",
- "platform" : "ios",
- "size" : "1024x1024"
- }
],
"info" : {
"author" : "xcode",
diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.png b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.png
deleted file mode 100644
index 10137e1..0000000
Binary files a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.png and /dev/null differ
diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.svg b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.svg
deleted file mode 100644
index a724a49..0000000
--- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-dark.svg
+++ /dev/null
@@ -1,1356 +0,0 @@
-
-
diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.png b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.png
deleted file mode 100644
index 3aec464..0000000
Binary files a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.png and /dev/null differ
diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.svg b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.svg
deleted file mode 100644
index 3928af1..0000000
--- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon-tinted.svg
+++ /dev/null
@@ -1,1356 +0,0 @@
-
-
diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.png b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.png
index 21122ad..cff0ef5 100644
Binary files a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.png and b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.png differ
diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.svg b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.svg
index e9ab20a..343abbe 100644
--- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.svg
+++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Assets.xcassets/AppIcon.appiconset/cardboy-icon.svg
@@ -13,6 +13,40 @@
xmlns:svg="http://www.w3.org/2000/svg">
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1247,109 +1281,109 @@
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
- inkscape:zoom="0.92030791"
- inkscape:cx="606.86211"
- inkscape:cy="530.80061"
- inkscape:window-width="1728"
- inkscape:window-height="1186"
- inkscape:window-x="832"
- inkscape:window-y="99"
- inkscape:window-maximized="0"
+ inkscape:zoom="0.76316607"
+ inkscape:cx="429.13333"
+ inkscape:cy="386.54758"
+ inkscape:window-width="2560"
+ inkscape:window-height="1381"
+ inkscape:window-x="0"
+ inkscape:window-y="31"
+ inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift
index ec7a727..f0f290c 100644
--- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift
+++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/ContentView.swift
@@ -77,6 +77,11 @@ private struct TimeSyncTabView: View {
}
.buttonStyle(.bordered)
}
+
+ Button(action: manager.sendTestNotification) {
+ Label("Send Test Notification", systemImage: "bell.badge.waveform")
+ }
+ .buttonStyle(.bordered)
}
VStack(spacing: 8) {
diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Info.plist b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Info.plist
index e999718..2c0dafb 100644
--- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Info.plist
+++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/Info.plist
@@ -6,5 +6,7 @@
bluetooth-central
+ NSUserNotificationUsageDescription
+ Allow Cardboy Companion to send local notifications for testing.
diff --git a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift
index 8389e6b..4122e40 100644
--- a/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift
+++ b/Firmware/cardboy-companion/cardboy-companion/cardboy-companion/TimeSyncManager.swift
@@ -2,8 +2,9 @@ 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"
@@ -116,6 +117,7 @@ final class TimeSyncManager: NSObject, ObservableObject {
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
@@ -141,6 +143,9 @@ final class TimeSyncManager: NSObject, ObservableObject {
// 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()
}
@@ -201,6 +206,70 @@ final class TimeSyncManager: NSObject, ObservableObject {
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() {
@@ -763,7 +832,17 @@ 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."
diff --git a/Firmware/components/backend-esp/include/cardboy/backend/esp/time_sync_service.hpp b/Firmware/components/backend-esp/include/cardboy/backend/esp/time_sync_service.hpp
index cbd5abd..a296cf9 100644
--- a/Firmware/components/backend-esp/include/cardboy/backend/esp/time_sync_service.hpp
+++ b/Firmware/components/backend-esp/include/cardboy/backend/esp/time_sync_service.hpp
@@ -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
diff --git a/Firmware/components/backend-esp/include/cardboy/backend/esp_backend.hpp b/Firmware/components/backend-esp/include/cardboy/backend/esp_backend.hpp
index a12707b..230b1ae 100644
--- a/Firmware/components/backend-esp/include/cardboy/backend/esp_backend.hpp
+++ b/Firmware/components/backend-esp/include/cardboy/backend/esp_backend.hpp
@@ -61,6 +61,7 @@ private:
class HighResClockService;
class FilesystemService;
class LoopHooksService;
+ class NotificationService;
std::unique_ptr buzzerService;
std::unique_ptr batteryService;
@@ -70,6 +71,7 @@ private:
std::unique_ptr filesystemService;
std::unique_ptr eventBus;
std::unique_ptr loopHooksService;
+ std::unique_ptr notificationService;
cardboy::sdk::Services services{};
};
diff --git a/Firmware/components/backend-esp/src/esp_backend.cpp b/Firmware/components/backend-esp/src/esp_backend.cpp
index a55b369..ce1c308 100644
--- a/Firmware/components/backend-esp/src/esp_backend.cpp
+++ b/Firmware/components/backend-esp/src/esp_backend.cpp
@@ -24,8 +24,11 @@
#include
#include
+#include
+#include
#include
#include
+#include
namespace cardboy::backend::esp {
@@ -37,6 +40,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();
}
@@ -127,6 +131,80 @@ public:
void onLoopIteration() override { vTaskDelay(1); }
};
+class EspRuntime::NotificationService final : public cardboy::sdk::INotificationCenter {
+public:
+ void pushNotification(Notification notification) override {
+ if (notification.timestamp == 0) {
+ notification.timestamp = static_cast(std::time(nullptr));
+ }
+
+ capLengths(notification);
+
+ std::lock_guard lock(mutex);
+ 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 lock(mutex);
+ return revisionCounter;
+ }
+
+ [[nodiscard]] std::vector recent(std::size_t limit) const override {
+ std::lock_guard lock(mutex);
+ std::vector out;
+ const std::size_t count = std::min(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 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 lock(mutex);
+ if (entries.empty())
+ return;
+ entries.clear();
+ ++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 entries;
+ std::uint64_t nextId = 1;
+ std::uint32_t revisionCounter = 0;
+};
+
EspRuntime::EspRuntime() : framebuffer(), input(), clock() {
initializeHardware();
@@ -138,20 +216,26 @@ EspRuntime::EspRuntime() : framebuffer(), input(), clock() {
filesystemService = std::make_unique();
eventBus = std::make_unique();
loopHooksService = std::make_unique();
+ notificationService = std::make_unique();
- 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.loopHooks = loopHooksService.get();
+ services.notifications = notificationService.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; }
diff --git a/Firmware/components/backend-esp/src/time_sync_service.cpp b/Firmware/components/backend-esp/src/time_sync_service.cpp
index 6a196ca..3e2a173 100644
--- a/Firmware/components/backend-esp/src/time_sync_service.cpp
+++ b/Firmware/components/backend-esp/src/time_sync_service.cpp
@@ -15,6 +15,7 @@
#include "host/ble_gatt.h"
#include "host/ble_hs.h"
#include "host/ble_hs_mbuf.h"
+#include "host/ble_store.h"
#include "host/util/util.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
@@ -29,6 +30,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -37,6 +39,10 @@
#include
#include
+#include "cardboy/sdk/services.hpp"
+
+extern "C" void ble_store_config_init(void);
+
namespace cardboy::backend::esp {
namespace {
@@ -44,9 +50,9 @@ namespace {
constexpr char kLogTag[] = "TimeSyncBLE";
constexpr char kDeviceName[] = "Cardboy";
-constexpr std::uint16_t kPreferredConnIntervalMin = BLE_GAP_CONN_ITVL_MS(80); // 80 ms
-constexpr std::uint16_t kPreferredConnIntervalMax = BLE_GAP_CONN_ITVL_MS(150); // 150 ms
-constexpr std::uint16_t kPreferredConnLatency = 2;
+constexpr std::uint16_t kPreferredConnIntervalMin = BLE_GAP_CONN_ITVL_MS(200); // 80 ms
+constexpr std::uint16_t kPreferredConnIntervalMax = BLE_GAP_CONN_ITVL_MS(300); // 150 ms
+constexpr std::uint16_t kPreferredConnLatency = 3;
constexpr std::uint16_t kPreferredSupervisionTimeout = BLE_GAP_SUPERVISION_TIMEOUT_MS(5000); // 5 s
constexpr float connIntervalUnitsToMs(std::uint16_t units) { return static_cast(units) * 1.25f; }
@@ -75,10 +81,12 @@ struct [[gnu::packed]] TimeSyncPayload {
static_assert(sizeof(TimeSyncPayload) == 12, "Unexpected payload size");
-static bool g_started = false;
-static uint8_t g_ownAddrType = BLE_OWN_ADDR_PUBLIC;
-static TaskHandle_t g_hostTaskHandle = nullptr;
-static uint16_t g_activeConnHandle = BLE_HS_CONN_HANDLE_NONE;
+static bool g_started = false;
+static uint8_t g_ownAddrType = BLE_OWN_ADDR_PUBLIC;
+static TaskHandle_t g_hostTaskHandle = nullptr;
+static uint16_t g_activeConnHandle = BLE_HS_CONN_HANDLE_NONE;
+static cardboy::sdk::INotificationCenter* g_notificationCenter = nullptr;
+static bool g_securityRequested = false;
struct ResponseMessage {
uint8_t opcode;
@@ -112,6 +120,86 @@ struct FileDownloadContext {
static FileUploadContext g_uploadCtx{};
static FileDownloadContext g_downloadCtx{};
+static const ble_uuid128_t kAncsServiceUuid = BLE_UUID128_INIT(0xD0, 0x00, 0x2D, 0x12, 0x1E, 0x4B, 0x0F, 0xA4, 0x99,
+ 0x4E, 0xCE, 0xB5, 0x31, 0xF4, 0x05, 0x79);
+static const ble_uuid128_t kAncsNotificationSourceUuid = BLE_UUID128_INIT(
+ 0xBD, 0x1D, 0xA2, 0x99, 0xE6, 0x25, 0x58, 0x8C, 0xD9, 0x42, 0x01, 0x63, 0x0D, 0x12, 0xBF, 0x9F);
+static const ble_uuid128_t kAncsDataSourceUuid = BLE_UUID128_INIT(0xFB, 0x7B, 0x7C, 0xCE, 0x6A, 0xB3, 0x44, 0xBE, 0xB5,
+ 0x4B, 0xD6, 0x24, 0xE9, 0xC6, 0xEA, 0x22);
+static const ble_uuid128_t kAncsControlPointUuid = BLE_UUID128_INIT(0xD9, 0xD9, 0xAA, 0xFD, 0xBD, 0x9B, 0x21, 0x98,
+ 0xA8, 0x49, 0xE1, 0x45, 0xF3, 0xD8, 0xD1, 0x69);
+
+static uint16_t g_ancsServiceEndHandle = 0;
+static uint16_t g_ancsNotificationSourceHandle = 0;
+static uint16_t g_ancsDataSourceHandle = 0;
+static uint16_t g_ancsControlPointHandle = 0;
+static uint16_t g_mtuSize = 23;
+
+struct PendingNotification {
+ uint32_t uid = 0;
+ uint8_t category = 0;
+ uint8_t flags = 0;
+ std::string appIdentifier;
+ std::string title;
+ std::string message;
+};
+
+static std::vector g_pendingNotifications;
+static std::vector g_dataSourceBuffer;
+static const ble_uuid16_t kClientConfigUuid = BLE_UUID16_INIT(BLE_GATT_DSC_CLT_CFG_UUID16);
+
+void resetAncsState() {
+ g_ancsServiceEndHandle = 0;
+ g_ancsNotificationSourceHandle = 0;
+ g_ancsDataSourceHandle = 0;
+ g_ancsControlPointHandle = 0;
+ g_mtuSize = 23;
+ g_dataSourceBuffer.clear();
+ g_pendingNotifications.clear();
+}
+
+PendingNotification* findPending(uint32_t uid) {
+ for (auto& entry: g_pendingNotifications) {
+ if (entry.uid == uid)
+ return &entry;
+ }
+ return nullptr;
+}
+
+PendingNotification& ensurePending(uint32_t uid) {
+ if (auto* existing = findPending(uid))
+ return *existing;
+ g_pendingNotifications.push_back({});
+ auto& pending = g_pendingNotifications.back();
+ pending.uid = uid;
+ return pending;
+}
+
+void finalizePending(uint32_t uid) {
+ if (!g_notificationCenter)
+ return;
+ for (auto it = g_pendingNotifications.begin(); it != g_pendingNotifications.end(); ++it) {
+ if (it->uid != uid)
+ continue;
+
+ cardboy::sdk::INotificationCenter::Notification note{};
+ note.timestamp = static_cast(time(nullptr));
+ if (!it->title.empty()) {
+ note.title = it->title;
+ } else if (!it->appIdentifier.empty()) {
+ note.title = it->appIdentifier;
+ } else {
+ note.title = "Notification";
+ }
+ note.body = it->message;
+ g_notificationCenter->pushNotification(std::move(note));
+ ESP_LOGI(kLogTag, "Stored notification uid=%" PRIu32 " title='%s' body='%s'", uid, it->title.c_str(),
+ it->message.c_str());
+ g_pendingNotifications.erase(it);
+ break;
+ }
+}
+
enum class FileCommandCode : uint8_t {
ListDirectory = 0x01,
UploadBegin = 0x02,
@@ -156,6 +244,15 @@ bool sendFileResponseNow(const ResponseMessage& msg);
void notificationTask(void* param);
bool scheduleDownloadChunk();
void processDownloadChunk();
+void handleAncsNotificationSource(uint16_t connHandle, const uint8_t* data, uint16_t length);
+bool handleAncsDataSource(const uint8_t* data, uint16_t length);
+void requestAncsAttributes(uint16_t connHandle, uint32_t uid);
+void applyPreferredConnectionParams(uint16_t connHandle);
+int ancsServiceDiscoveredCb(uint16_t connHandle, const ble_gatt_error* error, const ble_gatt_svc* svc, void* arg);
+int ancsCharacteristicDiscoveredCb(uint16_t connHandle, const ble_gatt_error* error, const ble_gatt_chr* chr,
+ void* arg);
+int ancsDescriptorDiscoveredCb(uint16_t connHandle, const ble_gatt_error* error, uint16_t chrValHandle,
+ const ble_gatt_dsc* dsc, void* arg);
static const ble_gatt_chr_def kTimeServiceCharacteristics[] = {
{
@@ -811,6 +908,215 @@ void handleRename(const uint8_t* payload, std::size_t length) {
}
}
+void requestAncsAttributes(uint16_t connHandle, uint32_t uid) {
+ if (!g_notificationCenter || g_ancsControlPointHandle == 0)
+ return;
+
+ static constexpr uint16_t kMaxTitle = 96;
+ static constexpr uint16_t kMaxMessage = 256;
+
+ uint8_t buffer[32];
+ std::size_t index = 0;
+ buffer[index++] = 0x00; // CommandIDGetNotificationAttributes
+ buffer[index++] = static_cast(uid & 0xFF);
+ buffer[index++] = static_cast((uid >> 8) & 0xFF);
+ buffer[index++] = static_cast((uid >> 16) & 0xFF);
+ buffer[index++] = static_cast((uid >> 24) & 0xFF);
+
+ buffer[index++] = 0x00; // App Identifier
+
+ buffer[index++] = 0x01; // Title
+ buffer[index++] = static_cast(kMaxTitle & 0xFF);
+ buffer[index++] = static_cast((kMaxTitle >> 8) & 0xFF);
+
+ buffer[index++] = 0x03; // Message
+ buffer[index++] = static_cast(kMaxMessage & 0xFF);
+ buffer[index++] = static_cast((kMaxMessage >> 8) & 0xFF);
+
+ const int rc = ble_gattc_write_flat(connHandle, g_ancsControlPointHandle, buffer, index, nullptr, nullptr);
+ if (rc != 0) {
+ ESP_LOGW(kLogTag, "ANCS attribute request failed: rc=%d uid=%" PRIu32, rc, uid);
+ } else {
+ ESP_LOGI(kLogTag, "Requested ANCS attributes for uid=%" PRIu32, uid);
+ }
+}
+
+void applyPreferredConnectionParams(uint16_t connHandle) {
+ ble_gap_upd_params params{
+ .itvl_min = kPreferredConnIntervalMin,
+ .itvl_max = kPreferredConnIntervalMax,
+ .latency = kPreferredConnLatency,
+ .supervision_timeout = kPreferredSupervisionTimeout,
+ .min_ce_len = 0,
+ .max_ce_len = 0,
+ };
+
+ const int rc = ble_gap_update_params(connHandle, ¶ms);
+ if (rc != 0) {
+ ESP_LOGW(kLogTag, "ble_gap_update_params failed (rc=%d)", rc);
+ } else {
+ ESP_LOGI(kLogTag, "Requested conn params: %.1f-%.1f ms latency %u timeout %.0f ms",
+ connIntervalUnitsToMs(params.itvl_min), connIntervalUnitsToMs(params.itvl_max), params.latency,
+ supervisionUnitsToMs(params.supervision_timeout));
+ }
+}
+
+void handleAncsNotificationSource(uint16_t connHandle, const uint8_t* data, uint16_t length) {
+ if (!g_notificationCenter || !data || length < 8)
+ return;
+
+ const uint8_t eventId = data[0];
+ const uint8_t eventFlags = data[1];
+ const uint8_t category = data[2];
+ const uint32_t uid = static_cast(data[4]) | (static_cast(data[5]) << 8) |
+ (static_cast(data[6]) << 16) | (static_cast(data[7]) << 24);
+
+ ESP_LOGI(kLogTag, "ANCS notification event=%u flags=0x%02x category=%u uid=%" PRIu32, eventId, eventFlags, category,
+ uid);
+
+ if (eventId == 2) { // Removed
+ finalizePending(uid);
+ return;
+ }
+
+ auto& pending = ensurePending(uid);
+ pending.flags = eventFlags;
+ pending.category = category;
+
+ requestAncsAttributes(connHandle, uid);
+}
+
+bool handleAncsDataSource(const uint8_t* data, uint16_t length) {
+ if (!g_notificationCenter || !data || length == 0)
+ return false;
+
+ g_dataSourceBuffer.insert(g_dataSourceBuffer.end(), data, data + length);
+ if (g_dataSourceBuffer.size() > 2048) {
+ ESP_LOGW(kLogTag, "Dropping oversized ANCS data buffer (%u bytes)",
+ static_cast(g_dataSourceBuffer.size()));
+ g_dataSourceBuffer.clear();
+ return false;
+ }
+ const uint8_t* buffer = g_dataSourceBuffer.data();
+ const uint16_t total = static_cast(g_dataSourceBuffer.size());
+
+ if (total < 5)
+ return false;
+
+ if (buffer[0] != 0x00)
+ return false;
+
+ const uint32_t uid = static_cast(buffer[1]) | (static_cast(buffer[2]) << 8) |
+ (static_cast(buffer[3]) << 16) | (static_cast(buffer[4]) << 24);
+
+ PendingNotification* pending = findPending(uid);
+ if (!pending)
+ pending = &ensurePending(uid);
+
+ std::size_t offset = 5;
+ while (offset + 3 <= total) {
+ const uint8_t attrId = buffer[offset];
+ const uint16_t attrLen =
+ static_cast(buffer[offset + 1]) | (static_cast(buffer[offset + 2]) << 8);
+ offset += 3;
+ if (offset + attrLen > total)
+ return false;
+
+ const char* valuePtr = reinterpret_cast(buffer + offset);
+ const std::string value(valuePtr, valuePtr + attrLen);
+ switch (attrId) {
+ case 0x00:
+ pending->appIdentifier = value;
+ ESP_LOGD(kLogTag, "ANCS uid=%" PRIu32 " appId=%.*s", uid, static_cast(attrLen), valuePtr);
+ break;
+ case 0x01:
+ pending->title = value;
+ ESP_LOGD(kLogTag, "ANCS uid=%" PRIu32 " title=%.*s", uid, static_cast(attrLen), valuePtr);
+ break;
+ case 0x03:
+ pending->message = value;
+ ESP_LOGD(kLogTag, "ANCS uid=%" PRIu32 " message=%.*s", uid, static_cast(attrLen), valuePtr);
+ break;
+ default:
+ break;
+ }
+ offset += attrLen;
+ }
+
+ if (offset != total)
+ return false;
+
+ ESP_LOGI(kLogTag, "ANCS data complete uid=%" PRIu32, uid);
+ finalizePending(uid);
+ g_dataSourceBuffer.clear();
+ return true;
+}
+
+int ancsDescriptorDiscoveredCb(uint16_t connHandle, const ble_gatt_error* error, uint16_t /*chr_val_handle*/,
+ const ble_gatt_dsc* dsc, void* /*arg*/) {
+ if (error->status == 0 && dsc) {
+ if (ble_uuid_cmp(&dsc->uuid.u, &kClientConfigUuid.u) == 0) {
+ const uint8_t enable[2] = {0x01, 0x00};
+ const int rc = ble_gattc_write_flat(connHandle, dsc->handle, enable, sizeof(enable), nullptr, nullptr);
+ if (rc != 0)
+ ESP_LOGW(kLogTag, "Failed to enable ANCS notifications (rc=%d) handle=%u", rc, dsc->handle);
+ else
+ ESP_LOGI(kLogTag, "Subscribed ANCS descriptor handle=%u", dsc->handle);
+ }
+ return 0;
+ }
+ if (error->status == BLE_HS_EDONE)
+ return 0;
+ return error->status;
+}
+
+int ancsCharacteristicDiscoveredCb(uint16_t connHandle, const ble_gatt_error* error, const ble_gatt_chr* chr,
+ void* /*arg*/) {
+ if (error->status == BLE_HS_EDONE)
+ return 0;
+ if (error->status != 0)
+ return error->status;
+
+ if (!chr)
+ return 0;
+
+ if ((chr->properties & BLE_GATT_CHR_PROP_NOTIFY) &&
+ ble_uuid_cmp(&chr->uuid.u, &kAncsNotificationSourceUuid.u) == 0) {
+ g_ancsNotificationSourceHandle = chr->val_handle;
+ ESP_LOGI(kLogTag, "ANCS notification source handle=%u", g_ancsNotificationSourceHandle);
+ ble_gattc_disc_all_dscs(connHandle, chr->val_handle, g_ancsServiceEndHandle, ancsDescriptorDiscoveredCb,
+ nullptr);
+ } else if ((chr->properties & BLE_GATT_CHR_PROP_NOTIFY) &&
+ ble_uuid_cmp(&chr->uuid.u, &kAncsDataSourceUuid.u) == 0) {
+ g_ancsDataSourceHandle = chr->val_handle;
+ ESP_LOGI(kLogTag, "ANCS data source handle=%u", g_ancsDataSourceHandle);
+ ble_gattc_disc_all_dscs(connHandle, chr->val_handle, g_ancsServiceEndHandle, ancsDescriptorDiscoveredCb,
+ nullptr);
+ } else if ((chr->properties & BLE_GATT_CHR_PROP_WRITE) &&
+ ble_uuid_cmp(&chr->uuid.u, &kAncsControlPointUuid.u) == 0) {
+ g_ancsControlPointHandle = chr->val_handle;
+ ESP_LOGI(kLogTag, "ANCS control point handle=%u", g_ancsControlPointHandle);
+ }
+
+ return 0;
+}
+
+int ancsServiceDiscoveredCb(uint16_t connHandle, const ble_gatt_error* error, const ble_gatt_svc* svc, void* /*arg*/) {
+ if (error->status == BLE_HS_EDONE)
+ return 0;
+ if (error->status != 0)
+ return error->status;
+ if (!svc) {
+ ESP_LOGW(kLogTag, "ANCS service missing");
+ return 0;
+ }
+
+ g_ancsServiceEndHandle = svc->end_handle;
+ ESP_LOGI(kLogTag, "ANCS service discovered: start=%u end=%u", svc->start_handle, svc->end_handle);
+ return ble_gattc_disc_all_chrs(connHandle, svc->start_handle, svc->end_handle, ancsCharacteristicDiscoveredCb,
+ nullptr);
+}
+
void handleGattsRegister(ble_gatt_register_ctxt* ctxt, void* /*arg*/) {
if (ctxt->op == BLE_GATT_REGISTER_OP_CHR) {
if (ble_uuid_cmp(ctxt->chr.chr_def->uuid, &kFileCommandCharUuid.u) == 0) {
@@ -969,27 +1275,6 @@ void logConnectionParams(uint16_t connHandle, const char* context) {
static_cast(desc.conn_latency), timeoutMs);
}
-void applyPreferredConnectionParams(uint16_t connHandle) {
- ble_gap_upd_params params{
- .itvl_min = kPreferredConnIntervalMin,
- .itvl_max = kPreferredConnIntervalMax,
- .latency = kPreferredConnLatency,
- .supervision_timeout = kPreferredSupervisionTimeout,
- .min_ce_len = 0,
- .max_ce_len = 0,
- };
-
- const int rc = ble_gap_update_params(connHandle, ¶ms);
- if (rc != 0) {
- ESP_LOGW(kLogTag, "Requesting preferred conn params failed (rc=%d)", rc);
- return;
- }
-
- ESP_LOGI(kLogTag, "Requested conn params: interval=%.0f-%.0f ms latency=%u supervision=%.0f ms",
- connIntervalUnitsToMs(kPreferredConnIntervalMin), connIntervalUnitsToMs(kPreferredConnIntervalMax),
- kPreferredConnLatency, supervisionUnitsToMs(kPreferredSupervisionTimeout));
-}
-
void startAdvertising() {
ble_hs_adv_fields fields{};
std::memset(&fields, 0, sizeof(fields));
@@ -1075,7 +1360,22 @@ int gapEventHandler(struct ble_gap_event* event, void* /*arg*/) {
ESP_LOGI(kLogTag, "Connected; handle=%d", event->connect.conn_handle);
g_activeConnHandle = event->connect.conn_handle;
logConnectionParams(event->connect.conn_handle, "Initial");
- applyPreferredConnectionParams(event->connect.conn_handle);
+
+ ble_gap_conn_desc desc{};
+ if (ble_gap_conn_find(event->connect.conn_handle, &desc) == 0) {
+ ESP_LOGI(kLogTag, "Security state on connect: bonded=%d encrypted=%d authenticated=%d key_size=%u",
+ desc.sec_state.bonded, desc.sec_state.encrypted, desc.sec_state.authenticated,
+ static_cast(desc.sec_state.key_size));
+ if (!desc.sec_state.encrypted && !desc.sec_state.bonded && !g_securityRequested) {
+ const int src = ble_gap_security_initiate(event->connect.conn_handle);
+ if (src == 0) {
+ g_securityRequested = true;
+ ESP_LOGI(kLogTag, "Security procedure initiated");
+ } else {
+ ESP_LOGW(kLogTag, "Failed to initiate security (rc=%d)", src);
+ }
+ }
+ }
} else {
ESP_LOGW(kLogTag, "Connection attempt failed; status=%d", event->connect.status);
startAdvertising();
@@ -1084,7 +1384,9 @@ int gapEventHandler(struct ble_gap_event* event, void* /*arg*/) {
case BLE_GAP_EVENT_DISCONNECT:
ESP_LOGI(kLogTag, "Disconnected; reason=%d", event->disconnect.reason);
- g_activeConnHandle = BLE_HS_CONN_HANDLE_NONE;
+ g_activeConnHandle = BLE_HS_CONN_HANDLE_NONE;
+ g_securityRequested = false;
+ resetAncsState();
resetUploadContext();
resetDownloadContext();
if (g_responseQueue)
@@ -1092,6 +1394,20 @@ int gapEventHandler(struct ble_gap_event* event, void* /*arg*/) {
startAdvertising();
break;
+ case BLE_GAP_EVENT_ENC_CHANGE:
+ if (event->enc_change.status == 0) {
+ ESP_LOGI(kLogTag, "Link encrypted; discovering ANCS");
+ resetAncsState();
+ g_securityRequested = false;
+ ble_gattc_disc_svc_by_uuid(event->enc_change.conn_handle, &kAncsServiceUuid.u, ancsServiceDiscoveredCb,
+ nullptr);
+ applyPreferredConnectionParams(event->enc_change.conn_handle);
+ } else {
+ ESP_LOGW(kLogTag, "Encryption change failed; status=%d", event->enc_change.status);
+ g_securityRequested = false;
+ }
+ break;
+
case BLE_GAP_EVENT_ADV_COMPLETE:
ESP_LOGI(kLogTag, "Advertising complete; restarting");
startAdvertising();
@@ -1108,24 +1424,39 @@ int gapEventHandler(struct ble_gap_event* event, void* /*arg*/) {
case BLE_GAP_EVENT_CONN_UPDATE_REQ:
if (event->conn_update_req.self_params) {
- auto& params = *event->conn_update_req.self_params;
- if (params.itvl_max > kPreferredConnIntervalMax)
- params.itvl_max = kPreferredConnIntervalMax;
- if (params.itvl_min > params.itvl_max)
- params.itvl_min = params.itvl_max;
- if (params.latency > kPreferredConnLatency)
- params.latency = kPreferredConnLatency;
- if (params.supervision_timeout > kPreferredSupervisionTimeout)
- params.supervision_timeout = kPreferredSupervisionTimeout;
- params.min_ce_len = 0;
- params.max_ce_len = 0;
-
+ const auto& params = *event->conn_update_req.self_params;
ESP_LOGI(kLogTag, "Peer update request -> interval %.1f-%.1f ms latency %u timeout %.0f ms",
connIntervalUnitsToMs(params.itvl_min), connIntervalUnitsToMs(params.itvl_max), params.latency,
supervisionUnitsToMs(params.supervision_timeout));
}
break;
+ case BLE_GAP_EVENT_NOTIFY_RX:
+ if (event->notify_rx.attr_handle == g_ancsNotificationSourceHandle) {
+ handleAncsNotificationSource(event->notify_rx.conn_handle, event->notify_rx.om->om_data,
+ event->notify_rx.om->om_len);
+ } else if (event->notify_rx.attr_handle == g_ancsDataSourceHandle) {
+ const uint16_t len = event->notify_rx.om->om_len;
+ ESP_LOGD(kLogTag, "ANCS data chunk len=%u", static_cast(len));
+ handleAncsDataSource(event->notify_rx.om->om_data, len);
+ }
+ break;
+
+ case BLE_GAP_EVENT_MTU:
+ g_mtuSize = event->mtu.value;
+ ESP_LOGI(kLogTag, "MTU updated to %u", g_mtuSize);
+ break;
+
+ case BLE_GAP_EVENT_REPEAT_PAIRING: {
+ ble_gap_conn_desc desc{};
+ if (ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc) == 0) {
+ ESP_LOGI(kLogTag, "Repeat pairing requested by %02X:%02X:%02X:%02X:%02X:%02X; keeping existing bond",
+ desc.peer_id_addr.val[0], desc.peer_id_addr.val[1], desc.peer_id_addr.val[2],
+ desc.peer_id_addr.val[3], desc.peer_id_addr.val[4], desc.peer_id_addr.val[5]);
+ }
+ return BLE_GAP_REPEAT_PAIRING_IGNORE;
+ }
+
default:
break;
}
@@ -1153,12 +1484,15 @@ bool initController() {
ble_hs_cfg.gatts_register_cb = handleGattsRegister;
ble_hs_cfg.store_status_cb = ble_store_util_status_rr;
ble_hs_cfg.sm_io_cap = BLE_HS_IO_NO_INPUT_OUTPUT;
- ble_hs_cfg.sm_bonding = 0;
+ ble_hs_cfg.sm_bonding = 1;
ble_hs_cfg.sm_mitm = 0;
ble_hs_cfg.sm_sc = 0;
+ ble_hs_cfg.sm_our_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
+ ble_hs_cfg.sm_their_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID;
ESP_ERROR_CHECK(nimble_port_init());
configureGap();
+ ble_store_config_init();
int gattRc = ble_gatts_count_cfg(kGattServices);
if (gattRc != 0) {
@@ -1182,6 +1516,8 @@ void ensure_time_sync_service_started() {
return;
}
+ resetAncsState();
+
if (!initController()) {
ESP_LOGE(kLogTag, "Unable to initialise BLE time sync service");
return;
@@ -1254,6 +1590,10 @@ void shutdown_time_sync_service() {
vQueueDelete(g_responseQueue);
g_responseQueue = nullptr;
}
+
+ resetAncsState();
}
+void set_notification_center(cardboy::sdk::INotificationCenter* center) { g_notificationCenter = center; }
+
} // namespace cardboy::backend::esp
diff --git a/Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp b/Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp
index eed5a74..fd1ed5a 100644
--- a/Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp
+++ b/Firmware/sdk/apps/lockscreen/src/lockscreen_app.cpp
@@ -10,6 +10,7 @@
#include
#include
#include
+#include
namespace apps {
@@ -37,7 +38,8 @@ struct TimeSnapshot {
class LockscreenApp final : public cardboy::sdk::IApp {
public:
- explicit LockscreenApp(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer), clock(ctx.clock) {}
+ explicit LockscreenApp(AppContext& ctx) :
+ context(ctx), framebuffer(ctx.framebuffer), clock(ctx.clock), notificationCenter(ctx.notificationCenter()) {}
void onStart() override {
cancelRefreshTimer();
@@ -45,6 +47,8 @@ public:
holdActive = false;
holdProgressMs = 0;
dirty = true;
+ lastNotificationInteractionMs = clock.millis();
+ refreshNotifications();
const auto snap = captureTime();
renderIfNeeded(snap);
lastSnapshot = snap;
@@ -68,15 +72,22 @@ public:
}
private:
- AppContext& context;
- Framebuffer& framebuffer;
- Clock& clock;
+ 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 notifications;
+ std::size_t selectedNotification = 0;
bool dirty = false;
cardboy::sdk::AppTimerHandle refreshTimer = cardboy::sdk::kInvalidAppTimer;
TimeSnapshot lastSnapshot{};
bool holdActive = false;
std::uint32_t holdProgressMs = 0;
+ std::uint32_t lastNotificationInteractionMs = 0;
void cancelRefreshTimer() {
if (refreshTimer != cardboy::sdk::kInvalidAppTimer) {
@@ -88,11 +99,68 @@ private:
static bool comboPressed(const cardboy::sdk::InputState& state) { return state.a && state.select; }
void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) {
+ const bool upPressed = button.current.up && !button.previous.up;
+ const bool downPressed = button.current.down && !button.previous.down;
+ bool navPressed = false;
+
+ if (!notifications.empty() && (upPressed || downPressed)) {
+ const std::size_t count = notifications.size();
+ lastNotificationInteractionMs = clock.millis();
+ navPressed = true;
+ if (count > 1) {
+ if (upPressed)
+ selectedNotification = (selectedNotification + count - 1) % count;
+ else if (downPressed)
+ selectedNotification = (selectedNotification + 1) % count;
+ }
+ }
+
const bool comboNow = comboPressed(button.current);
updateHoldState(comboNow);
+ if (navPressed)
+ dirty = true;
updateDisplay();
}
+ void refreshNotifications() {
+ if (!notificationCenter) {
+ if (!notifications.empty() || lastNotificationRevision != 0) {
+ notifications.clear();
+ selectedNotification = 0;
+ lastNotificationRevision = 0;
+ dirty = true;
+ }
+ return;
+ }
+ const std::uint32_t revision = notificationCenter->revision();
+ if (revision == lastNotificationRevision)
+ return;
+ lastNotificationRevision = revision;
+
+ const std::uint64_t previousId =
+ (selectedNotification < notifications.size()) ? notifications[selectedNotification].id : 0;
+
+ auto latest = notificationCenter->recent(kMaxDisplayedNotifications);
+ notifications = std::move(latest);
+
+ if (notifications.empty()) {
+ selectedNotification = 0;
+ } else if (previousId != 0) {
+ auto it = std::find_if(notifications.begin(), notifications.end(),
+ [previousId](const auto& note) { return note.id == previousId; });
+ if (it != notifications.end()) {
+ selectedNotification = static_cast(std::distance(notifications.begin(), it));
+ } else {
+ selectedNotification = 0;
+ }
+ } else {
+ selectedNotification = 0;
+ }
+
+ lastNotificationInteractionMs = clock.millis();
+ dirty = true;
+ }
+
void updateHoldState(bool comboNow) {
if (comboNow) {
if (!holdActive) {
@@ -127,6 +195,7 @@ private:
}
void updateDisplay() {
+ refreshNotifications();
const auto snap = captureTime();
if (!sameSnapshot(snap, lastSnapshot))
dirty = true;
@@ -195,6 +264,98 @@ private:
}
}
+ 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 wrapText(std::string_view text, int maxWidth, int scale, int letterSpacing,
+ int maxLines) {
+ std::vector 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(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(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(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(maxLines)) {
+ truncated = true;
+ break;
+ }
+ }
+ }
+
+ flushCurrent();
+ if (lines.size() > static_cast(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)
@@ -215,14 +376,105 @@ private:
const int scaleTime = 4;
const int scaleSeconds = 2;
const int scaleSmall = 1;
+ const int textLineHeight = font16x8::kGlyphHeight * scaleSmall;
+
+ const int cardMarginTop = 4;
+ const int cardMarginSide = 8;
+ const int cardPadding = 6;
+ const int cardLineSpacing = 4;
+ int cardHeight = 0;
+ const int cardWidth = framebuffer.width() - cardMarginSide * 2;
+
+ const std::uint32_t nowMs = clock.millis();
+ const bool hasNotifications = !notifications.empty();
+ const bool showNotificationDetails =
+ hasNotifications && (nowMs - lastNotificationInteractionMs <= kNotificationHideMs);
+
+ if (hasNotifications) {
+ const auto& note = notifications[selectedNotification];
+ if (showNotificationDetails) {
+ std::string title = note.title.empty() ? std::string("Notification") : note.title;
+ title = truncateWithEllipsis(title, cardWidth - cardPadding * 2, scaleSmall, 1);
+
+ auto bodyLines = wrapText(note.body, cardWidth - cardPadding * 2, scaleSmall, 1, 4);
+
+ cardHeight = cardPadding * 2 + textLineHeight;
+ if (!bodyLines.empty()) {
+ cardHeight += cardLineSpacing;
+ cardHeight += static_cast(bodyLines.size()) * textLineHeight;
+ if (bodyLines.size() > 1)
+ cardHeight += (static_cast(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 arrowX = counterX + (counterWidth - 8) / 2;
+ int arrowY = cardMarginTop + cardPadding + textLineHeight + 1;
+ font16x8::drawText(framebuffer, arrowX, arrowY, "^", scaleSmall, true, 0);
+ arrowY += textLineHeight + 2;
+ font16x8::drawText(framebuffer, arrowX, arrowY, "v", scaleSmall, true, 0);
+ }
+
+ if (!bodyLines.empty()) {
+ int bodyY = cardMarginTop + cardPadding + textLineHeight + cardLineSpacing;
+ for (const auto& line : bodyLines) {
+ font16x8::drawText(framebuffer, cardMarginSide + cardPadding, bodyY, line, scaleSmall, true, 1);
+ bodyY += textLineHeight + cardLineSpacing;
+ }
+ }
+ } else {
+ cardHeight = textLineHeight + cardPadding * 2;
+ char summary[32];
+ if (notifications.size() == 1)
+ std::snprintf(summary, sizeof(summary), "1 NOTIFICATION");
+ else
+ std::snprintf(summary, sizeof(summary), "%zu NOTIFICATIONS", notifications.size());
+ const int summaryWidth = font16x8::measureText(summary, scaleSmall, 1);
+ const int summaryX = (framebuffer.width() - summaryWidth) / 2;
+ const int summaryY = cardMarginTop;
+ font16x8::drawText(framebuffer, summaryX, summaryY, summary, scaleSmall, true, 1);
+
+ if (notifications.size() > 1) {
+ int arrowX = (framebuffer.width() - 8) / 2;
+ int arrowY = summaryY + textLineHeight + 1;
+ font16x8::drawText(framebuffer, arrowX, arrowY, "^", scaleSmall, true, 0);
+ arrowY += textLineHeight + 2;
+ font16x8::drawText(framebuffer, arrowX, arrowY, "v", scaleSmall, true, 0);
+ cardHeight = std::max(cardHeight, arrowY + textLineHeight - cardMarginTop);
+ }
+ }
+ }
+
+ const int defaultTimeY = (framebuffer.height() - font16x8::kGlyphHeight * scaleTime) / 2 - 8;
+ int timeY = defaultTimeY;
+ if (cardHeight > 0)
+ timeY = cardMarginTop + cardHeight + 16;
+ const int minTimeY = (cardHeight > 0) ? (cardMarginTop + cardHeight + 12) : 16;
+ const int maxTimeY =
+ std::max(minTimeY, framebuffer.height() - font16x8::kGlyphHeight * scaleTime - 48);
+ timeY = std::clamp(timeY, minTimeY, maxTimeY);
char hoursMinutes[6];
std::snprintf(hoursMinutes, sizeof(hoursMinutes), "%02d:%02d", snap.hour24, snap.minute);
- const int mainW = font16x8::measureText(hoursMinutes, scaleTime, 0);
- const int timeY = (framebuffer.height() - font16x8::kGlyphHeight * scaleTime) / 2 - 8;
- const int timeX = (framebuffer.width() - mainW) / 2;
- const int secX = timeX + mainW + 12;
- const int secY = timeY + font16x8::kGlyphHeight * scaleTime - font16x8::kGlyphHeight * scaleSeconds;
+ 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);
@@ -230,19 +482,28 @@ private:
font16x8::drawText(framebuffer, secX, secY, secs, scaleSeconds, true, 0);
const std::string dateLine = formatDate(snap);
- drawCenteredText(framebuffer, timeY + font16x8::kGlyphHeight * scaleTime + 24, dateLine, scaleSmall, 1);
- const char* instruction = holdActive ? "KEEP HOLDING A+SELECT" : "HOLD A+SELECT";
- drawCenteredText(framebuffer, framebuffer.height() - 52, instruction, scaleSmall, 1);
+ drawCenteredText(framebuffer, timeY + font16x8::kGlyphHeight * scaleTime + 16, dateLine, scaleSmall, 1);
+
+ const std::string instruction = holdActive ? "KEEP HOLDING A+SELECT" : "HOLD A+SELECT";
+ const int instructionWidth = font16x8::measureText(instruction, scaleSmall, 1);
+ const int barHeight = 14;
+ const int barY = framebuffer.height() - 24;
+ const int textY = barY + (barHeight - textLineHeight) / 2;
+ const int textX = 8;
+ font16x8::drawText(framebuffer, textX, textY, instruction, scaleSmall, true, 1);
+
+ int barX = textX + instructionWidth + 12;
+ int barWidth = framebuffer.width() - barX - 8;
+ if (barWidth < 40) {
+ barWidth = 40;
+ barX = std::min(barX, framebuffer.width() - barWidth - 8);
+ }
+
+ drawRectOutline(framebuffer, barX, barY, barWidth, barHeight);
if (holdActive || holdProgressMs > 0) {
- const int barWidth = framebuffer.width() - 64;
- const int barHeight = 14;
- const int barX = (framebuffer.width() - barWidth) / 2;
- const int barY = framebuffer.height() - 32;
- const int innerWidth = barWidth - 2;
+ const int innerWidth = barWidth - 2;
const int innerHeight = barHeight - 2;
- drawRectOutline(framebuffer, barX, barY, barWidth, barHeight);
-
const float ratio =
std::clamp(holdProgressMs / static_cast(kUnlockHoldMs), 0.0f, 1.0f);
const int fillWidth = static_cast(ratio * innerWidth + 0.5f);
diff --git a/Firmware/sdk/backend_interface/include/cardboy/sdk/services.hpp b/Firmware/sdk/backend_interface/include/cardboy/sdk/services.hpp
index aeb45a9..94170e6 100644
--- a/Firmware/sdk/backend_interface/include/cardboy/sdk/services.hpp
+++ b/Firmware/sdk/backend_interface/include/cardboy/sdk/services.hpp
@@ -6,6 +6,7 @@
#include
#include
#include
+#include
namespace cardboy::sdk {
@@ -69,6 +70,25 @@ public:
[[nodiscard]] virtual std::string basePath() const = 0;
};
+class INotificationCenter {
+public:
+ struct Notification {
+ std::uint64_t id = 0;
+ std::uint64_t timestamp = 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 recent(std::size_t limit) const = 0;
+ virtual void markAllRead() = 0;
+ virtual void clear() = 0;
+};
+
struct Services {
IBuzzer* buzzer = nullptr;
IBatteryMonitor* battery = nullptr;
@@ -78,6 +98,7 @@ struct Services {
IFilesystem* filesystem = nullptr;
IEventBus* eventBus = nullptr;
ILoopHooks* loopHooks = nullptr;
+ INotificationCenter* notifications = nullptr;
};
} // namespace cardboy::sdk
diff --git a/Firmware/sdk/backends/desktop/include/cardboy/backend/desktop_backend.hpp b/Firmware/sdk/backends/desktop/include/cardboy/backend/desktop_backend.hpp
index 1c2075d..0a0336b 100644
--- a/Firmware/sdk/backends/desktop/include/cardboy/backend/desktop_backend.hpp
+++ b/Firmware/sdk/backends/desktop/include/cardboy/backend/desktop_backend.hpp
@@ -86,6 +86,23 @@ private:
bool mounted = false;
};
+class DesktopNotificationCenter final : public cardboy::sdk::INotificationCenter {
+public:
+ void pushNotification(Notification notification) override;
+ [[nodiscard]] std::uint32_t revision() const override;
+ [[nodiscard]] std::vector recent(std::size_t limit) const override;
+ void markAllRead() override;
+ void clear() override;
+
+private:
+ static constexpr std::size_t kMaxEntries = 8;
+
+ mutable std::mutex mutex;
+ std::vector entries;
+ std::uint64_t nextId = 1;
+ std::uint32_t revisionCounter = 0;
+};
+
class DesktopEventBus final : public cardboy::sdk::IEventBus {
public:
explicit DesktopEventBus(DesktopRuntime& owner);
@@ -187,6 +204,7 @@ private:
DesktopHighResClock highResService;
DesktopFilesystem filesystemService;
DesktopEventBus eventBusService;
+ DesktopNotificationCenter notificationService;
cardboy::sdk::Services services{};
};
diff --git a/Firmware/sdk/backends/desktop/src/desktop_backend.cpp b/Firmware/sdk/backends/desktop/src/desktop_backend.cpp
index 761f9d3..644f1be 100644
--- a/Firmware/sdk/backends/desktop/src/desktop_backend.cpp
+++ b/Firmware/sdk/backends/desktop/src/desktop_backend.cpp
@@ -8,8 +8,10 @@
#include
#include
#include
+#include
#include
#include
+#include
namespace cardboy::backend::desktop {
@@ -149,6 +151,58 @@ bool DesktopFilesystem::mount() {
return mounted;
}
+void DesktopNotificationCenter::pushNotification(Notification notification) {
+ if (notification.timestamp == 0) {
+ notification.timestamp = static_cast(std::time(nullptr));
+ }
+
+ std::lock_guard lock(mutex);
+ notification.id = nextId++;
+ notification.unread = true;
+
+ if (entries.size() >= kMaxEntries)
+ entries.erase(entries.begin());
+ entries.push_back(std::move(notification));
+ ++revisionCounter;
+}
+
+std::uint32_t DesktopNotificationCenter::revision() const {
+ std::lock_guard lock(mutex);
+ return revisionCounter;
+}
+
+std::vector DesktopNotificationCenter::recent(std::size_t limit) const {
+ std::lock_guard lock(mutex);
+ const std::size_t count = std::min(limit, entries.size());
+ std::vector 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 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 lock(mutex);
+ if (entries.empty())
+ return;
+ entries.clear();
+ ++revisionCounter;
+}
+
DesktopFramebuffer::DesktopFramebuffer(DesktopRuntime& runtime) : runtime(runtime) {}
int DesktopFramebuffer::width_impl() const { return cardboy::sdk::kDisplayWidth; }
@@ -244,6 +298,7 @@ DesktopRuntime::DesktopRuntime() :
services.filesystem = &filesystemService;
services.eventBus = &eventBusService;
services.loopHooks = nullptr;
+ services.notifications = ¬ificationService;
}
cardboy::sdk::Services& DesktopRuntime::serviceRegistry() { return services; }
diff --git a/Firmware/sdk/core/include/cardboy/sdk/app_framework.hpp b/Firmware/sdk/core/include/cardboy/sdk/app_framework.hpp
index e244f89..ec0c2cf 100644
--- a/Firmware/sdk/core/include/cardboy/sdk/app_framework.hpp
+++ b/Firmware/sdk/core/include/cardboy/sdk/app_framework.hpp
@@ -64,6 +64,9 @@ struct AppContext {
[[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]] INotificationCenter* notificationCenter() const {
+ return services ? services->notifications : nullptr;
+ }
void requestAppSwitchByIndex(std::size_t index) {
pendingAppIndex = index;