@mentra/bluetooth-sdk 0.1.18 → 0.1.19

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.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +119 -16
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +0 -6
  4. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +22 -21
  5. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +457 -31
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  13. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +4 -1
  15. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
  17. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +103 -68
  19. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
  22. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +7 -9
  23. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
  24. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  28. package/build/BluetoothSdk.types.d.ts +12 -1
  29. package/build/BluetoothSdk.types.d.ts.map +1 -1
  30. package/build/BluetoothSdk.types.js.map +1 -1
  31. package/build/types/index.d.ts +3 -0
  32. package/build/types/index.d.ts.map +1 -0
  33. package/build/types/index.js +2 -0
  34. package/build/types/index.js.map +1 -0
  35. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  36. package/ios/Source/DeviceManager.swift +134 -29
  37. package/ios/Source/DeviceStore.swift +0 -5
  38. package/ios/Source/MentraBluetoothSDK.swift +26 -23
  39. package/ios/Source/OtaManifest.swift +1 -1
  40. package/ios/Source/sgcs/G2.swift +518 -11
  41. package/ios/Source/sgcs/MentraLive.swift +308 -30
  42. package/ios/Source/sgcs/MentraNex.swift +416 -32
  43. package/ios/Source/sgcs/SGCManager.swift +155 -0
  44. package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
  45. package/ios/Source/status/DeviceStatus.swift +0 -8
  46. package/ios/Source/utils/BleJsonCompact.swift +395 -0
  47. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  48. package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
  49. package/ios/Source/utils/MessageChunker.swift +73 -0
  50. package/package.json +14 -7
  51. package/src/BluetoothSdk.types.ts +13 -1
  52. package/src/types/index.ts +7 -0
@@ -6,6 +6,39 @@ import com.mentra.bluetoothsdk.DeviceStore
6
6
  import com.mentra.bluetoothsdk.PhotoRequest
7
7
  import com.mentra.bluetoothsdk.utils.ConnTypes
8
8
 
