• For Developers

Introduction to Noccela’s APIs and SDK

Noccela offers a variety of APIs tailored to various requirements. For real-time applications, we recommend our WebSocket API, which streams tag data directly from our positioning engine. This guide focuses on utilizing this particular API and its associated SDK. For alternative needs, we also provide an HTTP API and a Push API. The Push API is managed via the HTTP API. Our HTTP API comes with interactive and user-friendly Swagger documentation. The Push API enables you to set your own HTTP server as an endpoint, allowing you to subscribe to specific events, like a tag entering or leaving a geofenced zone.

This tutorial walks you through the creation of a real-time map application to visualize tag movements. You can fast-forward to the end to view the finished product, or you can execute sample codes step-by-step in your browser. You can also make changes to the sample code and see how that affects the result. You can use your browser’s developer tools to check for compilation and run-time errors. The code is compiled in the browser and injected into an iframe for sandboxing. We offer two versions of each step: one that leverages our JavaScript/TypeScript SDK and React for simplicity, and another that uses plain JavaScript to show direct interactions with the WebSocket API. You can find additional details in our API Documentation.

Demo part 1/5: Fetch access token

Fetch access token from Noccela’s OAuth 2.0 authorization server using client credentials grant type. We are using a demo user with access to a demo site for this demonstration. If you have access to Noccela’s partner portal, you can go there and create real credentials and use them instead. This happens from the Apps section.

Use Noccela SDK and React (plain JS is used otherwise)
function NoccelaApp() {
  let [token, setToken] = React.useState('N/A');

  const start = async () => {
    const { accessToken, expiresIn } = await NccIntegration.getToken(6886, 'SeRc2VDWx055tRCnsTvb6woLQbwVmnL4');
    setToken(accessToken);
  }

  React.useEffect(() => { start(); }, []);
  
  return (<>Token: {token}</>);
}
==========
async function getToken(clientId, clientSecret) {
  // Construct the full OAuth2 token endpoint.
  const url = new URL('/connect/token', 'https://auth.noccela.io').href;

  // Token request only supports x-www-form-urlencoded, not json.
  const authRequestBody = new URLSearchParams();
  authRequestBody.append('client_id', `${clientId}`);
  authRequestBody.append('client_secret', clientSecret);
  authRequestBody.append('grant_type', 'client_credentials');

  // Fetch JWT token from authentication server.
  const authResponse = await fetch(url, {
    method: 'post',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: authRequestBody,
  });

  const statusCode = authResponse.status;
  if (!authResponse.ok || statusCode !== 200) {
    throw Error(authResponse.statusText || `StatusCode ${statusCode}`);
  }

  // Pick the relevant properties.
  // Scopes are not used in Noccela APIs, at least yet so ignoring.
  const authResponseObject = await authResponse.json();

  if (authResponseObject.error) {
    throw Error(`Authentication failed: ${authResponseObject.error}`);
  }
  return {
    accessToken: authResponseObject.access_token,
    expiresIn: authResponseObject.expires_in
  };
}

async function start() {
  const output = document.getElementById('output');
  output.innerText = `Token: N/A`;
  const { accessToken, expiresIn } = await NccIntegration.getToken(6886, 'SeRc2VDWx055tRCnsTvb6woLQbwVmnL4');
  output.innerText = `Token: ${accessToken}`;
}

start();

Demo part 2/5: Get the domain of the WebSocket API

Utilize the acquired access token along with Noccela’s HTTP API to determine the domain for the WebSocket API. WebSocket is a communication protocol that enables full-duplex communication channels over a single, long-lived connection between the client and server. Unlike HTTP, which is request-response based, WebSocket allows for real-time data exchange, making it ideal for applications that require instant updates like messaging systems, online games, and IoT platforms. Noccela delivers data directly from the server that operates the site’s positioning engine. As a result, it’s essential to identify the domain of the server that hosts the positioning engine for that specific site. Each site is uniquely identified by a combination of two numbers: an account ID and a site ID. The domain is subject to change, so it’s crucial to verify the domain each time prior to establishing a connection.

