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.
@@ -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)
@@ -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
+ )