1616
1717import asyncio
1818from datetime import datetime , timedelta , timezone
19+ import os
1920import time
2021import hashlib
2122from predbat_metrics import record_api_call
@@ -134,13 +135,21 @@ def sort_schedule_by_start_time(schedule):
134135 return schedule
135136
136137
137- def validate_schedule (new_schedule , reserve , fdPwr_max ):
138+ def pad_schedule (schedule , target_count , reserve , fdPwr_max ):
139+ """Pad schedule to target_count with disabled SelfUse zero-time entries."""
140+ disabled_entry = {"enable" : 0 , "startHour" : 0 , "startMinute" : 0 , "endHour" : 0 , "endMinute" : 0 , "workMode" : "SelfUse" , "fdSoc" : reserve , "maxSoc" : 100 , "fdPwr" : fdPwr_max , "minSocOnGrid" : reserve }
141+ while target_count > 0 and len (schedule ) < target_count :
142+ schedule .append (disabled_entry .copy ())
143+ return schedule
144+
145+
146+ def validate_schedule (new_schedule , reserve , fdPwr_max , target_count = 0 ):
138147 # Sort schedule by start time, closest to midnight first
139148 new_schedule = sort_schedule_by_start_time (new_schedule )
140149 if not new_schedule :
141150 # No schedule entries so disable
142- new_schedule = [{"enable" : 1 , "startHour" : 0 , "startMinute" : 0 , "endHour" : 23 , "endMinute" : 59 , "workMode" : "SelfUse" , "fdSoc" : reserve , "maxSoc" : 100 , "fdPwr" : fdPwr_max , "minSocOnGrid" : reserve }]
143- return new_schedule
151+ result = [{"enable" : 1 , "startHour" : 0 , "startMinute" : 0 , "endHour" : 23 , "endMinute" : 59 , "workMode" : "SelfUse" , "fdSoc" : reserve , "maxSoc" : 100 , "fdPwr" : fdPwr_max , "minSocOnGrid" : reserve }]
152+ return pad_schedule ( result , target_count , reserve , fdPwr_max )
144153
145154 # Process all schedule entries
146155 result_schedule = []
@@ -203,7 +212,8 @@ def validate_schedule(new_schedule, reserve, fdPwr_max):
203212 demand_start_minute += 1
204213 result_schedule .append ({"enable" : 1 , "startHour" : demand_start_hour , "startMinute" : demand_start_minute , "endHour" : 23 , "endMinute" : 59 , "workMode" : "SelfUse" , "fdSoc" : reserve , "maxSoc" : 100 , "fdPwr" : fdPwr_max , "minSocOnGrid" : reserve })
205214
206- return result_schedule
215+ # Pad to target_count with disabled SelfUse entries if the device originally had more slots
216+ return pad_schedule (result_schedule , target_count , reserve , fdPwr_max )
207217
208218
209219class FoxAPI (ComponentBase , OAuthMixin ):
@@ -224,10 +234,10 @@ def initialize(self, key, automatic, inverter_sn=None, auth_method=None, token_e
224234 self .device_production_year = {}
225235 self .device_battery_charging_time = {}
226236 self .device_scheduler = {}
227- self .device_current_schedule = {}
228237 self .local_schedule = {}
229238 self .fdpwr_max = {}
230239 self .fdsoc_min = {}
240+ self .device_scheduler_count = {}
231241 # Rate limiting tracking
232242 self .requests_today = 0
233243 self .rate_limit_errors_today = 0
@@ -710,8 +720,6 @@ async def compute_schedule(self, deviceSN):
710720 "minSocOnGrid" : reserve ,
711721 "maxSoc" : 100 ,
712722 }
713- self .device_current_schedule [deviceSN ] = battery_slots
714-
715723 # Sort the groups so that group 0 is the first charge slot and group 1 is the first discharge slot
716724 # For multiple slots pick the enabled one first
717725 charge_group = {}
@@ -808,7 +816,7 @@ async def set_scheduler_enabled(self, deviceSN, enabled):
808816 # Do change enable if not already modified
809817 if self .device_scheduler .get (deviceSN , {}).get ("enable" , None ) == enabled_value :
810818 self .log ("Fox: Debug: Scheduler for {} already set to enabled {}" .format (deviceSN , enabled ))
811- return
819+ return False
812820
813821 self .log ("Fox: Debug: Setting scheduler enabled={} was {} for {}" .format (enabled , self .device_scheduler .get (deviceSN , {}).get ("enable" , None ), deviceSN ))
814822
@@ -818,6 +826,8 @@ async def set_scheduler_enabled(self, deviceSN, enabled):
818826 if deviceSN not in self .device_scheduler :
819827 self .device_scheduler [deviceSN ] = {}
820828 self .device_scheduler [deviceSN ]["enable" ] = enabled_value
829+ return True
830+ return False
821831
822832 async def set_scheduler (self , deviceSN , groups ):
823833 """
@@ -829,7 +839,7 @@ async def set_scheduler(self, deviceSN, groups):
829839 if not groups :
830840 if current_enable :
831841 # Disable scheduler if enabled and no groups
832- await self .set_scheduler_enabled (deviceSN , False )
842+ return await self .set_scheduler_enabled (deviceSN , False )
833843 else :
834844 # Compare old and new schedule to see if it needs setting
835845 same = schedules_are_equal (datetime .now (), current_groups , groups )
@@ -841,6 +851,8 @@ async def set_scheduler(self, deviceSN, groups):
841851 self .device_scheduler [deviceSN ] = {}
842852 self .device_scheduler [deviceSN ]["enable" ] = True
843853 self .device_scheduler [deviceSN ]["groups" ] = groups
854+ return True
855+ return False
844856
845857 async def publish_schedule_settings_ha (self , deviceSN ):
846858 """
@@ -1029,7 +1041,8 @@ async def get_scheduler(self, deviceSN, checkBattery=True):
10291041
10301042 # Min SOC On grid can change as Predbat writes reserve so this must be the real min
10311043 self .fdsoc_min [deviceSN ] = result .get ("properties" , {}).get ("fdsoc" , {}).get ("range" , {}).get ("min" , 10 )
1032- self .log ("Fox: Fetched schedule got {} fdPwr max {} fdSoc min {}" .format (result , self .fdpwr_max [deviceSN ], self .fdsoc_min [deviceSN ]))
1044+ self .device_scheduler_count [deviceSN ] = len (result .get ("groups" , []))
1045+ self .log ("Fox: Fetched schedule got {} fdPwr max {} fdSoc min {} groups {}" .format (result , self .fdpwr_max [deviceSN ], self .fdsoc_min [deviceSN ], self .device_scheduler_count [deviceSN ]))
10331046 self .device_scheduler [deviceSN ] = result
10341047 return result
10351048 return {}
@@ -1514,7 +1527,7 @@ async def write_battery_schedule_event(self, entity_id, value):
15141527 entity_id = entity_id .replace (f"number.{ self .prefix } _fox_" , "" )
15151528 sn = entity_id .split ("_" )[0 ]
15161529 serial = None
1517- for s in self .device_current_schedule :
1530+ for s in self .device_detail :
15181531 if s .lower () == sn .lower ():
15191532 serial = s
15201533 break
@@ -1619,12 +1632,10 @@ async def apply_battery_schedule(self, serial):
16191632 new_schedule .append (
16201633 {"enable" : 1 , "startHour" : start_hour , "startMinute" : start_minute , "endHour" : end_hour , "endMinute" : end_minute , "workMode" : "ForceDischarge" , "fdSoc" : max (soc , reserve ), "maxSoc" : reserve , "fdPwr" : power , "minSocOnGrid" : reserve }
16211634 )
1622- new_schedule = validate_schedule (new_schedule , reserve , fdPwr_max )
1635+ new_schedule = validate_schedule (new_schedule , reserve , fdPwr_max , self . device_scheduler_count . get ( serial , 0 ) )
16231636 self .log ("Fox: New schedule for {}: {}" .format (serial , new_schedule ))
1624- result = await self .set_scheduler (serial , new_schedule )
1625- if result is not None :
1626- self .device_current_schedule [serial ] = new_schedule
1627- await self .publish_data ()
1637+ await self .set_scheduler (serial , new_schedule )
1638+ await self .publish_data ()
16281639
16291640 async def automatic_config (self ):
16301641 """
@@ -1754,20 +1765,90 @@ def set_arg(self, key, value):
17541765 print (f"Set arg { key } = { value } (state={ state } )" )
17551766
17561767
1757- async def test_fox_api (sn , api_key , token_hash ): # pragma: no cover
1768+ async def test_write_schedule (sn , api_key , token_hash , token_expires , supabase_url , supabase_key , user_id ): # pragma: no cover
1769+ """
1770+ Write a hardcoded test schedule to the Fox API and read it back to verify
1771+ """
1772+ if supabase_url :
1773+ os .environ ["SUPABASE_URL" ] = supabase_url
1774+ if supabase_key :
1775+ os .environ ["SUPABASE_KEY" ] = supabase_key
1776+
1777+ schedule = [
1778+ {"endHour" : 20 , "fdPwr" : 7000 , "minSocOnGrid" : 10 , "workMode" : "ForceDischarge" , "fdSoc" : 10 , "enable" : 1 , "startHour" : 20 , "maxSoc" : 100 , "startMinute" : 16 , "endMinute" : 30 }, # 20:16 - 20:30
1779+ ]
1780+
1781+ mock_base = MockBase ()
1782+ if user_id :
1783+ mock_base .args ["user_id" ] = user_id
1784+
1785+ arg_dict = {"key" : api_key or "" , "automatic" : False }
1786+ if token_hash or supabase_url :
1787+ arg_dict ["auth_method" ] = "oauth"
1788+ arg_dict ["token_hash" ] = token_hash
1789+ arg_dict ["token_expires_at" ] = token_expires
1790+ fox_api = FoxAPI (mock_base , ** arg_dict )
1791+
1792+ # Discover devices if no SN provided
1793+ devices = await fox_api .get_device_list ()
1794+ if not devices :
1795+ print ("No devices found" )
1796+ return
1797+ serial = sn if sn else devices [0 ].get ("deviceSN" )
1798+ print (f"Using device SN: { serial } " )
1799+
1800+ # Fetch device detail so hasBattery check passes
1801+ await fox_api .get_device_detail (serial )
1802+
1803+ # Initial read back to check connectivity
1804+ read_back = await fox_api .get_scheduler (serial , checkBattery = False )
1805+
1806+ # Write the schedule
1807+ print (f"Writing schedule:\n { json .dumps (schedule , indent = 2 )} " )
1808+ schedule = validate_schedule (schedule , 10 , 7000 , fox_api .device_scheduler_count .get (serial , 0 ))
1809+ write_ok = await fox_api .set_scheduler (serial , schedule )
1810+ print (f"Write result: { write_ok } " )
1811+
1812+ # Read back and print
1813+ print ("Reading back schedule..." )
1814+ read_back = await fox_api .get_scheduler (serial , checkBattery = False )
1815+ read_back_groups = read_back .get ("groups" , [])
1816+ print (f"Read back schedule:\n { json .dumps (read_back , indent = 2 )} " )
1817+
1818+ # Compare written schedule against read-back groups
1819+ from datetime import datetime as _dt
1820+
1821+ match = schedules_are_equal (_dt .now (), schedule , read_back_groups )
1822+ print (f"Schedule match: { match } " )
1823+ if not match :
1824+ print ("WARNING: Written schedule does not match read-back schedule" )
1825+
1826+
1827+ async def test_fox_api (sn , api_key , token_hash , token_expires , supabase_url , supabase_key , user_id ): # pragma: no cover
17581828 """
17591829 Run a test
17601830 """
1761- print (f"Testing Fox API with key: { api_key [:10 ]} ..." )
1831+ # Set supabase env vars before constructing FoxAPI so OAuthMixin can find them
1832+ if supabase_url :
1833+ os .environ ["SUPABASE_URL" ] = supabase_url
1834+ if supabase_key :
1835+ os .environ ["SUPABASE_KEY" ] = supabase_key
1836+
1837+ if api_key :
1838+ print (f"Testing Fox API with api-key: { api_key [:10 ]} ..." )
1839+ else :
1840+ print ("Testing Fox API with OAuth token-hash..." )
17621841
17631842 # Create a mock base object
17641843 mock_base = MockBase ()
1844+ if user_id :
1845+ mock_base .args ["user_id" ] = user_id
17651846
1766- # Create FoxAPI instance with a lambda that returns the API key
1767- arg_dict = {}
1768- arg_dict = {"key" : api_key , "automatic" : True , "token_hash" : token_hash }
1769- if token_hash :
1847+ arg_dict = {"key" : api_key or "" , "automatic" : True }
1848+ if token_hash or supabase_url :
17701849 arg_dict ["auth_method" ] = "oauth"
1850+ arg_dict ["token_hash" ] = token_hash
1851+ arg_dict ["token_expires_at" ] = token_expires
17711852 fox_api = FoxAPI (mock_base , ** arg_dict )
17721853
17731854 # Call run() once
@@ -1782,16 +1863,29 @@ def main(): # pragma: no cover
17821863 """
17831864 parser = argparse .ArgumentParser (description = "Test Fox API" )
17841865 parser .add_argument ("--serial" , action = "store" , default = None , help = "Fox API serial number" )
1785- parser .add_argument ("--api-key" , required = True , help = "Fox API key" )
1786- parser .add_argument ("--token-hash" , action = "store" , help = "Fox API token hash" )
1866+ auth_group = parser .add_mutually_exclusive_group (required = True )
1867+ auth_group .add_argument ("--api-key" , help = "Fox API key" )
1868+ auth_group .add_argument ("--token-hash" , action = "store" , help = "Fox API OAuth token hash" )
1869+ parser .add_argument ("--token-expires" , action = "store" , help = "Fox API OAuth token expiry timestamp" )
1870+ parser .add_argument ("--supabase-url" , action = "store" , help = "Supabase URL for OAuth token refresh" )
1871+ parser .add_argument ("--supabase-key" , action = "store" , help = "Supabase anon key for OAuth token refresh" )
1872+ parser .add_argument ("--user-id" , action = "store" , help = "Supabase user ID for OAuth token refresh" )
1873+ parser .add_argument ("--write-schedule" , action = "store_true" , help = "Write a test schedule and read it back instead of running a full test" )
17871874
17881875 args = parser .parse_args ()
1789- key = args .api_key
17901876 serial = args .serial
1877+ api_key = args .api_key
17911878 token_hash = args .token_hash
1879+ token_expires = args .token_expires
1880+ supabase_url = args .supabase_url
1881+ supabase_key = args .supabase_key
1882+ user_id = args .user_id
17921883
17931884 # Run the test
1794- asyncio .run (test_fox_api (serial , key , token_hash ))
1885+ if args .write_schedule :
1886+ asyncio .run (test_write_schedule (serial , api_key , token_hash , token_expires , supabase_url , supabase_key , user_id ))
1887+ else :
1888+ asyncio .run (test_fox_api (serial , api_key , token_hash , token_expires , supabase_url , supabase_key , user_id ))
17951889
17961890
17971891if __name__ == "__main__" :
0 commit comments