pyhubblenetwork 0.0.2__tar.gz → 0.0.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyhubblenetwork
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Hubble SDK host-side tools
5
5
  Author-email: Paul Buckley <paul@hubble.com>
6
6
  License-Expression: Apache-2.0
@@ -84,9 +84,9 @@ pip install -e '.[dev]'
84
84
  ### Scan locally, then ingest to backend
85
85
 
86
86
  ```python
87
- from hubblenetwork import ble, Organization
87
+ from hubblenetwork import ble, Organization, Credentials
88
88
 
89
- org = Organization(org_id="org_123", api_token="sk_XXX")
89
+ org = Organization(Credentials(org_id="org_123", api_token="sk_XXX"))
90
90
  pkts = ble.scan(timeout=5.0)
91
91
  if len(pkts) > 0:
92
92
  org.ingest_packet(pkts[0])
@@ -97,9 +97,9 @@ else:
97
97
  ### Manage devices and query packets
98
98
 
99
99
  ```python
100
- from hubblenetwork import Organization
100
+ from hubblenetwork import Organization, Credentials
101
101
 
102
- org = Organization(org_id="org_123", api_token="sk_XXX")
102
+ org = Organization(Credentials(org_id="org_123", api_token="sk_XXX"))
103
103
 
104
104
  # Create a new device
105
105
  new_dev = org.register_device()
@@ -61,9 +61,9 @@ pip install -e '.[dev]'
61
61
  ### Scan locally, then ingest to backend
62
62
 
63
63
  ```python
64
- from hubblenetwork import ble, Organization
64
+ from hubblenetwork import ble, Organization, Credentials
65
65
 
66
- org = Organization(org_id="org_123", api_token="sk_XXX")
66
+ org = Organization(Credentials(org_id="org_123", api_token="sk_XXX"))
67
67
  pkts = ble.scan(timeout=5.0)
68
68
  if len(pkts) > 0:
69
69
  org.ingest_packet(pkts[0])
@@ -74,9 +74,9 @@ else:
74
74
  ### Manage devices and query packets
75
75
 
76
76
  ```python
77
- from hubblenetwork import Organization
77
+ from hubblenetwork import Organization, Credentials
78
78
 
79
- org = Organization(org_id="org_123", api_token="sk_XXX")
79
+ org = Organization(Credentials(org_id="org_123", api_token="sk_XXX"))
80
80
 
81
81
  # Create a new device
82
82
  new_dev = org.register_device()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyhubblenetwork"
7
- version = "0.0.2"
7
+ version = "0.0.4"
8
8
  requires-python = ">=3.9"
