diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index bd9727686..84ddb33c3 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -232,7 +232,6 @@ hahistory hainterface halfhourly hanchu -Hanchu hanchuess hanres HAOS diff --git a/.gitignore b/.gitignore index c7a38498a..da863beff 100644 --- a/.gitignore +++ b/.gitignore @@ -112,5 +112,8 @@ it # Runtime-generated manifest apps/predbat/manifest.yaml +# Dev-deploy/standalone commit marker (see coverage/deploy, hass.py) +apps/predbat/git_version.txt + # Claude Code local worktrees .claude/worktrees/ diff --git a/apps/predbat/download.py b/apps/predbat/download.py index c808bfb6d..8174a8e40 100644 --- a/apps/predbat/download.py +++ b/apps/predbat/download.py @@ -145,6 +145,40 @@ def remove_file_quietly(filepath): print("Warn: Failed to remove {}: {}".format(filepath, e)) +def read_deploy_git_version(this_path): + """ + Read the git_version.txt marker, if present, written by a dev deploy + (coverage/deploy) or a standalone launch (hass.py) from a git checkout, so the + running code can show which commit is actually installed instead of just the + release tag baked into THIS_VERSION. + + Args: + this_path (str): Directory to look for git_version.txt in, alongside predbat.py. + Returns: + str or None: The marker contents, or None if the file is absent or unreadable. + """ + filepath = os.path.join(this_path, "git_version.txt") + try: + if os.path.exists(filepath): + with open(filepath, "r") as f: + return f.read().strip() or None + except Exception as e: + print("Warn: Failed to read git_version.txt: {}".format(e)) + return None + + +def clear_deploy_git_version(this_path): + """ + Remove the git_version.txt dev marker after a real update has installed official + release files, so a stale commit marker from an earlier dev deploy doesn't linger + and get shown as the running version once it's no longer accurate. + + Args: + this_path (str): Directory the marker lives in, alongside predbat.py. + """ + remove_file_quietly(os.path.join(this_path, "git_version.txt")) + + def remove_staged_files(this_path, files, tag): """ Remove staged files, used to clean up after an aborted update. @@ -566,7 +600,9 @@ def predbat_update_move(version, files): for file in files: cmd += "mv -f {} {} && ".format(os.path.join(this_path, file + "." + tag), os.path.join(this_path, file)) cmd += "echo 'Update complete'" + clear_deploy_git_version(this_path) os.system(cmd) + return True return False diff --git a/apps/predbat/github.py b/apps/predbat/github.py index 3aa6795af..d164ca285 100644 --- a/apps/predbat/github.py +++ b/apps/predbat/github.py @@ -13,7 +13,7 @@ from ha import run_async from download import DEFAULT_PREDBAT_REPOSITORY, resolve_predbat_repository from utils import dp1 -from predbat import THIS_VERSION +from predbat import THIS_VERSION, THIS_VERSION_DISPLAY class GitHub: @@ -152,7 +152,7 @@ def download_predbat_releases(self): self.releases["latest_beta_body"] = release.get("body", "Unknown") found_latest_beta = True - self.log("Predbat {} repository {} version {} currently running, latest version is {}, latest beta is {}".format(__file__, repository, self.releases["this"], self.releases["latest"], self.releases["latest_beta"])) + self.log("Predbat {} repository {} version {} currently running, latest version is {}, latest beta is {}".format(__file__, repository, THIS_VERSION_DISPLAY, self.releases["latest"], self.releases["latest_beta"])) PREDBAT_UPDATE_OPTIONS = ["main"] this_tag = THIS_VERSION new_version = False diff --git a/apps/predbat/hass.py b/apps/predbat/hass.py index 58d48479f..966449af6 100644 --- a/apps/predbat/hass.py +++ b/apps/predbat/hass.py @@ -10,13 +10,38 @@ import yaml import sys import asyncio +import os +import subprocess + + +def write_git_version_marker(): + """ + Best-effort: when running from a git checkout - directly, or via the symlinked + .py files that coverage/standalone_ha sets up for a live-HA dev run - record the + commit as git_version.txt next to this file (predbat.py resolves its own __file__ + to the same directory) so predbat.py can show it instead of just the release tag. + Runs before predbat is imported, since that's when predbat.py reads the marker. + Silently does nothing if git isn't available or this isn't a checkout. + """ + this_dir = os.path.dirname(os.path.abspath(__file__)) + repo_dir = os.path.dirname(os.path.realpath(__file__)) + try: + commit = subprocess.check_output(["git", "-C", repo_dir, "rev-parse", "--short", "HEAD"], stderr=subprocess.DEVNULL).decode().strip() + dirty = bool(subprocess.check_output(["git", "-C", repo_dir, "status", "--porcelain"], stderr=subprocess.DEVNULL).decode().strip()) + with open(os.path.join(this_dir, "git_version.txt"), "w") as f: + f.write(commit + ("-dirty" if dirty else "")) + except Exception: + pass + + +write_git_version_marker() + import predbat import time from datetime import datetime, timedelta from multiprocessing import set_start_method import concurrent.futures import threading -import os import traceback diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 9aad580b5..e692682f1 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -19,8 +19,8 @@ import math import copy from html import escape as escape_html -from datetime import datetime, timedelta -from config import THIS_VERSION +from datetime import timedelta +from predbat import THIS_VERSION_DISPLAY from const import TIME_FORMAT, PREDICT_STEP, EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE, MINUTE_WATT from utils import dp0, dp1, dp2, dp3, calc_percent_limit, minute_data, minute_data_state, find_charge_rate from prediction import Prediction @@ -2588,9 +2588,9 @@ def record_status(self, message, debug="", had_errors=False, notify=False, extra "friendly_name": "Status", "detail": extra, "icon": "mdi:information", - "last_updated": str(datetime.now()), + "last_updated": self.now_utc_real.strftime(TIME_FORMAT), "debug": debug, - "version": THIS_VERSION, + "version": THIS_VERSION_DISPLAY, "error": (had_errors or self.had_errors), "error_count": error_count, }, diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 63067c40c..1def99e20 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -36,12 +36,19 @@ import asyncio THIS_VERSION = "v8.51.1" +THIS_VERSION_DISPLAY = THIS_VERSION -from download import predbat_update_move, predbat_update_download, check_install, DEFAULT_PREDBAT_REPOSITORY +from download import predbat_update_move, predbat_update_download, check_install, read_deploy_git_version, DEFAULT_PREDBAT_REPOSITORY from const import MINUTE_WATT # Only do the self-install/self-update logic if we are NOT compiled. if not IS_COMPILED: + # Show the actual commit for a dev deploy (coverage/deploy) or standalone git + # checkout (hass.py) rather than just the release tag - see git_version.txt + git_version = read_deploy_git_version(os.path.dirname(__file__)) + if git_version: + THIS_VERSION_DISPLAY = "{} ({})".format(THIS_VERSION, git_version) + # Sanity check the install and re-download if corrupted passed, modified = check_install(THIS_VERSION, repository=DEFAULT_PREDBAT_REPOSITORY) if not passed: @@ -53,7 +60,7 @@ elif modified: print("Warn: Predbat files are installed but have modifications") else: - print("Predbat files are installed correctly for version {}".format(THIS_VERSION)) + print("Predbat files are installed correctly for version {}".format(THIS_VERSION_DISPLAY)) else: # In compiled mode, we skip the entire self-update logic print("Running in compiled mode; skipping local file checks and auto-update.") @@ -1626,7 +1633,7 @@ def is_running(self): return False # Check if the last updated time is within the last 15 minutes - if (datetime.now() - predbat_last_updated).total_seconds() > 15 * 60: + if (datetime.now(timezone.utc) - predbat_last_updated).total_seconds() > 15 * 60: return False return True diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index b5977c424..ca950e8c6 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -30,7 +30,7 @@ PREDBAT_MODE_MONITOR, ) from config import CONFIG_API_OVERRIDE -from predbat import THIS_VERSION +from predbat import THIS_VERSION, THIS_VERSION_DISPLAY DEBUG_EXCLUDE_LIST = [ "ha_interface", @@ -817,7 +817,7 @@ def create_entity_list(self): """ text = "" - text += "# Predbat Dashboard - {}\n".format(THIS_VERSION) + text += "# Predbat Dashboard - {}\n".format(THIS_VERSION_DISPLAY) text += "type: entities\n" text += "Title: Predbat\n" text += "entities:\n" diff --git a/apps/predbat/web.py b/apps/predbat/web.py index 6d6166ac0..57a53087b 100644 --- a/apps/predbat/web.py +++ b/apps/predbat/web.py @@ -71,7 +71,7 @@ from utils import calc_percent_limit, str2time, dp0, dp2, dp4, format_time_ago, get_override_time_from_string, history_attribute, prune_today, mask_secret_args from const import TIME_FORMAT, TIME_FORMAT_DAILY, TIME_FORMAT_HA -from predbat import THIS_VERSION +from predbat import THIS_VERSION_DISPLAY from component_base import ComponentBase from config import APPS_SCHEMA from web_annual import AnnualPage @@ -808,6 +808,11 @@ def get_status_html(self, version): status_entity = self.prefix + ".status" last_updated = self.get_state_wrapper(status_entity, attribute="last_updated", default=None) + if last_updated: + try: + last_updated = str2time(last_updated).replace(tzinfo=None, microsecond=0) + except (ValueError, TypeError) as e: + self.log("Warn: Failed to parse last_updated time {}: {}".format(last_updated, e)) status = self.get_state_wrapper(status_entity, default="Unknown") detail = self.get_state_wrapper(status_entity, attribute="detail", default="") debug = self.get_state_wrapper(status_entity, attribute="debug", default="") @@ -1607,7 +1612,7 @@ def get_header(self, title, refresh=0, codemirror=False): if self.base.update_pending: calculating = True self.update_success_timestamp() - return get_header_html(title, calculating, self.default_page, self.arg_errors, THIS_VERSION, self.get_battery_status_icon(), refresh, codemirror=codemirror) + return get_header_html(title, calculating, self.default_page, self.arg_errors, THIS_VERSION_DISPLAY, self.get_battery_status_icon(), refresh, codemirror=codemirror) def get_chart_series(self, name, results, chart_type, color): """ @@ -2831,7 +2836,7 @@ async def html_dash_content(self, request): """ Return just the dashboard body content for AJAX refresh (preserves scroll position) """ - text = self.get_status_html(THIS_VERSION) + text = self.get_status_html(THIS_VERSION_DISPLAY) return web.Response(content_type="text/html", text=text) async def html_dash(self, request): @@ -2880,7 +2885,7 @@ async def html_dash(self, request): """ text += "
\n" text += '