time sync

This commit is contained in:
2025-10-19 19:54:24 +02:00
parent ecbcce12ea
commit 7c741c42dc
15 changed files with 1084 additions and 2 deletions

3
Firmware/.gitignore vendored
View File

@@ -2,4 +2,5 @@ build
cmake-build* cmake-build*
.idea .idea
.cache .cache
managed_components managed_components
*.gb

62
Firmware/cardboy-companion/.gitignore vendored Normal file
View File

@@ -0,0 +1,62 @@
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## Obj-C/Swift specific
*.hmap
## App packaging
*.ipa
*.dSYM.zip
*.dSYM
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
# Package.pins
# Package.resolved
# *.xcodeproj
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm
.build/
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
# *.xcworkspace
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts
Carthage/Build/
# fastlane
#
# It is recommended to not store the screenshots in the git repo.
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/#source-control
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output

View File

@@ -0,0 +1,29 @@
# Cardboy Time Sync Companion
This SwiftUI app connects to the Cardboy device over Bluetooth Low Energy and updates its wall clock using the custom time sync service exposed by the firmware. The sources live inside the existing `cardboy-companion/cardboy-companion.xcodeproj` so you can open and run them directly in Xcode.
## Requirements
- Xcode 15 or newer
- iOS 16 or newer deployment target (can be lowered to 15 with minor API tweaks)
- A Cardboy running firmware that includes the BLE time sync service
## How it works
1. The app scans for peripherals exposing service UUID `00000001-CA7B-4EFD-B5A6-10C3F4D3F230`.
2. Once connected it discovers characteristic `00000002-CA7B-4EFD-B5A6-10C3F4D3F231`.
3. Tapping **Sync Now** writes a 12byte payload containing:
- 8 bytes Unix epoch seconds (little endian)
- 2 bytes time zone offset in minutes from UTC (little endian)
- 1 byte DST flag (`1` if daylight saving is active)
- 1 reserved byte (`0`)
4. The firmware applies the timestamp with `settimeofday()` and updates the TZ environment variable so the clock app renders local time.
## Usage
1. Open `cardboy-companion/cardboy-companion.xcodeproj` in Xcode.
2. Enable the `CoreBluetooth` capability for the `cardboy-companion` target (Signing & Capabilities tab).
3. Build & run on a real device (BLE is not available in the simulator).
4. Allow Bluetooth permissions when prompted, then tap **Sync Now**.
Optionally bundle this code into your existing app—`TimeSyncManager` is selfcontained and can be reused.

View File

