homebridge-epex
Version:
Monitor EPEX energy prices using the ENTSO-E transparency platform API.
311 lines • 15 kB
JavaScript
import { EPEXPlatformAccessory } from './platformAccessory.js';
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
import axios from 'axios';
import { parseStringPromise } from 'xml2js';
/**
* HomebridgePlatform
* This class is the main constructor for your plugin, this is where you should
* parse the user config and discover/register accessories with Homebridge.
*/
export class EPEXMonitor {
log;
config;
api;
Service;
Characteristic;
// this is used to track restored cached accessories
accessories = new Map();
discoveredCacheUUIDs = [];
// all the price data - typically 48h (current + next day), to be used later for triggering alerts
allSlots = [];
currentPrice = null;
lastSlotHour = null;
timer;
// Add a getter and setter for currentPrice
getCurrentPrice() {
return this.currentPrice;
}
setCurrentPrice(price) {
this.currentPrice = price;
}
constructor(log, config, api) {
this.log = log;
this.config = config;
this.api = api;
this.Service = api.hap.Service;
this.Characteristic = api.hap.Characteristic;
this.log.debug('Finished initializing EPEX platform:', this.config.name);
this.log.info('EPEXMonitor initialized.');
// When this event is fired it means Homebridge has restored all cached accessories from disk.
// Dynamic Platform plugins should only register new accessories after this event was fired,
// in order to ensure they weren't added to homebridge already. This event can also be used
// to start discovery of new accessories.
this.api.on('didFinishLaunching', () => {
log.debug('Executed didFinishLaunching callback');
this.log.info('Starting EPEXMonitor...');
this.startPolling();
// run the method to discover / register your devices as accessories
this.discoverDevices();
});
}
/**
* Poll the ENTSO-E API periodically for energy price data.
*/
startPolling() {
const interval = (this.config.refreshInterval || 15) * 60 * 1000;
this.pollEPEXPrice(); // Initial fetch
this.timer = setInterval(() => {
this.pollEPEXPrice();
}, interval);
this.log.info(`Polling initialized. Interval: ${interval / 60000} minutes.`);
}
/**
* Fetch price data from the ENTSO-E API.
*/
async pollEPEXPrice() {
// Define a fallback slot and price
const fallbackSlot = () => ({
start: new Date(),
// fallback: price to be published in case the API provides no data
// Can not be higher than 100. Set to 1000 here because all prices are in Euro/MWh but
// published as ct/kWh.
// Limitation: https://developers.homebridge.io/#/characteristic/CurrentTemperature
price: 1000,
});
let currentSlot = fallbackSlot(); // default to fallback
// Check if the API key is present in the config, if not return early with fallback data
if (!this.config.apiKey || this.config.apiKey.trim() === '') {
this.log.warn('ENTSO-E API key is missing. Cannot fetch energy price data.');
const priceCtKwh = currentSlot.price / 10;
this.setCurrentPrice(priceCtKwh);
this.updateAccessories();
return;
}
try {
// Build request window & URL
const { start, end } = this.getEntsoeWindowFor48h();
const inOutDomain = this.config.in_Domain || '10YNL----------L';
const token = this.config.apiKey || 'invalid_token';
const url = 'https://web-api.tp.entsoe.eu/api'
+ '?documentType=A44'
+ `&in_Domain=${inOutDomain}`
+ `&out_Domain=${inOutDomain}`
+ `&periodStart=${start}`
+ `&periodEnd=${end}`
+ `&securityToken=${token}`;
// this.log.debug('Sending URL to ENTSO-E:', url);
const response = await axios.get(url);
// this.log.debug('Response from ENTSO-E:', response.data);
const timeslots = await this.parseAllTimeslots(response.data);
const now = Date.now();
// If no slots
if (!timeslots || timeslots.length === 0) {
this.log.warn('No timeslots returned by the API. Falling back to price=100.');
currentSlot = fallbackSlot();
}
else {
// If the current time is before the first slot provided by the ENTSOE API
if (now < timeslots[0].start.getTime()) {
this.log.warn('ENTSO-E API did not return complete data!');
this.log.warn(`All timeslots are in the future (now < ${timeslots[0].start.toISOString()})`);
this.log.warn('Falling back to price=100.');
currentSlot = fallbackSlot();
}
else {
// Else if the current time is after the last slot's end provided by the ENTSOE API
const last = timeslots[timeslots.length - 1];
const lastSlotEnd = last.start.getTime() + 60 * 60 * 1000; // 1-hour assumption
if (now >= lastSlotEnd) {
this.log.warn(`All slots ended by ${last.start.toISOString()}. Falling back to price=100.`);
currentSlot = fallbackSlot();
}
else {
// Otherwise, find the current slot (expected normal flow)
const found = timeslots.find((slot, idx) => {
const next = timeslots[idx + 1];
// If there is no next slot, this is the last slot -> use it
if (!next) {
return true;
}
// Otherwise, return true if slot.start <= now < next.start
return slot.start.getTime() <= now && now < next.start.getTime();
});
if (!found) {
this.log.warn('No suitable slot found. Falling back to max price=100.');
currentSlot = fallbackSlot();
}
else {
currentSlot = found;
}
}
}
}
const currentSlotHour = currentSlot.start.getHours();
const priceCtKwh = currentSlot.price / 10; // convert from Euro/MWh to ct/kWh
// Compare only the hour. If it changes, log info:
if (this.lastSlotHour !== currentSlotHour) {
this.lastSlotHour = currentSlotHour;
// Log the slot details in a friendly way
const date = new Date(currentSlot.start);
const formattedDate = date.toLocaleDateString('en-CA', { year: 'numeric', month: 'numeric', day: 'numeric' });
const formattedTime = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
const endHour = (parseInt(formattedTime.split(':')[0], 10) + 1) % 24;
const endHourStr = String(endHour).padStart(2, '0') + ':' + formattedTime.split(':')[1];
this.log.info(`Current time slot (local time) is ${formattedDate} ${formattedTime} - ${endHourStr}, ` +
`EPEX price (Euro/MWh)=${currentSlot.price}`);
this.log.info(`Published current EPEX Energy Price (ct/kWh): ${priceCtKwh}`);
}
else {
// No hour change → optional debug
this.log.debug(`Still hour ${currentSlotHour}; no new log.`);
}
// Always publish the price (even if no update)
this.setCurrentPrice(priceCtKwh);
this.updateAccessories();
}
catch (error) {
this.log.warn('Error fetching or parsing ENTSO-E data:', error);
// fallback
currentSlot = fallbackSlot();
const priceCtKwh = currentSlot.price / 10;
this.setCurrentPrice(priceCtKwh);
this.updateAccessories();
}
}
// Helper function that returns a start/end in the "YYYYMMDDHHmm" format for 48 hours
getEntsoeWindowFor48h() {
// Start at today’s midnight UTC
const now = new Date();
const todayMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0));
// The end is "todayMidnight + 48h"
const tomorrowMidnightPlus48 = new Date(todayMidnight.getTime() + 48 * 60 * 60 * 1000);
// Debug
this.log.debug(`[DEBUG] Current UTC time: ${now.toISOString()}`);
this.log.debug(`[DEBUG] Today’s midnight UTC: ${todayMidnight.toISOString()}`);
this.log.debug(`[DEBUG] 48 hours from today’s midnight UTC: ${tomorrowMidnightPlus48.toISOString()}`);
// Convert to the ENTSO-E string
const startStr = this.toEntsoeDateString(todayMidnight);
const endStr = this.toEntsoeDateString(tomorrowMidnightPlus48);
return { start: startStr, end: endStr };
}
/**
* Convert a date to the ENTSO-E required format (YYYYMMDDHHmm).
*/
toEntsoeDateString(date) {
// "2025-01-06T17:00:23.456Z" -> "20250106T1700"
const iso = date.toISOString(); // "2025-01-06T17:00:23.456Z"
const cleaned = iso.replace(/[-:]/g, ''); // "20250106T170023.456Z"
// Keep only "YYYYMMDDTHHMM" => slice(0,13) => "20250106T1700"
let partial = cleaned.slice(0, 13); // "20250106T1700"
partial = partial.replace('T', ''); // "202501061700"
return partial; // "202501061700"
}
/**
* Parse the ENTSO-E XML response for a full set of day-ahead timeslots.
* Returns an array of { start: Date, price: number } for each timeslot.
*/
async parseAllTimeslots(xmlData) {
// 1) Parse XML
const result = await parseStringPromise(xmlData, { explicitArray: false });
const timeSeries = result?.Publication_MarketDocument?.TimeSeries;
// If no TimeSeries, return empty
if (!timeSeries) {
this.log.warn('No TimeSeries found in ENTSO-E response');
return [];
}
// In some cases, `TimeSeries` can be an array of multiple series
const seriesArray = Array.isArray(timeSeries) ? timeSeries : [timeSeries];
// We'll accumulate all timeslots here
const allTimeslots = [];
for (const series of seriesArray) {
// Each series can have multiple Periods
const periodArray = Array.isArray(series.Period) ? series.Period : [series.Period];
for (const per of periodArray) {
// The official start time of this Period
const periodStartStr = per.timeInterval?.start;
if (!periodStartStr) {
this.log.warn('Period missing timeInterval.start');
continue;
}
// Determine resolution (often "PT60M" for hourly, "PT15M" for quarter-hour)
const resolution = per.resolution || 'PT60M';
const minutesPerSlot = resolution === 'PT15M' ? 15 : 60; // Basic assumption
// Convert periodStartStr to a Date
const dtStart = new Date(periodStartStr);
// Points can be array or single
const points = Array.isArray(per.Point) ? per.Point : [per.Point];
for (const p of points) {
const rawPos = parseInt(p.position || '1', 10) - 1;
const rawPrice = parseFloat(p['price.amount'] || '0');
const price = isNaN(rawPrice) ? 0 : rawPrice;
// Compute timeslot start by adding (rawPos * minutesPerSlot) to dtStart
const slotStart = new Date(dtStart.getTime() + rawPos * minutesPerSlot * 60000);
allTimeslots.push({
start: slotStart,
price: price,
});
}
}
}
// Sort all timeslots by start time
allTimeslots.sort((a, b) => a.start.getTime() - b.start.getTime());
// Log a CSV-like matrix for debugging
// Create "ISO,Price" lines
let matrixOutput = 'DateTime(UTC),Price (ct/kWh)\n';
for (const slot of allTimeslots) {
const isoStr = slot.start.toISOString(); // e.g. "2025-01-07T03:00:00.000Z"
matrixOutput += `${isoStr},${slot.price / 10}\n`;
}
this.log.debug('--- ENTSO-E Full-Day Timeslots ---\n' + matrixOutput);
// Return the full array
return allTimeslots;
}
/**
* Notify accessories of the updated price.
*/
accessoryHandlers = new Map();
updateAccessories() {
for (const accessory of this.accessories.values()) {
// Create or retrieve the EPEXPlatformAccessory instance
let epexAccessory = this.accessoryHandlers.get(accessory.UUID);
if (!epexAccessory) {
epexAccessory = new EPEXPlatformAccessory(this, accessory);
this.accessoryHandlers.set(accessory.UUID, epexAccessory);
}
// Update the price
epexAccessory.updatePrice(this.getCurrentPrice());
}
}
/**
* Restore cached accessories.
*/
configureAccessory(accessory) {
this.log.info('Loading accessory from cache:', accessory.displayName);
this.accessories.set(accessory.UUID, accessory);
}
/**
* Discover and register accessories.
*/
discoverDevices() {
const exampleDevices = [
{ id: 'PriceMonitor1', name: 'EPEX Price Monitor' },
];
for (const device of exampleDevices) {
const uuid = this.api.hap.uuid.generate(device.id);
const existingAccessory = this.accessories.get(uuid);
if (existingAccessory) {
this.log.info('Restoring accessory:', existingAccessory.displayName);
new EPEXPlatformAccessory(this, existingAccessory);
}
else {
this.log.info('Adding new accessory:', device.name);
const accessory = new this.api.platformAccessory(device.name || 'Unnamed Accessory', // Add a fallback name here
uuid);
accessory.context.device = device;
new EPEXPlatformAccessory(this, accessory);
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
}
}
}
}
//# sourceMappingURL=platform.js.map