voltkeeper 2026.5__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 (41) hide show
  1. voltkeeper/__init__.py +0 -0
  2. voltkeeper/_version.py +24 -0
  3. voltkeeper/annotate.py +325 -0
  4. voltkeeper/bluetooth/__init__.py +156 -0
  5. voltkeeper/bluetooth/cipher.py +59 -0
  6. voltkeeper/bluetooth/client.py +114 -0
  7. voltkeeper/bluetooth/exc.py +13 -0
  8. voltkeeper/bluetooth/handshake.py +146 -0
  9. voltkeeper/bus.py +51 -0
  10. voltkeeper/cli.py +1248 -0
  11. voltkeeper/core/__init__.py +0 -0
  12. voltkeeper/core/commands.py +88 -0
  13. voltkeeper/core/devices/__init__.py +0 -0
  14. voltkeeper/core/devices/_v1_alarm_tables.py +223 -0
  15. voltkeeper/core/devices/ac200l.py +102 -0
  16. voltkeeper/core/devices/ac200m.py +54 -0
  17. voltkeeper/core/devices/ac200pl.py +17 -0
  18. voltkeeper/core/devices/ac2a.py +194 -0
  19. voltkeeper/core/devices/ac300.py +91 -0
  20. voltkeeper/core/devices/ac500.py +100 -0
  21. voltkeeper/core/devices/ac60.py +73 -0
  22. voltkeeper/core/devices/bluetti_device.py +48 -0
  23. voltkeeper/core/devices/eb3a.py +48 -0
  24. voltkeeper/core/devices/ep500.py +11 -0
  25. voltkeeper/core/devices/ep600.py +11 -0
  26. voltkeeper/core/devices/v1_base.py +219 -0
  27. voltkeeper/core/devices/v2_base.py +237 -0
  28. voltkeeper/core/struct.py +281 -0
  29. voltkeeper/core/utils.py +66 -0
  30. voltkeeper/device_handler.py +118 -0
  31. voltkeeper/load_test.py +568 -0
  32. voltkeeper/mqtt_client.py +573 -0
  33. voltkeeper/probe.py +197 -0
  34. voltkeeper/scrub.py +87 -0
  35. voltkeeper/shutdown_watch.py +125 -0
  36. voltkeeper/validate.py +99 -0
  37. voltkeeper-2026.5.dist-info/METADATA +298 -0
  38. voltkeeper-2026.5.dist-info/RECORD +41 -0
  39. voltkeeper-2026.5.dist-info/WHEEL +4 -0
  40. voltkeeper-2026.5.dist-info/entry_points.txt +5 -0
  41. voltkeeper-2026.5.dist-info/licenses/LICENSE +21 -0
