hubble-gateway-sdk-python 0.1.0__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.2.0/.github/workflows/ci.yml +55 -0
- hubble_gateway_sdk_python-0.2.0/PKG-INFO +93 -0
- hubble_gateway_sdk_python-0.2.0/README.md +51 -0
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/pyproject.toml +2 -1
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/__init__.py +1 -1
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/auth.py +100 -19
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/config.py +12 -7
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/location.py +3 -1
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/models.py +34 -7
- hubble_gateway_sdk_python-0.2.0/src/hubble_gateway/scanner.py +228 -0
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/sender.py +95 -37
- {hubble_gateway_sdk_python-0.1.0 → 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.2.0/tests/test_server_config.py +302 -0
- hubble_gateway_sdk_python-0.1.0/PKG-INFO +0 -44
- hubble_gateway_sdk_python-0.1.0/README.md +0 -3
- hubble_gateway_sdk_python-0.1.0/src/hubble_gateway/scanner.py +0 -154
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/.github/workflows/pypi.yml +0 -0
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/.gitignore +0 -0
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/LICENSE +0 -0
- {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/tests/__init__.py +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
- name: Ruff check
|
|
31
|
+
run: ruff check .
|
|
32
|
+
|
|
33
|
+
- name: Ruff format
|
|
34
|
+
run: ruff format --check .
|
|
35
|
+
|
|
36
|
+
test:
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
strategy:
|
|
39
|
+
fail-fast: false
|
|
40
|
+
matrix:
|
|
41
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/checkout@v4
|
|
44
|
+
|
|
45
|
+
- uses: actions/setup-python@v5
|
|
46
|
+
with:
|
|
47
|
+
python-version: ${{ matrix.python-version }}
|
|
48
|
+
cache: pip
|
|
49
|
+
cache-dependency-path: pyproject.toml
|
|
50
|
+
|
|
51
|
+
- name: Install
|
|
52
|
+
run: python -m pip install -e ".[dev]"
|
|
53
|
+
|
|
54
|
+
- name: Pytest
|
|
55
|
+
run: pytest -q
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hubble-gateway-sdk-python
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for building Hubble Network BLE gateways
|
|
5
|
+
Project-URL: Homepage, https://github.com/HubbleNetwork/gateway-sdk-python
|
|
6
|
+
Project-URL: Documentation, https://github.com/HubbleNetwork/gateway-sdk-python#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/HubbleNetwork/gateway-sdk-python
|
|
8
|
+
Project-URL: Issues, https://github.com/HubbleNetwork/gateway-sdk-python/issues
|
|
9
|
+
Author-email: Hubble Network <dev@hubblenetwork.com>
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ble,bluetooth,gateway,hubble,iot,sdk
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Home Automation
|
|
22
|
+
Classifier: Topic :: System :: Networking
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: bleak>=0.21.0
|
|
25
|
+
Requires-Dist: httpx>=0.25.0
|
|
26
|
+
Requires-Dist: pydantic-settings>=2.1.0
|
|
27
|
+
Requires-Dist: pydantic>=2.5.0
|
|
28
|
+
Requires-Dist: structlog>=23.2.0
|
|
29
|
+
Provides-Extra: all
|
|
30
|
+
Requires-Dist: gpsd-py3>=0.3.0; extra == 'all'
|
|
31
|
+
Requires-Dist: pyserial-asyncio>=0.6; extra == 'all'
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: gpsd-py3>=0.3.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: pyserial-asyncio>=0.6; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
37
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
38
|
+
Provides-Extra: gps
|
|
39
|
+
Requires-Dist: gpsd-py3>=0.3.0; extra == 'gps'
|
|
40
|
+
Requires-Dist: pyserial-asyncio>=0.6; extra == 'gps'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# hubble-gateway
|
|
44
|
+
|
|
45
|
+
Python SDK for building Hubble Network BLE gateways. Import `hubble_gateway` in your application; this package is a library only (no CLI).
|
|
46
|
+
|
|
47
|
+
## Server config
|
|
48
|
+
|
|
49
|
+
gateway-api sends a `config` object (or `null`) in the registration, heartbeat
|
|
50
|
+
and token-refresh responses. `GatewayAuth` stores it as `server_config`.
|
|
51
|
+
`GatewaySender` reads it on every use, so each new response takes effect
|
|
52
|
+
without a restart.
|
|
53
|
+
|
|
54
|
+
| Key | Range | Overrides |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| `uploadIntervalMs` | 60,000 – 3,600,000 | `upload_interval_s` |
|
|
57
|
+
| `uploadBatchSize` | 1 – 500 | `batch_size` |
|
|
58
|
+
| `dedupWindowMs` | 5,000 – 3,600,000 | `dedup_window_s` |
|
|
59
|
+
|
|
60
|
+
- Each response replaces the stored config. It does not merge into it.
|
|
61
|
+
- `config: null` clears it, and the local values apply again.
|
|
62
|
+
- Out-of-range values are clamped. Values of the wrong type are ignored.
|
|
63
|
+
- Uploaded packets carry the 16-bit form (`FCA6`) as `serviceUuid`.
|
|
64
|
+
- Other keys (`activeUploadIntervalMs`, `activeDedupWindowMs`,
|
|
65
|
+
`uploadOnCellular`) are ignored: this SDK has no motion state or cellular gating.
|
|
66
|
+
|
|
67
|
+
The local default upload interval is 60 s, the same as the server's floor.
|
|
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
|
+
|
|
81
|
+
## Deduplication
|
|
82
|
+
|
|
83
|
+
The dedup key is `(device address, 16-bit service UUID, service data hex)`.
|
|
84
|
+
For a custom 128-bit UUID, the full UUID takes the place of the 16-bit one.
|
|
85
|
+
|
|
86
|
+
- The first sighting of a key opens a window of `dedup_window_s`.
|
|
87
|
+
- Repeats inside the window are dropped. If the first sighting is not uploaded
|
|
88
|
+
yet, a repeat adds to its sighting count and can raise its RSSI.
|
|
89
|
+
- The window is fixed at the first sighting. Repeats do not extend it, so a
|
|
90
|
+
device that beacons all the time is reported once per window.
|
|
91
|
+
- Windows survive uploads. An upload does not reset them.
|
|
92
|
+
- Expired keys are removed. The map is capped at `MAX_DEDUP_KEYS` (100,000);
|
|
93
|
+
past the cap, the oldest key is removed first.
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
## 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
|
+
|
|
39
|
+
## Deduplication
|
|
40
|
+
|
|
41
|
+
The dedup key is `(device address, 16-bit service UUID, service data hex)`.
|
|
42
|
+
For a custom 128-bit UUID, the full UUID takes the place of the 16-bit one.
|
|
43
|
+
|
|
44
|
+
- The first sighting of a key opens a window of `dedup_window_s`.
|
|
45
|
+
- Repeats inside the window are dropped. If the first sighting is not uploaded
|
|
46
|
+
yet, a repeat adds to its sighting count and can raise its RSSI.
|
|
47
|
+
- The window is fixed at the first sighting. Repeats do not extend it, so a
|
|
48
|
+
device that beacons all the time is reported once per window.
|
|
49
|
+
- Windows survive uploads. An upload does not reset them.
|
|
50
|
+
- Expired keys are removed. The map is capped at `MAX_DEDUP_KEYS` (100,000);
|
|
51
|
+
past the cap, the oldest key is removed first.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hubble-gateway-sdk-python"
|
|
7
|
-
version = "0.
|
|
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.0 → 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.0 → hubble_gateway_sdk_python-0.2.0}/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,70 @@ 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, uuid_to_16bit
|
|
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_PAYLOAD_SERVICE_UUIDS = 8
|
|
33
|
+
_HUBBLE_SERVICE_16 = uuid_to_16bit(HUBBLE_SERVICE_UUID)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _sanitize_payload_service_uuids(value: Any) -> list[str] | None:
|
|
37
|
+
"""Normalize a payloadServiceUuids list to ordered 16-bit forms (``FEED``).
|
|
38
|
+
|
|
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.
|
|
43
|
+
"""
|
|
44
|
+
if not isinstance(value, list):
|
|
45
|
+
return None
|
|
46
|
+
result: list[str] = []
|
|
47
|
+
for item in value:
|
|
48
|
+
u = normalize_service_uuid(item) if isinstance(item, str) else None
|
|
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]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sanitize_server_config(config: Any) -> dict[str, Any]:
|
|
58
|
+
"""Keep the keys this SDK uses, clamped to their ranges.
|
|
59
|
+
|
|
60
|
+
Values of the wrong type (null, strings, bools, NaN) are dropped, so the
|
|
61
|
+
local setting applies for that key.
|
|
62
|
+
"""
|
|
63
|
+
if not isinstance(config, dict):
|
|
64
|
+
return {}
|
|
65
|
+
result: dict[str, Any] = {}
|
|
66
|
+
# The legacy scanServiceUuids key is ignored.
|
|
67
|
+
uuids = _sanitize_payload_service_uuids(config.get("payloadServiceUuids"))
|
|
68
|
+
if uuids is not None:
|
|
69
|
+
result["payloadServiceUuids"] = uuids
|
|
70
|
+
for key, (low, high) in SERVER_CONFIG_RANGES.items():
|
|
71
|
+
value = config.get(key)
|
|
72
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
73
|
+
continue
|
|
74
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
75
|
+
continue
|
|
76
|
+
result[key] = min(max(int(value), low), high)
|
|
77
|
+
return result
|
|
78
|
+
|
|
19
79
|
|
|
20
80
|
def _device_id() -> str:
|
|
21
81
|
machine_id_path = Path("/etc/machine-id")
|
|
@@ -147,7 +207,9 @@ class GatewayAuth:
|
|
|
147
207
|
payload = _build_registration_payload()
|
|
148
208
|
logger.info("Registering gateway", base_url=self._base_url)
|
|
149
209
|
resp = await self._request(
|
|
150
|
-
"PUT",
|
|
210
|
+
"PUT",
|
|
211
|
+
"/api/v1/gateways",
|
|
212
|
+
json_body=payload,
|
|
151
213
|
extra_headers={"X-Sdk-Key": self._sdk_key},
|
|
152
214
|
)
|
|
153
215
|
if resp.status_code not in (200, 201):
|
|
@@ -173,7 +235,8 @@ class GatewayAuth:
|
|
|
173
235
|
async def _refresh(self) -> bool:
|
|
174
236
|
logger.info("Refreshing device token")
|
|
175
237
|
resp = await self._request(
|
|
176
|
-
"POST",
|
|
238
|
+
"POST",
|
|
239
|
+
"/api/v1/auth/refresh",
|
|
177
240
|
json_body={"refreshToken": self._refresh_token},
|
|
178
241
|
)
|
|
179
242
|
if resp.status_code != 200:
|
|
@@ -188,6 +251,7 @@ class GatewayAuth:
|
|
|
188
251
|
self._device_token = dt
|
|
189
252
|
self._refresh_token = rt
|
|
190
253
|
self._expires_at_ms = int(time.time() * 1000) + exp * 1000
|
|
254
|
+
self._apply_server_config(data)
|
|
191
255
|
self._persist_tokens()
|
|
192
256
|
logger.info("Token refreshed", expires_in_s=exp)
|
|
193
257
|
return True
|
|
@@ -197,14 +261,22 @@ class GatewayAuth:
|
|
|
197
261
|
return
|
|
198
262
|
payload = _build_registration_payload()
|
|
199
263
|
resp = await self.authenticated_request(
|
|
200
|
-
"PUT",
|
|
264
|
+
"PUT",
|
|
265
|
+
f"/api/v1/gateways/{self._gateway_id}",
|
|
266
|
+
json_body=payload,
|
|
201
267
|
)
|
|
202
268
|
if resp.status_code == 200:
|
|
203
269
|
self._apply_server_config(resp.json())
|
|
270
|
+
self._persist_tokens()
|
|
204
271
|
|
|
205
272
|
async def _request(
|
|
206
|
-
self,
|
|
207
|
-
|
|
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,
|
|
208
280
|
) -> httpx.Response:
|
|
209
281
|
assert self._client is not None, "call start() first"
|
|
210
282
|
headers: dict[str, str] = {}
|
|
@@ -213,24 +285,33 @@ class GatewayAuth:
|
|
|
213
285
|
if extra_headers:
|
|
214
286
|
headers.update(extra_headers)
|
|
215
287
|
return await self._client.request(
|
|
216
|
-
method,
|
|
288
|
+
method,
|
|
289
|
+
f"{self._base_url}{path}",
|
|
290
|
+
json=json_body,
|
|
291
|
+
headers=headers,
|
|
217
292
|
)
|
|
218
293
|
|
|
219
|
-
def _apply_server_config(self, data:
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
294
|
+
def _apply_server_config(self, data: Any) -> None:
|
|
295
|
+
# The server sends the full effective config each time, so replace
|
|
296
|
+
# rather than merge: a removed override must not stick. A response
|
|
297
|
+
# without a "config" key says nothing and keeps the current config.
|
|
298
|
+
if not isinstance(data, dict) or "config" not in data:
|
|
299
|
+
return
|
|
300
|
+
self.server_config = sanitize_server_config(data["config"])
|
|
224
301
|
|
|
225
302
|
def _persist_tokens(self) -> None:
|
|
226
303
|
self._token_path.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
-
self._token_path.write_text(
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
+
)
|
|
234
315
|
self._token_path.chmod(0o600)
|
|
235
316
|
|
|
236
317
|
def _load_persisted_tokens(self) -> None:
|
|
@@ -242,7 +323,7 @@ class GatewayAuth:
|
|
|
242
323
|
self._device_token = data.get("deviceToken")
|
|
243
324
|
self._refresh_token = data.get("refreshToken")
|
|
244
325
|
self._expires_at_ms = data.get("expiresAtMs", 0)
|
|
245
|
-
self.server_config = data.get("serverConfig"
|
|
326
|
+
self.server_config = sanitize_server_config(data.get("serverConfig"))
|
|
246
327
|
logger.info("Loaded persisted auth", gateway_id=self._gateway_id)
|
|
247
328
|
except (json.JSONDecodeError, OSError) as exc:
|
|
248
329
|
logger.warning("Failed to load persisted auth", error=str(exc))
|
{hubble_gateway_sdk_python-0.1.0 → 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,
|
|
@@ -53,15 +58,15 @@ class Settings(BaseSettings):
|
|
|
53
58
|
# Batching and deduplication
|
|
54
59
|
batch_size: int = Field(
|
|
55
60
|
default=500,
|
|
56
|
-
description="Max packets per upload batch",
|
|
61
|
+
description="Max packets per upload batch (server uploadBatchSize overrides)",
|
|
57
62
|
)
|
|
58
63
|
upload_interval_seconds: float = Field(
|
|
59
|
-
default=
|
|
60
|
-
description="Seconds between upload flushes",
|
|
64
|
+
default=60.0,
|
|
65
|
+
description="Seconds between upload flushes (server uploadIntervalMs overrides)",
|
|
61
66
|
)
|
|
62
67
|
dedup_window_seconds: float = Field(
|
|
63
68
|
default=300.0,
|
|
64
|
-
description="Deduplication window in seconds",
|
|
69
|
+
description="Deduplication window in seconds (server dedupWindowMs overrides)",
|
|
65
70
|
)
|
|
66
71
|
|
|
67
72
|
# Location
|
{hubble_gateway_sdk_python-0.1.0 → 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.0 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/models.py
RENAMED
|
@@ -1,18 +1,47 @@
|
|
|
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(r"^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize_service_uuid(value: str) -> str | None:
|
|
17
|
+
"""Return the uppercase 128-bit form of a BLE service UUID, or None if invalid.
|
|
18
|
+
|
|
19
|
+
``FCA6`` -> ``0000FCA6-0000-1000-8000-00805F9B34FB``. This is the form
|
|
20
|
+
bleak reports and filters on.
|
|
21
|
+
"""
|
|
22
|
+
u = value.strip().upper()
|
|
23
|
+
if _UUID16_PATTERN.match(u):
|
|
24
|
+
return f"0000{u}{BLUETOOTH_BASE_UUID_SUFFIX}"
|
|
25
|
+
if _UUID128_PATTERN.match(u):
|
|
26
|
+
return u
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_bluetooth_base_uuid(full_uuid: str) -> bool:
|
|
31
|
+
u = full_uuid.upper()
|
|
32
|
+
return len(u) == 36 and u.startswith("0000") and u.endswith(BLUETOOTH_BASE_UUID_SUFFIX)
|
|
33
|
+
|
|
9
34
|
|
|
10
35
|
def uuid_to_16bit(full_uuid: str) -> str:
|
|
11
|
-
"""
|
|
36
|
+
"""Short form of a BLE UUID, as the gateway-api packet schema expects.
|
|
12
37
|
|
|
13
|
-
``0000FCA6-0000-1000-8000-00805F9B34FB`` -> ``FCA6
|
|
38
|
+
``0000FCA6-0000-1000-8000-00805F9B34FB`` -> ``FCA6``. A UUID that is not
|
|
39
|
+
on the Bluetooth Base UUID has no 16-bit form, so it is returned whole
|
|
40
|
+
(uppercase) rather than as a misleading slice.
|
|
14
41
|
"""
|
|
15
|
-
|
|
42
|
+
if is_bluetooth_base_uuid(full_uuid):
|
|
43
|
+
return full_uuid[4:8].upper()
|
|
44
|
+
return full_uuid.upper()
|
|
16
45
|
|
|
17
46
|
|
|
18
47
|
class Location(BaseModel):
|
|
@@ -89,7 +118,7 @@ class BLEPacket(BaseModel):
|
|
|
89
118
|
|
|
90
119
|
@property
|
|
91
120
|
def service_uuid_16bit(self) -> str | None:
|
|
92
|
-
"""
|
|
121
|
+
"""Short form of matched UUID (e.g. 'FCA6'); full form if not a Base UUID."""
|
|
93
122
|
if self.service_uuid:
|
|
94
123
|
return uuid_to_16bit(self.service_uuid)
|
|
95
124
|
return None
|
|
@@ -99,9 +128,7 @@ class BLEPacket(BaseModel):
|
|
|
99
128
|
) -> dict[str, Any]:
|
|
100
129
|
"""Convert to gateway-api packet format."""
|
|
101
130
|
payload_bytes = self.get_matched_service_data()
|
|
102
|
-
service_data_b64 = (
|
|
103
|
-
base64.b64encode(payload_bytes).decode() if payload_bytes else None
|
|
104
|
-
)
|
|
131
|
+
service_data_b64 = base64.b64encode(payload_bytes).decode() if payload_bytes else None
|
|
105
132
|
ts_ms = int(self.timestamp.timestamp() * 1000)
|
|
106
133
|
|
|
107
134
|
packet: dict[str, Any] = {
|