Use Noccela SDK and React (plain JS is used otherwise)
function NoccelaApp() {
  // Noccela's SDK does this automatically for you
  return (<>🥳</>);
}
==========
async function getDomain(accountId, siteId, token) {
  // Construct the full get domain endpoint.
  const url = new URL(`/realtime/domain?Account=${accountId}&Site=${siteId}`, 'https://api.noccela.io').href;

  // Fetch the correct domain from the API server
  const domainResponse = await fetch(url, {
    method: 'get',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`
    }
  });

  const statusCode = domainResponse.status;
  if (!domainResponse.ok || statusCode !== 200) {
    throw Error(domainResponse.statusText || `StatusCode ${statusCode}`);
  }

  // Pick the relevant properties.
  const domainResponseObject = await domainResponse.json();

  if (domainResponseObject.error) {
    throw Error(`Get domain failed: ${domainResponseObject.error}`);
  }
  return domainResponseObject.domain;
}

async function getToken(clientId, clientSecret) {
  // Construct the full OAuth2 token endpoint.
  const url = new URL('/connect/token', 'https://auth.noccela.io').href;

  // Token request only supports x-www-form-urlencoded, not json.
  const authRequestBody = new URLSearchParams();
  authRequestBody.append('client_id', `${clientId}`);
  authRequestBody.append('client_secret', clientSecret);
  authRequestBody.append('grant_type', 'client_credentials');

  // Fetch JWT token from authentication server.
  const authResponse = await fetch(url, {
    method: 'post',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: authRequestBody,
  });

  const statusCode = authResponse.status;
  if (!authResponse.ok || statusCode !== 200) {
    throw Error(authResponse.statusText || `StatusCode ${statusCode}`);
  }

  // Pick the relevant properties.
  // Scopes are not used in Noccela APIs, at least yet so ignoring.
  const authResponseObject = await authResponse.json();

  if (authResponseObject.error) {
    throw Error(`Authentication failed: ${authResponseObject.error}`);
  }
  return {
    accessToken: authResponseObject.access_token,
    expiresIn: authResponseObject.expires_in
  };
}

async function start() {
  const output = document.getElementById('output');
  output.innerText = 'Domain: N/A';
  const { accessToken, expiresIn } = await getToken(6886, 'SeRc2VDWx055tRCnsTvb6woLQbwVmnL4');
  const domain = await getDomain(5001, 21, accessToken);
  output.innerText = `Domain: ${domain}`;
}

start();

Demo part 3/5: Connect to WebSocket, authenticate, and implement ping/pong

To connect to your site’s WebSocket API, use the retrieved domain in the following URL format: wss://{domain}/realtime?account={accountId}&site={siteId}.

Once the WebSocket connection is active, authenticate by sending your token in plain text as a message to the server. You’ll receive a JSON reply like this for a successful authentication: {"uniqueId":"authSuccess","status":"ok","payload":{"tokenExpiration":1693656429,"tokenIssued":1693570029,"cloudVersion":"v3.24.53-custom"}}

For most interactions, you’ll communicate using JSON messages, with authorization and ping/pong being the exceptions. The server will reply using the same “uniqueId” as your request, helping you link requests and responses.

Be aware that the access token has a limited lifespan. You can find its expiration time in the “tokenExpiration” field of the server’s JSON response, represented as a POSIX timestamp. A POSIX timestamp marks the seconds elapsed since January 1, 1970, 00:00:00 Coordinated Universal Time (UTC). Make sure to renew the token before it expires, ideally when less than half of its validity period remains. Failure to do so will result in the server disconnecting you.

Noccela’s server will occasionally send an empty ping message "" to your client. Respond with a pong message containing only the number "1" to maintain the connection and allow the server to gauge its quality. Failure to reply within two minutes will result in a timed-out connection.

Use Noccela SDK and React (plain JS is used otherwise)
function NoccelaApp() {
  let [status, setStatus] = React.useState('Initializing');

  const fetchToken = async () => await NccIntegration.getToken(6886, 'SeRc2VDWx055tRCnsTvb6woLQbwVmnL4').then(res => res.accessToken);
  const start = async () => {
    // Create websocket connection to Noccela's server
    // Noccela's SDK does the token update and reconnecting automatically, if you use connectPersistent instead of connect
    // Noccela's SDK does the ping/pong routine automatically
    const channel = new NccIntegration.EventChannel(5001,21);
    await channel.connectPersistent(fetchToken);
    setStatus('Initialized');
  }

  React.useEffect(() => { start(); }, []);

  return (<>Status: {status}</>);
}
==========
let ws;
let accessToken;
let updateTokenTimeout = null;

async function getToken(clientId, clientSecret) {
  // Construct the full OAuth2 token endpoint.
  const url = new URL('/connect/token', 'https://auth.noccela.io').href;

  // Token request only supports x-www-form-urlencoded, not json.
  const authRequestBody = new URLSearchParams();
  authRequestBody.append('client_id', `${clientId}`);
  authRequestBody.append('client_secret', clientSecret);
  authRequestBody.append('grant_type', 'client_credentials');

  // Fetch JWT token from authentication server.
  const authResponse = await fetch(url, {
    method: 'post',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: authRequestBody,
  });

  const statusCode = authResponse.status;
  if (!authResponse.ok || statusCode !== 200) {
    throw Error(authResponse.statusText || `StatusCode ${statusCode}`);
  }

  // Pick the relevant properties.
  // Scopes are not used in Noccela APIs, at least yet so ignoring.
  const authResponseObject = await authResponse.json();

  if (authResponseObject.error) {
    // Error returned by authentication server.
    throw Error(`Authentication failed: ${authResponseObject.error}`);
  }
  return {
    accessToken: authResponseObject.access_token,
    expiresIn: authResponseObject.expires_in
  };
}

async function getDomain(accountId, siteId, token) {
  // Construct the full get domain endpoint.
  const url = new URL(`/realtime/domain?Account=${accountId}&Site=${siteId}`, 'https://api.noccela.io').href;

  // Fetch the correct domain from the API server
  const domainResponse = await fetch(url, {
    method: 'get',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`
    }
  });

  const statusCode = domainResponse.status;
  if (!domainResponse.ok || statusCode !== 200) {
    throw Error(domainResponse.statusText || `StatusCode ${statusCode}`);
  }

  // Pick the relevant properties.
  const domainResponseObject = await domainResponse.json();

  if (domainResponseObject.error) {
    throw Error(`Get domain failed: ${domainResponseObject.error}`);
  }
  return domainResponseObject.domain;
}