voltkeeper/__init__.py ADDED
File without changes
voltkeeper/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '2026.5'
22
+ __version_tuple__ = version_tuple = (2026, 5)
23
+
24
+ __commit_id__ = commit_id = None
voltkeeper/annotate.py ADDED
@@ -0,0 +1,325 @@
1
+ # ABOUTME: Interactive annotate REPL — live polling, register-change detection, field-name prompts.
2
+ # ABOUTME: Unit 13 per IMPLEMENTATION_UNITS.md.
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from pathlib import Path
8
+
9
+ import click
10
+ import yaml
11
+
12
+ from .bluetooth.client import BluetoothClient
13
+ from .core.commands import ReadHoldingRegisters
14
+ from .probe import V1_BLOCKS, V2_BLOCKS, _detect_protocol
15
+ from .scrub import SYNTHETIC_SN_STR, scrub_profile, split_model_sn
16
+
17
+ # ── Diff helper ───────────────────────────────────────────────────────
18
+
19
+
20
+ def _diff(prev: bytes | None, curr: bytes) -> list[tuple[int, int, int]]:
21
+ """Compare two byte sequences, return (offset, old_byte, new_byte) for each change.
22
+
23
+ If *prev* is None (first read), returns empty list.
24
+ Diffs only up to the shorter of the two lengths.
25
+ """
26
+ if prev is None:
27
+ return []
28
+ length = min(len(prev), len(curr))
29
+ changes: list[tuple[int, int, int]] = []
30
+ for i in range(length):
31
+ if prev[i] != curr[i]:
32
+ changes.append((i, prev[i], curr[i]))
33
+ return changes
34
+
35
+
36
+ # ── File helpers ──────────────────────────────────────────────────────
37
+
38
+
39
+ def _load_or_init(profile_path: Path) -> dict:
40
+ """Load an existing YAML profile or return a fresh skeleton."""
41
+ if profile_path.exists():
42
+ with open(profile_path) as f:
43
+ profile = yaml.safe_load(f)
44
+ if isinstance(profile, dict):
45
+ return profile
46
+ return {"annotations": []}
47
+
48
+
49
+ def _save(profile: dict, profile_path: Path) -> None:
50
+ """Atomically write *profile* to *profile_path* as YAML.
51
+
52
+ Writes to a sibling temp file then ``os.replace``s it into place so
53
+ a Ctrl-C mid-write can't truncate an existing valid YAML.
54
+ """
55
+ import os
56
+
57
+ profile_path.parent.mkdir(parents=True, exist_ok=True)
58
+ tmp_path = profile_path.with_suffix(profile_path.suffix + ".tmp")
59
+ with open(tmp_path, "w") as f:
60
+ yaml.dump(profile, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
61
+ f.flush()
62
+ os.fsync(f.fileno())
63
+ os.replace(tmp_path, profile_path)
64
+
65
+
66
+ def _save_scrubbed(profile: dict, profile_path: Path) -> None:
67
+ """Save *profile* with the SN replaced by a synthetic placeholder.
68
+
69
+ Use this for any annotate persistence: the in-memory dict keeps the
70
+ real BLE name for registry-shortcut lookups across sessions, while
71
+ the on-disk YAML is privacy-safe to share.
72
+ """
73
+ _save(scrub_profile(profile), profile_path)
74
+
75
+
76
+ # ── UX helpers ────────────────────────────────────────────────────────
77
+
78
+
79
+ def _registry_field_hints() -> list[str]:
80
+ """Collect the union of WRITABLE_FIELD_NAMES across registered devices.
81
+
82
+ Surfaced to the user at session start so they have a vocabulary to
83
+ pull from when labelling changed bytes.
84
+ """
85
+ from .bluetooth import _device_registry
86
+
87
+ names: set[str] = set()
88
+ for cls in _device_registry().values():
89
+ names.update(getattr(cls, "WRITABLE_FIELD_NAMES", []))
90
+ return sorted(names)
91
+
92
+
93
+ BASELINE_POLLS = 10
94
+ """Cycles to observe at session start before listening for user-driven changes.
95
+
96
+ At ~1 second per cycle, ~10 polls is enough to catch the obvious noise
97
+ (currents, energy counters, frequency drift) without making the user wait
98
+ forever before they can interact with the device. Slowly-changing fields
99
+ (SOC ticks every few minutes, chgFullTime once a minute) won't show up
100
+ during baseline and will trigger one-off "spurious" changes during the
101
+ listening phase — acceptable cost.
102
+ """
103
+
104
+
105
+ def _annotation_index(profile: dict) -> dict[tuple[str, int], str]:
106
+ """Build a ``(block_name, offset) → name`` lookup from ``profile['annotations']``.
107
+
108
+ Used to surface the existing label for an already-labelled byte
109
+ when it changes again. Last entry wins on duplicate keys, matching
110
+ the latest-wins semantics enforced by ``_replace_annotation``.
111
+ """
112
+ out: dict[tuple[str, int], str] = {}
113
+ for entry in profile.get("annotations", []) or []:
114
+ if not isinstance(entry, dict):
115
+ continue
116
+ block = entry.get("block")
117
+ offset = entry.get("offset")
118
+ name = entry.get("name")
119
+ if isinstance(block, str) and isinstance(offset, int) and isinstance(name, str):
120
+ out[(block, offset)] = name
121
+ return out
122
+
123
+
124
+ def _replace_annotation(profile: dict, block: str, offset: int, name: str) -> None:
125
+ """Set the annotation for ``(block, offset)`` to *name*, removing any prior entry.
126
+
127
+ Latest-wins: typing a new name for an already-labelled byte replaces
128
+ the old label rather than stacking another entry alongside it. Keeps
129
+ the YAML clean for downstream maintainer consumption.
130
+ """
131
+ annotations = profile.setdefault("annotations", [])
132
+ profile["annotations"] = [
133
+ e for e in annotations if not (isinstance(e, dict) and e.get("block") == block and e.get("offset") == offset)
134
+ ]
135
+ profile["annotations"].append({"block": block, "offset": offset, "name": name})
136
+
137
+
138
+ async def _capture_baseline(
139
+ client: BluetoothClient,
140
+ blocks: list[tuple[int, int, str]],
141
+ polls: int = BASELINE_POLLS,
142
+ ) -> tuple[dict[str, bytes], dict[str, set[int]]]:
143
+ """Sample blocks for *polls* cycles. Return (last snapshot, volatile bytes).
144
+
145
+ A byte offset is "volatile" if its value changed at any point during
146
+ the baseline window — the listening loop suppresses changes at
147
+ those offsets so the user only sees signal from their own actions.
148
+ """
149
+ history: dict[str, list[bytes]] = {}
150
+ for cycle in range(polls):
151
+ for addr, size, block_name in blocks:
152
+ resp = await client.execute(ReadHoldingRegisters(addr, size))
153
+ history.setdefault(block_name, []).append(resp)
154
+ # In-place progress; final newline printed by caller.
155
+ click.echo(f" baseline poll {cycle + 1}/{polls}\r", nl=False)
156
+ if cycle < polls - 1:
157
+ await asyncio.sleep(1)
158
+ click.echo() # finalize the progress line
159
+
160
+ last: dict[str, bytes] = {}
161
+ volatile: dict[str, set[int]] = {}
162
+ for block_name, snapshots in history.items():
163
+ last[block_name] = snapshots[-1]
164
+ offsets: set[int] = set()
165
+ for i in range(1, len(snapshots)):
166
+ for offset, _, _ in _diff(snapshots[i - 1], snapshots[i]):
167
+ offsets.add(offset)
168
+ volatile[block_name] = offsets
169
+ return last, volatile
170
+
171
+
172
+ def _format_change(block_name: str, addr: int, offset: int, old_byte: int, new_byte: int) -> str:
173
+ """Render a one-line description of a changed byte using register coords.
174
+
175
+ Modbus addresses things by register (16-bit word), so reporting
176
+ register + byte-within-register is more useful than a raw byte
177
+ offset when the user later wants to look it up in FINDINGS or the
178
+ APK.
179
+ """
180
+ register = addr + (offset // 2)
181
+ byte_in_reg = offset % 2
182
+ return f" [{block_name} reg {register} byte {byte_in_reg}]: 0x{old_byte:02X} → 0x{new_byte:02X}"
183
+
184
+
185
+ def _print_intro(profile_path: Path, model_sn: tuple[str, str] | None, num_blocks: int, hints: list[str]) -> None:
186
+ """One-screen onboarding shown before polling starts.
187
+
188
+ Explains the workflow (toggle on the device → watch the diff →
189
+ label) and lists known field names so the user has a vocabulary.
190
+ """
191
+ if model_sn:
192
+ model, real_sn = model_sn
193
+ click.echo(f"\nAnnotating {model} SN {real_sn} → {profile_path}")
194
+ click.echo(f"(SN scrubbed to {SYNTHETIC_SN_STR} in the saved file)\n")
195
+ else:
196
+ click.echo(f"\nAnnotating → {profile_path}\n")
197
+
198
+ click.secho("How this works:", bold=True)
199
+ click.echo(" 1. Make a change on the device (toggle a switch, change a mode,")
200
+ click.echo(" wait for SOC to tick down).")
201
+ click.echo(" 2. Watch which bytes change in the output below.")
202
+ click.echo(" 3. Type a short field name when prompted, or press Enter to skip.")
203
+ click.echo(" 4. Repeat for each thing you want to map.\n")
204
+
205
+ if hints:
206
+ click.secho("Common field names from existing models:", bold=True)
207
+ # Wrap the hint list to fit a typical terminal width without dominating.
208
+ wrapped = click.wrap_text(", ".join(hints), width=72, initial_indent=" ", subsequent_indent=" ")
209
+ click.echo(wrapped + "\n")
210
+
211
+ click.echo(f"Polling {num_blocks} register blocks. Press Ctrl-C to stop.\n")
212
+
213
+
214
+ # ── Annotate loop ─────────────────────────────────────────────────────
215
+
216
+
217
+ async def annotate_loop(
218
+ address: str,
219
+ profile_path: Path,
220
+ *,
221
+ encrypted: bool,
222
+ device_name: str = "",
223
+ ) -> None:
224
+ """Connect, poll register blocks, prompt for field names on changes.
225
+
226
+ *device_name* is the BLE-advertised name (e.g., ``"AC2A2305000"``).
227
+ Passing it lets ``_detect_protocol`` take the registry shortcut on
228
+ the first run, before the profile YAML has a ``name`` field stored.
229
+ """
230
+ profile = _load_or_init(profile_path)
231
+ client = BluetoothClient(address, encrypted=encrypted)
232
+ await client.connect()
233
+
234
+ try:
235
+ # Prefer the live BLE name; fall back to whatever the saved profile has.
236
+ detect_name = device_name or profile.get("name", "") or ""
237
+ info = await _detect_protocol(client, detect_name)
238
+
239
+ if info.kind == "unknown":
240
+ # Fall back to sweeping known V1 blocks
241
+ blocks: list[tuple[int, int, str]] = [
242
+ (addr, size_fn(0), block_name) for addr, block_name, size_fn in V1_BLOCKS
243
+ ]
244
+ elif info.kind == "v1":
245
+ ver = info.version or 0
246
+ blocks = [(addr, size_fn(ver), block_name) for addr, block_name, size_fn in V1_BLOCKS]
247
+ else:
248
+ blocks = list(V2_BLOCKS)
249
+
250
+ if not blocks:
251
+ click.secho("No register blocks to poll.", fg="yellow")
252
+ return
253
+
254
+ # Save protocol info into the profile for future runs
255
+ profile.setdefault("protocol", info.kind)
256
+ profile.setdefault("protocol_version", info.version)
257
+ profile.setdefault("address", address)
258
+ profile.setdefault("encrypted", encrypted)
259
+ if detect_name:
260
+ profile.setdefault("name", detect_name)
261
+ _save_scrubbed(profile, profile_path)
262
+
263
+ _print_intro(
264
+ profile_path=profile_path,
265
+ model_sn=split_model_sn(detect_name),
266
+ num_blocks=len(blocks),
267
+ hints=_registry_field_hints(),
268
+ )
269
+
270
+ click.echo(f"Capturing baseline ({BASELINE_POLLS} polls) to identify noisy fields...")
271
+ last, volatile = await _capture_baseline(client, blocks, polls=BASELINE_POLLS)
272
+ suppressed = sum(len(s) for s in volatile.values())
273
+ click.echo(f"Baseline captured. Suppressing {suppressed} volatile byte(s) from change detection.")
274
+ click.echo("Make a change on the device now.\n")
275
+
276
+ # Lookup of (block, offset) → name from any prior session's annotations.
277
+ # Refreshed in-place as the user adds new labels.
278
+ annotations_index = _annotation_index(profile)
279
+
280
+ while True:
281
+ cycle_changes: list[tuple[str, int, int, int, int]] = []
282
+ for addr, size, block_name in blocks:
283
+ resp = await client.execute(ReadHoldingRegisters(addr, size))
284
+ prev = last.get(block_name)
285
+ vol = volatile.get(block_name, set())
286
+ for offset, old_byte, new_byte in _diff(prev, resp):
287
+ if offset in vol:
288
+ continue
289
+ cycle_changes.append((block_name, addr, offset, old_byte, new_byte))
290
+ last[block_name] = resp
291
+
292
+ if cycle_changes:
293
+ click.echo("─" * 60)
294
+ click.echo(f"{len(cycle_changes)} byte(s) changed:")
295
+ for block_name, addr, offset, old, new in cycle_changes:
296
+ line = _format_change(block_name, addr, offset, old, new)
297
+ existing = annotations_index.get((block_name, offset))
298
+ if existing:
299
+ line += click.style(f" (currently: {existing})", fg="cyan")
300
+ click.echo(line)
301
+ try:
302
+ field_name = click.prompt(
303
+ "\nField name for these changes (Enter to skip)",
304
+ default="",
305
+ show_default=False,
306
+ )
307
+ except (click.Abort, EOFError, KeyboardInterrupt):
308
+ click.echo()
309
+ return
310
+ if field_name.strip():
311
+ name_clean = field_name.strip()
312
+ for block_name, _addr, offset, _old, _new in cycle_changes:
313
+ _replace_annotation(profile, block_name, offset, name_clean)
314
+ annotations_index[(block_name, offset)] = name_clean
315
+ _save_scrubbed(profile, profile_path)
316
+ click.echo(f" ✓ recorded {len(cycle_changes)} entries as {name_clean!r}\n")
317
+ else:
318
+ click.echo(" (skipped)\n")
319
+
320
+ await asyncio.sleep(1)
321
+
322
+ except (KeyboardInterrupt, click.Abort):
323
+ click.echo("\nStopped.")
324
+ finally:
325
+ await client.disconnect()
@@ -0,0 +1,156 @@
1
+ # ABOUTME: BLE scan utilities — service-UUID-based scan, device factory, encryption classification.
2
+
3
+ import re
4
+ import sys
5
+ from dataclasses import dataclass
6
+
7
+ import click
8
+ from bleak import BleakScanner
9
+
10
+ SERVICE_UUID = "0000ff00-0000-1000-8000-00805f9b34fb"
11
+ WRITE_UUID = "0000ff02-0000-1000-8000-00805f9b34fb"
12
+ NOTIFY_UUID = "0000ff01-0000-1000-8000-00805f9b34fb"
13
+ _DEVICE_NAME_SN_RE = re.compile(r"^(AC2A|AC60|EP600|EP500|EB3A|AC300|AC500|AC200L|AC200PL|AC200M)(\d+)$")
14
+
15
+ PREFIX_PLAINTEXT = bytes.fromhex("424c5545545449")
16
+ PREFIX_ENCRYPTED = (
17
+ bytes.fromhex("424c5545545445"),
18
+ bytes.fromhex("424c5545545446"),
19
+ )
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ScanResult:
24
+ address: str
25
+ name: str
26
+ encrypted: bool | None
27
+
28
+ def display(self) -> str:
29
+ flag = "encrypted" if self.encrypted else "plaintext" if self.encrypted is False else "unknown"
30
+ return f"{self.address} — {self.name} [{flag}]"
31
+
32
+
33
+ def _parse_sn(name: str) -> str:
34
+ m = _DEVICE_NAME_SN_RE.match(name.strip())
35
+ if m:
36
+ return m[2]
37
+ return name.replace(":", "").replace("-", "")
38
+
39
+
40
+ async def lookup_scan_result(address: str, timeout: float = 5.0) -> ScanResult:
41
+ devices = await BleakScanner.discover(
42
+ timeout=timeout,
43
+ service_uuids=[SERVICE_UUID],
44
+ return_adv=True,
45
+ )
46
+ for addr, (device, adv) in devices.items():
47
+ if addr.upper() == address.upper():
48
+ name = (device.name or adv.local_name or "").strip()
49
+ if not name:
50
+ name = address
51
+ encrypted = _classify(adv)
52
+ return ScanResult(address=address, name=name, encrypted=encrypted)
53
+ return ScanResult(address=address, name=address, encrypted=None)
54
+
55
+
56
+ def _classify(adv) -> bool | None:
57
+ for blob in adv.manufacturer_data.values():
58
+ if blob.startswith(PREFIX_PLAINTEXT):
59
+ return False
60
+ if any(blob.startswith(p) for p in PREFIX_ENCRYPTED):
61
+ return True
62
+ return None
63
+
64
+
65
+ async def scan_devices(timeout: float = 10.0) -> list[ScanResult]:
66
+ click.echo(f"Scanning for Bluetti devices (service {SERVICE_UUID}) ...")
67
+ devices = await BleakScanner.discover(
68
+ timeout=timeout,
69
+ service_uuids=[SERVICE_UUID],
70
+ return_adv=True,
71
+ )
72
+
73
+ found: list[ScanResult] = []
74
+ for address, (device, adv) in devices.items():
75
+ name = (device.name or adv.local_name or "").strip()
76
+ if not name:
77
+ name = "(unknown)"
78
+ encrypted = _classify(adv)
79
+ found.append(ScanResult(address=address, name=name, encrypted=encrypted))
80
+
81
+ return sorted(found, key=lambda x: x.address)
82
+
83
+
84
+ async def pick_address_after_scan() -> ScanResult:
85
+ devices = await scan_devices()
86
+
87
+ if not devices:
88
+ click.secho("\nNo Bluetti devices found.", fg="red")
89
+ click.echo("Make sure the device is powered on and in Bluetooth range.")
90
+ sys.exit(1)
91
+
92
+ if len(devices) == 1:
93
+ sr = devices[0]
94
+ click.echo(f"\nFound 1 device \u2192 auto-selecting: {sr.display()}")
95
+ return sr
96
+
97
+ click.echo(f"\nFound {len(devices)} Bluetti devices:\n")
98
+ for i, sr in enumerate(devices, 1):
99
+ click.echo(f" [{click.style(str(i), fg='cyan')}] {sr.display()}")
100
+
101
+ click.echo()
102
+ while True:
103
+ try:
104
+ choice = input(f"Select device (1-{len(devices)}): ").strip()
105
+ idx = int(choice) - 1
106
+ if 0 <= idx < len(devices):
107
+ return devices[idx]
108
+ except (ValueError, EOFError, KeyboardInterrupt):
109
+ click.echo()
110
+ sys.exit(1)
111
+ click.echo(f"Enter a number between 1 and {len(devices)}.")
112
+
113
+
114
+ def _device_registry() -> dict[str, type]:
115
+ from ..core.devices.ac2a import AC2A
116
+ from ..core.devices.ac60 import AC60
117
+ from ..core.devices.ac200l import AC200L
118
+ from ..core.devices.ac200m import AC200M
119
+ from ..core.devices.ac200pl import AC200PL
120
+ from ..core.devices.ac300 import AC300
121
+ from ..core.devices.ac500 import AC500
122
+ from ..core.devices.eb3a import EB3A
123
+ from ..core.devices.ep500 import EP500
124
+ from ..core.devices.ep600 import EP600
125
+
126
+ return {
127
+ "AC2A": AC2A,
128
+ "AC60": AC60,
129
+ "AC200L": AC200L,
130
+ "AC200M": AC200M,
131
+ "AC200PL": AC200PL,
132
+ "AC300": AC300,
133
+ "AC500": AC500,
134
+ "EB3A": EB3A,
135
+ "EP500": EP500,
136
+ "EP600": EP600,
137
+ }
138
+
139
+
140
+ def device_registry() -> dict[str, type]:
141
+ return _device_registry()
142
+
143
+
144
+ def is_supported_device_type(prefix: str) -> bool:
145
+ return prefix in _device_registry()
146
+
147
+
148
+ def build_device(address: str, name: str):
149
+ sn = _parse_sn(name)
150
+ prefix_match = _DEVICE_NAME_SN_RE.match(name.strip())
151
+ prefix: str | None = prefix_match[1] if prefix_match else None
152
+ registry = _device_registry()
153
+ cls = registry.get(prefix) if prefix else None
154
+ if cls is None:
155
+ raise ValueError(f"Unsupported device model: {name!r}. Known prefixes: {sorted(registry)}")
156
+ return cls(address, sn)
@@ -0,0 +1,59 @@
1
+ # ABOUTME: AES-128-CBC cipher with chained IV and zero-padding (no PKCS) for Bluetti BLE encryption.
2
+ # ABOUTME: Unit 5 per IMPLEMENTATION_UNITS.md.
3
+ #
4
+ # Framing note: encrypt() zero-pads to a 16-byte boundary. decrypt() returns
5
+ # the full block-aligned plaintext including any padding bytes — it does NOT
6
+ # strip trailing zeros, because the Bluetti protocol carries plaintext bytes
7
+ # that legitimately end in 0x00 (Modbus register values < 256, CRC high
8
+ # bytes, ECDSA signature/checksum bytes, etc.). The upper layer (Modbus
9
+ # parser, handshake state machine) determines actual payload length from
10
+ # protocol-level fields and ignores trailing pad bytes.
11
+
12
+ import hashlib
13
+
14
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
15
+
16
+
17
+ def derive_iv(random_md5_hex: str) -> bytes:
18
+ return hashlib.md5(random_md5_hex.encode("ascii")).digest()
19
+
20
+
21
+ def _zero_pad(data: bytes, block: int = 16) -> bytes:
22
+ if len(data) % block == 0:
23
+ return data
24
+ return data + b"\x00" * (block - len(data) % block)
25
+
26
+
27
+ def encrypt(plaintext: bytes, key: bytes, iv: bytes) -> bytes:
28
+ if len(key) != 16:
29
+ raise ValueError("AES-128 key must be 16 bytes")
30
+ if len(iv) != 16:
31
+ raise ValueError("IV must be 16 bytes")
32
+ padded = _zero_pad(plaintext)
33
+ cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
34
+ enc = cipher.encryptor()
35
+ return enc.update(padded) + enc.finalize()
36
+
37
+
38
+ def decrypt(ciphertext: bytes, key: bytes, iv: bytes) -> bytes:
39
+ if len(ciphertext) % 16 != 0:
40
+ raise ValueError("Ciphertext length must be a multiple of 16")
41
+ cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
42
+ dec = cipher.decryptor()
43
+ return dec.update(ciphertext) + dec.finalize()
44
+
45
+
46
+ class CbcSession:
47
+ def __init__(self, key: bytes, initial_iv: bytes):
48
+ self._key = key
49
+ self._iv = initial_iv
50
+
51
+ def encrypt(self, plaintext: bytes) -> bytes:
52
+ ct = encrypt(plaintext, self._key, self._iv)
53
+ self._iv = ct[-16:]
54
+ return ct
55
+
56
+ def decrypt(self, ciphertext: bytes) -> bytes:
57
+ pt = decrypt(ciphertext, self._key, self._iv)
58
+ self._iv = ciphertext[-16:]
59
+ return pt
@@ -0,0 +1,114 @@
1
+ # ABOUTME: BLE client for Bluetti devices — connect, execute Modbus commands, disconnect. No auto-reconnect.
2
+ # ABOUTME: Supports optional AES-CBC encryption via handshake (Unit 7).
3
+
4
+ import asyncio
5
+ from typing import Optional
6
+
7
+ from bleak import BleakClient, BleakError
8
+
9
+ from ..core.commands import DeviceCommand
10
+ from . import NOTIFY_UUID, WRITE_UUID
11
+ from .cipher import CbcSession
12
+ from .exc import BadConnectionError, ModbusError, ParseError
13
+ from .handshake import HandshakeSession
14
+
15
+ RESPONSE_TIMEOUT = 5
16
+ MAX_RETRIES = 5
17
+
18
+
19
+ class BluetoothClient:
20
+ def __init__(self, address: str, *, encrypted: bool = False):
21
+ self.address = address
22
+ self.encrypted = encrypted
23
+ self.client: Optional[BleakClient] = None
24
+ self._session: Optional[CbcSession] = None
25
+ self._notify_response = bytearray()
26
+ self._notify_future: Optional[asyncio.Future] = None
27
+ self._current_cmd: Optional[DeviceCommand] = None
28
+
29
+ async def connect(self, timeout: float = 15.0) -> None:
30
+ self.client = BleakClient(self.address)
31
+ await self.client.connect(timeout=timeout)
32
+ if self.encrypted:
33
+ self._session = await HandshakeSession().run(self.client)
34
+ await self.client.start_notify(NOTIFY_UUID, self._on_notification) # type: ignore[arg-type]
35
+
36
+ @property
37
+ def is_connected(self) -> bool:
38
+ return self.client is not None and self.client.is_connected
39
+
40
+ async def disconnect(self) -> None:
41
+ if self.client and self.client.is_connected:
42
+ try:
43
+ await self.client.disconnect()
44
+ except BleakError:
45
+ pass
46
+
47
+ async def execute(self, cmd: DeviceCommand) -> bytes:
48
+ loop = asyncio.get_running_loop()
49
+ retries = 0
50
+
51
+ while True:
52
+ self._current_cmd = cmd
53
+ self._notify_future = loop.create_future()
54
+ self._notify_response = bytearray()
55
+
56
+ try:
57
+ assert self.client is not None
58
+ outgoing = bytes(cmd)
59
+ if self._session:
60
+ outgoing = self._session.encrypt(outgoing)
61
+ await self.client.write_gatt_char(WRITE_UUID, outgoing, response=False)
62
+ resp = await asyncio.wait_for(self._notify_future, timeout=RESPONSE_TIMEOUT)
63
+ except ParseError:
64
+ retries += 1
65
+ if retries >= MAX_RETRIES:
66
+ raise BadConnectionError(f"Too many retries on {cmd}")
67
+ continue
68
+ except asyncio.TimeoutError:
69
+ retries += 1
70
+ if retries >= MAX_RETRIES:
71
+ raise BadConnectionError(f"Timeout on {cmd} after {MAX_RETRIES} retries")
72
+ continue
73
+
74
+ if self._session:
75
+ resp = self._session.decrypt(resp)
76
+ resp = resp[: cmd.response_size()]
77
+ if not cmd.is_valid_response(resp):
78
+ retries += 1
79
+ if retries >= MAX_RETRIES:
80
+ raise BadConnectionError(
81
+ "Encrypted handshake completed but Modbus responses do not validate. "
82
+ "This device may require the per-SN key-binding step that Bluetti's "
83
+ "licensed library performs (status 3 in ble_crypt_link_handler). "
84
+ "Run with -v to capture the handshake transcript and open an issue."
85
+ )
86
+ continue
87
+
88
+ if cmd.is_exception_response(resp):
89
+ raise ModbusError(f"Modbus exception code: {resp[2]}")
90
+ return cmd.parse_response(resp)
91
+
92
+ async def perform_nowait(self, cmd: DeviceCommand) -> None:
93
+ await self.execute(cmd)
94
+
95
+ def _on_notification(self, _sender: int, data: bytearray) -> None:
96
+ if not self._notify_future or self._notify_future.done():
97
+ return
98
+
99
+ if data == b"AT+NAME?\r" or data == b"AT+ADV?\r":
100
+ self._notify_future.set_exception(BadConnectionError("Got AT+ notification"))
101
+ return
102
+
103
+ self._notify_response.extend(data)
104
+
105
+ assert self._current_cmd is not None
106
+ response_size = self._current_cmd.response_size()
107
+ expected = ((response_size + 15) // 16) * 16 if self._session else response_size
108
+
109
+ if len(self._notify_response) >= expected:
110
+ raw = bytes(self._notify_response[:expected])
111
+ if not self._session and not self._current_cmd.is_valid_response(raw):
112
+ self._notify_future.set_exception(ParseError("CRC check failed"))
113
+ else:
114
+ self._notify_future.set_result(raw)
@@ -0,0 +1,13 @@
1
+ # ABOUTME: BLE/Modbus exception types used across the bluetooth layer.
2
+
3
+
4
+ class ParseError(Exception):
5
+ pass
6
+
7
+
8
+ class ModbusError(Exception):
9
+ pass
10
+
11
+
12
+ class BadConnectionError(Exception):
13
+ pass