The GattLab contract language
Everything on this page is derived directly from the parser and test runner - if a contract works here, it works exactly the same when dispatched from the console.
What a contract is
A contract is a plain YAML file describing the behavior you expect from a BLE peripheral - what it should advertise as, which GATT service it must expose, and optionally which characteristics you want to write to and what response you expect back. The exact same text is parsed identically whether you validate it in the console, dispatch it from the Manager, or bundle it locally in GattLab Agent - one parser, one schema.
Keys are written with underscores (advertised_name, not advertisedName).
A complete example
This is the default contract used throughout GattLab - a Nordic Blinky-flashed nRF52840 with an LED characteristic and an echo characteristic.
suite: nrf52840-smoke
target:
advertised_name: "Nordic_Blinky"
name_match: prefix # exact | prefix
service_uuid: "00001523-1212-efde-1523-785feabcd123"
characteristic_steps:
- name: "LED on"
type: write
characteristic_uuid: "00001525-1212-efde-1523-785feabcd123"
value_hex: "01"
- name: "LED off"
type: write
characteristic_uuid: "00001525-1212-efde-1523-785feabcd123"
value_hex: "00"
- name: "Echo roundtrip (notify)"
type: write_expect_response
characteristic_uuid: "00001526-1212-efde-1523-785feabcd123"
value_hex: "AA55"
expected_value_hex: "AA55"
response_mode: notify_other
response_characteristic_uuid: "00001527-1212-efde-1523-785feabcd123"
timeout_seconds: 5
settings:
scan_timeout_seconds: 15
connect_timeout_seconds: 10
suite is a label only - it's never used by the runner, it just shows up as the "Suite"
column in history.
Schema reference
Top level
| Key | Type | Default | Notes |
|---|---|---|---|
suite | string | "" | Label only. |
target | object | - | See below. |
settings | object | - | See below. |
target.*
| Key | Type | Default | Required | Notes |
|---|---|---|---|---|
advertised_name | string | "" | Yes (dispatch) | Matched against the device's advertised name. |
name_match | exact | prefix | prefix | No | Anything other than the literal exact is treated as prefix (a StartsWith match). |
service_uuid | GUID string | "" | Yes (dispatch) | Parsed with Guid.Parse - must be a full 128-bit UUID. |
characteristic_steps | list | [] | No | Empty reproduces exactly the 3 fixed checks below - see the step type catalogue. |
settings.*
| Key | Type | Default |
|---|---|---|
scan_timeout_seconds | int | 15 |
connect_timeout_seconds | int | 10 |
characteristic_steps[].*
| Key | Type | Default | Applies to |
|---|---|---|---|
name | string | "" | All - becomes the step's name in results. |
type | write | write_expect_response | write | All - any other value behaves as write. |
characteristic_uuid | GUID string | "" | All |
value_hex | hex string | "" | All - must be even-length (e.g. "AA55", not "A55"). |
expected_value_hex | hex string | "" | write_expect_response |
response_mode | read | notify_same | notify_other | read | write_expect_response |
response_characteristic_uuid | GUID string | "" | write_expect_response + notify_other |
timeout_seconds | int | 5 | write_expect_response notify modes |
The three fixed checks
Every run always executes these three, in order, before any characteristic_steps:
| Step | What it does | Sample PASS detail | Sample FAIL detail |
|---|---|---|---|
| Discoverable | Scans until a device's advertised name matches target.advertised_name, within scan_timeout_seconds. |
Saw "Nordic_Blinky" after 3.1s. |
No device matching "Nordic_Blinky" (prefix) seen within 15s. |
| Connects | Connects to the discovered device within connect_timeout_seconds. |
Connected to Nordic_Blinky. |
Connection failed: ... |
| Required GATT service exists | Looks up target.service_uuid on the connected device. |
Found service 00001523-.... |
Service 00001523-... not found on device. |
If a device is never discovered, Connects and Required GATT service exists both report Skipped - device was not discovered. instead of running. If the connection fails, the service check reports Skipped - connection failed.
Characteristic step types
Each entry in target.characteristic_steps runs after the three fixed checks, only if the required service was found - in the order they're listed.
write
Writes value_hex to the characteristic and passes if the write completes without a GATT error.
- name: "LED on"
type: write
characteristic_uuid: "00001525-1212-efde-1523-785feabcd123"
value_hex: "01"
write_expect_response - response_mode: read
Writes, then reads the same characteristic back and compares bytes.
- name: "Echo roundtrip (read-back)"
type: write_expect_response
characteristic_uuid: "00001526-1212-efde-1523-785feabcd123"
value_hex: "AA55"
expected_value_hex: "AA55"
response_mode: read
write_expect_response - response_mode: notify_same
Subscribes to the same characteristic before writing, then waits for a notification carrying the expected bytes.
- name: "Echo roundtrip (notify, same characteristic)"
type: write_expect_response
characteristic_uuid: "00001526-1212-efde-1523-785feabcd123"
value_hex: "AA55"
expected_value_hex: "AA55"
response_mode: notify_same
timeout_seconds: 5
write_expect_response - response_mode: notify_other
Subscribes to a different characteristic (response_characteristic_uuid) before writing, then waits for the notification there.
- name: "Echo roundtrip (notify, other characteristic)"
type: write_expect_response
characteristic_uuid: "00001526-1212-efde-1523-785feabcd123"
value_hex: "AA55"
expected_value_hex: "AA55"
response_mode: notify_other
response_characteristic_uuid: "00001527-1212-efde-1523-785feabcd123"
timeout_seconds: 5
Subscribing happens before the write in both notify modes, specifically so a fast notification can't race ahead and be missed.
Failure details you'll see
| Situation | Detail |
|---|---|
| Characteristic doesn't exist | Characteristic {uuid} not found on device. |
| Write rejected | Write failed with result code {n}. |
| Read-back failed | Read-back failed with result code {n}. |
| Bytes didn't match | Expected AA55 via read-back but got 0000. |
| No notification arrived | No notification received on {uuid} within 5s. |
| Required service missing | Skipped - required service was not found. |
Reading results
Every step - the 3 fixed checks plus each characteristic step - becomes one row: a name, a PASS/FAIL chip,
a duration, and a detail message. The overall run is PASSED only if every step passed
(OverallPassed = steps.All(passed)) - there's no partial-credit state.
A detail starting with Skipped - means a prior step's failure prevented this one from running at all, not that this specific check failed on its own.
Error reference
These are the exact messages the API returns when a contract can't be dispatched at all:
| Situation | Message |
|---|---|
| YAML doesn't parse | Invalid contract YAML: {parser message} |
| Missing required fields | Contract YAML must specify target.advertised_name and target.service_uuid. |
| Target device not owned by you | agentId does not belong to this account. |
CI integration
Your CI runner never needs a Bluetooth radio. It dispatches a contract to GattLab, and the devices in your lab - the ones running the Agent app - execute it and report back. A GitHub-hosted runner can drive a phone sitting on a shelf in your office.
1. Mint an API token
Go to API tokens and create one. It's shown once; store it as a
repository secret named GATTLAB_TOKEN. A token is separate from your login and
can be revoked on its own, so a secret sitting in a build system stays under your control.
2. Add the step to your workflow
- uses: Umer-Mahmood/TestLabX/.github/actions/gattlab-run@main
with:
contract: tests/blinky.yaml
agents: android,windows
token: ${{ secrets.GATTLAB_TOKEN }}
junit: results/junit.xml
agents is a list of platforms - android, windows,
ios - and each one resolves to every online device of that platform on your
account. The step fails if a platform you asked for has no device online.
Not on GitHub Actions? cli/gattlab.sh is a plain shell script over
curl and jq; run it from any CI system.
3. Read the result
The step writes a JUnit XML report your CI can render as checks and annotations, appends a results table to the GitHub job summary, and exits non-zero when a device fails. The exit code distinguishes a real regression from a lab problem:
| Code | Meaning |
|---|---|
0 | Every dispatched device passed |
1 | At least one device failed or errored |
2 | Usage error, missing dependency, or an invalid contract |
3 | No online device for a requested platform |
4 | Timed out waiting for the run |
5 | Manager unreachable, or the token was rejected |
Three things to know before you rely on it
The Agent app has to be open on each device. It only polls for work while it's in the foreground, so a lab phone needs the app on-screen and the display kept awake. A device whose app is closed reads as offline, and the run exits 3.
Devices run one at a time. A fleet run dispatches sequentially, so
wall-clock time is roughly the number of devices times the length of one run. Set
timeout accordingly.
GattLab does not flash your firmware. It tests whatever peripheral is advertising near the agent, which is not necessarily the firmware this CI run built. Flash the device before the GattLab step, or you may be testing a stale build.
Not yet supported
Advertising interval and RSSI assertions, MTU and connection-parameter checks (interval, latency, supervision timeout), indications, descriptor reads, and multi-connection scenarios aren't in the contract language yet. Writing a contract expecting these today will simply be ignored by the parser, not rejected - double-check your contract against the schema tables above.