hubble-gateway-sdk-python 0.1.0__tar.gz → 0.1.1__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 (19) hide show
  1. hubble_gateway_sdk_python-0.1.1/.github/workflows/ci.yml +65 -0
  2. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/PKG-INFO +38 -2
  3. hubble_gateway_sdk_python-0.1.1/README.md +39 -0
  4. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/pyproject.toml +1 -1
  5. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/__init__.py +1 -1
  6. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/auth.py +65 -6
  7. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/config.py +4 -4
  8. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/models.py +35 -4
  9. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/scanner.py +49 -16
  10. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/sender.py +85 -34
  11. hubble_gateway_sdk_python-0.1.1/tests/test_scan_service_uuids.py +156 -0
  12. hubble_gateway_sdk_python-0.1.1/tests/test_server_config.py +291 -0
  13. hubble_gateway_sdk_python-0.1.0/README.md +0 -3
  14. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/.github/workflows/pypi.yml +0 -0
  15. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/.gitignore +0 -0
  16. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/LICENSE +0 -0
  17. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/location.py +0 -0
  18. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/ubx.py +0 -0
  19. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/tests/__init__.py +0 -0
@@ -0,0 +1,65 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ concurrency:
12
+ group: ${{ github.workflow }}-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ lint:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.12"
24
+ cache: pip
25
+ cache-dependency-path: pyproject.toml
26
+
27
+ - name: Install
28
+ run: python -m pip install -e ".[dev]"
29
+
30
+ # `ruff format --check` is not enforced yet: the existing sources are not
31
+ # format-clean, and reformatting them belongs in its own PR.
32
+ - name: Ruff
33
+ run: ruff check .
34
+
35
+ test:
36
+ runs-on: ubuntu-latest
37
+ strategy:
38
+ fail-fast: false
39
+ matrix:
40
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
41
+ steps:
42
+ - uses: actions/checkout@v4
43
+
44
+ - uses: actions/setup-python@v5
45
+ with:
46
+ python-version: ${{ matrix.python-version }}
47
+ cache: pip
48
+ cache-dependency-path: pyproject.toml
49
+
50
+ - name: Install
51
+ run: python -m pip install -e ".[dev]"
52
+
53
+ # pytest exits 5 when it collects no tests. main has no tests yet, so
54
+ # treat 5 as success until the first test file lands; every other
55
+ # non-zero code still fails the job.
56
+ - name: Pytest
57
+ run: |
58
+ set +e
59
+ pytest -q
60
+ code=$?
61
+ if [ "$code" -eq 5 ]; then
62
+ echo "::notice::pytest collected no tests (exit 5); treating as success."
63
+ exit 0
64
+ fi
65
+ exit "$code"
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: hubble-gateway-sdk-python
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Python SDK for building Hubble Network BLE gateways
5
5
  Project-URL: Homepage, https://github.com/HubbleNetwork/gateway-sdk-python
6
6
  Project-URL: Documentation, https://github.com/HubbleNetwork/gateway-sdk-python#readme
@@ -42,3 +42,39 @@ Description-Content-Type: text/markdown
42
42
  # hubble-gateway
43
43
 
44
44
  Python SDK for building Hubble Network BLE gateways. Import `hubble_gateway` in your application; this package is a library only (no CLI).
45
+
46
+ ## Server config
47
+
48
+ gateway-api sends a `config` object (or `null`) in the registration, heartbeat
49
+ and token-refresh responses. `GatewayAuth` stores it as `server_config`.
50
+ `GatewaySender` reads it on every use, so each new response takes effect
51
+ without a restart.
52
+
53
+ | Key | Range | Overrides |
54
+ | --- | --- | --- |
55
+ | `uploadIntervalMs` | 60,000 – 3,600,000 | `upload_interval_s` |
56
+ | `uploadBatchSize` | 1 – 500 | `batch_size` |
57
+ | `dedupWindowMs` | 5,000 – 3,600,000 | `dedup_window_s` |
58
+
59
+ - Each response replaces the stored config. It does not merge into it.
60
+ - `config: null` clears it, and the local values apply again.
61
+ - Out-of-range values are clamped. Values of the wrong type are ignored.
62
+ - Uploaded packets carry the 16-bit form (`FCA6`) as `serviceUuid`.
63
+ - Other keys (`activeUploadIntervalMs`, `activeDedupWindowMs`,
64
+ `uploadOnCellular`) are ignored: this SDK has no motion state or cellular gating.
65
+
66
+ The local default upload interval is 60 s, the same as the server's floor.
67
+
68
+ ## Deduplication
69
+
70
+ The dedup key is `(device address, 16-bit service UUID, service data hex)`.
71
+ For a custom 128-bit UUID, the full UUID takes the place of the 16-bit one.
72
+
73
+ - The first sighting of a key opens a window of `dedup_window_s`.
74
+ - Repeats inside the window are dropped. If the first sighting is not uploaded
75
+ yet, a repeat adds to its sighting count and can raise its RSSI.
76
+ - The window is fixed at the first sighting. Repeats do not extend it, so a
77
+ device that beacons all the time is reported once per window.
78
+ - Windows survive uploads. An upload does not reset them.
79
+ - Expired keys are removed. The map is capped at `MAX_DEDUP_KEYS` (100,000);
80
+ past the cap, the oldest key is removed first.
@@ -0,0 +1,39 @@
1
+ # hubble-gateway
2
+
3
+ Python SDK for building Hubble Network BLE gateways. Import `hubble_gateway` in your application; this package is a library only (no CLI).
4
+
5
+ ## Server config
6
+
7
+ gateway-api sends a `config` object (or `null`) in the registration, heartbeat
8
+ and token-refresh responses. `GatewayAuth` stores it as `server_config`.
9
+ `GatewaySender` reads it on every use, so each new response takes effect
10
+ without a restart.
11
+
12
+ | Key | Range | Overrides |
13
+ | --- | --- | --- |
14
+ | `uploadIntervalMs` | 60,000 – 3,600,000 | `upload_interval_s` |
15
+ | `uploadBatchSize` | 1 – 500 | `batch_size` |
16
+ | `dedupWindowMs` | 5,000 – 3,600,000 | `dedup_window_s` |
17
+
18
+ - Each response replaces the stored config. It does not merge into it.
19
+ - `config: null` clears it, and the local values apply again.
20
+ - Out-of-range values are clamped. Values of the wrong type are ignored.
21
+ - Uploaded packets carry the 16-bit form (`FCA6`) as `serviceUuid`.
22
+ - Other keys (`activeUploadIntervalMs`, `activeDedupWindowMs`,
23
+ `uploadOnCellular`) are ignored: this SDK has no motion state or cellular gating.
24
+
25
+ The local default upload interval is 60 s, the same as the server's floor.
26
+
27
+ ## Deduplication
28
+
29
+ The dedup key is `(device address, 16-bit service UUID, service data hex)`.
30
+ For a custom 128-bit UUID, the full UUID takes the place of the 16-bit one.
31
+
32
+ - The first sighting of a key opens a window of `dedup_window_s`.
33
+ - Repeats inside the window are dropped. If the first sighting is not uploaded
34
+ yet, a repeat adds to its sighting count and can raise its RSSI.
35
+ - The window is fixed at the first sighting. Repeats do not extend it, so a
36
+ device that beacons all the time is reported once per window.
37
+ - Windows survive uploads. An upload does not reset them.
38
+ - Expired keys are removed. The map is capped at `MAX_DEDUP_KEYS` (100,000);
39
+ past the cap, the oldest key is removed first.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hubble-gateway-sdk-python"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Python SDK for building Hubble Network BLE gateways"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -5,7 +5,7 @@ Import the components you need to build a gateway:
5
5
  from hubble_gateway import Scanner, GatewaySender, GatewayAuth, Location, BLEPacket