9
9
  authors = [
10
10
  { name="Paul Buckley", email="paul@hubble.com" },
@@ -11,6 +11,8 @@ from .packets import Location, EncryptedPacket, DecryptedPacket
11
11
  from .device import Device
12
12
  from .org import Organization
13
13
  from .crypto import decrypt
14
+ from .errors import InvalidCredentialsError
15
+ from .cloud import Credentials, Environment
14
16
 
15
17
  __all__ = [
16
18
  "ble",
@@ -21,4 +23,7 @@ __all__ = [
21
23
  "DecryptedPacket",
22
24
  "Device",
23
25
  "Organization",
26
+ "Credentials",
27
+ "Environment",
28
+ "InvalidCredentialsError",
24
29
  ]
@@ -1,9 +1,8 @@
1
1
  # hubblenetwork/ble.py
2
2
  from __future__ import annotations
3
-
4
3
  import asyncio
5
4
  from datetime import datetime, timezone
6
- from typing import Optional
5
+ from typing import Optional, List
7
6
  import geocoder
8
7
 
9
8
  from bleak import BleakScanner
@@ -90,3 +89,68 @@ def scan(timeout: float) -> List[EncryptedPacket]:
90
89
  # Create a task and block until it’s done via a new Future
91
90
  return loop.run_until_complete(_scan_async(timeout)) # type: ignore[func-returns-value]
92
91
  return loop.run_until_complete(_scan_async(timeout))
92
+
93
+
94
+ def scan_single(timeout: float) -> Optional[EncryptedPacket]:
95
+ """
96
+ Scan for a BLE advertisement that includes service data for UUID 0xFCA6 and
97
+ return it.
98
+ """
99
+
100
+ async def _scan_async(ttl: float) -> List[EncryptedPacket]:
101
+ done = asyncio.Event()
102
+ packet: Optional[EncryptedPacket] = None
103
+
104
+ def on_detect(device, adv_data) -> None:
105
+ nonlocal packet
106
+
107
+ # If we already found a packet, ignore further callbacks
108
+ if packet is not None:
109
+ return
110
+
111
+ # Normalize to a dict; bleak provides service_data as {uuid_str: bytes}
112
+ service_data = getattr(adv_data, "service_data", None) or {}
113
+ service_uuids = getattr(adv_data, "service_uuids", None) or []
114
+ payload = None
115
+
116
+ if _TARGET_UUID not in service_uuids:
117
+ return
118
+
119
+ # Keys are 128-bit UUID strings; compare lowercased
120
+ for uuid_str, data in service_data.items():
121
+ if (uuid_str or "").lower() == _TARGET_UUID:
122
+ payload = bytes(data)
123
+ break
124
+
125
+ if payload is None:
126
+ return
127
+
128
+ rssi = getattr(adv_data, "rssi", getattr(device, "rssi", 0)) or 0
129
+ packet = EncryptedPacket(
130
+ timestamp=int(datetime.now(timezone.utc).timestamp()),
131
+ location=_get_location(),
132
+ payload=payload,
133
+ rssi=int(rssi),
134
+ )
135
+ done.set()
136
+
137
+ # Start scanning and wait for first match or timeout
138
+ async with BleakScanner(detection_callback=on_detect):
139
+ try:
140
+ await asyncio.wait_for(done.wait(), timeout=ttl)
141
+ except asyncio.TimeoutError:
142
+ pass
143
+
144
+ return packet
145
+
146
+ # Run the async scanner. If there's already a running event loop (e.g., Jupyter),
147
+ # you can adapt this to use `await _scan_async(timeout)` instead.
148
+ try:
149
+ return asyncio.run(_scan_async(timeout))
150
+ except RuntimeError:
151
+ # Fallback for environments with an active loop (e.g., notebooks/async apps)
152
+ loop = asyncio.get_event_loop()
153
+ if loop.is_running():
154
+ # Create a task and block until it’s done via a new Future
155
+ return loop.run_until_complete(_scan_async(timeout)) # type: ignore[func-returns-value]
156
+ return loop.run_until_complete(_scan_async(timeout))
@@ -4,15 +4,16 @@ from __future__ import annotations
4
4
  import click
5
5
  import os
6
6
  import json
7
- import time
8
- import base64
9
7
  import sys
10
- from datetime import timezone, datetime
8
+ import time
9
+ from datetime import datetime
11
10
  from typing import Optional
12
11
  from hubblenetwork import Organization
13
12
  from hubblenetwork import Device, DecryptedPacket, EncryptedPacket
14
13
  from hubblenetwork import ble as ble_mod
15
14
  from hubblenetwork import decrypt
15
+ from hubblenetwork import cloud
16
+ from hubblenetwork import InvalidCredentialsError
16
17
 
17
18
 
18
19
  def _get_env_or_fail(name: str) -> str:
@@ -34,28 +35,53 @@ def _get_org_and_token(org_id, token) -> tuple[str, str]:
34
35
  return org_id, token
35
36
 
36
37
 
38
+ def _print_packet_table_header() -> None:
39
+ click.echo(
40
+ "\nTIME RSSI LAT LON PAYLOAD (B)"
41
+ )
42
+ click.echo(
43
+ "---------------------------------------------------------------------------"
44
+ )
45
+
46
+
47
+ def _print_packet_table_row(pkt) -> None:
48
+ ts = datetime.fromtimestamp(pkt.timestamp).strftime("%c")
49
+ click.echo(
50
+ f"{ts} {pkt.rssi} {pkt.location.lat:.6f} {pkt.location.lon:.6f} ",
51
+ nl=False,
52
+ )
53
+ if isinstance(pkt, DecryptedPacket):
54
+ click.echo(f'payload: "{pkt.payload}"')
55
+ elif isinstance(pkt, EncryptedPacket):
56
+ click.echo(f"{pkt.payload.hex()} ({len(pkt.payload)} bytes)")
57
+
58
+
59
+ def _print_packet_pretty(pkt) -> None:
60
+ ts = datetime.fromtimestamp(pkt.timestamp).strftime("%c")
61
+ loc = pkt.location
62
+ loc_str = (
63
+ f"{loc.lat:.6f},{loc.lon:.6f}"
64
+ if getattr(loc, "lat", None) is not None
65
+ else "unknown"
66
+ )
67
+ click.echo(click.style("=== BLE packet ===", bold=True))
68
+ click.echo(f"time: {ts}")
69
+ click.echo(f"rssi: {pkt.rssi} dBm")
70
+ click.echo(f"loc: {loc_str}")
71
+ # Show both hex and length
72
+ if isinstance(pkt, DecryptedPacket):
73
+ click.echo(f'payload: "{pkt.payload}"')
74
+ elif isinstance(pkt, EncryptedPacket):
75
+ click.echo(f"payload: {pkt.payload.hex()} ({len(pkt.payload)} bytes)")
76
+
77
+
37
78
  def _print_packets_pretty(pkts) -> None:
38
79
  if len(pkts) == 0:
39
80
  click.echo("No packets!")
40
81
  return
41
82
  """Pretty-print an EncryptedPacket."""
42
83
  for pkt in pkts:
43
- ts = datetime.fromtimestamp(pkt.timestamp).strftime("%c")
44
- loc = pkt.location
45
- loc_str = (
46
- f"{loc.lat:.6f},{loc.lon:.6f}"
47
- if getattr(loc, "lat", None) is not None
48
- else "unknown"
49
- )
50
- click.echo(click.style("=== BLE packet ===", bold=True))
51
- click.echo(f"time: {ts}")
52
- click.echo(f"rssi: {pkt.rssi} dBm")
53
- click.echo(f"loc: {loc_str}")
54
- # Show both hex and length
55
- if isinstance(pkt, DecryptedPacket):
56
- click.echo(f'payload: "{pkt.payload}"')
57
- elif isinstance(pkt, EncryptedPacket):
58
- click.echo(f"payload: {pkt.payload.hex()} ({len(pkt.payload)} bytes)")
84
+ _print_packet_pretty(pkt)
59
85
 
60
86
 
61
87
  def _print_packets_csv(pkts) -> None:
@@ -122,6 +148,36 @@ def cli() -> None:
122
148
  # top-level group; subcommands are added below
123
149
 
124
150
 
151
+ @cli.command("validate-credentials")
152
+ @click.option(
153
+ "--org-id",
154
+ "-o",
155
+ type=str,
156
+ envvar="HUBBLE_ORG_ID",
157
+ default=None,
158
+ show_default=False,
159
+ help="Organization ID (if not using HUBBLE_ORG_ID env var)",
160
+ )
161
+ @click.option(
162
+ "--token",
163
+ "-t",
164
+ type=str,
165
+ envvar="HUBBLE_API_TOKEN",
166
+ default=None,
167
+ show_default=False,
168
+ help="Token (if not using HUBBLE_API_TOKEN env var)",
169
+ )
170
+ def validate_credentials(org_id, token) -> None:
171
+ """Validate the given credentials"""
172
+ # subgroup for organization-related commands
173
+ credentials = cloud.Credentials(org_id, token)
174
+ env = cloud.get_env_from_credentials(credentials)
175
+ if env:
176
+ click.echo(f'Valid credentials (env="{env.name}")')
177
+ else:
178
+ click.secho("Invalid credentials!", fg="red", err=True)
179
+
180
+
125
181
  @cli.group()
126
182
  def ble() -> None:
127
183
  """BLE utilities."""
@@ -133,7 +189,6 @@ def ble() -> None:
133
189
  "--timeout",
134
190
  "-t",
135
191
  type=int,
136
- default=5,
137
192
  show_default=False,
138
193
  help="Timeout when scanning",
139
194
  )
@@ -146,48 +201,44 @@ def ble() -> None:
146
201
  help="Attempt to decrypt any received packet with the given key",
147
202
  )
