Skip to content

Commit e3ff059

Browse files
Add adaptive BLE mode detection for Triki motion input.
TrikiBLEMonitor classifies fast/normal/lowPower from packet timing with debounced transitions; TrikiMotionEngine and TrikiGameController adapt processing, HUD rate, idle UX, and optional wake-up INIT. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5be23f5 commit e3ff059

7 files changed

Lines changed: 469 additions & 82 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ VeltoKit/
3232
MotionConfig.swift # Presets per MotionMode
3333
BLE/BLEManager.swift # Stub → TrikiBLEManager
3434
Triki/TrikiBLEManager.swift # Scan, connect, reconnect, notify
35+
Triki/TrikiBLEMonitor.swift # fast/normal/lowPower from packet Δt
3536
Triki/TrikiParser.swift # Format-detecting int16 decode
3637
Triki/TrikiMotionEngine.swift # Velocity / shake / tilt / swing
3738
Triki/TrikiGameController.swift # Gamepad API (TrikiGameInput)

VeltoKit/MotionSDK.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ public final class MotionSDK: ObservableObject {
2626
@Published public internal(set) var isConnected = false
2727
/// Informuje, czy napływają pakiety BLE.
2828
@Published public internal(set) var isReceiving = false
29+
/// Wykryty tryb częstotliwości notify (fast / normal / low power).
30+
public var trikiBLEMode: TrikiBLEMode {
31+
trikiController?.getBLEMode() ?? .unknown
32+
}
2933
/// Ostatnia ramka wejścia publikowana do HUD.
3034
@Published public internal(set) var liveInput = GameInput()
3135

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import Foundation
2+
import os
3+
4+
/// Tryb wydajności notify firmware (wykrywany z odstępów między pakietami).
5+
public enum TrikiBLEMode: String, Sendable, Equatable, CaseIterable {
6+
case fast
7+
case normal
8+
case lowPower
9+
case unknown
10+
}
11+
12+
/// Wynik aktualizacji monitora — opcjonalna zmiana trybu po debounce.
13+
public struct TrikiBLEModeTransition: Sendable, Equatable {
14+
public let previous: TrikiBLEMode
15+
public let current: TrikiBLEMode
16+
public let packetDelta: TimeInterval
17+
}
18+
19+
/// Śledzi timing pakietów BLE i stabilnie wykrywa tryb fast / normal / low power.
20+
public final class TrikiBLEMonitor: @unchecked Sendable {
21+
private let log = Logger(subsystem: "com.koderteam.gametriki", category: "TrikiBLEMonitor")
22+
private let lock = NSLock()
23+
24+
/// Ostatni znany tryb (po debounce).
25+
public private(set) var currentMode: TrikiBLEMode = .unknown
26+
/// Ostatni zmierzony odstęp między pakietami (s).
27+
public private(set) var lastPacketDelta: TimeInterval = 0
28+
/// Czas ostatniego pakietu (s od 1970).
29+
public private(set) var lastTimestamp: TimeInterval = 0
30+
31+
/// Włącza log delta + przejść trybu.
32+
public var debugLoggingEnabled = false
33+
34+
/// Ile kolejnych pakietów z tym samym trybem przed commit (anty-flicker).
35+
public var requiredStablePackets = 3
36+
/// Ignoruj delty powyżej tej wartości przy klasyfikacji (przerwa / reconnect).
37+
public var maxClassifyDelta: TimeInterval = 3.0
38+
/// Po tylu sekundach bez pakietów → low power (UX idle).
39+
public var stalePacketThreshold: TimeInterval = 1.5
40+
41+
private var candidateMode: TrikiBLEMode = .unknown
42+
private var candidateStreak = 0
43+
44+
public init() {}
45+
46+
/// Rejestruje przyjęcie pakietu; zwraca przejście trybu po debounce (jeśli było).
47+
@discardableResult
48+
public func recordPacket(at timestamp: TimeInterval) -> TrikiBLEModeTransition? {
49+
lock.lock()
50+
defer { lock.unlock() }
51+
52+
var transition: TrikiBLEModeTransition?
53+
54+
if lastTimestamp > 0 {
55+
let delta = timestamp - lastTimestamp
56+
lastPacketDelta = delta
57+
58+
if debugLoggingEnabled {
59+
print(String(format: "[TrikiBLEMonitor] Δ=%.0fms", delta * 1000))
60+
log.debug("packet delta=\(delta, privacy: .public)s")
61+
}
62+
63+
if delta >= 0.001, delta <= maxClassifyDelta {
64+
let sample = Self.classify(delta: delta)
65+
transition = applyCandidate(sample, delta: delta)
66+
} else if delta > maxClassifyDelta, debugLoggingEnabled {
67+
print("[TrikiBLEMonitor] skip classify (Δ=\(String(format: "%.2f", delta))s)")
68+
}
69+
}
70+
71+
lastTimestamp = timestamp
72+
return transition
73+
}
74+
75+
/// Wywołuj z pętli gry gdy długo nie było pakietów (idle / low power UX).
76+
public func evaluateStale(now: TimeInterval) -> TrikiBLEModeTransition? {
77+
lock.lock()
78+
defer { lock.unlock() }
79+
80+
guard lastTimestamp > 0 else { return nil }
81+
let gap = now - lastTimestamp
82+
guard gap >= stalePacketThreshold else { return nil }
83+
return applyCandidate(.lowPower, delta: gap)
84+
}
85+
86+
public func reset() {
87+
lock.lock()
88+
defer { lock.unlock() }
89+
lastTimestamp = 0
90+
lastPacketDelta = 0
91+
currentMode = .unknown
92+
candidateMode = .unknown
93+
candidateStreak = 0
94+
}
95+
96+
// MARK: - Classification
97+
98+
public static func classify(delta: TimeInterval) -> TrikiBLEMode {
99+
if delta < 0.03 {
100+
return .fast
101+
}
102+
if delta < 0.2 {
103+
return .normal
104+
}
105+
return .lowPower
106+
}
107+
108+
private func applyCandidate(_ sample: TrikiBLEMode, delta: TimeInterval) -> TrikiBLEModeTransition? {
109+
if sample == candidateMode {
110+
candidateStreak += 1
111+
} else {
112+
candidateMode = sample
113+
candidateStreak = 1
114+
}
115+
116+
guard candidateStreak >= requiredStablePackets, sample != currentMode else {
117+
return nil
118+
}
119+
120+
let previous = currentMode
121+
currentMode = sample
122+
candidateStreak = 0
123+
124+
if debugLoggingEnabled {
125+
print("[TrikiBLEMonitor] mode \(previous.rawValue)\(sample.rawValue) (Δ=\(String(format: "%.0f", delta * 1000))ms)")
126+
}
127+
log.info("BLE mode \(previous.rawValue, privacy: .public)\(sample.rawValue, privacy: .public)")
128+
129+
return TrikiBLEModeTransition(previous: previous, current: sample, packetDelta: delta)
130+
}
131+
}

VeltoKit/Triki/TrikiGameController.swift

Lines changed: 87 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import Combine
22
import Foundation
33

4-
/// Publiczna fasada gamepada Triki — BLE → parsermotion`TrikiGameInput`.
4+
/// Publiczna fasada gamepada Triki — BLE → monitorparseradaptacyjny motion.
55
@MainActor
66
public final class TrikiGameController: ObservableObject {
7-
/// Tryb czułości (szybki vs stabilny).
87
public enum InputMode: Sendable, Equatable {
98
case game
109
case smooth
@@ -16,25 +15,39 @@ public final class TrikiGameController: ObservableObject {
1615
didSet { motion.inputMode = mapInputMode(inputMode) }
1716
}
1817

19-
/// Log hex + wartości parsera (DEV).
2018
public var debugParserLogging: Bool {
2119
get { parser.debugLoggingEnabled }
2220
set { parser.debugLoggingEnabled = newValue }
2321
}
2422

23+
public var debugBLEMonitorLogging: Bool {
24+
get { bleMonitor.debugLoggingEnabled }
25+
set { bleMonitor.debugLoggingEnabled = newValue }
26+
}
27+
28+
/// Odstęp publikacji HUD w trybie low power (mniej odświeżeń UI).
29+
public var lowPowerHudPublishInterval: TimeInterval = 0.35
30+
2531
@Published public private(set) var gameInput = TrikiGameInput()
32+
@Published public private(set) var bleMode: TrikiBLEMode = .unknown
2633
@Published public private(set) var isConnected = false
2734
@Published public private(set) var isReceiving = false
35+
/// Tekst podpowiedzi UX przy low power.
36+
@Published public private(set) var idleStatusMessage: String? = nil
2837

2938
private var parser = TrikiParser()
3039
private var motion = TrikiMotionEngine()
40+
private let bleMonitor = TrikiBLEMonitor()
3141
private var cancellables = Set<AnyCancellable>()
3242
private var lastPacketAt: TimeInterval = 0
3343
private var lastPollTime: TimeInterval?
44+
private var lastHudPublishAt: TimeInterval = 0
45+
private var lastWakeUpAttemptAt: TimeInterval = 0
3446

3547
private var moveHandlers: [(Float) -> Void] = []
3648
private var shakeHandlers: [() -> Void] = []
3749
private var actionHandlers: [() -> Void] = []
50+
private var modeChangedHandlers: [(TrikiBLEMode) -> Void] = []
3851

3952
public init() {
4053
motion.inputMode = .game
@@ -43,7 +56,6 @@ public final class TrikiGameController: ObservableObject {
4356

4457
// MARK: - Connection
4558

46-
/// Skan + auto-connect do jedynego urządzenia z „triki” w nazwie.
4759
public func connect() {
4860
ble.autoConnectWhenSingleLikelyMatch = true
4961
if ble.cachedPeripheralUUID != nil, ble.status == .idle {
@@ -58,21 +70,28 @@ public final class TrikiGameController: ObservableObject {
5870
resetSession()
5971
}
6072

61-
// MARK: - Manual bytes (własny central)
62-
6373
public func ingest(_ bytes: [UInt8]) {
6474
guard !bytes.isEmpty else { return }
75+
let now = Date().timeIntervalSince1970
76+
if let transition = bleMonitor.recordPacket(at: now) {
77+
applyBLEModeTransition(transition)
78+
}
6579
parser.append(bytes)
66-
lastPacketAt = Date().timeIntervalSince1970
80+
lastPacketAt = now
6781
if !isReceiving { isReceiving = true }
82+
updateIdleMessage()
6883
}
6984

70-
/// Wywołuj w pętli gry (~60 Hz). Zwraca bieżącą ramkę gamepada.
7185
@discardableResult
7286
public func tick(deltaTime: TimeInterval? = nil) -> TrikiGameInput {
7387
let now = Date().timeIntervalSince1970
7488
if now - lastPacketAt > 0.35, isReceiving { isReceiving = false }
7589

90+
if let transition = bleMonitor.evaluateStale(now: now) {
91+
applyBLEModeTransition(transition)
92+
}
93+
attemptWakeUpIfStuck(now: now)
94+
7695
let dt: TimeInterval
7796
if let deltaTime {
7897
dt = deltaTime
@@ -84,29 +103,38 @@ public final class TrikiGameController: ObservableObject {
84103
let frames = parser.drainParsedFrames()
85104
if !frames.isEmpty {
86105
motion.ingest(parsed: frames, deltaTime: dt)
87-
gameInput = motion.output
106+
publishGameInputIfNeeded(now: now)
88107
dispatchCallbacks()
108+
} else if bleMode == .lowPower, now - lastHudPublishAt >= lowPowerHudPublishInterval {
109+
gameInput = motion.output
110+
lastHudPublishAt = now
89111
}
112+
90113
return gameInput
91114
}
92115

93116
public func resetSession() {
94117
parser.reset()
95118
motion.reset()
119+
bleMonitor.reset()
96120
gameInput = TrikiGameInput()
121+
bleMode = .unknown
122+
idleStatusMessage = nil
97123
isReceiving = false
98124
lastPollTime = nil
125+
lastHudPublishAt = 0
126+
lastWakeUpAttemptAt = 0
99127
}
100128

101-
// MARK: - Gamepad API (bez surowych osi)
129+
// MARK: - Public API
130+
131+
public func getBLEMode() -> TrikiBLEMode { bleMode }
102132

103133
public func getDirection() -> Float { motion.getDirection() }
104134
public func getVelocity() -> Float { motion.getVelocity() }
105135
public func isShake() -> Bool { motion.isShake() }
106136
public func isMoving() -> Bool { motion.isMoving() }
107137

108-
// MARK: - Callbacks (DX)
109-
110138
public func onMove(_ handler: @escaping (Float) -> Void) {
111139
moveHandlers.append(handler)
112140
}
@@ -119,10 +147,15 @@ public final class TrikiGameController: ObservableObject {
119147
actionHandlers.append(handler)
120148
}
121149

150+
public func onModeChanged(_ handler: @escaping (TrikiBLEMode) -> Void) {
151+
modeChangedHandlers.append(handler)
152+
}
153+
122154
public func clearHandlers() {
123155
moveHandlers.removeAll()
124156
shakeHandlers.removeAll()
125157
actionHandlers.removeAll()
158+
modeChangedHandlers.removeAll()
126159
}
127160

128161
// MARK: - Private
@@ -146,10 +179,50 @@ public final class TrikiGameController: ObservableObject {
146179
.store(in: &cancellables)
147180
}
148181

182+
private func applyBLEModeTransition(_ transition: TrikiBLEModeTransition) {
183+
bleMode = transition.current
184+
motion.setBLEMode(transition.current)
185+
for handler in modeChangedHandlers {
186+
handler(transition.current)
187+
}
188+
updateIdleMessage()
189+
}
190+
191+
private func updateIdleMessage() {
192+
switch bleMode {
193+
case .lowPower:
194+
idleStatusMessage = "Bezruch — czekam na ruch"
195+
case .fast, .normal:
196+
idleStatusMessage = nil
197+
case .unknown:
198+
idleStatusMessage = nil
199+
}
200+
}
201+
202+
private func publishGameInputIfNeeded(now: TimeInterval) {
203+
let interval: TimeInterval = bleMode == .lowPower ? lowPowerHudPublishInterval : 0
204+
if interval > 0, now - lastHudPublishAt < interval, lastHudPublishAt > 0 {
205+
return
206+
}
207+
gameInput = motion.output
208+
lastHudPublishAt = now
209+
}
210+
211+
/// Po długim low power bez pakietów — delikatne „obudzenie” streamu (INIT).
212+
private func attemptWakeUpIfStuck(now: TimeInterval) {
213+
guard bleMode == .lowPower, isConnected else { return }
214+
guard now - lastPacketAt > 4.0 else { return }
215+
guard now - lastWakeUpAttemptAt > 5.0 else { return }
216+
lastWakeUpAttemptAt = now
217+
ble.sendInitAndStartIfReady()
218+
if debugBLEMonitorLogging {
219+
print("[TrikiGameController] wake-up INIT (low power stall)")
220+
}
221+
}
222+
149223
private func dispatchCallbacks() {
150-
let direction = gameInput.direction
151224
if gameInput.isMoving {
152-
for handler in moveHandlers { handler(direction) }
225+
for handler in moveHandlers { handler(gameInput.direction) }
153226
}
154227
if gameInput.isShake {
155228
for handler in shakeHandlers { handler() }

0 commit comments

Comments
 (0)