Skip to content

Commit a636471

Browse files
committed
fix: decode fault/warning codes to human-readable text
The dashboard showed raw integer fault/warning codes (e.g. "19") with no meaning. Add the LuxPower/EG4 fault and warning code tables and decode them to human-readable descriptions. - collector/fault_codes.py: FAULT_CODES (25) + WARNING_CODES (30) tables from the official LuxPower protocol PDF, with bit-packed 32-bit decode helpers (a single register can carry multiple simultaneous faults). - api/__init__.py: _fault_label/_warning_label helpers; /api/status and /api/summary now inject fault_code_text, warning_code_text, internal_fault_text, bms_fault_code_text, bms_warning_code_text alongside the raw codes (same pattern as state_label). - api/static/index.html: show the decoded text in the inverter detail view; string values render as-is (no numeric formatting). - tests/test_fault_codes.py: 12 tests covering single-register, bit-packed, zero, unknown, and API-layer decode.
1 parent 778c9a8 commit a636471

4 files changed

Lines changed: 267 additions & 5 deletions

File tree

‎api/__init__.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,34 @@ def _state_label(value) -> str:
120120
return f"{label} ({code})"
121121

122122

123+
def _fault_label(value) -> str:
124+
"""Return a human-readable label for a raw fault code value."""
125+
try:
126+
from collector.fault_codes import fault_code_text
127+
except Exception:
128+
return ""
129+
try:
130+
code = int(value)
131+
except (TypeError, ValueError):
132+
return ""
133+
text = fault_code_text(code)
134+
return text if text else ""
135+
136+
137+
def _warning_label(value) -> str:
138+
"""Return a human-readable label for a raw warning code value."""
139+
try:
140+
from collector.fault_codes import warning_code_text
141+
except Exception:
142+
return ""
143+
try:
144+
code = int(value)
145+
except (TypeError, ValueError):
146+
return ""
147+
text = warning_code_text(code)
148+
return text if text else ""
149+
150+
123151
# ── endpoints ────────────────────────────────────────────────────────────────
124152

125153
@app.get("/api/status")
@@ -156,6 +184,20 @@ def api_status():
156184
"unit": "",
157185
}
158186

187+
# Human-readable fault/warning labels alongside the raw codes.
188+
for raw_name, label_name, label_fn in (
189+
("fault_code", "fault_code_text", _fault_label),
190+
("warning_code", "warning_code_text", _warning_label),
191+
("internal_fault", "internal_fault_text", _fault_label),
192+
("bms_fault_code", "bms_fault_code_text", _fault_label),
193+
("bms_warning_code", "bms_warning_code_text", _warning_label),
194+
):
195+
if raw_name in registers:
196+
registers[label_name] = {
197+
"value": label_fn(registers[raw_name]["value"]),
198+
"unit": "",
199+
}
200+
159201
return {
160202
"snapshot_id": snap_id,
161203
"timestamp": ts_aware.isoformat(),
@@ -438,6 +480,20 @@ def api_summary():
438480
"unit": "",
439481
}
440482

483+
# Human-readable fault/warning labels alongside the raw codes.
484+
for raw_name, label_name, label_fn in (
485+
("fault_code", "fault_code_text", _fault_label),
486+
("warning_code", "warning_code_text", _warning_label),
487+
("internal_fault", "internal_fault_text", _fault_label),
488+
("bms_fault_code", "bms_fault_code_text", _fault_label),
489+
("bms_warning_code", "bms_warning_code_text", _warning_label),
490+
):
491+
if raw_name in registers:
492+
registers[label_name] = {
493+
"value": label_fn(registers[raw_name]["value"]),
494+
"unit": "",
495+
}
496+
441497
return {
442498
"snapshot_id": snap_id,
443499
"timestamp": ts_aware.isoformat(),

‎api/static/index.html‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -633,7 +633,9 @@ <h3>Timer Schedule</h3>
633633
${prop('Frequency','eps_frequency',{suffix:' Hz', digits:2})}
634634
${prop('State','state',{int:1})}
635635
${prop('Fault Code','fault_code',{int:1})}
636+
${prop('Fault','fault_code_text')}
636637
${prop('Warning Code','warning_code',{int:1})}
638+
${prop('Warning','warning_code_text')}
637639
${prop('Runtime','runtime',{suffix:' s', int:1})}
638640
</div></div></div><div class="card"><div class="card-section"><h3>Temperatures</h3><div class="props">
639641
${prop('Inverter','temp_inverter',{suffix:' °C'})}
@@ -719,11 +721,17 @@ <h3>Timer Schedule</h3>
719721
content.querySelectorAll('[data-reg]').forEach(el => {
720722
const v = val(liveData, el.dataset.reg, null);
721723
if (v !== null && v !== undefined) {
722-
const fmtFn = el.dataset.int ? fmtInt : (el.dataset.digits ? (n=>fmt(n, Number(el.dataset.digits))) : fmt);
723-
let text = fmtFn(v);
724-
if (text === null || text === undefined) text = '--';
725-
if (el.dataset.suffix) text += el.dataset.suffix;
726-
el.textContent = text;
724+
// String values (e.g. fault/warning text) are shown as-is; numeric
725+
// values go through the formatter.
726+
if (typeof v === 'string') {
727+
el.textContent = v || '—';
728+
} else {
729+
const fmtFn = el.dataset.int ? fmtInt : (el.dataset.digits ? (n=>fmt(n, Number(el.dataset.digits))) : fmt);
730+
let text = fmtFn(v);
731+
if (text === null || text === undefined) text = '--';
732+
if (el.dataset.suffix) text += el.dataset.suffix;
733+
el.textContent = text;
734+
}
727735
}
728736
});
729737
}

