foxesscloud 2.6.4__py3-none-any.whl → 2.6.6__py3-none-any.whl

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,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud
4
- Updated: 13 October 2024
4
+ Updated: 01 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.6"
13
+ version = "1.7.8"
14
14
  print(f"FoxESS-Cloud version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -583,7 +583,7 @@ battery = None
583
583
  batteries = None
584
584
  battery_settings = None
585
585
 
586
- # 1 = Residual Energy, 2 = Residual Capacity
586
+ # 1 = Residual Energy, 2 = Residual Capacity (HV), 3 = Residual Capacity per battery (Mira)
587
587
  residual_handling = 1
588
588
 
589
589
  # charge rates based on residual_handling
@@ -593,13 +593,20 @@ battery_params = {
593
593
  1: {'table': [ 0, 2, 10, 15, 25, 50, 50, 50, 50, 50, 30, 20, 0],
594
594
  'step': 5,
595
595
  'offset': 5,
596
- 'charge_loss': 0.975,
597
- 'discharge_loss': 0.975},
596
+ 'charge_loss': 0.974,
597
+ 'discharge_loss': 0.974},
598
+ # HV BMS v2 with firmware 1.014 or later
598
599
  2: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
599
600
  'step': 5,
600
601
  'offset': 5,
601
602
  'charge_loss': 1.08,
602
- 'discharge_loss': 0.85},
603
+ 'discharge_loss': 0.95},
604
+ # Mira BMS with firmware 1.014 or later
605
+ 3: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
606
+ 'step': 5,
607
+ 'offset': 5,
608
+ 'charge_loss': 0.974,
609
+ 'discharge_loss': 0.974},
603
610
  }
604
611
 
605
612
  def get_battery(info=1):
@@ -632,10 +639,13 @@ def get_battery(info=1):
632
639
  output(f"** get_battery().info, no result data, {errno_message(errno)}")
633
640
  else:
634
641
  battery['info'] = result['batteries'][0]
635
- if battery['info']['masterVersion'] >= '1.014' and battery['info']['masterSN'][:7] == '60BBHV2':
642
+ if battery['info'].get('slaveBatteries') is not None:
643
+ battery['count'] = len(battery['info']['slaveBatteries'])
644
+ if battery['info']['masterSN'][:7] == '60BBHV2' and battery['info']['masterVersion'] >= '1.014':
636
645
  residual_handling = 2
646
+ elif battery['info']['masterSN'][:7] == '60MBB01' and battery['info']['masterVersion'] >= '1.014':
647
+ residual_handling = 3
637
648
  battery['residual_handling'] = residual_handling
638
- battery['rated_capacity'] = None
639
649
  battery['soh'] = None
640
650
  battery['soh_supported'] = False
641
651
  if battery.get('residual') is not None:
@@ -644,6 +654,18 @@ def get_battery(info=1):
644
654
  capacity = battery.get('residual')
645
655
  soc = battery.get('soc')
646
656
  residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
657
+ if battery.get('count') is None:
658
+ battery['count'] = int(battery['volt'] / 49)
659
+ if battery.get('ratedCapacity') is None:
660
+ battery['ratedCapacity'] = 2560 * battery['count']
661
+ elif battery['residual_handling'] == 3:
662
+ if battery.get('count') is None:
663
+ battery['count'] = int(battery['volt'] / 49)
664
+ capacity = (battery['residual'] * battery['count']) if battery.get('residual') is not None else None
665
+ soc = battery.get('soc')
666
+ residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
667
+ if battery.get('ratedCapacity') is None:
668
+ battery['ratedCapacity'] = 2450 * battery['count']
647
669
  else:
648
670
  residual = battery.get('residual')
649
671
  soc = battery.get('soc')
@@ -654,6 +676,8 @@ def get_battery(info=1):
654
676
  params = battery_params[battery['residual_handling']]
655
677
  battery['charge_loss'] = params['charge_loss']
656
678
  battery['discharge_loss'] = params['discharge_loss']
679
+ if battery.get('ratedCapacity') is not None and battery.get('capacity') is not None:
680
+ battery['soh'] = round(battery['capacity'] * 1000 / battery['ratedCapacity'] * 100, 1)
657
681
  if battery.get('temperature') is not None:
658
682
  battery['charge_rate'] = params['table'][int((battery['temperature'] - params['offset']) / params['step'])]
659
683
  return battery
@@ -688,8 +712,13 @@ def get_batteries(info=1):
688
712
  batteries[i]['info'] = result['batteries'][i]
689
713
  for b in batteries:
690
714
  b['residual_handling'] = residual_handling
691
- if b.get('info') is not None and b['info']['masterVersion'] >= '1.014' and b['info']['masterSN'][:7] == '60BBHV2':
692
- b['residual_handling'] = 2
715
+ if b.get('info') is not None:
716
+ if b['info'].get('slaveBatteries') is not None:
717
+ b['count'] = len(b['info']['slaveBatteries'])
718
+ if b['info']['masterVersion'] >= '1.014' and b['info']['masterSN'][:7] == '60BBHV2':
719
+ b['residual_handling'] = 2
720
+ elif battery['info']['masterSN'][:7] == '60MBB01' and battery['info']['masterVersion'] >= '1.014':
721
+ residual_handling = 3
693
722
  rated_capacity = b.get('ratedCapacity')
694
723
  b['ratedCapacity'] = rated_capacity if rated_capacity is not None and rated_capacity > 100 else None
695
724
  soh = b.get('soh')
@@ -708,7 +737,7 @@ def get_batteries(info=1):
708
737
  soc = b.get('soc')
709
738
  residual = b['capacity'] * b['soc'] / 100
710
739
  b['residual'] = round(residual, 3)
711
- if b.get('capacity') is not None and b.get('ratedCapacity') is not None:
740
+ if b.get('ratedCapacity') is not None and b.get('capacity') is not None:
712
741
  b['soh'] = round(b['capacity'] * 1000 / b['ratedCapacity'] * 100, 1)
713
742
  b['charge_rate'] = 50
714
743
  params = battery_params[b['residual_handling']]
@@ -1022,7 +1051,7 @@ def get_remote_settings(key):
1022
1051
  result = response.json().get('result')
1023
1052
  if result is None:
1024
1053
  errno = response.json().get('errno')
1025
- output(f"** get_remote_settings(), no result data, {errno_message(errno)}")
1054
+ output(f"** get_remote_settings(), no result data, {errno_message(response)}")
1026
1055
  return None
1027
1056
  values = result.get('values')
1028
1057
  if values is None:
