initial commit

This commit is contained in:
2026-01-08 19:16:32 +00:00
commit d89aedd5af
121 changed files with 8509 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,431 @@
//
// BlePeripheralManager.swift
// iris
//
// Created by Codex.
//
import CoreBluetooth
import Foundation
#if canImport(UIKit)
import UIKit
#endif
final class BlePeripheralManager: NSObject, ObservableObject {
static let serviceUUID = CBUUID(string: "A0B0C0D0-E0F0-4A0B-9C0D-0E0F1A2B3C4D")
static let feedTxUUID = CBUUID(string: "A0B0C0D1-E0F0-4A0B-9C0D-0E0F1A2B3C4D")
static let controlRxUUID = CBUUID(string: "A0B0C0D2-E0F0-4A0B-9C0D-0E0F1A2B3C4D")
@Published private(set) var bluetoothState: CBManagerState = .unknown
@Published var advertisingEnabled: Bool = true
@Published private(set) var isAdvertising: Bool = false
@Published private(set) var isSubscribed: Bool = false
@Published private(set) var subscribedCount: Int = 0
@Published private(set) var lastMsgIdSent: UInt32 = 0
@Published private(set) var lastPingAt: Date? = nil
@Published private(set) var lastCommand: String? = nil
@Published private(set) var notifyQueueDepth: Int = 0
@Published private(set) var droppedNotifyPackets: Int = 0
@Published private(set) var lastNotifyAt: Date? = nil
@Published private(set) var lastDataAt: Date? = nil
private let queue = DispatchQueue(label: "iris.ble.peripheral.queue")
private lazy var peripheral = CBPeripheralManager(delegate: self, queue: queue)
private var service: CBMutableService?
private var feedTx: CBMutableCharacteristic?
private var controlRx: CBMutableCharacteristic?
private var subscribedCentralIds = Set<UUID>()
private var centralMaxUpdateLength: [UUID: Int] = [:]
private var lastReadValue: Data = Data()
private struct PendingMessage {
let msgId: UInt32
let msgType: UInt8
let payload: Data
let maxPayloadLen: Int
var nextChunkIndex: Int
let chunkCount: Int
var remainingChunks: Int { max(0, chunkCount - nextChunkIndex) }
func remainingBytesApprox(headerLen: Int) -> Int {
guard maxPayloadLen > 0 else { return remainingChunks * headerLen }
let remainingPayload = max(0, payload.count - (nextChunkIndex * maxPayloadLen))
return remainingPayload + (remainingChunks * headerLen)
}
}
private var pendingMessages: [PendingMessage] = []
private var pingTimer: DispatchSourceTimer?
private var msgId: UInt32 = 0
var onFirstSubscribe: (() -> Void)? = nil
var onControlCommand: ((String) -> Void)? = nil
private let packetHeaderLen = 9
private let maxQueuedNotifyPackets = 20_000
private let maxQueuedNotifyBytesApprox = 8_000_000
override init() {
super.init()
_ = peripheral
}
func start() {
queue.async { [weak self] in
self?.applyAdvertisingPolicy()
}
}
func stop() {
queue.async { [weak self] in
self?.publish { self?.advertisingEnabled = false }
self?.stopAdvertising()
self?.stopPing()
}
}
func sendOpaque(_ payload: Data, msgType: UInt8 = 1) {
queue.async { [weak self] in
self?.sendMessage(msgType: msgType, payload: payload)
}
}
func copyUUIDsToPasteboard() {
let text = """
SERVICE_UUID=\(Self.serviceUUID.uuidString)
FEED_TX_UUID=\(Self.feedTxUUID.uuidString)
CONTROL_RX_UUID=\(Self.controlRxUUID.uuidString)
"""
#if canImport(UIKit)
DispatchQueue.main.async {
UIPasteboard.general.string = text
}
#endif
}
private func ensureService() {
guard peripheral.state == .poweredOn else { return }
guard service == nil else { return }
peripheral.removeAllServices()
let feedTx = CBMutableCharacteristic(
type: Self.feedTxUUID,
properties: [.notify, .read],
value: nil,
permissions: [.readable]
)
let controlRx = CBMutableCharacteristic(
type: Self.controlRxUUID,
properties: [.writeWithoutResponse, .write],
value: nil,
permissions: [.writeable]
)
let service = CBMutableService(type: Self.serviceUUID, primary: true)
service.characteristics = [feedTx, controlRx]
self.service = service
self.feedTx = feedTx
self.controlRx = controlRx
peripheral.add(service)
}
private func applyAdvertisingPolicy() {
ensureService()
guard peripheral.state == .poweredOn else {
stopAdvertising()
return
}
if advertisingEnabled, subscribedCentralIds.isEmpty {
startAdvertising()
} else {
stopAdvertising()
}
}
private func startAdvertising() {
guard !isAdvertising else { return }
peripheral.startAdvertising([
CBAdvertisementDataLocalNameKey: "GlassNow",
CBAdvertisementDataServiceUUIDsKey: [Self.serviceUUID],
])
publish { self.isAdvertising = true }
}
private func stopAdvertising() {
guard isAdvertising else { return }
peripheral.stopAdvertising()
publish { self.isAdvertising = false }
}
private func startPing() {
guard pingTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 15, repeating: 15)
timer.setEventHandler { [weak self] in
self?.sendPing()
}
timer.resume()
pingTimer = timer
}
private func stopPing() {
pingTimer?.cancel()
pingTimer = nil
}
private func sendPing() {
sendMessage(msgType: 2, payload: Data())
publish { self.lastPingAt = Date() }
}
private func sendMessage(msgType: UInt8, payload: Data) {
guard let feedTx = feedTx else { return }
guard !subscribedCentralIds.isEmpty else { return }
lastReadValue = payload.trimmedTrailingWhitespace()
msgId &+= 1
publish { self.lastMsgIdSent = self.msgId }
let maxLen = currentMaxUpdateValueLength()
guard maxLen > packetHeaderLen else { return }
let maxPayloadLen = max(1, maxLen - packetHeaderLen)
let totalPayloadLen = lastReadValue.count
let chunkCount = max(1, Int(ceil(Double(totalPayloadLen) / Double(maxPayloadLen))))
guard chunkCount <= Int(UInt16.max) else { return }
flushPendingMessages()
var message = PendingMessage(
msgId: msgId,
msgType: msgType,
payload: lastReadValue,
maxPayloadLen: maxPayloadLen,
nextChunkIndex: 0,
chunkCount: chunkCount
)
if !pendingMessages.isEmpty {
enqueuePendingMessage(message)
return
}
if !sendPendingMessage(&message, characteristic: feedTx) {
pendingMessages.append(message)
enforceNotifyQueueLimits()
}
if msgType != 2 {
publish { self.lastDataAt = Date() }
}
publishNotifyQueueDepth()
}
private func buildPacket(msgId: UInt32, msgType: UInt8, chunkIndex: UInt16, chunkCount: UInt16, payloadSlice: Data.SubSequence) -> Data {
var data = Data()
data.reserveCapacity(packetHeaderLen + payloadSlice.count)
data.appendLE(msgId)
data.append(msgType)
data.appendLE(chunkIndex)
data.appendLE(chunkCount)
data.append(contentsOf: payloadSlice)
return data
}
private func sendPendingMessage(_ message: inout PendingMessage, characteristic: CBMutableCharacteristic) -> Bool {
while message.nextChunkIndex < message.chunkCount {
let start = message.nextChunkIndex * message.maxPayloadLen
let end = min(start + message.maxPayloadLen, message.payload.count)
let packet = buildPacket(
msgId: message.msgId,
msgType: message.msgType,
chunkIndex: UInt16(message.nextChunkIndex),
chunkCount: UInt16(message.chunkCount),
payloadSlice: message.payload[start..<end]
)
if peripheral.updateValue(packet, for: characteristic, onSubscribedCentrals: nil) {
publish { self.lastNotifyAt = Date() }
message.nextChunkIndex += 1
continue
}
return false
}
return true
}
private func flushPendingMessages() {
guard let feedTx = feedTx else { return }
while !pendingMessages.isEmpty {
if sendPendingMessage(&pendingMessages[0], characteristic: feedTx) {
pendingMessages.removeFirst()
continue
}
break
}
publishNotifyQueueDepth()
}
private func enqueuePendingMessage(_ message: PendingMessage) {
pendingMessages.append(message)
enforceNotifyQueueLimits()
publishNotifyQueueDepth()
}
private func publishNotifyQueueDepth() {
let depth = pendingMessages.reduce(0) { $0 + $1.remainingChunks }
publish { self.notifyQueueDepth = depth }
}
private func queuedNotifyBytesApprox() -> Int {
pendingMessages.reduce(0) { $0 + $1.remainingBytesApprox(headerLen: packetHeaderLen) }
}
private func enforceNotifyQueueLimits() {
var dropped = 0
while !pendingMessages.isEmpty {
let packetCount = pendingMessages.reduce(0) { $0 + $1.remainingChunks }
let bytesApprox = queuedNotifyBytesApprox()
if packetCount <= maxQueuedNotifyPackets, bytesApprox <= maxQueuedNotifyBytesApprox {
break
}
let removed = pendingMessages.removeLast()
dropped += removed.remainingChunks
}
if dropped > 0 {
publish { self.droppedNotifyPackets += dropped }
}
}
private func publish(_ block: @escaping () -> Void) {
DispatchQueue.main.async(execute: block)
}
private func currentMaxUpdateValueLength() -> Int {
let lengths = subscribedCentralIds.compactMap { centralMaxUpdateLength[$0] }.filter { $0 > 0 }
if let minLen = lengths.min() {
return minLen
}
return 180
}
}
extension BlePeripheralManager: CBPeripheralManagerDelegate {
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
queue.async { [weak self] in
guard let self = self else { return }
self.publish { self.bluetoothState = peripheral.state }
if peripheral.state != .poweredOn {
self.subscribedCentralIds.removeAll()
self.centralMaxUpdateLength.removeAll()
self.pendingMessages.removeAll()
self.stopPing()
self.stopAdvertising()
self.publish {
self.isSubscribed = false
self.subscribedCount = 0
self.notifyQueueDepth = 0
}
self.service = nil
self.feedTx = nil
self.controlRx = nil
return
}
self.ensureService()
self.applyAdvertisingPolicy()
}
}
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
queue.async { [weak self] in
self?.applyAdvertisingPolicy()
}
}
func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
publish { self.isAdvertising = (error == nil) }
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
guard characteristic.uuid == Self.feedTxUUID else { return }
queue.async { [weak self] in
guard let self = self else { return }
let wasEmpty = self.subscribedCentralIds.isEmpty
self.subscribedCentralIds.insert(central.identifier)
self.centralMaxUpdateLength[central.identifier] = central.maximumUpdateValueLength
self.publishNotifyQueueDepth()
self.publish {
self.isSubscribed = true
self.subscribedCount = self.subscribedCentralIds.count
}
self.applyAdvertisingPolicy()
self.startPing()
if wasEmpty {
self.onFirstSubscribe?()
}
}
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
guard characteristic.uuid == Self.feedTxUUID else { return }
queue.async { [weak self] in
guard let self = self else { return }
self.subscribedCentralIds.remove(central.identifier)
self.centralMaxUpdateLength[central.identifier] = nil
self.publishNotifyQueueDepth()
self.publish {
self.subscribedCount = self.subscribedCentralIds.count
self.isSubscribed = !self.subscribedCentralIds.isEmpty
}
if self.subscribedCentralIds.isEmpty {
self.stopPing()
}
self.applyAdvertisingPolicy()
}
}
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
queue.async { [weak self] in
self?.flushPendingMessages()
}
}
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) {
guard request.characteristic.uuid == Self.feedTxUUID else {
peripheral.respond(to: request, withResult: .requestNotSupported)
return
}
let maxLen = max(0, request.central.maximumUpdateValueLength)
request.value = maxLen > 0 ? lastReadValue.prefix(maxLen) : lastReadValue
peripheral.respond(to: request, withResult: .success)
}
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
for request in requests {
guard request.characteristic.uuid == Self.controlRxUUID else {
continue
}
let command = String(data: request.value ?? Data(), encoding: .utf8) ?? ""
publish { self.lastCommand = command }
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
self.onControlCommand?(trimmed)
peripheral.respond(to: request, withResult: .success)
}
}
}
private extension Data {
mutating func appendLE(_ value: UInt32) {
var v = value.littleEndian
Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) }
}
mutating func appendLE(_ value: UInt16) {
var v = value.littleEndian
Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) }
}
// `trimmedTrailingWhitespace()` lives in `iris/Utils/DataTrimming.swift`.
}

