Skip to content

Commit 1dab65f

Browse files
authored
Add auto-generated sensor & sensor groups to docs (#552)
1 parent d6794db commit 1dab65f

21 files changed

Lines changed: 2646 additions & 267 deletions

File tree

‎.pre-commit-config.yaml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ repos:
66

77
- repo: local
88
hooks:
9+
- id: gen_sensors
10+
name: Generate sensor docs
11+
entry: uv run scripts/gen_sensors.py
12+
language: system
13+
pass_filenames: false
14+
915
- id: pyproject-fmt
1016
name: pyproject-fmt
1117
entry: uv run pyproject-fmt pyproject.toml

‎scripts/copy2local.cmd‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
@echo off
2-
@rem CALL :copy_ss hass-addon-sunsynk-multi,\\192.168.1.8\addons\hass-addon-sunsynk-multi
2+
CALL :copy_ss hass-addon-sunsynk-multi,\\192.168.1.8\addons\hass-addon-sunsynk-multi
33
CALL :copy_addon hass-addon-mbusd,\\192.168.1.8\addons\hass-addon-mbusd
44

55
EXIT /B %ERRORLEVEL%

‎scripts/gen_sensors.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Sensors."""
2+
3+
import asyncio
4+
from collections import defaultdict
5+
from pathlib import Path
6+
7+
from ha_addon_sunsynk_multi.sensor_options import SENSOR_GROUPS
8+
from sunsynk import RWSensor, Sensor, SensorDefinitions
9+
from sunsynk.definitions import import_all_defs
10+
from sunsynk.utils import pretty_table, table_data
11+
12+
13+
async def main() -> None:
14+
"""Generate docs."""
15+
all_defs = import_all_defs()
16+
17+
all_defs = {simple_def_name(k): v for k, v in all_defs.items()}
18+
19+
generate_all_sensors(all_defs)
20+
generate_group_sensors(all_defs)
21+
22+
23+
def generate_all_sensors(all_defs: dict[str, SensorDefinitions]) -> None:
24+
"""Generate groups/all.html."""
25+
sensors = defaultdict[str, dict[str, Sensor]](dict)
26+
for name, defs in all_defs.items():
27+
for key, sen in defs.all.items():
28+
sensors[key][name] = sen
29+
30+
def getname(row: list[Sensor | None]) -> str:
31+
"""Get the name of the row."""
32+
for sen in row:
33+
if sen:
34+
if isinstance(sen, RWSensor):
35+
return f"{sen.name} (R/W)"
36+
return sen.name
37+
return "?"
38+
39+
def get_sensor_info(sensor: Sensor | None) -> str:
40+
"""Sensor info."""
41+
if not sensor:
42+
return ""
43+
reg = (
44+
str(sensor.address[0]) if len(sensor.address) == 1 else str(sensor.address)
45+
)
46+
return reg
47+
48+
headers = ["Name", *sorted(all_defs)]
49+
data = table_data(sensors.values(), headers)
50+
tab = pretty_table(
51+
*data, to_str=get_sensor_info, calculated_cols={"Name": getname}, wrap_length=0
52+
)
53+
print(tab.get_string())
54+
55+
html = tab.get_html_string()
56+
html = html.replace("<table>", '<table style="overflow-x: unset;" class="bigt">')
57+
58+
Path("www/docs/reference/groups/all.html").write_text(
59+
html, encoding="utf8", newline="\n"
60+
)
61+
62+
63+
def simple_def_name(d_name: str) -> str:
64+
"""Shorten the definition name."""
65+
return d_name.replace("single-phase", "1PH").replace("three-phase", "3PH")
66+
67+
68+
def generate_group_sensors(all_defs: dict[str, SensorDefinitions]) -> None:
69+
"""Generate groups/{name}.yml."""
70+
for g_name, s_name_in_group in SENSOR_GROUPS.items():
71+
if g_name == "all":
72+
continue
73+
g_res = "SENSORS:"
74+
for s_name in sorted(s_name_in_group):
75+
# a line per sensor, g_name as a comment
76+
defs_lst = list[str]()
77+
for d_name, sdef in all_defs.items():
78+
sen = sdef.all.get(s_name)
79+
if not sen:
80+
continue
81+
defs_lst.append(simple_def_name(d_name))
82+
if len(defs_lst) == len(all_defs):
83+
g_res += f"\n - {s_name}"
84+
else:
85+
g_res += f"\n - {s_name} # {', '.join(defs_lst)}"
86+
Path(f"www/docs/reference/groups/{g_name}.yml").write_text(
87+
g_res, encoding="utf8", newline="\n"
88+
)
89+
90+
91+
if __name__ == "__main__":
92+
asyncio.run(main())

‎src/ha_addon_sunsynk_multi/sensor_callback.py‎

Lines changed: 10 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@
33
import asyncio
44
import logging
55
from collections import defaultdict
6-
from collections.abc import Iterable
7-
from textwrap import wrap
86

97
import attrs
10-
from prettytable import PrettyTable
118

129
from sunsynk import RWSensor, Sensor, ValType
10+
from sunsynk.utils import pretty_table
1311

1412
from .a_inverter import AInverter
1513
from .a_sensor import ASensor, SensorOption
@@ -46,37 +44,18 @@ def _build_schedules(idx: int) -> tuple[dict[int, SensorRun], dict[int, SensorRu
4644
if idx > 1:
4745
return read_s, report_s
4846

49-
_print_table(
50-
data=(
51-
(e, ", ".join(s.sensor.id for s in r.sensors)) for e, r in read_s.items()
52-
),
53-
field_names=["s", "Sensors"],
54-
title=f"Read every (inverter {'1' if first else '>1'})",
55-
)
47+
inv_ref = f"inverter {'1' if first else '>1'}"
48+
hdrs = ["s", "Sensors"]
5649

57-
_print_table(
58-
data=(
59-
(e, ", ".join(s.sensor.id for s in r.sensors)) for e, r in report_s.items()
60-
),
61-
field_names=["s", "Sensors"],
62-
title=f"Report every (inverter {'1' if first else '>1'})",
63-
)
50+
data = [[e, ", ".join(s.sensor.id for s in r.sensors)] for e, r in read_s.items()]
51+
tab = pretty_table(hdrs, data)
52+
_LOG.info("Read every (%s)\n%s", inv_ref, tab.get_string())
6453

65-
return read_s, report_s
54+
data = [[e, ", ".join(s.sensor.id for s in r.sensors)] for e, r in report_s.items()]
55+
tab = pretty_table(hdrs, data)
56+
_LOG.info("Report every (%s)\n%s", inv_ref, tab.get_string())
6657

67-
68-
def _print_table(
69-
data: Iterable[tuple[int, str]], field_names: list[str], title: str
70-
) -> None:
71-
"""Print a table."""
72-
tab = PrettyTable()
73-
tab.field_names = field_names
74-
for key, val in sorted(data, key=lambda x: x[0]):
75-
wrapped = wrap(str(val) or "", 80) or [""]
76-
tab.add_row([key, wrapped[0]])
77-
for more in wrapped[1:]:
78-
tab.add_row(["", more])
79-
_LOG.info("%s\n%s", title, tab.get_string())
58+
return read_s, report_s
8059

8160

8261
def build_callback_schedule(ist: AInverter) -> AsyncCallback: # noqa: PLR0915

‎src/ha_addon_sunsynk_multi/timer_schedule.py‎

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
import logging
44

55
import attrs
6-
from prettytable import PrettyTable
76

87
from sunsynk import KWH, WATT, NumType, Sensor
98
from sunsynk.helpers import slug
109
from sunsynk.rwsensors import RWSensor
1110
from sunsynk.sensors import EnumSensor
11+
from sunsynk.utils import pretty_table
1212

1313
_LOG = logging.getLogger(__name__)
1414

@@ -118,31 +118,19 @@ def init_schedules(schedules: list[Schedule]) -> None:
118118
# _LOG.info("Added %s", sch)
119119
SCHEDULES[sch.key] = sch
120120

121-
tab = PrettyTable()
122-
tab.field_names = [
123-
"Key",
124-
"src",
125-
"Read",
126-
"Report",
127-
"Change by",
128-
"Change %",
129-
"Change any",
121+
hdr = ["Key", "src", "Read", "Report", "Change by", "Change %", "Change any"]
122+
data = [
123+
[
124+
schn,
125+
source.get(schn, ""),
126+
sch.read_every if sch.read_every else "once",
127+
sch.report_every if sch.report_every else "",
128+
sch.change_by if sch.change_by else "",
129+
sch.change_percent if sch.change_percent else "",
130+
sch.change_any if sch.change_any else "",
131+
]
132+
for schn, sch in SCHEDULES.items()
130133
]
134+
tab = pretty_table(hdr, data)
131135

132-
for schn, sch in SCHEDULES.items():
133-
# if schn not in updated:
134-
# _LOG.info("Default %s", sch)
135-
tab.add_row(
136-
[
137-
schn,
138-
source.get(schn, ""),
139-
sch.read_every if sch.read_every else "once",
140-
sch.report_every if sch.report_every else "",
141-
sch.change_by if sch.change_by else "",
142-
sch.change_percent if sch.change_percent else "",
143-
sch.change_any if sch.change_any else "",
144-
]
145-
)
146-
147-
# tab.add_rows([e, [s.sensor.id for s in r.sensors]] for e, r in report_s.items())
148136
_LOG.info("Schedules:\n%s", tab.get_string())

‎src/sunsynk/definitions/__init__.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,12 @@ def import_defs(name: str) -> SensorDefinitions:
5050
)
5151
mod = import_module(f"sunsynk.definitions.{libname}")
5252
return mod.SENSORS
53+
54+
55+
# get this from config.yaml
56+
ALL_DEFS = "single-phase|single-phase-16kw|three-phase|three-phase-hv".split("|")
57+
58+
59+
def import_all_defs() -> dict[str, SensorDefinitions]:
60+
"""Get all sensor definitions."""
61+
return {k: import_defs(k) for k in ALL_DEFS}

‎src/sunsynk/utils.py‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,79 @@
11
"""Utilities."""
22

3+
import importlib
34
import sys
4-
from importlib import import_module as _import_module
5+
from collections.abc import Callable, Iterable
56
from pathlib import Path
7+
from textwrap import wrap
68
from types import ModuleType
9+
from typing import Any
10+
11+
from prettytable import PrettyTable
712

813

914
def import_module(mod_name: str, folder: str | Path | None = None) -> ModuleType:
1015
"""import_module."""
1116
if folder:
1217
sys.path.insert(0, str(folder))
1318
try:
14-
mod_obj = _import_module(mod_name)
19+
mod_obj = importlib.import_module(mod_name)
1520
return mod_obj
1621
finally:
1722
if folder and sys.path[0] == str(folder):
1823
sys.path.pop(0)
24+
25+
26+
def ensure_str(v: Any) -> str:
27+
"""Ensure a value is a string."""
28+
return "" if v is None else str(v)
29+
30+
31+
def pretty_table[T](
32+
headers: list[str],
33+
data: list[list[T]],
34+
/,
35+
wrap_length: int = 80,
36+
to_str: Callable[[T], str] = ensure_str,
37+
calculated_cols: dict[str, Callable[[list[T]], str]] | None = None,
38+
) -> PrettyTable:
39+
"""Print a table."""
40+
for row_any in data:
41+
if len(row_any) > len(headers):
42+
headers.extend([f"Extra {i}" for i in range(len(headers), len(row_any))])
43+
44+
table = PrettyTable()
45+
table.field_names = headers
46+
47+
if calculated_cols:
48+
calculated_cols = dict(calculated_cols) # copy
49+
for colname in list(calculated_cols):
50+
if colname not in headers:
51+
headers.append(colname)
52+
idx = headers.index(colname)
53+
calculated_cols[str(idx)] = calculated_cols.pop(colname)
54+
55+
for row_any in data:
56+
row = [to_str(v) for v in row_any]
57+
if calculated_cols:
58+
for colidx, func in calculated_cols.items():
59+
row[int(colidx)] = func(row_any)
60+
61+
if wrap_length == 0 or not any(len(v) > wrap_length for v in row):
62+
table.add_row(row)
63+
continue
64+
65+
row_wrap = [wrap(v, wrap_length) for v in row]
66+
while any(row_wrap):
67+
table.add_row([r[0] if r else "" for r in row_wrap])
68+
row_wrap = [r[1:] for r in row_wrap]
69+
70+
return table
71+
72+
73+
def table_data[T](
74+
data: Iterable[dict[str, T]], /, headers: list[str] | None = None
75+
) -> tuple[list[str], list[list[T | None]]]:
76+
"""Convert a list of dictionaries to a table data format."""
77+
if headers is None:
78+
headers = list({k for v in data for k in v.keys()})
79+
return headers, [[v.get(k) for k in headers] for v in data]

‎src/tests/sunsynk/definitions/test_init.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Test sensor definitions."""
22

3-
from sunsynk.definitions import import_defs
3+
from sunsynk.definitions import import_all_defs, import_defs
44

55

66
def test_import_defs() -> None:
@@ -14,3 +14,9 @@ def test_import_defs() -> None:
1414
defs = import_defs(lib)
1515
assert len(defs.all) > 50
1616
assert defs.rated_power
17+
18+
19+
def test_all_defs() -> None:
20+
"""Test all sensor definitions."""
21+
all_defs = import_all_defs()
22+
assert len(all_defs) == 4

‎www/docs/.vitepress/theme/custom.css‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,29 @@
1414
--vp-c-green-dimm-1: rgba(13, 110, 253, 0.05);
1515
--vp-c-green-dimm-2: rgba(13, 110, 253, 0.2);
1616
--vp-c-green-dimm-3: rgba(13, 110, 253, 0.5);
17+
}
18+
19+
20+
21+
22+
23+
/* .bigt table {
24+
border-collapse: collapse;
25+
width: 100%;
26+
} */
27+
28+
/* .bigt th, .bigt td {
29+
padding: 10px;
30+
border: 1px solid #ccc;
31+
} */
32+
33+
.bigt table {
34+
overflow-x: unset !important;
35+
overflow-y: unset !important;
36+
}
37+
38+
.bigt thead th {
39+
position: sticky;
40+
top: 60px;
41+
z-index: 1;
1742
}

0 commit comments

Comments
 (0)