UNPKG

zigbee-herdsman-converters

Version:

Collection of device converters to be used with zigbee-herdsman

1,042 lines 87 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"); const featurePowerstripPowerOnBehavior = features.includes("PowerstripPowerOnBehavior"); const featureTwoPMInputMode = features.includes("2PMInputMode"); const featureOnePMInputMode = features.includes("1PMInputMode"); // 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) { const accumulated = (meta.state.rpc_data ?? "") + msg.data.data; state.rpc_data = accumulated; const expectedLen = meta.state.rpc_rxctl; if (expectedLen > 0 && accumulated.length >= expectedLen) { try { const response = JSON.parse(accumulated); if (response.result?.in_mode !== undefined) { const epName = response.result.id === 0 ? "sw1" : "sw2"; state[`switch_mode_${epName}`] = response.result.in_mode; } } catch { } } } return state; }, }, ]; const toZigbee = []; const configure = []; 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); } if (featurePowerstripPowerOnBehavior) { const powerOnBehaviorValues = ["off", "on", "previous", "match_input"]; for (const channel of [1, 2, 3, 4]) { exposes.push(e .enum("power_on_behavior", ea.STATE_SET, powerOnBehaviorValues) .withDescription("Behavior of the socket after a power outage. 'previous' restores the last known state.") .withCategory("config") .withEndpoint(String(channel))); } toZigbee.push({ key: ["power_on_behavior"], convertSet: async (entity, key, value, meta) => { utils.assertString(value, key); utils.assertString(meta.endpoint_name, "endpoint_name"); utils.assertEndpoint(entity); const switchId = Number(meta.endpoint_name) - 1; // shellyRPCCluster lives on a dedicated endpoint (239), but this expose is per-channel. // determineEndpoint() would return the per-channel endpoint when endpoint_name is set, // so we explicitly resolve the RPC endpoint via the device. const ep = entity.getDevice().getEndpoint(SHELLY_ENDPOINT_ID); if (!ep) throw new Error(`Shelly RPC endpoint ${SHELLY_ENDPOINT_ID} not found`); const shellyValue = value === "previous" ? "restore_last" : value; await rpcSend(ep, "Switch.SetConfig", { id: switchId, config: { initial_state: shellyValue } }); return { state: { power_on_behavior: value } }; }, }); } if (featureTwoPMInputMode) { const inModeValues = ["follow", "flip", "detached", "cycle", "activation"]; exposes.push((device, _options) => { if (utils.isDummyDevice(device) || !device.getEndpoint(SHELLY_ENDPOINT_ID)) return []; return [ e.enum("switch_mode", ea.ALL, inModeValues).withDescription("Switch input mode").withCategory("config").withEndpoint("sw1"), e.enum("switch_mode", ea.ALL, inModeValues).withDescription("Switch input mode").withCategory("config").withEndpoint("sw2"), ]; }); toZigbee.push({ key: ["switch_mode"], convertSet: async (entity, key, value, meta) => { const switchId = meta.endpoint_name === "sw1" ? 0 : 1; const ep = (0, utils_1.determineEndpoint)(entity, meta, "shellyRPCCluster"); await rpcSend(ep, "Switch.SetConfig", { id: switchId, config: { in_mode: value } }); return { state: { switch_mode: value } }; }, convertGet: async (entity, key, meta) => { const switchId = meta.endpoint_name === "sw1" ? 0 : 1; const ep = (0, utils_1.determineEndpoint)(entity, meta, "shellyRPCCluster"); await rpcSend(ep, "Switch.GetConfig", { id: switchId }); await rpcReceive(ep, "rpc_rxctl"); }, }); configure.push(async (device) => { const ep = device.getEndpoint(SHELLY_ENDPOINT_ID); if (!ep) return; try { for (const id of [0, 1]) { await rpcSend(ep, "Switch.GetConfig", { id }); await rpcReceive(ep, "rpc_rxctl"); } } catch (e) { logger_1.logger.warning(`Failed to read switch_mode during configure, use get to retry: ${e}`, NS); } }); } if (featureOnePMInputMode) { const inModeValues = ["follow", "flip", "detached", "cycle", "activation"]; exposes.push((device, _options) => { if (utils.isDummyDevice(device) || !device.getEndpoint(SHELLY_ENDPOINT_ID)) return []; return [e.enum("switch_mode", ea.ALL, inModeValues).withDescription("Switch input mode").withCategory("config").withEndpoint("sw1")]; }); toZigbee.push({ key: ["switch_mode"], convertSet: async (entity, key, value, meta) => { const ep = (0, utils_1.determineEndpoint)(entity, meta, "shellyRPCCluster"); await rpcSend(ep, "Switch.SetConfig", { id: 0, config: { in_mode: value } }); return { state: { switch_mode: value } }; }, convertGet: async (entity, key, meta) => { const ep = (0, utils_1.determineEndpoint)(entity, meta, "shellyRPCCluster"); await rpcSend(ep, "Switch.GetConfig", { id: 0 }); await rpcReceive(ep, "rpc_rxctl"); }, }); configure.push(async (device) => { const ep = device.getEndpoint(SHELLY_ENDPOINT_ID); if (!ep) return; try { await rpcSend(ep, "Switch.GetConfig", { id: 0 }); await rpcReceive(ep, "rpc_rxctl"); } catch (e) { logger_1.logger.warning(`Failed to read switch_mode during configure, use get to retry: ${e}`, NS); } }); } return { exposes, fromZigbee, toZigbee, configure, 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 }; }, shellyLightLevel(args) { const reporting = args?.reporting ?? { min: "1_MINUTE", max: 900, change: 0 }; return [ m.deviceAddCustomCluster("shellyLightLevel", { name: "shellyLightLevel", ID: 0xfc21, manufacturerCode: zigbee_herdsman_1.Zcl.ManufacturerCode.SHELLY, attributes: { lightLevel: { name: "lightLevel", ID: 0x0000, type: zigbee_herdsman_1.Zcl.DataType.UINT8 }, darkThreshold: { name: "darkThreshold", ID: 0x0001, type: zigbee_herdsman_1.Zcl.DataType.UINT24, write: true }, brightThreshold: { name: "brightThreshold", ID: 0x0002, type: zigbee_herdsman_1.Zcl.DataType.UINT24, write: true }, }, commands: {}, commandsResponse: {}, }), m.enumLookup({ name: "light_level", cluster: "shellyLightLevel", attribute: "lightLevel", lookup: { dark: 0, twilight: 1, bright: 2 }, description: "Coarse light level", reporting, access: "STATE_GET", }), m.numeric({ name: "dark_threshold", cluster: "shellyLightLevel", attribute: "darkThreshold", valueMin: 0, valueMax: 65535, reporting: false, description: "Lux threshold below which light level is dark", unit: "lx", access: "ALL", }), m.numeric({ name: "bright_threshold", cluster: "shellyLightLevel", attribute: "brightThreshold", valueMin: 0, valueMax: 65535, reporting: false, description: "Lux threshold above which light level is bright", unit: "lx", access: "ALL", }), ]; }, }; // ============================================================================= // Local From Zigbee Converters // ============================================================================= const handlePosition = e .enum("handle_position", ea.STATE, ["closed", "tilted", "open"]) .withDescription("Handle position: closed, tilted (partly open), or open"); 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)