Skip to content

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) => ShellyService to a single context object (ctx: ShellyServiceContext) => ShellyService where ctx = { http, mqtt, logger } — mirroring the existing homekit factory pattern. Update any code constructing ShellyService directly 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 client
  • server: "<broker-host>:1883" — your MQTT broker address
  • enable_rpc: true — enable the RPC-over-MQTT channel
  • rpc_ntf: true — enable NotifyStatus/NotifyEvent push notifications
  • status_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 NotifyFullStatus and NotifyStatus frames are dispatched by their actual component key — a switch:2 notification 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");
  }
}