Garmin: Basic http web request/response parsing

As per https://gadgetbridge.org/internals/specifics/garmin-protocol/#internal-structures
This commit is contained in:
José Rebelo
2025-12-15 20:45:19 +00:00
parent 790e18a9e3
commit 5fdc2b8fc6
6 changed files with 660 additions and 0 deletions
+1
View File
@@ -148,6 +148,7 @@
<w>shahrabani</w> <w>shahrabani</w>
<w>shimokawa</w> <w>shimokawa</w>
<w>shokz</w> <w>shokz</w>
<w>sint</w>
<w>skype</w> <w>skype</w>
<w>slezak</w> <w>slezak</w>
<w>sophanimus</w> <w>sophanimus</w>
@@ -0,0 +1,356 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class GarminJson {
private static final byte[] STRING_SECTION_MAGIC = {(byte) 0xab, (byte) 0xcd, (byte) 0xab, (byte) 0xcd};
private static final byte[] DATA_SECTION_MAGIC = {(byte) 0xda, (byte) 0x7a, (byte) 0xda, (byte) 0x7a};
private static final byte TYPE_NULL = 0x00;
private static final byte TYPE_SINT32 = 0x01;
private static final byte TYPE_FLOAT = 0x02;
private static final byte TYPE_STRING = 0x03;
private static final byte TYPE_ARRAY = 0x05;
private static final byte TYPE_BOOL = 0x09;
private static final byte TYPE_MAP = 0x0b;
private static final byte TYPE_SINT64 = 0x0e;
private static final byte TYPE_DOUBLE = 0x0f;
public static byte[] encode(final Object object) throws GarminJsonException {
try {
// First pass: collect all strings
Set<String> strings = new LinkedHashSet<>();
collectStrings(object, strings);
// Build string section
final ByteArrayOutputStream stringSection = new ByteArrayOutputStream();
int currentOffset = 0;
final Map<String, Integer> finalOffsets = new LinkedHashMap<>();
for (final String str : strings) {
finalOffsets.put(str, currentOffset);
final byte[] strBytes = str.getBytes(StandardCharsets.UTF_8);
final int length = strBytes.length + 1; // +1 for null terminator
stringSection.write((length >> 8) & 0xFF);
stringSection.write(length & 0xFF);
stringSection.write(strBytes);
stringSection.write(0x00);
currentOffset += 2 + strBytes.length + 1;
}
// Build data section (breadth-first)
final ByteArrayOutputStream dataSection = new ByteArrayOutputStream();
encodeValueBreadthFirst(object, dataSection, finalOffsets);
final ByteArrayOutputStream output = new ByteArrayOutputStream();
// Write string section, if any
if (stringSection.size() > 0) {
output.write(STRING_SECTION_MAGIC);
writeUint32BE(output, stringSection.size());
output.write(stringSection.toByteArray());
}
// Write data section
output.write(DATA_SECTION_MAGIC);
writeUint32BE(output, dataSection.size());
output.write(dataSection.toByteArray());
return output.toByteArray();
} catch (final IOException e) {
throw new GarminJsonException("Failed to encode GarminJson", e);
}
}
private static void encodeValueBreadthFirst(final Object rootObj,
final ByteArrayOutputStream out,
final Map<String, Integer> stringOffsets) throws GarminJsonException {
final Queue<Object> queue = new LinkedList<>();
queue.add(rootObj);
while (!queue.isEmpty()) {
final Object obj = queue.poll();
if (obj == null || obj instanceof JsonNull) {
out.write(TYPE_NULL);
} else if (obj instanceof JsonPrimitive jsonPrimitive) {
if (jsonPrimitive.isBoolean()) {
out.write(TYPE_BOOL);
out.write(jsonPrimitive.getAsBoolean() ? 0x01 : 0x00);
} else if (jsonPrimitive.isNumber()) {
final Number num = jsonPrimitive.getAsNumber();
// Check the actual value to determine the appropriate type
// This handles LazilyParsedNumber and other Number implementations
final double doubleValue = num.doubleValue();
final long longValue = num.longValue();
// Check if it's a floating point number
if (doubleValue != longValue || num instanceof Float || num instanceof Double) {
// It has a fractional part or is explicitly a float/double
if (num instanceof Float || (Math.abs(doubleValue) < Float.MAX_VALUE && doubleValue == (float) doubleValue)) {
out.write(TYPE_FLOAT);
writeFloat(out, num.floatValue());
} else {
out.write(TYPE_DOUBLE);
writeDouble(out, num.doubleValue());
}
} else {
// It's an integer value
if (longValue >= Integer.MIN_VALUE && longValue <= Integer.MAX_VALUE) {
out.write(TYPE_SINT32);
writeUint32BE(out, (int) longValue);
} else {
out.write(TYPE_SINT64);
writeUint64BE(out, longValue);
}
}
} else if (jsonPrimitive.isString()) {
out.write(TYPE_STRING);
final String str = jsonPrimitive.getAsString();
final Integer offset = stringOffsets.get(str);
if (offset == null) {
throw new GarminJsonException("String not found in offset map: " + str);
}
writeUint32BE(out, offset);
} else {
throw new GarminJsonException("Unexpected json primitive: " + jsonPrimitive.getClass().getName());
}
} else if (obj instanceof String str) {
out.write(TYPE_STRING);
final Integer offset = stringOffsets.get(str);
if (offset == null) {
throw new GarminJsonException("String not found in offset map: " + str);
}
writeUint32BE(out, offset);
} else if (obj instanceof JsonArray jsonArray) {
out.write(TYPE_ARRAY);
writeUint32BE(out, jsonArray.size());
// Add array elements to queue for breadth-first processing
for (final JsonElement element : jsonArray) {
queue.add(element);
}
} else if (obj instanceof JsonObject jsonObj) {
out.write(TYPE_MAP);
writeUint32BE(out, jsonObj.size());
// Add key-value pairs to queue for breadth-first processing
for (final Map.Entry<String, JsonElement> entry : jsonObj.entrySet()) {
queue.add(entry.getKey());
queue.add(entry.getValue());
}
} else {
throw new GarminJsonException("Unsupported type: " + obj.getClass().getName());
}
}
}
public static Object decode(final byte[] bytes) throws GarminJsonException {
final ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN);
// Parse string section if present
final Map<Integer, String> strings = new LinkedHashMap<>();
final byte[] magic = new byte[4];
buffer.get(magic);
if (Arrays.equals(magic, STRING_SECTION_MAGIC)) {
final int stringSectionLength = buffer.getInt();
final int stringSectionEnd = buffer.position() + stringSectionLength;
final int stringSectionStart = buffer.position();
while (buffer.position() < stringSectionEnd) {
final int stringStart = buffer.position() - stringSectionStart;
final int length = buffer.getShort() & 0xFFFF;
final byte[] strBytes = new byte[length - 1]; // -1 for null terminator
buffer.get(strBytes);
buffer.get(); // skip null terminator
final String str = new String(strBytes, StandardCharsets.UTF_8);
strings.put(stringStart, str);
}
// Read data section magic
buffer.get(magic);
}
if (!Arrays.equals(magic, DATA_SECTION_MAGIC)) {
throw new GarminJsonException("Expected data section magic, got " + GB.hexdump(magic));
}
final int dataSectionLength = buffer.getInt();
if (buffer.position() + dataSectionLength != bytes.length) {
throw new GarminJsonException("Data section length does not match");
}
final Object rootObject = decodeValue(buffer, strings);
// Process placeholders breadth-first using a queue
final Queue<Object> queue = new LinkedList<>();
queue.add(rootObject);
while (!queue.isEmpty()) {
Object current = queue.poll();
if (current instanceof MapPlaceholder mp) {
for (int i = 0; i < mp.size; i++) {
final Object key = decodeValue(buffer, strings);
final Object value = decodeValue(buffer, strings);
// Unwrap placeholders to get the actual JSON object/array
Object actualValue = value;
if (value instanceof MapPlaceholder) {
actualValue = ((MapPlaceholder) value).obj;
queue.add(value);
} else if (value instanceof ArrayPlaceholder) {
actualValue = ((ArrayPlaceholder) value).array;
queue.add(value);
}
mp.obj.add(String.valueOf(((JsonPrimitive) key).getAsString()), (JsonElement) actualValue);
}
} else if (current instanceof ArrayPlaceholder ap) {
for (int i = 0; i < ap.length; i++) {
Object value = decodeValue(buffer, strings);
// Unwrap placeholders to get the actual JSON object/array
Object actualValue = value;
if (value instanceof MapPlaceholder) {
actualValue = ((MapPlaceholder) value).obj;
queue.add(value);
} else if (value instanceof ArrayPlaceholder) {
actualValue = ((ArrayPlaceholder) value).array;
queue.add(value);
}
ap.array.add((JsonElement) actualValue);
}
}
}
if (rootObject instanceof ArrayPlaceholder ap) {
return ap.array;
} else if (rootObject instanceof MapPlaceholder mp) {
return mp.obj;
} else if (rootObject instanceof JsonElement jsonElement) {
return jsonElement;
} else {
throw new GarminJsonException("Unexpected root object " + rootObject.getClass());
}
}
private static void collectStrings(final Object rootObj, final Set<String> strings) {
final Queue<Object> queue = new LinkedList<>();
queue.add(rootObj);
while (!queue.isEmpty()) {
final Object obj = queue.poll();
if (obj instanceof JsonObject jsonObj) {
for (final Map.Entry<String, JsonElement> entry : jsonObj.entrySet()) {
strings.add(entry.getKey());
final JsonElement value = entry.getValue();
if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) {
strings.add(value.getAsString());
} else if (value.isJsonObject() || value.isJsonArray()) {
queue.add(value);
}
}
} else if (obj instanceof JsonArray jsonArray) {
for (final JsonElement element : jsonArray) {
if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) {
strings.add(element.getAsString());
} else if (element.isJsonObject() || element.isJsonArray()) {
queue.add(element);
}
}
} else if (obj instanceof String str) {
strings.add(str);
}
}
}
private static Object decodeValue(final ByteBuffer buffer, final Map<Integer, String> strings) throws GarminJsonException {
byte type = buffer.get();
switch (type) {
case TYPE_NULL:
return JsonNull.INSTANCE;
case TYPE_BOOL:
return new JsonPrimitive(buffer.get() != 0x00);
case TYPE_SINT32:
return new JsonPrimitive(buffer.getInt());
case TYPE_SINT64:
return new JsonPrimitive(buffer.getLong());
case TYPE_FLOAT:
return new JsonPrimitive(buffer.getFloat());
case TYPE_DOUBLE:
return new JsonPrimitive(buffer.getDouble());
case TYPE_STRING:
final int offset = buffer.getInt();
final String str = strings.get(offset);
if (str == null) {
throw new GarminJsonException("String not found in offset map: " + offset);
}
return new JsonPrimitive(str);
case TYPE_ARRAY:
final int arrayLength = buffer.getInt();
// Decoding is breadth-first - don't decode children yet
// return placeholder with the expected number of children
return new ArrayPlaceholder(new JsonArray(), arrayLength);
case TYPE_MAP:
final int mapSize = buffer.getInt();
// Decoding is breadth-first - don't decode children yet
// return placeholder with the expected number of children
return new MapPlaceholder(new JsonObject(), mapSize);
default:
throw new GarminJsonException("Unknown type: 0x" + Integer.toHexString(type & 0xFF));
}
}
private static void writeUint32BE(final ByteArrayOutputStream out, final int value) {
out.write((value >> 24) & 0xFF);
out.write((value >> 16) & 0xFF);
out.write((value >> 8) & 0xFF);
out.write(value & 0xFF);
}
private static void writeUint64BE(final ByteArrayOutputStream out, final long value) {
out.write((int) ((value >> 56) & 0xFF));
out.write((int) ((value >> 48) & 0xFF));
out.write((int) ((value >> 40) & 0xFF));
out.write((int) ((value >> 32) & 0xFF));
out.write((int) ((value >> 24) & 0xFF));
out.write((int) ((value >> 16) & 0xFF));
out.write((int) ((value >> 8) & 0xFF));
out.write((int) (value & 0xFF));
}
private static void writeFloat(final ByteArrayOutputStream out, final float value) {
writeUint32BE(out, Float.floatToIntBits(value));
}
private static void writeDouble(final ByteArrayOutputStream out, final double value) {
writeUint64BE(out, Double.doubleToLongBits(value));
}
private record MapPlaceholder(JsonObject obj, int size) {
}
private record ArrayPlaceholder(JsonArray array, int length) {
}
}
@@ -0,0 +1,11 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http;
public class GarminJsonException extends Exception {
public GarminJsonException(final String message) {
super(message);
}
public GarminJsonException(final String message, final Throwable cause) {
super(message, cause);
}
}
@@ -39,6 +39,14 @@ public class HttpHandler {
.build(); .build();
} }
return null; return null;
} else if (httpService.hasWebRequest()) {
final GdiHttpService.HttpService.WebResponse webResponse = handleWebRequest(httpService.getWebRequest());
if (webResponse != null) {
return GdiHttpService.HttpService.newBuilder()
.setWebResponse(webResponse)
.build();
}
return null;
} }
LOG.warn("Unsupported http service request {}", httpService); LOG.warn("Unsupported http service request {}", httpService);
@@ -147,4 +155,10 @@ public class HttpHandler {
.addAllHeader(responseHeaders) .addAllHeader(responseHeaders)
.build(); .build();
} }
public GdiHttpService.HttpService.WebResponse handleWebRequest(final GdiHttpService.HttpService.WebRequest webRequest) {
LOG.debug("Got webRequest: {} - {}", webRequest.getMethod(), webRequest.getUrl());
return null;
}
} }
@@ -23,9 +23,44 @@ message HttpService {
DATA_TRANSFER_ITEM_FAILURE = 400; DATA_TRANSFER_ITEM_FAILURE = 400;
} }
enum ResponseType {
JSON = 0;
URL_ENCODED = 1;
PLAIN_TEXT = 2;
XML = 3;
}
enum Version {
VERSION_1 = 0;
VERSION_2 = 1;
}
optional WebRequest webRequest = 1;
optional WebResponse webResponse = 2;
optional RawRequest rawRequest = 5; optional RawRequest rawRequest = 5;
optional RawResponse rawResponse = 6; optional RawResponse rawResponse = 6;
message WebRequest {
required string url = 1;
optional Method method = 2;
optional bytes headers = 3;
optional bytes body = 4;
optional uint32 maxResponseLength = 5;
optional bool httpHeadersInResponse = 6 [default = true];
optional bool compressResponseBody = 7 [default = false];
optional ResponseType responseType = 8;
optional Version version = 9 [default = VERSION_1];
}
message WebResponse {
optional Status status = 1;
optional uint32 httpStatus = 2;
optional bytes body = 3;
optional bytes headers = 4;
optional bytes size = 5;
optional ResponseType responseType = 6;
}
message RawRequest { message RawRequest {
required string url = 1; required string url = 1;
optional Method method = 3; optional Method method = 3;
@@ -0,0 +1,243 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonNull;
import com.google.gson.JsonObject;
import org.junit.Assert;
import org.junit.Ignore;
import org.junit.Test;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class GarminJsonTest {
private static final Gson GSON = new Gson();
final String example1json = """
{
"data": {
"glanceTemplate": {
"template": "{{ states('sensor.solarnet_power_grid_import') }}%"
}
},
"type": "render_template"
}
""";
final String example1hex = "abcdabcd000000710005646174610000" +
"057479706500001072656e6465725f74" +
"656d706c61746500000f676c616e6365" +
"54656d706c61746500000974656d706c" +
"6174650000337b7b2073746174657328" +
"2773656e736f722e736f6c61726e6574" +
"5f706f7765725f677269645f696d706f" +
"72742729207d7d2500da7ada7a000000" +
"2d0b0000000203000000000b00000001" +
"0300000007030000000e03000000200b" +
"000000010300000031030000003c";
final String example2json = """
{
"a": 1,
"b": {
"c": "d",
"e": "c",
"f": ["x", 1, { "123": "456","789": "100" }]
},
"array": [1, "two", true],
"nested": {
"level1": {
"level2": {
"level3": "value"
},
"level2_2": 42
}
}
}
""";
final String example2hex = "abcdabcd000000790006617272617900000262000002610000076e6573746564" +
"00000474776f000002660000026300000264000002650000076c6576656c3100" +
"0002780000096c6576656c325f320000076c6576656c32000004313233000004" +
"3435360000043738390000043130300000076c6576656c3300000676616c7565" +
"00da7ada7a000000a20b000000040300000000050000000303000000080b0000" +
"0003030000000c010000000103000000100b0000000101000000010300000019" +
"0901030000001f050000000303000000230300000027030000002b0300000023" +
"030000002f0b00000002030000003801000000010b00000002030000003c0100" +
"00002a03000000470b0000000103000000500300000056030000005c03000000" +
"6203000000680300000071";
@Test
public void testDecodeExample1() throws Exception {
final byte[] bytes = GB.hexStringToByteArray(example1hex);
final JsonObject result = (JsonObject) GarminJson.decode(bytes);
Assert.assertEquals(2, result.size());
Assert.assertTrue(result.has("data"));
Assert.assertTrue(result.has("type"));
Assert.assertEquals("render_template", result.get("type").getAsString());
final JsonObject data = result.getAsJsonObject("data");
Assert.assertEquals(1, data.size());
Assert.assertTrue(data.has("glanceTemplate"));
final JsonObject glanceTemplate = data.getAsJsonObject("glanceTemplate");
Assert.assertEquals(1, glanceTemplate.size());
Assert.assertTrue(glanceTemplate.has("template"));
Assert.assertEquals("{{ states('sensor.solarnet_power_grid_import') }}%",
glanceTemplate.get("template").getAsString());
}
@Test
public void testDecodeExample2() throws Exception {
final byte[] bytes = GB.hexStringToByteArray(example2hex);
final JsonObject result = (JsonObject) GarminJson.decode(bytes);
// root
Assert.assertEquals(4, result.size());
Assert.assertTrue(result.has("a"));
Assert.assertTrue(result.has("b"));
Assert.assertTrue(result.has("array"));
Assert.assertTrue(result.has("nested"));
// "a"
Assert.assertEquals(1, result.get("a").getAsInt());
// "b"
final JsonObject b = result.getAsJsonObject("b");
Assert.assertEquals(3, b.size());
Assert.assertEquals("d", b.get("c").getAsString());
Assert.assertEquals("c", b.get("e").getAsString());
// "b.f"
final JsonArray f = b.getAsJsonArray("f");
Assert.assertEquals(3, f.size());
Assert.assertEquals("x", f.get(0).getAsString());
Assert.assertEquals(1, f.get(1).getAsInt());
final JsonObject fObj = f.get(2).getAsJsonObject();
Assert.assertEquals(2, fObj.size());
Assert.assertEquals("456", fObj.get("123").getAsString());
Assert.assertEquals("100", fObj.get("789").getAsString());
// "array"
final JsonArray array = result.getAsJsonArray("array");
Assert.assertEquals(3, array.size());
Assert.assertEquals(1, array.get(0).getAsInt());
Assert.assertEquals("two", array.get(1).getAsString());
Assert.assertTrue(array.get(2).getAsBoolean());
// "nested"
final JsonObject nested = result.getAsJsonObject("nested");
Assert.assertEquals(1, nested.size());
final JsonObject level1 = nested.getAsJsonObject("level1");
Assert.assertEquals(2, level1.size());
Assert.assertEquals(42, level1.get("level2_2").getAsInt());
final JsonObject level2 = level1.getAsJsonObject("level2");
Assert.assertEquals(1, level2.size());
Assert.assertEquals("value", level2.get("level3").getAsString());
}
@Test
public void testEncodeExample1() throws Exception {
final JsonElement object = GSON.fromJson(example1json, JsonElement.class);
final byte[] encoded = GarminJson.encode(object);
Assert.assertEquals(
example1hex,
GB.hexdump(encoded).toLowerCase()
);
}
@Test
@Ignore("Field order is not kept by gson, so this is failing..")
public void testEncodeExample2() throws Exception {
final JsonElement object = GSON.fromJson(example2json, JsonElement.class);
final byte[] encoded = GarminJson.encode(object);
Assert.assertEquals(
example2hex,
GB.hexdump(encoded).toLowerCase()
);
}
@Test
public void testEncodeDecodeAllTypes() throws Exception {
final JsonObject json = new JsonObject();
// basic types
json.add("nullValue", JsonNull.INSTANCE);
json.addProperty("boolTrue", true);
json.addProperty("boolFalse", false);
json.addProperty("int32", 42);
json.addProperty("int64", 9223372036854775807L);
json.addProperty("float", 3.14f);
json.addProperty("double", 3.115281878499445d);
json.addProperty("string", "Hello, World!");
// array
final JsonArray array = new JsonArray();
array.add(1);
array.add("two");
array.add(true);
json.add("array", array);
// nested map
final JsonObject nested = new JsonObject();
nested.addProperty("nested_key", "nested_value");
json.add("map", nested);
// Encode and decode
final byte[] encoded = GarminJson.encode(json);
final JsonObject decoded = (JsonObject) GarminJson.decode(encoded);
// Validate
Assert.assertEquals(json.size(), decoded.size());
Assert.assertTrue(decoded.get("nullValue").isJsonNull());
Assert.assertEquals(json.get("boolTrue").getAsBoolean(), decoded.get("boolTrue").getAsBoolean());
Assert.assertEquals(json.get("boolFalse").getAsBoolean(), decoded.get("boolFalse").getAsBoolean());
Assert.assertEquals(json.get("int32").getAsInt(), decoded.get("int32").getAsInt());
Assert.assertEquals(json.get("int64").getAsLong(), decoded.get("int64").getAsLong());
Assert.assertEquals(json.get("float").getAsDouble(), decoded.get("float").getAsDouble(), 0.001);
Assert.assertEquals(json.get("double").getAsDouble(), decoded.get("double").getAsDouble(), 0.000001);
Assert.assertEquals(json.get("string").getAsString(), decoded.get("string").getAsString());
Assert.assertEquals(json.getAsJsonArray("array").size(), decoded.getAsJsonArray("array").size());
for (int i = 0; i < json.getAsJsonArray("array").size(); i++) {
Assert.assertEquals(json.getAsJsonArray("array").get(i), decoded.getAsJsonArray("array").get(i));
}
Assert.assertEquals(
json.getAsJsonObject("map").get("nested_key").getAsString(),
decoded.getAsJsonObject("map").get("nested_key").getAsString()
);
}
@Test
public void testEmptyObject() throws Exception {
final JsonObject json = new JsonObject();
final byte[] encoded = GarminJson.encode(json);
Assert.assertEquals(
"da7ada7a000000050b00000000",
GB.hexdump(encoded).toLowerCase()
);
final JsonObject decoded = (JsonObject) GarminJson.decode(encoded);
Assert.assertEquals(0, decoded.size());
}
@Test
public void testSimpleKeyValue() throws Exception {
final JsonObject json = new JsonObject();
json.addProperty("key", "value");
final byte[] encoded = GarminJson.encode(json);
Assert.assertEquals(
"abcdabcd0000000e00046b657900000676616c756500da7ada7a0000000f0b0000000103000000000300000006",
GB.hexdump(encoded).toLowerCase()
);
final JsonObject decoded = (JsonObject) GarminJson.decode(encoded);
Assert.assertEquals(1, decoded.size());
Assert.assertEquals("value", decoded.get("key").getAsString());
}
}