View File

@@ -0,0 +1,36 @@
//
// ContentView.swift
// iris
//
// Created by Kenneth on 06/01/2026.
//
import SwiftUI
struct ContentView: View {
@EnvironmentObject private var orchestrator: ContextOrchestrator
var body: some View {
TabView {
BleStatusView()
.tabItem { Label("BLE", systemImage: "dot.radiowaves.left.and.right") }
OrchestratorView()
.tabItem { Label("Orchestrator", systemImage: "bolt.horizontal.circle") }
}
.onAppear { orchestrator.start() }
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
if #available(iOS 16.0, *) {
let ble = BlePeripheralManager()
let orchestrator = ContextOrchestrator(ble: ble)
ContentView()
.environmentObject(ble)
.environmentObject(orchestrator)
} else {
ContentView()
}
}
}

View File

@@ -0,0 +1,200 @@
//
// CalendarDataSource.swift
// iris
//
// Created by Codex.
//
import EventKit
import Foundation
struct CalendarDataSourceConfig: Sendable {
var lookaheadSec: Int = 2 * 60 * 60
var soonWindowSec: Int = 30 * 60
var maxCandidates: Int = 3
var includeAllDay: Bool = false
var includeDeclined: Bool = false
init() {}
}
final class CalendarDataSource {
struct CandidatesResult: Sendable {
let candidates: [Candidate]
let error: String?
let diagnostics: [String: String]
}
private let store: EKEventStore
private let config: CalendarDataSourceConfig
init(store: EKEventStore = EKEventStore(), config: CalendarDataSourceConfig = .init()) {
self.store = store
self.config = config
}
func candidatesWithDiagnostics(now: Int) async -> CandidatesResult {
var diagnostics: [String: String] = [
"now": String(now),
"lookahead_sec": String(config.lookaheadSec),
"soon_window_sec": String(config.soonWindowSec),
]
let nowDate = Date(timeIntervalSince1970: TimeInterval(now))
let endDate = nowDate.addingTimeInterval(TimeInterval(config.lookaheadSec))
let auth = calendarAuthorizationStatusString()
diagnostics["auth"] = auth
let accessGranted = await ensureCalendarAccess()
diagnostics["access_granted"] = accessGranted ? "true" : "false"
guard accessGranted else {
return CandidatesResult(candidates: [], error: "Calendar access not granted.", diagnostics: diagnostics)
}
let predicate = store.predicateForEvents(withStart: nowDate, end: endDate, calendars: nil)
let events = store.events(matching: predicate)
diagnostics["events_matched"] = String(events.count)
let filtered = events
.filter { shouldInclude(event: $0) }
.sorted { $0.startDate < $1.startDate }
diagnostics["events_filtered"] = String(filtered.count)
let candidates = buildCandidates(from: filtered, now: now, nowDate: nowDate)
diagnostics["candidates"] = String(candidates.count)
return CandidatesResult(candidates: candidates, error: nil, diagnostics: diagnostics)
}
private func shouldInclude(event: EKEvent) -> Bool {
if event.isAllDay, !config.includeAllDay {
return false
}
if !config.includeDeclined {
// `EKEvent.participants` is optional; safest check is `eventStatus` and organizer availability.
// Many calendars wont provide participants; keep it simple for Phase 1.
if event.status == .canceled {
return false
}
}
return true
}
private func buildCandidates(from events: [EKEvent], now: Int, nowDate: Date) -> [Candidate] {
var results: [Candidate] = []
results.reserveCapacity(min(config.maxCandidates, events.count))
for event in events {
if results.count >= config.maxCandidates { break }
guard let start = event.startDate, let end = event.endDate else { continue }
let isOngoing = start <= nowDate && end > nowDate
let startsInSec = Int(start.timeIntervalSince(nowDate))
if !isOngoing, startsInSec > config.soonWindowSec {
continue
}
let title = (isOngoing ? "Now: \(event.title ?? "Event")" : event.title ?? "Upcoming")
.truncated(maxLength: TextConstraints.titleMax)
let subtitle = subtitleText(event: event, nowDate: nowDate)
.truncated(maxLength: TextConstraints.subtitleMax)
let confidence: Double = isOngoing ? 0.9 : 0.7
let ttl = ttlSec(end: end, nowDate: nowDate)
let id = "cal:\(event.eventIdentifier ?? UUID().uuidString):\(Int(start.timeIntervalSince1970))"
results.append(
Candidate(
id: id,
type: .info,
title: title,
subtitle: subtitle,
confidence: confidence,
createdAt: now,
ttlSec: ttl,
metadata: [
"source": "eventkit",
"calendar": event.calendar.title,
"start": String(Int(start.timeIntervalSince1970)),
"end": String(Int(end.timeIntervalSince1970)),
"all_day": event.isAllDay ? "true" : "false",
"location": event.location ?? "",
]
)
)
}
return results
}
private func ttlSec(end: Date, nowDate: Date) -> Int {
let ttl = Int(end.timeIntervalSince(nowDate))
// Keep the candidate alive until it ends, but cap at 2h and floor at 60s.
return min(max(ttl, 60), 2 * 60 * 60)
}
private func subtitleText(event: EKEvent, nowDate: Date) -> String {
guard let start = event.startDate, let end = event.endDate else { return "" }
let isOngoing = start <= nowDate && end > nowDate
if isOngoing {
let remainingMin = max(0, Int(floor(end.timeIntervalSince(nowDate) / 60.0)))
if let loc = event.location, !loc.isEmpty {
return "\(loc)\(remainingMin)m left"
}
return "\(remainingMin)m left"
}
let minutes = max(0, Int(floor(start.timeIntervalSince(nowDate) / 60.0)))
if let loc = event.location, !loc.isEmpty {
return "In \(minutes)m • \(loc)"
}
return "In \(minutes)m"
}
private func calendarAuthorizationStatusString() -> String {
if #available(iOS 17.0, *) {
return String(describing: EKEventStore.authorizationStatus(for: .event))
}
return String(describing: EKEventStore.authorizationStatus(for: .event))
}
private func ensureCalendarAccess() async -> Bool {
let status = EKEventStore.authorizationStatus(for: .event)
switch status {
case .authorized:
return true
case .denied, .restricted:
return false
case .notDetermined:
return await requestAccess()
@unknown default:
return false
}
}
private func requestAccess() async -> Bool {
await MainActor.run {
// Ensure the permission prompt is allowed to present.
}
return await withCheckedContinuation { continuation in
if #available(iOS 17.0, *) {
store.requestFullAccessToEvents { granted, _ in
continuation.resume(returning: granted)
}
} else {
store.requestAccess(to: .event) { granted, _ in
continuation.resume(returning: granted)
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
//
// POIDataSource.swift
// iris
//
// Created by Codex.
//
import CoreLocation
import Foundation
struct POIDataSourceConfig: Sendable {
var maxCandidates: Int = 3
init() {}
}
/// Placeholder POI source. Hook point for MapKit / local cache / server-driven POIs.
final class POIDataSource {
private let config: POIDataSourceConfig
init(config: POIDataSourceConfig = .init()) {
self.config = config
}
func candidates(for location: CLLocation, now: Int) async throws -> [Candidate] {
// Phase 1 stub: return nothing.
// (Still async/throws so the orchestrator can treat it uniformly with real implementations later.)
_ = config
_ = location
_ = now
return []
}
}

View File

@@ -0,0 +1,334 @@
//
// WeatherDataSource.swift
// iris
//
// Created by Codex.
//
import CoreLocation
import Foundation
import WeatherKit
struct WeatherAlertConfig: Sendable {
var rainLookaheadSec: Int = 20 * 60
var precipitationChanceThreshold: Double = 0.5
var gustThresholdMps: Double? = nil
var rainTTL: Int = 1800
var windTTL: Int = 3600
init() {}
}
protocol WeatherWarningProviding: Sendable {
func warningCandidates(location: CLLocation, now: Int) -> [Candidate]
}
struct NoopWeatherWarningProvider: WeatherWarningProviding {
func warningCandidates(location: CLLocation, now: Int) -> [Candidate] { [] }
}
/// Loads mock warning candidates from a local JSON file.
///
/// File format:
/// [
/// { "id":"warn:demo", "title":"...", "subtitle":"...", "ttl_sec":3600, "confidence":0.9, "meta": { "source":"mock" } }
/// ]
struct LocalMockWeatherWarningProvider: WeatherWarningProviding, Sendable {
struct MockWarning: Codable {
let id: String
let title: String
let subtitle: String
let ttlSec: Int?
let confidence: Double?
let meta: [String: String]?
enum CodingKeys: String, CodingKey {
case id
case title
case subtitle
case ttlSec = "ttl_sec"
case confidence
case meta
}
}
let url: URL?
init(url: URL?) {
self.url = url
}
func warningCandidates(location: CLLocation, now: Int) -> [Candidate] {
guard let url else { return [] }
guard let data = try? Data(contentsOf: url) else { return [] }
guard let items = try? JSONDecoder().decode([MockWarning].self, from: data) else { return [] }
return items.map { item in
Candidate(
id: item.id,
type: .weatherWarning,
title: item.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
confidence: min(max(item.confidence ?? 0.9, 0.0), 1.0),
createdAt: now,
ttlSec: max(1, item.ttlSec ?? 3600),
metadata: item.meta
)
}
}
}
@available(iOS 16.0, *)
final class WeatherDataSource {
private let service: WeatherService
private let config: WeatherAlertConfig
private let warningProvider: WeatherWarningProviding
init(service: WeatherService = .shared,
config: WeatherAlertConfig = .init(),
warningProvider: WeatherWarningProviding = LocalMockWeatherWarningProvider(
url: Bundle.main.url(forResource: "mock_weather_warnings", withExtension: "json")
)) {
self.service = service
self.config = config
self.warningProvider = warningProvider
}
/// Returns alert candidates derived from WeatherKit forecasts.
func candidates(for location: CLLocation, now: Int) async -> [Candidate] {
let result = await candidatesWithDiagnostics(for: location, now: now)
return result.candidates
}
struct CandidatesResult: Sendable {
let candidates: [Candidate]
let weatherKitError: String?
let diagnostics: [String: String]
}
func candidatesWithDiagnostics(for location: CLLocation, now: Int) async -> CandidatesResult {
var results: [Candidate] = []
var errorString: String? = nil
var diagnostics: [String: String] = [
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
"now": String(now),
"rain_lookahead_sec": String(config.rainLookaheadSec),
"precip_chance_threshold": String(format: "%.2f", config.precipitationChanceThreshold),
"gust_threshold_mps": config.gustThresholdMps.map { String(format: "%.2f", $0) } ?? "nil",
]
do {
let weather = try await service.weather(for: location)
results.append(currentConditionsCandidate(weather: weather, location: location, now: now))
diagnostics["minute_forecast"] = (weather.minuteForecast != nil) ? "present" : "nil"
diagnostics["hourly_count"] = String(weather.hourlyForecast.forecast.count)
if let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value {
diagnostics["current_gust_mps"] = String(format: "%.2f", gust)
} else {
diagnostics["current_gust_mps"] = "nil"
}
results.append(contentsOf: rainCandidates(weather: weather, location: location, now: now))
results.append(contentsOf: windCandidates(weather: weather, location: location, now: now))
diagnostics["candidates_weatherkit"] = String(results.count)
diagnostics.merge(rainDiagnostics(weather: weather, now: now)) { _, new in new }
} catch {
errorString = String(describing: error)
diagnostics["weatherkit_error"] = errorString
}
results.append(contentsOf: warningProvider.warningCandidates(location: location, now: now))
diagnostics["candidates_total"] = String(results.count)
return CandidatesResult(candidates: results, weatherKitError: errorString, diagnostics: diagnostics)
}
private func currentConditionsCandidate(weather: Weather, location: CLLocation, now: Int) -> Candidate {
let tempC = weather.currentWeather.temperature.converted(to: .celsius).value
let feelsC = weather.currentWeather.apparentTemperature.converted(to: .celsius).value
let tempInt = Int(tempC.rounded())
let feelsInt = Int(feelsC.rounded())
let cond = weather.currentWeather.condition.description
let conditionEnum = weather.currentWeather.condition
let title = "Now \(tempInt)°C \(cond)".truncated(maxLength: TextConstraints.titleMax)
let subtitle = "Feels \(feelsInt)°C".truncated(maxLength: TextConstraints.subtitleMax)
return Candidate(
id: "wx:now:\(now / 60)",
type: .currentWeather,
title: title,
subtitle: subtitle,
confidence: 0.8,
createdAt: now,
ttlSec: 1800,
condition: conditionEnum,
metadata: [
"source": "weatherkit_current",
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
)
}
private func rainCandidates(weather: Weather, location: CLLocation, now: Int) -> [Candidate] {
let nowDate = Date(timeIntervalSince1970: TimeInterval(now))
let lookahead = TimeInterval(config.rainLookaheadSec)
if let minuteForecast = weather.minuteForecast {
if let start = firstRainStart(minuteForecast.forecast, now: nowDate, within: lookahead) {
return [
Candidate(
id: "wx:rain:\(Int(start.timeIntervalSince1970))",
type: .weatherAlert,
title: rainTitle(start: start, now: nowDate).truncated(maxLength: TextConstraints.titleMax),
subtitle: "Carry an umbrella".truncated(maxLength: TextConstraints.subtitleMax),
confidence: 0.9,
createdAt: now,
ttlSec: config.rainTTL,
metadata: [
"source": "weatherkit_minutely",
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
)
]
}
return []
}
if let start = firstRainStartHourly(weather.hourlyForecast.forecast, now: nowDate, within: lookahead) {
return [
Candidate(
id: "wx:rain:\(Int(start.timeIntervalSince1970))",
type: .weatherAlert,
title: rainTitle(start: start, now: nowDate).truncated(maxLength: TextConstraints.titleMax),
subtitle: "Rain likely soon".truncated(maxLength: TextConstraints.subtitleMax),
confidence: 0.6,
createdAt: now,
ttlSec: config.rainTTL,
metadata: [
"source": "weatherkit_hourly_approx",
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
)
]
}
return []
}
private func windCandidates(weather: Weather, location: CLLocation, now: Int) -> [Candidate] {
guard let gustThreshold = config.gustThresholdMps else { return [] }
guard let gust = weather.currentWeather.wind.gust?.converted(to: .metersPerSecond).value else { return [] }
guard gust >= gustThreshold else { return [] }
let mph = Int((gust * 2.236936).rounded())
let title = "Wind gusts ~\(mph) mph".truncated(maxLength: TextConstraints.titleMax)
return [
Candidate(
id: "wx:wind:\(now):\(Int(gustThreshold * 10))",
type: .weatherAlert,
title: title,
subtitle: "Use caution outside".truncated(maxLength: TextConstraints.subtitleMax),
confidence: 0.8,
createdAt: now,
ttlSec: config.windTTL,
metadata: [
"source": "weatherkit_current",
"gust_mps": String(format: "%.2f", gust),
"threshold_mps": String(format: "%.2f", gustThreshold),
"lat": String(format: "%.5f", location.coordinate.latitude),
"lon": String(format: "%.5f", location.coordinate.longitude),
]
)
]
}
private func firstRainStart(_ minutes: [MinuteWeather], now: Date, within lookahead: TimeInterval) -> Date? {
for minute in minutes {
guard minute.date >= now else { continue }
let dt = minute.date.timeIntervalSince(now)
guard dt <= lookahead else { break }
let chance = minute.precipitationChance
let isRainy = minute.precipitation == .rain || minute.precipitation == .mixed
if isRainy, chance >= config.precipitationChanceThreshold {
return minute.date
}
}
return nil
}
private func firstRainStartHourly(_ hours: [HourWeather], now: Date, within lookahead: TimeInterval) -> Date? {
for hour in hours {
guard hour.date >= now else { continue }
let dt = hour.date.timeIntervalSince(now)
guard dt <= lookahead else { break }
let chance = hour.precipitationChance
let isRainy = hour.precipitation == .rain || hour.precipitation == .mixed
if isRainy, chance >= config.precipitationChanceThreshold {
return hour.date
}
}
return nil
}
private func rainTitle(start: Date, now: Date) -> String {
let minutes = max(0, Int(((start.timeIntervalSince(now)) / 60.0).rounded()))
if minutes <= 0 { return "Rain now" }
return "Rain in ~\(minutes) min"
}
private func rainDiagnostics(weather: Weather, now: Int) -> [String: String] {
let nowDate = Date(timeIntervalSince1970: TimeInterval(now))
let lookahead = TimeInterval(config.rainLookaheadSec)
if let minuteForecast = weather.minuteForecast {
var bestChance: Double = 0
var bestType: String = "none"
var firstAnyPrecipOffsetSec: Int? = nil
for minute in minuteForecast.forecast {
guard minute.date >= nowDate else { continue }
let dt = minute.date.timeIntervalSince(nowDate)
guard dt <= lookahead else { break }
if minute.precipitation != .none, firstAnyPrecipOffsetSec == nil {
firstAnyPrecipOffsetSec = Int(dt.rounded())
}
if minute.precipitationChance > bestChance {
bestChance = minute.precipitationChance
bestType = minute.precipitation.rawValue
}
}
return [
"rain_resolution": "minutely",
"rain_best_chance": String(format: "%.2f", bestChance),
"rain_best_type": bestType,
"rain_first_any_precip_offset_sec": firstAnyPrecipOffsetSec.map(String.init) ?? "nil",
]
}
var bestChance: Double = 0
var bestType: String = "none"
var firstAnyPrecipOffsetSec: Int? = nil
for hour in weather.hourlyForecast.forecast {
guard hour.date >= nowDate else { continue }
let dt = hour.date.timeIntervalSince(nowDate)
guard dt <= lookahead else { break }
if hour.precipitation != .none, firstAnyPrecipOffsetSec == nil {
firstAnyPrecipOffsetSec = Int(dt.rounded())
}
if hour.precipitationChance > bestChance {
bestChance = hour.precipitationChance
bestType = hour.precipitation.rawValue
}
}
return [
"rain_resolution": "hourly",
"rain_best_chance": String(format: "%.2f", bestChance),
"rain_best_type": bestType,
"rain_first_any_precip_offset_sec": firstAnyPrecipOffsetSec.map(String.init) ?? "nil",
]
}
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Allow Bluetooth to send context updates to Glass.</string>
<key>NSCalendarsUsageDescription</key>
<string>Allow calendar access to show upcoming events on Glass.</string>
<key>NSCalendarsFullAccessUsageDescription</key>
<string>Allow full calendar access to show upcoming events on Glass.</string>
<key>NSAppleMusicUsageDescription</key>
<string>Allow access to your Now Playing information to show music on Glass.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Allow location access to compute context cards for Glass.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Allow background location access to keep Glass context updated while moving.</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-peripheral</string>
<string>bluetooth-central</string>
<string>location</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,61 @@
//
// Candidate.swift
// iris
//
// Created by Codex.
//
import Foundation
import WeatherKit
struct Candidate: Codable, Equatable {
let id: String
let type: WinnerType
let title: String
let subtitle: String
let confidence: Double
let createdAt: Int
let ttlSec: Int
let condition: WeatherKit.WeatherCondition?
let metadata: [String: String]?
enum CodingKeys: String, CodingKey {
case id
case type
case title
case subtitle
case confidence
case createdAt
case ttlSec
case condition
case metadata
}
init(id: String,
type: WinnerType,
title: String,
subtitle: String,
confidence: Double,
createdAt: Int,
ttlSec: Int,
condition: WeatherKit.WeatherCondition? = nil,
metadata: [String: String]? = nil) {
self.id = id
self.type = type
self.title = title
self.subtitle = subtitle
self.confidence = confidence
self.createdAt = createdAt
self.ttlSec = ttlSec
self.condition = condition
self.metadata = metadata
}
func isExpired(at now: Int) -> Bool {
if ttlSec > 0 {
createdAt + ttlSec <= now
} else {
true
}
}
}

View File

@@ -0,0 +1,193 @@
//
// FeedEnvelope.swift
// iris
//
// Created by Codex.
//
import Foundation
import WeatherKit
struct FeedEnvelope: Codable, Equatable {
let schema: Int
let generatedAt: Int
let feed: [FeedCard]
let meta: FeedMeta
enum CodingKeys: String, CodingKey {
case schema
case generatedAt = "generated_at"
case feed
case meta
}
}
struct FeedCard: Codable, Equatable {
enum Bucket: String, Codable {
case rightNow = "RIGHT_NOW"
case fyi = "FYI"
}
let id: String
let type: WinnerType
let title: String
let subtitle: String
let priority: Double
let ttlSec: Int
let condition: WeatherKit.WeatherCondition?
let bucket: Bucket
let actions: [String]
enum CodingKeys: String, CodingKey {
case id
case type
case title
case subtitle
case priority
case ttlSec = "ttl_sec"
case condition
case bucket
case actions
}
init(id: String,
type: WinnerType,
title: String,
subtitle: String,
priority: Double,
ttlSec: Int,
condition: WeatherKit.WeatherCondition? = nil,
bucket: Bucket,
actions: [String]) {
self.id = id
self.type = type
self.title = title
self.subtitle = subtitle
self.priority = priority
self.ttlSec = ttlSec
self.condition = condition
self.bucket = bucket
self.actions = actions
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
type = try container.decode(WinnerType.self, forKey: .type)
title = try container.decode(String.self, forKey: .title)
subtitle = try container.decode(String.self, forKey: .subtitle)
priority = try container.decode(Double.self, forKey: .priority)
ttlSec = try container.decode(Int.self, forKey: .ttlSec)
bucket = try container.decode(Bucket.self, forKey: .bucket)
actions = try container.decode([String].self, forKey: .actions)
if let encoded = try container.decodeIfPresent(String.self, forKey: .condition) {
condition = WeatherKit.WeatherCondition.irisDecode(encoded)
} else {
condition = nil
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(type, forKey: .type)
try container.encode(title, forKey: .title)
try container.encode(subtitle, forKey: .subtitle)
try container.encode(priority, forKey: .priority)
try container.encode(ttlSec, forKey: .ttlSec)
try container.encode(bucket, forKey: .bucket)
try container.encode(actions, forKey: .actions)
if let condition {
try container.encode(condition.irisScreamingCase(), forKey: .condition)
}
}
}
struct FeedMeta: Codable, Equatable {
let winnerId: String
let unreadCount: Int
enum CodingKeys: String, CodingKey {
case winnerId = "winner_id"
case unreadCount = "unread_count"
}
}
extension FeedEnvelope {
static func fromWinnerAndWeather(now: Int, winner: WinnerEnvelope, weather: Candidate?) -> FeedEnvelope {
var cards: [FeedCard] = []
let winnerCard = FeedCard(
id: winner.winner.id,
type: winner.winner.type,
title: winner.winner.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: winner.winner.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: min(max(winner.winner.priority, 0.0), 1.0),
ttlSec: max(1, winner.winner.ttlSec),
condition: nil,
bucket: .rightNow,
actions: ["DISMISS"]
)
cards.append(winnerCard)
if let weather, weather.id != winner.winner.id {
let weatherCard = FeedCard(
id: weather.id,
type: weather.type,
title: weather.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: weather.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: min(max(weather.confidence, 0.0), 1.0),
ttlSec: max(1, weather.ttlSec),
condition: weather.condition,
bucket: .fyi,
actions: ["DISMISS"]
)
cards.append(weatherCard)
}
return FeedEnvelope(
schema: 1,
generatedAt: now,
feed: cards,
meta: FeedMeta(winnerId: winner.winner.id, unreadCount: cards.count)
)
}
}
extension FeedEnvelope {
static func allQuiet(now: Int, reason: String = "no_candidates", source: String = "engine") -> FeedEnvelope {
let card = FeedCard(
id: "quiet-000",
type: .allQuiet,
title: "All Quiet",
subtitle: "No urgent updates",
priority: 0.05,
ttlSec: 300,
condition: nil,
bucket: .rightNow,
actions: ["DISMISS"]
)
return FeedEnvelope(schema: 1, generatedAt: now, feed: [card], meta: FeedMeta(winnerId: card.id, unreadCount: 1))
}
func winnerCard() -> FeedCard? {
feed.first(where: { $0.id == meta.winnerId }) ?? feed.first
}
func asWinnerEnvelope() -> WinnerEnvelope {
let now = generatedAt
guard let winnerCard = winnerCard() else {
return WinnerEnvelope.allQuiet(now: now)
}
let winner = Winner(
id: winnerCard.id,
type: winnerCard.type,
title: winnerCard.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: winnerCard.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: min(max(winnerCard.priority, 0.0), 1.0),
ttlSec: max(1, winnerCard.ttlSec)
)
return WinnerEnvelope(schema: 1, generatedAt: now, winner: winner, debug: nil)
}
}

View File

@@ -0,0 +1,192 @@
//
// FeedStore.swift
// iris
//
// Created by Codex.
//
import Foundation
final class FeedStore {
struct CardKey: Hashable, Codable {
let id: String
let type: WinnerType
}
struct CardState: Codable, Equatable {
var lastShownAt: Int?
var dismissedUntil: Int?
var snoozedUntil: Int?
}
private struct Persisted: Codable {
var feed: FeedEnvelope?
var states: [String: CardState]
}
private let queue = DispatchQueue(label: "iris.feedstore.queue")
private let fileURL: URL
private var cachedFeed: FeedEnvelope?
private var states: [String: CardState] = [:]
init(filename: String = "feed_store_v1.json") {
self.fileURL = Self.defaultFileURL(filename: filename)
let persisted = Self.load(from: fileURL)
self.cachedFeed = persisted?.feed
self.states = persisted?.states ?? [:]
}
func getFeed(now: Int = Int(Date().timeIntervalSince1970)) -> FeedEnvelope {
queue.sync {
guard let feed = cachedFeed else {
return FeedEnvelope.allQuiet(now: now, reason: "no_feed", source: "store")
}
let filtered = normalizedFeed(feed, now: now)
if filtered.feed.isEmpty {
return FeedEnvelope.allQuiet(now: now, reason: "expired_or_suppressed", source: "store")
}
return filtered
}
}
func setFeed(_ feed: FeedEnvelope, now: Int = Int(Date().timeIntervalSince1970)) {
queue.sync {
let normalized = normalizedFeed(feed, now: now, applyingSuppression: false)
cachedFeed = normalized
markShown(feed: normalized, now: now)
save()
}
}
func lastShownAt(candidateId: String) -> Int? {
queue.sync {
let matches = states.compactMap { (key, value) -> Int? in
guard key.hasSuffix("|" + candidateId) else { return nil }
return value.lastShownAt
}
return matches.max()
}
}
func isSuppressed(id: String, type: WinnerType, now: Int) -> Bool {
queue.sync {
let key = Self.keyString(id: id, type: type)
guard let state = states[key] else { return false }
if let until = state.dismissedUntil, until > now { return true }
if let until = state.snoozedUntil, until > now { return true }
return false
}
}
func dismiss(id: String, type: WinnerType, until: Int? = nil) {
queue.sync {
let key = Self.keyString(id: id, type: type)
var state = states[key] ?? CardState()
state.dismissedUntil = until ?? Int.max
states[key] = state
save()
}
}
func snooze(id: String, type: WinnerType, until: Int) {
queue.sync {
let key = Self.keyString(id: id, type: type)
var state = states[key] ?? CardState()
state.snoozedUntil = until
states[key] = state
save()
}
}
func clearSuppression(id: String, type: WinnerType) {
queue.sync {
let key = Self.keyString(id: id, type: type)
var state = states[key] ?? CardState()
state.dismissedUntil = nil
state.snoozedUntil = nil
states[key] = state
save()
}
}
private func markShown(feed: FeedEnvelope, now: Int) {
for card in feed.feed {
let key = Self.keyString(id: card.id, type: card.type)
var state = states[key] ?? CardState()
state.lastShownAt = now
states[key] = state
}
}
private func normalizedFeed(_ feed: FeedEnvelope, now: Int, applyingSuppression: Bool = true) -> FeedEnvelope {
let normalizedCards = feed.feed.compactMap { card -> FeedCard? in
let ttl = max(1, card.ttlSec)
if feed.generatedAt + ttl <= now { return nil }
if applyingSuppression, isSuppressedUnlocked(id: card.id, type: card.type, now: now) { return nil }
return FeedCard(
id: card.id,
type: card.type,
title: card.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: card.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: min(max(card.priority, 0.0), 1.0),
ttlSec: ttl,
condition: card.condition,
bucket: card.bucket,
actions: card.actions
)
}
let winnerId = normalizedCards.first(where: { $0.id == feed.meta.winnerId })?.id ?? normalizedCards.first?.id ?? "quiet-000"
let normalized = FeedEnvelope(
schema: 1,
generatedAt: max(1, feed.generatedAt),
feed: normalizedCards,
meta: FeedMeta(winnerId: winnerId, unreadCount: normalizedCards.count)
)
return normalized
}
private func isSuppressedUnlocked(id: String, type: WinnerType, now: Int) -> Bool {
let key = Self.keyString(id: id, type: type)
guard let state = states[key] else { return false }
if let until = state.dismissedUntil, until > now { return true }
if let until = state.snoozedUntil, until > now { return true }
return false
}
private func save() {
let persisted = Persisted(feed: cachedFeed, states: states)
Self.save(persisted, to: fileURL)
}
private static func keyString(id: String, type: WinnerType) -> String {
"\(type.rawValue)|\(id)"
}
private static func defaultFileURL(filename: String) -> URL {
let fm = FileManager.default
let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fm.temporaryDirectory
let bundle = Bundle.main.bundleIdentifier ?? "iris"
return base
.appendingPathComponent(bundle, isDirectory: true)
.appendingPathComponent(filename, isDirectory: false)
}
private static func load(from url: URL) -> Persisted? {
guard let data = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder().decode(Persisted.self, from: data)
}
private static func save(_ persisted: Persisted, to url: URL) {
do {
let fm = FileManager.default
try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes]
let data = try encoder.encode(persisted)
try data.write(to: url, options: [.atomic])
} catch {
// Best-effort persistence.
}
}
}

View File

@@ -0,0 +1,81 @@
//
// HeuristicRanker.swift
// iris
//
// Created by Codex.
//
import Foundation
struct UserContext: Equatable {
var isMoving: Bool
var city: String
init(isMoving: Bool, city: String = "London") {
self.isMoving = isMoving
self.city = city
}
}
final class HeuristicRanker {
private let nowProvider: () -> Int
private let lastShownAtProvider: (String) -> Int?
init(now: @escaping () -> Int = { Int(Date().timeIntervalSince1970) },
lastShownAt: @escaping (String) -> Int? = { _ in nil }) {
self.nowProvider = now
self.lastShownAtProvider = lastShownAt
}
func pickWinner(from candidates: [Candidate], now: Int? = nil, context: UserContext) -> Winner {
let currentTime = now ?? nowProvider()
let valid = candidates
.filter { !$0.isExpired(at: currentTime) }
.filter { $0.confidence >= 0.0 }
guard !valid.isEmpty else {
return WinnerEnvelope.allQuiet(now: currentTime).winner
}
var best: (candidate: Candidate, score: Double)?
for candidate in valid {
let baseWeight = baseWeight(for: candidate.type)
var score = baseWeight * min(max(candidate.confidence, 0.0), 1.0)
if let shownAt = lastShownAtProvider(candidate.id),
currentTime - shownAt <= 2 * 60 * 60 {
score -= 0.4
}
if best == nil || score > best!.score {
best = (candidate, score)
}
}
guard let best else {
return WinnerEnvelope.allQuiet(now: currentTime).winner
}
let priority = min(max(best.score, 0.0), 1.0)
return Winner(
id: best.candidate.id,
type: best.candidate.type,
title: best.candidate.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: best.candidate.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: priority,
ttlSec: max(1, best.candidate.ttlSec)
)
}
private func baseWeight(for type: WinnerType) -> Double {
switch type {
case .weatherWarning: return 1.0
case .weatherAlert: return 0.9
case .transit: return 0.75
case .poiNearby: return 0.6
case .info: return 0.4
case .nowPlaying: return 0.25
case .currentWeather: return 0.0
case .allQuiet: return 0.0
}
}
}

View File

@@ -0,0 +1,50 @@
//
// WeatherKitConditionCoding.swift
// iris
//
// Created by Codex.
//
import Foundation
import WeatherKit
extension WeatherKit.WeatherCondition {
func irisScreamingCase() -> String {
Self.upperSnake(from: rawValue)
}
static func irisDecode(_ value: String) -> WeatherKit.WeatherCondition? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.uppercased() == "UNKNOWN" { return nil }
if let direct = WeatherKit.WeatherCondition(rawValue: trimmed) { return direct }
let lowerCamel = lowerCamel(fromScreamingOrSnake: trimmed)
if let mapped = WeatherKit.WeatherCondition(rawValue: lowerCamel) { return mapped }
return nil
}
private static func upperSnake(from value: String) -> String {
var out = ""
out.reserveCapacity(value.count + 8)
for scalar in value.unicodeScalars {
if CharacterSet.uppercaseLetters.contains(scalar), !out.isEmpty {
out.append("_")
}
out.append(String(scalar).uppercased())
}
return out
}
private static func lowerCamel(fromScreamingOrSnake value: String) -> String {
let parts = value
.split(separator: "_")
.map { $0.lowercased() }
guard let first = parts.first else { return value }
let rest = parts.dropFirst().map { $0.prefix(1).uppercased() + $0.dropFirst() }
return ([String(first)] + rest).joined()
}
}

View File

@@ -0,0 +1,37 @@
//
// Winner.swift
// iris
//
// Created by Codex.
//
import Foundation
enum WinnerType: String, Codable, CaseIterable {
case weatherAlert = "WEATHER_ALERT"
case weatherWarning = "WEATHER_WARNING"
case transit = "TRANSIT"
case poiNearby = "POI_NEARBY"
case info = "INFO"
case nowPlaying = "NOW_PLAYING"
case currentWeather = "CURRENT_WEATHER"
case allQuiet = "ALL_QUIET"
}
struct Winner: Codable, Equatable {
let id: String
let type: WinnerType
let title: String
let subtitle: String
let priority: Double
let ttlSec: Int
enum CodingKeys: String, CodingKey {
case id
case type
case title
case subtitle
case priority
case ttlSec = "ttl_sec"
}
}

View File

@@ -0,0 +1,100 @@
//
// WinnerEnvelope.swift
// iris
//
// Created by Codex.
//
import Foundation
struct WinnerEnvelope: Codable, Equatable {
struct DebugInfo: Codable, Equatable {
let reason: String
let source: String
}
let schema: Int
let generatedAt: Int
let winner: Winner
let debug: DebugInfo?
enum CodingKeys: String, CodingKey {
case schema
case generatedAt = "generated_at"
case winner
case debug
}
static func allQuiet(now: Int, reason: String = "no_candidates", source: String = "engine") -> WinnerEnvelope {
let winner = Winner(
id: "quiet-000",
type: .allQuiet,
title: "All Quiet",
subtitle: "No urgent updates",
priority: 0.05,
ttlSec: 300
)
return WinnerEnvelope(schema: 1, generatedAt: now, winner: winner, debug: .init(reason: reason, source: source))
}
}
enum EnvelopeValidationError: Error, LocalizedError {
case invalidSchema(Int)
case invalidPriority(Double)
case invalidTTL(Int)
var errorDescription: String? {
switch self {
case .invalidSchema(let schema):
return "Invalid schema \(schema). Expected 1."
case .invalidPriority(let priority):
return "Invalid priority \(priority). Must be between 0 and 1."
case .invalidTTL(let ttl):
return "Invalid ttl \(ttl). Must be greater than 0."
}
}
}
func validateEnvelope(_ envelope: WinnerEnvelope) throws -> WinnerEnvelope {
guard envelope.schema == 1 else {
throw EnvelopeValidationError.invalidSchema(envelope.schema)
}
guard envelope.winner.priority >= 0.0, envelope.winner.priority <= 1.0 else {
throw EnvelopeValidationError.invalidPriority(envelope.winner.priority)
}
guard envelope.winner.ttlSec > 0 else {
throw EnvelopeValidationError.invalidTTL(envelope.winner.ttlSec)
}
let validatedWinner = Winner(
id: envelope.winner.id,
type: envelope.winner.type,
title: envelope.winner.title.truncated(maxLength: TextConstraints.titleMax),
subtitle: envelope.winner.subtitle.truncated(maxLength: TextConstraints.subtitleMax),
priority: envelope.winner.priority,
ttlSec: envelope.winner.ttlSec
)
return WinnerEnvelope(
schema: envelope.schema,
generatedAt: envelope.generatedAt,
winner: validatedWinner,
debug: envelope.debug
)
}
enum TextConstraints {
static let titleMax = 26
static let subtitleMax = 30
static let ellipsis = "..."
}
extension String {
func truncated(maxLength: Int) -> String {
guard count > maxLength else { return self }
let ellipsisCount = TextConstraints.ellipsis.count
guard maxLength > ellipsisCount else { return String(prefix(maxLength)) }
return String(prefix(maxLength - ellipsisCount)) + TextConstraints.ellipsis
}
}

View File

@@ -0,0 +1,383 @@
//
// LocalServer.swift
// iris
//
// Created by Codex.
//
import Foundation
import Network
final class LocalServer: ObservableObject {
@Published private(set) var isRunning = false
@Published private(set) var port: Int
@Published private(set) var sseClientCount = 0
@Published private(set) var lastWinnerTitle = "All Quiet"
@Published private(set) var lastWinnerSubtitle = "No urgent updates"
@Published private(set) var lastBroadcastAt: Date? = nil
@Published private(set) var listenerState: String = "idle"
@Published private(set) var listenerError: String? = nil
@Published private(set) var lastConnectionAt: Date? = nil
@Published private(set) var localAddresses: [String] = []
private let queue = DispatchQueue(label: "iris.localserver.queue")
private var listener: NWListener?
private var browser: NWBrowser?
private var startDate = Date()
private var currentEnvelope: WinnerEnvelope
private var heartbeatTimer: DispatchSourceTimer?
private var addressTimer: DispatchSourceTimer?
private var requestBuffers: [ObjectIdentifier: Data] = [:]
private var clients: [ObjectIdentifier: SSEClient] = [:]
init(port: Int = 8765) {
self.port = port
let winner = Winner(
id: "quiet-000",
type: .allQuiet,
title: "All Quiet",
subtitle: "No urgent updates",
priority: 0.05,
ttlSec: 300
)
self.currentEnvelope = WinnerEnvelope(
schema: 1,
generatedAt: Int(Date().timeIntervalSince1970),
winner: winner,
debug: nil
)
}
var testURL: String {
"http://172.20.10.1:\(port)/v1/stream"
}
func start() {
guard listener == nil else { return }
let parameters = NWParameters.tcp
do {
let portValue = NWEndpoint.Port(rawValue: UInt16(port)) ?? 8765
let listener = try NWListener(using: parameters, on: portValue)
listener.newConnectionHandler = { [weak self] connection in
self?.handleNewConnection(connection)
}
listener.stateUpdateHandler = { [weak self] state in
DispatchQueue.main.async {
self?.listenerState = "\(state)"
if case .failed(let error) = state {
self?.listenerError = "\(error)"
}
self?.isRunning = (state == .ready)
}
}
self.listener = listener
self.startDate = Date()
listener.start(queue: queue)
startHeartbeat()
startLocalNetworkPrompt()
startAddressUpdates()
} catch {
DispatchQueue.main.async {
self.isRunning = false
self.listenerState = "failed"
self.listenerError = "\(error)"
}
}
}
func stop() {
listener?.cancel()
listener = nil
stopLocalNetworkPrompt()
stopHeartbeat()
stopAddressUpdates()
closeAllClients()
DispatchQueue.main.async {
self.isRunning = false
}
}
func broadcastWinner(_ envelope: WinnerEnvelope) {
let validated = (try? validateEnvelope(envelope)) ?? envelope
currentEnvelope = validated
DispatchQueue.main.async {
self.lastWinnerTitle = validated.winner.title
self.lastWinnerSubtitle = validated.winner.subtitle
self.lastBroadcastAt = Date()
}
let data = sseEvent(name: "winner", payload: jsonLine(from: validated))
broadcast(data: data)
}
private func handleNewConnection(_ connection: NWConnection) {
DispatchQueue.main.async {
self.lastConnectionAt = Date()
}
connection.stateUpdateHandler = { [weak self] state in
if case .failed = state {
self?.removeClient(for: connection)
} else if case .cancelled = state {
self?.removeClient(for: connection)
}
}
connection.start(queue: queue)
receiveRequest(on: connection)
}
private func receiveRequest(on connection: NWConnection) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in
guard let self = self else { return }
if let data = data, !data.isEmpty {
let key = ObjectIdentifier(connection)
var buffer = self.requestBuffers[key] ?? Data()
buffer.append(data)
self.requestBuffers[key] = buffer
if let requestLine = self.parseRequestLine(from: buffer) {
self.requestBuffers[key] = nil
self.handleRequest(requestLine: requestLine, connection: connection)
return
}
}
if isComplete || error != nil {
self.requestBuffers[ObjectIdentifier(connection)] = nil
self.removeClient(for: connection)
connection.cancel()
return
}
self.receiveRequest(on: connection)
}
}
private func parseRequestLine(from data: Data) -> String? {
guard let string = String(data: data, encoding: .utf8) else { return nil }
guard let range = string.range(of: "\r\n") else { return nil }
return String(string[..<range.lowerBound])
}
private func handleRequest(requestLine: String, connection: NWConnection) {
let parts = requestLine.split(separator: " ")
guard parts.count >= 2 else {
sendResponse(status: "400 Bad Request", body: "{}", contentType: "application/json", connection: connection)
return
}
let method = parts[0]
let path = String(parts[1])
guard method == "GET" else {
sendResponse(status: "405 Method Not Allowed", body: "{}", contentType: "application/json", connection: connection)
return
}
switch path {
case "/v1/health":
let uptime = Int(Date().timeIntervalSince(startDate))
let body = "{\"ok\":true,\"uptime_sec\":\(uptime)}"
sendResponse(status: "200 OK", body: body, contentType: "application/json", connection: connection)
case "/v1/winner":
let body = jsonLine(from: currentEnvelope)
sendResponse(status: "200 OK", body: body, contentType: "application/json", connection: connection)
case "/v1/stream":
startSSE(connection)
default:
sendResponse(status: "404 Not Found", body: "{}", contentType: "application/json", connection: connection)
}
}
private func sendResponse(status: String, body: String, contentType: String, connection: NWConnection) {
let response = """
HTTP/1.1 \(status)\r\n\
Content-Type: \(contentType)\r\n\
Content-Length: \(body.utf8.count)\r\n\
Connection: close\r\n\
\r\n\
\(body)
"""
connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in
connection.cancel()
})
}
private func startSSE(_ connection: NWConnection) {
let headers = """
HTTP/1.1 200 OK\r\n\
Content-Type: text/event-stream\r\n\
Cache-Control: no-cache\r\n\
Connection: keep-alive\r\n\
\r\n
"""
connection.send(content: headers.data(using: .utf8), completion: .contentProcessed { [weak self] error in
if error != nil {
connection.cancel()
return
}
self?.addClient(connection)
let initial = self?.sseEvent(name: "feed", payload: self?.initialFeedJSON() ?? "{}") ?? Data()
connection.send(content: initial, completion: .contentProcessed { _ in })
let status = self?.sseEvent(name: "status", payload: self?.statusJSON() ?? "{}") ?? Data()
connection.send(content: status, completion: .contentProcessed { _ in })
})
}
private func addClient(_ connection: NWConnection) {
let key = ObjectIdentifier(connection)
clients[key] = SSEClient(connection: connection)
DispatchQueue.main.async {
self.sseClientCount = self.clients.count
}
}
private func removeClient(for connection: NWConnection) {
let key = ObjectIdentifier(connection)
if clients.removeValue(forKey: key) != nil {
DispatchQueue.main.async {
self.sseClientCount = self.clients.count
}
}
}
private func closeAllClients() {
for client in clients.values {
client.connection.cancel()
}
clients.removeAll()
DispatchQueue.main.async {
self.sseClientCount = 0
}
}
private func startHeartbeat() {
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 15, repeating: 15)
timer.setEventHandler { [weak self] in
guard let self = self else { return }
let data = self.sseEvent(name: "ping", payload: "{}")
self.broadcast(data: data)
}
timer.resume()
heartbeatTimer = timer
}
private func stopHeartbeat() {
heartbeatTimer?.cancel()
heartbeatTimer = nil
}
private func startAddressUpdates() {
updateLocalAddresses()
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 10, repeating: 10)
timer.setEventHandler { [weak self] in
self?.updateLocalAddresses()
}
timer.resume()
addressTimer = timer
}
private func stopAddressUpdates() {
addressTimer?.cancel()
addressTimer = nil
}
private func updateLocalAddresses() {
let addresses = Self.localInterfaceAddresses()
DispatchQueue.main.async {
self.localAddresses = addresses
}
}
private func startLocalNetworkPrompt() {
guard browser == nil else { return }
let parameters = NWParameters.tcp
let browser = NWBrowser(for: .bonjour(type: "_http._tcp", domain: nil), using: parameters)
browser.stateUpdateHandler = { _ in }
browser.browseResultsChangedHandler = { _, _ in }
self.browser = browser
browser.start(queue: queue)
}
private func stopLocalNetworkPrompt() {
browser?.cancel()
browser = nil
}
private func broadcast(data: Data) {
for (key, client) in clients {
client.connection.send(content: data, completion: .contentProcessed { [weak self] error in
if error != nil {
self?.clients.removeValue(forKey: key)
DispatchQueue.main.async {
self?.sseClientCount = self?.clients.count ?? 0
}
}
})
}
}
private func jsonLine(from envelope: WinnerEnvelope) -> String {
let encoder = JSONEncoder()
if let data = try? encoder.encode(envelope),
let string = String(data: data, encoding: .utf8) {
return string
}
return "{}"
}
private func initialFeedJSON() -> String {
return "{\"schema\":1,\"generated_at\":1767716400,\"feed\":[{\"id\":\"demo:welcome\",\"type\":\"INFO\",\"title\":\"Glass Now online\",\"subtitle\":\"Connected to iPhone\",\"priority\":0.8,\"ttl_sec\":86400,\"bucket\":\"RIGHT_NOW\",\"actions\":[\"DISMISS\"]},{\"id\":\"demo:next\",\"type\":\"INFO\",\"title\":\"Next: Calendar\",\"subtitle\":\"Then Weather + POI\",\"priority\":0.4,\"ttl_sec\":86400,\"bucket\":\"FYI\",\"actions\":[\"DISMISS\"]}],\"meta\":{\"winner_id\":\"demo:welcome\",\"unread_count\":2}}"
}
private func statusJSON() -> String {
let uptime = Int(Date().timeIntervalSince(startDate))
return "{\"server\":\"iphone\",\"version\":\"v1\",\"uptime_sec\":\(uptime)}"
}
private func sseEvent(name: String, payload: String?) -> Data {
let dataLine = payload ?? "{}"
let message = "event: \(name)\n" + "data: \(dataLine)\n\n"
return Data(message.utf8)
}
}
private struct SSEClient {
let connection: NWConnection
}
private extension LocalServer {
static func localInterfaceAddresses() -> [String] {
var results: [String] = []
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let firstAddr = addrList else {
return results
}
defer { freeifaddrs(addrList) }
var ptr: UnsafeMutablePointer<ifaddrs>? = firstAddr
while let addr = ptr?.pointee {
let flags = Int32(addr.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
guard isUp, !isLoopback, let sa = addr.ifa_addr else {
ptr = addr.ifa_next
continue
}
let family = sa.pointee.sa_family
if family == UInt8(AF_INET) {
var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
sa,
socklen_t(sa.pointee.sa_len),
&hostname,
socklen_t(hostname.count),
nil,
0,
NI_NUMERICHOST
)
if result == 0, let ip = String(validatingUTF8: hostname) {
let name = String(cString: addr.ifa_name)
results.append("\(name): \(ip)")
}
}
ptr = addr.ifa_next
}
return results.sorted()
}
}

