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,202 @@
1
+ """Packet sender: batches, deduplicates, and uploads BLE sightings."""
2
+
3
+ import asyncio
4
+ import time
5
+ import uuid as uuid_mod
6
+ from collections.abc import Awaitable, Callable
7
+ from typing import Any
8
+
9
+ import structlog
10
+
11
+ from hubble_gateway.auth import GatewayAuth
12
+ from hubble_gateway.models import BLEPacket, Location
13
+
14
+ logger = structlog.get_logger(__name__)
15
+
16
+
17
+ class _DedupEntry:
18
+ __slots__ = ("packet", "strongest_rssi", "sighting_count", "first_seen_ms")
19
+
20
+ def __init__(self, packet: BLEPacket) -> None:
21
+ self.packet = packet
22
+ self.strongest_rssi = packet.rssi
23
+ self.sighting_count = 1
24
+ self.first_seen_ms = int(packet.timestamp.timestamp() * 1000)
25
+
26
+ def merge(self, packet: BLEPacket) -> None:
27
+ self.sighting_count += 1
28
+ if packet.rssi > self.strongest_rssi:
29
+ self.strongest_rssi = packet.rssi
30
+ self.packet = packet
31
+
32
+
33
+ class GatewaySender:
34
+ """Batches and uploads BLE sightings to the gateway API."""
35
+
36
+ def __init__(
37
+ self,
38
+ auth: GatewayAuth,
39
+ get_location: Callable[[], Awaitable[Location]] | None = None,
40
+ batch_size: int = 500,
41
+ upload_interval_s: float = 5.0,
42
+ dedup_window_s: float = 300.0,
43
+ ) -> None:
44
+ self._auth = auth
45
+ self._get_location = get_location
46
+ self._batch_size = batch_size
47
+ self._upload_interval = upload_interval_s
48
+ self._dedup_window = dedup_window_s
49
+ self._dedup_map: dict[tuple[str, str | None, str], _DedupEntry] = {}
50
+ self._dedup_lock = asyncio.Lock()
51
+ self._running = False
52
+ self._stats_sends = 0
53
+ self._stats_total = 0
54
+ self._stats_uploaded = 0
55
+ self._stats_devices: set[str] = set()
56
+
57
+ async def start(self) -> None:
58
+ self._running = True
59
+ self._apply_server_config()
60
+ asyncio.create_task(self._upload_loop())
61
+ if self._get_location:
62
+ asyncio.create_task(self._location_loop())
63
+ logger.info(
64
+ "Sender started",
65
+ gateway_id=self._auth.gateway_id,
66
+ upload_interval=self._upload_interval,
67
+ dedup_window=self._dedup_window,
68
+ batch_size=self._batch_size,
69
+ )
70
+
71
+ async def stop(self) -> None:
72
+ self._running = False
73
+ await self._flush()
74
+ logger.info(
75
+ "Sender stopped",
76
+ total_packets=self._stats_total,
77
+ uploaded=self._stats_uploaded,
78
+ sends=self._stats_sends,
79
+ unique_devices=len(self._stats_devices),
80
+ )
81
+
82
+ def add_packet(self, packet: BLEPacket) -> None:
83
+ self._stats_total += 1
84
+ self._stats_devices.add(packet.device_address)
85
+ key = (packet.device_address, packet.service_uuid_16bit, packet.service_data_hex)
86
+ existing = self._dedup_map.get(key)
87
+ if existing is not None:
88
+ age_ms = int(packet.timestamp.timestamp() * 1000) - existing.first_seen_ms
89
+ if age_ms < self._dedup_window * 1000:
90
+ existing.merge(packet)
91
+ return
92
+ self._dedup_map[key] = _DedupEntry(packet)
93
+
94
+ @property
95
+ def stats(self) -> dict[str, int]:
96
+ return {
97
+ "total_packets": self._stats_total,
98
+ "uploaded_packets": self._stats_uploaded,
99
+ "sends": self._stats_sends,
100
+ "unique_devices": len(self._stats_devices),
101
+ "buffer_size": len(self._dedup_map),
102
+ }
103
+
104
+ async def _upload_loop(self) -> None:
105
+ while self._running:
106
+ await asyncio.sleep(self._upload_interval)
107
+ if not self._dedup_map:
108
+ continue
109
+ await self._flush()
110
+
111
+ async def _location_loop(self) -> None:
112
+ _INTERVAL = 60.0
113
+ while self._running:
114
+ await asyncio.sleep(_INTERVAL)
115
+ if not self._get_location:
116
+ continue
117
+ try:
118
+ location = await self._get_location()
119
+ if location.latitude is not None and location.longitude is not None:
120
+ await self._upload_location(location)
121
+ except Exception as e:
122
+ logger.warning("Location upload error", error=str(e))
123
+
124
+ async def _flush(self) -> None:
125
+ async with self._dedup_lock:
126
+ if not self._dedup_map:
127
+ return
128
+ entries = list(self._dedup_map.values())
129
+ self._dedup_map.clear()
130
+
131
+ location = Location()
132
+ if self._get_location:
133
+ try:
134
+ location = await self._get_location()
135
+ except Exception as e:
136
+ logger.warning("Failed to get location", error=str(e))
137
+
138
+ for i in range(0, len(entries), self._batch_size):
139
+ batch = entries[i : i + self._batch_size]
140
+ await self._upload_batch(batch, location)
141
+
142
+ async def _upload_batch(self, entries: list[_DedupEntry], location: Location) -> None:
143
+ gateway_id = self._auth.gateway_id
144
+ if not gateway_id:
145
+ return
146
+
147
+ batch_id = str(uuid_mod.uuid4())
148
+ packets_json = []
149
+ for entry in entries:
150
+ pkt = entry.packet.to_gateway_packet(
151
+ location=location, sighting_count=entry.sighting_count,
152
+ )
153
+ pkt["rssi"] = entry.strongest_rssi
154
+ packets_json.append(pkt)
155
+
156
+ body: dict[str, Any] = {
157
+ "batchId": batch_id,
158
+ "sentAtMillis": int(time.time() * 1000),
159
+ "packets": packets_json,
160
+ }
161
+
162
+ try:
163
+ resp = await self._auth.authenticated_request(
164
+ "POST", f"/api/v1/gateways/{gateway_id}/packets", json_body=body,
165
+ )
166
+ if resp.status_code in (200, 201, 202):
167
+ self._stats_sends += 1
168
+ self._stats_uploaded += len(entries)
169
+ sightings = sum(e.sighting_count for e in entries)
170
+ logger.info(
171
+ "Batch uploaded",
172
+ batch_id=batch_id[:8],
173
+ packets=len(entries),
174
+ sightings=sightings,
175
+ )
176
+ else:
177
+ logger.warning("Upload failed", status=resp.status_code, body=resp.text[:200])
178
+ except Exception as e:
179
+ logger.error("Upload error", error=str(e))
180
+
181
+ async def _upload_location(self, location: Location) -> None:
182
+ gateway_id = self._auth.gateway_id
183
+ if not gateway_id:
184
+ return
185
+ body: dict[str, Any] = {
186
+ "batchId": str(uuid_mod.uuid4()),
187
+ "sentAtMillis": int(time.time() * 1000),
188
+ "locations": [location.to_gateway_dict()],
189
+ }
190
+ try:
191
+ await self._auth.authenticated_request(
192
+ "POST", f"/api/v1/gateways/{gateway_id}/locations", json_body=body,
193
+ )
194
+ except Exception as e:
195
+ logger.warning("Location upload failed", error=str(e))
196
+
197
+ def _apply_server_config(self) -> None:
198
+ cfg = self._auth.server_config
199
+ if "uploadBatchSize" in cfg:
200
+ self._batch_size = min(int(cfg["uploadBatchSize"]), 500)
201
+ if "uploadIntervalMs" in cfg:
202
+ self._upload_interval = max(int(cfg["uploadIntervalMs"]) / 1000, 1)
hubble_gateway/ubx.py ADDED
@@ -0,0 +1,230 @@
1
+ """Async UBX NAV-PVT reader for u-blox receivers (ZED-F9P, NEO-M9N, etc.)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import struct
7
+ from dataclasses import dataclass
8
+
9
+ import structlog
10
+
11
+ logger = structlog.get_logger(__name__)
12
+
13
+ UBX_SYNC_1 = 0xB5
14
+ UBX_SYNC_2 = 0x62
15
+ NAV_CLASS = 0x01
16
+ NAV_PVT_ID = 0x07
17
+ NAV_PVT_PAYLOAD_LEN = 92
18
+ READ_PVT_TIMEOUT_S = 10.0
19
+
20
+
21
+ def _ubx_checksum(data: bytes) -> tuple[int, int]:
22
+ ck_a = 0
23
+ ck_b = 0
24
+ for byte in data:
25
+ ck_a = (ck_a + byte) & 0xFF
26
+ ck_b = (ck_b + ck_a) & 0xFF
27
+ return ck_a, ck_b
28
+
29
+
30
+ def _ubx_frame(msg_class: int, msg_id: int, payload: bytes) -> bytes:
31
+ header = struct.pack("<BBH", msg_class, msg_id, len(payload))
32
+ body = header + payload
33
+ ck_a, ck_b = _ubx_checksum(body)
34
+ return b"\xb5\x62" + body + bytes([ck_a, ck_b])
35
+
36
+
37
+ NAV_PVT_POLL = _ubx_frame(NAV_CLASS, NAV_PVT_ID, b"")
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class UBXNavPVT:
42
+ itow_ms: int
43
+ year: int
44
+ month: int
45
+ day: int
46
+ hour: int
47
+ minute: int
48
+ second: int
49
+ valid: int
50
+ valid_date: bool
51
+ valid_time: bool
52
+ fix_type: int
53
+ flags: int
54
+ gnss_fix_ok: bool
55
+ num_sv: int
56
+ lat: float
57
+ lon: float
58
+ height_ellipsoid_m: float
59
+ alt_msl_m: float
60
+ hacc_m: float
61
+ vacc_m: float
62
+ ground_speed_ms: float
63
+ heading_deg: float
64
+ pdop: float
65
+
66
+
67
+ def _parse_nav_pvt_payload(payload: bytes) -> UBXNavPVT:
68
+ if len(payload) != NAV_PVT_PAYLOAD_LEN:
69
+ raise ValueError(f"NAV-PVT payload length {len(payload)}, expected {NAV_PVT_PAYLOAD_LEN}")
70
+
71
+ itow_ms = struct.unpack_from("<I", payload, 0)[0]
72
+ year = struct.unpack_from("<H", payload, 4)[0]
73
+ month, day, hour, minute, second, valid = struct.unpack_from("<BBBBBB", payload, 6)
74
+ fix_type = payload[20]
75
+ flags = payload[21]
76
+ num_sv = payload[23]
77
+ lon_i, lat_i, height_mm, hmsl_mm = struct.unpack_from("<iiii", payload, 24)
78
+ hacc_mm, vacc_mm = struct.unpack_from("<II", payload, 40)
79
+ g_speed_mm_s, head_mot = struct.unpack_from("<ii", payload, 60)
80
+ pdop_raw = struct.unpack_from("<H", payload, 76)[0]
81
+
82
+ return UBXNavPVT(
83
+ itow_ms=itow_ms, year=year, month=month, day=day,
84
+ hour=hour, minute=minute, second=second, valid=valid,
85
+ valid_date=bool(valid & 0x01), valid_time=bool(valid & 0x02),
86
+ fix_type=fix_type, flags=flags, gnss_fix_ok=bool(flags & 0x01),
87
+ num_sv=num_sv,
88
+ lat=lat_i / 1e7, lon=lon_i / 1e7,
89
+ height_ellipsoid_m=height_mm / 1000.0, alt_msl_m=hmsl_mm / 1000.0,
90
+ hacc_m=hacc_mm / 1000.0, vacc_m=vacc_mm / 1000.0,
91
+ ground_speed_ms=g_speed_mm_s / 1000.0, heading_deg=head_mot / 1e5,
92
+ pdop=pdop_raw / 100.0,
93
+ )
94
+
95
+
96
+ class UBXReader:
97
+ """Async UBX reader that polls NAV-PVT from a serial port."""
98
+
99
+ def __init__(self, port: str, baud_rate: int = 38400) -> None:
100
+ self._port = port
101
+ self._baud_rate = baud_rate
102
+ self._reader: asyncio.StreamReader | None = None
103
+ self._writer: asyncio.StreamWriter | None = None
104
+ self._buf = bytearray()
105
+
106
+ async def start(self) -> None:
107
+ import serial_asyncio
108
+
109
+ self._reader, self._writer = await serial_asyncio.open_serial_connection(
110
+ url=self._port, baudrate=self._baud_rate,
111
+ )
112
+ self._buf.clear()
113
+ logger.info("UBX reader connected", port=self._port, baud_rate=self._baud_rate)
114
+ await self._configure_uart()
115
+
116
+ async def _configure_uart(self) -> None:
117
+ """Enable UBX NAV-PVT output on UART (persisted to flash)."""
118
+ if self._writer is None:
119
+ return
120
+ kv = struct.pack("<IB", 0x10750001, 1) # CFG-UART2OUTPROT-UBX = true
121
+ kv += struct.pack("<IB", 0x20910008, 1) # CFG-MSGOUT-UBX_NAV_PVT_UART2 = 1
122
+ payload = struct.pack("<BBH", 0x00, 0x07, 0x0000) + kv
123
+ frame = _ubx_frame(0x06, 0x8A, payload)
124
+ self._writer.write(frame)
125
+ await self._writer.drain()
126
+ await asyncio.sleep(0.2)
127
+ logger.info("UBX UART configured")
128
+
129
+ def _poll_pvt(self) -> None:
130
+ if self._writer is not None:
131
+ self._writer.write(NAV_PVT_POLL)
132
+
133
+ async def read_pvt(self) -> UBXNavPVT | None:
134
+ if self._reader is None:
135
+ raise RuntimeError("call start() first")
136
+
137
+ self._poll_pvt()
138
+ loop = asyncio.get_running_loop()
139
+ deadline = loop.time() + READ_PVT_TIMEOUT_S
140
+
141
+ while True:
142
+ remaining = deadline - loop.time()
143
+ if remaining <= 0:
144
+ return None
145
+
146
+ while len(self._buf) < 2:
147
+ chunk = await self._read_chunk(4096, remaining)
148
+ if not chunk:
149
+ return None
150
+ self._buf.extend(chunk)
151
+ remaining = deadline - loop.time()
152
+ if remaining <= 0:
153
+ return None
154
+
155
+ sync_at = self._buf.find(bytes([UBX_SYNC_1, UBX_SYNC_2]))
156
+ if sync_at < 0:
157
+ if len(self._buf) > 1:
158
+ del self._buf[:-1]
159
+ elif len(self._buf) == 1 and self._buf[0] != UBX_SYNC_1:
160
+ self._buf.clear()
161
+ continue
162
+
163
+ if sync_at > 0:
164
+ del self._buf[:sync_at]
165
+
166
+ need = 6
167
+ while len(self._buf) < need:
168
+ chunk = await self._read_chunk(need - len(self._buf), remaining)
169
+ if not chunk:
170
+ return None
171
+ self._buf.extend(chunk)
172
+ remaining = deadline - loop.time()
173
+ if remaining <= 0:
174
+ return None
175
+
176
+ payload_len = struct.unpack_from("<H", self._buf, 4)[0]
177
+ frame_len = 6 + payload_len + 2
178
+
179
+ while len(self._buf) < frame_len:
180
+ chunk = await self._read_chunk(frame_len - len(self._buf), remaining)
181
+ if not chunk:
182
+ return None
183
+ self._buf.extend(chunk)
184
+ remaining = deadline - loop.time()
185
+ if remaining <= 0:
186
+ return None
187
+
188
+ body = bytes(self._buf[2:6 + payload_len])
189
+ ck_a, ck_b = self._buf[6 + payload_len], self._buf[6 + payload_len + 1]
190
+ exp_a, exp_b = _ubx_checksum(body)
191
+
192
+ if ck_a != exp_a or ck_b != exp_b:
193
+ del self._buf[0]
194
+ continue
195
+
196
+ del self._buf[:frame_len]
197
+
198
+ msg_class = body[0]
199
+ msg_id = body[1]
200
+ if (
201
+ msg_class == NAV_CLASS
202
+ and msg_id == NAV_PVT_ID
203
+ and payload_len == NAV_PVT_PAYLOAD_LEN
204
+ ):
205
+ try:
206
+ return _parse_nav_pvt_payload(body[4:])
207
+ except ValueError:
208
+ return None
209
+
210
+ async def _read_chunk(self, max_bytes: int, timeout_s: float) -> bytes | None:
211
+ if self._reader is None:
212
+ return None
213
+ try:
214
+ return await asyncio.wait_for(
215
+ self._reader.read(max(max_bytes, 1)), timeout=timeout_s
216
+ )
217
+ except asyncio.TimeoutError:
218
+ return None
219
+
220
+ async def stop(self) -> None:
221
+ if self._writer is not None:
222
+ self._writer.close()
223
+ try:
224
+ await self._writer.wait_closed()
225
+ except Exception:
226
+ pass
227
+ self._writer = None
228
+ self._reader = None
229
+ self._buf.clear()
230
+ logger.info("UBX reader stopped", port=self._port)
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: hubble-gateway-sdk-python
3
+ Version: 0.1.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: Topic :: Home Automation
21
+ Classifier: Topic :: System :: Networking
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: bleak>=0.21.0
24
+ Requires-Dist: httpx>=0.25.0
25
+ Requires-Dist: pydantic-settings>=2.1.0
26
+ Requires-Dist: pydantic>=2.5.0
27
+ Requires-Dist: structlog>=23.2.0
28
+ Provides-Extra: all
29
+ Requires-Dist: gpsd-py3>=0.3.0; extra == 'all'
30
+ Requires-Dist: pyserial-asyncio>=0.6; extra == 'all'
31
+ Provides-Extra: dev
32
+ Requires-Dist: gpsd-py3>=0.3.0; extra == 'dev'
33
+ Requires-Dist: pyserial-asyncio>=0.6; extra == 'dev'
34
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
35
+ Requires-Dist: pytest>=7.0; extra == 'dev'
36
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
37
+ Provides-Extra: gps
38
+ Requires-Dist: gpsd-py3>=0.3.0; extra == 'gps'
39
+ Requires-Dist: pyserial-asyncio>=0.6; extra == 'gps'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # hubble-gateway
43
+
44
+ Python SDK for building Hubble Network BLE gateways. Import `hubble_gateway` in your application; this package is a library only (no CLI).
@@ -0,0 +1,12 @@
1
+ hubble_gateway/__init__.py,sha256=yEpF8M0DMCqWkeRKYEzb4HRCj7qTdoXxjS2c4cnMkZ0,1148
2
+ hubble_gateway/auth.py,sha256=WVlSeuVYpkGK0I_DGRfxxx1U0HKOG47zMnwaRkefLIE,9137
3
+ hubble_gateway/config.py,sha256=OdEFHISltqQpYSJLIQa2k_jdG57s_aJaPTxMqfPS2v4,3135
4
+ hubble_gateway/location.py,sha256=RQbQWfyBv7UPCenCqR6F_vIIvFof-4AdvPfaa0ws8t8,14862
5
+ hubble_gateway/models.py,sha256=vQHGuC2RD5ph5d-Pj9G6kPurl89PKCoh_PBRDBZEXJs,4212
6
+ hubble_gateway/scanner.py,sha256=W5NXt_pHBawO0yREwtnOP4LkVO6pxkl6Z-UQm3hopko,5269
7
+ hubble_gateway/sender.py,sha256=61QlSMmATZKAJns3_0WkNOm7Tqlz3II5eGQnbBrvjfQ,7166
8
+ hubble_gateway/ubx.py,sha256=_PCfqAbkVsB-fKjR9kMTSHpYZXX9sf-PufDICBC5JUI,7424
9
+ hubble_gateway_sdk_python-0.1.0.dist-info/METADATA,sha256=iewTW8xgLOIdMqLAr-YIgqe5nTXjyS7Cq1KrwzPe7Tg,1915
10
+ hubble_gateway_sdk_python-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
11
+ hubble_gateway_sdk_python-0.1.0.dist-info/licenses/LICENSE,sha256=L6iQZR753mEQy5DSX2zByjAG1GlwCKrZDk5lifagvDI,11397
12
+ hubble_gateway_sdk_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any