Skip to main content
Version: 1.0

Web UI Development

The web component (web.svc.svelte) is the user interface shown in the Shelly app for your firmware extension. It's a Svelte component that reads and writes virtual components declared in your manifest and displays them to the user.

Basic Structure

Every web component receives a single prop, service, which is your entry point to the firmware extension:

<script lang="ts">
export let service: any;
const { state, brightness } = service.components.get();
</script>

<div class="control">
<button on:click={() => state.set(!$state.value)}>
{$state.value ? 'ON' : 'OFF'}
</button>
</div>

<style>
.control { padding: 20px; }
</style>

The Service Object

The service prop provides these properties:

components.get()

Retrieves your virtual components by their roles (keys from the manifest's vc):

const { state, brightness } = service.components.get();

Component Stores

Each component is a Svelte store with reactive properties:

{$state.value}              <!-- Current value -->
{$state.displayValue} <!-- Formatted value (with units) -->
{$state.componentName} <!-- Display name from manifest -->
{$state.unit} <!-- Unit suffix ("°C", "%", etc.) -->
{$state.step} <!-- Slider step size -->
{$state.config} <!-- Full manifest config -->

component.set(value)

Update a component's value:

<button on:click={() => state.set(!$state.value)}>Toggle</button>

For button components, pass the event name:

<button on:click={() => open.set('single_push')}>Open</button>

service.config

The firmware extension's configuration as a reactive store:

<script lang="ts">
const config = service.config;
let { name } = $config;

async function saveName() {
await service.setConfig({ name });
}
</script>

service.call(handler)

Invoke RPC handlers declared in the manifest:

<script lang="ts">
let options = [];

service.call('ListConfigOptions').then((result) => {
options = result.props;
});
</script>

Reactive Values

Svelte uses the $ prefix to subscribe to store values, which automatically update in the UI:

<script lang="ts">
const { state, brightness } = service.components.get();
</script>

<!-- These update automatically when the device changes them -->
<p>State: {$state.value ? 'ON' : 'OFF'}</p>
<p>Brightness: {$brightness.value}%</p>

Setting Values

Update components from user interaction:

<button on:click={() => state.set(true)}>
Turn On
</button>

<input
type="range"
value={$brightness.value}
on:change={(e) => brightness.set(+e.target.value)}
/>

Important: Always convert range input values to numbers with + or Number().

Common Patterns

Toggle Button

<script lang="ts">
export let service: any;
const { state } = service.components.get();
</script>

<button
on:click={() => state.set(!$state.value)}
class:active={$state.value}
>
{$state.value ? 'ON' : 'OFF'}
</button>

<style>
button {
padding: 12px 24px;
border: none;
border-radius: 4px;
background: #ccc;
}

button.active {
background: #4CAF50;
color: white;
}
</style>

Range Slider with Debounce

For performance, debounce slider changes to avoid flooding the device:

<script lang="ts">
export let service: any;
const { brightness } = service.components.get();

const DEBOUNCE_TIME = 300;
let timer: ReturnType<typeof setTimeout> | undefined;

function updateBrightness(newValue: number) {
clearTimeout(timer);
timer = setTimeout(() => brightness.set(newValue), DEBOUNCE_TIME);
}
</script>

<div class="control">
<label>Brightness: {$brightness.value}%</label>

<input
type="range"
min="0"
max="100"
value={$brightness.value}
on:change={(e) => updateBrightness(+e.target.value)}
/>
</div>

<style>
label { display: block; margin-bottom: 10px; }
input[type="range"] { width: 100%; }
</style>
<script lang="ts">
export let service: any;
const { mode } = service.components.get();
</script>

<select
value={$mode.value}
on:change={(e) => mode.set(e.target.value)}
>
<option value="off">Off</option>
<option value="heat">Heat</option>
<option value="cool">Cool</option>
</select>

Conditional Rendering

Show elements based on state:

<script lang="ts">
export let service: any;
const { state, brightness } = service.components.get();
</script>

<button on:click={() => state.set(!$state.value)}>
{$state.value ? 'ON' : 'OFF'}
</button>

{#if $state.value}
<div class="brightness-control">
<label>Brightness</label>
<input
type="range"
min="0"
max="100"
value={$brightness.value}
on:change={(e) => brightness.set(+e.target.value)}
/>
</div>
{/if}

Read-Only Display

For sensors:

<script lang="ts">
export let service: any;
const { temperature, humidity } = service.components.get();
</script>

<div class="readings">
<div>
<label>Temperature</label>
<p>{$temperature.value}°C</p>
</div>

<div>
<label>Humidity</label>
<p>{$humidity.value}%</p>
</div>
</div>

<style>
.readings { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
label { color: #666; font-size: 12px; }
p { margin: 8px 0 0 0; font-size: 20px; font-weight: bold; }
</style>

Multiple Controls

<script lang="ts">
export let service: any;
const { state, brightness, color_temp } = service.components.get();
</script>

<div class="controls">
<div class="control-group">
<h3>Power</h3>
<button
on:click={() => state.set(!$state.value)}
class:active={$state.value}
>
{$state.value ? 'ON' : 'OFF'}
</button>
</div>

{#if $state.value}
<div class="control-group">
<h3>Brightness</h3>
<input
type="range"
min="0"
max="100"
value={$brightness.value}
on:change={(e) => brightness.set(+e.target.value)}
/>
<p>{$brightness.value}%</p>
</div>

<div class="control-group">
<h3>Color Temperature</h3>
<input
type="range"
min="2700"
max="6500"
value={$color_temp.value}
on:change={(e) => color_temp.set(+e.target.value)}
/>
<p>{$color_temp.value}K</p>
</div>
{/if}
</div>

<style>
.controls { padding: 20px; }
.control-group { margin-bottom: 24px; padding: 16px; background: #f9f9f9; }
h3 { margin-top: 0; }
input[type="range"] { width: 100%; }
</style>

Svelte Directives

Conditional Blocks

{#if $state.value}
<p>Light is ON</p>
{:else}
<p>Light is OFF</p>
{/if}

Loops

{#each items as item (item.id)}
<button>{item.name}</button>
{/each}

Reactive Declarations

Variables that recompute when dependencies change:

<script lang="ts">
export let service: any;
const { brightness } = service.components.get();

$: percent = `${$brightness.value}%`;
$: isOn = $brightness.value > 0;
</script>

<p>{percent}</p>
{#if isOn}
<p>Light is on</p>
{/if}

Event Handling

Click Events

<button on:click={() => state.set(true)}>
Turn On
</button>

Input Changes

<input
on:change={(e) => brightness.set(+e.target.value)}
/>

Select Changes

<select on:change={(e) => mode.set(e.target.value)}>
<option value="off">Off</option>
</select>

Styling

Styles are scoped to the component automatically. Use CSS variables to match the app's theme:

<button class="my-button">Click</button>

<style>
.my-button {
background: var(--primary);
padding: 10px;
}
</style>

Class Bindings

Conditional styles:

<button class:active={$state.value}>
Toggle
</button>

<style>
button { padding: 12px; }
button.active { background: var(--primary); color: white; }
</style>

Lifecycle

Components mount and unmount automatically:

<script>
import { onMount, onDestroy } from 'svelte';

onMount(() => {
console.log('Component mounted');
});

onDestroy(() => {
console.log('Component destroyed');
});
</script>

Debugging

Check values in the browser console:

<script lang="ts">
export let service: any;
const { state } = service.components.get();

console.log('State store:', state);
console.log('Current value:', $state.value);
console.log('Config:', state.getConfig());
</script>

Complete Example: Smart Light

A full implementation with state and brightness:

<script lang="ts">
export let service: any;
const { state, brightness } = service.components.get();

const DEBOUNCE_TIME = 300;
let timer: ReturnType<typeof setTimeout> | undefined;

$: isOn = $state.value;
$: percent = Math.round($brightness.value);

function updateBrightness(newValue: number) {
clearTimeout(timer);
timer = setTimeout(() => brightness.set(newValue), DEBOUNCE_TIME);
}
</script>

<div class="light-control">
<div class="header">
<h2>Smart Light</h2>
<span class="status" class:active={isOn}>
{isOn ? 'ON' : 'OFF'}
</span>
</div>

<button
class="toggle"
class:active={isOn}
on:click={() => state.set(!isOn)}
>
{isOn ? '💡 ON' : '💡 OFF'}
</button>

{#if isOn}
<div class="brightness-section">
<div class="brightness-display">
<label>Brightness</label>
<div class="value">{percent}%</div>
</div>

<input
type="range"
min="0"
max="100"
value={percent}
on:change={(e) => updateBrightness(+e.target.value)}
class="slider"
/>
</div>
{/if}
</div>

<style>
.light-control {
max-width: 300px;
padding: 24px;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
border-radius: var(--card-radius);
color: white;
}

.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}

h2 { margin: 0; }

.status {
padding: 4px 12px;
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
font-size: 12px;
}

.status.active {
background: rgba(76, 175, 80, 0.4);
}

.toggle {
width: 100%;
padding: 16px;
margin-bottom: 24px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
color: white;
font-weight: bold;
cursor: pointer;
}

.toggle:hover { background: rgba(255, 255, 255, 0.2); }
.toggle.active { background: rgba(76, 175, 80, 0.3); }

.brightness-section {
background: rgba(255, 255, 255, 0.1);
padding: 16px;
border-radius: 8px;
}

.brightness-display {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}

.value { font-size: 20px; font-weight: bold; }

.slider { width: 100%; }
</style>