Create Wristivo iPhone and Apple Watch foundation
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
import RemoteCore
|
||||
|
||||
/// Proposed normalized thresholds; these are not calibrated motion measurements.
|
||||
public struct GestureConfiguration: Sendable, Equatable {
|
||||
public let activationThreshold: Double
|
||||
public let neutralThreshold: Double
|
||||
public let minimumInterval: TimeInterval
|
||||
public let maximumArmedDuration: TimeInterval
|
||||
public let axisDominanceRatio: Double
|
||||
|
||||
public init() {
|
||||
activationThreshold = 0.7
|
||||
neutralThreshold = 0.25
|
||||
minimumInterval = 0.3
|
||||
maximumArmedDuration = 10
|
||||
axisDominanceRatio = 0.6
|
||||
}
|
||||
|
||||
public enum ConfigurationError: Error { case invalidValues }
|
||||
|
||||
public init(activationThreshold: Double, neutralThreshold: Double,
|
||||
minimumInterval: TimeInterval, maximumArmedDuration: TimeInterval,
|
||||
axisDominanceRatio: Double) throws {
|
||||
guard activationThreshold.isFinite, neutralThreshold.isFinite,
|
||||
minimumInterval.isFinite, maximumArmedDuration.isFinite,
|
||||
axisDominanceRatio.isFinite,
|
||||
activationThreshold > 0, activationThreshold <= 1,
|
||||
neutralThreshold >= 0, neutralThreshold < activationThreshold,
|
||||
minimumInterval >= 0, maximumArmedDuration > 0,
|
||||
axisDominanceRatio > 0, axisDominanceRatio < 1 else {
|
||||
throw ConfigurationError.invalidValues
|
||||
}
|
||||
self.activationThreshold = activationThreshold
|
||||
self.neutralThreshold = neutralThreshold
|
||||
self.minimumInterval = minimumInterval
|
||||
self.maximumArmedDuration = maximumArmedDuration
|
||||
self.axisDominanceRatio = axisDominanceRatio
|
||||
}
|
||||
}
|
||||
|
||||
public struct GestureIntent: Sendable, Equatable {
|
||||
public let sessionID: UUID
|
||||
public let command: RemoteCommand
|
||||
/// Monotonic local seconds, never serialize this as a cross-device wall clock.
|
||||
public let timestamp: TimeInterval
|
||||
}
|
||||
|
||||
/// A deterministic gate over already-normalized directional input, NOT a classifier
|
||||
/// of accelerometer/gyroscope samples or an implementation of Apple system gestures.
|
||||
/// x positive = right; y positive = up. An adapter must establish wrist orientation.
|
||||
public struct DirectionalGestureGate: Sendable {
|
||||
public let configuration: GestureConfiguration
|
||||
public private(set) var sessionID: UUID?
|
||||
private var armedAt: TimeInterval?
|
||||
private var lastSampleAt: TimeInterval?
|
||||
private var lastEmissionAt: TimeInterval?
|
||||
private var ready = false
|
||||
|
||||
public init(configuration: GestureConfiguration = GestureConfiguration()) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
/// Deliberate foreground action only. After arming, a neutral sample is required.
|
||||
public mutating func arm(sessionID: UUID, at timestamp: TimeInterval) {
|
||||
disarm()
|
||||
guard timestamp.isFinite else { return }
|
||||
self.sessionID = sessionID
|
||||
armedAt = timestamp
|
||||
}
|
||||
|
||||
/// Call on app deactivation, reachability loss, TV/session change, or user cancel.
|
||||
public mutating func disarm() {
|
||||
sessionID = nil
|
||||
armedAt = nil
|
||||
lastSampleAt = nil
|
||||
lastEmissionAt = nil
|
||||
ready = false
|
||||
}
|
||||
|
||||
public mutating func process(x: Double, y: Double, at timestamp: TimeInterval) -> GestureIntent? {
|
||||
guard let sessionID, let armedAt else { return nil }
|
||||
guard timestamp.isFinite, timestamp >= armedAt,
|
||||
timestamp - armedAt < configuration.maximumArmedDuration else {
|
||||
disarm()
|
||||
return nil
|
||||
}
|
||||
guard lastSampleAt.map({ timestamp > $0 }) ?? true else { return nil }
|
||||
lastSampleAt = timestamp
|
||||
guard x.isFinite, y.isFinite, abs(x) <= 1, abs(y) <= 1 else {
|
||||
// Invalid input requires neutral recovery and cannot preserve readiness.
|
||||
ready = false
|
||||
return nil
|
||||
}
|
||||
let magnitude = max(abs(x), abs(y))
|
||||
if magnitude <= configuration.neutralThreshold {
|
||||
ready = true
|
||||
return nil
|
||||
}
|
||||
guard ready, magnitude >= configuration.activationThreshold else { return nil }
|
||||
// Every threshold excursion consumes readiness, including ambiguous diagonals
|
||||
// and debounce suppression. Holding an input can never trigger a late event.
|
||||
ready = false
|
||||
guard lastEmissionAt.map({ timestamp - $0 >= configuration.minimumInterval }) ?? true else {
|
||||
return nil
|
||||
}
|
||||
let command: RemoteCommand
|
||||
if abs(x) > abs(y), abs(y) <= abs(x) * configuration.axisDominanceRatio {
|
||||
command = x > 0 ? .right : .left
|
||||
} else if abs(y) > abs(x), abs(x) <= abs(y) * configuration.axisDominanceRatio {
|
||||
command = y > 0 ? .up : .down
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
lastEmissionAt = timestamp
|
||||
return GestureIntent(sessionID: sessionID, command: command, timestamp: timestamp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Foundation
|
||||
|
||||
public enum RemoteCommand: String, Codable, Sendable, CaseIterable {
|
||||
case up, down, left, right, select, back, home, playPause
|
||||
case volumeUp, volumeDown, mute
|
||||
}
|
||||
|
||||
/// Decoding is structural only. Run every decoded envelope through CommandAdmission
|
||||
/// immediately before transport dispatch. Never queue an accepted command for replay.
|
||||
public struct CommandEnvelope: Codable, Sendable, Equatable {
|
||||
public let schemaVersion: Int
|
||||
public let id: UUID
|
||||
public let sessionID: UUID
|
||||
public let televisionID: UUID
|
||||
public let sequence: UInt64
|
||||
public let issuedAt: Date
|
||||
public let expiresAt: Date
|
||||
public let command: RemoteCommand
|
||||
|
||||
public init(
|
||||
sessionID: UUID, televisionID: UUID, sequence: UInt64,
|
||||
issuedAt: Date, expiresAt: Date, command: RemoteCommand,
|
||||
id: UUID = UUID(), schemaVersion: Int = 1
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.id = id
|
||||
self.sessionID = sessionID
|
||||
self.televisionID = televisionID
|
||||
self.sequence = sequence
|
||||
self.issuedAt = issuedAt
|
||||
self.expiresAt = expiresAt
|
||||
self.command = command
|
||||
}
|
||||
}
|
||||
|
||||
public struct CommandReceipt: Codable, Sendable, Equatable {
|
||||
public enum Status: String, Codable, Sendable {
|
||||
/// Bytes were submitted to the transport; this does not verify a TV effect.
|
||||
case sentToTransport
|
||||
case rejected, expired, duplicate, unavailable
|
||||
}
|
||||
public let id: UUID
|
||||
public let status: Status
|
||||
|
||||
public init(id: UUID, status: Status) {
|
||||
self.id = id
|
||||
self.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/// Implementations own admission, readiness, credentials and bounded lifetime.
|
||||
/// They must not retry/replay expired commands or describe a send as TV confirmation.
|
||||
public protocol RemoteTransport: Sendable {
|
||||
func send(_ envelope: CommandEnvelope) async -> CommandReceipt
|
||||
}
|
||||
|
||||
public enum AdmissionRejection: String, Codable, Sendable, Equatable {
|
||||
case unsupportedSchema, wrongSession, wrongTelevision, invalidTimestamp
|
||||
case invalidLifetime, futureTimestamp, expired, stale, duplicate, outOfOrder
|
||||
}
|
||||
|
||||
public enum AdmissionDecision: Sendable, Equatable {
|
||||
case accepted
|
||||
case rejected(AdmissionRejection)
|
||||
}
|
||||
|
||||
/// Value-type admission gate: isolate mutation in the transport actor. State advances
|
||||
/// at admission, so a failed send consumes its sequence rather than replaying it.
|
||||
public struct CommandAdmission: Sendable {
|
||||
public private(set) var sessionID: UUID
|
||||
public private(set) var televisionID: UUID
|
||||
public private(set) var lastSequence: UInt64?
|
||||
public static let maximumAge: TimeInterval = 1
|
||||
public static let maximumFutureSkew: TimeInterval = 0.25
|
||||
public static let maximumLifetime: TimeInterval = 1
|
||||
public static let rememberedIDLimit = 128
|
||||
private var rememberedIDs: [UUID] = []
|
||||
private var rememberedIDSet: Set<UUID> = []
|
||||
|
||||
public init(sessionID: UUID, televisionID: UUID) {
|
||||
self.sessionID = sessionID
|
||||
self.televisionID = televisionID
|
||||
}
|
||||
|
||||
/// A reconnect must create a fresh session ID; never reset into an old session.
|
||||
/// The returned ID must be communicated to the watch before admitting input.
|
||||
@discardableResult
|
||||
public mutating func startNewSession(televisionID: UUID) -> UUID {
|
||||
sessionID = UUID()
|
||||
self.televisionID = televisionID
|
||||
lastSequence = nil
|
||||
rememberedIDs.removeAll(keepingCapacity: true)
|
||||
rememberedIDSet.removeAll(keepingCapacity: true)
|
||||
return sessionID
|
||||
}
|
||||
|
||||
public mutating func admit(_ envelope: CommandEnvelope, now: Date) -> AdmissionDecision {
|
||||
guard envelope.schemaVersion == 1 else { return .rejected(.unsupportedSchema) }
|
||||
guard envelope.sessionID == sessionID else { return .rejected(.wrongSession) }
|
||||
guard envelope.televisionID == televisionID else { return .rejected(.wrongTelevision) }
|
||||
let issued = envelope.issuedAt.timeIntervalSinceReferenceDate
|
||||
let expiry = envelope.expiresAt.timeIntervalSinceReferenceDate
|
||||
let current = now.timeIntervalSinceReferenceDate
|
||||
guard issued.isFinite, expiry.isFinite, current.isFinite else {
|
||||
return .rejected(.invalidTimestamp)
|
||||
}
|
||||
let lifetime = expiry - issued
|
||||
guard lifetime > 0, lifetime <= Self.maximumLifetime else {
|
||||
return .rejected(.invalidLifetime)
|
||||
}
|
||||
guard issued - current <= Self.maximumFutureSkew else {
|
||||
return .rejected(.futureTimestamp)
|
||||
}
|
||||
guard current - issued <= Self.maximumAge else { return .rejected(.stale) }
|
||||
guard current < expiry else { return .rejected(.expired) }
|
||||
guard !rememberedIDSet.contains(envelope.id) else { return .rejected(.duplicate) }
|
||||
if let lastSequence, envelope.sequence <= lastSequence {
|
||||
return .rejected(.outOfOrder)
|
||||
}
|
||||
lastSequence = envelope.sequence
|
||||
rememberedIDs.append(envelope.id)
|
||||
rememberedIDSet.insert(envelope.id)
|
||||
if rememberedIDs.count > Self.rememberedIDLimit {
|
||||
rememberedIDSet.remove(rememberedIDs.removeFirst())
|
||||
}
|
||||
return .accepted
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
import RemoteCore
|
||||
|
||||
/// Prototype wire format to verify against physical TV models. No network or TLS
|
||||
/// policy is implemented here. In particular, callers must never disable trust checks.
|
||||
public enum SamsungRemoteEncoder {
|
||||
public static let clientName = "Wristivo"
|
||||
|
||||
public static func key(for command: RemoteCommand) -> String {
|
||||
switch command {
|
||||
case .up: "KEY_UP"
|
||||
case .down: "KEY_DOWN"
|
||||
case .left: "KEY_LEFT"
|
||||
case .right: "KEY_RIGHT"
|
||||
case .select: "KEY_ENTER"
|
||||
case .back: "KEY_RETURN"
|
||||
case .home: "KEY_HOME"
|
||||
case .playPause: "KEY_PLAYPAUSE"
|
||||
case .volumeUp: "KEY_VOLUP"
|
||||
case .volumeDown: "KEY_VOLDOWN"
|
||||
case .mute: "KEY_MUTE"
|
||||
}
|
||||
}
|
||||
|
||||
public static func clickFrame(for command: RemoteCommand) throws -> Data {
|
||||
try JSONEncoder().encode(Frame(params: Parameters(DataOfCmd: key(for: command))))
|
||||
}
|
||||
|
||||
public enum EndpointError: Error, Sendable { case invalidHost }
|
||||
|
||||
/// Accepts a bare hostname or IPv4 address, or a bracketed IPv6 literal.
|
||||
/// Treat token as a secret: do not log the returned URL or persist it in telemetry.
|
||||
public static func endpoint(host: String, token: String? = nil) throws -> URL {
|
||||
guard !host.isEmpty, !host.contains(where: { $0.isWhitespace }),
|
||||
!host.contains("/"), !host.contains("@"),
|
||||
!host.contains("?"), !host.contains("#"),
|
||||
!host.contains("%"), !host.contains("\\"),
|
||||
(!host.contains(":") || (host.hasPrefix("[") && host.hasSuffix("]"))) else {
|
||||
throw EndpointError.invalidHost
|
||||
}
|
||||
var parts = URLComponents()
|
||||
parts.scheme = "wss"
|
||||
parts.host = host
|
||||
parts.port = 8002
|
||||
parts.path = "/api/v2/channels/samsung.remote.control"
|
||||
var items = [URLQueryItem(name: "name", value: Data(clientName.utf8).base64EncodedString())]
|
||||
if let token { items.append(URLQueryItem(name: "token", value: token)) }
|
||||
parts.queryItems = items
|
||||
// '+' is legal in an RFC 3986 query, but some embedded servers treat it as
|
||||
// a form-space. Encode it explicitly while retaining URLComponents escaping.
|
||||
parts.percentEncodedQuery = parts.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B")
|
||||
guard let url = parts.url, url.host != nil else { throw EndpointError.invalidHost }
|
||||
return url
|
||||
}
|
||||
|
||||
private struct Frame: Encodable {
|
||||
let method = "ms.remote.control"
|
||||
let params: Parameters
|
||||
}
|
||||
private struct Parameters: Encodable {
|
||||
let Cmd = "Click"
|
||||
let DataOfCmd: String
|
||||
let Option = "false"
|
||||
let TypeOfRemote = "SendRemoteKey"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user