6
6
  """
7
7
 
8
- __version__ = "0.1.0"
8
+ __version__ = "0.1.1"
9
9
 
10
10
  from hubble_gateway.auth import GatewayAuth
11
11
  from hubble_gateway.config import (
@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
  import locale
5
+ import math
5
6
  import platform
6
7
  import time
7
8
  from pathlib import Path
@@ -11,11 +12,65 @@ import httpx
11
12
  import structlog
12
13
 
13
14
  from hubble_gateway import __version__
15
+ from hubble_gateway.config import HUBBLE_SERVICE_UUID
16
+ from hubble_gateway.models import is_bluetooth_base_uuid, normalize_service_uuid
14
17
 
15
18
  logger = structlog.get_logger(__name__)
16
19
 
17
20
  _TOKEN_EXPIRY_MARGIN_S = 60
18
21
 
22
+ # Server config keys this SDK applies, with the inclusive range gateway-api
23
+ # allows for them. gateway-api may also send activeUploadIntervalMs,
24
+ # activeDedupWindowMs and uploadOnCellular; Python has no motion state or
25
+ # cellular gating, so those are dropped.
26
+ SERVER_CONFIG_RANGES: dict[str, tuple[int, int]] = {
27
+ "uploadIntervalMs": (60_000, 3_600_000),
28
+ "uploadBatchSize": (1, 500),
29
+ "dedupWindowMs": (5_000, 3_600_000),
30
+ }
31
+
32
+ MAX_SCAN_SERVICE_UUIDS = 16
33
+
34
+
35
+ def _sanitize_scan_service_uuids(value: Any) -> list[str] | None:
36
+ """Normalize a UUID list to uppercase 128-bit UUIDs, FCA6 first.
37
+
38
+ Only 16-bit services are valid (short form or written out on the Bluetooth
39
+ Base UUID), because gateway-api ingest keys packets on 16-bit service IDs.
40
+ Other entries are dropped; FCA6 is always kept, as gateway-api does.
41
+ Returns None when the value is not a list.
42
+ """
43
+ if not isinstance(value, list):
44
+ return None
45
+ result = [HUBBLE_SERVICE_UUID]
46
+ for item in value:
47
+ u = normalize_service_uuid(item) if isinstance(item, str) else None
48
+ if u is not None and is_bluetooth_base_uuid(u) and u not in result:
49
+ result.append(u)
50
+ return result[:MAX_SCAN_SERVICE_UUIDS]
51
+
52
+
53
+ def sanitize_server_config(config: Any) -> dict[str, Any]:
54
+ """Keep the keys this SDK uses, clamped to their ranges.
55
+
56
+ Values of the wrong type (null, strings, bools, NaN) are dropped, so the
57
+ local setting applies for that key.
58
+ """
59
+ if not isinstance(config, dict):
60
+ return {}
61
+ result: dict[str, Any] = {}
62
+ uuids = _sanitize_scan_service_uuids(config.get("scanServiceUuids"))
63
+ if uuids is not None:
64
+ result["scanServiceUuids"] = uuids
65
+ for key, (low, high) in SERVER_CONFIG_RANGES.items():
66
+ value = config.get(key)
67
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
68
+ continue
69
+ if isinstance(value, float) and not math.isfinite(value):
70
+ continue
71
+ result[key] = min(max(int(value), low), high)
72
+ return result
73
+
19
74
 
20
75
  def _device_id() -> str:
21
76
  machine_id_path = Path("/etc/machine-id")
@@ -188,6 +243,7 @@ class GatewayAuth:
188
243
  self._device_token = dt
189
244
  self._refresh_token = rt
190
245
  self._expires_at_ms = int(time.time() * 1000) + exp * 1000
246
+ self._apply_server_config(data)
191
247
  self._persist_tokens()
192
248
  logger.info("Token refreshed", expires_in_s=exp)
193
249
  return True
@@ -201,6 +257,7 @@ class GatewayAuth:
201
257
  )
202
258
  if resp.status_code == 200:
203
259
  self._apply_server_config(resp.json())
260
+ self._persist_tokens()
204
261
 
205
262
  async def _request(
206
263
  self, method: str, path: str, json_body: dict[str, Any] | None = None,
@@ -216,11 +273,13 @@ class GatewayAuth:
216
273
  method, f"{self._base_url}{path}", json=json_body, headers=headers,
217
274
  )
218
275
 
219
- def _apply_server_config(self, data: dict[str, Any]) -> None:
220
- config = data.get("config") or {}
221
- for key in ("uploadIntervalMs", "uploadBatchSize", "scanIntervalMs", "featureFlags"):
222
- if key in config:
223
- self.server_config[key] = config[key]
276
+ def _apply_server_config(self, data: Any) -> None:
277
+ # The server sends the full effective config each time, so replace
278
+ # rather than merge: a removed override must not stick. A response
279
+ # without a "config" key says nothing and keeps the current config.
280
+ if not isinstance(data, dict) or "config" not in data:
281
+ return
282
+ self.server_config = sanitize_server_config(data["config"])
224
283
 
225
284
  def _persist_tokens(self) -> None:
226
285
  self._token_path.parent.mkdir(parents=True, exist_ok=True)
@@ -242,7 +301,7 @@ class GatewayAuth:
242
301
  self._device_token = data.get("deviceToken")
243
302
  self._refresh_token = data.get("refreshToken")
244
303
  self._expires_at_ms = data.get("expiresAtMs", 0)
245
- self.server_config = data.get("serverConfig", {})
304
+ self.server_config = sanitize_server_config(data.get("serverConfig"))
246
305
  logger.info("Loaded persisted auth", gateway_id=self._gateway_id)
247
306
  except (json.JSONDecodeError, OSError) as exc:
248
307
  logger.warning("Failed to load persisted auth", error=str(exc))
@@ -53,15 +53,15 @@ class Settings(BaseSettings):
53
53
  # Batching and deduplication
54
54
  batch_size: int = Field(
55
55
  default=500,
56
- description="Max packets per upload batch",
56
+ description="Max packets per upload batch (server uploadBatchSize overrides)",
57
57
  )
58
58
  upload_interval_seconds: float = Field(
59
- default=5.0,
60
- description="Seconds between upload flushes",
59
+ default=60.0,
60
+ description="Seconds between upload flushes (server uploadIntervalMs overrides)",
61
61
  )
62
62
  dedup_window_seconds: float = Field(
63
63
  default=300.0,
64
- description="Deduplication window in seconds",
64
+ description="Deduplication window in seconds (server dedupWindowMs overrides)",
65
65
  )
66
66
 
67
67
  # Location
@@ -1,18 +1,49 @@
1
1
  """Data models for BLE packets and locations."""
2
2
 
3
3
  import base64
4
+ import re
4
5
  from datetime import datetime, timezone
5
6
  from typing import Any
6
7
 
7
8
  from pydantic import BaseModel, Field
8
9
 
10
+ BLUETOOTH_BASE_UUID_SUFFIX = "-0000-1000-8000-00805F9B34FB"
11
+
12
+ _UUID16_PATTERN = re.compile(r"^[0-9A-F]{4}$")
13
+ _UUID128_PATTERN = re.compile(
14
+ r"^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$"
15
+ )
16
+
17
+
18
+ def normalize_service_uuid(value: str) -> str | None:
19
+ """Return the uppercase 128-bit form of a BLE service UUID, or None if invalid.
20
+
21
+ ``FCA6`` -> ``0000FCA6-0000-1000-8000-00805F9B34FB``. This is the form
22
+ bleak reports and filters on.
23
+ """
24
+ u = value.strip().upper()
25
+ if _UUID16_PATTERN.match(u):
26
+ return f"0000{u}{BLUETOOTH_BASE_UUID_SUFFIX}"
27
+ if _UUID128_PATTERN.match(u):
28
+ return u
29
+ return None
30
+
31
+
32
+ def is_bluetooth_base_uuid(full_uuid: str) -> bool:
33
+ u = full_uuid.upper()
34
+ return len(u) == 36 and u.startswith("0000") and u.endswith(BLUETOOTH_BASE_UUID_SUFFIX)
35
+
9
36
 
10
37
  def uuid_to_16bit(full_uuid: str) -> str:
11
- """Extract 16-bit short form from a full 128-bit BLE UUID.
38
+ """Short form of a BLE UUID, as the gateway-api packet schema expects.
12
39
 
13
- ``0000FCA6-0000-1000-8000-00805F9B34FB`` -> ``FCA6``
40
+ ``0000FCA6-0000-1000-8000-00805F9B34FB`` -> ``FCA6``. A UUID that is not
41
+ on the Bluetooth Base UUID has no 16-bit form, so it is returned whole
42
+ (uppercase) rather than as a misleading slice.
14
43
  """