View File

@@ -0,0 +1,563 @@
//
// 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
}
}

View File

@@ -0,0 +1,16 @@
{
"schema": 1,
"generated_at": 1736217620,
"winner": {
"id": "quiet-000",
"type": "ALL_QUIET",
"title": "All Quiet",
"subtitle": "No urgent updates",
"priority": 0.05,
"ttl_sec": 300
},
"debug": {
"reason": "no_candidates",
"source": "engine"
}
}

View File

@@ -0,0 +1 @@
{"schema":1,"generated_at":1767716400,"feed":[{"id":"demo:welcome","type":"INFO","title":"Glass Now online","subtitle":"Connected to iPhone","priority":0.8,"ttl_sec":86400,"bucket":"RIGHT_NOW","actions":["DISMISS"]},{"id":"demo:next","type":"INFO","title":"Next: Calendar","subtitle":"Then Weather + POI","priority":0.4,"ttl_sec":86400,"bucket":"FYI","actions":["DISMISS"]},{"id":"music:now:demo","type":"NOW_PLAYING","title":"Midnight City","subtitle":"M83 • Hurry Up, We're Dreaming","priority":0.35,"ttl_sec":30,"bucket":"FYI","actions":["DISMISS"]}],"meta":{"winner_id":"demo:welcome","unread_count":3}}

