Files
synology_energy_scripts/uitlezen_laadpaal.py
Mark Kors 5476b5b15d Add API interaction for charger data retrieval and CSV export functionality
- Implemented login and logout endpoints for user authentication.
- Added functionality to fetch charger information and transaction data.
- Introduced methods to parse and save raw API data into CSV format.
- Enhanced logging for debugging and tracking API responses.
- Included environment variable loading for sensitive configuration.
2026-07-12 17:42:14 +02:00

336 lines
13 KiB
Python

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()