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>
Dropdown Select
<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>