hubble-gateway-sdk-python 0.1.1__tar.gz → 0.2.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 (19) hide show
  1. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/.github/workflows/ci.yml +5 -15
  2. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/PKG-INFO +14 -1
  3. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/README.md +12 -0
  4. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/pyproject.toml +2 -1
  5. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/__init__.py +1 -1
  6. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/auth.py +49 -27
  7. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/config.py +8 -3
  8. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/location.py +3 -1
  9. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/models.py +2 -6
  10. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/scanner.py +84 -43
  11. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/sender.py +11 -4
  12. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/ubx.py +26 -14
  13. hubble_gateway_sdk_python-0.2.0/tests/test_payload_service_uuids.py +305 -0
  14. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/tests/test_server_config.py +17 -6
  15. hubble_gateway_sdk_python-0.1.1/tests/test_scan_service_uuids.py +0 -156
  16. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/.github/workflows/pypi.yml +0 -0
  17. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/.gitignore +0 -0
  18. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/LICENSE +0 -0
  19. {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/tests/__init__.py +0 -0
@@ -27,11 +27,12 @@ jobs:
27
27
  - name: Install
28
28
  run: python -m pip install -e ".[dev]"
29
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
30
+ - name: Ruff check
33
31
  run: ruff check .
34
32
 
33
+ - name: Ruff format
34
+ run: ruff format --check .
35
+
35
36
  test:
36
37
  runs-on: ubuntu-latest
37
38
  strategy:
@@ -50,16 +51,5 @@ jobs:
50
51
  - name: Install
51
52
  run: python -m pip install -e ".[dev]"
52
53
 
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
54
  - 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"
55
+ run: pytest -q
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hubble-gateway-sdk-python
3
- Version: 0.1.1
3
+ Version: 0.2.0
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
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3
17
17
  Classifier: Programming Language :: Python :: 3.10
18
18
  Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
20
21
  Classifier: Topic :: Home Automation
21
22
  Classifier: Topic :: System :: Networking
22
23
  Requires-Python: >=3.10
@@ -65,6 +66,18 @@ without a restart.
65
66
 
66
67
  The local default upload interval is 60 s, the same as the server's floor.
67
68
 
69
+ ## Scanning
70
+
71
+ `Scanner` always scans for the Hubble service `FCA6`, plus any configured
72
+ payload services.
73
+
74
+ - One advertisement gives at most one uploaded sighting: the first service, in
75
+ priority order (`FCA6` first), that has non-empty service data.
76
+ - `stats["multi_payload_advertisements"]` counts advertisements that carried
77
+ service data for more than one payload service.
78
+ - `Scanner(service_uuids=[...])` adds scan-only services. Matching
79
+ advertisements reach the `on_advertisement` callback and are not uploaded.
80
+
68
81
  ## Deduplication
69
82
 
70
83
  The dedup key is `(device address, 16-bit service UUID, service data hex)`.
@@ -24,6 +24,18 @@ without a restart.
24
24
 
25
25
  The local default upload interval is 60 s, the same as the server's floor.
26
26
 
27
+ ## Scanning
28
+
29
+ `Scanner` always scans for the Hubble service `FCA6`, plus any configured
30
+ payload services.
31
+
32
+ - One advertisement gives at most one uploaded sighting: the first service, in
33
+ priority order (`FCA6` first), that has non-empty service data.
34
+ - `stats["multi_payload_advertisements"]` counts advertisements that carried
35
+ service data for more than one payload service.
36
+ - `Scanner(service_uuids=[...])` adds scan-only services. Matching
37
+ advertisements reach the `on_advertisement` callback and are not uploaded.
38
+
27
39
  ## Deduplication
28
40
 
29
41
  The dedup key is `(device address, 16-bit service UUID, service data hex)`.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hubble-gateway-sdk-python"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Python SDK for building Hubble Network BLE gateways"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -19,6 +19,7 @@ classifiers = [
19
19
  "Programming Language :: Python :: 3.10",
20
20
  "Programming Language :: Python :: 3.11",
21
21
  "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
22
23
  "Topic :: Home Automation",
23
24
  "Topic :: System :: Networking",
24
25
  ]
@@ -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.1"
8
+ __version__ = "0.2.0"
9
9
 
10
10
  from hubble_gateway.auth import GatewayAuth
11
11
  from hubble_gateway.config import (
@@ -13,7 +13,7 @@ import structlog
13
13
 
14
14
  from hubble_gateway import __version__
15
15
  from hubble_gateway.config import HUBBLE_SERVICE_UUID
16
- from hubble_gateway.models import is_bluetooth_base_uuid, normalize_service_uuid
16
+ from hubble_gateway.models import is_bluetooth_base_uuid, normalize_service_uuid, uuid_to_16bit
17
17
 
18
18
  logger = structlog.get_logger(__name__)
19
19
 
@@ -29,25 +29,29 @@ SERVER_CONFIG_RANGES: dict[str, tuple[int, int]] = {
29
29
  "dedupWindowMs": (5_000, 3_600_000),
30
30
  }
31
31
 
32
- MAX_SCAN_SERVICE_UUIDS = 16
32
+ MAX_PAYLOAD_SERVICE_UUIDS = 8
33
+ _HUBBLE_SERVICE_16 = uuid_to_16bit(HUBBLE_SERVICE_UUID)
33
34
 
34
35
 
35
- def _sanitize_scan_service_uuids(value: Any) -> list[str] | None:
36
- """Normalize a UUID list to uppercase 128-bit UUIDs, FCA6 first.
36
+ def _sanitize_payload_service_uuids(value: Any) -> list[str] | None:
37
+ """Normalize a payloadServiceUuids list to ordered 16-bit forms (``FEED``).
37
38
 
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.
39
+ Accepts the short form or the full form on the Bluetooth Base UUID, in any
40
+ case. Drops invalid entries and FCA6 (always scanned), dedupes, and keeps
41
+ the first MAX_PAYLOAD_SERVICE_UUIDS. Returns None when the value is not a
42
+ list.
42
43
  """
43
44
  if not isinstance(value, list):
44
45
  return None
45
- result = [HUBBLE_SERVICE_UUID]
46
+ result: list[str] = []
46
47
  for item in value:
47
48
  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]
49
+ if u is None or not is_bluetooth_base_uuid(u):
50
+ continue
51
+ short = uuid_to_16bit(u)
52
+ if short != _HUBBLE_SERVICE_16 and short not in result:
53
+ result.append(short)
54
+ return result[:MAX_PAYLOAD_SERVICE_UUIDS]
51
55
 
52
56
 
53
57
  def sanitize_server_config(config: Any) -> dict[str, Any]:
@@ -59,9 +63,10 @@ def sanitize_server_config(config: Any) -> dict[str, Any]:
59
63
  if not isinstance(config, dict):
60
64
  return {}
61
65
  result: dict[str, Any] = {}
62
- uuids = _sanitize_scan_service_uuids(config.get("scanServiceUuids"))
66
+ # The legacy scanServiceUuids key is ignored.
67
+ uuids = _sanitize_payload_service_uuids(config.get("payloadServiceUuids"))
63
68
  if uuids is not None:
64
- result["scanServiceUuids"] = uuids
69
+ result["payloadServiceUuids"] = uuids
65
70
  for key, (low, high) in SERVER_CONFIG_RANGES.items():
66
71
  value = config.get(key)
67
72
  if isinstance(value, bool) or not isinstance(value, (int, float)):
@@ -202,7 +207,9 @@ class GatewayAuth:
202
207
  payload = _build_registration_payload()
203
208
  logger.info("Registering gateway", base_url=self._base_url)
204
209
  resp = await self._request(
205
- "PUT", "/api/v1/gateways", json_body=payload,
210
+ "PUT",
211
+ "/api/v1/gateways",
212
+ json_body=payload,
206
213
  extra_headers={"X-Sdk-Key": self._sdk_key},
207
214
  )
208
215
  if resp.status_code not in (200, 201):
@@ -228,7 +235,8 @@ class GatewayAuth:
228
235
  async def _refresh(self) -> bool:
229
236
  logger.info("Refreshing device token")
230
237
  resp = await self._request(
231
- "POST", "/api/v1/auth/refresh",
238
+ "POST",
239
+ "/api/v1/auth/refresh",
232
240
  json_body={"refreshToken": self._refresh_token},
233
241
  )
234
242
  if resp.status_code != 200:
@@ -253,15 +261,22 @@ class GatewayAuth:
253
261
  return
254
262
  payload = _build_registration_payload()
255
263
  resp = await self.authenticated_request(
256
- "PUT", f"/api/v1/gateways/{self._gateway_id}", json_body=payload,
264
+ "PUT",
265
+ f"/api/v1/gateways/{self._gateway_id}",
266
+ json_body=payload,
257
267
  )
258
268
  if resp.status_code == 200:
259
269
  self._apply_server_config(resp.json())
260
270
  self._persist_tokens()
261
271
 
262
272
  async def _request(
263
- self, method: str, path: str, json_body: dict[str, Any] | None = None,
264
- *, authenticated: bool = False, extra_headers: dict[str, str] | None = None,
273
+ self,
274
+ method: str,
275
+ path: str,
276
+ json_body: dict[str, Any] | None = None,
277
+ *,
278
+ authenticated: bool = False,
279
+ extra_headers: dict[str, str] | None = None,
265
280
  ) -> httpx.Response:
266
281
  assert self._client is not None, "call start() first"
267
282
  headers: dict[str, str] = {}
@@ -270,7 +285,10 @@ class GatewayAuth:
270
285
  if extra_headers:
271
286
  headers.update(extra_headers)
272
287
  return await self._client.request(
273
- method, f"{self._base_url}{path}", json=json_body, headers=headers,
288
+ method,
289
+ f"{self._base_url}{path}",
290
+ json=json_body,
291
+ headers=headers,
274
292
  )
275
293
 
276
294
  def _apply_server_config(self, data: Any) -> None:
@@ -283,13 +301,17 @@ class GatewayAuth:
283
301
 
284
302
  def _persist_tokens(self) -> None:
285
303
  self._token_path.parent.mkdir(parents=True, exist_ok=True)
286
- self._token_path.write_text(json.dumps({
287
- "gatewayId": self._gateway_id,
288
- "deviceToken": self._device_token,
289
- "refreshToken": self._refresh_token,
290
- "expiresAtMs": self._expires_at_ms,
291
- "serverConfig": self.server_config,
292
- }))
304
+ self._token_path.write_text(
305
+ json.dumps(
306
+ {
307
+ "gatewayId": self._gateway_id,
308
+ "deviceToken": self._device_token,
309
+ "refreshToken": self._refresh_token,
310
+ "expiresAtMs": self._expires_at_ms,
311
+ "serverConfig": self.server_config,
312
+ }
313
+ )
314
+ )
293
315
  self._token_path.chmod(0o600)
294
316
 
295
317
  def _load_persisted_tokens(self) -> None:
@@ -5,7 +5,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
5
5
 
6
6
  HUBBLE_SERVICE_UUID = "0000FCA6-0000-1000-8000-00805F9B34FB"
7
7
  TILE_SERVICE_UUID = "0000FEED-0000-1000-8000-00805F9B34FB"
8
- DEFAULT_SERVICE_UUIDS = [HUBBLE_SERVICE_UUID, TILE_SERVICE_UUID]
8
+ # FCA6 is always scanned. Other payload services come from the
9
+ # payloadServiceUuids config key, so the compiled-in default is FCA6 only.
10
+ DEFAULT_SERVICE_UUIDS = [HUBBLE_SERVICE_UUID]
9
11
 
10
12
  GATEWAY_API_BASE_URL = "https://gw-api.hubble.com"
11
13
 
@@ -38,8 +40,11 @@ class Settings(BaseSettings):
38
40
 
39
41
  # BLE scanning
40
42
  service_uuids: list[str] = Field(
41
- default=DEFAULT_SERVICE_UUIDS,
42
- description="BLE service UUIDs to scan for",
43
+ default_factory=list,
44
+ description=(
45
+ "Extra BLE service UUIDs to scan for. Scan-only: they reach "
46
+ "on_advertisement listeners and are not uploaded."
47
+ ),
43
48
  )
44
49
  scan_duration_seconds: float = Field(
45
50
  default=5.0,
@@ -150,7 +150,8 @@ class SerialGPSLocationProvider(LocationProvider):
150
150
  import serial_asyncio
151
151
 
152
152
  self._reader, self._writer = await serial_asyncio.open_serial_connection(
153
- url=self._port, baudrate=self._baud_rate,
153
+ url=self._port,
154
+ baudrate=self._baud_rate,
154
155
  )
155
156
  self._running = True
156
157
  self._location.gps_state = "searching"
@@ -331,6 +332,7 @@ class GPSDLocationProvider(LocationProvider):
331
332
  async def start(self) -> None:
332
333
  try:
333
334
  import gpsd
335
+
334
336
  gpsd.connect()
335
337
  self._running = True
336
338
  logger.info("GPSD location provider started")
@@ -10,9 +10,7 @@ from pydantic import BaseModel, Field
10
10
  BLUETOOTH_BASE_UUID_SUFFIX = "-0000-1000-8000-00805F9B34FB"
11
11
 
12
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
- )
13
+ _UUID128_PATTERN = re.compile(r"^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$")
16
14
 
17
15
 
18
16
  def normalize_service_uuid(value: str) -> str | None:
@@ -130,9 +128,7 @@ class BLEPacket(BaseModel):
130
128
  ) -> dict[str, Any]:
131
129
  """Convert to gateway-api packet format."""
132
130
  payload_bytes = self.get_matched_service_data()
133
- service_data_b64 = (
134
- base64.b64encode(payload_bytes).decode() if payload_bytes else None
135
- )
131
+ service_data_b64 = base64.b64encode(payload_bytes).decode() if payload_bytes else None
136
132
  ts_ms = int(self.timestamp.timestamp() * 1000)
137
133
 
138
134
  packet: dict[str, Any] = {
@@ -10,8 +10,8 @@ from bleak import BleakScanner
10
10
  from bleak.backends.device import BLEDevice
11
11
  from bleak.backends.scanner import AdvertisementData
12
12
 
13
- from hubble_gateway.config import DEFAULT_SERVICE_UUIDS
14
- from hubble_gateway.models import BLEPacket, is_bluetooth_base_uuid, uuid_to_16bit
13
+ from hubble_gateway.config import HUBBLE_SERVICE_UUID
14
+ from hubble_gateway.models import BLEPacket, normalize_service_uuid, uuid_to_16bit
15
15
 
16
16
  if TYPE_CHECKING:
17
17
  from hubble_gateway.auth import GatewayAuth
@@ -19,12 +19,38 @@ if TYPE_CHECKING:
19
19
  logger = structlog.get_logger(__name__)
20
20
 
21
21
 
22
+ def _normalize_all(uuids: list[str]) -> list[str]:
23
+ """Lowercase 128-bit forms of the valid UUIDs, in order."""
24
+ result = []
25
+ for u in uuids:
26
+ full = normalize_service_uuid(u) if isinstance(u, str) else None
27
+ if full is not None:
28
+ result.append(full.lower())
29
+ return result
30
+
31
+
32
+ def _dedupe(uuids: list[str]) -> list[str]:
33
+ return list(dict.fromkeys(uuids))
34
+
35
+
22
36
  class Scanner:
23
37
  """Continuous BLE scanner that filters by service UUID(s).
24
38
 
25
- Supports scanning for multiple UUIDs simultaneously (e.g. Hubble FCA6
26
- and Tile FEED). Works with both internal BLE adapters and external USB
27
- dongles — pass ``adapter='hci1'`` to select a specific adapter.
39
+ Service classes:
40
+
41
+ - Hubble ``FCA6``: always scanned; service data is a payload.
42
+ - Payload services: the ``payloadServiceUuids`` list in
43
+ ``auth.server_config`` (0 to 8, ordered); service data is a payload.
44
+ - ``service_uuids``: scan-only; matching advertisements reach
45
+ ``on_advertisement`` but are not payloads.
46
+
47
+ One advertisement gives at most one payload, passed to ``on_packet``: the
48
+ first UUID with non-empty service data, in priority order ``FCA6`` then
49
+ the payload list. An advertisement with service data for more than one
50
+ payload UUID is counted in ``stats["multi_payload_advertisements"]``.
51
+
52
+ Works with both internal BLE adapters and external USB dongles — pass
53
+ ``adapter='hci1'`` to select a specific adapter.
28
54
  """
29
55
 
30
56
  def __init__(
@@ -34,19 +60,22 @@ class Scanner:
34
60
  on_packet: Callable[[BLEPacket], None] | None = None,
35
61
  adapter: str | None = None,
36
62
  auth: "GatewayAuth | None" = None,
63
+ on_advertisement: Callable[[BLEPacket], None] | None = None,
37
64
  ) -> None:
38
- self._local_service_uuids = list(service_uuids or DEFAULT_SERVICE_UUIDS)
65
+ self._scan_only_uuids = _normalize_all(service_uuids or [])
39
66
  self._auth = auth
67
+ self._payload_uuids: list[str] = []
40
68
  self._service_uuids: list[str] = []
41
- self._uuid_variants: dict[str, str] = {}
42
69
  self._scan_duration = scan_duration
43
70
  self._on_packet = on_packet
71
+ self._on_advertisement = on_advertisement
44
72
  self._adapter = adapter or None
45
73
  self._running = False
46
74
  self._paused = False
47
75
  self._seen_devices: set[str] = set()
48
76
  self._devices_this_scan: set[str] = set()
49
77
  self._total_packets = 0
78
+ self._multi_payload_advertisements = 0
50
79
  self._refresh_service_uuids()
51
80
 
52
81
  @property
@@ -54,49 +83,53 @@ class Scanner:
54
83
  """The UUIDs the current scan cycle filters on (lowercase 128-bit)."""
55
84
  return list(self._service_uuids)
56
85
 
57
- def _effective_service_uuids(self) -> list[str]:
86
+ @property
87
+ def payload_service_uuids(self) -> list[str]:
88
+ """Payload UUIDs in priority order, ``FCA6`` first (lowercase 128-bit)."""
89
+ return list(self._payload_uuids)
90
+
91
+ def _effective_payload_uuids(self) -> list[str]:
92
+ configured: list[str] = []
58
93
  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
94
+ value = self._auth.server_config.get("payloadServiceUuids")
95
+ if isinstance(value, list):
96
+ configured = value
97
+ return _dedupe([HUBBLE_SERVICE_UUID.lower(), *_normalize_all(configured)])
63
98
 
64
99
  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:
100
+ """Adopt the effective UUID lists; return True if they changed."""
101
+ payload = self._effective_payload_uuids()
102
+ scan = _dedupe([*payload, *self._scan_only_uuids])
103
+ if payload == self._payload_uuids and scan == self._service_uuids:
68
104
  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
105
+ self._payload_uuids = payload
106
+ self._service_uuids = scan
78
107
  return True
79
108
 
80
- def _match_service_uuid(self, advertised_uuids: list[str]) -> str | None:
81
- for adv_uuid in advertised_uuids:
82
- key = adv_uuid.lower()
83
- if key in self._uuid_variants:
84
- return self._uuid_variants[key]
85
- return None
86
-
87
- def _detection_callback(
88
- self, device: BLEDevice, advertisement_data: AdvertisementData
89
- ) -> None:
109
+ def _detection_callback(self, device: BLEDevice, advertisement_data: AdvertisementData) -> None:
90
110
  adv_uuids = [str(u).lower() for u in advertisement_data.service_uuids or []]
91
- matched_uuid = self._match_service_uuid(adv_uuids)
111
+ raw_service_data = advertisement_data.service_data or {}
112
+ service_data: dict[str, bytes] = {}
113
+ for k, v in raw_service_data.items():
114
+ u = normalize_service_uuid(str(k))
115
+ if u is not None:
116
+ service_data[u.lower()] = v
117
+
118
+ with_payload = [u for u in self._payload_uuids if service_data.get(u)]
119
+ if len(with_payload) > 1:
120
+ self._multi_payload_advertisements += 1
121
+ payload_uuid = with_payload[0] if with_payload else None
122
+
123
+ matched_uuid = payload_uuid
124
+ if matched_uuid is None:
125
+ present = set(_normalize_all(adv_uuids)) | set(service_data)
126
+ matched_uuid = next((u for u in self._service_uuids if u in present), None)
92
127
  if matched_uuid is None:
93
128
  return
94
129
 
95
130
  addr = device.address
96
- if addr not in self._seen_devices:
97
- self._seen_devices.add(addr)
131
+ self._seen_devices.add(addr)
98
132
  self._devices_this_scan.add(addr)
99
- self._total_packets += 1
100
133
 
101
134
  packet = BLEPacket(
102
135
  device_address=addr,
@@ -104,16 +137,22 @@ class Scanner:
104
137
  rssi=advertisement_data.rssi,
105
138
  service_uuids=adv_uuids,
106
139
  manufacturer_data=dict(advertisement_data.manufacturer_data or {}),
107
- service_data={str(k): v for k, v in (advertisement_data.service_data or {}).items()},
140
+ service_data={str(k): v for k, v in raw_service_data.items()},
108
141
  tx_power=advertisement_data.tx_power,
109
142
  timestamp=datetime.now(timezone.utc),
110
143
  service_uuid=matched_uuid,
111
144
  )
112
145
 
113
- short = uuid_to_16bit(matched_uuid)
146
+ if self._on_advertisement:
147
+ self._on_advertisement(packet)
148
+
149
+ if payload_uuid is None:
150
+ return
151
+
152
+ self._total_packets += 1
114
153
  logger.debug(
115
154
  "BLE packet",
116
- uuid=short,
155
+ uuid=uuid_to_16bit(payload_uuid),
117
156
  addr=addr[-8:],
118
157
  rssi=packet.rssi,
119
158
  name=packet.device_name,
@@ -124,10 +163,10 @@ class Scanner:
124
163
 
125
164
  async def start(self) -> None:
126
165
  self._running = True
127
- uuid_labels = [uuid_to_16bit(u) for u in self._service_uuids]
128
166
  logger.info(
129
167
  "Scanner started",
130
- service_uuids=uuid_labels,
168
+ payload_service_uuids=[uuid_to_16bit(u) for u in self._payload_uuids],
169
+ service_uuids=[uuid_to_16bit(u) for u in self._service_uuids],
131
170
  scan_duration=self._scan_duration,
132
171
  adapter=self._adapter or "default",
133
172
  )
@@ -139,6 +178,7 @@ class Scanner:
139
178
  if self._refresh_service_uuids():
140
179
  logger.info(
141
180
  "Scan UUIDs changed",
181
+ payload_service_uuids=[uuid_to_16bit(u) for u in self._payload_uuids],
142
182
  service_uuids=[uuid_to_16bit(u) for u in self._service_uuids],
143
183
  )
144
184
  scanner_kwargs: dict = {
@@ -184,4 +224,5 @@ class Scanner:
184
224
  "total_packets": self._total_packets,
185
225
  "unique_devices": len(self._seen_devices),
186
226
  "active_devices": len(self._devices_this_scan),
227
+ "multi_payload_advertisements": self._multi_payload_advertisements,
187
228
  }
@@ -96,7 +96,9 @@ class GatewaySender:
96
96
  self._stats_total += 1
97
97
  self._stats_devices.add(packet.device_address)
98
98
  key: _DedupKey = (
99
- packet.device_address, packet.service_uuid_16bit, packet.service_data_hex,
99
+ packet.device_address,
100
+ packet.service_uuid_16bit,
101
+ packet.service_data_hex,
100
102
  )
101
103
  now_ms = int(packet.timestamp.timestamp() * 1000)
102
104
  window_ms = self.dedup_window_s * 1000
@@ -206,7 +208,8 @@ class GatewaySender:
206
208
  packets_json = []
207
209
  for entry in entries:
208
210
  pkt = entry.packet.to_gateway_packet(
209
- location=location, sighting_count=entry.sighting_count,
211
+ location=location,
212
+ sighting_count=entry.sighting_count,
210
213
  )
211
214
  pkt["rssi"] = entry.strongest_rssi
212
215
  packets_json.append(pkt)
@@ -219,7 +222,9 @@ class GatewaySender:
219
222
 
220
223
  try:
221
224
  resp = await self._auth.authenticated_request(
222
- "POST", f"/api/v1/gateways/{gateway_id}/packets", json_body=body,
225
+ "POST",
226
+ f"/api/v1/gateways/{gateway_id}/packets",
227
+ json_body=body,
223
228
  )
224
229
  if resp.status_code in (200, 201, 202):
225
230
  self._stats_sends += 1
@@ -247,7 +252,9 @@ class GatewaySender:
247
252
  }
248
253
  try:
249
254
  await self._auth.authenticated_request(
250
- "POST", f"/api/v1/gateways/{gateway_id}/locations", json_body=body,
255
+ "POST",
256
+ f"/api/v1/gateways/{gateway_id}/locations",
257
+ json_body=body,
251
258
  )
252
259
  except Exception as e:
253
260
  logger.warning("Location upload failed", error=str(e))
@@ -80,15 +80,28 @@ def _parse_nav_pvt_payload(payload: bytes) -> UBXNavPVT:
80
80
  pdop_raw = struct.unpack_from("<H", payload, 76)[0]
81
81
 
82
82
  return UBXNavPVT(
83
- itow_ms=itow_ms, year=year, month=month, day=day,
84
- hour=hour, minute=minute, second=second, valid=valid,
85
- valid_date=bool(valid & 0x01), valid_time=bool(valid & 0x02),
86
- fix_type=fix_type, flags=flags, gnss_fix_ok=bool(flags & 0x01),
83
+ itow_ms=itow_ms,
84
+ year=year,
85
+ month=month,
86
+ day=day,
87
+ hour=hour,
88
+ minute=minute,
89
+ second=second,
90
+ valid=valid,
91
+ valid_date=bool(valid & 0x01),
92
+ valid_time=bool(valid & 0x02),
93
+ fix_type=fix_type,
94
+ flags=flags,
95
+ gnss_fix_ok=bool(flags & 0x01),
87
96
  num_sv=num_sv,
88
- lat=lat_i / 1e7, lon=lon_i / 1e7,
89
- height_ellipsoid_m=height_mm / 1000.0, alt_msl_m=hmsl_mm / 1000.0,
90
- hacc_m=hacc_mm / 1000.0, vacc_m=vacc_mm / 1000.0,
91
- ground_speed_ms=g_speed_mm_s / 1000.0, heading_deg=head_mot / 1e5,
97
+ lat=lat_i / 1e7,
98
+ lon=lon_i / 1e7,
99
+ height_ellipsoid_m=height_mm / 1000.0,
100
+ alt_msl_m=hmsl_mm / 1000.0,
101
+ hacc_m=hacc_mm / 1000.0,
102
+ vacc_m=vacc_mm / 1000.0,
103
+ ground_speed_ms=g_speed_mm_s / 1000.0,
104
+ heading_deg=head_mot / 1e5,
92
105
  pdop=pdop_raw / 100.0,
93
106
  )
94
107
 
@@ -107,7 +120,8 @@ class UBXReader:
107
120
  import serial_asyncio
108
121
 
109
122
  self._reader, self._writer = await serial_asyncio.open_serial_connection(
110
- url=self._port, baudrate=self._baud_rate,
123
+ url=self._port,
124
+ baudrate=self._baud_rate,
111
125
  )
112
126
  self._buf.clear()
113
127
  logger.info("UBX reader connected", port=self._port, baud_rate=self._baud_rate)
@@ -117,7 +131,7 @@ class UBXReader:
117
131
  """Enable UBX NAV-PVT output on UART (persisted to flash)."""
118
132
  if self._writer is None:
119
133
  return
120
- kv = struct.pack("<IB", 0x10750001, 1) # CFG-UART2OUTPROT-UBX = true
134
+ kv = struct.pack("<IB", 0x10750001, 1) # CFG-UART2OUTPROT-UBX = true
121
135
  kv += struct.pack("<IB", 0x20910008, 1) # CFG-MSGOUT-UBX_NAV_PVT_UART2 = 1
122
136
  payload = struct.pack("<BBH", 0x00, 0x07, 0x0000) + kv
123
137
  frame = _ubx_frame(0x06, 0x8A, payload)
@@ -185,7 +199,7 @@ class UBXReader:
185
199
  if remaining <= 0:
186
200
  return None
187
201
 
188
- body = bytes(self._buf[2:6 + payload_len])
202
+ body = bytes(self._buf[2 : 6 + payload_len])
189
203
  ck_a, ck_b = self._buf[6 + payload_len], self._buf[6 + payload_len + 1]
190
204
  exp_a, exp_b = _ubx_checksum(body)
191
205
 
@@ -211,9 +225,7 @@ class UBXReader:
211
225
  if self._reader is None:
212
226
  return None
213
227
  try:
214
- return await asyncio.wait_for(
215
- self._reader.read(max(max_bytes, 1)), timeout=timeout_s
216
- )
228
+ return await asyncio.wait_for(self._reader.read(max(max_bytes, 1)), timeout=timeout_s)
217
229
  except asyncio.TimeoutError:
218
230
  return None
219
231
 
@@ -0,0 +1,305 @@
1
+ import json
2
+ from types import SimpleNamespace
3
+
4
+ import httpx
5
+ import pytest
6
+
7
+ from hubble_gateway import scanner as scanner_mod
8
+ from hubble_gateway.auth import GatewayAuth, sanitize_server_config
9
+ from hubble_gateway.config import HUBBLE_SERVICE_UUID, TILE_SERVICE_UUID, Settings
10
+ from hubble_gateway.models import uuid_to_16bit
11
+ from hubble_gateway.scanner import Scanner
12
+
13
+ FCA6 = HUBBLE_SERVICE_UUID.lower()
14
+ FEED = TILE_SERVICE_UUID.lower()
15
+ CUSTOM = "12345678-ABCD-1234-ABCD-1234567890AB"
16
+ BASE_180F = "0000180f-0000-1000-8000-00805f9b34fb"
17
+ BASE_181A = "0000181a-0000-1000-8000-00805f9b34fb"
18
+
19
+
20
+ def _payload(raw):
21
+ return sanitize_server_config({"payloadServiceUuids": raw}).get("payloadServiceUuids")
22
+
23
+
24
+ # --- config parsing / validation / clamping ---------------------------------
25
+
26
+
27
+ @pytest.mark.parametrize(
28
+ ("raw", "expected"),
29
+ [
30
+ (["FEED"], ["FEED"]),
31
+ (["feed", "180f"], ["FEED", "180F"]),
32
+ ([], []),
33
+ (["FCA6"], []),
34
+ (["FCA6", "FEED", HUBBLE_SERVICE_UUID.lower()], ["FEED"]),
35
+ ([BASE_180F, "180F", "feed"], ["180F", "FEED"]),
36
+ ([BASE_180F.upper()], ["180F"]),
37
+ ([CUSTOM, CUSTOM.lower()], []),
38
+ (["00010000-0000-1000-8000-00805F9B34FB"], []),
39
+ (["FEED", "nope", 7, None, "FCA", "FEEDD", "", "FEED"], ["FEED"]),
40
+ ([" feed "], ["FEED"]),
41
+ ],
42
+ )
43
+ def test_payload_list_normalized(raw, expected):
44
+ assert _payload(raw) == expected
45
+
46
+
47
+ def test_payload_list_order_kept():
48
+ assert _payload(["181A", "FEED", "180F"]) == ["181A", "FEED", "180F"]
49
+
50
+
51
+ def test_payload_list_capped_at_8_after_filtering():
52
+ raw = ["FCA6", "nope"] + [f"{i:04X}" for i in range(1, 20)]
53
+ assert _payload(raw) == [f"{i:04X}" for i in range(1, 9)]
54
+
55
+
56
+ @pytest.mark.parametrize("raw", [None, "FCA6,FEED", {"a": 1}, 5])
57
+ def test_payload_list_wrong_type_dropped(raw):
58
+ assert "payloadServiceUuids" not in sanitize_server_config({"payloadServiceUuids": raw})
59
+
60
+
61
+ def test_legacy_scan_service_uuids_ignored():
62
+ cfg = sanitize_server_config({"scanServiceUuids": ["FCA6", "FEED"], "uploadBatchSize": 10})
63
+ assert cfg == {"uploadBatchSize": 10}
64
+
65
+
66
+ def test_legacy_key_alongside_new_key():
67
+ cfg = sanitize_server_config(
68
+ {"scanServiceUuids": ["FCA6", "180F"], "payloadServiceUuids": ["FEED"]}
69
+ )
70
+ assert cfg == {"payloadServiceUuids": ["FEED"]}
71
+
72
+
73
+ def test_uuid_to_16bit():
74
+ assert uuid_to_16bit(FCA6) == "FCA6"
75
+ assert uuid_to_16bit(CUSTOM.lower()) == CUSTOM
76
+
77
+
78
+ def test_settings_default_has_no_scan_only_uuids():
79
+ assert Settings(_env_file=None).service_uuids == []
80
+
81
+
82
+ # --- scanner: one payload sighting per advertisement ------------------------
83
+
84
+
85
+ def _auth(config=None):
86
+ return SimpleNamespace(server_config=config if config is not None else {})
87
+
88
+
89
+ def _adv(service_data: dict, service_uuids: list | None = None):
90
+ device = SimpleNamespace(address="AA:BB:CC:DD:EE:FF", name=None)
91
+ adv = SimpleNamespace(
92
+ service_uuids=list(service_data) if service_uuids is None else service_uuids,
93
+ service_data=service_data,
94
+ manufacturer_data={},
95
+ rssi=-50,
96
+ tx_power=None,
97
+ local_name=None,
98
+ )
99
+ return device, adv
100
+
101
+
102
+ def _scanner(payload=None, scan_only=None):
103
+ packets, adverts = [], []
104
+ cfg = {} if payload is None else {"payloadServiceUuids": payload}
105
+ s = Scanner(
106
+ service_uuids=scan_only,
107
+ on_packet=packets.append,
108
+ on_advertisement=adverts.append,
109
+ auth=_auth(cfg),
110
+ )
111
+ return s, packets, adverts
112
+
113
+
114
+ def test_fca6_always_scanned_without_config():
115
+ s, _, _ = _scanner()
116
+ assert s.payload_service_uuids == [FCA6]
117
+ assert s.service_uuids == [FCA6]
118
+ assert Scanner().service_uuids == [FCA6]
119
+
120
+
121
+ def test_filter_is_fca6_then_payload_then_scan_only_deduped():
122
+ s, _, _ = _scanner(payload=["FEED", "180F"], scan_only=["180F", CUSTOM, "fca6"])
123
+ assert s.payload_service_uuids == [FCA6, FEED, BASE_180F]
124
+ assert s.service_uuids == [FCA6, FEED, BASE_180F, CUSTOM.lower()]
125
+
126
+
127
+ def test_fca6_only_advert_emits_one():
128
+ s, packets, _ = _scanner(payload=["FEED"])
129
+ s._detection_callback(*_adv({FCA6: b"\x01"}))
130
+ assert [p.to_gateway_packet()["serviceUuid"] for p in packets] == ["FCA6"]
131
+ assert s.stats["multi_payload_advertisements"] == 0
132
+
133
+
134
+ def test_payload_uuid_emits():
135
+ s, packets, _ = _scanner(payload=["FEED"])
136
+ s._detection_callback(*_adv({FEED: b"\x02"}))
137
+ gp = packets[0].to_gateway_packet()
138
+ assert gp["serviceUuid"] == "FEED"
139
+ assert gp["serviceData"] == "Ag=="
140
+
141
+
142
+ def test_two_payloads_in_one_advert_fca6_wins_and_is_counted():
143
+ s, packets, _ = _scanner(payload=["FEED"])
144
+ s._detection_callback(*_adv({FEED: b"\x02", FCA6: b"\x01"}))
145
+ assert len(packets) == 1
146
+ assert packets[0].to_gateway_packet()["serviceUuid"] == "FCA6"
147
+ assert packets[0].to_gateway_packet()["serviceData"] == "AQ=="
148
+ assert s.stats["multi_payload_advertisements"] == 1
149
+
150
+
151
+ def test_two_payloads_list_order_wins():
152
+ s, packets, _ = _scanner(payload=["180F", "FEED"])
153
+ s._detection_callback(*_adv({FEED: b"\x02", BASE_180F: b"\x03"}))
154
+ assert [p.to_gateway_packet()["serviceUuid"] for p in packets] == ["180F"]
155
+ assert s.stats["multi_payload_advertisements"] == 1
156
+ assert s.stats["total_packets"] == 1
157
+
158
+
159
+ def test_empty_service_data_skipped_in_priority():
160
+ s, packets, _ = _scanner(payload=["FEED"])
161
+ s._detection_callback(*_adv({FCA6: b"", FEED: b"\x02"}))
162
+ assert [p.to_gateway_packet()["serviceUuid"] for p in packets] == ["FEED"]
163
+ assert s.stats["multi_payload_advertisements"] == 0
164
+
165
+
166
+ def test_listed_uuid_without_service_data_is_not_a_payload():
167
+ s, packets, adverts = _scanner(payload=["FEED"])
168
+ s._detection_callback(*_adv({}, service_uuids=[FEED]))
169
+ assert packets == []
170
+ assert len(adverts) == 1
171
+
172
+
173
+ def test_payload_not_in_list_is_dropped():
174
+ s, packets, adverts = _scanner(payload=["FEED"])
175
+ s._detection_callback(*_adv({BASE_180F: b"\x03"}))
176
+ assert packets == [] and adverts == []
177
+
178
+
179
+ def test_service_data_only_advert_matches():
180
+ s, packets, _ = _scanner(payload=["FEED"])
181
+ s._detection_callback(*_adv({FEED: b"\x02"}, service_uuids=[]))
182
+ assert [p.to_gateway_packet()["serviceUuid"] for p in packets] == ["FEED"]
183
+
184
+
185
+ def test_scan_only_reaches_listener_not_upload():
186
+ s, packets, adverts = _scanner(payload=["FEED"], scan_only=["180F"])
187
+ s._detection_callback(*_adv({BASE_180F: b"\x03"}))
188
+ assert packets == []
189
+ assert [a.service_uuid for a in adverts] == [BASE_180F]
190
+ assert s.stats["total_packets"] == 0
191
+
192
+
193
+ def test_scan_only_overlapping_payload_list_is_uploaded():
194
+ s, packets, adverts = _scanner(payload=["180F"], scan_only=["180F"])
195
+ s._detection_callback(*_adv({BASE_180F: b"\x03"}))
196
+ assert [p.to_gateway_packet()["serviceUuid"] for p in packets] == ["180F"]
197
+ assert len(adverts) == 1
198
+
199
+
200
+ def test_empty_list_means_fca6_only():
201
+ s, packets, _ = _scanner(payload=[])
202
+ s._detection_callback(*_adv({FEED: b"\x02"}))
203
+ s._detection_callback(*_adv({FCA6: b"\x01"}))
204
+ assert [p.to_gateway_packet()["serviceUuid"] for p in packets] == ["FCA6"]
205
+
206
+
207
+ def test_legacy_scan_list_in_scanner_config_is_ignored():
208
+ s = Scanner(auth=_auth({"scanServiceUuids": [FCA6, FEED]}))
209
+ assert s.service_uuids == [FCA6]
210
+
211
+
212
+ # --- config arrival ---------------------------------------------------------
213
+
214
+
215
+ async def test_scanner_applies_new_list_next_cycle(monkeypatch):
216
+ auth = _auth()
217
+ calls: list[list[str]] = []
218
+ s = Scanner(scan_duration=0, auth=auth)
219
+
220
+ class FakeBleak:
221
+ def __init__(self, **kwargs):
222
+ calls.append(list(kwargs["service_uuids"]))
223
+
224
+ async def __aenter__(self):
225
+ if len(calls) == 1:
226
+ auth.server_config = {"payloadServiceUuids": ["FEED", "180F"]}
227
+ elif len(calls) == 2:
228
+ auth.server_config = {}
229
+ else:
230
+ s._running = False
231
+ return self
232
+
233
+ async def __aexit__(self, *exc):
234
+ return False
235
+
236
+ monkeypatch.setattr(scanner_mod, "BleakScanner", FakeBleak)
237
+ await s.start()
238
+ assert calls == [[FCA6], [FCA6, FEED, BASE_180F], [FCA6]]
239
+
240
+
241
+ async def test_auth_three_case_rule(tmp_path):
242
+ bodies = {"heartbeat": {}}
243
+
244
+ def handler(request: httpx.Request) -> httpx.Response:
245
+ if request.url.path == "/api/v1/gateways":
246
+ return httpx.Response(
247
+ 201,
248
+ json={
249
+ "gatewayId": "gw-1",
250
+ "deviceToken": "dt",
251
+ "refreshToken": "rt",
252
+ "expiresInSeconds": 3600,
253
+ "config": {"payloadServiceUuids": ["FEED", "180F"]},
254
+ },
255
+ )
256
+ return httpx.Response(200, json=bodies["heartbeat"])
257
+
258
+ token_path = tmp_path / "a.json"
259
+ auth = GatewayAuth("k", "https://gw.test", token_path=str(token_path))
260
+ auth._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
261
+ assert await auth.ensure_authenticated()
262
+ s = Scanner(auth=auth)
263
+ assert s.payload_service_uuids == [FCA6, FEED, BASE_180F]
264
+ assert json.loads(token_path.read_text())["serverConfig"]["payloadServiceUuids"] == [
265
+ "FEED",
266
+ "180F",
267
+ ]
268
+
269
+ # No "config" key: keep the current list.
270
+ await auth.heartbeat()
271
+ assert auth.server_config["payloadServiceUuids"] == ["FEED", "180F"]
272
+
273
+ # Object without the field: FCA6 only.
274
+ bodies["heartbeat"] = {"config": {"uploadBatchSize": 10}}
275
+ await auth.heartbeat()
276
+ s._refresh_service_uuids()
277
+ assert s.payload_service_uuids == [FCA6]
278
+
279
+ # Object with the field again, then config: null clears it.
280
+ bodies["heartbeat"] = {"config": {"payloadServiceUuids": [BASE_181A.upper()]}}
281
+ await auth.heartbeat()
282
+ s._refresh_service_uuids()
283
+ assert s.payload_service_uuids == [FCA6, BASE_181A]
284
+
285
+ bodies["heartbeat"] = {"config": None}
286
+ await auth.heartbeat()
287
+ s._refresh_service_uuids()
288
+ assert s.payload_service_uuids == [FCA6]
289
+
290
+
291
+ def test_persisted_legacy_scan_list_dropped_on_load(tmp_path):
292
+ token_path = tmp_path / "a.json"
293
+ token_path.write_text(
294
+ json.dumps(
295
+ {
296
+ "gatewayId": "gw-1",
297
+ "serverConfig": {
298
+ "scanServiceUuids": [HUBBLE_SERVICE_UUID, TILE_SERVICE_UUID],
299
+ "payloadServiceUuids": ["feed", "FCA6"],
300
+ },
301
+ }
302
+ )
303
+ )
304
+ auth = GatewayAuth("k", "https://gw.test", token_path=str(token_path))
305
+ assert auth.server_config == {"payloadServiceUuids": ["FEED"]}
@@ -166,10 +166,18 @@ async def test_heartbeat_persists_config(auth, server, tmp_path):
166
166
 
167
167
  def test_persisted_stale_keys_dropped(tmp_path):
168
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
- }))
169
+ path.write_text(
170
+ json.dumps(
171
+ {
172
+ "gatewayId": "gw-1",
173
+ "serverConfig": {
174
+ "uploadIntervalMs": 1_000,
175
+ "scanIntervalMs": 5,
176
+ "featureFlags": {},
177
+ },
178
+ }
179
+ )
180
+ )
173
181
  a = GatewayAuth("sdk-key", "https://gw.test", token_path=str(path))
174
182
  assert a.server_config == {"uploadIntervalMs": 60_000}
175
183
 
@@ -260,8 +268,11 @@ async def test_dedup_keys_capped(auth, monkeypatch):
260
268
  for addr in ("A1", "A2", "A3", "A4", "A5"):
261
269
  sender.add_packet(_packet(addr, t0))
262
270
  assert sender.stats["dedup_keys"] == 3
263
- assert list(sender._window_start_ms) == [("A3", "FCA6", "01"), ("A4", "FCA6", "01"),
264
- ("A5", "FCA6", "01")]
271
+ assert list(sender._window_start_ms) == [
272
+ ("A3", "FCA6", "01"),
273
+ ("A4", "FCA6", "01"),
274
+ ("A5", "FCA6", "01"),
275
+ ]
265
276
 
266
277
 
267
278
  def test_default_upload_interval_is_60s(auth, monkeypatch):
@@ -1,156 +0,0 @@
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()]