foxesscloud 2.6.8__tar.gz → 2.6.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: foxesscloud
3
- Version: 2.6.8
3
+ Version: 2.6.9
4
4
  Summary: library for accessing Fox ESS cloud data using Open API
5
5
  Author-email: Tony Matthews <tony@quasair.co.uk>
6
6
  Project-URL: Homepage, https://github.com/TonyM1958/FoxESS-Cloud
@@ -104,8 +104,8 @@ Once an inverter is selected, you can make other calls to get information:
104
104
 
105
105
  ```
106
106
  f.get_generation()
107
- f.get_battery()
108
- f.get_batteries()
107
+ f.get_battery(info, rated, count)
108
+ f.get_batteries(info, rated, count)
109
109
  f.get_settings()
110
110
  f.get_charge()
111
111
  f.get_min()
@@ -119,7 +119,12 @@ Each of these calls will return a dictionary or list containing the relevant inf
119
119
  get_generation() will return the latest generation information for the device. The results are also stored in f.device as 'generationToday', 'generationMonth' and 'generationTotal'.
120
120
 
121
121
  get_battery() / get_batteries() returns the current battery status, including 'soc', 'volt', 'current', 'power', 'temperature' and 'residual'. The result also updates f.battery / f.batteries.
122
- get_batteries() returns multiple batteries (if available) as a list. get_battery() returns the first battery. Additional battery attributes include:
122
+ get_batteries() returns multiple batteries (if available) as a list. get_battery() returns the first battery. Parameters:
123
+ + 'info': get battery serial number info, if available. Default 0 (not available via Open API)
124
+ + 'rated': optional rated capacity for the battery in Wh to work out SoH. If not provided, it will try to work this out.
125
+ + 'count': optional battery count. If not provided, it will try to work this out.
126
+
127
+ Additional battery attributes provided include:
123
128
  + 'capacity': the estimated battery capacity, derrived from 'residual' and 'soc'
124
129
  + 'charge_rate': the estimated BMS charge rate available, based on the current 'temperature' of the BMS
125
130
  + 'charge_loss': the ratio of the kWh added to the battery for each kWh applied during charging
@@ -145,7 +150,7 @@ f.set_charge(ch1, st1, en1, ch2, st2, en2, enable)
145
150
  f.set_period(start, end, mode, min_soc, max_soc, fdsoc, fdpwr, price, segment)
146
151
  f.charge_periods(st0, en0, st1, en1, st2, en2, min_soc, target_soc, start_soc)
147
152
  f.set_schedule(periods, enable)
148
- f.set_named_settings(name, value)
153
+ f.set_named_settings(name, value, force)
149
154
  ```
150
155
 
151
156
  set_min() applies new SoC settings to the inverter. The parameters update battery_settings:
@@ -188,7 +193,9 @@ set_schedule() configures a list of scheduled work mode / soc changes with enabl
188
193
 
189
194
  set_named_settings() sets the 'name' setting to 'value'.
190
195
  + 'name' may also be a list of (name, value) pairs.
191
- + the only 'name' currently supported by Fox is 'ExportLimit' on H3 inverters
196
+ + 'force': setting to 1 will disable Mode Scheduler, if enabled. Default is 0.
197
+ + A return value of 1 is success. 0 means setting failed. None is another error e.g. device not found, invalid name or value.
198
+ + the only 'name' currently supported is 'ExportLimit'
192
199
 
193
200
 
194
201
  ## Real Time Data
@@ -797,6 +804,14 @@ This setting can be:
797
804
 
798
805
  # Version Info
799
806
 
807
+ 2.6.9<br>
808
+ Add get and set_named_settings() (for WorkMode and ExportLimit).
809
+ If a list of named settings is provided, the return value is a list indicating which settings succeeded (1) or failed (0).
810
+ Updates to get_battery() / get_batteries() to add optional rated and count parameters.
811
+ Updates to charge_needed() to end prediction at start of next charge period.
812
+ Correct charge time for Octopus Go tariff.
813
+ Update charge_needed() to show contingency achieved rather than requested.
814
+
800
815
  2.6.8<br>
801
816
  Add residual_handling=3 for Mira BMS with firmware 1.014 or later that returns residual capacity per battery.
802
817
  Calculate 'ratedCapacity' in get_battery() and 'soh' for HV2600 and Mira.
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.1
2
- Name: foxesscloud
3
- Version: 2.6.8
4
- Summary: library for accessing Fox ESS cloud data using Open API
5
- Author-email: Tony Matthews <tony@quasair.co.uk>
6
- Project-URL: Homepage, https://github.com/TonyM1958/FoxESS-Cloud
7
- Project-URL: Bug Tracker, https://github.com/TonyM1958/FoxESS-Cloud/issues
8
- Classifier: Programming Language :: Python :: 3
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Operating System :: OS Independent
11
- Requires-Python: >=3.7
12
- Description-Content-Type: text/markdown
13
- License-File: LICENCE
14
-
15
1
  # FoxESS-Cloud
16
2
 
17
3
  <a href="https://www.buymeacoffee.com/tonym1958" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="41" width="174" align="right"></a>
@@ -104,8 +90,8 @@ Once an inverter is selected, you can make other calls to get information:
104
90
 
105
91
  ```
106
92
  f.get_generation()
107
- f.get_battery()
108
- f.get_batteries()
93
+ f.get_battery(info, rated, count)
94
+ f.get_batteries(info, rated, count)
109
95
  f.get_settings()
110
96
  f.get_charge()
111
97
  f.get_min()
@@ -119,7 +105,12 @@ Each of these calls will return a dictionary or list containing the relevant inf
119
105
  get_generation() will return the latest generation information for the device. The results are also stored in f.device as 'generationToday', 'generationMonth' and 'generationTotal'.
120
106
 
121
107
  get_battery() / get_batteries() returns the current battery status, including 'soc', 'volt', 'current', 'power', 'temperature' and 'residual'. The result also updates f.battery / f.batteries.
122
- get_batteries() returns multiple batteries (if available) as a list. get_battery() returns the first battery. Additional battery attributes include:
108
+ get_batteries() returns multiple batteries (if available) as a list. get_battery() returns the first battery. Parameters:
109
+ + 'info': get battery serial number info, if available. Default 0 (not available via Open API)
110
+ + 'rated': optional rated capacity for the battery in Wh to work out SoH. If not provided, it will try to work this out.
111
+ + 'count': optional battery count. If not provided, it will try to work this out.
112
+
113
+ Additional battery attributes provided include:
123
114
  + 'capacity': the estimated battery capacity, derrived from 'residual' and 'soc'
124
115
  + 'charge_rate': the estimated BMS charge rate available, based on the current 'temperature' of the BMS
125
116
  + 'charge_loss': the ratio of the kWh added to the battery for each kWh applied during charging
@@ -145,7 +136,7 @@ f.set_charge(ch1, st1, en1, ch2, st2, en2, enable)
145
136
  f.set_period(start, end, mode, min_soc, max_soc, fdsoc, fdpwr, price, segment)
146
137
  f.charge_periods(st0, en0, st1, en1, st2, en2, min_soc, target_soc, start_soc)
147
138
  f.set_schedule(periods, enable)
148
- f.set_named_settings(name, value)
139
+ f.set_named_settings(name, value, force)
149
140
  ```
150
141
 
151
142
  set_min() applies new SoC settings to the inverter. The parameters update battery_settings:
@@ -188,7 +179,9 @@ set_schedule() configures a list of scheduled work mode / soc changes with enabl
188
179
 