15
- return full_uuid[4:8].upper()
44
+ if is_bluetooth_base_uuid(full_uuid):
45
+ return full_uuid[4:8].upper()
46
+ return full_uuid.upper()
16
47
 
17
48
 
18
49
  class Location(BaseModel):
@@ -89,7 +120,7 @@ class BLEPacket(BaseModel):
89
120
 
90
121
  @property
91
122
  def service_uuid_16bit(self) -> str | None:
92
- """16-bit short form of matched UUID (e.g. 'FCA6')."""
123
+ """Short form of matched UUID (e.g. 'FCA6'); full form if not a Base UUID."""
93
124
  if self.service_uuid:
94
125
  return uuid_to_16bit(self.service_uuid)
95
126
  return None
@@ -3,6 +3,7 @@
3
3
  import asyncio
4
4
  from collections.abc import Callable
5
5
  from datetime import datetime, timezone
6
+ from typing import TYPE_CHECKING
6
7
 
7
8
  import structlog
8
9
  from bleak import BleakScanner
@@ -10,7 +11,10 @@ from bleak.backends.device import BLEDevice
10
11
  from bleak.backends.scanner import AdvertisementData
11
12
 
12
13
  from hubble_gateway.config import DEFAULT_SERVICE_UUIDS
13
- from hubble_gateway.models import BLEPacket, uuid_to_16bit
14
+ from hubble_gateway.models import BLEPacket, is_bluetooth_base_uuid, uuid_to_16bit
15
+
16
+ if TYPE_CHECKING:
17
+ from hubble_gateway.auth import GatewayAuth
14
18
 
15
19
  logger = structlog.get_logger(__name__)
16
20
 
@@ -29,8 +33,12 @@ class Scanner:
29
33
  scan_duration: float = 5.0,
30
34
  on_packet: Callable[[BLEPacket], None] | None = None,
31
35
  adapter: str | None = None,
36
+ auth: "GatewayAuth | None" = None,
32
37
  ) -> None:
33
- self._service_uuids = [u.lower() for u in (service_uuids or DEFAULT_SERVICE_UUIDS)]
38
+ self._local_service_uuids = list(service_uuids or DEFAULT_SERVICE_UUIDS)
39
+ self._auth = auth
40
+ self._service_uuids: list[str] = []
41
+ self._uuid_variants: dict[str, str] = {}
34
42
  self._scan_duration = scan_duration
35
43
  self._on_packet = on_packet
36
44
  self._adapter = adapter or None
@@ -39,14 +47,35 @@ class Scanner:
39
47
  self._seen_devices: set[str] = set()
40
48
  self._devices_this_scan: set[str] = set()
41
49
  self._total_packets = 0
50
+ self._refresh_service_uuids()
42
51
 
