Skip to content

Commit 55ab979

Browse files
committed
Add GSS-derived characteristics, array fields, precise scaling
Bundled characteristics (191 -> 348): - recover the Fitness Machine profile and other characteristics from the last published Bluetooth SIG characteristic XML (2017) - add 139 characteristics derived from the current GATT Specification Supplement (the SIG retired the machine-readable characteristic XML ~2019); scalars are high fidelity, complex conditional/struct cases carry <!-- REVIEW --> markers Parser: - variable-length array fields via Field.repeats (0 = to end of data, N = fixed); the parser emits indexed holders Name[0], Name[1], ... (e.g. RR-Interval) - register the GSS float type names medfloat16/medfloat32 (IEEE-11073 16/32-bit) Numeric conversion: - exact BigDecimal scaling: decimal exponent via scaleByPowerOfTen, binary exponent via exact powers of two, so getBigDecimal/getBigInteger no longer inherit the rounding error of Math.pow(10, e) - simplify two's-complement int/long deserialization (same results) - implement the previously-unsupported IEEE-11073 serialize + deserializeDouble README refreshed: GSS provenance, array support; fixed stale links/paths. Signed-off-by: Vlad Kolotoff <vkolotoff@pm.me>
1 parent e775453 commit 55ab979

170 files changed

Lines changed: 4851 additions & 78 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 58 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,34 @@
22

33
A **simple** library/framework to work with Bluetooth Smart (BLE) GATT services and characteristics.
44

5-
Note: This is a fork from the no longer maintained project at https://github.com/sputnikdev/bluetooth-gatt-parser.
5+
> This is a fork of the no longer maintained project at
6+
> https://github.com/sputnikdev/bluetooth-gatt-parser.
7+
8+
Parsing a standard characteristic (Battery Level, `0x2A19`) is a one-liner:
69

7-
Have a look at an example of parsing a standard characteristic ([Battery Level 0x2A19](https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.characteristic.battery_level.xml)) value:
810
```java
911
BluetoothGattParserFactory.getDefault().parse("2A19", new byte[] {51}).get("Level").getInteger(null);
1012
```
11-
This would print 51.
1213

