Building a Complete Application

Step-by-step tutorial to build a complete application: login, search devices, display data, and show space information

Overview

This tutorial demonstrates how to build a complete Haltian IoT application that can:

  1. Authenticate - Login and obtain access tokens
  2. Search - Find devices by name, serial number, or ID
  3. Display Data - Show device measurements and position
  4. Show Context - Display building/floor information when device is in a space

This tutorial provides complete, working GraphQL queries that any AI agent or developer can use to build a functional application.

Prerequisites

  • Haltian IoT account with credentials
  • GraphQL client (Postman, Apollo, or any HTTP client)
  • GraphQL endpoint URL (provided by your deployment)

Configuration

API Endpoint

https://haltian-iot-api.eu.haltian.io/v1/graphql

Replace with your deployment’s endpoint URL.

Required Credentials

  • email - Your user email
  • organizationName - Your organization identifier
  • password - Your password

Step 1: Authentication

Login Mutation

Execute this mutation to obtain access and refresh tokens:

mutation Login($email: String!, $organizationName: String!, $password: String!) {
  login(
    loginInput: {
      email: $email
      organizationName: $organizationName
      password: $password
    }
  ) {
    accessToken
    expiresIn
    refreshExpiresIn
    refreshToken
    organizationId
  }
}

Variables

{
  "email": "your-email@example.com",
  "organizationName": "your-org",
  "password": "your-password"
}

Response

{
  "data": {
    "login": {
      "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "expiresIn": 900,
      "refreshExpiresIn": 43200,
      "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "organizationId": "2a749da0-5512-4b3e-8c4a-9b8f3c1d5e6f"
    }
  }
}

Token Storage

Store the accessToken and refreshToken for subsequent requests:

  • accessToken: Valid for 15 minutes (900 seconds)
  • refreshToken: Valid for 12 hours (43200 seconds)

Authorization Header

Include the access token in all subsequent requests:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Step 2: Search for Devices

Device Search Query

Search for devices by name, serial number, customer label, or UUID:

query DevicesByTextSearch($limit: Int = 25, $searchText: String, $searchUuid: uuid) {
  devices(
    limit: $limit
    where: {
      _or: [
        {identifiers: {customerLabelId: {_ilike: $searchText}}}
        {identifiers: {vendorSerial: {_ilike: $searchText}}}
        {identifiers: {giai: {_ilike: $searchText}}}
        {identifiers: {deviceId: {_eq: $searchUuid}}}
        {name: {_ilike: $searchText}}
        {externalId: {_ilike: $searchText}}
      ]
    }
  ) {
    id
    name
    connectorProtocol
    organizationId
    lastSeen
    availableMeasurements
    availableMessages
    fixedPositionGlobal
    spaceId
    identifiers {
      wirepasNodeId
      wirepasNetworkId
      wifiMac
      vendorSerial
      tuid
      lanMac
      imei
      customerLabelId
      bleMac
      deviceId
    }
    deviceType {
      make
      model
      productName
    }
    keywords {
      keyword
    }
    createdAt
    updatedAt
  }
}
{
  "limit": 25,
  "searchText": "%sensor%"
}
{
  "limit": 25,
  "searchUuid": "6e5f8ac6-a099-44e5-bb28-25fd3d1f47a6"
}

Response Example