@@ -0,0 +1,339 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
ECAB9A832EA550D9004BA9DE /* cardboy-companion.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "cardboy-companion.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
ECAB9A852EA550D9004BA9DE /* cardboy-companion */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "cardboy-companion";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
ECAB9A802EA550D9004BA9DE /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
ECAB9A7A2EA550D9004BA9DE = {
isa = PBXGroup;
children = (
ECAB9A852EA550D9004BA9DE /* cardboy-companion */,
ECAB9A842EA550D9004BA9DE /* Products */,
);
sourceTree = "<group>";
};
ECAB9A842EA550D9004BA9DE /* Products */ = {
isa = PBXGroup;
children = (
ECAB9A832EA550D9004BA9DE /* cardboy-companion.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
ECAB9A822EA550D9004BA9DE /* cardboy-companion */ = {
isa = PBXNativeTarget;
buildConfigurationList = ECAB9A8E2EA550DB004BA9DE /* Build configuration list for PBXNativeTarget "cardboy-companion" */;
buildPhases = (
ECAB9A7F2EA550D9004BA9DE /* Sources */,
ECAB9A802EA550D9004BA9DE /* Frameworks */,
ECAB9A812EA550D9004BA9DE /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
ECAB9A852EA550D9004BA9DE /* cardboy-companion */,
);
name = "cardboy-companion";
packageProductDependencies = (
);
productName = "cardboy-companion";
productReference = ECAB9A832EA550D9004BA9DE /* cardboy-companion.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
ECAB9A7B2EA550D9004BA9DE /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
TargetAttributes = {
ECAB9A822EA550D9004BA9DE = {
CreatedOnToolsVersion = 26.0.1;
};
};
};
buildConfigurationList = ECAB9A7E2EA550D9004BA9DE /* Build configuration list for PBXProject "cardboy-companion" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = ECAB9A7A2EA550D9004BA9DE;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = ECAB9A842EA550D9004BA9DE /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
ECAB9A822EA550D9004BA9DE /* cardboy-companion */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
ECAB9A812EA550D9004BA9DE /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
ECAB9A7F2EA550D9004BA9DE /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
ECAB9A8C2EA550DB004BA9DE /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
ECAB9A8D2EA550DB004BA9DE /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
ECAB9A8F2EA550DB004BA9DE /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = WX524QS7SH;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.usatiuk.cardboy-companion";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
ECAB9A902EA550DB004BA9DE /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = WX524QS7SH;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.usatiuk.cardboy-companion";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
ECAB9A7E2EA550D9004BA9DE /* Build configuration list for PBXProject "cardboy-companion" */ = {
isa = XCConfigurationList;
buildConfigurations = (
ECAB9A8C2EA550DB004BA9DE /* Debug */,
ECAB9A8D2EA550DB004BA9DE /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
ECAB9A8E2EA550DB004BA9DE /* Build configuration list for PBXNativeTarget "cardboy-companion" */ = {
isa = XCConfigurationList;
buildConfigurations = (
ECAB9A8F2EA550DB004BA9DE /* Debug */,
ECAB9A902EA550DB004BA9DE /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = ECAB9A7B2EA550D9004BA9DE /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

@@ -0,0 +1,58 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject private var manager: TimeSyncManager
private var formattedLastSync: String {
guard let date = manager.lastSyncDate else { return "Never" }
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .medium
return formatter.string(from: date)
}
var body: some View {
VStack(spacing: 24) {
Text("Cardboy Clock Sync")
.font(.title.bold())
VStack(spacing: 12) {
Text("State: \(manager.connectionState.rawValue)")
.font(.headline)
Text(manager.statusMessage)
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
HStack(spacing: 16) {
Button(action: manager.sendCurrentTime) {
Label("Sync Now", systemImage: "clock.arrow.2.circlepath")
}
.buttonStyle(.borderedProminent)
.disabled(manager.connectionState != .ready)
Button(action: manager.forceRescan) {
Label("Rescan", systemImage: "dot.radiowaves.left.and.right")
}
.buttonStyle(.bordered)
}
}
VStack(spacing: 8) {
Text("Last Sync:")
.font(.caption)
.foregroundColor(.secondary)
Text(formattedLastSync)
.font(.body.monospaced())
}
Spacer()
}
.padding()
}
}
#Preview {
ContentView()
.environmentObject(TimeSyncManager())
}

View File

@@ -0,0 +1,192 @@
import Combine
import CoreBluetooth
import Foundation
final class TimeSyncManager: NSObject, ObservableObject {
enum ConnectionState: String {
case idle = "Idle"
case scanning = "Scanning"
case connecting = "Connecting"
case discovering = "Discovering Services"
case ready = "Ready"
case failed = "Failed"
}
@Published private(set) var connectionState: ConnectionState = .idle
@Published private(set) var statusMessage: String = "Waiting for Bluetooth…"
@Published private(set) var lastSyncDate: Date?
private let serviceUUID = CBUUID(string: "00000001-CA7B-4EFD-B5A6-10C3F4D3F230")
private let characteristicUUID = CBUUID(string: "00000002-CA7B-4EFD-B5A6-10C3F4D3F231")
private var central: CBCentralManager!
private var targetPeripheral: CBPeripheral?
private var timeCharacteristic: CBCharacteristic?
private var retryTimer: Timer?
override init() {
super.init()
central = CBCentralManager(delegate: self, queue: nil)
}
func forceRescan() {
statusMessage = "Restarting scan…"
connectionState = .scanning
if central.state == .poweredOn {
central.stopScan()
targetPeripheral = nil
timeCharacteristic = nil
central.scanForPeripherals(withServices: [serviceUUID], options: [
CBCentralManagerScanOptionAllowDuplicatesKey: false
])
}
}
func sendCurrentTime() {
guard let peripheral = targetPeripheral,
let characteristic = timeCharacteristic else {
statusMessage = "Device is not ready."
return
}
let now = Date()
let epochSeconds = UInt64(now.timeIntervalSince1970.rounded())
let timezoneOffsetMinutes = Int16(TimeZone.current.secondsFromGMT(for: now) / 60)
let isDst = TimeZone.current.isDaylightSavingTime(for: now) ? UInt8(1) : UInt8(0)
var payload = Data()
var epochLe = epochSeconds.littleEndian
payload.append(UnsafeBufferPointer(start: &epochLe, count: 1))
var offsetLe = timezoneOffsetMinutes.littleEndian
payload.append(UnsafeBufferPointer(start: &offsetLe, count: 1))
payload.append(isDst)
payload.append(UInt8(0)) // Reserved byte
connectionState = .ready
statusMessage = "Sending current time…"
peripheral.writeValue(payload, for: characteristic, type: .withResponse)
}
}
extension TimeSyncManager: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
switch central.state {
case .unknown, .resetting:
connectionState = .idle
statusMessage = "Bluetooth is resetting…"
case .unsupported:
connectionState = .failed
statusMessage = "Bluetooth Low Energy is not supported on this device."
case .unauthorized:
connectionState = .failed
statusMessage = "Bluetooth permissions are missing."
case .poweredOff:
connectionState = .idle
statusMessage = "Turn on Bluetooth to continue."
case .poweredOn:
connectionState = .scanning
statusMessage = "Scanning for Cardboy…"
central.scanForPeripherals(withServices: [serviceUUID], options: [
CBCentralManagerScanOptionAllowDuplicatesKey: false
])
@unknown default:
connectionState = .failed
statusMessage = "Unknown Bluetooth state."
}
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any],
rssi RSSI: NSNumber) {
statusMessage = "Found \(peripheral.name ?? "device"), connecting…"
connectionState = .connecting
targetPeripheral = peripheral
peripheral.delegate = self
central.stopScan()
central.connect(peripheral, options: nil)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
statusMessage = "Connected. Discovering services…"
connectionState = .discovering
peripheral.discoverServices([serviceUUID])
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
statusMessage = "Failed to connect. \(error?.localizedDescription ?? "")"
connectionState = .failed
scheduleRetry()
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
statusMessage = "Disconnected. \(error?.localizedDescription ?? "")"
connectionState = .idle
scheduleRetry()
}
private func scheduleRetry() {
retryTimer?.invalidate()
retryTimer = Timer.scheduledTimer(withTimeInterval: 2.5, repeats: false) { [weak self] _ in
self?.forceRescan()
}
}
}
extension TimeSyncManager: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error {
statusMessage = "Service discovery failed: \(error.localizedDescription)"
connectionState = .failed
scheduleRetry()
return
}
guard let services = peripheral.services else {
statusMessage = "No services found on device."
connectionState = .failed
scheduleRetry()
return
}
for service in services where service.uuid == serviceUUID {
peripheral.discoverCharacteristics([characteristicUUID], for: service)
}
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
if let error {
statusMessage = "Characteristic discovery failed: \(error.localizedDescription)"
connectionState = .failed
scheduleRetry()
return
}
guard let characteristics = service.characteristics,
let targetCharacteristic = characteristics.first(where: { $0.uuid == characteristicUUID }) else {
statusMessage = "Time sync characteristic missing."
connectionState = .failed
scheduleRetry()
return
}
timeCharacteristic = targetCharacteristic
connectionState = .ready
statusMessage = "Ready to sync time."
sendCurrentTime()
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
if let error {
statusMessage = "Write failed: \(error.localizedDescription)"
connectionState = .failed
return
}
lastSyncDate = Date()
statusMessage = "Time synced at \(DateFormatter.localizedString(from: lastSyncDate!, dateStyle: .none, timeStyle: .medium))."
connectionState = .ready
}
}

