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.

What it measures is irrelevant.

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.

FieldValue
Service UUID6e1a0001-b5a3-f393-e0a9-e50e24dcca9e
Characteristic UUID6e1a0002-b5a3-f393-e0a9-e50e24dcca9e — read + notify
Payload4 bytes, little-endian IEEE-754 float
Attestation UUID6e1a0003-b5a3-f393-e0a9-e50e24dcca9e — read, optional. What it is for
Device nameWarrant 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.

Endianness is the contract, not a client setting.

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.

Change TOOL_KEY before you flash it. It ships as change-me-before-flashing.

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.

The unit does not travel on the wire.

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.

  1. 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.
  2. 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.

CodeUnitName
0x2701mmetre
0x2702kgkilogram
0x2703ssecond
0x2704Aampere
0x2705Kkelvin
0x2712m/smetre per second
0x2713m/s²metre per second squared
0x2722Hzhertz
0x2723Nnewton
0x2724Papascal
0x2725Jjoule
0x2726Wwatt
0x2728Vvolt
0x272F°Cdegree Celsius
0x27AD%percentage
Declare a code outside this table and the driver refuses to exist.

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 — vetted

The 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 reading

The connection fails with that message. That is a real outcome and it is not a zero.

Every rung above the last is still a measured value.

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

SymptomCause
Serial monitor goes quiet after bootCorrect. loop() only prints once a client is connected.
Device vanishes after the first disconnectAdvertising 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 arriveThe BLE2902 CCCD descriptor is missing.
status 133 on connectAndroid’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 8Out of range, or the device slept before the connection finished.
status 19 / status 22The device ended the connection, or this phone did.
Build fails on onDisconnectAn unpinned ESP32 core. 3.x added a second parameter and the sketch marks the override — see platformio.ini.
The reading is a battery percentageIt 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.