UNPKG

zigbee-herdsman-converters

Version:

Collection of device converters to be used with zigbee-herdsman

1,090 lines 64 kB
"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