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,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.
+3
View File
@@ -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")])])
+20 -1
View File
@@ -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) }
}