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,423 @@
|
|
|
1
|
+
"""Location providers: fixed, serial NMEA GPS, UBX (ZED-F9P), gpsd."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
|
|
12
|
+
from hubble_gateway.models import Location
|
|
13
|
+
|
|
14
|
+
logger = structlog.get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_nmea_coordinate(value: str, direction: str) -> float | None:
|
|
18
|
+
"""Parse NMEA coordinate (DDMM.MMMMM or DDDMM.MMMMM) to decimal degrees."""
|
|
19
|
+
if not value or not direction:
|
|
20
|
+
return None
|
|
21
|
+
try:
|
|
22
|
+
dot_pos = value.find(".")
|
|
23
|
+
if dot_pos < 2:
|
|
24
|
+
return None
|
|
25
|
+
deg_digits = 3 if len(value.split(".")[0]) > 4 else 2
|
|
26
|
+
degrees = float(value[:deg_digits])
|
|
27
|
+
minutes = float(value[deg_digits:])
|
|
28
|
+
decimal = degrees + (minutes / 60.0)
|
|
29
|
+
if direction in ("S", "W"):
|
|
30
|
+
decimal = -decimal
|
|
31
|
+
return decimal
|
|
32
|
+
except (ValueError, IndexError):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LocationProvider(ABC):
|
|
37
|
+
"""Abstract base class for location providers."""
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
async def get_location(self) -> Location: ...
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def start(self) -> None: ...
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
async def stop(self) -> None: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FixedLocationProvider(LocationProvider):
|
|
50
|
+
"""Returns a static configured location."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, latitude: float | None, longitude: float | None) -> None:
|
|
53
|
+
self._location = Location(
|
|
54
|
+
latitude=latitude,
|
|
55
|
+
longitude=longitude,
|
|
56
|
+
source="fixed" if latitude and longitude else "unknown",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
async def get_location(self) -> Location:
|
|
60
|
+
return self._location
|
|
61
|
+
|
|
62
|
+
async def start(self) -> None:
|
|
63
|
+
logger.info(
|
|
64
|
+
"Fixed location provider",
|
|
65
|
+
lat=self._location.latitude,
|
|
66
|
+
lon=self._location.longitude,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def stop(self) -> None:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class GPSKalmanFilter:
|
|
74
|
+
"""Simple 2D Kalman filter for GPS position smoothing."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
process_noise: float = 1e-10,
|
|
79
|
+
measurement_noise: float = 2e-8,
|
|
80
|
+
) -> None:
|
|
81
|
+
self._process_noise = process_noise
|
|
82
|
+
self._measurement_noise = measurement_noise
|
|
83
|
+
self._state_lat: float | None = None
|
|
84
|
+
self._state_lon: float | None = None
|
|
85
|
+
self._p_lat: float = 1.0
|
|
86
|
+
self._p_lon: float = 1.0
|
|
87
|
+
|
|
88
|
+
def update(self, lat: float, lon: float) -> tuple[float, float]:
|
|
89
|
+
if self._state_lat is None or self._state_lon is None:
|
|
90
|
+
self._state_lat = lat
|
|
91
|
+
self._state_lon = lon
|
|
92
|
+
return lat, lon
|
|
93
|
+
|
|
94
|
+
self._p_lat += self._process_noise
|
|
95
|
+
self._p_lon += self._process_noise
|
|
96
|
+
|
|
97
|
+
k_lat = self._p_lat / (self._p_lat + self._measurement_noise)
|
|
98
|
+
k_lon = self._p_lon / (self._p_lon + self._measurement_noise)
|
|
99
|
+
|
|
100
|
+
self._state_lat = self._state_lat + k_lat * (lat - self._state_lat)
|
|
101
|
+
self._state_lon = self._state_lon + k_lon * (lon - self._state_lon)
|
|
102
|
+
|
|
103
|
+
self._p_lat = (1 - k_lat) * self._p_lat
|
|
104
|
+
self._p_lon = (1 - k_lon) * self._p_lon
|
|
105
|
+
|
|
106
|
+
return self._state_lat, self._state_lon
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class SerialGPSLocationProvider(LocationProvider):
|
|
110
|
+
"""Reads NMEA sentences from a serial GPS with Kalman filtering.
|
|
111
|
+
|
|
112
|
+
Supports common Pi GPS hats that output GPRMC/GPGGA over a UART serial port.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
port: str = "/dev/ttyAMA0",
|
|
118
|
+
baud_rate: int = 9600,
|
|
119
|
+
fallback_lat: float | None = None,
|
|
120
|
+
fallback_lon: float | None = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
self._port = port
|
|
123
|
+
self._baud_rate = baud_rate
|
|
124
|
+
self._fallback_lat = fallback_lat
|
|
125
|
+
self._fallback_lon = fallback_lon
|
|
126
|
+
self._location = Location(
|
|
127
|
+
latitude=fallback_lat,
|
|
128
|
+
longitude=fallback_lon,
|
|
129
|
+
source="gps-fallback" if fallback_lat else "unknown",
|
|
130
|
+
gps_state="off",
|
|
131
|
+
)
|
|
132
|
+
self._running = False
|
|
133
|
+
self._reader: asyncio.StreamReader | None = None
|
|
134
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
135
|
+
self._fix_count = 0
|
|
136
|
+
self._kalman = GPSKalmanFilter()
|
|
137
|
+
self._hdop: float | None = None
|
|
138
|
+
self._altitude: float | None = None
|
|
139
|
+
self._sat_count: int | None = None
|
|
140
|
+
self._speed_ms: float | None = None
|
|
141
|
+
self._course_deg: float | None = None
|
|
142
|
+
|
|
143
|
+
if fallback_lat is not None and fallback_lon is not None:
|
|
144
|
+
self._kalman.update(fallback_lat, fallback_lon)
|
|
145
|
+
|
|
146
|
+
async def get_location(self) -> Location:
|
|
147
|
+
return self._location
|
|
148
|
+
|
|
149
|
+
async def start(self) -> None:
|
|
150
|
+
import serial_asyncio
|
|
151
|
+
|
|
152
|
+
self._reader, self._writer = await serial_asyncio.open_serial_connection(
|
|
153
|
+
url=self._port, baudrate=self._baud_rate,
|
|
154
|
+
)
|
|
155
|
+
self._running = True
|
|
156
|
+
self._location.gps_state = "searching"
|
|
157
|
+
logger.info("Serial GPS started", port=self._port, baud=self._baud_rate)
|
|
158
|
+
asyncio.create_task(self._read_loop())
|
|
159
|
+
|
|
160
|
+
async def _read_loop(self) -> None:
|
|
161
|
+
while self._running and self._reader:
|
|
162
|
+
try:
|
|
163
|
+
line = await asyncio.wait_for(self._reader.readline(), timeout=5.0)
|
|
164
|
+
sentence = line.decode("ascii", errors="ignore").strip()
|
|
165
|
+
if sentence.startswith(("$GPGGA", "$GNGGA")):
|
|
166
|
+
self._parse_gga(sentence)
|
|
167
|
+
elif sentence.startswith(("$GPRMC", "$GNRMC")):
|
|
168
|
+
self._parse_rmc(sentence)
|
|
169
|
+
except asyncio.TimeoutError:
|
|
170
|
+
logger.debug("GPS read timeout")
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.warning("GPS read error", error=str(e))
|
|
173
|
+
await asyncio.sleep(0.1)
|
|
174
|
+
|
|
175
|
+
def _parse_gga(self, sentence: str) -> None:
|
|
176
|
+
try:
|
|
177
|
+
if "*" in sentence:
|
|
178
|
+
sentence = sentence.split("*")[0]
|
|
179
|
+
parts = sentence.split(",")
|
|
180
|
+
if len(parts) < 10:
|
|
181
|
+
return
|
|
182
|
+
quality = int(parts[6]) if parts[6] else 0
|
|
183
|
+
if quality == 0:
|
|
184
|
+
return
|
|
185
|
+
self._sat_count = int(parts[7]) if parts[7] else None
|
|
186
|
+
self._hdop = float(parts[8]) if parts[8] else None
|
|
187
|
+
self._altitude = float(parts[9]) if parts[9] else None
|
|
188
|
+
except (ValueError, IndexError):
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
def _parse_rmc(self, sentence: str) -> None:
|
|
192
|
+
try:
|
|
193
|
+
if "*" in sentence:
|
|
194
|
+
sentence = sentence.split("*")[0]
|
|
195
|
+
parts = sentence.split(",")
|
|
196
|
+
if len(parts) < 7 or parts[2] != "A":
|
|
197
|
+
return
|
|
198
|
+
lat = parse_nmea_coordinate(parts[3], parts[4])
|
|
199
|
+
lon = parse_nmea_coordinate(parts[5], parts[6])
|
|
200
|
+
if len(parts) > 7 and parts[7]:
|
|
201
|
+
self._speed_ms = float(parts[7]) * 0.514444
|
|
202
|
+
if len(parts) > 8 and parts[8]:
|
|
203
|
+
self._course_deg = float(parts[8])
|
|
204
|
+
if lat is not None and lon is not None:
|
|
205
|
+
filtered_lat, filtered_lon = self._kalman.update(lat, lon)
|
|
206
|
+
self._fix_count += 1
|
|
207
|
+
self._location = Location(
|
|
208
|
+
latitude=filtered_lat,
|
|
209
|
+
longitude=filtered_lon,
|
|
210
|
+
altitude=self._altitude,
|
|
211
|
+
source="gps",
|
|
212
|
+
gps_state="fix",
|
|
213
|
+
fix_count=self._fix_count,
|
|
214
|
+
hdop=self._hdop,
|
|
215
|
+
sat_count=self._sat_count,
|
|
216
|
+
speed_ms=self._speed_ms,
|
|
217
|
+
course_deg=self._course_deg,
|
|
218
|
+
)
|
|
219
|
+
if self._fix_count <= 5 or self._fix_count % 60 == 0:
|
|
220
|
+
logger.info(
|
|
221
|
+
"GPS fix",
|
|
222
|
+
fix=self._fix_count,
|
|
223
|
+
lat=round(filtered_lat, 6),
|
|
224
|
+
lon=round(filtered_lon, 6),
|
|
225
|
+
sats=self._sat_count,
|
|
226
|
+
hdop=self._hdop,
|
|
227
|
+
)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logger.warning("Failed to parse RMC", error=str(e))
|
|
230
|
+
|
|
231
|
+
async def stop(self) -> None:
|
|
232
|
+
self._running = False
|
|
233
|
+
if self._writer:
|
|
234
|
+
self._writer.close()
|
|
235
|
+
logger.info("Serial GPS stopped", fixes=self._fix_count)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class UBXLocationProvider(LocationProvider):
|
|
239
|
+
"""Reads UBX NAV-PVT from a u-blox receiver (ZED-F9P, NEO-M9N)."""
|
|
240
|
+
|
|
241
|
+
def __init__(
|
|
242
|
+
self,
|
|
243
|
+
port: str,
|
|
244
|
+
baud_rate: int = 38400,
|
|
245
|
+
fallback_lat: float | None = None,
|
|
246
|
+
fallback_lon: float | None = None,
|
|
247
|
+
poll_interval_s: float = 1.0,
|
|
248
|
+
) -> None:
|
|
249
|
+
self._port = port
|
|
250
|
+
self._baud_rate = baud_rate
|
|
251
|
+
self._fallback_lat = fallback_lat
|
|
252
|
+
self._fallback_lon = fallback_lon
|
|
253
|
+
self._poll_interval = poll_interval_s
|
|
254
|
+
self._reader = None
|
|
255
|
+
self._running = False
|
|
256
|
+
self._fix_count = 0
|
|
257
|
+
self._location = Location(
|
|
258
|
+
latitude=fallback_lat,
|
|
259
|
+
longitude=fallback_lon,
|
|
260
|
+
source="gps-fallback" if fallback_lat else "unknown",
|
|
261
|
+
gps_state="off",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
async def start(self) -> None:
|
|
265
|
+
from hubble_gateway.ubx import UBXReader
|
|
266
|
+
|
|
267
|
+
self._reader = UBXReader(port=self._port, baud_rate=self._baud_rate)
|
|
268
|
+
await self._reader.start()
|
|
269
|
+
self._running = True
|
|
270
|
+
self._location.gps_state = "searching"
|
|
271
|
+
asyncio.create_task(self._read_loop())
|
|
272
|
+
logger.info("UBX location provider started", port=self._port)
|
|
273
|
+
|
|
274
|
+
async def get_location(self) -> Location:
|
|
275
|
+
return self._location
|
|
276
|
+
|
|
277
|
+
async def _read_loop(self) -> None:
|
|
278
|
+
while self._running and self._reader:
|
|
279
|
+
try:
|
|
280
|
+
pvt = await self._reader.read_pvt()
|
|
281
|
+
if pvt and pvt.gnss_fix_ok and pvt.fix_type >= 2:
|
|
282
|
+
self._fix_count += 1
|
|
283
|
+
self._location = Location(
|
|
284
|
+
latitude=pvt.lat,
|
|
285
|
+
longitude=pvt.lon,
|
|
286
|
+
altitude=pvt.alt_msl_m,
|
|
287
|
+
horizontal_accuracy=pvt.hacc_m,
|
|
288
|
+
vertical_accuracy=pvt.vacc_m,
|
|
289
|
+
source="gps",
|
|
290
|
+
gps_state="fix",
|
|
291
|
+
fix_count=self._fix_count,
|
|
292
|
+
hdop=pvt.pdop,
|
|
293
|
+
sat_count=pvt.num_sv,
|
|
294
|
+
speed_ms=pvt.ground_speed_ms,
|
|
295
|
+
course_deg=pvt.heading_deg,
|
|
296
|
+
)
|
|
297
|
+
if self._fix_count <= 5 or self._fix_count % 60 == 0:
|
|
298
|
+
logger.info(
|
|
299
|
+
"UBX fix",
|
|
300
|
+
fix=self._fix_count,
|
|
301
|
+
lat=round(pvt.lat, 7),
|
|
302
|
+
lon=round(pvt.lon, 7),
|
|
303
|
+
sats=pvt.num_sv,
|
|
304
|
+
hacc=round(pvt.hacc_m, 2),
|
|
305
|
+
)
|
|
306
|
+
elif pvt:
|
|
307
|
+
self._location.gps_state = "searching"
|
|
308
|
+
self._location.sat_count = pvt.num_sv
|
|
309
|
+
except Exception as e:
|
|
310
|
+
logger.warning("UBX read error", error=str(e))
|
|
311
|
+
await asyncio.sleep(self._poll_interval)
|
|
312
|
+
|
|
313
|
+
async def stop(self) -> None:
|
|
314
|
+
self._running = False
|
|
315
|
+
if self._reader:
|
|
316
|
+
await self._reader.stop()
|
|
317
|
+
self._reader = None
|
|
318
|
+
logger.info("UBX location provider stopped", fixes=self._fix_count)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class GPSDLocationProvider(LocationProvider):
|
|
322
|
+
"""Location from the gpsd daemon (requires gpsd-py3)."""
|
|
323
|
+
|
|
324
|
+
def __init__(self) -> None:
|
|
325
|
+
self._location = Location(source="gpsd", gps_state="off")
|
|
326
|
+
self._running = False
|
|
327
|
+
|
|
328
|
+
async def get_location(self) -> Location:
|
|
329
|
+
return self._location
|
|
330
|
+
|
|
331
|
+
async def start(self) -> None:
|
|
332
|
+
try:
|
|
333
|
+
import gpsd
|
|
334
|
+
gpsd.connect()
|
|
335
|
+
self._running = True
|
|
336
|
+
logger.info("GPSD location provider started")
|
|
337
|
+
asyncio.create_task(self._update_loop())
|
|
338
|
+
except ImportError:
|
|
339
|
+
logger.warning("gpsd-py3 not installed; pip install gpsd-py3")
|
|
340
|
+
raise
|
|
341
|
+
except Exception as e:
|
|
342
|
+
logger.error("Failed to connect to gpsd", error=str(e))
|
|
343
|
+
raise
|
|
344
|
+
|
|
345
|
+
async def _update_loop(self) -> None:
|
|
346
|
+
import gpsd
|
|
347
|
+
|
|
348
|
+
while self._running:
|
|
349
|
+
try:
|
|
350
|
+
packet = gpsd.get_current()
|
|
351
|
+
if packet.mode >= 2:
|
|
352
|
+
self._location = Location(
|
|
353
|
+
latitude=packet.lat,
|
|
354
|
+
longitude=packet.lon,
|
|
355
|
+
altitude=packet.alt if packet.mode >= 3 else None,
|
|
356
|
+
horizontal_accuracy=packet.error.get("x"),
|
|
357
|
+
source="gpsd",
|
|
358
|
+
gps_state="fix",
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
self._location.gps_state = "searching"
|
|
362
|
+
except Exception as e:
|
|
363
|
+
logger.warning("GPSD error", error=str(e))
|
|
364
|
+
await asyncio.sleep(1.0)
|
|
365
|
+
|
|
366
|
+
async def stop(self) -> None:
|
|
367
|
+
self._running = False
|
|
368
|
+
logger.info("GPSD stopped")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
async def create_location_provider(
|
|
372
|
+
gps_enabled: bool = False,
|
|
373
|
+
fixed_lat: float | None = None,
|
|
374
|
+
fixed_lon: float | None = None,
|
|
375
|
+
gps_port: str | None = None,
|
|
376
|
+
gps_baud_rate: int = 9600,
|
|
377
|
+
gps_module: str = "nmea",
|
|
378
|
+
) -> LocationProvider:
|
|
379
|
+
"""Create the appropriate location provider.
|
|
380
|
+
|
|
381
|
+
Priority when gps_enabled:
|
|
382
|
+
1. UBX (gps_module="zed_f9p" and serial port exists)
|
|
383
|
+
2. Serial NMEA (gps_module="nmea" and serial port exists)
|
|
384
|
+
3. gpsd
|
|
385
|
+
4. Fixed fallback
|
|
386
|
+
"""
|
|
387
|
+
if gps_enabled and platform.system() == "Linux":
|
|
388
|
+
if gps_module == "zed_f9p" and gps_port and os.path.exists(gps_port):
|
|
389
|
+
try:
|
|
390
|
+
provider = UBXLocationProvider(
|
|
391
|
+
port=gps_port,
|
|
392
|
+
baud_rate=gps_baud_rate,
|
|
393
|
+
fallback_lat=fixed_lat,
|
|
394
|
+
fallback_lon=fixed_lon,
|
|
395
|
+
)
|
|
396
|
+
await provider.start()
|
|
397
|
+
return provider
|
|
398
|
+
except Exception as e:
|
|
399
|
+
logger.warning("UBX GPS unavailable, trying NMEA", error=str(e))
|
|
400
|
+
|
|
401
|
+
if gps_port and os.path.exists(gps_port):
|
|
402
|
+
try:
|
|
403
|
+
provider = SerialGPSLocationProvider(
|
|
404
|
+
port=gps_port,
|
|
405
|
+
baud_rate=gps_baud_rate,
|
|
406
|
+
fallback_lat=fixed_lat,
|
|
407
|
+
fallback_lon=fixed_lon,
|
|
408
|
+
)
|
|
409
|
+
await provider.start()
|
|
410
|
+
return provider
|
|
411
|
+
except Exception as e:
|
|
412
|
+
logger.warning("Serial GPS unavailable, trying gpsd", error=str(e))
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
provider = GPSDLocationProvider()
|
|
416
|
+
await provider.start()
|
|
417
|
+
return provider
|
|
418
|
+
except Exception:
|
|
419
|
+
logger.warning("gpsd unavailable, using fixed location")
|
|
420
|
+
|
|
421
|
+
provider = FixedLocationProvider(fixed_lat, fixed_lon)
|
|
422
|
+
await provider.start()
|
|
423
|
+
return provider
|
hubble_gateway/models.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Data models for BLE packets and locations."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def uuid_to_16bit(full_uuid: str) -> str:
|
|
11
|
+
"""Extract 16-bit short form from a full 128-bit BLE UUID.
|
|
12
|
+
|
|
13
|
+
``0000FCA6-0000-1000-8000-00805F9B34FB`` -> ``FCA6``
|
|
14
|
+
"""
|
|
15
|
+
return full_uuid[4:8].upper()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Location(BaseModel):
|
|
19
|
+
"""Geographic location."""
|
|
20
|
+
|
|
21
|
+
latitude: float | None = None
|
|
22
|
+
longitude: float | None = None
|
|
23
|
+
altitude: float | None = None
|
|
24
|
+
horizontal_accuracy: float | None = None
|
|
25
|
+
vertical_accuracy: float | None = None
|
|
26
|
+
source: str = "unknown"
|
|
27
|
+
gps_state: str = "off"
|
|
28
|
+
fix_count: int = 0
|
|
29
|
+
hdop: float | None = None
|
|
30
|
+
sat_count: int | None = None
|
|
31
|
+
speed_ms: float | None = None
|
|
32
|
+
course_deg: float | None = None
|
|
33
|
+
|
|
34
|
+
def to_gateway_dict(self, timestamp_ms: int | None = None) -> dict[str, Any]:
|
|
35
|
+
"""Convert to gateway-api location format."""
|
|
36
|
+
result: dict[str, Any] = {
|
|
37
|
+
"latitude": self.latitude if self.latitude is not None else 0.0,
|
|
38
|
+
"longitude": self.longitude if self.longitude is not None else 0.0,
|
|
39
|
+
"horizontalAccuracyMeters": (
|
|
40
|
+
self.horizontal_accuracy if self.horizontal_accuracy is not None else 10.0
|
|
41
|
+
),
|
|
42
|
+
"timestampMillis": timestamp_ms or int(datetime.now(timezone.utc).timestamp() * 1000),
|
|
43
|
+
}
|
|
44
|
+
if self.altitude is not None:
|
|
45
|
+
result["altitude"] = self.altitude
|
|
46
|
+
if self.vertical_accuracy is not None:
|
|
47
|
+
result["verticalAccuracyMeters"] = self.vertical_accuracy
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BLEPacket(BaseModel):
|
|
52
|
+
"""A single BLE advertisement packet."""
|
|
53
|
+
|
|
54
|
+
device_address: str = Field(description="BLE device MAC address")
|
|
55
|
+
device_name: str | None = Field(default=None)
|
|
56
|
+
rssi: int = Field(description="RSSI in dBm")
|
|
57
|
+
service_uuids: list[str] = Field(default_factory=list)
|
|
58
|
+
manufacturer_data: dict[int, bytes] = Field(default_factory=dict)
|
|
59
|
+
service_data: dict[str, bytes] = Field(default_factory=dict)
|
|
60
|
+
tx_power: int | None = Field(default=None)
|
|
61
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
62
|
+
service_uuid: str | None = Field(
|
|
63
|
+
default=None,
|
|
64
|
+
description="Matched service UUID (set by scanner)",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
class Config:
|
|
68
|
+
json_encoders = {
|
|
69
|
+
bytes: lambda v: v.hex(),
|
|
70
|
+
datetime: lambda v: v.isoformat(),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
def get_matched_service_data(self) -> bytes | None:
|
|
74
|
+
"""Get service data for the matched service UUID."""
|
|
75
|
+
if self.service_uuid:
|
|
76
|
+
target = self.service_uuid.lower()
|
|
77
|
+
for uid, data in self.service_data.items():
|
|
78
|
+
if uid.lower() == target:
|
|
79
|
+
return data
|
|
80
|
+
for uid, data in self.service_data.items():
|
|
81
|
+
return data
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def service_data_hex(self) -> str:
|
|
86
|
+
"""Hex of matched service data (dedup key component)."""
|
|
87
|
+
data = self.get_matched_service_data()
|
|
88
|
+
return data.hex() if data else ""
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def service_uuid_16bit(self) -> str | None:
|
|
92
|
+
"""16-bit short form of matched UUID (e.g. 'FCA6')."""
|
|
93
|
+
if self.service_uuid:
|
|
94
|
+
return uuid_to_16bit(self.service_uuid)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def to_gateway_packet(
|
|
98
|
+
self, location: Location | None = None, sighting_count: int = 1
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
"""Convert to gateway-api packet format."""
|
|
101
|
+
payload_bytes = self.get_matched_service_data()
|
|
102
|
+
service_data_b64 = (
|
|
103
|
+
base64.b64encode(payload_bytes).decode() if payload_bytes else None
|
|
104
|
+
)
|
|
105
|
+
ts_ms = int(self.timestamp.timestamp() * 1000)
|
|
106
|
+
|
|
107
|
+
packet: dict[str, Any] = {
|
|
108
|
+
"deviceAddress": self.device_address,
|
|
109
|
+
"rssi": self.rssi,
|
|
110
|
+
"timestampMillis": ts_ms,
|
|
111
|
+
"serviceUuid": self.service_uuid_16bit,
|
|
112
|
+
"sightingCount": sighting_count,
|
|
113
|
+
}
|
|
114
|
+
if service_data_b64:
|
|
115
|
+
packet["serviceData"] = service_data_b64
|
|
116
|
+
if location:
|
|
117
|
+
packet["location"] = location.to_gateway_dict(timestamp_ms=ts_ms)
|
|
118
|
+
return packet
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""BLE scanner using bleak with multi-UUID filtering."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
from bleak import BleakScanner
|
|
9
|
+
from bleak.backends.device import BLEDevice
|
|
10
|
+
from bleak.backends.scanner import AdvertisementData
|
|
11
|
+
|
|
12
|
+
from hubble_gateway.config import DEFAULT_SERVICE_UUIDS
|
|
13
|
+
from hubble_gateway.models import BLEPacket, uuid_to_16bit
|
|
14
|
+
|
|
15
|
+
logger = structlog.get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Scanner:
|
|
19
|
+
"""Continuous BLE scanner that filters by service UUID(s).
|
|
20
|
+
|
|
21
|
+
Supports scanning for multiple UUIDs simultaneously (e.g. Hubble FCA6
|
|
22
|
+
and Tile FEED). Works with both internal BLE adapters and external USB
|
|
23
|
+
dongles — pass ``adapter='hci1'`` to select a specific adapter.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
service_uuids: list[str] | None = None,
|
|
29
|
+
scan_duration: float = 5.0,
|
|
30
|
+
on_packet: Callable[[BLEPacket], None] | None = None,
|
|
31
|
+
adapter: str | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._service_uuids = [u.lower() for u in (service_uuids or DEFAULT_SERVICE_UUIDS)]
|
|
34
|
+
self._scan_duration = scan_duration
|
|
35
|
+
self._on_packet = on_packet
|
|
36
|
+
self._adapter = adapter or None
|
|
37
|
+
self._running = False
|
|
38
|
+
self._paused = False
|
|
39
|
+
self._seen_devices: set[str] = set()
|
|
40
|
+
self._devices_this_scan: set[str] = set()
|
|
41
|
+
self._total_packets = 0
|
|
42
|
+
|
|
43
|
+
self._uuid_variants: dict[str, str] = {}
|
|
44
|
+
for full_uuid in self._service_uuids:
|
|
45
|
+
short = full_uuid[4:8]
|
|
46
|
+
canonical = f"0000{short}-0000-1000-8000-00805f9b34fb"
|
|
47
|
+
self._uuid_variants[full_uuid] = full_uuid
|
|
48
|
+
self._uuid_variants[short] = full_uuid
|
|
49
|
+
self._uuid_variants[canonical] = full_uuid
|
|
50
|
+
|
|
51
|
+
def _match_service_uuid(self, advertised_uuids: list[str]) -> str | None:
|
|
52
|
+
for adv_uuid in advertised_uuids:
|
|
53
|
+
key = adv_uuid.lower()
|
|
54
|
+
if key in self._uuid_variants:
|
|
55
|
+
return self._uuid_variants[key]
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def _detection_callback(
|
|
59
|
+
self, device: BLEDevice, advertisement_data: AdvertisementData
|
|
60
|
+
) -> None:
|
|
61
|
+
adv_uuids = [str(u).lower() for u in advertisement_data.service_uuids or []]
|
|
62
|
+
matched_uuid = self._match_service_uuid(adv_uuids)
|
|
63
|
+
if matched_uuid is None:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
addr = device.address
|
|
67
|
+
if addr not in self._seen_devices:
|
|
68
|
+
self._seen_devices.add(addr)
|
|
69
|
+
self._devices_this_scan.add(addr)
|
|
70
|
+
self._total_packets += 1
|
|
71
|
+
|
|
72
|
+
packet = BLEPacket(
|
|
73
|
+
device_address=addr,
|
|
74
|
+
device_name=device.name or advertisement_data.local_name,
|
|
75
|
+
rssi=advertisement_data.rssi,
|
|
76
|
+
service_uuids=adv_uuids,
|
|
77
|
+
manufacturer_data=dict(advertisement_data.manufacturer_data or {}),
|
|
78
|
+
service_data={str(k): v for k, v in (advertisement_data.service_data or {}).items()},
|
|
79
|
+
tx_power=advertisement_data.tx_power,
|
|
80
|
+
timestamp=datetime.now(timezone.utc),
|
|
81
|
+
service_uuid=matched_uuid,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
short = uuid_to_16bit(matched_uuid)
|
|
85
|
+
logger.debug(
|
|
86
|
+
"BLE packet",
|
|
87
|
+
uuid=short,
|
|
88
|
+
addr=addr[-8:],
|
|
89
|
+
rssi=packet.rssi,
|
|
90
|
+
name=packet.device_name,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if self._on_packet:
|
|
94
|
+
self._on_packet(packet)
|
|
95
|
+
|
|
96
|
+
async def start(self) -> None:
|
|
97
|
+
self._running = True
|
|
98
|
+
uuid_labels = [uuid_to_16bit(u) for u in self._service_uuids]
|
|
99
|
+
logger.info(
|
|
100
|
+
"Scanner started",
|
|
101
|
+
service_uuids=uuid_labels,
|
|
102
|
+
scan_duration=self._scan_duration,
|
|
103
|
+
adapter=self._adapter or "default",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
scanner_kwargs: dict = {
|
|
107
|
+
"detection_callback": self._detection_callback,
|
|
108
|
+
"service_uuids": self._service_uuids,
|
|
109
|
+
}
|
|
110
|
+
if self._adapter:
|
|
111
|
+
scanner_kwargs["adapter"] = self._adapter
|
|
112
|
+
|
|
113
|
+
while self._running:
|
|
114
|
+
if self._paused:
|
|
115
|
+
await asyncio.sleep(0.5)
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
self._devices_this_scan.clear()
|
|
119
|
+
async with BleakScanner(**scanner_kwargs) as _scanner:
|
|
120
|
+
await asyncio.sleep(self._scan_duration)
|
|
121
|
+
logger.debug(
|
|
122
|
+
"Scan cycle",
|
|
123
|
+
devices=len(self._devices_this_scan),
|
|
124
|
+
total_unique=len(self._seen_devices),
|
|
125
|
+
total_packets=self._total_packets,
|
|
126
|
+
)
|
|
127
|
+
except asyncio.CancelledError:
|
|
128
|
+
break
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.error("Scanner error", error=str(e))
|
|
131
|
+
await asyncio.sleep(2.0)
|
|
132
|
+
|
|
133
|
+
logger.info("Scanner stopped", total_devices=len(self._seen_devices))
|
|
134
|
+
|
|
135
|
+
async def stop(self) -> None:
|
|
136
|
+
self._running = False
|
|
137
|
+
|
|
138
|
+
def pause(self) -> None:
|
|
139
|
+
self._paused = True
|
|
140
|
+
|
|
141
|
+
def resume(self) -> None:
|
|
142
|
+
self._paused = False
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def is_running(self) -> bool:
|
|
146
|
+
return self._running
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def stats(self) -> dict[str, int]:
|
|
150
|
+
return {
|
|
151
|
+
"total_packets": self._total_packets,
|
|
152
|
+
"unique_devices": len(self._seen_devices),
|
|
153
|
+
"active_devices": len(self._devices_this_scan),
|
|
154
|
+
}
|