- 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)
311 lines
14 KiB
Swift
311 lines
14 KiB
Swift
// 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."
|
||
}
|
||
}
|
||
}
|