43
- self._uuid_variants: dict[str, str] = {}
44
- for full_uuid in self._service_uuids:
45
- short = full_uuid[4:8]
46
- canonical = f"0000{short}-0000-1000-8000-00805f9b34fb"
47
- self._uuid_variants[full_uuid] = full_uuid
48
- self._uuid_variants[short] = full_uuid
49
- self._uuid_variants[canonical] = full_uuid
52
+ @property
53
+ def service_uuids(self) -> list[str]:
54
+ """The UUIDs the current scan cycle filters on (lowercase 128-bit)."""
55
+ return list(self._service_uuids)
56
+
57
+ def _effective_service_uuids(self) -> list[str]:
58
+ if self._auth is not None:
59
+ server = self._auth.server_config.get("scanServiceUuids")
60
+ if server:
61
+ return server
62
+ return self._local_service_uuids
63
+
64
+ def _refresh_service_uuids(self) -> bool:
65
+ """Adopt the effective UUID list; return True if it changed."""
66
+ uuids = [u.lower() for u in self._effective_service_uuids()]
67
+ if uuids == self._service_uuids:
68
+ return False
69
+ variants: dict[str, str] = {}
70
+ for full_uuid in uuids:
71
+ variants[full_uuid] = full_uuid
72
+ # Only a Bluetooth Base UUID has a 16-bit alias; slicing any other
73
+ # UUID would match an unrelated 16-bit service.
74
+ if is_bluetooth_base_uuid(full_uuid):
75
+ variants[full_uuid[4:8]] = full_uuid
76
+ self._service_uuids = uuids
77
+ self._uuid_variants = variants
78
+ return True
50
79
 
51
80
  def _match_service_uuid(self, advertised_uuids: list[str]) -> str | None:
52
81
  for adv_uuid in advertised_uuids:
@@ -103,17 +132,21 @@ class Scanner:
103
132
  adapter=self._adapter or "default",
104
133
  )
105
134
 
106
- scanner_kwargs: dict = {
107
- "detection_callback": self._detection_callback,
108
- "service_uuids": self._service_uuids,
109
- }
110
- if self._adapter:
111
- scanner_kwargs["adapter"] = self._adapter
112
-
113
135
  while self._running:
114
136
  if self._paused:
115
137
  await asyncio.sleep(0.5)
116
138
  continue
139
+ if self._refresh_service_uuids():
140
+ logger.info(
141
+ "Scan UUIDs changed",
142
+ service_uuids=[uuid_to_16bit(u) for u in self._service_uuids],
143
+ )
144
+ scanner_kwargs: dict = {
145
+ "detection_callback": self._detection_callback,
146
+ "service_uuids": self._service_uuids,
147
+ }
148
+ if self._adapter:
149
+ scanner_kwargs["adapter"] = self._adapter
117
150
  try:
118
151
  self._devices_this_scan.clear()
119
152
  async with BleakScanner(**scanner_kwargs) as _scanner:
@@ -13,15 +13,21 @@ from hubble_gateway.models import BLEPacket, Location
13
13
 
14
14
  logger = structlog.get_logger(__name__)
15
15
 
16
+ _DedupKey = tuple[str, str | None, str]
17
+
18
+ # Hard cap on remembered dedup keys. Age-based expiry normally keeps the map
19
+ # far smaller; the cap only bounds memory if a flood of distinct keys arrives
20
+ # inside one window.
21
+ MAX_DEDUP_KEYS = 100_000
22
+
16
23
 
17
24
  class _DedupEntry:
18
- __slots__ = ("packet", "strongest_rssi", "sighting_count", "first_seen_ms")
25
+ __slots__ = ("packet", "strongest_rssi", "sighting_count")
19
26
 
20
27
  def __init__(self, packet: BLEPacket) -> None:
21
28
  self.packet = packet
22
29
  self.strongest_rssi = packet.rssi
23
30
  self.sighting_count = 1
24
- self.first_seen_ms = int(packet.timestamp.timestamp() * 1000)
25
31
 
26
32
  def merge(self, packet: BLEPacket) -> None:
27
33
  self.sighting_count += 1
@@ -38,34 +44,41 @@ class GatewaySender:
38
44
  auth: GatewayAuth,
39
45
  get_location: Callable[[], Awaitable[Location]] | None = None,
40
46
  batch_size: int = 500,
41
- upload_interval_s: float = 5.0,
47
+ upload_interval_s: float = 60.0,
42
48
  dedup_window_s: float = 300.0,
43
49
  ) -> None:
44
50
  self._auth = auth
45
51
  self._get_location = get_location
46
- self._batch_size = batch_size
47
- self._upload_interval = upload_interval_s
48
- self._dedup_window = dedup_window_s
49
- self._dedup_map: dict[tuple[str, str | None, str], _DedupEntry] = {}
52
+ self._local_batch_size = batch_size
53
+ self._local_upload_interval_s = upload_interval_s
54
+ self._local_dedup_window_s = dedup_window_s
55
+ # Sightings waiting for the next upload.
56
+ self._pending: list[_DedupEntry] = []
57
+ # Pending entry per key for the window it opened, so in-window repeats
58
+ # before the upload add to its sighting count.
59
+ self._open: dict[_DedupKey, _DedupEntry] = {}
60
+ # When each key last opened a window. Survives uploads; kept in
61
+ # ascending time order so expiry and the cap pop from the front.
62
+ self._window_start_ms: dict[_DedupKey, int] = {}
50
63
  self._dedup_lock = asyncio.Lock()
51
64
  self._running = False
52
65
  self._stats_sends = 0
53
66
  self._stats_total = 0
54
67
  self._stats_uploaded = 0
68
+ self._stats_dropped = 0
55
69
  self._stats_devices: set[str] = set()
56
70
 
57
71
  async def start(self) -> None:
58
72
  self._running = True
59
- self._apply_server_config()
60
73
  asyncio.create_task(self._upload_loop())
61
74
  if self._get_location:
62
75
  asyncio.create_task(self._location_loop())
63
76
  logger.info(
64
77
  "Sender started",
65
78
  gateway_id=self._auth.gateway_id,
66
- upload_interval=self._upload_interval,
67
- dedup_window=self._dedup_window,
68
- batch_size=self._batch_size,
79
+ upload_interval=self.upload_interval_s,
80
+ dedup_window=self.dedup_window_s,
81
+ batch_size=self.batch_size,
69
82
  )
70
83
 
71
84
  async def stop(self) -> None:
@@ -82,14 +95,55 @@ class GatewaySender:
82
95
  def add_packet(self, packet: BLEPacket) -> None:
