pylxpweb 0.1.0__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pylxpweb/__init__.py +47 -2
- pylxpweb/api_namespace.py +241 -0
- pylxpweb/cli/__init__.py +3 -0
- pylxpweb/cli/collect_device_data.py +874 -0
- pylxpweb/client.py +387 -26
- pylxpweb/constants/__init__.py +481 -0
- pylxpweb/constants/api.py +48 -0
- pylxpweb/constants/devices.py +98 -0
- pylxpweb/constants/locations.py +227 -0
- pylxpweb/{constants.py → constants/registers.py} +72 -238
- pylxpweb/constants/scaling.py +479 -0
- pylxpweb/devices/__init__.py +32 -0
- pylxpweb/devices/_firmware_update_mixin.py +504 -0
- pylxpweb/devices/_mid_runtime_properties.py +545 -0
- pylxpweb/devices/base.py +122 -0
- pylxpweb/devices/battery.py +589 -0
- pylxpweb/devices/battery_bank.py +331 -0
- pylxpweb/devices/inverters/__init__.py +32 -0
- pylxpweb/devices/inverters/_features.py +378 -0
- pylxpweb/devices/inverters/_runtime_properties.py +596 -0
- pylxpweb/devices/inverters/base.py +2124 -0
- pylxpweb/devices/inverters/generic.py +192 -0
- pylxpweb/devices/inverters/hybrid.py +274 -0
- pylxpweb/devices/mid_device.py +183 -0
- pylxpweb/devices/models.py +126 -0
- pylxpweb/devices/parallel_group.py +351 -0
- pylxpweb/devices/station.py +908 -0
- pylxpweb/endpoints/control.py +980 -2
- pylxpweb/endpoints/devices.py +249 -16
- pylxpweb/endpoints/firmware.py +43 -10
- pylxpweb/endpoints/plants.py +15 -19
- pylxpweb/exceptions.py +4 -0
- pylxpweb/models.py +629 -40
- pylxpweb/transports/__init__.py +78 -0
- pylxpweb/transports/capabilities.py +101 -0
- pylxpweb/transports/data.py +495 -0
- pylxpweb/transports/exceptions.py +59 -0
- pylxpweb/transports/factory.py +119 -0
- pylxpweb/transports/http.py +329 -0
- pylxpweb/transports/modbus.py +557 -0
- pylxpweb/transports/protocol.py +217 -0
- {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.0.dist-info}/METADATA +130 -85
- pylxpweb-0.5.0.dist-info/RECORD +52 -0
- {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.0.dist-info}/WHEEL +1 -1
- pylxpweb-0.5.0.dist-info/entry_points.txt +3 -0
- pylxpweb-0.1.0.dist-info/RECORD +0 -19
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Device Data Collection Tool for pylxpweb.
|
|
3
|
+
|
|
4
|
+
This script automatically discovers and collects comprehensive device information
|
|
5
|
+
from all Luxpower/EG4 inverters in your account. It's designed to help developers
|
|
6
|
+
add support for new/unknown inverter models.
|
|
7
|
+
|
|
8
|
+
The tool:
|
|
9
|
+
1. Discovers all plants and devices in your account
|
|
10
|
+
2. Reads all register values from each device
|
|
11
|
+
3. Generates JSON and Markdown reports for each device
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
pylxpweb-collect -u YOUR_USERNAME -p YOUR_PASSWORD
|
|
15
|
+
|
|
16
|
+
# For EU Luxpower portal users:
|
|
17
|
+
pylxpweb-collect -u YOUR_USERNAME -p YOUR_PASSWORD -b https://eu.luxpowertek.com
|
|
18
|
+
|
|
19
|
+
# For US Luxpower portal users:
|
|
20
|
+
pylxpweb-collect -u YOUR_USERNAME -p YOUR_PASSWORD -b https://us.luxpowertek.com
|
|
21
|
+
|
|
22
|
+
For detailed instructions, see: https://github.com/joyfulhouse/pylxpweb/blob/main/docs/COLLECT_DEVICE_DATA.md
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import asyncio
|
|
29
|
+
import json
|
|
30
|
+
import sys
|
|
31
|
+
import zipfile
|
|
32
|
+
from datetime import datetime
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
from urllib.parse import quote, urlencode
|
|
36
|
+
|
|
37
|
+
# Handle imports whether run as module or directly
|
|
38
|
+
try:
|
|
39
|
+
from pylxpweb import __version__
|
|
40
|
+
from pylxpweb.client import LuxpowerClient
|
|
41
|
+
except ImportError:
|
|
42
|
+
# Running directly from source
|
|
43
|
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
|
44
|
+
from pylxpweb import __version__
|
|
45
|
+
from pylxpweb.client import LuxpowerClient
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
GITHUB_ISSUES_URL = "https://github.com/joyfulhouse/pylxpweb/issues/new"
|
|
49
|
+
GITHUB_DISCUSSIONS_URL = "https://github.com/joyfulhouse/pylxpweb/discussions"
|
|
50
|
+
|
|
51
|
+
# Keys in sample_values that may contain sensitive data
|
|
52
|
+
SENSITIVE_PARAM_KEYS = {
|
|
53
|
+
"HOLD_SERIAL_NUM",
|
|
54
|
+
"HOLD_DATALOG_SN",
|
|
55
|
+
"HOLD_MODBUS_ADDR",
|
|
56
|
+
# Add any other parameter names that might contain sensitive data
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sanitize_serial(serial: str) -> str:
|
|
61
|
+
"""Mask a serial number for privacy.
|
|
62
|
+
|
|
63
|
+
Example: "4512670118" -> "45******18"
|
|
64
|
+
"""
|
|
65
|
+
if len(serial) >= 6:
|
|
66
|
+
return f"{serial[:2]}{'*' * (len(serial) - 4)}{serial[-2:]}"
|
|
67
|
+
elif len(serial) > 2:
|
|
68
|
+
return f"{serial[0]}{'*' * (len(serial) - 2)}{serial[-1]}"
|
|
69
|
+
return "*" * len(serial)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sanitize_plant_name(_name: str) -> str:
|
|
73
|
+
"""Replace plant name with generic placeholder."""
|
|
74
|
+
return "My Solar System"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def sanitize_location(value: Any) -> Any:
|
|
78
|
+
"""Sanitize location-related values (lat/long, addresses)."""
|
|
79
|
+
if value is None:
|
|
80
|
+
return None
|
|
81
|
+
if isinstance(value, (int, float)):
|
|
82
|
+
# Latitude/longitude - replace with 0.0
|
|
83
|
+
return 0.0
|
|
84
|
+
if isinstance(value, str):
|
|
85
|
+
# Address string - replace with placeholder
|
|
86
|
+
return "123 Example Street"
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def sanitize_output(data: dict[str, Any], serial_map: dict[str, str]) -> dict[str, Any]:
|
|
91
|
+
"""Sanitize sensitive data in the output structure.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
data: The output data dictionary
|
|
95
|
+
serial_map: Mapping of original serial -> sanitized serial
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Sanitized copy of the data
|
|
99
|
+
"""
|
|
100
|
+
import copy
|
|
101
|
+
|
|
102
|
+
result = copy.deepcopy(data)
|
|
103
|
+
|
|
104
|
+
# Sanitize metadata
|
|
105
|
+
if "metadata" in result:
|
|
106
|
+
meta = result["metadata"]
|
|
107
|
+
if "serial_num" in meta:
|
|
108
|
+
original = meta["serial_num"]
|
|
109
|
+
meta["serial_num"] = serial_map.get(original, sanitize_serial(original))
|
|
110
|
+
|
|
111
|
+
# Sanitize register blocks
|
|
112
|
+
if "register_blocks" in result:
|
|
113
|
+
for block in result["register_blocks"]:
|
|
114
|
+
if "sample_values" in block:
|
|
115
|
+
samples = block["sample_values"]
|
|
116
|
+
for key in list(samples.keys()):
|
|
117
|
+
# Sanitize known sensitive parameters
|
|
118
|
+
if key in SENSITIVE_PARAM_KEYS:
|
|
119
|
+
val = samples[key]
|
|
120
|
+
if isinstance(val, str) and len(val) >= 4:
|
|
121
|
+
# Check if it's a serial number in our map
|
|
122
|
+
if val in serial_map:
|
|
123
|
+
samples[key] = serial_map[val]
|
|
124
|
+
else:
|
|
125
|
+
samples[key] = sanitize_serial(val)
|
|
126
|
+
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def merge_ranges(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
|
131
|
+
"""Merge overlapping register ranges into consolidated ranges."""
|
|
132
|
+
if not ranges:
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
intervals = [(start, start + length - 1) for start, length in ranges]
|
|
136
|
+
intervals.sort(key=lambda x: x[0])
|
|
137
|
+
|
|
138
|
+
merged = [intervals[0]]
|
|
139
|
+
for current_start, current_end in intervals[1:]:
|
|
140
|
+
last_start, last_end = merged[-1]
|
|
141
|
+
if current_start <= last_end + 1:
|
|
142
|
+
merged[-1] = (last_start, max(last_end, current_end))
|
|
143
|
+
else:
|
|
144
|
+
merged.append((current_start, current_end))
|
|
145
|
+
|
|
146
|
+
return [(start, end - start + 1) for start, end in merged]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def find_min_block_size(
|
|
150
|
+
client: LuxpowerClient,
|
|
151
|
+
serial_num: str,
|
|
152
|
+
start_register: int,
|
|
153
|
+
max_size: int = 127,
|
|
154
|
+
) -> tuple[int | None, dict[str, Any]]:
|
|
155
|
+
"""Find minimum block size needed to get data from a register."""
|
|
156
|
+
for block_size in range(1, max_size + 1):
|
|
157
|
+
try:
|
|
158
|
+
response = await client.api.control.read_parameters(
|
|
159
|
+
serial_num,
|
|
160
|
+
start_register=start_register,
|
|
161
|
+
point_number=block_size,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if response.success and response.parameters:
|
|
165
|
+
return (block_size, response.parameters)
|
|
166
|
+
|
|
167
|
+
await asyncio.sleep(0.1)
|
|
168
|
+
|
|
169
|
+
except Exception:
|
|
170
|
+
await asyncio.sleep(0.1)
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
return (None, {})
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def validate_block_boundaries(
|
|
177
|
+
client: LuxpowerClient,
|
|
178
|
+
serial_num: str,
|
|
179
|
+
start_register: int,
|
|
180
|
+
block_size: int,
|
|
181
|
+
baseline_params: dict[str, Any],
|
|
182
|
+
) -> dict[str, Any]:
|
|
183
|
+
"""Detect leading empty registers in a multi-register block."""
|
|
184
|
+
if block_size <= 1:
|
|
185
|
+
return {
|
|
186
|
+
"original_start": start_register,
|
|
187
|
+
"original_size": block_size,
|
|
188
|
+
"actual_start": start_register,
|
|
189
|
+
"actual_size": block_size,
|
|
190
|
+
"leading_empty_registers": 0,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
baseline_param_keys = sorted(baseline_params.keys())
|
|
194
|
+
leading_empty = 0
|
|
195
|
+
|
|
196
|
+
for offset in range(1, block_size):
|
|
197
|
+
test_start = start_register + offset
|
|
198
|
+
test_size = block_size - offset
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
test_response = await client.api.control.read_parameters(
|
|
202
|
+
serial_num,
|
|
203
|
+
start_register=test_start,
|
|
204
|
+
point_number=test_size,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if not test_response.success or not test_response.parameters:
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
test_param_keys = sorted(test_response.parameters.keys())
|
|
211
|
+
|
|
212
|
+
if test_param_keys == baseline_param_keys:
|
|
213
|
+
leading_empty = offset
|
|
214
|
+
await asyncio.sleep(0.1)
|
|
215
|
+
else:
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
except Exception:
|
|
219
|
+
break
|
|
220
|
+
|
|
221
|
+
actual_start = start_register + leading_empty
|
|
222
|
+
actual_size = block_size - leading_empty
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
"original_start": start_register,
|
|
226
|
+
"original_size": block_size,
|
|
227
|
+
"actual_start": actual_start,
|
|
228
|
+
"actual_size": actual_size,
|
|
229
|
+
"leading_empty_registers": leading_empty,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def map_register_range(
|
|
234
|
+
client: LuxpowerClient,
|
|
235
|
+
serial_num: str,
|
|
236
|
+
start: int,
|
|
237
|
+
length: int,
|
|
238
|
+
validate_boundaries: bool = True,
|
|
239
|
+
indent: str = " ",
|
|
240
|
+
) -> list[dict[str, Any]]:
|
|
241
|
+
"""Map a register range using dynamic block sizing."""
|
|
242
|
+
print(f"{indent}Mapping registers {start} to {start + length - 1}")
|
|
243
|
+
|
|
244
|
+
blocks = []
|
|
245
|
+
range_end = start + length
|
|
246
|
+
current_reg = start
|
|
247
|
+
|
|
248
|
+
while current_reg < range_end:
|
|
249
|
+
print(f"{indent} Register {current_reg:4d}: ", end="", flush=True)
|
|
250
|
+
|
|
251
|
+
block_size, params = await find_min_block_size(
|
|
252
|
+
client, serial_num, current_reg, max_size=127
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if block_size is None:
|
|
256
|
+
print("No data - stopping scan")
|
|
257
|
+
break
|
|
258
|
+
|
|
259
|
+
param_keys = sorted(params.keys())
|
|
260
|
+
print(f"Block size={block_size:2d}, {len(param_keys):3d} params")
|
|
261
|
+
|
|
262
|
+
boundary_info: dict[str, Any] = {}
|
|
263
|
+
if validate_boundaries and block_size > 1:
|
|
264
|
+
boundary_info = await validate_block_boundaries(
|
|
265
|
+
client, serial_num, current_reg, block_size, params
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if boundary_info["leading_empty_registers"] > 0:
|
|
269
|
+
print(
|
|
270
|
+
f"{indent} -> Actual: register {boundary_info['actual_start']}, "
|
|
271
|
+
f"size {boundary_info['actual_size']} "
|
|
272
|
+
f"({boundary_info['leading_empty_registers']} leading empty)"
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
boundary_info = {
|
|
276
|
+
"original_start": current_reg,
|
|
277
|
+
"original_size": block_size,
|
|
278
|
+
"actual_start": current_reg,
|
|
279
|
+
"actual_size": block_size,
|
|
280
|
+
"leading_empty_registers": 0,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
blocks.append(
|
|
284
|
+
{
|
|
285
|
+
"start_register": current_reg,
|
|
286
|
+
"block_size": block_size,
|
|
287
|
+
"end_register": current_reg + block_size - 1,
|
|
288
|
+
"parameter_count": len(param_keys),
|
|
289
|
+
"parameter_keys": param_keys,
|
|
290
|
+
"sample_values": params,
|
|
291
|
+
"boundary_validation": boundary_info,
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
current_reg += block_size
|
|
296
|
+
|
|
297
|
+
return blocks
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def format_sample_value(value: Any) -> str:
|
|
301
|
+
"""Format a sample value for markdown table."""
|
|
302
|
+
if isinstance(value, (bool, int, float)):
|
|
303
|
+
return str(value)
|
|
304
|
+
elif isinstance(value, str):
|
|
305
|
+
return f'"{value}"'
|
|
306
|
+
else:
|
|
307
|
+
return str(value)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def create_markdown_report(data: dict[str, Any]) -> str:
|
|
311
|
+
"""Create markdown report from register data."""
|
|
312
|
+
metadata = data.get("metadata", {})
|
|
313
|
+
statistics = data.get("statistics", {})
|
|
314
|
+
register_blocks = data.get("register_blocks", [])
|
|
315
|
+
|
|
316
|
+
lines = []
|
|
317
|
+
|
|
318
|
+
# Title
|
|
319
|
+
device_type = metadata.get("device_type", "Unknown Device")
|
|
320
|
+
serial_num = metadata.get("serial_num", "Unknown")
|
|
321
|
+
lines.append(f"# {device_type} Register Map")
|
|
322
|
+
lines.append(f"## Serial Number: {serial_num}")
|
|
323
|
+
lines.append("")
|
|
324
|
+
|
|
325
|
+
# Metadata
|
|
326
|
+
lines.append("## Metadata")
|
|
327
|
+
lines.append("")
|
|
328
|
+
lines.append(f"- **Timestamp**: {metadata.get('timestamp', 'N/A')}")
|
|
329
|
+
lines.append(f"- **pylxpweb Version**: {metadata.get('pylxpweb_version', 'N/A')}")
|
|
330
|
+
lines.append(f"- **Base URL**: {metadata.get('base_url', 'N/A')}")
|
|
331
|
+
lines.append(f"- **Device Type**: {device_type}")
|
|
332
|
+
lines.append("")
|
|
333
|
+
|
|
334
|
+
# Statistics
|
|
335
|
+
lines.append("### Statistics")
|
|
336
|
+
lines.append("")
|
|
337
|
+
lines.append(f"- **Total Register Blocks**: {statistics.get('total_blocks', 'N/A')}")
|
|
338
|
+
lines.append(f"- **Total Parameters**: {statistics.get('total_parameters', 'N/A')}")
|
|
339
|
+
lines.append(
|
|
340
|
+
f"- **Blocks with Leading Empty**: {statistics.get('blocks_with_leading_empty', 'N/A')}"
|
|
341
|
+
)
|
|
342
|
+
lines.append("")
|
|
343
|
+
|
|
344
|
+
# Register table
|
|
345
|
+
lines.append("## Register Map")
|
|
346
|
+
lines.append("")
|
|
347
|
+
lines.append("| Register | Start | Length | Parameters | Sample Values |")
|
|
348
|
+
lines.append("|----------|-------|--------|------------|---------------|")
|
|
349
|
+
|
|
350
|
+
for block in register_blocks:
|
|
351
|
+
original_start = block["start_register"]
|
|
352
|
+
original_size = block["block_size"]
|
|
353
|
+
params = block["parameter_keys"]
|
|
354
|
+
samples = block["sample_values"]
|
|
355
|
+
|
|
356
|
+
boundary = block.get("boundary_validation", {})
|
|
357
|
+
actual_start = boundary.get("actual_start", original_start)
|
|
358
|
+
actual_size = boundary.get("actual_size", original_size)
|
|
359
|
+
leading_empty = boundary.get("leading_empty_registers", 0)
|
|
360
|
+
|
|
361
|
+
# Add row for leading empty registers
|
|
362
|
+
if leading_empty > 0:
|
|
363
|
+
empty_start = original_start
|
|
364
|
+
if leading_empty == 1:
|
|
365
|
+
empty_display = str(empty_start)
|
|
366
|
+
else:
|
|
367
|
+
empty_end = empty_start + leading_empty - 1
|
|
368
|
+
empty_display = f"{empty_start}-{empty_end}"
|
|
369
|
+
lines.append(f"| {empty_display} | {empty_start} | {leading_empty} | `<EMPTY>` | - |")
|
|
370
|
+
|
|
371
|
+
# Format register display
|
|
372
|
+
if actual_size == 1:
|
|
373
|
+
reg_display = str(actual_start)
|
|
374
|
+
else:
|
|
375
|
+
end_reg = actual_start + actual_size - 1
|
|
376
|
+
reg_display = f"{actual_start}-{end_reg}"
|
|
377
|
+
|
|
378
|
+
if len(params) == 0:
|
|
379
|
+
lines.append(f"| {reg_display} | {actual_start} | {actual_size} | `<EMPTY>` | - |")
|
|
380
|
+
elif len(params) == 1:
|
|
381
|
+
param = params[0]
|
|
382
|
+
value = format_sample_value(samples.get(param, "N/A"))
|
|
383
|
+
lines.append(
|
|
384
|
+
f"| {reg_display} | {actual_start} | {actual_size} | `{param}` | {value} |"
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
param_list = "<br>".join([f"`{p}`" for p in params])
|
|
388
|
+
value_list = "<br>".join([format_sample_value(samples.get(p, "N/A")) for p in params])
|
|
389
|
+
lines.append(
|
|
390
|
+
f"| {reg_display} | {actual_start} | {actual_size} | {param_list} | {value_list} |"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
lines.append("")
|
|
394
|
+
lines.append("---")
|
|
395
|
+
lines.append("")
|
|
396
|
+
lines.append(f"*Generated by pylxpweb v{__version__}*")
|
|
397
|
+
|
|
398
|
+
return "\n".join(lines)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def get_default_ranges(device_type: str) -> list[tuple[int, int]]:
|
|
402
|
+
"""Get default register ranges based on device type."""
|
|
403
|
+
device_lower = device_type.lower()
|
|
404
|
+
|
|
405
|
+
if "gridboss" in device_lower or "grid boss" in device_lower or "mid" in device_lower:
|
|
406
|
+
# GridBOSS/MID devices have extended register ranges
|
|
407
|
+
return [(0, 381), (2032, 127)]
|
|
408
|
+
else:
|
|
409
|
+
# Standard inverters (18KPV, etc.)
|
|
410
|
+
return [(0, 127), (127, 127), (240, 127)]
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
async def discover_all_devices(
|
|
414
|
+
client: LuxpowerClient,
|
|
415
|
+
) -> list[dict[str, Any]]:
|
|
416
|
+
"""Discover all plants and devices in the account."""
|
|
417
|
+
devices: list[dict[str, Any]] = []
|
|
418
|
+
|
|
419
|
+
plants = await client.api.plants.get_plants()
|
|
420
|
+
|
|
421
|
+
for plant in plants.rows:
|
|
422
|
+
plant_devices = await client.api.devices.get_devices(plant.plantId)
|
|
423
|
+
for device in plant_devices.rows:
|
|
424
|
+
devices.append(
|
|
425
|
+
{
|
|
426
|
+
"serial_num": device.serialNum,
|
|
427
|
+
"device_type": device.deviceTypeText,
|
|
428
|
+
"plant_id": plant.plantId,
|
|
429
|
+
"plant_name": plant.name,
|
|
430
|
+
"status": device.statusText,
|
|
431
|
+
}
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
return devices
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
async def collect_single_device(
|
|
438
|
+
client: LuxpowerClient,
|
|
439
|
+
serial_num: str,
|
|
440
|
+
device_type: str,
|
|
441
|
+
output_dir: Path,
|
|
442
|
+
sanitize: bool = False,
|
|
443
|
+
serial_map: dict[str, str] | None = None,
|
|
444
|
+
) -> tuple[Path, Path] | None:
|
|
445
|
+
"""Collect data from a single device."""
|
|
446
|
+
if serial_map is None:
|
|
447
|
+
serial_map = {}
|
|
448
|
+
# Get register ranges for this device type
|
|
449
|
+
ranges = get_default_ranges(device_type)
|
|
450
|
+
merged_ranges = merge_ranges(ranges)
|
|
451
|
+
|
|
452
|
+
print(f" Register ranges: {len(merged_ranges)}")
|
|
453
|
+
for start, length in merged_ranges:
|
|
454
|
+
print(f" - {start} to {start + length - 1}")
|
|
455
|
+
|
|
456
|
+
# Map all register ranges
|
|
457
|
+
all_blocks: list[dict[str, Any]] = []
|
|
458
|
+
for range_idx, (start, length) in enumerate(merged_ranges, 1):
|
|
459
|
+
print(f" Scanning range {range_idx}/{len(merged_ranges)}...")
|
|
460
|
+
try:
|
|
461
|
+
blocks = await map_register_range(
|
|
462
|
+
client, serial_num, start, length, validate_boundaries=True, indent=" "
|
|
463
|
+
)
|
|
464
|
+
all_blocks.extend(blocks)
|
|
465
|
+
except Exception as e:
|
|
466
|
+
print(f" Error scanning range: {e}")
|
|
467
|
+
continue
|
|
468
|
+
|
|
469
|
+
if not all_blocks:
|
|
470
|
+
print(" No data collected - device may be offline")
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
# Calculate statistics
|
|
474
|
+
all_params: set[str] = set()
|
|
475
|
+
for block in all_blocks:
|
|
476
|
+
all_params.update(block["parameter_keys"])
|
|
477
|
+
|
|
478
|
+
blocks_with_leading_empty = [
|
|
479
|
+
b for b in all_blocks if b["boundary_validation"]["leading_empty_registers"] > 0
|
|
480
|
+
]
|
|
481
|
+
|
|
482
|
+
# Build output structure
|
|
483
|
+
output = {
|
|
484
|
+
"metadata": {
|
|
485
|
+
"timestamp": datetime.now().astimezone().isoformat(),
|
|
486
|
+
"pylxpweb_version": __version__,
|
|
487
|
+
"base_url": client.base_url,
|
|
488
|
+
"serial_num": serial_num,
|
|
489
|
+
"device_type": device_type,
|
|
490
|
+
"merged_ranges": [
|
|
491
|
+
{"start": start, "length": length, "end": start + length - 1}
|
|
492
|
+
for start, length in merged_ranges
|
|
493
|
+
],
|
|
494
|
+
},
|
|
495
|
+
"statistics": {
|
|
496
|
+
"total_blocks": len(all_blocks),
|
|
497
|
+
"total_parameters": len(all_params),
|
|
498
|
+
"blocks_with_leading_empty": len(blocks_with_leading_empty),
|
|
499
|
+
},
|
|
500
|
+
"register_blocks": all_blocks,
|
|
501
|
+
"all_parameter_names": sorted(all_params),
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
# Apply sanitization if requested
|
|
505
|
+
if sanitize:
|
|
506
|
+
output = sanitize_output(output, serial_map)
|
|
507
|
+
# Use sanitized serial for filename
|
|
508
|
+
serial_for_filename = serial_map.get(serial_num, sanitize_serial(serial_num))
|
|
509
|
+
else:
|
|
510
|
+
serial_for_filename = serial_num
|
|
511
|
+
|
|
512
|
+
# Generate filenames
|
|
513
|
+
device_type_clean = device_type.replace(" ", "").replace("-", "")
|
|
514
|
+
base_name = f"{device_type_clean}_{serial_for_filename}"
|
|
515
|
+
|
|
516
|
+
json_path = output_dir / f"{base_name}.json"
|
|
517
|
+
md_path = output_dir / f"{base_name}.md"
|
|
518
|
+
|
|
519
|
+
# Write JSON
|
|
520
|
+
print(f" Writing {json_path.name}...")
|
|
521
|
+
with open(json_path, "w", encoding="utf-8") as f:
|
|
522
|
+
json.dump(output, f, indent=2, default=str)
|
|
523
|
+
|
|
524
|
+
# Write Markdown
|
|
525
|
+
print(f" Writing {md_path.name}...")
|
|
526
|
+
markdown_content = create_markdown_report(output)
|
|
527
|
+
with open(md_path, "w", encoding="utf-8") as f:
|
|
528
|
+
f.write(markdown_content)
|
|
529
|
+
|
|
530
|
+
return json_path, md_path
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def create_zip_archive(
|
|
534
|
+
created_files: list[tuple[Path, Path]],
|
|
535
|
+
output_dir: Path,
|
|
536
|
+
sanitize: bool = False,
|
|
537
|
+
) -> Path:
|
|
538
|
+
"""Create a zip archive of all generated files.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
created_files: List of (json_path, md_path) tuples
|
|
542
|
+
output_dir: Directory where zip should be created
|
|
543
|
+
sanitize: Whether files were sanitized (affects filename)
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
Path to the created zip file
|
|
547
|
+
"""
|
|
548
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
549
|
+
suffix = "_sanitized" if sanitize else ""
|
|
550
|
+
zip_name = f"pylxpweb_device_data_{timestamp}{suffix}.zip"
|
|
551
|
+
zip_path = output_dir / zip_name
|
|
552
|
+
|
|
553
|
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
554
|
+
for json_path, md_path in created_files:
|
|
555
|
+
# Add files with just their filename (no directory structure)
|
|
556
|
+
zf.write(json_path, json_path.name)
|
|
557
|
+
zf.write(md_path, md_path.name)
|
|
558
|
+
|
|
559
|
+
return zip_path
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def generate_issue_url(
|
|
563
|
+
devices: list[dict[str, Any]],
|
|
564
|
+
zip_filename: str,
|
|
565
|
+
sanitized: bool = False,
|
|
566
|
+
) -> str:
|
|
567
|
+
"""Generate a pre-filled GitHub issue URL.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
devices: List of device info dicts with device_type, serial_num, status
|
|
571
|
+
zip_filename: Name of the zip file to attach
|
|
572
|
+
sanitized: Whether data was sanitized
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
URL string with pre-filled title and body
|
|
576
|
+
"""
|
|
577
|
+
# Build title from device types
|
|
578
|
+
device_types = sorted({d["device_type"] for d in devices})
|
|
579
|
+
if len(device_types) == 1:
|
|
580
|
+
title = f"Add support for {device_types[0]}"
|
|
581
|
+
else:
|
|
582
|
+
title = f"Add support for {', '.join(device_types)}"
|
|
583
|
+
|
|
584
|
+
# Build body with device info
|
|
585
|
+
body_lines = [
|
|
586
|
+
"## Device Information",
|
|
587
|
+
"",
|
|
588
|
+
"| Device Type | Serial | Status |",
|
|
589
|
+
"|-------------|--------|--------|",
|
|
590
|
+
]
|
|
591
|
+
|
|
592
|
+
for device in devices:
|
|
593
|
+
dtype = device["device_type"]
|
|
594
|
+
serial = device.get("display_serial", device["serial_num"])
|
|
595
|
+
status = device["status"]
|
|
596
|
+
body_lines.append(f"| {dtype} | {serial} | {status} |")
|
|
597
|
+
|
|
598
|
+
body_lines.extend(
|
|
599
|
+
[
|
|
600
|
+
"",
|
|
601
|
+
"## Features Needed",
|
|
602
|
+
"",
|
|
603
|
+
"<!-- Please describe what features you need supported -->",
|
|
604
|
+
"- [ ] Battery monitoring and control",
|
|
605
|
+
"- [ ] Grid export/import limits",
|
|
606
|
+
"- [ ] Time-of-use scheduling",
|
|
607
|
+
"- [ ] Other: ",
|
|
608
|
+
"",
|
|
609
|
+
"## Inverter Details",
|
|
610
|
+
"",
|
|
611
|
+
"- **Firmware Version**: <!-- Check your inverter's display or web portal -->",
|
|
612
|
+
"- **Inverter Type**: <!-- Hybrid / Grid-tie / Off-grid -->",
|
|
613
|
+
"- **Battery Type**: <!-- LiFePO4 / Lead-acid / None -->",
|
|
614
|
+
"",
|
|
615
|
+
"## Attached Data",
|
|
616
|
+
"",
|
|
617
|
+
f"Please attach the zip file: `{zip_filename}`",
|
|
618
|
+
"",
|
|
619
|
+
"---",
|
|
620
|
+
f"*Collected with pylxpweb v{__version__}*",
|
|
621
|
+
]
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
if sanitized:
|
|
625
|
+
body_lines.insert(0, "> Note: Serial numbers have been sanitized for privacy.\n")
|
|
626
|
+
|
|
627
|
+
body = "\n".join(body_lines)
|
|
628
|
+
|
|
629
|
+
# Build URL with query parameters
|
|
630
|
+
params = {
|
|
631
|
+
"title": title,
|
|
632
|
+
"body": body,
|
|
633
|
+
"labels": "new-device",
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return f"{GITHUB_ISSUES_URL}?{urlencode(params, quote_via=quote)}"
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def print_upload_instructions(
|
|
640
|
+
devices: list[dict[str, Any]],
|
|
641
|
+
created_files: list[tuple[Path, Path]],
|
|
642
|
+
zip_path: Path | None = None,
|
|
643
|
+
sanitized: bool = False,
|
|
644
|
+
) -> None:
|
|
645
|
+
"""Print instructions for uploading the data."""
|
|
646
|
+
print("\n" + "=" * 70)
|
|
647
|
+
print(" UPLOAD INSTRUCTIONS")
|
|
648
|
+
print("=" * 70)
|
|
649
|
+
print()
|
|
650
|
+
|
|
651
|
+
# Generate pre-filled issue URL
|
|
652
|
+
if zip_path:
|
|
653
|
+
issue_url = generate_issue_url(devices, zip_path.name, sanitized)
|
|
654
|
+
print("CLICK THIS LINK to create a pre-filled GitHub issue:")
|
|
655
|
+
print()
|
|
656
|
+
print(f" {issue_url}")
|
|
657
|
+
print()
|
|
658
|
+
print("-" * 70)
|
|
659
|
+
print()
|
|
660
|
+
print("After the page opens:")
|
|
661
|
+
print()
|
|
662
|
+
print("1. Review the pre-filled information (edit if needed)")
|
|
663
|
+
print()
|
|
664
|
+
print("2. ATTACH this zip file by dragging it into the description:")
|
|
665
|
+
print(f" >>> {zip_path.name} <<<")
|
|
666
|
+
print()
|
|
667
|
+
print("3. Click 'Submit new issue'")
|
|
668
|
+
else:
|
|
669
|
+
print("Create a new GitHub issue at:")
|
|
670
|
+
print(f" {GITHUB_ISSUES_URL}")
|
|
671
|
+
print()
|
|
672
|
+
print("Attach these files:")
|
|
673
|
+
for json_path, md_path in created_files:
|
|
674
|
+
print(f" - {json_path.name}")
|
|
675
|
+
print(f" - {md_path.name}")
|
|
676
|
+
|
|
677
|
+
print()
|
|
678
|
+
print("-" * 70)
|
|
679
|
+
if sanitized:
|
|
680
|
+
print("NOTE: Sensitive data (serial numbers, locations) has been SANITIZED.")
|
|
681
|
+
print(" The files are safe to share publicly.")
|
|
682
|
+
else:
|
|
683
|
+
print("NOTE: Serial numbers are NOT sanitized. Re-run with --sanitize if needed.")
|
|
684
|
+
print()
|
|
685
|
+
print("Questions? Start a discussion at:")
|
|
686
|
+
print(f" {GITHUB_DISCUSSIONS_URL}")
|
|
687
|
+
print("=" * 70)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
async def main_async(args: argparse.Namespace) -> int:
|
|
691
|
+
"""Async main entry point."""
|
|
692
|
+
username = args.username
|
|
693
|
+
password = args.password
|
|
694
|
+
base_url = args.base_url or "https://monitor.eg4electronics.com"
|
|
695
|
+
sanitize = args.sanitize
|
|
696
|
+
|
|
697
|
+
# Create output directory
|
|
698
|
+
output_dir = Path(args.output_dir) if args.output_dir else Path.cwd()
|
|
699
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
700
|
+
|
|
701
|
+
print()
|
|
702
|
+
print("=" * 70)
|
|
703
|
+
print(" pylxpweb Device Data Collection Tool")
|
|
704
|
+
print(f" Version: {__version__}")
|
|
705
|
+
print("=" * 70)
|
|
706
|
+
print()
|
|
707
|
+
print(f"Base URL: {base_url}")
|
|
708
|
+
print(f"Output Directory: {output_dir.absolute()}")
|
|
709
|
+
if sanitize:
|
|
710
|
+
print("Sanitization: ENABLED (serial numbers and locations will be masked)")
|
|
711
|
+
print()
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
async with LuxpowerClient(username, password, base_url=base_url) as client:
|
|
715
|
+
# Discover all devices
|
|
716
|
+
print("Discovering devices in your account...")
|
|
717
|
+
devices = await discover_all_devices(client)
|
|
718
|
+
|
|
719
|
+
if not devices:
|
|
720
|
+
print("\nNo devices found in your account.")
|
|
721
|
+
print("Please check your credentials and try again.")
|
|
722
|
+
return 1
|
|
723
|
+
|
|
724
|
+
# Build serial map for sanitization (all serials discovered)
|
|
725
|
+
serial_map: dict[str, str] = {}
|
|
726
|
+
if sanitize:
|
|
727
|
+
for device in devices:
|
|
728
|
+
original = device["serial_num"]
|
|
729
|
+
serial_map[original] = sanitize_serial(original)
|
|
730
|
+
|
|
731
|
+
# Add display_serial to each device for use in issue URL
|
|
732
|
+
for device in devices:
|
|
733
|
+
serial = device["serial_num"]
|
|
734
|
+
device["display_serial"] = serial_map.get(serial, serial) if sanitize else serial
|
|
735
|
+
|
|
736
|
+
print(f"\nFound {len(devices)} device(s):")
|
|
737
|
+
for i, device in enumerate(devices, 1):
|
|
738
|
+
dtype = device["device_type"]
|
|
739
|
+
display_serial = device["display_serial"]
|
|
740
|
+
status = device["status"]
|
|
741
|
+
print(f" {i}. {dtype} ({display_serial}) - {status}")
|
|
742
|
+
print()
|
|
743
|
+
|
|
744
|
+
# Collect data from each device
|
|
745
|
+
created_files: list[tuple[Path, Path]] = []
|
|
746
|
+
for i, device in enumerate(devices, 1):
|
|
747
|
+
display_serial = (
|
|
748
|
+
serial_map.get(device["serial_num"], device["serial_num"])
|
|
749
|
+
if sanitize
|
|
750
|
+
else device["serial_num"]
|
|
751
|
+
)
|
|
752
|
+
print(f"\n[{i}/{len(devices)}] Processing: {device['device_type']}")
|
|
753
|
+
print(f" Serial: {display_serial}")
|
|
754
|
+
print(f" Status: {device['status']}")
|
|
755
|
+
|
|
756
|
+
result = await collect_single_device(
|
|
757
|
+
client,
|
|
758
|
+
device["serial_num"],
|
|
759
|
+
device["device_type"],
|
|
760
|
+
output_dir,
|
|
761
|
+
sanitize=sanitize,
|
|
762
|
+
serial_map=serial_map,
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
if result:
|
|
766
|
+
created_files.append(result)
|
|
767
|
+
print(" Done!")
|
|
768
|
+
|
|
769
|
+
if not created_files:
|
|
770
|
+
print("\nNo data was collected. All devices may be offline.")
|
|
771
|
+
return 1
|
|
772
|
+
|
|
773
|
+
# Create zip archive
|
|
774
|
+
print("\nCreating zip archive...")
|
|
775
|
+
zip_path = create_zip_archive(created_files, output_dir, sanitize)
|
|
776
|
+
print(f" Created: {zip_path.name}")
|
|
777
|
+
|
|
778
|
+
# Print summary
|
|
779
|
+
print()
|
|
780
|
+
print("=" * 70)
|
|
781
|
+
print(" Collection Complete!")
|
|
782
|
+
print("=" * 70)
|
|
783
|
+
print()
|
|
784
|
+
print(f"Files created in: {output_dir.absolute()}")
|
|
785
|
+
print()
|
|
786
|
+
print(" ZIP FILE (attach this):")
|
|
787
|
+
print(f" >>> {zip_path.name} <<<")
|
|
788
|
+
print()
|
|
789
|
+
print(" Individual files (included in zip):")
|
|
790
|
+
for json_path, md_path in created_files:
|
|
791
|
+
print(f" - {json_path.name}")
|
|
792
|
+
print(f" - {md_path.name}")
|
|
793
|
+
|
|
794
|
+
# Print upload instructions with pre-filled issue link
|
|
795
|
+
print_upload_instructions(devices, created_files, zip_path, sanitize)
|
|
796
|
+
|
|
797
|
+
return 0
|
|
798
|
+
|
|
799
|
+
except Exception as e:
|
|
800
|
+
print(f"\nError: {e}")
|
|
801
|
+
import traceback
|
|
802
|
+
|
|
803
|
+
traceback.print_exc()
|
|
804
|
+
return 1
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def main() -> None:
|
|
808
|
+
"""Main entry point."""
|
|
809
|
+
parser = argparse.ArgumentParser(
|
|
810
|
+
description="Automatically collect device data from all inverters in your account",
|
|
811
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
812
|
+
epilog="""
|
|
813
|
+
Examples:
|
|
814
|
+
# Standard usage (EG4 users)
|
|
815
|
+
pylxpweb-collect -u your@email.com -p YourPassword
|
|
816
|
+
|
|
817
|
+
# EU Luxpower portal users
|
|
818
|
+
pylxpweb-collect -u your@email.com -p YourPassword -b https://eu.luxpowertek.com
|
|
819
|
+
|
|
820
|
+
# US Luxpower portal users
|
|
821
|
+
pylxpweb-collect -u your@email.com -p YourPassword -b https://us.luxpowertek.com
|
|
822
|
+
|
|
823
|
+
# Save files to a specific folder
|
|
824
|
+
pylxpweb-collect -u your@email.com -p YourPassword -o ./my_inverter_data
|
|
825
|
+
|
|
826
|
+
Regional API Endpoints:
|
|
827
|
+
- EG4 (US): https://monitor.eg4electronics.com (default)
|
|
828
|
+
- Luxpower (US): https://us.luxpowertek.com
|
|
829
|
+
- Luxpower (EU): https://eu.luxpowertek.com
|
|
830
|
+
|
|
831
|
+
The tool will automatically:
|
|
832
|
+
1. Discover all plants and devices in your account
|
|
833
|
+
2. Read all register values from each device
|
|
834
|
+
3. Generate JSON and Markdown reports for each device
|
|
835
|
+
|
|
836
|
+
For detailed instructions: https://github.com/joyfulhouse/pylxpweb/blob/main/docs/COLLECT_DEVICE_DATA.md
|
|
837
|
+
""",
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
parser.add_argument(
|
|
841
|
+
"--username",
|
|
842
|
+
"-u",
|
|
843
|
+
required=True,
|
|
844
|
+
help="Your EG4/Luxpower web portal username (email)",
|
|
845
|
+
)
|
|
846
|
+
parser.add_argument(
|
|
847
|
+
"--password",
|
|
848
|
+
"-p",
|
|
849
|
+
required=True,
|
|
850
|
+
help="Your EG4/Luxpower web portal password",
|
|
851
|
+
)
|
|
852
|
+
parser.add_argument(
|
|
853
|
+
"--base-url",
|
|
854
|
+
"-b",
|
|
855
|
+
help="API base URL (default: https://monitor.eg4electronics.com)",
|
|
856
|
+
)
|
|
857
|
+
parser.add_argument(
|
|
858
|
+
"--output-dir",
|
|
859
|
+
"-o",
|
|
860
|
+
help="Output directory for generated files (default: current directory)",
|
|
861
|
+
)
|
|
862
|
+
parser.add_argument(
|
|
863
|
+
"--sanitize",
|
|
864
|
+
"-S",
|
|
865
|
+
action="store_true",
|
|
866
|
+
help="Sanitize sensitive data (serial numbers, plant names, locations) in output files",
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
args = parser.parse_args()
|
|
870
|
+
sys.exit(asyncio.run(main_async(args)))
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
if __name__ == "__main__":
|
|
874
|
+
main()
|