Files
fuelboard/Shared/FuelPriceProvider.swift
T
FuelBoard Contributor 366e21a1b4 P0: live provider chain — GitHub mirror primary, relay fallback, bundled dump last resort + app telemetry beacon
- MirrorFuelProvider: fetches CURRENT prices from raw.githubusercontent
  (latest.json pointer → history/<day>.json full dump), app-group day-cache
  so the ~2.8 MB dump is re-downloaded only when the mirror pushes a new day
- LiveChainProvider: full path (app refresh) GitHub → relay; focused path
  (background alert checks) relay-first so alerts never pull the full dump
  over mobile data; records which leg served for About + telemetry
- FuelBeacon: fire-and-forget X-Client app ping to the relay's existing
  widget-diag route (zero relay changes) — attribution + cadence on-LAN,
  silent skip off-LAN is the reachability datum; /stats app-hit spike =
  GitHub path failing
- Bundled dump: FuelBoardDump dataset (real 2026-08-15 snapshot, 8,022
  stations) + BundledDumpProvider + scripts/refresh_bundled_dump.sh —
  no-network last resort replaces the demo sample set
- ContentView: About meta from the chain; catch falls back cached → bundled
  → sample
- 89 tests (6 new: chain order x4, cache decision, beacon URL); Release
  build green; beacon route verified against the live relay (204 + logged)
2026-08-15 13:00:24 +01:00