83
96
  self._stats_total += 1
84
97
  self._stats_devices.add(packet.device_address)
85
- key = (packet.device_address, packet.service_uuid_16bit, packet.service_data_hex)
86
- existing = self._dedup_map.get(key)
87
- if existing is not None:
88
- age_ms = int(packet.timestamp.timestamp() * 1000) - existing.first_seen_ms
89
- if age_ms < self._dedup_window * 1000:
90
- existing.merge(packet)
91
- return
92
- self._dedup_map[key] = _DedupEntry(packet)
98
+ key: _DedupKey = (
99
+ packet.device_address, packet.service_uuid_16bit, packet.service_data_hex,
100
+ )
101
+ now_ms = int(packet.timestamp.timestamp() * 1000)
102
+ window_ms = self.dedup_window_s * 1000
103
+ self._expire_dedup_keys(now_ms, window_ms)
104
+
105
+ # The window is anchored at the sighting that opened it, not slid by
106
+ # repeats: a device that beacons nonstop is still reported once per
107
+ # window instead of never again.
108
+ start_ms = self._window_start_ms.get(key)
109
+ if start_ms is not None and now_ms - start_ms < window_ms:
110
+ entry = self._open.get(key)
111
+ if entry is not None:
112
+ entry.merge(packet)
113
+ else:
114
+ self._stats_dropped += 1
115
+ return
116
+
117
+ self._window_start_ms.pop(key, None)
118
+ self._window_start_ms[key] = now_ms
119
+ entry = _DedupEntry(packet)
120
+ self._pending.append(entry)
121
+ self._open[key] = entry
122
+
123
+ def _expire_dedup_keys(self, now_ms: int, window_ms: float) -> None:
124
+ starts = self._window_start_ms
125
+ while starts:
126
+ key = next(iter(starts))
127
+ if now_ms - starts[key] < window_ms and len(starts) < MAX_DEDUP_KEYS:
128
+ break
129
+ del starts[key]
130
+
131
+ # Read the auth's server config on every use rather than caching it, so a
132
+ # new config from any registration, heartbeat or token refresh applies at
133
+ # once, and a cleared config falls back to the local values.
134
+ @property
135
+ def batch_size(self) -> int:
136
+ return self._auth.server_config.get("uploadBatchSize", self._local_batch_size)
137
+
138
+ @property
139
+ def upload_interval_s(self) -> float:
140
+ ms = self._auth.server_config.get("uploadIntervalMs")
141
+ return self._local_upload_interval_s if ms is None else ms / 1000
142
+
143
+ @property
144
+ def dedup_window_s(self) -> float:
145
+ ms = self._auth.server_config.get("dedupWindowMs")
146
+ return self._local_dedup_window_s if ms is None else ms / 1000
93
147
 
94
148
  @property
95
149
  def stats(self) -> dict[str, int]:
@@ -98,13 +152,15 @@ class GatewaySender:
98
152
  "uploaded_packets": self._stats_uploaded,
99
153
  "sends": self._stats_sends,
100
154
  "unique_devices": len(self._stats_devices),
101
- "buffer_size": len(self._dedup_map),
155
+ "dropped_duplicates": self._stats_dropped,
156
+ "buffer_size": len(self._pending),
157
+ "dedup_keys": len(self._window_start_ms),
102
158
  }
103
159
 
104
160
  async def _upload_loop(self) -> None:
105
161
  while self._running:
106
- await asyncio.sleep(self._upload_interval)
107
- if not self._dedup_map:
162
+ await asyncio.sleep(self.upload_interval_s)
163
+ if not self._pending:
108
164
  continue
109
165
  await self._flush()
110
166
 
@@ -123,10 +179,11 @@ class GatewaySender:
123
179
 
124
180
  async def _flush(self) -> None:
125
181
  async with self._dedup_lock:
126
- if not self._dedup_map:
182
+ if not self._pending:
127
183
  return
128
- entries = list(self._dedup_map.values())
129
- self._dedup_map.clear()
184
+ entries = self._pending
185
+ self._pending = []
186
+ self._open.clear()
130
187
 
131
188
  location = Location()
132
189
  if self._get_location:
@@ -135,8 +192,9 @@ class GatewaySender:
135
192
  except Exception as e:
136
193
  logger.warning("Failed to get location", error=str(e))
137
194
 
138
- for i in range(0, len(entries), self._batch_size):
139
- batch = entries[i : i + self._batch_size]
195
+ batch_size = self.batch_size
196
+ for i in range(0, len(entries), batch_size):
197
+ batch = entries[i : i + batch_size]
140
198
  await self._upload_batch(batch, location)
141
199
 
142
200
  async def _upload_batch(self, entries: list[_DedupEntry], location: Location) -> None:
@@ -193,10 +251,3 @@ class GatewaySender:
193
251
  )
194
252
  except Exception as e:
195
253
  logger.warning("Location upload failed", error=str(e))