189
180
  set_named_settings() sets the 'name' setting to 'value'.
190
181
  + 'name' may also be a list of (name, value) pairs.
191
- + the only 'name' currently supported by Fox is 'ExportLimit' on H3 inverters
182
+ + 'force': setting to 1 will disable Mode Scheduler, if enabled. Default is 0.
183
+ + A return value of 1 is success. 0 means setting failed. None is another error e.g. device not found, invalid name or value.
184
+ + the only 'name' currently supported is 'ExportLimit'
192
185
 
193
186
 
194
187
  ## Real Time Data
@@ -797,6 +790,14 @@ This setting can be:
797
790
 
798
791
  # Version Info
799
792
 
793
+ 2.6.9<br>
794
+ Add get and set_named_settings() (for WorkMode and ExportLimit).
795
+ If a list of named settings is provided, the return value is a list indicating which settings succeeded (1) or failed (0).
796
+ Updates to get_battery() / get_batteries() to add optional rated and count parameters.
797
+ Updates to charge_needed() to end prediction at start of next charge period.
798
+ Correct charge time for Octopus Go tariff.
799
+ Update charge_needed() to show contingency achieved rather than requested.
800
+
800
801
  2.6.8<br>
801
802
  Add residual_handling=3 for Mira BMS with firmware 1.014 or later that returns residual capacity per battery.
802
803
  Calculate 'ratedCapacity' in get_battery() and 'soh' for HV2600 and Mira.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "foxesscloud"
7
- version = "2.6.8"
7
+ version = "2.6.9"
8
8
  authors = [
9
9
  {name="Tony Matthews", email="tony@quasair.co.uk"},
10
10
  ]
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud
4
- Updated: 03 November 2024
4
+ Updated: 05 November 2024
5
5
  By: Tony Matthews
