pyhubblenetwork 0.0.1__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.
@@ -0,0 +1,27 @@
1
+ # hubblenetwork/__init__.py
2
+ """
3
+ Hubble Python SDK — public API façade.
4
+ Import from here; internal module layout may change without notice.
5
+ """
6
+
7
+ from . import ble
8
+ from . import cloud
9
+
10
+ from .packets import Location, EncryptedPacket, DecryptedPacket
11
+ from .device import Device
12
+ from .org import Organization
13
+ from .crypto import decrypt
14
+
15
+ __all__ = [
16
+ "ble",
17
+ "cloud",
18
+ "decrypt",
19
+ "Location",
20
+ "EncryptedPacket",
21
+ "DecryptedPacket",
22
+ "Device",
23
+ "Organization",
24
+ "flash_elf",
25
+ "fetch_elf",
26
+ "patch_elf",
27
+ ]
hubblenetwork/ble.py ADDED
@@ -0,0 +1,93 @@
1
+ # hubblenetwork/ble.py
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from datetime import datetime, timezone
6
+ from typing import Optional
7
+ import geocoder
8
+
9
+ from bleak import BleakScanner
10
+
11
+ # Import your dataclass
12
+ from .packets import (
13
+ Location,
14
+ EncryptedPacket,
15
+ )
16
+
17
+ """
18
+ 16-bit UUID 0xFCA6 in 128-bit Bluetooth Base UUID form
19
+
20
+ Bluetooth spec defines a base UUID 0000xxxx-0000-1000-8000-00805F9B34FB.
21
+ Any 16-bit (or 32-bit) UUID is expanded into that base by substituting xxxx.
22
+
23
+ Libraries normalize to consistent 128-bit strings so you don’t have to guess
24
+ whether a platform will report 16- vs 128-bit in scan results.
25
+
26
+ In bleak, AdvertisementData.service_uuids and the keys in AdvertisementData.service_data
27
+ are 128-bit strings. So matching against the normalized 128-bit form is the most portable.
28
+ """
29
+ _TARGET_UUID = "0000fca6-0000-1000-8000-00805f9b34fb"
30
+
31
+
32
+ def _get_location() -> Location:
33
+ geo = geocoder.ip("me")
34
+ lat, lon = geo.latlng
35
+ return Location(lat=lat, lon=lon)
36
+
37
+
38
+ def scan(timeout: float) -> Optional[EncryptedPacket]:
39
+ """
40
+ Scan for a BLE advertisement that includes service data for UUID 0xFCA6 and
41
+ return it as an EncryptedPacket (payload=data bytes, rssi from the adv).
42
+ Returns None if nothing is found within `timeout` seconds.
43
+
44
+ This is a synchronous convenience wrapper around an asyncio scanner.
45
+ """
46
+
47
+ async def _scan_async(ttl: float) -> List[EncryptedPacket]:
48
+ done = asyncio.Event()
49
+ packets = []
50
+
51
+ def on_detect(device, adv_data) -> None:
52
+ nonlocal packets
53
+ # Normalize to a dict; bleak provides service_data as {uuid_str: bytes}
54
+ service_data = getattr(adv_data, "service_data", None) or {}
55
+ payload = None
56
+
57
+ # Keys are 128-bit UUID strings; compare lowercased
58
+ for uuid_str, data in service_data.items():
59
+ if (uuid_str or "").lower() == _TARGET_UUID:
60
+ payload = bytes(data)
61
+ break
62
+
63
+ if payload is not None:
64
+ rssi = getattr(adv_data, "rssi", getattr(device, "rssi", 0)) or 0
65
+ packets.append(
66
+ EncryptedPacket(
67
+ timestamp=int(datetime.now(timezone.utc).timestamp()),
68
+ location=_get_location(),
69
+ payload=payload,
70
+ rssi=int(rssi),
71
+ )
72
+ )
73
+
74
+ # Start scanning and wait for first match or timeout
75
+ async with BleakScanner(detection_callback=on_detect):
76
+ try:
77
+ await asyncio.wait_for(done.wait(), timeout=ttl)
78
+ except asyncio.TimeoutError:
79
+ pass
80
+
81
+ return packets
82
+
83
+ # Run the async scanner. If there's already a running event loop (e.g., Jupyter),
84
+ # you can adapt this to use `await _scan_async(timeout)` instead.
85
+ try:
86
+ return asyncio.run(_scan_async(timeout))
87
+ except RuntimeError:
88
+ # Fallback for environments with an active loop (e.g., notebooks/async apps)
89
+ loop = asyncio.get_event_loop()
90
+ if loop.is_running():
91
+ # Create a task and block until it’s done via a new Future
92
+ return loop.run_until_complete(_scan_async(timeout)) # type: ignore[func-returns-value]
93
+ return loop.run_until_complete(_scan_async(timeout))
hubblenetwork/cli.py ADDED
@@ -0,0 +1,309 @@
1
+ # hubblenetwork/cli.py
2
+ from __future__ import annotations
3
+
4
+ import click
5
+ import os
6
+ import json
7
+ import time
8
+ import base64
9
+ import sys
10
+ from datetime import timezone, datetime
11
+ from typing import Optional
12
+ from hubblenetwork import Organization
13
+ from hubblenetwork import Device, DecryptedPacket, EncryptedPacket
14
+ from hubblenetwork import ble as ble_mod
15
+ from hubblenetwork import decrypt
16
+
17
+
18
+ def _get_env_or_fail(name: str) -> str:
19
+ val = os.getenv(name)
20
+ if not val:
21
+ raise click.ClickException(f"[ERROR] {name} environment variable not set")
22
+ return val
23
+
24
+
25
+ def _get_org_and_token(org_id, token) -> tuple[str, str]:
26
+ """
27
+ Helper function that checks if the given token and/or org
28
+ are None and gets the env var if not
29
+ """
30
+ if not token:
31
+ token = _get_env_or_fail("HUBBLE_API_TOKEN")
32
+ if not org_id:
33
+ org_id = _get_env_or_fail("HUBBLE_ORG_ID")
34
+ return org_id, token
35
+
36
+
37
+ def _print_packets_pretty(pkts) -> None:
38
+ """Pretty-print an EncryptedPacket."""
39
+ for pkt in pkts:
40
+ ts = datetime.fromtimestamp(pkt.timestamp).strftime("%c")
41
+ loc = pkt.location
42
+ loc_str = (
43
+ f"{loc.lat:.6f},{loc.lon:.6f}"
44
+ if getattr(loc, "lat", None) is not None
45
+ else "unknown"
46
+ )
47
+ click.echo(click.style("=== BLE packet ===", bold=True))
48
+ click.echo(f"time: {ts}")
49
+ click.echo(f"rssi: {pkt.rssi} dBm")
50
+ click.echo(f"loc: {loc_str}")
51
+ # Show both hex and length
52
+ if isinstance(pkt, DecryptedPacket):
53
+ click.echo(f'payload: "{pkt.payload}"')
54
+ elif isinstance(pkt, EncryptedPacket):
55
+ click.echo(f"payload: {pkt.payload.hex()} ({len(pkt.payload)} bytes)")
56
+
57
+
58
+ def _print_packets_csv(pkts) -> None:
59
+ for pkt in pkts:
60
+ ts = datetime.fromtimestamp(pkt.timestamp).strftime("%c")
61
+ if isinstance(pkt, DecryptedPacket):
62
+ payload = pkt.payload
63
+ elif isinstance(pkt, EncryptedPacket):
64
+ payload = pkt.payload.hex()
65
+ click.echo(f"{ts}, {pkt.location.lat:.6f}, {pkt.location.lon:.6f}, {payload}")
66
+
67
+
68
+ def _print_packets_kepler(pkts) -> None:
69
+ """
70
+ https://kepler.gl/demo
71
+
72
+ Can ingest this JSON to visualize a travel path for a device.
73
+ """
74
+ data = {
75
+ "type": "FeatureCollection",
76
+ "features": [
77
+ {
78
+ "type": "Feature",
79
+ "properties": {"vendor": "A"},
80
+ "geometry": {"type": "LineString", "coordinates": []},
81
+ }
82
+ ],
83
+ }
84
+
85
+ for pkt in pkts:
86
+ row = [pkt.location.lon, pkt.location.lat, 0, pkt.timestamp]
87
+ data["features"][0]["geometry"]["coordinates"].append(row)
88
+ click.echo(json.dumps(data))
89
+
90
+
91
+ def _print_packets(pkts, output: str = "pretty") -> None:
92
+ func_name = f"_print_packets_{output.lower().strip()}"
93
+ func = getattr(sys.modules[__name__], func_name, None)
94
+ if callable(func):
95
+ func(pkts)
96
+ else:
97
+ _print_packets_pretty(pkts)
98
+
99
+
100
+ def _print_device(dev: Device) -> None:
101
+ click.echo(f'id: "{dev.id}", ', nl=False)
102
+ click.echo(f'name: "{dev.name}", ', nl=False)
103
+ click.echo(f"tags: {str(dev.tags)}, ", nl=False)
104
+ ts = datetime.fromtimestamp(dev.created_ts).strftime("%c")
105
+ click.echo(f'created: "{ts}", ', nl=False)
106
+ click.echo(f"active: {str(dev.active)}", nl=False)
107
+ if dev.key:
108
+ click.secho(f', key: "{dev.key}"')
109
+ else:
110
+ click.echo("")
111
+
112
+
113
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
114
+ def cli() -> None:
115
+ """Hubble SDK CLI."""
116
+ # top-level group; subcommands are added below
117
+
118
+
119
+ @cli.group()
120
+ def ble() -> None:
121
+ """BLE utilities."""
122
+ # subgroup for BLE-related commands
123
+
124
+
125
+ @ble.command("scan")
126
+ @click.option(
127
+ "--timeout",
128
+ "-t",
129
+ type=int,
130
+ default=5,
131
+ show_default=False,
132
+ help="Timeout when scanning",
133
+ )
134
+ @click.option(
135
+ "--key",
136
+ "-k",
137
+ type=str,
138
+ default=None,
139
+ show_default=False,
140
+ help="Attempt to decrypt any received packet with the given key",
141
+ )
142
+ @click.option("--ingest", is_flag=True)
143
+ def ble_scan(timeout, ingest: bool = False, key: str = None) -> None:
144
+ """
145
+ Scan for UUID 0xFCA6 and print the first packet found within TIMEOUT seconds.
146
+
147
+ Example:
148
+ hubblenetwork ble scan 1
149
+ """
150
+ click.secho(
151
+ f"[INFO] Scanning for Hubble devices (timeout={timeout}s)... ", nl=False
152
+ )
153
+ pkts = ble_mod.scan(timeout=timeout)
154
+ if len(pkts) == 0:
155
+ click.secho(f"[WARNING] No packet found within {timeout:.2f}s", fg="yellow")
156
+ raise SystemExit(1)
157
+ click.echo("[COMPLETE]")
158
+
159
+ click.echo("\n[INFO] Encrypted packets received:")
160
+ _print_packets(pkts)
161
+
162
+ # If we have a key, attempt to decrypt
163
+ if key:
164
+ key = bytearray(base64.b64decode(key))
165
+ decrypted_pkts = []
166
+ for pkt in pkts:
167
+ decrypted_pkt = decrypt(key, pkt)
168
+ if decrypted_pkt:
169
+ decrypted_pkts.append(decrypted_pkt)
170
+ if len(decrypted_pkts) > 0:
171
+ click.echo("\n[INFO] Locally decrypted packets:")
172
+ _print_packets(decrypted_pkts)
173
+ else:
174
+ click.secho("\n[WARNING] No locally decryptable packets found", fg="yellow")
175
+
176
+ if ingest:
177
+ click.echo("[INFO] Ingesting packet(s) into the backend... ", nl=False)
178
+ org = Organization(
179
+ org_id=_get_env_or_fail("HUBBLE_ORG_ID"),
180
+ api_token=_get_env_or_fail("HUBBLE_API_TOKEN"),
181
+ )
182
+ for pkt in pkts:
183
+ org.ingest_packet(pkt)
184
+ click.echo("[SUCCESS]")
185
+
186
+
187
+ @cli.group()
188
+ def org() -> None:
189
+ """Organization utilities."""
190
+ # subgroup for organization-related commands
191
+
192
+
193
+ @click.option(
194
+ "--org-id",
195
+ "-o",
196
+ type=str,
197
+ default=None,
198
+ show_default=False,
199
+ help="Organization ID (if not using HUBBLE_ORG_ID env var)",
200
+ )
201
+ @click.option(
202
+ "--token",
203
+ "-t",
204
+ type=str,
205
+ default=None,
206
+ show_default=False, # show default in --help
207
+ help="Token (if not using HUBBLE_API_TOKEN env var)",
208
+ )
209
+ @org.command("list-devices")
210
+ def list_devices(org_id, token) -> None:
211
+ org_id, token = _get_org_and_token(org_id, token)
212
+
213
+ org = Organization(org_id=org_id, api_token=token)
214
+ devices = org.list_devices()
215
+ for device in devices:
216
+ _print_device(device)
217
+
218
+
219
+ @click.option(
220
+ "--org-id",
221
+ "-o",
222
+ type=str,
223
+ default=None,
224
+ show_default=False,
225
+ help="Organization ID (if not using HUBBLE_ORG_ID env var)",
226
+ )
227
+ @click.option(
228
+ "--token",
229
+ "-t",
230
+ type=str,
231
+ default=None,
232
+ show_default=False, # show default in --help
233
+ help="Token (if not using HUBBLE_API_TOKEN env var)",
234
+ )
235
+ @org.command("register_device")
236
+ def register_device(org_id, token) -> None:
237
+ org_id, token = _get_org_and_token(org_id, token)
238
+
239
+ org = Organization(org_id=org_id, api_token=token)
240
+ click.secho(str(org.register_device()))
241
+
242
+
243
+ @org.command("get-packets")
244
+ @click.argument("device-id", type=str)
245
+ @click.option(
246
+ "--org-id",
247
+ "-o",
248
+ type=str,
249
+ default=None,
250
+ show_default=False,
251
+ help="Organization ID (if not using HUBBLE_ORG_ID env var)",
252
+ )
253
+ @click.option(
254
+ "--token",
255
+ "-t",
256
+ type=str,
257
+ default=None,
258
+ show_default=False, # show default in --help
259
+ help="Token (if not using HUBBLE_API_TOKEN env var)",
260
+ )
261
+ @click.option(
262
+ "--output",
263
+ type=str,
264
+ default=None,
265
+ show_default=False, # show default in --help
266
+ help="Output format (None, pretty, csv)",
267
+ )
268
+ @click.option(
269
+ "--days",
270
+ "-d",
271
+ type=int,
272
+ default=7,
273
+ show_default=False, # show default in --help
274
+ help="Number of days to query back (from now)",
275
+ )
276
+ def get_packets(device_id, org_id, token, output: str = None, days: int = 7) -> None:
277
+ org_id, token = _get_org_and_token(org_id, token)
278
+
279
+ org = Organization(org_id=org_id, api_token=token)
280
+ device = Device(id=device_id)
281
+ packets = org.retrieve_packets(device, days=days)
282
+ _print_packets(packets, output)
283
+
284
+
285
+ @cli.group()
286
+ def demo() -> None:
287
+ """Demo functionality"""
288
+
289
+
290
+ def main(argv: Optional[list[str]] = None) -> int:
291
+ """
292
+ Entry point used by console_scripts.
293
+
294
+ Returns a process exit code instead of letting Click call sys.exit for easier testing.
295
+ """
296
+ try:
297
+ # standalone_mode=False prevents Click from calling sys.exit itself.
298
+ cli.main(args=argv, prog_name="hubble", standalone_mode=False)
299
+ except SystemExit as e:
300
+ return int(e.code)
301
+ except Exception as e: # safety net to avoid tracebacks in user CLI
302
+ click.secho(f"Unexpected error: {e}", fg="red", err=True)
303
+ return 2
304
+ return 0
305
+
306
+
307
+ if __name__ == "__main__":
308
+ # Forward command-line args (excluding the program name) to main()
309
+ raise SystemExit(main())
hubblenetwork/cloud.py ADDED
@@ -0,0 +1,180 @@
1
+ # hubble/cloud_api.py
2
+ from __future__ import annotations
3
+ import httpx
4
+ import time
5
+ import base64
6
+ from typing import Any, Optional
7
+ from collections.abc import MutableMapping
8
+ from .packets import EncryptedPacket, DecryptedPacket
9
+ from .device import Device
10
+ from .errors import (
11
+ BackendError,
12
+ NetworkError,
13
+ APITimeout,
14
+ raise_for_response,
15
+ )
16
+
17
+ _API_BASE: str = "https://api.hubble.com/api"
18
+
19
+
20
+ def _auth_headers(api_token: str) -> dict[str, str]:
21
+ return {
22
+ "Authorization": f"Bearer {api_token}",
23
+ "Accept": "application/json",
24
+ "Content-Type": "application/json",
25
+ }
26
+
27
+
28
+ def _list_devices_endpoint(org_id: str) -> str:
29
+ return f"/org/{org_id}/devices"
30
+
31
+
32
+ def _register_device_endpoint(org_id: str) -> str:
33
+ return f"/v2/org/{org_id}/devices"
34
+
35
+
36
+ def _retrive_org_packets_endpoint(org_id: str) -> str:
37
+ return f"/org/{org_id}/packets"
38
+
39
+
40
+ def _ingest_packets_endpoint(org_id: str) -> str:
41
+ return f"/org/{org_id}/packets"
42
+
43
+
44
+ def cloud_request(
45
+ method: str,
46
+ path: str,
47
+ *,
48
+ api_token: Optional[str] = None,
49
+ json: Any = None,
50
+ timeout_s: float = 10.0,
51
+ params: Optional[MutableMapping[str, Any]] = None,
52
+ ) -> Any:
53
+ """
54
+ Make a single HTTP request to the Hubble Cloud API and return parsed JSON.
55
+
56
+ - `method`: "GET", "POST", etc.
57
+ - `path`: endpoint path (e.g., "/devices" or "orgs/{id}/devices")
58
+ - `api_token`: API token for auth (optional, but recommended)
59
+ - `org_id`: if provided, will be added as query param `orgId=<org_id>`
60
+ (skip or embed in `path` if your endpoint uses a path param instead)
61
+ - `json`: request JSON body (for POST/PUT/PATCH)
62
+ - `timeout_s`: request timeout in seconds
63
+ - `params`: optional HTTP request parameters
64
+ """
65
+ path = path.lstrip("/")
66
+ url = f"{_API_BASE}/{path}"
67
+
68
+ # headers
69
+ headers: MutableMapping[str, str] = {
70
+ "Accept": "application/json",
71
+ "Content-Type": "application/json",
72
+ }
73
+ if api_token:
74
+ headers["Authorization"] = f"Bearer {api_token}"
75
+
76
+ try:
77
+ with httpx.Client(timeout=timeout_s) as client:
78
+ resp = client.request(
79
+ method.upper(), url, params=params, headers=headers, json=json
80
+ )
81
+ except httpx.TimeoutException as e:
82
+ raise APITimeout(f"Request timed out: {method} {url}") from e
83
+ except httpx.HTTPError as e:
84
+ raise NetworkError(f"Network error: {method} {url}: {e}") from e
85
+
86
+ if resp.status_code != 200:
87
+ body = None
88
+ try:
89
+ body = resp.json()
90
+ except Exception:
91
+ body = resp.text
92
+ raise_for_response(resp.status_code, body=body)
93
+
94
+ # Parse JSON body
95
+ try:
96
+ return resp.json()
97
+ except ValueError as e:
98
+ # Server said "application/json" but body isn't JSON
99
+ raise BackendError(f"Non-JSON response from {url}") from e
100
+
101
+
102
+ def ingest(*, org_id: str, api_token: str, packet: EncryptedPacket) -> None:
103
+ """Push an encrypted packet to the cloud."""
104
+ pass
105
+
106
+
107
+ def register_device(
108
+ *, org_id: str, api_token: str, name: Optional[str] = None
109
+ ) -> Device:
110
+ """Create a new device and return it."""
111
+ data = {
112
+ "n_devices": 1,
113
+ "encryption": "AES-256-CTR",
114
+ }
115
+ return cloud_request(
116
+ method="POST",
117
+ path=_register_device_endpoint(org_id),
118
+ api_token=api_token,
119
+ json=data,
120
+ )
121
+
122
+
123
+ def list_devices(*, org_id: str, api_token: str) -> list[Device]:
124
+ """
125
+ List devices for the org (keys typically omitted).
126
+
127
+ Returns:
128
+ json response from server
129
+
130
+ """
131
+ return cloud_request(
132
+ method="GET",
133
+ path=_list_devices_endpoint(org_id),
134
+ api_token=api_token,
135
+ )
136
+
137
+
138
+ def retrieve_packets(
139
+ *, org_id: str, api_token: str, device_id: Optional[str] = None, days: int = 7
140
+ ) -> Optional[DecryptedPacket]:
141
+ """Fetch decrypted packets for a device."""
142
+ params = {"start": (int(time.time()) - (days * 24 * 60 * 60))}
143
+ if device_id:
144
+ params["device_id"] = device_id
145
+ return cloud_request(
146
+ method="GET",
147
+ path=_retrive_org_packets_endpoint(org_id),
148
+ api_token=api_token,
149
+ params=params,
150
+ )
151
+
152
+
153
+ def ingest_packet(*, org_id: str, api_token: str, packet: EncryptedPacket) -> None:
154
+ body = {
155
+ "ble_locations": [
156
+ {
157
+ "location": {
158
+ "latitude": packet.location.lat,
159
+ "longitude": packet.location.lon,
160
+ "timestamp": packet.timestamp,
161
+ "horizontal_accuracy": 42,
162
+ "altitude": 42,
163
+ "vertical_accuracy": 42,
164
+ },
165
+ "adv": [
166
+ {
167
+ "payload": base64.b64encode(packet.payload).decode("utf-8"),
168
+ "rssi": packet.rssi,
169
+ "timestamp": packet.timestamp,
170
+ }
171
+ ],
172
+ }
173
+ ]
174
+ }
175
+ return cloud_request(
176
+ method="POST",
177
+ path=_ingest_packets_endpoint(org_id),
178
+ api_token=api_token,
179
+ json=body,
180
+ )
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+ from Crypto.Cipher import AES
3
+ from Crypto.Hash import CMAC
4
+ from Crypto.Protocol.KDF import SP800_108_Counter
5
+ from datetime import datetime, timezone
6
+
7
+ from .packets import EncryptedPacket, DecryptedPacket
8
+
9
+ # Valid values are 16 and 32, respectively for AES-128 and AES-256
10
+ _HUBBLE_AES_KEY_SIZE = 32
11
+
12
+ _HUBBLE_AES_NONCE_SIZE = 12
13
+ _HUBBLE_AES_TAG_SIZE = 4
14
+
15
+
16
+ def _generate_kdf_key(key: bytes, key_size: int, label: str, context: int) -> bytes:
17
+ label = label.encode()
18
+ context = str(context).encode()
19
+
20
+ return SP800_108_Counter(
21
+ key,
22
+ key_size,
23
+ lambda session_key, data: CMAC.new(session_key, data, AES).digest(),
24
+ label=label,
25
+ context=context,
26
+ )
27
+
28
+
29
+ def _get_nonce(key: bytes, time_counter: int, counter: int) -> bytes:
30
+ nonce_key = _generate_kdf_key(key, _HUBBLE_AES_KEY_SIZE, "NonceKey", time_counter)
31
+
32
+ return _generate_kdf_key(nonce_key, _HUBBLE_AES_NONCE_SIZE, "Nonce", counter)
33
+
34
+
35
+ def _get_encryption_key(key: bytes, time_counter: int, counter: int) -> bytes:
36
+ encryption_key = _generate_kdf_key(
37
+ key, _HUBBLE_AES_KEY_SIZE, "EncryptionKey", time_counter
38
+ )
39
+
40
+ return _generate_kdf_key(encryption_key, _HUBBLE_AES_KEY_SIZE, "Key", counter)
41
+
42
+
43
+ def _get_auth_tag(key: bytes, ciphertext: bytes) -> bytes:
44
+ computed_cmac = CMAC.new(key, ciphertext, AES).digest()
45
+
46
+ return computed_cmac[:_HUBBLE_AES_TAG_SIZE]
47
+
48
+
49
+ def _aes_decrypt(key: bytes, session_nonce: bytes, ciphertext: bytes) -> bytes:
50
+ cipher = AES.new(key, AES.MODE_CTR, nonce=session_nonce)
51
+
52
+ return cipher.decrypt(ciphertext)
53
+
54
+
55
+ def decrypt(
56
+ key: bytes, encrypted_pkt: EncryptedPacket, days: int = 2
57
+ ) -> Optional[DecryptedPacket]:
58
+ ble_adv = encrypted_pkt.payload
59
+ seq_no = int.from_bytes(ble_adv[0:2], "big") & 0x3FF
60
+ device_id = ble_adv[2:6].hex()
61
+ auth_tag = ble_adv[6:10]
62
+ encrypted_payload = ble_adv[10:]
63
+ day_offset = 0
64
+
65
+ time_counter = int(datetime.now(timezone.utc).timestamp()) // 86400
66
+
67
+ for t in range(-days, days + 1):
68
+ daily_key = _get_encryption_key(key, time_counter + t, seq_no)
69
+ tag = _get_auth_tag(daily_key, encrypted_payload)
70
+
71
+ if tag == auth_tag:
72
+ day_offset = t
73
+ nonce = _get_nonce(key, time_counter, seq_no)
74
+ decrypted_payload = _aes_decrypt(daily_key, nonce, encrypted_payload)
75
+ return DecryptedPacket(
76
+ timestamp=encrypted_pkt.timestamp,
77
+ device_id="",
78
+ device_name="",
79
+ location=encrypted_pkt.location,
80
+ tags=[],
81
+ payload=decrypted_payload,
82
+ rssi=encrypted_pkt.rssi,
83
+ counter=None,
84
+ sequence=None,
85
+ )
86
+ return None