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.
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/.github/workflows/ci.yml +5 -15
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/PKG-INFO +14 -1
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/README.md +12 -0
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/pyproject.toml +2 -1
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/__init__.py +1 -1
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/auth.py +49 -27
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/config.py +8 -3
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/location.py +3 -1
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/models.py +2 -6
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/scanner.py +84 -43
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/sender.py +11 -4
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/ubx.py +26 -14
- hubble_gateway_sdk_python-0.2.0/tests/test_payload_service_uuids.py +305 -0
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/tests/test_server_config.py +17 -6
- hubble_gateway_sdk_python-0.1.1/tests/test_scan_service_uuids.py +0 -156
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/.github/workflows/pypi.yml +0 -0
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/.gitignore +0 -0
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/LICENSE +0 -0
- {hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/tests/__init__.py +0 -0
{hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/.github/workflows/ci.yml
RENAMED
|
@@ -27,11 +27,12 @@ jobs:
|
|
|
27
27
|
- name: Install
|
|
28
28
|
run: python -m pip install -e ".[dev]"
|
|
29
29
|
|
|
30
|
-
|
|
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.
|
|
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.
|
|
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
|
]
|
{hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/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.
|
|
8
|
+
__version__ = "0.2.0"
|
|
9
9
|
|
|
10
10
|
from hubble_gateway.auth import GatewayAuth
|
|
11
11
|
from hubble_gateway.config import (
|
{hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/auth.py
RENAMED
|
@@ -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
|
-
|
|
32
|
+
MAX_PAYLOAD_SERVICE_UUIDS = 8
|
|
33
|
+
_HUBBLE_SERVICE_16 = uuid_to_16bit(HUBBLE_SERVICE_UUID)
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
def
|
|
36
|
-
"""Normalize a
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 = [
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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["
|
|
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",
|
|
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",
|
|
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",
|
|
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,
|
|
264
|
-
|
|
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,
|
|
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(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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:
|
{hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/config.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
42
|
-
description=
|
|
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,
|
{hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/location.py
RENAMED
|
@@ -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,
|
|
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")
|
{hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/models.py
RENAMED
|
@@ -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] = {
|
{hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/scanner.py
RENAMED
|
@@ -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
|
|
14
|
-
from hubble_gateway.models import BLEPacket,
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
if
|
|
61
|
-
|
|
62
|
-
return
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
}
|
{hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/sender.py
RENAMED
|
@@ -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,
|
|
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,
|
|
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",
|
|
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",
|
|
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))
|
{hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/ubx.py
RENAMED
|
@@ -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,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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,
|
|
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)
|
|
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"]}
|
{hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/tests/test_server_config.py
RENAMED
|
@@ -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(
|
|
170
|
-
|
|
171
|
-
|
|
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) == [
|
|
264
|
-
|
|
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()]
|
{hubble_gateway_sdk_python-0.1.1 → hubble_gateway_sdk_python-0.2.0}/.github/workflows/pypi.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|