pyhubblenetwork 0.2.0__tar.gz → 0.3.0__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 (21) hide show
  1. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/.gitignore +4 -1
  2. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/PKG-INFO +14 -2
  3. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/README.md +13 -1
  4. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/pyproject.toml +2 -1
  5. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/cli.py +124 -59
  6. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/crypto.py +17 -7
  7. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/device.py +12 -0
  8. pyhubblenetwork-0.3.0/tests/test_cli_payload.py +249 -0
  9. pyhubblenetwork-0.3.0/tests/test_counter_eid.py +214 -0
  10. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/LICENSE +0 -0
  11. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/plugins/pyhubblenetwork/skills/hubble-ready-test/README.md +0 -0
  12. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/__init__.py +0 -0
  13. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/ble.py +0 -0
  14. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/cloud.py +0 -0
  15. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/errors.py +0 -0
  16. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/org.py +0 -0
  17. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/packets.py +0 -0
  18. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/src/hubblenetwork/ready.py +0 -0
  19. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/tests/__init__.py +0 -0
  20. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/tests/conftest.py +0 -0
  21. {pyhubblenetwork-0.2.0 → pyhubblenetwork-0.3.0}/tests/test_cloud_integration.py +0 -0
@@ -157,4 +157,7 @@ cython_debug/
157
157
  # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158
158
  # and can be added to the global gitignore or merged into this file. For a more nuclear
159
159
  # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160
- #.idea/
160
+ #.idea/
161
+
162
+ # Git worktrees
163
+ .worktrees/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyhubblenetwork
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Hubble SDK host-side tools
5
5
  Author-email: Paul Buckley <paul@hubble.com>
6
6
  License-Expression: Apache-2.0
@@ -139,8 +139,20 @@ If installed, the `hubblenetwork` command is available:
139
139
  ```bash
140
140
  hubblenetwork --help
141
141
  hubblenetwork ble scan
142
+ hubblenetwork ble scan --payload-format hex
143
+ hubblenetwork org get-packets --payload-format string
142
144
  ```
143
145
 
146
+ ### Payload format option
147
+
148
+ Commands that output packet data (`ble scan`, `ble detect`, `org get-packets`) support the `--payload-format` flag to control how payloads are displayed:
149
+
150
+ * `base64` (default) — encode payloads as base64
151
+ * `hex` — display payloads as hexadecimal
152
+ * `string` — decode payloads as UTF-8 text (falls back to `<invalid UTF-8>` if bytes are not valid UTF-8)
153
+
154
+ This applies to all output formats (tabular, json, csv).
155
+
144
156
  ## Configuration
145
157
 
146
158
  Some functions read defaults from environment variables if not provided explicitly. Suggested variables:
@@ -207,5 +219,5 @@ ruff check src
207
219
  ## Releases & versioning
208
220
 
209
221
  * Follows **SemVer** (MAJOR.MINOR.PATCH).
210
- * Tagged releases (e.g., `v0.2.0`) publish wheels/sdists to PyPI.
222
+ * Tagged releases (e.g., `v0.3.0`) publish wheels/sdists to PyPI.
211
223
  * Release process: (add short steps for how to cut a release—tagging, CI release job, PyPI publish credentials).
@@ -116,8 +116,20 @@ If installed, the `hubblenetwork` command is available:
116
116
  ```bash
117
117
  hubblenetwork --help
118
118
  hubblenetwork ble scan
119
+ hubblenetwork ble scan --payload-format hex
120
+ hubblenetwork org get-packets --payload-format string
119
121
  ```
120
122
 
123
+ ### Payload format option
124
+
125
+ Commands that output packet data (`ble scan`, `ble detect`, `org get-packets`) support the `--payload-format` flag to control how payloads are displayed:
126
+
127
+ * `base64` (default) — encode payloads as base64
128
+ * `hex` — display payloads as hexadecimal
129
+ * `string` — decode payloads as UTF-8 text (falls back to `<invalid UTF-8>` if bytes are not valid UTF-8)
130
+
131
+ This applies to all output formats (tabular, json, csv).
132
+
121
133
  ## Configuration
122
134
 
123
135
  Some functions read defaults from environment variables if not provided explicitly. Suggested variables:
@@ -184,5 +196,5 @@ ruff check src
184
196
  ## Releases & versioning
185
197
 
186
198
  * Follows **SemVer** (MAJOR.MINOR.PATCH).
187
- * Tagged releases (e.g., `v0.2.0`) publish wheels/sdists to PyPI.
199
+ * Tagged releases (e.g., `v0.3.0`) publish wheels/sdists to PyPI.
188
200
  * Release process: (add short steps for how to cut a release—tagging, CI release job, PyPI publish credentials).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyhubblenetwork"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  requires-python = ">=3.9"
