Repository navigation
Expand file tree
/
Copy pathfox.py
More file actions
2400 lines (2144 loc) · 129 KB
/
Copy pathfox.py
File metadata and controls
2400 lines (2144 loc) · 129 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -----------------------------------------------------------------------------
# Predbat Home Battery System
# Copyright Trefor Southwell 2026 - All Rights Reserved
# This application maybe used for personal use only and not for commercial use
# -----------------------------------------------------------------------------
# Fox API Library
# -----------------------------------------------------------------------------
"""Fox ESS Cloud API integration.
Cloud API client for Fox ESS inverters with scheduler-based charge/discharge
control. Manages work mode scheduling (SelfUse, ForceCharge, ForceDischarge,
Feedin), real-time monitoring, and device settings via the Fox ESS Cloud API.
"""
import asyncio
from datetime import datetime, timedelta, timezone
import os
import time
import hashlib
from predbat_metrics import record_api_call
import aiohttp
import json
import argparse
import random
from component_base import ComponentBase
from mock_base import MockBase
from oauth_mixin import OAuthMixin
from utils import dp2
# Define TIME_FORMAT_HA locally to avoid dependency issues
TIME_FORMAT_HA = "%Y-%m-%dT%H:%M:%S%z"
BASE_TIME = datetime.strptime("00:00", "%H:%M")
OPTIONS_TIME = [((BASE_TIME + timedelta(seconds=minute * 60)).strftime("%H:%M")) for minute in range(0, 24 * 60, 1)]
OPTIONS_TIME_FULL = [((BASE_TIME + timedelta(seconds=minute * 60)).strftime("%H:%M") + ":00") for minute in range(0, 24 * 60, 1)]
FOX_DOMAIN = "https://www.foxesscloud.com"
FOX_LANG = "en"
TIMEOUT = 60
FOX_RETRIES = 10
FOX_SETTINGS = ["ExportLimit", "MaxSoc", "GridCode", "WorkMode", "MinSoc", "MinSocOnGrid"]
OPTIONS_WORK_MODE = ["SelfUse", "ForceCharge", "ForceDischarge", "Feedin"]
# Fox errno codes meaning "this device does not support this setting" - permanent, not transient,
# so once seen for a given device/key we stop polling and writing that setting entirely
FOX_SETTINGS_UNSUPPORTED_ERRNO = (42015, 44096)
# Fallback values applied when a setting is found to be unsupported, so the rest of Predbat still
# has something sensible to work with instead of a missing entry. Keys not listed here (ExportLimit,
# MinSocOnGrid) are simply left absent - existing callers already treat a missing setting as the
# safe default (e.g. export_limit falls back to unlimited). WorkMode carries an enumList so it
# still publishes as a select entity: automatic_config wires inverter_mode to a hardcoded
# select.*_setting_workmode entity id, and a plain sensor there would leave it unresolvable (None).
FOX_SETTINGS_DEFAULTS = {
"workmode": {"value": "SelfUse", "enumList": OPTIONS_WORK_MODE},
"maxsoc": {"value": 100},
"gridcode": {"value": None},
"minsoc": {"value": 10},
}
# Bump this whenever the shape/derivation of device_settings entries changes (e.g. adding
# range/unit/precision to schedule-derived settings) so a persisted cache from before that
# change is detected as stale and forces one settings/scheduler refresh regardless of age,
# instead of being reused as-is - potentially forever, since nothing else would ever correct it.
FOX_SETTINGS_CACHE_VERSION = 3
# Storage cache keys for device data persisted between reboots
FOX_CACHE_KEYS = ["device_list", "device_detail", "battery_charging_time", "device_settings", "device_settings_unavailable", "device_settings_version", "scheduler_state", "device_values", "device_production_month"]
# Maximum age (minutes) of cached data before an API refresh is triggered
FOX_REFRESH_STATIC = 24 * 60 # Device list, detail and battery charge times rarely change
FOX_REFRESH_SETTINGS = 60 # Device settings and scheduler
FOX_REFRESH_PRODUCTION = 15 # Monthly production totals
FOX_REFRESH_REALTIME = 5 # Real time monitoring data
# Fox productType codes that must use the v2/v3 scheduler API. Fox's v1 scheduler
# endpoints return errno 41200 permanently for these (EVO series) even though the
# devices fully support the scheduler — verified against EVO 10-5-H units (productType
# "812"); KH-series and others (which work on v1) are deliberately excluded. Detection is
# by productType, not by v1 failure: errno 41200 doubles as a transient rate-limit code, so
# keying off a v1 failure would wrongly reroute a healthy device after a single blip.
# Extend this set as new EVO product types are confirmed.
FOX_V2_SCHEDULER_PRODUCT_TYPES = {"812"}
# Dummy attribute table for testing
fox_attribute_table = {"mode": {}}
def schedules_are_equal(time_now, schedule1, schedule2):
"""
Docstring for schedules_are_equal
:param time_now: Datetime object
:param schedule1: Schedule to compare
:param schedule2: Schedule to compare
"""
schedule1 = sort_schedule_by_start_time(schedule1)
schedule2 = sort_schedule_by_start_time(schedule2)
same = True
if len(schedule1) != len(schedule2):
same = False
else:
for i in range(0, len(schedule2)):
for key in schedule2[i]:
if schedule2[i][key] != schedule1[i].get(key, None):
same = False
break
if not same:
break
return same
def print_schedule_diff(label1, schedule1, label2, schedule2): # pragma: no cover
"""
Print a per-group, per-key diff between two schedules for debugging a reported mismatch.
Sorts/strips both schedules the same way schedules_are_equal does, then - unlike
schedules_are_equal, which stops at the first mismatched key - compares the union of keys
across every group so a difference caused by a field one side simply doesn't have (e.g.
exportLimit only appearing in a read-back) is reported clearly instead of read as a generic
"not equal".
"""
sorted1 = sort_schedule_by_start_time(schedule1)
sorted2 = sort_schedule_by_start_time(schedule2)
if len(sorted1) != len(sorted2):
print(f"Group count differs: {label1}={len(sorted1)} groups, {label2}={len(sorted2)} groups")
for i in range(max(len(sorted1), len(sorted2))):
entry1 = sorted1[i] if i < len(sorted1) else None
entry2 = sorted2[i] if i < len(sorted2) else None
if entry1 is None:
print(f" [{i}] only in {label2}: {entry2}")
continue
if entry2 is None:
print(f" [{i}] only in {label1}: {entry1}")
continue
diff_keys = [key for key in sorted(set(entry1) | set(entry2)) if entry1.get(key, None) != entry2.get(key, None)]
if diff_keys:
print(f" [{i}] start={entry1.get('startHour')}:{entry1.get('startMinute', 0):02d} mismatched keys: {diff_keys}")
for key in diff_keys:
print(f" {key}: {label1}={entry1.get(key, '<missing>')} {label2}={entry2.get(key, '<missing>')}")
def end_minute_inclusive_to_exclusive(end_hour, end_minute):
"""
Adjust end minute that is inclusive to exclusive (add 1 minute).
Handles overflow to next hour and special case at end of day.
Args:
end_hour: Hour value (0-23)
end_minute: Minute value (0-59)
Returns:
Tuple of (adjusted_end_hour, adjusted_end_minute)
"""
if end_minute != 0:
end_minute += 1
if end_minute == 60:
if end_hour == 23:
end_minute = 59
else:
end_minute = 0
end_hour += 1
return end_hour, end_minute
def end_minute_exclusive_to_inclusive(end_hour, end_minute):
"""
Convert exclusive end time to inclusive format (subtract 1 minute).
Handles underflow to previous hour and special case at start of day.
Args:
end_hour: Hour value (0-23)
end_minute: Minute value (0-59)
Returns:
Tuple of (adjusted_end_hour, adjusted_end_minute)
"""
if end_minute == 0:
end_hour -= 1
end_minute = 59
if end_hour < 0:
end_hour = 23
end_minute = 59
elif end_minute != 59:
end_minute -= 1
return end_hour, end_minute
def minutes_to_schedule_time(start_hour, start_minute):
start_minutes = start_hour * 60 + start_minute
return start_minutes
def schedule_strip_disabled(schedule):
new_schedule = []
for entry in schedule:
if entry.get("enable", 0) == 1:
new_schedule.append(entry)
return new_schedule
def sort_schedule_by_start_time(schedule):
schedule = schedule_strip_disabled(schedule)
schedule = sorted(schedule, key=lambda x: (minutes_to_schedule_time(x["startHour"], x["startMinute"])))
return schedule
def pad_schedule(schedule, target_count, reserve, fdPwr_max):
"""Pad schedule to target_count with disabled SelfUse zero-time entries."""
disabled_entry = {"enable": 0, "startHour": 0, "startMinute": 0, "endHour": 0, "endMinute": 0, "workMode": "SelfUse", "fdSoc": reserve, "maxSoc": 100, "fdPwr": fdPwr_max, "minSocOnGrid": reserve}
while target_count > 0 and len(schedule) < target_count:
schedule.append(disabled_entry.copy())
return schedule
def validate_schedule(new_schedule, reserve, fdPwr_max, target_count=0):
# Sort schedule by start time, closest to midnight first
new_schedule = sort_schedule_by_start_time(new_schedule)
if not new_schedule:
# No schedule entries so disable
result = [{"enable": 1, "startHour": 0, "startMinute": 0, "endHour": 23, "endMinute": 59, "workMode": "SelfUse", "fdSoc": reserve, "maxSoc": 100, "fdPwr": fdPwr_max, "minSocOnGrid": reserve}]
return pad_schedule(result, target_count, reserve, fdPwr_max)
# Process all schedule entries
result_schedule = []
# Adjust end times to be inclusive for all entries
for entry in new_schedule:
end_hour = entry["endHour"]
end_minute = entry["endMinute"]
end_hour, end_minute = end_minute_exclusive_to_inclusive(end_hour, end_minute)
entry["endHour"] = end_hour
entry["endMinute"] = end_minute
# Add demand mode before first entry if needed
first_entry = new_schedule[0]
if first_entry["startHour"] != 0 or first_entry["startMinute"] != 0:
demand_end_hour, demand_end_minute = end_minute_exclusive_to_inclusive(first_entry["startHour"], first_entry["startMinute"])
result_schedule.append({"enable": 1, "startHour": 0, "startMinute": 0, "endHour": demand_end_hour, "endMinute": demand_end_minute, "workMode": "SelfUse", "fdSoc": reserve, "maxSoc": 100, "fdPwr": fdPwr_max, "minSocOnGrid": reserve})
# Add schedule entries and fill gaps between them
for i, entry in enumerate(new_schedule):
result_schedule.append(entry)
# Check if there's a gap between this entry and the next
if i < len(new_schedule) - 1:
next_entry = new_schedule[i + 1]
current_end_hour = entry["endHour"]
current_end_minute = entry["endMinute"]
next_start_hour = next_entry["startHour"]
next_start_minute = next_entry["startMinute"]
# Calculate gap start time (one minute after current entry ends)
gap_start_hour = current_end_hour
gap_start_minute = current_end_minute
if gap_start_minute == 59:
gap_start_hour += 1
gap_start_minute = 0
else:
gap_start_minute += 1
# Check if there's actually a gap
gap_start_minutes = gap_start_hour * 60 + gap_start_minute
next_start_minutes = next_start_hour * 60 + next_start_minute
if gap_start_minutes < next_start_minutes:
# Fill the gap with SelfUse
gap_end_hour, gap_end_minute = end_minute_exclusive_to_inclusive(next_start_hour, next_start_minute)
result_schedule.append(
{"enable": 1, "startHour": gap_start_hour, "startMinute": gap_start_minute, "endHour": gap_end_hour, "endMinute": gap_end_minute, "workMode": "SelfUse", "fdSoc": reserve, "maxSoc": 100, "fdPwr": fdPwr_max, "minSocOnGrid": reserve}
)
# Add demand mode after last entry if needed
last_entry = new_schedule[-1]
if last_entry["endHour"] != 23 or last_entry["endMinute"] != 59:
demand_start_hour = last_entry["endHour"]
demand_start_minute = last_entry["endMinute"]
if demand_start_minute == 59:
demand_start_hour += 1
demand_start_minute = 0
else:
demand_start_minute += 1
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})
# Pad to target_count with disabled SelfUse entries if the device originally had more slots
return pad_schedule(result_schedule, target_count, reserve, fdPwr_max)
# Group fields that the v3 scheduler API nests inside 'extraParam'
V3_EXTRA_PARAM_KEYS = ["minSocOnGrid", "fdSoc", "fdPwr", "maxSoc", "importLimit", "exportLimit", "pvLimit", "reactivePower"]
# Schedule group field -> FOX device-setting name, for exportLimit/importLimit/pvLimit values
# that a schedule read observes but Predbat's own schedule writes never populate. Carried
# forward on a v3 write (see get_schedule_extra_param) using the max seen across all groups
# (see update_settings_from_schedule) so writing a new schedule does not implicitly clear them.
SCHEDULE_DERIVED_LIMITS = [("exportLimit", "ExportLimit"), ("importLimit", "ImportLimit"), ("pvLimit", "PvLimit")]
# All schedule group fields derived into device_settings on a read, each with the aggregate used
# to combine per-slot values into a single device-level setting. Includes SCHEDULE_DERIVED_LIMITS
# plus maxSoc (max) and minSocOnGrid (min), which are read-only derivations - not carried on
# write since Predbat's schedule groups already set those per-slot from the live plan.
SCHEDULE_DERIVED_SETTINGS = [(group_key, setting_key, max) for group_key, setting_key in SCHEDULE_DERIVED_LIMITS] + [("maxSoc", "MaxSoc", max), ("minSocOnGrid", "MinSocOnGrid", min)]
def groups_to_v3(groups, extra_extra_param=None):
"""
Convert flat v1 scheduler groups into the nested shape used by the v3 API
Only enabled groups are carried (v1 uses fixed slots with enable flags while v3
takes just the active periods) and SOC/power fields move inside 'extraParam',
matching the request shape the foxesscloud reference library sends.
extra_extra_param merges additional extraParam fields (exportLimit/importLimit/pvLimit)
into every group, since Predbat's own schedule groups never populate them and omitting
them from the write risks Fox resetting them to a default. It must not contain maxSoc or
minSocOnGrid, which already come from the live plan per-group via V3_EXTRA_PARAM_KEYS.
"""
v3_groups = []
for group in groups:
if not group.get("enable", 1):
continue
v3_group = {key: value for key, value in group.items() if key not in V3_EXTRA_PARAM_KEYS and key != "enable"}
extra_param = {key: group[key] for key in V3_EXTRA_PARAM_KEYS if key in group}
if extra_extra_param:
extra_param.update(extra_extra_param)
if extra_param:
v3_group["extraParam"] = extra_param
v3_groups.append(v3_group)
return v3_groups
class FoxAPI(ComponentBase, OAuthMixin):
"""Fox API client."""
def initialize(self, key, automatic, automatic_ignore_pv=False, inverter_sn=None, auth_method=None, token_expires_at=None, token_hash=None):
"""Initialise the Fox API component"""
self.key = key
self.automatic = automatic
self.automatic_ignore_pv = automatic_ignore_pv
self.failures_total = 0
self.device_list = []
self.device_detail = {}
self.device_power_generation = {}
self.available_variables = {}
self.device_values = {}
self.device_settings = {}
# {deviceSN: [key_lower, ...]} of settings the device has reported as unsupported (errno
# 42015/44096), so they are never polled or written to again
self.device_settings_unavailable = {}
# Version of the persisted device_settings cache actually on disk; 0 (never matches
# FOX_SETTINGS_CACHE_VERSION) until load_cached_data() loads a real value, so a fresh
# install/first-ever poll is treated the same as a stale cache - both force one refresh
self.device_settings_version = 0
# Set within request_get_func for the duration of a single request_get() call, so callers
# can tell an "unsupported" failure (permanent) apart from a transient one
self.last_unsupported = False
self.device_production_month = {}
self.device_production_year = {}
self.device_battery_charging_time = {}
self.device_scheduler = {}
self.local_schedule = {}
self.fdpwr_max = {}
self.fdsoc_min = {}
self.device_scheduler_count = {}
# Age (datetime of last update) of each cached data category, used to drive age-based refresh
self.data_age = {}
# Rate limiting tracking
self.requests_today = 0
self.rate_limit_errors_today = 0
self.start_time_today = None
self.last_midnight_utc = None
# Initialise OAuth support
self._init_oauth(auth_method, key, token_expires_at, "fox_ess")
self.token_hash = token_hash or ""
# Convert inverter_sn to list
if inverter_sn is None:
self.inverter_sn_filter = []
elif isinstance(inverter_sn, str):
self.inverter_sn_filter = [inverter_sn]
else:
self.inverter_sn_filter = inverter_sn
def should_allow_retry(self):
"""
Calculate if retries should be allowed based on current API usage rate.
Returns True if rate is <= 60/hour, False otherwise.
Uses 30-minute minimum floor to prevent false positives during cold start.
"""
if not self.start_time_today:
return True
elapsed_seconds = max((datetime.now(timezone.utc) - self.start_time_today).total_seconds(), 1800)
hourly_rate = (self.requests_today * 3600) / elapsed_seconds
return hourly_rate <= 60
def is_alive(self):
"""
Check if the API is alive
"""
return self.api_started and self.device_list
async def run(self, seconds, first):
"""
Main run loop
"""
# Initialise start time on first run
if first:
self.start_time_today = datetime.now(timezone.utc)
self.last_midnight_utc = self.midnight_utc
# Check for midnight boundary crossing and reset daily counters
current_midnight = self.midnight_utc
if self.last_midnight_utc is not None and self.last_midnight_utc != current_midnight:
# Midnight has passed - reset daily counters
self.log(f"Fox: Midnight reset - requests_today: {self.requests_today}, " f"rate_limit_errors_today: {self.rate_limit_errors_today}")
self.requests_today = 0
self.rate_limit_errors_today = 0
self.start_time_today = datetime.now(timezone.utc)
self.last_midnight_utc = current_midnight
# Log API usage statistics
if self.start_time_today:
elapsed_seconds = max((datetime.now(timezone.utc) - self.start_time_today).total_seconds(), 1800)
elapsed_minutes = elapsed_seconds / 60
hourly_rate = (self.requests_today * 3600) / elapsed_seconds
retry_allowed = self.should_allow_retry()
self.log(f"Fox: API usage: {self.requests_today} requests over {elapsed_minutes:.1f} minutes " f"({hourly_rate:.1f}/hour), retry_allowed={retry_allowed}")
# On first start, restore cached device data from storage so that a quick reboot can
# avoid re-polling the Fox cloud API for settings/production/realtime data that is still fresh
if first:
await self.load_cached_data()
# Device list rarely changes - refresh based on the age of the cached data
if first or self._needs_refresh("device_list", FOX_REFRESH_STATIC):
prev_sns = {d.get("deviceSN") for d in self.device_list}
devices = await self.get_device_list()
self.log("Fox API: Found {} devices".format(len(self.device_list)))
# Only persist and reset the 24h refresh timer when the poll actually succeeded; on a
# transient API failure we keep any cached list and retry on the next cycle
if devices:
await self._save_cache("device_list", self.device_list)
# If the set of device serial numbers changed, drop all per-device caches so
# every category is re-fetched for the new device immediately
new_sns = {d.get("deviceSN") for d in self.device_list}
if new_sns != prev_sns:
for key in FOX_CACHE_KEYS:
if key != "device_list":
self.data_age.pop(key, None)
if not self.device_list:
self.log("Error: FoxAPI: No devices found, unable to start API")
return False
# Device detail and battery charging times rarely change - refresh based on age
if self._needs_refresh("device_detail", FOX_REFRESH_STATIC):
detail_updated = False
battery_updated = False
for device in self.device_list:
sn = device.get("deviceSN", None)
if sn:
# get_device_detail returns None on failure, the data (always non-empty) on success
if await self.get_device_detail(sn) is not None:
detail_updated = True
# get_battery_charging_time returns {} on failure or for a non-battery device
if await self.get_battery_charging_time(sn) is not None:
battery_updated = True
# Persist each cache once after polling all devices, only when a poll succeeded
if detail_updated:
await self._save_cache("device_detail", self.device_detail)
if battery_updated:
await self._save_cache("battery_charging_time", self.device_battery_charging_time)
# Seed device values from history only on a cold start where we have nothing cached
if first and not self.device_values:
for device in self.device_list:
sn = device.get("deviceSN", None)
if sn:
await self.get_device_history(sn)
# Device settings and scheduler - refresh based on age. Also force a refresh, regardless
# of age, when the persisted cache predates FOX_SETTINGS_CACHE_VERSION - a one-time
# self-heal after a code update changes how settings are derived/shaped (e.g. adding
# range/unit/precision), so a customer isn't stuck reusing a stale-shaped cached value
# for up to FOX_REFRESH_SETTINGS. Once refreshed, the version is saved and this stops
# firing - restarts do not otherwise force a refresh, to avoid hammering the API.
stale_cache_version = self.device_settings_version != FOX_SETTINGS_CACHE_VERSION
settings_refresh = stale_cache_version or self._needs_refresh("device_settings", FOX_REFRESH_SETTINGS)
if settings_refresh:
settings_updated = False
scheduler_updated = False
for device in self.device_list:
sn = device.get("deviceSN", None)
if sn:
# Both return {} on failure or for a non-battery device, the data on success
if await self.get_device_settings(sn) is not None:
settings_updated = True
if await self.get_scheduler(sn) is not None:
scheduler_updated = True
if settings_updated or scheduler_updated:
# update_settings_from_schedule() (called from get_scheduler()) mutates
# device_settings too, so this must save on scheduler_updated as well - not
# just settings_updated - or a schedule-derived upgrade is lost on restart
await self._save_cache("device_settings", self.device_settings)
if stale_cache_version and scheduler_updated:
# Only get_scheduler() -> update_settings_from_schedule() can actually produce
# the upgraded (range/unit/precision) shape this version tracks, so the version
# must not be marked current on settings_updated alone
self.device_settings_version = FOX_SETTINGS_CACHE_VERSION
await self._save_cache("device_settings_version", FOX_SETTINGS_CACHE_VERSION)
if self.device_settings_unavailable:
await self._save_cache("device_settings_unavailable", self.device_settings_unavailable)
if scheduler_updated:
await self._save_cache("scheduler_state", self._scheduler_state())
# Recompute the local schedule (cheap, no API calls) on first start or after a refresh
if first or settings_refresh:
for device in self.device_list:
sn = device.get("deviceSN", None)
if sn:
await self.get_schedule_settings_ha(sn)
await self.compute_schedule(sn)
# Total production metrics - refresh based on age
production_refresh = self._needs_refresh("device_production_month", FOX_REFRESH_PRODUCTION)
if production_refresh:
production_updated = False
for device in self.device_list:
sn = device.get("deviceSN", None)
if sn:
# get_device_production_month returns None on failure
if await self.get_device_production_month(sn) is not None:
production_updated = True
if production_updated:
await self._save_cache("device_production_month", self.device_production_month)
# Real time monitoring data - refresh based on age
realtime_refresh = self._needs_refresh("device_values", FOX_REFRESH_REALTIME)
if realtime_refresh:
realtime_updated = False
for device in self.device_list:
sn = device.get("deviceSN", None)
if sn:
# get_real_time_data returns None on failure
if await self.get_real_time_data(sn) is not None:
realtime_updated = True
if realtime_updated:
await self._save_cache("device_values", self.device_values)
# Publish to HA whenever we have refreshed data (or on first start to populate entities)
if first or settings_refresh or production_refresh or realtime_refresh:
await self.publish_data()
# Automatic configuration on first run
if first and self.automatic:
await self.automatic_config()
return True
def _data_age_minutes(self, key):
"""
Return the age in minutes of the in-memory data for a cache key, or None if unknown.
"""
timestamp = self.data_age.get(key, None)
if timestamp is None:
return None
return (datetime.now(timezone.utc) - timestamp).total_seconds() / 60.0
def _needs_refresh(self, key, max_age_minutes):
"""
Return True if the data for a cache key is missing or older than max_age_minutes.
"""
age = self._data_age_minutes(key)
return age is None or age >= max_age_minutes
def _scheduler_state(self):
"""
Bundle the scheduler-derived state into a single object for caching.
"""
return {
"scheduler": self.device_scheduler,
"fdpwr_max": self.fdpwr_max,
"fdsoc_min": self.fdsoc_min,
"count": self.device_scheduler_count,
}
async def _save_cache(self, key, data):
"""
Save data to storage under the fox module and record its update time in memory.
"""
now = datetime.now(timezone.utc)
self.data_age[key] = now
if self.storage:
# Expire after a day so stale data doesn't linger in the cache forever
await self.storage.save("fox", key, data, format="json", expiry=now + timedelta(days=1))
async def _load_cache(self, key):
"""
Load cached data for a key from storage, recording its age. Returns None if absent.
"""
if not self.storage:
return None
data = await self.storage.load("fox", key)
if data is None:
return None
age = await self.storage.age("fox", key)
if age is None:
return None
self.data_age[key] = datetime.now(timezone.utc) - timedelta(minutes=age)
return data
async def load_cached_data(self):
"""
Restore cached device data from storage on startup.
Loads each category of device data that was previously saved, recording its age so the
age-based refresh logic can decide whether a fresh API poll is needed. This avoids
re-polling the Fox cloud API after a quick reboot when the data is still fresh.
"""
if not self.storage:
return
device_list = await self._load_cache("device_list")
if device_list is not None:
self.device_list = device_list
self.update_success_timestamp()
device_detail = await self._load_cache("device_detail")
if device_detail is not None:
self.device_detail = device_detail
battery_charging_time = await self._load_cache("battery_charging_time")
if battery_charging_time is not None:
self.device_battery_charging_time = battery_charging_time
device_settings = await self._load_cache("device_settings")
if device_settings is not None:
self.device_settings = device_settings
device_settings_unavailable = await self._load_cache("device_settings_unavailable")
if device_settings_unavailable is not None:
self.device_settings_unavailable = device_settings_unavailable
device_settings_version = await self._load_cache("device_settings_version")
if device_settings_version is not None:
self.device_settings_version = device_settings_version
scheduler_state = await self._load_cache("scheduler_state")
if isinstance(scheduler_state, dict):
self.device_scheduler = scheduler_state.get("scheduler", {})
self.fdpwr_max = scheduler_state.get("fdpwr_max", {})
self.fdsoc_min = scheduler_state.get("fdsoc_min", {})
self.device_scheduler_count = scheduler_state.get("count", {})
device_values = await self._load_cache("device_values")
if device_values is not None:
self.device_values = device_values
device_production_month = await self._load_cache("device_production_month")
if device_production_month is not None:
self.device_production_month = device_production_month
self.log("Fox: Restored cached data from storage for keys {}".format(sorted(self.data_age.keys())))
async def get_available_variables(self):
"""
Get available variables for the device
{
'todayYield': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'Today’s power generation', 'Energy-storage inverter': True},
'pvPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'PVPower', 'Energy-storage inverter': True},
'pv1Volt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'PV1Volt', 'Energy-storage inverter': True},
'pv1Current': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'PV1Current', 'Energy-storage inverter': True},
'pv1Power': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'PV1Power', 'Energy-storage inverter': True},
'pv2Volt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'PV2Volt', 'Energy-storage inverter': True},
'pv2Current': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'PV2Current', 'Energy-storage inverter': True},
'pv2Power': {'unit': 'kW', 'name': 'PV2Power'},
..
'epsPower': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'EPSPower', 'Energy-storage inverter': True},
'epsCurrentR': {'unit': 'A', 'Grid-tied inverter': False, 'name': 'EPS-RCurrent', 'Energy-storage inverter': True},
'epsVoltR': {'unit': 'V', 'Grid-tied inverter': False, 'name': 'EPS-RVolt', 'Energy-storage inverter': True},
'epsPowerR': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'EPS-RPower', 'Energy-storage inverter': True},
'epsCurrentS': {'unit': 'A', 'Grid-tied inverter': False, 'name': 'EPS-SCurrent', 'Energy-storage inverter': True},
'epsVoltS': {'unit': 'V', 'Grid-tied inverter': False, 'name': 'EPS-SVolt', 'Energy-storage inverter': True},
'epsPowerS': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'EPS-SPower', 'Energy-storage inverter': True},
'epsCurrentT': {'unit': 'A', 'Grid-tied inverter': False, 'name': 'EPS-TCurrent', 'Energy-storage inverter': True},
'epsVoltT': {'unit': 'V', 'Grid-tied inverter': False, 'name': 'EPS-TVolt', 'Energy-storage inverter': True},
'epsPowerT': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'EPS-TPower', 'Energy-storage inverter': True},
'RCurrent': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'RCurrent', 'Energy-storage inverter': True},
'RVolt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'RVolt', 'Energy-storage inverter': True},
'RFreq': {'unit': 'Hz', 'Grid-tied inverter': True, 'name': 'RFreq', 'Energy-storage inverter': True},
'RPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'RPower', 'Energy-storage inverter': True},
'SCurrent': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'SCurrent', 'Energy-storage inverter': True},
'SVolt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'SVolt', 'Energy-storage inverter': True},
'SFreq': {'unit': 'Hz', 'Grid-tied inverter': True, 'name': 'SFreq', 'Energy-storage inverter': True},
'SPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'SPower', 'Energy-storage inverter': True},
'TCurrent': {'unit': 'A', 'Grid-tied inverter': True, 'name': 'TCurrent', 'Energy-storage inverter': True},
'TVolt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'TVolt', 'Energy-storage inverter': True},
'TFreq': {'unit': 'Hz', 'Grid-tied inverter': True, 'name': 'TFreq', 'Energy-storage inverter': True},
'TPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'TPower', 'Energy-storage inverter': True},
'ambientTemperation': {'unit': '℃', 'Grid-tied inverter': True, 'name': 'AmbientTemperature', 'Energy-storage inverter': True},
'boostTemperation': {'unit': '℃', 'Grid-tied inverter': True, 'name': 'BoostTemperature', 'Energy-storage inverter': True},
'invTemperation': {'unit': '℃', 'Grid-tied inverter': True, 'name': 'InvTemperation', 'Energy-storage inverter': True},
'chargeTemperature': {'unit': '℃', 'Grid-tied inverter': True, 'name': 'ChargeTemperature', 'Energy-storage inverter': True},
'batTemperature': {'unit': '℃', 'Grid-tied inverter': False, 'name': 'batTemperature', 'Energy-storage inverter': True},
'dspTemperature': {'unit': '℃', 'Grid-tied inverter': True, 'name': 'DSPTemperature', 'Energy-storage inverter': True},
'loadsPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'Load Power', 'Energy-storage inverter': True},
'loadsPowerR': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'LoadsRPower', 'Energy-storage inverter': True},
'loadsPowerS': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'LoadsSPower', 'Energy-storage inverter': True},
'loadsPowerT': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'LoadsTPower', 'Energy-storage inverter': True},
'generationPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'Output Power', 'Energy-storage inverter': True},
'feedinPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'Feed-in Power', 'Energy-storage inverter': True},
'gridConsumptionPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'GridConsumption Power', 'Energy-storage inverter': True},
'invBatVolt': {'unit': 'V', 'Grid-tied inverter': True, 'name': 'InvBatVolt', 'Energy-storage inverter': True},
'invBatCurrent': {'note': 'Positive Discharge, Negative Charge', 'unit': 'A', 'Grid-tied inverter': False, 'name': 'InvBatCurrent', 'Energy-storage inverter': True},
'invBatPower': {'note': 'Positive Discharge, Negative Charge', 'unit': 'kW', 'Grid-tied inverter': False, 'name': 'invBatPower', 'Energy-storage inverter': True},
'batChargePower': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'Charge Power', 'Energy-storage inverter': True},
'batDischargePower': {'unit': 'kW', 'Grid-tied inverter': False, 'name': 'Discharge Power', 'Energy-storage inverter': True},
'batVolt': {'unit': 'V', 'Grid-tied inverter': False, 'name': 'BatVolt', 'Energy-storage inverter': True},
'batCurrent': {'unit': 'A', 'Grid-tied inverter': False, 'name': 'BatCurrent', 'Energy-storage inverter': True},
'meterPower': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'MeterPower', 'Energy-storage inverter': True},
'meterPower2': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'Meter2Power', 'Energy-storage inverter': True},
'meterPowerR': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'MeterRPower', 'Energy-storage inverter': True},
'meterPowerS': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'MeterSPower', 'Energy-storage inverter': True},
'meterPowerT': {'unit': 'kW', 'Grid-tied inverter': True, 'name': 'MeterTPower', 'Energy-storage inverter': True},
'SoC': {'unit': '%', 'Grid-tied inverter': False, 'name': 'SoC', 'Energy-storage inverter': True},
'ReactivePower': {'unit': 'kVar', 'Grid-tied inverter': True, 'name': 'ReactivePower', 'Energy-storage inverter': True},
'PowerFactor': {'Grid-tied inverter': True, 'name': 'PowerFactor', 'Energy-storage inverter': True},
'generation': {'unit': 'kWh', 'Grid-tied inverter': True, 'name': 'Cumulative power generation', 'Energy-storage inverter': True},
'ResidualEnergy': {'unit': '0.01kWh', 'Grid-tied inverter': False, 'name': 'Battery Residual Energy', 'Energy-storage inverter': True},
'runningState': {'Grid-tied inverter': True, 'name': 'Running State', 'Energy-storage inverter': True, 'enum': {'165': 'fault', '166': 'permanent-fault', '167': 'standby', '168': 'upgrading', '169': 'fct', '170': 'illegal', '160': 'self-test', '161': 'waiting', '162': 'checking', '163': 'on-grid', '164': 'off-grid'}},
'batStatus': {'Grid-tied inverter': False, 'name': 'Battery Status', 'Energy-storage inverter': True},
'batStatusV2': {'Grid-tied inverter': False, 'name': 'Battery Status Name', 'Energy-storage inverter': True},
'currentFault': {'Grid-tied inverter': True, 'name': 'The current error code is reported', 'Energy-storage inverter': True},
'currentFaultCount': {'Grid-tied inverter': True, 'name': 'The number of errors', 'Energy-storage inverter': True},
'energyThroughput': {'unit': 'Wh', 'Grid-tied inverter': False, 'name': 'Battery throughput', 'Energy-storage inverter': True},
'SOH': {'unit': '%', 'Grid-tied inverter': False, 'name': 'SOH', 'Energy-storage inverter': True},
'gridConsumption': {'unit': 'kWh', 'Grid-tied inverter': True, 'name': 'Total grid electricity consumption', 'Energy-storage inverter': True},
'loads': {'unit': 'kWh', 'Grid-tied inverter': True, 'name': 'Load power consumption', 'Energy-storage inverter': True},
'feedin': {'unit': 'kWh', 'Grid-tied inverter': True, 'name': 'The total energy of the feeder', 'Energy-storage inverter': True},
'chargeEnergyToTal': {'unit': 'kWh', 'Grid-tied inverter': False, 'name': 'Total charge energy', 'Energy-storage inverter': True},
'dischargeEnergyToTal': {'unit': 'kWh', 'Grid-tied inverter': False, 'name': 'Total discharge energy', 'Energy-storage inverter': True}
"""
GET_AVAILABLE_VARIABLES = "/op/v0/device/variable/get"
result = await self.request_get(GET_AVAILABLE_VARIABLES)
available_data = {}
if result is not None and isinstance(result, list):
for variable_item in result:
for variable_id in variable_item:
variable = variable_item.get(variable_id, {})
name = variable.get("name", {})
name = name.get(FOX_LANG, "")
variable["name"] = name
available_data[variable_id] = variable
self.available_variables = available_data
async def get_real_time_data(self, deviceSN):
"""
Get real-time data
[
{'datas':
[
{'unit': 'kW', 'name': 'PVPower', 'variable': 'pvPower', 'value': 1.8559999999999999},
{'unit': 'V', 'name': 'PV1Volt', 'variable': 'pv1Volt', 'value': 372.8},
{'unit': 'A', 'name': 'PV1Current', 'variable': 'pv1Current', 'value': 3.0},
{'unit': 'kW', 'name': 'PV1Power', 'variable': 'pv1Power', 'value': 1.128},
{'unit': 'V', 'name': 'PV2Volt', 'variable': 'pv2Volt', 'value': 236.4},
{'unit': 'A', 'name': 'PV2Current', 'variable': 'pv2Current', 'value': 3.0},
{'unit': 'kW', 'name': 'PV2Power', 'variable': 'pv2Power', 'value': 0.728},
{'unit': 'V', 'name': 'PV3Volt', 'variable': 'pv3Volt', 'value': 3.4},
{'unit': 'A', 'name': 'PV3Current', 'variable': 'pv3Current', 'value': 0.0},
{'unit': 'kW', 'name': 'PV3Power', 'variable': 'pv3Power', 'value': 0.0},
{'unit': 'V', 'name': 'PV4Volt', 'variable': 'pv4Volt', 'value': 0.0},
{'unit': 'A', 'name': 'PV4Current', 'variable': 'pv4Current', 'value': 0.0},
{'unit': 'kW', 'name': 'PV4Power', 'variable': 'pv4Power', 'value': 0.0},
{'unit': 'kW', 'name': 'EPSPower', 'variable': 'epsPower', 'value': 0.0},
{'unit': 'A', 'name': 'EPS-RCurrent', 'variable': 'epsCurrentR', 'value': 1.0},
{'unit': 'V', 'name': 'EPS-RVolt', 'variable': 'epsVoltR', 'value': 246.4},
{'unit': 'kW', 'name': 'EPS-RPower', 'variable': 'epsPowerR', 'value': 0.0},
{'unit': 'A', 'name': 'RCurrent', 'variable': 'RCurrent', 'value': 32.3},
{'unit': 'V', 'name': 'RVolt', 'variable': 'RVolt', 'value': 247.4},
{'unit': 'Hz', 'name': 'RFreq', 'variable': 'RFreq', 'value': 49.97},
{'unit': 'kW', 'name': 'RPower', 'variable': 'RPower', 'value': 7.993},
{'unit': '℃', 'name': 'AmbientTemperature', 'variable': 'ambientTemperation', 'value': 33.5},
{'unit': '℃', 'name': 'InvTemperation', 'variable': 'invTemperation', 'value': 29.7},
{'unit': '℃', 'name': 'batTemperature', 'variable': 'batTemperature', 'value': 33.6},
{'unit': 'kW', 'name': 'Load Power', 'variable': 'loadsPower', 'value': 17.046},
{'unit': 'kW', 'name': 'Output Power', 'variable': 'generationPower', 'value': 7.993},
{'unit': 'kW', 'name': 'Feed-in Power', 'variable': 'feedinPower', 'value': 0.0},
{'unit': 'kW', 'name': 'GridConsumption Power', 'variable': 'gridConsumptionPower', 'value': 9.053},
{'unit': 'V', 'name': 'InvBatVolt', 'variable': 'invBatVolt', 'value': 400.6},
{'unit': 'A', 'name': 'InvBatCurrent', 'variable': 'invBatCurrent', 'value': 16.4},
{'unit': 'kW', 'name': 'invBatPower', 'variable': 'invBatPower', 'value': 6.604},
{'unit': 'kW', 'name': 'Charge Power', 'variable': 'batChargePower', 'value': 0.0},
{'unit': 'kW', 'name': 'Discharge Power', 'variable': 'batDischargePower', 'value': 6.604},
{'unit': 'V', 'name': 'BatVolt', 'variable': 'batVolt', 'value': 399.1},
{'unit': 'A', 'name': 'BatCurrent', 'variable': 'batCurrent', 'value': 3.9},
{'unit': 'kW', 'name': 'MeterPower', 'variable': 'meterPower', 'value': 9.053},
{'unit': 'kW', 'name': 'Meter2Power', 'variable': 'meterPower2', 'value': 0.0},
{'unit': '%', 'name': 'SoC', 'variable': 'SoC', 'value': 26.0},
{'unit': 'kWh', 'name': 'Cumulative power generation', 'variable': 'generation', 'value': 6133.3},
{'unit': '0.01kWh', 'name': 'Battery Residual Energy', 'variable': 'ResidualEnergy', 'value': 10.34},
{'name': 'Running State', 'variable': 'runningState', 'value': '163'},
{'name': 'Battery Status', 'variable': 'batStatus', 'value': '1'},
{'name': 'Battery Status Name', 'variable': 'batStatusV2', 'value': 'Charge'},
{'name': 'The current error code is reported', 'variable': 'currentFault', 'value': ''},
{'name': 'The number of errors', 'variable': 'currentFaultCount', 'value': '0'},
{'unit': 'kWh', 'name': 'Battery throughput', 'variable': 'energyThroughput', 'value': 2255.872},
{'unit': '%', 'name': 'SOH', 'variable': 'SOH', 'value': 99.0},
{'unit': 'kWh', 'name': 'Total grid electricity consumption', 'variable': 'gridConsumption', 'value': 1712.7},
{'unit': 'kWh', 'name': 'Load power consumption', 'variable': 'loads', 'value': 4012.8},
{'unit': 'kWh', 'name': 'The total energy of the feeder', 'variable': 'feedin', 'value': 3730.6},
{'unit': 'kWh', 'name': 'Total charge energy', 'variable': 'chargeEnergyToTal', 'value': 1061.0},
{'unit': 'kWh', 'name': 'Total discharge energy', 'variable': 'dischargeEnergyToTal', 'value': 1532.6},
{'unit': "kWh", "name": "Photovoltaic power generation","variable": "PVEnergyTotal","value": 10291.7}
],
'time': '2025-09-14 18:43:09 BST+0100', 'deviceSN': '60KE8020479C034'}
]
"""
GET_REAL_TIME_DATA = "/op/v1/device/real/query"
query = {"lang": FOX_LANG, "sns": [deviceSN]}
result = await self.request_get(GET_REAL_TIME_DATA, post=True, datain=query)
if result is not None and isinstance(result, list):
for item in result:
if "datas" in item:
timestamp = item.get("time", "")
datas = item["datas"]
for data_item in datas:
unit = data_item.get("unit", "")
name = data_item.get("name", "")
variable = data_item.get("variable", "")
value = data_item.get("value", None)
if unit == "℃":
unit = "°C"
if name and (value is not None):
if deviceSN not in self.device_values:
self.device_values[deviceSN] = {}
self.device_values[deviceSN][variable] = {"timestamp": timestamp, "value": value, "unit": unit, "name": name}
return result
async def get_device_history(self, deviceSN):
"""
Get device history
"""
GET_DEVICE_HISTORY = "/op/v0/device/history/query"
timestamp = round(time.time() * 1000)
query = {"sn": deviceSN, "begin": timestamp - 1000 * 60 * 60 * 1, "end": timestamp}
result = await self.request_get(GET_DEVICE_HISTORY, post=True, datain=query)
if result is not None and isinstance(result, list):
for item in result:
if "datas" in item:
datas = item["datas"]
for data_item in datas:
unit = data_item.get("unit", "")
name = data_item.get("name", "")
variable = data_item.get("variable", "")
history = data_item.get("data", [])
point = history[-1] if history else {}
timestamp = point.get("time", "")
value = point.get("value", None)
if unit == "℃":
unit = "°C"
if timestamp and variable and value is not None:
if deviceSN not in self.device_values:
self.device_values[deviceSN] = {}
self.device_values[deviceSN][variable] = {"timestamp": timestamp, "value": value, "unit": unit, "name": name}
async def get_device_detail(self, deviceSN):
"""
Get device information
{
'deviceType': 'KH8',
'masterVersion': '1.34',
'afciVersion': '',
'hasPV': True,
'deviceSN':
'1234567890ABCDE',
'slaveVersion': '1.01',
'capacity': 8,
'hasBattery': True,
'function': {'scheduler': True},
'hardwareVersion': '--',
'managerVersion': '1.28',
'stationName': 'My Home',
'moduleSN': '12348020479C034',
'batteryList':
[{'batterySN': 'YYYYY', 'model': 'EP11', 'type': 'bcu', 'version': '1.005'},
{'batterySN': 'YYYYY', 'model': 'EP11', 'type': 'bmu', 'version': '1.05', 'capacity': 10360},
{'batterySN': 'YYYYY', 'model': 'EP11', 'type': 'ivu', 'version': '0.00'}],
'productType': 'KH',
'stationID': '23123-213123-231329',
'status': 1
}
"""
GET_DEVICE_INFO = f"/op/v0/device/detail"
query = {"sn": deviceSN}
result = await self.request_get(GET_DEVICE_INFO, post=False, datain=query)
if result is not None:
self.device_detail[deviceSN] = result
self.log("Fox: Device detail {}".format(result))
return result
@staticmethod
def capacity_watts(detail):
"""
Return the device's rated capacity in watts, correcting for Fox reporting the
device/detail 'capacity' field as a truncated integer on half-kW models (e.g. a
10.5kW KH10.5 inverter reports capacity=10). If deviceType ends in '.5' and the
raw capacity is an exact multiple of 1000W, bump it up to end in 500.
"""
capacity = detail.get("capacity", 0) * 1000.0
if capacity and capacity % 1000 == 0 and str(detail.get("deviceType", "")).endswith(".5"):
capacity += 500.0
return capacity
async def get_device_settings(self, deviceSN, checkBattery=True):
"""
Get device settings
Returns non-None once every key has been settled (read successfully or marked
unavailable), even if none of them are actually supported by this device - otherwise a
device where every setting is unsupported would never persist a cache age, and the
scheduler poll sharing the same refresh gate would run every cycle instead of hourly.
"""
# Check if device has battery
if checkBattery and not self.device_detail.get(deviceSN, {}).get("hasBattery", False):
# These controls don't exist for non-battery devices
return {}
settled = True
for key in FOX_SETTINGS:
if await self.get_device_setting(deviceSN, key) is None and not self.is_setting_unavailable(deviceSN, key):
settled = False
if not settled:
return None
else:
return self.device_settings.get(deviceSN, {})
def is_setting_unavailable(self, deviceSN, key):
"""
Return True if this device has already reported the setting as unsupported.
"""
return key.lower() in self.device_settings_unavailable.get(deviceSN, [])
def mark_setting_unavailable(self, deviceSN, key):
"""
Record that a Fox device does not support a setting (errno 42015/44096) so it is never
polled or written to again, applying a safe fallback value where one is defined.
"""
key_lower = key.lower()
unavailable = self.device_settings_unavailable.setdefault(deviceSN, [])
if key_lower not in unavailable:
unavailable.append(key_lower)
self.log(f"Fox: Warn: Setting {key} is not supported by device {deviceSN}, will not read or write it again")
if key_lower in FOX_SETTINGS_DEFAULTS:
self.device_settings.setdefault(deviceSN, {})[key] = dict(FOX_SETTINGS_DEFAULTS[key_lower])
async def get_device_setting(self, deviceSN, key):
"""
Get device setting
{'enumList': ['PeakShaving', 'Feedin', 'SelfUse'], 'unit': '', 'precision': 1.0, 'value': 'SelfUse'}
"""
if self.is_setting_unavailable(deviceSN, key):
return self.device_settings.get(deviceSN, {}).get(key, None)
GET_DEVICE_SETTING = "/op/v0/device/setting/get"
result = await self.request_get(GET_DEVICE_SETTING, datain={"sn": deviceSN, "key": key}, post=True)
if result is not None:
if deviceSN not in self.device_settings:
self.device_settings[deviceSN] = {}
self.device_settings[deviceSN][key] = result
return result
elif self.last_unsupported:
self.mark_setting_unavailable(deviceSN, key)
else:
self.log(f"Fox: Warn: Failed to get device setting for {deviceSN} key {key}")
return None
async def set_device_setting(self, deviceSN, key, value):
"""
Set device setting
"""
if self.is_setting_unavailable(deviceSN, key):
self.log(f"Fox: Warn: Ignoring write to unsupported setting {key} for device {deviceSN}")
return True
SET_DEVICE_SETTING = "/op/v0/device/setting/set"
result = await self.request_get(SET_DEVICE_SETTING, datain={"sn": deviceSN, "key": key, "value": value, "lang": FOX_LANG}, post=True)
if result is None:
if self.last_unsupported:
self.mark_setting_unavailable(deviceSN, key)