async function updateToken(isFirstToken) {
  const response = await getToken(6886, 'SeRc2VDWx055tRCnsTvb6woLQbwVmnL4');
  accessToken = response.accessToken;
  const expiresIn = response.expiresIn;
  const now = Date.now();
  updateTokenTimeout = setTimeout(() => {
    updateToken(false);
  }, expiresIn*1000/2);
  if (!isFirstToken) {
    ws.send(JSON.stringify({
      uniqueId: 'refreshToken',
      action: 'refreshToken',
      payload: {
        token: accessToken
      }
    }));
  }
}

async function start() {
  const output = document.getElementById('output');
  output.innerHTML = 'Status: Connecting';
  try {
    if (updateTokenTimeout !== null) {
      clearTimeout(updateTokenTimeout);
      updateTokenTimeout = null;
    }
    const accountId = 5001;
    const siteId = 21;
    await updateToken(true);
    const domain = await getDomain(accountId, siteId, accessToken);
    ws = new WebSocket(`wss://${domain}/realtime?account=${accountId}&site=${siteId}`);
  } catch (error) {
    output.innerHTML = 'Status: Connection error';
    setTimeout(() => {
      start();
    }, 5000);
  }

  // Connection opened
  ws.addEventListener('open', (event) => {
    ws.send(accessToken);
  });
  
   // Connection closed
  ws.addEventListener('close', (event) => {
    start();
  });

  ws.addEventListener('message', (event) => {
    // Responds to pings with pongs
    if (event.data === '') {
      ws.send('1');
      return;
    }

    // Handle JSON messages from the API
    const message = JSON.parse(event.data);
    switch (message.uniqueId){
      case 'authSuccess':
        output.innerHTML = 'Status: Connected';
        return;        
    }
  });
}

start();

Demo part 4/5: Stream live tag data from WebSocket

