Skip to content

Commit 00286bd

Browse files
committed
chore: add model capability layer + expand holding registers
Add a capability layer (collector/capabilities.py) that maps each inverter model to the hardware features it actually supports, and filters the global holding-register map so a 6K user never sees 18K-only settings. The LuxPower/EG4 family shares ONE register map; the difference is which registers are meaningful per model. Capability flags (PV string count, split/three-phase, AFCI, generator, smart load, AC couple, WattNode, QV/QP curves, grid peak shaving) tag registers, and MODEL_CAPABILITIES maps each model to its flags. Also expand HOLDING_REGISTERS from ~60 to 200 registers: grid/freq protection limits, Q(V)/P(V) curves, lead-acid temp limits, system config, derate scheduling, SOC curve, generator charge, AFCI/VoltWatt, smart load, AC couple, peak shaving, WattNode, and advanced settings. Wire the filter into the API (holding/automation endpoints) and the automation engine so unsupported registers are hidden and never written. Extend holding read batches to cover registers 0-261. Dangerous registers (reset 11, time 12-14) are intentionally NOT exposed.
1 parent 367096f commit 00286bd

5 files changed

Lines changed: 490 additions & 18 deletions

File tree

‎api/__init__.py‎

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,12 +1038,15 @@ def _load_db_setting(name: str) -> Optional[str]:
10381038
find_frames,
10391039
MODBUS_READ_HOLD,
10401040
)
1041+
from collector.capabilities import filter_holding_registers
1042+
from collector.drivers.registry import DEFAULT_MODEL
10411043
from collector.automation import _engineering_to_raw, _write_holding_register, AutomationEngine, AUTOMATION_TYPES, ALL_CONDITION_KINDS, SETTING_NAME_TO_REGISTER
10421044

10431045

10441046
@app.get("/api/automation/registers")
10451047
def api_automation_registers():
10461048
"""Return the list of writable holding registers (used by the schedule editor)."""
1049+
regs = _holding_registers_for_model()
10471050
return {
10481051
"registers": [
10491052
{
@@ -1056,7 +1059,7 @@ def api_automation_registers():
10561059
"max": info.get("max"),
10571060
"desc": info["desc"],
10581061
}
1059-
for reg, info in sorted(HOLDING_REGISTERS.items())
1062+
for reg, info in sorted(regs.items())
10601063
]
10611064
}
10621065