148
203
  @click.option("--ingest", is_flag=True)
149
- def ble_scan(timeout, ingest: bool = False, key: str = None) -> None:
204
+ def ble_scan(
205
+ timeout: Optional(int) = None, ingest: bool = False, key: str = None
206
+ ) -> None:
150
207
  """
151
208
  Scan for UUID 0xFCA6 and print the first packet found within TIMEOUT seconds.
152
209
 
153
210
  Example:
154
211
  hubblenetwork ble scan 1
155
212
  """
156
- click.secho(
157
- f"[INFO] Scanning for Hubble devices (timeout={timeout}s)... ", nl=False
158
- )
159
- pkts = ble_mod.scan(timeout=timeout)
160
- if len(pkts) == 0:
161
- click.secho(f"[WARNING] No packet found within {timeout:.2f}s", fg="yellow")
162
- raise SystemExit(1)
163
- click.echo("[COMPLETE]")
164
-
165
- click.echo("\n[INFO] Encrypted packets received:")
166
- _print_packets(pkts)
167
-
168
- # If we have a key, attempt to decrypt
169
- if key:
170
- key = bytearray(base64.b64decode(key))
171
- decrypted_pkts = []
172
- for pkt in pkts:
173
- decrypted_pkt = decrypt(key, pkt)
174
- if decrypted_pkt:
175
- decrypted_pkts.append(decrypted_pkt)
176
- if len(decrypted_pkts) > 0:
177
- click.echo("\n[INFO] Locally decrypted packets:")
178
- _print_packets(decrypted_pkts)
179
- else:
180
- click.secho("\n[WARNING] No locally decryptable packets found", fg="yellow")
213
+ click.secho("[INFO] Scanning for Hubble devices...")
214
+ _print_packet_table_header()
181
215
 