Now that we have an authenticated WebSocket connection and have implemented the ping/pong and reauthentication routines, we can start using the WebSocket API. You can look at Noccela’s API document for all of the capabilities of the API. For this demo, we are going to use the initialTagState and registerTagDiffStream actions. It is recommended to get the initial tag state when connecting to the server. It returns a complete list of tags on the site and all their properties. Once you have the initial state of the tags, you can use registerTagDiffStream action, to register to receive all changes of the tags’ properties. Such changes might be, for example, a change in battery status or the location of the tag. If you are only interested in the location you can use the simplified version of registerTagDiffStream called registerTagLocation which only sends updates to the location properties of the tag. Such as X, Y, and Z.

Use Noccela SDK and React (plain JS is used otherwise)
function NoccelaApp() {
  let [tags, setTags] = React.useState({});
  let channel = null;

  const handleInitialTags = (err, payload) => setTags(payload);
  const handleTagUpdate = (err, payload) => {
    setTags(prevState => {
      payload.tags && Object.keys(payload.tags).forEach(key => prevState[key] = { ...prevState[key], ...payload.tags[key] });
      payload.removedTags?.forEach(tagKey => delete prevState[tagKey]);
      return { ...prevState };
    });
  };

  const fetchToken = async () => await NccIntegration.getToken(6886, 'SeRc2VDWx055tRCnsTvb6woLQbwVmnL4').then(res => res.accessToken);
  const start = async () => {
    // Create websocket connection to Noccela's server and register to receive data
    channel = new NccIntegration.EventChannel(5001,21);
    await channel.connectPersistent(fetchToken);
    await channel.registerInitialTagState(handleInitialTags);
    await channel.registerTagDiffStream(handleTagUpdate);
  }

  React.useEffect(() => { start(); }, []);

  return (<><b>Tags:</b>
    <ul>{Object.keys(tags).map(sn =>
      <li>Serial Number: {sn}; X: {tags[sn].x}; Y: {tags[sn].y}; Area IDs: {tags[sn].areas.join(',')}</li>
    )}</ul></>);
}
==========
let ws;
let tags = {};
let channel = null;
let accessToken;
let updateTokenTimeout = null;

const output = document.getElementById('output');
const tagsTitle = document.createElement('b');
tagsTitle.innerText = 'Tags:';
output.appendChild(tagsTitle);
const tagsList = document.createElement('ul');
output.appendChild(tagsList);

function loadInitialTags(initialTagsResponse){
  const bytes = atob(initialTagsResponse.payload);
  const arrayBuffer = new ArrayBuffer(bytes.length);
  const intArray = new Uint8Array(arrayBuffer);

  for (let i = 0; i < bytes.length; i++) {
    intArray[i] = bytes.charCodeAt(i);
  }

  const payload = msgpack.deserialize(intArray);

  tags = {}

  for (const [deviceId, tagData] of Object.entries(payload)) {
    tags[deviceId] = {
      name: tagData[1],
      batteryVoltage: tagData[2],
      batteryStatus: tagData[3],
      status: tagData[4],
      areas: tagData[5],
      wire: tagData[6],
      reed: tagData[7],
      isOnline: tagData[8],
      timestamp: tagData[9],
      x: tagData[10],
      y: tagData[11],
      z: tagData[19],
      accelerometer: tagData[12],
      floorId: tagData[13],
      signalLost: tagData[14],
      powerSave: tagData[15],
      deviceModel: tagData[16],
      fwVersion: tagData[17],
      strokeCount: tagData[18],
      uncertaintyDistance: tagData[20]
    }
  }
  renderTags();
}

function loadTagUpdate(tagUpdate) {
  for (const [deviceId, data] of Object.entries(tagUpdate.payload.tags)) {
    if (deviceId in tags) {
      Object.assign(tags[deviceId], data);
    } else {
       tags[deviceId] = data;
    }
  }
  renderTags();
}

