The UK Government has managed to get the petrol stations to update their fuel prices centrally. I’ve built this Node-RED flow to grab the data and find the cheapest petrol station within x miles of a lat/long co-ordinate and output a HA friendly sensor message (that can drop into a Node-RED HA companion sensor of your choosing). It has attributes giving the cheapest station for different fuel types, and another attribute with a list of nearby stations and their details (sorted by distance away).
You’ll need a UK OneGov login to read all the documentation and an OAuth token from here: UK Fuel Finder, create an application to get your client_id and client_secret then copy them into the CONFIG section of the first function node in the flow - add in your Lat/Long (you should have them in zone.home in HA) and how many miles away you want the geofence casting for stations to check.
The way the API works is you download in batches ALL the stations in the UK (but this flow only stores those nearby), then you refresh price updates (in this flow every three hours, but you can do it every few minutes if you want to - although station updates take 30 minutes to get into the database).
Once the initial batch download is done - the price changes are very quick. You only need to do batch downloads when new stations are added or dropped, or they change names or details. Once a week, or overnight should be fine(!)
The flow takes care of the OAuth token management so you can install and forget. The batch download takes some time (a minute or so) - I’ve put triggers in for doing it in the small hours of the night. I’ve added rate limits in as well to make sure it doesn’t hammer the API excessively.
If you want to play with the formatting of the data into HA - it’s all done in the final node of the flow. I’ve also stuck a signature on the end of the flow so updates only hit HA when there is a data change - you can strip that out if you need HA to get constant updates every time the price trigger fires even if there is no change.
[
{
"id": "ff_inject_prices_3h",
"type": "inject",
"z": "ff_tab_01",
"name": "Poll prices (every 3h)",
"props": [
{
"p": "topic",
"vt": "str"
}
],
"repeat": "10800",
"crontab": "",
"once": true,
"onceDelay": 10,
"topic": "prices",
"x": 480,
"y": 280,
"wires": [
[
"ff_fn_start"
]
]
},
{
"id": "ff_inject_stations_daily",
"type": "inject",
"z": "ff_tab_01",
"name": "Poll stations (daily)",
"props": [
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "10 3 * * *",
"once": true,
"onceDelay": 20,
"topic": "stations",
"x": 490,
"y": 320,
"wires": [
[
"ff_fn_start"
]
]
},
{
"id": "ff_inject_manual",
"type": "inject",
"z": "ff_tab_01",
"name": "Manual (stations+prices)",
"props": [
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"topic": "manual",
"x": 470,
"y": 360,
"wires": [
[
"ff_fn_start"
]
]
},
{
"id": "ff_fn_start",
"type": "function",
"z": "ff_tab_01",
"name": "Refresh Token or Call API",
"func": "/**\n * V1.0 UK Fuel Finder API\n * Phil\n * CONFIG - EDIT THESE VALUES\n */\nconst CONFIG = {\n client_id: \"YOUR_CLIENT_ID\",\n client_secret: \"YOUR_CLIENT_SECRET\",\n home_lat: YOUR_LAT,\n home_lon: YOURL_LONG,\n radius_miles: 10\n};\n\nconst OAUTH_KEY = \"ff:oauth\";\nconst TOKEN_SKEW = 30;\n\nfunction now() { return Math.floor(Date.now() / 1000); }\n\nfunction pad(n) { return String(n).padStart(2, \"0\"); }\n\nfunction effectiveStartFromMaxTs(maxTs, minutesBack = 30) {\n if (!maxTs) return null;\n const d = new Date(maxTs);\n if (isNaN(d.getTime())) return null;\n d.setMinutes(d.getMinutes() - minutesBack);\n return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;\n}\n\n// Store config in flow for other nodes\nflow.set('ffConfig', CONFIG);\n\nconst topic = msg.topic || \"\";\nlet kind = topic === \"manual\" ? \"stations\" : topic;\n\nif (kind !== \"stations\" && kind !== \"prices\") {\n node.error(\"Invalid topic\");\n return null;\n}\n\nmsg.kind = kind;\nmsg.batch = 1;\n\n// Check if we need to get/refresh token\nconst cached = flow.get(OAUTH_KEY);\nif (cached?.access_token && cached?.expires_at && now() < (cached.expires_at - TOKEN_SKEW)) {\n msg.token = cached.access_token;\n node.status({ fill: \"green\", shape: \"dot\", text: `creating API request` });\n return [null, msg]; // Already have valid token, go to API request\n}\n\n// Need to get token\nconst useRefresh = !!cached?.refresh_token;\nmsg.method = \"POST\";\nmsg.url = useRefresh\n ? \"https://www.fuel-finder.service.gov.uk/api/v1/oauth/regenerate_access_token\"\n : \"https://www.fuel-finder.service.gov.uk/api/v1/oauth/generate_access_token\";\n\nmsg.headers = { \"content-type\": \"application/json\", \"accept\": \"application/json\" };\nmsg.payload = useRefresh\n ? { client_id: CONFIG.client_id, refresh_token: cached.refresh_token }\n : { client_id: CONFIG.client_id, client_secret: CONFIG.client_secret };\n\nnode.status({ fill: \"blue\", shape: \"dot\", text: `refreshing OAuth token` });\n\nreturn [msg, null]; // Go get token",
"outputs": 2,
"timeout": "",
"noerr": 4,
"initialize": "",
"finalize": "",
"libs": [],
"x": 790,
"y": 300,
"wires": [
[
"c6fca3d87018f53a"
],
[
"ff_fn_build_api_request"
]
]
},
{
"id": "ff_http",
"type": "http request",
"z": "ff_tab_01",
"name": "Make HTTP Call",
"method": "use",
"ret": "obj",
"paytoqs": "ignore",
"url": "",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 1240,
"y": 300,
"wires": [
[
"ff_fn_response_handler"
]
]
},
{
"id": "ff_fn_response_handler",
"type": "function",
"z": "ff_tab_01",
"name": "New Token or API Data",
"func": "const OAUTH_KEY = \"ff:oauth\";\n\nfunction now() { return Math.floor(Date.now() / 1000); }\n\n// If this is OAuth response\nif (msg.url && msg.url.includes('/oauth/')) {\n const p = msg.payload;\n const data = (p.data && typeof p.data === \"object\") ? p.data : p;\n const access = data.access_token;\n const expiresIn = Number(data.expires_in || 3600);\n const refresh = data.refresh_token;\n\n if (!access) {\n node.error(\"No access_token in OAuth response\");\n return null;\n }\n\n const cached = flow.get(OAUTH_KEY) || {};\n flow.set(OAUTH_KEY, {\n access_token: access,\n expires_at: now() + Math.max(60, expiresIn),\n refresh_token: refresh || cached.refresh_token || null\n });\n\n msg.token = access;\n node.status({ fill: \"green\", shape: \"dot\", text: `token ok, initiating API request` });\n return [msg, null]; // Now build API request\n}\n\n// This is API data response - accumulate batches\nconst batch = Array.isArray(msg.payload) ? msg.payload : [];\nconst kind = msg.kind;\n\n// Get accumulated data\nconst accumulated = flow.get(`ff_accumulated_${kind}`) || [];\naccumulated.push(...batch);\nflow.set(`ff_accumulated_${kind}`, accumulated);\n\nmsg.batchSize = batch.length;\n\nif (batch.length < 500) {\n // Last batch - send all accumulated data for processing\n msg.payload = accumulated;\n flow.set(`ff_accumulated_${kind}`, []); // Clear\n node.status({ fill: \"green\", shape: \"dot\", text: `batches built, send to process` });\n return [null, msg];\n} else {\n // More batches available\n msg.batch = (msg.batch || 1) + 1;\n node.status({ fill: \"blue\", shape: \"dot\", text: `requesting batch ${msg.batch}` });\n return [msg, null];\n}",
"outputs": 2,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1470,
"y": 300,
"wires": [
[
"ff_delay_between_batches"
],
[
"ff_fn_process_data"
]
]
},
{
"id": "ff_delay_between_batches",
"type": "delay",
"z": "ff_tab_01",
"name": "Rate Limit",
"pauseType": "rate",
"timeout": "5",
"timeoutUnits": "seconds",
"rate": "1",
"nbRateUnits": "1",
"rateUnits": "second",
"randomFirst": "1",
"randomLast": "5",
"randomUnits": "seconds",
"drop": false,
"allowrate": false,
"outputs": 1,
"x": 1680,
"y": 260,
"wires": [
[
"ff_link_to_api_builder"
]
]
},
{
"id": "ff_link_to_api_builder",
"type": "link out",
"z": "ff_tab_01",
"name": "To FF API builder",
"mode": "link",
"links": [
"ff_link_from_delay"
],
"x": 1795,
"y": 260,
"wires": []
},
{
"id": "ff_link_from_delay",
"type": "link in",
"z": "ff_tab_01",
"name": "From FF Delay Next Request",
"links": [
"ff_link_to_api_builder"
],
"x": 885,
"y": 360,
"wires": [
[
"ff_fn_build_api_request"
]
]
},
{
"id": "ff_fn_build_api_request",
"type": "function",
"z": "ff_tab_01",
"name": "Build API request",
"func": "const token = msg.token;\nif (!token) {\n node.error(\"No token available\");\n return null;\n}\n\nfunction pad(n) { return String(n).padStart(2, \"0\"); }\n\nfunction effectiveStartFromMaxTs(maxTs, minutesBack = 30) {\n if (!maxTs) return null;\n const d = new Date(maxTs);\n if (isNaN(d.getTime())) return null;\n d.setMinutes(d.getMinutes() - minutesBack);\n return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;\n}\n\nconst kind = msg.kind;\nconst batch = msg.batch || 1;\n\nlet path, qs;\n\nif (kind === \"stations\") {\n path = \"/api/v1/pfs\";\n qs = { \"batch-number\": batch };\n \n // Clear caches on first batch\n if (batch === 1) {\n flow.set('ffNearbyStationsById', {});\n flow.set('ffNearbyIds', []);\n flow.set('ff_accumulated_stations', []);\n }\n} else if (kind === \"prices\") {\n path = \"/api/v1/pfs/fuel-prices\";\n const sinceTs = flow.get(\"ffPricesMaxTs\") || null;\n const eff = effectiveStartFromMaxTs(sinceTs, 30);\n qs = { \"batch-number\": batch };\n if (eff) qs[\"effective-start-timestamp\"] = eff;\n \n // Clear cache on first batch\n if (batch === 1) {\n flow.set('ff_accumulated_prices', []);\n }\n}\n\nconst url = new URL(`https://www.fuel-finder.service.gov.uk${path}`);\nfor (const [k, v] of Object.entries(qs)) {\n if (v !== undefined && v !== null && String(v).length) {\n url.searchParams.set(k, String(v));\n }\n}\n\nmsg.method = \"GET\";\nmsg.url = url.toString();\nmsg.headers = { accept: \"application/json\", authorization: `Bearer ${token}` };\ndelete msg.payload;\n\nnode.status({ fill: \"blue\", shape: \"dot\", text: `${kind} batch ${batch}` });\n\nreturn msg;",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1030,
"y": 320,
"wires": [
[
"ff_http"
]
]
},
{
"id": "ff_fn_process_data",
"type": "function",
"z": "ff_tab_01",
"name": "Process Stations and Prices Data",
"func": "const CONFIG = flow.get('ffConfig') || {};\nconst kind = msg.kind;\nconst batch = Array.isArray(msg.payload) ? msg.payload : [];\n\nfunction weekdayLondonLower() {\n const d = new Date(new Date().toLocaleString('en-GB', { timeZone: 'Europe/London' }));\n return ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][d.getDay()];\n}\n\nfunction openingToday(station) {\n const day = weekdayLondonLower();\n const d = station?.opening_times?.usual_days?.[day];\n if (!d) return null;\n if (d.is_24_hours) return '24h';\n const open = String(d.open || '').slice(0, 5);\n const close = String(d.close || '').slice(0, 5);\n if (!open || !close || (open === '00:00' && close === '00:00')) return null;\n return `${open}-${close}`;\n}\n\nfunction toRad(d) { return d * Math.PI / 180; }\n\nfunction havKm(aLat, aLon, bLat, bLon) {\n const R = 6371;\n const dLat = toRad(bLat - aLat);\n const dLon = toRad(bLon - aLon);\n const x = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * (Math.sin(dLon / 2) ** 2);\n return 2 * R * Math.asin(Math.min(1, Math.sqrt(x)));\n}\n\nif (kind === \"stations\") {\n const radiusKm = CONFIG.radius_miles * 1.609344;\n const byId = flow.get('ffNearbyStationsById') || {};\n const ids = new Set(flow.get('ffNearbyIds') || []);\n\n for (const s of batch) {\n const id = s?.node_id;\n if (!id) continue;\n\n if (s.temporary_closure === true || s.permanent_closure === true) {\n ids.delete(id);\n delete byId[id];\n continue;\n }\n\n const lat = Number(s?.location?.latitude);\n const lon = Number(s?.location?.longitude);\n if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;\n\n const km = havKm(CONFIG.home_lat, CONFIG.home_lon, lat, lon);\n if (km > radiusKm) {\n ids.delete(id);\n delete byId[id];\n continue;\n }\n\n byId[id] = {\n id,\n name: s.trading_name || s.brand_name || '',\n brand: s.brand_name || '',\n postcode: s?.location?.postcode || '',\n lat, lon,\n miles: Number((km / 1.609344).toFixed(2)),\n open_today: openingToday(s),\n is_mss: !!s.is_motorway_service_station,\n is_supermarket: !!s.is_supermarket_service_station,\n fuel_types: Array.isArray(s.fuel_types) ? s.fuel_types : [],\n amenities: Array.isArray(s.amenities) ? s.amenities : [],\n };\n ids.add(id);\n }\n\n flow.set('ffNearbyStationsById', byId);\n flow.set('ffNearbyIds', Array.from(ids));\n\n // Clean up accumulation array\n flow.set('ff_accumulated_stations', undefined);\n\n node.status({ fill: \"green\", shape: \"dot\", text: `${ids.size} stations` });\n\n // Trigger prices refresh\n return { topic: \"prices\", payload: Date.now() };\n}\n\nif (kind === \"prices\") {\n const nearbyIds = new Set(flow.get('ffNearbyIds') || []);\n if (!nearbyIds.size) {\n node.warn(\"No stations loaded yet, skipping prices\");\n // Clean up accumulation array even on error\n flow.set('ff_accumulated_prices', undefined);\n return null;\n }\n\n const byId = flow.get('ffNearbyPricesById') || {};\n let maxTs = flow.get('ffPricesMaxTs') || null;\n\n for (const rec of batch) {\n const id = rec?.node_id;\n if (!id || !nearbyIds.has(id)) continue;\n\n const fuels = Array.isArray(rec.fuel_prices) ? rec.fuel_prices : [];\n const cur = byId[id] || {};\n\n for (const f of fuels) {\n const ft = f?.fuel_type;\n if (!ft) continue;\n\n const price = (f.price === null || f.price === undefined || f.price === '') ? null : Number(f.price);\n const ts = f.price_last_updated || null;\n\n cur[ft] = { price, ts };\n if (ts && (!maxTs || ts > maxTs)) maxTs = ts;\n }\n\n byId[id] = cur;\n }\n\n flow.set('ffNearbyPricesById', byId);\n if (maxTs) flow.set('ffPricesMaxTs', maxTs);\n\n // Clean up accumulation array\n flow.set('ff_accumulated_prices', undefined);\n\n node.status({ fill: \"green\", shape: \"dot\", text: `prices updated` });\n\n // Trigger HA build\n return { topic: \"build_ha\", payload: Date.now() };\n}\n\nreturn null;",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1760,
"y": 340,
"wires": [
[
"ff_fn_router"
]
]
},
{
"id": "ff_fn_router",
"type": "switch",
"z": "ff_tab_01",
"name": "Route: prices or HA build",
"property": "topic",
"propertyType": "msg",
"rules": [
{
"t": "eq",
"v": "prices",
"vt": "str"
},
{
"t": "eq",
"v": "build_ha",
"vt": "str"
}
],
"checkall": "true",
"repair": false,
"outputs": 2,
"x": 2050,
"y": 340,
"wires": [
[
"ff_link_prices_trigger"
],
[
"ff_fn_build_ha_payload"
]
]
},
{
"id": "ff_link_prices_trigger",
"type": "link out",
"z": "ff_tab_01",
"name": "Trigger prices",
"mode": "link",
"links": [
"ff_link_to_start_inject"
],
"x": 2215,
"y": 320,
"wires": []
},
{
"id": "ff_link_to_start_inject",
"type": "link in",
"z": "ff_tab_01",
"name": "To start node",
"links": [
"ff_link_prices_trigger"
],
"x": 565,
"y": 240,
"wires": [
[
"ff_fn_start"
]
]
},
{
"id": "ff_fn_build_ha_payload",
"type": "function",
"z": "ff_tab_01",
"name": "Build HA payload + signature",
"func": "const stationsById = flow.get('ffNearbyStationsById') || {};\nconst pricesById = flow.get('ffNearbyPricesById') || {};\nconst ids = Object.keys(stationsById);\n\nconst joined = [];\nfor (const id of ids) {\n const s = stationsById[id];\n const p = pricesById[id] || {};\n\n // Skip stations with no price data at all\n const hasAnyPrice = p.E10?.price || p.B7_STANDARD?.price;\n if (!hasAnyPrice) continue;\n\n joined.push({\n id,\n name: s.name,\n postcode: s.postcode,\n miles: s.miles,\n open_today: s.open_today,\n prices: p,\n });\n}\n\n// Phil's smart to Title Case\nfunction toTitleCase(str) {\n if (!str || typeof str !== \"string\") return \"\";\n\n // Extract HTML entities\n const entities = [];\n const placeholder = \"\\uFFF0\";\n str = str.replace(/&[#a-zA-Z0-9]+;/g, (m) => {\n entities.push(m);\n return `${placeholder}${entities.length - 1}${placeholder}`;\n });\n\n const smallWordsStr = \"a|an|and|as|at|but|by|en|for|if|in|of|on|or|the|to|v\\\\.?|via|vs\\\\.?|de|di\";\n const smallWordsRegex = new RegExp(`\\\\b(${smallWordsStr})\\\\b`, \"gi\");\n const punctuation = /([!\\\"#$%&'()*+,./:;<=>?@[\\\\\\]^_`{|}~-]*)/;\n const sentenceBoundaries = /[:.;?!] |(?: |^)[\"']/g;\n\n const parts = [];\n let lastIndex = 0;\n let match;\n\n while ((match = sentenceBoundaries.exec(str)) !== null) {\n parts.push(processPart(str.substring(lastIndex, match.index)));\n parts.push(match[0]);\n lastIndex = sentenceBoundaries.lastIndex;\n }\n parts.push(processPart(str.substring(lastIndex)));\n\n let result = parts\n .join(\"\")\n .replace(/ V(s?)\\. /gi, \" v$1. \")\n .replace(/(['’])[Ss]\\b/g, \"$1s\")\n .replace(\n /\\b(Q&A|F1|F2|F3|TV|USA|UK|US|CEO|CFO|CTO|IT|AI|API|URL|HTML|CSS|JS|PDF|XML|JSON)\\b/gi,\n (m) => m.toUpperCase()\n );\n\n // Restore entities\n result = result.replace(new RegExp(`${placeholder}(\\\\d+)${placeholder}`, \"g\"), (_, i) => entities[i]);\n\n return result;\n\n function processPart(part) {\n if (!part) return \"\";\n\n return part\n .replace(/\\b([a-z][a-z.']*)\\b/gi, (w) => (/[a-z]\\.[a-z]/i.test(w) ? w : capWord(w)))\n .replace(smallWordsRegex, (m, word, offset, string) => {\n const isFirst = offset === 0;\n const isLast = offset + m.length === string.length;\n return (isFirst || isLast) ? capWord(word) : String(word).toLowerCase();\n })\n .replace(new RegExp(`^${punctuation.source}\\\\b(${smallWordsStr})\\\\b`, \"gi\"), (all, punct, word) => punct + capWord(word))\n .replace(new RegExp(`\\\\b(${smallWordsStr})\\\\b${punctuation.source}$`, \"gi\"), (all, word, punct) => capWord(word) + punct);\n }\n\n function capWord(word) {\n word = String(word);\n return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();\n }\n}\n\n// Sort by distance (closest first)\njoined.sort((a, b) => (a.miles || 999) - (b.miles || 999));\n\nfunction cheapest(ft) {\n let best = null;\n for (const r of joined) {\n const v = r.prices?.[ft]?.price;\n if (typeof v !== 'number' || !isFinite(v)) continue;\n if (!best || v < best.price) {\n best = {\n id: r.id,\n name: toTitleCase(r.name),\n postcode: r.postcode,\n miles: r.miles,\n price: Number(v.toFixed(1)),\n ts: r.prices?.[ft]?.ts || null\n };\n }\n }\n return best;\n}\n\nconst bestE10 = cheapest('E10');\nconst bestB7 = cheapest('B7_STANDARD');\n\n// Build stations array with all relevant data\nconst stationsArray = joined.map(r => ({\n id: r.id,\n name: toTitleCase(r.name),\n postcode: r.postcode,\n miles: r.miles,\n open_today: r.open_today,\n e10_price: r.prices?.E10?.price ? Number(r.prices.E10.price.toFixed(1)) : null,\n e10_updated: r.prices?.E10?.ts || null,\n b7_price: r.prices?.B7_STANDARD?.price ? Number(r.prices.B7_STANDARD.price.toFixed(1)) : null,\n b7_updated: r.prices?.B7_STANDARD?.ts || null\n}));\n\n// Simple signature without crypto\nconst model = {\n count: joined.length,\n best_e10_price: bestE10?.price || null,\n best_b7_price: bestB7?.price || null,\n station_ids: joined.map(r => r.id).join(',')\n};\n\nmsg.signature = JSON.stringify(model);\n\nmsg.payload = {\n state: joined.length,\n attributes: {\n icon: 'mdi:gas-station',\n best_e10: bestE10,\n best_b7: bestB7,\n stations: stationsArray,\n prices_max_ts: flow.get('ffPricesMaxTs') || null\n }\n};\nnode.status({ fill: \"green\", shape: \"dot\", text: `payload completed` });\n\nreturn msg;",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 2320,
"y": 360,
"wires": [
[
"ff_rbe_signature"
]
]
},
{
"id": "ff_rbe_signature",
"type": "rbe",
"z": "ff_tab_01",
"name": "RBE (signature)",
"func": "rbe",
"gap": "",
"start": "",
"inout": "out",
"property": "signature",
"x": 2560,
"y": 360,
"wires": [
[
"ff_debug_output"
]
]
},
{
"id": "ff_debug_output",
"type": "debug",
"z": "ff_tab_01",
"name": "Output (wire to HA sensor)",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"x": 2800,
"y": 360,
"wires": []
},
{
"id": "c6fca3d87018f53a",
"type": "fan",
"z": "ff_tab_01",
"name": "Refresh OA Token",
"x": 1030,
"y": 280,
"wires": [
[
"ff_http"
]
]
},
{
"id": "04684680a8264b7a",
"type": "global-config",
"env": [],
"modules": {
"node-red-contrib-fan": "1.0.2"
}
}
]