6
6
  """
7
7
  ##################################################################################################
@@ -10,7 +10,7 @@ By: Tony Matthews
10
10
  # ALL RIGHTS ARE RESERVED © Tony Matthews 2023
11
11
  ##################################################################################################
12
12
 
13
- version = "1.7.9"
13
+ version = "1.8.0"
14
14
  print(f"FoxESS-Cloud version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -275,7 +275,7 @@ def get_token():
275
275
  info = None
276
276
 
277
277
  def get_info():
278
- global token, debug_setting, info, messages
278
+ global debug_setting, info, messages
279
279
  if get_token() is None:
280
280
  return None
281
281
  output(f"getting access", 2)
@@ -307,7 +307,7 @@ def get_info():
307
307
  status = None
308
308
 
309
309
  def get_status(station=0):
310
- global token, debug_setting, info, messages, status
310
+ global debug_setting, info, messages, status
311
311
  if get_token() is None:
312
312
  return None
313
313
  output(f"getting status", 2)
@@ -335,7 +335,7 @@ site = None
335
335
  station_id = None
336
336
 
337
337
  def get_site(name=None):
338
- global token, site_list, site, debug_setting, messages, station_id
338
+ global site_list, site, debug_setting, messages, station_id
339
339
  if get_token() is None:
340
340
  return None
341
341
  if site is not None and name is None:
@@ -376,6 +376,7 @@ def get_site(name=None):
376
376
  station_id = site['stationID']
377
377
  return site
378
378
 
379
+
379
380
  ##################################################################################################
380
381
  # get list of data loggers
381
382
  ##################################################################################################
@@ -384,7 +385,7 @@ logger_list = None
384
385
  logger = None
385
386
 
386
387
  def get_logger(sn=None):
387
- global token, logger_list, logger, debug_setting, messages
388
+ global logger_list, logger, debug_setting, messages
388
389
  if get_token() is None:
389
390
  return None
390
391
  if logger is not None and sn is None:
@@ -435,7 +436,7 @@ var_list = None
435
436
  raw_vars = var_list
436
437
 
437
438
  def get_device(sn=None):
438
- global token, device_list, device, device_id, device_sn, firmware, battery, var_list, debug_setting, messages, flag, schedule, templates, remote_settings
439
+ global device_list, device, device_id, device_sn, firmware, battery, var_list, debug_setting, messages, flag, schedule, templates, remote_settings
439
440
  if get_token() is None:
440
441
  return None
441
442
  if device is not None:
@@ -527,7 +528,7 @@ def get_device(sn=None):
527
528
  ##################################################################################################
528
529
 
529
530
  def get_vars():
530
- global token, device_id, debug_setting, messages
531
+ global device_id, debug_setting, messages
531
532
  if get_device() is None:
532
533
  return None
533
534
  output(f"getting variables", 2)
@@ -555,7 +556,7 @@ def get_vars():
555
556
  firmware = None
556
557
 
557
558
  def get_firmware():
558
- global token, device_id, firmware, debug_setting, messages
559
+ global device_id, firmware, debug_setting, messages
559
560
  if get_device() is None:
560
561
  return None
561
562
  output(f"getting firmware", 2)
@@ -609,8 +610,8 @@ battery_params = {
609
610
  'discharge_loss': 0.974},
610
611
  }
611
612
 
612
- def get_battery(info=1):
613
- global token, device_id, battery, debug_setting, messages, residual_handling, battery_params
613
+ def get_battery(info=1, rated=None, count=None):
614
+ global device_id, battery, debug_setting, messages, residual_handling, battery_params
614
615
  if get_device() is None:
615
616
  return None
616
617
  output(f"getting battery", 2)
@@ -655,24 +656,26 @@ def get_battery(info=1):
655
656
  soc = battery.get('soc')
656
657
  residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
657
658
  if battery.get('count') is None:
658
- battery['count'] = int(battery['volt'] / 49)
659
+ battery['count'] = int(battery['volt'] / 49) if count is None else count
659
660
  if battery.get('ratedCapacity') is None:
660
- battery['ratedCapacity'] = 2560 * battery['count']
661
+ battery['ratedCapacity'] = 2560 * battery['count'] if rated is None else rated
661
662
  elif battery['residual_handling'] == 3:
662
663
  if battery.get('count') is None:
663
- battery['count'] = int(battery['volt'] / 49)
664
+ battery['count'] = int(battery['volt'] / 49) if count is None else count
664
665
  capacity = (battery['residual'] * battery['count']) if battery.get('residual') is not None else None
665
666
  soc = battery.get('soc')
666
667
  residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
667
668
  if battery.get('ratedCapacity') is None:
668
- battery['ratedCapacity'] = 2450 * battery['count']
669
+ battery['ratedCapacity'] = 2450 * battery['count'] if rated is None else rated
669
670
  else:
670
671
  residual = battery.get('residual')
671
672
  soc = battery.get('soc')
672
- capacity = residual / soc * 100 if residual is not None and soc is not None and soc > 0 else None
673
+ capacity = residual / soc * 100 if residual is not None and soc is not None and soc > 0 else None
674
+ if battery.get('ratedCapacity') is None and rated is not None:
675
+ battery['ratedCapacity'] = rated
673
676
  battery['capacity'] = round(capacity, 3)
674
677
  battery['residual'] = round(residual, 3)
675
- battery['charge_rate'] = 50
678
+ battery['charge_rate'] = None
676
679
  params = battery_params[battery['residual_handling']]
677
680
  battery['charge_loss'] = params['charge_loss']
678
681
  battery['discharge_loss'] = params['discharge_loss']
@@ -682,8 +685,8 @@ def get_battery(info=1):
682
685
  battery['charge_rate'] = params['table'][int((battery['temperature'] - params['offset']) / params['step'])]
683
686
  return battery
684
687
 
685
- def get_batteries(info=1):
686
- global token, device_id, battery, debug_setting, messages, batteries, battery_params, residual_handling
688
+ def get_batteries(info=1, rated=None, count=None):
689
+ global device_id, battery, debug_setting, messages, batteries, battery_params, residual_handling
687
690
  if get_device() is None:
688
691
  return None
689
692
  output(f"getting batteries", 2)
@@ -710,7 +713,15 @@ def get_batteries(info=1):
710
713
  else:
711
714
  for i in range(0, len(batteries)):
712
715
  batteries[i]['info'] = result['batteries'][i]
713
- for b in batteries:
716
+ if type(rated) is not list:
717
+ rated = [rated]
718
+ while len(rated) < len(batteries):
719
+ rated.append(None)
720
+ if type(count) is not list:
721
+ count = [count]
722
+ while len(count) < len(batteries):
723
+ count.append(None)
724
+ for i,b in enumerate(batteries):
714
725
  b['residual_handling'] = residual_handling
715
726
  if b.get('info') is not None:
716
727
  if b['info'].get('slaveBatteries') is not None:
@@ -718,9 +729,11 @@ def get_batteries(info=1):
718
729
  if b['info']['masterSN'][:7] == '60BBHV2' and b['info']['masterVersion'] >= '1.014':
719
730
  b['residual_handling'] = 2
720
731
  elif b['info']['masterSN'][:7] == '60MBB01' and b['info']['masterVersion'] >= '1.014':
721
- residual_handling = 3
732
+ b['residual_handling'] = 3
733
+ if b.get('count') is None:
734
+ b['count'] = count[i]
722
735
  rated_capacity = b.get('ratedCapacity')
723
- b['ratedCapacity'] = rated_capacity if rated_capacity is not None and rated_capacity > 100 else None
736
+ b['ratedCapacity'] = rated_capacity if rated_capacity is not None and rated_capacity > 100 else rated[i]
724
737
  soh = b.get('soh')
725
738
  b['soh'] = int(soh) if soh.isnumeric() and int(soh) > 10 else None
726
739
  b['soh_supported'] = b['soh'] is not None
@@ -739,7 +752,7 @@ def get_batteries(info=1):
739
752
  b['residual'] = round(residual, 3)
740
753
  if b.get('ratedCapacity') is not None and b.get('capacity') is not None:
741
754
  b['soh'] = round(b['capacity'] * 1000 / b['ratedCapacity'] * 100, 1)
742
- b['charge_rate'] = 50
755
+ b['charge_rate'] = None
743
756
  params = battery_params[b['residual_handling']]
744
757
  b['charge_loss'] = params['charge_loss']
745
758
  b['discharge_loss'] = params['discharge_loss']
@@ -752,7 +765,7 @@ def get_batteries(info=1):
752
765
  ##################################################################################################
753
766
 
754
767
  def get_charge():
755
- global token, device_sn, battery_settings, debug_setting, messages
768
+ global device_sn, battery_settings, debug_setting, messages
756
769
  if get_device() is None:
757
770
  return None
758
771
  if battery_settings is None:
@@ -789,7 +802,7 @@ def time_period(t):
789
802
  return result
790
803
 
791
804
  def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force=0, enable=1):
792
- global token, device_sn, battery_settings, debug_setting, messages, schedule
805
+ global device_sn, battery_settings, debug_setting, messages, schedule
793
806
  if get_device() is None:
794
807
  return None
795
808
  if battery_settings is None:
@@ -863,7 +876,7 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
863
876
  ##################################################################################################
864
877
 
865
878
  def get_min():
866
- global token, device_sn, battery_settings, debug_setting, messages
879
+ global device_sn, battery_settings, debug_setting, messages
867
880
  if get_device() is None:
868
881
  return None
869
882
  if battery_settings is None:
@@ -888,7 +901,7 @@ def get_min():
888
901
  ##################################################################################################
889
902
 
890
903
  def set_min(minGridSoc = None, minSoc = None, force = 0):
891
- global token, device_sn, battery_settings, debug_setting, messages
904
+ global device_sn, battery_settings, debug_setting, messages
892
905
  if get_device() is None:
893
906
  return None
894
907
  if get_schedule().get('enable'):
@@ -944,6 +957,13 @@ merge_settings = { # keys to add
944
957
  # 'k106__': 'operation_mode__work_mode',
945
958
  },
946
959
  'values': ['SelfUse', 'Feedin', 'Backup']},
960
+ 'ExportLimit': {'keys': {
961
+ 'h115__': 'basic2__05',
962
+ 'h116__': 'basic2__05',
963
+ 'h117__': 'basic2__05',
964
+ # 'k106__': 'basic2__05',
965
+ },
966
+ 'valueType': 'int'},
947
967
  'BatteryVolt': {'keys': {
948
968
  'h115__': ['h115__14', 'h115__15', 'h115__16'],
949
969
  'h116__': ['h116__15', 'h116__16', 'h116__17'],
@@ -1003,7 +1023,7 @@ def get_ui():
1003
1023
  block = p['block'] and len(p['properties']) > 1
1004
1024
  for e in p['properties']:
1005
1025
  valueType = e['elemType']['valueType']
1006
- item = {'name': e['key'].replace(protocol,'')} if block else {'key': e['key']} #, 'group': p['name']}
1026
+ item = {'name': e['key'].replace(protocol,'')} if block else {'keys': e['key']} #, 'group': p['name']}
1007
1027
  if e['elemType'].get('uiItems') is not None:
1008
1028
  item['values'] = e['elemType']['uiItems']
1009
1029
  elif e.get('range') is not None:
@@ -1018,7 +1038,7 @@ def get_ui():
1018
1038
  else:
1019
1039
  named_settings[e['name']] = item
1020
1040
  if block:
1021
- named_settings[p['name']] = {'key': p['key'], 'type': 'block', 'items': items}
1041
+ named_settings[p['name']] = {'keys': p['key'], 'type': 'block', 'items': items}
1022
1042
  for name in merge_settings.keys():
1023
1043
  if named_settings.get(name) is None and merge_settings[name]['keys'].get(protocol) is not None:
1024
1044
  named_settings[name] = {'keys': merge_settings[name]['keys'][protocol]}
@@ -1028,7 +1048,7 @@ def get_ui():
1028
1048
  return remote_settings
1029
1049
 
1030
1050
  def get_remote_settings(key):
1031
- global token, device_id, debug_setting, messages
1051
+ global device_id, debug_setting, messages
1032
1052
  if get_device() is None:
1033
1053
  return None
1034
1054
  output(f"getting remote settings", 2)
@@ -1090,10 +1110,66 @@ def get_named_settings(name):
1090
1110
  return values
1091
1111
  return result
1092
1112
 
1113
+ def set_named_settings(name, value, force=0):
1114
+ global named_settings
1115
+ if get_device() is None:
1116
+ return None
1117
+ if force == 1 and get_schedule().get('enable'):
1118
+ set_schedule(enable=0)
1119
+ if type(name) is list:
1120
+ result = []
1121
+ for (n, v) in name:
1122
+ result.append(set_named_settings(name=n, value=v))
1123
+ return result
1124
+ if named_settings is None or named_settings.get(name) is None:
1125
+ output(f"** set_named_settings(): {name} was not recognised")
1126
+ return None
1127
+ keys = named_settings[name].get('keys')
1128
+ if keys is None:
1129
+ output(f"** set_named_settings(): no keys for name: {name}")
1130
+ return None
1131
+ item_type = named_settings[name].get('type')
1132
+ if item_type is None:
1133
+ values = {keys: str(value)}
1134
+ elif item_type == 'block':
1135
+ items = named_setting[name]['items']
1136
+ n = len(items)
1137
+ if type(value) is not list or n != len(value):
1138
+ output(f"** set_named_settings(): {name} requires list of {n} values")
1139
+ return None
1140
+ values = {}
1141
+ for i in range(0, n):
1142
+ values[items[i]['name']] = str(value[i])
1143
+ elif item_type == 'list':
1144
+ if type(value) is not list:
1145
+ output(f"** set_named_settings(): {name} requires a list of values")
1146
+ return None
1147
+ values = {keys: value}
1148
+ else:
1149
+ values = {keys: str(value)}
1150
+ output(f"\nSetting {name} to {value}", 1)
1151
+ values['raw'] = ''
1152
+ data = {'id': device_id, 'key': keys, 'values': values}
1153
+ setting_delay()
1154
+ response = signed_post(path="/c/v0/device/setting/set", data=data)
1155
+ if response.status_code != 200:
1156
+ output(f"** set_named_settings() got response code {response.status_code}: {response.reason}")
1157
+ return None
1158
+ errno = response.json().get('errno')
1159
+ if errno != 0:
1160
+ if errno == 44096:
1161
+ output(f"** cannot update {name} when schedule is active")
1162
+ else:
1163
+ output(f"** set_named_settings(): ({name}, {value}) {errno_message(errno)}")
1164
+ return 0
1165
+ return 1
1166
+
1093
1167
  ##################################################################################################
1094
1168
  # wrappers for named settings
1095
1169
  ##################################################################################################
1096
1170
 
1171
+ work_modes = ['SelfUse', 'Feedin', 'Backup', 'ForceCharge', 'ForceDischarge']
1172
+ settable_modes = work_modes[:3]
1097
1173
  work_mode = None
1098
1174
 
1099
1175
  def get_work_mode():
@@ -1103,6 +1179,18 @@ def get_work_mode():
1103
1179
  work_mode = get_named_settings('WorkMode')
1104
1180
  return work_mode
1105
1181
 
1182
+ def set_work_mode(mode, force=0):
1183
+ global settable_modes, work_mode, debug_setting
1184
+ if get_device() is None:
1185
+ return None
1186
+ if mode not in settable_modes:
1187
+ output(f"** work mode: must be one of {settable_modes}")
1188
+ return None
1189
+ result = set_named_settings(name='WorkMode', value=mode, force=force)
1190
+ if result is not None and result == 1:
1191
+ work_mode = mode
1192
+ return result
1193
+
1106
1194
  def get_cell_volts():
1107
1195
  values = get_named_settings('BatteryVolt')
1108
1196
  if values is None:
@@ -1130,46 +1218,6 @@ def get_cell_temps(nbat=8):
1130
1218
  break
1131
1219
  return bat_temps
1132
1220
 
1133
-
1134
- ##################################################################################################
1135
- # set work mode
1136
- ##################################################################################################
1137
-
1138
- work_modes = ['SelfUse', 'Feedin', 'Backup', 'ForceCharge', 'ForceDischarge']
1139
- settable_modes = work_modes[:3]
1140
-
1141
- def set_work_mode(mode, force = 0):
1142
- global token, device_id, work_modes, work_mode, debug_setting, messages, schedule
1143
- if get_device() is None:
1144
- return None
1145
- if mode not in settable_modes:
1146
- output(f"** work mode: must be one of {settable_modes}")
1147
- return None
1148
- if get_flag() is None:
1149
- return None
1150
- if schedule.get('enable') == True:
1151
- if force == 0:
1152
- output(f"** set_work_mode(): cannot set work mode when a schedule is enabled")
1153
- return None
1154
- set_schedule(enable=0)
1155
- output(f"\nSetting work mode: {mode}", 1)
1156
- data = {'id': device_id, 'key': 'operation_mode__work_mode', 'values': {'operation_mode__work_mode': mode}, 'raw': ''}
1157
- setting_delay()
1158
- response = signed_post(path="/c/v0/device/setting/set", data=data)
1159
- if response.status_code != 200:
1160
- output(f"** set_work_mode() got response code {response.status_code}: {response.reason}")
1161
- return None
1162
- errno = response.json().get('errno')
1163
- if errno != 0:
1164
- if errno == 44096:
1165
- output(f"** cannot update settings when schedule is active")
1166
- else:
1167
- output(f"** set_work_mode(), {errno_message(errno)}")
1168
- return None
1169
- work_mode = mode
1170
- return work_mode
1171
-
1172
-
1173
1221
  ##################################################################################################
1174
1222
  # get schedule
1175
1223
  ##################################################################################################
@@ -1179,7 +1227,7 @@ templates = None
1179
1227
 
1180
1228
  # get the current enable flag
1181
1229
  def get_flag():
1182
- global token, device_id, device_sn, schedule, debug_setting, messages
1230
+ global device_id, device_sn, schedule, debug_setting, messages
1183
1231
  if get_device() is None:
1184
1232
  return None
1185
1233
  output(f"getting flag", 2)
@@ -1216,7 +1264,7 @@ def get_flag():
1216
1264
 
1217
1265
  # get the current schedule
1218
1266
  def get_schedule():
1219
- global token, device_id, schedule, debug_setting, messages
1267
+ global device_id, schedule, debug_setting, messages
1220
1268
  if get_flag() is None:
1221
1269
  return None
1222
1270
  if schedule.get('support') == False:
@@ -1258,7 +1306,7 @@ def build_strategy_from_schedule():
1258
1306
 
1259
1307
  # get the details for a specific template
1260
1308
  def get_template_detail(template):
1261
- global token, device_id, schedule, debug_setting, messages, templates
1309
+ global device_id, schedule, debug_setting, messages, templates
1262
1310
  if get_flag() is None:
1263
1311
  return None
1264
1312
  if schedule.get('support') == False:
@@ -1280,7 +1328,7 @@ def get_template_detail(template):
1280
1328
 
1281
1329
  # get the preset templates that contains periods
1282
1330
  def get_templates(template_type=[1,2]):
1283
- global token, device_id, flag, schedule, debug_setting, messages, templates
1331
+ global device_id, flag, schedule, debug_setting, messages, templates
1284
1332
  if get_flag() is None:
1285
1333
  return None
1286
1334
  if schedule.get('support') == False:
@@ -1393,7 +1441,7 @@ def set_period(start=None, end=None, mode=None, min_soc=None, max_soc=None, fdso
1393
1441
 
1394
1442
  # set a schedule from a period or list of periods
1395
1443
  def set_schedule(periods=None, template=None, enable=True):
1396
- global token, device_sn, debug_setting, messages, schedule, templates
1444
+ global device_sn, debug_setting, messages, schedule, templates
1397
1445
  if get_flag() is None:
1398
1446
  return None
1399
1447
  if schedule.get('support') == False:
@@ -1482,7 +1530,7 @@ sample_time = 5.0 # 5 minutes default
1482
1530
  sample_rounding = 2 # round to 30 seconds
1483
1531
 
1484
1532
  def get_raw(time_span='hour', d=None, v=None, summary=1, save=None, load=None, plot=0, station=0):
1485
- global token, device_id, debug_setting, var_list, invert_ct2, tariff, max_power_kw, messages, sample_rounding, sample_time, storage
1533
+ global device_id, debug_setting, var_list, invert_ct2, tariff, max_power_kw, messages, sample_rounding, sample_time, storage
1486
1534
  if station == 0 and get_device() is None:
1487
1535
  return None
1488
1536
  elif station == 1 and get_site() is None:
@@ -1754,7 +1802,7 @@ fix_value_threshold = 200000000.0
1754
1802
  fix_value_mask = 0x0000FFFF
1755
1803
 
1756
1804
  def get_report(report_type='day', d=None, v=None, summary=1, save=None, load=None, plot=0, station=0):
1757
- global token, device_id, station_id, var_list, debug_setting, report_vars, messages, station_id
1805
+ global device_id, station_id, var_list, debug_setting, report_vars, messages, station_id
1758
1806
  if station == 0 and get_device() is None:
1759
1807
  return None
1760
1808
  elif station == 1 and get_site() is None:
@@ -1990,7 +2038,7 @@ def plot_report(result, plot=1, station=0):
1990
2038
  ##################################################################################################
1991
2039
 
1992
2040
  def get_earnings():
1993
- global token, device_id, station_id, var_list, debug_setting, messages
2041
+ global device_id, station_id, var_list, debug_setting, messages
1994
2042
  if get_device() is None:
1995
2043
  return None
1996
2044
  id_name = 'deviceID'
@@ -2259,7 +2307,7 @@ octopus_cosy = {
2259
2307
  # time periods for Octopus Go
2260
2308
  octopus_go = {
2261
2309
  'name': 'Octopus Go',
2262
- 'off_peak1': {'start': 0.5, 'end': 4.5, 'hold': 1},
2310
+ 'off_peak1': {'start': 0.5, 'end': 5.5, 'hold': 1},
2263
2311
  'forecast_times': [21, 22]
2264
2312
  }
2265
2313
 
@@ -2928,7 +2976,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2928
2976
  if len(times) == 0:
2929
2977
  times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'hold': force_charge})
2930
2978
  output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
2931
- time_to_end1 = None
2979
+ time_to_run = None
2932
2980
  for t in times:
2933
2981
  if hour_in(hour_now, t) and update_settings > 0:
2934
2982
  update_settings = 0
@@ -2940,7 +2988,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2940
2988
  t['time_to_start'] = time_to_start
2941
2989
  t['time_to_end'] = time_to_end
2942
2990
  t['charge_time'] = charge_time
2943
- time_to_end1 = time_to_end if time_to_end1 is None else time_to_end1
2991
+ if time_to_run is None:
2992
+ time_to_run = time_to_start
2944
2993
  # get next charge slot
2945
2994
  times = sorted(times, key=lambda t: t['time_to_start'])
2946
2995
  charge_key = times[0]['key']
@@ -2952,7 +3001,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2952
3001
  # work out time window and times with clock changes
2953
3002
  charge_today = (base_hour + time_to_start / steps_per_hour) < 24
2954
3003
  forecast_day = today if charge_today else tomorrow
2955
- run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
3004
+ run_to = time_to_run if time_to_end < time_to_run else time_to_run + 24 * steps_per_hour
2956
3005
  run_time = int(run_to + 0.99) + 1 + hour_adjustment * steps_per_hour
2957
3006
  time_line = [round_time(base_hour + x / steps_per_hour - (hour_adjustment if x >= time_change else 0)) for x in range(0, run_time)]
2958
3007
  bat_hold = times[0]['hold']
@@ -3007,12 +3056,16 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3007
3056
  model = device.get('deviceType')
3008
3057
  min_soc = charge_config['min_soc'] if charge_config['min_soc'] is not None else 10
3009
3058
  max_soc = charge_config['max_soc'] if charge_config['max_soc'] is not None else 100
3059
+ reserve = capacity * min_soc / 100
3060
+ # charge current may be derated based on temperature
3061
+ charge_current = device_current if charge_config['charge_current'] is None else charge_config['charge_current']
3062
+ if bms_charge_current is not None and charge_current > bms_charge_current:
3063
+ charge_current = bms_charge_current
3010
3064
  volt_curve = charge_config['volt_curve']
3011
3065
  nominal_soc = charge_config['nominal_soc']
3012
3066
  volt_nominal = interpolate(nominal_soc / 10, volt_curve)
3013
3067
  bat_resistance = charge_config['bat_resistance'] * bat_volt / volt_nominal
3014
3068
  bat_ocv = (bat_volt + bat_current * bat_resistance) * volt_nominal / interpolate(current_soc / 10, volt_curve)
3015
- reserve = capacity * min_soc / 100
3016
3069
  output(f"\nBattery Info:")
3017
3070
  output(f" Capacity: {capacity:.2f}kWh")
3018
3071
  output(f" Residual: {residual:.2f}kWh")
@@ -3022,15 +3075,11 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3022
3075
  output(f" Min SoC: {min_soc}% ({reserve:.2f}kWh)")
3023
3076
  output(f" Current SoC: {current_soc}%")
3024
3077
  output(f" Max SoC: {max_soc}% ({capacity * max_soc / 100:.2f}kWh)")
3025
- output(f" Max Charge: {bms_charge_current:.1f}A")
3078
+ output(f" Max Charge: {charge_current:.1f}A")
3026
3079
  output(f" Temperature: {temperature:.1f}°C")
3027
3080
  output(f" Resistance: {bat_resistance:.2f} ohms")
3028
3081
  output(f" Nominal OCV: {bat_ocv:.1f}V at {nominal_soc}% SoC")
3029
3082
  output(f" Losses: {charge_loss * 100:.1f}% charge / {discharge_loss * 100:.1f}% discharge")
3030
- # charge current may be derated based on temperature
3031
- charge_current = device_current if charge_config['charge_current'] is None else charge_config['charge_current']
3032
- if charge_current > bms_charge_current:
3033
- charge_current = bms_charge_current
3034
3083
  # inverter losses
3035
3084
  inverter_power = charge_config['inverter_power'] if charge_config['inverter_power'] is not None else round(device_power, 0) * 25
3036
3085
  operating_loss = inverter_power / 1000
@@ -3225,6 +3274,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3225
3274
  output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
3226
3275
  charge_message = "no charge needed"
3227
3276
  kwh_needed = 0.0
3277
+ kwh_spare = kwh_min - reserve
3228
3278
  hours = 0.0
3229
3279
  start_timed = time_to_end
3230
3280
  end_timed = time_to_end
@@ -3232,6 +3282,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3232
3282
  else:
3233
3283
  # work out time to add kwh_needed to battery
3234
3284
  charge_rate = charge_power * charge_loss
3285
+ discharge_rate = max([(start_residual - end_residual) / charge_time - bms_loss, 0.0])
3235
3286
  hours = kwh_needed / charge_rate
3236
3287
  if test_charge is None:
3237
3288
  output(f"\nCharge needed: {kwh_needed:.2f}kWh ({hours_time(hours)})")
@@ -3242,13 +3293,15 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3242
3293
  if hours > charge_time:
3243
3294
  hours = charge_time
3244
3295
  elif hours > hours_to_full:
3245
- kwh_shortfall = (hours - hours_to_full) * charge_rate # amount of energy that won't be added
3246
- required = hours_to_full + charge_time * kwh_shortfall / abs(start_residual - end_residual) # time to recover energy not added
3296
+ kwh_shortfall = kwh_needed - (capacity - start_residual) # amount of energy that won't be added
3297
+ required = (hours_to_full + kwh_shortfall / discharge_rate) if discharge_rate > 0.0 else charge_time
3247
3298
  hours = required if required > hours and required < charge_time else charge_time
3248
3299
  # round charge time and work out what will actually be added
3249
3300
  min_hours = charge_config['min_hours']
3250
3301
  hours = int(hours / min_hours + 0.99) * min_hours
3251
3302
  kwh_added = (hours * charge_rate) if hours < hours_to_full else (capacity - start_residual)
3303
+ kwh_added += discharge_rate * hours # discharge saved by charging
3304
+ kwh_spare = kwh_min - reserve + kwh_added
3252
3305
  # rework charge and discharge
3253
3306
  charge_period = get_best_charge_period(start_at, hours)
3254
3307
  charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
@@ -3284,7 +3337,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3284
3337
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
3285
3338
  # show the results
3286
3339
  output(f" End SoC: {end_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
3287
- output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
3340
+ output(f" Contingency: {kwh_spare / capacity * 100:.0f}% SoC ({kwh_spare:.2f}kWh)")
3288
3341
  if not charge_today:
3289
3342
  output(f" PV cover: {expected / consumption * 100:.0f}% ({expected:.1f}/{consumption:.1f})")
3290
3343
  # setup charging
@@ -3516,19 +3569,19 @@ def bat_count(cell_count):
3516
3569
  battery_info_app_key = "aug938dqt5cbqhvq69ixc4v39q6wtw"
3517
3570
 
3518
3571
  # show information about the current state of the batteries
3519
- def battery_info(log=0, plot=1, count=None, info=1, bat=None):
3572
+ def battery_info(log=0, plot=1, rated=None, count=None, info=1, bat=None):
3520
3573
  global debug_setting, battery_info_app_key
3521
3574
  if bat is None:
3522
- bats = get_batteries(info=info)
3575
+ bats = get_batteries(info=info, rated=rated, count=count)
3523
3576
  if bats is None:
3524
3577
  return None
3525
3578
  for i in range(0, len(bats)):
3526
3579
  output(f"\n----------------------- BMS {i+1} -----------------------")
3527
- battery_info(log=log, plot=plot, count=count, info=info, bat=bats[i])
3580
+ battery_info(log=log, plot=plot, info=info, bat=bats[i])
3528
3581
  return None
3529
3582
  output_spool(battery_info_app_key)
3530
3583
  nbat = None
3531
- if info == 1 and bat.get('info') is not None:
3584
+ if bat.get('info') is not None:
3532
3585
  b = bat['info']
3533
3586
  output(f"SN {b['masterSN']}, {b['masterBatType']}, Version {b['masterVersion']} (BMS)")
3534
3587
  nbat = 0
@@ -3551,7 +3604,7 @@ def battery_info(log=0, plot=1, count=None, info=1, bat=None):
3551
3604
  return None
3552
3605
  nv = len(cell_volts)
3553
3606
  if nbat is None:
3554
- nbat = bat_count(nv) if count is None else count
3607
+ nbat = bat_count(nv) if bat.get('count') is None else bat['count']
3555
3608
  if nbat is None:
3556
3609
  output(f"** battery_info(): unable to match cells_per_battery for {nv}")
3557
3610
  output_close()
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud using Open API
4
- Updated: 03 November 2024
4
+ Updated: 05 November 2024
5
5
  By: Tony Matthews
6
6
  """
