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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,10 @@ strip, with gold for the battery giving power.
- **The app expresses intent; the box decides.**
- **Positive watts flow into the site, negative out.** The UI never shows a
raw minus sign.
- **Lane 0 frames are byte-identical in length and constant in cadence.**
A test enforces this.
- **Lane 0 frames are byte-identical in length.** A test enforces this.
Over the relay they also keep a constant cadence. Through FTW Cloud the
box sends each reading as it lands, and a beat once per subscribed
period when nothing moves, so nothing may assume one frame a second.
- **The cache is a cache, never the original.**
- **The cache key is not PRF-wrapped.** Cold start paints before Face ID.
PRF gates enrollment and privileged commands, not reading.
Expand Down
2 changes: 1 addition & 1 deletion appleApp/FTW/UI/NodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ final class NodeTraceModel {
let resized = size.height != self.size.height
self.size = size
let series = built
frame = TraceFrame(series: series, previous: resized ? nil : frame?.series, arrivedAt: now)
frame = resized ? TraceFrame(series: series, previous: nil, arrivedAt: now) : TraceFrame(series: series, after: frame, arrivedAt: now)
if playing, let last = series.last, series.domain.span > 0 {
AXLiveAudioGraph.updateValue((last.v - series.domain.lower) / series.domain.span)
}
Expand Down
14 changes: 8 additions & 6 deletions appleApp/FTW/Watch/WatchBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,9 @@ final class WatchBridge: NSObject {
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`.
/// Now's readings to the watch as what it shows moves, and once a second
/// otherwise, until the watch looks away, stops being reachable, or says
/// nothing for `lookMs`.
private func forwardNow(_ home: HomeModels) async {
Self.log.info("watch looks: sending Now")
let frames = FrameWatch(site: home.site)
Expand All @@ -194,18 +195,19 @@ final class WatchBridge: NSObject {
frames.stop()
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))
var last = WatchShare.now(home)
send(news: last.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
// At most a message a second unless what the wrist shows moved.
guard let record = WatchShare.now(home), WatchShare.worthSending(record, after: last) else { continue }
last = record
send(news: .now(record))
forwardedAtMs = nowMs
}
Expand Down
12 changes: 12 additions & 0 deletions appleApp/FTWCharts/LiveTrace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ struct TraceFrame: Equatable {
var rest = Path()
}

/// The frame after `old`. Its slide starts where `old` draws its head
/// now, so a reading that lands while the last one still slides carries
/// on from there rather than jumping to where that slide was headed.
init(series: LiveSeries, after old: TraceFrame?, arrivedAt: Date) {
var from = old?.series
if let old, let before = old.previous {
let progress = TraceInk.ease(arrivedAt.timeIntervalSince(old.arrivedAt), TraceInk.slide)
if progress < 1 { from = old.series.midSlide(from: before, progress: progress) }
}
self.init(series: series, previous: from, arrivedAt: arrivedAt)
}

init(series: LiveSeries, previous: LiveSeries?, arrivedAt: Date) {
self.series = series
self.previous = previous
Expand Down
4 changes: 3 additions & 1 deletion appleApp/FTWKit/Sources/FTWKit/Carrier/TunnelCarrier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import FTWShared
/// Cloud hands it to the box under the device its own handshake proved. `d`
/// is the box session byte for byte, Noise to the box's key, so FTW Cloud
/// can drop or delay a frame and can neither read nor forge one. Lane 0
/// keeps its padding and cadence inside it.
/// keeps its padding inside it. The box sends each reading as it lands
/// rather than on a one-second pulse, and a beat when nothing moves for
/// the subscribed period.
///
/// One socket instead of two: the box session's message 1 waits for the app
/// session's message 1 and rides inside it, so the box session is up one
Expand Down
5 changes: 4 additions & 1 deletion appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import Foundation
public final class NoiseTransport {
public static let seqBytes = 8
public static let overhead = seqBytes + Noise.tagBytes
/// At 1 Hz on lane 0, 64 frames is a minute of tolerance and one word.
/// One word. Over the relay lane 0 goes once a second, so 64 frames is
/// a minute; through FTW Cloud up to five a second, about 13 s, while
/// the box's link holds a paced bulk frame at most about 9 s (srcfl/ftw
/// `cloudlink.MaxBulkWait`).
public static let replayWindow: UInt64 = 64

public let handshakeHash: Bytes
Expand Down
21 changes: 15 additions & 6 deletions appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,16 @@ public final class LiveTrace {

/// What a node's live curve shows.
public nonisolated static let windowMs: Double = 5 * 60_000
/// Ten minutes at 1 Hz. Hidden, at 0.2 Hz, the same room lasts longer,
/// and the window still shows only the last five minutes.
nonisolated static let capacity = 600
/// Five minutes at the box's fastest: through FTW Cloud it sends each
/// reading as it lands, at most five frames a second. A slower stream
/// fills the same room more slowly, and the window still shows only the
/// last five minutes.
nonisolated static let capacity = 1_500
/// A value the ring already holds, read again within this, adds no
/// point: a frame that carries another device's reading is not a new
/// reading of this one. A slow device still repeats once a second, as
/// it did when the box streamed at 1 Hz.
nonisolated static let repeatMs: Double = 1_000
/// Past this a silence in a 1 Hz stream is a gap: it misses a beat now
/// and then, and three are a stream that stopped. Each source says its
/// own (`SiteModel.readingGapMs`); this is the box's stream in front.
Expand All @@ -105,8 +112,8 @@ public final class LiveTrace {
public var toMs: Double?
}

/// Moves once per recorded reading, so a curve redraws once a second
/// and nothing else does.
/// Moves once per recorded reading, so a curve redraws when a value
/// moves, or once a second while none does, and nothing else does.
public private(set) var revision = 0
/// Nil when nothing was restored at launch.
public private(set) var resumed: Resumed?
Expand Down Expand Up @@ -157,6 +164,7 @@ public final class LiveTrace {
var ring = rings[fid] ?? RingBuffer(capacity: Self.capacity)
let last = ring.last
if let last, atMs <= last.t { continue }
if let last, !last.restored, last.v == v, atMs - last.t < Self.repeatMs { continue }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Track suppressed frames before deciding gaps

When an unchanged field arrives at 5 Hz, this early return leaves its recorded timestamp up to 800 ms behind the most recent frame. If the carrier then pauses for 2.6–3.5 seconds—still within gapMs and potentially still considered live—the next point can appear more than 3.5 seconds after last.t, causing record and LiveSeries to create a false break in the trace. Keep a per-field last-observed timestamp (and current hold) even when suppressing the duplicate, and use that timestamp for gap detection.

Useful? React with 👍 / 👎.

let run: Int
if let last, !last.restored, atMs - last.t <= holds[fid, default: gapMs] {
run = last.run
Expand Down Expand Up @@ -256,7 +264,8 @@ public final class LiveTrace {

/// The live rings as `ring-<site>` keeps them: per field, times as steps in
/// whole ms after the first, the values, and where each run starts. About
/// 15 KB for five minutes of Now's fields.
/// 15 KB for five minutes of Now's fields read once a second, and up to five
/// times that when a fast driver moves them five times a second.
struct RingFile: Codable, Equatable, Sendable {
static let version = 1

Expand Down
11 changes: 6 additions & 5 deletions appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ private let askLog = Logger(subsystem: "energy.ftw.app", category: "asks")
public final class SiteModel {
/// Fields the Now screen draws. Their sources drive the freshness band.
static let nowFids = [Contract.FID.gridW, Contract.FID.pvW, Contract.FID.batteryW, Contract.FID.batterySoc, Contract.FID.loadW, Contract.FID.evW]
/// A 1 Hz stream is always a moment behind and a dropped tick is normal
/// on a phone; past a couple of beats the silence is real.
/// The box beats at least once a second, and through FTW Cloud sends
/// each reading between beats as it lands. A dropped frame is normal on
/// a phone; past a couple of beats the silence is real.
static let streamQuietAfterMs: Double = 3_000
/// How long a foreground stream gets to prove its socket still works.
public static let foregroundFrameDeadlineMs: Double = 2_500
Expand All @@ -51,9 +52,9 @@ public final class SiteModel {
/// FTW Cloud: the send time rule U gives. The live trace stamps by it.
@ObservationIgnored public private(set) var readingStampMs: Double?
/// How long the newest reading stands before a silence is a gap. The
/// box streams at 1 Hz in front and 0.2 Hz hidden, and misses a beat
/// now and then; FTW Cloud sends on change and a heartbeat at least
/// every H.
/// box beats at least once a second in front and every 5 s hidden, and
/// misses a beat now and then; FTW Cloud's copy sends on change and a
/// heartbeat at least 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.
Expand Down
15 changes: 15 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,21 @@ public enum WatchShare {
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))
}

/// Whether Now is worth a message to the watch after `last`: what the
/// wrist shows moved (a figure, a word, a level, the glow or the
/// answer to "in control"), or a second has passed. Through FTW Cloud
/// frames come as readings land, up to five a second; the watch draws
/// about a point a second, and its radio is the cost.
public static func worthSending(_ next: CacheRecord<WatchNow>, after last: CacheRecord<WatchNow>?) -> Bool {
guard let last else { return true }
guard next.readAtMs > last.readAtMs else { return false }
if next.carrier != last.carrier || next.srcState != last.srcState || next.value.control != last.value.control
|| WatchGlance.figures(next.value, lit: true) != WatchGlance.figures(last.value, lit: true) {
return true
}
return next.readAtMs - last.readAtMs >= LiveSample.repeatMs
}

static func reading(_ r: NodeReading) -> WatchNow.Reading {
let way: WatchNow.Way
switch r.node {
Expand Down
25 changes: 25 additions & 0 deletions appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ public struct LiveSample: Equatable, Sendable {
self.v = v
self.seen = seen
}

/// Through FTW Cloud frames come as readings land, up to five a second,
/// and most carry another device's news. A value a trace holds, read
/// again within this, adds no point; a device that did not move still
/// repeats once a second, as at 1 Hz. The phone's ring keeps the same
/// rule (FTWKit `LiveTrace.repeatMs`).
public static let repeatMs: Double = 1_000

/// Whether `next` adds a point after `last`: it is newer, and its value
/// or sight moved, or a second has passed.
public static func adds(_ next: LiveSample, after last: LiveSample?) -> Bool {
guard let last else { return true }
guard next.t > last.t else { return false }
return next.v != last.v || next.seen != last.seen || next.t - last.t >= repeatMs
}
}

/// The live trace's geometry, built once per reading off the main actor.
Expand Down Expand Up @@ -419,6 +434,16 @@ extension LiveSeries {
return out
}

/// This series as its slide from `old` draws it at `progress`: the last
/// run where the slide has it now. A reading that lands mid-slide slides
/// on from here, instead of jumping to where the last slide was headed.
public func midSlide(from old: LiveSeries, progress: Double) -> LiveSeries {
guard let curve = sliding(from: old, progress: progress), !curves.isEmpty else { return self }
var s = self
s.curves[s.curves.count - 1] = curve
return s
}

/// A curve as line and body paths in this geometry, for one frame of a
/// slide: the same split by sight as a built run.
public func paths(_ curve: [Point]) -> (line: ChartPath, lineUnseen: ChartPath, body: ChartPath, bodyUnseen: ChartPath) {
Expand Down
114 changes: 114 additions & 0 deletions appleApp/FTWKit/Tests/FTWKitTests/LiveLatencyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import Foundation
import Security
import Testing
@testable import FTWKit

/// How long a reading takes from the box's driver to this session's decode,
/// against a real `ftwcloudd` and the box srcfl/ftw runs as
/// `TestLiveBoxForTheiPhone` (go/internal/appuplink), whose grid reading is
/// the wall-clock millisecond it arrived. Off unless the environment names
/// them:
///
/// FTW_LIVE_LATENCY_DIR=/path the box's directory: pair.url,
/// noise.pub and ca.pem; decode.log and
/// open.log are written here
/// FTW_LIVE_CLOUD_URL=wss://127.0.0.1:18843/v1/app
/// FTW_LIVE_LATENCY_SECONDS=120 per phase, visible and hidden
///
/// Each decoded grid reading is logged with the wall clock in microseconds,
/// so a script pairs it with the box's own log of arrivals.
@MainActor
@Suite(.enabled(if: ProcessInfo.processInfo.environment["FTW_LIVE_LATENCY_DIR"] != nil), .serialized)
struct LiveLatencyTests {
let env = ProcessInfo.processInfo.environment
var dir: URL { URL(fileURLWithPath: env["FTW_LIVE_LATENCY_DIR"]!) }

func read(_ name: String) throws -> String {
try String(contentsOf: dir.appendingPathComponent(name), encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)
}

/// The grid readings this session decoded, and when.
@MainActor final class Decodes {
var phase = "pair"
var last: Double?
var lines: [String] = []

func watch(_ core: Session) {
let inner = core.onChange
core.onChange = { [self] s in
if s.carrier == .tunnel, let v = s.fields[2], v != last {
last = v
lines.append("\(Int64(Date().timeIntervalSince1970 * 1_000_000)) \(Int64(v)) \(phase)")
}
inner?(s)
}
}
}

@Test func readingsFromTheDriverToTheDecode() async throws {
let pem = try read("ca.pem")
let der = try #require(Data(base64Encoded: pem.split(separator: "\n").filter { !$0.hasPrefix("-----") }.joined()))
let anchor = try #require(SecCertificateCreateWithData(nil, der as CFData))
let endpoint = CloudEndpoint(url: URL(string: env["FTW_LIVE_CLOUD_URL"] ?? "wss://127.0.0.1:18843/v1/app")!,
serviceKeys: [try Base64url.decode(read("noise.pub"))])
let relayURL = URL(string: "wss://127.0.0.1:9")!
let sockets: WebSocketFactory = { url, events in
url.port == relayURL.port
? URLSessionWebSocket.factory()(url, events)
: PinnedWebSocket(url: url, events: events, anchor: anchor)
}
let store = MemoryStore()
let files = SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-live-latency-\(UUID().uuidString)"), store: store)
func model() -> AppModel {
AppModel(store: store, files: files, passkeys: FakePasskeys(), scheduler: LiveScheduler(),
build: "live-latency-test", ua: "test", relayURL: relayURL, cloudEndpoint: endpoint, makeSocket: sockets)
}
let app = model()
await PairModel(app: app).pair(try read("pair.url"))
let site = try #require(app.site)
_ = try await until { site.session.phase == .streaming && site.carrier == .tunnel }

let seconds = UInt64(env["FTW_LIVE_LATENCY_SECONDS"] ?? "120") ?? 120
let decodes = Decodes()
decodes.watch(site.core)
decodes.phase = "visible"
try await Task.sleep(nanoseconds: seconds * 1_000_000_000)
decodes.phase = "hidden"
site.setVisible(false)
try await Task.sleep(nanoseconds: seconds * 1_000_000_000)
try decodes.lines.joined(separator: "\n").write(to: dir.appendingPathComponent("decode.log"), atomically: true, encoding: .utf8)
print("latency: \(decodes.lines.count) grid readings decoded")

// Cold opens: a new app over the same store, from launch to the
// first reading the box sent through FTW Cloud.
app.suspend()
var opens: [String] = []
for i in 0..<10 {
try await Task.sleep(nanoseconds: 3_000_000_000)
let cold = model()
let t0 = Date()
cold.launch()
let s = try #require(cold.site)
let firstLive = Decodes()
firstLive.phase = "open"
firstLive.watch(s.core)
let streaming = try await until { s.session.phase == .streaming && s.carrier == .tunnel }
let live = try await until { !firstLive.lines.isEmpty }
opens.append("\(i) \(Int((streaming) * 1000)) \(Int((Date().timeIntervalSince(t0)) * 1000)) \(Int(live * 1000))")
cold.suspend()
}
try opens.joined(separator: "\n").write(to: dir.appendingPathComponent("open.log"), atomically: true, encoding: .utf8)
print("latency: cold open (i, to streaming ms, to first new reading ms):\n\(opens.joined(separator: "\n"))")
try? "done".write(to: dir.appendingPathComponent("stop"), atomically: true, encoding: .utf8)
}

/// Wait for `ok`, polling every 1 ms, and return how long it took.
func until(_ seconds: Double = 20, _ ok: () -> Bool) async throws -> Double {
let start = Date()
while !ok() {
if Date().timeIntervalSince(start) > seconds { throw CancellationError() }
try await Task.sleep(nanoseconds: 1_000_000)
}
return Date().timeIntervalSince(start)
}
}
25 changes: 23 additions & 2 deletions appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,34 @@ import FTWShared
}

@MainActor
@Test func theRingHoldsTenMinutesAndNoMore() {
@Test func theRingHoldsFiveMinutesAtTheFastestAndNoMore() {
let trace = LiveTrace()
for i in 0..<(LiveTrace.capacity + 50) { trace.record([Contract.FID.gridW: Double(i)], atMs: Double(i) * 1000) }
// Five frames a second, the most the box sends through FTW Cloud.
for i in 0..<(LiveTrace.capacity + 50) { trace.record([Contract.FID.gridW: Double(i)], atMs: Double(i) * 200) }
let points = trace.points(Contract.FID.gridW, sinceMs: 0)
#expect(points.count == LiveTrace.capacity)
#expect(points.first?.v == 50)
#expect(points.last?.v == Double(LiveTrace.capacity + 49))
#expect(points.last!.t - points.first!.t >= LiveTrace.windowMs - 200)
}

/// Frames come as readings land, so most carry another device's news.
/// A device whose value did not move repeats once a second, as it did
/// at 1 Hz, and its curve keeps one run.
@MainActor
@Test func aFrameThatCarriesAnotherDevicesReadingAddsNoPoint() {
let trace = LiveTrace()
let t0 = 1_000_000.0
for i in 0..<50 {
trace.record([Contract.FID.gridW: Double(1000 + i), Contract.FID.pvW: -2000], atMs: t0 + Double(i) * 200)
}
#expect(trace.points(Contract.FID.gridW, sinceMs: 0).count == 50)
let pv = trace.points(Contract.FID.pvW, sinceMs: 0)
#expect(pv.count == 10)
#expect(Set(LiveTrace.runs(pv).map(\.run)).count == 1)
// A move goes in at once, whatever the last point's age.
trace.record([Contract.FID.pvW: -2100], atMs: t0 + 50 * 200)
#expect(trace.points(Contract.FID.pvW, sinceMs: 0).last?.v == -2100)
}

@MainActor
Expand Down
Loading
Loading