13-
**Features:**
14+
This prints `51`.
15+
16+
## Features
17+
18+
1. Ships the standard [Bluetooth SIG GATT services and characteristics](https://www.bluetooth.com/specifications/assigned-numbers/), plus a number
19+
of characteristics recovered from the SIG's retired characteristic XML and
20+
generated from the current [GATT Specification Supplement](https://www.bluetooth.com/specifications/gss/).
21+
2. Parses single- and multi-field characteristics into a user-friendly data format.
22+
3. Serializes (writes) single- and multi-field characteristics.
23+
4. Validates input against the GATT specification (format types and mandatory fields).
24+
5. Supports variable-length array fields (e.g. the RR-Interval list in Heart Rate Measurement).
25+
6. Extensible: user-defined services and characteristics via drop-in XML.
26+
7. Supports all defined [format types](https://www.bluetooth.com/specifications/assigned-numbers/), including the
27+
IEEE-11073 `SFLOAT`/`FLOAT` (GSS `medfloat16`/`medfloat32`) types.
1428

15-
1. Supports 99% of the existing/standard [GATT services and characteristics specifications](https://www.bluetooth.com/specifications/gatt).
16-
2. Parse/read single and multi field characteristics into a user-friendly data format.
17-
3. Writing single and multi field characteristics.
18-
4. Validating input data whether it conforms to GATT specifications (format types and mandatory fields).
19-
5. Extensibility. User defined services and characteristics.
20-
6. Support for all defined [format types](https://www.bluetooth.com/specifications/assigned-numbers/format-types).
29+
## Usage
30+
31+
Add the Maven dependency:
2132

22-
**Start using the library by including a maven dependency in your project:**
2333
```xml
2434
<dependency>
2535
<groupId>org.openhab</groupId>
@@ -28,66 +38,72 @@ This would print 51.
2838
</dependency>
2939
```
3040

31-
A more complex example of parsing multi-field characteristics ([Heart Rate service](https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.service.heart_rate.xml)):
41+
Reading and writing multi-field characteristics:
3242

3343
```java
34-
// Getting a default implementation which is capable of reading/writing the standard GATT services and characteristics
44+
// A default parser that reads/writes the bundled standard GATT services and characteristics.
3545
BluetoothGattParser parser = BluetoothGattParserFactory.getDefault();
3646

37-
// Reading Body Sensor Location (0x2A38) characteristic (sigle field)
38-
byte[] data = new byte[] {1}; // 1 == Chest
39-
GattResponse response = parser.parse("2A38", data);
40-
String sensorLocation = response.get("Body Sensor Location").getInteger(null); // prints 1 (Chest)
47+
// Read Body Sensor Location (0x2A38) — a single-field characteristic.
48+
GattResponse response = parser.parse("2A38", new byte[] {1}); // 1 == Chest
49+
int sensorLocation = response.get("Body Sensor Location").getInteger(null); // 1 (Chest)
4150

42-
// Reading Heart Rate Measurement (0x2A37) characteristic (multi field)
43-
byte[] data = new byte[] {20, 74, 13, 3};
44-
GattResponse response = parser.parse("2A37", data);
45-
String heartRateValue = response.get("Heart Rate Measurement Value (uint8)").getInteger(null); // prints 74
46-
String rrIntervalValue = response.get("RR-Interval").getInteger(null); // prints 781
51+
// Read Heart Rate Measurement (0x2A37) — a multi-field characteristic.
52+
response = parser.parse("2A37", new byte[] {20, 74, 13, 3});
53+
int heartRate = response.get("Heart Rate Measurement Value (8 bit resolution)").getInteger(null); // 74
4754

48-
// Writing Heart Rate Control Point (0x2A39) characteristic
55+
// Write Heart Rate Control Point (0x2A39).
4956
GattRequest request = parser.prepare("2A39");
50-
request.setField("Heart Rate Control Point", 1); // control value to be sent to a bluetooth device
57+
request.setField("Heart Rate Control Point", 1);
5158
byte[] data = parser.serialize(request);
5259
```
5360

54-
See more examples in the integration tests: [GenericCharacteristicParserIntegrationTest](src/test/java/org/bluetooth/gattparser/GenericCharacteristicParserIntegrationTest.java)
55-
56-
---
57-
**Extending the library with user defined services and characteristics**
61+
See more in the integration tests:
62+
[GenericCharacteristicParserIntegrationTest](src/test/java/org/openhab/bluetooth/gattparser/GenericCharacteristicParserIntegrationTest.java).
5863

59-
The gatt-parser library is designed to be able to add support for some new custom services/characteristics or to override an existing ("approved") [service and characteristic](https://www.bluetooth.com/specifications/gatt). This can be done by just providing a new GATT XML file which specifies your service and characteristic (have a look at the standard definition for the [Battery Level characteristic](src/main/resources/gatt/characteristic/org.bluetooth.characteristic.battery_level.xml)). The library will read your custom files and build internal rules/conditions for parsing and serialization of your custom characteristics. This means you don't have to write any code to parse/serialize simple or complex custom characteristics.
64+
## Extending with user-defined services and characteristics
6065

61-
_Loading XML GATT specification files (GATT-like specifications) from a folder:_
66+
The library can add support for custom services/characteristics, or override a
67+
bundled one, purely by providing a GATT XML file — no code required. See the
68+
bundled [Battery Level characteristic](src/main/resources/gatt/characteristic/org.bluetooth.characteristic.battery_level.xml)
69+
for the schema.
6270

6371
```java
6472
BluetoothGattParser parser = BluetoothGattParserFactory.getDefault();
65-
File extensionsFolderFile = new File(..);
66-
gattParser.loadExtensionsFromFolder(extensions);
73+
parser.loadExtensionsFromFolder(new File("/path/to/gatt-extensions"));
6774
```
6875

69-
**A custom parser can be added for a characteristic if you are not satisfied with the default one**
76+
If the generic parser is not enough for a given characteristic, register your own:
7077

71-
See the default one for a hint and a reference: [GenericCharacteristicParser](src/main/java/org/bluetooth/gattparser/GenericCharacteristicParser.java)
7278
```java
7379
BluetoothGattParser parser = BluetoothGattParserFactory.getDefault();
74-
CharacteristicParser customParser = new ...; // your own implementation
75-
parser.registerParser(CHARACTERISTIC_UUID, customParser);
80+
parser.registerParser(CHARACTERISTIC_UUID, myCustomParser);
7681
```
7782

78-
---
79-
## Contribution
83+
## Bundled GATT specifications
84+
85+
Each characteristic/service is one XML file under `src/main/resources/gatt/`. A
86+
build-time generator indexes them into `gatt_spec_registry.json` (the `type`
87+
attribute of each file must equal its filename).
8088

81-
You are welcome to contribute to the project.
89+
The Bluetooth SIG **retired** the machine-readable characteristic XML repository
90+
around 2019. Since then it publishes the [GATT Specification Supplement (GSS)](https://www.bluetooth.com/specifications/gss/)
91+
as YAML, where the field structure is present but scaling/unit/presence are
92+
encoded as a rigid mini-grammar inside prose. The characteristics added here were
93+
derived from that GSS: scalars are high fidelity, while cases the prose cannot
94+
express unambiguously carry an inline `<!-- REVIEW -->` marker rather than a
95+
fabricated value.
8296

83-
The build process is streamlined by using standard maven tools.
97+
## Contribution
98+
99+
Contributions are welcome. Build with Maven:
84100

85-
To build the project with maven:
86101
```bash
87102
mvn clean install
88103
```
89104

90-
To cut a new release and upload it to the Maven Central Repository:
105+
Cut a release to Maven Central:
106+
91107
```bash
92108
mvn release:prepare -B
93109
mvn release:perform

src/main/java/org/openhab/bluetooth/gattparser/FieldHolder.java

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ public Long getLong(Long def) {
161161
public BigInteger getBigInteger(BigInteger def) {
162162
BigDecimal result = new BigDecimalConverter(null).convert(BigDecimal.class, prepareValue());
163163
return result != null
164-
? result.multiply(BigDecimal.valueOf(getMultiplier()))
164+
? result.multiply(getMultiplierExact())
165165
.add(BigDecimal.valueOf(getOffset())).setScale(0, RoundingMode.HALF_UP).toBigInteger()
166166
: def;
167167
}
@@ -175,7 +175,7 @@ public BigInteger getBigInteger(BigInteger def) {
175175
public BigDecimal getBigDecimal(BigDecimal def) {
176176
BigDecimal result = new BigDecimalConverter(null).convert(BigDecimal.class, prepareValue());
177177
return result != null
178-
? result.multiply(BigDecimal.valueOf(getMultiplier()))
178+
? result.multiply(getMultiplierExact())
179179
: def;
180180
}
181181

@@ -585,6 +585,29 @@ private double getMultiplier() {
585585
return multiplier;
586586
}
587587

588+
/**
589+
* Exact BigDecimal counterpart of {@link #getMultiplier()}. The decimal exponent is applied via
590+
* {@link BigDecimal#scaleByPowerOfTen(int)} and the binary exponent via exact powers of two, so
591+
* the BigDecimal paths do not inherit the rounding error of {@code Math.pow(10, e)}.
592+
*/
593+
private BigDecimal getMultiplierExact() {
594+
BigDecimal multiplier = BigDecimal.ONE;
595+
if (field.getDecimalExponent() != null) {
596+
multiplier = multiplier.scaleByPowerOfTen(field.getDecimalExponent());
597+
}
598+
if (field.getBinaryExponent() != null) {
599+
int b = field.getBinaryExponent();
600+
BigDecimal powerOfTwo = new BigDecimal(BigInteger.TWO.pow(Math.abs(b)));
601+
multiplier = b >= 0
602+
? multiplier.multiply(powerOfTwo)
603+
: multiplier.divide(powerOfTwo); // negative power of two is always exact (terminates in binary)
604+
}
605+
if (field.getMultiplier() != null && field.getMultiplier() != 0) {
606+
multiplier = multiplier.multiply(BigDecimal.valueOf(field.getMultiplier()));
607+
}
608+
return multiplier;
609+
}
610+
588611
/**
589612
* Reads offset-to-be-added to field value received from request.
590613
* This is an extension to official GATT characteristic field specification,

src/main/java/org/openhab/bluetooth/gattparser/GenericCharacteristicParser.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,24 @@ public LinkedHashMap<String, FieldHolder> parse(Characteristic characteristic, b
9090
continue;
9191
}
9292
FieldFormat fieldFormat = field.getFormat();
93+
if (field.isRepeated()) {
94+
// Array field (GSS uint16[n] etc.): repeat the element either a fixed number
95+
// of times or, when repeats == 0, until the data is exhausted. Elements are
96+
// exposed as indexed holders "Name[0]", "Name[1]", ...
97+
int elementSize = fieldFormat.getSize();
98+
if (elementSize == FieldFormat.FULL_SIZE || elementSize <= 0) {
99+
throw new CharacteristicFormatException(
100+
"Repeated field \"" + field.getName() + "\" must have a fixed-size element format.");
101+
}
102+
int repeats = field.getRepeats();
103+
int index = 0;
104+
while (repeats == 0 ? offset + elementSize <= raw.length * 8 : index < repeats) {
105+
result.put(field.getName() + "[" + index + "]", parseField(field, raw, offset));
106+
offset += elementSize;
107+
index++;
108+
}
109+
continue;
110+
}
93111
result.put(field.getName(), parseField(field, raw, offset));
94112
if (fieldFormat.getSize() == FieldFormat.FULL_SIZE) {
95113
// full size field, e.g. a string

src/main/java/org/openhab/bluetooth/gattparser/num/IEEE11073FloatingPointNumberFormatter.java

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,23 +82,81 @@ public Float deserializeFloat(BitSet bits) {
8282

8383
@Override
8484
public Double deserializeDouble(BitSet bits) {
85-
throw new IllegalStateException("Operation not supported");
85+
// IEEE-11073 defines no 64-bit form; the 32-bit FLOAT is the widest, decoded as a Double.
86+
Float value = deserializeFloat(bits);
87+
return value != null ? value.doubleValue() : null;
8688
}
8789

8890

8991
@Override
9092
public BitSet serializeSFloat(Float number) {
91-
throw new IllegalStateException("Operation not supported");
93+
return serialize(number, 12, 4, SFLOAT_NaN, SFLOAT_POSITIVE_INFINITY, SFLOAT_NEGATIVE_INFINITY);
9294
}
9395

9496
@Override
9597
public BitSet serializeFloat(Float number) {
96-
throw new IllegalStateException("Operation not supported");
98+
return serialize(number, 24, 8, FLOAT_NaN, FLOAT_POSITIVE_INFINITY, FLOAT_NEGATIVE_INFINITY);
9799
}
98100

99101
@Override
100102
public BitSet serializeDouble(Double number) {
101-
throw new IllegalStateException("Operation not supported");
103+
return serializeFloat(number != null ? number.floatValue() : null);
104+
}
105+
106+
/**
107+
* Encodes a decimal value as an IEEE-11073 SFLOAT/FLOAT: a two's-complement mantissa in the low
108+
* {@code mantissaSize} bits and a base-10 exponent in the top {@code exponentSize} bits, laid out
109+
* little-endian to match the deserialize path. The exponent is chosen so the mantissa fits its
110+
* signed range while preserving as much precision as the format allows.
111+
*/
112+
private BitSet serialize(Float number, int mantissaSize, int exponentSize,
113+
int nanMantissa, int posInfMantissa, int negInfMantissa) {
114+
int mantissa;
115+
int exponent = 0;
116+
if (number == null || Float.isNaN(number)) {
117+
mantissa = nanMantissa;
118+
} else if (number == Float.POSITIVE_INFINITY) {
119+
mantissa = posInfMantissa;
120+
} else if (number == Float.NEGATIVE_INFINITY) {
121+
mantissa = negInfMantissa;
122+
} else {
123+
long mantissaMax = (1L << (mantissaSize - 1)) - 1; // largest positive signed mantissa
124+
long mantissaMin = -(1L << (mantissaSize - 1));
125+
int expMax = (1 << (exponentSize - 1)) - 1;
126+
int expMin = -(1 << (exponentSize - 1));
127+
double value = number;
128+
// Raise the exponent until the mantissa fits the signed range.
129+
double scaled = value;
130+
while ((Math.round(scaled) > mantissaMax || Math.round(scaled) < mantissaMin) && exponent < expMax) {
131+
scaled /= 10.0;
132+
exponent++;
133+
}
134+
// Lower the exponent to keep fractional precision while the mantissa still fits.
135+
while (exponent > expMin) {
136+
double finer = scaled * 10.0;
137+
if (Math.round(finer) > mantissaMax || Math.round(finer) < mantissaMin) {
138+
break;
139+
}
140+
scaled = finer;
141+
exponent--;
142+
}
143+
mantissa = (int) Math.round(scaled);
144+
}
145+
146+
BitSet result = new BitSet(mantissaSize + exponentSize);
147+
BitSet mantissaBits = twosComplementNumberFormatter.serialize(mantissa, mantissaSize, true);
148+
BitSet exponentBits = twosComplementNumberFormatter.serialize(exponent, exponentSize, true);
149+
for (int i = 0; i < mantissaSize; i++) {
150+
if (mantissaBits.get(i)) {
151+
result.set(i);
152+
}
153+
}
154+
for (int i = 0; i < exponentSize; i++) {
155+
if (exponentBits.get(i)) {
156+
result.set(mantissaSize + i);
157+
}
158+
}
159+
return result;
102160
}
103161

104162
}

src/main/java/org/openhab/bluetooth/gattparser/num/TwosComplementNumberFormatter.java

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,42 +33,37 @@ public Integer deserializeInteger(BitSet bits, int size, boolean signed) {
3333
if (size > 32) {
3434
throw new IllegalArgumentException("size must be less or equal 32");
3535
}
36-
37-
if (size == 1) {
38-
signed = false;
39-
}
40-
41-
boolean isNegative = signed && size > 1 && bits.get(size - 1);
42-
int value = isNegative ? -1 : 0;
43-
for (int i = 0; i < bits.length() && i < size; i++) {
44-
if (isNegative && !bits.get(i)) {
45-
value ^= 1 << i;
46-
} else if (!isNegative && bits.get(i)) {
47-
value |= 1 << i;
48-
}
49-
}
50-
return value;
36+
return (int) deserialize(bits, size, signed);
5137
}
5238

5339
@Override
5440
public Long deserializeLong(BitSet bits, int size, boolean signed) {
5541
if (size > 64) {
5642
throw new IllegalArgumentException("size must be less or equal than 64");
5743
}
44+
return deserialize(bits, size, signed);
45+
}
5846

47+
/**
48+
* Reads up to 64 bits, little-endian, into a long and sign-extends when required. The BitSet
49+
* already holds the bits little-endian (bit 0 = LSB), so a straight accumulation of the low
50+
* {@code size} bits yields the unsigned magnitude; a set top bit under two's-complement is then
51+
* extended into the unused high bits.
52+
*/
53+
private long deserialize(BitSet bits, int size, boolean signed) {
5954
if (size == 1) {
6055
signed = false;
6156
}
62-
63-
boolean isNegative = signed && size > 1 && bits.get(size - 1);
64-
long value = isNegative ? -1L : 0L;
65-
for (int i = 0; i < bits.length() && i < size; i++) {
66-
if (isNegative && !bits.get(i)) {
67-
value ^= 1L << i;
68-
} else if (!isNegative && bits.get(i)) {
57+
long value = 0L;
58+
int limit = Math.min(size, bits.length());
59+
for (int i = 0; i < limit; i++) {
60+
if (bits.get(i)) {
6961
value |= 1L << i;
7062
}
7163
}
64+
if (signed && size > 1 && size < 64 && (value & (1L << (size - 1))) != 0) {
65+
value |= -(1L << size); // sign-extend the two's-complement top bit
66+
}
7267
return value;
7368
}
7469

0 commit comments

Comments
 (0)