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 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"]