Files
Vlad DolomanandClaude Sonnet 5.5 62f027d6a9 Measure cache freshness from request arrival time
Record when the server received a request and use that, not the time the
inverter answered, for the cache window, so slow inverter reads no longer
shorten or stretch it for other clients.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 13:24:43 +02:00

297 lines
12 KiB
Python

import threading
import time
from flask import Flask, jsonify, render_template, request
from pysolarmanv5 import PySolarmanV5
from writable_registers import WRITABLE, validate_write
try:
import config
except ImportError:
raise SystemExit(
"config.py not found. Copy config.py.example to config.py and fill in your inverter details."
)
app = Flask(__name__)
SOCKET_TIMEOUT = getattr(config, "INVERTER_SOCKET_TIMEOUT", 5)
# The dashboard polls every CLIENT_POLL_INTERVAL seconds. Cache a reading for slightly
# less than that, so one client's poll refreshes the data and any other clients polling
# within the window get the cached reply (the logger only tolerates one session at a time).
CLIENT_POLL_INTERVAL = 5.0
CACHE_TTL = CLIENT_POLL_INTERVAL - 0.2
_lock = threading.Lock()
_cache = {"time": 0.0, "data": None}
# BMS packs: 14 registers each starting at 600 (pack 1 = 600-613 ... pack 15 = 796-809)
BMS_BASE = 600
BMS_PACK_SIZE = 14
BMS_MAX_PACKS = 15
BMS_RESCAN_INTERVAL = 300.0 # seconds between full scans for connected packs
MAX_READ_CHUNK = 100 # registers per Modbus read
# Pack numbers (1-based) that reported data at the last scan; None = not scanned yet.
_bms_state = {"packs": None, "scanned": 0.0}
def to_signed(val):
return val if val < 32768 else val - 65536
def swap_bytes(raw):
return ((raw & 0xFF) << 8) | (raw >> 8)
def read_registers(inverter, start, count):
"""Read `count` holding registers in chunks the logger handles reliably."""
values = []
while len(values) < count:
chunk = min(MAX_READ_CHUNK, count - len(values))
values.extend(inverter.read_holding_registers(register_addr=start + len(values), quantity=chunk))
return values
def pack_registers(bms, pack):
i = (pack - 1) * BMS_PACK_SIZE
return bms[i:i + BMS_PACK_SIZE]
def detect_active_packs(bms):
"""Packs whose module voltage or SOC register is non-zero.
Empty packs read as zeros apart from a constant placeholder in the temperature
register, so only voltage (offset 0) and SOC (offset 3) are checked. Zero is the
same with or without byte swapping.
"""
return [n for n in range(1, len(bms) // BMS_PACK_SIZE + 1)
if pack_registers(bms, n)[0] != 0 or pack_registers(bms, n)[3] != 0]
def parse_bms_pack(pack, regs):
"""Decode one pack. SOC is in 0.1% (<= 1000). Some packs (seen on pack 2) are
reported byte-swapped by the firmware, so if the raw SOC is out of range but
becomes valid after swapping, swap every register of the pack back."""
soc_raw = regs[3]
if soc_raw > 1000 and swap_bytes(soc_raw) <= 1000:
regs = [swap_bytes(r) for r in regs]
return {
"pack": pack,
"voltage": round(regs[0] * 0.01, 2), # 0.01V
"current": round(to_signed(regs[1]) * 0.1, 1), # 0.1A, negative = discharge
"temp": round((regs[2] - 1000) * 0.1, 1), # offset +1000, 0.1C
"soc": round(regs[3] * 0.1, 1), # 0.1%
"charge_voltage": round(regs[6] * 0.01, 2), # 0.01V
"charge_current_limit": round(regs[7] * 0.1, 1), # 0.1A, BMS-requested limit
}
def connect():
return PySolarmanV5(
config.INVERTER_IP,
config.INVERTER_SERIAL,
port=config.INVERTER_PORT,
mb_slave_id=config.INVERTER_SLAVE_ID,
socket_timeout=SOCKET_TIMEOUT,
verbose=False
)
def get_inverter_data():
"""
Connects to the inverter and reads voltage registers.
Returns a dictionary with the data.
"""
inverter = None
try:
inverter = connect()
# Reading blocks:
# Block 1: 150 - 216 (67 registers) - Voltages, Currents, Battery, PV, Control
# Block 2: 600 - 809 (up to 15 packs x 14 registers) - BMS Data
# Block 3: 245 (1 register) - Max Grid Output
# Block 4: 292 - 293 (2 registers) - Peak Shaving
# Block 5: 312 - 325 (14 registers) - Lithium battery / BMS limits
block1 = inverter.read_holding_registers(register_addr=150, quantity=67)
# BMS: scan all possible packs on first use and every BMS_RESCAN_INTERVAL;
# otherwise only read up to the last pack that was found.
now = time.monotonic()
scan = (_bms_state["packs"] is None
or now - _bms_state["scanned"] >= BMS_RESCAN_INTERVAL)
if scan:
bms_count = BMS_MAX_PACKS
else:
bms_count = max(_bms_state["packs"], default=0)
bms = read_registers(inverter, BMS_BASE, bms_count * BMS_PACK_SIZE) if bms_count else []
block3 = inverter.read_holding_registers(register_addr=245, quantity=1)
block4 = inverter.read_holding_registers(register_addr=292, quantity=2)
block6 = inverter.read_holding_registers(register_addr=230, quantity=1)
block5 = inverter.read_holding_registers(register_addr=312, quantity=14)
data = {}
# Block 1 Parsing (150-216)
data["input_voltage"] = round(block1[0] * 0.1, 1) # 150
data["output_voltage"] = round(block1[4] * 0.1, 1) # 154
data["load_voltage"] = round(block1[7] * 0.1, 1) # 157
data["grid_current"] = round(to_signed(block1[10]) * 0.01, 2) # 160
data["grid_clamp_current"] = round(to_signed(block1[12]) * 0.01, 2) # 162
data["output_current"] = round(to_signed(block1[14]) * 0.01, 2) # 164
data["load_current"] = round(to_signed(block1[29]) * 0.01, 2) # 179
data["batt_temp"] = round((block1[32] - 1000) * 0.1, 1) # 182 (offset +1000, 0.1C)
data["batt_voltage"] = round(block1[33] * 0.01, 2) # 183
data["batt_soc"] = block1[34] # 184
data["batt_charge_status"] = block1[35] # 185
data["pv1_power"] = block1[36] # 186
data["pv2_power"] = block1[37] # 187
data["batt_power"] = to_signed(block1[40]) # 190
data["batt_current"] = round(to_signed(block1[41]) * 0.01, 2) # 191
data["grid_relay_status"] = block1[44] # 194
data["gen_relay_status"] = block1[45] # 195
data["max_charge_current"] = block1[60] # 210
data["max_discharge_current"] = block1[61] # 211
data["batt_charge_efficiency"] = round(block1[66] * 0.1, 1) # 216 (0.1%)
# Block 2 Parsing (BMS packs)
if scan:
_bms_state["packs"] = detect_active_packs(bms)
_bms_state["scanned"] = now
data["bms_packs"] = [parse_bms_pack(n, pack_registers(bms, n))
for n in _bms_state["packs"] if n * BMS_PACK_SIZE <= len(bms)]
# Block 3 Parsing (245)
data["max_grid_output_power"] = block3[0] # 245
# Register 230 (writable)
data["grid_charge_current"] = block6[0] # 230 (1A)
# Block 4 Parsing (292-293)
data["gen_peak_shaving_power"] = block4[0] # 292
data["grid_peak_shaving_power"] = block4[1] # 293
# Block 5 Parsing (312-325, index = register - 312)
data["charge_voltage"] = round(block5[0] * 0.01, 2) # 312
data["discharge_voltage"] = round(block5[1] * 0.01, 2) # 313
data["charge_current_limit"] = block5[2] # 314
data["discharge_current_limit"] = block5[3] # 315
data["real_time_capacity"] = block5[4] # 316
data["real_time_voltage"] = round(block5[5] * 0.01, 2) # 317
data["real_time_current"] = to_signed(block5[6]) # 318
data["real_time_temp"] = round((block5[7] - 1000) * 0.1, 1) # 319 (offset +1000)
data["max_charge_current_limit"] = block5[8] # 320
data["max_discharge_current_limit"] = block5[9] # 321
# 322-324 skipped (alarm / fault / flags)
data["lithium_battery_type"] = block5[13] # 325
data["status"] = "success"
return data
except Exception as e:
return {
"status": "error",
"message": str(e)
}
finally:
if inverter is not None:
try:
inverter.disconnect()
except Exception:
pass
def get_cached_data():
"""Serialize inverter access and reuse a recent successful reading.
Freshness is measured from when the server received the request, not from when
the inverter answered, so slow inverter reads don't stretch the cache window.
"""
arrived = time.monotonic()
with _lock:
if _cache["data"] is not None and arrived - _cache["time"] < CACHE_TTL:
return _cache["data"]
data = get_inverter_data()
if data.get("status") == "success":
_cache["data"] = data
_cache["time"] = arrived
return data
def write_inverter_registers(writes):
"""Write already-validated {register: value} pairs, reading each back to verify.
Returns (applied, error): `applied` maps register -> value confirmed by read-back,
`error` is a message for the first failure (None on success).
"""
applied = {}
inverter = None
try:
inverter = connect()
for reg, value in writes.items():
inverter.write_multiple_holding_registers(reg, [value])
readback = inverter.read_holding_registers(register_addr=reg, quantity=1)
if not readback or readback[0] != value:
got = readback[0] if readback else None
return applied, f"Register {reg}: wrote {value} but read back {got}"
applied[reg] = readback[0]
return applied, None
except Exception as e:
return applied, f"{type(e).__name__}: {e}"
finally:
if inverter is not None:
try:
inverter.disconnect()
except Exception:
pass
@app.route('/')
def index():
return render_template('index.html', writable=WRITABLE, poll_interval_ms=int(CLIENT_POLL_INTERVAL * 1000))
@app.route('/api/data')
def api_data():
data = get_cached_data()
status = 200 if data.get("status") == "success" else 502
return jsonify(data), status
@app.route('/api/registers', methods=['POST'])
def api_registers():
"""Write whitelisted registers. Body: {"writes": {"230": 43, "293": 4500}}.
All values are validated before anything is written. Requiring a JSON body means
a cross-site form post cannot trigger this (a cross-origin fetch needs a CORS
preflight, which this app does not grant).
"""
body = request.get_json(silent=True)
raw = body.get("writes") if isinstance(body, dict) else None
if not isinstance(raw, dict) or not raw:
return jsonify({"status": "error", "message": 'Body must be {"writes": {"<register>": <int>}}'}), 400
writes = {}
for key, value in raw.items():
if not (isinstance(key, str) and key.isascii() and key.isdecimal()):
return jsonify({"status": "error", "message": f"Invalid register {key!r}"}), 400
reg = int(key)
error = validate_write(reg, value)
if error:
return jsonify({"status": "error", "message": error}), 400
writes[reg] = value
with _lock:
applied, error = write_inverter_registers(writes)
_cache["data"] = None # force a fresh read after any write attempt
if error:
return jsonify({"status": "error", "message": error, "applied": applied}), 502
return jsonify({"status": "success", "applied": applied})
if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0')