@@ -1323,10 +1352,11 @@ def set_period(start=None, end=None, mode=None, min_soc=None, max_soc=None, fdso
1323
1352
  price = segment.get('price')
1324
1353
  start = time_hours(start)
1325
1354
  # adjust exclusive time to inclusive
1326
- end = round_time(time_hours(end) - 1/60)
1355
+ end = time_hours(end)
1327
1356
  if start is None or end is None or start >= end:
1328
1357
  output(f"set_period(): ** invalid period times: {hours_time(start)}-{hours_time(end)}")
1329
1358
  return None
1359
+ end = round_time(end - 1/60)
1330
1360
  mode = 'SelfUse' if mode is None else mode
1331
1361
  if mode not in work_modes:
1332
1362
  output(f"** mode must be one of {work_modes}")
@@ -1399,7 +1429,7 @@ def set_schedule(periods=None, template=None, enable=True):
1399
1429
  if len(periods) > 8:
1400
1430
  output(f"** set_schedule(): maximum of 8 periods allowed, {len(periods)} provided")
1401
1431
  return None
1402
- data = {'pollcy': periods, 'deviceSN': device_sn}
1432
+ data = {'pollcy': periods[-8:], 'deviceSN': device_sn}
1403
1433
  schedule['pollcy'] = periods
1404
1434
  schedule['template_id'] = None
1405
1435
  elif template is not None:
@@ -1436,7 +1466,7 @@ def set_schedule(periods=None, template=None, enable=True):
1436
1466
  # d = day 'YYYY-MM-DD'. Can also include 'HH:MM' in 'hour' mode
1437
1467
  # v = list of variables to get
1438
1468
  # summary = 0: raw data, 1: add max, min, sum, 2: summarise and drop raw data, 3: calculate state
1439
- # save = "xxxxx": save the raw results to xxxxx_raw_<time_span>_<d>.json
1469
+ # save = "xxxxx": save the raw results to xxxxx_history_<time_span>_<d>.json
1440
1470
  # load = "<file>": load the raw results from <file>
1441
1471
  # plot = 0: no plot, 1: plot variables separately, 2: combine variables
1442
1472
  # station = 0: use device_id, 1: use station_id
@@ -1504,7 +1534,7 @@ def get_raw(time_span='hour', d=None, v=None, summary=1, save=None, load=None, p
1504
1534
  result = json.load(file)
1505
1535
  file.close()
1506
1536
  if save is not None:
1507
- file_name = save + "_raw_" + time_span + "_" + d[0:10].replace('-','') + ".txt"
1537
+ file_name = save + "_history_" + time_span + "_" + d[0:10].replace('-','') + ".txt"
1508
1538
  file = open(storage + file_name, 'w', encoding='utf-8')
1509
1539
  json.dump(result, file, indent=4, ensure_ascii= False)
1510
1540
  file.close()
@@ -1709,7 +1739,7 @@ get_history = get_raw
1709
1739
  # d = day 'YYYY-MM-DD'
1710
1740
  # v = list of report variables to get
1711
1741
  # summary = 0, 1, 2: do a quick total energy report for a day
1712
- # save = "xxxxx": save the report results to xxxxx_raw_<time_span>_<d>.json
1742
+ # save = "xxxxx": save the report results to xxxxx_report_<time_span>_<d>.json
1713
1743
  # load = "<file>": load the report results from <file>
1714
1744
  # plot = 0: no plot, 1 = plot variables separately, 2 = combine variables
1715
1745
  # station = 0: use device_id, 1 = use station_id
@@ -1839,7 +1869,7 @@ def get_report(report_type='day', d=None, v=None, summary=1, save=None, load=Non
1839
1869
  result = json.load(file)
1840
1870
  file.close()
1841
1871
  elif save is not None:
1842
- file_name = save + "_rep_" + report_type + "_" + d.replace('-','') + ".txt"
1872
+ file_name = save + "_report_" + report_type + "_" + d.replace('-','') + ".txt"
1843
1873
  file = open(storage + file_name, 'w', encoding='utf-8')
1844
1874
  json.dump(result, file, indent=4, ensure_ascii= False)
1845
1875
  file.close()
@@ -2588,7 +2618,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
2588
2618
  elif type(strategy) is not list:
2589
2619
  strategy = [strategy]
2590
2620
  output(f"\nStrategy")
2591
- use['strategy'] = get_strategy(use=use, strategy=strategy, quiet=0, remove=[use.get('off_peak1'), use.get('off_peak2'), use.get('off_peak3'), use.get('off_peak4')])
2621
+ use['strategy'] = get_strategy(use=use, strategy=strategy, quiet=0) #, remove=[use.get('off_peak1'), use.get('off_peak2'), use.get('off_peak3'), use.get('off_peak4')])
2592
2622
  output_close(plot=tariff_config['show_plot'])
2593
2623
  if update == 1:
2594
2624
  tariff = use
@@ -2665,15 +2695,15 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
2665
2695
  return profile[:run_time]
2666
2696
 
2667
2697
  # build the timed work mode profile from the tariff strategy:
2668
- def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, current_mode=None):
2698
+ def strategy_timed(timed_mode, time_line, run_time, min_soc=10, max_soc=100, current_mode=None):
2669
2699
  global tariff, steps_per_hour
2670
2700
  work_mode_timed = []
2671
2701
  min_soc_now = min_soc
2672
2702
  max_soc_now = max_soc
2673
2703
  current_mode = 'SelfUse' if current_mode is None else current_mode
2674
2704
  strategy = get_strategy(timed_mode=timed_mode)
2675
- h = base_hour
2676
2705
  for i in range(0, run_time):
2706
+ h = time_line[i]
2677
2707
  period = {'mode': current_mode, 'min_soc': min_soc_now, 'max_soc': max_soc, 'fdpwr': 0, 'fdsoc': min_soc_now, 'duration': 1.0, 'charge': 0.0,
2678
2708
  'pv': 0.0, 'discharge': 0.0, 'hold': 0, 'kwh': None}
2679
2709
  if strategy is not None:
@@ -2692,8 +2722,8 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2692
2722
  if d.get('fdpwr') is not None:
2693
2723
  period['fdpwr'] = d['fdpwr']
2694
2724
  period['duration'] = duration_in(h, d, steps_per_hour) * steps_per_hour
2725
+ break
2695
2726
  work_mode_timed.append(period)
2696
- h = round_time(h + 1 / steps_per_hour)
2697
2727
  return work_mode_timed
2698
2728
 
2699
2729
  # build the timed battery residual from the charge / discharge, work mode and min_soc
@@ -2706,7 +2736,8 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2706
2736
  discharge_loss = charge_config['discharge_loss']
2707
2737
  charge_limit = charge_config['charge_limit']
2708
2738
  float_charge = charge_config['float_charge']
2709
- for i in range(0, len(work_mode_timed)):
2739
+ run_time = len(work_mode_timed)
2740
+ for i in range(0, run_time):
2710
2741
  w = work_mode_timed[i]
2711
2742
  w['kwh'] = kwh_current
2712
2743
  max_now = w['max_soc'] * capacity / 100
@@ -2717,6 +2748,7 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2717
2748
  if kwh_current > capacity:
2718
2749
  # battery is full
2719
2750
  kwh_current = capacity
2751
+ w = work_mode_timed[i+1] if (i + 1) < run_time else w
2720
2752
  min_soc_now = w['fdsoc'] if w['mode'] =='ForceDischarge' else w['min_soc']
2721
2753
  reserve_now = capacity * min_soc_now / 100
2722
2754
  if kwh_current < reserve_now and (i < time_to_next or kwh_min is None):
@@ -2746,7 +2778,7 @@ def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
2746
2778
  period = times[0] if len(times) > 0 else work_mode_timed[0]
2747
2779
  next_period = work_mode_timed[t]
2748
2780
  h = base_hour + t / steps_per_hour
2749
- if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold']:
2781
+ if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold'] or period['min_soc'] != next_period['min_soc']:
2750
2782
  s = {'start': start % 24, 'end': h % 24, 'mode': period['mode'], 'min_soc': period['min_soc']}
2751
2783
  if period['mode'] == 'ForceDischarge':
2752
2784
  s['fdsoc'] = period.get('fdsoc')
@@ -2838,7 +2870,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
2838
2870
  def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
2839
2871
  forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
2840
2872
  global device, seasonality, solcast_api_key, debug_setting, tariff, solar_arrays, legend_location, time_shift, charge_needed_app_key
2841
- global timed_strategy, steps_per_hour, base_time, storage, battery, charge_rates
2873
+ global timed_strategy, steps_per_hour, base_time, storage, battery, battery_params
2842
2874
  print(f"\n---------------- charge_needed ----------------")
2843
2875
  # validate parameters
2844
2876
  args = locals()
@@ -2945,8 +2977,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2945
2977
  bat_power = 0.0
2946
2978
  temperature = 30
2947
2979
  bms_charge_current = 15
2948
- charge_loss = charge_rates[2]['charge_loss']
2949
- discharge_loss = charge_rates[2]['discharge_loss']
2980
+ charge_loss = battery_params[2]['charge_loss']
2981
+ discharge_loss = battery_params[2]['discharge_loss']
2950
2982
  bat_current = 0.0
2951
2983
  device_power = 6.0
2952
2984
  device_current = 35
@@ -2968,8 +3000,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2968
3000
  output(f"Battery capacity could not be estimated. Please add the parameter 'capacity=xx' in kWh")
2969
3001
  return None
2970
3002
  bms_charge_current = battery.get('charge_rate')
2971
- charge_loss = battery['charge_loss'] if battery.get('charge_loss') is not None else 1.0
2972
- discharge_loss = battery['discharge_loss'] if battery.get('discharge_loss') is not None else 1.0
3003
+ charge_loss = battery['charge_loss'] if battery.get('charge_loss') is not None else 0.974
3004
+ discharge_loss = battery['discharge_loss'] if battery.get('discharge_loss') is not None else 0.974
2973
3005
  device_power = device.get('power')
2974
3006
  device_current = device.get('max_charge_current')
2975
3007
  model = device.get('deviceType')
@@ -3141,7 +3173,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3141
3173
  # produce time lines for charge, discharge and work mode
3142
3174
  charge_timed = [min([charge_limit, c_float(x) * pv_loss]) for x in generation_timed]
3143
3175
  discharge_timed = [min([discharge_limit, c_float(x) / dc_ac_loss]) + bms_loss for x in consumption_timed]
3144
- work_mode_timed = strategy_timed(timed_mode, base_hour, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
3176
+ work_mode_timed = strategy_timed(timed_mode, time_line, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
3145
3177
  for i in range(0, len(work_mode_timed)):
3146
3178
  # get work mode
3147
3179
  work_mode = work_mode_timed[i]['mode']
@@ -3269,14 +3301,15 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3269
3301
  set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1, enable=update_settings)
3270
3302
  if update_settings == 0:
3271
3303
  output(f"\nNo changes made to charge settings")
3304
+ start_t = 0 # int(hour_now % 1 + 0.5) * steps_per_hour
3272
3305
  if show_data > 0:
3273
3306
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3274
3307
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
3275
3308
  h = base_hour
3276
- t = 0
3309
+ t = start_t
3277
3310
  while t < len(time_line) and bat_timed[t] is not None:
3278
3311
  col = h % data_wrap
3279
- s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3312
+ s += f"\n {hours_time(time_line[t])}" if t == start_t or col == 0 else ""
3280
3313
  s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
3281
3314
  h += 1
3282
3315
  t += steps_per_hour
@@ -3284,8 +3317,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3284
3317
  if show_plot > 0:
3285
3318
  print()
3286
3319
  plt.figure(figsize=(figure_width, figure_width/2))
3287
- x_timed = [i for i in range(0, run_time)]
3288
- x_ticks = [i for i in range(0, run_time, steps_per_hour)]
3320
+ x_timed = [i for i in range(start_t, run_time)]
3321
+ x_ticks = [i for i in range(start_t, run_time, steps_per_hour)]
3289
3322
  plt.xticks(ticks=x_ticks, labels=[hours_time(time_line[x]) for x in x_ticks], rotation=90, fontsize=8, ha='center')
3290
3323
  if show_plot == 1:
3291
3324
  title = f"Predicted Battery SoC % at {base_time}({charge_message})"
@@ -3395,14 +3428,15 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3, d=None):
3395
3428
  for v in plots.keys():
3396
3429
  for i in range(0, run_time):
3397
3430
  plots[v][i] = plots[v][i] / count[v][i] if count[v][i] > 0 else None
3431
+ start_t = 0 #int(hour_now % 1 + 0.5) * steps_per_hour
3398
3432
  if show_data > 0 and plots.get('SoC') is not None:
3399
3433
  data_wrap = 1 #charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 1
3400
3434
  s = f"\nBattery Energy kWh (predicted / actual):" if show_data == 2 else f"\nBattery SoC (predicted / actual):"
3401
3435
  h = base_hour
3402
- t = 0
3436
+ t = start_t
3403
3437
  while t < len(time_line) and bat_timed[t] is not None and plots['SoC'][t] is not None:
3404
3438
  col = h % data_wrap
3405
- s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3439
+ s += f"\n {hours_time(time_line[t])}" if t == start_t or col == 0 else ""
3406
3440
  s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
3407
3441
  s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
3408
3442
  h += 1
@@ -3411,8 +3445,8 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3, d=None):
3411
3445
  if show_plot > 0:
3412
3446
  print()
3413
3447
  plt.figure(figsize=(figure_width, figure_width/2))
3414
- x_timed = [i for i in range(0, run_time)]
3415
- x_ticks = [i for i in range(0, run_time, steps_per_hour)]
3448
+ x_timed = [i for i in range(start_t, run_time)]
3449
+ x_ticks = [i for i in range(start_t, run_time, steps_per_hour)]
3416
3450
  plt.xticks(ticks=x_ticks, labels=[hours_time(time_line[x]) for x in x_ticks], rotation=90, fontsize=8, ha='center')
3417
3451
  if show_plot == 1:
3418
3452
  title = f"Predicted Battery SoC % at {base_time}({charge_message})"
@@ -3555,11 +3589,11 @@ def battery_info(log=0, plot=1, count=None, info=1, bat=None):
3555
3589
  s +=f",{v:.0f}"
3556
3590
  return s
3557
3591
  output(f"Current SoC: {current_soc}%")
3558
- output(f"Capacity: {capacity:.2f}kWh" + (" (Residual / SoC x 100)" if bat['residual_handling'] == 1 else ""))
3559
- output(f"Residual: {residual:.2f}kWh" + (" (SoC x Capacity / 100)" if bat['residual_handling'] == 2 else ""))
3592
+ output(f"Capacity: {capacity:.2f}kWh" + (" (calculated)" if bat['residual_handling'] in [1,3] else ""))
3593
+ output(f"Residual: {residual:.2f}kWh" + (" (calculated)" if bat['residual_handling'] in [2,3] else ""))
3560
3594
  if rated_capacity is not None and bat_soh is not None:
3561
3595
  output(f"Rated Capacity: {rated_capacity / 1000:.2f}kWh")
3562
- output(f"SoH: {bat_soh:.1f}%" + (" (Capacity / Rated Capacity x 100)" if not bat['soh_supported'] else ""))
3596
+ output(f"SoH: {bat_soh:.1f}%" + (" (calculated)" if not bat['soh_supported'] else ""))
3563
3597
  output(f"InvBatVolt: {bat_volt:.1f}V")
3564
3598
  output(f"InvBatCurrent: {bat_current:.1f}A")
3565
3599
  output(f"State: {'Charging' if bat_power < 0 else 'Discharging'} ({abs(bat_power):.3f}kW)")
@@ -3968,7 +4002,6 @@ class Solcast :
3968
4002
  # The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
3969
4003
  # The forecasts and estimated also both include the current time, so the data has to be de-duplicated to get an accurate total for a day
3970
4004
  global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
3971
- self.data = {}
3972
4005
  now = convert_date(d)
3973
4006
  self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
3974
4007
  self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
@@ -3976,6 +4009,8 @@ class Solcast :
3976
4009
  self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
3977
4010
  self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
3978
4011
  self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
4012
+ self.data = {}
4013
+ self.rids = []
3979
4014
  if reload == 1 and os.path.exists(storage + self.save):
3980
4015
  os.remove(storage + self.save)
3981
4016
  if self.save is not None and os.path.exists(storage + self.save):
@@ -3984,33 +4019,37 @@ class Solcast :
3984
4019
  file.close()
3985
4020
  if len(self.data) == 0:
3986
4021
  print(f"No data in {self.save}")
3987
- elif reload == 2 and 'date' in self.data and self.data['date'] != self.today:
3988
- self.data = {}
3989
- elif debug_setting > 0 and not quiet:
3990
- print(f"Using data for {self.data['date']} from {self.save}")
3991
- if len(self.data) == 0 :
4022
+ else:
4023
+ self.rids = self.data['forecasts'].keys() if self.data.get('forecasts') is not None else []
4024
+ if reload == 2 and self.data.get('date') is not None and self.data['date'] != self.today:
4025
+ self.data = {}
4026
+ elif debug_setting > 0 and not quiet:
4027
+ print(f"Using data for {self.data['date']} from {self.save}")
4028
+ if len(self.data) == 0:
3992
4029
  if solcast_api_key is None or solcast_api_key == 'my.solcast_api_key>':
3993
4030
  print(f"\nSolcast: solcast_api_key not set, exiting")
3994
4031
  return
3995
4032
  self.credentials = HTTPBasicAuth(solcast_api_key, '')
3996
- if debug_setting > 1 and not quiet:
3997
- print(f"Getting rids from solcast.com")
3998
- params = {'format' : 'json'}
3999
- response = requests.get(solcast_url + 'rooftop_sites', auth = self.credentials, params = params)
4000
- if response.status_code != 200:
4001
- if response.status_code == 429:
4002
- print(f"\nSolcast API call limit reached for today")
4003
- else:
4004
- print(f"Solcast: response code getting rooftop_sites was {response.status_code}: {response.reason}")
4005
- return
4006
- sites = response.json().get('sites')
4033
+ if len(self.rids) == 0:
4034
+ if debug_setting > 1 and not quiet:
4035
+ print(f"Getting rids from solcast.com")
4036
+ params = {'format' : 'json'}
4037
+ response = requests.get(solcast_url + 'rooftop_sites', auth = self.credentials, params = params)
4038
+ if response.status_code != 200:
4039
+ if response.status_code == 429:
4040
+ print(f"\nSolcast API call limit reached for today")
4041
+ else:
4042
+ print(f"Solcast: response code getting rooftop_sites was {response.status_code}: {response.reason}")
4043
+ return
4044
+ sites = response.json().get('sites')
4045
+ self.rids = [s['resource_id'] for s in sites]
4007
4046
  if debug_setting > 0 and not quiet:
4008
4047
  print(f"Getting forecast for {self.today} from solcast.com")
4009
4048
  self.data['date'] = self.today
4010
4049
  params = {'format' : 'json', 'hours' : 168, 'period' : 'PT30M'} # always get 168 x 30 min values
4011
4050
  for t in ['forecasts'] if estimated == 0 else ['forecasts', 'estimated_actuals']:
4012
4051
  self.data[t] = {}
4013
- for rid in [s['resource_id'] for s in sites] :
4052
+ for rid in self.rids:
4014
4053
  response = requests.get(solcast_url + 'rooftop_sites/' + rid + '/' + t, auth = self.credentials, params = params)
4015
4054
  if response.status_code != 200 :
4016
4055
  if response.status_code == 429:
foxesscloud/openapi.py CHANGED
@@ -1,7 +1,7 @@
1
1
  ##################################################################################################
2
2
  """
3
3
  Module: Fox ESS Cloud using Open API
4
- Updated: 13 October 2024
4
+ Updated: 01 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.4"
13
+ version = "2.6.6"
14
14
  print(f"FoxESS-Cloud Open API version {version}")
15
15
 
16
16
  debug_setting = 1
@@ -281,7 +281,7 @@ var_table = None
281
281
  var_list = None
282
282
 
283
283
  def get_vars():
284
- global var_table, var_list, debug_setting, messages, lang, token
284
+ global var_table, var_list, debug_setting, messages, lang
285
285
  if api_key is None:
286
286
  output(f"** please generate an API Key at foxesscloud.com and provide this (f.api_key='your API key')")
287
287
  return None
@@ -548,7 +548,7 @@ battery_settings = None
548
548
  battery_vars = ['SoC', 'invBatVolt', 'invBatCurrent', 'invBatPower', 'batTemperature', 'ResidualEnergy' ]
549
549
  battery_data = ['soc', 'volt', 'current', 'power', 'temperature', 'residual']
550
550
 
551
- # 1 = returns Residual Energy. 2 = resturns Residual Capacity
551
+ # 1 = Residual Energy, 2 = Residual Capacity (HV), 3 = Residual Capacity per battery (Mira)
552
552
  residual_handling = 1
553
553
 
554
554
  # charge rates based on residual_handling
@@ -558,13 +558,20 @@ battery_params = {
558
558
  1: {'table': [ 0, 2, 10, 15, 25, 50, 50, 50, 50, 50, 30, 20, 0],
559
559
  'step': 5,
560
560
  'offset': 5,
561
- 'charge_loss': 0.975,
562
- 'discharge_loss': 0.975},
561
+ 'charge_loss': 0.974,
562
+ 'discharge_loss': 0.974},
563
+ # HV BMS v2 with firmware 1.014 or later
563
564
  2: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
564
565
  'step': 5,
565
566
  'offset': 5,
566
- 'charge_loss': 1.080,
567
- 'discharge_loss': 0.85},
567
+ 'charge_loss': 1.08,
568
+ 'discharge_loss': 0.95},
569
+ # Mira BMS with firmware 1.014 or later
570
+ 3: {'table': [ 0, 2, 10, 10, 15, 15, 25, 50, 50, 50, 30, 20, 0],
571
+ 'step': 5,
572
+ 'offset': 5,
573
+ 'charge_loss': 0.974,
574
+ 'discharge_loss': 0.974},
568
575
  }
569
576
 
570
577
  def get_battery(info=0, v=None):
@@ -580,17 +587,28 @@ def get_battery(info=0, v=None):
580
587
  for i in range(0, len(battery_vars)):
581
588
  battery[battery_data[i]] = result[i].get('value')
582
589
  battery['residual_handling'] = residual_handling
583
- battery['rated_capacity'] = None
584
590
  battery['soh'] = None
585
591
  battery['soh_supported'] = False
586
592
  if battery['residual_handling'] == 2:
587
593
  capacity = battery.get('residual')
588
594
  soc = battery.get('soc')
589
595
  residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
596
+ if battery.get('count') is None:
597
+ battery['count'] = int(battery['volt'] / 49)
598
+ if battery.get('ratedCapacity') is None:
599
+ battery['ratedCapacity'] = 2560 * battery['count']
600
+ elif battery['residual_handling'] == 3:
601
+ if battery.get('count') is None:
602
+ battery['count'] = int(battery['volt'] / 49)
603
+ capacity = (battery['residual'] * battery['count']) if battery.get('residual') is not None else None
604
+ soc = battery.get('soc')
605
+ residual = capacity * soc / 100 if capacity is not None and soc is not None else capacity
606
+ if battery.get('ratedCapacity') is None:
607
+ battery['ratedCapacity'] = 2450 * battery['count']
590
608
  else:
591
609
  residual = battery.get('residual')
592
610
  soc = battery.get('soc')
593
- capacity = residual / soc * 100 if residual is not None and soc is not None and soc > 0 else None
611
+ capacity = residual / soc * 100 if residual is not None and soc is not None and soc > 0 else None
594
612
  battery['capacity'] = round(capacity, 3)
595
613
  battery['residual'] = round(residual, 3)
596
614
  battery['status'] = 1
@@ -598,6 +616,8 @@ def get_battery(info=0, v=None):
598
616
  params = battery_params[battery['residual_handling']]
599
617
  battery['charge_loss'] = params['charge_loss']
600
618
  battery['discharge_loss'] = params['discharge_loss']
619
+ if battery.get('ratedCapacity') is not None and battery.get('capacity') is not None:
620
+ battery['soh'] = round(battery['capacity'] * 1000 / battery['ratedCapacity'] * 100, 1)
601
621
  if battery.get('temperature') is not None:
602
622
  battery['charge_rate'] = params['table'][int((battery['temperature'] - params['offset']) / params['step'])]
603
623
  return battery
@@ -615,7 +635,7 @@ def get_batteries(info=0):
615
635
  ##################################################################################################
616
636
 
617
637
  def get_charge():
618
- global token, device_sn, battery_settings, debug_setting
638
+ global device_sn, battery_settings, debug_setting
619
639
  if get_device() is None:
620
640
  return None
621
641
  if battery_settings is None:
@@ -647,7 +667,7 @@ def time_period(t, n):
647
667
  return result
648
668
 
649
669
  def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force = 0, enable=1):
650
- global token, device_sn, battery_settings, debug_setting, time_period_vars
670
+ global device_sn, battery_settings, debug_setting, time_period_vars
651
671
  if get_device() is None:
652
672
  return None
653
673
  if battery_settings is None:
@@ -723,7 +743,7 @@ def set_charge(ch1=None, st1=None, en1=None, ch2=None, st2=None, en2=None, force
723
743
  ##################################################################################################
724
744
 
725
745
  def get_min():
726
- global token, device_sn, battery_settings, debug_setting
746
+ global device_sn, battery_settings, debug_setting
727
747
  if get_device() is None:
728
748
  return None
729
749
  if battery_settings is None:
@@ -747,7 +767,7 @@ def get_min():
747
767
  ##################################################################################################
748
768
 
749
769
  def set_min(minSocOnGrid = None, minSoc = None, force = 0):
750
- global token, device_sn, schedule, battery_settings, debug_setting
770
+ global device_sn, schedule, battery_settings, debug_setting
751
771
  if get_device() is None:
752
772
  return None
753
773
  if schedule['enable'] == True:
@@ -801,160 +821,71 @@ def get_settings():
801
821
  # get remote settings
802
822
  ##################################################################################################
803
823
 
804
- remote_settings = None # raw UI info
805
- named_settings = None # processed UI info
806
- merge_settings = { # keys to add
807
- 'WorkMode': {'keys': {
808
- 'h115__': 'operation_mode__work_mode',
809
- 'h116__': 'operation_mode__work_mode',
810
- 'h117__': 'operation_mode__work_mode',
811
- # 'k106__': 'operation_mode__work_mode',
812
- },
813
- 'values': ['SelfUse', 'Feedin', 'Backup']},
814
- 'BatteryVolt': {'keys': {
815
- 'h115__': ['h115__14', 'h115__15', 'h115__16'],
816
- 'h116__': ['h116__15', 'h116__16', 'h116__17'],
817
- 'h117__': ['h117__15', 'h117__16', 'h117__17'],
818
- # 'k106__': ['k106__xx', 'k106__xx', 'k106__xx'],
819
- },
820
- 'type': 'list',
821
- 'valueType': 'float',
822
- 'unit': 'V'},
823
- 'BatteryTemp': {'keys': {
824
- 'h115__': 'h115__17',
825
- 'h116__': 'h116__18',
826
- 'h117__': 'h117__18',
827
- # 'k106__': 'k106__xx',
828
- },
829
- 'type': 'list',
830
- 'valueType': 'int',
831
- 'unit': '℃'},
832
- }
824
+ # store for named settings info
825
+ named_settings = {}
833
826
 
834
- def get_ui():
835
- global device_id, debug_setting, messages, remote_settings, named_settings, merge_settings
836
- if get_device() is None:
837
- return None
838
- if remote_settings is None:
839
- output(f"getting ui settings", 2)
840
- params = {'id': device_id}
841
- response = signed_get(path="/generic/v0/device/setting/ui", params=params)
842
- if response.status_code != 200:
843
- output(f"** get_ui() got response code {response.status_code}: {response.reason}")
844
- return None
845
- result = response.json().get('result')
846
- if result is None:
847
- errno = response.json().get('errno')
848
- output(f"** get_ui(), no result data, {errno_message(errno)}")
849
- return None
850
- remote_settings = result
851
- protocol = remote_settings['protocol'].lower().replace('xx','__')
852
- named_settings = {'_protocol': protocol}
853
- volt_n = 0
854
- volt_keys = []
855
- for p in remote_settings['parameters']:
856
- if p['name'][:11] == 'BatteryVolt': # merge BatteryVolts
857
- output(f" found {p['name']} with key {p['key']}", 2)
858
- volt_n += 1
859
- volt_keys.append(p['key'])
860
- if volt_n == 3:
861
- named_settings['BatteryVolt'] = {'keys': volt_keys, 'type': 'list', 'valueType': 'float', 'unit': p['properties'][0]['unit']}
862
- elif volt_n > 3:
863
- print(f"** get_ui(): more than 3 groups found for BatteryVolt, n={volt_n}")
864
- elif p['name'][:11] == 'BatteryTemp':
865
- output(f" found {p['name']} with key {p['key']}", 2)
866
- named_settings['BatteryTemp'] = {'keys': p['key'], 'type': 'list', 'valueType': 'int', 'unit': p['properties'][0]['unit']}
867
- else:
868
- items = []
869
- block = p['block'] and len(p['properties']) > 1
870
- for e in p['properties']:
871
- valueType = e['elemType']['valueType']
872
- item = {'name': e['key'].replace(protocol,'')} if block else {'key': e['key']} #, 'group': p['name']}
873
- if e['elemType'].get('uiItems') is not None:
874
- item['values'] = e['elemType']['uiItems']
875
- elif e.get('range') is not None:
876
- item['range'] = e['range']
877
- item['valueType'] = 'float' if type(e['range']['hi']) is float else 'int'
878
- else:
879
- item['type'] = valueType
880
- if e.get('unit') is not None and len(e['unit']) > 0:
881
- item['unit'] = e['unit']
882
- if block:
883
- items.append(item)
884
- else:
885
- named_settings[e['name']] = item
886
- if block:
887
- named_settings[p['name']] = {'key': p['key'], 'type': 'block', 'items': items}
888
- for name in merge_settings.keys():
889
- if named_settings.get(name) is None and merge_settings[name]['keys'].get(protocol) is not None:
890
- named_settings[name] = {'keys': merge_settings[name]['keys'][protocol]}
891
- for k in merge_settings[name].keys():
892
- if k != 'keys':
893
- named_settings[name][k] = merge_settings[name][k]
894
- return remote_settings
895
-
896
- def get_remote_settings(key):
897
- global token, device_id, debug_setting, messages
827
+ def get_remote_settings(name):
828
+ global device_sn, debug_setting, messages, name_data
898
829
  if get_device() is None:
899
830
  return None
900
831
  output(f"getting remote settings", 2)
901
- if key is None:
832
+ if name is None:
902
833
  return None
903
- if type(key) is list:
834
+ if type(name) is list:
904
835
  values = {}
905
- for k in key:
906
- v = get_remote_settings(k)
836
+ for n in name:
837
+ v = get_remote_settings(n)
907
838
  if v is None:
908
- return
839
+ continue
909
840
  for x in v.keys():
910
841
  values[x] = v[x]
911
842
  return values
912
- params = {'id': device_id, 'hasVersionHead': 1, 'key': key}
913
- response = signed_get(path="/c/v0/device/setting/get", params=params)
843
+ body = {'sn': device_sn, 'key': name}
844
+ response = signed_post(path="/op/v0/device/setting/get", body=body)
914
845
  if response.status_code != 200:
915
846
  output(f"** get_remote_settings() got response code {response.status_code}: {response.reason}")
916
847
  return None
917
848
  result = response.json().get('result')
918
849
  if result is None:
919
850
  errno = response.json().get('errno')
920
- output(f"** get_remote_settings(), no result data, {errno_message(errno)}")
851
+ output(f"** get_remote_settings(), no result data, {errno_message(response)}")
921
852
  return None
922
- values = result.get('values')
923
- if values is None:
924
- output(f"** get_remote_settings(), no values data")
853
+ named_settings[name] = result
854
+ value = result.get('value')
855
+ if value is None:
856
+ output(f"** get_remote_settings(), no value for {name}")
925
857
  return None
926
- return values
858
+ return value
927
859
 
928
860
  def get_named_settings(name):
929
- global named_settings
930
- if type(name) is list:
931
- result = []
932
- for n in name:
933
- result.append(get_named_settings(n))
934
- return result
935
- if named_settings is None or named_settings.get(name) is None:
936
- output(f"** get_named_settings(): {name} was not recognised")
861
+ return get_remote_settings(name)
862
+
863
+ def set_named_setting(name, value):
864
+ global device_sn, debug_setting
865
+ if get_device() is None:
937
866
  return None
938
- keys = named_settings[name].get('keys')
939
- if keys is None:
940
- output(f"** get_named_settings(): no keys for name: {name}")
867
+ if type(name) is list:
868
+ count = 0
869
+ 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
874
+ output(f"\nSetting {name} to {value}", 1)
875
+ body = {'sn': device_sn, 'key': name, 'value': f"{value}"}
876
+ setting_delay()
877
+ response = signed_post(path="/op/v0/device/setting/set", body=body)
878
+ if response.status_code != 200:
879
+ output(f"** set_named_settings(): ({name}, {value}) got response code {response.status_code}: {response.reason}")
941
880
  return None
942
- output(f"getting named_settings for {name} using {keys}", 2)
943
- result = get_remote_settings(keys)
944
- if result is None:
945
- output(f"** get_named_settings(): no result for {name} using key: {keys}")
881
+ errno = response.json().get('errno')
882
+ if errno != 0:
883
+ if errno == 44096:
884
+ output(f"** cannot update settings when schedule is active")
885
+ else:
886
+ output(f"** set_named_settings(): ({name}, {value}) {errno_message(response)}")
946
887
  return None
947
- result_type = named_settings[name].get('type')
948
- value_type = named_settings[name].get('valueType')
949
- if result_type is None:
950
- v = result.get([k for k in result.keys()][0])
951
- return v if value_type is None else c_float(v) if value_type == 'float' else c_int(v)
952
- if result_type == 'list':
953
- values = []
954
- for k in sorted(result.keys()):
955
- values.append(result[k] if value_type is None else c_float(result[k]) if value_type == 'float' else c_int(result[k]))
956
- return values
957
- return result
888
+ return 1
958
889
 
959
890
  ##################################################################################################
960
891
  # wrappers for named settings
@@ -964,10 +895,10 @@ work_mode = None
964
895
 
965
896
  def get_work_mode():
966
897
  global work_mode
967
- # print(f"** get_work_mode(): not available via Open API")
968
- return None
969
898
  if get_device() is None:
970
899
  return None
900
+ # not implemented by Open API, skip to avoid error
901
+ return None
971
902
  work_mode = get_named_settings('WorkMode')
972
903
  return work_mode
973
904
 
@@ -983,6 +914,8 @@ temp_slots_per_battery = 8
983
914
 
984
915
  def get_cell_temps(nbat=8):
985
916
  global temp_slots_per_battery
917
+ print(f"** get_cell_temps(): not available via Open API")
918
+ return None
986
919
  values = get_named_settings('BatteryTemp')
987
920
  if values is None:
988
921
  return None
@@ -1008,9 +941,7 @@ work_modes = ['SelfUse', 'Feedin', 'Backup', 'ForceCharge', 'ForceDischarge']
1008
941
  settable_modes = work_modes[:3]
1009
942
 
1010
943
  def set_work_mode(mode, force = 0):
1011
- global token, device_sn, work_modes, work_mode, debug_setting
1012
- print(f"** set_work_mode(): not available via Open API")
1013
- return None
944
+ global device_sn, work_modes, work_mode, debug_setting
1014
945
  if get_device() is None:
1015
946
  return None
1016
947
  if mode not in settable_modes:
@@ -1022,7 +953,7 @@ def set_work_mode(mode, force = 0):
1022
953
  return None
1023
954
  set_schedule(enable=0)
1024
955
  output(f"\nSetting work mode: {mode}", 1)
1025
- body = {'sn': device_sn, 'key': 'operation_mode__work_mode', 'values': {'operation_mode__work_mode': mode}, 'raw': ''}
956
+ body = {'sn': device_sn, 'key': 'WorkMode', 'value': mode}
1026
957
  setting_delay()
1027
958
  response = signed_post(path="/op/v0/device/setting/set", body=body)
1028
959
  if response.status_code != 200:
@@ -1140,10 +1071,11 @@ def set_period(start=None, end=None, mode=None, min_soc=None, max_soc=None, fdso
1140
1071
  price = segment.get('price')
1141
1072
  start = time_hours(start)
1142
1073
  # adjust exclusive time to inclusive
1143
- end = round_time(time_hours(end) - 1/60)
1074
+ end = time_hours(end)
1144
1075
  if start is None or end is None or start >= end:
1145
1076
  output(f"set_period(): ** invalid period times: {hours_time(start)}-{hours_time(end)}")
1146
1077
  return None
1078
+ end = round_time(end - 1/60)
1147
1079
  mode = 'SelfUse' if mode is None else mode
1148
1080
  if mode not in work_modes:
1149
1081
  output(f"** mode must be one of {work_modes}")
@@ -1180,7 +1112,7 @@ def set_period(start=None, end=None, mode=None, min_soc=None, max_soc=None, fdso
1180
1112
 
1181
1113
  # set a schedule from a period or list of time segment periods
1182
1114
  def set_schedule(periods=None, enable=True):
1183
- global token, device_sn, debug_setting, schedule
1115
+ global device_sn, debug_setting, schedule
1184
1116
  if get_flag() is None:
1185
1117
  return None
1186
1118
  if schedule.get('support') == False:
@@ -1200,8 +1132,7 @@ def set_schedule(periods=None, enable=True):
1200
1132
  periods = [periods]
1201
1133
  if len(periods) > 8:
1202
1134
  output(f"** set_schedule(): maximum of 8 periods allowed, {len(periods)} provided")
1203
- return None
1204
- body = {'deviceSN': device_sn, 'groups': periods}
1135
+ body = {'deviceSN': device_sn, 'groups': periods[-8:]}
1205
1136
  setting_delay()
1206
1137
  response = signed_post(path="/op/v0/device/scheduler/enable", body=body)
1207
1138
  if response.status_code != 200:
@@ -1279,7 +1210,7 @@ def get_real(v = None):
1279
1210
  # d = day 'YYYY-MM-DD'. Can also include 'HH:MM' in 'hour' mode
1280
1211
  # v = list of variables to get
1281
1212
  # summary = 0: raw data, 1: add max, min, sum, 2: summarise and drop raw data, 3: calculate state
1282
- # save = "xxxxx": save the raw results to xxxxx_raw_<time_span>_<d>.json
1213
+ # save = "xxxxx": save the raw results to xxxxx_history_<time_span>_<d>.json
1283
1214
  # load = "<file>": load the raw results from <file>
1284
1215
  # plot = 0: no plot, 1: plot variables separately, 2: combine variables
1285
1216
  ##################################################################################################
@@ -1294,7 +1225,7 @@ sample_time = 5.0 # 5 minutes default
1294
1225
  sample_rounding = 2 # round to 30 seconds
1295
1226
 
1296
1227
  def get_history(time_span='hour', d=None, v=None, summary=1, save=None, load=None, plot=0):
1297
- global token, device_sn, debug_setting, var_list, invert_ct2, tariff, max_power_kw, sample_rounding, sample_time, residual_scale, storage
1228
+ global device_sn, debug_setting, var_list, invert_ct2, tariff, max_power_kw, sample_rounding, sample_time, residual_scale, storage
1298
1229
  if get_device() is None:
1299
1230
  return None
1300
1231
  time_span = time_span.lower()
@@ -1546,7 +1477,7 @@ def report_value_profile(result):
1546
1477
  # d = day 'YYYY-MM-DD'
1547
1478
  # v = list of report variables to get
1548
1479
  # summary = 0, 1, 2: do a quick total energy report for a day
1549
- # save = "xxxxx": save the report results to xxxxx_raw_<time_span>_<d>.json
1480
+ # save = "xxxxx": save the report results to xxxxx_report_<time_span>_<d>.json
1550
1481
  # load = "<file>": load the report results from <file>
1551
1482
  # plot = 0: no plot, 1 = plot variables separately, 2 = combine variables
1552
1483
  ##################################################################################################
@@ -1560,7 +1491,7 @@ fix_value_threshold = 200000000.0
1560
1491
  fix_value_mask = 0x0000FFFF
1561
1492
 
1562
1493
  def get_report(dimension='day', d=None, v=None, summary=1, save=None, load=None, plot=0):
1563
- global token, device_sn, var_list, debug_setting, report_vars, storage
1494
+ global device_sn, var_list, debug_setting, report_vars, storage
1564
1495
  if get_device() is None:
1565
1496
  return None
1566
1497
  # process list of days
@@ -2392,7 +2323,7 @@ def set_tariff(find, update=1, times=None, forecast_times=None, strategy=None, d
2392
2323
  elif type(strategy) is not list:
2393
2324
  strategy = [strategy]
2394
2325
  output(f"\nStrategy")
2395
- use['strategy'] = get_strategy(use=use, strategy=strategy, quiet=0, remove=[use.get('off_peak1'), use.get('off_peak2'), use.get('off_peak3'), use.get('off_peak4')])
2326
+ use['strategy'] = get_strategy(use=use, strategy=strategy, quiet=0) #, remove=[use.get('off_peak1'), use.get('off_peak2'), use.get('off_peak3'), use.get('off_peak4')])
2396
2327
  output_close(plot=tariff_config['show_plot'])
2397
2328
  if update == 1:
2398
2329
  tariff = use
@@ -2469,15 +2400,15 @@ def forecast_value_timed(forecast, today, tomorrow, base_hour, run_time, time_of
2469
2400
  return profile[:run_time]
2470
2401
 
2471
2402
  # build the timed work mode profile from the tariff strategy:
2472
- def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, current_mode=None):
2403
+ def strategy_timed(timed_mode, time_line, run_time, min_soc=10, max_soc=100, current_mode=None):
2473
2404
  global tariff, steps_per_hour
2474
2405
  work_mode_timed = []
2475
2406
  min_soc_now = min_soc
2476
2407
  max_soc_now = max_soc
2477
2408
  current_mode = 'SelfUse' if current_mode is None else current_mode
2478
2409
  strategy = get_strategy(timed_mode=timed_mode)
2479
- h = base_hour
2480
2410
  for i in range(0, run_time):
2411
+ h = time_line[i]
2481
2412
  period = {'mode': current_mode, 'min_soc': min_soc_now, 'max_soc': max_soc, 'fdpwr': 0, 'fdsoc': min_soc_now, 'duration': 1.0, 'charge': 0.0,
2482
2413
  'pv': 0.0, 'discharge': 0.0, 'hold': 0, 'kwh': None}
2483
2414
  if strategy is not None:
@@ -2497,11 +2428,10 @@ def strategy_timed(timed_mode, base_hour, run_time, min_soc=10, max_soc=100, cur
2497
2428
  period['fdpwr'] = d['fdpwr']
2498
2429
  period['duration'] = duration_in(h, d) * steps_per_hour
2499
2430
  work_mode_timed.append(period)
2500
- h = round_time(h + 1 / steps_per_hour)
2501
2431
  return work_mode_timed
2502
2432
 
2503
2433
  # build the timed battery residual from the charge / discharge, work mode and min_soc
2504
- # note: all power values are as measured at the inverter battery connection
2434
+ # all power values are as measured at the inverter battery connection
2505
2435
  def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=None, reserve_drain=None):
2506
2436
  global charge_config, steps_per_hour
2507
2437
  allowed_drain = charge_config['allowed_drain'] if charge_config.get('allowed_drain') is not None else 4
@@ -2510,7 +2440,8 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2510
2440
  discharge_loss = charge_config['discharge_loss']
2511
2441
  charge_limit = charge_config['charge_limit']
2512
2442
  float_charge = charge_config['float_charge']
2513
- for i in range(0, len(work_mode_timed)):
2443
+ run_time = len(work_mode_timed)
2444
+ for i in range(0, run_time):
2514
2445
  w = work_mode_timed[i]
2515
2446
  w['kwh'] = kwh_current
2516
2447
  max_now = w['max_soc'] * capacity / 100
@@ -2521,6 +2452,7 @@ def battery_timed(work_mode_timed, kwh_current, capacity, time_to_next, kwh_min=
2521
2452
  if kwh_current > capacity:
2522
2453
  # battery is full
2523
2454
  kwh_current = capacity
2455
+ w = work_mode_timed[i+1] if (i + 1) < run_time else w
2524
2456
  min_soc_now = w['fdsoc'] if w['mode'] =='ForceDischarge' else w['min_soc']
2525
2457
  reserve_now = capacity * min_soc_now / 100
2526
2458
  if kwh_current < reserve_now and (i < time_to_next or kwh_min is None):
@@ -2550,7 +2482,7 @@ def charge_periods(work_mode_timed, base_hour, min_soc, capacity):
2550
2482
  period = times[0] if len(times) > 0 else work_mode_timed[0]
2551
2483
  next_period = work_mode_timed[t]
2552
2484
  h = base_hour + t / steps_per_hour
2553
- if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold']:
2485
+ if h == 24 or period['mode'] != next_period['mode'] or period['hold'] != next_period['hold'] or period['min_soc'] != next_period['min_soc']:
2554
2486
  s = {'start': start % 24, 'end': h % 24, 'mode': period['mode'], 'min_soc': period['min_soc']}
2555
2487
  if period['mode'] == 'ForceDischarge':
2556
2488
  s['fdsoc'] = period.get('fdsoc')
@@ -2642,7 +2574,7 @@ charge_needed_app_key = "awcr5gro2v13oher3v1qu6hwnovp28"
2642
2574
  def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=None, show_plot=None, run_after=None, reload=2,
2643
2575
  forecast_times=None, force_charge=0, test_time=None, test_soc=None, test_charge=None, **settings):
2644
2576
  global device, seasonality, solcast_api_key, debug_setting, tariff, solar_arrays, legend_location, time_shift, charge_needed_app_key
2645
- global timed_strategy, steps_per_hour, base_time, storage, battery, charge_rates
2577
+ global timed_strategy, steps_per_hour, base_time, storage, battery, battery_params
2646
2578
  print(f"\n---------------- charge_needed ----------------")
2647
2579
  # validate parameters
2648
2580
  args = locals()
@@ -2749,8 +2681,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2749
2681
  bat_power = 0.0
2750
2682
  temperature = 30
2751
2683
  bms_charge_current = 15
2752
- charge_loss = charge_rates[2]['charge_loss']
2753
- discharge_loss = charge_rates[2]['discharge_loss']
2684
+ charge_loss = battery_params[2]['charge_loss']
2685
+ discharge_loss = battery_params[2]['discharge_loss']
2754
2686
  bat_current = 0.0
2755
2687
  device_power = 6.0
2756
2688
  device_current = 35
@@ -2772,8 +2704,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2772
2704
  output(f"Battery capacity could not be estimated. Please add the parameter 'capacity=xx' in kWh")
2773
2705
  return None
2774
2706
  bms_charge_current = battery.get('charge_rate')
2775
- charge_loss = battery['charge_loss'] if battery.get('charge_loss') is not None else 1.0
2776
- discharge_loss = battery['discharge_loss'] if battery.get('discharge_loss') is not None else 1.0
2707
+ charge_loss = battery['charge_loss'] if battery.get('charge_loss') is not None else 0.974
2708
+ discharge_loss = battery['discharge_loss'] if battery.get('discharge_loss') is not None else 0.974
2777
2709
  device_power = device.get('power')
2778
2710
  device_current = device.get('max_charge_current')
2779
2711
  model = device.get('deviceType')
@@ -2943,7 +2875,7 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
2943
2875
  # produce time lines for charge, discharge and work mode
2944
2876
  charge_timed = [min([charge_limit, c_float(x) * pv_loss]) for x in generation_timed]
2945
2877
  discharge_timed = [min([discharge_limit, c_float(x) / dc_ac_loss]) + bms_loss for x in consumption_timed]
2946
- work_mode_timed = strategy_timed(timed_mode, base_hour, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
2878
+ work_mode_timed = strategy_timed(timed_mode, time_line, run_time, min_soc=min_soc, max_soc=max_soc, current_mode=current_mode)
2947
2879
  for i in range(0, len(work_mode_timed)):
2948
2880
  # get work mode
2949
2881
  work_mode = work_mode_timed[i]['mode']
@@ -3071,14 +3003,15 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3071
3003
  set_charge(ch1=False, st1=start1, en1=end1, ch2=True, st2=start2, en2=end2, force=1, enable=update_settings)
3072
3004
  if update_settings == 0:
3073
3005
  output(f"\nNo changes made to charge settings")
3006
+ start_t = 0 #int(hour_now % 1 + 0.5) * steps_per_hour
3074
3007
  if show_data > 0:
3075
3008
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3076
3009
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
3077
3010
  h = base_hour
3078
- t = 0
3011
+ t = start_t
3079
3012
  while t < len(time_line) and bat_timed[t] is not None:
3080
3013
  col = h % data_wrap
3081
- s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3014
+ s += f"\n {hours_time(time_line[t])}" if t == start_t or col == 0 else ""
3082
3015
  s += f" {bat_timed[t]:5.2f}" if show_data == 2 else f" {bat_timed[t] / capacity * 100:3.0f}%"
3083
3016
  h += 1
3084
3017
  t += steps_per_hour
@@ -3086,8 +3019,8 @@ def charge_needed(forecast=None, update_settings=0, timed_mode=None, show_data=N
3086
3019
  if show_plot > 0:
3087
3020
  print()
3088
3021
  plt.figure(figsize=(figure_width, figure_width/2))
3089
- x_timed = [i for i in range(0, run_time)]
3090
- x_ticks = [i for i in range(0, run_time, steps_per_hour)]
3022
+ x_timed = [i for i in range(start_t, run_time)]
3023
+ x_ticks = [i for i in range(start_t, run_time, steps_per_hour)]
3091
3024
  plt.xticks(ticks=x_ticks, labels=[hours_time(time_line[x]) for x in x_ticks], rotation=90, fontsize=8, ha='center')
3092
3025
  if show_plot == 1:
3093
3026
  title = f"Predicted Battery SoC % at {base_time}({charge_message})"
@@ -3196,14 +3129,15 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3196
3129
  for v in plots.keys():
3197
3130
  for i in range(0, run_time):
3198
3131
  plots[v][i] = plots[v][i] / count[v][i] if count[v][i] > 0 else None
3132
+ start_t = 0 #int(hour_now % 1 + 0.5) * steps_per_hour
3199
3133
  if show_data > 0 and plots.get('SoC') is not None:
3200
3134
  data_wrap = charge_config['data_wrap'] if charge_config.get('data_wrap') is not None else 6
3201
3135
  s = f"\nBattery Energy kWh:" if show_data == 2 else f"\nBattery SoC:"
3202
3136
  h = base_hour
3203
- t = 0
3137
+ t = start_t
3204
3138
  while t < len(time_line) and bat_timed[t] is not None and plots['SoC'][t] is not None:
3205
3139
  col = h % data_wrap
3206
- s += f"\n {hours_time(time_line[t])}" if t == 0 or col == 0 else ""
3140
+ s += f"\n {hours_time(time_line[t])}" if t == start_t or col == 0 else ""
3207
3141
  s += f" {plots['SoC'][t]:5.2f}" if show_data == 2 else f" {plots['SoC'][t] / capacity * 100:3.0f}%"
3208
3142
  h += 1
3209
3143
  t += steps_per_hour
@@ -3211,8 +3145,8 @@ def charge_compare(save=None, v=None, show_data=1, show_plot=3):
3211
3145
  if show_plot > 0:
3212
3146
  print()
3213
3147
  plt.figure(figsize=(figure_width, figure_width/2))
3214
- x_timed = [i for i in range(0, run_time)]
3215
- x_ticks = [i for i in range(0, run_time, steps_per_hour)]
3148
+ x_timed = [i for i in range(start_t, run_time)]
3149
+ x_ticks = [i for i in range(start_t, run_time, steps_per_hour)]
3216
3150
  plt.xticks(ticks=x_ticks, labels=[hours_time(time_line[x]) for x in x_ticks], rotation=90, fontsize=8, ha='center')
3217
3151
  if show_plot == 1:
3218
3152
  title = f"Predicted Battery SoC % at {base_time}({charge_message})"
@@ -3354,8 +3288,8 @@ def battery_info(log=0, plot=1, count=None, info=1, bat=None):
3354
3288
  s +=f",{v:.0f}"
3355
3289
  return s
3356
3290
  output(f"Current SoC: {current_soc}%")
3357
- output(f"Capacity: {capacity:.2f}kWh" + (" (Residual / SoC x 100)" if bat['residual_handling'] == 1 else ""))
3358
- output(f"Residual: {residual:.2f}kWh" + (" (SoC x Capacity / 100)" if bat['residual_handling'] == 2 else ""))
3291
+ output(f"Capacity: {capacity:.2f}kWh" + (" (calculated)" if bat['residual_handling'] in [1,3] else ""))
3292
+ output(f"Residual: {residual:.2f}kWh" + (" (calculated)" if bat['residual_handling'] in [2,3] else ""))
3359
3293
  if rated_capacity is not None and bat_soh is not None:
3360
3294
  output(f"Rated Capacity: {rated_capacity / 1000:.2f}kWh")
3361
3295
  output(f"SoH: {bat_soh:.1f}%" + (" (Capacity / Rated Capacity x 100)" if not bat['soh_supported'] else ""))
@@ -3767,7 +3701,6 @@ class Solcast :
3767
3701
  # The forecasts and estimated both include the current date, so the total number of days covered is 2 * days - 1.
3768
3702
  # The forecasts and estimated also both include the current time, so the data has to be de-duplicated to get an accurate total for a day
3769
3703
  global debug_setting, solcast_url, solcast_api_key, solcast_save, storage
3770
- self.data = {}
3771
3704
  now = convert_date(d)
3772
3705
  self.shading = None if shading is None else shading if shading.get('solcast') is None else shading['solcast']
3773
3706
  self.today = datetime.strftime(datetime.date(now), '%Y-%m-%d')
@@ -3775,6 +3708,8 @@ class Solcast :
3775
3708
  self.tomorrow = datetime.strftime(datetime.date(now + timedelta(days=1)), '%Y-%m-%d')
3776
3709
  self.yesterday = datetime.strftime(datetime.date(now - timedelta(days=1)), '%Y-%m-%d')
3777
3710
  self.save = solcast_save #.replace('.', '_%.'.replace('%', self.today.replace('-','')))
3711
+ self.data = {}
3712
+ self.rids = []
3778
3713
  if reload == 1 and os.path.exists(storage + self.save):
3779
3714
  os.remove(storage + self.save)
3780
3715
  if self.save is not None and os.path.exists(storage + self.save):
@@ -3783,33 +3718,37 @@ class Solcast :
3783
3718
  file.close()
3784
3719
  if len(self.data) == 0:
3785
3720
  print(f"No data in {self.save}")
3786
- elif reload == 2 and 'date' in self.data and self.data['date'] != self.today:
3787
- self.data = {}
3788
- elif debug_setting > 0 and not quiet:
3789
- print(f"Using data for {self.data['date']} from {self.save}")
3721
+ else:
3722
+ self.rids = self.data['forecasts'].keys() if self.data.get('forecasts') is not None else []
3723
+ if reload == 2 and self.data.get('date') is not None and self.data['date'] != self.today:
3724
+ self.data = {}
3725
+ elif debug_setting > 0 and not quiet:
3726
+ print(f"Using data for {self.data['date']} from {self.save}")
3790
3727
  if len(self.data) == 0 :
3791
3728
  if solcast_api_key is None or solcast_api_key == 'my.solcast_api_key>':
3792
3729
  print(f"\nSolcast: solcast_api_key not set, exiting")
3793
3730
  return
3794
3731
  self.credentials = HTTPBasicAuth(solcast_api_key, '')
3795
- if debug_setting > 1 and not quiet:
3796
- print(f"Getting rids from solcast.com")
3797
- params = {'format' : 'json'}
3798
- response = requests.get(solcast_url + 'rooftop_sites', auth = self.credentials, params = params)
3799
- if response.status_code != 200:
3800
- if response.status_code == 429:
3801
- print(f"\nSolcast API call limit reached for today")
3802
- else:
3803
- print(f"Solcast: response code getting rooftop_sites was {response.status_code}: {response.reason}")
3804
- return
3805
- sites = response.json().get('sites')
3732
+ if len(self.rids) == 0:
3733
+ if debug_setting > 1 and not quiet:
3734
+ print(f"Getting rids from solcast.com")
3735
+ params = {'format' : 'json'}
3736
+ response = requests.get(solcast_url + 'rooftop_sites', auth = self.credentials, params = params)
3737
+ if response.status_code != 200:
3738
+ if response.status_code == 429:
3739
+ print(f"\nSolcast API call limit reached for today")
3740
+ else:
3741
+ print(f"Solcast: response code getting rooftop_sites was {response.status_code}: {response.reason}")
3742
+ return
3743
+ sites = response.json().get('sites')
3744
+ self.rids = [s['resource_id'] for s in sites]
3806
3745
  if debug_setting > 0 and not quiet:
3807
3746
  print(f"Getting forecast for {self.today} from solcast.com")
3808
3747
  self.data['date'] = self.today
3809
3748
  params = {'format' : 'json', 'hours' : 168, 'period' : 'PT30M'} # always get 168 x 30 min values
3810
3749
  for t in ['forecasts'] if estimated == 0 else ['forecasts', 'estimated_actuals']:
3811
3750
  self.data[t] = {}
3812
- for rid in [s['resource_id'] for s in sites] :
3751
+ for rid in self.rids:
3813
3752
  response = requests.get(solcast_url + 'rooftop_sites/' + rid + '/' + t, auth = self.credentials, params = params)
3814
3753
  if response.status_code != 200 :
3815
3754
  if response.status_code == 429:
@@ -3818,7 +3757,7 @@ class Solcast :
3818
3757
  print(f"Solcast: response code getting {t} was {response.status_code}: {response.reason}")
3819
3758
  return
3820
3759
  self.data[t][rid] = response.json().get(t)
3821
- if self.save is not None :
3760
+ if self.save is not None:
3822
3761
  file = open(storage + self.save, 'w')
3823
3762
  json.dump(self.data, file, sort_keys = True, indent=4, ensure_ascii= False)
3824
3763
  file.close()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: foxesscloud
3
- Version: 2.6.4
3
+ Version: 2.6.6
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
@@ -111,6 +111,7 @@ f.get_charge()
111
111
  f.get_min()
112
112
  f.get_flag()
113
113
  f.get_schedule()
114
+ f.get_named_settings(name)
114
115
 
115
116
  ```
116
117
  Each of these calls will return a dictionary or list containing the relevant information.
@@ -130,6 +131,10 @@ get_flag() returns the current scheduler enable / support / maxsoc flags
130
131
 
131
132
  get_schedule() returns the current work mode / soc schedule settings. The result is stored in f.schedule.
132
133
 
134
+ get_named_settings() returns the value of a named setting. If 'name' is a list, it returns a list of values.
135
+ + f.named_settings is updated. This is dictionary of information and current value, indexed by 'name.
136
+ + the only name currently supported by Fox is 'ExportLimit' and this is only available for H3 inverters.
137
+
133
138
 
134
139
  ## Inverter Settings
135
140
  You can change inverter settings using:
@@ -140,6 +145,7 @@ f.set_charge(ch1, st1, en1, ch2, st2, en2, enable)
140
145
  f.set_period(start, end, mode, min_soc, max_soc, fdsoc, fdpwr, price, segment)
141
146
  f.charge_periods(st0, en0, st1, en1, st2, en2, min_soc, target_soc, start_soc)
142
147
  f.set_schedule(periods, enable)
148
+ f.set_named_settings(name, value)
143
149
  ```
144
150
 
145
151
  set_min() applies new SoC settings to the inverter. The parameters update battery_settings:
@@ -180,6 +186,10 @@ set_schedule() configures a list of scheduled work mode / soc changes with enabl
180
186
  + periods: a time segment or list of time segments created using f.set_period().
181
187
  + enable: 1 to enable schedules, 0 to disable schedules. The default is 1.
182
188
 
189
+ set_named_settings() sets the 'name' setting to 'value'.
190
+ + 'name' may also be a list of (name, value) pairs.
191
+ + the only 'name' currently supported by Fox is 'ExportLimit' on H3 inverters
192
+
183
193
 
184
194
  ## Real Time Data
185
195
  Real time data reports the latest values for inverter variables, collected every 5 minutes:
@@ -787,6 +797,21 @@ This setting can be:
787
797
 
788
798
  # Version Info
789
799
 
800
+ 2.6.6<br>
801
+ Add residual_handling=3 for Mira BMS with firmware 1.014 or later that returns residual capacity per battery.
802
+ Calculate 'ratedCapacity' in get_battery() and 'soh' for HV2600 and Mira.
803
+ Allow unlimited periods in strategy, including overlap with charge periods but warn and limit if the periods sent to inverter would be more than 8.
804
+ Improve behaviour prediction for schedules when clocks change due to day light saving.
805
+ Improve schedule generation and prediction when Min Soc changes.
806
+ Cache Solcast RIDS to reduce API usage (run with reload=1 if arrays are edited and cached RIDs need to be updated).
807
+ Remove spurious error message when (failing) to get inverter work mode.
808
+
809
+ 2.6.5<br>
810
+ Add get_named_settings() and set_named_settings().
811
+ Update get_work_mode() and set_work_mode() to use named settings (still doesn't work though as blocked by Fox)
812
+ Updated get_history() and get_report() saved filenames to use _history_ and _report_ for consistency.
813
+ Update calibration of 'charge_loss' and 'discharge_loss'.
814
+
790
815
  2.6.4<br>
791
816
  Increase default plungs_slots from 6 to 8.
792
817
  Correct battery capacity in get_batteries().
@@ -0,0 +1,7 @@
1
+ foxesscloud/foxesscloud.py,sha256=lmIa4uFTrwUSyRj2HWFMiuJ1UItOQzDfojqZQv_FjHY,219893
2
+ foxesscloud/openapi.py,sha256=V7vlbq_Cynde-BMf13UziuKfni8a8aX_lQej1fC6cO0,204609
3
+ foxesscloud-2.6.6.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
4
+ foxesscloud-2.6.6.dist-info/METADATA,sha256=2upWkpfGnqFrpRN3dU9Snbpj1hPHdiCenTrHqlD1L74,59461
5
+ foxesscloud-2.6.6.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
6
+ foxesscloud-2.6.6.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
7
+ foxesscloud-2.6.6.dist-info/RECORD,,
@@ -1,7 +0,0 @@
1
- foxesscloud/foxesscloud.py,sha256=kfb3Ard_s3LZ1hN53RfwcRUyEeMGd6TviHtoFmQyYDY,217547
2
- foxesscloud/openapi.py,sha256=jFWMAlbhHAFqlKZlg8rALgU-jziUdrTjM0b4I0eTyAQ,207518
3
- foxesscloud-2.6.4.dist-info/LICENCE,sha256=-3xv8CElCJV8Bc8PbAsg3iyxMpAK8MoJneM3rXigxqI,1074
4
- foxesscloud-2.6.4.dist-info/METADATA,sha256=uNjXzpjeHGt65jOvDoLApHmb8-awuWtRg9BRfJKnzFU,57854
5
- foxesscloud-2.6.4.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
6
- foxesscloud-2.6.4.dist-info/top_level.txt,sha256=IWOrKSNZCLU6IDXSX_b4_bqCfbZoWAT4CC0w0Lg7PuU,12
7
- foxesscloud-2.6.4.dist-info/RECORD,,