‎collector/fault_codes.py‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Fault and warning code tables for the LuxPower / EG4 inverter family.
2+
3+
These tables translate the raw integer codes reported in the input registers
4+
(6 = internal fault, 60-61 = fault code, 62-63 = warning code, 99 = BMS fault,
5+
100 = BMS warning) into human-readable descriptions.
6+
7+
Source: the official LuxPower Modbus protocol PDF (06-01-01-PTC-Luxpower
8+
MODBUS Protocol_2024.04.26.pdf) and the EG4 18KPV Modbus protocol PDF, as
9+
extracted into `docs/reference/luxpower-ha-register-map.md`. LuxPower and EG4
10+
share the protocol, so these tables apply across the whole family.
11+
12+
The codes are bit-packed 32-bit values (low word + high word). A single
13+
register can carry multiple simultaneous faults/warnings, so the decode
14+
helpers return a list of descriptions rather than a single string.
15+
"""
16+
from __future__ import annotations
17+
18+
from typing import List, Optional
19+
20+
# ── Fault codes (input registers 6, 60-61) ─────────────────────────────────
21+
22+
FAULT_CODES: dict[int, str] = {
23+
0: "Internal communication failure 1",
24+
1: "Model fault",
25+
8: "Parallel CAN communication failure",
26+
9: "The host is missing",
27+
10: "Inconsistent rated power",
28+
11: "Inconsistent AC or safety settings",
29+
12: "UPS short circuit",
30+
13: "UPS reverse current",
31+
14: "BUS short circuit",
32+
15: "Abnormal phase in three-phase system",
33+
16: "Relay failure",
34+
17: "Internal communication failure 2",
35+
18: "Internal communication failure 3",
36+
19: "BUS overvoltage",
37+
20: "EPS connection fault",
38+
21: "PV overvoltage",
39+
22: "Overcurrent protection",
40+
23: "Neutral fault",
41+
24: "PV short circuit",
42+
25: "Heatsink temperature out of range",
43+
26: "Internal failure",
44+
27: "Consistency failure",
45+
28: "Inconsistent generator connection",
46+
29: "Parallel sync signal loss",
47+
31: "Internal communication failure 4",
48+
}
49+
50+
# ── Warning codes (input registers 62-63, 100) ─────────────────────────────
51+
52+
WARNING_CODES: dict[int, str] = {
53+
0: "Battery communication failed",
54+
1: "AFCI communication failure",
55+
2: "Battery low temperature",
56+
3: "Meter communication failed",
57+
4: "Battery cannot be charged/discharged",
58+
5: "Automated test failed",
59+
6: "RSD active",
60+
7: "LCD communication failure",
61+
8: "Software version mismatch",
62+
9: "Fan is stuck",
63+
10: "Grid overload",
64+
11: "Parallel secondaries exceed limit",
65+
12: "Battery reverse MOS abnormal",
66+
13: "Radiator temperature out of range",
67+
14: "Multiple primary units in parallel",
68+
15: "Battery reverse",
69+
16: "No grid connection",
70+
17: "Grid voltage out of range",
71+
18: "Grid frequency out of range",
72+
20: "Insulation resistance low",
73+
21: "Leakage current too high",
74+
22: "DCI exceeded standard",
75+
23: "PV short circuit",
76+
25: "Battery overvoltage",
77+
26: "Battery undervoltage",
78+
27: "Battery open circuit",
79+
28: "EPS overload",
80+
29: "EPS voltage high",
81+
30: "Meter reversed",
82+
31: "DCV exceeded standard",
83+
}
84+
85+
86+
def _decode_bits(value: int, table: dict[int, str]) -> List[str]:
87+
"""Decode a bit-packed code value into a list of descriptions.
88+
89+
Each set bit (or, for the single-register internal/BMS codes, the raw
90+
integer value) maps to a description. Returns an empty list when the
91+
value is 0 (no fault/warning) or has no known bits set.
92+
"""
93+
if value is None or value == 0:
94+
return []
95+
96+
# Single-register codes (internal fault, BMS fault/warning) are plain
97+
# integers, not bitmasks. Try a direct lookup first.
98+
if value in table:
99+
return [table[value]]
100+
101+
# Bit-packed 32-bit codes: each set bit is a distinct fault/warning.
102+
names: List[str] = []
103+
for bit in range(32):
104+
if value & (1 << bit):
105+
name = table.get(bit)
106+
if name:
107+
names.append(name)
108+
return names
109+
110+
111+
def decode_fault_code(value: Optional[int]) -> List[str]:
112+
"""Return the list of fault descriptions for a raw fault code value."""
113+
return _decode_bits(value, FAULT_CODES)
114+
115+
116+
def decode_warning_code(value: Optional[int]) -> List[str]:
117+
"""Return the list of warning descriptions for a raw warning code value."""
118+
return _decode_bits(value, WARNING_CODES)
119+
120+
121+
def fault_code_text(value: Optional[int]) -> str:
122+
"""Return a comma-joined human-readable fault string ('' when none)."""
123+
return ", ".join(decode_fault_code(value))
124+
125+
126+
def warning_code_text(value: Optional[int]) -> str:
127+
"""Return a comma-joined human-readable warning string ('' when none)."""
128+
return ", ".join(decode_warning_code(value))

‎tests/test_fault_codes.py‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Tests for the fault/warning code decode tables."""
2+
3+
import pytest
4+
5+
from collector.fault_codes import (
6+
FAULT_CODES,
7+
WARNING_CODES,
8+
decode_fault_code,
9+
decode_warning_code,
10+
fault_code_text,
11+
warning_code_text,
12+
)
13+
14+
15+
def test_fault_code_single_register():
16+
# Internal fault register 6 = 19 -> BUS overvoltage
17+
assert fault_code_text(19) == "BUS overvoltage"
18+
19+
20+
def test_fault_code_bitmask_multiple():
21+
# Bits 19 (BUS overvoltage) + 21 (PV overvoltage)
22+
raw = (1 << 19) | (1 << 21)
23+
names = decode_fault_code(raw)
24+
assert "BUS overvoltage" in names
25+
assert "PV overvoltage" in names
26+
27+
28+
def test_warning_code_bitmask_multiple():
29+
# Bits 16 (no grid) + 17 (grid voltage out of range)
30+
raw = (1 << 16) | (1 << 17)
31+
names = decode_warning_code(raw)
32+
assert "No grid connection" in names
33+
assert "Grid voltage out of range" in names
34+
35+
36+
def test_zero_code_returns_empty():
37+
assert decode_fault_code(0) == []
38+
assert decode_warning_code(0) == []
39+
assert fault_code_text(0) == ""
40+
assert warning_code_text(0) == ""
41+
42+
43+
def test_none_code_returns_empty():
44+
assert decode_fault_code(None) == []
45+
assert decode_warning_code(None) == []
46+
47+
48+
def test_unknown_code_returns_empty():
49+
# A bit with no known mapping (e.g. bit 30 for fault) -> empty
50+
assert decode_fault_code(1 << 30) == []
51+
52+
53+
def test_tables_are_complete():
54+
# Sanity: the tables contain the known code ranges from the protocol doc.
55+
assert 19 in FAULT_CODES # BUS overvoltage
56+
assert 23 in FAULT_CODES # Neutral fault
57+
assert 16 in WARNING_CODES # No grid connection
58+
assert 31 in WARNING_CODES # DCV exceeded standard
59+
60+
61+
def test_api_fault_label():
62+
from api import _fault_label, _warning_label
63+
assert _fault_label(19) == "BUS overvoltage"
64+
assert _fault_label(0) == ""
65+
assert _warning_label(16) == "No grid connection"
66+
assert _warning_label(0) == ""
67+
# 32-bit packed fault code (bits 19 + 21)
68+
packed = (1 << 19) | (1 << 21)
69+
assert "BUS overvoltage" in _fault_label(packed)
70+
assert "PV overvoltage" in _fault_label(packed)

0 commit comments

Comments
 (0)