Skip to content

Commit 94a9294

Browse files
Reset discharge limit register for GECloud use, entity edit, external event route (#2715)
* Reset discharge downto * Entity editting routing for external events * [pre-commit.ci lite] apply automatic fixes --------- Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1 parent ba03315 commit 94a9294

4 files changed

Lines changed: 212 additions & 9 deletions

File tree

‎apps/predbat/gecloud.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,7 @@ async def async_automatic_config(self, devices):
670670
self.base.args["charge_limit"] = ["number.predbat_gecloud_" + device + "_ac_charge_upper_percent_limit" for device in batteries]
671671
self.base.args["discharge_start_time"] = ["select.predbat_gecloud_" + device + "_dc_discharge_1_start_time" for device in batteries]
672672
self.base.args["discharge_end_time"] = ["select.predbat_gecloud_" + device + "_dc_discharge_1_end_time" for device in batteries]
673+
self.base.args["discharge_target_soc"] = ["select.predbat_gecloud_" + device + "_dc_discharge_1_lower_soc_percent_limit" for device in batteries]
673674
self.base.args["scheduled_charge_enable"] = ["switch.predbat_gecloud_" + device + "_ac_charge_enable" for device in batteries]
674675
self.base.args["scheduled_discharge_enable"] = ["switch.predbat_gecloud_" + device + "_enable_dc_discharge" for device in batteries]
675676
self.base.args["pause_mode"] = ["select.predbat_gecloud_" + device + "_pause_battery" for device in batteries]

‎apps/predbat/ha.py‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,6 @@ async def set_state_external(self, entity_id, state, attributes={}):
465465
new_state = {"entity_id": entity_id, "state": state, "attributes": attributes}
466466
old_value = self.get_state(entity_id)
467467
old_state = self.state_data.get(entity_id.lower(), None)
468-
found = False
469468

470469
# See if the modified entity is in the CONFIG_ITEMS list
471470
for item in self.base.CONFIG_ITEMS:
@@ -495,13 +494,31 @@ async def set_state_external(self, entity_id, state, attributes={}):
495494
await self.base.trigger_callback(service_data)
496495
return
497496

498-
if not found:
499-
# If its not found then its a sensor and we can set the state directly
500-
self.set_state(entity_id, state, attributes)
497+
# Call back to Predbat handled entities
498+
domain = entity_id.split(".")[0] if "." in entity_id else ""
499+
service_data = {"domain": domain}
500+
if domain in ["switch", "input_boolean"]:
501+
# For toggle switches, check if value is 'on' or 'off'
502+
service_data["service"] = "turn_on" if new_value else "turn_off"
503+
service_data["service_data"] = {"entity_id": entity_id}
504+
elif domain in ["number", "input_number"]:
505+
service_data["service"] = "set_value"
506+
service_data["service_data"] = {"entity_id": entity_id, "value": new_value}
507+
elif domain in ["select", "input_select"]:
508+
service_data["service"] = "select_option"
509+
service_data["service_data"] = {"entity_id": entity_id, "option": new_value}
510+
else:
511+
service_data = None
512+
if service_data:
513+
await self.base.trigger_callback(service_data)
514+
return
515+
516+
# If its not found then its a sensor and we can set the state directly
517+
self.set_state(entity_id, state, attributes)
501518

502-
# Only trigger on value change or you get too many updates
503-
if (old_value is None) or (new_value != old_value):
504-
await self.trigger_watch_list(entity_id, attributes, old_state, new_state)
519+
# Only trigger on value change or you get too many updates
520+
if (old_value is None) or (new_value != old_value):
521+
await self.base.trigger_watch_list(entity_id, attributes, old_state, new_state)
505522

506523
def set_state(self, entity_id, state, attributes={}):
507524
"""

‎apps/predbat/plan.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,8 +1110,8 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
11101110
self.publish_html_plan(pv_forecast_minute_step, pv_forecast_minute10_step, load_minutes_step, load_minutes_step10, self.end_record)
11111111

11121112
# Web history
1113-
if self.components.get_component("web_interface"):
1114-
self.components.get_component("web_interface").history_update()
1113+
if self.components.get_component("web"):
1114+
self.components.get_component("web").history_update()
11151115

11161116
# Destroy pool
11171117
if self.pool:

‎apps/predbat/web.py‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ async def start(self):
122122
app.router.add_get("/charts", self.html_charts)
123123
app.router.add_get("/config", self.html_config)
124124
app.router.add_get("/entity", self.html_entity)
125+
app.router.add_post("/entity", self.html_entity_post)
125126
app.router.add_post("/config", self.html_config_post)
126127
app.router.add_get("/dash", self.html_dash)
127128
app.router.add_post("/dash", self.html_dash_post)
@@ -694,6 +695,10 @@ async def html_entity(self, request):
694695
else:
695696
text += config_text
696697

698+
control_html = self.get_entity_control_html(entity, days)
699+
if control_html:
700+
text += control_html
701+
697702
text += "<h2>History Chart</h2>\n"
698703
text += '<div id="chart"></div>'
699704
now_str = self.base.now_utc.strftime(TIME_FORMAT)
@@ -736,6 +741,186 @@ async def html_entity(self, request):
736741
text += "</body></html>\n"
737742
return web.Response(content_type="text/html", text=text)
738743

744+
async def html_entity_post(self, request):
745+
"""
746+
Handle POST request for entity value updates
747+
"""
748+
try:
749+
postdata = await request.post()
750+
entity_id = postdata.get("entity_id", "")
751+
new_value = postdata.get("value", "")
752+
days = postdata.get("days", "7")
753+
attributes = self.base.dashboard_values.get(entity_id, {}).get("attributes", {})
754+
755+
if entity_id:
756+
# Determine entity type from entity_id domain
757+
domain = entity_id.split(".")[0] if "." in entity_id else ""
758+
759+
# Convert value based on entity type
760+
if domain in ["switch", "input_boolean"]:
761+
# For toggle switches, check if value is 'on' or 'off'
762+
new_value = new_value.lower() == "on"
763+
elif domain in ["number", "input_number"]:
764+
try:
765+
new_value = float(new_value) if new_value else 0.0
766+
except ValueError:
767+
new_value = 0.0
768+
elif domain in ["select", "input_select"]:
769+
# Keep as string for select entities
770+
pass
771+
772+
# Set the entity state
773+
await self.base.ha_interface.set_state_external(entity_id, new_value, attributes=attributes)
774+
self.log(f"Entity {entity_id} updated to {new_value} via web interface")
775+
776+
except Exception as e:
777+
self.log(f"Error updating entity value: {e}")
778+
779+
# Redirect back to entity page with same parameters
780+
redirect_url = f"./entity?entity_id={entity_id}&days={days}"
781+
raise web.HTTPFound(redirect_url)
782+
783+
def is_entity_writable(self, entity_id):
784+
"""
785+
Determine if an entity is writable and return its control type
786+
Returns: (is_writable, control_type, options)
787+
"""
788+
if not entity_id or "." not in entity_id:
789+
return False, None, None
790+
791+
domain = entity_id.split(".")[0]
792+
793+
# Check if it's a writable entity type
794+
if domain in ["switch", "input_boolean"]:
795+
return True, "toggle", None
796+
elif domain in ["number", "input_number"]:
797+
return True, "number", None
798+
elif domain in ["select", "input_select"]:
799+
options = self.base.get_state_wrapper(entity_id=entity_id, attribute="options", default=[])
800+
return True, "select", options
801+
802+
return False, None, None
803+
804+
def get_entity_control_html(self, entity_id, days):
805+
"""
806+
Generate HTML control for writable entities
807+
"""
808+
current_value = self.base.get_state_wrapper(entity_id=entity_id)
809+
810+
is_writable, control_type, options = self.is_entity_writable(entity_id)
811+
812+
if not is_writable:
813+
return ""
814+
815+
html = '<div class="entity-edit-container" style="margin-top: 15px; padding: 15px; border: 1px solid var(--border-color, #ddd); border-radius: 5px; background-color: var(--background-secondary, #f9f9f9);">'
816+
817+
# Add CSS for dark mode support
818+
html += """
819+
<style>
820+
.entity-edit-container {
821+
--border-color: #ddd;
822+
--background-secondary: #f9f9f9;
823+
--text-color: #333;
824+
--text-secondary: #666;
825+
--input-background: #fff;
826+
}
827+
828+
body.dark-mode .entity-edit-container {
829+
--border-color: #555;
830+
--background-secondary: #2d2d2d;
831+
--text-color: #e0e0e0;
832+
--text-secondary: #bbb;
833+
--input-background: #3d3d3d;
834+
color: var(--text-color);
835+
}
836+
837+
body.dark-mode .entity-edit-container input[type="number"],
838+
body.dark-mode .entity-edit-container select {
839+
background-color: var(--input-background) !important;
840+
border-color: var(--border-color) !important;
841+
color: var(--text-color) !important;
842+
}
843+
844+
body.dark-mode .entity-edit-container span {
845+
color: var(--text-color) !important;
846+
}
847+
</style>
848+
"""
849+
html += f'<form method="post" action="./entity" style="display: flex; align-items: center; gap: 10px;">'
850+
html += f'<input type="hidden" name="entity_id" value="{entity_id}">'
851+
html += f'<input type="hidden" name="days" value="{days}">'
852+
853+
if control_type == "toggle":
854+
is_active = str(current_value).lower() in ["true", "on", "1"]
855+
toggle_class = "toggle-switch active" if is_active else "toggle-switch"
856+
857+
html += f'<div style="display: flex; align-items: center; gap: 10px;">'
858+
html += f'<button class="{toggle_class}" type="button" onclick="toggleEntitySwitch(this, \'{entity_id}\', {days})"></button>'
859+
html += f"<span>Enable/Disable</span>"
860+
html += f"</div>"
861+
862+
# Add JavaScript for toggle functionality
863+
html += f"""
864+
<script>
865+
function toggleEntitySwitch(button, entityId, days) {{
866+
// Toggle the visual state
867+
button.classList.toggle('active');
868+
869+
// Determine the new value
870+
const newValue = button.classList.contains('active') ? 'on' : 'off';
871+
872+
// Create form data
873+
const formData = new FormData();
874+
formData.append('entity_id', entityId);
875+
formData.append('days', days);
876+
formData.append('value', newValue);
877+
878+
// Submit the form
879+
fetch('./entity', {{
880+
method: 'POST',
881+
body: formData
882+
}}).then(response => {{
883+
if (response.redirected) {{
884+
window.location.href = response.url;
885+
}}
886+
}}).catch(error => {{
887+
console.error('Error:', error);
888+
// Revert the toggle on error
889+
button.classList.toggle('active');
890+
}});
891+
}}
892+
</script>
893+
"""
894+
895+
elif control_type == "number":
896+
min_val = self.base.get_state_wrapper(entity_id=entity_id, attribute="min", default=None)
897+
max_val = self.base.get_state_wrapper(entity_id=entity_id, attribute="max", default=None)
898+
step = self.base.get_state_wrapper(entity_id=entity_id, attribute="step", default=1)
899+
unit = self.base.get_state_wrapper(entity_id=entity_id, attribute="unit_of_measurement", default="")
900+
901+
unit_display = f" {unit}" if unit else ""
902+
903+
html += f'<input type="number" name="value" value="{current_value}" '
904+
if min_val is not None and max_val is not None and step is not None:
905+
html += f'min="{min_val}" max="{max_val}" step="{step}" '
906+
html += f'style="padding: 8px; border-radius: 4px; border: 1px solid var(--border-color, #ccc); width: 120px; background-color: var(--input-background, #fff); color: var(--text-color, #333);" '
907+
if unit_display:
908+
html += f'<span style="margin-left: 5px; color: var(--text-secondary, #666);">{unit}</span>'
909+
html += '<button type="submit" style="padding: 8px 16px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">Update</button>'
910+
911+
elif control_type == "select" and options:
912+
html += f'<select name="value" style="padding: 8px; border-radius: 4px; border: 1px solid var(--border-color, #ccc); min-width: 150px; background-color: var(--input-background, #fff); color: var(--text-color, #333);">'
913+
for option in options:
914+
selected = "selected" if str(option) == str(current_value) else ""
915+
html += f'<option value="{option}" {selected}>{option}</option>'
916+
html += "</select>"
917+
html += '<button type="submit" style="padding: 8px 16px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">Update</button>'
918+
919+
html += "</form>"
920+
html += "</div>"
921+
922+
return html
923+
739924
def get_entity_detailedForecast(self, entity, subitem="pv_estimate"):
740925
results = {}
741926
detailedForecast = self.base.dashboard_values.get(entity, {}).get("attributes", {}).get("detailedForecast", {})

0 commit comments

Comments
 (0)