Highlight
EnergyKit exposes real-time forecasts of grid cleanliness and electricity prices to apps as an AsyncSequence, so EVs and smart thermostats can shift load to times when power is cleaner and cheaper.
Core Content
Plug an EV in at 6:30 PM and the car starts charging right away. That is what most apps do by default. The problem is that 6:30 to 9:00 PM is the peak demand window in most US time zones. Electricity is most expensive then, and coal and natural gas plants supply most of the load. By the time wind power comes online overnight and demand drops, the car is already full. The user pays more and uses dirtier electricity. Last year the Home app’s Grid Forecast feature showed users this problem, but third-party apps had no way to plug a “when is power cleaner” signal into their own charging schedulers.
WWDC25’s EnergyKit exposes the same data that drives Grid Forecast as a framework. The core abstractions are EnergyVenue (a physical charging location bound to a HomeKit Home) and ElectricityGuidance (a 0 to 1 forecast value, where lower means cleaner and cheaper). EV vendors and thermostat vendors can now pull a 24-hour window of guidance values and replace “start charging now” with “charge during the hours with the lowest guidance values.” The companion LoadEvent feedback channel writes actual charging behavior back to EnergyKit, which then produces insights like “how much of this charging session came from clean hours” and surfaces them to the user. The data lives in Core Data on-device and syncs end-to-end encrypted to CloudKit, shared with members of the HomeKit Home.
Detailed Content
EnergyKit integration has four steps: pick a venue, subscribe to guidance, report LoadEvents, and query insights.
Step one is onboarding (03:13). The user enables Clean Energy Charging in your app for a charging location. The app fetches the list of nearby EnergyVenue objects, lets the user pick one, and persists the venue’s UUID. Each launch re-validates that the venue still exists:
// Retrieve an EnergyVenue
import EnergyKit
import Foundation
@Observable final class EnergyVenueManager {
let venue: EnergyVenue
init?(venueID: UUID) async {
guard let energyVenue = await EnergyVenue.venue(for: venueID) else {
return nil
}
venue = energyVenue
}
}
Key points:
EnergyVenue.venue(for:)is async because the venue metadata may need to be read from a system service.- It returns an optional. The venue can become invalid if the user deletes it from the Home app, so the initializer is failable.
- Persist the
UUID, not the venue object itself. Re-resolve the venue at every launch.
Step two is subscribing to guidance (06:03). ElectricityGuidance.Query picks an action type. EVs use .shift (move the same load to a better time slot); thermostats use .reduce (use less directly). sharedService.guidance(...) returns an AsyncSequence that pushes a new value every time the grid forecast refreshes:
// Fetch ElectricityGuidance
import EnergyKit
import Foundation
@Observable final class EnergyVenueManager {
// The current active guidance.
var guidance: ElectricityGuidance?
fileprivate func streamGuidance(
venueID: UUID,
update: (_ guidance: ElectricityGuidance) -> Void
) async throws {
let query = ElectricityGuidance.Query(suggestedAction: .shift)
for try await currentGuidance in ElectricityGuidance.sharedService.guidance(
using: query,
at: venueID
) {
update(currentGuidance)
break
}
}
}
Key points:
Query(suggestedAction: .shift)tells the system that this is a shiftable load (an EV). The system tunes its forecast around that semantic.for try awaitis the standard way to consume an AsyncSequence, withthrowshandling network and permission failures.- The
breakmakes this a one-shot read. The code grabs the latest guidance once to drive a schedule, instead of monitoring continuously. updateuses a callback rather than writing to a property directly, which makes it easier for the caller to update an observable object across an actor boundary.
For long-running tracking (for example, re-scheduling mid-charge when the grid forecast changes), drop the break. Background refresh scenarios must be wrapped in a BackgroundTask (07:00):
// Fetch ElectricityGuidance
import EnergyKit
import Foundation
@Observable final class EnergyVenueManager {
// The task used to stream guidance.
private var streamGuidanceTask: Task<(), Error>?
///Start streaming guidance and store the value in the observed property 'guidance'.
func startGuidanceMonitoring() {
streamGuidanceTask?.cancel()
streamGuidanceTask = Task.detached { [weak self] in
if let venueID = self?.venue.id {
try? await self?.streamGuidance(venueID: venueID) { guidance in
self?.guidance = guidance
if Task.isCancelled {
return
}
}
}
}
}
}
Key points:
streamGuidanceTask?.cancel()cancels the old stream before starting a new one, which avoids leaking concurrent tasks.Task.detachedwith[weak self]makes sure the task does not block the manager from being deallocated.- The
Task.isCancelledcheck inside the update closure makes sure the cancel signal can exit the callback chain promptly.
Step three is LoadEvent feedback (starting at 11:30). One charging session has three states: begin / active / end. Apple recommends generating an active event every 15 minutes during steady-state charging, plus events on pause, schedule change, or sudden power changes:
// Start a session
import EnergyKit
// A controller that handles an electric vehicle
@Observable class ElectricVehicleController {
// The session
var session: ElectricVehicleLoadEvent.Session?
// The current guidance stored at the EV
var currentGuidance: ElectricityGuidance
// Whether the EV is following guidance
var isFollowingGuidance: Bool = true
fileprivate func beginSession() {
session = ElectricVehicleLoadEvent.Session(
id: UUID(),
state: .begin,
guidanceState: .init(
wasFollowingGuidance: isFollowingGuidance,
guidanceToken: currentGuidance.guidanceToken
)
)
}
}
Key points:
- A single session uses one
UUIDacross begin/active/end. All three events share the same id. - The
guidanceTokencomes from the guidance you fetched earlier, and it is only valid on the device that fetched it. wasFollowingGuidancelets EnergyKit tell the difference between “user override” and “followed guidance.”
Measurements use Foundation’s Measurement<UnitPower> and Measurement<UnitEnergy>, in milliwatts and milliwatt-hours (11:30). Submit events in a batch:
// Submit events
import EnergyKit
// A controller that handles an electric vehicle
@Observable class ElectricVehicleController {
// EnergyVenue
// The venue at which the EV uses energy
var currentVenue: EnergyVenue
// Electric EV Events
// The list of generated EV load events
var events = [ElectricVehicleLoadEvent]()
func submitEvents() async throws {
try await currentVenue.submitEvents(events)
}
}
Key points:
submitEventshangs off the venue directly. The charging location an event belongs to is fixed, so you do not pass it again.- Apple explicitly recommends batching to cut IPC overhead, and forbids submitting events between two charging sessions.
- Submitted data flows through CloudKit end-to-end encryption and is shared with members of the HomeKit Home.
Step four is querying insights (13:25). ElectricityInsightQuery uses an OptionSet of .cleanliness and .tariff to pick the dimensions you want. With granularity .daily, it returns daily aggregates:
// Create an insight query
import EnergyKit
@Observable final class EnergyVenueManager {
func createInsightsQuery(on date: Date) -> ElectricityInsightQuery {
return ElectricityInsightQuery(
options: .cleanliness.union(.tariff),
range: self.dayInterval(date: date),
granularity: .daily,
flowDirection: .imported
)
}
}
Key points:
flowDirection: .importedmeans drawing power from the grid (charging). Switch toexportedif you do V2G discharging..cleanlinessbuckets energy into three tiers: clean / reduce / avoid..tariffbuckets it into five tiers from super off peak to critical peak.- The tariff dimension only has data when the user has linked an electricity account in the Home app and is on a time-of-use plan.
End-to-end, your app moves from “is there power” to “where does the power come from, when is the cheapest hour to use it, and how did the user actually use it.”
Key Takeaways
-
What to do: Add a Clean Energy Charging switch to your EV or storage app
- Why it is worth doing: Instant charging by default makes users buy the most expensive and dirtiest power during peak hours. Once EnergyKit is on, the system computes the window for you, and the app only needs to start charging at the better times. This is a difference users can feel.
- How to start: First build an onboarding flow that lets the user pick an
EnergyVenuefor each charging location and store the UUID. Then drop astreamGuidancecall into your existing charging trigger logic, and use the returned value to decide whether to start now.
-
What to do: Treat LoadEvents as product analytics data
- Why it is worth doing: LoadEvents are not just for EnergyKit’s insights. They are themselves a fine-grained charging log with power, energy, and timestamps. Back in your app you can build a “clean charging share this week” card, a year-end review, or a community leaderboard.
- How to start: Add an event cache layer to your charging controller. Append on a 15-minute window or on a power change. At session end, call
submitEventsonce, and copy the cache aside for local analysis.
-
What to do: Wire smart thermostats and water heaters to
.reduceguidance- Why it is worth doing: EVs shift (move time slots), but thermostats reduce (use less directly). EnergyKit covers both semantics in the same framework. For users in regions with tight grids, dropping a couple of degrees during critical hours is real money.
- How to start: Switch the query’s suggestedAction to
.reduce. When guidance is high, widen the target temperature by 1–2°C and tell the user why. Pair it with the critical peak tag for a more aggressive policy.
-
What to do: Use BackgroundTask to keep guidance fresh through long charges
- Why it is worth doing: A Level 2 charge can run 6–8 hours. The app cannot stay in the foreground that long. When guidance refreshes mid-charge, the car needs to follow.
- How to start: Wrap
streamGuidanceTaskin aBGAppRefreshTaskor in a widget’s update callback. When guidance changes past a threshold, re-plan the charging schedule and write a freshguidanceTokeninto the next batch of LoadEvents.
Related Sessions
- Finish tasks in the background — the official recommended way to keep refreshing ElectricityGuidance from a background task
- Demystify concurrency in SwiftUI — how to consume the EnergyKit AsyncSequence inside SwiftUI
- Meet the Smart Home — background on the Home app and HomeKit Home as a whole
- Build a UIKit document-based app — another integration pattern for the same generation of system services frameworks
Comments
GitHub Issues · utterances