Skip to content

Commit

Permalink
cleanup (see details)
Browse files Browse the repository at this point in the history
- a lot of fixme's are gone
- better logging
- handle all sungrow communication in one thread
- remember last set state for sungrow battery to not send unnecessary
commands.
- avoid exceptions on missing data
- better thread supervision (check, if medhods from class instance is
called in same thread)
- avoid exception, if Tibber does not provide price
- tesla wattage scaling fix
- switched to amp-based charging instead of watts.
  • Loading branch information
fabianhu committed Jan 5, 2024
1 parent aaf3f3a commit 60aca91
Show file tree
Hide file tree
Showing 10 changed files with 266 additions and 187 deletions.
71 changes: 33 additions & 38 deletions efb_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@

import config
import modbus
import task_tibber
import tesla_interface
import lib.tesla_api.tesla_api_2024
import lib.tibber
Expand Down Expand Up @@ -106,17 +105,12 @@ def __init__(self):
self.myTesla = tesla_interface.TeslaCar(config.tesla_vin, lib.tesla_api.tesla_api_2024.TeslaAPI())

# timing stuff
#self.last_car_update = time.time()
# self.last_car_charge_update = time.time()
self.last_car_charge_current_sync = time.time()
#self.last_heater_update = time.time()
#self.last_tasmota_update = time.time()

self.EMERGENCY_HEATER_OFF = False

self.sg = sungrow.SungrowSH(sungrow_ip, 502)


RS485_bus = modbus.Bus("/dev/ttyUSB0")
self.temperatures_heating = modbus.Nt18b07TemperatureIn(RS485_bus, 1)
self.rel1 = modbus.Bestep2Relays(RS485_bus, 0xff)
Expand All @@ -128,17 +122,13 @@ def __init__(self):
self.heatpower = 0

self.car_charge_amp = 0 #fixme move to Tesla!
self.car_charging_phases = 3 #fixme move to Tesla!

self.last_tibber_schedule = datetime.now()-timedelta(days=2) # set to the past

self.tas = tasmota.Tasmotas()

self.dtu = openDTU.OpenDTU(openDTU_ip)

logger.info("Start ####################################################################")
logger.log("Start ####################################################################")

taskctl = lib.intervaltask.TaskController()
taskctl.add_task("tesla", self.do_car_update, 5*60,30)
taskctl.add_task("tesla_charge", self.do_car_charge, 30, 20)
Expand Down Expand Up @@ -190,6 +180,17 @@ def do_car_update(self): # every 5 min
def do_sungrow_update(self):
self.sg.update() # get values from Wechselrichter

car_charge_power = self.myTesla.car_db_data.get_value_filtered('CAR_charge_W')
# battery_soc = self.sg.measurements.get_value('ELE Battery level')
# fixme batrecommend = task_tibber.check_battery(battery_soc)

# stop DISCHARGING the battery on high load from car and control battery at same point.
if self.myTesla.is_here_and_connected() and car_charge_power > 10000: # fixme check this decision!!
self.sg.set_forced_charge(0)
else:
self.sg.set_forced_charge(None)


def do_temperature_update(self): # every 2s

self.temperatures_heating.get_temperatures()
Expand All @@ -199,9 +200,12 @@ def do_temperature_update(self): # every 2s

self.heating_measurements.update_value("Pi Temp", measure_pi_temp())

boiler_temp_sum = (self.heating_measurements.get_value("HEAT Boiler Oben") + self.heating_measurements.get_value("HEAT Boiler Unten") + self.heating_measurements.get_value(
"HEAT Boiler 3")) / 3.0
self.heating_measurements.update_value("HEAT Water prc", map_to_percentage(boiler_temp_sum, (35 + 30 + 25) / 3.0, temp_setpoint))
try:
boiler_temp_sum = (self.heating_measurements.get_value("HEAT Boiler Oben") + self.heating_measurements.get_value("HEAT Boiler Unten") + self.heating_measurements.get_value(
"HEAT Boiler 3")) / 3.0
self.heating_measurements.update_value("HEAT Water prc", map_to_percentage(boiler_temp_sum, (35 + 30 + 25) / 3.0, temp_setpoint))
except TypeError: # catch exception if value is None ! (RTU init error)
logger.error("Water heater values not present.")

