Skip to content

Commit f801e4d

Browse files
committed
feat(docs): add script to auto-generate sensors.md and update sensor documentation
- Introduce `generate_sensors_md.py` to automate `sensors.md` generation from integration files. - Update `sensors.md` with auto-generated tables for hybrid and string inverter sensors. - Preserve custom sections like "Waveshare" and "Solar Inverter Modes". - Add hybrid and string inverter register range classifications. - Extend `.gitignore` to exclude proprietary Solis docs directory. - Ensure generation integrates seamlessly with existing sensor data structures.
1 parent bc7e289 commit f801e4d

3 files changed

Lines changed: 1011 additions & 377 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,4 @@ dmypy.json
7070
/.idea/
7171
/docs/proprietary_solis/
7272
/docs/proprietary_solis/
73+
/docs/proprietary_solis/

docs/generate_sensors_md.py

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
"""Regenerate docs/source/sensors.md entity tables from definition files.
2+
3+
Preserves the Waveshare and Solar Inverter Modes sections from the existing file.
4+
Run from repo root: uv run python docs/generate_sensors_md.py
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import re
10+
import sys
11+
from enum import Enum
12+
from pathlib import Path
13+
14+
ROOT = Path(__file__).resolve().parents[1]
15+
sys.path.insert(0, str(ROOT))
16+
17+
from custom_components.solis_modbus.data.enums import InverterFeature, InverterType # noqa: E402
18+
from custom_components.solis_modbus.data.solis_config import InverterConfig, InverterOptions # noqa: E402
19+
from custom_components.solis_modbus.sensor_data.hybrid_sensors import ( # noqa: E402
20+
hybrid_sensors,
21+
hybrid_sensors_derived,
22+
)
23+
from custom_components.solis_modbus.sensor_data.select_sensors import get_select_sensors # noqa: E402
24+
from custom_components.solis_modbus.sensor_data.string_sensors import ( # noqa: E402
25+
string_sensors,
26+
string_sensors_derived,
27+
)
28+
from custom_components.solis_modbus.sensor_data.switch_sensors import get_switch_sensors # noqa: E402
29+
from custom_components.solis_modbus.sensor_data.time_sensors import get_time_sensors # noqa: E402
30+
31+
OUT = ROOT / "docs" / "source" / "sensors.md"
32+
33+
34+
def enum_name(value) -> str:
35+
if value is None:
36+
return ""
37+
if isinstance(value, Enum):
38+
return value.name
39+
return str(value)
40+
41+
42+
def format_registers(registers) -> str:
43+
if not registers:
44+
return ""
45+
regs = [str(r) for r in registers]
46+
# Collapse long consecutive serial-number style ranges
47+
try:
48+
nums = [int(r) for r in regs]
49+
except ValueError:
50+
return ", ".join(regs)
51+
if len(nums) >= 4 and nums == list(range(nums[0], nums[0] + len(nums))):
52+
return f"{nums[0]} - {nums[-1]}"
53+
return ", ".join(regs)
54+
55+
56+
def pad_row(cells: list[str], widths: list[int]) -> str:
57+
return "| " + " | ".join(c.ljust(w) for c, w in zip(cells, widths)) + " |"
58+
59+
60+
def pad_sep(widths: list[int]) -> str:
61+
return "|" + "|".join("-" * (w + 2) for w in widths) + "|"
62+
63+
64+
def table(headers: list[str], rows: list[list[str]]) -> str:
65+
widths = [len(h) for h in headers]
66+
for row in rows:
67+
for i, cell in enumerate(row):
68+
widths[i] = max(widths[i], len(cell))
69+
lines = [pad_row(headers, widths), pad_sep(widths)]
70+
lines.extend(pad_row(row, widths) for row in rows)
71+
return "\n".join(lines)
72+
73+
74+
def iter_sensor_entities(groups):
75+
for group in groups:
76+
for entity in group.get("entities", []):
77+
if entity.get("type") == "reserve" or entity.get("name") == "reserve":
78+
continue
79+
yield entity
80+
81+
82+
def sensor_row(entity: dict, *, prefix: str = "") -> list[str]:
83+
name = entity.get("name", "")
84+
if prefix and not name.startswith(prefix):
85+
name = f"{prefix}{name}"
86+
regs = entity.get("register", [])
87+
if isinstance(regs, (str, int)):
88+
regs = [regs]
89+
return [
90+
name,
91+
enum_name(entity.get("device_class")),
92+
enum_name(entity.get("unit_of_measurement")),
93+
enum_name(entity.get("state_class")),
94+
format_registers(regs),
95+
]
96+
97+
98+
def hybrid_config_all_features() -> InverterConfig:
99+
"""Config that enables every optional hybrid feature for full docs coverage."""
100+
return InverterConfig(
101+
model="S6-EH3P",
102+
wattage=[10000],
103+
phases=3,
104+
type=InverterType.HYBRID,
105+
options=InverterOptions(
106+
pv=True,
107+
battery=True,
108+
hv_battery=True,
109+
generator=True,
110+
v2=True,
111+
ac_coupling=True,
112+
parallel=True,
113+
dual_meter=True,
114+
epm=True,
115+
),
116+
features=[InverterFeature.SMART_PORT],
117+
)
118+
119+
120+
def string_config() -> InverterConfig:
121+
return InverterConfig(
122+
model="S5-GR3P",
123+
wattage=[10000],
124+
phases=3,
125+
type=InverterType.GRID,
126+
options=InverterOptions(epm=True),
127+
features=[],
128+
)
129+
130+
131+
def build_input_rows(entities) -> list[list[str]]:
132+
rows = []
133+
for entity in entities:
134+
if not entity.get("editable"):
135+
continue
136+
rows.append(sensor_row(entity, prefix="Solis "))
137+
rows.sort(key=lambda r: (r[4], r[0]))
138+
return rows
139+
140+
141+
def build_switch_rows(config) -> list[list[str]]:
142+
rows = []
143+
for group in get_switch_sensors(config):
144+
register = group.get("register", group.get("read_register"))
145+
for entity in group.get("entities", []):
146+
name = entity["name"]
147+
if not name.startswith("Solis "):
148+
name = f"Solis {name}"
149+
bit = entity.get("bit_position")
150+
bit_s = "" if bit is None else str(bit)
151+
note = ""
152+
if entity.get("inverted"):
153+
note = "Inverted"
154+
if entity.get("keep_alive"):
155+
note = (note + "; " if note else "") + "Keep-alive while ON"
156+
if group.get("write_register") and group.get("write_register") != register:
157+
note = (note + "; " if note else "") + f"write {group['write_register']}"
158+
rows.append([name, str(register), bit_s, note])
159+
rows.sort(key=lambda r: (int(r[1]), r[2] or "99", r[0]))
160+
return rows
161+
162+
163+
def build_time_rows(config) -> list[list[str]]:
164+
rows = []
165+
for entity in get_time_sensors(config):
166+
name = entity["name"]
167+
if not name.startswith("Solis "):
168+
name = f"Solis {name}"
169+
rows.append([name, str(entity["register"])])
170+
return rows
171+
172+
173+
def build_select_rows(config) -> list[list[str]]:
174+
rows = []
175+
for group in get_select_sensors(config):
176+
options = ", ".join(e["name"] for e in group.get("entities", []))
177+
rows.append([f"Solis {group['name']}", str(group["register"]), options])
178+
return rows
179+
180+
181+
def build_sensor_rows(groups, derived, *, prefix: str = "") -> list[list[str]]:
182+
rows = [sensor_row(e, prefix=prefix) for e in iter_sensor_entities(groups)]
183+
rows.extend(sensor_row(e, prefix=prefix) for e in derived)
184+
# Sort by first register then name
185+
def sort_key(row):
186+
regs = row[4]
187+
first = regs.split(",")[0].split("-")[0].strip()
188+
try:
189+
return (int(first), row[0])
190+
except ValueError:
191+
return (10**9, row[0])
192+
193+
rows.sort(key=sort_key)
194+
return rows
195+
196+
197+
def extract_preserved_sections(existing: str) -> tuple[str, str]:
198+
waveshare = ""
199+
modes = ""
200+
m = re.search(r"(# Waveshare\n.*?)(?=\n# String Inverter Sensors\n)", existing, re.S)
201+
if m:
202+
waveshare = m.group(1).rstrip() + "\n"
203+
m = re.search(r"(# Solar Inverter Modes in Solis Inverters\n.*)\Z", existing, re.S)
204+
if m:
205+
modes = m.group(1).rstrip() + "\n"
206+
return waveshare, modes
207+
208+
209+
def main() -> None:
210+
existing = OUT.read_text(encoding="utf-8") if OUT.exists() else ""
211+
waveshare, modes = extract_preserved_sections(existing)
212+
if not waveshare:
213+
waveshare = (
214+
"# Waveshare\n"
215+
"This is only required if your values are higher than expected, if you aren't experiencing this, this should be disabled.\n"
216+
)
217+
if not modes:
218+
modes = "# Solar Inverter Modes in Solis Inverters\n"
219+
220+
hybrid_cfg = hybrid_config_all_features()
221+
string_cfg = string_config()
222+
223+
hybrid_entities = list(iter_sensor_entities(hybrid_sensors))
224+
string_entities = list(iter_sensor_entities(string_sensors))
225+
226+
sensor_headers = ["Name", "Device Class", "Unit Of Measurement", "State Class", "Registers"]
227+
228+
hybrid_switch_rows = build_switch_rows(hybrid_cfg)
229+
string_switch_rows = [
230+
r for r in build_switch_rows(string_cfg) if r[1] not in {row[1] for row in hybrid_switch_rows} or "power limit" in r[0].lower()
231+
]
232+
# Avoid duplicating 90005 if present on both
233+
hybrid_names = {r[0] for r in hybrid_switch_rows}
234+
string_switch_rows = [r for r in string_switch_rows if r[0] not in hybrid_names]
235+
236+
parts = [
237+
"---",
238+
"myst:",
239+
' enable_extensions: [ "colon_fence" ]',
240+
"---",
241+
"",
242+
"The following sensors are provided in the integration.",
243+
"",
244+
"Tables below are generated from the integration definition files "
245+
"(`hybrid_sensors.py`, `string_sensors.py`, switches/selects/times). "
246+
"Hybrid and string are separate hardware profiles — only one applies per install. "
247+
"Optional feature entities (Meter 2, dispatch, V2 Grid TOU, Smart Port, etc.) "
248+
"are listed even if disabled in your options.",
249+
"",
250+
"# String Inverter Registers",
251+
"The string inverter uses the following register ranges:",
252+
"- 2xxx: Basic information and measurements",
253+
"- 3xxx: AC and DC measurements, status information",
254+
"- 36xxx: Additional measurements and energy data",
255+
"",
256+
"# Hybrid Inverter Registers",
257+
"The hybrid inverter uses the following register ranges:",
258+
"- 33xxx: Basic information and measurements",
259+
"- 34xxx: Additional measurements",
260+
"- 35xxx: Inverter type definition",
261+
"- 43xxx / 44xxx: Control settings and parameters",
262+
"- 90xxx: Derived values",
263+
"",
264+
"# Input Control Sensors",
265+
"Editable number entities (hybrid).",
266+
"",
267+
table(sensor_headers, build_input_rows(hybrid_entities)),
268+
"",
269+
"# Switch Control Sensors",
270+
"",
271+
table(
272+
["Name", "Register", "Bit Position", "Note"],
273+
hybrid_switch_rows + string_switch_rows,
274+
),
275+
"",
276+
"# Select Control Sensors",
277+
"",
278+
table(
279+
["Name", "Register", "Options"],
280+
build_select_rows(hybrid_cfg),
281+
),
282+
"",
283+
"# Time Control Sensors",
284+
"",
285+
table(["Name", "Register"], build_time_rows(hybrid_cfg)),
286+
"",
287+
"# Hybrid Inverter Sensors",
288+
"",
289+
table(
290+
sensor_headers,
291+
build_sensor_rows(hybrid_sensors, hybrid_sensors_derived, prefix="Solis "),
292+
),
293+
"",
294+
waveshare.rstrip(),
295+
"",
296+
"# String Inverter Sensors",
297+
"",
298+
table(
299+
sensor_headers,
300+
build_sensor_rows(string_sensors, string_sensors_derived, prefix=""),
301+
),
302+
"",
303+
modes.rstrip(),
304+
"",
305+
]
306+
307+
text = "\n".join(parts)
308+
text = text.replace("| Solis Solis Modbus Enabled ", "| Solis Modbus Enabled ")
309+
OUT.write_text(text, encoding="utf-8")
310+
311+
# Coverage report
312+
hybrid_count = len(hybrid_entities) + len(hybrid_sensors_derived)
313+
string_count = len(string_entities) + len(string_sensors_derived)
314+
print(f"Wrote {OUT}")
315+
print(f"Hybrid sensors+derived: {hybrid_count}")
316+
print(f"String sensors+derived: {string_count}")
317+
print(f"Input (editable): {len(build_input_rows(hybrid_entities))}")
318+
print(f"Switches: {len(hybrid_switch_rows) + len(string_switch_rows)}")
319+
print(f"Selects: {len(build_select_rows(hybrid_cfg))}")
320+
print(f"Times: {len(build_time_rows(hybrid_cfg))}")
321+
322+
323+
if __name__ == "__main__":
324+
main()

0 commit comments

Comments
 (0)