UNPKG

matterbridge-shelly

Version:
352 lines (351 loc) 25.5 kB
import { db, debugStringify, dn, er, hk, idn, or, rs, YELLOW, zb } from 'matterbridge/logger'; import { BooleanState, ColorControl, ElectricalEnergyMeasurement, ElectricalPowerMeasurement, IlluminanceMeasurement, LevelControl, OccupancySensing, OnOff, PowerSource, RelativeHumidityMeasurement, SmokeCoAlarm, TemperatureMeasurement, Thermostat, WindowCovering, } from 'matterbridge/matter/clusters'; import { isValidArray, isValidBoolean, isValidNumber, isValidObject, isValidString, rgbColorToHslColor } from 'matterbridge/utils'; import { isLightComponent, isSwitchComponent } from './shellyComponent.js'; export function shellyUpdateHandler(platform, matterbridgeDevice, shellyDevice, component, property, value, endpointName) { let endpoint; if (endpointName === 'PowerSource') endpoint = matterbridgeDevice; if (!endpoint) endpoint = matterbridgeDevice.getChildEndpointByName(endpointName ?? component); if (!endpoint) endpoint = matterbridgeDevice.getChildEndpointByName(component.replace(':', '')); if (!endpoint) { shellyDevice.log.debug(`shellyUpdateHandler error: endpoint ${component} not found for shelly device ${dn}${shellyDevice?.id}${db}`); return; } const shellyComponent = shellyDevice.getComponent(component); if (!shellyComponent) { shellyDevice.log.debug(`shellyUpdateHandler error: component ${component} not found for shelly device ${dn}${shellyDevice?.id}${db}`); return; } shellyDevice.log.info(`${db}Shelly message for device ${idn}${shellyDevice.id}${rs}${db} ` + `${hk}${shellyComponent.name}${db}:${hk}${component}${db}:${zb}${property}${db}:${YELLOW}${value !== null && typeof value === 'object' ? debugStringify(value) : value}${rs}`); if ((isLightComponent(shellyComponent) || isSwitchComponent(shellyComponent)) && property === 'state' && isValidBoolean(value)) { endpoint.setAttribute(OnOff.Cluster.id, 'onOff', value, shellyDevice.log); } if (isLightComponent(shellyComponent) && (property === 'gain' || property === 'brightness') && isValidNumber(value, 0, 100)) { endpoint.setAttribute(LevelControl.Cluster.id, 'currentLevel', Math.max(Math.min(Math.round((value / 100) * 254), 254), 1), shellyDevice.log); } if (isLightComponent(shellyComponent) && ['red', 'green', 'blue'].includes(property) && isValidNumber(value, 0, 255)) { const red = property === 'red' ? value : shellyComponent.getValue('red'); const green = property === 'green' ? value : shellyComponent.getValue('green'); const blue = property === 'blue' ? value : shellyComponent.getValue('blue'); const hsl = rgbColorToHslColor({ r: red, g: green, b: blue }); matterbridgeDevice.log.debug(`ColorRgbToHsl: R:${red} G:${green} B:${blue} => H:${hsl.h} S:${hsl.s} L:${hsl.l}`); if (shellyDevice.colorUpdateTimeout) clearTimeout(shellyDevice.colorUpdateTimeout); shellyDevice.colorUpdateTimeout = setTimeout(() => { const hue = Math.max(Math.min(Math.round((hsl.h / 360) * 254), 254), 0); const saturation = Math.max(Math.min(Math.round((hsl.s / 100) * 254), 254), 0); if (isValidNumber(hue, 0, 254)) endpoint?.setAttribute(ColorControl.Cluster.id, 'currentHue', hue, shellyDevice.log); if (isValidNumber(saturation, 0, 254)) endpoint?.setAttribute(ColorControl.Cluster.id, 'currentSaturation', saturation, shellyDevice.log); endpoint?.setAttribute(ColorControl.Cluster.id, 'colorMode', ColorControl.ColorMode.CurrentHueAndCurrentSaturation, shellyDevice.log); }, shellyDevice.colorUpdateTimeoutMs).unref(); } if (isLightComponent(shellyComponent) && property === 'temp' && isValidNumber(value, 2700, 6500)) { const minValue = shellyDevice.model === 'SHBDUO-1' ? 2700 : 3000; const maxValue = 6500; const minMatterTemp = 147; const maxMatterTemp = 500; const matterTemp = Math.max(Math.min(Math.round(((value - minValue) / (maxValue - minValue)) * (minMatterTemp - maxMatterTemp) + maxMatterTemp), maxMatterTemp), minMatterTemp); matterbridgeDevice.log.debug(`ColorTemp for ${shellyDevice.model}: colorTemperature:${value} => colorTemperatureMireds:${matterTemp}`); endpoint.setAttribute(ColorControl.Cluster.id, 'colorMode', ColorControl.ColorMode.ColorTemperatureMireds, shellyDevice.log); endpoint.setAttribute(ColorControl.Cluster.id, 'enhancedColorMode', ColorControl.EnhancedColorMode.ColorTemperatureMireds, shellyDevice.log); endpoint.setAttribute(ColorControl.Cluster.id, 'colorTemperatureMireds', matterTemp, shellyDevice.log); } if (isLightComponent(shellyComponent) && property === 'ct' && isValidNumber(value, 2700, 6500)) { let minValue = 2700; let maxValue = 6500; const range = shellyComponent.getProperty('ct_range')?.value; if (isValidArray(range, 2, 2) && isValidNumber(range[0], 2700, 6500) && isValidNumber(range[1], 2700, 6500)) { minValue = range[0]; maxValue = range[1]; } const minMatterTemp = 147; const maxMatterTemp = 500; const matterTemp = Math.max(Math.min(Math.round(((value - minValue) / (maxValue - minValue)) * (minMatterTemp - maxMatterTemp) + maxMatterTemp), maxMatterTemp), minMatterTemp); matterbridgeDevice.log.debug(`ColorTemp for ${shellyDevice.model}: colorTemperature:${value} => colorTemperatureMireds:${matterTemp}`); endpoint.setAttribute(ColorControl.Cluster.id, 'colorMode', ColorControl.ColorMode.ColorTemperatureMireds, shellyDevice.log); endpoint.setAttribute(ColorControl.Cluster.id, 'enhancedColorMode', ColorControl.EnhancedColorMode.ColorTemperatureMireds, shellyDevice.log); endpoint.setAttribute(ColorControl.Cluster.id, 'colorTemperatureMireds', matterTemp, shellyDevice.log); } if (isLightComponent(shellyComponent) && property === 'rgb' && isValidArray(value, 3, 3) && isValidNumber(value[0], 0, 255) && isValidNumber(value[1], 0, 255) && isValidNumber(value[2], 0, 255)) { const hsl = rgbColorToHslColor({ r: value[0], g: value[1], b: value[2] }); matterbridgeDevice.log.debug(`ColorRgbToHsl: R:${value[0]} G:${value[1]} B:${value[2]} => H:${hsl.h} S:${hsl.s} L:${hsl.l}`); const hue = Math.max(Math.min(Math.round((hsl.h / 360) * 254), 254), 0); const saturation = Math.max(Math.min(Math.round((hsl.s / 100) * 254), 254), 0); if (isValidNumber(hue, 0, 254)) endpoint.setAttribute(ColorControl.Cluster.id, 'currentHue', hue, shellyDevice.log); if (isValidNumber(hue, 0, 254)) endpoint.setAttribute(ColorControl.Cluster.id, 'currentSaturation', saturation, shellyDevice.log); endpoint.setAttribute(ColorControl.Cluster.id, 'colorMode', ColorControl.ColorMode.CurrentHueAndCurrentSaturation, shellyDevice.log); } if (shellyComponent.name === 'Input' && property === 'state' && isValidBoolean(value)) { if (platform.config.inputContactList && platform.config.inputContactList.includes(shellyDevice.id)) { endpoint.setAttribute(BooleanState.Cluster.id, 'stateValue', value, shellyDevice.log); } if (platform.config.inputMomentaryList && platform.config.inputMomentaryList.includes(shellyDevice.id) && value === true) { endpoint.triggerSwitchEvent('Single', shellyDevice.log); } if (platform.config.inputLatchingList && platform.config.inputLatchingList.includes(shellyDevice.id)) { endpoint.triggerSwitchEvent(value ? 'Press' : 'Release', shellyDevice.log); } } if (shellyComponent.name === 'Input' && property === 'event_cnt' && isValidNumber(value) && shellyComponent.hasProperty('event')) { if (platform.config.inputMomentaryList && platform.config.inputMomentaryList.includes(shellyDevice.id)) { const event = shellyComponent.getValue('event'); if (!isValidString(event, 1)) return; if (event === 'S') { endpoint.triggerSwitchEvent('Single', shellyDevice.log); } if (event === 'SS') { endpoint.triggerSwitchEvent('Double', shellyDevice.log); } if (event === 'L') { endpoint.triggerSwitchEvent('Long', shellyDevice.log); } } } if (shellyComponent.name === 'Battery' && property === 'level' && isValidNumber(value, 0, 100)) { endpoint.setAttribute(PowerSource.Cluster.id, 'batPercentRemaining', Math.min(Math.max(value * 2, 0), 200), shellyDevice.log); if (value < 10) endpoint.setAttribute(PowerSource.Cluster.id, 'batChargeLevel', PowerSource.BatChargeLevel.Critical, shellyDevice.log); else if (value < 20) endpoint.setAttribute(PowerSource.Cluster.id, 'batChargeLevel', PowerSource.BatChargeLevel.Warning, shellyDevice.log); else endpoint.setAttribute(PowerSource.Cluster.id, 'batChargeLevel', PowerSource.BatChargeLevel.Ok, shellyDevice.log); } if (shellyComponent.name === 'Battery' && property === 'voltage' && isValidNumber(value, 0)) { endpoint.setAttribute(PowerSource.Cluster.id, 'batVoltage', value * 1000, shellyDevice.log); } if (shellyComponent.name === 'Battery' && property === 'charging' && isValidNumber(value)) { endpoint.setAttribute(PowerSource.Cluster.id, 'batChargeState', value ? PowerSource.BatChargeState.IsCharging : PowerSource.BatChargeState.IsNotCharging, matterbridgeDevice.log); } if (shellyComponent.name === 'Devicepower' && property === 'battery' && isValidObject(value, 2)) { const battery = value; if (isValidNumber(battery.V, 0, 12) && isValidNumber(battery.percent, 0, 100)) { endpoint.setAttribute(PowerSource.Cluster.id, 'batPercentRemaining', battery.percent * 2, shellyDevice.log); if (battery.percent < 10) endpoint.setAttribute(PowerSource.Cluster.id, 'batChargeLevel', PowerSource.BatChargeLevel.Critical, shellyDevice.log); else if (battery.percent < 20) endpoint.setAttribute(PowerSource.Cluster.id, 'batChargeLevel', PowerSource.BatChargeLevel.Warning, shellyDevice.log); else endpoint.setAttribute(PowerSource.Cluster.id, 'batChargeLevel', PowerSource.BatChargeLevel.Ok, shellyDevice.log); endpoint.setAttribute(PowerSource.Cluster.id, 'batVoltage', battery.V * 1000, shellyDevice.log); } } if (shellyComponent.name === 'Sensor' && property === 'motion' && isValidBoolean(value)) { endpoint.setAttribute(OccupancySensing.Cluster.id, 'occupancy', { occupied: value }, shellyDevice.log); } if (shellyComponent.name === 'Sensor' && property === 'contact_open' && isValidBoolean(value)) { endpoint.setAttribute(BooleanState.Cluster.id, 'stateValue', !value, shellyDevice.log); } if (shellyComponent.name === 'Flood' && property === 'flood' && isValidBoolean(value)) { endpoint.setAttribute(BooleanState.Cluster.id, 'stateValue', value, shellyDevice.log); } if (shellyComponent.name === 'Flood' && property === 'alarm' && isValidBoolean(value)) { endpoint.setAttribute(BooleanState.Cluster.id, 'stateValue', value, shellyDevice.log); } if (shellyComponent.name === 'Gas' && property === 'alarm_state' && isValidString(value)) { endpoint.setAttribute(BooleanState.Cluster.id, 'stateValue', value === 'none', shellyDevice.log); } if (shellyComponent.name === 'Smoke' && property === 'alarm' && isValidBoolean(value)) { endpoint.setAttribute(SmokeCoAlarm.Cluster.id, 'smokeState', value ? SmokeCoAlarm.AlarmState.Critical : SmokeCoAlarm.AlarmState.Normal, shellyDevice.log); } if (shellyComponent.name === 'Lux' && property === 'value' && isValidNumber(value, 0)) { const matterLux = Math.round(Math.max(Math.min(10000 * Math.log10(value), 0xfffe), 0)); endpoint.setAttribute(IlluminanceMeasurement.Cluster.id, 'measuredValue', matterLux, shellyDevice.log); } if (shellyComponent.name === 'Temperature' && (property === 'value' || property === 'tC') && isValidNumber(value, -100, +100)) { endpoint.setAttribute(TemperatureMeasurement.Cluster.id, 'measuredValue', Math.round(value * 100), shellyDevice.log); } if (shellyComponent.name === 'Humidity' && (property === 'value' || property === 'rh') && isValidNumber(value, 0, 100)) { endpoint.setAttribute(RelativeHumidityMeasurement.Cluster.id, 'measuredValue', Math.round(value * 100), shellyDevice.log); } if (shellyComponent.name === 'Illuminance' && property === 'lux' && isValidNumber(value, 0)) { const matterLux = Math.round(Math.max(Math.min(10000 * Math.log10(value), 0xfffe), 0)); endpoint.setAttribute(IlluminanceMeasurement.Cluster.id, 'measuredValue', matterLux, shellyDevice.log); } if (shellyComponent.name === 'Vibration' && property === 'vibration' && isValidBoolean(value)) { if (value) endpoint.triggerSwitchEvent('Single', shellyDevice.log); } if (shellyDevice.gen === 1) { if (shellyComponent.name === 'Thermostat' && property === 'target_t' && isValidObject(value)) { const target = value; if (target.enabled === false) endpoint.setAttribute(Thermostat.Cluster.id, 'systemMode', Thermostat.SystemMode.Off, shellyDevice.log); else if (target.enabled === true) endpoint.setAttribute(Thermostat.Cluster.id, 'systemMode', Thermostat.SystemMode.Heat, shellyDevice.log); if (isValidNumber(target.value, 4, 31)) endpoint.setAttribute(Thermostat.Cluster.id, 'occupiedHeatingSetpoint', target.value * 100, shellyDevice.log); } if (shellyComponent.name === 'Thermostat' && property === 'tmp' && isValidObject(value)) { const current = value; if (isValidNumber(current.value, -55, +125) && current.is_valid === true) endpoint.setAttribute(Thermostat.Cluster.id, 'localTemperature', current.value * 100, shellyDevice.log); } } else if (shellyDevice.gen > 1) { if (shellyComponent.name === 'Thermostat' && property === 'enable' && isValidBoolean(value)) { if (value === false) endpoint.setAttribute(Thermostat.Cluster.id, 'systemMode', Thermostat.SystemMode.Off, shellyDevice.log); else if (value === true && shellyComponent.hasProperty('type') && shellyComponent.getValue('type') === 'heating') endpoint.setAttribute(Thermostat.Cluster.id, 'systemMode', Thermostat.SystemMode.Heat, shellyDevice.log); else if (value === true && shellyComponent.hasProperty('type') && shellyComponent.getValue('type') === 'cooling') endpoint.setAttribute(Thermostat.Cluster.id, 'systemMode', Thermostat.SystemMode.Cool, shellyDevice.log); } if (shellyComponent.name === 'Thermostat' && property === 'current_C' && isValidNumber(value, -100, +100)) { endpoint.setAttribute(Thermostat.Cluster.id, 'localTemperature', value * 100, shellyDevice.log); } if (shellyComponent.name === 'Thermostat' && property === 'target_C' && isValidNumber(value, -100, +100)) { if (shellyComponent.hasProperty('type') && shellyComponent.getValue('type') === 'heating') endpoint.setAttribute(Thermostat.Cluster.id, 'occupiedHeatingSetpoint', value * 100, shellyDevice.log); if (shellyComponent.hasProperty('type') && shellyComponent.getValue('type') === 'cooling') endpoint.setAttribute(Thermostat.Cluster.id, 'occupiedCoolingSetpoint', value * 100, shellyDevice.log); } } if (shellyComponent.name === 'Cover' || shellyComponent.name === 'Roller') { if (property === 'state' && isValidString(value, 4)) { if ((shellyDevice.gen === 1 && value === 'stop') || (shellyDevice.gen > 1 && value === 'stopped')) { const status = WindowCovering.MovementStatus.Stopped; endpoint.setAttribute(WindowCovering.Cluster.id, 'operationalStatus', { global: status, lift: status, tilt: status }, shellyDevice.log); shellyDevice.coverUpdateTimeout = setTimeout(() => { shellyDevice.log.debug(`Setting target position to current position on endpoint ${or}${endpoint?.name}:${endpoint?.number}${db}`); const current = endpoint?.getAttribute(WindowCovering.Cluster.id, 'currentPositionLiftPercent100ths', shellyDevice.log); if (!isValidNumber(current, 0, 10000)) { matterbridgeDevice.log.error(`Error: current position not found on endpoint ${or}${endpoint?.name}:${endpoint?.number}${er}`); return; } endpoint?.setAttribute(WindowCovering.Cluster.id, 'targetPositionLiftPercent100ths', current, shellyDevice.log); }, shellyDevice.coverUpdateTimeoutMs).unref(); } if (shellyDevice.gen > 1 && value === 'open') { endpoint.setAttribute(WindowCovering.Cluster.id, 'targetPositionLiftPercent100ths', 0, shellyDevice.log); endpoint.setAttribute(WindowCovering.Cluster.id, 'currentPositionLiftPercent100ths', 0, shellyDevice.log); const status = WindowCovering.MovementStatus.Stopped; endpoint.setAttribute(WindowCovering.Cluster.id, 'operationalStatus', { global: status, lift: status, tilt: status }, shellyDevice.log); } if (shellyDevice.gen > 1 && value === 'closed') { endpoint.setAttribute(WindowCovering.Cluster.id, 'targetPositionLiftPercent100ths', 10000, shellyDevice.log); endpoint.setAttribute(WindowCovering.Cluster.id, 'currentPositionLiftPercent100ths', 10000, shellyDevice.log); const status = WindowCovering.MovementStatus.Stopped; endpoint.setAttribute(WindowCovering.Cluster.id, 'operationalStatus', { global: status, lift: status, tilt: status }, shellyDevice.log); } if ((shellyDevice.gen === 1 && value === 'open') || (shellyDevice.gen > 1 && value === 'opening')) { const status = WindowCovering.MovementStatus.Opening; endpoint.setAttribute(WindowCovering.Cluster.id, 'operationalStatus', { global: status, lift: status, tilt: status }, shellyDevice.log); } if ((shellyDevice.gen === 1 && value === 'close') || (shellyDevice.gen > 1 && value === 'closing')) { const status = WindowCovering.MovementStatus.Closing; endpoint.setAttribute(WindowCovering.Cluster.id, 'operationalStatus', { global: status, lift: status, tilt: status }, shellyDevice.log); } } else if (property === 'current_pos' && isValidNumber(value, 0, 100)) { const matterPos = 10000 - Math.min(Math.max(Math.round(value * 100), 0), 10000); endpoint.setAttribute(WindowCovering.Cluster.id, 'currentPositionLiftPercent100ths', matterPos, shellyDevice.log); } else if (property === 'target_pos' && isValidNumber(value, 0, 100)) { const matterPos = 10000 - Math.min(Math.max(Math.round(value * 100), 0), 10000); endpoint.setAttribute(WindowCovering.Cluster.id, 'targetPositionLiftPercent100ths', matterPos, shellyDevice.log); } } if (['Light', 'Rgb', 'Relay', 'Switch', 'Cover', 'Roller', 'PowerMeter'].includes(shellyComponent.name)) { if (!platform.validateEntity(shellyDevice.id, 'PowerMeter')) return; if (shellyDevice.profile === 'triphase' && shellyComponent.id === 'em:0') { if (property === 'total_current') property = 'current'; if (property === 'total_act_power') property = 'act_power'; if (property === 'total_act') property = 'total_act_energy'; if (property === 'total_act_ret') property = 'total_act_ret_energy'; if (property.startsWith('a_') || property.startsWith('b_') || property.startsWith('c_')) { if (property.startsWith('a_')) endpoint = matterbridgeDevice.getChildEndpointByName('em:1'); if (property.startsWith('b_')) endpoint = matterbridgeDevice.getChildEndpointByName('em:2'); if (property.startsWith('c_')) endpoint = matterbridgeDevice.getChildEndpointByName('em:3'); if (!endpoint) { shellyDevice.log.debug(`****shellyUpdateHandler error: endpoint not found for triphase shelly device ${dn}${shellyDevice?.id}${db}`); return; } const originalProperty = property; property = property.replace(/^a_/, '').replace(/^b_/, '').replace(/^c_/, ''); shellyDevice.log.debug(`***shellyUpdateHandler property ${originalProperty} remapped to ${endpoint?.id}:${property} for triphase shelly device ${dn}${shellyDevice?.id}${db}`); } } if ((property === 'power' || property === 'apower' || property === 'act_power') && isValidNumber(value, 0)) { if (property === 'power' && shellyComponent.id.startsWith('light') && shellyDevice.id.startsWith('shellyrgbw2')) return; const power = Math.round(value * 1000) / 1000; endpoint.setAttribute(ElectricalPowerMeasurement.Cluster.id, 'activePower', Math.round(power * 1000), shellyDevice.log); if (property === 'act_power') return; if (shellyComponent.id.startsWith('emeter')) return; if (shellyComponent.hasProperty('current')) return; if (property === 'power' && shellyDevice.hasComponent('sys') && shellyDevice.getComponent('sys')?.hasProperty('voltage')) { const voltage = shellyDevice.getComponent('sys')?.getValue('voltage'); if (isValidNumber(voltage, 10)) { endpoint.setAttribute(ElectricalPowerMeasurement.Cluster.id, 'voltage', Math.round(voltage * 1000), shellyDevice.log); const current = Math.round((value / voltage) * 1000) / 1000; endpoint.setAttribute(ElectricalPowerMeasurement.Cluster.id, 'activeCurrent', Math.round(current * 1000), shellyDevice.log); } } const voltage = shellyComponent.hasProperty('voltage') ? shellyComponent.getValue('voltage') : undefined; if (isValidNumber(voltage, 10)) { const current = Math.round((value / voltage) * 1000) / 1000; endpoint.setAttribute(ElectricalPowerMeasurement.Cluster.id, 'activeCurrent', Math.round(current * 1000), shellyDevice.log); } } if (property === 'total' && isValidNumber(value, 0)) { const energy = Math.round(value * 1000) / 1000; endpoint.setAttribute(ElectricalEnergyMeasurement.Cluster.id, 'cumulativeEnergyImported', { energy: Math.round(energy * 1000) }, shellyDevice.log); } if (property === 'aenergy' && isValidObject(value) && isValidNumber(value.total, 0)) { const energy = Math.round(value.total * 1000) / 1000; endpoint.setAttribute(ElectricalEnergyMeasurement.Cluster.id, 'cumulativeEnergyImported', { energy: Math.round(energy * 1000) }, shellyDevice.log); } if (property === 'total_act_energy' && isValidNumber(value, 0)) { const energy = Math.round(value * 1000) / 1000; endpoint.setAttribute(ElectricalEnergyMeasurement.Cluster.id, 'cumulativeEnergyImported', { energy: Math.round(energy * 1000) }, shellyDevice.log); } if (property === 'total_act_ret_energy' && isValidNumber(value, 0)) { const energy = Math.round(value * 1000) / 1000; endpoint.setAttribute(ElectricalEnergyMeasurement.Cluster.id, 'cumulativeEnergyExported', { energy: Math.round(energy * 1000) }, shellyDevice.log); } if (property === 'voltage' && isValidNumber(value, 0)) { endpoint.setAttribute(ElectricalPowerMeasurement.Cluster.id, 'voltage', Math.round(value * 1000), shellyDevice.log); } if (property === 'current' && isValidNumber(value, 0)) { endpoint.setAttribute(ElectricalPowerMeasurement.Cluster.id, 'activeCurrent', Math.round(value * 1000), shellyDevice.log); if (shellyComponent.hasProperty('act_power')) return; if (shellyComponent.hasProperty('apower')) return; if (shellyComponent.hasProperty('power')) return; if (shellyComponent.id.startsWith('emeter')) return; const voltage = shellyComponent.hasProperty('voltage') ? shellyComponent.getValue('voltage') : undefined; if (isValidNumber(voltage, 0)) { const power = Math.round(value * voltage * 1000) / 1000; endpoint.setAttribute(ElectricalPowerMeasurement.Cluster.id, 'activePower', Math.round(power * 1000), shellyDevice.log); } } } }