182
216
  if ingest:
183
- click.echo("[INFO] Ingesting packet(s) into the backend... ", nl=False)
184
217
  org = Organization(
185
218
  org_id=_get_env_or_fail("HUBBLE_ORG_ID"),
186
219
  api_token=_get_env_or_fail("HUBBLE_API_TOKEN"),
187
220
  )
188
- for pkt in pkts:
221
+
222
+ start = time.monotonic()
223
+ deadline = None if timeout is None else start + timeout
224
+
225
+ while deadline is None or time.monotonic() < deadline:
226
+ this_timeout = None if deadline is None else max(deadline - time.monotonic(), 0)
227
+
228
+ pkt = ble_mod.scan_single(timeout=this_timeout)
229
+ if not pkt:
230
+ break
231
+
232
+ # If we have a key, attempt to decrypt
233
+ if key:
234
+ decrypted_pkt = decrypt(key, pkt)
235
+ if decrypted_pkt:
236
+ _print_packet_table_row(pkt)
237
+ else:
238
+ _print_packet_table_row(pkt)
239
+
240
+ if ingest:
189
241
  org.ingest_packet(pkt)
190
- click.echo("[SUCCESS]")
191
242
 
192
243
 
193
244
  pass_orgcfg = click.make_pass_decorator(Organization, ensure=True)
@@ -212,21 +263,14 @@ pass_orgcfg = click.make_pass_decorator(Organization, ensure=True)
212
263
  show_default=False,
213
264
  help="Token (if not using HUBBLE_API_TOKEN env var)",
214
265
  )
215
- @click.option(
216
- "--url",
217
- "-u",
218
- type=str,
219
- envvar="HUBBLE_BASE_URL",
220
- default=None,
221
- show_default=False,
222
- help="Base URL to override production (if not using HUBBLE_BASE_URL env var)",
223
- )
224
266
  @click.pass_context
225
- def org(ctx, org_id, token, url) -> None:
267
+ def org(ctx, org_id, token) -> None:
226
268
  """Organization utilities."""
227
269
  # subgroup for organization-related commands
228
- ctx.obj = Organization(org_id=org_id, api_token=token)
229
- ctx.obj.base_url = url
270
+ try:
271
+ ctx.obj = Organization(org_id=org_id, api_token=token)
272
+ except InvalidCredentialsError as e:
273
+ raise click.BadParameter(str(e))
230
274
 
231
275
 
232
276
  @org.command("info")
@@ -247,9 +291,19 @@ def list_devices(org: Organization) -> None:
247
291
 
248
292
 
249
293
  @org.command("register-device")
294
+ @click.option(
295
+ "--encryption",
296
+ "-e",
297
+ type=str,
298
+ default=None,
299
+ show_default=False, # show default in --help
300
+ help="Encryption type [AES-256-CTR, AES-128-CTR]",
301
+ )
250
302
  @pass_orgcfg
251
- def register_device(org: Organization) -> None:
252
- click.secho(str(org.register_device()))
303
+ def register_device(org: Organization, encryption) -> None:
304
+ if encryption:
305
+ click.secho(f'[INFO] Overriding default encryption, using "{encryption}"')
306
+ click.secho(str(org.register_device(encryption=encryption)))
253
307
 
254
308
 
255
309
  @org.command("set-device-name")
@@ -1,5 +1,6 @@
1
1
  # hubble/cloud_api.py
2
2
  from __future__ import annotations
3
+ from dataclasses import dataclass
3
4
  import httpx
4
5
  import time
5
6
  import base64
@@ -13,11 +14,23 @@ from .errors import (
13
14
  raise_for_response,
14
15
  )
15
16
 
