UNPKG

homebridge-tuya-laundry

Version:

Allows washer/dryer cycle completion notifications using Tuya smart plugs with power meter, now using local control.

372 lines 20.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.LaundryDeviceTracker = void 0; const luxon_1 = require("luxon"); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const errors_1 = require("./errors"); class LaundryDeviceTracker { constructor(log, messageGateway, config, api, smartPlugService) { this.log = log; this.messageGateway = messageGateway; this.config = config; this.api = api; this.smartPlugService = smartPlugService; this.cumulativeConsumption = 0; // watt-seconds (Ws) for the current run this.cumulativeSinceStartDetected = 0; // Ws accumulated since startDetectedTime when inside after-run window (used to delay start confirmation) this.lastMeasurementTime = luxon_1.DateTime.now(); this.lastDpsStatus = null; this.currentInterval = 5000; // polling interval in ms (1s when active, 5s when idle) this.powerLog = []; this.minPower = Number.POSITIVE_INFINITY; this.maxPower = 0; this.totalPower = 0; this.sampleCount = 0; this.dryRunRuns = []; const deviceName = this.config.name || this.config.deviceId; this.log.debug(`Initializing LaundryDeviceTracker with config: ${JSON.stringify(this.config, null, 2)}`); } async init(localDevices) { const deviceName = this.config.name || this.config.deviceId; if (this.config.startValue < this.config.endValue) { throw new Error('startValue cannot be smaller than endValue.'); } if (!this.config.localKey) { this.log.error(`Missing localKey for device ${deviceName}. Please provide a valid localKey.`); return; } try { const devices = localDevices !== null && localDevices !== void 0 ? localDevices : await this.smartPlugService.discoverLocalDevices(); const selectedDevice = devices.find(device => device.deviceId === this.config.deviceId); if (!selectedDevice) { this.log.warn(`Device ${deviceName} not found on LAN.`); return; } selectedDevice.localKey = this.config.localKey; this.log.info(`Device ${deviceName} found on LAN. Starting power tracking.`); this.detectStartStop(selectedDevice); } catch (error) { this.log.error(`Error initializing device ${deviceName}: ${errors_1.errorMessage(error)}`); } } async detectStartStop(selectedDevice) { setInterval(async () => { try { const deviceName = this.config.name || this.config.deviceId; const powerData = await this.getPowerValue(selectedDevice); if (typeof powerData.watt !== 'number') { this.log.error(`Received invalid power value: ${powerData.watt} (expected a number).`); return; } this.log.debug(`Current power for ${deviceName}: ${powerData.watt}W`); this.incomingData(powerData.watt); // Run the helper method to check start and stop conditions await this.checkStartStopConditions(deviceName, powerData); } catch (error) { this.log.error(`Error during start/stop detection: ${errors_1.errorMessage(error)}`); } }, this.currentInterval); } // Helper method to get power value with caching async getPowerValue(selectedDevice) { var _a; const dpsStatus = await this.smartPlugService.getLocalDPS(selectedDevice, this.log); if (JSON.stringify(dpsStatus) === JSON.stringify(this.lastDpsStatus)) { this.log.debug(`No change in device status for ${selectedDevice.deviceId}, skipping further checks.`); const cached = (_a = this.lastDpsStatus) === null || _a === void 0 ? void 0 : _a.dps[this.config.powerValueId]; return { watt: cached !== undefined ? cached : null, rawDps: this.lastDpsStatus }; } this.lastDpsStatus = dpsStatus; // Check powerValue is defined so that 0 is treated as a valid reading const powerValue = dpsStatus === null || dpsStatus === void 0 ? void 0 : dpsStatus.dps[this.config.powerValueId]; const voltage = dpsStatus === null || dpsStatus === void 0 ? void 0 : dpsStatus.dps['20']; const current = dpsStatus === null || dpsStatus === void 0 ? void 0 : dpsStatus.dps['18']; return { watt: powerValue !== undefined ? powerValue : null, voltage, current, rawDps: dpsStatus, }; } // Method to dynamically adjust the interval based on activity adjustInterval(isActive) { this.currentInterval = isActive ? 1000 : 5000; // 1 second when active, 5 seconds when idle this.log.debug(`Adjusted polling interval to ${this.currentInterval}ms based on activity.`); } /** True if any min-run criteria are configured (used to distinguish full cycles from short after-run cycles). */ hasMinRunCriteriaConfigured() { const c = this.config; return ((c.minRunDurationSec !== undefined && c.minRunDurationSec !== null) || (c.minRunKWh !== undefined && c.minRunKWh !== null) || (c.minRunAvgPowerW !== undefined && c.minRunAvgPowerW !== null)); } /** After-run window in minutes (supports deprecated nachlaufWindowMin for backward compatibility). */ getAfterRunWindowMin() { var _a; const c = this.config; return (_a = c.afterRunWindowMin) !== null && _a !== void 0 ? _a : c.nachlaufWindowMin; } /** True if we are within the after-run window (minutes after last full cycle end). Inside this window, start is only confirmed once min-run criteria are met. */ isInsideAfterRunWindow() { const windowMin = this.getAfterRunWindowMin(); if (windowMin === undefined || windowMin === null || !this.lastFullCycleEndTime) return false; const minutesSince = luxon_1.DateTime.now().diff(this.lastFullCycleEndTime, 'minutes').minutes; return minutesSince < windowMin; } /** True if the run (duration, energy, avg power) meets all configured min-run criteria (i.e. counts as a full cycle). */ meetsMinRunCriteria(durationSec, totalKWh, avgPower) { const c = this.config; if (c.minRunDurationSec !== undefined && c.minRunDurationSec !== null && durationSec < c.minRunDurationSec) return false; if (c.minRunKWh !== undefined && c.minRunKWh !== null && totalKWh < c.minRunKWh) return false; if (c.minRunAvgPowerW !== undefined && c.minRunAvgPowerW !== null && avgPower < c.minRunAvgPowerW) return false; return true; } // Helper method to check start and stop conditions async checkStartStopConditions(deviceName, powerData) { var _a; const powerValue = powerData.watt; // Check whether the machine started if (!this.isActive && this.startDetected && this.startDetectedTime) { const secondsDiff = luxon_1.DateTime.now().diff(this.startDetectedTime, 'seconds').seconds; const kWhSinceStart = this.cumulativeSinceStartDetected / 3600000; this.log.debug(`Checking start confirmation: ${secondsDiff} seconds since start detected, ${kWhSinceStart.toFixed(4)} kWh since start.`); let shouldConfirmStart = false; if (this.getAfterRunWindowMin() != null && this.isInsideAfterRunWindow()) { // Inside after-run window: confirm start only when min-run criteria are already met (avoids false start on after-run power spikes) const minDur = this.config.minRunDurationSec; const minKWh = this.config.minRunKWh; if (minDur != null || minKWh != null) { shouldConfirmStart = (minDur == null || secondsDiff >= minDur) || (minKWh == null || kWhSinceStart >= minKWh); } else { shouldConfirmStart = secondsDiff > this.config.startDuration; } } else { shouldConfirmStart = secondsDiff > this.config.startDuration; } if (shouldConfirmStart) { this.log.info(`${deviceName} has started!`); if (!this.config.dryRun && this.config.startMessage) { await this.messageGateway.send(this.config.startMessage); } if (this.config.dryRun) { this.log.info(`[Dry Run] Run started – no notification sent. Stats will be logged when run ends.`); } this.isActive = true; this.updateAccessorySwitchState(true); this.cumulativeConsumption = 0; // Reset cumulative consumption for the new cycle this.cumulativeSinceStartDetected = 0; this.startDetected = false; // Reset start detection this.startDetectedTime = undefined; this.adjustInterval(true); // Switch to a more frequent interval this.startTime = luxon_1.DateTime.now(); this.powerLog = []; this.lastMeasurementTime = luxon_1.DateTime.now(); this.minPower = Number.POSITIVE_INFINITY; this.maxPower = 0; this.totalPower = 0; this.sampleCount = 0; } } const now = luxon_1.DateTime.now(); const timeDiff = now.diff(this.lastMeasurementTime, 'seconds').seconds; this.lastMeasurementTime = now; const powerW = powerValue / 10; const energyConsumed = powerW * timeDiff; // in watt-seconds (W-s) if (this.isActive) { this.cumulativeConsumption += energyConsumed; this.minPower = Math.min(this.minPower, powerW); this.maxPower = Math.max(this.maxPower, powerW); this.totalPower += powerW; this.sampleCount++; } else if (this.startDetected && this.isInsideAfterRunWindow()) { this.cumulativeSinceStartDetected += energyConsumed; } const totalKWh = this.cumulativeConsumption / 3600000; if (this.config.exportPowerLog && (this.isActive || this.startDetected || this.endDetected)) { this.powerLog.push({ timestamp: now.toISO(), watt: powerW, deltaWs: energyConsumed, totalKWh, isActive: !!this.isActive, interval: this.currentInterval, rawDps: powerData.rawDps, voltage: powerData.voltage, current: powerData.current, }); } if (this.isActive) { this.log.debug(`Added ${energyConsumed} W·s. Total cumulativeConsumption: ${this.cumulativeConsumption} W·s, ${totalKWh.toFixed(4)} kWh`); } // Check whether the machine has stopped if (this.endDetected && this.endDetectedTime) { const secondsDiff = luxon_1.DateTime.now().diff(this.endDetectedTime, 'seconds').seconds; if (secondsDiff > this.config.endDuration && this.isActive) { const kWhConsumed = this.cumulativeConsumption / 3600000; // Convert watt-seconds to kWh this.endTime = luxon_1.DateTime.now(); const durationSec = this.startTime ? this.endTime.diff(this.startTime, 'seconds').seconds : 0; const avgPower = this.sampleCount > 0 ? this.totalPower / this.sampleCount : 0; const maxPower = this.maxPower; if (this.config.dryRun) { this.logRunAndSuggestThresholds(durationSec, kWhConsumed, avgPower, maxPower); } else { const isFullCycle = !this.hasMinRunCriteriaConfigured() || this.meetsMinRunCriteria(durationSec, kWhConsumed, avgPower); if (!isFullCycle) { this.log.info(`Run ignored (short cycle): duration ${durationSec.toFixed(0)}s, ${kWhConsumed.toFixed(4)} kWh, avg ${avgPower.toFixed(1)} W`); } else { this.log.info(`Device finished the job. Total consumption: ${kWhConsumed.toFixed(2)} kWh`); const endMessage = `${this.config.endMessage || ''} Total consumption: ${kWhConsumed.toFixed(2)} kWh.`; this.messageGateway.send(endMessage); if (this.config.exportPowerLog) { await this.exportPowerLog({ startTime: (_a = this.startTime) === null || _a === void 0 ? void 0 : _a.toISO(), endTime: this.endTime.toISO(), durationSec, minPower: this.minPower === Number.POSITIVE_INFINITY ? 0 : this.minPower, maxPower: this.maxPower, avgPower, totalKWh: kWhConsumed, }); } this.lastFullCycleEndTime = this.endTime; } } this.isActive = false; this.updateAccessorySwitchState(false); this.cumulativeConsumption = 0; // Reset for the next cycle this.endDetected = false; // Reset end detection this.endDetectedTime = undefined; this.adjustInterval(false); // Switch to a less frequent interval this.powerLog = []; } } } /** Dry run: record run stats and write suggested thresholds to logs/dry-run-<deviceId>.json */ logRunAndSuggestThresholds(durationSec, totalKWh, avgPower, maxPower) { var _a, _b, _c, _d; const deviceName = this.config.name || this.config.deviceId; const run = { startTime: (_b = (_a = this.startTime) === null || _a === void 0 ? void 0 : _a.toISO()) !== null && _b !== void 0 ? _b : '', endTime: (_d = (_c = this.endTime) === null || _c === void 0 ? void 0 : _c.toISO()) !== null && _d !== void 0 ? _d : '', durationSec, totalKWh, avgPower, maxPower, }; this.dryRunRuns.push(run); this.log.info(`[Dry Run] Run finished: duration ${durationSec.toFixed(0)}s, ${totalKWh.toFixed(4)} kWh, avg ${avgPower.toFixed(1)} W, max ${maxPower.toFixed(1)} W`); const suggested = this.computeSuggestedThresholds(); const dir = path_1.default.resolve('logs'); const filePath = path_1.default.join(dir, `dry-run-${this.config.deviceId}.json`); const data = JSON.stringify({ deviceId: this.config.deviceId, deviceName, runs: this.dryRunRuns, suggested, hint: 'Copy suggested values into your device config (minRunDurationSec, minRunKWh, minRunAvgPowerW, afterRunWindowMin). Then set dryRun to false.', }, null, 2); fs_1.default.promises .mkdir(dir, { recursive: true }) .then(() => fs_1.default.promises.writeFile(filePath, data)) .then(() => { this.log.info(`[Dry Run] Suggested thresholds written to ${filePath}`); }) .catch(err => { this.log.error(`[Dry Run] Failed to write ${filePath}: ${errors_1.errorMessage(err)}`); }); } /** From recorded runs, suggest min-run thresholds that separate full cycles from short after-run cycles. */ computeSuggestedThresholds() { const FULL_CYCLE_MIN_DURATION_SEC = 600; const FULL_CYCLE_MIN_KWH = 0.05; const fullCycleRuns = this.dryRunRuns.filter(r => r.durationSec >= FULL_CYCLE_MIN_DURATION_SEC && r.totalKWh >= FULL_CYCLE_MIN_KWH); const shortCycleRuns = this.dryRunRuns.filter(r => r.durationSec < FULL_CYCLE_MIN_DURATION_SEC || r.totalKWh < FULL_CYCLE_MIN_KWH); let minRunDurationSec = 600; let minRunKWh = 0.05; let minRunAvgPowerW = 100; if (fullCycleRuns.length > 0) { const minFullDuration = Math.min(...fullCycleRuns.map(r => r.durationSec)); const minFullKWh = Math.min(...fullCycleRuns.map(r => r.totalKWh)); const minFullAvgPower = Math.min(...fullCycleRuns.map(r => r.avgPower)); const maxShortDuration = shortCycleRuns.length > 0 ? Math.max(...shortCycleRuns.map(r => r.durationSec)) : 0; const maxShortKWh = shortCycleRuns.length > 0 ? Math.max(...shortCycleRuns.map(r => r.totalKWh)) : 0; const maxShortAvgPower = shortCycleRuns.length > 0 ? Math.max(...shortCycleRuns.map(r => r.avgPower)) : 0; minRunDurationSec = Math.max(120, Math.round(Math.max(maxShortDuration + 60, minFullDuration * 0.25))); minRunKWh = Math.max(0.01, Math.round(Math.max(maxShortKWh * 2, minFullKWh * 0.15) * 100) / 100); minRunAvgPowerW = Math.max(50, Math.round(Math.max(maxShortAvgPower * 2, minFullAvgPower * 0.2))); } return { minRunDurationSec, minRunKWh, minRunAvgPowerW, afterRunWindowMin: 30, }; } incomingData(value) { const deviceName = this.config.name || this.config.deviceId; this.log.debug(`Processing incoming power data for ${deviceName}: ${value}`); if (value >= this.config.startValue) { if (!this.isActive && !this.startDetected) { this.startDetected = true; this.startDetectedTime = luxon_1.DateTime.now(); this.log.debug(`Detected start value for ${deviceName}. Waiting ${this.config.startDuration} seconds for confirmation.`); } } else { this.startDetected = false; this.startDetectedTime = undefined; this.cumulativeSinceStartDetected = 0; } if (value <= this.config.endValue) { if (this.isActive && !this.endDetected) { this.endDetected = true; this.endDetectedTime = luxon_1.DateTime.now(); this.log.debug(`Detected end value for ${deviceName}. Waiting ${this.config.endDuration} seconds for confirmation.`); } } else { this.endDetected = false; this.endDetectedTime = undefined; } } updateAccessorySwitchState(isOn) { if (this.config.exposeStateSwitch && this.accessory) { const service = this.accessory.getService(this.api.hap.Service.Switch); service === null || service === void 0 ? void 0 : service.setCharacteristic(this.api.hap.Characteristic.On, isOn); this.log.debug(`Updated accessory switch state for ${this.config.name}: ${isOn ? 'On' : 'Off'}`); } } async exportPowerLog(stats) { try { const deviceId = this.config.deviceId; const timestamp = (stats.endTime || luxon_1.DateTime.now().toISO()).replace(/:/g, '-'); const dir = path_1.default.resolve('logs'); await fs_1.default.promises.mkdir(dir, { recursive: true }); const filePath = path_1.default.join(dir, `${deviceId}-${timestamp}.json`); const data = JSON.stringify({ ...stats, powerLog: this.powerLog }, null, 2); await fs_1.default.promises.writeFile(filePath, data); this.log.info(`Exported power log to ${filePath}`); this.powerLog = []; } catch (error) { this.log.error(`Failed to export power log: ${errors_1.errorMessage(error)}`); } } } exports.LaundryDeviceTracker = LaundryDeviceTracker; //# sourceMappingURL=laundryDeviceTracker.js.map