3737 "last_error" : None ,
3838 "scheduler_status" : "stopped" ,
3939 "weather_skip_active" : False ,
40- "weather_skip_reason" : None
40+ "weather_skip_reason" : None ,
41+ "free_energy_active" : False
4142}
4243weather_forecast_cache = {
4344 "forecast" : None ,
@@ -208,6 +209,56 @@ def is_within_discharge_window() -> bool:
208209 return start_time <= now <= end_time
209210
210211
212+ def is_within_free_energy_window () -> bool :
213+ """Check if current time is within free energy window"""
214+ free_energy = config .get ("free_energy" , {})
215+
216+ if not free_energy .get ("enabled" , False ):
217+ return False
218+
219+ start_time_str = free_energy .get ("start_time" , "11:00" )
220+ end_time_str = free_energy .get ("end_time" , "14:00" )
221+
222+ now = datetime .now ()
223+ start_parts = start_time_str .split (":" )
224+ end_parts = end_time_str .split (":" )
225+
226+ start_time = now .replace (
227+ hour = int (start_parts [0 ]),
228+ minute = int (start_parts [1 ]),
229+ second = 0 ,
230+ microsecond = 0
231+ )
232+ end_time = now .replace (
233+ hour = int (end_parts [0 ]),
234+ minute = int (end_parts [1 ]),
235+ second = 0 ,
236+ microsecond = 0
237+ )
238+
239+ # Handle overnight windows
240+ if end_time <= start_time :
241+ end_time += timedelta (days = 1 )
242+ if now < start_time :
243+ now += timedelta (days = 1 )
244+
245+ return start_time <= now <= end_time
246+
247+
248+ def get_free_energy_tou_params ():
249+ """Get free energy TOU parameters if enabled"""
250+ free_energy = config .get ("free_energy" , {})
251+
252+ if not free_energy .get ("enabled" , False ):
253+ return None , None , None
254+
255+ return (
256+ free_energy .get ("start_time" , "11:00" ),
257+ free_energy .get ("end_time" , "14:00" ),
258+ free_energy .get ("target_soc" , 100 )
259+ )
260+
261+
211262def scheduler_loop ():
212263 """Main scheduler loop for automatic mode switching"""
213264 global current_state , scheduler_running
@@ -232,13 +283,18 @@ def scheduler_loop():
232283 current_state ["last_check" ] = datetime .now ().isoformat ()
233284
234285 in_window = is_within_discharge_window ()
286+ in_free_energy_window = is_within_free_energy_window ()
287+ current_state ["free_energy_active" ] = in_free_energy_window
235288
236289 # Check weather forecast for skip condition
237290 weather_skip , weather_reason = should_skip_discharge_for_weather ()
238291 current_state ["weather_skip_active" ] = weather_skip
239292 current_state ["weather_skip_reason" ] = weather_reason
240293
241- logger .info (f"Check: in_window={ in_window } , soc={ soc } , cutoff={ cutoff_soc } , reserve={ min_soc_reserve } , weather_skip={ weather_skip } " )
294+ # Get free energy TOU params
295+ free_energy_start , free_energy_end , free_energy_soc = get_free_energy_tou_params ()
296+
297+ logger .info (f"Check: in_window={ in_window } , soc={ soc } , cutoff={ cutoff_soc } , reserve={ min_soc_reserve } , weather_skip={ weather_skip } , free_energy={ in_free_energy_window } " )
242298
243299 # Determine if we should be in force discharge mode
244300 # Force discharge when: in window AND SoC above cutoff AND NOT weather skip
@@ -276,7 +332,10 @@ def scheduler_loop():
276332 window_end = window_end ,
277333 min_soc_reserve = min_soc_reserve ,
278334 window_soc = cutoff_soc ,
279- power = max_power
335+ power = max_power ,
336+ free_energy_start = free_energy_start ,
337+ free_energy_end = free_energy_end ,
338+ free_energy_soc = free_energy_soc
280339 )
281340 if not tou_result .get ("success" ):
282341 logger .warning (f"TOU update failed: { tou_result .get ('msg' )} " )
@@ -301,7 +360,10 @@ def scheduler_loop():
301360 window_end = window_end ,
302361 min_soc_reserve = min_soc_reserve ,
303362 window_soc = min_soc_reserve ,
304- power = max_power
363+ power = max_power ,
364+ free_energy_start = free_energy_start ,
365+ free_energy_end = free_energy_end ,
366+ free_energy_soc = free_energy_soc
305367 )
306368 if not tou_result .get ("success" ):
307369 logger .warning (f"TOU update failed: { tou_result .get ('msg' )} " )
@@ -378,13 +440,25 @@ def get_status():
378440 "threshold_days" : weather_config .get ("bad_weather_threshold_days" , 2 )
379441 }
380442
443+ # Include free energy status
444+ free_energy_config = config .get ("free_energy" , {})
445+ free_energy_status = {
446+ "enabled" : free_energy_config .get ("enabled" , False ),
447+ "active" : current_state .get ("free_energy_active" , False ),
448+ "start_time" : free_energy_config .get ("start_time" , "11:00" ),
449+ "end_time" : free_energy_config .get ("end_time" , "14:00" ),
450+ "target_soc" : free_energy_config .get ("target_soc" , 100 )
451+ }
452+
381453 return jsonify ({
382454 "current_state" : current_state ,
383455 "schedule" : schedule ,
384456 "in_discharge_window" : is_within_discharge_window (),
457+ "in_free_energy_window" : is_within_free_energy_window (),
385458 "server_time" : datetime .now ().isoformat (),
386459 "tou_settings" : tou_settings ,
387- "weather" : weather_status
460+ "weather" : weather_status ,
461+ "free_energy" : free_energy_status
388462 })
389463
390464
@@ -476,12 +550,18 @@ def update_config():
476550 else :
477551 window_soc = min_soc_reserve
478552
553+ # Get free energy TOU params
554+ free_energy_start , free_energy_end , free_energy_soc = get_free_energy_tou_params ()
555+
479556 result = client .set_tou_settings (
480557 window_start = window_start ,
481558 window_end = window_end ,
482559 min_soc_reserve = min_soc_reserve ,
483560 window_soc = window_soc ,
484- power = max_power
561+ power = max_power ,
562+ free_energy_start = free_energy_start ,
563+ free_energy_end = free_energy_end ,
564+ free_energy_soc = free_energy_soc
485565 )
486566 if not result .get ("success" ):
487567 return jsonify ({"success" : False , "error" : f"Failed to update TOU: { result .get ('msg' )} " })
@@ -612,6 +692,75 @@ def update_weather_config():
612692 return jsonify ({"success" : False , "error" : str (e )}), 500
613693
614694
695+ @app .route ('/api/free-energy/config' )
696+ def get_free_energy_config ():
697+ """Get free energy configuration"""
698+ free_energy_config = config .get ("free_energy" , {})
699+ return jsonify ({
700+ "enabled" : free_energy_config .get ("enabled" , False ),
701+ "start_time" : free_energy_config .get ("start_time" , "11:00" ),
702+ "end_time" : free_energy_config .get ("end_time" , "14:00" ),
703+ "target_soc" : free_energy_config .get ("target_soc" , 100 )
704+ })
705+
706+
707+ @app .route ('/api/free-energy/config' , methods = ['POST' ])
708+ def update_free_energy_config ():
709+ """Update free energy configuration"""
710+ try :
711+ body = request .get_json ()
712+
713+ if "free_energy" not in config :
714+ config ["free_energy" ] = {}
715+
716+ # Update allowed fields
717+ if "enabled" in body :
718+ config ["free_energy" ]["enabled" ] = body ["enabled" ]
719+ if "start_time" in body :
720+ config ["free_energy" ]["start_time" ] = body ["start_time" ]
721+ if "end_time" in body :
722+ config ["free_energy" ]["end_time" ] = body ["end_time" ]
723+ if "target_soc" in body :
724+ config ["free_energy" ]["target_soc" ] = body ["target_soc" ]
725+
726+ save_config ()
727+
728+ # If update_tou is requested, sync TOU to inverter with new settings
729+ if body .get ("update_tou" , False ):
730+ schedule = config .get ("schedule" , {})
731+ min_soc_reserve = schedule .get ("min_soc_reserve" , 20 )
732+ cutoff_soc = schedule .get ("force_discharge_cutoff_soc" , 50 )
733+ max_power = schedule .get ("max_discharge_power" , 10000 )
734+ window_start = schedule .get ("force_discharge_start" , "17:30" )
735+ window_end = schedule .get ("force_discharge_end" , "19:30" )
736+
737+ # Determine window SoC based on current state
738+ if current_state .get ("force_discharge_active" ):
739+ window_soc = cutoff_soc
740+ else :
741+ window_soc = min_soc_reserve
742+
743+ # Get free energy TOU params (now with new config)
744+ free_energy_start , free_energy_end , free_energy_soc = get_free_energy_tou_params ()
745+
746+ result = client .set_tou_settings (
747+ window_start = window_start ,
748+ window_end = window_end ,
749+ min_soc_reserve = min_soc_reserve ,
750+ window_soc = window_soc ,
751+ power = max_power ,
752+ free_energy_start = free_energy_start ,
753+ free_energy_end = free_energy_end ,
754+ free_energy_soc = free_energy_soc
755+ )
756+ if not result .get ("success" ):
757+ return jsonify ({"success" : False , "error" : f"Failed to update TOU: { result .get ('msg' )} " })
758+
759+ return jsonify ({"success" : True })
760+ except Exception as e :
761+ return jsonify ({"success" : False , "error" : str (e )}), 500
762+
763+
615764if __name__ == '__main__' :
616765 load_config ()
617766 init_client ()
0 commit comments