Integrate live phone and watch remote prototype with guarded motion control

This commit is contained in:
2026-09-18 19:11:52 +04:00
parent 3e787543c0
commit 8131f4f873
54 changed files with 4355 additions and 329 deletions
@@ -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?
}
}