View File

@@ -0,0 +1,13 @@
import SwiftUI
@main
struct cardboy_companionApp: App {
@StateObject private var manager = TimeSyncManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(manager)
}
}
}

View File

@@ -10,6 +10,7 @@ idf_component_register(
"src/i2c_global.cpp" "src/i2c_global.cpp"
"src/shutdowner.cpp" "src/shutdowner.cpp"
"src/spi_global.cpp" "src/spi_global.cpp"
"src/time_sync_service.cpp"
INCLUDE_DIRS INCLUDE_DIRS
"include" "include"
PRIV_REQUIRES PRIV_REQUIRES
@@ -19,6 +20,7 @@ idf_component_register(
esp_driver_spi esp_driver_spi
littlefs littlefs
nvs_flash nvs_flash
bt
) )
add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../sdk/utils" cardboy_utils_esp) add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../sdk/utils" cardboy_utils_esp)

View File

@@ -0,0 +1,20 @@
#pragma once
namespace cardboy::backend::esp {
/**
* Ensure the BLE time synchronisation service is running.
*
* Safe to call multiple times; subsequent calls become no-ops once the
* service has been started successfully.
*/
void ensure_time_sync_service_started();
/**
* Stop the BLE time synchronisation service if it is running.
* A no-op on platforms that do not support the BLE implementation.
*/
void shutdown_time_sync_service();
} // namespace cardboy::backend::esp

