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.
- hubble_gateway_sdk_python-0.1.1/.github/workflows/ci.yml +65 -0
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/PKG-INFO +38 -2
- hubble_gateway_sdk_python-0.1.1/README.md +39 -0
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/pyproject.toml +1 -1
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/__init__.py +1 -1
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/auth.py +65 -6
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/config.py +4 -4
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/models.py +35 -4
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/scanner.py +49 -16
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/sender.py +85 -34
- hubble_gateway_sdk_python-0.1.1/tests/test_scan_service_uuids.py +156 -0
- hubble_gateway_sdk_python-0.1.1/tests/test_server_config.py +291 -0
- hubble_gateway_sdk_python-0.1.0/README.md +0 -3
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/.github/workflows/pypi.yml +0 -0
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/.gitignore +0 -0
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/LICENSE +0 -0
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/location.py +0 -0
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/ubx.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: hubble-gateway-sdk-python
|
|
3
|
-
Version: 0.1.
|
|
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.
|
{hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/__init__.py
RENAMED
|
@@ -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.
|
|
8
|
+
__version__ = "0.1.1"
|
|
9
9
|
|
|
10
10
|
from hubble_gateway.auth import GatewayAuth
|
|
11
11
|
from hubble_gateway.config import (
|
{hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/auth.py
RENAMED
|
@@ -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:
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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))
|
{hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/config.py
RENAMED
|
@@ -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=
|
|
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
|
{hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/models.py
RENAMED
|
@@ -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
|
-
"""
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
{hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/scanner.py
RENAMED
|
@@ -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.
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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:
|
{hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/sender.py
RENAMED
|
@@ -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"
|
|
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 =
|
|
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.
|
|
47
|
-
self.
|
|
48
|
-
self.
|
|
49
|
-
|
|
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.
|
|
67
|
-
dedup_window=self.
|
|
68
|
-
batch_size=self.
|
|
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 = (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
"
|
|
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.
|
|
107
|
-
if not self.
|
|
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.
|
|
182
|
+
if not self._pending:
|
|
127
183
|
return
|
|
128
|
-
entries =
|
|
129
|
-
self.
|
|
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
|
-
|
|
139
|
-
|
|
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]
|
{hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/.github/workflows/pypi.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/location.py
RENAMED
|
File without changes
|
{hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.1.1}/src/hubble_gateway/ubx.py
RENAMED
|
File without changes
|
|
File without changes
|