@@ -87,6 +87,22 @@ async def handle_oauth_401(self):
8787# Viewer query to discover all account numbers under the authenticated user
8888KRAKEN_VIEWER_QUERY = """{ viewer { accounts { number } } }"""
8989
90+ # GraphQL applicableRates query — fallback when REST product endpoint returns 404
91+ # (product code removed/replaced while customer is still on the tariff).
92+ # Returns value (pence/kWh inc VAT), validFrom, validTo for the requested window.
93+ KRAKEN_APPLICABLE_RATES_QUERY = """{{
94+ applicableRates(
95+ accountNumber: "{account_number}"
96+ mpxn: "{mpan}"
97+ startAt: "{start_at}"
98+ endAt: "{end_at}"
99+ ) {{
100+ value
101+ validFrom
102+ validTo
103+ }}
104+ }}"""
105+
90106KRAKEN_BASE_URLS = {
91107 "edf" : "https://api.edfgb-kraken.energy" ,
92108 "eon" : "https://api.eonnext-kraken.energy" ,
@@ -123,6 +139,7 @@ def initialize(
123139
124140 self .account_id = account_id
125141 self .configured_mpan = mpan # From SaaS config — preferred MPAN to match
142+ self .import_mpan = None # Set after first successful tariff discovery
126143 self .current_tariff = None
127144 self .export_tariff = None # Export tariff (discovered dynamically)
128145 self .wired = False
@@ -430,6 +447,9 @@ async def async_find_tariffs(self):
430447
431448 new_tariff = {"tariff_code" : import_result ["tariff_code" ], "product_code" : import_result ["product_code" ]}
432449
450+ # Store MPAN for GraphQL fallback in async_fetch_rates_graphql()
451+ self .import_mpan = import_result ["mpan" ]
452+
433453 # Discover export tariff — always re-discover to detect tariff changes
434454 await self ._discover_export_tariff (all_meter_points , my_address )
435455
@@ -493,16 +513,83 @@ def _normalize_rate_timestamps(results):
493513
494514 return results
495515
516+ async def async_fetch_rates_graphql (self , mpan ):
517+ """Fetch import rates via GraphQL applicableRates — fallback when REST returns non-200.
518+
519+ Used when the product code has been removed from the REST API (e.g. product replaced
520+ mid-agreement). The applicableRates query returns the rates currently applicable to
521+ the customer regardless of product lifecycle.
522+
523+ Args:
524+ mpan: The import MPAN (meter point access number) for the account.
525+
526+ Returns list of rate dicts with value_inc_vat, value_exc_vat, valid_from, valid_to, or None.
527+ """
528+ now = datetime .now (timezone .utc )
529+ midnight_utc = now .replace (hour = 0 , minute = 0 , second = 0 , microsecond = 0 )
530+
531+ # Mirror the window used by fetch_octopus_rates → minute_data(forecast_days + 1, midnight_utc).
532+ # Start one day before midnight so any rate period that began earlier today is included.
533+ # End at midnight + (forecast_days + 1) to cover the full planning horizon.
534+ forecast_hours = self .get_arg ("forecast_hours" , 48 )
535+ forecast_days = int ((forecast_hours + 23 ) / 24 )
536+ start_at = (midnight_utc - timedelta (days = 1 )).strftime ("%Y-%m-%dT%H:%M:%SZ" )
537+ end_at = (midnight_utc + timedelta (days = forecast_days + 1 )).strftime ("%Y-%m-%dT%H:%M:%SZ" )
538+ query = KRAKEN_APPLICABLE_RATES_QUERY .format (
539+ account_number = self .account_id ,
540+ mpan = mpan ,
541+ start_at = start_at ,
542+ end_at = end_at ,
543+ )
544+ data = await self .async_graphql_query (query , "applicable-rates-graphql" )
545+ if not data :
546+ return None
547+
548+ raw_rates = data .get ("applicableRates" , [])
549+ if not raw_rates :
550+ self .log ("Warn: Kraken: applicableRates GraphQL returned no rate periods" )
551+ return None
552+
553+ results = []
554+ for r in raw_rates :
555+ value = r .get ("value" )
556+ if value is None :
557+ continue
558+ value_inc_vat = float (value )
559+ value_exc_vat = round (value_inc_vat / 1.05 , 4 )
560+ results .append (
561+ {
562+ "value_inc_vat" : value_inc_vat ,
563+ "value_exc_vat" : value_exc_vat ,
564+ "valid_from" : r .get ("validFrom" ),
565+ "valid_to" : r .get ("validTo" ),
566+ }
567+ )
568+
569+ if not results :
570+ return None
571+
572+ results = self ._normalize_rate_timestamps (results )
573+ self .log (f"Kraken: Fetched { len (results )} rate periods via GraphQL applicableRates for MPAN { mpan } " )
574+ return results
575+
496576 async def async_fetch_rates (self , tariff = None ):
497- """Fetch rates from public REST endpoint. No auth needed. Returns list of rate objects or None."""
577+ """Fetch rates from public REST endpoint. No auth needed. Returns list of rate objects or None.
578+
579+ Falls back to GraphQL applicableRates if the REST endpoint returns a non-200 status
580+ (e.g. 404 when the product code has been removed from the API) and self.import_mpan
581+ is known. Only applied to import tariff fetches — export rates have no GraphQL fallback.
582+ """
498583 tariff = tariff or self .current_tariff
499584 if not tariff :
500585 return None
501586
587+ is_import = tariff == self .current_tariff
502588 url = self .build_rates_url (tariff ["product_code" ], tariff ["tariff_code" ])
503589
504590 all_results = []
505591 pages = 0
592+ http_error_status = None
506593 try :
507594 timeout = aiohttp .ClientTimeout (total = 30 )
508595 async with aiohttp .ClientSession (timeout = timeout ) as session :
@@ -511,25 +598,35 @@ async def async_fetch_rates(self, tariff=None):
511598 if response .status != 200 :
512599 self .log (f"Warn: Kraken: Rates HTTP { response .status } for { url } " )
513600 self .failures_total += 1
514- return None
601+ http_error_status = response .status
602+ break
515603 data = await response .json ()
516604
517605 all_results .extend (data .get ("results" , []))
518606 url = data .get ("next" ) # Pagination
519607 pages += 1
520608
521- if url :
522- self .log (f"Warn: Kraken: Rate pagination capped at { pages } pages, more data available" )
523-
524- all_results = self ._normalize_rate_timestamps (all_results )
525- self .log (f"Kraken: Fetched { len (all_results )} rate periods for { tariff ['tariff_code' ]} " )
526- return all_results
527-
528609 except (aiohttp .ClientError , asyncio .TimeoutError ) as e :
529610 self .log (f"Warn: Kraken: Network error fetching rates: { e } " )
530611 self .failures_total += 1
531612 return None
532613
614+ if http_error_status is not None :
615+ # Only fall back to GraphQL for permanent "product not found" responses (404/410).
616+ # Transient errors (429, 500, 503, …) should surface as failures, not trigger
617+ # an extra GraphQL request that would mask the outage.
618+ if http_error_status in (404 , 410 ) and is_import and self .import_mpan :
619+ self .log (f"Kraken: REST rates returned HTTP { http_error_status } , falling back to GraphQL applicableRates for MPAN { self .import_mpan } " )
620+ return await self .async_fetch_rates_graphql (self .import_mpan )
621+ return None
622+
623+ if url :
624+ self .log (f"Warn: Kraken: Rate pagination capped at { pages } pages, more data available" )
625+
626+ all_results = self ._normalize_rate_timestamps (all_results )
627+ self .log (f"Kraken: Fetched { len (all_results )} rate periods for { tariff ['tariff_code' ]} " )
628+ return all_results
629+
533630 def get_entity_name (self , root , suffix ):
534631 """Construct entity name. Same pattern as OctopusAPI.get_entity_name."""
535632 entity_name = root + "." + self .prefix + "_kraken_" + self .account_id .replace ("-" , "_" ) + "_" + suffix
0 commit comments