Skip to content

Commit e778e41

Browse files
Merge pull request #3835 from springfall2008/fix/fox_schedule
Fix fox schedule issues
2 parents fe4ad89 + 6edb9fb commit e778e41

4 files changed

Lines changed: 210 additions & 53 deletions

File tree

‎apps/predbat/fox.py‎

Lines changed: 120 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import asyncio
1818
from datetime import datetime, timedelta, timezone
19+
import os
1920
import time
2021
import hashlib
2122
from 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

209219
class 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

17971891
if __name__ == "__main__":

‎apps/predbat/predbat.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
import requests
3737
import asyncio
3838

39-
THIS_VERSION = "v8.37.4"
39+
THIS_VERSION = "v8.37.5"
4040

4141
from download import predbat_update_move, predbat_update_download, check_install, resolve_predbat_repository, DEFAULT_PREDBAT_REPOSITORY
4242
from const import MINUTE_WATT

0 commit comments

Comments
 (0)