View File

@@ -0,0 +1,16 @@
{
"schema": 1,
"generated_at": 1736217615,
"winner": {
"id": "poi-park-003",
"type": "POI_NEARBY",
"title": "Riverside Park",
"subtitle": "2 min walk, open now",
"priority": 0.45,
"ttl_sec": 1200
},
"debug": {
"reason": "nearby_poi",
"source": "local_search"
}
}

View File

@@ -0,0 +1,16 @@
{
"schema": 1,
"generated_at": 1736217610,
"winner": {
"id": "transit-005",
"type": "TRANSIT",
"title": "Train 4 Arrival",
"subtitle": "Platform 2 in 6 min",
"priority": 0.7,
"ttl_sec": 600
},
"debug": {
"reason": "upcoming_departure",
"source": "gtfs"
}
}

View File

@@ -0,0 +1,16 @@
{
"schema": 1,
"generated_at": 1736217600,
"winner": {
"id": "alert-rain-001",
"type": "WEATHER_ALERT",
"title": "Rain Soon",
"subtitle": "Light rain in 20 min",
"priority": 0.92,
"ttl_sec": 900
},
"debug": {
"reason": "incoming_precip",
"source": "weatherkit"
}
}

View File

@@ -0,0 +1,16 @@
{
"schema": 1,
"generated_at": 1736217605,
"winner": {
"id": "warn-wind-002",
"type": "WEATHER_WARNING",
"title": "High Wind",
"subtitle": "Gusts up to 35 mph",
"priority": 0.78,
"ttl_sec": 1800
},
"debug": {
"reason": "wind_advisory",
"source": "noaa"
}
}

