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:
| Event | Direction | Description |
|---|---|---|
output | Component → Device | User interaction (e.g. toggle switch, move slider) |
request | Component → Device | Component 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.