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>
297 lines
12 KiB
Python
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')
|