View File

@@ -0,0 +1,25 @@
//
// DataTrimming.swift
// iris
//
// Created by Codex.
//
import Foundation
extension Data {
func trimmedTrailingWhitespace() -> Data {
guard !isEmpty else { return self }
var endIndex = count
while endIndex > 0 {
let b = self[endIndex - 1]
if b == 0x0A || b == 0x0D || b == 0x20 || b == 0x09 {
endIndex -= 1
} else {
break
}
}
return prefix(endIndex)
}
}

View File

@@ -0,0 +1,71 @@
//
// CandidatesViewModel.swift
// iris
//
// Created by Codex.
//
import CoreLocation
import Foundation
import os
@MainActor
final class CandidatesViewModel: ObservableObject {
@Published private(set) var candidates: [Candidate] = []
@Published private(set) var lastUpdatedAt: Date? = nil
@Published private(set) var isLoading = false
@Published private(set) var lastError: String? = nil
@Published private(set) var diagnostics: [String: String] = [:]
var demoLatitude: Double = 51.5074
var demoLongitude: Double = -0.1278
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "CandidatesViewModel")
func refresh() {
guard !isLoading else { return }
isLoading = true
lastError = nil
diagnostics = [:]
let location = CLLocation(latitude: demoLatitude, longitude: demoLongitude)
let now = Int(Date().timeIntervalSince1970)
logger.info("Refresh start lat=\(self.demoLatitude, format: .fixed(precision: 4)) lon=\(self.demoLongitude, format: .fixed(precision: 4)) now=\(now)")
Task {
defer {
Task { @MainActor in
self.isLoading = false
self.lastUpdatedAt = Date()
}
}
if #available(iOS 16.0, *) {
let ds = WeatherDataSource()
let result = await ds.candidatesWithDiagnostics(for: location, now: now)
await MainActor.run {
self.candidates = result.candidates.sorted { $0.confidence > $1.confidence }
self.diagnostics = result.diagnostics
if let error = result.weatherKitError {
self.lastError = "WeatherKit error: \(error)"
}
}
if let error = result.weatherKitError {
self.logger.error("WeatherKit error: \(error)")
}
self.logger.info("Produced candidates count=\(result.candidates.count)")
for c in result.candidates {
self.logger.info("Candidate id=\(c.id, privacy: .public) type=\(c.type.rawValue, privacy: .public) conf=\(c.confidence, format: .fixed(precision: 2)) ttl=\(c.ttlSec) title=\(c.title, privacy: .public)")
}
if result.candidates.isEmpty {
self.logger.info("Diagnostics: \(String(describing: result.diagnostics), privacy: .public)")
}
} else {
await MainActor.run {
self.candidates = []
self.lastError = "WeatherKit requires iOS 16+."
}
}
}
}
}

