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
5 changes: 5 additions & 0 deletions appleApp/FTW/Car/CarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ struct CarView: View {
List {
if let lp {
StatusSection(lp: lp, model: model, site: home.site)
// Drawn from what the last launch kept; inert until the box
// has spoken in this session.
LevelSection(lp: lp, model: model, canControl: home.site.canConfigure)
.disabled(!home.site.heardFromBox)
GoalSection(lp: lp, model: model, site: home.site, canControl: home.site.canConfigure, editing: $editingGoal)
.disabled(!home.site.heardFromBox)
if app.isDemo { DemoSection(lp: lp, model: model) }
} else if model.loaded {
Section { Text(L("This box has no charger.")) }
Expand All @@ -39,6 +43,7 @@ struct CarView: View {
.safeAreaBar(edge: .bottom) {
if let lp, lp.pluggedIn, home.site.canConfigure {
ControlBar(lp: lp, model: model)
.disabled(!home.site.heardFromBox)
}
}
.scrollEdgeEffectStyle(.hard, for: .bottom)
Expand Down
3 changes: 3 additions & 0 deletions appleApp/FTW/FTWApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ struct FTWApp: App {
#if DEBUG
// For screenshots: `-FTWDemo YES` opens the simulated home, and
// `-FTWDemoWidgets YES` lets it leave its records for the widgets.
// `-FTWDemoCache YES` lets it keep its sealed cache like a paired
// home, to record and time a cold start.
model.demoSharesWidgets = UserDefaults.standard.bool(forKey: "FTWDemoWidgets")
model.demoKeepsCache = UserDefaults.standard.bool(forKey: "FTWDemoCache")
if UserDefaults.standard.bool(forKey: "FTWDemo") { model.startDemo() }
// For end-to-end tests: `-FTWPairLink <url>` offers a pairing link
// as a scanned QR would, where the simulator has no camera.
Expand Down
9 changes: 8 additions & 1 deletion appleApp/FTW/UI/NodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,13 @@ private struct PeriodSection: View {
return NodeSummary.energy(node, e, today: period == .today)
case .ahead:
let now = site.nowMs
// "No plan" only once the box has answered without one; until
// then the line keeps its place.
switch node {
case .battery, .grid where models.plan.plan == nil && !models.plan.loaded:
return models.plan.problem ?? " "
case .solar, .home where models.plan.forecastStamp == nil && !models.plan.forecastLoaded:
return " "
case .battery: return PlanText.battery(models.plan.ahead(nowMs: now), stale: models.plan.plan?.stale ?? false, nowMs: now, zone: zone)
case .grid: return PlanText.grid(models.plan.ahead(nowMs: now), stale: models.plan.plan?.stale ?? false)
case .solar: return PlanText.solar(models.plan.forecastAhead(nowMs: now))
Expand Down Expand Up @@ -591,10 +597,11 @@ private struct ModeSection: View {
}

@ViewBuilder private var change: some View {
if mode.canControl {
if mode.mayControl {
Button(action: choose) {
Text(L("Change")).lineLimit(1).fixedSize()
}
.disabled(!mode.canControl)
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.small)
Expand Down
29 changes: 13 additions & 16 deletions appleApp/FTW/UI/NowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ private struct NowBody: View {
Card { Text(L("This app is older than your box. Some things are hidden until it updates.")) }
}
StatusSection(site: site, mode: home.nodes.mode, choose: chooseMode)
FlowSection(site: site, loadpoints: home.loadpoints, zoom: zoom, fill: fill, canCommand: site.canConfigure)
// Controls wait for the box: a role kept from an earlier launch
// draws the home, never a command.
FlowSection(site: site, loadpoints: home.loadpoints, zoom: zoom, fill: fill, canCommand: site.canConfigure && site.heardFromBox)
PlanSection(site: site, models: home.nodes, prices: home.prices, zoom: zoom)
AboutLink(app: app)
.padding(.top, -18)
Expand Down Expand Up @@ -155,7 +157,8 @@ private struct StatusSection: View {
sources: site.session.sources
),
mode: mode.label.map { ModeLabel(text: $0, sending: mode.sending, doubt: mode.doubt) },
canChoose: mode.canControl,
canChoose: mode.mayControl,
canPress: mode.canControl,
choose: choose
)
.equatable()
Expand All @@ -177,11 +180,14 @@ struct ModeLabel: Equatable {
struct StatusRow: View, Equatable {
let answer: InControl.Answer
let mode: ModeLabel?
/// This phone may change the mode: the choice is drawn.
let canChoose: Bool
/// The box has spoken, so the choice opens. Until then it stays inert.
var canPress = true
let choose: () -> Void

nonisolated static func == (a: StatusRow, b: StatusRow) -> Bool {
a.answer == b.answer && a.mode == b.mode && a.canChoose == b.canChoose
a.answer == b.answer && a.mode == b.mode && a.canChoose == b.canChoose && a.canPress == b.canPress
}

var body: some View {
Expand Down Expand Up @@ -282,7 +288,7 @@ struct StatusRow: View, Equatable {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!canChoose)
.disabled(!canChoose || !canPress)
.accessibilityLabel(L("Mode"))
.accessibilityValue(mode.text)
.accessibilityHint(canChoose ? L("Change mode") : "")
Expand Down Expand Up @@ -320,16 +326,8 @@ private struct FlowSection: View {

var body: some View {
// The stream carries the car's watts; a box that leaves them out
// gets the charger's last answer.
let evW = loadpoints.stale ? 0 : Flow.loadpointChargeW(loadpoints.points)
let fields = Flow.withLoadpointEV(site.session.fields, evW: evW)
let car = loadpoints.points.first.flatMap { loadpoints.level($0) }
let readings = NowFlow.readings(
fields: fields,
freshness: { site.freshness(fid: $0) },
carLevel: car.map { ($0.pct, $0.origin == .assumed || $0.origin == .unconfirmed) },
hasCar: !loadpoints.points.isEmpty
)
// gets the charger's last answer, with its age.
let readings = NowFlow.readings(site: site, loadpoints: loadpoints)
// The drawing has fixed places and stops growing at xLarge. At the
// accessibility sizes each node gets a row of its own instead, so
// its figure can grow with the text.
Expand Down Expand Up @@ -432,12 +430,11 @@ private struct PlanSection: View {
// The plan marks the minute, not the second: it redraws once a
// minute however often the clock ticks.
let minute = (site.nowMs / 60_000).rounded(.down) * 60_000
let entry = models.planShare.entry
let window = prices.prices
let current = window.flatMap { w in w.slots.first { minute >= $0.startMs && minute < $0.startMs + $0.durationMs } }
NavigationLink(value: HomeRoute.plan) {
PlanCard(
moment: PlanGlance.live(entry, nowMs: minute),
moment: models.planShare.moment(nowMs: minute),
priceNow: current.map { PriceStrip.text($0.totalMinor, window?.currency) },
unit: PriceUnits.unit(window?.currency).perKwh
)
Expand Down
16 changes: 15 additions & 1 deletion appleApp/FTW/UI/PlanCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ struct PlanCard: View, Equatable {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Kicker(PlanGlance.title)
// A plan kept from an earlier launch says when the box made
// it, until the box answers.
if let age = moment.ageText {
Text(age)
.font(.caption2)
.foregroundStyle(Theme.fgMuted)
.lineLimit(1)
.transition(.opacity)
}
Spacer(minLength: 8)
if let priceNow {
Text("\(Text(L("Price now")).font(.caption2).foregroundStyle(Theme.fgMuted)) \(Text(AppWords.decimal(priceNow)).font(Theme.number(13, weight: .bold)).foregroundStyle(Theme.fg)) \(Text(unit).font(.caption2).foregroundStyle(Theme.fgMuted))")
Expand All @@ -27,12 +36,16 @@ struct PlanCard: View, Equatable {
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.fgMuted)
}
// Two lines kept whatever the sentence, so the card holds its
// height when the plan lands. Before the box's first answer
// the line is a placeholder, not a claim about the plan.
Text(moment.summary)
.font(.subheadline.weight(.semibold))
.foregroundStyle(moment.isGrey ? Theme.fgDim : Theme.fg)
.lineLimit(2)
.lineLimit(2, reservesSpace: true)
.minimumScaleFactor(0.85)
.fixedSize(horizontal: false, vertical: true)
.redacted(reason: moment.placeholder ? .placeholder : [])
.contentTransition(.opacity)
PlanLanes(moment: moment, compact: true)
.frame(height: 50)
Expand All @@ -44,6 +57,7 @@ struct PlanCard: View, Equatable {
.overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1))
.contentShape(RoundedRectangle(cornerRadius: Theme.radius))
.animation(Theme.spring, value: moment.summary)
.animation(Theme.spring, value: moment.ageText)
.accessibilityElement(children: .ignore)
.accessibilityLabel(PlanGlance.title)
.accessibilityValue([moment.summary, priceNow.map { "\(L("Price now")) \($0) \(unit)" }].compactMap { $0 }.joined(separator: ". "))
Expand Down
13 changes: 7 additions & 6 deletions appleApp/FTW/UI/PlanView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,13 @@ private struct PlanBody: View {
var body: some View {
// The plan marks the minute: it redraws once a minute, not a second.
let minute = (site.nowMs / 60_000).rounded(.down) * 60_000
let moment = PlanGlance.live(models.planShare.entry, nowMs: minute)
let moment = models.planShare.moment(nowMs: minute)
VStack(alignment: .leading, spacing: 4) {
Text(moment.summary)
.font(.headline)
.foregroundStyle(Theme.fg)
.foregroundStyle(moment.isGrey ? Theme.fgDim : Theme.fg)
.fixedSize(horizontal: false, vertical: true)
.redacted(reason: moment.placeholder ? .placeholder : [])
Text(stateLine(moment))
.font(.footnote)
.foregroundStyle(moment.state == .boxStale ? Theme.generation : Theme.fgDim)
Expand All @@ -73,8 +74,8 @@ private struct PlanBody: View {
/// When the box made it, or that it could not.
private func stateLine(_ m: PlanGlance.Moment) -> String {
if m.state == .boxStale { return L("Your box couldn't plan ahead just now, so it runs on safe defaults.") }
guard let at = models.plan.receivedAtMs else { return L("Reading the plan from your box…") }
return NodeWords.format("Planned by your box at %@. It plans again every quarter of an hour.", AppWords.clock(at, zone: .current))
guard let at = models.plan.receivedAtMs ?? m.readAtMs else { return L("Reading the plan from your box…") }
return NodeWords.format("Planned by your box at %@. It plans again every quarter of an hour.", AppWords.clock(at, zone: m.timeZone))
}
}

Expand Down Expand Up @@ -245,8 +246,8 @@ private struct CompactMode: View {
Spacer(minLength: 8)
Text(label)
.font(.subheadline.weight(.semibold))
.foregroundStyle(mode.canControl ? Theme.accent : Theme.fg)
if mode.canControl {
.foregroundStyle(mode.mayControl ? Theme.accent : Theme.fg)
if mode.mayControl {
Image(systemName: "chevron.up.chevron.down")
.font(.caption2.weight(.bold))
.foregroundStyle(Theme.accent)
Expand Down
3 changes: 3 additions & 0 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ public final class SimulatedBox {
/// estimate meanwhile and the data grows into it. 0 sends all at once.
public var holdTileMs: Double = 0
private var heldTile = false
/// The plan as last made, with the quarter and revision it was made
/// for. Core plans each quarter and sends the same plan until the next.
var madePlan: (quarter: Double, rev: UInt64, plan: Plan)?
/// The share of today the box has prices for, as
/// `priced_coverage_pct`. Below 0.95 the grid widget marks its cost
/// partial.
Expand Down
8 changes: 7 additions & 1 deletion appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -206,15 +206,21 @@ extension SimulatedBox {
}

/// The plan as `plan.get` answers it.
/// The plan as Core sends it: made once a quarter, or when the mode
/// or the preferences change, and the same plan until then.
func sessionPlan() -> Plan {
let quarter = (scheduler.nowMs / 900_000).rounded(.down)
if let made = madePlan, made.quarter == quarter, made.rev == house.planRev { return made.plan }
let steps = house.plan(nowMs: scheduler.nowMs)
return Plan(
let plan = Plan(
rev: house.planRev,
uptimeMs: uptimeMs,
slots: steps.map { PlanSlot(startMs: $0.startMs.rounded(), durationMs: $0.durationMs.rounded(), batteryW: $0.batteryW, gridW: $0.gridW, priceMinor: $0.priceMinor, reason: $0.reason) },
stale: false,
ceilingW: nil
)
madePlan = (quarter, house.planRev, plan)
return plan
}

/// The planner's own document's actions, as GET /api/mpc/plan carries
Expand Down
10 changes: 5 additions & 5 deletions appleApp/FTWKit/Sources/FTWKit/Format/EV.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import FTWShared
/// sentences that describe it. No sentence claims what the box has not
/// said: a charger that does not know the car's charge is described by what
/// it does know, never by an invented percentage.
public struct Loadpoint: Equatable, Sendable, Identifiable {
public struct Schedule: Equatable, Sendable {
public struct Loadpoint: Equatable, Sendable, Identifiable, Codable {
public struct Schedule: Equatable, Sendable, Codable {
public var socPct: Double
/// The ready time as the box sends it: the UTC minute of day of that
/// time on today's date.
Expand All @@ -31,7 +31,7 @@ public struct Loadpoint: Equatable, Sendable, Identifiable {
}
}

public struct Manual: Equatable, Sendable {
public struct Manual: Equatable, Sendable, Codable {
public var state: String?
public var requestedA: Double?
public var requestedW: Double?
Expand All @@ -41,14 +41,14 @@ public struct Loadpoint: Equatable, Sendable, Identifiable {
public var chargerUpdatedAtMs: Double?
}

public struct Charger: Equatable, Sendable {
public struct Charger: Equatable, Sendable, Codable {
public var known: Bool
public var available: Bool?
public var updatedAtMs: Double?
public var reason: String?
}

public struct Window: Equatable, Sendable, Identifiable {
public struct Window: Equatable, Sendable, Identifiable, Codable {
public var fromMs: Double
public var toMs: Double
public var peakW: Double?
Expand Down
4 changes: 2 additions & 2 deletions appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ public struct HistEnd: Sendable, Equatable {

// MARK: - The plan

public struct PlanSlot: Equatable, Sendable, Identifiable {
public struct PlanSlot: Equatable, Sendable, Identifiable, Codable {
/// Wall clock: these are future times a person reads.
public var startMs: Double
public var durationMs: Double
Expand Down Expand Up @@ -151,7 +151,7 @@ public struct PlanSlot: Equatable, Sendable, Identifiable {

/// What the box means to do, slot by slot. Intent, not prophecy: the box
/// replans as the day turns out.
public struct Plan: Equatable, Sendable {
public struct Plan: Equatable, Sendable, Codable {
public var rev: UInt64
public var uptimeMs: Double
public var slots: [PlanSlot]
Expand Down
4 changes: 2 additions & 2 deletions appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public struct FieldDef: Equatable, Sendable, Codable {
}
}

public struct ModeInfo: Equatable, Sendable, Identifiable {
public struct ModeInfo: Equatable, Sendable, Identifiable, Codable {
public var key: String
public var label: String
public var tooltip: String
Expand All @@ -80,7 +80,7 @@ public struct BootProgress: Equatable, Sendable {
public var etaMs: Double?
}

public struct BoxInfo: Equatable, Sendable {
public struct BoxInfo: Equatable, Sendable, Codable {
public var id: String
public var build: String
public var tz: String
Expand Down
41 changes: 23 additions & 18 deletions appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,28 +176,33 @@ public final class Session {

// MARK: Lifecycle

/// Seed state from the cache before any carrier exists. The readings
/// were true when captured and the band says how long ago; carrier stays
/// `cache`, which is a carrier and not a failure.
/// Seed state from the cache before any carrier exists, in one change.
/// The readings were true when captured and the band says how long ago;
/// carrier stays `cache`, which is a carrier and not a failure.
///
/// `hello` is what the box said at hello on an earlier launch: its caps,
/// its modes, the box and this phone's role, so the mode label and the
/// household's clock are there in the first frame. `heardFromBox` stays
/// false, so none of it unlocks a control, and hello replaces it all.
///
/// The cache read races the connect. Landing mid-handshake, only the
/// data paints: the phase, carrier and clock belong to the connection.
public func restore(_ snapshot: CachedSnapshot) {
switch state.phase {
case .streaming:
return
case .handshaking, .subscribing, .booting:
if !state.fields.isEmpty { return }
var s = state
snapshot.apply(to: &s, includeClock: false)
state = s
default:
var s = state
snapshot.apply(to: &s, includeClock: true)
s.phase = .idle
s.carrier = .cache
state = s
public func restore(_ snapshot: CachedSnapshot?, hello: HomeCache.Hello? = nil) {
var s = state
if let hello, !s.heardFromBox { hello.apply(to: &s) }
if let snapshot {
switch s.phase {
case .streaming:
break
case .handshaking, .subscribing, .booting:
if s.fields.isEmpty { snapshot.apply(to: &s, includeClock: false) }
default:
snapshot.apply(to: &s, includeClock: true)
s.phase = .idle
s.carrier = .cache
}
}
state = s
}

/// Attach a carrier and start the handshake. Replaces any current one.
Expand Down
Loading
Loading