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
53 changes: 53 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ public struct CloudView: Sendable {
/// The newest of each class this phone shows.
public private(set) var live: CloudWire.Live?
public private(set) var lp: CloudWire.LP?
/// Each car's level and goal. A charger's `carId` joins it; where the
/// two disagree, this wins.
public private(set) var cars: CloudWire.Cars?
/// When the box sent the newest lp and car, by the phone's clock, when
/// a rule anchors it.
private var sentAt: [String: Int64] = [:]
/// Kept for the screens and left out of `grade`, as in Go: a price
/// window ages by its own slots, not by the box's sources.
public private(set) var prices: CloudWire.Prices?
Expand Down Expand Up @@ -115,6 +121,45 @@ public struct CloudView: Sendable {
m.map { ($0.uptimeMs, $0.ages) }
}

/// A charger's level and goal as its session's car has them, when the
/// car class holds that car; the charger as sent otherwise. The box
/// fills a charger's level fields from its car, so the two agree; where
/// they do not, as when one class arrived after the other, the car
/// wins (srcfl/ftw `docs/protocol-cloud.md`, lp). `socAgeS` is then the
/// car's, counted to the car class's send; `socReadAtMs` dates it.
public func joined(_ l: CloudWire.Loadpoint) -> CloudWire.Loadpoint {
guard let car = cars?.car(l.carId) else { return l }
var out = l
out.socPermille = car.socPermille
out.socAgeS = nil
switch true {
case car.socPermille == nil: out.socSource = "unknown"
case car.socSource == "assumed": out.socSource = "assumed"
case car.socEstimate || car.socSource != "vehicle": out.socSource = "inferred"
default:
out.socSource = "vehicle"
out.socAgeS = car.socAgeS
}
out.targetPermille = car.goal.flatMap { $0.targetPermille > 0 ? $0.targetPermille : nil }
out.recurring = (car.goal?.days ?? 0) != 0
if car.goal == nil { out.readyByMs = nil }
out.surplusOnly = car.surplusOnly
return out
}

/// When a charger's level was read, by the phone's clock: from its
/// car's class and that class's own send time when the class holds the
/// car, from the charger's message and its send otherwise. Nil without
/// an age, or when no rule anchors that send.
public func socReadAtMs(_ l: CloudWire.Loadpoint) -> Int64? {
if let car = cars?.car(l.carId) {
guard let age = car.socAgeS, let sent = sentAt[CloudWire.msgCar] else { return nil }
return sent &- age &* 1000
}
guard let age = l.socAgeS, let sent = sentAt[CloudWire.msgLP] else { return nil }
return sent &- age &* 1000
}

/// The boot the view holds, nil before the first message.
public var boot: Bytes? { currentBoot.isEmpty ? nil : currentBoot }

Expand Down Expand Up @@ -195,6 +240,8 @@ public struct CloudView: Sendable {
}
case .lp(let p):
lp = p
case .cars(let c):
cars = c
case .prices(let p):
prices = p
case .hb(let b):
Expand All @@ -207,6 +254,7 @@ public struct CloudView: Sendable {
break
}
seq[envelope.t] = h.seq
if envelope.t == CloudWire.msgLP || envelope.t == CloudWire.msgCar { sentAt[envelope.t] = send }
if let send, send > newest { newest = send }
}

