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,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
@@ -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
+ }