Skip to content
This repository was archived by the owner on Aug 14, 2026. It is now read-only.

Commit d30df69

Browse files
committed
free energy window support (untested)
1 parent ad7ef4e commit d30df69

6 files changed

Lines changed: 1352 additions & 13 deletions

File tree

‎app.py‎

Lines changed: 155 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
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
}
4243
weather_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+
211262
def 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+
615764
if __name__ == '__main__':
616765
load_config()
617766
init_client()

‎config.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,11 @@
2222
"bad_weather_threshold_days": 2,
2323
"bad_weather_conditions": ["Rain", "Thunderstorm", "Drizzle", "Snow"],
2424
"min_cloud_cover_percent": 70
25+
},
26+
"free_energy": {
27+
"enabled": false,
28+
"start_time": "11:00",
29+
"end_time": "14:00",
30+
"target_soc": 100
2531
}
2632
}

‎deye_client.py‎

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -144,22 +144,90 @@ def set_tou_settings(
144144
window_end: str,
145145
min_soc_reserve: int,
146146
window_soc: int,
147-
power: int
147+
power: int,
148+
free_energy_start: Optional[str] = None,
149+
free_energy_end: Optional[str] = None,
150+
free_energy_soc: Optional[int] = None
148151
) -> Dict[str, Any]:
149152
"""
150-
Update TOU settings on inverter with 3 time periods
153+
Update TOU settings on inverter with time periods
151154
152155
Args:
153156
window_start: Force discharge window start time (HH:MM)
154157
window_end: Force discharge window end time (HH:MM)
155158
min_soc_reserve: SoC for periods outside the window
156159
window_soc: SoC for the discharge window period
157160
power: Max discharge power in watts
161+
free_energy_start: Optional free energy window start time (HH:MM)
162+
free_energy_end: Optional free energy window end time (HH:MM)
163+
free_energy_soc: Optional target SoC for grid charging during free energy window
158164
"""
159-
logger.info(f"Setting TOU: window={window_start}-{window_end}, reserve_soc={min_soc_reserve}, window_soc={window_soc}, power={power}")
160-
payload = {
161-
"deviceSn": self.device_sn,
162-
"timeUseSettingItems": [
165+
# Build time periods based on whether free energy window is enabled
166+
free_energy_enabled = all([free_energy_start, free_energy_end, free_energy_soc is not None])
167+
168+
if free_energy_enabled:
169+
logger.info(f"Setting TOU: window={window_start}-{window_end}, reserve_soc={min_soc_reserve}, "
170+
f"window_soc={window_soc}, power={power}, "
171+
f"free_energy={free_energy_start}-{free_energy_end} (soc={free_energy_soc})")
172+
else:
173+
logger.info(f"Setting TOU: window={window_start}-{window_end}, reserve_soc={min_soc_reserve}, "
174+
f"window_soc={window_soc}, power={power}")
175+
176+
# Start building time periods
177+
time_items = []
178+
179+
if free_energy_enabled:
180+
# With free energy window: need to interleave the periods properly
181+
# We'll create periods: 00:00, free_energy_start, free_energy_end, window_start, window_end, 23:00
182+
# Note: Some periods may be skipped if they overlap
183+
184+
time_items = [
185+
{
186+
"enableGeneration": False,
187+
"enableGridCharge": False,
188+
"power": power,
189+
"soc": min_soc_reserve,
190+
"time": "00:00"
191+
},
192+
{
193+
"enableGeneration": False,
194+
"enableGridCharge": True,
195+
"power": power,
196+
"soc": free_energy_soc,
197+
"time": free_energy_start
198+
},
199+
{
200+
"enableGeneration": False,
201+
"enableGridCharge": False,
202+
"power": power,
203+
"soc": min_soc_reserve,
204+
"time": free_energy_end
205+
},
206+
{
207+
"enableGeneration": False,
208+
"enableGridCharge": False,
209+
"power": power,
210+
"soc": window_soc,
211+
"time": window_start
212+
},
213+
{
214+
"enableGeneration": False,
215+
"enableGridCharge": False,
216+
"power": power,
217+
"soc": min_soc_reserve,
218+
"time": window_end
219+
},
220+
{
221+
"enableGeneration": False,
222+
"enableGridCharge": False,
223+
"power": power,
224+
"soc": min_soc_reserve,
225+
"time": "23:00"
226+
}
227+
]
228+
else:
229+
# Original behavior without free energy window
230+
time_items = [
163231
{
164232
"enableGeneration": False,
165233
"enableGridCharge": False,
@@ -203,6 +271,10 @@ def set_tou_settings(
203271
"time": "23:00"
204272
}
205273
]
274+
275+
payload = {
276+
"deviceSn": self.device_sn,
277+
"timeUseSettingItems": time_items
206278
}
207279
return self._make_request("POST", "/v1.0/order/sys/tou/update", payload)
208280

0 commit comments

Comments
 (0)