async function getToken(clientId, clientSecret) {
  // Construct the full OAuth2 token endpoint.
  const url = new URL('/connect/token', 'https://auth.noccela.io').href;

  // Token request only supports x-www-form-urlencoded, not json.
  const authRequestBody = new URLSearchParams();
  authRequestBody.append('client_id', `${clientId}`);
  authRequestBody.append('client_secret', clientSecret);
  authRequestBody.append('grant_type', 'client_credentials');

  // Fetch JWT token from authentication server.
  const authResponse = await fetch(url, {
    method: 'post',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: authRequestBody,
  });

  const statusCode = authResponse.status;
  if (!authResponse.ok || statusCode !== 200) {
    throw Error(authResponse.statusText || `StatusCode ${statusCode}`);
  }

  // Pick the relevant properties.
  // Scopes are not used in Noccela APIs, at least yet so ignoring.
  const authResponseObject = await authResponse.json();

  if (authResponseObject.error) {
    // Error returned by authentication server.
    throw Error(`Authentication failed: ${authResponseObject.error}`);
  }
  return {
    accessToken: authResponseObject.access_token,
    expiresIn: authResponseObject.expires_in
  };
}

async function getDomain(accountId, siteId, token) {
  // Construct the full get domain endpoint.
  const url = new URL(`/realtime/domain?Account=${accountId}&Site=${siteId}`, 'https://api.noccela.io').href;

  // Fetch the correct domain from the API server
  const domainResponse = await fetch(url, {
    method: 'get',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`
    }
  });

  const statusCode = domainResponse.status;
  if (!domainResponse.ok || statusCode !== 200) {
    throw Error(domainResponse.statusText || `StatusCode ${statusCode}`);
  }

  // Pick the relevant properties.
  const domainResponseObject = await domainResponse.json();

  if (domainResponseObject.error) {
    // Error returned by API server
    throw Error(`Get domain failed: ${domainResponseObject.error}`);
  }
  return domainResponseObject.domain;
}

async function updateToken(isFirstToken) {
  const response = await getToken(6886, 'SeRc2VDWx055tRCnsTvb6woLQbwVmnL4');
  accessToken = response.accessToken;
  const expiresIn = response.expiresIn;
  const now  = Date.now();
  updateTokenTimeout = setTimeout(() => {
    updateToken(false);
  }, expiresIn*1000/2);
  if (!isFirstToken) {
    ws.send(JSON.stringify({
      uniqueId: 'refreshToken',
      action: 'refreshToken',
      payload: {
        token: accessToken
      }
    }));
  }
}

async function start() {
  try {
    if (updateTokenTimeout !== null) {
    clearTimeout(updateTokenTimeout);
    updateTokenTimeout = null;
    }
    const accountId = 5001;
    const siteId = 21;
    await updateToken(true);
    const domain = await getDomain(accountId, siteId, accessToken);
    ws = new WebSocket(`wss://${domain}/realtime?account=${accountId}&site=${siteId}`);
  } catch (error) {
    setTimeout(() => {
      start();
    }, 5000);
  }

  // Connection opened
  ws.addEventListener('open', (event) => {
    ws.send(accessToken);
  });
  
  // Connection closed
  ws.addEventListener('close', (event) => {
    start();
  });

  ws.addEventListener('message', (event) => {
    // Responds to pings with pongs
    if (event.data === '') {
      ws.send('1');
      return;
    }

    // Handle JSON messages from the API
    const message = JSON.parse(event.data);
    switch (message.uniqueId){
      case 'authSuccess':
        ws.send(JSON.stringify({
          'uniqueId': 'initialTags',
          'action': 'initialTagState',
          'payload': {
            'deviceIds': null
          }
        }));
        return;      
    }
    switch (message.action) {
      case 'initialTagState':
        loadInitialTags(message);
        ws.send(JSON.stringify({
          'uniqueId': 'registerTags',
          'action': 'registerTagDiffStream',
          'payload': {
            'deviceIds': null
          }
        }));
        return;
      case 'tagDiffStream':
        loadTagUpdate(message);
        return;
    }
  });
}

function renderTags() {
  Object.keys(tags).forEach(serialNumber => {
    let li = tagsList.querySelector(`li[data-serial-number='${serialNumber}']`);
    
    // Create li if it does not exist
    if (!li) {
      li = document.createElement('li');
      li.setAttribute('data-serial-number', serialNumber);
      tagsList.appendChild(li);
    }
    
    // Update position
    li.innerText = `Serial Number: ${serialNumber}; X: ${tags[serialNumber].x}; ` +
      `Y: ${tags[serialNumber].y}; Area IDs: ${tags[serialNumber].areas.join(',')}`;
  });
  
  // Remove lis for tags that no longer exist
  const existingLis = tagsList.querySelectorAll('li[data-serial-number]');
  existingLis.forEach(li => {
    const serialNumber = li.getAttribute('data-serial-number');
    if (!tags[serialNumber]) li.remove();
  });
}

start();

Demo part 5/5: Get the site layout and visualize everything on a map

In the previous step, we initiated the streaming of tag coordinates, but raw numerical coordinates aren’t particularly user-friendly. They become much more intuitive when visualized as points on a blueprint, so our next step is to fetch the site layout, structured as floors => layers => items.

Various types of layers exist, such as infrastructure layers, blueprint layers, and area layers. Infrastructure layers house the UWB Base Stations that establish the UWB network, and each floor has one such layer. Blueprint layers store blueprint images, while area layers hold shapes like rectangles, circles, and polygons useful for geofencing. A floor can have multiple blueprint and area layers or none at all.

In this demonstration, we’re working with a simplified example featuring a single floor, no area layers, and just one blueprint layer containing one blueprint. If you’re developing a real-world map application, you’ll likely need a more sophisticated approach that displays all floors, layers, blueprints, and areas.

We’ll be extending the existing code by adding two new API actions: getSite and getBlueprint. After successfully authenticating the WebSocket connection, we’ll use getSite to obtain essential information about the site, particularly its layout. We’ll target the sole blueprint item in the only blueprint layer of the single floor, as this item holds both the coordinate data and the file ID for the blueprint image. With this data in hand, we’ll call getBlueprint to retrieve the actual blueprint image file.

To wrap things up, we’ll revise the rendering code to display a map and animate tag movements on it, instead of merely listing the coordinates as text. For the animation, we are presuming that the update frequency is 8Hz. For a real application, you might want to have a more sophisticated animation logic.

Use Noccela SDK and React (plain JS is used otherwise)
function NoccelaApp() {
  let [blueprint, setBlueprint] = React.useState({maxX:1, maxY:1, minX:0, minY:0, image:null});
  let [tags, setTags] = React.useState({});
  let channel;

  const handleInitialTags = (err, payload) => setTags(payload);
  const handleTagUpdate = (err, payload) => {
    setTags(prevState => {
      payload.tags && Object.keys(payload.tags).forEach(key => prevState[key] = { ...prevState[key], ...payload.tags[key] });
      payload.removedTags?.forEach(tagKey => delete prevState[tagKey]);
      return { ...prevState };
    });
  };

  const loadLayout = async (err, payload) => {
    const layers = payload.layout.floors[0].layers; // Presume that we have one floor
    const blueprint = layers.find(layer => layer.type === 2).items[0]; // Presume that we have one blueprint layer with one blueprint
    blueprint.image = (await channel.getBlueprint(blueprint.fileId)).payload.data;
    setBlueprint(blueprint);
  }
  
  const fetchToken = async () => await NccIntegration.getToken(6886, 'SeRc2VDWx055tRCnsTvb6woLQbwVmnL4').then(res => res.accessToken);
  const start = async () => {
    channel = new NccIntegration.EventChannel(5001, 21);
    await channel.connectPersistent(fetchToken);
    await channel.registerSiteInformation(loadLayout);
    await channel.registerInitialTagState(handleInitialTags);
    await channel.registerTagDiffStream(handleTagUpdate);
    await channel.registerLayoutChanges(loadLayout);
  }

  React.useEffect(() => { start(); }, []);

  return (<>{Object.keys(tags).map(serialNumber => 
      <div style={{
        width: '14px', height: '14px', position: 'absolute', backgroundColor: '#fcc018',
        border: '3px solid #000', borderRadius: '50%', transition: 'all 0.125s linear', 
        left: `calc(100vw * ${(tags[serialNumber].x - blueprint.minX) / (blueprint.maxX - blueprint.minX)} - 10px)`,
        top: `calc(100vw * ${(blueprint.maxY - blueprint.minY) / (blueprint.maxX - blueprint.minX)} *
          (1 - ${(tags[serialNumber].y - blueprint.minY) / (blueprint.maxY - blueprint.minY)}) - 10px)`
      }}/>
    )}
    <img style={{ width: '100%' }} src={`data:image/png;base64, ${blueprint.image}`} /></>);
}
==========
let ws;
let blueprintData = { maxX: 1, maxY: 1, minX: 0, minY: 0 };
let tags = {};
let channel = null;
let accessToken;
let updateTokenTimeout = null;

const output = document.getElementById('output');
output.innerHTML = '';
const img = document.createElement('img');
img.style.width = '100%';
output.appendChild(img);

function loadInitialTags(initialTagsResponse){
  const bytes = atob(initialTagsResponse.payload);
  const arrayBuffer = new ArrayBuffer(bytes.length);
  const intArray = new Uint8Array(arrayBuffer);

  for (let i = 0; i < bytes.length; i++) {
    intArray[i] = bytes.charCodeAt(i);
  }

  const payload = msgpack.deserialize(intArray);

  tags = {}

  for (const [deviceId, tagData] of Object.entries(payload)) {
    tags[deviceId] = {
      name: tagData[1],
      batteryVoltage: tagData[2],
      batteryStatus: tagData[3],
      status: tagData[4],
      areas: tagData[5],
      wire: tagData[6],
      reed: tagData[7],
      isOnline: tagData[8],
      timestamp: tagData[9],
      x: tagData[10],
      y: tagData[11],
      z: tagData[19],
      accelerometer: tagData[12],
      floorId: tagData[13],
      signalLost: tagData[14],
      powerSave: tagData[15],
      deviceModel: tagData[16],
      fwVersion: tagData[17],
      strokeCount: tagData[18],
      uncertaintyDistance: tagData[20]
    }
  }
  renderTags();
}

function loadTagUpdate(tagUpdate) {
  for (const [deviceId, data] of Object.entries(tagUpdate.payload.tags)) {
    if (deviceId in tags) {
      Object.assign(tags[deviceId], data);
    } else {
      tags[deviceId] = data;
    }
  }
  renderTags();
}

async function getToken(clientId, clientSecret) {
  // Construct the full OAuth2 token endpoint.
  const url = new URL('/connect/token', 'https://auth.noccela.io').href;

  // Token request only supports x-www-form-urlencoded, not json.
  const authRequestBody = new URLSearchParams();
  authRequestBody.append('client_id', `${clientId}`);
  authRequestBody.append('client_secret', clientSecret);
  authRequestBody.append('grant_type', 'client_credentials');

  // Fetch JWT token from authentication server.
  const authResponse = await fetch(url, {
    method: 'post',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: authRequestBody,
  });

  const statusCode = authResponse.status;
  if (!authResponse.ok || statusCode !== 200) {
    throw Error(authResponse.statusText || `StatusCode ${statusCode}`);
  }

  // Pick the relevant properties.
  // Scopes are not used in Noccela APIs, at least yet so ignoring.
  const authResponseObject = await authResponse.json();

  if (authResponseObject.error) {
    // Error returned by authentication server.
    throw Error(`Authentication failed: ${authResponseObject.error}`);
  }
  return {
    accessToken: authResponseObject.access_token,
    expiresIn: authResponseObject.expires_in
  };
}

async function getDomain(accountId, siteId, token) {
  // Construct the full get domain endpoint.
  const url = new URL(`/realtime/domain?Account=${accountId}&Site=${siteId}`, 'https://api.noccela.io').href;

  // Fetch the correct domain from the API server
  const domainResponse = await fetch(url, {
    method: 'get',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`
    }
  });

  const statusCode = domainResponse.status;
  if (!domainResponse.ok || statusCode !== 200) {
    throw Error(domainResponse.statusText || `StatusCode ${statusCode}`);
  }

  // Pick the relevant properties.
  const domainResponseObject = await domainResponse.json();

  if (domainResponseObject.error) {
    // Error returned by API server
    throw Error(`Get domain failed: ${domainResponseObject.error}`);
  }
  return domainResponseObject.domain;
}

function loadLayout(getSiteResponse, ws) {
  const layers = getSiteResponse.payload.layout.floors[0].layers;
  const blueprintLayer = layers.find(layer => layer.type === 2);
  blueprintData = blueprintLayer.items[0];

  ws.send(JSON.stringify({
    'uniqueId': 'blueprint',
    'action': 'getBlueprint',
    'payload': {
      'fileId': blueprintData.fileId
    }
  }));
}

function loadBlueprint(getBlueprintResponse) {
  const blueprintImage = getBlueprintResponse.payload.data;
  img.src = `data:image/png;base64, ${blueprintImage}`;
}

async function updateToken(isFirstToken) {
  const response = await getToken(6886, 'SeRc2VDWx055tRCnsTvb6woLQbwVmnL4');
  accessToken = response.accessToken;
  const expiresIn = response.expiresIn;
  updateTokenTimeout = setTimeout(() => {
    updateToken(false);
  }, expiresIn*1000/2);
  if (!isFirstToken) {
    ws.send(JSON.stringify({
      uniqueId: 'refreshToken',
      action: 'refreshToken',
      payload: {
        token: accessToken
      }
    }));
  }
}

async function start() {
  try {
    if (updateTokenTimeout !== null) {
      clearTimeout(updateTokenTimeout);
      updateTokenTimeout = null;
    }
    const accountId = 5001;
    const siteId = 21;
    await updateToken(true);
    const domain = await getDomain(accountId, siteId, accessToken);
    ws = new WebSocket(`wss://${domain}/realtime?account=${accountId}&site=${siteId}`);
  } catch (error) {
    setTimeout(() => {
      start();
    }, 5000);
  }

  // Connection opened
  ws.addEventListener('open', (event) => {
    ws.send(accessToken);
  });
  
  // Connection closed
  ws.addEventListener('close', (event) => {
    start();
  });

  ws.addEventListener('message', (event) => {
    // Responds to pings with pongs
    if (event.data === '') {
      ws.send('1');
      return;
    }

    // Handle JSON messages from the API
    const message = JSON.parse(event.data);
    switch (message.uniqueId){
      case 'authSuccess':
        ws.send(JSON.stringify({
          'uniqueId': 'site',
          'action': 'getSite'
        }));
        return;
      case 'site':
        loadLayout(message, ws);
        return;
      case 'blueprint':
        loadBlueprint(message);
        ws.send(JSON.stringify({
          'uniqueId': 'initialTags',
          'action': 'initialTagState',
          'payload': {
            'deviceIds': null
          }
        }));
        return;         
    }
    switch (message.action) {
      case 'initialTagState':
        loadInitialTags(message);
        ws.send(JSON.stringify({
          'uniqueId': 'registerTags',
          'action': 'registerTagDiffStream',
          'payload': {
            'deviceIds': null
          }
        }));
        return;
      case 'tagDiffStream':
        loadTagUpdate(message);
        return;
    }
  });
}

function renderTags() {
  Object.keys(tags).forEach(serialNumber => {
    let div = output.querySelector(`div[data-serial-number='${serialNumber}']`);
    
    // Create div if it does not exist
    if (!div) {
      div = document.createElement('div');
      div.setAttribute('data-serial-number', serialNumber);
      div.style.width = '14px';
      div.style.height = '14px';
      div.style.position = 'absolute';
      div.style.border = '3px solid #000';
      div.style.borderRadius = '50%';
      div.style.transition = 'all 0.125s linear';
      div.style.backgroundColor = '#fcc018';
      output.appendChild(div);
    }
    
    // Update position
    div.style.left = `calc(100vw * ${(tags[serialNumber].x - blueprintData.minX) / (blueprintData.maxX - blueprintData.minX)} - 10px)`;
    div.style.top = `calc(100vw * ${(blueprintData.maxY - blueprintData.minY) / (blueprintData.maxX - blueprintData.minX)} * (1 - ${(tags[serialNumber].y - blueprintData.minY) / (blueprintData.maxY - blueprintData.minY)}) - 10px)`;
  });
  
  // Remove divs for tags that no longer exist
  const existingDivs = output.querySelectorAll('div[data-serial-number]');
  existingDivs.forEach(div => {
    const serialNumber = div.getAttribute('data-serial-number');
    if (!tags[serialNumber]) div.remove();
  });
}

start();