196
-
197
- def _apply_server_config(self) -> None:
198
- cfg = self._auth.server_config
199
- if "uploadBatchSize" in cfg:
200
- self._batch_size = min(int(cfg["uploadBatchSize"]), 500)
201
- if "uploadIntervalMs" in cfg:
202
- self._upload_interval = max(int(cfg["uploadIntervalMs"]) / 1000, 1)
@@ -0,0 +1,156 @@
1
+ from types import SimpleNamespace
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from hubble_gateway import scanner as scanner_mod
7
+ from hubble_gateway.auth import GatewayAuth, sanitize_server_config
8
+ from hubble_gateway.config import HUBBLE_SERVICE_UUID, TILE_SERVICE_UUID
9
+ from hubble_gateway.models import uuid_to_16bit
10
+ from hubble_gateway.scanner import Scanner
11
+
12
+ FCA6 = HUBBLE_SERVICE_UUID
13
+ FEED = TILE_SERVICE_UUID
14
+ CUSTOM = "12345678-ABCD-1234-ABCD-1234567890AB"
15
+ BASE_180F = "0000180F-0000-1000-8000-00805F9B34FB"
16
+
17
+
18
+ def _scan(raw):
19
+ return sanitize_server_config({"scanServiceUuids": raw}).get("scanServiceUuids")
20
+
21
+
22
+ @pytest.mark.parametrize(
23
+ ("raw", "expected"),
24
+ [
25
+ (["FCA6", "FEED"], [FCA6, FEED]),
26
+ (["feed"], [FCA6, FEED]),
27
+ ([], [FCA6]),
28
+ (["180F", FCA6.lower()], [FCA6, BASE_180F]),
29
+ ([BASE_180F.lower(), "180F"], [FCA6, BASE_180F]),
30
+ ([CUSTOM.lower()], [FCA6]),
31
+ (["FEED", CUSTOM], [FCA6, FEED]),
32
+ (["00010000-0000-1000-8000-00805F9B34FB"], [FCA6]),
33
+ (["FEED", "nope", 7, None, "FCA", "FEED"], [FCA6, FEED]),
34
+ ],
35
+ )
36
+ def test_scan_list_normalized(raw, expected):
37
+ assert _scan(raw) == expected
38
+
39
+
40
+ def test_scan_list_capped_at_16():
41
+ raw = [f"{i:04X}" for i in range(1, 40)]
42
+ result = _scan(raw)
43
+ assert len(result) == 16
44
+ assert result[0] == FCA6
45
+
46
+
47
+ @pytest.mark.parametrize("raw", [None, "FCA6,FEED", {"a": 1}])
48
+ def test_scan_list_wrong_type_ignored(raw):
49
+ assert _scan(raw) is None
50
+
51
+
52
+ def test_uuid_to_16bit():
53
+ assert uuid_to_16bit(FCA6.lower()) == "FCA6"
54
+ assert uuid_to_16bit(CUSTOM.lower()) == CUSTOM
55
+
56
+
57
+ def _auth(config=None):
58
+ return SimpleNamespace(server_config=config or {})
59
+
60
+
61
+ def _adv(uuid: str, data: bytes = b"\x01"):
62
+ device = SimpleNamespace(address="AA:BB:CC:DD:EE:FF", name=None)
63
+ adv = SimpleNamespace(
64
+ service_uuids=[uuid],
65
+ service_data={uuid: data},
66
+ manufacturer_data={},
67
+ rssi=-50,
68
+ tx_power=None,
69
+ local_name=None,
70
+ )
71
+ return device, adv
72
+
73
+
74
+ def test_scanner_local_list_without_server_config():
75
+ s = Scanner(service_uuids=[FCA6, FEED], auth=_auth())
76
+ assert s.service_uuids == [FCA6.lower(), FEED.lower()]
77
+
78
+
79
+ def test_scanner_server_list_replaces_local():
80
+ packets = []
81
+ s = Scanner(
82
+ service_uuids=[FCA6, FEED],
83
+ on_packet=packets.append,
84
+ auth=_auth({"scanServiceUuids": [FCA6, BASE_180F]}),
85
+ )
86
+ assert s.service_uuids == [FCA6.lower(), BASE_180F.lower()]
87
+
88
+ s._detection_callback(*_adv(FEED.lower()))
89
+ assert packets == []
90
+
91
+ s._detection_callback(*_adv(BASE_180F.lower()))
92
+ assert packets[0].to_gateway_packet()["serviceUuid"] == "180F"
93
+
94
+ s._detection_callback(*_adv("fca6"))
95
+ assert packets[1].to_gateway_packet()["serviceUuid"] == "FCA6"
96
+
97
+
98
+ def test_local_custom_uuid_has_no_16bit_alias():
99
+ s = Scanner(service_uuids=[CUSTOM])
100
+ assert s._match_service_uuid(["5678"]) is None
101
+
102
+
103
+ async def test_scanner_applies_new_list_next_cycle(monkeypatch):
104
+ auth = _auth()
105
+ calls: list[list[str]] = []
106
+ s = Scanner(service_uuids=[FCA6, FEED], scan_duration=0, auth=auth)
107
+
108
+ class FakeBleak:
109
+ def __init__(self, **kwargs):
110
+ calls.append(list(kwargs["service_uuids"]))
111
+
112
+ async def __aenter__(self):
113
+ if len(calls) == 1:
114
+ auth.server_config = {"scanServiceUuids": [FCA6, BASE_180F]}
115
+ elif len(calls) == 2:
116
+ auth.server_config = {}
117
+ else:
118
+ s._running = False
119
+ return self
120
+
121
+ async def __aexit__(self, *exc):
122
+ return False
123
+
124
+ monkeypatch.setattr(scanner_mod, "BleakScanner", FakeBleak)
125
+ await s.start()
126
+ assert calls == [
127
+ [FCA6.lower(), FEED.lower()],
128
+ [FCA6.lower(), BASE_180F.lower()],
129
+ [FCA6.lower(), FEED.lower()],
130
+ ]
131
+
132
+
133
+ async def test_auth_scan_list_absent_keeps_null_clears(tmp_path):
134
+ bodies = {"heartbeat": {}}
135
+
136
+ def handler(request: httpx.Request) -> httpx.Response:
137
+ if request.url.path == "/api/v1/gateways":
138
+ return httpx.Response(201, json={
139
+ "gatewayId": "gw-1", "deviceToken": "dt", "refreshToken": "rt",
140
+ "expiresInSeconds": 3600, "config": {"scanServiceUuids": ["FEED", "180F"]},
141
+ })
142
+ return httpx.Response(200, json=bodies["heartbeat"])
143
+
144
+ auth = GatewayAuth("k", "https://gw.test", token_path=str(tmp_path / "a.json"))
145
+ auth._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
146
+ assert await auth.ensure_authenticated()
147
+ s = Scanner(service_uuids=[FCA6, FEED], auth=auth)
148
+ assert auth.server_config["scanServiceUuids"] == [FCA6, FEED, BASE_180F]
149
+
150
+ await auth.heartbeat()
151
+ assert auth.server_config["scanServiceUuids"] == [FCA6, FEED, BASE_180F]
152
+
153
+ bodies["heartbeat"] = {"config": None}
154
+ await auth.heartbeat()
155
+ s._refresh_service_uuids()
156
+ assert s.service_uuids == [FCA6.lower(), FEED.lower()]
@@ -0,0 +1,291 @@
1
+ import json
2
+ from datetime import datetime, timedelta, timezone
3
+
4
+ import httpx
5
+ import pytest
6
+
7
+ from hubble_gateway.auth import GatewayAuth, sanitize_server_config
8
+ from hubble_gateway.config import Settings
9
+ from hubble_gateway.models import BLEPacket
10
+ from hubble_gateway.sender import GatewaySender
11
+
12
+ NORMAL = {"uploadIntervalMs": 300_000, "uploadBatchSize": 500, "dedupWindowMs": 300_000}
13
+
14
+
15
+ def _register_body(config):
16
+ return {
17
+ "gatewayId": "gw-1",
18
+ "deviceToken": "dt",
19
+ "refreshToken": "rt",
20
+ "expiresInSeconds": 3600,
21
+ "config": config,
22
+ }
23
+
24
+
25
+ class FakeServer:
26
+ """Stands in for gateway-api; tests set the body each endpoint returns."""
27
+
28
+ def __init__(self) -> None:
29
+ self.register_config: object = None
30
+ self.heartbeat_body: dict = {}
31
+ self.refresh_body: dict = {}
32
+
33
+ def handler(self, request: httpx.Request) -> httpx.Response:
34
+ path = request.url.path
35
+ if request.method == "PUT" and path == "/api/v1/gateways":
36
+ body = json.loads(request.content)
37
+ assert body["platform"]["sdk"] == "hubble-gateway-python"
38
+ return httpx.Response(201, json=_register_body(self.register_config))
39
+ if request.method == "PUT" and path == "/api/v1/gateways/gw-1":
40
+ return httpx.Response(200, json=self.heartbeat_body)
41
+ if request.method == "POST" and path == "/api/v1/auth/refresh":
42
+ return httpx.Response(200, json=self.refresh_body)
43
+ return httpx.Response(404)
44
+
45
+
46
+ @pytest.fixture
47
+ def server():
48
+ return FakeServer()
49
+
50
+
51
+ @pytest.fixture
52
+ def auth(tmp_path, server):
53
+ a = GatewayAuth("sdk-key", "https://gw.test", token_path=str(tmp_path / "auth.json"))
54
+ a._client = httpx.AsyncClient(transport=httpx.MockTransport(server.handler))
55
+ return a
56
+
57
+
58
+ def _packet(addr: str, ts: datetime) -> BLEPacket:
59
+ return BLEPacket(
60
+ device_address=addr,
61
+ rssi=-60,
62
+ service_data={"0000fca6-0000-1000-8000-00805f9b34fb": b"\x01"},
63
+ service_uuid="0000fca6-0000-1000-8000-00805f9b34fb",
64
+ timestamp=ts,
65
+ )
66
+
67
+
68
+ @pytest.mark.parametrize(
69
+ ("raw", "expected"),
70
+ [
71
+ (NORMAL, NORMAL),
72
+ ({"uploadIntervalMs": 1_000}, {"uploadIntervalMs": 60_000}),
73
+ ({"uploadIntervalMs": 99_000_000}, {"uploadIntervalMs": 3_600_000}),
74
+ ({"uploadBatchSize": 0}, {"uploadBatchSize": 1}),
75
+ ({"uploadBatchSize": 10_000}, {"uploadBatchSize": 500}),
76
+ ({"dedupWindowMs": 1}, {"dedupWindowMs": 5_000}),
77
+ ({"dedupWindowMs": 99_000_000}, {"dedupWindowMs": 3_600_000}),
78
+ ({"uploadIntervalMs": 120_000.0}, {"uploadIntervalMs": 120_000}),
79
+ ],
80
+ )
81
+ def test_sanitize_clamps(raw, expected):
82
+ assert sanitize_server_config(raw) == expected
83
+
84
+
85
+ @pytest.mark.parametrize(
86
+ "raw",
87
+ [
88
+ {"uploadIntervalMs": None},
89
+ {"uploadIntervalMs": "300000"},
90
+ {"uploadBatchSize": True},
91
+ {"dedupWindowMs": float("nan")},
92
+ {"dedupWindowMs": [5_000]},
93
+ ],
94
+ )
95
+ def test_sanitize_ignores_bad_types(raw):
96
+ assert sanitize_server_config(raw) == {}
97
+
98
+
99
+ @pytest.mark.parametrize("raw", [None, "eco", [], 42])
100
+ def test_sanitize_non_object(raw):
101
+ assert sanitize_server_config(raw) == {}
102
+
103
+
104
+ def test_sanitize_drops_unused_keys():
105
+ raw = {
106
+ **NORMAL,
107
+ "scanIntervalMs": 10_000,
108
+ "featureFlags": {"x": True},
109
+ "activeUploadIntervalMs": 60_000,
110
+ "activeDedupWindowMs": 60_000,
111
+ "uploadOnCellular": False,
112
+ }
113
+ assert sanitize_server_config(raw) == NORMAL
114
+
115
+
116
+ async def test_heartbeat_replaces_not_merges(auth, server):
117
+ server.register_config = NORMAL
118
+ assert await auth.ensure_authenticated()
119
+ assert auth.server_config == NORMAL
120
+
121
+ server.heartbeat_body = {"config": {"uploadIntervalMs": 900_000}}
122
+ await auth.heartbeat()
123
+ assert auth.server_config == {"uploadIntervalMs": 900_000}
124
+
125
+
126
+ async def test_null_config_clears(auth, server):
127
+ server.register_config = NORMAL
128
+ assert await auth.ensure_authenticated()
129
+
130
+ server.heartbeat_body = {"config": None}
131
+ await auth.heartbeat()
132
+ assert auth.server_config == {}
133
+
134
+
135
+ async def test_missing_config_key_keeps_config(auth, server):
136
+ server.register_config = NORMAL
137
+ assert await auth.ensure_authenticated()
138
+
139
+ server.heartbeat_body = {}
140
+ await auth.heartbeat()
141
+ assert auth.server_config == NORMAL
142
+
143
+
144
+ async def test_refresh_applies_config(auth, server):
145
+ server.register_config = NORMAL
146
+ assert await auth.ensure_authenticated()
147
+
148
+ server.refresh_body = {
149
+ "deviceToken": "dt2",
150
+ "expiresInSeconds": 3600,
151
+ "config": {"uploadIntervalMs": 60_000, "dedupWindowMs": 60_000},
152
+ }
153
+ assert await auth._refresh()
154
+ assert auth.server_config == {"uploadIntervalMs": 60_000, "dedupWindowMs": 60_000}
155
+
156
+
157
+ async def test_heartbeat_persists_config(auth, server, tmp_path):
158
+ server.register_config = NORMAL
159
+ assert await auth.ensure_authenticated()
160
+ server.heartbeat_body = {"config": None}
161
+ await auth.heartbeat()
162
+
163
+ reloaded = GatewayAuth("sdk-key", "https://gw.test", token_path=str(tmp_path / "auth.json"))
164
+ assert reloaded.server_config == {}
165
+
166
+
167
+ def test_persisted_stale_keys_dropped(tmp_path):
168
+ path = tmp_path / "auth.json"
169
+ path.write_text(json.dumps({
170
+ "gatewayId": "gw-1",
171
+ "serverConfig": {"uploadIntervalMs": 1_000, "scanIntervalMs": 5, "featureFlags": {}},
172
+ }))
173
+ a = GatewayAuth("sdk-key", "https://gw.test", token_path=str(path))
174
+ assert a.server_config == {"uploadIntervalMs": 60_000}
175
+
176
+
177
+ async def test_sender_reapplies_after_heartbeat(auth, server):
178
+ sender = GatewaySender(auth, batch_size=200, upload_interval_s=5.0, dedup_window_s=30.0)
179
+ assert (sender.batch_size, sender.upload_interval_s, sender.dedup_window_s) == (200, 5.0, 30.0)
180
+
181
+ server.register_config = NORMAL
182
+ assert await auth.ensure_authenticated()
183
+ assert (sender.batch_size, sender.upload_interval_s, sender.dedup_window_s) == (500, 300, 300)
184
+
185
+ server.heartbeat_body = {"config": {"uploadIntervalMs": 900_000, "dedupWindowMs": 600_000}}
186
+ await auth.heartbeat()
187
+ assert (sender.batch_size, sender.upload_interval_s, sender.dedup_window_s) == (200, 900, 600)
188
+
189
+ server.heartbeat_body = {"config": None}
190
+ await auth.heartbeat()
191
+ assert (sender.batch_size, sender.upload_interval_s, sender.dedup_window_s) == (200, 5.0, 30.0)
192
+
193
+
194
+ async def test_sender_applies_dedup_window(auth, server):
195
+ server.register_config = {"dedupWindowMs": 60_000}
196
+ assert await auth.ensure_authenticated()
197
+ sender = GatewaySender(auth, dedup_window_s=1.0)
198
+
199
+ t0 = datetime.now(timezone.utc)
200
+ sender.add_packet(_packet("AA", t0))
201
+ sender.add_packet(_packet("AA", t0 + timedelta(seconds=30)))
202
+ assert sender.stats["buffer_size"] == 1
203
+ assert sender._pending[0].sighting_count == 2
204
+
205
+ sender.add_packet(_packet("AA", t0 + timedelta(seconds=61)))
206
+ assert sender.stats["buffer_size"] == 2
207
+
208
+
209
+ def _capture_uploads(auth, server) -> list[list[str]]:
210
+ uploads: list[list[str]] = []
211
+
212
+ def handler(request: httpx.Request) -> httpx.Response:
213
+ if request.url.path.endswith("/packets"):
214
+ packets = json.loads(request.content)["packets"]
215
+ uploads.append([p["deviceAddress"] for p in packets])
216
+ return httpx.Response(202)
217
+ return server.handler(request)
218
+
219
+ auth._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
220
+ return uploads
221
+
222
+
223
+ async def test_duplicate_dropped_across_flush(auth, server):
224
+ server.register_config = {"dedupWindowMs": 60_000}
225
+ assert await auth.ensure_authenticated()
226
+ uploads = _capture_uploads(auth, server)
227
+ sender = GatewaySender(auth)
228
+
229
+ t0 = datetime.now(timezone.utc)
230
+ sender.add_packet(_packet("AA", t0))
231
+ await sender._flush()
232
+ assert uploads == [["AA"]]
233
+
234
+ sender.add_packet(_packet("AA", t0 + timedelta(seconds=30)))
235
+ assert sender.stats["buffer_size"] == 0
236
+ assert sender.stats["dropped_duplicates"] == 1
237
+ await sender._flush()
238
+ assert uploads == [["AA"]]
239
+
240
+ sender.add_packet(_packet("AA", t0 + timedelta(seconds=61)))
241
+ await sender._flush()
242
+ assert uploads == [["AA"], ["AA"]]
243
+
244
+
245
+ async def test_dedup_keys_expire_by_age(auth):
246
+ sender = GatewaySender(auth, dedup_window_s=10.0)
247
+ t0 = datetime.now(timezone.utc)
248
+ for addr in ("A1", "A2", "A3"):
249
+ sender.add_packet(_packet(addr, t0))
250
+ assert sender.stats["dedup_keys"] == 3
251
+
252
+ sender.add_packet(_packet("B1", t0 + timedelta(seconds=11)))
253
+ assert sender.stats["dedup_keys"] == 1
254
+
255
+
256
+ async def test_dedup_keys_capped(auth, monkeypatch):
257
+ monkeypatch.setattr("hubble_gateway.sender.MAX_DEDUP_KEYS", 3)
258
+ sender = GatewaySender(auth, dedup_window_s=600.0)
259
+ t0 = datetime.now(timezone.utc)
260
+ for addr in ("A1", "A2", "A3", "A4", "A5"):
261
+ sender.add_packet(_packet(addr, t0))
262
+ assert sender.stats["dedup_keys"] == 3
263
+ assert list(sender._window_start_ms) == [("A3", "FCA6", "01"), ("A4", "FCA6", "01"),
264
+ ("A5", "FCA6", "01")]
265
+
266
+
267
+ def test_default_upload_interval_is_60s(auth, monkeypatch):
268
+ monkeypatch.delenv("HUBBLE_UPLOAD_INTERVAL_SECONDS", raising=False)
269
+ assert Settings(_env_file=None).upload_interval_seconds == 60.0
270
+ assert GatewaySender(auth).upload_interval_s == 60.0
271
+
272
+
273
+ async def test_sender_flush_uses_server_batch_size(auth, server):
274
+ server.register_config = {"uploadBatchSize": 2}
275
+ assert await auth.ensure_authenticated()
276
+
277
+ batches: list[int] = []
278
+
279
+ def handler(request: httpx.Request) -> httpx.Response:
280
+ if request.url.path.endswith("/packets"):
281
+ batches.append(len(json.loads(request.content)["packets"]))
282
+ return httpx.Response(202)
283
+ return server.handler(request)
284
+
285
+ auth._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
286
+ sender = GatewaySender(auth, batch_size=500)
287
+ t0 = datetime.now(timezone.utc)
288
+ for addr in ("A1", "A2", "A3", "A4", "A5"):
289
+ sender.add_packet(_packet(addr, t0))
290
+ await sender._flush()
291
+ assert batches == [2, 2, 1]
@@ -1,3 +0,0 @@
1
- # hubble-gateway
2
-
3
- Python SDK for building Hubble Network BLE gateways. Import `hubble_gateway` in your application; this package is a library only (no CLI).