Integrate live phone and watch remote prototype with guarded motion control
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
> Historical agent handoff. The integrated composition and current test status are documented in `docs/FIRST-DEVICE-PROTOTYPE.md` and `docs/VALIDATION.md`. Proposed wiring below has been incorporated or superseded.
|
||||
|
||||
# MotionCapture integration proposal
|
||||
|
||||
Owner: workstream C (`feat/wrist-motion`).
|
||||
|
||||
This change introduces a watch-only adapter boundary without changing the
|
||||
RemoteCore envelope. `MotionCaptureService` emits `GestureCore.GestureRecognition`
|
||||
values (semantic `RemoteCommand`, confidence, and a local monotonic timestamp)
|
||||
to the watch shell. It never creates envelopes, carries TV identity, or retries
|
||||
commands.
|
||||
|
||||
## Proposed target membership
|
||||
|
||||
The integrator should add `Adapters/MotionCapture/Sources/MotionCapture/*.swift`
|
||||
to a watchOS-only target that depends on the `GestureCore` package product. The
|
||||
pure classifier and its tests remain in `Packages/WristivoKit` and therefore
|
||||
build on macOS/iOS/watchOS. Core Motion must not be imported by the package.
|
||||
|
||||
## Lifecycle and permission contract
|
||||
|
||||
The watch shell calls `start(sessionID:)` only after calibration and explicit
|
||||
foreground arming. `stop(reason:)` is idempotent and must be called for scene
|
||||
inactivity, relay loss, target/session changes, expiry, cancellation, or sensor
|
||||
failure. A subsequent start creates a fresh neutral baseline. Hardware
|
||||
availability and start-handler failures are surfaced as typed errors; no
|
||||
permission denial is treated as an empty sample stream. The watch target needs
|
||||
the platform's truthful motion purpose string in its host configuration.
|
||||
|
||||
## Migration and tests
|
||||
|
||||
No wire/schema migration is required. Existing watch code can initially ignore
|
||||
the new service and continue using touch controls. The integrator must add the
|
||||
watch target membership and motion purpose string, then run the package tests
|
||||
and a physical permission/foreground suspension check. Simulator and synthetic
|
||||
traces validate logic only; no hardware recognition claim is made by this
|
||||
workstream.
|
||||
@@ -0,0 +1,3 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
let package = Package(name: "MotionCapture", platforms: [.watchOS(.v10)], products: [.library(name: "MotionCapture", targets: ["MotionCapture"])], dependencies: [.package(path: "../../Packages/WristivoKit")], targets: [.target(name: "MotionCapture", dependencies: [.product(name: "GestureCore", package: "WristivoKit")])])
|
||||
@@ -1,3 +1,22 @@
|
||||
# MotionCapture
|
||||
|
||||
Planned adapter: watch-only Core Motion acquisition and calibrated classification. No live implementation is present. Owner: workstream C in docs/agents/README.md. The integrator must establish injected protocols and source/test target membership before implementation. Keep platform APIs out of pure package modules.
|
||||
This workstream provides a watch-only `CMMotionManager` adapter and a pure
|
||||
calibrated classifier. The adapter is foreground/lifecycle-bound: it stops on
|
||||
inactive scene, relay loss, expiry, cancellation, or sensor failure. Samples
|
||||
are bounded and in memory only; no recording mode or raw traces are included.
|
||||
|
||||
The orientation transform (wrist side and crown orientation) and thresholds are
|
||||
versioned hypotheses pending physical validation. The package tests prove
|
||||
state-machine behavior only and are not evidence of reliable watch recognition.
|
||||
|
||||
## Evaluation report (pending hardware)
|
||||
|
||||
No Apple Watch or TV has been tested for this workstream. Precision, recall,
|
||||
incidental-movement false triggers, and the direction confusion matrix remain
|
||||
`pending physical evaluation`; do not infer them from synthetic fixtures.
|
||||
The required report should include per-direction TP/FP/FN, precision/recall,
|
||||
duplicate count, false triggers per armed hour, and a 4x4 actual-vs-predicted
|
||||
matrix split by wrist and crown orientation.
|
||||
|
||||
See `INTEGRATION-PROPOSAL.md` for target membership, permission-string, and
|
||||
lifecycle integration requirements.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import CoreMotion
|
||||
import GestureCore
|
||||
|
||||
public struct MotionCalibration: Codable, Sendable, Equatable {
|
||||
public static let algorithmVersion = 1
|
||||
public let algorithmVersion: Int
|
||||
public let wrist: WristSide
|
||||
public let crown: CrownOrientation
|
||||
public let neutralRotationRate: SIMD3<Double>
|
||||
public let scale: Double
|
||||
|
||||
public init(wrist: WristSide, crown: CrownOrientation,
|
||||
neutralRotationRate: SIMD3<Double>, scale: Double = 2.0) {
|
||||
self.algorithmVersion = Self.algorithmVersion; self.wrist = wrist; self.crown = crown
|
||||
self.neutralRotationRate = neutralRotationRate; self.scale = max(0.1, scale)
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts Core Motion rotation-rate samples into user-relative axes. This
|
||||
/// does not integrate acceleration and therefore makes no absolute-position
|
||||
/// claim. Orientation signs are a versioned hypothesis and require hardware
|
||||
/// validation for each supported watch/crown configuration.
|
||||
public struct MotionAxisNormalizer: Sendable {
|
||||
public let calibration: MotionCalibration
|
||||
private var baseline: SIMD3<Double>
|
||||
|
||||
public init(calibration: MotionCalibration) {
|
||||
self.calibration = calibration
|
||||
self.baseline = calibration.neutralRotationRate
|
||||
}
|
||||
|
||||
public mutating func normalize(_ motion: CMDeviceMotion) -> NormalizedMotionSample {
|
||||
let rate = motion.rotationRate
|
||||
guard rate.x.isFinite, rate.y.isFinite, rate.z.isFinite, motion.timestamp.isFinite else {
|
||||
// Propagate invalidity to the pure classifier; never clamp NaN into a direction.
|
||||
return NormalizedMotionSample(timestamp: motion.timestamp, x: .nan, y: .nan, quality: 0)
|
||||
}
|
||||
var x = (rate.x - baseline.x) / calibration.scale
|
||||
var y = (rate.y - baseline.y) / calibration.scale
|
||||
if calibration.wrist == .left { x = -x }
|
||||
if calibration.crown == .awayFromHand { y = -y }
|
||||
return NormalizedMotionSample(timestamp: motion.timestamp,
|
||||
x: max(-1, min(1, x)), y: max(-1, min(1, y)), quality: 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
@preconcurrency import CoreMotion
|
||||
import GestureCore
|
||||
|
||||
public enum MotionCaptureStopReason: Sendable { case user, inactive, disconnected, expired, cancelled, sensorFailure }
|
||||
public enum MotionCaptureError: Error, Sendable, Equatable { case unavailable, permissionDenied, failed(String) }
|
||||
|
||||
/// Foreground-only sensor owner. Calibration and armed capture never overlap.
|
||||
@MainActor
|
||||
public final class MotionCaptureService {
|
||||
public typealias RecognitionHandler = @Sendable (GestureRecognition) -> Void
|
||||
public typealias FailureHandler = @Sendable (MotionCaptureError) -> Void
|
||||
private let manager = CMMotionManager()
|
||||
private var recognizer = CalibratedGestureRecognizer()
|
||||
private var expiryTask: Task<Void, Never>?
|
||||
private var generation = UUID()
|
||||
public var isAvailable: Bool { manager.isDeviceMotionAvailable }
|
||||
public init() {}
|
||||
|
||||
public func calibrate(wrist: WristSide, crown: CrownOrientation,
|
||||
completion: @escaping @MainActor (Result<MotionCalibration, MotionCaptureError>) -> Void) {
|
||||
stop(reason: .cancelled)
|
||||
guard isAvailable else { completion(.failure(.unavailable)); return }
|
||||
let capture = generation
|
||||
var samples: [SIMD3<Double>] = []
|
||||
var firstTimestamp: Double?
|
||||
var previousTimestamp: Double?
|
||||
manager.deviceMotionUpdateInterval = 1 / 50
|
||||
manager.startDeviceMotionUpdates(using: .xArbitraryZVertical, to: .main) { [weak self] motion, error in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self, self.generation == capture else { return }
|
||||
guard error == nil, let motion else {
|
||||
self.stop(reason: .sensorFailure); completion(.failure(.failed("Motion permission or sensor unavailable"))); return
|
||||
}
|
||||
let r = motion.rotationRate
|
||||
let v = SIMD3(r.x, r.y, r.z)
|
||||
guard r.x.isFinite, r.y.isFinite, r.z.isFinite else { return }
|
||||
let stable = max(abs(r.x), abs(r.y), abs(r.z)) < 0.15
|
||||
if !stable || (previousTimestamp.map { motion.timestamp - $0 > 0.12 || motion.timestamp <= $0 } ?? false) {
|
||||
samples.removeAll(); firstTimestamp = nil
|
||||
}
|
||||
previousTimestamp = motion.timestamp
|
||||
guard stable else { return }
|
||||
if firstTimestamp == nil { firstTimestamp = motion.timestamp }
|
||||
samples.append(v)
|
||||
if motion.timestamp - (firstTimestamp ?? motion.timestamp) >= 1, samples.count >= 30 {
|
||||
let baseline = samples.reduce(SIMD3<Double>(repeating: 0), +) / Double(samples.count)
|
||||
self.stop(reason: .user)
|
||||
completion(.success(MotionCalibration(wrist: wrist, crown: crown, neutralRotationRate: baseline)))
|
||||
}
|
||||
}
|
||||
}
|
||||
expiryTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(8))
|
||||
guard !Task.isCancelled, let self, self.generation == capture else { return }
|
||||
self.stop(reason: .expired); completion(.failure(.failed("Calibration timed out. Hold still and retry.")))
|
||||
}
|
||||
}
|
||||
|
||||
public func start(sessionID: UUID, calibration: MotionCalibration, duration: TimeInterval = 10,
|
||||
onRecognition: @escaping RecognitionHandler, onFailure: @escaping FailureHandler,
|
||||
onExpiry: @escaping @MainActor () -> Void = {}) throws {
|
||||
stop(reason: .cancelled)
|
||||
guard isAvailable else { throw MotionCaptureError.unavailable }
|
||||
guard duration.isFinite, duration > 0, duration <= 30 else { throw MotionCaptureError.failed("Invalid duration") }
|
||||
let capture = generation
|
||||
var normalizer = MotionAxisNormalizer(calibration: calibration)
|
||||
var armed = false
|
||||
manager.deviceMotionUpdateInterval = 1 / 50
|
||||
manager.startDeviceMotionUpdates(using: .xArbitraryZVertical, to: .main) { [weak self] motion, error in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self, self.generation == capture else { return }
|
||||
guard error == nil, let motion else {
|
||||
self.stop(reason: .sensorFailure); onFailure(.failed("Motion permission or sensor unavailable")); return
|
||||
}
|
||||
if !armed { self.recognizer.arm(sessionID: sessionID, at: motion.timestamp); armed = true }
|
||||
if let result = self.recognizer.process(normalizer.normalize(motion)) { onRecognition(result) }
|
||||
}
|
||||
}
|
||||
expiryTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(duration))
|
||||
guard !Task.isCancelled, let self, self.generation == capture else { return }
|
||||
self.stop(reason: .expired); onExpiry()
|
||||
}
|
||||
}
|
||||
public func stop(reason: MotionCaptureStopReason) {
|
||||
generation = UUID(); manager.stopDeviceMotionUpdates(); expiryTask?.cancel(); expiryTask = nil; recognizer.disarm()
|
||||
}
|
||||
public func expire() { stop(reason: .expired) }
|
||||
public func setInactive() { stop(reason: .inactive) }
|
||||
public func setDisconnected() { stop(reason: .disconnected) }
|
||||
}
|
||||
Reference in New Issue
Block a user