16
- ENVIRONMENTS = {
17
- "PROD": "https://api.hubble.com",
18
- "TESTING": "https://api-testing.hubblenetwork.io",
19
- }
20
- default_env_url = None
17
+
18
+ @dataclass(frozen=True)
19
+ class Environment:
20
+ name: str
21
+ url: str
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Credentials:
26
+ org_id: str
27
+ api_token: str
28
+
29
+
30
+ _ENVIRONMENTS = [
31
+ Environment("PROD", "https://api.hubble.com"),
32
+ Environment("TESTING", "https://api-testing.hubblenetwork.io"),
33
+ ]
21
34
 
22
35
 
23
36
  def _auth_headers(api_token: str) -> dict[str, str]:
@@ -28,69 +41,64 @@ def _auth_headers(api_token: str) -> dict[str, str]:
28
41
  }
29
42
 
30
43
 
31
- def _list_devices_endpoint(org_id: str) -> str:
32
- return f"/org/{org_id}/devices"
44
+ def _list_devices_endpoint(credentials: Credentials) -> str:
45
+ return f"/org/{credentials.org_id}/devices"
33
46
 
34
47
 
35
- def _register_device_endpoint(org_id: str) -> str:
36
- return f"/v2/org/{org_id}/devices"
48
+ def _register_device_endpoint(credentials: Credentials) -> str:
49
+ return f"/v2/org/{credentials.org_id}/devices"
37
50
 
38
51
 
39
- def _retrieve_org_packets_endpoint(org_id: str) -> str:
40
- return f"/org/{org_id}/packets"
52
+ def _retrieve_org_packets_endpoint(credentials: Credentials) -> str:
53
+ return f"/org/{credentials.org_id}/packets"
41
54
 
42
55
 
43
- def _ingest_packets_endpoint(org_id: str) -> str:
44
- return f"/org/{org_id}/packets"
56
+ def _ingest_packets_endpoint(credentials: Credentials) -> str:
57
+ return f"/org/{credentials.org_id}/packets"
45
58
 
46
59
 
47
- def _update_device_endpoint(org_id: str, device_id: str) -> str:
48
- return f"/org/{org_id}/devices/{device_id}"
60
+ def _update_device_endpoint(credentials: Credentials, device_id: str) -> str:
61
+ return f"/org/{credentials.org_id}/devices/{device_id}"
49
62
 
50
63
 
51
- def _retrive_org_metadata_endpoint(org_id: str) -> str:
52
- return f"/org/{org_id}"
64
+ def _retrieve_org_metadata_endpoint(credentials: Credentials) -> str:
65
+ return f"/org/{credentials.org_id}"
53
66
 
54
67
 
55
- def set_env(env: str) -> None:
56
- default_env_url = env
68
+ def _validate_key_endpoint(credentials: Credentials) -> str:
69
+ return f"/org/{credentials.org_id}/check"
57
70
 
58
71
 
59
72
  def cloud_request(
73
+ *,
60
74
  method: str,
61
75
  path: str,
62
- *,
63
- api_token: Optional[str] = None,
76
+ env: Environment,
77
+ credentials: Optional[Credentials] = None,
64
78
  json: Any = None,
65
79
  timeout_s: float = 10.0,
66
80
  params: Optional[MutableMapping[str, Any]] = None,
67
- base_url: Optional[str] = None,
68
81
  ) -> Any:
69
82
  """
70
83
  Make a single HTTP request to the Hubble Cloud API and return parsed JSON.
71
84
 
72
85
  - `method`: "GET", "POST", etc.
73
86
  - `path`: endpoint path (e.g., "/devices" or "orgs/{id}/devices")
74
- - `api_token`: API token for auth (optional, but recommended)
75
- - `org_id`: if provided, will be added as query param `orgId=<org_id>`
76
- (skip or embed in `path` if your endpoint uses a path param instead)
87
+ - `credentials`: Credentials to use for this call
88
+ - `env`: Environment to call into (typically prod or testing)
77
89
  - `json`: request JSON body (for POST/PUT/PATCH)
78
90
  - `timeout_s`: request timeout in seconds
79
91
  - `params`: optional HTTP request parameters
80
- - `base_url`: URL to use in place of default production URL
81
92
  """
82
- path = path.lstrip("/")
83
- base_url = base_url if base_url is not None else default_env_url
84
- base_url = base_url.rstrip("/")
85
- url = f"{base_url}/api/{path}"
93
+ url = f"{env.url.rstrip('/')}/api/{path.lstrip('/')}"
86
94
 
87
95
  # headers
