bxp-sdk 2.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.
- bxp_binary.py +327 -0
- bxp_cli.py +860 -0
- bxp_sdk-2.1.0.dist-info/METADATA +414 -0
- bxp_sdk-2.1.0.dist-info/RECORD +9 -0
- bxp_sdk-2.1.0.dist-info/WHEEL +5 -0
- bxp_sdk-2.1.0.dist-info/entry_points.txt +9 -0
- bxp_sdk-2.1.0.dist-info/licenses/LICENSE +129 -0
- bxp_sdk-2.1.0.dist-info/top_level.txt +3 -0
- bxp_sdk.py +1077 -0
bxp_binary.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BXP Binary Container Format (spec §5.1–5.2)
|
|
3
|
+
Breathe Exposure Protocol
|
|
4
|
+
|
|
5
|
+
Implements the native binary `.bxp` container: a 32-byte fixed header
|
|
6
|
+
followed by a JSON payload (optionally gzip-compressed). This is the
|
|
7
|
+
"compact, efficient, designed for devices" counterpart to `.bxp.json`
|
|
8
|
+
described in SPEC.md section 5.1. Per spec, both representations MUST
|
|
9
|
+
be losslessly interconvertible — that round trip is what this module
|
|
10
|
+
and its tests guarantee.
|
|
11
|
+
|
|
12
|
+
Header layout (big-endian, 32 bytes total):
|
|
13
|
+
|
|
14
|
+
Offset Len Field
|
|
15
|
+
0x00 4 Magic Number 0x42585000 ("BXP\\0")
|
|
16
|
+
0x04 2 Major Version uint16
|
|
17
|
+
0x06 2 Minor Version uint16
|
|
18
|
+
0x08 1 File Type 0x01=reading 0x02=aggregate 0x03=agent
|
|
19
|
+
0x04=device 0x05=alert 0x06=meta
|
|
20
|
+
0x09 1 Flags bit0=compressed bit1=encrypted
|
|
21
|
+
bit2=signed bit3=draft
|
|
22
|
+
0x0A 2 Reserved 0x0000
|
|
23
|
+
0x0C 8 Timestamp Unix epoch microseconds, int64
|
|
24
|
+
0x14 4 Payload Length uint32, size of payload AS STORED
|
|
25
|
+
(i.e. after compression, if any)
|
|
26
|
+
0x18 4 Header Checksum CRC32 of bytes 0x00-0x17
|
|
27
|
+
0x1C 4 Payload Checksum CRC32 of the stored payload bytes
|
|
28
|
+
|
|
29
|
+
Encryption (flag bit1) is specified but its cipher/key-exchange is not
|
|
30
|
+
yet pinned down in SPEC.md — encode_bxp_binary raises NotImplementedError
|
|
31
|
+
if asked to encrypt, rather than silently producing a non-interoperable
|
|
32
|
+
file. Everything else (compression, signing flag passthrough, checksums,
|
|
33
|
+
round trip) is fully implemented.
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
from bxp_binary import encode_bxp_binary, decode_bxp_binary
|
|
37
|
+
|
|
38
|
+
raw = encode_bxp_binary(record, file_type="reading", compress=True)
|
|
39
|
+
Path("reading.bxp").write_bytes(raw)
|
|
40
|
+
|
|
41
|
+
decoded = decode_bxp_binary(Path("reading.bxp").read_bytes())
|
|
42
|
+
record = decoded["record"]
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import gzip
|
|
46
|
+
import json
|
|
47
|
+
import struct
|
|
48
|
+
import zlib
|
|
49
|
+
|
|
50
|
+
MAGIC = 0x42585000 # "BXP\0"
|
|
51
|
+
|
|
52
|
+
# The highest major version this decoder understands. Per SPEC.md §5.8,
|
|
53
|
+
# a major-version mismatch MUST be rejected rather than guessed at; a
|
|
54
|
+
# minor version higher than SUPPORTED_MINOR for a supported major MUST
|
|
55
|
+
# still be parsed (unknown fields are simply ignored/preserved, §5.7).
|
|
56
|
+
SUPPORTED_MAJOR = 2
|
|
57
|
+
|
|
58
|
+
HEADER_STRUCT = struct.Struct(">IHHBBHqI") # bytes 0x00-0x17 (24 bytes)
|
|
59
|
+
CHECKSUM_STRUCT = struct.Struct(">II") # bytes 0x18-0x1F (8 bytes)
|
|
60
|
+
HEADER_SIZE = HEADER_STRUCT.size + CHECKSUM_STRUCT.size # 32
|
|
61
|
+
|
|
62
|
+
FILE_TYPES = {
|
|
63
|
+
"reading": 0x01,
|
|
64
|
+
"aggregate": 0x02,
|
|
65
|
+
"agent": 0x03,
|
|
66
|
+
"device": 0x04,
|
|
67
|
+
"alert": 0x05,
|
|
68
|
+
"meta": 0x06,
|
|
69
|
+
}
|
|
70
|
+
FILE_TYPES_REV = {v: k for k, v in FILE_TYPES.items()}
|
|
71
|
+
|
|
72
|
+
FLAG_COMPRESSED = 1 << 0
|
|
73
|
+
FLAG_ENCRYPTED = 1 << 1
|
|
74
|
+
FLAG_SIGNED = 1 << 2
|
|
75
|
+
FLAG_DRAFT = 1 << 3
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class BXPBinaryError(ValueError):
|
|
79
|
+
"""Raised when a .bxp binary file fails to parse or verify."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _version_tuple(bxp_version: str) -> tuple:
|
|
83
|
+
parts = str(bxp_version).split(".")
|
|
84
|
+
major = int(parts[0]) if len(parts) > 0 and parts[0].isdigit() else 2
|
|
85
|
+
minor = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
|
|
86
|
+
return major, minor
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def encode_bxp_binary(
|
|
90
|
+
record: dict,
|
|
91
|
+
file_type: str = "reading",
|
|
92
|
+
compress: bool = False,
|
|
93
|
+
encrypt: bool = False,
|
|
94
|
+
signed: bool = False,
|
|
95
|
+
draft: bool = False,
|
|
96
|
+
) -> bytes:
|
|
97
|
+
"""
|
|
98
|
+
Encode a BXP record dict into the binary `.bxp` container format
|
|
99
|
+
(spec §5.2). The payload is the record's canonical JSON serialization
|
|
100
|
+
(sorted keys, tight separators — the same canonicalization write_bxp
|
|
101
|
+
uses for payloadHash), optionally gzip-compressed.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
record: A BXP reading/aggregate/agent/device/alert/meta record,
|
|
105
|
+
e.g. as returned by write_bxp() / bxp_sdk record dicts.
|
|
106
|
+
file_type: One of FILE_TYPES keys ("reading", "aggregate", "agent",
|
|
107
|
+
"device", "alert", "meta"). Defaults to "reading".
|
|
108
|
+
compress: If True, gzip-compress the JSON payload and set flag bit0.
|
|
109
|
+
encrypt: Not yet implemented (spec's encryption scheme is not
|
|
110
|
+
pinned down) — raises NotImplementedError if True.
|
|
111
|
+
signed: Sets flag bit2 to advertise a signature is present in
|
|
112
|
+
the payload's own "signature" field. Does not itself
|
|
113
|
+
compute a signature (that's the SDK/device's job).
|
|
114
|
+
draft: Sets flag bit3 (draft/unfinalized record).
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Raw bytes: 32-byte header + payload.
|
|
118
|
+
"""
|
|
119
|
+
if encrypt:
|
|
120
|
+
raise NotImplementedError(
|
|
121
|
+
"BXP binary encryption (flag bit1) is specified but its "
|
|
122
|
+
"cipher/key-exchange is not yet defined in SPEC.md; encode "
|
|
123
|
+
"an unencrypted container and encrypt the file at rest instead."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if file_type not in FILE_TYPES:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"Unknown file_type {file_type!r}; expected one of "
|
|
129
|
+
f"{sorted(FILE_TYPES)}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
major, minor = _version_tuple(record.get("bxpVersion", "2.0"))
|
|
133
|
+
|
|
134
|
+
payload_json = json.dumps(
|
|
135
|
+
record, sort_keys=True, separators=(",", ":"), default=str
|
|
136
|
+
).encode("utf-8")
|
|
137
|
+
|
|
138
|
+
flags = 0
|
|
139
|
+
if compress:
|
|
140
|
+
payload = gzip.compress(payload_json, mtime=0)
|
|
141
|
+
flags |= FLAG_COMPRESSED
|
|
142
|
+
else:
|
|
143
|
+
payload = payload_json
|
|
144
|
+
if signed:
|
|
145
|
+
flags |= FLAG_SIGNED
|
|
146
|
+
if draft:
|
|
147
|
+
flags |= FLAG_DRAFT
|
|
148
|
+
|
|
149
|
+
ts_us = int(record.get("timestampUs") or 0)
|
|
150
|
+
|
|
151
|
+
header_body = HEADER_STRUCT.pack(
|
|
152
|
+
MAGIC, major, minor, FILE_TYPES[file_type], flags, 0x0000,
|
|
153
|
+
ts_us, len(payload)
|
|
154
|
+
)
|
|
155
|
+
header_checksum = zlib.crc32(header_body) & 0xFFFFFFFF
|
|
156
|
+
payload_checksum = zlib.crc32(payload) & 0xFFFFFFFF
|
|
157
|
+
checksums = CHECKSUM_STRUCT.pack(header_checksum, payload_checksum)
|
|
158
|
+
|
|
159
|
+
return header_body + checksums + payload
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Upper bound on the size a compressed payload may inflate to. Without
|
|
163
|
+
# it a few hundred KB of gzip (a "decompression bomb") makes the decoder
|
|
164
|
+
# allocate gigabytes -- and the reference server feeds this function
|
|
165
|
+
# unauthenticated uploads. Legitimate readings are a few KB; batch
|
|
166
|
+
# containers a few MB at most.
|
|
167
|
+
MAX_DECOMPRESSED_BYTES = 16 * 1024 * 1024
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _gunzip_bounded(data: bytes, limit: int = MAX_DECOMPRESSED_BYTES) -> bytes:
|
|
171
|
+
d = zlib.decompressobj(wbits=31) # 16 + 15: gzip container
|
|
172
|
+
try:
|
|
173
|
+
out = d.decompress(data, limit + 1)
|
|
174
|
+
except zlib.error as e:
|
|
175
|
+
raise BXPBinaryError(f"Failed to gzip-decompress payload: {e}")
|
|
176
|
+
if len(out) > limit or d.unconsumed_tail:
|
|
177
|
+
raise BXPBinaryError(
|
|
178
|
+
f"Decompressed payload exceeds the {limit}-byte limit "
|
|
179
|
+
"(possible decompression bomb)"
|
|
180
|
+
)
|
|
181
|
+
if not d.eof:
|
|
182
|
+
raise BXPBinaryError(
|
|
183
|
+
"Failed to gzip-decompress payload: stream ended before the "
|
|
184
|
+
"end-of-stream marker (truncated)"
|
|
185
|
+
)
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def decode_bxp_binary(raw: bytes, verify: bool = True, supported_major: int = SUPPORTED_MAJOR) -> dict:
|
|
190
|
+
"""
|
|
191
|
+
Decode a binary `.bxp` container back into a BXP record dict plus
|
|
192
|
+
header metadata.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
raw: Full file contents (header + payload).
|
|
196
|
+
verify: If True (default), raise BXPBinaryError on magic number,
|
|
197
|
+
length, checksum, or unsupported-major-version mismatch
|
|
198
|
+
instead of returning a result with integrity flags set
|
|
199
|
+
to False. Minor-version differences are never an error
|
|
200
|
+
(SPEC.md §5.8): unrecognized optional fields are simply
|
|
201
|
+
ignored, per §5.7.
|
|
202
|
+
supported_major: The major version this caller understands.
|
|
203
|
+
Defaults to SUPPORTED_MAJOR (this module's current
|
|
204
|
+
understanding of the wire format). A file whose major
|
|
205
|
+
version differs is rejected when verify=True, per the
|
|
206
|
+
MUST-refuse rule in SPEC.md §5.8 — the payload is not
|
|
207
|
+
guessed at.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
{
|
|
211
|
+
"record": <decoded dict>,
|
|
212
|
+
"header": {
|
|
213
|
+
"majorVersion", "minorVersion", "fileType", "flags",
|
|
214
|
+
"timestampUs", "payloadLength",
|
|
215
|
+
},
|
|
216
|
+
"headerChecksumOk": bool,
|
|
217
|
+
"payloadChecksumOk": bool,
|
|
218
|
+
"majorVersionSupported": bool,
|
|
219
|
+
}
|
|
220
|
+
"""
|
|
221
|
+
if len(raw) < HEADER_SIZE:
|
|
222
|
+
raise BXPBinaryError(
|
|
223
|
+
f"File too short to be a .bxp binary container: "
|
|
224
|
+
f"{len(raw)} bytes (need at least {HEADER_SIZE})"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
header_body = raw[:HEADER_STRUCT.size]
|
|
228
|
+
checksums_raw = raw[HEADER_STRUCT.size:HEADER_SIZE]
|
|
229
|
+
payload = raw[HEADER_SIZE:]
|
|
230
|
+
|
|
231
|
+
magic, major, minor, file_type_code, flags, reserved, ts_us, payload_len = (
|
|
232
|
+
HEADER_STRUCT.unpack(header_body)
|
|
233
|
+
)
|
|
234
|
+
header_checksum, payload_checksum = CHECKSUM_STRUCT.unpack(checksums_raw)
|
|
235
|
+
|
|
236
|
+
if magic != MAGIC:
|
|
237
|
+
raise BXPBinaryError(
|
|
238
|
+
f"Bad magic number: 0x{magic:08X} (expected 0x{MAGIC:08X}) — "
|
|
239
|
+
"not a .bxp binary file"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
major_version_supported = (major == supported_major)
|
|
243
|
+
if verify and not major_version_supported:
|
|
244
|
+
raise BXPBinaryError(
|
|
245
|
+
f"Unsupported major version {major} (this decoder supports "
|
|
246
|
+
f"major version {supported_major}) — refusing to guess at "
|
|
247
|
+
"payload structure per SPEC.md §5.8"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
computed_header_checksum = zlib.crc32(header_body) & 0xFFFFFFFF
|
|
251
|
+
header_checksum_ok = computed_header_checksum == header_checksum
|
|
252
|
+
if verify and not header_checksum_ok:
|
|
253
|
+
raise BXPBinaryError(
|
|
254
|
+
f"Header checksum mismatch: file claims 0x{header_checksum:08X}, "
|
|
255
|
+
f"computed 0x{computed_header_checksum:08X} — header may be corrupt"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if len(payload) != payload_len:
|
|
259
|
+
msg = (
|
|
260
|
+
f"Payload length mismatch: header claims {payload_len} bytes, "
|
|
261
|
+
f"found {len(payload)}"
|
|
262
|
+
)
|
|
263
|
+
if verify:
|
|
264
|
+
raise BXPBinaryError(msg)
|
|
265
|
+
|
|
266
|
+
computed_payload_checksum = zlib.crc32(payload) & 0xFFFFFFFF
|
|
267
|
+
payload_checksum_ok = computed_payload_checksum == payload_checksum
|
|
268
|
+
if verify and not payload_checksum_ok:
|
|
269
|
+
raise BXPBinaryError(
|
|
270
|
+
f"Payload checksum mismatch: file claims 0x{payload_checksum:08X}, "
|
|
271
|
+
f"computed 0x{computed_payload_checksum:08X} — payload may be "
|
|
272
|
+
"corrupt or truncated"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
payload_json = payload
|
|
276
|
+
if flags & FLAG_COMPRESSED:
|
|
277
|
+
payload_json = _gunzip_bounded(payload)
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
record = json.loads(payload_json.decode("utf-8"))
|
|
281
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
282
|
+
raise BXPBinaryError(f"Payload is not valid JSON: {e}")
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
"record": record,
|
|
286
|
+
"header": {
|
|
287
|
+
"majorVersion": major,
|
|
288
|
+
"minorVersion": minor,
|
|
289
|
+
"fileType": FILE_TYPES_REV.get(file_type_code, file_type_code),
|
|
290
|
+
"flags": {
|
|
291
|
+
"compressed": bool(flags & FLAG_COMPRESSED),
|
|
292
|
+
"encrypted": bool(flags & FLAG_ENCRYPTED),
|
|
293
|
+
"signed": bool(flags & FLAG_SIGNED),
|
|
294
|
+
"draft": bool(flags & FLAG_DRAFT),
|
|
295
|
+
},
|
|
296
|
+
"timestampUs": ts_us,
|
|
297
|
+
"payloadLength": payload_len,
|
|
298
|
+
},
|
|
299
|
+
"headerChecksumOk": header_checksum_ok,
|
|
300
|
+
"payloadChecksumOk": payload_checksum_ok,
|
|
301
|
+
"majorVersionSupported": major_version_supported,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def bxp_json_to_binary(
|
|
306
|
+
json_path,
|
|
307
|
+
binary_path,
|
|
308
|
+
file_type: str = "reading",
|
|
309
|
+
compress: bool = False,
|
|
310
|
+
) -> bytes:
|
|
311
|
+
"""Convert a .bxp.json file on disk to a binary .bxp file. Returns the raw bytes written."""
|
|
312
|
+
from pathlib import Path
|
|
313
|
+
record = json.loads(Path(json_path).read_text(encoding="utf-8"))
|
|
314
|
+
raw = encode_bxp_binary(record, file_type=file_type, compress=compress)
|
|
315
|
+
Path(binary_path).write_bytes(raw)
|
|
316
|
+
return raw
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def bxp_binary_to_json(binary_path, json_path) -> dict:
|
|
320
|
+
"""Convert a binary .bxp file on disk to a .bxp.json file. Returns the decoded record."""
|
|
321
|
+
from pathlib import Path
|
|
322
|
+
raw = Path(binary_path).read_bytes()
|
|
323
|
+
decoded = decode_bxp_binary(raw)
|
|
324
|
+
Path(json_path).write_text(
|
|
325
|
+
json.dumps(decoded["record"], indent=2, default=str), encoding="utf-8"
|
|
326
|
+
)
|
|
327
|
+
return decoded["record"]
|