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.
Files changed (21) hide show
  1. hubble_gateway_sdk_python-0.2.0/.github/workflows/ci.yml +55 -0
  2. hubble_gateway_sdk_python-0.2.0/PKG-INFO +93 -0
  3. hubble_gateway_sdk_python-0.2.0/README.md +51 -0
  4. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/pyproject.toml +2 -1
  5. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/__init__.py +1 -1
  6. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/auth.py +100 -19
  7. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/config.py +12 -7
  8. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/location.py +3 -1
  9. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/models.py +34 -7
  10. hubble_gateway_sdk_python-0.2.0/src/hubble_gateway/scanner.py +228 -0
  11. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/sender.py +95 -37
  12. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/src/hubble_gateway/ubx.py +26 -14
  13. hubble_gateway_sdk_python-0.2.0/tests/test_payload_service_uuids.py +305 -0
  14. hubble_gateway_sdk_python-0.2.0/tests/test_server_config.py +302 -0
  15. hubble_gateway_sdk_python-0.1.0/PKG-INFO +0 -44
  16. hubble_gateway_sdk_python-0.1.0/README.md +0 -3
  17. hubble_gateway_sdk_python-0.1.0/src/hubble_gateway/scanner.py +0 -154
  18. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/.github/workflows/pypi.yml +0 -0
  19. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/.gitignore +0 -0
  20. {hubble_gateway_sdk_python-0.1.0 → hubble_gateway_sdk_python-0.2.0}/LICENSE +0 -0
  21. {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.1.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
  ]
@@ -5,7 +5,7 @@ Import the components you need to build a gateway:
5
5
  from hubble_gateway import Scanner, GatewaySender, GatewayAuth, Location, BLEPacket
6
6
  """
7
7
 
8
- __version__ = "0.1.0"
8
+ __version__ = "0.2.0"
9
9
 
10
10
  from hubble_gateway.auth import GatewayAuth
11
11
  from hubble_gateway.config import (
@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
  import locale
5
+ import math
5
6
  import platform
6
7
  import time
7
8
  from pathlib import Path
@@ -11,11 +12,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", "/api/v1/gateways", json_body=payload,
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", "/api/v1/auth/refresh",
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", f"/api/v1/gateways/{self._gateway_id}", json_body=payload,
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, method: str, path: str, json_body: dict[str, Any] | None = None,
207
- *, authenticated: bool = False, extra_headers: dict[str, str] | None = None,
273
+ self,
274
+ method: str,
275
+ path: str,
276
+ json_body: dict[str, Any] | None = None,
277
+ *,
278
+ authenticated: bool = False,
279
+ extra_headers: dict[str, str] | None = None,
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, f"{self._base_url}{path}", json=json_body, headers=headers,
288
+ method,
289
+ f"{self._base_url}{path}",
290
+ json=json_body,
291
+ headers=headers,
217
292
  )
218
293
 
219
- def _apply_server_config(self, data: dict[str, Any]) -> None:
220
- config = data.get("config") or {}
221
- for key in ("uploadIntervalMs", "uploadBatchSize", "scanIntervalMs", "featureFlags"):
222
- if key in config:
223
- self.server_config[key] = config[key]
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(json.dumps({
228
- "gatewayId": self._gateway_id,
229
- "deviceToken": self._device_token,
230
- "refreshToken": self._refresh_token,
231
- "expiresAtMs": self._expires_at_ms,
232
- "serverConfig": self.server_config,
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))
@@ -5,7 +5,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
5
5
 
6
6
  HUBBLE_SERVICE_UUID = "0000FCA6-0000-1000-8000-00805F9B34FB"
7
7
  TILE_SERVICE_UUID = "0000FEED-0000-1000-8000-00805F9B34FB"
8
- DEFAULT_SERVICE_UUIDS = [HUBBLE_SERVICE_UUID, TILE_SERVICE_UUID]
8
+ # FCA6 is always scanned. Other payload services come from the
9
+ # payloadServiceUuids config key, so the compiled-in default is FCA6 only.
10
+ DEFAULT_SERVICE_UUIDS = [HUBBLE_SERVICE_UUID]
9
11
 
10
12
  GATEWAY_API_BASE_URL = "https://gw-api.hubble.com"
11
13
 
@@ -38,8 +40,11 @@ class Settings(BaseSettings):
38
40
 
39
41
  # BLE scanning
40
42
  service_uuids: list[str] = Field(
41
- default=DEFAULT_SERVICE_UUIDS,
42
- description="BLE service UUIDs to scan for",
43
+ default_factory=list,
44
+ description=(
45
+ "Extra BLE service UUIDs to scan for. Scan-only: they reach "
46
+ "on_advertisement listeners and are not uploaded."
47
+ ),
43
48
  )
44
49
  scan_duration_seconds: float = Field(
45
50
  default=5.0,
@@ -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=5.0,
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
@@ -150,7 +150,8 @@ class SerialGPSLocationProvider(LocationProvider):
150
150
  import serial_asyncio
151
151
 
152
152
  self._reader, self._writer = await serial_asyncio.open_serial_connection(
153
- url=self._port, baudrate=self._baud_rate,
153
+ url=self._port,
154
+ baudrate=self._baud_rate,
154
155
  )
155
156
  self._running = True
156
157
  self._location.gps_state = "searching"
@@ -331,6 +332,7 @@ class GPSDLocationProvider(LocationProvider):
331
332
  async def start(self) -> None:
332
333
  try:
333
334
  import gpsd
335
+
334
336
  gpsd.connect()
335
337
  self._running = True
336
338
  logger.info("GPSD location provider started")
@@ -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
- """Extract 16-bit short form from a full 128-bit BLE UUID.
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
- return full_uuid[4:8].upper()
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
- """16-bit short form of matched UUID (e.g. 'FCA6')."""
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] = {