# EnergyKit Patterns

Larger building blocks for an EnergyKit app. Basics live in the
[skill overview](../SKILL.md). The API is new in iOS 26; confirm names against
current documentation. The code assumes a 26.1 deployment target, because venue
lookup and some error cases are 26.1 APIs.

## Observable guidance manager

```swift
import EnergyKit
import Observation

@Observable @MainActor
final class GridForecastModel {
    private(set) var venues: [EnergyVenue] = []
    var selectedVenue: EnergyVenue?
    private(set) var latest: ElectricityGuidance?
    private(set) var slots: [ElectricityGuidance.Value] = []
    private(set) var isLoading = false
    private(set) var problem: String?

    private var watcher: Task<Void, Never>?

    func loadVenues() async {
        isLoading = true
        defer { isLoading = false }
        do {
            venues = try await EnergyVenue.venues()
            selectedVenue = venues.first
            if let first = venues.first { startWatching(first.id) }
        } catch {
            problem = explain(error)
        }
    }

    func startWatching(_ venueID: UUID, action: ElectricityGuidance.SuggestedAction = .shift) {
        watcher?.cancel()
        watcher = Task { [weak self] in
            let query = ElectricityGuidance.Query(suggestedAction: action)
            do {
                for try await forecast in ElectricityGuidance.sharedService.guidance(using: query, at: venueID) {
                    guard let self else { return }
                    self.latest = forecast
                    self.slots = forecast.values
                }
            } catch {
                self?.problem = self?.explain(error)
            }
        }
    }

    func stopWatching() {
        watcher?.cancel()
        watcher = nil
    }

    var cleanestSlot: ElectricityGuidance.Value? { slots.min { $0.rating < $1.rating } }
    var hasRatePlan: Bool { latest?.options.contains(.locationHasRatePlan) ?? false }
    var usesRatePlan: Bool { latest?.options.contains(.guidanceIncorporatesRatePlan) ?? false }

    private func explain(_ error: Error) -> String {
        guard let energy = error as? EnergyKitError else { return error.localizedDescription }
        switch energy {
        case .unsupportedRegion:      return "Forecasts are not offered in this region."
        case .guidanceUnavailable:    return "No forecast is available right now."
        case .venueUnavailable:       return "Add your home in the Home app to get forecasts."
        case .permissionDenied:       return "The app is not allowed to use EnergyKit."
        case .serviceUnavailable:     return "The service is down. Try again soon."
        case .rateLimitExceeded:      return "Too many requests. Waiting before trying again."
        case .invalidLoadEvent:       return "A load event was rejected."
        case .inProgress:             return "A request is already running."
        case .locationServicesDenied: return "Location access is needed for forecasts."
        @unknown default:             return "Unexpected EnergyKit error."
        }
    }
}
```

## EV charging sessions

Only use a token EnergyKit returned for this venue and the device that asked
for guidance. Never synthesize one.

```swift
actor ChargeSessionRecorder {
    private let chargerID: String
    private let venue: EnergyVenue
    private var token: UUID?
    private var sessionID: UUID?
    private var queued: [ElectricVehicleLoadEvent] = []

    init(chargerID: String, venue: EnergyVenue) {
        self.chargerID = chargerID
        self.venue = venue
    }

    func adopt(token: UUID) { self.token = token }   // from ElectricityGuidance.guidanceToken

    func plugIn(percent: Int) async throws {
        sessionID = UUID()
        queued.append(try event(.begin, kilowatts: 0, kilowattHours: 0, percent: percent))
        try await flush()
    }

    func sample(kilowatts: Double, kilowattHours: Double, percent: Int) async throws {
        queued.append(try event(.active, kilowatts: kilowatts, kilowattHours: kilowattHours, percent: percent))
        if queued.count >= 4 { try await flush() }
    }

    func unplug(kilowattHours: Double, percent: Int) async throws {
        queued.append(try event(.end, kilowatts: 0, kilowattHours: kilowattHours, percent: percent))
        try await flush()
        sessionID = nil
    }

    private func flush() async throws {
        let outgoing = queued
        queued.removeAll()
        guard !outgoing.isEmpty else { return }
        try await venue.submitEvents(outgoing)
    }

    private func event(_ state: ElectricVehicleLoadEvent.Session.State,
                       kilowatts: Double, kilowattHours: Double, percent: Int) throws -> ElectricVehicleLoadEvent {
        guard let token, let sessionID else { throw ChargeError.noGuidanceOrSession }
        return ElectricVehicleLoadEvent(
            timestamp: .now,
            measurement: .init(stateOfCharge: percent, direction: .imported,
                               power: Measurement(value: kilowatts, unit: .kilowatts),
                               energy: Measurement(value: kilowattHours, unit: .kilowattHours)),
            session: .init(id: sessionID, state: state,
                           guidanceState: .init(wasFollowingGuidance: true, guidanceToken: token)),
            deviceID: chargerID)
    }
}

enum ChargeError: Error { case noGuidanceOrSession }
```