7
7
  ##################################################################################################
@@ -10,7 +10,7 @@ By: Tony Matthews
10
10
  # ALL RIGHTS ARE RESERVED © Tony Matthews 2024
11
11
  ##################################################################################################
12
12
 
13
- version = "2.6.8"
13
+ version = "2.6.9"
14
14
  print(f"FoxESS-Cloud Open API version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -574,7 +574,7 @@ battery_params = {
574
574
  'discharge_loss': 0.974},
575
575
  }
576
576
 
577
- def get_battery(info=0, v=None):
577
+ def get_battery(info=0, v=None, rated=None, count=None):
578
578
  global device_sn, battery, debug_setting, residual_handling, battery_params
579
579
  if get_device() is None:
580
580
  return None
@@ -594,25 +594,29 @@ def get_battery(info=0, v=None):
594
594
  soc = battery.get('soc')
595
595
  residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
596
596
  if battery.get('count') is None:
597
- battery['count'] = int(battery['volt'] / 49)
597
+ battery['count'] = int(battery['volt'] / 49) if count is None else count
598
598
  if battery.get('ratedCapacity') is None:
599
- battery['ratedCapacity'] = 2560 * battery['count']
599
+ battery['ratedCapacity'] = 2560 * battery['count'] if rated is None else rated
600
600
  elif battery['residual_handling'] == 3:
601
601
  if battery.get('count') is None:
602
- battery['count'] = int(battery['volt'] / 49)
602
+ battery['count'] = int(battery['volt'] / 49) if count is None else count
603
603
  capacity = (battery['residual'] * battery['count']) if battery.get('residual') is not None else None
604
604
  soc = battery.get('soc')
605
605
  residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
606
606
  if battery.get('ratedCapacity') is None:
607
- battery['ratedCapacity'] = 2450 * battery['count']
607
+ battery['ratedCapacity'] = 2450 * battery['count'] if rated is None else rated
608
608
  else:
609
609
  residual = battery.get('residual')
610
610
  soc = battery.get('soc')
611
611
  capacity = residual / soc * 100 if residual is not None and soc is not None and soc > 0 else None
612
+ if battery.get('count') is None or battery['count'] < 1:
613
+ battery['count'] = count
614
+ if battery.get('ratedCapacity') is None or battery['ratedCapacity'] < 100:
615
+ battery['ratedCapacity'] = rated
612
616
  battery['capacity'] = round(capacity, 3)
613
617
  battery['residual'] = round(residual, 3)
614
618
  battery['status'] = 1
615
- battery['charge_rate'] = 50
619
+ battery['charge_rate'] = None
616
620
  params = battery_params[battery['residual_handling']]