{
  "data": {
    "devices": [
      {
        "id": "6e5f8ac6-a099-44e5-bb28-25fd3d1f47a6",
        "name": "Temperature Sensor 101",
        "connectorProtocol": "WIREPAS",
        "organizationId": "2a749da0-5512-4b3e-8c4a-9b8f3c1d5e6f",
        "lastSeen": "2026-02-05T10:30:45.123Z",
        "availableMeasurements": [
          "ambientTemperature",
          "relativeHumidity",
          "batteryVoltage"
        ],
        "availableMessages": ["wirepasNeighborDiagnostics"],
        "fixedPositionGlobal": {
          "type": "Point",
          "coordinates": [24.9384, 60.1699]
        },
        "spaceId": "55a0a3d0-84b6-4ace-b131-0b2420053e91",
        "identifiers": {
          "wirepasNodeId": 123456,
          "wirepasNetworkId": 7890,
          "vendorSerial": "TSN-2024-0101",
          "customerLabelId": "FLOOR3-ZONE-A-01",
          "deviceId": "6e5f8ac6-a099-44e5-bb28-25fd3d1f47a6"
        },
        "deviceType": {
          "make": "Haltian",
          "model": "TMP001",
          "productName": "Temperature Sensor"
        },
        "keywords": [
          {"keyword": "temperature"},
          {"keyword": "humidity"}
        ],
        "createdAt": "2025-10-15T08:20:00.000Z",
        "updatedAt": "2026-02-05T10:30:45.123Z"
      }
    ]
  }
}

Important Search Notes

  • Use % wildcards with _ilike for partial text matching
  • _ilike is case-insensitive
  • availableMeasurements shows which measurement types this device supports
  • Use the returned device id for subsequent queries

Device Location: Two Methods

Devices can have location information in two ways:

  1. Fixed Installation - Device has fixedPositionGlobal and spaceId set directly in the device record (for permanently installed sensors)
  2. Measurement Position - Device location tracked through measurementLastPosition (for mobile devices or recent position updates)

Add these fields to your device search to check for fixed installation:

query DevicesByTextSearch($limit: Int = 25, $searchText: String) {
  devices(
    limit: $limit
    where: {
      _or: [
        {identifiers: {customerLabelId: {_ilike: $searchText}}}
        {name: {_ilike: $searchText}}
      ]
    }
  ) {
    id
    name
    fixedPositionGlobal
    spaceId
    # ... other fields
  }
}

When to use which:

  • Check device fixedPositionGlobal and spaceId first for fixed installations
  • Query measurementLastPosition for mobile devices or if fixed position is not set
  • measurementLastPosition provides the most recent position data

Step 3: Get Device Location

Method 1: Check Fixed Installation (Preferred for Static Devices)

If the device returned from the search has fixedPositionGlobal and spaceId set, you already have the location:

{
  "id": "6e5f8ac6-a099-44e5-bb28-25fd3d1f47a6",
  "name": "Temperature Sensor 101",
  "fixedPositionGlobal": {
    "type": "Point",
    "coordinates": [24.9384, 60.1699]
  },
  "spaceId": "55a0a3d0-84b6-4ace-b131-0b2420053e91"
}

Skip to Step 4 to get space information.

Method 2: Query Measurement Position (For Mobile Devices)

query MeasurementLastPosition($device: uuid) {
  measurementLastPosition(where: {deviceId: {_eq: $device}}) {
    deviceId
    isStatic
    measuredAt
    positionGlobal
    spaceId
    zones {
      measuredAt
      zone {
        id
        name
      }
    }
  }
}

Variables

{
  "device": "6e5f8ac6-a099-44e5-bb28-25fd3d1f47a6"
}

Response Example

{
  "data": {
    "measurementLastPosition": [
      {
        "deviceId": "6e5f8ac6-a099-44e5-bb28-25fd3d1f47a6",
        "isStatic": true,
        "measuredAt": "2026-02-05T10:30:45.491Z",
        "positionGlobal": {
          "type": "Point",
          "coordinates": [24.9384, 60.1699]
        },
        "spaceId": "55a0a3d0-84b6-4ace-b131-0b2420053e91",
        "zones": [
          {
            "measuredAt": "2026-02-05T10:30:45.491Z",
            "zone": {
              "id": "7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
              "name": "Meeting Room A"
            }
          }
        ]
      }
    ]
  }
}

Response Field Descriptions

