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.
Files changed (48) hide show
  1. {solixble-3.9.0 → solixble-4.0.0b2}/PKG-INFO +4 -1
  2. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/__init__.py +4 -0
  3. solixble-4.0.0b2/SolixBLE/advertisement.py +70 -0
  4. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/const.py +31 -7
  5. solixble-4.0.0b2/SolixBLE/constructs.py +445 -0
  6. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/device.py +375 -409
  7. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/c1000.py +75 -52
  8. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/c1000g2.py +31 -24
  9. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/c300.py +76 -52
  10. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/c300dc.py +53 -24
  11. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/c800.py +74 -50
  12. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/f2600.py +113 -72
  13. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/f3800.py +76 -0
  14. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/prime_charger_160w.py +63 -33
  15. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/prime_charger_250w.py +96 -58
  16. solixble-4.0.0b2/SolixBLE/factory.py +95 -0
  17. solixble-4.0.0b2/SolixBLE/prime_device.py +375 -0
  18. solixble-4.0.0b2/SolixBLE/transport.py +51 -0
  19. solixble-4.0.0b2/SolixBLE/utilities.py +114 -0
  20. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE.egg-info/PKG-INFO +4 -1
  21. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE.egg-info/SOURCES.txt +12 -1
  22. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE.egg-info/requires.txt +3 -0
  23. {solixble-3.9.0 → solixble-4.0.0b2}/pyproject.toml +5 -2
  24. solixble-4.0.0b2/tests/test_advertisement.py +152 -0
  25. solixble-4.0.0b2/tests/test_commands.py +242 -0
  26. {solixble-3.9.0 → solixble-4.0.0b2}/tests/test_connection.py +20 -14
  27. solixble-4.0.0b2/tests/test_constructs.py +100 -0
  28. {solixble-3.9.0 → solixble-4.0.0b2}/tests/test_devices.py +31 -313
  29. solixble-4.0.0b2/tests/test_factory.py +94 -0
  30. {solixble-3.9.0 → solixble-4.0.0b2}/tests/test_prime.py +6 -5
  31. solixble-4.0.0b2/tests/test_routing.py +215 -0
  32. solixble-4.0.0b2/tests/test_transport.py +119 -0
  33. solixble-4.0.0b2/tests/test_utilities.py +49 -0
  34. solixble-3.9.0/SolixBLE/prime_device.py +0 -524
  35. solixble-3.9.0/SolixBLE/utilities.py +0 -47
  36. {solixble-3.9.0 → solixble-4.0.0b2}/LICENSE.txt +0 -0
  37. {solixble-3.9.0 → solixble-4.0.0b2}/README.md +0 -0
  38. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/__init__.py +0 -0
  39. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/f2000.py +0 -0
  40. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/generic.py +0 -0
  41. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/maggo_3in1.py +0 -0
  42. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/prime_power_bank_20k.py +0 -0
  43. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/solarbank2.py +0 -0
  44. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/devices/solarbank3.py +0 -0
  45. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE/states.py +0 -0
  46. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE.egg-info/dependency_links.txt +0 -0
  47. {solixble-3.9.0 → solixble-4.0.0b2}/SolixBLE.egg-info/top_level.txt +0 -0
  48. {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.9.0
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 = "ff0940000300010005a10443ad8c69a22462326463306231372d623735642d346162662d626136652d656337633939376332336537a30120a40200f0a50140fa"
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
+ """