hubble-gateway-sdk-python 0.1.0__py3-none-any.whl
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/__init__.py +47 -0
- hubble_gateway/auth.py +255 -0
- hubble_gateway/config.py +109 -0
- hubble_gateway/location.py +423 -0
- hubble_gateway/models.py +118 -0
- hubble_gateway/scanner.py +154 -0
- hubble_gateway/sender.py +202 -0
- hubble_gateway/ubx.py +230 -0
- hubble_gateway_sdk_python-0.1.0.dist-info/METADATA +44 -0
- hubble_gateway_sdk_python-0.1.0.dist-info/RECORD +12 -0
- hubble_gateway_sdk_python-0.1.0.dist-info/WHEEL +4 -0
- hubble_gateway_sdk_python-0.1.0.dist-info/licenses/LICENSE +203 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Hubble Network BLE Gateway SDK.
|
|
2
|
+
|
|
3
|
+
Import the components you need to build a gateway:
|
|
4
|
+
|
|
5
|
+
from hubble_gateway import Scanner, GatewaySender, GatewayAuth, Location, BLEPacket
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
from hubble_gateway.auth import GatewayAuth
|
|
11
|
+
from hubble_gateway.config import (
|
|
12
|
+
DEFAULT_SERVICE_UUIDS,
|
|
13
|
+
GATEWAY_API_BASE_URL,
|
|
14
|
+
HUBBLE_SERVICE_UUID,
|
|
15
|
+
TILE_SERVICE_UUID,
|
|
16
|
+
Settings,
|
|
17
|
+
)
|
|
18
|
+
from hubble_gateway.location import (
|
|
19
|
+
FixedLocationProvider,
|
|
20
|
+
GPSDLocationProvider,
|
|
21
|
+
LocationProvider,
|
|
22
|
+
SerialGPSLocationProvider,
|
|
23
|
+
UBXLocationProvider,
|
|
24
|
+
create_location_provider,
|
|
25
|
+
)
|
|
26
|
+
from hubble_gateway.models import BLEPacket, Location
|
|
27
|
+
from hubble_gateway.scanner import Scanner
|
|
28
|
+
from hubble_gateway.sender import GatewaySender
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"BLEPacket",
|
|
32
|
+
"DEFAULT_SERVICE_UUIDS",
|
|
33
|
+
"FixedLocationProvider",
|
|
34
|
+
"GATEWAY_API_BASE_URL",
|
|
35
|
+
"GPSDLocationProvider",
|
|
36
|
+
"GatewayAuth",
|
|
37
|
+
"GatewaySender",
|
|
38
|
+
"HUBBLE_SERVICE_UUID",
|
|
39
|
+
"Location",
|
|
40
|
+
"LocationProvider",
|
|
41
|
+
"Scanner",
|
|
42
|
+
"SerialGPSLocationProvider",
|
|
43
|
+
"Settings",
|
|
44
|
+
"TILE_SERVICE_UUID",
|
|
45
|
+
"UBXLocationProvider",
|
|
46
|
+
"create_location_provider",
|
|
47
|
+
]
|
hubble_gateway/auth.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Gateway API authentication: SDK key registration and token lifecycle."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import locale
|
|
5
|
+
import platform
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
from hubble_gateway import __version__
|
|
14
|
+
|
|
15
|
+
logger = structlog.get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
_TOKEN_EXPIRY_MARGIN_S = 60
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _device_id() -> str:
|
|
21
|
+
machine_id_path = Path("/etc/machine-id")
|
|
22
|
+
if machine_id_path.exists():
|
|
23
|
+
return machine_id_path.read_text().strip()
|
|
24
|
+
return platform.node()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _device_model() -> str:
|
|
28
|
+
model_path = Path("/proc/device-tree/model")
|
|
29
|
+
if model_path.exists():
|
|
30
|
+
return model_path.read_text().strip().rstrip("\x00")
|
|
31
|
+
return platform.machine()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _device_manufacturer() -> str:
|
|
35
|
+
model = _device_model().lower()
|
|
36
|
+
if "raspberry" in model:
|
|
37
|
+
return "Raspberry Pi"
|
|
38
|
+
return platform.system()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ble_adapter_state() -> str:
|
|
42
|
+
hci_path = Path("/sys/class/bluetooth/hci0")
|
|
43
|
+
return "on" if hci_path.exists() else "off"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_registration_payload() -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"sentAtMillis": int(time.time() * 1000),
|
|
49
|
+
"deviceId": _device_id(),
|
|
50
|
+
"type": "custom",
|
|
51
|
+
"manufacturer": _device_manufacturer(),
|
|
52
|
+
"model": _device_model(),
|
|
53
|
+
"firmwareVersion": __version__,
|
|
54
|
+
"locale": locale.getdefaultlocale()[0] or "en-US",
|
|
55
|
+
"bleAdapterState": _ble_adapter_state(),
|
|
56
|
+
"capabilities": ["ble_scan", "location"],
|
|
57
|
+
"platform": {
|
|
58
|
+
"os": platform.system(),
|
|
59
|
+
"osVersion": platform.release(),
|
|
60
|
+
"pythonVersion": platform.python_version(),
|
|
61
|
+
"sdk": "hubble-gateway-python",
|
|
62
|
+
"sdkVersion": __version__,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class GatewayAuth:
|
|
68
|
+
"""Manages gateway registration and Bearer-token lifecycle.
|
|
69
|
+
|
|
70
|
+
Persists tokens to a local JSON file so the gateway survives
|
|
71
|
+
restarts without re-registering.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
sdk_key: str,
|
|
77
|
+
base_url: str,
|
|
78
|
+
token_path: str = "~/.hubble-gateway/auth.json",
|
|
79
|
+
) -> None:
|
|
80
|
+
self._sdk_key = sdk_key
|
|
81
|
+
self._base_url = base_url.rstrip("/")
|
|
82
|
+
self._token_path = Path(token_path).expanduser()
|
|
83
|
+
|
|
84
|
+
self._gateway_id: str | None = None
|
|
85
|
+
self._device_token: str | None = None
|
|
86
|
+
self._refresh_token: str | None = None
|
|
87
|
+
self._expires_at_ms: int = 0
|
|
88
|
+
self.server_config: dict[str, Any] = {}
|
|
89
|
+
self._client: httpx.AsyncClient | None = None
|
|
90
|
+
self._load_persisted_tokens()
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def gateway_id(self) -> str | None:
|
|
94
|
+
return self._gateway_id
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def is_registered(self) -> bool:
|
|
98
|
+
return self._gateway_id is not None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def has_valid_token(self) -> bool:
|
|
102
|
+
if not self._device_token:
|
|
103
|
+
return False
|
|
104
|
+
return int(time.time() * 1000) < (self._expires_at_ms - _TOKEN_EXPIRY_MARGIN_S * 1000)
|
|
105
|
+
|
|
106
|
+
async def start(self) -> None:
|
|
107
|
+
self._client = httpx.AsyncClient(
|
|
108
|
+
timeout=httpx.Timeout(30.0),
|
|
109
|
+
headers={
|
|
110
|
+
"Content-Type": "application/json",
|
|
111
|
+
"Accept": "application/json",
|
|
112
|
+
"User-Agent": f"hubble-gateway-python/{__version__}",
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def stop(self) -> None:
|
|
117
|
+
if self._client:
|
|
118
|
+
await self._client.aclose()
|
|
119
|
+
self._client = None
|
|
120
|
+
|
|
121
|
+
async def ensure_authenticated(self) -> bool:
|
|
122
|
+
if self.has_valid_token:
|
|
123
|
+
return True
|
|
124
|
+
if self._refresh_token and self._gateway_id:
|
|
125
|
+
if await self._refresh():
|
|
126
|
+
return True
|
|
127
|
+
logger.warning("Token refresh failed; will re-register")
|
|
128
|
+
self._clear_tokens()
|
|
129
|
+
return await self._register()
|
|
130
|
+
|
|
131
|
+
async def authenticated_request(
|
|
132
|
+
self, method: str, path: str, json_body: dict[str, Any] | None = None
|
|
133
|
+
) -> httpx.Response:
|
|
134
|
+
if not await self.ensure_authenticated():
|
|
135
|
+
raise RuntimeError("Gateway authentication failed")
|
|
136
|
+
resp = await self._request(method, path, json_body, authenticated=True)
|
|
137
|
+
if resp.status_code == 401:
|
|
138
|
+
body = resp.json() if resp.content else {}
|
|
139
|
+
if body.get("code") == "token_expired":
|
|
140
|
+
logger.info("Token expired mid-request, refreshing")
|
|
141
|
+
self._device_token = None
|
|
142
|
+
if await self.ensure_authenticated():
|
|
143
|
+
resp = await self._request(method, path, json_body, authenticated=True)
|
|
144
|
+
return resp
|
|
145
|
+
|
|
146
|
+
async def _register(self) -> bool:
|
|
147
|
+
payload = _build_registration_payload()
|
|
148
|
+
logger.info("Registering gateway", base_url=self._base_url)
|
|
149
|
+
resp = await self._request(
|
|
150
|
+
"PUT", "/api/v1/gateways", json_body=payload,
|
|
151
|
+
extra_headers={"X-Sdk-Key": self._sdk_key},
|
|
152
|
+
)
|
|
153
|
+
if resp.status_code not in (200, 201):
|
|
154
|
+
logger.error("Registration failed", status=resp.status_code, body=resp.text[:300])
|
|
155
|
+
return False
|
|
156
|
+
data = resp.json()
|
|
157
|
+
gid = data.get("gatewayId", "")
|
|
158
|
+
dt = data.get("deviceToken", "")
|
|
159
|
+
rt = data.get("refreshToken", "")
|
|
160
|
+
exp = data.get("expiresInSeconds", 0)
|
|
161
|
+
if not (gid and dt and rt and exp > 0):
|
|
162
|
+
logger.error("Registration response missing required fields", data=data)
|
|
163
|
+
return False
|
|
164
|
+
self._gateway_id = gid
|
|
165
|
+
self._device_token = dt
|
|
166
|
+
self._refresh_token = rt
|
|
167
|
+
self._expires_at_ms = int(time.time() * 1000) + exp * 1000
|
|
168
|
+
self._apply_server_config(data)
|
|
169
|
+
self._persist_tokens()
|
|
170
|
+
logger.info("Gateway registered", gateway_id=gid, expires_in_s=exp)
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
async def _refresh(self) -> bool:
|
|
174
|
+
logger.info("Refreshing device token")
|
|
175
|
+
resp = await self._request(
|
|
176
|
+
"POST", "/api/v1/auth/refresh",
|
|
177
|
+
json_body={"refreshToken": self._refresh_token},
|
|
178
|
+
)
|
|
179
|
+
if resp.status_code != 200:
|
|
180
|
+
logger.warning("Token refresh failed", status=resp.status_code)
|
|
181
|
+
return False
|
|
182
|
+
data = resp.json()
|
|
183
|
+
dt = data.get("deviceToken", "")
|
|
184
|
+
rt = data.get("refreshToken", self._refresh_token)
|
|
185
|
+
exp = data.get("expiresInSeconds", 0)
|
|
186
|
+
if not (dt and exp > 0):
|
|
187
|
+
return False
|
|
188
|
+
self._device_token = dt
|
|
189
|
+
self._refresh_token = rt
|
|
190
|
+
self._expires_at_ms = int(time.time() * 1000) + exp * 1000
|
|
191
|
+
self._persist_tokens()
|
|
192
|
+
logger.info("Token refreshed", expires_in_s=exp)
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
async def heartbeat(self) -> None:
|
|
196
|
+
if not self._gateway_id:
|
|
197
|
+
return
|
|
198
|
+
payload = _build_registration_payload()
|
|
199
|
+
resp = await self.authenticated_request(
|
|
200
|
+
"PUT", f"/api/v1/gateways/{self._gateway_id}", json_body=payload,
|
|
201
|
+
)
|
|
202
|
+
if resp.status_code == 200:
|
|
203
|
+
self._apply_server_config(resp.json())
|
|
204
|
+
|
|
205
|
+
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,
|
|
208
|
+
) -> httpx.Response:
|
|
209
|
+
assert self._client is not None, "call start() first"
|
|
210
|
+
headers: dict[str, str] = {}
|
|
211
|
+
if authenticated and self._device_token:
|
|
212
|
+
headers["Authorization"] = f"Bearer {self._device_token}"
|
|
213
|
+
if extra_headers:
|
|
214
|
+
headers.update(extra_headers)
|
|
215
|
+
return await self._client.request(
|
|
216
|
+
method, f"{self._base_url}{path}", json=json_body, headers=headers,
|
|
217
|
+
)
|
|
218
|
+
|
|
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]
|
|
224
|
+
|
|
225
|
+
def _persist_tokens(self) -> None:
|
|
226
|
+
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
|
+
}))
|
|
234
|
+
self._token_path.chmod(0o600)
|
|
235
|
+
|
|
236
|
+
def _load_persisted_tokens(self) -> None:
|
|
237
|
+
if not self._token_path.exists():
|
|
238
|
+
return
|
|
239
|
+
try:
|
|
240
|
+
data = json.loads(self._token_path.read_text())
|
|
241
|
+
self._gateway_id = data.get("gatewayId")
|
|
242
|
+
self._device_token = data.get("deviceToken")
|
|
243
|
+
self._refresh_token = data.get("refreshToken")
|
|
244
|
+
self._expires_at_ms = data.get("expiresAtMs", 0)
|
|
245
|
+
self.server_config = data.get("serverConfig", {})
|
|
246
|
+
logger.info("Loaded persisted auth", gateway_id=self._gateway_id)
|
|
247
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
248
|
+
logger.warning("Failed to load persisted auth", error=str(exc))
|
|
249
|
+
|
|
250
|
+
def _clear_tokens(self) -> None:
|
|
251
|
+
self._device_token = None
|
|
252
|
+
self._refresh_token = None
|
|
253
|
+
self._expires_at_ms = 0
|
|
254
|
+
if self._token_path.exists():
|
|
255
|
+
self._token_path.unlink(missing_ok=True)
|
hubble_gateway/config.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Configuration for the Hubble Gateway SDK."""
|
|
2
|
+
|
|
3
|
+
from pydantic import Field, field_validator
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
HUBBLE_SERVICE_UUID = "0000FCA6-0000-1000-8000-00805F9B34FB"
|
|
7
|
+
TILE_SERVICE_UUID = "0000FEED-0000-1000-8000-00805F9B34FB"
|
|
8
|
+
DEFAULT_SERVICE_UUIDS = [HUBBLE_SERVICE_UUID, TILE_SERVICE_UUID]
|
|
9
|
+
|
|
10
|
+
GATEWAY_API_BASE_URL = "https://gw-api.hubble.com"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Settings(BaseSettings):
|
|
14
|
+
"""Gateway SDK settings loaded from environment variables or .env file."""
|
|
15
|
+
|
|
16
|
+
model_config = SettingsConfigDict(
|
|
17
|
+
env_file=".env",
|
|
18
|
+
env_file_encoding="utf-8",
|
|
19
|
+
case_sensitive=False,
|
|
20
|
+
env_prefix="HUBBLE_",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Required
|
|
24
|
+
sdk_key: str = Field(
|
|
25
|
+
default="",
|
|
26
|
+
description="SDK key from Hubble dashboard (required)",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# API
|
|
30
|
+
api_base_url: str = Field(
|
|
31
|
+
default=GATEWAY_API_BASE_URL,
|
|
32
|
+
description="Gateway API base URL",
|
|
33
|
+
)
|
|
34
|
+
auth_token_path: str = Field(
|
|
35
|
+
default="~/.hubble-gateway/auth.json",
|
|
36
|
+
description="Path for persisted auth tokens",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# BLE scanning
|
|
40
|
+
service_uuids: list[str] = Field(
|
|
41
|
+
default=DEFAULT_SERVICE_UUIDS,
|
|
42
|
+
description="BLE service UUIDs to scan for",
|
|
43
|
+
)
|
|
44
|
+
scan_duration_seconds: float = Field(
|
|
45
|
+
default=5.0,
|
|
46
|
+
description="Duration of each BLE scan cycle in seconds",
|
|
47
|
+
)
|
|
48
|
+
ble_adapter: str = Field(
|
|
49
|
+
default="",
|
|
50
|
+
description="BLE adapter to use (e.g. 'hci0', 'hci1' for USB dongle). Empty = default.",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Batching and deduplication
|
|
54
|
+
batch_size: int = Field(
|
|
55
|
+
default=500,
|
|
56
|
+
description="Max packets per upload batch",
|
|
57
|
+
)
|
|
58
|
+
upload_interval_seconds: float = Field(
|
|
59
|
+
default=5.0,
|
|
60
|
+
description="Seconds between upload flushes",
|
|
61
|
+
)
|
|
62
|
+
dedup_window_seconds: float = Field(
|
|
63
|
+
default=300.0,
|
|
64
|
+
description="Deduplication window in seconds",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Location
|
|
68
|
+
latitude: float | None = Field(
|
|
69
|
+
default=None,
|
|
70
|
+
description="Fixed latitude (used when GPS unavailable)",
|
|
71
|
+
)
|
|
72
|
+
longitude: float | None = Field(
|
|
73
|
+
default=None,
|
|
74
|
+
description="Fixed longitude (used when GPS unavailable)",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@field_validator("latitude", "longitude", mode="before")
|
|
78
|
+
@classmethod
|
|
79
|
+
def empty_str_to_none(cls, v):
|
|
80
|
+
if v == "" or v is None:
|
|
81
|
+
return None
|
|
82
|
+
return v
|
|
83
|
+
|
|
84
|
+
gps_enabled: bool = Field(
|
|
85
|
+
default=False,
|
|
86
|
+
description="Enable GPS auto-detection",
|
|
87
|
+
)
|
|
88
|
+
gps_port: str = Field(
|
|
89
|
+
default="/dev/ttyAMA0",
|
|
90
|
+
description="Serial port for GPS module",
|
|
91
|
+
)
|
|
92
|
+
gps_baud_rate: int = Field(
|
|
93
|
+
default=9600,
|
|
94
|
+
description="Baud rate for GPS serial",
|
|
95
|
+
)
|
|
96
|
+
gps_module: str = Field(
|
|
97
|
+
default="nmea",
|
|
98
|
+
description="GPS module type: 'nmea' (generic NMEA hats), 'zed_f9p' (u-blox UBX binary)",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Logging
|
|
102
|
+
log_level: str = Field(
|
|
103
|
+
default="INFO",
|
|
104
|
+
description="Log level: DEBUG, INFO, WARNING, ERROR",
|
|
105
|
+
)
|
|
106
|
+
log_json: bool = Field(
|
|
107
|
+
default=True,
|
|
108
|
+
description="Emit structured JSON logs (disable for human-readable)",
|
|
109
|
+
)
|