Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/predbat/gecloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@ async def async_automatic_config(self, devices):
self.base.args["charge_limit"] = ["number.predbat_gecloud_" + device + "_ac_charge_upper_percent_limit" for device in batteries]
self.base.args["discharge_start_time"] = ["select.predbat_gecloud_" + device + "_dc_discharge_1_start_time" for device in batteries]
self.base.args["discharge_end_time"] = ["select.predbat_gecloud_" + device + "_dc_discharge_1_end_time" for device in batteries]
self.base.args["discharge_target_soc"] = ["select.predbat_gecloud_" + device + "_dc_discharge_1_lower_soc_percent_limit" for device in batteries]
self.base.args["scheduled_charge_enable"] = ["switch.predbat_gecloud_" + device + "_ac_charge_enable" for device in batteries]
self.base.args["scheduled_discharge_enable"] = ["switch.predbat_gecloud_" + device + "_enable_dc_discharge" for device in batteries]
self.base.args["pause_mode"] = ["select.predbat_gecloud_" + device + "_pause_battery" for device in batteries]
Expand Down
31 changes: 24 additions & 7 deletions apps/predbat/ha.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,6 @@ async def set_state_external(self, entity_id, state, attributes={}):
new_state = {"entity_id": entity_id, "state": state, "attributes": attributes}
old_value = self.get_state(entity_id)
old_state = self.state_data.get(entity_id.lower(), None)
found = False

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

if not found:
# If its not found then its a sensor and we can set the state directly
self.set_state(entity_id, state, attributes)
# Call back to Predbat handled entities
domain = entity_id.split(".")[0] if "." in entity_id else ""
service_data = {"domain": domain}
if domain in ["switch", "input_boolean"]:
# For toggle switches, check if value is 'on' or 'off'
service_data["service"] = "turn_on" if new_value else "turn_off"

Copilot AI Oct 3, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable new_value is used but not defined in this scope. It should be state instead, which contains the new value passed to the function.

Copilot uses AI. Check for mistakes.
service_data["service_data"] = {"entity_id": entity_id}
elif domain in ["number", "input_number"]:
service_data["service"] = "set_value"
service_data["service_data"] = {"entity_id": entity_id, "value": new_value}
elif domain in ["select", "input_select"]:
service_data["service"] = "select_option"
service_data["service_data"] = {"entity_id": entity_id, "option": new_value}
Comment on lines +506 to +509

Copilot AI Oct 3, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable new_value is used but not defined in this scope. It should be state instead, which contains the new value passed to the function.

Copilot uses AI. Check for mistakes.
else:
service_data = None
if service_data:
await self.base.trigger_callback(service_data)
return

# If its not found then its a sensor and we can set the state directly
self.set_state(entity_id, state, attributes)

# Only trigger on value change or you get too many updates
if (old_value is None) or (new_value != old_value):
await self.trigger_watch_list(entity_id, attributes, old_state, new_state)
# Only trigger on value change or you get too many updates
if (old_value is None) or (new_value != old_value):

Copilot AI Oct 3, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable new_value is used but not defined in this scope. It should be state instead, which contains the new value passed to the function.

Copilot uses AI. Check for mistakes.
await self.base.trigger_watch_list(entity_id, attributes, old_state, new_state)

Copilot AI Oct 3, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method call was changed from self.trigger_watch_list to self.base.trigger_watch_list, but this appears to be incorrect as trigger_watch_list should be a method of the current class, not the base class.

Suggested change
await self.base.trigger_watch_list(entity_id, attributes, old_state, new_state)
await self.trigger_watch_list(entity_id, attributes, old_state, new_state)

Copilot uses AI. Check for mistakes.