@@ -1098,22 +1101,24 @@ def api_automation_conditions():
10981101
@app.get("/api/automation/settings")
10991102
def api_automation_settings():
11001103
"""Return the 50 SolarAssistant-style setting names with lux-mon register mapping."""
1101-
from collector.protocol import HOLDING_REGISTERS, HOLDING_BY_NAME
1104+
from collector.protocol import HOLDING_BY_NAME
1105+
regs = _holding_registers_for_model()
11021106
settings = []
11031107
for sa_name, reg_name in SETTING_NAME_TO_REGISTER.items():
11041108
mapped = None
11051109
if reg_name and reg_name in HOLDING_BY_NAME:
11061110
reg_addr = HOLDING_BY_NAME[reg_name]
1107-
info = HOLDING_REGISTERS[reg_addr]
1108-
mapped = {
1109-
"name": reg_name,
1110-
"address": reg_addr,
1111-
"unit": info.get("unit"),
1112-
"scale": info.get("scale"),
1113-
"min": info.get("min"),
1114-
"max": info.get("max"),
1115-
"desc": info.get("desc"),
1116-
}
1111+
if reg_addr in regs:
1112+
info = regs[reg_addr]
1113+
mapped = {
1114+
"name": reg_name,
1115+
"address": reg_addr,
1116+
"unit": info.get("unit"),
1117+
"scale": info.get("scale"),
1118+
"min": info.get("min"),
1119+
"max": info.get("max"),
1120+
"desc": info.get("desc"),
1121+
}
11171122
settings.append({
11181123
"name": sa_name,
11191124
"label": sa_name.replace("_", " ").title(),
@@ -1300,6 +1305,16 @@ def _resolve_dongle() -> dict:
13001305
}
13011306

13021307

1308+
def _resolve_model() -> str:
1309+
"""Resolve the active inverter model from env or DB settings."""
1310+
return os.getenv("LUX_INVERTER_MODEL") or _load_db_setting("inverter_model") or DEFAULT_MODEL
1311+
1312+
1313+
def _holding_registers_for_model() -> Dict[int, dict]:
1314+
"""Return the holding-register map filtered to the active model's capabilities."""
1315+
return filter_holding_registers(HOLDING_REGISTERS, _resolve_model())
1316+
1317+
13031318
class QuickChargeBody(BaseModel):
13041319
minutes: Optional[int] = None
13051320
dry_run: bool = False
@@ -1360,9 +1375,9 @@ def _read_holding_block(
13601375

13611376

13621377
def _read_all_holding_registers(dongle: dict) -> Dict[int, int]:
1363-
"""Read holding registers 0-169 from the inverter in batches."""
1378+
"""Read holding registers 0-261 from the inverter in batches."""
13641379
raw: Dict[int, int] = {}
1365-
batches = [(0, 40), (40, 40), (80, 40), (120, 40), (160, 40)]
1380+
batches = [(0, 40), (40, 40), (80, 40), (120, 40), (160, 40), (200, 40), (240, 22)]
13661381
for start, count in batches:
13671382
ok, block, msg = _read_holding_block(
13681383
dongle["dongle_host"],
@@ -1391,7 +1406,8 @@ def api_holding_list():
13911406
raise HTTPException(400, "datalog_serial / inverter_serial not configured")
13921407
raw = _read_all_holding_registers(dongle)
13931408
result = {}
1394-
for reg, info in sorted(HOLDING_REGISTERS.items()):
1409+
regs = _holding_registers_for_model()
1410+
for reg, info in sorted(regs.items()):
13951411
if reg in raw:
13961412
result[info["name"]] = {
13971413
"address": reg,
@@ -1413,7 +1429,8 @@ def api_holding_controllable():
14131429
entities without hardcoding a static list.
14141430
"""
14151431
result = {}
1416-
for reg, info in sorted(HOLDING_REGISTERS.items()):
1432+
regs = _holding_registers_for_model()
1433+
for reg, info in sorted(regs.items()):
14171434
name = info["name"]
14181435
unit = info.get("unit", "")
14191436
scale = info.get("scale", 1.0)
@@ -1449,10 +1466,12 @@ def api_holding_get(name: str):
14491466
"""Read a single named holding register from the inverter."""
14501467
if name not in HOLDING_BY_NAME:
14511468
raise HTTPException(404, f"Unknown holding register: {name}")
1469+
reg = HOLDING_BY_NAME[name]
1470+
if reg not in _holding_registers_for_model():
1471+
raise HTTPException(404, f"Register {name} not supported by this inverter model")
14521472
dongle = _resolve_dongle()
14531473
if not dongle["datalog_serial"] or not dongle["inverter_serial"]:
14541474
raise HTTPException(400, "datalog_serial / inverter_serial not configured")
1455-
reg = HOLDING_BY_NAME[name]
14561475
info = HOLDING_REGISTERS[reg]
14571476
ok, raw_map, msg = _read_holding_block(
14581477
dongle["dongle_host"],
@@ -1487,6 +1506,8 @@ def api_holding_put(name: str, body: HoldingUpdate):
14871506
if name not in HOLDING_BY_NAME:
14881507
raise HTTPException(404, f"Unknown holding register: {name}")
14891508
reg = HOLDING_BY_NAME[name]
1509+
if reg not in _holding_registers_for_model():
1510+
raise HTTPException(404, f"Register {name} not supported by this inverter model")
14901511
info = HOLDING_REGISTERS[reg]
14911512
min_val = info.get("min")
14921513
max_val = info.get("max")

‎collector/automation.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@
6161
build_write_request,
6262
find_frames,
6363
)
64+
from .capabilities import filter_holding_registers
65+
from .drivers.registry import DEFAULT_MODEL
6466

6567
logger = logging.getLogger("luxmon.automation")
6668

@@ -669,6 +671,14 @@ def _set_setting(self, name: str, value: str) -> None:
669671
except Exception:
670672
logger.exception("Failed to write setting %s", name)
671673

674+
def _resolve_model(self) -> str:
675+
"""Resolve the active inverter model from the settings table."""
676+
return self._get_setting("inverter_model", DEFAULT_MODEL)
677+
678+
def _register_supported(self, reg_addr: int) -> bool:
679+
"""Return True if the register is supported by the active model."""
680+
return reg_addr in filter_holding_registers(HOLDING_REGISTERS, self._resolve_model())
681+
672682
def load(self) -> List[Automation]:
673683
raw = self._get_setting(SETTING_AUTOMATIONS, "[]")
674684
try:
@@ -917,6 +927,11 @@ def _do_write(
917927
f"Register '{reg_name}' not found in HOLDING_BY_NAME")
918928
return
919929

930+
if not self._register_supported(reg_addr):
931+
self._log(automation_id, automation_name, setting_name, None, value, dry_run, False,
932+
f"Register '{reg_name}' not supported by this inverter model")
933+
return
934+
920935
meta = HOLDING_REGISTERS[reg_addr]
921936
clamped = _clamp_to_register(value, meta)
922937
raw = _engineering_to_raw(clamped, meta)

‎collector/capabilities.py‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Model capability layer for lux-mon.
2+
3+
The LuxPower/EG4 family shares ONE register map — there is no per-model
4+
register table. An 18KPV and a 6000XP both *have* register 22 (PV start
5+
voltage), register 60 (active power %), etc. The difference is which
6+
registers are *meaningful/functional* on a given model.
7+
8+
This module defines:
9+
10+
* CAPABILITIES — the set of feature flags a model may or may not support.
11+
* MODEL_CAPABILITIES — maps each `inverter_model` setting value to the
12+
capabilities its hardware actually has.
13+
* filter_holding_registers() — filters the global holding-register map
14+
down to the registers that apply to a given model.
15+
16+
The model is identified at runtime by reading hold registers 7-8 (4 ASCII
17+
chars = firmware/model code) and register 16 (Device Type / DTC). The
18+
`inverter_model` setting is the user-facing alias for that detection.
19+
20+
Capability flags are coarse feature groups, not per-register toggles. A
21+
register is tagged with the capability it requires; if the model lacks that
22+
capability, the register is hidden from the UI/API and never written.
23+
"""
24+
from __future__ import annotations
25+
26+
from typing import Dict, FrozenSet, Set
27+
28+
# ── Capability flags ────────────────────────────────────────────────
29+
#
30+
# Each flag names a hardware feature that some LuxPower/EG4 models have and
31+
# others do not. Registers are tagged with the flag(s) they require.
32+
33+
# PV string count. The SNA family (6000XP, 6500EX, 3000EHV, 12000XP) has 2
34+
# MPPT inputs (PV1/PV2). The 12kPV has 3 (PV1-3). The 18KPV has up to 4-6.
35+
PV1 = "pv1"
36+
PV2 = "pv2"
37+
PV3 = "pv3"
38+
PV4 = "pv4"
39+
PV5 = "pv5"
40+
PV6 = "pv6"
41+
42+
# Split-phase (120/240V) vs single-phase (120V) vs three-phase (208/400V).
43+
SINGLE_PHASE = "single_phase"
44+
SPLIT_PHASE = "split_phase"
45+
THREE_PHASE = "three_phase"
46+
47+
# Feature groups.
48+
AFCI = "afci" # arc-fault circuit interrupter (18KPV)
49+
GENERATOR = "generator" # generator input / generator charge
50+
SMART_LOAD = "smart_load" # smart-load output port
51+
AC_COUPLE = "ac_couple" # AC-coupled PV input
52+
WATTNODE = "wattnode" # WattNode external CT meter
53+
GRID_PEAK_SHAVING = "grid_peak_shaving"
54+
VOLT_WATT = "volt_watt" # Volt-Watt curve
55+
QV_CURVE = "qv_curve" # Q(V) reactive power curve
56+
QP_CURVE = "qp_curve" # cosphi(P) curve
57+
SEVEN_DAY_SCHEDULE = "seven_day_schedule" # 7-day scheduling (regs 500-723)
58+
BATTERY_BMS = "battery_bms" # per-battery BMS registers (5000+)
59+
60+
# ── Model → capabilities ───────────────────────────────────────────
61+
62+
# SNA family: 2-MPPT, single-phase, no AFCI/generator/smart-load/AC-couple.
63+
# This is the capture-validated reference family (6000XP and rebadges).
64+
_SNA_CAPS: FrozenSet[str] = frozenset({
65+
PV1, PV2,
66+
SINGLE_PHASE,
67+
BATTERY_BMS,
68+
})
69+
70+
# 18KPV family: 3+ MPPT, split-phase, AFCI, generator, smart load, AC couple.
71+
_18KPV_CAPS: FrozenSet[str] = frozenset({
72+
PV1, PV2, PV3, PV4, PV5, PV6,
73+
SPLIT_PHASE,
74+
AFCI,
75+
GENERATOR,
76+
SMART_LOAD,
77+
AC_COUPLE,
78+
WATTNODE,
79+
GRID_PEAK_SHAVING,
80+
VOLT_WATT,
81+
QV_CURVE,
82+
QP_CURVE,
83+
SEVEN_DAY_SCHEDULE,
84+
BATTERY_BMS,
85+
})
86+
87+
# 12kPV: 3 MPPT, split-phase, but no AFCI (it's a smaller 18KPV sibling).
88+
_12KPV_CAPS: FrozenSet[str] = frozenset({
89+
PV1, PV2, PV3,
90+
SPLIT_PHASE,
91+
GENERATOR,
92+
SMART_LOAD,
93+
AC_COUPLE,
94+
GRID_PEAK_SHAVING,
95+
VOLT_WATT,
96+
QV_CURVE,
97+
QP_CURVE,
98+
SEVEN_DAY_SCHEDULE,
99+
BATTERY_BMS,
100+
})
101+
102+
MODEL_CAPABILITIES: Dict[str, FrozenSet[str]] = {
103+
# ── SNA family (2-MPPT single-phase) ──
104+
"eg4_6000xp": _SNA_CAPS,
105+
"luxpower_sna": _SNA_CAPS,
106+
"eg4_12000xp": _SNA_CAPS,
107+
"eg4_6500ex": _SNA_CAPS,
108+
"eg4_3000ehv": _SNA_CAPS,
109+
"lxp_6k": _SNA_CAPS,
110+
"bigbattery_sna_6k": _SNA_CAPS,
111+
112+
# ── 18KPV family (3+ MPPT split-phase, AFCI) ──
113+
"eg4_18kpv": _18KPV_CAPS,
114+
"lxp_18k": _18KPV_CAPS,
115+
"fortress_envy_12k": _18KPV_CAPS,
116+
117+
# ── 12kPV (3 MPPT split-phase, no AFCI) ──
118+
"eg4_12kpv": _12KPV_CAPS,
119+
"lxp_12k": _12KPV_CAPS,
120+
}
121+
122+
# Default capabilities when the model is unknown (assume the most capable
123+
# family so nothing is hidden that might actually apply).
124+
DEFAULT_CAPABILITIES: FrozenSet[str] = _18KPV_CAPS
125+
126+
127+
def capabilities_for(model: str) -> FrozenSet[str]:
128+
"""Return the capability set for a model, defaulting to the full set."""
129+
return MODEL_CAPABILITIES.get(model, DEFAULT_CAPABILITIES)
130+
131+
132+
def register_applies(
133+
capabilities: FrozenSet[str],
134+
required: Set[str] | FrozenSet[str] | None,
135+
) -> bool:
136+
"""Return True if a register's required capabilities are all present.
137+
138+
A register with no `required` capabilities applies to every model.
139+
"""
140+
if not required:
141+
return True
142+
return required.issubset(capabilities)
143+
144+
145+
def filter_holding_registers(
146+
holding_registers: Dict[int, dict],
147+
model: str,
148+
) -> Dict[int, dict]:
149+
"""Filter a holding-register map down to the registers a model supports.
150+
151+
Registers without a `capabilities` key are always included (they are
152+
shared across the whole family). Registers with a `capabilities` key are
153+
included only if the model has every required capability.
154+
"""
155+
caps = capabilities_for(model)
156+
return {
157+
reg: info
158+
for reg, info in holding_registers.items()
159+
if register_applies(caps, info.get("capabilities"))
160+
}

0 commit comments

Comments
 (0)