Open hardware · MIT
The instrument is the cheap part.
A value is measured when it reached the record from a paired device without passing through a human. Nothing in that sentence says what the device cost, who made it, or what it measures. This page is the whole of the hardware side: one sketch you can read in a sitting, five lines of wire contract, and the three ways the client will read a device that has never heard of us.
What you are flashing
firmware/warrant_reference_instrument.ino turns any ESP32 dev module into a BLE peripheral that notifies a 32-bit float twice a second. The Android client recognises it, reads it, and drops the number into a measurement field on a live form. No libraries beyond the ESP32 Arduino core.
The shipped sketch returns a sine sweep across 25–31, so you can watch the client both pass and fail an acceptance rule of 6–9 Nm without anybody faking a number. It is not a torque wrench and the record does not pretend it is. It exists to prove the path end to end and to make the driver abstraction concrete — replace one function and nothing above the driver changes. That substitution is the entire argument.
The wire contract
Five facts, and they must agree with Esp32ReferenceDriver in the Android client. Change them in one place and you must change them in the other — or change nothing and fall back to the generic path, which reads the device but marks the reading as unvetted.
| Field | Value |
|---|---|
| Service UUID | 6e1a0001-b5a3-f393-e0a9-e50e24dcca9e |
| Characteristic UUID | 6e1a0002-b5a3-f393-e0a9-e50e24dcca9e — read + notify |
| Payload | 4 bytes, little-endian IEEE-754 float |
| Attestation UUID | 6e1a0003-b5a3-f393-e0a9-e50e24dcca9e — read, optional. What it is for |
| Device name | Warrant Ref 01 — the driver also matches the Warrant name prefix |
The characteristic carries a 0x2902 CCCD descriptor. Without it a client can subscribe and will simply never be notified, which looks exactly like dead hardware.
If you port the sketch to a big-endian part, byte-swap in the firmware. The client decodes little-endian because the wire format says little-endian.
Proving the number is yours
Everything above gets a number onto the screen. It does not yet get one onto a record, and the gap between those two things is the reason this section exists. A plain characteristic is a broadcast: anything within radio range can advertise the same service and the same four bytes, and the handset carrying them cannot tell the difference. A reading that arrives that way is real enough to show and is not evidence of anything.
So the sketch publishes a second characteristic beside the plain one, carrying what the device signed. It is the whole of the distance between measured and unvetted-. Reading it is optional by design — a device that does not expose it still pairs and still reads, and its numbers simply cannot be called measured, because nothing but the instrument’s own key can make that claim.
frame 40 bytes: counter u32 LE | value 4 bytes | HMAC-SHA256 32 bytes HMAC-SHA256(TOOL_KEY, "warrant-reading-v1|" TOOL_ID "|" counter "|" <the 4 raw bytes>)
The signed frame, and the message underneath it.
The leading warrant-reading-v1 is a domain separator, and it is not decoration: signed material without one can be lifted whole and replayed into any other protocol that happens to share the key. It matches READING_SIGNATURE_V1 in web/src/server/instruments.ts, which is the code that checks it. The four bytes signed are the same four bytes the plain characteristic serves — the signature covers the reading itself, not a re-encoding of it.
The counter is persisted to NVS and strictly increases, and it survives a reboot on purpose. BLE is a broadcast, so a frame can simply be recorded off the air and played back later; the server spends each counter once and refuses one it has already seen. A counter that restarted at zero on every power cycle would make every frame the device ever sent replayable again.
The key is the entire identity of the instrument — a board flashed with the published default can be impersonated by anyone who has read this page, which is everyone. Change TOOL_ID too, because that is the name the record will carry. Neither may ever appear in the Android app: the point of signing on the device is that the handset cannot forge a frame it is merely carrying, and a key shipped inside the client gives that away completely.
A browser will not do this. The web client is capped at the open tier and never reads the attestation characteristic, because attestation is what the installed app adds — which is why the same procedure that goes green on a phone refuses on a laptop rather than quietly settling for a weaker number.
Flashing it
PlatformIO, no IDE
cd firmware pio run -t upload pio device monitor
First run downloads the toolchain, about two minutes. Monitor is 115200.
platformio.ini pins espressif32@~6.5.0 deliberately. That release ships arduino-esp32 2.0.x, whose BLEServerCallbacks::onDisconnect takes one parameter. Core 3.x added a second, and the sketch marks the override — so an unpinned build fails to compile on a newer core rather than misbehaving quietly.
Arduino IDE
Board ESP32 Dev Module. Upload, then open the serial monitor at 115200. You should see it announce itself and then go quiet:
Warrant reference instrument starting advertising as Warrant Ref 01
loop() only prints once a client is connected, so silence with nothing paired is correct and is not a sign the board failed to start. Pair from the app and the notifications begin.
Putting your own sensor behind it
One function. Everything above it — the driver, the form field, the acceptance rule, the sealed record — is untouched.
static float readSensor() {
// the shipped stand-in: a slow sweep across the acceptance band
static float t = 0.0f;
t += 0.05f;
return 28.0f + 3.0f * sinf(t);
}firmware/warrant_reference_instrument.ino
static float readSensor() {
return myLoadCell.getNewtonMetres();
}Any sensor, any bus. Return a float in the unit you intend to report.
Return whatever your sensor produces, as a float, in a unit you have decided on. Do not average, clamp or smooth it into looking healthy — a reading that is out of band is a real outcome and the product is built to show it as one.
Four bytes of float carry no unit, so Esp32ReferenceDriver supplies one — and it says Nm, because that is what the reference instrument claims. Attach a thermometer without changing anything else and the record will read 21.4 Nm: measured, sealed, and wrong. You have two honest ways out.
- Edit the driver. Change produces in Drivers.kt — unit, and the plausible min and max. Correct, one line, and it means your build of the app is now specific to your instrument.
- Let the device declare it. Add a presentation-format descriptor and the client reads the unit off the hardware, with no per-vendor code written on our side. This is the better answer and it is the next section.
Declaring your own unit
The 0x2904 Characteristic Presentation Format descriptor is seven bytes in which a device states its own width, its own scale and its own unit. Where it is present, decoding is following a specification rather than inferring one, and a reading from it is marked declared- instead of unvetted-.
#include <BLE2904.h> BLE2904* fmt = new BLE2904(); fmt->setFormat(BLE2904::FORMAT_FLOAT32); // 0x14 — 4 bytes, little-endian fmt->setExponent(0); // value = wire × 10^exponent fmt->setUnit(0x272F); // °C, from the SIG assigned numbers fmt->setNamespace(1); fmt->setDescription(0); characteristic->addDescriptor(fmt);
arduino-esp32 2.0.x. Add this beside the BLE2902 already in setup().
The exponent is a signed base-10 scale, so a sint16 in hundredths declares FORMAT_SINT16 with an exponent of -2 and sends whole integers on the wire. Sign extension happens from the top of the declared format, not the top of the byte width — a sint12 signs from bit 11, which is why the client tracks both.
Units the client can resolve
Fifteen codes, and the table is partial on purpose. A missing code surfaces as null and stops the driver; a wrong code puts a confident, incorrect unit on a sealed record, which is the exact failure this product exists to prevent.
| Code | Unit | Name |
|---|---|---|
0x2701 | m | metre |
0x2702 | kg | kilogram |
0x2703 | s | second |
0x2704 | A | ampere |
0x2705 | K | kelvin |
0x2712 | m/s | metre per second |
0x2713 | m/s² | metre per second squared |
0x2722 | Hz | hertz |
0x2723 | N | newton |
0x2724 | Pa | pascal |
0x2725 | J | joule |
0x2726 | W | watt |
0x2728 | V | volt |
0x272F | °C | degree Celsius |
0x27AD | % | percentage |
A number with no unit is not a measurement and cannot be checked against an acceptance rule, so the client falls back to the generic path and labels the reading as a guess rather than inventing a unit. Torque is the case you will hit first: the SIG has a code for newton metre and this table does not carry it yet. Add codes to PresentationFormat.UNITS in GattTree.kt against the published assigned-numbers list — never from memory.
0x2700, unitless, is absent rather than mapped to an empty string. A characteristic that declares itself unitless is not carrying a measurement and is treated exactly like an unknown code.
While you are adding descriptors, 0x2901 — the user description — costs nothing and gives the pairing screen your own words for what the characteristic is. It is free context and almost every device omits it.
A device you cannot reflash
You do not have to run our firmware at all. Point the client at an unfamiliar BLE device and it works down three rungs, in this order, and records which one it landed on. Below them is a fourth outcome, which is not a reading at all.
A driver we wrote
no prefix — vettedThe reference instrument, or the Bluetooth SIG Environmental Sensing service that any conforming sensor exposes — sint16 in hundredths of a degree, specified by the profile rather than guessed. Someone checked the encoding against firmware or a published spec by hand.
The device declared its encoding
declared-A 0x2904 descriptor stated the width, scale and unit, and the client followed that specification. Nothing was inferred and no per-vendor code was written. This is the rung the previous section puts you on.
Nobody knows, so it says so
unvetted-No usable descriptor. The client decodes as a little-endian float or sint16. That is a guess, and the tool id on the record says it is a guess.
Nothing on the device could carry a reading
no readingThe connection fails with that message. That is a real outcome and it is not a zero.
The reading genuinely came from a paired device without passing through a human, which is the property that decides the class. The prefix records how much is known about how it was decoded — a different question, and one the record is not allowed to blur into the first.
What the client refuses to do
Which characteristic gets read is not “the first one that answers”. Before anything is decoded the client throws candidates away:
- Infrastructure services — generic access and attribute, device information, battery, transmit power, current time, DST, reference time. They describe the device; they do not measure anything.
- Known decoys. Battery level above all: a uint8 of 87 decodes cleanly, passes any plausibility check, is enumerated before the vendor characteristic on a great many devices, and is not a measurement. The clock characteristics are on the list too — they decode cleanly and change constantly, so they survive a naive does-it-move test as well, and were caught ranking as candidates against a real device.
- Anything neither readable nor subscribable.
What survives is ranked with a declared encoding ahead of one that must be inferred. Then, on connect, every characteristic on the device is written to the log with the verdict on each — not what was chosen, but why the rest lost:
6e1a0001/6e1a0002 [notify+read] declared=float32e0 °C - CHOSEN 0000180f/00002a19 [read] - skipped, known decoy and never a reading 0000180a/00002a29 [read] - skipped, infrastructure service 0000fff0/0000fff3 [write] - skipped, neither readable nor subscribable 0000fff0/0000fff1 [read] - candidate, outranked
GattTree.explain(), written on every connect.
At a bench with a phone and an unfamiliar device, a chosen characteristic with no account of the alternatives is the point at which you start guessing. Nothing is allowed to drop out of that list silently.
The decode itself refuses in four more places:
- A frame narrower than the declared format returns nothing. Decoding four bytes as a sint32 when three arrived means inventing the fourth, and the result is indistinguishable from a real number.
- A value outside what the wire format can express is out of range and flagged.
- A garbage frame read as a float arrives as NaN or an infinity. It renders as invalid, never as Infinity Nm inside a green measured badge.
- Readings are written to two decimal places. A 32-bit float widens to 26.606204986572266 — seventeen significant digits from a sensor with nothing like that resolution. A number carries an implicit claim about how precisely it was measured.
When it does not work
| Symptom | Cause |
|---|---|
| Serial monitor goes quiet after boot | Correct. loop() only prints once a client is connected. |
| Device vanishes after the first disconnect | Advertising was not restarted. The shipped sketch calls startAdvertising() from onDisconnect for exactly this reason — it reads as dead hardware and costs an hour every time. |
| Client subscribes, no notifications ever arrive | The BLE2902 CCCD descriptor is missing. |
status 133 on connect | Android’s generic Bluetooth error and by a distance the most common BLE failure on the platform. Usually transient — try again, and wake the device first if it sleeps. |
status 8 | Out of range, or the device slept before the connection finished. |
status 19 / status 22 | The device ended the connection, or this phone did. |
Build fails on onDisconnect | An unpinned ESP32 core. 3.x added a second parameter and the sketch marks the override — see platformio.ini. |
| The reading is a battery percentage | It should not be — battery level is on the decoy list. If it happens, read the explain() log and open an issue with it. |
Stopping is not failing. Tapping a device to connect cancels the scan, which is the whole point of tapping, and so does leaving the screen. Neither is reported as a fault.
Licence and source
MIT. The firmware is three files — the sketch, a platformio.ini and a README — and the client-side drivers it talks to are another six. Take any of it.
git clone https://github.com/mattrickslauer/warrant cd warrant/firmware
Rung three is the interesting unfinished work: enumerate the GATT tree, read the public spec for the service, infer the encoding, emit a real driver, compile it, run it against the live device, retry on failure. That is what Wright is for, and the About page says where it has got to.