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

KeyTypeDefaultNotes
suitestring""Label only.
targetobject-See below.
settingsobject-See below.

target.*

KeyTypeDefaultRequiredNotes
advertised_namestring""Yes (dispatch)Matched against the device's advertised name.
name_matchexact | prefixprefixNoAnything other than the literal exact is treated as prefix (a StartsWith match).
service_uuidGUID string""Yes (dispatch)Parsed with Guid.Parse - must be a full 128-bit UUID.
characteristic_stepslist[]NoEmpty reproduces exactly the 3 fixed checks below - see the step type catalogue.

settings.*

KeyTypeDefault
scan_timeout_secondsint15
connect_timeout_secondsint10

characteristic_steps[].*

KeyTypeDefaultApplies to
namestring""All - becomes the step's name in results.
typewrite | write_expect_responsewriteAll - any other value behaves as write.
characteristic_uuidGUID string""All
value_hexhex string""All - must be even-length (e.g. "AA55", not "A55").
expected_value_hexhex string""write_expect_response
response_moderead | notify_same | notify_otherreadwrite_expect_response
response_characteristic_uuidGUID string""write_expect_response + notify_other
timeout_secondsint5write_expect_response notify modes

The three fixed checks

Every run always executes these three, in order, before any characteristic_steps:

StepWhat it doesSample PASS detailSample 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

SituationDetail
Characteristic doesn't existCharacteristic {uuid} not found on device.
Write rejectedWrite failed with result code {n}.
Read-back failedRead-back failed with result code {n}.
Bytes didn't matchExpected AA55 via read-back but got 0000.
No notification arrivedNo notification received on {uuid} within 5s.
Required service missingSkipped - 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:

SituationMessage
YAML doesn't parseInvalid contract YAML: {parser message}
Missing required fieldsContract YAML must specify target.advertised_name and target.service_uuid.
Target device not owned by youagentId 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:

CodeMeaning
0Every dispatched device passed
1At least one device failed or errored
2Usage error, missing dependency, or an invalid contract
3No online device for a requested platform
4Timed out waiting for the run
5Manager 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.