UNPKG

homebridge-nordpool-baltics

Version:

Plugin exposes virtual accessories (switch, light, presence, temperature) and enables HomeKit automation by Nordpool electricity price in Lithuania, Latvia, Estonia, Finland, Sweden, Norway, Denmark, Germany, Luxembourg, Austria.

464 lines 24.6 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 (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Functions = void 0; const funcs_Elering_1 = require("./funcs_Elering"); const funcs_SpotHinta_1 = require("./funcs_SpotHinta"); const funcs_Awattar_1 = require("./funcs_Awattar"); const funcs_OMIE_1 = require("./funcs_OMIE"); const settings_1 = require("./settings"); const luxon_1 = require("luxon"); const asciichart = __importStar(require("asciichart")); const settings_2 = require("./settings"); class Functions { constructor(platform, accessory, service, api) { var _a, _b, _c, _d, _e; this.platform = platform; this.accessory = accessory; this.service = service; this.api = api; this.decimalPrecision = (_a = this.platform.config.decimalPrecision) !== null && _a !== void 0 ? _a : 1; this.excessivePriceMargin = (_b = this.platform.config.excessivePriceMargin) !== null && _b !== void 0 ? _b : 200; this.minPriciestMargin = (_c = this.platform.config.minPriciestMargin) !== null && _c !== void 0 ? _c : 0; this.plotTheChart = (_d = this.platform.config.plotTheChart) !== null && _d !== void 0 ? _d : false; this.dynamicCheapestConsecutiveHours = (_e = this.platform.config.dynamicCheapestConsecutiveHours) !== null && _e !== void 0 ? _e : false; this.pricesCache = (0, settings_2.defaultPricesCache)(this.api, this.platform.log); } async initAccessories() { this.accessory.getService(this.platform.Service.AccessoryInformation) .setCharacteristic(this.platform.Characteristic.Manufacturer, settings_2.PLATFORM_MANUFACTURER) .setCharacteristic(this.platform.Characteristic.Model, settings_2.PLATFORM_MODEL) .setCharacteristic(this.platform.Characteristic.SerialNumber, settings_2.PLATFORM_SERIAL_NUMBER); // init light sensor for current price this.service.currently = this.accessory.getService('Nordpool_currentPrice') || this.accessory.addService(this.platform.Service.LightSensor, 'Nordpool_currentPrice', 'currentPrice'); // set default price level if (this.service.currently) { this.service.currently.getCharacteristic(this.platform.Characteristic.CurrentAmbientLightLevel) .updateValue(settings_2.pricing.currently); } // hourly ticker this.service.hourlyTickerSwitch = this.accessory.getService('Nordpool_hourlyTickerSwitch') || this.accessory.addService(this.platform.Service.Switch, 'Nordpool_hourlyTickerSwitch', 'hourlyTickerSwitch'); // current hour as temperature sensor if (this.platform.config['currentHour'] !== undefined && this.platform.config['currentHour']) { this.service.currentHour = this.accessory.getService('Nordpool_currentHour') || this.accessory.addService(this.platform.Service.TemperatureSensor, 'Nordpool_currentHour', 'currentHour'); if (this.service.currentHour) { this.service.currentHour.getCharacteristic(this.platform.Characteristic.CurrentTemperature) .updateValue((0, settings_2.fnc_currentHour)(this.platform.config)); } } else { const currentHourService = this.accessory.getService('Nordpool_currentHour'); if (currentHourService !== undefined) { this.accessory.removeService(currentHourService); this.platform.log.debug('Accessory Nordpool_currentHour removed according to Plugin Config'); } } // turn OFF hourly ticker if its turned on by schedule or manually if (this.service.hourlyTickerSwitch) { this.service.hourlyTickerSwitch.getCharacteristic(this.platform.Characteristic.On) .on('set', (value, callback) => { if (value) { // If switch is manually turned on, start a timer to switch it back off after 1 second setTimeout(() => { this.service.hourlyTickerSwitch.updateCharacteristic(this.platform.Characteristic.On, false); }, 1000); } callback(null); }); } // init virtual occupancy sensors for price levels for (const key of Object.keys(this.service)) { if (/^(cheapest|priciest)/.test(key)) { const accessoryService = this.accessory.getService(`Nordpool_${key}`); if (this.platform.config[key] !== undefined && !this.platform.config[key]) { if (accessoryService !== undefined) { this.accessory.removeService(accessoryService); this.platform.log.debug(`Accessory Nordpool_${key} removed according to Plugin Config`); } else { this.platform.log.debug(`Accessory Nordpool_${key} skipped according to Plugin Config`); } continue; } this.service[key] = accessoryService || this.accessory.addService(this.platform.Service.OccupancySensor, `Nordpool_${key}`, key); if (this.service[key]) { this.service[key] .getCharacteristic(this.platform.Characteristic.OccupancyDetected) .setValue(this.platform.Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED); } } } // make sure accessories cache on homebridge gets updated this.platform.api.updatePlatformAccessories([this.accessory]); } async pullNordpoolData() { try { let rawData; if (this.platform.config.area.match(/^(LT|LV|EE|FI)$/)) { rawData = await (0, funcs_Elering_1.eleringEE_getNordpoolData)(this.platform.log, this.platform.config); } if (!rawData && this.platform.config.area.match(/^(DE|LU|AT)$/)) { rawData = await (0, funcs_Awattar_1.awattar_getNordpoolData)(this.platform.log, this.platform.config); } if (!rawData && this.platform.config.area.match(/^(ES|PT)$/)) { rawData = await (0, funcs_OMIE_1.omie_getNordpoolData)(this.platform.log, this.platform.config); } // retry for Baltics on different provider // other countries use this as primary if (!rawData) { rawData = await (0, funcs_SpotHinta_1.spothinta_getNordpoolData)(this.platform.log, this.platform.config); } if (!rawData) { this.platform.log.warn(`ERR: No response from API for ${this.platform.config.area} area`); return null; } // Convert 15-minute data to hourly averages this.platform.log.warn('NOTE: Hourly prices are averaged from 15-minute intervals'); return this.convertToHourlyAverages(rawData); } catch (error) { this.platform.log.error('API Error:', error instanceof Error ? error.message : String(error)); return null; } } async checkSystemTimezone() { const systemTimezone = luxon_1.DateTime.local().toFormat('ZZ'); const preferredTimezone = luxon_1.DateTime.local().setZone((0, settings_2.defaultAreaTimezone)(this.platform.config)).toFormat('ZZ'); if (systemTimezone !== preferredTimezone) { this.platform.log.warn(`WARN: System timezone ${systemTimezone} DOES NOT match with ${this.platform.config.area} area timezone ${preferredTimezone}. ` + 'This may result in incorrect time-to-price coding. If possible, please update your system time setting to match timezone of ' + 'your specified Nordpool area.'); } else { this.platform.log.debug(`OK: system timezone ${systemTimezone} match ${this.platform.config.area} area timezone ${preferredTimezone}`); } } async applySolarOverride(config, force) { if (config.solarOverride === null || config.solarOverride === false) { return; } const today = luxon_1.DateTime.local(); const todayKey = (0, settings_1.fnc_todayKey)(config); const solarOverrideApplied = await this.pricesCache.get(`solarOverrideApplied_${todayKey}`); if (!force && solarOverrideApplied) { return; } if (today.month < 3 || today.month > 9) { this.platform.log.warn('Solar power plant override applies in March-September months only.'); this.pricesCache.set(`solarOverrideApplied_${todayKey}`, true); return; } const latitude = config.latitude || 55; const daysDifference = today.diff(luxon_1.DateTime.fromObject({ year: today.year, month: 6, day: 24 }), 'days').days; const solarOffsetMinutes = Math.abs(daysDifference) * (1.6 + 0.04 * (latitude - 55)); const solarOverrideJuneHourStart = luxon_1.DateTime.fromObject({ hour: config.solarOverrideJuneHourStart }) .plus({ minutes: solarOffsetMinutes }); const solarOverrideJuneHourStartDecimal = Math.round(solarOverrideJuneHourStart.hour + solarOverrideJuneHourStart.minute / 60); // one hour added, to make configured value 'inclusive' const solarOverrideJuneHourEnd = luxon_1.DateTime.fromObject({ hour: config.solarOverrideJuneHourEnd + 1 }) .minus({ minutes: solarOffsetMinutes }); const solarOverrideJuneHourEndDecimal = Math.round(solarOverrideJuneHourEnd.hour + solarOverrideJuneHourEnd.minute / 60); if (solarOverrideJuneHourStartDecimal < solarOverrideJuneHourEndDecimal) { this.platform.log.debug(`solarOffsetMinutes: ${solarOffsetMinutes}`); this.platform.log.debug(`solarOverrideJuneHourStart: ${solarOverrideJuneHourStart.toJSON()}`); this.platform.log.debug(`solarOverrideJuneHourEnd: ${solarOverrideJuneHourEnd.toJSON()}`); this.platform.log.warn(`Hours from ${solarOverrideJuneHourStartDecimal} to ${solarOverrideJuneHourEndDecimal - 1} (inclusive) are overridden ` + 'price values to 0 because of solar plant settings.'); for (let i = solarOverrideJuneHourStartDecimal; i < solarOverrideJuneHourEndDecimal; i++) { settings_2.pricing.today[i].price = 0; } } this.pricesCache.set(todayKey, settings_2.pricing.today); this.pricesCache.set(`solarOverrideApplied_${todayKey}`, true); } getCheapestHoursToday() { // make sure these arrays are empty on each (new day) re-calculation for (const key of Object.keys(settings_2.pricing)) { if (!/^(cheapest|priciest|cheapest5HoursConsec)/.test(key)) { continue; } settings_2.pricing[key] = []; } const sortedPrices = [...settings_2.pricing.today].sort((a, b) => a.price - b.price); settings_2.pricing.median = parseFloat(((sortedPrices[Math.floor(sortedPrices.length / 2) - 1].price + sortedPrices[Math.ceil(sortedPrices.length / 2)].price) / 2).toFixed(this.decimalPrecision)); settings_2.pricing.today .map((price) => ({ value: price.price, hour: price.hour })) .forEach(({ value, hour }) => { if (value <= sortedPrices[0].price) { settings_2.pricing.cheapestHour.push(hour); } if (value <= sortedPrices[3].price) { settings_2.pricing.cheapest4Hours.push(hour); } if (value <= sortedPrices[4].price) { settings_2.pricing.cheapest5Hours.push(hour); } if (value <= sortedPrices[5].price) { settings_2.pricing.cheapest6Hours.push(hour); } if (value <= sortedPrices[6].price) { settings_2.pricing.cheapest7Hours.push(hour); } if (value <= sortedPrices[7].price) { settings_2.pricing.cheapest8Hours.push(hour); } if (value <= sortedPrices[8].price) { settings_2.pricing.cheapest9Hours.push(hour); } if (value <= sortedPrices[9].price) { settings_2.pricing.cheapest10Hours.push(hour); } if (value <= sortedPrices[10].price) { settings_2.pricing.cheapest11Hours.push(hour); } if (value <= sortedPrices[11].price) { settings_2.pricing.cheapest12Hours.push(hour); } // last element if ((value >= (sortedPrices[sortedPrices.length - 1].price * 0.9) || value >= settings_2.pricing.median * this.excessivePriceMargin / 100) && !settings_2.pricing.cheapest12Hours.includes(hour) && value > this.minPriciestMargin) { settings_2.pricing.priciestHour.push(hour); } }); this.platform.log.info(`Cheapest hour(s): ${settings_2.pricing.cheapestHour.join(', ')}`); for (let i = 4; i <= 12; i++) { const key = `cheapest${i}Hours`; if (this.platform.config[key] !== undefined && this.platform.config[key]) { this.platform.log.info(`${i} cheapest hours: ${settings_2.pricing[key].join(', ')}`); } } if (settings_2.pricing.priciestHour.length === 0) { this.platform.log.info(`Most expensive hour(s): N/A (all hours prices fall below ${this.minPriciestMargin} cents)`); } else { this.platform.log.info(`Most expensive hour(s): ${settings_2.pricing.priciestHour.join(', ')}`); } this.platform.log.info(`Median price today: ${settings_2.pricing.median} cents`); if (this.plotTheChart) { this.plotPricesChart().then().catch((error) => { this.platform.log.error('An error occurred plotting the chart for today\'s Nordpool data: ', error); }); } } async getCheapestConsecutiveHours(numHours, pricesSequence) { // if not required on plugin config, just return empty if (this.platform.config['cheapest5HoursConsec'] !== undefined && !this.platform.config['cheapest5HoursConsec']) { return []; } if (pricesSequence.length < numHours) { this.platform.log.error(`Insufficient price data to calculate ${numHours} consecutive hours.`); return []; } // try cached from 2-days calculation - if not avail then calculate fresh let retVal = await this.pricesCache.get('5consecutiveUpdated', []); if (retVal === undefined || retVal.length === 0) { const hourSequences = []; for (let i = 0; i <= pricesSequence.length - numHours; i++) { const totalSum = pricesSequence.slice(i, i + numHours).reduce((total, priceObj) => total + priceObj.price, 0); hourSequences.push({ startHour: i, total: totalSum }); } const cheapestHours = hourSequences.sort((a, b) => a.total - b.total)[0]; retVal = Array.from({ length: numHours }, (_, i) => pricesSequence[cheapestHours.startHour + i].hour); } this.platform.log.info(`Consecutive ${numHours} cheapest hours: ${retVal.join(', ')}`); return retVal; } async plotPricesChart() { const priceData = settings_2.pricing.today.map(elem => elem.price); const chart = asciichart.plot(priceData, { padding: ' ', // 6 spaces height: 9, }); const lines = chart.split('\n'); lines.forEach((line) => { this.platform.log.warn(line); }); } setOccupancyByHour(currentHour, accessoryName) { let characteristic = this.platform.Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED; if (settings_2.pricing[accessoryName].includes(currentHour)) { characteristic = this.platform.Characteristic.OccupancyDetected.OCCUPANCY_DETECTED; } const accessoryService = this.service[accessoryName]; if (accessoryService !== undefined && accessoryService !== null) { accessoryService.setCharacteristic(this.platform.Characteristic.OccupancyDetected, characteristic); } } async analyze_and_setServices(currentHour) { if (settings_2.pricing.today.length === 25 || settings_2.pricing.today.length === 24 || settings_2.pricing.today.length === 23) { settings_2.pricing.currently = settings_2.pricing.today[currentHour]['price']; } else { this.platform.log.warn('WARN: Unable to determine current hour Nordpool price because data not available'); return; } settings_2.pricing.currentHour = currentHour; if (this.service.currentHour) { this.service.currentHour.getCharacteristic(this.platform.Characteristic.CurrentTemperature) .updateValue(currentHour); } await this.applySolarOverride(this.platform.config, false); // if new day or cheapest hours not calculated yet if (currentHour === 0 || settings_2.pricing.cheapest4Hours.length === 0) { this.getCheapestHoursToday(); } if (settings_2.pricing.cheapest5HoursConsec.length === 0 || currentHour === 0 || (currentHour === 7 && this.dynamicCheapestConsecutiveHours)) { this.getCheapestConsecutiveHours(5, settings_2.pricing.today).then((retVal) => { settings_2.pricing.cheapest5HoursConsec = retVal; this.setOccupancyByHour(currentHour, 'cheapest5HoursConsec'); }).catch((error) => { settings_2.pricing.cheapest5HoursConsec = []; // make sure its empty in case of error this.platform.log.error('An error occurred calculating cheapest 5 consecutive hours: ', error); }); } // set current price level on light sensor if (this.service.currently) { this.service.currently.getCharacteristic(this.platform.Characteristic.CurrentAmbientLightLevel) .updateValue(settings_2.pricing.currently >= 0.0001 ? settings_2.pricing.currently : 0.0001); } // set price levels on relevant occupancy sensors for (const key of Object.keys(settings_2.pricing)) { if (!/^(cheapest|priciest)/.test(key)) { continue; } if (!this.service[key] || !Array.isArray(settings_2.pricing[key])) { continue; } this.setOccupancyByHour(currentHour, key); } this.platform.log.info(`Hour: ${currentHour}; Price: ${settings_2.pricing.currently} cents`); // toggle hourly ticker in 1s ON if (this.service.hourlyTickerSwitch) { setTimeout(() => { this.service.hourlyTickerSwitch.setCharacteristic(this.platform.Characteristic.On, true); }, 1000); } } async getCheapestHoursIn2days() { // make sure its not allowed to execute if not enabled on plugin config if (!this.dynamicCheapestConsecutiveHours) { return; } const tomorrowKey = (0, settings_2.fnc_tomorrowKey)(this.platform.config); const currentHour = (0, settings_2.fnc_currentHour)(this.platform.config); let tomorrow = []; tomorrow = await this.pricesCache.get(tomorrowKey, []); let twoDaysPricing = []; // stop function if not full data if (settings_2.pricing.today.length !== 24 || tomorrow.length !== 24) { return; } const remainingHoursToday = Array.from({ length: Math.min(24 - currentHour, 24) }, (_, i) => currentHour + i); // Check if any of the remaining hours are within the cheapest consecutive hours if (settings_2.pricing.cheapest5HoursConsec.some(hour => remainingHoursToday.includes(hour))) { // from now till next day 6AM twoDaysPricing = settings_2.pricing.today.slice(currentHour, 24).concat(tomorrow.slice(0, 7)); } else { // do nothing, allow recalculate 0AM this.pricesCache.remove('5consecutiveUpdated'); return; } this.getCheapestConsecutiveHours(5, twoDaysPricing).then((retVal) => { settings_2.pricing.cheapest5HoursConsec = retVal; this.setOccupancyByHour(currentHour, 'cheapest5HoursConsec'); // ttl in seconds till next morning 7am const ttl = this.ttlSecondsTill_7AM(); this.pricesCache.set('5consecutiveUpdated', retVal, ttl); }).catch((error) => { this.platform.log.error('An error occurred calculating cheapest 5 consecutive hours: ', error); }); } ttlSecondsTill_7AM() { const now = luxon_1.DateTime.local(); let next7am = now.startOf('day').plus({ hours: 6, minutes: 59 }); if (now >= next7am) { next7am = next7am.plus({ days: 1 }); } return next7am.diff(now, 'seconds').seconds; } fillMissingHours(data, dayKey) { // If we have all expected hours, return as-is if (data.length >= 24) { return data; } // Make a copy to avoid modifying the original const filledData = [...data]; // Sort by hour to ensure proper sequence filledData.sort((a, b) => a.hour - b.hour); // Find the missing hour by looking for gaps in the sequence for (let i = 0; i < filledData.length - 1; i++) { if (filledData[i + 1].hour !== filledData[i].hour + 1) { const missingHour = filledData[i].hour + 1; const previousHour = filledData[i]; // Clone the previous hour's data for the missing hour const clonedHour = { ...previousHour, hour: missingHour, day: dayKey, }; filledData.push(clonedHour); filledData.sort((a, b) => a.hour - b.hour); this.platform.log.warn(`WARN: Added missing hour ${missingHour} by duplicating hour ${previousHour.hour} data`); // Since we only expect to be missing one hour, we can break after fixing it break; } } return filledData; } convertToHourlyAverages(data) { // Group data by day and hour const hourlyDataMap = new Map(); data.forEach(item => { const key = `${item.day}-${item.hour}`; if (!hourlyDataMap.has(key)) { hourlyDataMap.set(key, { total: item.price, count: 1, hour: item.hour, day: item.day, }); } else { const existing = hourlyDataMap.get(key); existing.total += item.price; existing.count += 1; } }); // Convert map back to array with averaged prices return Array.from(hourlyDataMap.values()).map(({ day, hour, total, count }) => ({ day, hour, price: parseFloat((total / count).toFixed(this.decimalPrecision)), // Use configured precision for averaging })); } } exports.Functions = Functions; //# sourceMappingURL=functions.js.map