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
JavaScript
"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