# prepare values:
boiler_temp_bot = self.heating_measurements.get_value('HEAT Boiler Unten')
Expand Down Expand Up @@ -247,31 +251,19 @@ def do_car_charge(self):
export_power = self.sg.measurements.get_value_filtered('ELE Export power')
battery_power = self.sg.measurements.get_value_filtered('ELE Battery power c')
battery_soc = self.sg.measurements.get_value('ELE Battery level')
car_charge_power = self.myTesla.car_db_data.get_value_filtered('CAR_charge_W')

batrecommend = task_tibber.check_battery(battery_soc)

# stop DISCHARGING the battery on high load from car and control battery at same point.
if self.myTesla.is_here_and_connected() and car_charge_power > 10000:
if batrecommend is None:
self.sg.set_forced_charge(0)
else:
self.sg.set_forced_charge(batrecommend)
else:
self.sg.set_forced_charge(batrecommend)


# ready for solar overflow charging
if self.myTesla.is_here_and_connected() and not self.stop_tesla and not self.myTesla.current_request == 16 and not (
if self.myTesla.is_here_and_connected() and not self.stop_tesla and not self.myTesla.last_current_request == 16 and not (
export_power is None or
battery_power is None or
battery_soc is None
):
# avoid charging with wrong value for too long
# actual_set_charger_W = self.myTesla.charge_actual_W
if self.myTesla.current_actual > self.car_charge_amp and time.time() - self.last_car_charge_current_sync > 6 * 60:
logger.info(f"CAR charge current sync: was {self.car_charge_amp}, now {self.myTesla.current_actual}")
self.car_charge_amp = self.myTesla.current_actual # fixme at least one should not exist
if self.myTesla.last_current_actual > self.car_charge_amp and time.time() - self.last_car_charge_current_sync > 6 * 60:
logger.info(f"CAR charge current sync: was {self.car_charge_amp}, now {self.myTesla.last_current_actual}")
self.car_charge_amp = self.myTesla.last_current_actual # fixme at least one should not exist
self.last_car_charge_current_sync = time.time()

if battery_soc > 30 and battery_power > -4000 and export_power > (-4500 if self.island_mode else -200): # only allow charging over x% battery
Expand All @@ -288,12 +280,12 @@ def do_car_charge(self):

if (export_power + battery_power + phantasy_power) > 750 and self.car_charge_amp < 15:
self.car_charge_amp += 1
self.myTesla.set_charge(True, self.car_charge_amp * 230 * self.car_charging_phases)
self.myTesla.set_charge(True, self.car_charge_amp)
# logger.info("Tesla inc", self.car_charge_amp)

if (export_power + battery_power + phantasy_power) < (-500 if self.island_mode else 0) and self.car_charge_amp > 0:
self.car_charge_amp -= 1
self.myTesla.set_charge(True, self.car_charge_amp * 230 * self.car_charging_phases)
self.myTesla.set_charge(True, self.car_charge_amp)

# logger.info("Tesla dec", self.car_charge_amp)

Expand All @@ -308,7 +300,7 @@ def do_car_charge(self):
self.car_charge_amp = 0 # Tesla not ready
# logger.info("Car not ready")

self.heating_measurements.update_value("CAR Charge command", self.car_charge_amp * 230 * self.car_charging_phases)
self.heating_measurements.update_value("CAR Charge command", self.car_charge_amp * 230 * self.myTesla.last_charging_phases)

if self.car_charge_amp > 0 or self.myTesla.is_charging:
self.allowHeater = False
Expand Down Expand Up @@ -427,7 +419,10 @@ def do_tesla_tibber_planning(self):
TIBBER_CAR_CHARGE_CURRENT = 16 # todo parameter - must be the same value as the decision, if solar overflow charging!!

now = datetime.now()
#fixme take into account the time of last connection?

if not self.myTesla.is_here_and_connected():
self.last_tibber_schedule = now - timedelta(days=2) # set to the past

if now-self.last_tibber_schedule > timedelta(hours=2) and self.myTesla.is_here_and_connected():
soc = self.myTesla.car_db_data.get_value("CAR_battery_level")
soclim = self.myTesla.car_db_data.get_value("CAR_charge_limit_soc")
Expand All @@ -439,25 +434,25 @@ def do_tesla_tibber_planning(self):
return

if soc is None or soclim is None:
logger.error("Tibber Charge Plan no info from Tesla")
logger.error("Tibber Car Charge Plan no info from Tesla")
return

mins = lib.tibber.tibber.datetime_to_minutes_after_midnight(myTibber.cheapest_charging_time(soc,soclim))
if mins is None:
logger.error(f"Tibber we had no result from {myTibber.prices}")
logger.error(f"Tibber we had no result from {myTibber.prices} with SOC: {soc} and limit: {soclim}")
return

self.myTesla.tesla_api.cmd_charge_set_schedule(self.myTesla.vin, mins)

self.last_tibber_schedule = now
logger.debug(f"Tibbering {mins/60} h")
logger.info(f"Tibber Car charge plan is start at {mins/60} h")




if __name__ == '__main__':
efb = ElectronFluxBalancer()
while True:
efb.do_temperature_update()
efb.do_temperature_update() # fixme we do not have a watchdog here!
efb.do_heater_update()
time.sleep(2)
time.sleep(2)
54 changes: 39 additions & 15 deletions lib/intervaltask/intervaltask.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,43 @@
import functools
import logging
import threading
import time
from datetime import datetime
from ..logger import Logger
logger = Logger()
logger = Logger(log_level=logging.DEBUG, log_path="tasks.log")

import traceback

# fixme think about multiprocessing

def thread_alert(func):
"""
Check, if methods of class are executed in same thread.
Add self.init_thread_id = None to the class you want to check.
add @lib.intervaltask.intervaltask.thread_alert decorator to method. and import lib.intervaltask
:param func:
:return:
"""
@functools.wraps(func)
def wrapper(instance, *args, **kwargs):
class_name = instance.__class__.__name__
current_thread_id = threading.get_ident()
if instance.init_thread_id is None:
instance.init_thread_id = current_thread_id
logger.info(f"Initialising '{func.__name__}()' for class {class_name} in thread '{threading.current_thread().name}'.")
if instance.init_thread_id != current_thread_id:
logger.error(f"Alarm: Method '{func.__name__}()' in class {class_name} executed in another Thread '{threading.current_thread().name}' than the Initialisation thread.")

return func(instance, *args, **kwargs)

return wrapper

class TaskController:
def __init__(self):
self.tasks = {}
self.lock = threading.Lock()

def add_task(self, name, func, interval, watchdog_timeout):
t = threading.Thread(target=self._run_task, args=(name,))
t = threading.Thread(target=self._run_task, name=name, args=(name,))
self.tasks[name] = {
'func': func,
'original_func': func, # Store a copy of the original function
Expand All @@ -41,7 +64,7 @@ def _run_task(self, name):
start_time = time.time()

if not callable(func):
logger.log(f"Task {name} not callable: {func}, {self.tasks[name]}") # fixme check, how to recover
logger.error(f"Task {name} not callable: {func}, {self.tasks[name]}") # fixme check, how to recover
self.log_func_details(func, name)
self.tasks[name]['func'] = self.tasks[name]['original_func'] # Restore original function
else:
Expand All @@ -50,8 +73,8 @@ def _run_task(self, name):
except Exception as e:
tb = traceback.extract_tb(e.__traceback__)
filename, line, func, text = tb[-1]
logger.log(f"Exception occurred in task {name} in file {filename} at line {line}: Exception : {e}")
logger.log(f"Traceback: {traceback.format_exc()}")
logger.error(f"Exception occurred in task {name} in file {filename} at line {line}: Exception : {e}")
logger.debug(f"Traceback: {traceback.format_exc()}")


end_time = time.time()
Expand All @@ -66,25 +89,26 @@ def _run_task(self, name):

def _restart_task(self, name):
with self.lock:
logger.log(f"Watchdog activated for task {name}.")
logger.error(f"Watchdog activated for task {name}.")
task_data = self.tasks[name]
if self.is_task_running(name): # check if task is still running
logger.error(f"Task {name} is still running and the timeout of {self.tasks[name]['watchdog_timeout']}s is over")
# fixme we did not kill it!
raise Exception(f"Task {name} is hanging")
raise Exception(f"Task {name} is hanging with data: {task_data}")
else:
logger.log(f"Task {name} died on the way - restarting")
logger.error(f"Task {name} died on the way - restarting")
t = threading.Thread(target=self._run_task, args=(name,))
self.tasks[name]['thread'] = t
t.start()

def log_func_details(self, func, task_name):
logger.log(f"Details of non-callable task '{task_name}':")
logger.log(f"Type: {type(func)}")
logger.log(f"String representation: {func}")
logger.log(f"Attributes: {dir(func)}")
@staticmethod
def log_func_details(func, task_name):
logger.debug(f"Details of non-callable task '{task_name}':")
logger.debug(f"Type: {type(func)}")
logger.debug(f"String representation: {func}")
logger.debug(f"Attributes: {dir(func)}")
if hasattr(func, '__dict__'):
logger.log(f"Object dictionary: {func.__dict__}")
logger.debug(f"Object dictionary: {func.__dict__}")

# Example usage
def task1():
Expand Down
35 changes: 23 additions & 12 deletions lib/logger/logger.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,42 @@
import datetime
import logging
import inspect

class Logger:
def __init__(self, log_level=logging.ERROR, log_path='log.log'):
"""
Initializes a Logger object for module-specific logging.
This method configures a logger with a file handler, suitable for logging messages from the calling module. The logger
captures all messages (level DEBUG), while the file handler records messages based on `log_level`.
The logger's name is automatically set to the calling module's name, allowing distinct log outputs for different modules.
Log messages are formatted to include timestamp, logger name, level, and message.
Parameters:
- log_level (logging.Level, optional): Minimum level of log messages to be recorded. Defaults to logging.ERROR.
- log_path (str, optional): File path for the log output. Defaults to 'log.log'.
Note:
- The logger level and file handler level are independent; the file handler may filter out lower-severity messages.
"""

# Retrieve the caller's module name using inspect
#caller_frame = inspect.stack()[1]
#module_name = inspect.getmodule(caller_frame[0]).__name__
caller_frame = inspect.currentframe().f_back
module_name = caller_frame.f_globals['__name__']

self.log_handler = logging.FileHandler(log_path)

self.logger = logging.getLogger(module_name)
self.logger.setLevel(logging.DEBUG) # Set to the lowest level to capture all messages by default

self.log_handler = logging.FileHandler(log_path)
self.log_handler.setLevel(log_level)

formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
self.log_handler.setFormatter(formatter)

self.logger.addHandler(self.log_handler)

def get_logger(self, module_name, log_level=logging.ERROR):
module_logger = logging.getLogger(module_name)
module_logger.setLevel(log_level)
module_logger.propagate = False # Prevent logs from propagating up the hierarchy

if not any(isinstance(handler, logging.FileHandler) for handler in module_logger.handlers):
module_logger.addHandler(self.log_handler) # Add the file handler if not already present

return module_logger
self.logint(f"Logger created from {module_name} logging to {log_path}")
self.logger.info("############### START ############################################")

def debug(self, message):
self.logger.debug(message)
Expand All @@ -48,6 +56,9 @@ def error(self, message):
def critical(self, message):
self.logger.critical(message)

@staticmethod
def logint(message):
with open("logger_internal.log", "a") as f: f.write(f"{datetime.datetime.now()} {message}\n")



Expand Down
5 changes: 4 additions & 1 deletion lib/parameters/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,14 @@ def __init__(self, host='0.0.0.0', port=5000):
self.host = host
self.port = port

def add_parameter(self, _name:str, _min:float, _max:float, _unit:str, _type:str, _step:float, _value ):
self.central_parameters[_name] = {'min': _min, 'max': _max, 'unit': _unit, 'type': _type, 'step': _step, 'value': _value}

def run_server(self):
self.app.run(host=self.host, port=self.port)


def index(self):
def index(self): # the html representation
if request.method == 'POST':
print("post")
for key, param in self.central_parameters.items():
Expand Down
2 changes: 1 addition & 1 deletion lib/tesla_api
Submodule tesla_api updated 2 files
+5 −2 README.md
+41 −27 tesla_api_2024.py
Loading

0 comments on commit 60aca91

Please sign in to comment.