zigbee-herdsman-converters
Version:
Collection of device converters to be used with zigbee-herdsman
1,090 lines • 64 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.definitions = void 0;
const zigbee_herdsman_1 = require("zigbee-herdsman");
const fz = __importStar(require("../converters/fromZigbee"));
const exposes = __importStar(require("../lib/exposes"));
const logger_1 = require("../lib/logger");
const m = __importStar(require("../lib/modernExtend"));
const utils = __importStar(require("../lib/utils"));
const utils_1 = require("../lib/utils");
const e = exposes.presets;
const ea = exposes.access;
const SHELLY_ENDPOINT_ID = 239;
const SHELLY_OPTIONS = { profileId: zigbee_herdsman_1.ZSpec.CUSTOM_SHELLY_PROFILE_ID };
const NS = "zhc:shelly";
const HA_ELECTRICAL_MEASUREMENT_CLUSTER_ID = 0x0b04;
const HA_ELECTRICAL_MEASUREMENT_POWER_FACTOR_ATTR_ID = 0x0510;
/**
* Get or initialize WS90 meta storage on device
*/
function getWS90Meta(device) {
if (!device.meta.ws90) {
device.meta.ws90 = {};
}
return device.meta.ws90;
}
/**
* Calculate dew point using Magnus formula
*/
function calculateDewPoint(T, Rh) {
if (T === undefined || Rh === undefined || Rh <= 0)
return null;
const a = 17.27;
const b = 237.7;
const alpha = (a * T) / (b + T) + Math.log(Rh / 100);
return Math.round(((b * alpha) / (a - alpha)) * 10) / 10;
}
/**
* Calculate humidex (Canadian heat index)
*/
function calculateHumidex(T, Rh) {
if (T === undefined || Rh === undefined)
return null;
const dewPoint = calculateDewPoint(T, Rh);
if (dewPoint === null)
return null;
const ee = 6.11 * Math.exp(5417.753 * (1 / 273.15 - 1 / (273.15 + dewPoint)));
return Math.round((T + 0.5555 * (ee - 10)) * 10) / 10;
}
/**
* Calculate wind chill (formula valid for T <= 10°C and wind >= 4.8 km/h)
*/
function calculateWindChill(T, windMs) {
if (T === undefined || windMs === undefined)
return null;
const windKmh = windMs * 3.6;
if (T > 10 || windKmh < 4.8)
return Math.round(T * 10) / 10;
const wc = 13.12 + 0.6215 * T - 11.37 * windKmh ** 0.16 + 0.3965 * T * windKmh ** 0.16;
return Math.round(wc * 10) / 10;
}
/**
* Calculate heat stress percentage using sigmoid curve
*/
function calculateHeatStress(T, Rh, lux, windMs, precipitation) {
if (T === undefined)
return null;
const solar = (lux || 0) / 100;
const base = T + solar / 100 + (Rh || 0) / 10;
const cooled = base - (windMs || 0) / 2;
const adjusted = cooled - ((precipitation || 0) > 0 ? 3 : 0);
const scaled = (adjusted - 18) / (42 - 18);
const sigmoid = 1 / (1 + Math.E ** (-4 * (scaled - 0.5)));
return Math.max(Math.round(sigmoid * 100), 0);
}
/**
* Calculate apparent temperature (wind chill when cold, humidex when warm)
*/
function calculateApparentTemperature(T, Rh, windMs) {
if (T === undefined)
return null;
const windChill = calculateWindChill(T, windMs);
const humidex = calculateHumidex(T, Rh);
if (windChill !== null && windChill < T)
return windChill;
if (humidex !== null && humidex > T)
return humidex;
return Math.round(T * 10) / 10;
}
/**
* Calculate rain rate from precipitation changes (mm/h)
*/
function calculateRainRate(meta, precipitation) {
if (precipitation === undefined)
return null;
const now = Date.now();
const history = meta.precipHistory;
if (!history) {
meta.precipHistory = { value: precipitation, time: now };
return 0;
}
const timeDeltaMs = now - history.time;
const precipDelta = precipitation - history.value;
if (timeDeltaMs < 60000)
return null;
if (precipDelta < 0)
return 0;
meta.precipHistory = { value: precipitation, time: now };
const timeDeltaHours = timeDeltaMs / (1000 * 60 * 60);
const rate = precipDelta / timeDeltaHours;
return Math.min(Math.round(rate * 10) / 10, 300);
}
/**
* Calculate pressure trend (hPa/hour)
*/
function calculatePressureTrend(meta, pressure) {
if (pressure === undefined)
return null;
const now = Date.now();
const history = meta.pressureHistory;
if (!history) {
meta.pressureHistory = { value: pressure, time: now };
return 0;
}
const timeDeltaMs = now - history.time;
const pressureDelta = pressure - history.value;
if (timeDeltaMs < 1800000)
return null;
meta.pressureHistory = { value: pressure, time: now };
const timeDeltaHours = timeDeltaMs / (1000 * 60 * 60);
const rate = pressureDelta / timeDeltaHours;
return Math.round(rate * 10) / 10;
}
/**
* Determine weather condition based on sensor data
*/
function calculateWeatherCondition(state) {
const { temperature, illuminance, rain_status, wind_speed, rain_rate, pressure, pressure_trend } = state;
if (illuminance === undefined)
return null;
const isRaining = rain_status === true && rain_rate !== undefined && rain_rate > 0;
const isPouring = isRaining && rain_rate > 10;
const isWindy = wind_speed !== undefined && wind_speed > 10;
const isNight = illuminance < 10;
const isLowPressure = pressure !== undefined && pressure < 1000;
const isPressureFalling = pressure_trend !== undefined && pressure_trend < -2;
const isHail = isRaining &&
rain_rate > 5 &&
illuminance < 5000 &&
wind_speed !== undefined &&
wind_speed > 5 &&
(isLowPressure || isPressureFalling);
const isSnowing = isRaining && temperature !== undefined && temperature < 1 && !isHail;
if (isHail)
return "hail";
if (isSnowing)
return "snowy";
if (isPouring)
return "pouring";
if (isRaining)
return "rainy";
if (isNight) {
return isWindy ? "windy" : "clear-night";
}
if (illuminance > 40000) {
return isWindy ? "windy" : "sunny";
}
if (illuminance > 10000) {
return isWindy ? "windy-variant" : "partlycloudy";
}
return "cloudy";
}
/**
* Update calculated values whenever we get new sensor data (uses device.meta for persistence)
*/
function updateWS90CalculatedValues(device, payload) {
const meta = getWS90Meta(device);
if (!meta.state)
meta.state = {};
Object.assign(meta.state, payload);
const state = meta.state;
const result = {};
const temp = state.temperature;
const humidity = state.humidity;
const windSpeed = state.wind_speed;
const lux = state.illuminance;
const precip = state.precipitation;
const pressure = state.pressure;
if (temp !== undefined && humidity !== undefined) {
const dewPoint = calculateDewPoint(temp, humidity);
if (dewPoint !== null)
result.dew_point = dewPoint;
const humidex = calculateHumidex(temp, humidity);
if (humidex !== null)
result.humidex = humidex;
const heatStress = calculateHeatStress(temp, humidity, lux, windSpeed, precip);
if (heatStress !== null)
result.heat_stress = heatStress;
}
if (temp !== undefined && windSpeed !== undefined) {
const windChill = calculateWindChill(temp, windSpeed);
if (windChill !== null)
result.wind_chill = windChill;
}
if (temp !== undefined) {
const apparent = calculateApparentTemperature(temp, humidity, windSpeed);
if (apparent !== null)
result.apparent_temperature = apparent;
}
if (pressure !== undefined) {
const trend = calculatePressureTrend(meta, pressure);
if (trend !== null) {
result.pressure_trend = trend;
state.pressure_trend = trend;
}
else if (typeof state.pressure_trend === "number") {
result.pressure_trend = state.pressure_trend;
}
}
const condition = calculateWeatherCondition(state);
if (condition !== null)
result.weather_condition = condition;
// Save device meta to persist across restarts
device.save();
return result;
}
// =============================================================================
// Shelly Modern Extend
// =============================================================================
const shellyModernExtend = {
shellyPowerFactorInt16Fix() {
// Shelly Gen4 devices report haElectricalMeasurement.powerFactor (0x0510) as INT16 (0x29)
// while zigbee-herdsman defines it as INT8 (0x28). This breaks configureReporting (INVALID_DATA_TYPE).
return m.deviceAddCustomCluster("haElectricalMeasurement", {
name: "haElectricalMeasurement",
ID: HA_ELECTRICAL_MEASUREMENT_CLUSTER_ID,
attributes: {
powerFactor: { name: "powerFactor", ID: HA_ELECTRICAL_MEASUREMENT_POWER_FACTOR_ATTR_ID, type: zigbee_herdsman_1.Zcl.DataType.INT16 },
},
commands: {},
commandsResponse: {},
});
},
shellyCustomClusters() {
return [
m.deviceAddCustomCluster("shellyRPCCluster", {
name: "shellyRPCCluster",
ID: 0xfc01,
manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.SHELLY,
attributes: {
data: { name: "data", ID: 0x0000, type: zigbee_herdsman_1.Zcl.DataType.CHAR_STR, write: true },
txCtl: { name: "txCtl", ID: 0x0001, type: zigbee_herdsman_1.Zcl.DataType.UINT32, write: true, max: 0xffffffff },
rxCtl: { name: "rxCtl", ID: 0x0002, type: zigbee_herdsman_1.Zcl.DataType.UINT32, write: true, max: 0xffffffff },
},
commands: {},
commandsResponse: {},
}),
m.deviceAddCustomCluster("shellyWiFiSetupCluster", {
name: "shellyWiFiSetupCluster",
ID: 0xfc02,
manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.SHELLY,
attributes: {
status: { name: "status", ID: 0x0000, type: zigbee_herdsman_1.Zcl.DataType.CHAR_STR, write: true },
ip: { name: "ip", ID: 0x0001, type: zigbee_herdsman_1.Zcl.DataType.CHAR_STR, write: true },
actionCode: { name: "actionCode", ID: 0x0002, type: zigbee_herdsman_1.Zcl.DataType.UINT8, write: true, max: 0xff },
dhcp: { name: "dhcp", ID: 0x0003, type: zigbee_herdsman_1.Zcl.DataType.BOOLEAN, write: true },
enabled: { name: "enabled", ID: 0x0004, type: zigbee_herdsman_1.Zcl.DataType.BOOLEAN, write: true },
ssid: { name: "ssid", ID: 0x0005, type: zigbee_herdsman_1.Zcl.DataType.CHAR_STR, write: true },
password: { name: "password", ID: 0x0006, type: zigbee_herdsman_1.Zcl.DataType.CHAR_STR, write: true },
staticIp: { name: "staticIp", ID: 0x0007, type: zigbee_herdsman_1.Zcl.DataType.CHAR_STR, write: true },
netMask: { name: "netMask", ID: 0x0008, type: zigbee_herdsman_1.Zcl.DataType.CHAR_STR, write: true },
gateway: { name: "gateway", ID: 0x0009, type: zigbee_herdsman_1.Zcl.DataType.CHAR_STR, write: true },
nameServer: { name: "nameServer", ID: 0x000a, type: zigbee_herdsman_1.Zcl.DataType.CHAR_STR, write: true },
},
commands: {},
commandsResponse: {},
}),
];
},
shellyRPCSetup(features = []) {
// Set helper variables
const shellyRPCBugFixed = false; // For firmware 20250819-150402/ga0def2d
const featureDev = features.includes("Dev");
const featurePowerstripUI = features.includes("PowerstripUI");
// Generic helper functions
const validateTime = (value) => {
const hhmmRegex = /^([01][0-9]|2[0-3]):[0-5][0-9]$/;
if (value === undefined || !value.match(hhmmRegex)) {
throw new Error(`Invalid time "${value}"`);
}
};
// RPC helper functions
let rpcSending = false;
const rpcSendRaw = async (endpoint, message) => {
// Since RPC messages require multiple writes to complete, we have to make sure
// we're not interleaving them accidentally. This is good enough for now, at least
// until the RPC receive firmware bug is fixed by Shelly.
while (rpcSending) {
await (0, utils_1.sleep)(200);
}
try {
rpcSending = true;
const splitBytes = 40;
logger_1.logger.debug(">>> shellyRPC write TxCtl", NS);
const txCtl = message.length;
await endpoint.write("shellyRPCCluster", { txCtl: txCtl }, SHELLY_OPTIONS);
logger_1.logger.debug(`>>> TxCtl: ${txCtl}`, NS);
logger_1.logger.debug(">>> shellyRPC write Data", NS);
let dataToSend = message;
while (dataToSend.length > 0) {
const data = dataToSend.substring(0, splitBytes);
dataToSend = dataToSend.substring(splitBytes);
await endpoint.write("shellyRPCCluster", { data: data }, SHELLY_OPTIONS);
logger_1.logger.debug(`>>> Data: ${data}`, NS);
}
}
finally {
rpcSending = false;
}
};
const rpcSend = async (endpoint, method, params = undefined) => {
const command = {
id: 1, // We can't read replies anyway so don't care for now
method: method,
params: params,
};
return await rpcSendRaw(endpoint, JSON.stringify(command));
};
const rpcReceive = async (endpoint, key) => {
logger_1.logger.debug(`||| shellyRPC rpcReceive(${key})`, NS);
if (key === "rpc_rxctl") {
logger_1.logger.debug(">>> shellyRPC read RxCtl", NS);
const result = await endpoint.read("shellyRPCCluster", ["rxCtl"], SHELLY_OPTIONS);
logger_1.logger.debug(`<<< RxCtl: ${JSON.stringify(result)}`, NS);
}
else if (key === "rpc_data") {
logger_1.logger.debug(">>> shellyRPC read Data", NS);
const result = await endpoint.read("shellyRPCCluster", ["data"], { ...SHELLY_OPTIONS, timeout: 1000 });
logger_1.logger.debug(`<<< Data: ${JSON.stringify(result)}`, NS);
}
};
// Features for exposes
const featurePercentage = (name, label) => {
return e.numeric(name, ea.STATE_SET).withValueMin(0).withValueMax(100).withValueStep(1).withLabel(label).withUnit("%");
};
const featureButtonEnabled = (id) => {
return e.binary(`switch_${id}`, ea.STATE_SET, "momentary", "detached").withLabel(`Endpoint: ${id + 1}`);
};
const exposes = [];
const exposesDev = [
e
.text("rpc_tx", ea.STATE_SET)
.withLabel("TX Data")
.withDescription("See https://shelly-api-docs.shelly.cloud/gen2/Devices/Gen4/ShellyPowerStripG4"),
e.text("rpc_rxctl", ea.STATE_GET).withLabel("RxCtl").withDescription("RX bytes available").withCategory("diagnostic"),
e.text("rpc_data", ea.STATE_GET).withLabel("Data").withDescription("RX Data").withCategory("diagnostic"),
];
const exposesPowerstripUI = [
e
.enum("led_mode", ea.STATE_SET, ["off", "switch", "power"])
.withLabel("LED Mode")
.withDescription("Controls the behaviour of the LED rings around the sockets")
.withCategory("config"),
e
.composite("led_colors", "led_colors", ea.ALL)
.withFeature(featurePercentage("on_r", "Red (on)"))
.withFeature(featurePercentage("on_g", "Green (on)"))
.withFeature(featurePercentage("on_b", "Blue (on)"))
.withFeature(featurePercentage("on_brightness", "Brightness (on)"))
.withFeature(featurePercentage("off_r", "Red (off)"))
.withFeature(featurePercentage("off_g", "Green (off)"))
.withFeature(featurePercentage("off_b", "Blue (off)"))
.withFeature(featurePercentage("off_brightness", "Brightness (off)"))
.withLabel("LED colors in 'switch' mode")
.withCategory("config"),
featurePercentage("led_power_brightness", "LED brightness in 'power' mode").withCategory("config"),
e
.composite("led_night_mode", "led_night_mode", ea.ALL)
.withFeature(e.binary("enable", ea.STATE_SET, true, false))
.withFeature(featurePercentage("brightness", "Brightness"))
.withFeature(e.text("from", ea.STATE_SET).withLabel("Active from").withDescription("hh:mm"))
.withFeature(e.text("until", ea.STATE_SET).withLabel("Active until").withDescription("hh:mm"))
.withLabel("LED night mode")
.withDescription("Adjust LED brightness during night time")
.withCategory("config"),
e
.composite("buttons_enabled", "buttons_enabled", ea.ALL)
.withFeature(featureButtonEnabled(0))
.withFeature(featureButtonEnabled(1))
.withFeature(featureButtonEnabled(2))
.withFeature(featureButtonEnabled(3))
.withLabel("Buttons enabled")
.withCategory("config"),
];
const fromZigbee = [
{
cluster: "shellyRPCCluster",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const state = {};
// Diagnostic data
if (msg.data.rxCtl !== undefined) {
state.rpc_rxctl = msg.data.rxCtl;
state.rpc_data = "";
}
if (msg.data.data !== undefined)
state.rpc_data = meta.state.rpc_data + msg.data.data;
return state;
},
},
];
const toZigbee = [];
const toZigbeeDev = [
{
key: ["rpc_rxctl", "rpc_data"],
convertGet: async (entity, key, meta) => {
const ep = (0, utils_1.determineEndpoint)(entity, meta, "shellyRPCCluster");
await rpcReceive(ep, key);
},
},
{
key: ["rpc_tx"],
convertSet: async (entity, key, value, meta) => {
logger_1.logger.debug(`>>> toZigbee.convertSet(${key}): ${value}`, NS);
const ep = (0, utils_1.determineEndpoint)(entity, meta, "shellyRPCCluster");
await rpcSendRaw(ep, value);
await rpcReceive(ep, "rpc_rxctl");
if (shellyRPCBugFixed) {
await rpcReceive(ep, "rpc_data");
}
else {
return { state: { rpc_data: "[Refresh for response]" } };
}
},
},
];
const toZigbeePowerstripUI = [
{
key: ["led_mode"],
convertSet: async (entity, key, value, meta) => {
const ep = (0, utils_1.determineEndpoint)(entity, meta, "shellyRPCCluster");
await rpcSend(ep, "POWERSTRIP_UI.SetConfig", {
config: {
leds: {
mode: value,
},
},
});
},
},
{
key: ["led_colors"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertObject)(value);
const ep = (0, utils_1.determineEndpoint)(entity, meta, "shellyRPCCluster");
await rpcSend(ep, "POWERSTRIP_UI.SetConfig", {
config: {
leds: {
colors: {
"switch:0": {
on: {
rgb: [value.on_r ?? 0, value.on_g ?? 0, value.on_b ?? 0],
brightness: value.on_brightness ?? 0,
},
off: {
rgb: [value.off_r ?? 0, value.off_g ?? 0, value.off_b ?? 0],
brightness: value.off_brightness ?? 0,
},
},
},
},
},
});
},
},
{
key: ["led_power_brightness"],
convertSet: async (entity, key, value, meta) => {
const ep = (0, utils_1.determineEndpoint)(entity, meta, "shellyRPCCluster");
await rpcSend(ep, "POWERSTRIP_UI.SetConfig", {
config: {
leds: {
colors: {
power: {
brightness: value ?? 0,
},
},
},
},
});
},
},
{
key: ["led_night_mode"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertObject)(value);
validateTime(value.from);
validateTime(value.until);
const ep = (0, utils_1.determineEndpoint)(entity, meta, "shellyRPCCluster");
await rpcSend(ep, "POWERSTRIP_UI.SetConfig", {
config: {
leds: {
night_mode: {
enable: value.enable,
brightness: value.brightness,
active_between: [value.from, value.until],
},
},
},
});
},
},
{
key: ["buttons_enabled"],
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertObject)(value);
const ep = (0, utils_1.determineEndpoint)(entity, meta, "shellyRPCCluster");
await rpcSend(ep, "POWERSTRIP_UI.SetConfig", {
config: {
controls: {
"switch:0": {
in_mode: value.switch_0,
},
"switch:1": {
in_mode: value.switch_1,
},
"switch:2": {
in_mode: value.switch_2,
},
"switch:3": {
in_mode: value.switch_3,
},
},
},
});
},
},
];
if (featureDev) {
exposes.push(...exposesDev);
toZigbee.push(...toZigbeeDev);
}
if (featurePowerstripUI) {
exposes.push(...exposesPowerstripUI);
toZigbee.push(...toZigbeePowerstripUI);
}
return { exposes, fromZigbee, toZigbee, isModernExtend: true };
},
shellyWiFiSetup() {
// biome-ignore lint/suspicious/noExplicitAny: generic
const refresh = async (endpoint) => {
await endpoint.write("shellyWiFiSetupCluster", { actionCode: 0 }, SHELLY_OPTIONS);
await endpoint.read("shellyWiFiSetupCluster", ["status", "ip", "enabled", "dhcp", "ssid"], SHELLY_OPTIONS);
await endpoint.read("shellyWiFiSetupCluster", ["staticIp", "netMask"], SHELLY_OPTIONS);
await endpoint.read("shellyWiFiSetupCluster", ["gateway", "nameServer"], SHELLY_OPTIONS);
};
const exposes = [
e.text("wifi_status", ea.STATE_GET).withLabel("Wi-Fi status").withDescription("Current connection status").withCategory("diagnostic"),
e
.text("ip_address", ea.STATE_GET)
.withLabel("IP address")
.withDescription("IP address currently assigned to the device")
.withCategory("diagnostic"),
e
.binary("dhcp_enabled", ea.STATE_GET, true, false)
.withLabel("DHCP enabled")
.withDescription("Indicates whether DHCP is used to automatically assign network settings")
.withCategory("diagnostic"),
e
.composite("wifi_config", "wifi_config", ea.ALL)
.withFeature(e.binary("enabled", ea.STATE_SET, true, false).withLabel("Wi-Fi enabled").withDescription("Enable/disable Wi-Fi connectivity"))
.withFeature(e.text("ssid", ea.STATE_SET).withLabel("Network").withDescription("Name (SSID) of the Wi-Fi network to connect to"))
.withFeature(e.text("password", ea.SET).withLabel("Password").withDescription("Password for the selected Wi-Fi network"))
.withFeature(e
.text("static_ip", ea.STATE_SET)
.withLabel("IPv4 address")
.withDescription("Manually assigned IP address (used when DHCP is disabled)"))
.withFeature(e.text("net_mask", ea.STATE_SET).withLabel("Network mask").withDescription("Subnet mask for the static IP configuration"))
.withFeature(e.text("gateway", ea.STATE_SET).withLabel("Gateway").withDescription("Default gateway address for static IP configuration"))
.withFeature(e.text("name_server", ea.STATE_SET).withLabel("DNS").withDescription("Name server address for static IP configuration"))
.withLabel("Wi-Fi Configuration")
.withCategory("config"),
];
// biome-ignore lint/suspicious/noExplicitAny: generic
const fromZigbee = [
{
cluster: "shellyWiFiSetupCluster",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const wifi_config = {};
const state = { wifi_config };
// Diagnostic data
if (msg.data.status !== undefined)
state.wifi_status = msg.data.status;
if (msg.data.ip !== undefined)
state.ip_address = msg.data.ip;
if (msg.data.dhcp !== undefined)
state.dhcp_enabled = msg.data.dhcp === 1;
// Wi-Fi config
if (msg.data.enabled !== undefined)
wifi_config.enabled = msg.data.enabled === 1;
if (msg.data.ssid !== undefined)
wifi_config.ssid = msg.data.ssid;
if (msg.data.staticIp !== undefined)
wifi_config.static_ip = msg.data.staticIp;
if (msg.data.netMask !== undefined)
wifi_config.net_mask = msg.data.netMask;
if (msg.data.gateway !== undefined)
wifi_config.gateway = msg.data.gateway;
if (msg.data.nameServer !== undefined)
wifi_config.name_server = msg.data.nameServer;
// Cleanup empty keys
for (const key in wifi_config) {
if (wifi_config[key] === "") {
wifi_config[key] = undefined;
}
}
return state;
},
},
];
const toZigbee = [
{
key: ["wifi_status", "ip_address", "dhcp_enabled"],
convertGet: async (entity, key, meta) => {
const ep = (0, utils_1.determineEndpoint)(entity, meta, "shellyWiFiSetupCluster");
await refresh(ep);
},
},
{
key: ["wifi_config"],
convertGet: async (entity, key, meta) => {
const ep = (0, utils_1.determineEndpoint)(entity, meta, "shellyWiFiSetupCluster");
await refresh(ep);
},
convertSet: async (entity, key, value, meta) => {
(0, utils_1.assertObject)(value);
const ep = (0, utils_1.determineEndpoint)(entity, meta, "shellyWiFiSetupCluster");
const attr1 = {
enabled: value.enabled === true,
ssid: value.ssid || "",
};
await ep.write("shellyWiFiSetupCluster", attr1, SHELLY_OPTIONS);
const attr2 = {
password: value.password || "",
};
await ep.write("shellyWiFiSetupCluster", attr2, SHELLY_OPTIONS);
const attr3 = {
staticIp: value.static_ip || "",
netMask: value.net_mask || "",
};
await ep.write("shellyWiFiSetupCluster", attr3, SHELLY_OPTIONS);
const attr4 = {
gateway: value.gateway || "",
nameServer: value.name_server || "",
};
await ep.write("shellyWiFiSetupCluster", attr4, SHELLY_OPTIONS);
const attr5 = {
actionCode: 1,
};
await ep.write("shellyWiFiSetupCluster", attr5, SHELLY_OPTIONS);
return {
state: {
wifi_config: {
enabled: attr1.enabled,
ssid: attr1.ssid === "" ? undefined : attr1.ssid,
static_ip: attr3.staticIp === "" ? undefined : attr3.staticIp,
net_mask: attr3.netMask === "" ? undefined : attr3.netMask,
gateway: attr4.gateway === "" ? undefined : attr4.gateway,
name_server: attr4.nameServer === "" ? undefined : attr4.nameServer,
},
},
};
},
},
];
const configure = [
async (device, coordinatorEndpoint, definition) => {
const ep = device.getEndpoint(SHELLY_ENDPOINT_ID);
await refresh(ep);
},
];
return { exposes, fromZigbee, toZigbee, configure, isModernExtend: true };
},
ws90CalculatedValues() {
const exposes = [
// Calculated values only
e.numeric("dew_point", ea.STATE).withUnit("°C").withDescription("Calculated dew point temperature"),
e.numeric("wind_chill", ea.STATE).withUnit("°C").withDescription("Calculated wind chill temperature"),
e.numeric("humidex", ea.STATE).withUnit("°C").withDescription("Calculated humidex (feels-like for warm conditions)"),
e.numeric("apparent_temperature", ea.STATE).withUnit("°C").withDescription("Calculated apparent temperature"),
e.numeric("heat_stress", ea.STATE).withUnit("%").withDescription("Calculated heat stress percentage (0-100%)"),
e.numeric("rain_rate", ea.STATE).withUnit("mm/h").withDescription("Calculated rainfall rate"),
e.numeric("pressure_trend", ea.STATE).withUnit("hPa/h").withDescription("Pressure change rate (negative = falling)"),
e.text("weather_condition", ea.STATE).withDescription("Weather condition (sunny, rainy, snowy, cloudy, etc.)"),
];
// biome-ignore lint/suspicious/noExplicitAny: custom clusters not in type registry
const fromZigbee = [
{
cluster: "msTemperatureMeasurement",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data.measuredValue !== undefined) {
const temperature = msg.data.measuredValue / 100;
const calculated = updateWS90CalculatedValues(msg.device, { temperature });
return calculated; // Only calculated values; m.temperature() handles base temperature
}
},
},
{
cluster: "msRelativeHumidity",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data.measuredValue !== undefined) {
const humidity = msg.data.measuredValue / 100;
const calculated = updateWS90CalculatedValues(msg.device, { humidity });
return calculated; // Only calculated values; m.humidity() handles base humidity
}
},
},
{
cluster: "msPressureMeasurement",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data.measuredValue !== undefined) {
const pressure = msg.data.measuredValue / 10;
const calculated = updateWS90CalculatedValues(msg.device, { pressure });
return calculated; // Only calculated values; m.pressure() handles base pressure
}
},
},
{
cluster: "msIlluminanceMeasurement",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
if (msg.data.measuredValue !== undefined) {
const measuredValue = msg.data.measuredValue;
const illuminance = measuredValue > 0 ? Math.round(10 ** ((measuredValue - 1) / 10000)) : 0;
const calculated = updateWS90CalculatedValues(msg.device, { illuminance });
return calculated; // Only calculated values; m.illuminance() handles base illuminance
}
},
},
{
cluster: "shellyWS90UV",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const data = msg.data;
if (data.uvIndex !== undefined) {
const uv_index = data.uvIndex / 10;
const calculated = updateWS90CalculatedValues(msg.device, { uv_index });
return calculated;
}
},
},
{
cluster: "shellyWS90Wind",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const data = msg.data;
const payload = {};
if (data.windSpeed !== undefined)
payload.wind_speed = data.windSpeed / 10;
if (data.windDirection !== undefined)
payload.wind_direction = data.windDirection / 10;
if (data.gustSpeed !== undefined)
payload.gust_speed = data.gustSpeed / 10;
const calculated = updateWS90CalculatedValues(msg.device, payload);
return calculated;
},
},
{
cluster: "shellyWS90Rain",
type: ["attributeReport", "readResponse"],
convert: (model, msg, publish, options, meta) => {
const data = msg.data;
const payload = {};
if (data.rainStatus !== undefined)
payload.rain_status = Boolean(data.rainStatus);
if (data.precipitation !== undefined) {
payload.precipitation = data.precipitation / 10;
}
// Calculate rain_rate (it's a calculated value, not a base sensor value)
const ws90Meta = getWS90Meta(msg.device);
const rainRate = calculateRainRate(ws90Meta, payload.precipitation);
const rain_rate = rainRate !== null ? rainRate : 0;
// Update state with precipitation and rain_rate
const stateUpdate = { ...payload, rain_rate };
const calculated = updateWS90CalculatedValues(msg.device, stateUpdate);
// Include rain_rate in calculated values
calculated.rain_rate = rain_rate;
msg.device.save();
return calculated; // Only calculated values; m.binary()/m.numeric() handle base rain values
},
},
];
return { exposes, fromZigbee, isModernExtend: true };
},
};
// =============================================================================
// Local From Zigbee Converters
// =============================================================================
const fzLocal = {
one_button_events: {
cluster: "genOnOff",
type: ["commandToggle"],
convert: (model, msg, publish, options, meta) => {
const event = utils.getFromLookup(msg.endpoint.ID, { 1: "single", 2: "double", 3: "triple" });
return { action: event };
},
},
one_button_scene_events: {
cluster: "genScenes",
type: ["commandRecall"],
convert: (model, msg, publish, options, meta) => {
const event = utils.getFromLookup(`${msg.endpoint.ID}`, { "1": "single_long", "2": "double_long", "3": "triple_long" });
return { action: event };
},
},
four_buttons_single_events: {
cluster: "genOnOff",
type: ["commandOn", "commandOff", "commandToggle"],
convert: (model, msg, publish, options, meta) => {
const event = utils.getFromLookup(`${msg.endpoint.ID}_${msg.type}`, {
"1_commandOn": "1_single",
"1_commandOff": "2_single",
"2_commandOn": "3_single",
"2_commandOff": "4_single",
"1_commandToggle": "1_single",
"2_commandToggle": "2_single",
"3_commandToggle": "3_single",
"4_commandToggle": "4_single",
});
return { action: event };
},
},
four_buttons_hold_events: {
cluster: "genLevelCtrl",
type: ["commandStep"],
convert: (model, msg, publish, options, meta) => {
const event = utils.getFromLookup(`${msg.endpoint.ID}_${msg.data.stepmode}`, {
"1_0": "1_hold",
"1_1": "2_hold",
"2_0": "3_hold",
"2_1": "4_hold",
});
return { action: event };
},
},
four_buttons_scene_events: {
cluster: "genScenes",
type: ["commandRecall"],
convert: (model, msg, publish, options, meta) => {
const event = utils.getFromLookup(`${msg.endpoint.ID}_${msg.data.sceneid}`, {
"1_1": "1_double",
"2_1": "2_double",
"3_1": "3_double",
"4_1": "4_double",
"1_2": "1_triple",
"2_2": "2_triple",
"3_2": "3_triple",
"4_2": "4_triple",
"1_11": "1_single_long",
"2_11": "2_single_long",
"3_11": "3_single_long",
"4_11": "4_single_long",
"1_12": "1_double_long",
"2_12": "2_double_long",
"3_12": "3_double_long",
"4_12": "4_double_long",
"1_13": "1_triple_long",
"2_13": "2_triple_long",
"3_13": "3_triple_long",
"4_13": "4_triple_long",
});
return { action: event };
},
},
};
// =============================================================================
// Device Definitions
// =============================================================================
exports.definitions = [
{
zigbeeModel: ["Mini1", "1 Mini"],
model: "S4SW-001X8EU",
vendor: "Shelly",
description: "1 Mini Gen 4",
extend: [m.onOff({ powerOnBehavior: false }), ...shellyModernExtend.shellyCustomClusters(), shellyModernExtend.shellyWiFiSetup()],
},
{
fingerprint: [{ modelID: "1", manufacturerName: "Shelly" }],
model: "S4SW-001X16EU",
vendor: "Shelly",
description: "1 Gen 4",
extend: [m.onOff({ powerOnBehavior: false }), ...shellyModernExtend.shellyCustomClusters(), shellyModernExtend.shellyWiFiSetup()],
},
{
zigbeeModel: ["Mini1PM", "1PM Mini"],
model: "S4SW-001P8EU",
vendor: "Shelly",
description: "1PM Mini Gen 4",
extend: [
m.onOff({ powerOnBehavior: false }),
m.electricityMeter({ producedEnergy: true, acFrequency: true }),
shellyModernExtend.shellyPowerFactorInt16Fix(),
...shellyModernExtend.shellyCustomClusters(),
shellyModernExtend.shellyWiFiSetup(),
],
},
{
zigbeeModel: ["1PM"],
model: "S4SW-001P16EU",
vendor: "Shelly",
description: "1PM Gen 4",
extend: [
m.onOff({ powerOnBehavior: false }),
m.electricityMeter({ producedEnergy: true, acFrequency: true }),
shellyModernExtend.shellyPowerFactorInt16Fix(),
...shellyModernExtend.shellyCustomClusters(),
shellyModernExtend.shellyWiFiSetup(),
],
},
{
zigbeeModel: ["EM Mini"],
model: "S4EM-001PXCEU16",
vendor: "Shelly",
description: "EM Mini Gen4",
extend: [
m.electricityMeter({ producedEnergy: true, acFrequency: true }),
shellyModernExtend.shellyPowerFactorInt16Fix(),
...shellyModernExtend.shellyCustomClusters(),
shellyModernExtend.shellyWiFiSetup(),
],
},
{
fingerprint: [
{
type: "Router",
manufacturerName: "Shelly",
modelID: "2PM",
endpoints: [
{ ID: 1, profileID: 260, deviceID: 514, inputClusters: [0, 3, 4, 5, 258], outputClusters: [] },
{ ID: 239, profileID: 49153, deviceID: 8193, inputClusters: [64513, 64514], outputClusters: [] },
{ ID: 242, profileID: 41440, deviceID: 97, inputClusters: [], outputClusters: [33] },
],
},
],
model: "S4SW-002P16EU-COVER",
vendor: "Shelly",
description: "2PM Gen4 (Cover mode)",
extend: [m.windowCovering({ controls: ["lift", "tilt"] }), ...shellyModernExtend.shellyCustomClusters(), shellyModernExtend.shellyWiFiSetup()],
},
{
fingerprint: [
{
type: "Router",
manufacturerName: "Shelly",
modelID: "2PM",
endpoints: [
{ ID: 1, profileID: 260, deviceID: 266, inputClusters: [0, 3, 4, 5, 6, 2820, 1794], outputClusters: [] },
{ ID: 2, profileID: 260, deviceID: 266, inputClusters: [4, 5, 6, 2820, 1794], outputClusters: [] },
{ ID: 239, profileID: 49153, deviceID: 8193, inputClusters: [64513, 64514], outputClusters: [] },
{ ID: 242, profileID: 41440, deviceID: 97, inputClusters: [], outputClusters: [33] },
],
},
],
model: "S4SW-002P16EU-SWITCH",
vendor: "Shelly",
description: "2PM Gen4 (Switch mode)",
extend: [
m.deviceEndpoints({ endpoints: { l1: 1, l2: 2 } }),
m.onOff({ powerOnBehavior: false, endpointNames: ["l1", "l2"] }),
m.electricityMeter({ producedEnergy: true, acFrequency: true, endpointNames: ["l1", "l2"] }),
shellyModernExtend.shellyPowerFactorInt16Fix(),
...shellyModernExtend.shellyCustomClusters(),
shellyModernExtend.shellyWiFiSetup(),
],
},
{
fingerprint: [{ modelID: "Plug US", manufacturerName: "Shelly" }],
model: "S4PL-00116US",
vendor: "Shelly",
description: "Plug US Gen4",
extend: [
m.onOff({ powerOnBehavior: false }),
m.electricityMeter(),
shellyModernExtend.shellyPowerFactorInt16Fix(),
...shellyModernExtend.shellyCustomClusters(),
shellyModernExtend.shellyWiFiSetup(),
],
},
{
fingerprint: [{ modelID: "Power Strip", manufacturerName: "Shelly" }],
model: "S4PL-00416EU",
vendor: "Shelly",
description: "Power strip 4 Gen4",
version: "0.0.1",
extend: [
m.deviceEndpoints({ endpoints: { "1": 1, "2": 2, "3": 3, "4": 4 } }),
m.onOff({ powerOnBehavior: false, endpointNames: ["1", "2", "3", "4"] }),
m.electricityMeter({
endpointNames: ["1", "2", "3", "4"],
// Reduce reporting to prevent crashes
// https://github.com/Koenkk/zigbee2mqtt/issues/31183
acFrequency: { change: 125 },
current: { change: 60 },
voltage: { change: 625 },
power: { change: 6 },
energy: { change: 125000 },
}),
shellyModernExtend.shellyPowerFactorInt16Fix(),
...shellyModernExtend.shellyCustomClusters(),
shellyModernExtend.shellyRPCSetup(["PowerstripUI"]),
shellyModernExtend.shellyWiFiSetup(),
],
},
{
fingerprint: [{ modelID: "Flood", manufacturerName: "Shelly" }],
model: "S4SN-0071A",
vendor: "Shelly",
description: "Flood Gen 4",
extend: [
m.battery(),
m.iasZoneAlarm({ zoneType: "water_leak", zoneAttributes: ["alarm_1", "tamper", "battery_low"] }),
...shellyModernExtend.shellyCustomClusters(),
shellyModernExtend.shellyWiFiSetup(),
],
},
{
fingerprint: [{ modelID: "Ecowitt WS90", manufacturerName: "Shelly" }],
model: "WS90",
vendor: "Shelly",
description: "Weather station",
extend: [
m.battery(),
m.illuminance(),
m.temperature(),
m.pressure(),
m.humidity(),
m.deviceAddCustomCluster("shellyWS90Wind", {
name: "shellyWS90Wind",
ID: 0xfc01,
manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.SHELLY,
attributes: {
windSpeed: { name: "windSpeed", ID: 0x0000, type: zigbee_herdsman_1.Zcl.DataType.UINT16 },
windDirection: { name: "windDirection", ID: 0x0004, type: zigbee_herdsman_1.Zcl.DataType.UINT16 },
gustSpeed: { name: "gustSpeed", ID: 0x0007, type: zigbee_herdsman_1.Zcl.DataType.UINT16 },
},
commands: {},
commandsResponse: {},
}),
m.numeric({
name: "wind_speed",
cluster: "shellyWS90Wind",
attribute: "windSpeed",
v