Shelly Devices¶
The built-in ShellyService controls Shelly Gen 2 devices (Plus Plug S, Plus 1PM Mini, Plus 2PM, Pro 4PM, etc.) over either their local HTTP RPC API or their native RPC-over-MQTT channel. No cloud account or internet connection required. Multi-component Pro-series appliances are enumerated component-by-component — see Multi-component devices.
Breaking change:
ShellyService's constructor now takes(http, mqtt, logger)instead of(http, logger), and its engine service-factory signature changed from(http, logger) => ShellyServiceto a single context object(ctx: ShellyServiceContext) => ShellyServicewherectx = { http, mqtt, logger }— mirroring the existinghomekitfactory pattern. Update any code constructingShellyServicedirectly or registering it as a factory-form engine service.
Registering devices¶
HTTP transport¶
Register devices in a factory function passed to services.shelly in your entry point:
import { createEngine, ShellyService } from "@ts-ha/core";
const engine = createEngine({
automationsDir: "./automations",
services: {
shelly: ({ http, mqtt, logger }) => {
const svc = new ShellyService(http, mqtt, logger);
svc.registerMany({
"living_room_plug": "192.168.1.50",
"tv_plug": "shelly-tv.local", // mDNS hostnames work
"desk_lamp": "http://192.168.1.52", // full URLs are normalised
"bedroom_shutter": "shelly-2pm.local:8080", // custom ports work
});
return svc;
},
},
});
await engine.start();
You can also register a single device:
import { createEngine, ShellyService } from "@ts-ha/core";
const engine = createEngine({
automationsDir: "./automations",
services: {
shelly: ({ http, mqtt, logger }) => {
const svc = new ShellyService(http, mqtt, logger);
svc.register("kitchen_plug", "192.168.1.55");
return svc;
},
},
});
await engine.start();
MQTT transport¶
Shelly Gen2 devices also expose a native JSON-RPC channel over MQTT: commands are published to <topicPrefix>/rpc, responses come back on a single shared response topic, status changes push instantly over the device's events topic (instead of being polled), and presence is tracked via an online LWT topic. The events topic carries both notification methods — NotifyFullStatus (the full component status a device publishes when its MQTT connection is established) and NotifyStatus (subsequent incremental changes) — and the source ingests both. This is useful when devices aren't reliably reachable by IP, or when you want instant (rather than polled) status updates in HomeKit.
Because the events topic is not retained, a connect-time NotifyFullStatus published before the engine subscribed would never arrive. At source start — and whenever an MQTT-transport device is registered after start — the device source therefore seeds each logical entry's current status through the same RPC status query (Switch.GetStatus / Cover.GetStatus) used to poll HTTP devices, so a switch's on (and a cover's position/state) is present from first enumeration rather than only after a physical change. A seed failure or timeout is logged and non-fatal — the device stays enumerable and the source otherwise starts normally. HTTP-transport devices remain poll-backed and are not seeded.
Register an MQTT-transport device with the object-form overload, using the device's MQTT topic prefix (e.g. "shellyplus1-a8032abe54dc") instead of a host:
import { createEngine, ShellyService } from "@ts-ha/core";
const engine = createEngine({
automationsDir: "./automations",
services: {
shelly: ({ http, mqtt, logger }) => {
const svc = new ShellyService(http, mqtt, logger);
// HTTP and MQTT devices can be mixed on the same ShellyService instance.
svc.register("living_room_plug", "192.168.1.50");
svc.register("garage_plug", {
transport: "mqtt",
topicPrefix: "shellyplus1-a8032abe54dc",
type: "switch",
});
return svc;
},
},
});
await engine.start();
registerMany() also accepts mixed HTTP/MQTT entries in array form:
svc.registerMany([
{ name: "living_room_plug", host: "192.168.1.50" },
{ name: "garage_plug", transport: "mqtt", topicPrefix: "shellyplus1-a8032abe54dc" },
]);
A device's transport is fixed at registration — there is no per-call override and no automatic fallback between transports. If an MQTT call fails or times out, it is not retried over HTTP, and vice versa.
Required on-device setup¶
Before registering a device with transport: "mqtt", enable MQTT + RPC-over-MQTT on the device itself (via its web UI or MQTT.SetConfig):
enable: true— enable the MQTT clientserver: "<broker-host>:1883"— your MQTT broker addressenable_rpc: true— enable the RPC-over-MQTT channelrpc_ntf: true— enableNotifyStatus/NotifyEventpush notificationsstatus_ntf: true— include full component status in notifications
The MQTT RPC src identifier¶
Every MQTT RPC request carries a src value that the device echoes back on the shared <src>/rpc response topic, so the application knows which response subscription is "ours". This value is configurable via the MQTT_SHELLY_RPC_SRC environment variable (default: "ts-home-automation"). If multiple application instances share one broker, give each a distinct src — otherwise, one instance may receive another's RPC responses.
import { createEngine, loadConfig, ShellyService } from "@ts-ha/core";
const config = loadConfig();
const engine = createEngine({
automationsDir: "./automations",
services: {
shelly: ({ http, mqtt, logger }) =>
new ShellyService(http, mqtt, logger, config.mqtt.shellyRpcSrc),
},
});
MQTT RPC requests have a fixed 5-second timeout (not configurable) — if a device doesn't respond within that window, the call rejects with a descriptive error naming the device, topic prefix, method, and timeout duration.
Multi-component devices¶
Shelly Pro-series appliances are multi-component: a single physical device exposes
several independent switch:N / cover:N channels (for example, a Pro 4PM
exposes switch:0 through switch:3). Register such a device once — the
device source enumerates its components and presents each channel as its own
logical device.
svc.register("pro_4pm", "192.168.1.60"); // HTTP
svc.register("pro_4pm_shop", { // MQTT
transport: "mqtt",
topicPrefix: "shellypro4pm-a8032abe54dc",
});
Per-channel logical devices¶
At startup the source queries each device's component inventory
(Shelly.GetComponents, paging through the entire inventory — a response is
capped at one page and may sort switch components onto a later page) and
materializes one logical device per component, each with its own descriptor,
state, capability description, and HomeKit accessory. A four-switch Pro 4PM
appears as four switches; a device that exposes only switch:0 is unchanged.
- Awaited discovery:
start()completes component discovery for every already-registered device before returning, so enumeration is complete once the engine is up. A device does not appear until its discovery resolves. Devices registered after startup are discovered asynchronously and appear when resolved. - MQTT transport: one subscription per physical device; both
NotifyFullStatusandNotifyStatusframes are dispatched by their actual component key — aswitch:2notification updates only channel 2. Each logical entry is seeded with its current status at subscription (see MQTT transport). - HTTP transport: every component channel is polled independently; HTTP devices are not seeded.
- Commands are addressed to a logical channel via that channel's identity
(see below); the RPC carries the channel as its component
id. - Device-level queries (
getDeviceInfo,getSysStatus,reboot) remain device-scoped and channel-independent.
Channel identity¶
Identities are stable across restarts for the same physical device, channel, and
component name, and are the names you pass to the ShellyService command methods
(and the ids the device source exposes):
- A device that exposes exactly one component keeps the base registration name as its identity — existing single-channel deployments are unchanged.
- A device with multiple components uses the component's configured name
when it is present and unique across every registered device; otherwise the
identity falls back to
<base>_<channel>. Residual collisions are resolved deterministically: two component kinds sharing one channel use<base>_<kind>_<channel>, and any remaining clash gets a numeric suffix. The display name is the configured name when present, else a channel-qualified<base> <channel>(kind-qualified when two kinds share a channel), so two channels of one appliance never display identically. - For an expanded multi-component device, the base registration is not a logical device: a command naming it is rejected as not found. Only device-level queries resolve against it.
Renaming a component on the device is treated as a new identity. Persisted room/visibility state for the old id is orphaned, and the HomeKit accessory is created under a new persistence key (
shelly:<id>), which requires a fresh pairing. This is the deliberate trade-off of using human-meaningful component names over opaque numeric ids.
Discovery failure¶
Component discovery is best-effort and never blocks registration. If the inventory cannot be queried, returns an unexpected shape, or yields no recognized switch/cover components, the device falls back to a single channel-0 component using its declared registration type — i.e. the pre-multi-channel behavior.
Switch methods¶
For a multi-component device, name is the channel's logical identity (a configured component name, or <base>_<channel>) — see Channel identity. The RPC targets that channel's component id.
| Method | Returns | Description |
|---|---|---|
turnOn(name, toggleAfter?) |
Promise<void> |
Turn on; optional auto-off after N seconds |
turnOff(name, toggleAfter?) |
Promise<void> |
Turn off; optional auto-on after N seconds |
toggle(name) |
Promise<void> |
Toggle the switch |
isOn(name) |
Promise<boolean> |
Check if currently on |
getPower(name) |
Promise<number> |
Current power draw in Watts |
getStatus(name) |
Promise<SwitchStatus> |
Full switch status |
getConfig(name) |
Promise<SwitchConfig> |
Switch configuration |
getDeviceInfo(name) |
Promise<DeviceInfo> |
Model, firmware, MAC address |
getSysStatus(name) |
Promise<SysStatus> |
Uptime, RAM, available updates |
reboot(name, delayMs?) |
Promise<void> |
Reboot the device |
Switch status fields¶
const shelly = this.services.get<ShellyService>("shelly");
if (!shelly) return;
const status = await shelly.getStatus("living_room_plug");
status.output // boolean — on or off
status.apower // number — active power in Watts
status.voltage // number — voltage in Volts
status.current // number — current in Amps
status.aenergy // { total: Wh, by_minute: mWh[], minute_ts: unix }
status.temperature // { tC: number, tF: number }
Example: auto-off after TV goes idle¶
import type { ShellyService } from "@ts-ha/core";
export default class TvAutoOff extends Automation {
readonly name = "tv-auto-off";
readonly triggers: Trigger[] = [
{ type: "cron", expression: "0 23 * * *" },
];
async execute(): Promise<void> {
const shelly = this.services.get<ShellyService>("shelly");
if (!shelly) return;
const status = await shelly.getStatus("tv_plug");
if (status.output && status.apower < 5) {
this.logger.info("TV is idle, switching off");
await shelly.turnOff("tv_plug");
}
}
}
Cover / shutter methods¶
For Shelly Plus 2PM devices configured in roller mode (and the cover channels of multi-component appliances). For a multi-component device, name is the channel's logical identity — see Channel identity.
| Method | Returns | Description |
|---|---|---|
coverOpen(name, duration?) |
Promise<void> |
Open; optional stop after N seconds |
coverClose(name, duration?) |
Promise<void> |
Close; optional stop after N seconds |
coverStop(name) |
Promise<void> |
Stop movement immediately |
coverGoToPosition(name, pos) |
Promise<void> |
Move to absolute position 0–100 |
coverMoveRelative(name, offset) |
Promise<void> |
Move by relative offset -100 to 100 |
getCoverStatus(name) |
Promise<CoverStatus> |
Full cover status |
getCoverConfig(name) |
Promise<CoverConfig> |
Cover configuration |
getCoverPosition(name) |
Promise<number \| null> |
Current position 0–100, null if uncalibrated |
getCoverState(name) |
Promise<CoverState> |
Current state string |
coverCalibrate(name) |
Promise<void> |
Start calibration (full open → close cycle) |
Cover states¶
"open" | "closed" | "opening" | "closing" | "stopped"
Cover status fields¶
const shelly = this.services.get<ShellyService>("shelly");
if (!shelly) return;
const status = await shelly.getCoverStatus("bedroom_shutter");
status.state // CoverState string
status.current_pos // number 0–100, or null if uncalibrated
status.apower // active power in Watts
status.pos_control // true if calibrated for position control
Example: close shutters at sunset via cron¶
import type { ShellyService } from "@ts-ha/core";
export default class SunsetShutters extends Automation {
readonly name = "sunset-shutters";
readonly triggers: Trigger[] = [
{ type: "cron", expression: "0 21 * * *" },
];
async execute(): Promise<void> {
const shelly = this.services.get<ShellyService>("shelly");
if (!shelly) return;
await shelly.coverClose("bedroom_shutter");
await shelly.coverClose("living_room_shutter");
}
}