FieldTypeDescription
deviceIdUUIDDevice identifier
isStaticBooleantrue if device is stationary, false if mobile
measuredAtTimestampWhen the measurement was taken (ISO 8601 UTC)
positionGlobalGeoJSON PointGlobal coordinates [longitude, latitude]
spaceIdUUIDSpace where device is located (use for Step 4)
zonesArrayZone assignments with timestamps

Step 4: Get Building/Floor Information

When a device has a spaceId, retrieve the complete space hierarchy (building → floor → room).

Space Query

query Space($id: uuid!) {
  spaces(where: {id: {_eq: $id}}) {
    id
    name
    type
    parentId
    organizationId
    createdAt
    updatedAt
    drawing {
      id
      widthMeters
      depthMeters
      url
      data
    }
    positionGlobal
    northVector
    zones {
      id
      name
      type
      polygonGlobal
      keywords {
        keyword
      }
    }
  }
}

Variables

Use the spaceId from Step 3:

{
  "id": "55a0a3d0-84b6-4ace-b131-0b2420053e91"
}

Response Example (Floor Level)

{
  "data": {
    "spaces": [
      {
        "id": "55a0a3d0-84b6-4ace-b131-0b2420053e91",
        "name": "Floor 3",
        "type": "floor",
        "parentId": "44a9b2c1-73a5-4bcd-a020-1b1309042e80",
        "organizationId": "2a749da0-5512-4b3e-8c4a-9b8f3c1d5e6f",
        "createdAt": "2025-10-15T08:00:00.000Z",
        "updatedAt": "2025-12-20T14:30:00.000Z",
        "drawing": {
          "id": "88b7c6d5-e4f3-4a2b-9c1d-0e2f3a4b5c6d",
          "widthMeters": 50.0,
          "depthMeters": 30.0,
          "url": "https://storage.example.com/floorplans/floor3.svg",
          "data": "<?xml version=\"1.0\"?>..."
        },
        "positionGlobal": {
          "type": "Point",
          "coordinates": [24.9384, 60.1699]
        },
        "northVector": 45.0,
        "zones": [
          {
            "id": "7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
            "name": "Meeting Room A",
            "type": "room",
            "polygonGlobal": {
              "type": "Polygon",
              "coordinates": [[[0, 0], [10, 0], [10, 8], [0, 8], [0, 0]]]
            },
            "keywords": [
              {"keyword": "meeting"},
              {"keyword": "conference"}
            ]
          }
        ]
      }
    ]
  }
}

Get Parent Space (Building)

If the space has a parentId, query it to get the building information:

query Space($id: uuid!) {
  spaces(where: {id: {_eq: $id}}) {
    id
    name
    type
    parentId
    organizationId
    positionGlobal
  }
}

Variables (Using parentId from previous response)

{
  "id": "44a9b2c1-73a5-4bcd-a020-1b1309042e80"
}

Response Example (Building Level)

{
  "data": {
    "spaces": [
      {
        "id": "44a9b2c1-73a5-4bcd-a020-1b1309042e80",
        "name": "Helsinki Office",
        "type": "building",
        "parentId": "33a8b1c0-62a4-4abc-9f1f-0a0208031d7f",
        "organizationId": "2a749da0-5512-4b3e-8c4a-9b8f3c1d5e6f",
        "positionGlobal": {
          "type": "Point",
          "coordinates": [24.9384, 60.1699]
        }
      }
    ]
  }
}

Space Type Hierarchy

The type field indicates the space level:

TypeDescriptionTypical Parent
siteRoot location (address/campus)None (parentId is null)
buildingBuilding within a sitesite
floorFloor within a buildingbuilding
roomRoom or zone within a floorfloor

Step 5: Complete Application Flow

Pseudocode Implementation

# Step 1: Authentication
def login(email, organization_name, password):
    response = graphql_mutation(LOGIN_MUTATION, {
        "email": email,
        "organizationName": organization_name,
        "password": password
    })
    return response.data.login.accessToken