def set_state(self, entity_id, state, attributes={}):
"""
Expand Down
4 changes: 2 additions & 2 deletions apps/predbat/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -1110,8 +1110,8 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
self.publish_html_plan(pv_forecast_minute_step, pv_forecast_minute10_step, load_minutes_step, load_minutes_step10, self.end_record)

# Web history
if self.components.get_component("web_interface"):
self.components.get_component("web_interface").history_update()
if self.components.get_component("web"):
self.components.get_component("web").history_update()

# Destroy pool
if self.pool:
Expand Down
185 changes: 185 additions & 0 deletions apps/predbat/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ async def start(self):
app.router.add_get("/charts", self.html_charts)
app.router.add_get("/config", self.html_config)
app.router.add_get("/entity", self.html_entity)
app.router.add_post("/entity", self.html_entity_post)
app.router.add_post("/config", self.html_config_post)
app.router.add_get("/dash", self.html_dash)
app.router.add_post("/dash", self.html_dash_post)
Expand Down Expand Up @@ -694,6 +695,10 @@ async def html_entity(self, request):
else:
text += config_text

control_html = self.get_entity_control_html(entity, days)
if control_html:
text += control_html

text += "<h2>History Chart</h2>\n"
text += '<div id="chart"></div>'
now_str = self.base.now_utc.strftime(TIME_FORMAT)
Expand Down Expand Up @@ -736,6 +741,186 @@ async def html_entity(self, request):
text += "</body></html>\n"
return web.Response(content_type="text/html", text=text)

async def html_entity_post(self, request):
"""
Handle POST request for entity value updates
"""
try:
postdata = await request.post()
entity_id = postdata.get("entity_id", "")
new_value = postdata.get("value", "")
days = postdata.get("days", "7")
attributes = self.base.dashboard_values.get(entity_id, {}).get("attributes", {})

if entity_id:
# Determine entity type from entity_id domain
domain = entity_id.split(".")[0] if "." in entity_id else ""

# Convert value based on entity type
if domain in ["switch", "input_boolean"]:
# For toggle switches, check if value is 'on' or 'off'
new_value = new_value.lower() == "on"
elif domain in ["number", "input_number"]:
try:
new_value = float(new_value) if new_value else 0.0
except ValueError:
new_value = 0.0
elif domain in ["select", "input_select"]:
# Keep as string for select entities
pass

# Set the entity state
await self.base.ha_interface.set_state_external(entity_id, new_value, attributes=attributes)
self.log(f"Entity {entity_id} updated to {new_value} via web interface")

except Exception as e:
self.log(f"Error updating entity value: {e}")

# Redirect back to entity page with same parameters
redirect_url = f"./entity?entity_id={entity_id}&days={days}"
raise web.HTTPFound(redirect_url)

def is_entity_writable(self, entity_id):
"""
Determine if an entity is writable and return its control type
Returns: (is_writable, control_type, options)
"""
if not entity_id or "." not in entity_id:
return False, None, None

domain = entity_id.split(".")[0]

# Check if it's a writable entity type
if domain in ["switch", "input_boolean"]:
return True, "toggle", None
elif domain in ["number", "input_number"]:
return True, "number", None
elif domain in ["select", "input_select"]:
options = self.base.get_state_wrapper(entity_id=entity_id, attribute="options", default=[])
return True, "select", options

return False, None, None

def get_entity_control_html(self, entity_id, days):
"""
Generate HTML control for writable entities
"""
current_value = self.base.get_state_wrapper(entity_id=entity_id)

is_writable, control_type, options = self.is_entity_writable(entity_id)

if not is_writable:
return ""

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);">'

# Add CSS for dark mode support
html += """
<style>
.entity-edit-container {
--border-color: #ddd;
--background-secondary: #f9f9f9;
--text-color: #333;
--text-secondary: #666;
--input-background: #fff;
}

body.dark-mode .entity-edit-container {
--border-color: #555;
--background-secondary: #2d2d2d;
--text-color: #e0e0e0;
--text-secondary: #bbb;
--input-background: #3d3d3d;
color: var(--text-color);
}

body.dark-mode .entity-edit-container input[type="number"],
body.dark-mode .entity-edit-container select {
background-color: var(--input-background) !important;
border-color: var(--border-color) !important;
color: var(--text-color) !important;
}

body.dark-mode .entity-edit-container span {
color: var(--text-color) !important;
}
</style>
"""
html += f'<form method="post" action="./entity" style="display: flex; align-items: center; gap: 10px;">'
html += f'<input type="hidden" name="entity_id" value="{entity_id}">'
html += f'<input type="hidden" name="days" value="{days}">'

if control_type == "toggle":
is_active = str(current_value).lower() in ["true", "on", "1"]
toggle_class = "toggle-switch active" if is_active else "toggle-switch"

html += f'<div style="display: flex; align-items: center; gap: 10px;">'
html += f'<button class="{toggle_class}" type="button" onclick="toggleEntitySwitch(this, \'{entity_id}\', {days})"></button>'
html += f"<span>Enable/Disable</span>"
html += f"</div>"

# Add JavaScript for toggle functionality
html += f"""
<script>
function toggleEntitySwitch(button, entityId, days) {{
// Toggle the visual state
button.classList.toggle('active');

// Determine the new value
const newValue = button.classList.contains('active') ? 'on' : 'off';

// Create form data
const formData = new FormData();
formData.append('entity_id', entityId);
formData.append('days', days);
formData.append('value', newValue);

// Submit the form
fetch('./entity', {{
method: 'POST',
body: formData
}}).then(response => {{
if (response.redirected) {{
window.location.href = response.url;
}}
}}).catch(error => {{
console.error('Error:', error);
// Revert the toggle on error
button.classList.toggle('active');
}});
}}
</script>
"""

elif control_type == "number":
min_val = self.base.get_state_wrapper(entity_id=entity_id, attribute="min", default=None)
max_val = self.base.get_state_wrapper(entity_id=entity_id, attribute="max", default=None)
step = self.base.get_state_wrapper(entity_id=entity_id, attribute="step", default=1)
unit = self.base.get_state_wrapper(entity_id=entity_id, attribute="unit_of_measurement", default="")

unit_display = f" {unit}" if unit else ""

html += f'<input type="number" name="value" value="{current_value}" '
if min_val is not None and max_val is not None and step is not None:
html += f'min="{min_val}" max="{max_val}" step="{step}" '
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);" '
if unit_display:
html += f'<span style="margin-left: 5px; color: var(--text-secondary, #666);">{unit}</span>'
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>'

elif control_type == "select" and options:
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);">'
for option in options:
selected = "selected" if str(option) == str(current_value) else ""
html += f'<option value="{option}" {selected}>{option}</option>'
html += "</select>"
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>'

html += "</form>"
html += "</div>"

return html

def get_entity_detailedForecast(self, entity, subitem="pv_estimate"):
results = {}
detailedForecast = self.base.dashboard_values.get(entity, {}).get("attributes", {}).get("detailedForecast", {})
Expand Down