Expert Guide

Advanced configuration for AI optimizers and complex systems.

Web Components

Fentrica Web Components are UI components that render real-time device controls — switches, sliders, sensors, climate controls, door locks, and more. Each component corresponds to a Fentrica controller UI node and communicates with the physical device through @larva.io/clouddevice.

Installation

npm install @larva.io/webcomponents

Usage

React

import { LarApp } from '@larva.io/webcomponents/react';

function App() {
  return (
    <div>
      <LarApp />
    </div>
  );
}

Vue 3

Register the plugin globally:

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { LarvaWebcomponents } from '@larva.io/webcomponents/vue'

createApp(App)
  .use(LarvaWebcomponents)
  .mount('#app')

Use components in templates:

<template>
  <LarApp />
</template>

<script>
import { LarApp } from '@larva.io/webcomponents/vue';

export default {
  components: {
    LarApp
  }
}
</script>

Vanilla JavaScript / HTML

<script type="module">
  import { defineCustomElements } from '@larva.io/webcomponents/loader';
  defineCustomElements();
</script>

<lar-app></lar-app>

Dynamic Rendering

In practice, you don't hardcode specific components. Instead, you dynamically render components based on the UI node configuration returned by device.getUINodes() from @larva.io/clouddevice.

Vue 3 Example

<template>
  <component
    v-for="item in nodeList"
    :is="item.node.type"
    :key="item.node.id"
    :node-id="item.node.id"
    :icon="item.ui.icon"
    :main-title="item.ui.name"
    @output="item.connection.handleNodeOutput"
    @request="item.connection.handleNodeRequest"
  >
    <!-- Render linked sub-nodes -->
    <component
      v-for="sub in item.ui.linkednodes"
      :is="sub.node.type"
      :key="sub.node.id"
      :node-id="sub.node.id"
      @output="item.connection.handleNodeOutput"
      @request="item.connection.handleNodeRequest"
    />
  </component>
</template>

React Example

import * as Components from '@larva.io/webcomponents/react';

function DeviceUI({ nodeList }) {
  return nodeList.map((item) => {
    const Component = Components[item.node.type];
    if (!Component) return null;

    return (
      <Component
        key={item.node.id}
        nodeId={item.node.id}
        icon={item.ui.icon}
        mainTitle={item.ui.name}
        onOutput={(e) => item.connection.handleNodeOutput(e)}
        onRequest={(e) => item.connection.handleNodeRequest(e)}
      />
    );
  });
}

Event Handling

Web components communicate with devices through two event types:

EventDirectionDescription
outputComponent → DeviceUser interaction (e.g. toggle switch, move slider)
requestComponent → DeviceComponent requests data from the device

Both events must be routed to the correct DeviceConnection.handleNodeOutput or handleNodeRequest method. The device responds through broadcast events, which update the component state automatically.

┌───────────────┐  output/request  ┌──────────────┐  WebSocket  ┌──────────┐
│ Web Component │ ───────────────► │ Cloud Device │ ──────────► │  Device  │
│    (lar-*)    │ ◄─────────────── │  Connection  │ ◄────────── │  (Edge)  │
└───────────────┘    broadcast     └──────────────┘             └──────────┘

Complete Integration Example (Vue 3)

A full Vue 3 component that fetches units, connects to devices, renders web components, and handles lifecycle correctly:

<template>
  <div v-if="loading" class="loading">Connecting to devices...</div>
  <div v-else-if="error" class="error">{{ error }}</div>
  <div v-else class="smart-space">
    <component
      v-for="item in nodeList"
      :is="item.node.type"
      :key="item.node.id"
      :node-id="item.node.id"
      :icon="item.ui.icon"
      :main-title="item.ui.name"
      @output="item.connection.handleNodeOutput"
      @request="item.connection.handleNodeRequest"
    >
      <component
        v-for="sub in item.linkedNodes"
        :is="sub.node.type"
        :key="sub.node.id"
        :node-id="sub.node.id"
        @output="item.connection.handleNodeOutput"
        @request="item.connection.handleNodeRequest"
      />
    </component>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { Device } from '@larva.io/clouddevice';
import type { UINode } from '@larva.io/clouddevice';

const props = defineProps<{
  orgId: string;
  serviceConnectionId: string;
  accessPolicyId: string;
  getAccessToken: () => Promise<string>;
}>();

interface NodeWithConnection extends UINode {
  connection: Device;
  linkedNodes: UINode[];
}

const nodeList = ref<NodeWithConnection[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
let connections: Device[] = [];

// --- Fetch units and devices from Service Connection API ---

async function fetchUnitsDevices() {
  const token = await props.getAccessToken();
  const res = await fetch(
    `https://api.building.fentrica.com/orgs/orgs/${props.orgId}/services/connections/${props.serviceConnectionId}` +
    `/access-policies/${props.accessPolicyId}/views/units-devices`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`API error: ${res.status}`);
  return res.json();
}

// --- Open WebSocket connections to all devices in a unit ---

async function openConnections(devices: { id: string }[], unitId: string) {
  connections = devices.map(
    (d) => new Device(d.id, unitId, props.getAccessToken, {
      server: 'wss://broker.fentrica.com',
      timeout: 8000
    })
  );

  connections.forEach((c) => {
    c.addEventListener('closed', () => console.warn('Device disconnected:', c.deviceId));
    c.addEventListener('error', (e) => console.error('Device error:', e.detail));
  });

  await Promise.all(connections.map((c) => c.open()));

  // Fetch UI nodes from each connection
  const allNodes = await Promise.all(
    connections.map(async (c) => {
      const nodes = await c.getUINodes();
      return nodes.map((n) => ({ ...n, connection: c, linkedNodes: [] }));
    })
  );

  nodeList.value = allNodes.flat();
}

// --- Close all connections ---

async function closeAll() {
  await Promise.all(connections.map((c) => c.close()));
  connections = [];
  nodeList.value = [];
}

// --- Lifecycle: connect on mount, close on unmount ---

onMounted(async () => {
  try {
    const units = await fetchUnitsDevices();
    if (units.length > 0) {
      await openConnections(units[0].devices, units[0].id);
    }
  } catch (e: any) {
    error.value = e.message || 'Failed to connect';
  } finally {
    loading.value = false;
  }
});

onUnmounted(() => closeAll());

// --- Visibility: close on background, reconnect on foreground ---

function onVisibilityChange() {
  if (document.visibilityState === 'hidden') {
    closeAll();
  } else if (!loading.value && !error.value) {
    loading.value = true;
    fetchUnitsDevices()
      .then((units) => units.length > 0 && openConnections(units[0].devices, units[0].id))
      .catch((e) => { error.value = e.message; })
      .finally(() => { loading.value = false; });
  }
}

onMounted(() => document.addEventListener('visibilitychange', onVisibilityChange));
onUnmounted(() => document.removeEventListener('visibilitychange', onVisibilityChange));
</script>

The example covers:

  • Fetching units and devices from the Service Connection API
  • Opening WebSocket connections with a token function (not a static string)
  • Dynamically rendering web components from the UI node configuration
  • Closing connections on unmount and when the tab goes to the background
  • Re-creating connections when the tab returns to the foreground

For a working reference implementation, see the case study video on the Getting Started page showing how Eften Living integrates Fentrica smart spaces into their tenant app.