# Step 2: Search Device
def search_device(access_token, search_text):
    response = graphql_query(DEVICE_SEARCH_QUERY, {
        "searchText": f"%{search_text}%"
    }, auth_token=access_token)
    return response.data.devices[0]  # Return first match

# Step 3: Get Device Location
def get_device_location(access_token, device):
    # Method 1: Check if device has fixed installation
    if device.fixedPositionGlobal and device.spaceId:
        return {
            "position": device.fixedPositionGlobal,
            "spaceId": device.spaceId,
            "isFixed": True,
            "zones": []
        }
    
    # Method 2: Query measurement position for mobile devices
    response = graphql_query(MEASUREMENT_LAST_POSITION_QUERY, {
        "device": device.id
    }, auth_token=access_token)
    
    if response.data.measurementLastPosition:
        pos = response.data.measurementLastPosition[0]
        return {
            "position": pos.positionGlobal,
            "spaceId": pos.spaceId,
            "isFixed": False,
            "zones": pos.zones,
            "measuredAt": pos.measuredAt
        }
    
    return None

# Step 4: Get Space Hierarchy
def get_space_hierarchy(access_token, space_id):
    hierarchy = []
    current_space_id = space_id
    
    while current_space_id:
        response = graphql_query(SPACE_QUERY, {
            "id": current_space_id
        }, auth_token=access_token)
        
        space = response.data.spaces[0]
        hierarchy.insert(0, space)  # Add to beginning of list
        current_space_id = space.parentId
    
    return hierarchy

# Complete Flow
def display_device_with_context(email, org, password, search_term):
    # Authenticate
    token = login(email, org, password)
    
    # Search device
    device = search_device(token, search_term)
    print(f"Device: {device.name} ({device.deviceType.productName})")
    
    # Get location (checks both fixed installation and measurement position)
    location = get_device_location(token, device)
    
    if location:
        print(f"Location: {location['position'].coordinates}")
        print(f"Installation type: {'Fixed' if location['isFixed'] else 'Mobile'}")
        
        if not location['isFixed'] and 'measuredAt' in location:
            print(f"Last measured: {location['measuredAt']}")
        
        # Get space context
        if location['spaceId']:
            hierarchy = get_space_hierarchy(token, location['spaceId'])
            
            # Display hierarchy: Site → Building → Floor → Room
            location_path = " → ".join([s.name for s in hierarchy])
            print(f"Context: {location_path}")
            
            # Display zone if available
            if location['zones']:
                zone_name = location['zones'][0].zone.name
                print(f"Zone: {zone_name}")
    else:
        print("No location data available for this device")

# Usage
display_device_with_context(
    email="user@example.com",
    org="demo",
    password="secret",
    search_term="sensor"
)

Expected Output

For a Fixed Installation:

Device: Temperature Sensor 101 (Temperature Sensor)
Location: [24.9384, 60.1699]
Installation type: Fixed
Context: Helsinki Campus → Helsinki Office → Floor 3 → Meeting Room A
Zone: Meeting Room A

For a Mobile Device:

Device: Mobile Tag 205 (Asset Tag)
Location: [24.9385, 60.1700]
Installation type: Mobile
Last measured: 2026-02-05T10:30:45.491Z
Context: Helsinki Campus → Helsinki Office → Floor 3
Zone: Conference Area

Step 6: Token Refresh (Optional)

When the access token expires (after 15 minutes), use the refresh token:

mutation RefreshToken($organizationId: String!, $refreshToken: String!) {
  refreshToken(
    refreshTokenInput: {
      organizationId: $organizationId
      refreshToken: $refreshToken
    }
  ) {
    accessToken
    expiresIn
    refreshExpiresIn
    refreshToken
  }
}

Variables

