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
14 changes: 10 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,16 @@ native, and smooth enough to impress.
quote the numbers in any PR that touches launch.
- **The handshake runs beside the first frame, never ahead of it.**
- **No timer loops.** Lane 0 drives Now. Until the box streams charger
state (M5, or FTW Cloud C3), the chargers are read when a screen that
shows them appears, after a command, and when the stream says the car
started or stopped drawing. The plan's shortfall is read when the car
view opens and when the car's plan changes.
state (M5, or FTW Cloud C3), the chargers are read when someone starts
looking and the last read is over a minute old, after a command, and
when the stream says the car started or stopped drawing. The plan's
shortfall is read when the car view opens and when the car's plan
changes.
- **Ask by the data's age.** `LiveAsk` names what it wants by how old the
data is (the five minutes, the quarter, the day) and keeps an answer
while that name holds. A view appearing or a redial asks only when the
answer is older than its time to live. Reads wait for the box's one API
slot in `ReadOrder`: chargers, prefs, plan, status, the rest.
- **A frame redraws what moved.** Small views that read only what they
show, value inputs, and `Equatable` with `.equatable()` where a frame
would otherwise redraw a whole section. A clock on screen ticks by the
Expand Down
14 changes: 10 additions & 4 deletions appleApp/FTW/FTWApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,19 @@ struct FTWApp: App {
}
.onChange(of: scenePhase, initial: true) { _, phase in
// Inactive is a glance at Control Center or the app
// switcher: slow the stream, keep the session. Only the
// background closes it.
app.setVisible(phase == .active)
// switcher, or the way back from the background: the
// stream keeps 1 Hz, and a return dials here, not at
// active. Only the background closes the session.
PricesInBackground.inBackground = phase == .background
if phase == .background {
switch phase {
case .background:
app.setVisible(false)
app.suspend()
Task { await PricesInBackground.schedule(app) }
case .inactive:
app.glance()
default:
app.setVisible(true)
}
}
}
Expand Down
9 changes: 5 additions & 4 deletions appleApp/FTW/UI/NodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,15 +224,16 @@ final class NodeTraceModel {
/// What the chart and the hero read: held still while a finger is down.
var shown: TraceFrame? { held ?? frame }

func build(_ points: [LiveTrace.Point], node: EnergyNode, carrier: CarrierKind, size: CGSize) {
/// `gapMs` is the silence the source in use allows before the line
/// breaks.
func build(_ samples: [LiveSample], node: EnergyNode, gapMs: Double, size: CGSize) {
guard size.width > 0, size.height > 0 else { return }
let resized = size != self.size
self.size = size
let samples = points.map { LiveSample(t: $0.t, v: node.shown($0.v)) }
let now = Date()
let series = LiveSeries.build(
samples,
sourceGapMs: NodeLive.sourceGapMs(carrier),
sourceGapMs: gapMs,
windowMs: LiveTrace.windowMs,
nowMs: now.timeIntervalSince1970 * 1000,
size: size,
Expand Down Expand Up @@ -684,7 +685,7 @@ private struct LiveChartArea: View {
let began = ContinuousClock.now
defer { TraceCost.log.debug("trace built in \((ContinuousClock.now - began) / .microseconds(1), format: .fixed(precision: 0)) µs") }
#endif
trace.build(live.points(node.fid, sinceMs: Date().timeIntervalSince1970 * 1000 - LiveTrace.windowMs - 10_000), node: node, carrier: site.carrier, size: plot)
trace.build(live.samples(node, sinceMs: Date().timeIntervalSince1970 * 1000 - LiveTrace.windowMs - 10_000), node: node, gapMs: site.readingGapMs, size: plot)
}

}
Expand Down
5 changes: 5 additions & 0 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ public final class SimulatedBox {
public private(set) var receivedCommands: [String] = []
/// Every API request, as "METHOD /path", so a test can count reads.
public private(set) var receivedRequests: [String] = []
/// Every request frame in the order it came: `hist.query`, `plan.get`,
/// `price.get`, and each API request as "METHOD /path".
public private(set) var asks: [String] = []
public private(set) var stepUpsSeen = 0

public nonisolated static let modes: [ModeInfo] = [
Expand Down Expand Up @@ -101,6 +104,7 @@ public final class SimulatedBox {
guard let frame = try? Frame.decode(bytes) else { return }
let env = frame.envelope
let body = env.b ?? CBOR.emptyMap
if ["price.get", "hist.query", "plan.get"].contains(env.t) { asks.append(env.t) }
switch env.t {
case "hello": onHello(body)
case "sub":
Expand Down Expand Up @@ -428,6 +432,7 @@ public final class SimulatedBox {
for e in body["query"]?.entries ?? [] { if let k = e.key.string, let v = e.value.string { query[k] = v } }
let requestBody = body["body"]?.byteString.flatMap { try? JSON(parsing: $0) }
receivedRequests.append("\(method) \(path)")
asks.append("\(method) \(path)")

guard path.hasPrefix("/api/"), let route = Self.match(method, path) else {
refuse(id, "E_UNKNOWN_OP", [])
Expand Down
76 changes: 60 additions & 16 deletions appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import Foundation
import FTWShared
import os

/// Every request frame the session puts on the wire, so a log can count
/// what a return from the background costs. Read with
/// `log show --predicate 'subsystem == "energy.ftw.app" AND category == "asks"'`.
private let askLog = Logger(subsystem: "energy.ftw.app", category: "asks")

/// The session: handshake, subscription and the field register.
///
Expand Down Expand Up @@ -90,6 +96,18 @@ public struct CommandError: Error, Equatable, HelpfulError {
public let help: String
}

/// Which read goes first when several wait for the box's one API slot: what
/// Now's car needs, then the mode, then the plan, then the device status,
/// then the rest. Anything a person asked for goes before all of them.
/// `LiveAsk` sets it for the reads it makes.
public enum ReadOrder: Int, Sendable, Comparable {
case person, loadpoints, prefs, plan, status, later

@TaskLocal public static var current: ReadOrder = .person

public static func < (a: ReadOrder, b: ReadOrder) -> Bool { a.rawValue < b.rawValue }
}

@MainActor
public final class Session {
public static let apiTimeoutMs: Double = 20_000
Expand All @@ -113,6 +131,10 @@ public final class Session {
didSet { onChange?(state) }
}

/// Request frames sent since the session was made: history, the plan,
/// prices and API reads. Commands are not counted; a person asked.
public private(set) var requestsSent = 0

/// One listener: the site model. Called after every change.
public var onChange: (@MainActor (SessionState) -> Void)?

Expand Down Expand Up @@ -160,11 +182,16 @@ public final class Session {
private var pendingCommands: [String: PendingCommand] = [:]

// The api queue: one call on the wire at a time, because that is how
// many the box serves.
// many the box serves. Waiting calls go in `ReadOrder`, then in turn.
private var apiBusy = false
private var apiWaiters: [(id: Int, continuation: CheckedContinuation<Void, Error>, timer: Cancellable)] = []
private var apiWaiters: [(id: Int, order: ReadOrder, continuation: CheckedContinuation<Void, Error>, timer: Cancellable)] = []
private var apiWaiterSeq = 0
private var apiGeneration = 0
private var apiGrant: Cancellable?
/// An idle slot waits one display frame before a read gets it, so reads
/// asked for in the same moment go in their order, not in the order
/// their tasks happened to start. What a person asked for never waits.
static let apiGatherMs: Double = 16

public init(build: String, ua: String = "native", locales: [String] = ["en"], subscription: Subscription = Subscription(), scheduler: Scheduler) {
self.build = build
Expand Down Expand Up @@ -289,6 +316,7 @@ public final class Session {
p.continuation.resume(throwing: SessionError.timedOut("prices"))
}
pendingPrices[id] = PendingSingle(continuation: cont, timer: timer)
sent("price.get")
carrier?.send(frame)
}
}
Expand All @@ -302,6 +330,7 @@ public final class Session {
return try await withCheckedThrowingContinuation { cont in
pendingHistory[id] = PendingHistory(onChunk: onChunk, continuation: cont, timer: nil)
armHistory(id)
sent("hist.query")
carrier?.send(frame)
}
}
Expand All @@ -317,6 +346,7 @@ public final class Session {
p.continuation.resume(throwing: SessionError.timedOut("plan"))
}
pendingPlan[id] = PendingSingle(continuation: cont, timer: timer)
sent("plan.get")
carrier?.send(frame)
}
}
Expand All @@ -328,7 +358,7 @@ public final class Session {
/// success, and a call never crosses a disconnect into a new session.
public func api(_ req: APIRequest) async throws -> APIResponse {
let generation = apiGeneration
try await acquireAPISlot()
try await acquireAPISlot(ReadOrder.current)
defer { releaseAPISlot() }
for attempt in 0... {
if generation != apiGeneration { throw SessionError.carrierClosed }
Expand All @@ -353,11 +383,7 @@ public final class Session {
}
}

private func acquireAPISlot() async throws {
if !apiBusy {
apiBusy = true
return
}
private func acquireAPISlot(_ order: ReadOrder) async throws {
apiWaiterSeq += 1
let mine = apiWaiterSeq
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
Expand All @@ -366,18 +392,30 @@ public final class Session {
self.apiWaiters.remove(at: i)
cont.resume(throwing: SessionError.queueTimedOut)
}
apiWaiters.append((mine, cont, timer))
apiWaiters.append((mine, order, cont, timer))
if order == .person {
grantAPISlot()
} else if !apiBusy, apiGrant == nil {
apiGrant = scheduler.after(Self.apiGatherMs) { [weak self] in
self?.apiGrant = nil
self?.grantAPISlot()
}
}
}
}

private func releaseAPISlot() {
if apiWaiters.isEmpty {
apiBusy = false
return
}
let next = apiWaiters.removeFirst()
next.timer.cancel()
next.continuation.resume()
apiBusy = false
grantAPISlot()
}

/// The slot to the first waiting call in order.
private func grantAPISlot() {
guard !apiBusy, let next = apiWaiters.indices.min(by: { (apiWaiters[$0].order, apiWaiters[$0].id) < (apiWaiters[$1].order, apiWaiters[$1].id) }) else { return }
let waiter = apiWaiters.remove(at: next)
waiter.timer.cancel()
apiBusy = true
waiter.continuation.resume()
}

private func dispatchAPI(_ req: APIRequest) async throws -> APIResponse {
Expand All @@ -388,6 +426,7 @@ public final class Session {
return try await withCheckedThrowingContinuation { cont in
pendingAPI[id] = PendingAPI(continuation: cont)
armAPI(id)
sent("api.req \(req.method.rawValue) \(req.path)")
carrier.send(frame)
}
}
Expand Down Expand Up @@ -456,6 +495,11 @@ public final class Session {
return "\(String(c[0..<8]))-\(String(c[8..<12]))-\(String(c[12..<16]))-\(String(c[16..<20]))-\(String(c[20...]))"
}

private func sent(_ what: String) {
requestsSent += 1
askLog.notice("ask \(what, privacy: .public)")
}

// MARK: Frames in

private func onCarrierStatus(_ status: CarrierStatus, kind: CarrierKind) {
Expand Down
10 changes: 10 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,16 @@ public final class AppModel {
}
}

/// Inactive: a glance at Control Center or the app switcher, or the app
/// on its way back to the front. The stream keeps its rate, a return
/// from the background dials at once, so the handshake runs beside the
/// app switch, and what is on screen is written in case the app is
/// closed from the switcher.
public func glance() {
setVisible(true)
site?.persistNow()
Comment on lines +238 to +240

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cancel the inactive reconnect when backgrounding

When a suspended app enters the switcher, FTWApp.swift routes .inactive through glance(), which clears suspended and launches the asynchronous connect() task. If the user returns to .background before that task reaches site.connect, suspend() closes the currently absent carrier but does not invalidate the pending connection; connect() checks only its generation and site identity, so it can subsequently attach a new carrier while the app is backgrounded, consuming a relay stream despite the background-close policy. Cancel or invalidate this reconnect in suspend(), or recheck background/visibility immediately before attaching the carrier.

Useful? React with 👍 / 👎.

}

/// 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.
public func suspend() {
Expand Down
Loading
Loading