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.
Files changed (46) hide show
  1. pylxpweb/__init__.py +47 -2
  2. pylxpweb/api_namespace.py +241 -0
  3. pylxpweb/cli/__init__.py +3 -0
  4. pylxpweb/cli/collect_device_data.py +874 -0
  5. pylxpweb/client.py +387 -26
  6. pylxpweb/constants/__init__.py +481 -0
  7. pylxpweb/constants/api.py +48 -0
  8. pylxpweb/constants/devices.py +98 -0
  9. pylxpweb/constants/locations.py +227 -0
  10. pylxpweb/{constants.py → constants/registers.py} +72 -238
  11. pylxpweb/constants/scaling.py +479 -0
  12. pylxpweb/devices/__init__.py +32 -0
  13. pylxpweb/devices/_firmware_update_mixin.py +504 -0
  14. pylxpweb/devices/_mid_runtime_properties.py +545 -0
  15. pylxpweb/devices/base.py +122 -0
  16. pylxpweb/devices/battery.py +589 -0
  17. pylxpweb/devices/battery_bank.py +331 -0
  18. pylxpweb/devices/inverters/__init__.py +32 -0
  19. pylxpweb/devices/inverters/_features.py +378 -0
  20. pylxpweb/devices/inverters/_runtime_properties.py +596 -0
  21. pylxpweb/devices/inverters/base.py +2124 -0
  22. pylxpweb/devices/inverters/generic.py +192 -0
  23. pylxpweb/devices/inverters/hybrid.py +274 -0
  24. pylxpweb/devices/mid_device.py +183 -0
  25. pylxpweb/devices/models.py +126 -0
  26. pylxpweb/devices/parallel_group.py +351 -0
  27. pylxpweb/devices/station.py +908 -0
  28. pylxpweb/endpoints/control.py +980 -2
  29. pylxpweb/endpoints/devices.py +249 -16
  30. pylxpweb/endpoints/firmware.py +43 -10
  31. pylxpweb/endpoints/plants.py +15 -19
  32. pylxpweb/exceptions.py +4 -0
  33. pylxpweb/models.py +629 -40
  34. pylxpweb/transports/__init__.py +78 -0
  35. pylxpweb/transports/capabilities.py +101 -0
  36. pylxpweb/transports/data.py +495 -0
  37. pylxpweb/transports/exceptions.py +59 -0
  38. pylxpweb/transports/factory.py +119 -0
  39. pylxpweb/transports/http.py +329 -0
  40. pylxpweb/transports/modbus.py +557 -0
  41. pylxpweb/transports/protocol.py +217 -0
  42. {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.0.dist-info}/METADATA +130 -85
  43. pylxpweb-0.5.0.dist-info/RECORD +52 -0
  44. {pylxpweb-0.1.0.dist-info → pylxpweb-0.5.0.dist-info}/WHEEL +1 -1
  45. pylxpweb-0.5.0.dist-info/entry_points.txt +3 -0
  46. 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()