SolixBLE 3.9.0__tar.gz → 4.0.0b2__tar.gz
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.
- {solixble-3.9.0 → solixble-4.0.0b2}/PKG-INFO +4 -1
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/__init__.py +4 -0
- solixble-4.0.0b2/SolixBLE/advertisement.py +70 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/const.py +31 -7
- solixble-4.0.0b2/SolixBLE/constructs.py +445 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/device.py +375 -409
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/c1000.py +75 -52
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/c1000g2.py +31 -24
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/c300.py +76 -52
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/c300dc.py +53 -24
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/c800.py +74 -50
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/f2600.py +113 -72
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/f3800.py +76 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/prime_charger_160w.py +63 -33
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/prime_charger_250w.py +96 -58
- solixble-4.0.0b2/SolixBLE/factory.py +95 -0
- solixble-4.0.0b2/SolixBLE/prime_device.py +375 -0
- solixble-4.0.0b2/SolixBLE/transport.py +51 -0
- solixble-4.0.0b2/SolixBLE/utilities.py +114 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE.egg-info/PKG-INFO +4 -1
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE.egg-info/SOURCES.txt +12 -1
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE.egg-info/requires.txt +3 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/pyproject.toml +5 -2
- solixble-4.0.0b2/tests/test_advertisement.py +152 -0
- solixble-4.0.0b2/tests/test_commands.py +242 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/tests/test_connection.py +20 -14
- solixble-4.0.0b2/tests/test_constructs.py +100 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/tests/test_devices.py +31 -313
- solixble-4.0.0b2/tests/test_factory.py +94 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/tests/test_prime.py +6 -5
- solixble-4.0.0b2/tests/test_routing.py +215 -0
- solixble-4.0.0b2/tests/test_transport.py +119 -0
- solixble-4.0.0b2/tests/test_utilities.py +49 -0
- solixble-3.9.0/SolixBLE/prime_device.py +0 -524
- solixble-3.9.0/SolixBLE/utilities.py +0 -47
- {solixble-3.9.0 → solixble-4.0.0b2}/LICENSE.txt +0 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/README.md +0 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/__init__.py +0 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/f2000.py +0 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/generic.py +0 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/maggo_3in1.py +0 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/prime_power_bank_20k.py +0 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/solarbank2.py +0 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/solarbank3.py +0 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/states.py +0 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE.egg-info/dependency_links.txt +0 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE.egg-info/top_level.txt +0 -0
- {solixble-3.9.0 → solixble-4.0.0b2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: SolixBLE
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.0b2
|
|
4
4
|
Summary: Python module for monitoring & controlling Bluetooth Anker Solix devices
|
|
5
5
|
Author-email: Harvey Lelliott <harveylelliott@duck.com>
|
|
6
6
|
License: MIT License
|
|
@@ -45,6 +45,9 @@ Requires-Dist: bleak>=0.19.0
|
|
|
45
45
|
Requires-Dist: cryptography
|
|
46
46
|
Requires-Dist: pycryptodome
|
|
47
47
|
Requires-Dist: bleak-retry-connector
|
|
48
|
+
Requires-Dist: construct
|
|
49
|
+
Requires-Dist: tzdata
|
|
50
|
+
Requires-Dist: tzlocal
|
|
48
51
|
Dynamic: license-file
|
|
49
52
|
|
|
50
53
|
# SolixBLE
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from .advertisement import capability_from_advertisement
|
|
7
8
|
from .device import SolixBLEDevice
|
|
8
9
|
from .devices import (
|
|
9
10
|
C300,
|
|
@@ -22,6 +23,7 @@ from .devices import (
|
|
|
22
23
|
Solarbank2,
|
|
23
24
|
Solarbank3,
|
|
24
25
|
)
|
|
26
|
+
from .factory import device_class_from_advertisement
|
|
25
27
|
from .prime_device import PrimeDevice
|
|
26
28
|
from .states import (
|
|
27
29
|
ChargingStatus,
|
|
@@ -60,4 +62,6 @@ __all__ = [
|
|
|
60
62
|
"TemperatureUnit",
|
|
61
63
|
"PortOverload",
|
|
62
64
|
"discover_devices",
|
|
65
|
+
"capability_from_advertisement",
|
|
66
|
+
"device_class_from_advertisement",
|
|
63
67
|
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Anker manufacturer advertisement record.
|
|
2
|
+
|
|
3
|
+
Anker devices publish a fixed-shape record under BLE company identifier
|
|
4
|
+
``0xffff``: the device MAC, the model key (``product_type``), the sku, and on
|
|
5
|
+
newer firmware a ``capability`` byte whose ``0x04`` bit means the device
|
|
6
|
+
accepts the encrypted negotiation. All of it is readable before connecting.
|
|
7
|
+
|
|
8
|
+
.. moduleauthor:: kb1ibt
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from construct import ( # type: ignore[import-untyped] # construct ships no types
|
|
16
|
+
Bytes,
|
|
17
|
+
ConstructError,
|
|
18
|
+
Container,
|
|
19
|
+
Error,
|
|
20
|
+
Int8ub,
|
|
21
|
+
Optional,
|
|
22
|
+
PaddedString,
|
|
23
|
+
Struct,
|
|
24
|
+
Switch,
|
|
25
|
+
this,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from bleak.backends.scanner import AdvertisementData
|
|
30
|
+
|
|
31
|
+
#: BLE company identifier the record is published under.
|
|
32
|
+
ANKER_COMPANY_ID = 0xFFFF
|
|
33
|
+
|
|
34
|
+
#: Capability bit: the device accepts the encrypted ``4xxx`` negotiation.
|
|
35
|
+
CAPABILITY_ENCRYPTED_ECDH = 0x04
|
|
36
|
+
|
|
37
|
+
#: ``version_code | mac(6) | bind_type | product_type(2) | sku(3-4) | capability(0-1)``.
|
|
38
|
+
ANKER_RECORD = Struct(
|
|
39
|
+
"version_code" / Int8ub,
|
|
40
|
+
"mac" / Bytes(6),
|
|
41
|
+
"bind_type" / Int8ub,
|
|
42
|
+
"product_type" / Bytes(2),
|
|
43
|
+
"sku"
|
|
44
|
+
/ Switch(
|
|
45
|
+
this.version_code,
|
|
46
|
+
{1: PaddedString(3, "ascii"), 2: PaddedString(4, "ascii")},
|
|
47
|
+
default=Error,
|
|
48
|
+
),
|
|
49
|
+
"capability" / Optional(Int8ub),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_advertisement(data: bytes) -> Container | None:
|
|
54
|
+
"""Decode an Anker ``0xffff`` manufacturer record, or None if malformed."""
|
|
55
|
+
try:
|
|
56
|
+
return ANKER_RECORD.parse(data)
|
|
57
|
+
except ConstructError:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def record_from_advertisement(advertisement: AdvertisementData) -> Container | None:
|
|
62
|
+
"""Decode the Anker record of a scan result, if it carries one."""
|
|
63
|
+
data = advertisement.manufacturer_data.get(ANKER_COMPANY_ID)
|
|
64
|
+
return parse_advertisement(data) if data is not None else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def capability_from_advertisement(advertisement: AdvertisementData) -> int | None:
|
|
68
|
+
"""Read the capability byte from a scan result, or None if absent."""
|
|
69
|
+
record = record_from_advertisement(advertisement)
|
|
70
|
+
return record.capability if record is not None else None
|
|
@@ -13,6 +13,27 @@ UUID_COMMAND = "8c850002-0302-41c5-b46e-cf057c562025"
|
|
|
13
13
|
#: GATT Service UUID for identifying Solix/Prime devices (Tested on C300X, C1000, and Prime 160w Charger).
|
|
14
14
|
UUID_IDENTIFIER = "0000ff09-0000-1000-8000-00805f9b34fb"
|
|
15
15
|
|
|
16
|
+
#: GATT Service UUID of devices on the legacy transport (e.g the 767 / F2000).
|
|
17
|
+
LEGACY_SERVICE = "00001780-0000-1000-8000-00805f9b34fb"
|
|
18
|
+
|
|
19
|
+
#: GATT Characteristic UUID for legacy transport telemetry. Is subscribable.
|
|
20
|
+
UUID_TELEMETRY_LEGACY = "00008888-0000-1000-8000-00805f9b34fb"
|
|
21
|
+
|
|
22
|
+
#: GATT Characteristic UUID for sending legacy transport commands.
|
|
23
|
+
UUID_COMMAND_LEGACY = "00007777-0000-1000-8000-00805f9b34fb"
|
|
24
|
+
|
|
25
|
+
#: GATT Service UUID of devices on the 2215 transport (e.g the A1340 Prime power bank).
|
|
26
|
+
SERVICE_2215 = "00002215-0000-1000-8000-00805f9b34fb"
|
|
27
|
+
|
|
28
|
+
#: GATT Characteristic UUID for 2215 transport telemetry. Is subscribable.
|
|
29
|
+
UUID_TELEMETRY_2215 = "22150003-4002-81c5-b46e-cf057c562025"
|
|
30
|
+
|
|
31
|
+
#: GATT Characteristic UUID for sending 2215 transport commands / negotiating.
|
|
32
|
+
UUID_COMMAND_2215 = "22150002-4002-81c5-b46e-cf057c562025"
|
|
33
|
+
|
|
34
|
+
#: GATT Service UUIDs that identify an Anker device on any transport.
|
|
35
|
+
UUID_IDENTIFIERS = (UUID_IDENTIFIER, LEGACY_SERVICE, SERVICE_2215)
|
|
36
|
+
|
|
16
37
|
#: Time to wait before re-connecting on an unexpected disconnect.
|
|
17
38
|
RECONNECT_DELAY = 3
|
|
18
39
|
|
|
@@ -44,6 +65,12 @@ DEFAULT_METADATA_FLOAT = -1.0
|
|
|
44
65
|
#: Bool value for unknown boolean attributes.
|
|
45
66
|
DEFAULT_METADATA_BOOL = None
|
|
46
67
|
|
|
68
|
+
#: The pattern used in telemetry packers from some Anker devices
|
|
69
|
+
TELEMETRY_PATTERN_A = "03010f"
|
|
70
|
+
|
|
71
|
+
#: The pattern used in negotiation packets from Anker devices
|
|
72
|
+
NEGOTIATION_PATTERN = "030001"
|
|
73
|
+
|
|
47
74
|
#: Command used to initiate negotiations
|
|
48
75
|
NEGOTIATION_COMMAND_0 = "ff0936000300010001a10442ad8c69a22462326463306231372d623735642d346162662d626136652d656337633939376332336537b9"
|
|
49
76
|
|
|
@@ -54,7 +81,7 @@ NEGOTIATION_COMMAND_1 = "ff093d000300010003a10442ad8c69a22462326463306231372d623
|
|
|
54
81
|
NEGOTIATION_COMMAND_2 = "ff0936000300010029a10442ad8c69a22462326463306231372d623735642d346162662d626136652d65633763393937633233653791"
|
|
55
82
|
|
|
56
83
|
#: Response to receiving 3rd negotiation message
|
|
57
|
-
NEGOTIATION_COMMAND_3 = "
|
|
84
|
+
NEGOTIATION_COMMAND_3 = "ff0940000300010005a10442ad8c69a22462326463306231372d623735642d346162662d626136652d656337633939376332336537a30120a40200f0a50140fb"
|
|
58
85
|
|
|
59
86
|
#: Response to receiving 4th negotiation message
|
|
60
87
|
NEGOTIATION_COMMAND_4 = "ff094c000300010021a140060ea168f232aedb37fb2d120c49180329ac72ab5ec3eb8fd30a2f252dc5e151dabccd9b1dc1e288704ca760a0d8c918e5c94823a1f609a4bf07fb4c33ee219085"
|
|
@@ -62,12 +89,6 @@ NEGOTIATION_COMMAND_4 = "ff094c000300010021a140060ea168f232aedb37fb2d120c4918032
|
|
|
62
89
|
#: Response to receiving 5th negotiation message
|
|
63
90
|
NEGOTIATION_COMMAND_5 = "ff095a000300014022580bc0532a53c739adf3da7b994a7b5f221bcc16bab6392c215cb4faaf41d9d58e2c81c016e474c78eed5569147cb74a1f22ca2b3fad2e209dbbcfbdaca352034a6c479f055f68581b5f1e22348809f526"
|
|
64
91
|
|
|
65
|
-
#: The unix timestamp that is agreed upon in the negotiations. This is used
|
|
66
|
-
#: by Anker to protect against replay attacks as commands must contain the
|
|
67
|
-
#: current encrypted time.
|
|
68
|
-
BASE_TIMESTAMP = "42ad8c69"
|
|
69
|
-
|
|
70
|
-
|
|
71
92
|
#: The private key this program uses to perform the ECDH negotiation to
|
|
72
93
|
#: get a shared secret which is then used as an AES key for encrypting
|
|
73
94
|
#: communications between the program and the power station. Yes I know it
|
|
@@ -76,3 +97,6 @@ BASE_TIMESTAMP = "42ad8c69"
|
|
|
76
97
|
#: reason this has to be done at all is because Anker power stations no longer
|
|
77
98
|
#: support sending telemetry in plain text after the latest firmware update.
|
|
78
99
|
PRIVATE_KEY = "7dfbea61cd95cee49c458ad7419e817f1ade9a66136de3c7d5787af1458e39f4"
|
|
100
|
+
|
|
101
|
+
# POSIX timezone to use if determining the system time zone fails
|
|
102
|
+
FALLBACK_TZ = "GMT0BST,M3.5.0/1,M10.5.0"
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Data structures of packets.
|
|
2
|
+
|
|
3
|
+
This module contains the byte structures used for encoding and
|
|
4
|
+
decoding the packet format used by Anker devices.
|
|
5
|
+
|
|
6
|
+
.. moduleauthor:: Harvey Lelliott (flip-dots) <harveylelliott@duck.com>
|
|
7
|
+
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import operator
|
|
12
|
+
from functools import reduce
|
|
13
|
+
from typing import Any, Self
|
|
14
|
+
|
|
15
|
+
from construct import (
|
|
16
|
+
BitsInteger,
|
|
17
|
+
BitStruct,
|
|
18
|
+
Bytes,
|
|
19
|
+
Checksum,
|
|
20
|
+
Const,
|
|
21
|
+
Container,
|
|
22
|
+
ExprAdapter,
|
|
23
|
+
ExprValidator,
|
|
24
|
+
Flag,
|
|
25
|
+
GreedyBytes,
|
|
26
|
+
GreedyRange,
|
|
27
|
+
Hex,
|
|
28
|
+
HexDump,
|
|
29
|
+
If,
|
|
30
|
+
Int8ub,
|
|
31
|
+
Int8ul,
|
|
32
|
+
Int16ul,
|
|
33
|
+
Nibble,
|
|
34
|
+
Optional,
|
|
35
|
+
RawCopy,
|
|
36
|
+
Rebuild,
|
|
37
|
+
Struct,
|
|
38
|
+
this,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
from SolixBLE.utilities import _to_bytes
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_val(obj: Any, key: str, default: Any=None) -> Any:
|
|
45
|
+
"""
|
|
46
|
+
Return value from dictionary, container, objects, or None.
|
|
47
|
+
|
|
48
|
+
:param obj: Object to extract value from.
|
|
49
|
+
:param key: The key or property to extract from the object.
|
|
50
|
+
:param default: Default to return if not found.
|
|
51
|
+
:returns: Found value or default.
|
|
52
|
+
"""
|
|
53
|
+
if obj is None:
|
|
54
|
+
return default
|
|
55
|
+
if isinstance(obj, dict):
|
|
56
|
+
return obj.get(key, default)
|
|
57
|
+
return getattr(obj, key, default)
|
|
58
|
+
|
|
59
|
+
Packet = ExprAdapter(
|
|
60
|
+
|
|
61
|
+
# Structure of the packet
|
|
62
|
+
Struct(
|
|
63
|
+
|
|
64
|
+
# Bytes of packet excluding checksum
|
|
65
|
+
"content" / RawCopy(
|
|
66
|
+
Struct(
|
|
67
|
+
|
|
68
|
+
# Header of the packet
|
|
69
|
+
"header" / Hex(Const(bytes.fromhex("ff09"))),
|
|
70
|
+
|
|
71
|
+
# Length of the entire packet
|
|
72
|
+
"length" / Rebuild(Int16ul, lambda this: 10 + len(this.payload_bytes)),
|
|
73
|
+
|
|
74
|
+
# Pattern of the packet (e.g negotiation type, telemetry type etc)
|
|
75
|
+
"pattern" / Hex(Bytes(3)),
|
|
76
|
+
|
|
77
|
+
# Command of the packet (e.g turn on, off, etc)
|
|
78
|
+
"cmd" / Hex(Bytes(2)),
|
|
79
|
+
|
|
80
|
+
# Payload bytes of the packet (may be encrypted or fragmented)
|
|
81
|
+
"payload_bytes" / HexDump(Bytes(lambda this: this.length - 10)),
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
|
|
85
|
+
# XOR checksum of the packet
|
|
86
|
+
"checksum" / Hex(Checksum(
|
|
87
|
+
Int8ul,
|
|
88
|
+
lambda data: reduce(operator.xor, data, 0),
|
|
89
|
+
this.content.data,
|
|
90
|
+
)),
|
|
91
|
+
),
|
|
92
|
+
|
|
93
|
+
# Encoders and decoders which allow for direct access
|
|
94
|
+
# (e.g packet.cmd rather than packet.content.cmd)
|
|
95
|
+
decoder=lambda p, _: p.content.value,
|
|
96
|
+
encoder=lambda p, _: {
|
|
97
|
+
"content": {
|
|
98
|
+
"value": {
|
|
99
|
+
"header": _to_bytes(_get_val(p, "header", "ff09")),
|
|
100
|
+
"pattern": _to_bytes(_get_val(p, "pattern")),
|
|
101
|
+
"cmd": _to_bytes(_get_val(p, "cmd")),
|
|
102
|
+
"payload_bytes": _to_bytes(_get_val(p, "payload_bytes", b"")),
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
"""
|
|
108
|
+
Anker device packet.
|
|
109
|
+
|
|
110
|
+
This class represents a packet of an Anker device. Packets are made up of a
|
|
111
|
+
header, size, pattern, cmd, payload, and a checksum.
|
|
112
|
+
|
|
113
|
+
Structure: <Header 2B> <Size 2B> <Pattern 3B> <CMD 2B> <Payload nB> <Checksum 1B>.
|
|
114
|
+
|
|
115
|
+
Usage:
|
|
116
|
+
.. code-block:: python
|
|
117
|
+
:linenos:
|
|
118
|
+
|
|
119
|
+
packet = Packet.parse(packet_bytes)
|
|
120
|
+
print(f"p: {packet.pattern}, c: {packet.cmd}, b: {packet.payload_bytes}")
|
|
121
|
+
|
|
122
|
+
packet_bytes = Packet.build({
|
|
123
|
+
"pattern": "030001",
|
|
124
|
+
"cmd": "0000",
|
|
125
|
+
"payload_bytes": "a101a20200a303010000",
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
#: Dispatch channel of negotiation requests, replies and the grant.
|
|
131
|
+
CHANNEL_NEGOTIATION = 0x01
|
|
132
|
+
|
|
133
|
+
#: Dispatch channel of session commands, replies and pushes.
|
|
134
|
+
CHANNEL_SESSION = 0x0F
|
|
135
|
+
|
|
136
|
+
#: Dispatch channel of app session pushes.
|
|
137
|
+
CHANNEL_APP = 0x11
|
|
138
|
+
|
|
139
|
+
#: Composer of a reply that echoes the request's pattern.
|
|
140
|
+
COMPOSER_REPLY = 0x00
|
|
141
|
+
|
|
142
|
+
#: Composer of a frame the device sends on its own.
|
|
143
|
+
COMPOSER_SEND = 0x01
|
|
144
|
+
|
|
145
|
+
PacketPattern = Struct(
|
|
146
|
+
"family" / Int8ub,
|
|
147
|
+
"composer" / Int8ub,
|
|
148
|
+
"channel" / Int8ub,
|
|
149
|
+
)
|
|
150
|
+
"""
|
|
151
|
+
Pattern of an Anker packet: ``03 <composer> <channel>``.
|
|
152
|
+
|
|
153
|
+
The channel is the module's dispatch key (``0x01`` negotiation, ``0x0f`` /
|
|
154
|
+
``0x11`` session); the composer is ``01`` on frames the module sends on its
|
|
155
|
+
own and ``00`` on replies that echo the request.
|
|
156
|
+
|
|
157
|
+
Usage:
|
|
158
|
+
.. code-block:: python
|
|
159
|
+
:linenos:
|
|
160
|
+
|
|
161
|
+
pattern = PacketPattern.parse(packet.pattern)
|
|
162
|
+
print(f"composer: {pattern.composer}, channel: {pattern.channel}")
|
|
163
|
+
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
PacketCommand = ExprAdapter(
|
|
167
|
+
BitStruct(
|
|
168
|
+
"fragmented" / Flag,
|
|
169
|
+
"encrypted" / Flag,
|
|
170
|
+
"reserved" / BitsInteger(2),
|
|
171
|
+
"msgtype" / BitsInteger(12),
|
|
172
|
+
),
|
|
173
|
+
decoder=lambda obj, _: Container(
|
|
174
|
+
fragmented=obj.fragmented,
|
|
175
|
+
encrypted=obj.encrypted,
|
|
176
|
+
msgtype=obj.msgtype,
|
|
177
|
+
response=bool(obj.msgtype & 0x800),
|
|
178
|
+
),
|
|
179
|
+
encoder=lambda obj, _: {"reserved": 0, **obj},
|
|
180
|
+
)
|
|
181
|
+
"""
|
|
182
|
+
Command of an Anker packet: link flags in the high nibble, a 12-bit message type.
|
|
183
|
+
|
|
184
|
+
``0x80`` marks a fragment (the payload's first byte is index << 4 | total) and
|
|
185
|
+
``0x40`` an encrypted/authorized link; a response is the request's message
|
|
186
|
+
type with ``0x800`` set.
|
|
187
|
+
|
|
188
|
+
Usage:
|
|
189
|
+
.. code-block:: python
|
|
190
|
+
:linenos:
|
|
191
|
+
|
|
192
|
+
command = PacketCommand.parse(packet.cmd)
|
|
193
|
+
print(f"msgtype: {command.msgtype:03x}, encrypted: {command.encrypted}")
|
|
194
|
+
|
|
195
|
+
cmd_bytes = PacketCommand.build({
|
|
196
|
+
"fragmented": False,
|
|
197
|
+
"encrypted": True,
|
|
198
|
+
"msgtype": 0x005,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
FragmentedPayload = Struct(
|
|
204
|
+
|
|
205
|
+
# Fragment information
|
|
206
|
+
"frag" / BitStruct(
|
|
207
|
+
"index" / Nibble,
|
|
208
|
+
"total" / Nibble,
|
|
209
|
+
),
|
|
210
|
+
|
|
211
|
+
# The content of the payload
|
|
212
|
+
"data" / GreedyBytes,
|
|
213
|
+
)
|
|
214
|
+
"""
|
|
215
|
+
Payload section of an Anker packet that is fragmented.
|
|
216
|
+
|
|
217
|
+
The "frag" section represents the fragmentation information of the payload.
|
|
218
|
+
This information is not always present in non-fragmented packets.
|
|
219
|
+
|
|
220
|
+
The "data" section represents the content of the fragment and may be encrypted.
|
|
221
|
+
The fragments must be re-assembled before decryption can begin.
|
|
222
|
+
|
|
223
|
+
This structure is used for re-assembling fragmented payloads only.
|
|
224
|
+
|
|
225
|
+
Structure: <Index 4b> <Total 4b> <Data nB>.
|
|
226
|
+
|
|
227
|
+
Usage:
|
|
228
|
+
|
|
229
|
+
.. code-block:: python
|
|
230
|
+
:linenos:
|
|
231
|
+
|
|
232
|
+
frag_payload = FragmentedPayload.parse(payload_bytes)
|
|
233
|
+
print(f"{frag_p.frag.index}/{frag_p.frag.total}: {frag_p.data}")
|
|
234
|
+
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class ParameterContainer(Container):
|
|
239
|
+
"""Subclass to allow for direct action on the parameter type."""
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def value_legacy(self) -> bytes:
|
|
243
|
+
"""Return the type byte prepended to the value bytes for non-typed values."""
|
|
244
|
+
type_byte = self.type.to_bytes(1) if self.get("type") is not None else b""
|
|
245
|
+
val_bytes = self.get("value") or b""
|
|
246
|
+
return type_byte + val_bytes
|
|
247
|
+
|
|
248
|
+
def to_dict(self, types: bool | None = None) -> dict[str, str]: # noqa: FBT001
|
|
249
|
+
"""Return possible representations of the parameter in dict form.
|
|
250
|
+
|
|
251
|
+
:param: Parameter to be interpreted.
|
|
252
|
+
:types: Display parameter with type information (T=y, F=n, N=both).
|
|
253
|
+
:returns: Dictionary of encodings to decoded values.
|
|
254
|
+
"""
|
|
255
|
+
representation: dict[str, str] = {}
|
|
256
|
+
|
|
257
|
+
# Representation where no type info is encoded
|
|
258
|
+
p_bytes = self.value_legacy
|
|
259
|
+
|
|
260
|
+
# Representation where first byte encodes type information
|
|
261
|
+
p_bytes_t = bytes(self.value)
|
|
262
|
+
|
|
263
|
+
if types is not True:
|
|
264
|
+
representation.update({
|
|
265
|
+
"bytes": str(p_bytes),
|
|
266
|
+
"hex": p_bytes.hex(),
|
|
267
|
+
"int": int.from_bytes(p_bytes, byteorder="little", signed=True),
|
|
268
|
+
"uint": int.from_bytes(p_bytes, byteorder="little", signed=False),
|
|
269
|
+
"length": len(p_bytes),
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
if types is not False:
|
|
273
|
+
representation.update({
|
|
274
|
+
"type (t)": self.type,
|
|
275
|
+
"bytes (t)": str(p_bytes_t),
|
|
276
|
+
"hex (t)": p_bytes_t.hex(),
|
|
277
|
+
"int (t)": int.from_bytes(p_bytes_t, byteorder="little", signed=True),
|
|
278
|
+
"uint (t)": int.from_bytes(p_bytes_t, byteorder="little", signed=False),
|
|
279
|
+
"length (t)": len(p_bytes_t),
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
return representation
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
Parameter = ExprAdapter(
|
|
286
|
+
Struct(
|
|
287
|
+
|
|
288
|
+
# The key of the parameter (e.g a1, a2, ...)
|
|
289
|
+
"key" / Hex(Bytes(1)),
|
|
290
|
+
|
|
291
|
+
# The length of the parameter excluding the key
|
|
292
|
+
"length" / Rebuild(
|
|
293
|
+
Int8ul,
|
|
294
|
+
lambda p: (1 if p.get("type") is not None else 0) + len(p.get("value") or b""),
|
|
295
|
+
),
|
|
296
|
+
|
|
297
|
+
# Optional type of the parameter
|
|
298
|
+
"type" / If(
|
|
299
|
+
lambda p: p.get("type") is not None if p._building else p.length > 1,
|
|
300
|
+
Int8ul,
|
|
301
|
+
),
|
|
302
|
+
|
|
303
|
+
# Optional content of the parameter
|
|
304
|
+
"value" / If(
|
|
305
|
+
lambda p: (p.length - (1 if p.type is not None else 0)) > 0,
|
|
306
|
+
HexDump(Bytes(lambda p: p.length - (1 if p.type is not None else 0))),
|
|
307
|
+
),
|
|
308
|
+
),
|
|
309
|
+
decoder=lambda obj, _: ParameterContainer(obj),
|
|
310
|
+
encoder=lambda obj, _: obj,
|
|
311
|
+
)
|
|
312
|
+
"""
|
|
313
|
+
Individual parameter of a payload of an Anker packet.
|
|
314
|
+
|
|
315
|
+
Paramaters contain a key (e.g a1, a2, ...), the length, optional
|
|
316
|
+
type information, and an optional content.
|
|
317
|
+
|
|
318
|
+
Structure: <Key 1B> <Length 1B> <Type 1B> <Content nB>.
|
|
319
|
+
|
|
320
|
+
The length value is the length of the entire parameter excluding the key.
|
|
321
|
+
|
|
322
|
+
This structure is only used as a part of the Parameters type for creating,
|
|
323
|
+
modifying, encoding, and decoding payloads.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class ParameterDict(dict):
|
|
328
|
+
"""Subclass to allow for direct action on the paramaters type."""
|
|
329
|
+
|
|
330
|
+
def __init__(self, *args, prefix: bytes | None = None, **kwargs):
|
|
331
|
+
super().__init__(*args, **kwargs)
|
|
332
|
+
self.prefix = prefix
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def status(self) -> int | None:
|
|
336
|
+
"""Status byte of a reply, or None for a push."""
|
|
337
|
+
return self.prefix[0] if self.prefix else None
|
|
338
|
+
|
|
339
|
+
def diff(self, old: Self, types: bool | None = None) -> str: # noqa: FBT001
|
|
340
|
+
"""
|
|
341
|
+
Return changes from previous parameters to this in string representation.
|
|
342
|
+
|
|
343
|
+
:param old: Previous entry to compare against.
|
|
344
|
+
:param types: Display parameter with type information (T=y, F=n, N=both).
|
|
345
|
+
"""
|
|
346
|
+
differences: dict[str, str] = {}
|
|
347
|
+
|
|
348
|
+
changed = {k for k in old.keys() & self.keys() if old[k] != self[k]}
|
|
349
|
+
added = self.keys() - old.keys()
|
|
350
|
+
removed = old.keys() - self.keys()
|
|
351
|
+
|
|
352
|
+
for k in sorted(changed | added | removed):
|
|
353
|
+
|
|
354
|
+
# Parameter modified
|
|
355
|
+
if k in changed:
|
|
356
|
+
old_p = old[k].to_dict(types=types)
|
|
357
|
+
new_p = self[k].to_dict(types=types)
|
|
358
|
+
|
|
359
|
+
differences[k] = {
|
|
360
|
+
"state": "~",
|
|
361
|
+
**{f: f"{old_p[f]} -> {new_p[f]}" for f in old_p},
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
# Parameter added
|
|
365
|
+
elif k in added:
|
|
366
|
+
differences[k] = {
|
|
367
|
+
"state": "+",
|
|
368
|
+
**self[k].to_dict(types=types),
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
# Parameter removed
|
|
372
|
+
elif k in removed:
|
|
373
|
+
differences[k] = {
|
|
374
|
+
"state": "-",
|
|
375
|
+
**old[k].to_dict(types=types),
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return json.dumps(differences, indent=4)
|
|
379
|
+
|
|
380
|
+
def to_str(self, verbose: bool = False, types: bool | None = None) -> str:
|
|
381
|
+
"""
|
|
382
|
+
Return string representation of potential parameter encodings.
|
|
383
|
+
|
|
384
|
+
:param verbose: Return possible representations instead of plain bytes.
|
|
385
|
+
:param types: Display parameter with type information (T=y, F=n, N=both).
|
|
386
|
+
:returns: String representation of parameters.
|
|
387
|
+
"""
|
|
388
|
+
if verbose:
|
|
389
|
+
return json.dumps({k: p.to_dict(types) for k, p in self.items()}, indent=4)
|
|
390
|
+
return str({k: v.value_legacy.hex() for k, v in self.items()})
|
|
391
|
+
|
|
392
|
+
def __str__(self) -> str:
|
|
393
|
+
"""Return string representation of potential parameter encodings."""
|
|
394
|
+
return self.to_str()
|
|
395
|
+
|
|
396
|
+
STATUS_LIMIT = 0xA1
|
|
397
|
+
"""TLV tags start at ``0xa1``; a lower first byte is a reply's status."""
|
|
398
|
+
|
|
399
|
+
Parameters = ExprAdapter(
|
|
400
|
+
Struct(
|
|
401
|
+
|
|
402
|
+
# Optional status byte (replies only)
|
|
403
|
+
"prefix" / If(
|
|
404
|
+
lambda this: this._parsing or this.prefix is not None,
|
|
405
|
+
Optional(ExprValidator(Bytes(1), lambda obj, _: obj[0] < STATUS_LIMIT)),
|
|
406
|
+
),
|
|
407
|
+
|
|
408
|
+
# List of parameters
|
|
409
|
+
"parameters" / GreedyRange(Parameter),
|
|
410
|
+
),
|
|
411
|
+
decoder=lambda obj, _: ParameterDict(
|
|
412
|
+
{p.key.hex(): p for p in obj.parameters},
|
|
413
|
+
prefix=obj.prefix,
|
|
414
|
+
),
|
|
415
|
+
encoder=lambda ps, _: {
|
|
416
|
+
"prefix": getattr(ps, "prefix", None),
|
|
417
|
+
"parameters": list(ps.values()) if isinstance(ps, dict) else ps,
|
|
418
|
+
},
|
|
419
|
+
)
|
|
420
|
+
"""
|
|
421
|
+
Decoded parameters of the payload of an Anker packet.
|
|
422
|
+
|
|
423
|
+
The payload of Anker packets is made up of a list of
|
|
424
|
+
parameters. The payload of a reply starts with a status byte;
|
|
425
|
+
pushes have none.
|
|
426
|
+
|
|
427
|
+
Structure: <Prefix 1B> <Parameter 1 nB> ... <Parameter n nB>.
|
|
428
|
+
|
|
429
|
+
This structure is used to encode, decode, modify, and generate payloads.
|
|
430
|
+
|
|
431
|
+
Usage:
|
|
432
|
+
|
|
433
|
+
.. code-block:: python
|
|
434
|
+
:linenos:
|
|
435
|
+
|
|
436
|
+
parameters = Parameters.parse(reassembled_payload)
|
|
437
|
+
parameters["a1"] = Parameter({
|
|
438
|
+
"key": "a1",
|
|
439
|
+
"type": 12,
|
|
440
|
+
"value": "00ff",
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
plaintext_payload = Parameters.build(parameters)
|
|
444
|
+
|
|
445
|
+
"""
|