View File

@@ -9,6 +9,7 @@
#include "cardboy/backend/esp/i2c_global.hpp" #include "cardboy/backend/esp/i2c_global.hpp"
#include "cardboy/backend/esp/shutdowner.hpp" #include "cardboy/backend/esp/shutdowner.hpp"
#include "cardboy/backend/esp/spi_global.hpp" #include "cardboy/backend/esp/spi_global.hpp"
#include "cardboy/backend/esp/time_sync_service.hpp"
#include "cardboy/sdk/display_spec.hpp" #include "cardboy/sdk/display_spec.hpp"
@@ -150,7 +151,7 @@ EspRuntime::EspRuntime() : framebuffer(), input(), clock() {
Buttons::get().setEventBus(eventBus.get()); Buttons::get().setEventBus(eventBus.get());
} }
EspRuntime::~EspRuntime() = default; EspRuntime::~EspRuntime() { shutdown_time_sync_service(); }
cardboy::sdk::Services& EspRuntime::serviceRegistry() { return services; } cardboy::sdk::Services& EspRuntime::serviceRegistry() { return services; }
@@ -180,6 +181,8 @@ void EspRuntime::initializeHardware() {
Buzzer::get().init(); Buzzer::get().init();
FsHelper::get().mount(); FsHelper::get().mount();
ensure_time_sync_service_started();
} }
void EspFramebuffer::clear_impl(bool on) { void EspFramebuffer::clear_impl(bool on) {

View File

@@ -0,0 +1,304 @@
#include "cardboy/backend/esp/time_sync_service.hpp"
#include "sdkconfig.h"
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "host/ble_gatt.h"
#include "host/ble_hs.h"
#include "host/util/util.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
#include <cerrno>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <esp_bt.h>
#include <esp_err.h>
namespace cardboy::backend::esp {
namespace {
constexpr char kLogTag[] = "TimeSyncBLE";
constexpr char kDeviceName[] = "Cardboy";
// 128-bit UUIDs (little-endian order for NimBLE macros)
static const ble_uuid128_t kTimeServiceUuid = BLE_UUID128_INIT(0x30, 0xF2, 0xD3, 0xF4, 0xC3, 0x10, 0xA6, 0xB5, 0xFD,
0x4E, 0x7B, 0xCA, 0x01, 0x00, 0x00, 0x00);
static const ble_uuid128_t kTimeWriteCharUuid = BLE_UUID128_INIT(0x31, 0xF2, 0xD3, 0xF4, 0xC3, 0x10, 0xA6, 0xB5, 0xFD,
0x4E, 0x7B, 0xCA, 0x02, 0x00, 0x00, 0x00);
struct [[gnu::packed]] TimeSyncPayload {
std::uint64_t epochSeconds; // Unix time in seconds (UTC)
std::int16_t timezoneOffsetMinutes; // Minutes east of UTC
std::uint8_t daylightSavingActive; // Non-zero if DST active at source
std::uint8_t reserved; // Reserved for alignment / future use
};
static_assert(sizeof(TimeSyncPayload) == 12, "Unexpected payload size");
static bool g_started = false;
static uint8_t g_ownAddrType = BLE_OWN_ADDR_PUBLIC;
static TaskHandle_t g_hostTaskHandle = nullptr;
int gapEventHandler(struct ble_gap_event* event, void* arg);
void startAdvertising();
void setSystemTimeFromPayload(const TimeSyncPayload& payload) {
timeval tv{};
tv.tv_sec = static_cast<time_t>(payload.epochSeconds);
tv.tv_usec = 0;
if (settimeofday(&tv, nullptr) != 0) {
ESP_LOGW(kLogTag, "Failed to set system time (errno=%d)", errno);
} else {
ESP_LOGI(kLogTag, "Wall time updated: epoch=%llu dst=%u offset=%dmin",
static_cast<unsigned long long>(payload.epochSeconds), payload.daylightSavingActive,
static_cast<int>(payload.timezoneOffsetMinutes));
}
// Apply timezone offset by updating TZ environment variable.
// POSIX TZ strings invert the sign relative to the offset from UTC.
const int offsetMin = static_cast<int>(payload.timezoneOffsetMinutes);
const int absOffset = std::abs(offsetMin);
const int hours = absOffset / 60;
const int minutes = absOffset % 60;
char tzString[16];
const char signChar = (offsetMin >= 0) ? '-' : '+';
if (minutes == 0) {
std::snprintf(tzString, sizeof(tzString), "GMT%c%d", signChar, hours);
} else {
std::snprintf(tzString, sizeof(tzString), "GMT%c%d:%02d", signChar, hours, minutes);
}
setenv("TZ", tzString, 1);
tzset();
ESP_LOGI(kLogTag, "Timezone updated to %s", tzString);
}
int timeSyncWriteAccess(uint16_t /*conn_handle*/, uint16_t /*attr_handle*/, ble_gatt_access_ctxt* ctxt, void* /*arg*/) {
if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) {
return BLE_ATT_ERR_READ_NOT_PERMITTED;
}
const std::uint16_t incomingLen = OS_MBUF_PKTLEN(ctxt->om);
if (incomingLen != sizeof(TimeSyncPayload)) {
ESP_LOGW(kLogTag, "Invalid payload length: %u", incomingLen);
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
TimeSyncPayload payload{};
const int rc = os_mbuf_copydata(ctxt->om, 0, sizeof(TimeSyncPayload), &payload);
if (rc != 0) {
ESP_LOGW(kLogTag, "Failed to read payload (rc=%d)", rc);
return BLE_ATT_ERR_UNLIKELY;
}
setSystemTimeFromPayload(payload);
return 0;
}
const ble_gatt_svc_def kGattServices[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &kTimeServiceUuid.u,
.characteristics =
(ble_gatt_chr_def[]) {
{
.uuid = &kTimeWriteCharUuid.u,
.access_cb = timeSyncWriteAccess,
.flags = static_cast<uint8_t>(BLE_GATT_CHR_F_WRITE |
BLE_GATT_CHR_F_WRITE_NO_RSP),
},
{
0,
},
},
},
{
0,
},
};
void startAdvertising() {
ble_hs_adv_fields fields{};
std::memset(&fields, 0, sizeof(fields));
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
const char* name = ble_svc_gap_device_name();
fields.name = reinterpret_cast<const std::uint8_t*>(name);
fields.name_len = static_cast<std::uint8_t>(std::strlen(name));
fields.name_is_complete = 1;
fields.uuids128 = const_cast<ble_uuid128_t*>(&kTimeServiceUuid);
fields.num_uuids128 = 1;
fields.uuids128_is_complete = 1;
int rc = ble_gap_adv_set_fields(&fields);
if (rc != 0) {
ESP_LOGE(kLogTag, "ble_gap_adv_set_fields failed: %d", rc);
return;
}
ble_hs_adv_fields rspFields{};
std::memset(&rspFields, 0, sizeof(rspFields));
rspFields.tx_pwr_lvl_is_present = 1;
rspFields.tx_pwr_lvl = BLE_HS_ADV_TX_PWR_LVL_AUTO;
rc = ble_gap_adv_rsp_set_fields(&rspFields);
if (rc != 0) {
ESP_LOGE(kLogTag, "ble_gap_adv_rsp_set_fields failed: %d", rc);
return;
}
ble_gap_adv_params advParams{};
std::memset(&advParams, 0, sizeof(advParams));
advParams.conn_mode = BLE_GAP_CONN_MODE_UND;
advParams.disc_mode = BLE_GAP_DISC_MODE_GEN;
rc = ble_gap_adv_start(g_ownAddrType, nullptr, BLE_HS_FOREVER, &advParams, gapEventHandler, nullptr);
if (rc != 0) {
ESP_LOGE(kLogTag, "ble_gap_adv_start failed: %d", rc);
} else {
ESP_LOGI(kLogTag, "Advertising started");
}
}
void onReset(int reason) { ESP_LOGW(kLogTag, "Resetting state; reason=%d", reason); }
void onSync() {
int rc = ble_hs_id_infer_auto(0, &g_ownAddrType);
if (rc != 0) {
ESP_LOGE(kLogTag, "ble_hs_id_infer_auto failed: %d", rc);
return;
}
std::uint8_t addrVal[6];
rc = ble_hs_id_copy_addr(g_ownAddrType, addrVal, nullptr);
if (rc == 0) {
ESP_LOGI(kLogTag, "Device address: %02X:%02X:%02X:%02X:%02X:%02X", addrVal[5], addrVal[4], addrVal[3],
addrVal[2], addrVal[1], addrVal[0]);
}
startAdvertising();
}
int gapEventHandler(struct ble_gap_event* event, void* /*arg*/) {
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
if (event->connect.status == 0) {
ESP_LOGI(kLogTag, "Connected; handle=%d", event->connect.conn_handle);
} else {
ESP_LOGW(kLogTag, "Connection attempt failed; status=%d", event->connect.status);
startAdvertising();
}
break;
case BLE_GAP_EVENT_DISCONNECT:
ESP_LOGI(kLogTag, "Disconnected; reason=%d", event->disconnect.reason);
startAdvertising();
break;
case BLE_GAP_EVENT_ADV_COMPLETE:
ESP_LOGI(kLogTag, "Advertising complete; restarting");
startAdvertising();
break;
default:
break;
}
return 0;
}
void hostTask(void* /*param*/) {
g_hostTaskHandle = xTaskGetCurrentTaskHandle();
nimble_port_run(); // This call blocks until NimBLE stops
nimble_port_freertos_deinit();
g_hostTaskHandle = nullptr;
vTaskDelete(nullptr);
}
void configureGap() {
ble_svc_gap_init();
ble_svc_gap_device_name_set(kDeviceName);
ble_svc_gatt_init();
}
bool initController() {
ble_hs_cfg.reset_cb = onReset;
ble_hs_cfg.sync_cb = onSync;
ble_hs_cfg.gatts_register_cb = nullptr;
ble_hs_cfg.store_status_cb = ble_store_util_status_rr;
ble_hs_cfg.sm_io_cap = BLE_HS_IO_NO_INPUT_OUTPUT;
ble_hs_cfg.sm_bonding = 0;
ble_hs_cfg.sm_mitm = 0;
ble_hs_cfg.sm_sc = 0;
ESP_ERROR_CHECK(nimble_port_init());
configureGap();
int gattRc = ble_gatts_count_cfg(kGattServices);
if (gattRc != 0) {
ESP_LOGE(kLogTag, "ble_gatts_count_cfg failed (rc=%d)", gattRc);
return false;
}
gattRc = ble_gatts_add_svcs(kGattServices);
if (gattRc != 0) {
ESP_LOGE(kLogTag, "ble_gatts_add_svcs failed (rc=%d)", gattRc);
return false;
}
return true;
}
} // namespace
void ensure_time_sync_service_started() {
if (g_started) {
return;
}
if (!initController()) {
ESP_LOGE(kLogTag, "Unable to initialise BLE time sync service");
return;
}
nimble_port_freertos_init(hostTask);
g_started = true;
ESP_LOGI(kLogTag, "BLE time sync service initialised");
}
void shutdown_time_sync_service() {
if (!g_started) {
return;
}
int rc = nimble_port_stop();
if (rc == 0) {
// Wait for host task to exit
while (g_hostTaskHandle != nullptr) {
vTaskDelay(pdMS_TO_TICKS(10));
}
}
nimble_port_deinit();
// esp_nimble_hci_and_controller_deinit();
esp_bt_controller_disable();
esp_bt_controller_deinit();
g_started = false;
ESP_LOGI(kLogTag, "BLE time sync service stopped");
}
} // namespace cardboy::backend::esp