9
+ /**
10
+ * One element of a scene frame (display.render()). Geometry is raw pixels on
11
+ * the device's drawable canvas; `change` is the host differ's annotation
12
+ * against the previous frame sent to this device.
13
+ */
14
+ data class SceneElement(
15
+ val id: String,
16
+ val type: String, // "text" | "rect" | "image"
17
+ val x: Int,
18
+ val y: Int,
19
+ val w: Int,
20
+ val h: Int,
21
+ val text: String?,
22
+ val data: String?, // base64 image pixels (SGC decodes → re-encodes to wire format)
23
+ val border: Int,
24
+ val radius: Int,
25
+ val change: String, // "created" | "updated" | "moved" | "unchanged"
26
+ val contentHash: String
27
+ )
28
+
29
+ /**
30
+ * A whole scene from the host pipeline — the full frame plus per-element change
31
+ * annotations and host-computed removes. Full-frame consumers can serialize
32
+ * `elements` and ignore the annotations; per-element consumers walk them.
33
+ */
34
+ data class SceneFrame(
35
+ val appId: String,
36
+ val epoch: Int,
37
+ val replay: Boolean,
38
+ val elements: List<SceneElement>,
39
+ val removed: List<String>
40
+ )
41
+
9
42
  abstract class SGCManager {
10
43
  // Hard coded device properties:
11
44
  @JvmField var type: String = ""
@@ -91,6 +124,107 @@ abstract class SGCManager {
91
124
  borderWidth: Int = 0,
92
125
  borderRadius: Int = 0
93
126
  ) {}
127
+
128
+ /**
129
+ * Retained-mode layout element (bitmap). Like [displayBitmap] but tagged with a stable
130
+ * [elementId] within a [layoutId] scene, so an SGC that supports it (Nex) can diff:
131
+ * same id -> update in place, new id -> create, new layoutId -> clear the previous scene.
132
+ * Default: ignore the ids and behave exactly like [displayBitmap].
133
+ */
134
+ open fun drawLayoutBitmap(
135
+ base64ImageData: String,
136
+ x: Int,
137
+ y: Int,
138
+ width: Int,
139
+ height: Int,
140
+ elementId: String,
141
+ layoutId: String?
142
+ ): Boolean = displayBitmap(base64ImageData, x, y, width, height)
143
+
144
+ /** Retained-mode layout element (text). Default: behave like [sendPositionedText]. */
145
+ open fun drawLayoutText(
146
+ text: String,
147
+ x: Int,
148
+ y: Int,
149
+ width: Int,
150
+ height: Int,
151
+ borderWidth: Int,
152
+ borderRadius: Int,
153
+ elementId: String,
154
+ layoutId: String?
155
+ ) = sendPositionedText(text, x, y, width, height, borderWidth, borderRadius)
156
+
157
+ /** Remove a retained layout element by id. Default: no-op (SGC doesn't support layouts). */
158
+ open fun removeLayoutElement(elementId: String, layoutId: String?) {}
159
+
160
+ /**
161
+ * Apply a whole scene frame from the host pipeline (display.render()).
162
+ *
163
+ * The host has already diffed the scene against what it last sent, so each
164
+ * element arrives annotated (created/updated/moved/unchanged) and `removed`
165
+ * lists what disappeared. This default implementation is fully generic:
166
+ *
167
+ * - PAINT-THEN-SWEEP: creates/updates/moves first (new content lands over
168
+ * or beside old), removes LAST — so a scene transition never shows a
169
+ * blank interval.
170
+ * - "unchanged" elements are skipped entirely (the host diff is the truth;
171
+ * this is also what prevents image re-uploads over BLE).
172
+ * - `replay` frames arrive all-"created" after a reconnect/recovery —
173
+ * [onSceneReplay] lets an SGC reset its component registry first so
174
+ * creates take the create path (updates to dead firmware ids are dropped
175
+ * silently by hardware).
176
+ * - rects compile to empty bordered text boxes (no shape primitive on
177
+ * current targets); a rect always gets a visible border.
178
+ *
179
+ * SGCs with per-element verbs (G2, Mentra Display) just implement
180
+ * [drawLayoutText]/[drawLayoutBitmap]/[removeLayoutElement]. A full-frame
181
+ * device (e.g. NIMO-style) overrides this whole method instead and
182
+ * serializes [SceneFrame.elements], ignoring the annotations.
183
+ */
184
+ open fun applySceneFrame(frame: SceneFrame) {
185
+ if (frame.replay) {
186
+ onSceneReplay(frame.appId)
187
+ }
188
+ // An id in `removed` that ALSO appears in `elements` is a type change
189
+ // (the differ keys matches by type:id). Its removal must run BEFORE the
190
+ // paint — registries key by id, so a post-paint sweep would delete the
191
+ // just-painted replacement.
192
+ val paintedIds = frame.elements.mapTo(HashSet()) { it.id }
193
+ for (id in frame.removed) {
194
+ if (id in paintedIds) removeLayoutElement(id, frame.appId)
195
+ }
196
+ for (el in frame.elements) {
197
+ if (!frame.replay && el.change == "unchanged") continue
198
+ when (el.type) {
199
+ "text" ->
200
+ drawLayoutText(el.text ?: "", el.x, el.y, el.w, el.h, el.border, el.radius, el.id, frame.appId)
201
+ "rect" ->
202
+ drawLayoutText("", el.x, el.y, el.w, el.h, maxOf(1, el.border), el.radius, el.id, frame.appId)
203
+ "image" ->
204
+ el.data?.let { drawLayoutBitmap(it, el.x, el.y, el.w, el.h, el.id, frame.appId) }
205
+ else -> Bridge.log("SGC: applySceneFrame: unknown element type ${el.type}")
206
+ }
207
+ }
208
+ for (id in frame.removed) {
209
+ if (id !in paintedIds) removeLayoutElement(id, frame.appId)
210
+ }
211
+ }
212
+
213
+ /**
214
+ * A replay frame is about to repaint from scratch — forget any retained
215
+ * component bookkeeping for [appId] so "created" elements take the create
216
+ * path. Default: no-op.
217
+ */
218
+ open fun onSceneReplay(appId: String) {}
219
+
220
+ /**
221
+ * Remove a set of scene elements (scene→legacy handoff: a legacy layout is
222
+ * about to draw and the previous scene's elements must not linger under it).
223
+ */
224
+ open fun clearSceneElements(elementIds: List<String>) {
225
+ for (id in elementIds) removeLayoutElement(id, null)
226
+ }
227
+
94
228
  abstract fun showDashboard()
95
229
  abstract fun setDashboardPosition(height: Int, depth: Int)
96
230
 
@@ -255,7 +255,6 @@ internal data class BluetoothStatus(
255
255
  val shouldSendPcm: Boolean,
256
256
  val shouldSendLc3: Boolean,
257
257
  val shouldSendTranscript: Boolean,
258
- val offlineCaptionsRunning: Boolean,
259
258
  val localSttFallbackActive: Boolean,
260
259
  val shouldSendBootingMessage: Boolean,
261
260
  ) {
@@ -307,7 +306,6 @@ internal data class BluetoothStatus(
307
306
  "should_send_pcm" to shouldSendPcm,
308
307
  "should_send_lc3" to shouldSendLc3,
309
308
  "should_send_transcript" to shouldSendTranscript,
310
- "offline_captions_running" to offlineCaptionsRunning,
311
309
  "local_stt_fallback_active" to localSttFallbackActive,
312
310
  "shouldSendBootingMessage" to shouldSendBootingMessage,
313
311
  )
@@ -354,7 +352,6 @@ internal data class BluetoothStatus(
354
352
  shouldSendPcm = boolValue(values, "should_send_pcm") ?: false,
355
353
  shouldSendLc3 = boolValue(values, "should_send_lc3") ?: false,
356
354
  shouldSendTranscript = boolValue(values, "should_send_transcript") ?: false,
357
- offlineCaptionsRunning = boolValue(values, "offline_captions_running") ?: false,
358
355
  localSttFallbackActive = boolValue(values, "local_stt_fallback_active") ?: false,
359
356
  shouldSendBootingMessage = boolValue(values, "shouldSendBootingMessage") ?: true,
360
357
  )
@@ -558,7 +555,6 @@ internal data class BluetoothStatusUpdate(
558
555
  val shouldSendPcm: Boolean? = null,
559
556
  val shouldSendLc3: Boolean? = null,
560
557
  val shouldSendTranscript: Boolean? = null,
561
- val offlineCaptionsRunning: Boolean? = null,
562
558
  val localSttFallbackActive: Boolean? = null,
563
559
  val shouldSendBootingMessage: Boolean? = null,
564
560
  ) {
@@ -600,7 +596,6 @@ internal data class BluetoothStatusUpdate(
600
596
  putIfNotNull("should_send_pcm", shouldSendPcm)
601
597
  putIfNotNull("should_send_lc3", shouldSendLc3)
602
598
  putIfNotNull("should_send_transcript", shouldSendTranscript)
603
- putIfNotNull("offline_captions_running", offlineCaptionsRunning)
604
599
  putIfNotNull("local_stt_fallback_active", localSttFallbackActive)
605
600
  putIfNotNull("shouldSendBootingMessage", shouldSendBootingMessage)
606
601
  }
@@ -650,7 +645,6 @@ internal data class BluetoothStatusUpdate(
650
645
  shouldSendPcm = optionalBoolValue(values, "should_send_pcm"),
651
646
  shouldSendLc3 = optionalBoolValue(values, "should_send_lc3"),
652
647
  shouldSendTranscript = optionalBoolValue(values, "should_send_transcript"),
653
- offlineCaptionsRunning = optionalBoolValue(values, "offline_captions_running"),
654
648
  localSttFallbackActive = optionalBoolValue(values, "local_stt_fallback_active"),
655
649
  shouldSendBootingMessage = optionalBoolValue(values, "shouldSendBootingMessage"),
656
650
  )
@@ -0,0 +1,493 @@
1
+ package com.mentra.bluetoothsdk.utils;
2
+
3
+ import java.nio.charset.StandardCharsets;
4
+ import java.util.ArrayList;
5
+ import java.util.Arrays;
6
+ import java.util.Collections;
7
+ import java.util.HashMap;
8
+ import java.util.HashSet;
9
+ import java.util.Iterator;
10
+ import java.util.List;
11
+ import java.util.Map;
12
+ import java.util.Set;
13
+ import org.json.JSONArray;
14
+ import org.json.JSONException;
15
+ import org.json.JSONObject;
16
+
17
+ /** BLE Wire Protocol v2 JSON compaction: short keys, enum ints, omitted defaults, config diff. */
18
+ public final class BleJsonCompact {
19
+
20
+ private static final Map<String, String> LONG_TO_SHORT = new HashMap<>();
21
+ private static final Map<String, String> SHORT_TO_LONG = new HashMap<>();
22
+
23
+ private static final Map<String, Integer> PHOTO_STATUS_TO_INT = new HashMap<>();
24
+ private static final Map<Integer, String> PHOTO_STATUS_FROM_INT = new HashMap<>();
25
+ private static final Map<String, Integer> KIND_TO_INT = new HashMap<>();
26
+ private static final Map<Integer, String> KIND_FROM_INT = new HashMap<>();
27
+ private static final Map<String, Integer> SOURCE_TO_INT = new HashMap<>();
28
+ private static final Map<Integer, String> SOURCE_FROM_INT = new HashMap<>();
29
+ private static final Map<String, Integer> AE_STATE_TO_INT = new HashMap<>();
30
+ private static final Map<Integer, String> AE_STATE_FROM_INT = new HashMap<>();
31
+
32
+ public static final String KEY_RESOLVED_CONFIG_HASH = "rch";
33
+
34
+ /** ROI 8–10 message types: compact outbound and accept compact inbound. */
35
+ private static final Set<String> HIGH_ROI_MESSAGE_TYPES =
36
+ Collections.unmodifiableSet(
37
+ new HashSet<>(
38
+ Arrays.asList(
39
+ "photo_status",
40
+ "stream_status",
41
+ "wifi_scan_result",
42
+ "start_stream",
43
+ "photo_response")));
44
+
45
+ /** Structural chunk envelope (always compact on wire). */
46
+ private static final Set<String> CHUNK_MESSAGE_TYPES =
47
+ Collections.unmodifiableSet(new HashSet<>(Arrays.asList("ck", "chunked_msg")));
48
+
49
+ private static long sessionConnectEpochMs;
50
+ private static boolean resolvedConfigSent;
51
+ private static String currentSessionResolvedConfigHash;
52
+ private static final Map<String, JSONObject> resolvedConfigByHash = new HashMap<>();
53
+
54
+ static {
55
+ putKey("type", "t");
56
+ putKey("requestId", "r");
57
+ putKey("timestamp", "ts");
58
+ putKey("status", "s");
59
+ putKey("kind", "k");
60
+ putKey("source", "src");
61
+ putKey("requestedCaptureConfig", "rcc");
62
+ putKey("meteredPreview", "mp");
63
+ putKey("exposureTimeNs", "etn");
64
+ putKey("captureMetadata", "cm");
65
+ putKey("aeStateName", "aes");
66
+ putKey("transferMethod", "tm");
67
+ putKey("manual", "m");
68
+ putKey("reconnecting", "rc");
69
+
70
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "accepted", 0);
71
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "queued", 1);
72
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "configuring", 2);
73
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "capturing", 3);
74
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "captured", 4);
75
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "uploading", 5);
76
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "uploaded", 6);
77
+ putEnum(PHOTO_STATUS_TO_INT, PHOTO_STATUS_FROM_INT, "failed", 7);
78
+
79
+ putEnum(KIND_TO_INT, KIND_FROM_INT, "lifecycle", 0);
80
+ putEnum(KIND_TO_INT, KIND_FROM_INT, "snapshot", 1);
81
+
82
+ putEnum(SOURCE_TO_INT, SOURCE_FROM_INT, "sdk", 0);
83
+ putEnum(SOURCE_TO_INT, SOURCE_FROM_INT, "button", 1);
84
+
85
+ putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "CONVERGED", 0);
86
+ putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "SEARCHING", 1);
87
+ putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "INACTIVE", 2);
88
+ putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "LOCKED", 3);
89
+ putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "FLASH_REQUIRED", 4);
90
+ putEnum(AE_STATE_TO_INT, AE_STATE_FROM_INT, "PRECAPTURE", 5);
91
+ }
92
+
93
+ private BleJsonCompact() {}
94
+
95
+ public static void resetSession() {
96
+ sessionConnectEpochMs = 0L;
97
+ resolvedConfigSent = false;
98
+ currentSessionResolvedConfigHash = null;
99
+ resolvedConfigByHash.clear();
100
+ }
101
+
102
+ public static void markSessionConnected(long epochMs) {
103
+ resetSession();
104
+ sessionConnectEpochMs = epochMs;
105
+ }
106
+
107
+ public static boolean isCameraCommandJson(String jsonData) {
108
+ if (jsonData == null || jsonData.isEmpty()) {
109
+ return false;
110
+ }
111
+ return jsonData.contains("cs_pho")
112
+ || jsonData.contains("cs_cpho")
113
+ || jsonData.contains("cs_vid")
114
+ || jsonData.contains("\"type\":\"take_photo\"");
115
+ }
116
+
117
+ public static boolean shouldCompactOutbound(String messageType) {
118
+ return messageType != null && HIGH_ROI_MESSAGE_TYPES.contains(messageType);
119
+ }
120
+
121
+ public static boolean supportsCompactInbound(String messageType) {
122
+ if (messageType == null || messageType.isEmpty()) {
123
+ return false;
124
+ }
125
+ return HIGH_ROI_MESSAGE_TYPES.contains(messageType)
126
+ || CHUNK_MESSAGE_TYPES.contains(messageType);
127
+ }
128
+
129
+ /** Compact wire form uses short `t` key without verbose `type`. */
130
+ public static boolean isCompactWireForm(JSONObject json) {
131
+ return json != null && json.has("t") && !json.has("type");
132
+ }
133
+
134
+ public static String extractMessageType(JSONObject json) {
135
+ if (json == null) {
136
+ return "";
137
+ }
138
+ if (json.has("type")) {
139
+ return json.optString("type", "");
140
+ }
141
+ if (json.has("t")) {
142
+ return json.optString("t", "");
143
+ }
144
+ return "";
145
+ }
146
+
147
+ public static JSONObject encode(JSONObject json) throws JSONException {
148
+ if (json == null) {
149
+ return null;
150
+ }
151
+ if (isCameraCommandJson(json.toString())) {
152
+ return json;
153
+ }
154
+ String messageType = extractMessageType(json);
155
+ if (!shouldCompactOutbound(messageType)) {
156
+ return json;
157
+ }
158
+ return compactObject(json, true);
159
+ }
160
+
161
+ public static JSONObject encode(String jsonData) throws JSONException {
162
+ if (jsonData == null || !jsonData.startsWith("{")) {
163
+ return null;
164
+ }
165
+ return encode(new JSONObject(jsonData));
166
+ }
167
+
168
+ public static JSONObject decode(JSONObject json) throws JSONException {
169
+ if (json == null) {
170
+ return null;
171
+ }
172
+ if (isCameraCommandJson(json.toString())) {
173
+ return json;
174
+ }
175
+ return expandObject(json, true);
176
+ }
177
+
178
+ /**
179
+ * Expand compact wire JSON only when the message type is on the ROI allowlist.
180
+ *
181
+ * @return expanded JSON, passthrough expanded JSON, or {@code null} if compact form is unsupported
182
+ */
183
+ public static JSONObject decodeIfSupported(JSONObject json) throws JSONException {
184
+ if (json == null) {
185
+ return null;
186
+ }
187
+ if (isCameraCommandJson(json.toString())) {
188
+ return json;
189
+ }
190
+ if (!isCompactWireForm(json)) {
191
+ return json;
192
+ }
193
+ String compactType = json.optString("t", "");
194
+ if (!supportsCompactInbound(compactType)) {
195
+ return null;
196
+ }
197
+ return expandObject(json, true);
198
+ }
199
+
200
+ public static JSONObject decode(String jsonData) throws JSONException {
201
+ if (jsonData == null || !jsonData.startsWith("{")) {
202
+ return null;
203
+ }
204
+ return decode(new JSONObject(jsonData));
205
+ }
206
+
207
+ private static void putKey(String longKey, String shortKey) {
208
+ LONG_TO_SHORT.put(longKey, shortKey);
209
+ SHORT_TO_LONG.put(shortKey, longKey);
210
+ }
211
+
212
+ private static void putEnum(
213
+ Map<String, Integer> toInt, Map<Integer, String> fromInt, String value, int code) {
214
+ toInt.put(value, code);
215
+ fromInt.put(code, value);
216
+ }
217
+
218
+ private static JSONObject compactObject(JSONObject input, boolean topLevel) throws JSONException {
219
+ JSONObject output = new JSONObject();
220
+ String messageType = input.optString("type", input.optString("t", ""));
221
+ boolean skipResolvedConfig = false;
222
+
223
+ if (topLevel && shouldDiffResolvedConfig(messageType) && input.has("resolvedConfig")) {
224
+ JSONObject resolvedConfig = input.getJSONObject("resolvedConfig");
225
+ String hash = hashConfig(resolvedConfig);
226
+ resolvedConfigByHash.put(hash, cloneObject(resolvedConfig));
227
+ if (resolvedConfigSent && hash.equals(currentSessionResolvedConfigHash)) {
228
+ output.put(KEY_RESOLVED_CONFIG_HASH, hash);
229
+ skipResolvedConfig = true;
230
+ } else {
231
+ currentSessionResolvedConfigHash = hash;
232
+ resolvedConfigSent = true;
233
+ }
234
+ }
235
+
236
+ Iterator<String> keys = input.keys();
237
+ while (keys.hasNext()) {
238
+ String key = keys.next();
239
+ if (skipResolvedConfig && "resolvedConfig".equals(key)) {
240
+ continue;
241
+ }
242
+ Object value = input.get(key);
243
+ String shortKey = LONG_TO_SHORT.getOrDefault(key, key);
244
+ Object compacted = compactValue(key, value, messageType);
245
+ if (shouldOmit(compacted)) {
246
+ continue;
247
+ }
248
+ output.put(shortKey, compacted);
249
+ }
250
+
251
+ if (topLevel) {
252
+ compactTimestamp(output);
253
+ }
254
+ return output;
255
+ }
256
+
257
+ private static boolean shouldDiffResolvedConfig(String messageType) {
258
+ return "photo_status".equals(messageType) || "stream_status".equals(messageType);
259
+ }
260
+
261
+ private static Object compactValue(String longKey, Object value, String messageType)
262
+ throws JSONException {
263
+ if (value == JSONObject.NULL || value == null) {
264
+ return null;
265
+ }
266
+ if (value instanceof JSONObject) {
267
+ return compactObject((JSONObject) value, false);
268
+ }
269
+ if (value instanceof JSONArray) {
270
+ return compactArray((JSONArray) value);
271
+ }
272
+ if (value instanceof Boolean || value instanceof Number) {
273
+ return value;
274
+ }
275
+ String str = String.valueOf(value);
276
+ if ("status".equals(longKey) && "photo_status".equals(messageType)) {
277
+ Integer code = PHOTO_STATUS_TO_INT.get(str);
278
+ if (code != null) {
279
+ return code;
280
+ }
281
+ }
282
+ if ("kind".equals(longKey)) {
283
+ Integer code = KIND_TO_INT.get(str);
284
+ if (code != null) {
285
+ return code;
286
+ }
287
+ }
288
+ if ("source".equals(longKey)) {
289
+ Integer code = SOURCE_TO_INT.get(str);
290
+ if (code != null) {
291
+ return code;
292
+ }
293
+ }
294
+ if ("aeStateName".equals(longKey)) {
295
+ Integer code = AE_STATE_TO_INT.get(str);
296
+ if (code != null) {
297
+ return code;
298
+ }
299
+ }
300
+ return str;
301
+ }
302
+
303
+ private static JSONArray compactArray(JSONArray array) throws JSONException {
304
+ JSONArray out = new JSONArray();
305
+ for (int i = 0; i < array.length(); i++) {
306
+ Object value = array.get(i);
307
+ if (value instanceof JSONObject) {
308
+ out.put(compactObject((JSONObject) value, false));
309
+ } else {
310
+ out.put(value);
311
+ }
312
+ }
313
+ return out;
314
+ }
315
+
316
+ private static boolean shouldOmit(Object value) {
317
+ if (value == null || value == JSONObject.NULL) {
318
+ return true;
319
+ }
320
+ if (value instanceof Boolean && !((Boolean) value)) {
321
+ return true;
322
+ }
323
+ if (value instanceof JSONObject && ((JSONObject) value).length() == 0) {
324
+ return true;
325
+ }
326
+ if (value instanceof JSONArray && ((JSONArray) value).length() == 0) {
327
+ return true;
328
+ }
329
+ return false;
330
+ }
331
+
332
+ private static void compactTimestamp(JSONObject output) throws JSONException {
333
+ String tsKey = output.has("timestamp") ? "timestamp" : (output.has("ts") ? "ts" : null);
334
+ if (tsKey == null) {
335
+ return;
336
+ }
337
+ long absolute = output.optLong(tsKey);
338
+ if (absolute <= 0) {
339
+ return;
340
+ }
341
+ if (sessionConnectEpochMs <= 0) {
342
+ sessionConnectEpochMs = absolute;
343
+ }
344
+ output.put("ts", absolute - sessionConnectEpochMs);
345
+ if (!"ts".equals(tsKey)) {
346
+ output.remove(tsKey);
347
+ }
348
+ }
349
+
350
+ private static JSONObject expandObject(JSONObject input, boolean topLevel) throws JSONException {
351
+ JSONObject output = new JSONObject();
352
+ String messageType = input.optString("type", input.optString("t", ""));
353
+
354
+ Iterator<String> keys = input.keys();
355
+ while (keys.hasNext()) {
356
+ String key = keys.next();
357
+ Object value = input.get(key);
358
+ if (topLevel && "ts".equals(key)) {
359
+ output.put("ts", value);
360
+ continue;
361
+ }
362
+ String longKey = SHORT_TO_LONG.getOrDefault(key, key);
363
+ Object expanded = expandValue(longKey, value, messageType);
364
+ if (expanded == null || expanded == JSONObject.NULL) {
365
+ continue;
366
+ }
367
+ output.put(longKey, expanded);
368
+ }
369
+
370
+ if (topLevel) {
371
+ expandTimestamp(output);
372
+ expandResolvedConfigHash(output);
373
+ cacheResolvedConfigIfPresent(output);
374
+ }
375
+ return output;
376
+ }
377
+
378
+ private static void cacheResolvedConfigIfPresent(JSONObject output) throws JSONException {
379
+ if (!output.has("resolvedConfig")) {
380
+ return;
381
+ }
382
+ JSONObject resolvedConfig = output.getJSONObject("resolvedConfig");
383
+ String hash = hashConfig(resolvedConfig);
384
+ resolvedConfigByHash.put(hash, cloneObject(resolvedConfig));
385
+ currentSessionResolvedConfigHash = hash;
386
+ resolvedConfigSent = true;
387
+ }
388
+
389
+ private static void expandResolvedConfigHash(JSONObject output) throws JSONException {
390
+ if (output.has("resolvedConfig") || !output.has(KEY_RESOLVED_CONFIG_HASH)) {
391
+ return;
392
+ }
393
+ String hash = output.optString(KEY_RESOLVED_CONFIG_HASH, "");
394
+ JSONObject cached = resolvedConfigByHash.get(hash);
395
+ if (cached != null) {
396
+ output.put("resolvedConfig", cloneObject(cached));
397
+ }
398
+ output.remove(KEY_RESOLVED_CONFIG_HASH);
399
+ }
400
+
401
+ private static Object expandValue(String longKey, Object value, String messageType)
402
+ throws JSONException {
403
+ if (value == JSONObject.NULL || value == null) {
404
+ return null;
405
+ }
406
+ if (value instanceof JSONObject) {
407
+ return expandObject((JSONObject) value, false);
408
+ }
409
+ if (value instanceof JSONArray) {
410
+ JSONArray out = new JSONArray();
411
+ JSONArray array = (JSONArray) value;
412
+ for (int i = 0; i < array.length(); i++) {
413
+ Object item = array.get(i);
414
+ if (item instanceof JSONObject) {
415
+ out.put(expandObject((JSONObject) item, false));
416
+ } else {
417
+ out.put(item);
418
+ }
419
+ }
420
+ return out;
421
+ }
422
+ if ("status".equals(longKey) && value instanceof Number && "photo_status".equals(messageType)) {
423
+ String restored = PHOTO_STATUS_FROM_INT.get(((Number) value).intValue());
424
+ return restored != null ? restored : value;
425
+ }
426
+ if ("kind".equals(longKey) && value instanceof Number) {
427
+ String restored = KIND_FROM_INT.get(((Number) value).intValue());
428
+ return restored != null ? restored : value;
429
+ }
430
+ if ("source".equals(longKey) && value instanceof Number) {
431
+ String restored = SOURCE_FROM_INT.get(((Number) value).intValue());
432
+ return restored != null ? restored : value;
433
+ }
434
+ if ("aeStateName".equals(longKey) && value instanceof Number) {
435
+ String restored = AE_STATE_FROM_INT.get(((Number) value).intValue());
436
+ return restored != null ? restored : value;
437
+ }
438
+ return value;
439
+ }
440
+
441
+ private static void expandTimestamp(JSONObject output) throws JSONException {
442
+ if (!output.has("timestamp") && output.has("ts")) {
443
+ long delta = output.optLong("ts");
444
+ long absolute = sessionConnectEpochMs > 0 ? sessionConnectEpochMs + delta : delta;
445
+ output.put("timestamp", absolute);
446
+ output.remove("ts");
447
+ if (sessionConnectEpochMs <= 0) {
448
+ sessionConnectEpochMs = absolute - delta;
449
+ }
450
+ }
451
+ }
452
+
453
+ public static String hashConfig(JSONObject config) {
454
+ try {
455
+ return fnv1a32Hex(canonicalObject(config).toString());
456
+ } catch (JSONException e) {
457
+ return fnv1a32Hex(config.toString());
458
+ }
459
+ }
460
+
461
+ private static JSONObject canonicalObject(JSONObject input) throws JSONException {
462
+ List<String> keys = new ArrayList<>();
463
+ Iterator<String> it = input.keys();
464
+ while (it.hasNext()) {
465
+ keys.add(it.next());
466
+ }
467
+ Collections.sort(keys);
468
+ JSONObject out = new JSONObject();
469
+ for (String key : keys) {
470
+ Object value = input.get(key);
471
+ if (value instanceof JSONObject) {
472
+ out.put(key, canonicalObject((JSONObject) value));
473
+ } else {
474
+ out.put(key, value);
475
+ }
476
+ }
477
+ return out;
478
+ }
479
+
480
+ private static JSONObject cloneObject(JSONObject input) throws JSONException {
481
+ return new JSONObject(input.toString());
482
+ }
483
+
484
+ private static String fnv1a32Hex(String value) {
485
+ byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
486
+ int hash = 0x811c9dc5;
487
+ for (byte b : bytes) {
488
+ hash ^= (b & 0xff);
489
+ hash *= 0x01000193;
490
+ }
491
+ return String.format("%08x", hash);
492
+ }
493
+ }