{
  "organizationId": "2a749da0-5512-4b3e-8c4a-9b8f3c1d5e6f",
  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Error Handling

Common Error Responses

Authentication Failed

{
  "errors": [
    {
      "message": "Invalid credentials",
      "extensions": {
        "code": "AUTHENTICATION_ERROR"
      }
    }
  ]
}

Token Expired

{
  "errors": [
    {
      "message": "Token expired",
      "extensions": {
        "code": "UNAUTHENTICATED"
      }
    }
  ]
}

Device Not Found

{
  "data": {
    "devices": []
  }
}
def safe_graphql_query(query, variables, auth_token):
    try:
        response = graphql_query(query, variables, auth_token)
        
        if response.errors:
            for error in response.errors:
                if error.extensions.code == "UNAUTHENTICATED":
                    # Refresh token and retry
                    new_token = refresh_access_token()
                    return graphql_query(query, variables, new_token)
                else:
                    raise Exception(f"GraphQL Error: {error.message}")
        
        return response.data
        
    except Exception as e:
        log_error(f"Query failed: {e}")
        raise

Complete HTTP Examples

Using cURL

Login

curl -X POST https://haltian-iot-api.eu.haltian.io/v1/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation Login($email: String!, $organizationName: String!, $password: String!) { login(loginInput: {email: $email, organizationName: $organizationName, password: $password}) { accessToken expiresIn refreshExpiresIn refreshToken organizationId } }",
    "variables": {
      "email": "user@example.com",
      "organizationName": "demo",
      "password": "secret"
    }
  }'

Search Device

curl -X POST https://haltian-iot-api.eu.haltian.io/v1/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJhbGc..." \
  -d '{
    "query": "query DevicesByTextSearch($searchText: String) { devices(limit: 25, where: {_or: [{name: {_ilike: $searchText}}]}) { id name fixedPositionGlobal spaceId deviceType { productName } } }",
    "variables": {
      "searchText": "%sensor%"
    }
  }'

Get Measurement Position (if needed)

curl -X POST https://haltian-iot-api.eu.haltian.io/v1/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJhbGc..." \
  -d '{
    "query": "query MeasurementLastPosition($device: uuid) { measurementLastPosition(where: {deviceId: {_eq: $device}}) { deviceId positionGlobal spaceId zones { zone { name } } } }",
    "variables": {
      "device": "6e5f8ac6-a099-44e5-bb28-25fd3d1f47a6"
    }
  }'

AI Agent Instructions

This tutorial is designed to be parseable by AI agents. Key integration points:

Required Inputs

An AI agent building this application needs:

  1. Credentials: email, organizationName, password
  2. Endpoint: GraphQL API URL
  3. Search Term: Device identifier or partial name

Expected Outputs

The application should provide:

  1. Device Information: Name, type, identifiers
  2. Installation Type: Fixed or mobile
  3. Current Position: Coordinates (from fixed installation or measurement)
  4. Location Context: Full hierarchy from site to zone
  5. Zone Assignment: Current zone if applicable

Implementation Checklist

  • Execute login mutation and store tokens
  • Implement authorization header injection
  • Execute device search with fixedPositionGlobal and spaceId fields
  • Check if device has fixed installation first
  • Query measurement position only if fixed position is not set
  • Handle empty search results
  • Query measurement position for selected device
  • Recursively query space hierarchy using parentId
  • Display complete location path
  • Implement token refresh logic
  • Handle authentication errors
  • Format timestamps for user display

GraphQL Client Configuration

const client = new ApolloClient({
  uri: 'https://haltian-iot-api.eu.haltian.io/v1/graphql',
  request: (operation) => {
    const token = localStorage.getItem('accessToken');
    operation.setContext({
      headers: {
        authorization: token ? `Bearer ${token}` : '',
      }
    });
  },
});

Next Steps

Summary

This tutorial covered:

Authentication - Login and token management
Device Search - Finding devices by various identifiers
Measurement Data - Retrieving position and sensor data
Space Hierarchy - Building complete location context
Error Handling - Managing common failure scenarios
Complete Implementation - Working pseudocode example

All queries are production-ready and extracted from the Haltian IoT Studio iOS application.