Files
aris-old/IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift

564 lines
21 KiB
Swift
Raw Normal View History

2026-01-08 19:16:32 +00:00
//
// ContextOrchestrator.swift
// iris
//
// Created by Codex.
//
import CoreLocation
import Foundation
import MediaPlayer
import MusicKit
import os
@available(iOS 16.0, *)
@MainActor
final class ContextOrchestrator: NSObject, ObservableObject {
@Published private(set) var authorization: CLAuthorizationStatus = .notDetermined
@Published private(set) var lastLocation: CLLocation? = nil
@Published private(set) var lastRecomputeAt: Date? = nil
@Published private(set) var lastRecomputeReason: String? = nil
@Published private(set) var lastWinner: WinnerEnvelope? = nil
@Published private(set) var lastError: String? = nil
@Published private(set) var lastCandidates: [Candidate] = []
@Published private(set) var lastWeatherDiagnostics: [String: String] = [:]
@Published private(set) var lastPipelineElapsedMs: Int? = nil
@Published private(set) var lastFetchFailed: Bool = false
@Published private(set) var musicAuthorization: MusicAuthorization.Status = .notDetermined
@Published private(set) var nowPlaying: NowPlayingSnapshot? = nil
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "ContextOrchestrator")
private let locationManager = CLLocationManager()
private let weatherDataSource = WeatherDataSource()
private let calendarDataSource = CalendarDataSource()
private let poiDataSource = POIDataSource()
private let ranker: HeuristicRanker
private let store: FeedStore
private let server: LocalServer
private let ble: BlePeripheralManager
private let nowPlayingMonitor = NowPlayingMonitor()
private var lastRecomputeLocation: CLLocation? = nil
private var lastRecomputeAccuracy: CLLocationAccuracy? = nil
private var recomputeInFlight = false
private var lastRecomputeAttemptAt: Date? = nil
init(store: FeedStore = FeedStore(),
server: LocalServer = LocalServer(),
ble: BlePeripheralManager) {
self.store = store
self.server = server
self.ble = ble
self.ranker = HeuristicRanker(lastShownAt: { id in store.lastShownAt(candidateId: id) })
super.init()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.distanceFilter = kCLDistanceFilterNone
locationManager.pausesLocationUpdatesAutomatically = false
locationManager.allowsBackgroundLocationUpdates = true
ble.onFirstSubscribe = { [weak self] in
Task { @MainActor in
self?.logger.info("BLE subscribed: pushing latest winner")
self?.pushLatestWinnerToBle()
}
}
ble.onControlCommand = { [weak self] command in
Task { @MainActor in
self?.handleBleControl(command)
}
}
nowPlayingMonitor.onUpdate = { [weak self] update in
Task { @MainActor in
guard let self else { return }
self.musicAuthorization = update.authorization
self.nowPlaying = update.snapshot
self.pushLatestWinnerToBle()
}
}
let feed = store.getFeed()
lastWinner = feed.asWinnerEnvelope()
}
func start() {
authorization = locationManager.authorizationStatus
logger.info("start auth=\(String(describing: self.authorization), privacy: .public)")
server.start()
nowPlayingMonitor.start()
requestPermissionsIfNeeded()
locationManager.startUpdatingLocation()
}
func stop() {
locationManager.stopUpdatingLocation()
nowPlayingMonitor.stop()
}
func recomputeNow(reason: String = "manual") {
guard let location = lastLocation ?? locationManager.location else {
logger.info("recomputeNow skipped: no location")
return
}
maybeRecompute(for: location, reason: reason, force: true)
}
func sendFixtureFeedNow() {
guard let url = Bundle.main.url(forResource: "full_feed_fixture", withExtension: "json", subdirectory: "ProtocolFixtures")
?? Bundle.main.url(forResource: "full_feed_fixture", withExtension: "json"),
let data = try? Data(contentsOf: url) else {
logger.error("fixture feed missing in bundle")
return
}
ble.sendOpaque(data.trimmedTrailingWhitespace(), msgType: 1)
logger.info("sent fixture feed bytes=\(data.count)")
}
private func requestPermissionsIfNeeded() {
switch locationManager.authorizationStatus {
case .notDetermined:
logger.info("requestWhenInUseAuthorization")
locationManager.requestWhenInUseAuthorization()
case .authorizedWhenInUse:
logger.info("requestAlwaysAuthorization")
locationManager.requestAlwaysAuthorization()
case .authorizedAlways:
break
case .restricted, .denied:
lastError = "Location permission denied."
@unknown default:
break
}
}
private func maybeRecompute(for location: CLLocation, reason: String, force: Bool) {
let now = Date()
let hardThrottleSec: TimeInterval = 60
if !force, let lastAttempt = lastRecomputeAttemptAt, now.timeIntervalSince(lastAttempt) < hardThrottleSec {
logger.info("skip recompute (throttle) reason=\(reason, privacy: .public)")
return
}
lastRecomputeAttemptAt = now
if recomputeInFlight {
logger.info("skip recompute (in-flight) reason=\(reason, privacy: .public)")
return
}
recomputeInFlight = true
lastRecomputeReason = reason
logger.info("recompute start reason=\(reason, privacy: .public) lat=\(location.coordinate.latitude, format: .fixed(precision: 5)) lon=\(location.coordinate.longitude, format: .fixed(precision: 5)) acc=\(location.horizontalAccuracy, format: .fixed(precision: 1))")
Task {
await self.recomputePipeline(location: location, reason: reason)
}
}
private func shouldTriggerRecompute(for location: CLLocation) -> (Bool, String) {
if lastRecomputeAt == nil {
return (true, "initial")
}
let now = Date()
if let last = lastRecomputeAt, now.timeIntervalSince(last) > 15 * 60 {
return (true, "timer_15m")
}
if let lastLoc = lastRecomputeLocation {
let dist = location.distance(from: lastLoc)
if dist > 250 {
return (true, "moved_250m")
}
}
if let lastAcc = lastRecomputeAccuracy, location.horizontalAccuracy > 0, lastAcc > 0 {
if lastAcc - location.horizontalAccuracy > 50 {
return (true, "accuracy_improved_50m")
}
}
return (false, "no_trigger")
}
private func recomputePipeline(location: CLLocation, reason: String) async {
defer {
Task { @MainActor in
self.recomputeInFlight = false
}
}
let nowEpoch = Int(Date().timeIntervalSince1970)
let userContext = UserContext(isMoving: location.speed >= 1.0, city: "London")
let start = Date()
async let weatherResult = withTimeoutResult(seconds: 6) {
await self.weatherDataSource.candidatesWithDiagnostics(for: location, now: nowEpoch)
}
async let calendarResult = withTimeoutResult(seconds: 6) {
await self.calendarDataSource.candidatesWithDiagnostics(now: nowEpoch)
}
async let poiResult = withTimeoutResult(seconds: 6) {
try await self.poiDataSource.candidates(for: location, now: nowEpoch)
}
let wxRes = await weatherResult
let calRes = await calendarResult
let poiRes = await poiResult
var candidates: [Candidate] = []
var fetchFailed = false
var wxDiagnostics: [String: String] = [:]
var weatherNowCandidate: Candidate? = nil
switch wxRes {
case .success(let wx):
candidates.append(contentsOf: wx.candidates)
wxDiagnostics = wx.diagnostics
weatherNowCandidate = wx.candidates.first(where: { $0.type == .currentWeather }) ?? wx.candidates.first(where: { $0.id.hasPrefix("wx:now:") })
if let wxErr = wx.weatherKitError {
fetchFailed = true
logger.warning("weather fetch error: \(wxErr, privacy: .public)")
}
case .failure(let error):
fetchFailed = true
logger.error("weather fetch failed: \(String(describing: error), privacy: .public)")
}
switch poiRes {
case .success(let pois):
candidates.append(contentsOf: pois)
case .failure(let error):
fetchFailed = true
logger.error("poi fetch failed: \(String(describing: error), privacy: .public)")
}
switch calRes {
case .success(let cal):
candidates.append(contentsOf: cal.candidates)
if let err = cal.error {
fetchFailed = true
logger.warning("calendar error: \(err, privacy: .public)")
}
case .failure(let error):
fetchFailed = true
logger.error("calendar fetch failed: \(String(describing: error), privacy: .public)")
}
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
lastPipelineElapsedMs = elapsedMs
lastFetchFailed = fetchFailed
lastCandidates = candidates
lastWeatherDiagnostics = wxDiagnostics
logger.info("pipeline candidates total=\(candidates.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)")
if fetchFailed, candidates.isEmpty {
let fallbackFeed = store.getFeed(now: nowEpoch)
let fallbackWinner = fallbackFeed.asWinnerEnvelope()
lastWinner = fallbackWinner
lastError = "Fetch failed; using previous winner."
server.broadcastWinner(fallbackWinner)
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: fallbackFeed, now: nowEpoch))) ?? Data(), msgType: 1)
return
}
let unsuppressed = candidates
.filter { $0.type != .currentWeather }
.filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) }
let winner = ranker.pickWinner(from: unsuppressed, now: nowEpoch, context: userContext)
let envelope = WinnerEnvelope(schema: 1, generatedAt: nowEpoch, winner: winner, debug: nil)
let validated = (try? validateEnvelope(envelope)) ?? envelope
let feedEnvelope = FeedEnvelope.fromWinnerAndWeather(now: nowEpoch, winner: validated, weather: weatherNowCandidate)
store.setFeed(feedEnvelope, now: nowEpoch)
lastWinner = validated
lastRecomputeAt = Date()
lastRecomputeLocation = location
lastRecomputeAccuracy = location.horizontalAccuracy
lastError = fetchFailed ? "Partial fetch failure." : nil
logger.info("winner id=\(validated.winner.id, privacy: .public) type=\(validated.winner.type.rawValue, privacy: .public) prio=\(validated.winner.priority, format: .fixed(precision: 2)) ttl=\(validated.winner.ttlSec)")
server.broadcastWinner(validated)
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feedEnvelope, now: nowEpoch))) ?? Data(), msgType: 1)
}
private func pushLatestWinnerToBle() {
let nowEpoch = Int(Date().timeIntervalSince1970)
let feed = store.getFeed(now: nowEpoch)
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feed, now: nowEpoch))) ?? Data(), msgType: 1)
}
private func handleBleControl(_ command: String) {
guard !command.isEmpty else { return }
if command == "REQ_FULL" {
logger.info("BLE control REQ_FULL")
pushLatestWinnerToBle()
return
}
if command.hasPrefix("ACK:") {
logger.info("BLE control \(command, privacy: .public)")
return
}
logger.info("BLE control unknown=\(command, privacy: .public)")
}
private func feedForGlass(base: FeedEnvelope, now: Int) -> FeedEnvelope {
guard let nowPlayingCard = nowPlaying?.asFeedCard(baseGeneratedAt: base.generatedAt, now: now) else {
return base
}
var cards = base.feed.filter { $0.type != .nowPlaying }
// Append after existing FYI cards (e.g. weather).
cards.append(nowPlayingCard)
return FeedEnvelope(
schema: base.schema,
generatedAt: base.generatedAt,
feed: cards,
meta: FeedMeta(winnerId: base.meta.winnerId, unreadCount: cards.count)
)
}
}
extension ContextOrchestrator: CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
authorization = manager.authorizationStatus
logger.info("auth changed=\(String(describing: self.authorization), privacy: .public)")
requestPermissionsIfNeeded()
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
logger.error("location error: \(String(describing: error), privacy: .public)")
lastError = "Location error: \(error)"
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let best = locations.sorted(by: { $0.horizontalAccuracy < $1.horizontalAccuracy }).first else { return }
lastLocation = best
let (should, reason) = shouldTriggerRecompute(for: best)
if should {
maybeRecompute(for: best, reason: reason, force: false)
}
}
}
enum TimeoutError: Error {
case timedOut
}
func withTimeoutResult<T>(seconds: Double, operation: @escaping () async throws -> T) async -> Result<T, Error> {
do {
let value = try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw TimeoutError.timedOut
}
let result = try await group.next()!
group.cancelAll()
return result
}
return .success(value)
} catch {
return .failure(error)
}
}
@available(iOS 16.0, *)
struct NowPlayingSnapshot: Equatable, Sendable {
let itemId: String
let title: String
let artist: String?
let album: String?
let playbackStatus: MusicKit.MusicPlayer.PlaybackStatus
func asFeedCard(baseGeneratedAt: Int, now: Int) -> FeedCard {
let desiredLifetimeSec = 30
let ttl = max(1, (now - baseGeneratedAt) + desiredLifetimeSec)
let subtitleParts = [artist, album]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let subtitle = subtitleParts.isEmpty ? "Apple Music" : subtitleParts.joined(separator: "")
return FeedCard(
id: "music:now:\(itemId)",
type: .nowPlaying,
title: title.truncated(maxLength: TextConstraints.titleMax),
subtitle: subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: playbackStatus == .playing ? 0.35 : 0.2,
ttlSec: ttl,
condition: nil,
bucket: .fyi,
actions: ["DISMISS"]
)
}
}
@available(iOS 16.0, *)
@MainActor
final class NowPlayingMonitor {
struct Update: Sendable {
let authorization: MusicAuthorization.Status
let snapshot: NowPlayingSnapshot?
}
var onUpdate: ((Update) -> Void)? = nil
private let player = SystemMusicPlayer.shared
private let mpController = MPMusicPlayerController.systemMusicPlayer
private var observers: [NSObjectProtocol] = []
private var pollTimer: DispatchSourceTimer?
private var isRunning = false
private var authorization: MusicAuthorization.Status = .notDetermined
private var lastSnapshot: NowPlayingSnapshot? = nil
func start() {
guard !isRunning else { return }
isRunning = true
mpController.beginGeneratingPlaybackNotifications()
observers.append(
NotificationCenter.default.addObserver(
forName: .MPMusicPlayerControllerNowPlayingItemDidChange,
object: mpController,
queue: .main
) { [weak self] _ in
Task { @MainActor in self?.refresh(reason: "mp_now_playing_changed") }
}
)
observers.append(
NotificationCenter.default.addObserver(
forName: .MPMusicPlayerControllerPlaybackStateDidChange,
object: mpController,
queue: .main
) { [weak self] _ in
Task { @MainActor in self?.refresh(reason: "mp_playback_state_changed") }
}
)
startPolling()
Task { @MainActor in
await ensureAuthorization()
refresh(reason: "start")
}
}
func stop() {
guard isRunning else { return }
isRunning = false
pollTimer?.cancel()
pollTimer = nil
for token in observers {
NotificationCenter.default.removeObserver(token)
}
observers.removeAll()
mpController.endGeneratingPlaybackNotifications()
}
private func startPolling() {
guard pollTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(deadline: .now() + 2, repeating: 2)
timer.setEventHandler { [weak self] in
guard let self else { return }
self.refresh(reason: "poll")
}
timer.resume()
pollTimer = timer
}
private func ensureAuthorization() async {
if authorization == .notDetermined {
authorization = await MusicAuthorization.request()
onUpdate?(Update(authorization: authorization, snapshot: lastSnapshot))
}
}
private func refresh(reason: String) {
guard authorization == .authorized else {
if lastSnapshot != nil {
lastSnapshot = nil
onUpdate?(Update(authorization: authorization, snapshot: nil))
}
return
}
let playback = player.state.playbackStatus
guard playback != .stopped else {
if lastSnapshot != nil {
lastSnapshot = nil
onUpdate?(Update(authorization: authorization, snapshot: nil))
}
return
}
let mpItem = mpController.nowPlayingItem
let musicKitItem = player.queue.currentEntry?.item
let itemId = sanitizeId(
musicKitItem.map { String(describing: $0.id) }
?? mpItem.map { "mp:\($0.persistentID)" }
?? UUID().uuidString
)
let musicKitTitle = musicKitItem.map(nowPlayingTitle(from:))
let title = normalizeTitle(musicKitTitle) ?? normalizeTitle(mpItem?.title) ?? "Now Playing"
let artist = normalizePart(musicKitItem.flatMap(nowPlayingArtist(from:))) ?? normalizePart(mpItem?.artist)
let album = normalizePart(musicKitItem.flatMap(nowPlayingAlbum(from:))) ?? normalizePart(mpItem?.albumTitle)
let snapshot = NowPlayingSnapshot(itemId: itemId, title: title, artist: artist, album: album, playbackStatus: playback)
guard snapshot != lastSnapshot else { return }
lastSnapshot = snapshot
onUpdate?(Update(authorization: authorization, snapshot: snapshot))
}
private func nowPlayingTitle(from item: MusicItem) -> String {
if let song = item as? Song { return song.title }
if let album = item as? Album { return album.title }
if let playlist = item as? Playlist { return playlist.name }
return "Now Playing"
}
private func nowPlayingArtist(from item: MusicItem) -> String? {
if let song = item as? Song { return song.artistName }
if let album = item as? Album { return album.artistName }
return nil
}
private func nowPlayingAlbum(from item: MusicItem) -> String? {
if let song = item as? Song { return song.albumTitle }
return nil
}
private func sanitizeId(_ raw: String) -> String {
raw
.replacingOccurrences(of: " ", with: "_")
.replacingOccurrences(of: "\n", with: "_")
.replacingOccurrences(of: "\t", with: "_")
}
private func normalizePart(_ raw: String?) -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func normalizeTitle(_ raw: String?) -> String? {
guard let value = normalizePart(raw) else { return nil }
if value == "Now Playing" { return nil }
return value
}
}