311 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// FuelPriceProvider.swift — data-source seam for FuelBoard.
//
// The official UK Fuel Finder API (api.fuelfinder.service.gov.uk) is the live
// source for station-level prices, but it requires GOV.UK One Login + OAuth 2.0
// client credentials. For the scaffold we ship SampleFuelProvider (realistic
// stations around a location, works offline), plus the FuelFinderProvider
// skeleton with the exact integration steps documented inline so wiring the
// live API later is a drop-in: change `activeProvider` to `.fuelFinder`.
import Foundation
// MARK: - Provider protocol
protocol FuelPriceProviding {
/// Fetch stations with prices. `location` may be nil (sort by price only).
/// `radiusKM` nil = full dataset (device filters); set = focused server-side
/// radius (alert path). Throws on failure so callers can fall back.
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation]
}
enum FuelPriceProvider {
/// Default: the live chain — GitHub mirror (primary, off-LAN) → LAN
/// relay (fallback) → bundled dump (last resort in the app target).
static let active: FuelPriceProviding = LiveChainProvider()
/// Decodes a relay payload into stations, applying the defensive price
/// band. Internal so the unit-test target can exercise the guard.
static func decodeStations(from data: Data) throws -> [FuelStation] {
let payload = try JSONDecoder().decode(RelayResponse.self, from: data)
return payload.stations.map { relay in
FuelStation(
id: relay.id ?? relay.name ?? UUID().uuidString,
name: (relay.name ?? "Unknown").sanitizedStationTitle,
brand: relay.brand ?? "",
address: relay.address ?? "",
postcode: relay.postcode ?? "",
lat: relay.lat ?? 0,
lng: relay.lng ?? 0,
prices: relay.allPrices,
priceUpdated: nil
)
}
}
/// Price sanity band (pence/litre) — anything outside is relay regression
/// garbage and must never reach calculations (shared by the live decode
/// and the history mirror decoder).
static let priceBand: ClosedRange<Double> = 50...500
/// Maps relay grade keys (E5/E10/DIESEL…) to FuelType with the defensive
/// band guard. Shared by the relay decode and the history mirror parser so
/// both surfaces apply identical sanitisation.
static func mapGrades(_ prices: [String: Double]) -> [FuelType: Double] {
var result: [FuelType: Double] = [:]
for (grade, value) in prices {
guard priceBand.contains(value) else { continue }
switch grade.uppercased() {
case "E10": result[.e10] = value
case "E5": result[.e5] = value
case "DIESEL", "B7", "B7S", "B7P", "B10": result[.diesel] = value
default: break
}
}
return result
}
/// Decodes the relay envelope metadata (source, dataset update time) — the
/// About section shows these so the user can see which data source is
/// live and how fresh the GOV.UK data itself is. The fields are additive
/// on the relay, so older relays simply return nil values.
static func decodeRelayMeta(from data: Data) -> RelayMeta? {
guard let payload = try? JSONDecoder().decode(RelayResponse.self, from: data) else {
return nil
}
return RelayMeta(
source: payload.source,
stationCount: payload.stationsCount,
dataUpdated: payload.dataUpdated
)
}
}
// MARK: - Relay provider (default)
/// Talks to the FuelBoard Relay (keyless proxy). The relay serves the FULL-UK
/// Fuel Finder dataset — every station in England, Wales, Scotland, NI — and
/// filters/sorts server-side. No credentials in the app.
struct RelayFuelProvider: FuelPriceProviding {
var baseURL = URL(string: "http://192.168.1.131:8789")!
/// Envelope metadata from the most recent successful relay fetch —
/// consumed by Settings → About (source api/csv, station count, GOV.UK
/// dataset update time). Written by fetchStations, read by the view.
static var latestMeta: RelayMeta?
/// Admin telemetry: the relay tags inbound connections by client kind
/// (app vs widget) so the developer can monitor the poll model. Siri
/// never fetches (cached-data-first). Purely observational — the relay
/// ignores the header for responses.
static let clientTagHeader = "X-Client"
static func relayRequest(_ url: URL, client: String, timeout: TimeInterval = 30) -> URLRequest {
var request = URLRequest(url: url)
request.timeoutInterval = timeout
request.setValue(client, forHTTPHeaderField: clientTagHeader)
return request
}
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
var components = URLComponents(url: baseURL.appendingPathComponent("api/v1/stations"), resolvingAgainstBaseURL: false)!
var query: [URLQueryItem] = [URLQueryItem(name: "fuel", value: fuel.rawValue)]
if let lat, let lng {
query.append(URLQueryItem(name: "lat", value: String(lat)))
query.append(URLQueryItem(name: "lng", value: String(lng)))
}
if let radiusKM {
// Focused fetch (alert path) — small response, server-side radius.
query.append(URLQueryItem(name: "radius", value: String(radiusKM)))
query.append(URLQueryItem(name: "limit", value: "500"))
} else {
// Full-UK dump: NO radius — the device filters locally
// (5/10/15-mile radius, fuel, sort). 10,000 covers the dataset.
query.append(URLQueryItem(name: "limit", value: "10000"))
}
components.queryItems = query
let request = RelayFuelProvider.relayRequest(components.url!, client: "app")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw FuelProviderError.relayUnavailable
}
let stations = try FuelPriceProvider.decodeStations(from: data)
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
return stations
}
}
private struct RelayResponse: Codable {
/// Envelope metadata surfaced in Settings → About so the user can see
/// which relay source is live and how fresh the GOV.UK data is.
let source: String?
let stationsCount: Int?
let dataUpdated: String?
enum CodingKeys: String, CodingKey {
case source, stations
case stationsCount = "stations_count"
case dataUpdated = "data_updated"
}
struct RelayStation: Codable {
let id: String?
let name: String?
let brand: String?
let address: String?
let postcode: String?
let lat: Double?
let lng: Double?
let price: Double?
let prices: [String: Double]?
/// Map relay grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
/// station sells — one fetch populates every fuel tab. Defensive guard:
/// any grade outside the 50–500 pence/litre band is dropped, so a relay
/// regression (e.g. the band being removed server-side) can't re-poison
/// the nationwide cheapest reference with 1.3p / 1589p garbage.
var allPrices: [FuelType: Double] {
var result = FuelPriceProvider.mapGrades(prices ?? [:])
// Backwards-compat: relay versions without `prices` still send `price`.
if result.isEmpty, let price, FuelPriceProvider.priceBand.contains(price) {
result[.e10] = price
}
return result
}
}
let stations: [RelayStation]
}
/// Envelope metadata from the relay response — which data source is live
/// (api/csv) and the freshest price-update timestamp the GOV.UK server
/// itself reports for the dataset. Displayed in Settings → About.
struct RelayMeta: Codable, Equatable {
let source: String?
let stationCount: Int?
let dataUpdated: String?
}
// MARK: - Sample provider (offline fallback only)
/// Representative stations spread across ENGLAND (one cluster per region) so
/// the fallback works anywhere in the country — it is NOT tied to one town.
/// Real England-wide data comes from the relay (full-UK Fuel Finder CSV/API).
/// Prices in pence/litre.
struct SampleFuelProvider: FuelPriceProviding {
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
try await Task.sleep(nanoseconds: 300_000_000) // simulate fetch
return Self.sampleStations
}
static let sampleStations: [FuelStation] = [
// Yorkshire & the Humber (Halifax area)
FuelStation(id: "y1", name: "Morrisons Halifax", brand: "Morrisons",
address: "Haugh Shaw Road", postcode: "HX1 3TU",
lat: 53.7265, lng: -1.8580,
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9],
priceUpdated: nil),
FuelStation(id: "y2", name: "Asda Leeds Crown Point", brand: "Asda",
address: "Crown Point Road", postcode: "LS10 1ET",
lat: 53.7800, lng: -1.5300,
prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.9],
priceUpdated: nil),
FuelStation(id: "y3", name: "Tesco Express York", brand: "Tesco",
address: "Fulford Road", postcode: "YO10 4AB",
lat: 53.9500, lng: -1.0800,
prices: [.e10: 138.9, .e5: 146.9, .diesel: 145.9],
priceUpdated: nil),
// North West
FuelStation(id: "nw1", name: "Sainsbury's Manchester", brand: "Sainsbury's",
address: "Regent Road", postcode: "M5 3QH",
lat: 53.4700, lng: -2.2800,
prices: [.e10: 139.9, .e5: 147.9, .diesel: 146.9],
priceUpdated: nil),
FuelStation(id: "nw2", name: "Shell Liverpool", brand: "Shell",
address: "Rice Lane", postcode: "L9 1AD",
lat: 53.4400, lng: -2.9600,
prices: [.e10: 142.9, .e5: 149.9, .diesel: 148.9],
priceUpdated: nil),
// Midlands
FuelStation(id: "m1", name: "Morrisons Birmingham", brand: "Morrisons",
address: "Hagley Road", postcode: "B16 8NA",
lat: 52.4700, lng: -1.9500,
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9],
priceUpdated: nil),
FuelStation(id: "m2", name: "Tesco Express Nottingham", brand: "Tesco",
address: "Mansfield Road", postcode: "NG5 2DP",
lat: 52.9900, lng: -1.1500,
prices: [.e10: 138.9, .e5: 146.9, .diesel: 145.9],
priceUpdated: nil),
// South East / London
FuelStation(id: "se1", name: "Asda Wembley", brand: "Asda",
address: "Great Central Way", postcode: "HA9 0DB",
lat: 51.5500, lng: -0.2800,
prices: [.e10: 136.9, .e5: 143.9, .diesel: 143.9],
priceUpdated: nil),
FuelStation(id: "se2", name: "BP Brighton", brand: "BP",
address: "Lewes Road", postcode: "BN2 3QB",
lat: 50.8300, lng: -0.1100,
prices: [.e10: 140.9, .diesel: 147.9],
priceUpdated: nil),
// South West
FuelStation(id: "sw1", name: "Esso Bristol", brand: "Esso",
address: "Wells Road", postcode: "BS4 2PP",
lat: 51.4400, lng: -2.5700,
prices: [.e10: 141.9, .e5: 148.9, .diesel: 147.9],
priceUpdated: nil),
// North East
FuelStation(id: "ne1", name: "Gulf Newcastle", brand: "Gulf",
address: "Scotswood Road", postcode: "NE4 7AA",
lat: 54.9700, lng: -1.6400,
prices: [.e10: 143.9, .e5: 151.9, .diesel: 149.9],
priceUpdated: nil),
]
}
// MARK: - Fuel Finder API provider (integration point)
/// Live provider skeleton for the official GOV.UK Fuel Finder API.
///
/// To activate:
/// 1. Sign in at developer.fuel-finder.service.gov.uk with GOV.UK One Login,
/// register an app, and generate client credentials (client_id + secret).
/// 2. Implement token fetch: POST to the token endpoint with OAuth 2.0
/// client_credentials grant → access_token (expires in 1h).
/// 3. GET prices with `Authorization: Bearer <token>` and decode the
/// stations JSON into [FuelStation].
///
/// Endpoints (per gov.uk docs):
/// base: https://api.fuelfinder.service.gov.uk
/// auth: base + /v1/token
/// prices: base + /v1/prices (full snapshot)
/// price: base + /v1/prices/GB-12345 (single station)
///
/// Then switch `FuelPriceProvider.active` to `FuelFinderProvider(...)`.
struct FuelFinderProvider: FuelPriceProviding {
let baseURL = URL(string: "https://api.fuelfinder.service.gov.uk")!
let clientID: String
let clientSecret: String
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
// TODO: OAuth token → GET /v1/prices → map to FuelStation.
// The live API requires authentication; see notes above.
throw FuelProviderError.notImplemented
}
}
enum FuelProviderError: LocalizedError {
case notImplemented
case relayUnavailable
case mirrorUnavailable
var errorDescription: String? {
switch self {
case .notImplemented:
return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials."
case .relayUnavailable:
return "FuelBoard Relay unreachable — showing cached/sample data."
case .mirrorUnavailable:
return "GitHub price mirror unreachable."
}
}
}