Expand Down Expand Up @@ -319,6 +367,7 @@ public struct CloudView: Sendable {
private enum Message {
case live(CloudWire.Live)
case lp(CloudWire.LP)
case cars(CloudWire.Cars)
case prices(CloudWire.Prices)
case hb(CloudWire.HB)
/// Classes the view only orders by seq.
Expand All @@ -328,6 +377,7 @@ public struct CloudView: Sendable {
switch t {
case CloudWire.msgLive: self = .live(try CloudWire.Live(body: b))
case CloudWire.msgLP: self = .lp(try CloudWire.LP(body: b))
case CloudWire.msgCar: self = .cars(try CloudWire.Cars(body: b))
case CloudWire.msgPrices: self = .prices(try CloudWire.Prices(body: b))
case CloudWire.msgHB: self = .hb(try CloudWire.HB(body: b))
default: self = .other(try CloudWire.Header(body: b))
Expand All @@ -338,6 +388,7 @@ public struct CloudView: Sendable {
switch self {
case .live(let l): return l.header
case .lp(let p): return p.header
case .cars(let c): return c.header
case .prices(let p): return p.header
case .hb(let b): return b.header
case .other(let h): return h
Expand All @@ -360,6 +411,8 @@ public struct CloudView: Sendable {
fieldSources = [:]
live = nil
lp = nil
cars = nil
sentAt = [:]
prices = nil
hb = nil
m = nil
Expand Down
99 changes: 95 additions & 4 deletions appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ public enum CloudWire {
public static let msgEvent = "event"
public static let msgTiles = "tiles"
public static let msgHB = "hb"
/// Each car's level and goal, by opaque id (srcfl/ftw `docs/cars.md`).
public static let msgCar = "car"

/// Everything else the box sends (hello, roster, bye) is the service's
/// alone and never reaches a phone.
public static let phoneClasses: Set<String> = [msgLive, msgLP, msgPrices, msgPlan, msgHealth, msgEvent, msgTiles, msgHB]
/// Everything else the box sends (hello, roster, bye, notice) is the
/// service's alone and never reaches a phone as a class.
public static let phoneClasses: Set<String> = [msgLive, msgLP, msgCar, msgPrices, msgPlan, msgHealth, msgEvent, msgTiles, msgHB]

// The app session's own types. All unsigned.
/// Handshake message 1, `{hz}`.
Expand Down Expand Up @@ -251,6 +253,12 @@ public enum CloudWire {
opaqueID(grantKey, "lp\0", loadpoint)
}

/// A car's opaque id, cut the same way with "car\0" from the id the
/// owner's app minted, so it differs per home.
static func carID(grantKey: Bytes, car: String) -> String {
opaqueID(grantKey, "car\0", car)
}

private static func opaqueID(_ key: Bytes, _ tag: String, _ name: String) -> String {
Base64url.encode(Array(Primitives.hmacSHA256(key: key, Bytes(tag.utf8) + Bytes(name.utf8)).prefix(8)))
}
Expand Down Expand Up @@ -453,6 +461,57 @@ public enum CloudWire {
public var nextStartMs: Int64?
public var nextEndMs: Int64?
public var nextWh: Int64?
/// Opaque id of the session's car, the join to `Cars`. Empty while
/// nothing is plugged in or the box does not know the car. With it,
/// the level and goal fields above are views of that car, and where
/// the two disagree the car class wins.
public var carId: String = ""
/// charger, car, only, tap, asking or none (`docs/cars.md`).
public var carIdentifiedBy: String = ""
public var carCorrected: Bool = false
public var carCorrectedFrom: String = ""
/// While asking: the cars a tap may pick, at most four.
public var choose: [String] = []
}

/// Each car's level and goal on this box. Nothing here names a car; the
/// names come from the box over Noise.
public struct Cars: Equatable, Sendable {
public var header: Header
public var cars: [Car]

/// The car with this opaque id, as a charger's `carId` names it.
public func car(_ id: String) -> Car? {
id.isEmpty ? nil : cars.first { $0.id == id }
}
}

public struct Car: Equatable, Sendable {
/// Opaque car id.
public var id: String
/// The car's level; the car's or a person's own number while
/// `socEstimate` is false. Nil when the box has none.
public var socPermille: Int64?
/// vehicle, manual or assumed; empty without a level.
public var socSource: String
/// Seconds since the car reported the level or someone set it, at
/// send.
public var socAgeS: Int64?
/// Energy into the car since then; negative after V2X.
public var energySinceWh: Int64
public var socEstimate: Bool
public var chargeLimitPermille: Int64?
public var goal: CarGoal?
public var surplusOnly: Bool
}

public struct CarGoal: Equatable, Sendable {
public var targetPermille: Int64
/// Minutes after midnight in the box's time zone.
public var readyByMin: Int64
/// ISO weekday mask, Monday in bit 0; 0 is once, 127 every day.
public var days: Int64
public var finishAtLimit: Bool
}

/// A manual charging hold.
Expand Down Expand Up @@ -581,7 +640,39 @@ extension CloudWire.Loadpoint {
boost: try f.bool("boost"),
nextStartMs: try f.optionalInt("nextStartMs"),
nextEndMs: try f.optionalInt("nextEndMs"),
nextWh: try f.optionalInt("nextWh")
nextWh: try f.optionalInt("nextWh"),
carId: try f.text("carId"),
carIdentifiedBy: try f.text("carIdentifiedBy"),
carCorrected: try f.bool("carCorrected"),
carCorrectedFrom: try f.text("carCorrectedFrom"),
choose: try CloudWire.texts(f["choose"], "choose")
)
}
}

extension CloudWire.Cars {
public init(body: Bytes) throws {
let f = try CloudWire.Fields(body: body)
self.init(header: try CloudWire.Header(f),
cars: try f.list("cars") { try CloudWire.Car(CloudWire.Fields($0, "cars")) })
}
}

extension CloudWire.Car {
init(_ f: CloudWire.Fields) throws {
self.init(
id: try f.text("id"),
socPermille: try f.optionalInt("socPermille"),
socSource: try f.text("socSource"),
socAgeS: try f.optionalInt("socAgeS"),
energySinceWh: try f.int("energySinceWh"),
socEstimate: try f.bool("socEstimate"),
chargeLimitPermille: try f.optionalInt("chargeLimitPermille"),
goal: try f.optional("goal").map { g in
CloudWire.CarGoal(targetPermille: try g.int("targetPermille"), readyByMin: try g.int("readyByMin"),
days: try g.int("days"), finishAtLimit: try g.bool("finishAtLimit"))
},
surplusOnly: try f.bool("surplusOnly")
)
}
}
Expand Down
24 changes: 17 additions & 7 deletions appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1138,7 +1138,7 @@ extension SiteModel {
cloud = c
cloudHome = home
cloudStatus = c.status
if cloudOn { installPushSink() }
if takesAlerts { installPushSink() }
c.start()
}

Expand Down Expand Up @@ -1194,11 +1194,18 @@ extension SiteModel {
cloudDirty = true
persistNow()
startCloud()
// Alerts come with the readable copy, and only then is the person
// asked to allow them.
if cloudOn, cloud != nil { installPushSink() } else if !cloudOn { uninstallPushSink() }
// Alerts come with the readable copy, or to an owner's phone for the
// security notices whenever the box is linked, and only then is the
// person asked to allow them.
if takesAlerts, cloud != nil { installPushSink() } else if !takesAlerts { uninstallPushSink() }
}

/// Whether this phone registers for FTW Cloud's alerts: an owner's
/// whenever the box is linked, since security notices go to every owner
/// with the readable copy on or off; another phone only while the copy
/// carries its events.
private var takesAlerts: Bool { cloudOn || canConfigure }

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 Wait for confirmed ownership before prompting

Before the box answers hello, SessionState.role defaults to owner, so takesAlerts is true and startCloud installs the push sink even for a viewer, removed phone, or other unconfirmed connection. PushTokens.install immediately requests notification authorization; a later hello can uninstall the sink but cannot retract that one-time system prompt, and the asynchronous install can race with the uninstall. Require heardFromBox for the owner-only branch while retaining cloudOn for a known readable copy.

Useful? React with 👍 / 👎.


// MARK: Pushes

/// The App Group files the notification extension reads. None in the
Expand Down Expand Up @@ -1288,7 +1295,7 @@ extension SiteModel {
guard var view = cloudView, (try? view.accept(env, arrivalMs: Int64(at))) != nil else { return false }
cloudView = view
switch env.t {
case CloudWire.msgLP: takeCloudCars(at)
case CloudWire.msgLP, CloudWire.msgCar: takeCloudCars(at)

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 Use the car timestamp for car-triggered updates

When a car envelope arrives after the current lp, this calls takeCloudCars, but that function stamps the joined result using view.sentMs(lp.header, ...). LoadpointsModel.takeCloud rejects a non-cache result whose asOfMs is no newer than the displayed stamp, so after the LP has been consumed, a car-only SoC or goal update receives the same old LP timestamp and is discarded. Stamp car-triggered projections with the car header, or the newest relevant class send time.

Useful? React with 👍 / 👎.

case CloudWire.msgPrices: takeCloudPrices(at)
default: break
}
Expand Down Expand Up @@ -1397,13 +1404,16 @@ extension SiteModel {
guard let view = cloudView, let lp = view.lp, let identity = cloudIdentity else { return }
let now = scheduler.nowMs
let g = view.grade(nowMs: Int64(now))
let cars = lp.loadpoints.map { l in
let cars = lp.loadpoints.map(view.joined).map { l in
CarEntry.Car(
loadpointID: identity.loadpoints[l.id] ?? l.id,
pluggedIn: l.plugged,
socPct: l.socPermille.map { (Double($0) / 10).rounded() },
socSource: l.socSource,
socReadAtMs: l.socAgeS.map { now - Double($0) * 1000 },
// Dated from the message that carried the age: the car
// class's own send for a car. Without an anchor, a charger
// without a car is dated from now, as before cars.
socReadAtMs: view.socReadAtMs(l).map(Double.init) ?? (l.carId.isEmpty ? l.socAgeS.map { now - Double($0) * 1000 } : nil),
powerW: Double(l.powerW),
targetSocPct: l.targetPermille.map { (Double($0) / 10).rounded() }
)
Expand Down
56 changes: 51 additions & 5 deletions appleApp/FTWKit/Sources/FTWShared/PushAlert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public enum PushKind {
public static let cloudGranted = "cloud.granted"
public static let cloudRevoked = "cloud.revoked"
/// Access from outside the house, for every owner (docs/agents.md in
/// srcfl/ftw). They come as the box's `notice`, never as an event.
/// srcfl/ftw). They come as the box's `notice`, never as an event, and
/// FTW Cloud brings them with the readable copy on or off.
public static let agentAdded = "agent.added"
public static let agentRequest = "agent.request"
public static let agentExpiring = "agent.expiring"
Expand Down Expand Up @@ -98,6 +99,8 @@ public struct PushAlert: Sendable, Equatable {
static let clockLeadMs: Int64 = 2_000
/// How old an event may be and still get its text and buttons.
static let maxAgeMs: Int64 = 10 * 60_000
/// How old a notice may be: a day.
static let noticeMaxAgeMs: Int64 = 24 * 60 * 60_000
/// How long rule U's anchor stays usable after it was last learned.
static let ruleULifetimeMs: Int64 = 24 * 60 * 60_000
/// Rule U's drift margin: 0.01 % of the time since it was learned.
Expand Down Expand Up @@ -138,16 +141,22 @@ public struct PushAlert: Sendable, Equatable {
guard let event = a.event, !event.isEmpty else {
return a.kind == PushKind.boxUnreachable ? PushAlert(.unreachable, home: home) : generic
}
// A notice reads as an event of its kind, with the client's name as
// its one argument when it carries one.
guard a.kind == nil || a.kind == "",
let env = Envelope(Array(event)), env.t == "event",
let env = Envelope(Array(event)), env.t == "event" || env.t == "notice",
let pk = boxKey(home), verify(pk, home: home, env),
let ev = Event(env.b), var at = sendTime(ev, offset: offset(home), nowMs: nowMs) else {
let ev = env.t == "event" ? Event(env.b) : Event(notice: env.b),
var at = sendTime(ev, offset: offset(home), nowMs: nowMs) else {
return generic
}
if let ts = ev.ts { at = min(at, ts) }
let age = nowMs.subtractingReportingOverflow(at)
guard PushKind.all.contains(ev.kind), ev.kind != PushKind.boxUnreachable, !PushKind.notices.contains(ev.kind),
!age.overflow, age.partialValue <= maxAgeMs else {
// A notice kind comes only as a notice, and a notice states a
// lasting fact, so it lasts a day.
let notice = env.t == "notice"
guard PushKind.all.contains(ev.kind), ev.kind != PushKind.boxUnreachable, PushKind.notices.contains(ev.kind) == notice,
!age.overflow, age.partialValue <= (notice ? noticeMaxAgeMs : maxAgeMs) else {
return generic
}
return PushAlert(.event, home: home, kind: ev.kind, args: ev.args, src: ev.src, lp: ev.lp, boot: ev.boot.map { Data($0) }, seq: ev.seq, atMs: at)
Expand Down Expand Up @@ -202,6 +211,17 @@ public struct PushAlert: Sendable, Equatable {
}
}

/// A notice's client name may be at most this many characters: the
/// consent page keeps 64 and marks a cut name with "…", as in Go.
static let noticeClientMaxRunes = 65

/// A client name as Go's NoticeClientOK takes it: no control or format
/// character, and not too long.
static func clientOK(_ c: String) -> Bool {
!c.isEmpty && c.unicodeScalars.count <= noticeClientMaxRunes &&
!c.unicodeScalars.contains { $0.properties.generalCategory == .control || $0.properties.generalCategory == .format }
}

/// The event body. A field may be missing or null; one of the wrong
/// type makes the whole body unreadable, as it does in Go.
struct Event {
Expand Down Expand Up @@ -238,6 +258,32 @@ public struct PushAlert: Sendable, Equatable {
return nil
}
}

/// A notice body, read as ReadNotice in Go reads it: the header, a
/// notice kind and, on agent.added only, the client's name. Any
/// other field makes it no notice, so it cannot carry a reading.
init?(notice b: [UInt8]) {
let allowed: Set<String> = ["seq", "boot", "uptimeMs", "wallMs", "kind", "client"]
guard let m = (try? PushCBOR.decode(b))?.textMap, Set(m.keys).isSubset(of: allowed) else { return nil }
func field<T>(_ key: String, _ read: (PushCBOR) -> T?) throws -> T? {
guard let v = m[key], v != .null else { return nil }
guard let out = read(v) else { throw PushCBOR.Malformed() }
return out
}
do {
seq = try field("seq", \.uint) ?? 0
boot = try field("boot", \.byteString)
uptimeMs = try field("uptimeMs", \.int64) ?? 0
wallMs = try field("wallMs", \.int64)
kind = try field("kind", \.text) ?? ""
let client = try field("client", \.text) ?? ""
guard PushKind.notices.contains(kind), boot?.count == 16, uptimeMs >= 0,
client.isEmpty || (kind == PushKind.agentAdded && PushAlert.clientOK(client)) else { return nil }
if !client.isEmpty { args = ["client": client] }
} catch {
return nil
}
}
}
}

Expand Down
Loading
Loading