617
621
  battery['charge_loss'] = params['charge_loss']
618
622
  battery['discharge_loss'] = params['discharge_loss']
@@ -622,9 +626,13 @@ def get_battery(info=0, v=None):
622
626
  battery['charge_rate'] = params['table'][int((battery['temperature'] - params['offset']) / params['step'])]
623
627
  return battery
624
628
 
625
- def get_batteries(info=0):
629
+ def get_batteries(info=0, rated=None, count=None):
626
630
  global battery, batteries
627
- get_battery(info=info)
631
+ if type(rated) is not list:
632
+ rated = [rated]
633
+ if type(count) is not list:
634
+ count = [count]
635
+ get_battery(info=info, rated=rated[0], count=count[0])
628
636
  if battery is None:
629
637
  return None
630
638
  batteries = [battery]
@@ -860,17 +868,17 @@ def get_remote_settings(name):
860
868
  def get_named_settings(name):
861
869
  return get_remote_settings(name)
862
870
 
863
- def set_named_setting(name, value):
871
+ def set_named_settings(name, value, force=0):
864
872
  global device_sn, debug_setting
865
873
  if get_device() is None:
866
874
  return None
875
+ if force == 1 and get_schedule().get('enable'):
876
+ set_schedule(enable=0)
867
877
  if type(name) is list:
868
- count = 0
878
+ result = []
869
879
  for (n, v) in name:
