Integrate live phone and watch remote prototype with guarded motion control
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
import RemoteCore
|
||||
|
||||
public enum WristSide: String, Codable, Sendable { case left, right }
|
||||
public enum CrownOrientation: String, Codable, Sendable { case towardHand, awayFromHand }
|
||||
|
||||
/// A normalized, user-relative sample. Values are dimensionless and bounded by
|
||||
/// the motion adapter; this type intentionally contains no sensor or TV data.
|
||||
public struct NormalizedMotionSample: Sendable, Equatable {
|
||||
public let timestamp: TimeInterval
|
||||
public let x: Double
|
||||
public let y: Double
|
||||
public let quality: Double
|
||||
|
||||
public init(timestamp: TimeInterval, x: Double, y: Double, quality: Double = 1) {
|
||||
self.timestamp = timestamp; self.x = x; self.y = y; self.quality = quality
|
||||
}
|
||||
}
|
||||
|
||||
public struct GestureRecognition: Sendable, Equatable {
|
||||
public let sessionID: UUID
|
||||
public let command: RemoteCommand
|
||||
public let confidence: Double
|
||||
public let timestamp: TimeInterval
|
||||
}
|
||||
|
||||
public struct GestureRecognitionConfiguration: Sendable, Equatable {
|
||||
public var activationMagnitude: Double = 0.55
|
||||
public var neutralMagnitude: Double = 0.18
|
||||
public var minimumNeutralDwell: TimeInterval = 0.20
|
||||
public var candidateWindow: ClosedRange<TimeInterval> = 0.12...0.65
|
||||
public var cooldown: TimeInterval = 0.40
|
||||
public var stableNeutralDwell: TimeInterval = 0.20
|
||||
public var maximumSampleGap: TimeInterval = 0.12
|
||||
public var dominantAxisRatio: Double = 1.5
|
||||
public var minimumQuality: Double = 0.65
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
/// A deterministic classifier over calibrated, user-relative samples. It is
|
||||
/// deliberately conservative: mixed axes, poor quality, gaps and slow drift
|
||||
/// produce no intent. One accepted excursion consumes readiness until a stable
|
||||
/// neutral pose is observed after cooldown.
|
||||
public struct CalibratedGestureRecognizer: Sendable {
|
||||
public let configuration: GestureRecognitionConfiguration
|
||||
public private(set) var sessionID: UUID?
|
||||
|
||||
private var armedAt: TimeInterval?
|
||||
private var lastTimestamp: TimeInterval?
|
||||
private var neutralSince: TimeInterval?
|
||||
private var candidateStart: TimeInterval?
|
||||
private var candidatePeakX = 0.0
|
||||
private var candidatePeakY = 0.0
|
||||
private var candidateQuality = 1.0
|
||||
private var cooldownUntil: TimeInterval?
|
||||
private var waitingForNeutral = true
|
||||
|
||||
public init(configuration: GestureRecognitionConfiguration = .init()) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
public mutating func arm(sessionID: UUID, at timestamp: TimeInterval) {
|
||||
disarm()
|
||||
guard timestamp.isFinite else { return }
|
||||
self.sessionID = sessionID; armedAt = timestamp; lastTimestamp = nil
|
||||
waitingForNeutral = true
|
||||
}
|
||||
|
||||
public mutating func disarm() {
|
||||
sessionID = nil; armedAt = nil; lastTimestamp = nil
|
||||
neutralSince = nil; candidateStart = nil; candidatePeakX = 0; candidatePeakY = 0
|
||||
candidateQuality = 1; cooldownUntil = nil; waitingForNeutral = true
|
||||
}
|
||||
|
||||
public mutating func process(_ sample: NormalizedMotionSample) -> GestureRecognition? {
|
||||
guard let sessionID, let armedAt, sample.timestamp.isFinite,
|
||||
sample.timestamp >= armedAt,
|
||||
sample.x.isFinite, sample.y.isFinite,
|
||||
sample.quality.isFinite, sample.quality >= 0,
|
||||
sample.quality <= 1 else {
|
||||
resetCandidate(); waitingForNeutral = true; neutralSince = nil
|
||||
return nil
|
||||
}
|
||||
if let lastTimestamp, sample.timestamp <= lastTimestamp { return nil }
|
||||
if let lastTimestamp, sample.timestamp - lastTimestamp > configuration.maximumSampleGap {
|
||||
resetCandidate(); waitingForNeutral = true; neutralSince = nil
|
||||
}
|
||||
lastTimestamp = sample.timestamp
|
||||
let magnitude = hypot(sample.x, sample.y)
|
||||
guard magnitude <= 1.0 else { resetCandidate(); waitingForNeutral = true; return nil }
|
||||
|
||||
if magnitude <= configuration.neutralMagnitude {
|
||||
if neutralSince == nil { neutralSince = sample.timestamp }
|
||||
if let cooldownUntil, sample.timestamp < cooldownUntil { return nil }
|
||||
if sample.timestamp - (neutralSince ?? sample.timestamp) >= configuration.stableNeutralDwell {
|
||||
waitingForNeutral = false; resetCandidate()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
neutralSince = nil
|
||||
guard sample.quality >= configuration.minimumQuality else { resetCandidate(); waitingForNeutral = true; return nil }
|
||||
guard !waitingForNeutral else { return nil }
|
||||
|
||||
if candidateStart == nil {
|
||||
guard magnitude >= configuration.neutralMagnitude else { return nil }
|
||||
candidateStart = sample.timestamp; candidatePeakX = sample.x; candidatePeakY = sample.y
|
||||
candidateQuality = sample.quality
|
||||
return nil
|
||||
}
|
||||
guard let start = candidateStart else { return nil }
|
||||
let duration = sample.timestamp - start
|
||||
guard duration <= configuration.candidateWindow.upperBound else { resetCandidate(); waitingForNeutral = true; return nil }
|
||||
candidatePeakX = abs(sample.x) > abs(candidatePeakX) ? sample.x : candidatePeakX
|
||||
candidatePeakY = abs(sample.y) > abs(candidatePeakY) ? sample.y : candidatePeakY
|
||||
candidateQuality = min(candidateQuality, sample.quality)
|
||||
let peakX = abs(candidatePeakX), peakY = abs(candidatePeakY)
|
||||
let peak = max(peakX, peakY)
|
||||
guard duration >= configuration.candidateWindow.lowerBound,
|
||||
peak >= configuration.activationMagnitude else { return nil }
|
||||
let dominant = max(peakX, peakY) / max(0.0001, min(peakX, peakY))
|
||||
guard dominant >= configuration.dominantAxisRatio else { resetCandidate(); waitingForNeutral = true; return nil }
|
||||
let command: RemoteCommand
|
||||
if peakX > peakY { command = candidatePeakX > 0 ? .right : .left }
|
||||
else { command = candidatePeakY > 0 ? .up : .down }
|
||||
let confidence = min(1, 0.5 * (peak / max(0.0001, configuration.activationMagnitude))
|
||||
+ 0.3 * min(1, dominant / 3) + 0.2 * candidateQuality)
|
||||
resetCandidate(); waitingForNeutral = true; cooldownUntil = sample.timestamp + configuration.cooldown
|
||||
return GestureRecognition(sessionID: sessionID, command: command, confidence: confidence, timestamp: sample.timestamp)
|
||||
}
|
||||
|
||||
private mutating func resetCandidate() {
|
||||
candidateStart = nil; candidatePeakX = 0; candidatePeakY = 0; candidateQuality = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
|
||||
/// Events accepted from the candidate Samsung remote-control channel.
|
||||
///
|
||||
/// This parser intentionally exposes no arbitrary method or key supplied by the TV.
|
||||
/// Unknown events are inert and can be counted without logging their payload.
|
||||
public enum SamsungRemoteEvent: Sendable, Equatable {
|
||||
case connected(token: String?)
|
||||
case unauthorized
|
||||
case timedOut
|
||||
case error
|
||||
case unknown
|
||||
}
|
||||
|
||||
public enum SamsungEventParserError: Error, Sendable, Equatable {
|
||||
case empty
|
||||
case oversized
|
||||
case malformed
|
||||
case invalidEventName
|
||||
case invalidToken
|
||||
}
|
||||
|
||||
/// A size-bounded, allowlist-based parser for WebSocket event messages.
|
||||
public struct SamsungEventParser: Sendable {
|
||||
public static let defaultMaximumMessageBytes = 16 * 1024
|
||||
public static let maximumEventNameBytes = 128
|
||||
public static let maximumTokenBytes = 4 * 1024
|
||||
|
||||
public let maximumMessageBytes: Int
|
||||
|
||||
public init(maximumMessageBytes: Int = Self.defaultMaximumMessageBytes) {
|
||||
self.maximumMessageBytes = max(1, maximumMessageBytes)
|
||||
}
|
||||
|
||||
public func parse(_ data: Data) throws -> SamsungRemoteEvent {
|
||||
guard !data.isEmpty else { throw SamsungEventParserError.empty }
|
||||
guard data.count <= maximumMessageBytes else {
|
||||
throw SamsungEventParserError.oversized
|
||||
}
|
||||
|
||||
let message: Message
|
||||
do {
|
||||
message = try JSONDecoder().decode(Message.self, from: data)
|
||||
} catch {
|
||||
throw SamsungEventParserError.malformed
|
||||
}
|
||||
|
||||
guard let eventData = message.event.data(using: .utf8),
|
||||
!eventData.isEmpty,
|
||||
eventData.count <= Self.maximumEventNameBytes else {
|
||||
throw SamsungEventParserError.invalidEventName
|
||||
}
|
||||
|
||||
switch message.event {
|
||||
case "ms.channel.connect":
|
||||
guard let token = message.data?.token else { return .connected(token: nil) }
|
||||
guard let tokenData = token.data(using: .utf8),
|
||||
!tokenData.isEmpty,
|
||||
tokenData.count <= Self.maximumTokenBytes else {
|
||||
throw SamsungEventParserError.invalidToken
|
||||
}
|
||||
return .connected(token: token)
|
||||
case "ms.channel.unauthorized":
|
||||
return .unauthorized
|
||||
case "ms.channel.timeOut":
|
||||
return .timedOut
|
||||
case "ms.error":
|
||||
return .error
|
||||
default:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
private struct Message: Decodable {
|
||||
let event: String
|
||||
let data: EventData?
|
||||
}
|
||||
|
||||
private struct EventData: Decodable {
|
||||
let token: String?
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import RemoteCore
|
||||
@testable import GestureCore
|
||||
|
||||
private func sample(_ t: Double, _ x: Double = 0, _ y: Double = 0, _ q: Double = 1) -> NormalizedMotionSample {
|
||||
NormalizedMotionSample(timestamp: t, x: x, y: y, quality: q)
|
||||
}
|
||||
|
||||
@Test func requiresStableNeutralAndEmitsOneDirectionalEvent() {
|
||||
var configuration = GestureRecognitionConfiguration(); configuration.maximumSampleGap = 0.3
|
||||
var recognizer = CalibratedGestureRecognizer(configuration: configuration)
|
||||
let id = UUID(); recognizer.arm(sessionID: id, at: 0)
|
||||
#expect(recognizer.process(sample(0)) == nil)
|
||||
#expect(recognizer.process(sample(0.21)) == nil)
|
||||
#expect(recognizer.process(sample(0.30, 0.65, 0)) == nil)
|
||||
#expect(recognizer.process(sample(0.41, 0.8, 0)) == nil)
|
||||
let result = recognizer.process(sample(0.50, 0.9, 0))
|
||||
#expect(result?.command == .right)
|
||||
#expect(result?.sessionID == id)
|
||||
#expect(recognizer.process(sample(0.55, 0.9, 0)) == nil)
|
||||
}
|
||||
|
||||
@Test func diagonalLowConfidenceGapAndOutOfOrderAreRejected() {
|
||||
var recognizer = CalibratedGestureRecognizer()
|
||||
recognizer.arm(sessionID: UUID(), at: 0)
|
||||
_ = recognizer.process(sample(0)); _ = recognizer.process(sample(0.21))
|
||||
_ = recognizer.process(sample(0.35, 0.8, 0.75))
|
||||
#expect(recognizer.process(sample(0.50, 0.8, 0.75)) == nil) // ambiguous
|
||||
#expect(recognizer.process(sample(0.49, 0.9, 0)) == nil) // stale
|
||||
_ = recognizer.process(sample(0.70))
|
||||
#expect(recognizer.process(sample(0.90, 0.9, 0, 0.2)) == nil)
|
||||
}
|
||||
|
||||
@Test func cooldownAndReturnToNeutralSuppressReverseMotion() {
|
||||
var configuration = GestureRecognitionConfiguration(); configuration.maximumSampleGap = 0.3
|
||||
var recognizer = CalibratedGestureRecognizer(configuration: configuration)
|
||||
recognizer.arm(sessionID: UUID(), at: 0)
|
||||
_ = recognizer.process(sample(0)); _ = recognizer.process(sample(0.21))
|
||||
#expect(recognizer.process(sample(0.30, 0.9, 0)) == nil)
|
||||
#expect(recognizer.process(sample(0.41, 0.9, 0)) == nil)
|
||||
#expect(recognizer.process(sample(0.50, 0.9, 0))?.command == .right)
|
||||
_ = recognizer.process(sample(0.60)); _ = recognizer.process(sample(0.70)); _ = recognizer.process(sample(0.80))
|
||||
_ = recognizer.process(sample(0.90)); _ = recognizer.process(sample(0.95))
|
||||
#expect(recognizer.process(sample(1.05, -0.9, 0)) == nil)
|
||||
#expect(recognizer.process(sample(1.15, -0.9, 0)) == nil)
|
||||
#expect(recognizer.process(sample(1.25, -0.9, 0))?.command == .left)
|
||||
}
|
||||
|
||||
@Test func disarmClearsSessionAndNoReplayAfterRearm() {
|
||||
var recognizer = CalibratedGestureRecognizer()
|
||||
recognizer.arm(sessionID: UUID(), at: 0); _ = recognizer.process(sample(0)); _ = recognizer.process(sample(0.21))
|
||||
recognizer.disarm()
|
||||
#expect(recognizer.process(sample(1, 0.9, 0)) == nil)
|
||||
recognizer.arm(sessionID: UUID(), at: 2)
|
||||
#expect(recognizer.process(sample(2.1, 0.9, 0)) == nil)
|
||||
}
|
||||
|
||||
@Test func invalidSampleDiscardsCandidateUntilFreshNeutral() {
|
||||
var recognizer = CalibratedGestureRecognizer()
|
||||
recognizer.arm(sessionID: UUID(), at: 0)
|
||||
for index in 0...20 {
|
||||
_ = recognizer.process(.init(timestamp: Double(index) / 50, x: 0, y: 0))
|
||||
}
|
||||
_ = recognizer.process(.init(timestamp: 0.42, x: 0.8, y: 0))
|
||||
_ = recognizer.process(.init(timestamp: 0.44, x: .nan, y: 0))
|
||||
for index in 23...40 {
|
||||
#expect(recognizer.process(.init(timestamp: Double(index) / 50, x: 0.8, y: 0)) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import SamsungProtocol
|
||||
|
||||
private let parser = SamsungEventParser(maximumMessageBytes: 256)
|
||||
|
||||
@Test func parsesAllowlistedPairingEvents() throws {
|
||||
#expect(try parser.parse(Data(#"{"event":"ms.channel.connect","data":{"token":"synthetic-token"}}"#.utf8)) == .connected(token: "synthetic-token"))
|
||||
#expect(try parser.parse(Data(#"{"event":"ms.channel.connect","data":{}}"#.utf8)) == .connected(token: nil))
|
||||
#expect(try parser.parse(Data(#"{"event":"ms.channel.unauthorized"}"#.utf8)) == .unauthorized)
|
||||
#expect(try parser.parse(Data(#"{"event":"ms.channel.timeOut"}"#.utf8)) == .timedOut)
|
||||
#expect(try parser.parse(Data(#"{"event":"ms.error","data":{"message":"redacted"}}"#.utf8)) == .error)
|
||||
}
|
||||
|
||||
@Test func unknownEventsAreInert() throws {
|
||||
let fixture = Data(#"{"event":"synthetic.unknown","data":{"token":"not-consumed"}}"#.utf8)
|
||||
#expect(try parser.parse(fixture) == .unknown)
|
||||
}
|
||||
|
||||
@Test func rejectsMalformedEmptyAndOversizedMessages() {
|
||||
#expect(throws: SamsungEventParserError.empty) { try parser.parse(Data()) }
|
||||
#expect(throws: SamsungEventParserError.malformed) { try parser.parse(Data(#"{"event":42}"#.utf8)) }
|
||||
#expect(throws: SamsungEventParserError.oversized) { try parser.parse(Data(repeating: 0x41, count: 257)) }
|
||||
}
|
||||
|
||||
@Test func boundsEventNamesAndTokens() {
|
||||
let longEvent = String(repeating: "e", count: SamsungEventParser.maximumEventNameBytes + 1)
|
||||
let eventFixture = Data(#"{"event":"\#(longEvent)"}"#.utf8)
|
||||
#expect(throws: SamsungEventParserError.invalidEventName) { try parser.parse(eventFixture) }
|
||||
|
||||
let tokenParser = SamsungEventParser(maximumMessageBytes: 8 * 1024)
|
||||
let longToken = String(repeating: "t", count: SamsungEventParser.maximumTokenBytes + 1)
|
||||
let tokenFixture = Data(#"{"event":"ms.channel.connect","data":{"token":"\#(longToken)"}}"#.utf8)
|
||||
#expect(throws: SamsungEventParserError.invalidToken) { try tokenParser.parse(tokenFixture) }
|
||||
#expect(throws: SamsungEventParserError.invalidToken) {
|
||||
try tokenParser.parse(Data(#"{"event":"ms.channel.connect","data":{"token":""}}"#.utf8))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user