88
96
  headers: MutableMapping[str, str] = {
89
97
  "Accept": "application/json",
90
98
  "Content-Type": "application/json",
91
99
  }
92
- if api_token:
93
- headers["Authorization"] = f"Bearer {api_token}"
100
+ if credentials:
101
+ headers["Authorization"] = f"Bearer {credentials.api_token}"
94
102
 
95
103
  try:
96
104
  with httpx.Client(timeout=timeout_s) as client:
@@ -118,31 +126,44 @@ def cloud_request(
118
126
  raise BackendError(f"Non-JSON response from {url}") from e
119
127
 
120
128
 
129
+ def get_env_from_credentials(credentials: Credentials) -> Optional[Environment]:
130
+ for env in _ENVIRONMENTS:
131
+ try:
132
+ # If this call fails then we know we don't have the
133
+ # credentials for this environment
134
+ cloud_request(
135
+ method="GET",
136
+ path=_validate_key_endpoint(credentials),
137
+ credentials=credentials,
138
+ env=env,
139
+ )
140
+ return env
141
+ except:
142
+ pass
143
+ return None
144
+
145
+
121
146
  def register_device(
122
- *,
123
- org_id: str,
124
- api_token: str,
125
- base_url: Optional[str] = None,
147
+ *, credentials: Credentials, env: Environment, encryption: str = "AES-256-CTR"
126
148
  ) -> Any:
127
149
  """Create a new device and return it."""
128
150
  data = {
129
151
  "n_devices": 1,
130
- "encryption": "AES-256-CTR",
152
+ "encryption": "AES-256-CTR" if not encryption else encryption,
131
153
  }
132
154
  return cloud_request(
133
155
  method="POST",
134
- path=_register_device_endpoint(org_id),
135
- api_token=api_token,
156
+ env=env,
157
+ path=_register_device_endpoint(credentials),
158
+ credentials=credentials,
136
159
  json=data,
137
- base_url=base_url,
138
160
  )
139
161
 
140
162
 
141
163
  def update_device(
142
164
  *,
143
- org_id: str,
144
- api_token: str,
145
- base_url: Optional[str] = None,
165
+ credentials: Credentials,
166
+ env: Environment,
146
167
  name: str,
147
168
  device_id: str,
148
169
  ) -> Any:
@@ -153,15 +174,17 @@ def update_device(
153
174
  }
154
175
  return cloud_request(
155
176
  method="PATCH",
156
- path=_update_device_endpoint(org_id, device_id),
157
- api_token=api_token,
177
+ env=env,
178
+ path=_update_device_endpoint(credentials, device_id),
179
+ credentials=credentials,
158
180
  json=data,
159
- base_url=base_url,
160
181
  )
161
182
 
162
183
 
163
184
  def list_devices(
164
- *, org_id: str, api_token: str, base_url: Optional[str] = None
185
+ *,
186
+ credentials: Credentials,
187
+ env: Environment,
165
188
  ) -> list[Any]:
166
189
  """
167
190
  List devices for the org (keys typically omitted).
@@ -172,19 +195,18 @@ def list_devices(
172
195
  """
173
196
  return cloud_request(
174
197
  method="GET",
175
- path=_list_devices_endpoint(org_id),
176
- api_token=api_token,
177
- base_url=base_url,
198
+ env=env,
199
+ path=_list_devices_endpoint(credentials),
200
+ credentials=credentials,
178
201
  )
179
202
 
180
203
 
181
204
  def retrieve_packets(
182
205
  *,
183
- org_id: str,
184
- api_token: str,
185
- device_id: Optional[str] = None,
206
+ credentials: Credentials,
207
+ env: Environment,
208
+ device_id: str,
186
209
  days: int = 7,
187
- base_url: Optional[str] = None,
188
210
  ) -> Any:
189
211
  """Fetch decrypted packets for a device."""
190
212
  params = {"start": (int(time.time()) - (days * 24 * 60 * 60))}
@@ -192,19 +214,18 @@ def retrieve_packets(
192
214
  params["device_id"] = device_id
193
215
  return cloud_request(
194
216
  method="GET",
195
- path=_retrieve_org_packets_endpoint(org_id),
196
- api_token=api_token,
217
+ env=env,
218
+ path=_retrieve_org_packets_endpoint(credentials),
219
+ credentials=credentials,
197
220
  params=params,
198
- base_url=base_url,
199
221
  )
200
222
 
201
223
 
202
224
  def ingest_packet(
203
225
  *,
204
- org_id: str,
205
- api_token: str,
226
+ credentials: Credentials,
227
+ env: Environment,
206
228
  packet: EncryptedPacket,
207
- base_url: Optional[str] = None,
208
229
  ) -> Any:
209
230
  body = {
210
231
  "ble_locations": [
@@ -229,18 +250,17 @@ def ingest_packet(
229
250
  }
230
251
  return cloud_request(
231
252
  method="POST",
232
- path=_ingest_packets_endpoint(org_id),
233
- api_token=api_token,
253
+ env=env,
254
+ path=_ingest_packets_endpoint(credentials),
255
+ credentials=credentials,
234
256
  json=body,
235
- base_url=base_url,
236
257
  )
237
258
 
238
259
 
239
260
  def retrieve_org_metadata(
240
261
  *,
241
- org_id: str,
242
- api_token: str,
243
- base_url: Optional[str] = None,
262
+ credentials: Credentials,
263
+ env: Environment,
244
264
  ) -> Any:
245
265
  """
246
266
  Get organizational metadata
@@ -251,7 +271,7 @@ def retrieve_org_metadata(
251
271
  """
252
272
  return cloud_request(
253
273
  method="GET",
254
- path=_retrive_org_metadata_endpoint(org_id),
255
- api_token=api_token,
256
- base_url=base_url,
274
+ env=env,
275
+ path=_retrieve_org_metadata_endpoint(credentials),
276
+ credentials=credentials,
257
277
  )
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+ from typing import Optional
2
3
  from Crypto.Cipher import AES
3
4
  from Crypto.Hash import CMAC
4
5
  from Crypto.Protocol.KDF import SP800_108_Counter
@@ -57,10 +58,8 @@ def decrypt(
57
58
  ) -> Optional[DecryptedPacket]:
58
59
  ble_adv = encrypted_pkt.payload
59
60
  seq_no = int.from_bytes(ble_adv[0:2], "big") & 0x3FF
60
- device_id = ble_adv[2:6].hex()
61
61
  auth_tag = ble_adv[6:10]
62
62
  encrypted_payload = ble_adv[10:]
63
- day_offset = 0
64
63
 
65
64
  time_counter = int(datetime.now(timezone.utc).timestamp()) // 86400
66
65
 
@@ -69,8 +68,7 @@ def decrypt(
69
68
  tag = _get_auth_tag(daily_key, encrypted_payload)
70
69
 
71
70
  if tag == auth_tag:
72
- day_offset = t
73
- nonce = _get_nonce(key, time_counter, seq_no)
71
+ nonce = _get_nonce(key, time_counter + t, seq_no)
74
72
  decrypted_payload = _aes_decrypt(daily_key, nonce, encrypted_payload)
75
73
  return DecryptedPacket(
76
74
  timestamp=encrypted_pkt.timestamp,
@@ -2,7 +2,6 @@
2
2
  from __future__ import annotations
3
3
  from dataclasses import dataclass
4
4
  from typing import Dict, List, Optional
5
- from .packets import EncryptedPacket, DecryptedPacket
6
5
 
7
6
 
8
7
  @dataclass
@@ -40,6 +40,10 @@ class APITimeout(BackendError):
40
40
  """The API call exceeded its allowed timeout."""
41
41
 
42
42
 
43
+ class InvalidCredentialsError(BackendError):
44
+ """Invalid credentials passed in"""
45
+
46
+
43
47
  # Request/response semantics
44
48
  class ValidationError(BackendError):
45
49
  """The request was invalid (schema/semantics)."""
@@ -1,12 +1,11 @@
1
1
  # hubble/org.py
2
2
  from __future__ import annotations
3
- from dataclasses import dataclass
4
3
  from typing import Optional, List
5
4
 
6
5
  from . import cloud
7
- from .packets import DecryptedPacket, Location
6
+ from .packets import DecryptedPacket, EncryptedPacket, Location
8
7
  from .device import Device
9
- from .errors import BackendError
8
+ from .errors import InvalidCredentialsError
10
9
 
11
10
 
12
11
  class Organization:
@@ -15,42 +14,35 @@ class Organization:
15
14
  Used to manage devices and fetch decrypted packets from the backend.
16
15
  """
17
16
 
18
- org_id: str
19
- api_token: str
20
-
21
- api_base_url: str
22
- env: str
23
17
  name: str
24
18
 
25
- def __init__(self, org_id: str, api_token: str) -> str:
26
- self.org_id = org_id
27
- self.api_token = api_token
19
+ credentials: cloud.Credentials
20
+ env: cloud.Environment
28
21
 
29
- # Attempt to resolve environment (testing or prod)
30
- resp = None
31
- for env, url in cloud.ENVIRONMENTS.items():
32
- try:
33
- resp = cloud.retrieve_org_metadata(
34
- org_id=self.org_id, api_token=self.api_token, base_url=url
35
- )
36
- self.api_base_url = url
37
- self.env = env
38
- break
39
- except:
40
- pass
41
- if not resp:
42
- raise BackendError(f"Unable to determine environment")
43
- self.name = resp["name"]
22
+ def __init__(
23
+ self,
24
+ org_id: Optional(str) = None,
25
+ api_token: Optional(str) = None,
26
+ credentials: Optional(cloud.Credentials) = None,
27
+ ) -> str:
28
+ if credentials:
29
+ self.credentials = credentials
30
+ else:
31
+ self.credentials = cloud.Credentials(org_id, api_token)
32
+ self.env = cloud.get_env_from_credentials(self.credentials)
33
+ if not self.env:
34
+ raise InvalidCredentialsError("Invalid credentials passed in.")
35
+ self.name = cloud.retrieve_org_metadata(
36
+ credentials=self.credentials, env=self.env
37
+ )["name"]
44
38
 
45
- def register_device(self) -> Device:
39
+ def register_device(self, encryption: Optional[str] = None) -> Device:
46
40
  """
47
41
  Register a new device in this organization and return it.
48
42
  Returned Device will have an ID and provisioned key.
49
43
  """
50
44
  resp = cloud.register_device(
51
- org_id=self.org_id,
52
- api_token=self.api_token,
53
- base_url=self.api_base_url,
45
+ credentials=self.credentials, env=self.env, encryption=encryption
54
46
  )
55
47
  # Currently, only registering a single device and taking the
56
48
  # first in the returned list
@@ -63,11 +55,10 @@ class Organization:
63
55
  Returned Device will have an ID and provisioned key.
64
56
  """
65
57
  resp = cloud.update_device(
66
- org_id=self.org_id,
67
- api_token=self.api_token,
58
+ credentials=self.credentials,
59
+ env=self.env,
68
60
  name=name,
69
61
  device_id=device_id,
70
- base_url=self.api_base_url,
71
62
  )
72
63
  return Device(id=resp["id"], name=resp["name"])
73
64
 
@@ -79,9 +70,7 @@ class Organization:
79
70
  list[Device]
80
71
  """
81
72
 
82
- payload = cloud.list_devices(
83
- org_id=self.org_id, api_token=self.api_token, base_url=self.api_base_url
84
- )
73
+ payload = cloud.list_devices(credentials=self.credentials, env=self.env)
85
74
  raw_list = payload["devices"]
86
75
 
87
76
  # Turn each JSON object into a Device
@@ -96,11 +85,10 @@ class Organization:
96
85
  or None if none exists.
97
86
  """
98
87
  resp = cloud.retrieve_packets(
99
- org_id=self.org_id,
100
- api_token=self.api_token,
88
+ credentials=self.credentials,
89
+ env=self.env,
101
90
  device_id=device.id,
102
91
  days=days,
103
- base_url=self.api_base_url,
104
92
  )
105
93
  packets = []
106
94
  for packet in resp["packets"]:
@@ -108,9 +96,9 @@ class Organization:
108
96
  DecryptedPacket(
109
97
  timestamp=int(packet["device"]["timestamp"]),
110
98
  device_id=packet["device"]["id"],
111
- device_name=packet["device"]["name"]
112
- if "name" in packet["device"]
113
- else "",
99
+ device_name=(
100
+ packet["device"]["name"] if "name" in packet["device"] else ""
101
+ ),
114
102
  location=Location(
115
103
  lat=packet["location"]["latitude"],
116
104
  lon=packet["location"]["longitude"],
@@ -126,8 +114,7 @@ class Organization:
126
114
 
127
115
  def ingest_packet(self, packet: EncryptedPacket) -> None:
128
116
  cloud.ingest_packet(
129
- org_id=self.org_id,
130
- api_token=self.api_token,
117
+ credentials=self.credentials,
118
+ env=self.env,
131
119
  packet=packet,
132
- base_url=self.api_base_url,
133
120
  )
@@ -1,7 +1,6 @@
1
1
  # hubble/packets.py
2
2
  from __future__ import annotations
3
3
  from dataclasses import dataclass
4
- from datetime import datetime
5
4
  from typing import Optional, Dict
6
5
 
7
6
 
File without changes