870
- result = set_named_settings(name=n, value=v)
871
- if result is not None:
872
- count += 1
873
- return count
880
+ result.append(set_named_settings(name=n, value=v))
881
+ return result
874
882
  output(f"\nSetting {name} to {value}", 1)
875
883
  body = {'sn': device_sn, 'key': name, 'value': f"{value}"}
876
884
  setting_delay()
@@ -881,10 +889,10 @@ def set_named_setting(name, value):
881
889
  errno = response.json().get('errno')
882
890
  if errno != 0:
883
891
  if errno == 44096:
884
- output(f"** cannot update settings when schedule is active")
892
+ output(f"** cannot update {name} when schedule is active")
885
893
  else:
886
894
  output(f"** set_named_settings(): ({name}, {value}) {errno_message(response)}")
887
- return None
895
+ return 0
888
896
  return 1
889
897
 
890
898
  ##################################################################################################
@@ -1964,7 +1972,7 @@ octopus_cosy = {
1964
1972
  # time periods for Octopus Go
1965
1973
  octopus_go = {
1966
1974
  'name': 'Octopus Go',
1967
- 'off_peak1': {'start': 0.5, 'end': 4.5, 'hold': 1},
1975
+ 'off_peak1': {'start': 0.5, 'end': 5.5, 'hold': 1},
1968
1976
  'forecast_times': [21, 22]
1969
1977
  }
1970
1978
 
@@ -2632,7 +2640,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2632
2640
  if len(times) == 0:
2633
2641
  times.append({'key': 'off_peak1', 'start': round_time(base_hour + 1), 'end': round_time(base_hour + 4), 'hold': force_charge})
2634
2642
  output(f"Charge time: {hours_time(base_hour + 1)}-{hours_time(base_hour + 4)}")
2635
- time_to_end1 = None
2643
+ time_to_run = None
2636
2644
  for t in times:
2637
2645
  if hour_in(hour_now, t) and update_settings > 0:
2638
2646
  update_settings = 0
@@ -2644,7 +2652,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2644
2652
  t['time_to_start'] = time_to_start
2645
2653
  t['time_to_end'] = time_to_end
2646
2654
  t['charge_time'] = charge_time
2647
- time_to_end1 = time_to_end if time_to_end1 is None else time_to_end1
2655
+ if time_to_run is None:
2656
+ time_to_run = time_to_start
2648
2657
  # get next charge slot
2649
2658
  times = sorted(times, key=lambda t: t['time_to_start'])
2650
2659
  charge_key = times[0]['key']
@@ -2656,7 +2665,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2656
2665
  # work out time window and times with clock changes
2657
2666
  charge_today = (base_hour + time_to_start / steps_per_hour) < 24
2658
2667
  forecast_day = today if charge_today else tomorrow
2659
- run_to = time_to_end1 if time_to_end < time_to_end1 else time_to_end1 + 24 * steps_per_hour
2668
+ run_to = time_to_run if time_to_end < time_to_run else time_to_run + 24 * steps_per_hour
2660
2669
  run_time = int(run_to + 0.99) + 1 + hour_adjustment * steps_per_hour
2661
2670
  time_line = [round_time(base_hour + x / steps_per_hour - (hour_adjustment if x >= time_change else 0)) for x in range(0, run_time)]
2662
2671
  bat_hold = times[0]['hold']
@@ -2711,12 +2720,16 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2711
2720
  model = device.get('deviceType')
2712
2721
  min_soc = charge_config['min_soc'] if charge_config['min_soc'] is not None else 10
2713
2722
  max_soc = charge_config['max_soc'] if charge_config['max_soc'] is not None else 100
2723
+ reserve = capacity * min_soc / 100
2724
+ # charge current may be derated based on temperature
2725
+ charge_current = device_current if charge_config['charge_current'] is None else charge_config['charge_current']
2726
+ if bms_charge_current is not None and charge_current > bms_charge_current:
2727
+ charge_current = bms_charge_current
2714
2728
  volt_curve = charge_config['volt_curve']
2715
2729
  nominal_soc = charge_config['nominal_soc']
2716
2730
  volt_nominal = interpolate(nominal_soc / 10, volt_curve)
2717
2731
  bat_resistance = charge_config['bat_resistance'] * bat_volt / volt_nominal
2718
2732
  bat_ocv = (bat_volt + bat_current * bat_resistance) * volt_nominal / interpolate(current_soc / 10, volt_curve)
2719
- reserve = capacity * min_soc / 100
2720
2733
  output(f"\nBattery Info:")
2721
2734
  output(f" Capacity: {capacity:.2f}kWh")
2722
2735
  output(f" Residual: {residual:.2f}kWh")
@@ -2727,7 +2740,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2727
2740
  output(f" Current SoC: {current_soc}%")
2728
2741
  output(f" Max SoC: {max_soc}% ({capacity * max_soc / 100:.2f}kWh)")
2729
2742
  output(f" Temperature: {temperature:.1f}°C")
2730
- output(f" Max Charge: {bms_charge_current:.1f}A")
2743
+ output(f" Max Charge: {charge_current:.1f}A")
2731
2744
  output(f" Resistance: {bat_resistance:.2f} ohms")
2732
2745
  output(f" Nominal OCV: {bat_ocv:.1f}V at {nominal_soc}% SoC")
2733
2746
  # charge current may be derated based on temperature
@@ -2927,6 +2940,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2927
2940
  output(f" SoC now: {current_soc:.0f}% at {hours_time(hour_now)} on {today}")
2928
2941
  charge_message = "no charge needed"
2929
2942
  kwh_needed = 0.0
2943
+ kwh_spare = kwh_min - reserve
2930
2944
  hours = 0.0
2931
2945
  start_timed = time_to_end
2932
2946
  end_timed = time_to_end
@@ -2934,6 +2948,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2934
2948
  else:
2935
2949
  # work out time to add kwh_needed to battery
2936
2950
  charge_rate = charge_power * charge_loss
2951
+ discharge_rate = max([(start_residual - end_residual) / charge_time - bms_loss, 0.0])
2937
2952
  hours = kwh_needed / charge_rate
2938
2953
  if test_charge is None:
2939
2954
  output(f"\nCharge needed: {kwh_needed:.2f}kWh ({hours_time(hours)})")
@@ -2944,13 +2959,15 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2944
2959
  if hours > charge_time:
2945
2960
  hours = charge_time
2946
2961
  elif hours > hours_to_full:
2947
- kwh_shortfall = (hours - hours_to_full) * charge_rate # amount of energy that won't be added
2948
- required = hours_to_full + charge_time * kwh_shortfall / abs(start_residual - end_residual) # hold time to recover energy not added
2962
+ kwh_shortfall = kwh_needed - (capacity - start_residual) # amount of energy that won't be added
2963
+ required = (hours_to_full + kwh_shortfall / discharge_rate) if discharge_rate > 0.0 else charge_time
2949
2964
  hours = required if required > hours and required < charge_time else charge_time
2950
2965
  # round charge time and work out what will actually be added
2951
2966
  min_hours = charge_config['min_hours']
2952
2967
  hours = int(hours / min_hours + 0.99) * min_hours
2953
2968
  kwh_added = (hours * charge_rate) if hours < hours_to_full else (capacity - start_residual)
2969
+ kwh_added += discharge_rate * hours # discharge saved during charging
2970
+ kwh_spare = kwh_min - reserve + kwh_added
2954
2971
  # rework charge and discharge
2955
2972
  charge_period = get_best_charge_period(start_at, hours)
2956
2973
  charge_offset = round_time(charge_period['start'] - start_at) if charge_period is not None else 0
@@ -2986,7 +3003,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2986
3003
  end_residual = interpolate(time_to_end, bat_timed) # residual when charge time ends
2987
3004
  # show the results
2988
3005
  output(f" End SoC: {end_residual / capacity * 100:.0f}% at {hours_time(adjusted_hour(time_to_end, time_line))} ({end_residual:.2f}kWh)")
2989
- output(f" Contingency: {kwh_contingency / capacity * 100:.0f}% SoC ({kwh_contingency:.2f}kWh)")
3006
+ output(f" Contingency: {kwh_spare / capacity * 100:.0f}% SoC ({kwh_spare:.2f}kWh)")
2990
3007
  if not charge_today:
2991
3008
  output(f" PV cover: {expected / consumption * 100:.0f}% ({expected:.1f}/{consumption:.1f})")
2992
3009
  # setup charging
@@ -3215,19 +3232,19 @@ def bat_count(cell_count):
3215
3232
  battery_info_app_key = "aug938dqt5cbqhvq69ixc4v39q6wtw"
3216
3233
 
3217
3234
  # show information about the current state of the batteries
3218
- def battery_info(log=0, plot=1, count=None, info=1, bat=None):
3235
+ def battery_info(log=0, plot=1, rated=None, count=None, info=1, bat=None):
3219
3236
  global debug_setting, battery_info_app_key
3220
3237
  if bat is None:
3221
- bats = get_batteries(info=info)
3238
+ bats = get_batteries(info=info, rated=rated, count=count)
3222
3239
  if bats is None:
3223
3240
  return None
3224
3241
  for i in range(0, len(bats)):
3225
3242
  output(f"\n----------------------- BMS {i+1} -----------------------")
3226
- battery_info(log=log, plot=plot, count=count, info=info, bat=bats[i])
3243
+ battery_info(log=log, plot=plot, info=info, bat=bats[i])
3227
3244
  return None
3228
3245
  output_spool(battery_info_app_key)
3229
3246
  nbat = None
3230
- if info == 1 and bat.get('info') is not None:
3247
+ if bat.get('info') is not None:
3231
3248
  b = bat['info']
3232
3249
  output(f"SN {b['masterSN']}, {b['masterBatType']}, Version {b['masterVersion']} (BMS)")
3233
3250
  nbat = 0
@@ -3250,7 +3267,7 @@ def battery_info(log=0, plot=1, count=None, info=1, bat=None):
3250
3267
  return None
3251
3268
  nv = len(cell_volts)
3252
3269
  if nbat is None:
3253
- nbat = bat_count(nv) if count is None else count
3270
+ nbat = bat_count(nv) if bat.get('count') is None else bat['count']
3254
3271
  if nbat is None:
3255
3272
  output(f"** battery_info(): unable to match cells_per_battery for {nv}")
3256
3273
  output_close()
@@ -1,3 +1,17 @@
1
+ Metadata-Version: 2.1
2
+ Name: foxesscloud
3
+ Version: 2.6.9
4
+ Summary: library for accessing Fox ESS cloud data using Open API
5
+ Author-email: Tony Matthews <tony@quasair.co.uk>
6
+ Project-URL: Homepage, https://github.com/TonyM1958/FoxESS-Cloud
7
+ Project-URL: Bug Tracker, https://github.com/TonyM1958/FoxESS-Cloud/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.7
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENCE
14
+
1
15
  # FoxESS-Cloud
2
16
 
3
17
  <a href="https://www.buymeacoffee.com/tonym1958" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="41" width="174" align="right"></a>
@@ -90,8 +104,8 @@ Once an inverter is selected, you can make other calls to get information:
90
104
 
91
105
  ```
92
106
  f.get_generation()
93
- f.get_battery()
94
- f.get_batteries()
107
+ f.get_battery(info, rated, count)
108
+ f.get_batteries(info, rated, count)
95
109
  f.get_settings()
96
110
  f.get_charge()
97
111
  f.get_min()
@@ -105,7 +119,12 @@ Each of these calls will return a dictionary or list containing the relevant inf
105
119
  get_generation() will return the latest generation information for the device. The results are also stored in f.device as 'generationToday', 'generationMonth' and 'generationTotal'.
106
120
 
107
121
  get_battery() / get_batteries() returns the current battery status, including 'soc', 'volt', 'current', 'power', 'temperature' and 'residual'. The result also updates f.battery / f.batteries.
108
- get_batteries() returns multiple batteries (if available) as a list. get_battery() returns the first battery. Additional battery attributes include:
122
+ get_batteries() returns multiple batteries (if available) as a list. get_battery() returns the first battery. Parameters:
123
+ + 'info': get battery serial number info, if available. Default 0 (not available via Open API)
124
+ + 'rated': optional rated capacity for the battery in Wh to work out SoH. If not provided, it will try to work this out.
125
+ + 'count': optional battery count. If not provided, it will try to work this out.
126
+
127
+ Additional battery attributes provided include:
109
128
  + 'capacity': the estimated battery capacity, derrived from 'residual' and 'soc'
110
129
  + 'charge_rate': the estimated BMS charge rate available, based on the current 'temperature' of the BMS
111
130
  + 'charge_loss': the ratio of the kWh added to the battery for each kWh applied during charging
@@ -131,7 +150,7 @@ f.set_charge(ch1, st1, en1, ch2, st2, en2, enable)
131
150
  f.set_period(start, end, mode, min_soc, max_soc, fdsoc, fdpwr, price, segment)
132
151
  f.charge_periods(st0, en0, st1, en1, st2, en2, min_soc, target_soc, start_soc)
133
152
  f.set_schedule(periods, enable)
134
- f.set_named_settings(name, value)
153
+ f.set_named_settings(name, value, force)
135
154
  ```
136
155
 
137
156
  set_min() applies new SoC settings to the inverter. The parameters update battery_settings:
@@ -174,7 +193,9 @@ set_schedule() configures a list of scheduled work mode / soc changes with enabl
174
193
 
175
194
  set_named_settings() sets the 'name' setting to 'value'.
176
195
  + 'name' may also be a list of (name, value) pairs.
177
- + the only 'name' currently supported by Fox is 'ExportLimit' on H3 inverters
196
+ + 'force': setting to 1 will disable Mode Scheduler, if enabled. Default is 0.
197
+ + A return value of 1 is success. 0 means setting failed. None is another error e.g. device not found, invalid name or value.
198
+ + the only 'name' currently supported is 'ExportLimit'
178
199
 
179
200
 
180
201
  ## Real Time Data
@@ -783,6 +804,14 @@ This setting can be:
783
804
 
784
805
  # Version Info
785
806
 
807
+ 2.6.9<br>
808
+ Add get and set_named_settings() (for WorkMode and ExportLimit).
809
+ If a list of named settings is provided, the return value is a list indicating which settings succeeded (1) or failed (0).
810
+ Updates to get_battery() / get_batteries() to add optional rated and count parameters.
811
+ Updates to charge_needed() to end prediction at start of next charge period.
812
+ Correct charge time for Octopus Go tariff.
813
+ Update charge_needed() to show contingency achieved rather than requested.
814
+
786
815
  2.6.8<br>
787
816
  Add residual_handling=3 for Mira BMS with firmware 1.014 or later that returns residual capacity per battery.
788
817
  Calculate 'ratedCapacity' in get_battery() and 'soh' for HV2600 and Mira.
File without changes
File without changes