Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions appleApp/FTW/Watch/WatchBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,18 @@ final class WatchBridge: NSObject {
/// readings followed.
private var look: Task<Void, Never>?
private var lookTimer: Task<Void, Never>?
/// The watch says it still looks each time it comes to the front. Past
/// this without word, the session is let go.
/// The time iOS gives this app in the background for a look, taken anew
/// with each word from the watch.
private var lookTime: BackgroundTime?
/// The watch says it still looks each time it comes to the front, and
/// every 20 s while it stays there. Past this without word, the session
/// is let go.
private static let lookMs: Double = 120_000
/// When Now last went to the watch.
private var forwardedAtMs: Double = 0
/// A look asked again after this long without Now gets the last three
/// minutes again: the watch missed them.
private static let missedMs: Double = 10_000

private var nowMs: Double { Date.now.timeIntervalSince1970 * 1000 }

Expand Down Expand Up @@ -86,7 +95,9 @@ final class WatchBridge: NSObject {
Self.log.info("no watch to hand the context to: paired \(session.isPaired, privacy: .public), FTW on it \(session.isWatchAppInstalled, privacy: .public)")
return
}
let ctx = WatchShare.context(app.home, isDemo: app.isDemo, receipt: lastReceipt, nowMs: nowMs)
// No home and no paired site is a sign-out; no home with a site is
// a phone still opening it, which the watch leaves be.
let ctx = WatchShare.context(app.home, isDemo: app.isDemo, signedOut: app.home == nil && app.sites.current() == nil, receipt: lastReceipt, nowMs: nowMs)
if let lastSent, lastSent.sameAs(ctx) { return }
do {
let data = try ctx.packed()
Expand Down Expand Up @@ -129,15 +140,24 @@ final class WatchBridge: NSObject {

private func startLooking() {
send()
// Each word from the watch takes the time in the background anew
// before the last runs out, so a steady look with the phone locked
// never gaps.
let time = BackgroundTime("FTW watch looks") { [weak self] in self?.stopLooking() }
lookTime?.end()
lookTime = time
// Asked again while it looks: keep the session, and only the clock
// that lets it go starts over.
// that lets it go starts over. A watch that went without Now
// meanwhile gets the last three minutes again.
if look != nil, nowMs - forwardedAtMs > Self.missedMs, let home = app?.home {
send(news: .recent(WatchShare.recent(home, nowMs: nowMs)))
}
if look == nil, let runner {
look = Task { [weak self] in
let time = BackgroundTime("FTW watch looks") { self?.stopLooking() }
defer { time.end() }
_ = await runner.hold { home in
await self?.forwardNow(home)
}
self?.lookEnded()
}
}
lookTimer?.cancel()
Expand All @@ -155,6 +175,14 @@ final class WatchBridge: NSObject {
lookTimer = nil
}

/// The look's session is let go and suspended: the time in the
/// background goes too, unless a new look has begun.
private func lookEnded() {
guard look == nil else { return }
lookTime?.end()
lookTime = nil
}

/// Now's readings to the watch as each frame lands, until the watch
/// looks away, stops being reachable, or says nothing for `lookMs`.
private func forwardNow(_ home: HomeModels) async {
Expand All @@ -167,14 +195,19 @@ final class WatchBridge: NSObject {
done()
}
var lastAt: Double?
// The reply to the look: the last three minutes first, so the trace
// opens whole, then Now as it stands.
send(news: .recent(WatchShare.recent(home, nowMs: nowMs)))
send(news: WatchShare.now(home).map(WatchNews.now))
forwardedAtMs = nowMs
// Ends when the look is let go; a watch out of reach meanwhile gets
// nothing, and its reachability ends the look.
for await _ in frames.stream {
guard session?.isReachable == true else { continue }
guard let record = WatchShare.now(home), record.readAtMs != lastAt else { continue }
lastAt = record.readAtMs
send(news: .now(record))
forwardedAtMs = nowMs
}
Self.log.info("watch looked away: Now no longer sent")
}
Expand Down
7 changes: 5 additions & 2 deletions appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -266,9 +266,12 @@ public final class AppModel {
}

/// The app went to the background: close the session and keep what is
/// on screen. The demo has no socket to free and keeps running.
/// on screen. The demo has no socket to free and keeps running. While an
/// Apple Watch looks through this phone nothing closes; the look's end
/// calls this again.
public func suspend() {
guard let site else { return }
// A watch looking keeps the session; the end of its look suspends.
guard let site, !site.watched else { return }
if isDemo {
site.setVisible(false)
} else {
Expand Down
34 changes: 31 additions & 3 deletions appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ public final class SiteModel {
/// every H.
@ObservationIgnored public private(set) var readingGapMs: Double = LiveTrace.gapMs
public private(set) var documentVisible = true
/// The app itself is on screen; `documentVisible` also counts a watch.
@ObservationIgnored private var appVisible = true
/// Apple Watches looking at Now through this phone.
@ObservationIgnored private var watchers = 0
/// The clock screens read for ages. It moves with every reading, and
/// between readings only when something on screen would change: the
/// minute, a stream going quiet, a grade lapsing, or a counter the band
Expand Down Expand Up @@ -285,11 +289,35 @@ public final class SiteModel {
persistNow(wait: true)
}

/// Match the stream's cadence to whether anyone can see it.
/// Match the stream's cadence to whether anyone can see it: this app on
/// screen, or an Apple Watch looking through it.
public func setVisible(_ visible: Bool) {
guard visible else {
appVisible = visible
if !visible { persistNow() }
follow()
}

/// An Apple Watch looks at Now through this phone. While one does, the
/// stream runs as it does with the app on screen, whatever the phone's
/// own state: 1 Hz from the box, looking on FTW Cloud, and reads as for
/// a screen. Pair each call with `endWatching`.
public func beginWatching() {
watchers += 1
follow()
}

public func endWatching() {
watchers = max(0, watchers - 1)
follow()
}

/// A watch looks: the session stays open while the app is in the
/// background, until the look ends.
public var watched: Bool { watchers > 0 }

private func follow() {
guard appVisible || watchers > 0 else {
hide()
persistNow()
return
}
let was = documentVisible
Expand Down
55 changes: 38 additions & 17 deletions appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ import Observation
public enum WatchShare {
// MARK: What the watch shows

public static func context(_ home: HomeModels?, isDemo: Bool, receipt: WatchReceipt? = nil, nowMs: Double) -> WatchContext {
guard let home else { return .empty(sentAtMs: nowMs) }
/// `signedOut` says why there is no home: the phone signed out or was
/// never paired, so the watch forgets; else the phone is still opening
/// its home and the watch keeps what it has.
public static func context(_ home: HomeModels?, isDemo: Bool, signedOut: Bool = false, receipt: WatchReceipt? = nil, nowMs: Double) -> WatchContext {
guard let home else { return signedOut ? .signOut(sentAtMs: nowMs) : .opening(sentAtMs: nowMs) }
let key = homeKey(home)
return WatchContext(
home: key,
Expand All @@ -31,19 +34,45 @@ public enum WatchShare {
}

/// The models the watch's pages show, read as if a screen showing them
/// had appeared: the prices, the chargers, the plan and the mode. Call
/// the returned closure when the watch looks away.
/// had appeared: the prices, the chargers, the plan with the forecast
/// that gives the dial its sun, and the mode. The stream runs as for a
/// screen meanwhile, at 1 Hz, even with the phone locked in a pocket, so
/// the wrist sees what the app would. Call the returned closure when the
/// watch looks away.
public static func looking(_ home: HomeModels) -> @MainActor () -> Void {
let (prices, chargers, plan, mode) = (home.prices, home.loadpoints, home.nodes.plan, home.nodes.mode)
let (site, prices, chargers, plan, mode) = (home.site, home.prices, home.loadpoints, home.nodes.plan, home.nodes.mode)
site.beginWatching()
prices.activate()
chargers.activate()
plan.activate()
plan.activate(forecast: true)
mode.activate()
return {
prices.deactivate()
chargers.deactivate()
plan.deactivate()
plan.deactivate(forecast: true)
mode.deactivate()
site.endWatching()
}
}

/// The last three minutes of each node as this phone recorded them, for
/// the reply to `.look`: the watch's trace opens whole, in ink.
public static func recent(_ home: HomeModels, nowMs: Double) -> WatchRecent {
let since = nowMs - WatchRecent.windowMs
var samples = [WatchNow.Node: [LiveSample]]()
for node in EnergyNode.allCases {
samples[watchNode(node)] = home.nodes.live.samples(node, sinceMs: since)
}
return WatchRecent.make(samples, nowMs: nowMs)
}

static func watchNode(_ node: EnergyNode) -> WatchNow.Node {
switch node {
case .solar: return .solar
case .battery: return .battery
case .home: return .home
case .grid: return .grid
case .car: return .car
}
}

Expand Down Expand Up @@ -103,7 +132,7 @@ public enum WatchShare {
case .warning: control = .notInControl
case .neutral, .stale: control = .unknown
}
return CacheRecord(siteId: key ?? homeKey(home), carrier: site.carrier, srcState: site.isLive ? .live : .stale, readAtMs: at, value: WatchNow(readings: readings, control: control))
return CacheRecord(siteId: key ?? homeKey(home), carrier: site.carrier, srcState: site.isLive ? .live : .stale, readAtMs: at, value: WatchNow(readings: readings, control: control, standsMs: site.readingGapMs))
}

static func reading(_ r: NodeReading) -> WatchNow.Reading {
Expand All @@ -116,15 +145,7 @@ public enum WatchShare {
case .grid: way = !r.flowing ? .balanced : (r.towardHome ? .buying : .selling)
case .car: way = r.flowing ? .charging : .resting
}
let node: WatchNow.Node
switch r.node {
case .solar: node = .solar
case .battery: node = .battery
case .home: node = .home
case .grid: node = .grid
case .car: node = .car
}
return WatchNow.Reading(node: node, watts: r.watts, way: way, levelPct: r.levelPct, levelAssumed: r.levelAssumed, live: r.freshness.live)
return WatchNow.Reading(node: watchNode(r.node), watts: r.watts, way: way, levelPct: r.levelPct, levelAssumed: r.levelAssumed, live: r.freshness.live)
}

static func modes(_ home: HomeModels) -> WatchModes? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
"SENT" = "SKICKAT";
"ACCEPTED" = "MOTTAGET";
"CONFIRMED" = "BEKRÄFTAT";
"SENDING…" = "SKICKAR…";
"▲ NOT CONFIRMED" = "▲ EJ BEKRÄFTAT";
"▲ NOT SENT" = "▲ INTE SKICKAT";
"Sending to your iPhone…" = "Skickar till din iPhone …";
"Your iPhone is sending it to your box…" = "Din iPhone skickar det till boxen …";
"Your iPhone can't be reached. Nothing was sent." = "Din iPhone nås inte. Inget skickades.";
Expand Down
Loading
Loading