import requests import urllib3 from datetime import datetime import re import os from dotenv import load_dotenv load_dotenv() urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # ── Configuratie ────────────────────────────────────────────── HOST = os.getenv("HOST", "https://192.168.178.184") USERNAME = os.getenv("CHARGER_USERNAME", "admin") PASSWORD = os.getenv("CHARGER_PASSWORD") # ───────────────────────────────────────────────────────────── session = requests.Session() session.verify = False # ── Communicatie ────────────────────────────────────────────── def login(): session.post(f"{HOST}/api/login", json={ "username": USERNAME, "password": PASSWORD }).raise_for_status() print("Ingelogd") def logout(): session.post(f"{HOST}/api/logout", json={ "username": USERNAME, "password": PASSWORD }) print("Uitgelogd") def get_info(): r = session.get(f"{HOST}/api/info") r.raise_for_status() return r.json() def fetch_page(offset): r = session.get(f"{HOST}/api/transactions", params={"offset": offset}) r.raise_for_status() return r.text def get_all_raw(): """Haalt alle transactiepagina's op via paginering. Stopt zodra de record-nummers terugvallen (circulaire buffer bereikt). Een niet-afgesloten transactie kan een "gat" in de recordnummering achterlaten (een gereserveerde maar nooit weggeschreven slot). Een pagina die op zo'n gat begint komt leeg terug, ook al staat er verderop gewoon weer geldige data. Daarom wordt bij een lege pagina niet meteen gestopt, maar met oplopende stappen verderop geprobeerd of de data doorloopt, voordat we concluderen dat het echt het einde is. """ debug = os.getenv("DEBUG") == "1" MAX_GAP = 5000 # verst dat we een leeg/kapot record proberen te overspringen all_raw = "" offset = 0 while True: raw = fetch_page(offset) stripped = raw.strip().rstrip('}').strip() if debug: print(f"\n[DEBUG] offset={offset} len(raw)={len(raw)} stripped_preview={stripped[:80]!r}") if not stripped or stripped in ('{"version":2,', '{"version":2'): if debug: print(f"[DEBUG] stop: lege/afgesloten pagina bij offset={offset}") break offsets = re.findall(r'^(\d+)_\w+:', raw, re.MULTILINE) if not offsets: # Mogelijk een gat door een niet-afgesloten transactie: probeer # verderop met oplopende stappen of de data daar doorloopt. gap = 1 while gap <= MAX_GAP: probe_offset = offset + gap probe_raw = fetch_page(probe_offset) probe_offsets = re.findall(r'^(\d+)_\w+:', probe_raw, re.MULTILINE) if debug: print(f"[DEBUG] gat-probe offset={probe_offset} -> {'data gevonden' if probe_offsets else 'nog leeg'}") if probe_offsets: offset, raw, offsets = probe_offset, probe_raw, probe_offsets break gap *= 4 else: if debug: print(f"[DEBUG] stop: geen data gevonden binnen {MAX_GAP} na offset={offset}") break all_raw += raw next_offset = max(int(x) for x in offsets) + 1 if debug: print(f"[DEBUG] gevonden offsets {min(int(x) for x in offsets)}..{max(int(x) for x in offsets)}, next_offset={next_offset}") if next_offset <= offset: if debug: print(f"[DEBUG] stop: next_offset({next_offset}) <= offset({offset}) — geen voortgang") break offset = next_offset return all_raw # ── Parser ──────────────────────────────────────────────────── def parse_transactions(raw): transactions = [] current_tx = None last_offset = None seen_starts = set() # Verwijder pagina-headers eerst, zodat }{"version":2,305085_ correct wordt gesplitst raw = re.sub(r'\{"version":\d+,\s*', '', raw) # Records worden soms op één regel samengevoegd: "...N}305085_txstop2:..." # Splits op } gevolgd door optionele spaties en een recordnummer raw = re.sub(r'\}\s*(\d+_)', r'\n\1', raw) for line in raw.splitlines(): line = line.strip().rstrip('}').strip() if not line: continue match = re.match(r'^(\d+)_(\w+):\s*(.+)$', line) if not match: continue offset_str, record_type, data = match.groups() offset = int(offset_str) # Cirkelbuffer: recordnummers lopen monotoon op. Val terug = oude/herhaalde # data (buffer-wraparound) → stop met parsen. Timestamps zijn hiervoor niet # betrouwbaar: na een onverwachte reset (bv. door een niet-afgesloten # transactie) kan de klok van de paal tijdelijk terugvallen, waardoor latere, # geldige transacties anders ten onrechte als "oude data" werden gezien. if last_offset is not None and offset < last_offset: break last_offset = offset if record_type == 'txstart2': m = re.match( r'id (0x[0-9a-fA-F]+), socket (\d+), ' r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) ' r'([\d.]+)kWh (\S+)', data ) if m: start_time = datetime.strptime(m.group(3), '%Y-%m-%d %H:%M:%S') # Inhoudelijke dedup: de paal kan bij het overspringen van een gat # (zie get_all_raw) oude data teruggeven onder een nieuw, oplopend # offset-label. Komt dezelfde sessie-signatuur al voor, dan is de # cirkelbuffer echt rond → stop met parsen. start_key = (m.group(1), int(m.group(2)), start_time, float(m.group(4))) if start_key in seen_starts: break seen_starts.add(start_key) current_tx = { 'rfid': m.group(1), 'socket': int(m.group(2)), 'start_time': start_time, 'start_kwh': float(m.group(4)), 'tag': m.group(5), 'end_time': None, 'end_kwh': None, 'charged_kwh': None, 'status': 'lopend', 'measurements': [] } transactions.append(current_tx) elif record_type == 'txstop2' and current_tx: # Correct record type — was eerder fout als 'txend' m = re.match( r'id (0x[0-9a-fA-F]+), socket (\d+), ' r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) ' r'([\d.]+)kWh', data ) if m: current_tx['end_time'] = datetime.strptime(m.group(3), '%Y-%m-%d %H:%M:%S') current_tx['end_kwh'] = float(m.group(4)) current_tx['status'] = 'afgesloten' current_tx = None # Sessie klaar elif record_type == 'mv' and current_tx: # Sla korte mv-regels over (geen volledige meetwaarden) m = re.match( r'socket \d+, (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) ' r'([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+)', data ) if m: meting = { 'time': datetime.strptime(m.group(1), '%Y-%m-%d %H:%M:%S'), 'current_a': float(m.group(2)), 'energy_wh': float(m.group(3)), 'freq_hz': float(m.group(4)), 'power_w': float(m.group(5)), 'power_factor': float(m.group(6)), 'temp_c': float(m.group(7)), } current_tx['measurements'].append(meting) # Alleen bijwerken als sessie nog niet afgesloten via txstop2 # en de meettijd na de starttijd ligt (circulaire buffer kan oude data bevatten) if current_tx['status'] == 'lopend' and meting['time'] >= current_tx['start_time']: current_tx['end_time'] = meting['time'] current_tx['end_kwh'] = meting['energy_wh'] / 1000 # Geladen kWh berekenen for tx in transactions: if tx['start_kwh'] is not None and tx['end_kwh'] is not None: tx['charged_kwh'] = round(tx['end_kwh'] - tx['start_kwh'], 3) return transactions # ── Rapportage ──────────────────────────────────────────────── def print_summary(transactions): print(f"\n{'='*68}") print(f" Totaal sessies gevonden: {len(transactions)}") print(f"{'='*68}") for i, tx in enumerate(transactions, 1): duur = "?" if tx['start_time'] and tx['end_time']: delta = tx['end_time'] - tx['start_time'] uur, rest = divmod(int(delta.total_seconds()), 3600) min_ = rest // 60 duur = f"{uur}u {min_:02d}m" kwh_str = f"{tx['charged_kwh']} kWh" if tx['charged_kwh'] is not None else "?" eind_str = tx['end_time'].strftime('%Y-%m-%d %H:%M') if tx['end_time'] else '?' print( f" #{i:<3} {tx['start_time'].strftime('%Y-%m-%d %H:%M')} " f"→ {eind_str} " f"| {kwh_str:>10} " f"| {duur:>7} " f"| {tx['status']}" ) print(f"{'='*68}") totaal = sum(tx['charged_kwh'] for tx in transactions if tx['charged_kwh']) print(f" Totaal geladen: {round(totaal, 3)} kWh") print(f"{'='*68}\n") def save_as_csv(raw, device_id, filename=None): """Schrijft de ruwe API-data naar een CSV in ACE Service Installer formaat.""" from datetime import datetime if filename is None: filename = f"{device_id}_Transactions.csv" now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") lines = [ f"# Device, {device_id}", f"# Generated, {now}", ] # Zelfde normalisatie als parse_transactions raw = re.sub(r'\{"version":\d+,\s*', '', raw) raw = re.sub(r'\}\s*(\d+_)', r'\n\1', raw) last_offset = None seen_starts = set() for line in raw.splitlines(): line = line.rstrip('}').strip() if not line: continue # Cirkelbuffer: recordnummers lopen monotoon op; val terug = oude/herhaalde data offset_match = re.match(r'^(\d+)_', line) if offset_match: offset = int(offset_match.group(1)) if last_offset is not None and offset < last_offset: break last_offset = offset # Verwijder offset-nummers: "76_mv:" → "mv:", "0_txstart2:" → "txstart2:" line = re.sub(r'^\d+_', '', line) # Inhoudelijke dedup: de paal kan bij een gat in de nummering oude data # teruggeven onder een nieuw, oplopend offset-label (zie get_all_raw). # Komt dezelfde txstart2-payload al voor, dan is de cirkelbuffer echt # rond → stop. if line.startswith('txstart2:'): payload = line[len('txstart2:'):].strip() if payload in seen_starts: break seen_starts.add(payload) lines.append(line) with open(filename, 'w', encoding='utf-8') as f: f.write('\n'.join(lines) + '\n') print(f"Opgeslagen als: {filename}") return filename # ── Main ────────────────────────────────────────────────────── if __name__ == "__main__": info = get_info() device_id = info.get('Identity', 'UNKNOWN') print(f"Paal: {info.get('Model')} | FW: {info.get('FWVersion')} | ID: {info.get('ObjectId')}") login() try: print("\nTransacties ophalen...", end=' ', flush=True) raw = get_all_raw() print("Verwerken...", end=' ', flush=True) transactions = parse_transactions(raw) print("Klaar.\n") print_summary(transactions) print("Opslaan als CSV...", end=' ', flush=True) start_dates = [tx['start_time'] for tx in transactions if tx['start_time']] end_dates = [tx['end_time'] for tx in transactions if tx['end_time']] if start_dates and end_dates: date_from = min(start_dates).strftime('%Y%m%d') date_to = max(end_dates).strftime('%Y%m%d') filename = f"{device_id}_Transactions_{date_from}_{date_to}.csv" else: filename = f"{device_id}_Transactions.csv" save_as_csv(raw, device_id, filename) finally: logout()