## HVAC stages

Report each heating or cooling stage change with the real token in force for
the venue. Stage 1 to stage 2 heat, heat to cooling, cooling to idle, and
shutting down are four separate events, not one summary row. If the equipment
reports very frequently, buffer and submit on a timer or at session end, but
still capture every significant state change when it happens.

```swift
actor ClimateRecorder {
    private let thermostatID: String
    private let venue: EnergyVenue
    private let token: UUID
    private var runID = UUID()

    init(thermostatID: String, venue: EnergyVenue, token: UUID) {
        self.thermostatID = thermostatID
        self.venue = venue
        self.token = token
    }

    func start(stage: Int) async throws {
        runID = UUID()
        try await send(.begin, stage: stage)
    }

    func changeStage(to stage: Int) async throws { try await send(.active, stage: stage) }

    func stop() async throws { try await send(.end, stage: 0) }

    private func send(_ state: ElectricHVACLoadEvent.Session.State, stage: Int) async throws {
        let event = ElectricHVACLoadEvent(
            timestamp: .now,
            measurement: .init(stage: stage),
            session: .init(id: runID, state: state,
                           guidanceState: .init(wasFollowingGuidance: true, guidanceToken: token)),
            deviceID: thermostatID)
        try await venue.submitEvents([event])
    }
}
```

## Dashboard view

```swift
import SwiftUI

struct GridDashboard: View {
    @Environment(GridForecastModel.self) private var model

    var body: some View {
        Group {
            if model.isLoading {
                ProgressView("Loading forecast")
            } else if let problem = model.problem {
                ContentUnavailableView("No Forecast", systemImage: "bolt.slash", description: Text(problem))
            } else {
                List {
                    if let venue = model.selectedVenue {
                        Section("Home") { Text(venue.name) }
                    }
                    if let best = model.cleanestSlot {
                        Section("Best time") {
                            Text(best.interval.start, style: .time)
                            Text("Rating \(best.rating, format: .number.precision(.fractionLength(2)))")
                        }
                    }
                    Section("Timeline") { ForecastList(values: model.slots) }
                    if model.usesRatePlan {
                        Label("Includes your rate plan", systemImage: "dollarsign.circle")
                    }
                }
            }
        }
        .task { await model.loadVenues() }
    }
}
```

## Preparing insight charts

`dataByGridCleanliness` needs 26.1. Keep the chart model's cleanliness fields
optional and guard them when the app still supports 26.0.

```swift
struct UsageBar: Identifiable {
    let id: Date
    let totalKWh: Double
    let cleanerKWh: Double?
    let lessCleanKWh: Double?
}

func bar(from record: ElectricityInsightRecord<Measurement<UnitEnergy>>) -> UsageBar {
    let total = record.totalEnergy?.converted(to: .kilowattHours).value ?? 0
    var cleaner: Double?
    var lessClean: Double?
    if #available(iOS 26.1, *) {
        cleaner = record.dataByGridCleanliness?.cleaner?.converted(to: .kilowattHours).value
        lessClean = record.dataByGridCleanliness?.lessClean?.converted(to: .kilowattHours).value
    }
    return UsageBar(id: record.range.start, totalKWh: total, cleanerKWh: cleaner, lessCleanKWh: lessClean)
}
```

## Retrying

Only transient errors deserve a retry: `.serviceUnavailable` and
`.rateLimitExceeded`, up to three attempts with a `2^(attempt+1)` second pause.
`.unsupportedRegion`, `.permissionDenied` and `.venueUnavailable` are permanent
for this session; surface them instead.

```swift
func retryingEnergyCall<T>(_ work: () async throws -> T) async throws -> T {
    var attempt = 0
    while true {
        do {
            return try await work()
        } catch let error as EnergyKitError where attempt < 2 &&
                    (error == .serviceUnavailable || error == .rateLimitExceeded) {
            attempt += 1
            try await Task.sleep(for: .seconds(1 << attempt))
        }
    }
}
```

## Venue picker

```swift
struct HomePicker: View {
    @State private var homes: [EnergyVenue] = []

    var body: some View {
        Group {
            if homes.isEmpty {
                ContentUnavailableView("No Home Found", systemImage: "house",
                                       description: Text("Create your home in the Home app, then come back."))
            } else {
                List(homes, id: \.id) { home in
                    NavigationLink(home.name) { VenueDetail(venue: home) }
                }
            }
        }
        .task { homes = (try? await EnergyVenue.venues()) ?? [] }
    }
}
```
