Think the below should do the trick. Do change the path and the database configuration!
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
This script fixes the broken statistics in the HomeAssistant database caused by a bug in the Riemann Sum in HA 2023.05
Usage: python HA_FixNegativeStatistics [--list]
"""
import json
import os
import sys
import shutil
import pymysql.cursors
from datetime import datetime
__author__ = "Sebastian Hollas"
__version__ = "2.0.0"
####################################################################################
# USER INPUT REQUIRED !
# Connection details for the MySQL database
DB_HOST = 'core-mariadb'
DB_USER = 'homeassistant'
DB_PASSWORD = 'homeassistant'
DB_NAME = 'homeassistant'
####################################################################################
HA_CONFIG_ROOT = "/config"
ENTITIES_FILE = os.path.join(HA_CONFIG_ROOT, "entities.list")
RESTORE_STATE_PATH = os.path.join(HA_CONFIG_ROOT, ".storage", "core.restore_state")
CONFIG_ENTRIES_PATH = os.path.join(HA_CONFIG_ROOT, ".storage", "core.config_entries")
ENTITY_REGISTRY_PATH = os.path.join(HA_CONFIG_ROOT, ".storage", "core.entity_registry")
if not os.path.isfile(RESTORE_STATE_PATH):
sys.exit(f"File {RESTORE_STATE_PATH} does not exist! (Path to HomeAssistant config valid?)")
if not os.path.isfile(CONFIG_ENTRIES_PATH):
sys.exit(f"File {CONFIG_ENTRIES_PATH} does not exist! (Path to HomeAssistant config valid?)")
if not os.path.isfile(ENTITY_REGISTRY_PATH):
sys.exit(f"File {ENTITY_REGISTRY_PATH} does not exist! (Path to HomeAssistant config valid?)")
connection = pymysql.connect(
host=DB_HOST,
user=DB_USER,
password=DB_PASSWORD,
database=DB_NAME,
cursorclass=pymysql.cursors.DictCursor
)
def main():
if len(sys.argv) == 1:
if os.path.isfile(f"{RESTORE_STATE_PATH}.BAK"):
sys.exit("core.restore_state backup file already exists!")
shutil.copyfile(RESTORE_STATE_PATH, f"{RESTORE_STATE_PATH}.BAK")
if not os.path.isfile(ENTITIES_FILE):
sys.exit(f"File {ENTITIES_FILE} does not exist! (Run with --list first and remove unwanted entities)")
with open(ENTITIES_FILE, "r") as file:
ENTITIES = file.read().splitlines()
fixDatabase(ENTITIES=ENTITIES)
elif len(sys.argv) == 2 and sys.argv[1] == "--list":
with open(ENTITIES_FILE, "w") as file:
for entity_id in getEntitiesPrecision():
file.write(f"{entity_id}\n")
print(f"File '{ENTITIES_FILE}' created with entities that are most likely Riemann Sum Entities"
f"\nPlease adjust to your needs and rerun the script with no arguments.")
else:
sys.exit("Unknown input argument!")
def fixDatabase(ENTITIES: list):
EntityPrecision = getEntitiesPrecision()
with connection.cursor() as cursor:
for entity_id in ENTITIES:
cursor.execute("SELECT metadata_id FROM states_meta WHERE entity_id=%s", (entity_id,))
result = cursor.fetchone()
if not result:
print(f" [WARNING]: Entity with name '{entity_id}' does not exist in table states_meta! Skipping...")
continue
metadata_id_states = result['metadata_id']
cursor.execute("SELECT id FROM statistics_meta WHERE statistic_id=%s", (entity_id,))
result = cursor.fetchone()
if not result:
print(f" [WARNING]: Entity with name '{entity_id}' does not exist in table states_meta! Skipping...")
continue
metadata_id_statistics = result['id']
if entity_id not in EntityPrecision:
print(f" [WARNING]: Entity seems not to be a Riemann Sum Entity! Skipping...")
continue
roundDigits = EntityPrecision[entity_id]
print("\n========================================================================")
print(f"{entity_id} | {metadata_id_states = } | {metadata_id_statistics = }")
lastValidSum = recalculateStatistics(cursor, metadata_id=metadata_id_statistics, key="sum", roundDigits=roundDigits)
lastValidState = recalculateStatistics(cursor, metadata_id=metadata_id_statistics, key="state", roundDigits=roundDigits)
fixShortTerm(cursor, metadata_id=metadata_id_statistics, lastValidSum=lastValidSum, lastValidState=lastValidState)
recalculateStates(cursor, metadata_id=metadata_id_states, roundDigits=roundDigits)
fixLastValidState(entity_id=entity_id, lastValidState=lastValidState)
print(f"\n{connection.affected_rows()} changes made to database!")
connection.commit()
connection.close()
def recalculateStatistics(cursor, metadata_id: int, key: str, roundDigits: int) -> str:
print(f" Fixing table statistics for key: {key}")
cursor.execute("SELECT id,{} FROM statistics WHERE metadata_id=%s ORDER BY created_ts".format(key), (metadata_id,))
result = cursor.fetchall()
try:
current_value = float(result[0][key])
except ValueError:
sys.exit(f" [ERROR]: Cannot fix this entity because first entry in table 'statistics' for {key} is not a number! Sorry!")
for index, row in enumerate(result[1:]):
pre_row = result[index]
if row[key] < current_value:
if row[key] >= pre_row[key]:
current_value += (row[key] - pre_row[key])
roundedValue = f"{current_value:.{roundDigits}f}"
print(f" Updating {row['id']} = {row[key]} -> {roundedValue}")
cursor.execute("UPDATE statistics SET {}=%s WHERE id=%s".format(key), (roundedValue, row['id']))
continue
current_value = row[key]
return f"{current_value:.{roundDigits}f}"
def fixShortTerm(cursor, metadata_id: int, lastValidSum: str, lastValidState: str):
print(" Deleting short term statistics")
cursor.execute("DELETE FROM statistics_short_term WHERE metadata_id=%s", (metadata_id,))
now = datetime.now()
minute_end = now.minute - (now.minute % 5)
minute_start = (minute_end - 5) if minute_end else 55
now_end = now.replace(minute=minute_end, second=0, microsecond=0)
now_start = now.replace(minute=minute_start, second=0, microsecond=0)
cursor.execute("INSERT INTO statistics_short_term (state, sum, metadata_id, created_ts, start_ts) VALUES(%s, %s, %s, %s, %s)",
(lastValidState, lastValidSum, metadata_id, now_end.timestamp(), now_start.timestamp()))
def recalculateStates(cursor, metadata_id: int, roundDigits: int):
print(f" Fixing table states")
cursor.execute("SELECT state_id,state,old_state_id,attributes_id FROM states WHERE metadata_id=%s ORDER BY state_id",
(metadata_id,))
result = cursor.fetchall()
try:
current_state = float(result[0]['state'])
attributes_id = result[0]['attributes_id']
except ValueError:
sys.exit(" [ERROR]: Cannot fix this entity because first entry in table 'states' is not a number! Sorry!")
for index, row in enumerate(result[1:]):
pre_row = result[index]
state_id = row['state_id']
state = row['state']
old_state_id = row['old_state_id']
attr_id = row['attributes_id']
if old_state_id is None:
cursor.execute("UPDATE states SET old_state_id=%s WHERE state_id=%s", (pre_row['state_id'], state_id))
if attributes_id != attr_id:
cursor.execute("UPDATE states SET attributes_id=%s WHERE state_id=%s", (attributes_id, state_id))
if state is None or not state.replace(".", "", 1).isdigit():
roundedValue = f"{current_state:.{roundDigits}f}"
print(f" Updating {state_id} = {state} -> {roundedValue}")
cursor.execute("UPDATE states SET state=%s WHERE state_id=%s", (roundedValue, state_id))
continue
state = float(state)
if state < current_state:
if pre_row['state'] and pre_row['state'].replace(".", "", 1).isdigit() and state >= float(pre_row['state']):
current_state += (state - float(pre_row['state']))
roundedValue = f"{current_state:.{roundDigits}f}"
print(f" Updating {state_id} = {state} -> {roundedValue}")
cursor.execute("UPDATE states SET state=%s WHERE state_id=%s", (roundedValue, state_id))
continue
current_state = state
def fixLastValidState(entity_id: str, lastValidState: str):
with open(RESTORE_STATE_PATH, "r") as file:
restore_state = json.load(file)
for state in restore_state["data"]:
if state["state"]["entity_id"] == entity_id:
state["state"]["state"] = lastValidState
state["extra_data"]["native_value"]["decimal_str"] = lastValidState
state["extra_data"]["last_valid_state"] = lastValidState
break
with open(RESTORE_STATE_PATH, "w") as file:
json.dump(restore_state, file, indent=2, ensure_ascii=False)
def getEntitiesPrecision() -> dict[str, int]:
returnDict = dict()
with open(CONFIG_ENTRIES_PATH, "r") as file:
configEntries = json.load(file)
with open(ENTITY_REGISTRY_PATH, "r") as file:
configEntities = json.load(file)
configIds = dict()
for configEntry in configEntries["data"]["entries"]:
number = configEntry["options"].get("round", -1)
if number == -1:
continue
configIds[configEntry["entry_id"]] = int(number)
for configEntity in configEntities["data"]["entities"]:
if configEntity["config_entry_id"] not in configIds:
continue
entity_id = configEntity["entity_id"]
config_entry_id = configEntity["config_entry_id"]
# Store precision and entity_id
returnDict[entity_id] = configIds[config_entry_id]
# Return dict with format {entity_id: precision}
return returnDict
if __name__ == "__main__":
# Call main function
main()
# Exit with positive return value
sys.exit(0)