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.
- voltkeeper/__init__.py +0 -0
- voltkeeper/_version.py +24 -0
- voltkeeper/annotate.py +325 -0
- voltkeeper/bluetooth/__init__.py +156 -0
- voltkeeper/bluetooth/cipher.py +59 -0
- voltkeeper/bluetooth/client.py +114 -0
- voltkeeper/bluetooth/exc.py +13 -0
- voltkeeper/bluetooth/handshake.py +146 -0
- voltkeeper/bus.py +51 -0
- voltkeeper/cli.py +1248 -0
- voltkeeper/core/__init__.py +0 -0
- voltkeeper/core/commands.py +88 -0
- voltkeeper/core/devices/__init__.py +0 -0
- voltkeeper/core/devices/_v1_alarm_tables.py +223 -0
- voltkeeper/core/devices/ac200l.py +102 -0
- voltkeeper/core/devices/ac200m.py +54 -0
- voltkeeper/core/devices/ac200pl.py +17 -0
- voltkeeper/core/devices/ac2a.py +194 -0
- voltkeeper/core/devices/ac300.py +91 -0
- voltkeeper/core/devices/ac500.py +100 -0
- voltkeeper/core/devices/ac60.py +73 -0
- voltkeeper/core/devices/bluetti_device.py +48 -0
- voltkeeper/core/devices/eb3a.py +48 -0
- voltkeeper/core/devices/ep500.py +11 -0
- voltkeeper/core/devices/ep600.py +11 -0
- voltkeeper/core/devices/v1_base.py +219 -0
- voltkeeper/core/devices/v2_base.py +237 -0
- voltkeeper/core/struct.py +281 -0
- voltkeeper/core/utils.py +66 -0
- voltkeeper/device_handler.py +118 -0
- voltkeeper/load_test.py +568 -0
- voltkeeper/mqtt_client.py +573 -0
- voltkeeper/probe.py +197 -0
- voltkeeper/scrub.py +87 -0
- voltkeeper/shutdown_watch.py +125 -0
- voltkeeper/validate.py +99 -0
- voltkeeper-2026.5.dist-info/METADATA +298 -0
- voltkeeper-2026.5.dist-info/RECORD +41 -0
- voltkeeper-2026.5.dist-info/WHEEL +4 -0
- voltkeeper-2026.5.dist-info/entry_points.txt +5 -0
- 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)
|