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