# V2.0 Refactored to class structure # @reboot sleep 20 && sudo python3 /var/www/html/BatteryMon_4.py & # COMPATIBILITY: Python 3.5.3+ # setup_logging() kutsutaan nyt heti ohjelman alussa # readArduino ei enää voi palauttaa None — energia päivitetään suoraan self.energy:n kautta # SIGKILL poistettu signal-handlereista (se kaatoi ohjelman käynnistyksessä) # readGraphFile aikavälilasku korjattu käyttämään total_seconds() / 3600 # Windows-polku käyttää nyt r"..." raw string -etuliitettä # atexit.register rekisteröidään nyt oikeasti # DEBUG SETTINGS #************************************************************************************** DEBUG_INFO = True DEBUG_DETAIL = False def debug_info(*args, **kwargs): if DEBUG_INFO: print(*args, **kwargs) def debug_detail(*args, **kwargs): if DEBUG_DETAIL: print(*args, **kwargs) # IMPORTS #************************************************************************************** import platform from pathlib import Path import sys import os import time import datetime from datetime import timedelta import csv import logging import traceback import signal import atexit sys.path.append(r"C:\Users\kauko\env\Lib\site-packages") import serial import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt import matplotlib.dates as mdates plt.style.use('ggplot') # FILE PATHS #************************************************************************************** if platform.system() == "Windows": base_path = Path(r"C:\Apache24\htdocs\Data") else: base_path = Path("/var/www/html/Data") Controlfile = str(base_path / 'Control.csv') StatusFile = str(base_path / 'Status.csv') DataFile = str(base_path / 'Tiedosto.csv') GraphFile = str(base_path / 'GraphFile.csv') Graph = str(base_path / 'Graph6') LOG_FILE = str(base_path / 'BatteryMon_errors.log') # SERIAL PORT #************************************************************************************** def get_serial_port(baud=9600, timeout=1): """Try multiple serial ports and return the first one that opens successfully.""" if platform.system() == "Windows": candidates = ["COM3", "COM4", "COM5", "COM6", "COM7", "COM8"] elif platform.system() == "Linux": candidates = ["/dev/ttyACM0", "/dev/ttyACM1", "/dev/ttyUSB0", "/dev/ttyUSB1"] else: raise EnvironmentError("Unsupported platform") for port in candidates: try: s = serial.Serial(port, baud, timeout=timeout) s.close() print("Arduino found on port: {}".format(port)) return port except (serial.SerialException, OSError): print("Port {} not available, trying next...".format(port)) raise EnvironmentError("No Arduino found. Tried: {}".format(candidates)) # LOGGING #************************************************************************************** def setup_logging(): logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(LOG_FILE), logging.StreamHandler() ] ) logging.info("BatteryMon script started") # HELPER FUNCTIONS #************************************************************************************** def format_timedelta(td): total_seconds = int(td.total_seconds()) hours, remainder = divmod(total_seconds, 3600) minutes, seconds = divmod(remainder, 60) return "{:02}:{:02}:{:02}".format(hours, minutes, seconds) def parse_timedelta(time_str): try: parts = list(map(int, time_str.split(':'))) if len(parts) == 3: return timedelta(hours=parts[0], minutes=parts[1], seconds=parts[2]) elif len(parts) == 4: return timedelta(hours=parts[0], minutes=parts[1], seconds=parts[2], milliseconds=parts[3]) except ValueError: print("Invalid time format:", time_str) return timedelta() # BATTERY MONITOR CLASS #************************************************************************************** class BatteryMonitor: def __init__(self, serial_port, baud=9600, x_length=600): # Serial self.serial = serial.Serial(serial_port, baud, timeout=1) time.sleep(1) self.serial.flushInput() self.serial.flushOutput() time.sleep(1) # Calibration self.voltage_factor = 0.0189 self.idc_factor = 0.715 / 20 self.i_offset = 0 self.i_cal = 1.0 # Measurements self.voltage = 11.5 self.current = 0.0 self.energy = 0.0 self.energy_in = 0.0 self.energy_out = 0.0 self.gen_on = 1 # Battery parameters self.ah_value = 300 self.v_min = 10.0 # Charger autostart thresholds self.v_start = 0.0 self.ah_start = 0.0 self.v_stop = 0.0 self.ah_stop = 0.0 self.inv_trigger = 0.0 # Charger state tracking self.charge_time = timedelta() self.charge_time_tot = timedelta() self.charger_off_filter = 140 self.charge_attempts = 0 self.charge_start_time = time.time() self.charge_source = "None" self.charge_source_timer = None self.gen_timer = time.time() # State machine self.state = "PowerOff" self.alarm = "None" # Voltage alarm timers self.v_min_timer = None self.v_min_timer2 = None # Graph data self.x_length = x_length now = datetime.datetime.now().replace(microsecond=0) self.x = [now + timedelta(seconds=i - x_length) for i in range(x_length)] self.values = [0.0] * x_length # Voltage history self.values2 = [0.0] * x_length # Current history self.values3 = [0.0] * x_length # Energy history # Timing self.time0 = datetime.datetime.now() self.x2 = datetime.datetime.now() self.graph_time = time.time() self.graph_timer = 600 self.start_time = time.time() self.short_timer_on = 0 self.sec5_timer = 60 # Control data self.control_data0 = [] # Register cleanup atexit.register(self.cleanup_on_exit) # ------------------------------------------------------------------ # GRAPH # ------------------------------------------------------------------ def draw_graph(self): plt.figure(1) plt.clf() fig = plt.figure(1) fig.set_figwidth(5) fig.set_figheight(8) plt.subplot(311) plt.ylim(0, self.ah_value) plt.title("Varaustila %1.1f Ah" % self.energy, fontweight='bold', fontsize=12) plt.plot(self.x, self.values3, color='mediumseagreen', linewidth=2) plt.grid(linewidth=1) plt.axhspan(60, 300, facecolor="green", alpha=0.1) plt.axhspan(30, 60, facecolor="yellow", alpha=0.1) plt.axhspan(0, 30, facecolor="red", alpha=0.1) plt.subplot(312) plt.title("Virta %1.1f A, (+lataus, -purku)" % self.current, fontweight='bold', fontsize=12) if max(self.values2) < 10 and min(self.values2) > -10: plt.ylim(-10, 10) plt.plot(self.x, self.values2, color='lightcoral', linewidth=2) plt.subplot(313) plt.title("Jannite %1.1f V, GenOn %1.0f" % (self.voltage, self.gen_on), fontweight='bold', fontsize=12) plt.ylim(10, 16) plt.plot(self.x, self.values, color='steelblue', linewidth=2) plt.grid(linewidth=1) plt.axhspan(14.8, 16, facecolor="red", alpha=0.1) plt.axhspan(self.v_min, 14.8, facecolor="green", alpha=0.1) plt.axhspan(10, self.v_min, facecolor="red", alpha=0.1) fig.tight_layout() plt.gcf().autofmt_xdate() def update_graph_data(self): if 0 <= self.voltage <= 10240000: self.values.append(self.voltage); self.values.pop(0) self.values2.append(self.current); self.values2.pop(0) self.values3.append(self.energy); self.values3.pop(0) self.x.append(self.x2); self.x.pop(0) self.draw_graph() plt.savefig(Graph, bbox_inches="tight") else: print("Invalid! Voltage out of range") # ------------------------------------------------------------------ # ARDUINO COMMUNICATION # ------------------------------------------------------------------ def read_arduino(self): try: data = self.serial.readline()[:-2] b = data koe = b.split(b" ")[0] if koe == b"Graph1": vdc = b.split(b" ")[1] idc = b.split(b" ")[2] self.gen_on = int(b.split(b" ")[3]) voltage1 = int(vdc) self.voltage = round(voltage1 * self.voltage_factor, 2) current1 = int(idc) self.current = round(self.i_offset + self.i_cal * current1 * self.idc_factor, 3) if current1 * current1 == 1: self.current = 0 elapsed = (datetime.datetime.now() - self.time0).total_seconds() new_energy = round(self.energy + self.current * elapsed / 3600, 2) new_energy = max(0.1, min(new_energy, self.ah_value - 2)) self.energy = new_energy self.time0 = datetime.datetime.now() energy_change = round(self.current * elapsed / 3600, 3) if energy_change > 0: self.energy_in = round(self.energy_in + energy_change, 3) else: self.energy_out = round(self.energy_out - energy_change, 3) time_str = str(datetime.datetime.now().replace(microsecond=0)) self.x2 = datetime.datetime.strptime(time_str, '%Y-%m-%d %H:%M:%S') self.update_status_file() self.update_graph_data() self.update_data_file() except serial.SerialException as e: logging.error("Serial error: {}".format(e)) raise except ValueError as e: logging.error("Data parsing error: {}".format(e)) except Exception as e: logging.error("Unexpected error in read_arduino: {}".format(e)) raise def send_to_arduino(self, label, data): task = "<{} {}>".format(label, data) print("Sent command:", task) self.serial.write(task.encode()) # ------------------------------------------------------------------ # CONTROL FILE # ------------------------------------------------------------------ def init_control_data(self): with open(Controlfile, 'r') as f: self.control_data0 = list(csv.reader(f)) for row in self.control_data0: row[1] = "xx" def get_control_value(self, label): for row in self.control_data0: if row[0] == label: return float(row[1]) return None def update_control_data0(self, label, value): for row in self.control_data0: if row[0] == label: row[1] = value print("Python changes control data:", label, value) break def write_control_file(self): with open(Controlfile, "w", newline='', encoding='utf-8') as f: csv.writer(f).writerows(self.control_data0) def read_control_file(self): with open(Controlfile, 'r') as f: control_data = list(csv.reader(f)) if self.control_data0 == control_data: return for ind, row in enumerate(control_data): if self.control_data0[ind] == row: continue label, data = row[0], row[1] if label == "Energy" and float(data) >= 2: self.energy = float(data) self.update_control_data0("Energy", 1) self.write_control_file() elif label == "AhValue": self.ah_value = float(data) elif label == "IOffset": self.i_offset = float(data) elif label == "ICal": self.i_cal = float(data) elif label == "VMin": self.v_min = float(data) elif label == "Command" and data == "ResetGen1": self.charge_time = timedelta(seconds=0) self.update_control_data0("Command", 0) self.write_control_file() elif label == "Command" and data == "ResetGen2": self.charge_time_tot = timedelta(seconds=0) self.update_control_data0("Command", 0) self.write_control_file() elif label == "VStart": self.v_start = float(data) elif label == "AhStart": self.ah_start = float(data) elif label == "VStop": self.v_stop = float(data) elif label == "AhStop": self.ah_stop = float(data) elif label == "InvTrigger": self.inv_trigger = float(data) elif label == "Run" and data == "Stop": self.update_graph_file() print("Stopped by Run stop command") sys.exit() self.send_to_arduino(label, data) self.control_data0 = control_data # ------------------------------------------------------------------ # STATUS / DATA FILES # ------------------------------------------------------------------ def create_data_file(self): if os.path.isfile(DataFile): print("Data file exists") else: print("Create new Data file") with open(DataFile, "w", newline="") as f: w = csv.writer(f) w.writerow(['Date', 'Ah', 'I', 'V']) w.writerow([0, 0, 0, 0]) def update_data_file(self): with open(DataFile, "a", newline='', encoding='utf-8') as f: csv.writer(f).writerow([self.x2, self.energy, self.current, self.voltage]) def update_status_file(self): with open(StatusFile, "w", newline='', encoding='utf-8') as f: csv.writer(f).writerows([ ["Voltage/V", self.voltage], ["Current/A", self.current], ["Energy/Ah", self.energy], ["EnergyIn", self.energy_in], ["EnergyOut", self.energy_out], ["Timestamp", self.x2], ["ChargeTime", format_timedelta(self.charge_time)], ["ChargeTimeTot", format_timedelta(self.charge_time_tot)], ["State", self.state], ["Alarm", self.alarm], ["ChargeSource", self.charge_source], ]) def read_status_file(self): data = {} with open(StatusFile, 'r') as f: for row in csv.reader(f): if len(row) == 2: data[row[0]] = row[1] try: self.energy = float(data.get("Energy/Ah", 0.0)) self.energy_in = float(data.get("EnergyIn", 0.0)) self.energy_out = float(data.get("EnergyOut", 0.0)) self.charge_time = parse_timedelta(data.get("ChargeTime", "00:00:00")) self.charge_time_tot = parse_timedelta(data.get("ChargeTimeTot", "00:00:00")) except ValueError as e: print("Error parsing status file:", e) # ------------------------------------------------------------------ # GRAPH FILE # ------------------------------------------------------------------ def update_graph_file(self): with open(GraphFile, "w", newline='', encoding='utf-8') as f: w = csv.writer(f) for i in range(self.x_length): w.writerow([self.x[i], self.values[i], self.values2[i], self.values3[i]]) def read_graph_file(self): try: with open(GraphFile, "r", newline='', encoding='utf-8') as f: graph_data = list(csv.reader(f)) if len(graph_data) < self.x_length: print("Graph file too short, skipping load") return time1 = datetime.datetime.strptime(graph_data[1][0], '%Y-%m-%d %H:%M:%S') hours_old = (datetime.datetime.now() - time1).total_seconds() / 3600 if hours_old > 28: print("Graph file too old ({:.1f}h), starting fresh".format(hours_old)) return for i in range(self.x_length): self.x[i] = datetime.datetime.strptime(graph_data[i][0], '%Y-%m-%d %H:%M:%S') self.values[i] = float(graph_data[i][1]) self.values2[i] = float(graph_data[i][2]) self.values3[i] = float(graph_data[i][3]) except FileNotFoundError: print("Graph file not found, starting with empty graph") except (ValueError, IndexError) as e: print("Graph file corrupted, starting with empty graph: {}".format(e)) except Exception as e: print("Unexpected error reading graph file: {}".format(e)) # ------------------------------------------------------------------ # CHARGE CONTROL # ------------------------------------------------------------------ def start_charge(self, source="None"): self.state = "Charge" self.charge_source = source self.charge_source_timer = None self.gen_timer = time.time() self.charge_attempts -= 1 self.charge_start_time = time.time() debug_info("Charge started by: {}, attempts remaining: {}".format(source, self.charge_attempts)) self.read_control_file() self.update_control_data0("Load", 1) self.update_control_data0("Charge", 1) self.update_control_data0("GraphT", 5) self.write_control_file() def stop_charge(self, source="Unknown"): self.state = "PowerOn" self.charge_source_timer = time.time() debug_info("Charge stopped by: {}".format(source)) self.read_control_file() self.update_control_data0("Charge", 0) self.update_control_data0("GraphT", 5) self.write_control_file() # ------------------------------------------------------------------ # MONITORS # ------------------------------------------------------------------ def monitor_charge_source(self): if self.charge_source_timer is not None and self.charge_source != "None": if (time.time() - self.charge_source_timer) >= 140: debug_info("ChargeSource cooldown expired: {} -> None".format(self.charge_source)) self.charge_source = "None" self.charge_source_timer = None def monitor_voltage(self): if self.alarm != "VMin" and self.voltage < self.v_min: if self.v_min_timer is None: self.v_min_timer = time.time() elif time.time() - self.v_min_timer >= 20: self.alarm = "VMin" print("Voltage {}V below {}V for 20s → Alarm VMin".format(self.voltage, self.v_min)) self.update_control_data0("Load", 0) self.write_control_file() elif self.alarm == "VMin" and self.voltage >= self.v_min: if self.v_min_timer2 is None: self.v_min_timer2 = time.time() elif time.time() - self.v_min_timer2 >= 140: self.alarm = "None" print("Voltage {}V above {}V for 140s → Alarm cleared".format(self.voltage, self.v_min)) self.update_control_data0("Load", 1) self.write_control_file() else: self.v_min_timer = None self.v_min_timer2 = None # ------------------------------------------------------------------ # STATE MACHINE # ------------------------------------------------------------------ def state_machine(self): prev_state = self.state if self.state == "PowerOff": if self.get_control_value("Load") == 1 and self.alarm != "VMin": self.state = "PowerOn" elif self.state == "PowerOn": if self.get_control_value("Load") == 0: self.state = "PowerOff" elif self.get_control_value("Charge") == 1: self.charge_attempts = 3 self.start_charge("Manual") elif self.gen_on == 0 and self.charge_source == "None": self.charge_attempts = 1 self.start_charge("Generator") elif (self.get_control_value("AutoStart") == 1 and (self.energy < self.ah_start or self.voltage <= self.v_start)): debug_info("AutoStart: Energy={}/{}, V={}/{}".format( self.energy, self.ah_start, self.voltage, self.v_start)) self.charge_attempts = 3 self.start_charge("AutoStart") elif self.alarm == "VMin": self.state = "PowerOff" elif self.state == "Charge": if (self.get_control_value("AutoStart") == 1 and (self.energy > self.ah_stop or self.voltage > self.v_stop)): debug_info("AutoStart stop: Energy={}/{}, V={}/{}".format( self.energy, self.ah_stop, self.voltage, self.v_stop)) self.stop_charge("AutoStart") elif self.get_control_value("AutoStart") == 0 and self.get_control_value("Charge") == 0: self.stop_charge("Manual") if self.gen_on == 1: if (time.time() - self.gen_timer) > self.charger_off_filter: print("No charger input – stopping charge") self.gen_timer = time.time() self.stop_charge("Timeout") elif self.gen_on == 0: self.gen_timer = time.time() if prev_state != self.state: debug_info("→ Siirtyi tilaan {}".format(self.state)) debug_detail("GenOn: {}".format(self.gen_on)) # ------------------------------------------------------------------ # CHARGE TIME ACCUMULATOR # ------------------------------------------------------------------ def update_charge_time(self): if self.state == "Charge": elapsed = time.time() - self.charge_start_time self.charge_time += timedelta(seconds=elapsed) self.charge_time_tot += timedelta(seconds=elapsed) self.charge_start_time = time.time() # ------------------------------------------------------------------ # SHORT TIMER (5 sec graph interval) # ------------------------------------------------------------------ def update_short_timer(self): try: if (len(self.control_data0) > 0 and len(self.control_data0[0]) > 1 and float(self.control_data0[0][1]) == 5): if self.short_timer_on == 0: self.short_timer_on = 1 self.start_time = time.time() if self.short_timer_on == 1 and (time.time() - self.start_time) > self.sec5_timer: self.short_timer_on = 0 self.update_control_data0("GraphT", 120) self.write_control_file() except (IndexError, ValueError, TypeError) as e: print("Error in short timer check: {}".format(e)) try: self.init_control_data() except Exception as init_err: print("Failed to reinitialize control data: {}".format(init_err)) # ------------------------------------------------------------------ # SIGNAL HANDLERS / CLEANUP # ------------------------------------------------------------------ def cleanup_on_exit(self): logging.info("BatteryMon exiting – saving graph file") self.update_graph_file() # ------------------------------------------------------------------ # MAIN LOOP # ------------------------------------------------------------------ def run(self): self.create_data_file() self.read_status_file() self.read_graph_file() self.read_control_file() self.update_control_data0("GraphT", 5) self.update_control_data0("Charge", 0) self.write_control_file() self.v_min_timer = None self.v_min_timer2 = None while True: self.read_control_file() self.read_arduino() self.monitor_voltage() self.monitor_charge_source() self.state_machine() self.update_short_timer() self.update_charge_time() if (time.time() - self.graph_time) > self.graph_timer: self.update_graph_file() self.graph_time = time.time() # ENTRY POINT #************************************************************************************** if __name__ == '__main__': setup_logging() monitor = BatteryMonitor( serial_port=get_serial_port(), baud=9600, x_length=600 # Number of samples in Graph ) # Signal handlers – NOTE: SIGKILL is NOT catchable and has been removed def _signal_handler(signum, frame): names = { signal.SIGTERM: "SIGTERM", signal.SIGINT: "SIGINT", } if hasattr(signal, 'SIGHUP'): # Linux only names[signal.SIGHUP] = "SIGHUP" logging.error("Received signal: {}".format(names.get(signum, str(signum)))) monitor.update_graph_file() sys.exit(0) signal.signal(signal.SIGTERM, _signal_handler) signal.signal(signal.SIGINT, _signal_handler) if hasattr(signal, 'SIGHUP'): # Linux only, not available on Windows signal.signal(signal.SIGHUP, _signal_handler) monitor.init_control_data() monitor.run()