View File

@@ -0,0 +1,124 @@
//
// BleStatusView.swift
// iris
//
// Created by Codex.
//
import SwiftUI
struct BleStatusView: View {
@EnvironmentObject private var ble: BlePeripheralManager
@EnvironmentObject private var orchestrator: ContextOrchestrator
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text("GlassNow BLE")
.font(.title2.bold())
Text("Bluetooth: \(bluetoothStateText)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Toggle(isOn: $ble.advertisingEnabled) {
VStack(alignment: .leading, spacing: 2) {
Text("Advertising")
.font(.headline)
Text(ble.isAdvertising ? "On" : "Off")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.onChange(of: ble.advertisingEnabled) { _ in
ble.start()
}
VStack(alignment: .leading, spacing: 8) {
Text("Connection")
.font(.headline)
Text("Subscribed: \(ble.isSubscribed ? "Yes" : "No")")
.font(.subheadline)
Text("Subscribers: \(ble.subscribedCount)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
Text("Telemetry")
.font(.headline)
Text("Last msgId: \(ble.lastMsgIdSent)")
.font(.subheadline)
Text("Last ping: \(ble.lastPingAt.map { timeOnly(from: $0) } ?? "Never")")
.font(.subheadline)
Text("Last data: \(ble.lastDataAt.map { timeOnly(from: $0) } ?? "Never")")
.font(.subheadline)
Text("Notify queue: \(ble.notifyQueueDepth)")
.font(.subheadline)
.foregroundStyle(.secondary)
if ble.droppedNotifyPackets > 0 {
Text("Dropped notify packets: \(ble.droppedNotifyPackets)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Text("Last notify: \(ble.lastNotifyAt.map { timeOnly(from: $0) } ?? "Never")")
.font(.subheadline)
.foregroundStyle(.secondary)
if let cmd = ble.lastCommand, !cmd.isEmpty {
Text("Last control: \(cmd)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
VStack(alignment: .leading, spacing: 12) {
Button("Send Fixture Feed Now") {
orchestrator.sendFixtureFeedNow()
}
.buttonStyle(.borderedProminent)
Button("Copy UUIDs") {
ble.copyUUIDsToPasteboard()
}
.buttonStyle(.bordered)
}
VStack(alignment: .leading, spacing: 8) {
Text("UUIDs")
.font(.headline)
Text("Service: \(BlePeripheralManager.serviceUUID.uuidString)\nFEED_TX: \(BlePeripheralManager.feedTxUUID.uuidString)\nCONTROL_RX: \(BlePeripheralManager.controlRxUUID.uuidString)")
.font(.caption)
.textSelection(.enabled)
}
}
.padding(.vertical, 16)
.padding(.horizontal, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear { ble.start() }
}
private var bluetoothStateText: String {
switch ble.bluetoothState {
case .unknown: return "Unknown"
case .resetting: return "Resetting"
case .unsupported: return "Unsupported"
case .unauthorized: return "Unauthorized"
case .poweredOff: return "Powered Off"
case .poweredOn: return "Powered On"
@unknown default: return "Other"
}
}
private func timeOnly(from date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .medium
return formatter.string(from: date)
}
}
struct BleStatusView_Previews: PreviewProvider {
static var previews: some View {
Text("Preview unavailable (requires EnvironmentObjects).")
}
}

View File

@@ -0,0 +1,120 @@
//
// CandidatesView.swift
// iris
//
// Created by Codex.
//
import SwiftUI
struct CandidatesView: View {
@StateObject private var model = CandidatesViewModel()
var body: some View {
NavigationStack {
List {
Section("Source") {
LabeledContent("Location") {
Text("London (demo)")
}
if let updated = model.lastUpdatedAt {
LabeledContent("Last update") { Text(timeOnly(from: updated)) }
} else {
LabeledContent("Last update") { Text("Never") }
}
if let error = model.lastError {
Text(error)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
if !model.diagnostics.isEmpty {
Section("Diagnostics") {
ForEach(model.diagnostics.keys.sorted(), id: \.self) { key in
LabeledContent(key) {
Text(model.diagnostics[key] ?? "")
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
}
Section("Candidates (\(model.candidates.count))") {
if model.candidates.isEmpty {
Text(model.isLoading ? "Loading…" : "No candidates")
.foregroundStyle(.secondary)
} else {
ForEach(model.candidates, id: \.id) { candidate in
CandidateRow(candidate: candidate)
}
}
}
}
.navigationTitle("Candidates")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(model.isLoading ? "Refreshing…" : "Refresh") {
model.refresh()
}
.disabled(model.isLoading)
}
}
.onAppear { model.refresh() }
}
}
private func timeOnly(from date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .medium
return formatter.string(from: date)
}
}
private struct CandidateRow: View {
let candidate: Candidate
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
Text(candidate.title)
.font(.headline)
.lineLimit(1)
Spacer()
Text(candidate.type.rawValue)
.font(.caption)
.foregroundStyle(.secondary)
}
Text(candidate.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: 12) {
Text(String(format: "conf %.2f", candidate.confidence))
Text("ttl \(candidate.ttlSec)s")
Text(expiresText(now: Int(Date().timeIntervalSince1970)))
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
private func expiresText(now: Int) -> String {
let expiresAt = candidate.createdAt + candidate.ttlSec
let remaining = expiresAt - now
if remaining <= 0 { return "expired" }
if remaining < 60 { return "in \(remaining)s" }
return "in \(remaining / 60)m"
}
}
struct CandidatesView_Previews: PreviewProvider {
static var previews: some View {
CandidatesView()
}
}

View File

@@ -0,0 +1,176 @@
//
// OrchestratorView.swift
// iris
//
// Created by Codex.
//
import CoreLocation
import Foundation
import MusicKit
import SwiftUI
@available(iOS 16.0, *)
struct OrchestratorView: View {
@EnvironmentObject private var orchestrator: ContextOrchestrator
var body: some View {
NavigationStack {
List {
Section("Location") {
LabeledContent("Auth") { Text(authText(orchestrator.authorization)) }
if let loc = orchestrator.lastLocation {
LabeledContent("Lat/Lon") {
Text("\(format(loc.coordinate.latitude, 5)), \(format(loc.coordinate.longitude, 5))")
.textSelection(.enabled)
}
LabeledContent("Accuracy") { Text("\(Int(loc.horizontalAccuracy)) m") }
LabeledContent("Speed") { Text(speedText(loc.speed)) }
} else {
Text("No location yet")
.foregroundStyle(.secondary)
}
}
Section("Recompute") {
LabeledContent("Last reason") { Text(orchestrator.lastRecomputeReason ?? "") }
LabeledContent("Last time") { Text(orchestrator.lastRecomputeAt.map(timeOnly) ?? "") }
LabeledContent("Elapsed") { Text(orchestrator.lastPipelineElapsedMs.map { "\($0) ms" } ?? "") }
LabeledContent("Fetch failed") { Text(orchestrator.lastFetchFailed ? "Yes" : "No") }
if let err = orchestrator.lastError {
Text(err)
.font(.footnote)
.foregroundStyle(.secondary)
}
Button("Recompute Now") { orchestrator.recomputeNow() }
}
Section("Winner") {
if let env = orchestrator.lastWinner {
Text(env.winner.title)
.font(.headline)
Text(env.winner.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
Text("type \(env.winner.type.rawValue) • prio \(String(format: "%.2f", env.winner.priority)) • ttl \(env.winner.ttlSec)s")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("No winner yet")
.foregroundStyle(.secondary)
}
}
Section("Now Playing") {
LabeledContent("Music auth") { Text(musicAuthText(orchestrator.musicAuthorization)) }
if let snapshot = orchestrator.nowPlaying {
Text(snapshot.title)
.font(.headline)
.lineLimit(1)
Text(nowPlayingSubtitle(snapshot))
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
Text(String(describing: snapshot.playbackStatus))
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(orchestrator.musicAuthorization == .authorized ? "Nothing playing" : "Not authorized")
.foregroundStyle(.secondary)
}
}
Section("Candidates (\(orchestrator.lastCandidates.count))") {
if orchestrator.lastCandidates.isEmpty {
Text("No candidates")
.foregroundStyle(.secondary)
} else {
ForEach(orchestrator.lastCandidates, id: \.id) { c in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(c.title)
.font(.headline)
.lineLimit(1)
Spacer()
Text(c.type.rawValue)
.font(.caption)
.foregroundStyle(.secondary)
}
Text(c.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
Text("conf \(String(format: "%.2f", c.confidence)) • ttl \(c.ttlSec)s")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
}
if !orchestrator.lastWeatherDiagnostics.isEmpty {
Section("Weather Diagnostics") {
ForEach(orchestrator.lastWeatherDiagnostics.keys.sorted(), id: \.self) { key in
LabeledContent(key) {
Text(orchestrator.lastWeatherDiagnostics[key] ?? "")
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
}
Section("Test") {
Button("Send Fixture Feed Now") { orchestrator.sendFixtureFeedNow() }
}
}
.navigationTitle("Orchestrator")
}
}
private func authText(_ s: CLAuthorizationStatus) -> String {
switch s {
case .notDetermined: return "Not Determined"
case .restricted: return "Restricted"
case .denied: return "Denied"
case .authorizedAlways: return "Always"
case .authorizedWhenInUse: return "When In Use"
@unknown default: return "Other"
}
}
private func timeOnly(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .medium
return formatter.string(from: date)
}
private func speedText(_ speed: CLLocationSpeed) -> String {
guard speed >= 0 else { return "" }
return "\(String(format: "%.1f", speed)) m/s"
}
private func musicAuthText(_ status: MusicAuthorization.Status) -> String {
switch status {
case .notDetermined: return "Not Determined"
case .denied: return "Denied"
case .restricted: return "Restricted"
case .authorized: return "Authorized"
@unknown default: return "Other"
}
}
private func nowPlayingSubtitle(_ snapshot: NowPlayingSnapshot) -> String {
let parts = [snapshot.artist, snapshot.album]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
return parts.isEmpty ? "Apple Music" : parts.joined(separator: "")
}
private func format(_ value: Double, _ precision: Int) -> String {
String(format: "%.\(precision)f", value)
}
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.weatherkit</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,28 @@
//
// irisApp.swift
// iris
//
// Created by Kenneth on 06/01/2026.
//
import SwiftUI
@main
struct irisApp: App {
@StateObject private var ble: BlePeripheralManager
@StateObject private var orchestrator: ContextOrchestrator
init() {
let bleManager = BlePeripheralManager()
_ble = StateObject(wrappedValue: bleManager)
_orchestrator = StateObject(wrappedValue: ContextOrchestrator(ble: bleManager))
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(ble)
.environmentObject(orchestrator)
}
}
}