9
9
  authors = [
10
10
  { name="Paul Buckley", email="paul@hubble.com" },
@@ -45,6 +45,7 @@ include = [
45
45
  # ---- Testing & dev tooling ----
46
46
  [tool.pytest.ini_options]
47
47
  testpaths = ["tests"]
48
+ pythonpath = ["src"]
48
49
  markers = [
49
50
  "ble: tests that require BLE hardware/permissions",
50
51
  "integration: slow or environment-dependent tests",
@@ -13,7 +13,7 @@ from datetime import datetime
13
13
  from typing import Optional, List
14
14
  from tabulate import tabulate
15
15
  from hubblenetwork import Organization
16
- from hubblenetwork import Device, DecryptedPacket, EncryptedPacket
16
+ from hubblenetwork import Device, DecryptedPacket
17
17
  from hubblenetwork import ble as ble_mod
18
18
  from hubblenetwork import ready as ready_mod
19
19
  from hubblenetwork import decrypt
@@ -28,6 +28,22 @@ _handler.setFormatter(logging.Formatter("%(levelname)s - %(message)s"))
28
28
  logger.addHandler(_handler)
29
29
 
30
30
 
31
+ def _format_payload(payload, fmt: str) -> str:
32
+ """Format packet payload bytes for display."""
33
+ if not isinstance(payload, bytes):
34
+ return str(payload)
35
+ if fmt == "hex":
36
+ return payload.hex().upper()
37
+ elif fmt == "string":
38
+ try:
39
+ return payload.decode("utf-8")
40
+ except UnicodeDecodeError:
41
+ click.echo("Warning: payload contains non-UTF-8 bytes", err=True)
42
+ return "<invalid UTF-8>"
43
+ else: # base64 (default)
44
+ return base64.b64encode(payload).decode("ascii")
45
+
46
+
31
47
  def _get_env_or_fail(name: str) -> str:
32
48
  val = os.getenv(name)
33
49
  if not val:
@@ -47,7 +63,7 @@ def _get_org_and_token(org_id, token) -> tuple[str, str]:
47
63
  return org_id, token
48
64
 
49
65
 
50
- def _packet_to_dict(pkt) -> dict:
66
+ def _packet_to_dict(pkt, payload_format: str = "base64") -> dict:
51
67
  """Convert a packet to a dictionary for JSON serialization."""
52
68
  ts = datetime.fromtimestamp(pkt.timestamp).strftime("%c")
53
69
  data = {
@@ -59,24 +75,8 @@ def _packet_to_dict(pkt) -> dict:
59
75
  if isinstance(pkt, DecryptedPacket):
60
76
  data["counter"] = pkt.counter
61
77
  data["sequence"] = pkt.sequence
62
- # Decode payload to string if possible, otherwise use hex
63
- try:
64
- data["payload"] = (
65
- pkt.payload.decode("utf-8")
66
- if isinstance(pkt.payload, bytes)
67
- else str(pkt.payload)
68
- )
69
- except UnicodeDecodeError:
70
- data["payload_hex"] = (
71
- pkt.payload.hex()
72
- if isinstance(pkt.payload, bytes)
73
- else str(pkt.payload)
74
- )
75
- else:
76
- # EncryptedPacket - show payload as hex
77
- data["payload_hex"] = (
78
- pkt.payload.hex() if isinstance(pkt.payload, bytes) else str(pkt.payload)
79
- )
78
+
79
+ data["payload"] = _format_payload(pkt.payload, payload_format)
80
80
 
81
81
  if not pkt.location.fake:
82
82
  data["location"] = {
@@ -208,11 +208,12 @@ class _StreamingTablePrinter(_StreamingPrinterBase):
208
208
  "PAYLOAD": 20,
209
209
  }
210
210
 
211
- def __init__(self):
211
+ def __init__(self, payload_format: str = "base64"):
212
212
  super().__init__()
213
213
  self._header_printed = False
214
214
  self._headers: List[str] = []
215
215
  self._column_config: dict = {}
216
+ self._payload_format = payload_format
216
217
 
217
218
  def _determine_columns(self, pkt) -> tuple[List[str], dict]:
218
219
  """Determine column headers and configuration based on packet type."""
@@ -271,7 +272,7 @@ class _StreamingTablePrinter(_StreamingPrinterBase):
271
272
  row.append(f"{loc.lat:.6f},{loc.lon:.6f}")
272
273
 
273
274
  if self._column_config["is_decrypted"]:
274
- row.append(f'"{pkt.payload}"')
275
+ row.append(_format_payload(pkt.payload, self._payload_format))
275
276
 
276
277
  # Print the data row
277
278
  click.echo(self._format_row(row))
@@ -282,9 +283,10 @@ class _StreamingTablePrinter(_StreamingPrinterBase):
282
283
  class _StreamingJsonPrinter(_StreamingPrinterBase):
283
284
  """Print packets as a streaming JSON array."""
284
285
 
285
- def __init__(self):
286
+ def __init__(self, payload_format: str = "base64"):
286
287
  super().__init__()
287
288
  self._array_started = False
289
+ self._payload_format = payload_format
288
290
 
289
291
  @property
290
292
  def suppress_info_messages(self) -> bool:
@@ -292,7 +294,7 @@ class _StreamingJsonPrinter(_StreamingPrinterBase):
292
294
 
293
295
  def print_row(self, pkt) -> None:
294
296
  """Print a single packet as JSON."""
295
- pkt_dict = _packet_to_dict(pkt)
297
+ pkt_dict = _packet_to_dict(pkt, self._payload_format)
296
298
  if not self._array_started:
297
299
  click.echo("[")
298
300
  self._array_started = True
@@ -321,13 +323,12 @@ _STREAMING_PRINTERS = {
321
323
  }
322
324
 
323
325
 
324
- def _print_packets_tabular(pkts: List) -> None:
326
+ def _print_packets_tabular(pkts: List, payload_format: str = "base64") -> None:
325
327
  """Print packets in a formatted table using tabulate."""
326
328
  if not pkts:
327
329
  click.echo("No packets!")
328
330
  return
329
331
 
330
- # For batch printing, use the full table format
331
332
  first_pkt = pkts[0]
332
333
  is_decrypted = isinstance(first_pkt, DecryptedPacket)
333
334
  has_real_location = not first_pkt.location.fake
@@ -353,50 +354,37 @@ def _print_packets_tabular(pkts: List) -> None:
353
354
  row.append(f"{loc.lat:.6f},{loc.lon:.6f}")
354
355
 
355
356
  if is_decrypted:
356
- row.append(f'"{pkt.payload}"')
357
+ row.append(_format_payload(pkt.payload, payload_format))
357
358
 
358
359
  rows.append(row)
359
360
 
360
361
  click.echo("\n" + tabulate(rows, headers=headers, tablefmt="grid"))
361
362
 
362
363
 
363
- def _print_packets_csv(pkts) -> None:
364
+ def _print_packets_csv(pkts, payload_format: str = "base64") -> None:
364
365
  click.echo("timestamp, datetime, latitude, longitude, payload")
365
366
  for pkt in pkts:
366
367
  ts = datetime.fromtimestamp(pkt.timestamp).strftime("%c")
367
- if isinstance(pkt, DecryptedPacket):
368
- payload = pkt.payload
369
- elif isinstance(pkt, EncryptedPacket):
370
- payload = pkt.payload.hex()
368
+ payload_str = _format_payload(pkt.payload, payload_format)
371
369
  click.echo(
372
- f'{pkt.timestamp}, {ts}, {pkt.location.lat:.6f}, {pkt.location.lon:.6f}, "{payload}"'
370
+ f'{pkt.timestamp}, {ts}, {pkt.location.lat:.6f}, {pkt.location.lon:.6f}, "{payload_str}"'
373
371
  )
374
372
 
375
373
 
376
- def _print_packets_json(pkts) -> None:
374
+ def _print_packets_json(pkts, payload_format: str = "base64") -> None:
377
375
  """Print packets as a JSON array."""
378
- json_packets = [_packet_to_dict(pkt) for pkt in pkts]
376
+ json_packets = [_packet_to_dict(pkt, payload_format) for pkt in pkts]
379
377
  click.echo(json.dumps(json_packets, indent=2))
380
378
 
381
379
 
382
- _OUTPUT_FORMATS = {
383
- "csv": "_print_packets_csv",
384
- "tabular": "_print_packets_tabular",
385
- "json": "_print_packets_json",
386
- }
387
-
388
-
389
- def _print_packets(pkts, output: str = "tabular") -> None:
390
- if not output:
391
- _print_packets_tabular(pkts)
392
- return
393
-
394
- format_key = output.lower().strip()
395
- if format_key in _OUTPUT_FORMATS:
396
- func = globals()[_OUTPUT_FORMATS[format_key]]
397
- func(pkts)
380
+ def _print_packets(pkts, output: str = "tabular", payload_format: str = "base64") -> None:
381
+ format_key = (output or "tabular").lower().strip()
382
+ if format_key == "json":
383
+ _print_packets_json(pkts, payload_format)
384
+ elif format_key == "csv":
385
+ _print_packets_csv(pkts, payload_format)
398
386
  else:
399
- _print_packets_tabular(pkts)
387
+ _print_packets_tabular(pkts, payload_format)
400
388
 
401
389
 
402
390
  def _print_device(dev: Device) -> None:
@@ -483,6 +471,20 @@ def ble() -> None:
483
471
  show_default=False,
484
472
  help="Key to decrypt packets (base64 encoded, required)",
485
473
  )
474
+ @click.option(
475
+ "--days",
476
+ "-d",
477
+ type=int,
478
+ default=2,
479
+ show_default=True,
480
+ help="Number of days to check back when decrypting",
481
+ )
482
+ @click.option(
483
+ "--eid-pool-size",
484
+ type=int,
485
+ default=None,
486
+ help="Use counter-based EID with given pool size (0..N-1) instead of UTC-based",
487
+ )
486
488
  @click.option(
487
489
  "--format",
488
490
  "-o",
@@ -492,16 +494,29 @@ def ble() -> None:
492
494
  show_default=True,
493
495
  help="Output format",
494
496
  )
497
+ @click.option(
498
+ "--payload-format",
499
+ "payload_format",
500
+ type=click.Choice(["base64", "hex", "string"], case_sensitive=False),
501
+ default="base64",
502
+ show_default=True,
503
+ help="Encoding format for packet payload",
504
+ )
495
505
  @click.option(
496
506
  "--debug",
497
507
  is_flag=True,
498
508
  default=False,
499
509
  help="Enable debug logging to stderr",
500
510
  )
511
+ @click.pass_context
501
512
  def ble_detect(
513
+ ctx,
502
514
  timeout: Optional[int] = None,
503
515
  key: str = None,
516
+ days: int = 2,
517
+ eid_pool_size: Optional[int] = None,
504
518
  output_format: str = "tabular",
519
+ payload_format: str = "base64",
505
520
  debug: bool = False,
506
521
  ) -> None:
507
522
  """
@@ -516,6 +531,14 @@ def ble_detect(
516
531
  """
517
532
  use_json = output_format.lower() == "json"
518
533
 
534
+ # Validate --eid-pool-size and --days mutual exclusivity
535
+ if eid_pool_size is not None:
536
+ days_source = ctx.get_parameter_source("days")
537
+ if days_source == click.core.ParameterSource.COMMANDLINE:
538
+ raise click.UsageError(
539
+ "--eid-pool-size and --days are mutually exclusive"
540
+ )
541
+
519
542
  # Set log level based on debug flag
520
543
  logger.setLevel(logging.DEBUG if debug else logging.WARNING)
521
544
 
@@ -565,7 +588,9 @@ def ble_detect(
565
588
  logger.debug("Packet received, attempting decryption...")
566
589
 
567
590
  # Attempt to decrypt the packet
568
- decrypted_pkt = decrypt(decoded_key, pkt)
591
+ decrypted_pkt = decrypt(
592
+ decoded_key, pkt, days=days, eid_pool_size=eid_pool_size
593
+ )
569
594
 
570
595
  if decrypted_pkt:
571
596
  # If we can decrypt it, output success
@@ -574,20 +599,22 @@ def ble_detect(
574
599
  )
575
600
  logger.info("Packet decrypted successfully!")
576
601
 
602
+ payload_str = _format_payload(decrypted_pkt.payload, payload_format)
577
603
  if use_json:
578
604
  result = {
579
605
  "success": True,
580
606
  "packet": {
581
607
  "datetime": datetime_str,
582
608
  "rssi": decrypted_pkt.rssi,
583
- "payload_bytes": len(decrypted_pkt.payload),
609
+ "payload": payload_str,
610
+ "counter": decrypted_pkt.counter,
584
611
  },
585
612
  }
586
613
  click.echo(json.dumps(result))
587
614
  else:
588
615
  click.secho("[SUCCESS] ", fg="green", nl=False)
589
616
  click.echo(
590
- f"Packet decrypted: {datetime_str}, RSSI: {decrypted_pkt.rssi} dBm, {len(decrypted_pkt.payload)} bytes"
617
+ f"Packet decrypted: {datetime_str}, RSSI: {decrypted_pkt.rssi} dBm, payload: {payload_str}, counter: {decrypted_pkt.counter}"
591
618
  )
592
619
  return
593
620
 
@@ -631,6 +658,12 @@ def ble_detect(
631
658
  show_default=True,
632
659
  help="Number of days to check back when decrypting",
633
660
  )
661
+ @click.option(
662
+ "--eid-pool-size",
663
+ type=int,
664
+ default=None,
665
+ help="Use counter-based EID with given pool size (0..N-1) instead of UTC-based",
666
+ )
634
667
  @click.option("--ingest", is_flag=True, help="Ingest packets to backend (requires key)")
635
668
  @click.option(
636
669
  "--format",
@@ -641,13 +674,25 @@ def ble_detect(
641
674
  show_default=True,
642
675
  help="Output format for packets",
643
676
  )
677
+ @click.option(
678
+ "--payload-format",
679
+ "payload_format",
680
+ type=click.Choice(["base64", "hex", "string"], case_sensitive=False),
681
+ default="base64",
682
+ show_default=True,
683
+ help="Encoding format for packet payload",
684
+ )
685
+ @click.pass_context
644
686
  def ble_scan(
687
+ ctx,
645
688
  timeout: Optional[int] = None,
646
689
  count: Optional[int] = None,
647
690
  ingest: bool = False,
648
691
  key: Optional[str] = None,
649
692
  days: int = 2,
693
+ eid_pool_size: Optional[int] = None,
650
694
  output_format: str = "tabular",
695
+ payload_format: str = "base64",
651
696
  ) -> None:
652
697
  """
653
698
  Scan for UUID 0xFCA6 and print packets as they are found.
@@ -658,11 +703,21 @@ def ble_scan(
658
703
  hubblenetwork ble scan -o json --timeout 10
659
704
  hubblenetwork ble scan -n 5 # Stop after 5 packets
660
705
  """
706
+ # Validate --eid-pool-size constraints
707
+ if eid_pool_size is not None:
708
+ if not key:
709
+ raise click.UsageError("--eid-pool-size requires --key")
710
+ days_source = ctx.get_parameter_source("days")
711
+ if days_source == click.core.ParameterSource.COMMANDLINE:
712
+ raise click.UsageError(
713
+ "--eid-pool-size and --days are mutually exclusive"
714
+ )
715
+
661
716
  # Get the appropriate streaming printer
662
717
  printer_class = _STREAMING_PRINTERS.get(
663
718
  output_format.lower(), _StreamingTablePrinter
664
719
  )
665
- printer = printer_class()
720
+ printer = printer_class(payload_format=payload_format)
666
721
 
667
722
  if not printer.suppress_info_messages:
668
723
  click.secho("[INFO] Scanning for Hubble devices... (Press Ctrl+C to stop)")
@@ -703,7 +758,9 @@ def ble_scan(
703
758
 
704
759
  # If we have a key, attempt to decrypt
705
760
  if decoded_key:
706
- decrypted_pkt = decrypt(decoded_key, pkt, days=days)
761
+ decrypted_pkt = decrypt(
762
+ decoded_key, pkt, days=days, eid_pool_size=eid_pool_size
763
+ )
707
764
  if decrypted_pkt:
708
765
  printer.print_row(decrypted_pkt)
709
766
  # We only allow ingestion of packets you know the key of
@@ -2227,9 +2284,17 @@ def set_device_name(org: Organization, device_id: str, name: str) -> None:
2227
2284
  show_default=True,
2228
2285
  help="Number of days to query back (from now)",
2229
2286
  )
2287
+ @click.option(
2288
+ "--payload-format",
2289
+ "payload_format",
2290
+ type=click.Choice(["base64", "hex", "string"], case_sensitive=False),
2291
+ default="base64",
2292
+ show_default=True,
2293
+ help="Encoding format for packet payload",
2294
+ )
2230
2295
  @pass_orgcfg
2231
2296
  def get_packets(
2232
- org: Organization, device_id: str, output_format: str = "tabular", days: int = 7
2297
+ org: Organization, device_id: str, output_format: str = "tabular", days: int = 7, payload_format: str = "base64"
2233
2298
  ) -> None:
2234
2299
  """
2235
2300
  Retrieve and display packets for a device.
@@ -2241,7 +2306,7 @@ def get_packets(
2241
2306
  """
2242
2307
  device = Device(id=device_id)
2243
2308
  packets = org.retrieve_packets(device, days=days)
2244
- _print_packets(packets, output_format)
2309
+ _print_packets(packets, output_format, payload_format)
2245
2310
 
2246
2311
 
2247
2312
  def main(argv: Optional[list[str]] = None) -> int:
@@ -75,19 +75,29 @@ def _check_tag_matches(
75
75
 
76
76
 
77
77
  def decrypt(
78
- key: bytes, encrypted_pkt: EncryptedPacket, days: int = 2
78
+ key: bytes,
79
+ encrypted_pkt: EncryptedPacket,
80
+ days: int = 2,
81
+ eid_pool_size: Optional[int] = None,
79
82
  ) -> Optional[DecryptedPacket]:
83
+ if eid_pool_size is not None and days != 2:
84
+ raise ValueError("Cannot specify both eid_pool_size and days")
85
+
80
86
  parsed = ParsedPacket(encrypted_pkt)
81
87
  keylen = len(key)
82
88
 
83
- time_counter = int(datetime.now(timezone.utc).timestamp()) // 86400
89
+ if eid_pool_size is not None:
90
+ candidates = range(eid_pool_size)
91
+ else:
92
+ time_counter = int(datetime.now(timezone.utc).timestamp()) // 86400
93
+ candidates = (time_counter + t for t in range(-days, days + 1))
84
94
 
85
- for t in range(-days, days + 1):
86
- if _check_tag_matches(key, time_counter + t, parsed):
95
+ for candidate in candidates:
96
+ if _check_tag_matches(key, candidate, parsed):
87
97
  daily_key = _get_encryption_key(
88
- key, time_counter + t, parsed.seq_no, keylen=keylen
98
+ key, candidate, parsed.seq_no, keylen=keylen
89
99
  )
90
- nonce = _get_nonce(key, time_counter + t, parsed.seq_no, keylen=keylen)
100
+ nonce = _get_nonce(key, candidate, parsed.seq_no, keylen=keylen)
91
101
  decrypted_payload = _aes_decrypt(daily_key, nonce, parsed.encrypted_payload)
92
102
  return DecryptedPacket(
93
103
  timestamp=encrypted_pkt.timestamp,
@@ -97,7 +107,7 @@ def decrypt(
97
107
  tags={},
98
108
  payload=decrypted_payload,
99
109
  rssi=encrypted_pkt.rssi,
100
- counter=time_counter + t,
110
+ counter=candidate,
101
111
  sequence=parsed.seq_no,
102
112
  )
103
113
  return None
@@ -1,5 +1,6 @@
1
1
  # hubble/device.py
2
2
  from __future__ import annotations
3
+ import base64
3
4
  from dataclasses import dataclass
4
5
  from typing import Dict, Optional
5
6
 
@@ -18,6 +19,17 @@ class Device:
18
19
  created_ts: Optional[int] = None
19
20
  active: Optional[bool] = False
20
21
 
22
+ def __str__(self) -> str:
23
+ key_str = (
24
+ base64.b64encode(self.key).decode("ascii")
25
+ if isinstance(self.key, bytes)
26
+ else self.key
27
+ )
28
+ return (
29
+ f"Device(id={self.id!r}, key={key_str!r}, name={self.name!r}, "
30
+ f"tags={self.tags!r}, created_ts={self.created_ts!r}, active={self.active!r})"
31
+ )
32
+
21
33
  @classmethod
22
34
  def from_json(cls, json):
23
35
  return cls(
@@ -0,0 +1,249 @@
1
+ import base64
2
+ import pytest
3
+ from unittest.mock import MagicMock
4
+ from hubblenetwork.packets import EncryptedPacket, DecryptedPacket
5
+ from hubblenetwork.packets import Location
6
+
7
+
8
+ def fake_location():
9
+ loc = MagicMock(spec=Location)
10
+ loc.fake = True
11
+ loc.lat = 0.0
12
+ loc.lon = 0.0
13
+ return loc
14
+
15
+
16
+ def make_encrypted_packet(payload: bytes) -> EncryptedPacket:
17
+ return EncryptedPacket(
18
+ timestamp=1700000000,
19
+ location=fake_location(),
20
+ payload=payload,
21
+ rssi=-70,
22
+ )
23
+
24
+
25
+ def make_decrypted_packet(payload: bytes) -> DecryptedPacket:
26
+ return DecryptedPacket(
27
+ timestamp=1700000000,
28
+ device_id="dev123",
29
+ device_name="Test Device",
30
+ location=fake_location(),
31
+ tags={},
32
+ payload=payload,
33
+ rssi=-70,
34
+ counter=42,
35
+ sequence=1,
36
+ )
37
+
38
+
39
+ class TestFormatPayload:
40
+ """Tests for the _format_payload helper."""
41
+
42
+ def test_base64_encoding(self):
43
+ from hubblenetwork.cli import _format_payload
44
+ assert _format_payload(b'\x01\x02\x03', "base64") == "AQID"
45
+
46
+ def test_hex_encoding_uppercase(self):
47
+ from hubblenetwork.cli import _format_payload
48
+ assert _format_payload(b'\xab\xc6\x79', "hex") == "ABC679"
49
+
50
+ def test_hex_encoding_all_zeros(self):
51
+ from hubblenetwork.cli import _format_payload
52
+ assert _format_payload(b'\x00\x00', "hex") == "0000"
53
+
54
+ def test_string_encoding_valid_utf8(self):
55
+ from hubblenetwork.cli import _format_payload
56
+ assert _format_payload(b'hello world', "string") == "hello world"
57
+
58
+ def test_string_encoding_invalid_utf8_returns_fallback(self, capsys):
59
+ from hubblenetwork.cli import _format_payload
60
+ result = _format_payload(b'\xff\xfe', "string")
61
+ assert result == "<invalid UTF-8>"
62
+
63
+ def test_string_encoding_invalid_utf8_warns_to_stderr(self, capsys):
64
+ from hubblenetwork.cli import _format_payload
65
+ _format_payload(b'\xff\xfe', "string")
66
+ captured = capsys.readouterr()
67
+ assert "Warning" in captured.err
68
+
69
+ def test_empty_bytes_base64(self):
70
+ from hubblenetwork.cli import _format_payload
71
+ assert _format_payload(b'', "base64") == ""
72
+
73
+ def test_empty_bytes_hex(self):
74
+ from hubblenetwork.cli import _format_payload
75
+ assert _format_payload(b'', "hex") == ""
76
+
77
+ def test_non_bytes_passthrough(self):
78
+ from hubblenetwork.cli import _format_payload
79
+ assert _format_payload("already a string", "base64") == "already a string"
80
+
81
+
82
+ class TestPacketToDict:
83
+ """Tests for _packet_to_dict payload encoding."""
84
+
85
+ def test_encrypted_packet_payload_is_base64(self):
86
+ from hubblenetwork.cli import _packet_to_dict
87
+ raw = b'\x01\x02\x03\xff'
88
+ pkt = make_encrypted_packet(raw)
89
+ result = _packet_to_dict(pkt)
90
+ assert "payload" in result
91
+ assert result["payload"] == base64.b64encode(raw).decode("ascii")
92
+ assert "payload_hex" not in result
93
+
94
+ def test_decrypted_packet_payload_is_base64(self):
95
+ from hubblenetwork.cli import _packet_to_dict
96
+ raw = b'\xde\xad\xbe\xef'
97
+ pkt = make_decrypted_packet(raw)
98
+ result = _packet_to_dict(pkt)
99
+ assert "payload" in result
100
+ assert result["payload"] == base64.b64encode(raw).decode("ascii")
101
+ assert "payload_hex" not in result
102
+
103
+ def test_decrypted_packet_utf8_payload_still_base64(self):
104
+ """Even valid UTF-8 should be base64 encoded."""
105
+ from hubblenetwork.cli import _packet_to_dict
106
+ raw = b'hello world'
107
+ pkt = make_decrypted_packet(raw)
108
+ result = _packet_to_dict(pkt)
109
+ assert result["payload"] == base64.b64encode(raw).decode("ascii")
110
+
111
+ def test_empty_payload_is_base64(self):
112
+ from hubblenetwork.cli import _packet_to_dict
113
+ pkt = make_encrypted_packet(b'')
114
+ result = _packet_to_dict(pkt)
115
+ assert result["payload"] == ""
116
+
117
+ def test_encrypted_packet_payload_hex(self):
118
+ from hubblenetwork.cli import _packet_to_dict
119
+ raw = b'\xab\xc6\x79'
120
+ pkt = make_encrypted_packet(raw)
121
+ result = _packet_to_dict(pkt, payload_format="hex")
122
+ assert result["payload"] == "ABC679"
123
+
124
+ def test_decrypted_packet_payload_string(self):
125
+ from hubblenetwork.cli import _packet_to_dict
126
+ raw = b'sensor:42'
127
+ pkt = make_decrypted_packet(raw)
128
+ result = _packet_to_dict(pkt, payload_format="string")
129
+ assert result["payload"] == "sensor:42"
130
+
131
+ def test_decrypted_packet_payload_string_invalid_utf8(self, capsys):
132
+ from hubblenetwork.cli import _packet_to_dict
133
+ raw = b'\xff\xfe'
134
+ pkt = make_decrypted_packet(raw)
135
+ result = _packet_to_dict(pkt, payload_format="string")
136
+ assert result["payload"] == "<invalid UTF-8>"
137
+ captured = capsys.readouterr()
138
+ assert "Warning" in captured.err
139
+
140
+
141
+ class TestStreamingTablePrinter:
142
+ """Tests for tabular payload display."""
143
+
144
+ def test_table_row_payload_is_base64(self, capsys):
145
+ from hubblenetwork.cli import _StreamingTablePrinter
146
+ raw = b'\x01\x02\x03'
147
+ pkt = make_decrypted_packet(raw)
148
+ printer = _StreamingTablePrinter()
149
+ printer.print_row(pkt)
150
+ captured = capsys.readouterr()
151
+ expected_b64 = base64.b64encode(raw).decode("ascii")
152
+ assert expected_b64 in captured.out
153
+
154
+
155
+ class TestStreamingTablePrinterPayloadFormat:
156
+ """Tests for _StreamingTablePrinter with non-default payload formats."""
157
+
158
+ def test_hex_format(self, capsys):
159
+ from hubblenetwork.cli import _StreamingTablePrinter
160
+ raw = b'\xab\xc6\x79'
161
+ pkt = make_decrypted_packet(raw)
162
+ printer = _StreamingTablePrinter(payload_format="hex")
163
+ printer.print_row(pkt)
164
+ captured = capsys.readouterr()
165
+ assert "ABC679" in captured.out
166
+
167
+ def test_string_format_valid_utf8(self, capsys):
168
+ from hubblenetwork.cli import _StreamingTablePrinter
169
+ raw = b'sensor:42'
170
+ pkt = make_decrypted_packet(raw)
171
+ printer = _StreamingTablePrinter(payload_format="string")
172
+ printer.print_row(pkt)
173
+ captured = capsys.readouterr()
174
+ assert "sensor:42" in captured.out
175
+
176
+ def test_default_is_still_base64(self, capsys):
177
+ from hubblenetwork.cli import _StreamingTablePrinter
178
+ raw = b'\x01\x02\x03'
179
+ pkt = make_decrypted_packet(raw)
180
+ printer = _StreamingTablePrinter()
181
+ printer.print_row(pkt)
182
+ captured = capsys.readouterr()
183
+ assert base64.b64encode(raw).decode("ascii") in captured.out
184
+
185
+
186
+ class TestStreamingJsonPrinterPayloadFormat:
187
+ """Tests for _StreamingJsonPrinter with non-default payload formats."""
188
+
189
+ def test_hex_format(self, capsys):
190
+ from hubblenetwork.cli import _StreamingJsonPrinter
191
+ raw = b'\xab\xc6\x79'
192
+ pkt = make_decrypted_packet(raw)
193
+ printer = _StreamingJsonPrinter(payload_format="hex")
194
+ printer.print_row(pkt)
195
+ captured = capsys.readouterr()
196
+ assert "ABC679" in captured.out
197
+
198
+ def test_default_is_base64(self, capsys):
199
+ from hubblenetwork.cli import _StreamingJsonPrinter
200
+ raw = b'\x01\x02\x03'
201
+ pkt = make_decrypted_packet(raw)
202
+ printer = _StreamingJsonPrinter()
203
+ printer.print_row(pkt)
204
+ captured = capsys.readouterr()
205
+ assert base64.b64encode(raw).decode("ascii") in captured.out
206
+
207
+
208
+ class TestBatchPrinters:
209
+ """Tests for _print_packets_* functions with payload_format."""
210
+
211
+ def test_tabular_hex(self, capsys):
212
+ from hubblenetwork.cli import _print_packets_tabular
213
+ raw = b'\xab\xc6\x79'
214
+ pkt = make_decrypted_packet(raw)
215
+ _print_packets_tabular([pkt], payload_format="hex")
216
+ captured = capsys.readouterr()
217
+ assert "ABC679" in captured.out
218
+
219
+ def test_tabular_default_base64(self, capsys):
220
+ from hubblenetwork.cli import _print_packets_tabular
221
+ raw = b'\x01\x02\x03'
222
+ pkt = make_decrypted_packet(raw)
223
+ _print_packets_tabular([pkt])
224
+ captured = capsys.readouterr()
225
+ assert base64.b64encode(raw).decode("ascii") in captured.out
226
+
227
+ def test_json_hex(self, capsys):
228
+ from hubblenetwork.cli import _print_packets_json
229
+ raw = b'\xab\xc6\x79'
230
+ pkt = make_decrypted_packet(raw)
231
+ _print_packets_json([pkt], payload_format="hex")
232
+ captured = capsys.readouterr()
233
+ assert "ABC679" in captured.out
234
+
235
+ def test_csv_hex(self, capsys):
236
+ from hubblenetwork.cli import _print_packets_csv
237
+ raw = b'\xab\xc6\x79'
238
+ pkt = make_decrypted_packet(raw)
239
+ _print_packets_csv([pkt], payload_format="hex")
240
+ captured = capsys.readouterr()
241
+ assert "ABC679" in captured.out
242
+
243
+ def test_csv_string(self, capsys):
244
+ from hubblenetwork.cli import _print_packets_csv
245
+ raw = b'temp:25'
246
+ pkt = make_decrypted_packet(raw)
247
+ _print_packets_csv([pkt], payload_format="string")
248
+ captured = capsys.readouterr()
249
+ assert "temp:25" in captured.out
@@ -0,0 +1,214 @@
1
+ """Tests for counter-based EID decryption (eid_pool_size parameter)."""
2
+
3
+ import struct
4
+ import pytest
5
+ from click.testing import CliRunner
6
+ from Crypto.Cipher import AES
7
+
8
+ from hubblenetwork.crypto import (
9
+ _get_encryption_key,
10
+ _get_nonce,
11
+ _get_auth_tag,
12
+ decrypt,
13
+ )
14
+ from hubblenetwork.packets import EncryptedPacket, Location
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Helpers – build a valid BLE advertisement payload for a given counter value
19
+ # ---------------------------------------------------------------------------
20
+
21
+ def _encrypt_payload(key: bytes, counter_value: int, seq_no: int, plaintext: bytes) -> bytes:
22
+ """Encrypt plaintext the same way firmware does and return a BLE adv payload."""
23
+ keylen = len(key)
24
+ daily_key = _get_encryption_key(key, counter_value, seq_no, keylen=keylen)
25
+ nonce = _get_nonce(key, counter_value, seq_no, keylen=keylen)
26
+
27
+ cipher = AES.new(daily_key, AES.MODE_CTR, nonce=nonce)
28
+ ciphertext = cipher.encrypt(plaintext)
29
+
30
+ auth_tag = _get_auth_tag(daily_key, ciphertext)
31
+
32
+ # BLE adv layout: seq_no (2 big-endian) + 4 padding bytes + auth_tag (4) + ciphertext
33
+ seq_bytes = struct.pack(">H", seq_no & 0x3FF)
34
+ padding = b"\x00" * 4
35
+ return seq_bytes + padding + auth_tag + ciphertext
36
+
37
+
38
+ def _make_encrypted_packet(key: bytes, counter_value: int, seq_no: int, plaintext: bytes) -> EncryptedPacket:
39
+ """Build an EncryptedPacket whose auth_tag matches *counter_value*."""
40
+ payload = _encrypt_payload(key, counter_value, seq_no, plaintext)
41
+ return EncryptedPacket(
42
+ timestamp=1700000000,
43
+ location=Location(lat=0.0, lon=0.0, fake=True),
44
+ payload=payload,
45
+ rssi=-70,
46
+ )
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Fixtures
51
+ # ---------------------------------------------------------------------------
52
+
53
+ # Deterministic 32-byte key (AES-256-CTR)
54
+ _KEY_256 = bytes(range(32))
55
+ # Deterministic 16-byte key (AES-128-CTR)
56
+ _KEY_128 = bytes(range(16))
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # decrypt() with eid_pool_size
61
+ # ---------------------------------------------------------------------------
62
+
63
+ class TestCounterEidDecrypt:
64
+ """Test decrypt() counter-based EID mode."""
65
+
66
+ def test_decrypt_finds_counter_value(self):
67
+ """decrypt() with eid_pool_size finds the correct counter."""
68
+ counter_value = 7
69
+ pkt = _make_encrypted_packet(_KEY_256, counter_value, seq_no=1, plaintext=b"hello")
70
+
71
+ result = decrypt(_KEY_256, pkt, eid_pool_size=32)
72
+
73
+ assert result is not None
74
+ assert result.payload == b"hello"
75
+ assert result.counter == counter_value
76
+
77
+ def test_decrypt_counter_zero(self):
78
+ """Counter value 0 is found."""
79
+ pkt = _make_encrypted_packet(_KEY_256, 0, seq_no=5, plaintext=b"zero")
80
+
81
+ result = decrypt(_KEY_256, pkt, eid_pool_size=10)
82
+
83
+ assert result is not None
84
+ assert result.counter == 0
85
+ assert result.payload == b"zero"
86
+
87
+ def test_decrypt_counter_at_pool_boundary(self):
88
+ """Counter value N-1 (last in pool) is found."""
89
+ pool_size = 16
90
+ pkt = _make_encrypted_packet(_KEY_128, pool_size - 1, seq_no=2, plaintext=b"edge")
91
+
92
+ result = decrypt(_KEY_128, pkt, eid_pool_size=pool_size)
93
+
94
+ assert result is not None
95
+ assert result.counter == pool_size - 1
96
+ assert result.payload == b"edge"
97
+
98
+ def test_decrypt_counter_outside_pool_returns_none(self):
99
+ """Counter value >= pool_size is not found."""
100
+ pkt = _make_encrypted_packet(_KEY_256, 50, seq_no=3, plaintext=b"miss")
101
+
102
+ result = decrypt(_KEY_256, pkt, eid_pool_size=32)
103
+
104
+ assert result is None
105
+
106
+ def test_decrypt_wrong_key_returns_none(self):
107
+ """Wrong key doesn't match any counter."""
108
+ pkt = _make_encrypted_packet(_KEY_256, 5, seq_no=1, plaintext=b"data")
109
+ wrong_key = bytes(range(1, 33))
110
+
111
+ result = decrypt(wrong_key, pkt, eid_pool_size=32)
112
+
113
+ assert result is None
114
+
115
+ def test_decrypt_aes128_counter_mode(self):
116
+ """Counter-based decryption works with AES-128 keys."""
117
+ pkt = _make_encrypted_packet(_KEY_128, 3, seq_no=10, plaintext=b"aes128")
118
+
119
+ result = decrypt(_KEY_128, pkt, eid_pool_size=8)
120
+
121
+ assert result is not None
122
+ assert result.payload == b"aes128"
123
+ assert result.counter == 3
124
+
125
+ def test_decrypt_preserves_packet_metadata(self):
126
+ """Decrypted packet retains timestamp, rssi, location, sequence."""
127
+ pkt = _make_encrypted_packet(_KEY_256, 2, seq_no=42, plaintext=b"meta")
128
+
129
+ result = decrypt(_KEY_256, pkt, eid_pool_size=10)
130
+
131
+ assert result is not None
132
+ assert result.timestamp == 1700000000
133
+ assert result.rssi == -70
134
+ assert result.sequence == 42
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Validation: eid_pool_size + days mutual exclusivity
139
+ # ---------------------------------------------------------------------------
140
+
141
+ class TestCounterEidValidation:
142
+ """Test that eid_pool_size and days are mutually exclusive."""
143
+
144
+ def test_raises_when_both_eid_pool_size_and_days_set(self):
145
+ pkt = _make_encrypted_packet(_KEY_256, 0, seq_no=1, plaintext=b"x")
146
+
147
+ with pytest.raises(ValueError, match="Cannot specify both"):
148
+ decrypt(_KEY_256, pkt, days=5, eid_pool_size=10)
149
+
150
+ def test_eid_pool_size_with_default_days_is_ok(self):
151
+ """eid_pool_size + days=2 (the default) should NOT raise."""
152
+ pkt = _make_encrypted_packet(_KEY_256, 0, seq_no=1, plaintext=b"ok")
153
+
154
+ result = decrypt(_KEY_256, pkt, days=2, eid_pool_size=10)
155
+ assert result is not None
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # CLI option validation (ble scan / ble detect)
160
+ # ---------------------------------------------------------------------------
161
+
162
+ class TestCliEidPoolSizeOptions:
163
+ """Test --eid-pool-size CLI option validation."""
164
+
165
+ @pytest.fixture
166
+ def runner(self):
167
+ return CliRunner()
168
+
169
+ def test_scan_eid_pool_size_without_key_errors(self, runner):
170
+ from hubblenetwork.cli import cli
171
+
172
+ result = runner.invoke(cli, ["ble", "scan", "--eid-pool-size", "32"])
173
+ assert result.exit_code != 0
174
+ assert "requires --key" in result.output or "requires --key" in (result.exception and str(result.exception) or "")
175
+
176
+ def test_scan_eid_pool_size_with_days_errors(self, runner):
177
+ from hubblenetwork.cli import cli
178
+
179
+ result = runner.invoke(cli, [
180
+ "ble", "scan",
181
+ "--key", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
182
+ "--eid-pool-size", "32",
183
+ "--days", "3",
184
+ ])
185
+ assert result.exit_code != 0
186
+
187
+ def test_detect_eid_pool_size_with_days_errors(self, runner):
188
+ from hubblenetwork.cli import cli
189
+
190
+ result = runner.invoke(cli, [
191
+ "ble", "detect",
192
+ "--key", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
193
+ "--eid-pool-size", "32",
194
+ "--days", "3",
195
+ ])
196
+ assert result.exit_code != 0
197
+
198
+ def test_scan_help_shows_eid_pool_size(self, runner):
199
+ from hubblenetwork.cli import cli
200
+
201
+ result = runner.invoke(cli, ["ble", "scan", "--help"])
202
+ assert "--eid-pool-size" in result.output
203
+
204
+ def test_detect_help_shows_eid_pool_size(self, runner):
205
+ from hubblenetwork.cli import cli
206
+
207
+ result = runner.invoke(cli, ["ble", "detect", "--help"])
208
+ assert "--eid-pool-size" in result.output
209
+
210
+ def test_detect_help_shows_days(self, runner):
211
+ from hubblenetwork.cli import cli
212
+
213
+ result = runner.invoke(cli, ["ble", "detect", "--help"])
214
+ assert "--days" in result.output
File without changes