iobroker.pvforecast
Version:
1,084 lines (938 loc) • 116 kB
JavaScript
'use strict';
const utils = require('@iobroker/adapter-core');
const moment = require('moment');
const axios = require('axios').default;
const solcast = require(`${__dirname}/lib/solcast`);
const pvnode = require(`${__dirname}/lib/pvnode`);
const CronJob = require('cron').CronJob;
let globalunit = 1000;
const TYPE_ENERGY = 'energy';
const TYPE_POWER = 'power';
const STEP_FULL = 'full';
const STEP_HALF = 'half';
const STEP_QUARTER = 'quarter';
async function asyncForEach(array, callback) {
for (let index = 0; index < array.length; index++) {
await callback(array[index], index, array);
}
}
class Pvforecast extends utils.Adapter {
/**
* @param {Partial<utils.AdapterOptions>} [options]
*/
constructor(options) {
super({
...options,
name: 'pvforecast',
useFormatDate: true,
});
this.dataUpdateInProgess = false;
this.globalEveryHour = {};
this.reqInterval = 60;
this.hasApiKey = false;
this.timeZone = 'Europe/Berlin';
this.pvLongitude = undefined;
this.pvLatitude = undefined;
this.updateServiceDataTimeout = null;
this.updateActualDataCron = null;
this.pvnodeServiceDataFetched = false;
this.pvnodePlantFetchIndex = 0;
this.on('ready', this.onReady.bind(this));
this.on('stateChange', this.onStateChange.bind(this));
this.on('unload', this.onUnload.bind(this));
}
async onReady() {
this.logSensitive(`instance config: ${JSON.stringify(this.config)}`);
this.pvLongitude = this.config.longitude;
this.pvLatitude = this.config.latitude;
if (
(!this.pvLongitude && this.pvLongitude !== 0) ||
isNaN(this.pvLongitude) ||
(!this.pvLatitude && this.pvLatitude !== 0) ||
isNaN(this.pvLatitude)
) {
this.log.debug('[onReady] longitude and/or latitude not set, loading system configuration');
// Check system values
if (this.longitude && this.latitude) {
this.pvLongitude = this.longitude;
this.pvLatitude = this.latitude;
this.log.info(`using system latitude: ${this.pvLatitude} longitude: ${this.pvLongitude}`);
}
}
if (
this.pvLongitude == '' ||
typeof this.pvLongitude == 'undefined' ||
isNaN(this.pvLongitude) ||
this.pvLatitude == '' ||
typeof this.pvLatitude == 'undefined' ||
isNaN(this.pvLatitude)
) {
this.log.error('Please set the longitude and latitude in the adapter (or system) configuration!');
return;
}
if (
this.config.service === 'solcast' &&
(typeof this.config.devicesSolcast === 'undefined' || !this.config.devicesSolcast.length)
) {
this.log.error('Please set at least one solcast device in the adapter configuration!');
return;
} else if (
this.config.service !== 'solcast' &&
(typeof this.config.devices === 'undefined' || !this.config.devices.length)
) {
this.log.error('Please set at least one device in the adapter configuration!');
return;
}
try {
const plantArray = this.getPlantConfigData();
// Validate plants
await asyncForEach(plantArray, async plant => {
if (!plant.name) {
throw new Error(`Invalid device configuration: Found plant without name`);
}
if (this.config.service === 'solcast') {
if (!plant.resourceId || plant.resourceId === 'xxxx-xxxx-xxxx-xxxx') {
throw new Error(`Invalid device configuration: Found plant without resource id`);
}
} else if (this.config.service === 'pvnode' && this.config.pvnodeV2) {
// V2 mode: all PV config is encoded in the site_id on the pvnode portal
} else {
if (isNaN(plant.azimuth)) {
throw new Error(`Invalid device configuration: Found plant without azimuth`);
}
if (isNaN(plant.tilt)) {
throw new Error(`Invalid device configuration: Found plant with incorrect tilt`);
}
if (!plant.peakpower || isNaN(plant.peakpower) || plant.peakpower < 0) {
throw new Error(`Invalid device configuration: Found plant without peak power`);
}
}
if (this.config.influxinstace) {
const cleanPlantId = this.cleanNamespace(plant.name);
this.log.info(
`InfluxDB logging is enabled - forecast for plant "${plant.name}" will be available @ "${this.namespace}.plants.${cleanPlantId}.power"`,
);
}
});
if (this.config.influxinstace) {
this.log.info(
`InfluxDB logging is enabled - forecast summary will be available @ "${this.namespace}.summary.power"`,
);
}
// Get list of valid plants by configuration
const plantsKeep = plantArray.map(d => `${this.namespace}.plants.${this.cleanNamespace(d.name)}`);
// Delete plants which are not configured (anymore)
const allDevices = await this.getDevicesAsync();
const plantDevices = allDevices.filter(p => p._id.startsWith(`${this.namespace}.plants.`));
this.log.debug(
`Existing plant devices: ${JSON.stringify(plantDevices)} - configured: ${JSON.stringify(plantsKeep)}`,
);
await asyncForEach(plantDevices, async deviceObj => {
if (!plantsKeep.includes(deviceObj._id)) {
await this.delObjectAsync(deviceObj._id, { recursive: true });
this.log.info(`Deleted plant with id: "${deviceObj._id}" - (not found in configuration)`);
}
});
} catch (err) {
this.log.error(err);
return;
}
if (!this.config.interval || this.config.interval < 60) {
this.reqInterval = 60 * 60 * 1000;
this.log.warn(
'The interval is set to less than 60 minutes. Please set a higher value in the adapter configuration!',
);
} else {
this.log.debug(`The interval is set to ${this.config.interval} minutes`);
this.reqInterval = this.config.interval * 60 * 1000;
}
// Check if API key is configured
if (typeof this.config.apiKey !== 'undefined' && this.config.apiKey !== '') {
this.hasApiKey = true;
} else {
this.config.apiKey = '';
}
if (this.config.service === 'solcast' && !this.hasApiKey) {
this.log.error('Please set the API key for Solcast in the adapter configuration!');
return;
}
if (this.config.service == 'spa' && (!this.hasApiKey || this.config.apiKey.length < 20)) {
this.log.error('Please set the API key for SolarPredictionAPI in the adapter configuration!');
return;
}
if (this.config.service === 'pvnode' && !this.hasApiKey) {
this.log.error('Please set the API key for pvnode in the adapter configuration!');
return;
}
if (this.config.service === 'pvnode') {
if (this.config.pvnodeV2) {
if (!this.config.pvnodeSiteId) {
this.log.error(
'[pvnode] API v2 is enabled but no Site-ID is configured. Please enter the pvnode Site-ID in the adapter configuration!',
);
return;
}
this.log.info(`[pvnode] Using API v2 with site ID: ${this.config.pvnodeSiteId}`);
} else {
this.log.warn(
'[pvnode] Using API v1 (deprecated). Enable "pvnode API v2" and enter your Site-ID to switch to v2. pvnode will shut down v1 on 2026-12-31.',
);
}
// Auto-set poll interval based on subscription tier (applies to v1 and v2)
const tier = this.config.pvnodeTier || 'free';
if (tier === 'plus') {
this.reqInterval = 10 * 60 * 1000;
this.log.info('[pvnode] Poll interval auto-set to 10 min (Plus tier — nowcasting)');
} else if (tier === 'light') {
this.reqInterval = 60 * 60 * 1000;
this.log.info('[pvnode] Poll interval auto-set to 60 min (Light tier)');
} else {
this.reqInterval = 24 * 60 * 60 * 1000;
this.log.info('[pvnode] Poll interval auto-set to 24 h (Free tier)');
}
}
if (this.config.watt_kw) {
globalunit = 1;
}
if (this.hasApiKey && this.config.service === 'forecastsolar' && this.config.weatherEnabled) {
this.log.info(
'Weather data is enabled in configuration - a professional account is required to request weather information',
);
}
await this.subscribeStatesAsync('plants.*');
await this.createAndDeleteStates();
await this.updateServiceDataInterval();
await this.updateActualDataInterval();
try {
this.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
this.log.info(`Starting internal update cron (every 15 Minutes) for timezone: ${this.timeZone}`);
} catch {
this.log.warn(`Unable to get system timezone - fallback to Europe/Berlin`);
}
this.updateActualDataCron = new CronJob(
'*/15 * * * *',
() => this.updateActualDataInterval(),
() => this.log.debug('stopped updateActualDataInterval'),
true,
this.timeZone,
);
this.log.debug(`[updateActualDataCron] next execution: ${this.updateActualDataCron.nextDate()}`);
}
/**
* Is called if a subscribed state changes
*
* @param {string} id
* @param {ioBroker.State | null | undefined} state
*/
onStateChange(id, state) {
if (state && !state.ack) {
this.log.debug(`state ${id} changed: ${state.val} (ack = ${state.ack})`);
if (id.startsWith(this.namespace)) {
this.getForeignObjectAsync(id)
.then(obj => {
if (obj?.native?.resetId) {
this.log.debug(`state "${id}" changed - resetting "${obj.native.resetId}" to 0`);
return this.setState(obj.native.resetId, { val: 0, ack: true });
}
})
.then(() => {
this.updateServiceDataInterval();
})
.catch(err => {
this.log.error(err);
});
}
}
}
async updateServiceDataInterval() {
if (this.updateServiceDataTimeout) {
this.clearTimeout(this.updateServiceDataTimeout);
}
await this.updateServiceData();
await this.updateServiceWeatherData();
if (this.config.service === 'solcast') {
this.reqInterval = moment().startOf('day').add(1, 'days').add(1, 'hours').valueOf() - moment().valueOf();
this.log.debug(`updateServiceDataInterval (solcast) - next service refresh in ${this.reqInterval}ms`);
} else if (this.config.service === 'pvnode') {
this.log.debug(`updateServiceDataInterval (pvnode) - next service refresh in ${this.reqInterval}ms`);
}
this.updateServiceDataTimeout = this.setTimeout(async () => {
this.updateServiceDataTimeout = null;
await this.updateServiceDataInterval();
}, this.reqInterval);
}
prepareData(data) {
return Object.keys(data)
.map(timeStr => {
const timeObj = moment(timeStr);
const minute = timeObj.minute();
let customFormat = 'HH:mm:ss';
if (this.config.everyhourStepsize === STEP_FULL) {
// :00
customFormat = 'HH:00:00';
} else if (this.config.everyhourStepsize === STEP_HALF) {
// :00, :30
if (minute >= 30) {
customFormat = 'HH:30:00';
} else if (minute >= 0) {
customFormat = 'HH:00:00';
}
} else if (this.config.everyhourStepsize === STEP_QUARTER) {
// :00, :15, :30, :45
if (minute >= 45) {
customFormat = 'HH:45:00';
} else if (minute >= 30) {
customFormat = 'HH:30:00';
} else if (minute >= 15) {
customFormat = 'HH:15:00';
} else if (minute >= 0) {
customFormat = 'HH:00:00';
}
}
return { date: timeObj.format(`YYYY-MM-DD ${customFormat}`), value: data[timeStr] };
})
.reduce((resultObj, dateObj) => {
const { date, value } = dateObj;
if (Object.prototype.hasOwnProperty.call(resultObj, date)) {
resultObj[date] += value;
return resultObj;
}
return { ...resultObj, [date]: value };
}, {});
}
getPlantConfigData() {
return (this.config.service === 'solcast' ? this.config.devicesSolcast : this.config.devices) || [];
}
async updateActualDataInterval() {
const todaysDate = moment().date();
const tomorrowDate = moment().add(1, 'days').date();
this.log.debug(`[updateActualDataInterval] starting update (today: ${todaysDate}, tomorrow: ${tomorrowDate})`);
const plantArray = this.getPlantConfigData();
const jsonDataSummary = [];
const jsonDataSummaryClearsky = [];
const jsonDataSummaryTemp = {};
const jsonDataSummaryWeatherCode = {};
const jsonTableSummary = [];
const jsonGraphSummary = [];
const jsonGraphLabelSummary = [];
this.globalEveryHour = {};
let totalPowerNow = 0;
const totalPowerInstalled = plantArray.reduce(
(totalInstalled, plant) => totalInstalled + plant.peakpower * 1000,
0,
);
let totalEnergyNow = 0;
let totalEnergyNowUntilEndOfDay = 0;
let totalEnergyToday = 0;
let totalEnergyTomorrow = 0;
await asyncForEach(plantArray, async (plant, index) => {
const cleanPlantId = this.cleanNamespace(plant.name);
const plantPowerInstalled = plant.peakpower * 1000; // kWp => Wp
this.globalEveryHour[cleanPlantId] = [];
const serviceDataState = await this.getStateAsync(`plants.${cleanPlantId}.service.data`);
if (serviceDataState && serviceDataState.val) {
try {
const data = JSON.parse(serviceDataState.val);
// cancel if no data
if (typeof data === 'undefined') {
return;
}
this.log.debug(
`[updateActualDataInterval] current service data for plants.${cleanPlantId}.service.data ("${plant.name}"): ${JSON.stringify(data)}`,
);
const mappedWattHoursPeriod = this.prepareData(data.watt_hours_period);
this.log.debug(
`[updateActualDataInterval] prepared energy data for plants.${cleanPlantId}.energy.*: ${JSON.stringify(mappedWattHoursPeriod)}`,
);
for (const time in mappedWattHoursPeriod) {
if (this.config.everyhourEnabled) {
await this.saveEveryHour(
TYPE_ENERGY,
cleanPlantId,
`plants.${cleanPlantId}.energy.hoursToday`,
todaysDate,
time,
mappedWattHoursPeriod[time] / globalunit,
);
await this.saveEveryHour(
TYPE_ENERGY,
cleanPlantId,
`plants.${cleanPlantId}.energy.hoursTomorrow`,
tomorrowDate,
time,
mappedWattHoursPeriod[time] / globalunit,
);
}
// add to InfluxDB
// await this.addToInfluxDB(`plants.${cleanPlantId}.energy`, moment(time).valueOf(), mappedWattHoursPeriod[time] / globalunit);
}
for (const time in data.watts) {
if (this.config.everyhourEnabled) {
await this.saveEveryHour(
TYPE_POWER,
cleanPlantId,
`plants.${cleanPlantId}.power.hoursToday`,
todaysDate,
time,
data.watts[time] / globalunit,
);
await this.saveEveryHour(
TYPE_POWER,
cleanPlantId,
`plants.${cleanPlantId}.power.hoursTomorrow`,
tomorrowDate,
time,
data.watts[time] / globalunit,
);
}
// add to InfluxDB
await this.addToInfluxDB(
`plants.${cleanPlantId}.power`,
moment(time).valueOf(),
data.watts[time] / globalunit,
);
}
const powerNow =
Object.keys(data.watts)
.filter(timeStr => moment(timeStr).valueOf() < moment().valueOf())
.map(key => data.watts[key])
.pop() || 0;
const energyToday = data.watt_hours_day[moment().format('YYYY-MM-DD')];
const energyTomorrow = data.watt_hours_day[moment().add(1, 'days').format('YYYY-MM-DD')];
const energyNow =
Object.keys(data.watt_hours)
.filter(timeStr => moment(timeStr).valueOf() < moment().valueOf())
.map(key => data.watt_hours[key])
.pop() || 0;
const energyNowUntilEndOfDay = Object.keys(mappedWattHoursPeriod)
.filter(timeStr => {
const m = moment(timeStr);
return m.valueOf() >= moment().valueOf() && todaysDate === m.date();
})
.reduce((total, timeStr) => total + mappedWattHoursPeriod[timeStr], 0);
totalPowerNow += powerNow;
totalEnergyNow += energyNow;
totalEnergyNowUntilEndOfDay += energyNowUntilEndOfDay;
totalEnergyToday += energyToday;
totalEnergyTomorrow += energyTomorrow;
await this.setStateChangedAsync(`plants.${cleanPlantId}.power.now`, {
val: Number(powerNow / globalunit),
ack: true,
});
await this.setStateChangedAsync(`plants.${cleanPlantId}.power.installed`, {
val: Number(plantPowerInstalled / globalunit),
ack: true,
});
await this.setStateChangedAsync(`plants.${cleanPlantId}.energy.now`, {
val: Number(energyNow / globalunit),
ack: true,
});
await this.setStateChangedAsync(`plants.${cleanPlantId}.energy.nowUntilEndOfDay`, {
val: Number(energyNowUntilEndOfDay / globalunit),
ack: true,
});
await this.setStateChangedAsync(`plants.${cleanPlantId}.energy.today`, {
val: Number(energyToday / globalunit),
ack: true,
});
await this.setStateChangedAsync(`plants.${cleanPlantId}.energy.tomorrow`, {
val: Number(energyTomorrow / globalunit),
ack: true,
});
await this.setStateChangedAsync(`plants.${cleanPlantId}.name`, { val: plant.name, ack: true });
if (this.config.everyhourEnabled) {
await this.saveEveryHourEmptyStates(
TYPE_ENERGY,
cleanPlantId,
`plants.${cleanPlantId}.energy.hoursToday`,
todaysDate,
);
await this.saveEveryHourEmptyStates(
TYPE_ENERGY,
cleanPlantId,
`plants.${cleanPlantId}.energy.hoursTomorrow`,
tomorrowDate,
);
await this.saveEveryHourEmptyStates(
TYPE_POWER,
cleanPlantId,
`plants.${cleanPlantId}.power.hoursToday`,
todaysDate,
);
await this.saveEveryHourEmptyStates(
TYPE_POWER,
cleanPlantId,
`plants.${cleanPlantId}.power.hoursTomorrow`,
tomorrowDate,
);
}
// JSON Data
const jsonData = [];
const hasPvnodeData = this.config.service === 'pvnode' && data.watts_clearsky;
for (const time in data.watts) {
const power = data.watts[time] / globalunit;
const timestamp = moment(time).valueOf();
const entry = {
t: timestamp,
y: power,
};
// Include pvnode-specific fields if available
if (hasPvnodeData) {
if (data.watts_clearsky[time] != null) {
entry.clearsky = data.watts_clearsky[time] / globalunit;
}
if (data.temperature?.[time] != null) {
entry.temp = data.temperature[time];
}
if (data.weather_code?.[time] != null) {
entry.weather_code = data.weather_code[time];
}
}
jsonData.push(entry);
if (jsonDataSummary?.[timestamp] === undefined) {
jsonDataSummary[timestamp] = 0;
}
jsonDataSummary[timestamp] = Math.round((jsonDataSummary[timestamp] + power) * 1000) / 1000; // Limit result to 3 digits
// Accumulate clearsky for summary (pvnode only, summed across plants)
if (hasPvnodeData && data.watts_clearsky[time] != null) {
if (jsonDataSummaryClearsky[timestamp] === undefined) {
jsonDataSummaryClearsky[timestamp] = 0;
}
jsonDataSummaryClearsky[timestamp] =
Math.round(
(jsonDataSummaryClearsky[timestamp] + data.watts_clearsky[time] / globalunit) *
1000,
) / 1000;
}
// Temperature and weather_code for summary (first value wins, same for all plants)
if (hasPvnodeData) {
if (data.temperature?.[time] != null && jsonDataSummaryTemp[timestamp] === undefined) {
jsonDataSummaryTemp[timestamp] = data.temperature[time];
}
if (
data.weather_code?.[time] != null &&
jsonDataSummaryWeatherCode[timestamp] === undefined
) {
jsonDataSummaryWeatherCode[timestamp] = data.weather_code[time];
}
}
}
this.log.debug(`generated JSON data of "${plant.name}": ${JSON.stringify(jsonData)}`);
await this.setState(`plants.${cleanPlantId}.JSONData`, {
val: JSON.stringify(jsonData, null, 2),
ack: true,
});
// JSON Table
const jsonTable = [];
let wattindex = 0;
for (const time in data.watts) {
const power = data.watts[time] / globalunit;
const tableRow = {
Time: time,
Power: this.formatValue(power, this.config.watt_kw ? 0 : 3),
};
// Include pvnode-specific fields if available
if (hasPvnodeData) {
if (data.watts_clearsky[time] != null) {
tableRow.Clearsky = this.formatValue(
data.watts_clearsky[time] / globalunit,
this.config.watt_kw ? 0 : 3,
);
}
if (data.temperature?.[time] != null) {
tableRow.Temperature = data.temperature[time];
}
if (data.weather_code?.[time] != null) {
tableRow.WeatherCode = data.weather_code[time];
}
}
jsonTable.push(tableRow);
if (index === 0) {
jsonTableSummary[wattindex] = { Time: time };
jsonTableSummary[wattindex]['Total'] = power;
if (hasPvnodeData) {
jsonTableSummary[wattindex]['TotalClearsky'] = 0;
}
} else {
jsonTableSummary[wattindex]['Total'] = jsonTableSummary[wattindex]['Total'] + power;
}
// Accumulate clearsky for summary table (pvnode only)
if (hasPvnodeData && data.watts_clearsky[time] != null) {
jsonTableSummary[wattindex]['TotalClearsky'] =
(jsonTableSummary[wattindex]['TotalClearsky'] || 0) +
data.watts_clearsky[time] / globalunit;
}
// Temperature and weather_code for summary table (first value wins)
if (hasPvnodeData) {
if (
data.temperature?.[time] != null &&
jsonTableSummary[wattindex]['Temperature'] === undefined
) {
jsonTableSummary[wattindex]['Temperature'] = data.temperature[time];
}
if (
data.weather_code?.[time] != null &&
jsonTableSummary[wattindex]['WeatherCode'] === undefined
) {
jsonTableSummary[wattindex]['WeatherCode'] = data.weather_code[time];
}
}
jsonTableSummary[wattindex][plant.name] = this.formatValue(power, this.config.watt_kw ? 0 : 3);
wattindex++;
}
this.log.debug(`generated JSON table of "${plant.name}": ${JSON.stringify(jsonTable)}`);
await this.setState(`plants.${cleanPlantId}.JSONTable`, {
val: JSON.stringify(jsonTable, null, 2),
ack: true,
});
// JSON Graph
if (this.config.chartingEnabled) {
const jsonGraphData = [];
const jsonGraphLabels = [];
for (const time in data.watts) {
const timeMoment = moment(time);
if (!this.config.chartingJustToday || timeMoment.date() === todaysDate) {
const timeInCustomFormat = timeMoment.format(this.config.chartingLabelFormat);
// Add label if not exists
if (!jsonGraphLabelSummary.includes(timeInCustomFormat)) {
jsonGraphLabelSummary.push(timeInCustomFormat);
}
jsonGraphLabels.push(timeInCustomFormat);
jsonGraphData.push(data.watts[time] / globalunit);
}
}
// https://github.com/Scrounger/ioBroker.vis-materialdesign/blob/master/README.md
const jsonGraph = {
// graph
data: jsonGraphData,
type: 'bar',
legendText: plant.name,
displayOrder: index + 1,
color: plantArray[index].graphcolor,
tooltip_AppendText: this.config.watt_kw ? 'W' : 'kW',
//datalabel_append: this.config.watt_kw ? 'W' : 'kW',
datalabel_show: true,
datalabel_rotation: this.config.chartingRoation,
datalabel_color: plantArray[index].labelcolor,
datalabel_fontSize: this.config.chartingLabelSize,
// graph bar chart spfeicifc
barIsStacked: true,
barStackId: 1,
// graph y-Axis
yAxis_id: 0,
yAxis_position: 'left',
yAxis_show: true,
yAxis_appendix: this.config.watt_kw ? 'W' : 'kW',
yAxis_step: this.config.chartingAxisStepY,
yAxis_max: plantPowerInstalled / globalunit,
yAxis_maximumDigits: this.config.watt_kw ? 0 : 3,
};
jsonGraphSummary.push({
...jsonGraph,
yAxis_max: totalPowerInstalled / globalunit,
});
this.log.debug(`generated JSON graph of "${plant.name}": ${JSON.stringify(jsonGraph)}`);
await this.setState(`plants.${cleanPlantId}.JSONGraph`, {
val: JSON.stringify({ graphs: [jsonGraph], axisLabels: jsonGraphLabels }, null, 2),
ack: true,
});
} else {
await this.setState(`plants.${cleanPlantId}.JSONGraph`, {
val: JSON.stringify({}),
ack: true,
q: 0x02,
c: 'Charting is disabled',
});
}
await this.setState(`plants.${cleanPlantId}.lastUpdated`, { val: moment().valueOf(), ack: true });
this.log.debug(`finished plant update: "${plant.name}"`);
} catch (err) {
this.log.debug(`unable to update "${plant.name}": ${err}`);
}
}
});
this.log.debug('finished plants update');
if (this.config.everyhourEnabled) {
await this.saveEveryHourSummary(TYPE_ENERGY, 'summary.energy.hoursToday', todaysDate);
await this.saveEveryHourSummary(TYPE_ENERGY, 'summary.energy.hoursTomorrow', tomorrowDate);
await this.saveEveryHourSummary(TYPE_POWER, 'summary.power.hoursToday', todaysDate);
await this.saveEveryHourSummary(TYPE_POWER, 'summary.power.hoursTomorrow', tomorrowDate);
this.log.debug(`global time: ${JSON.stringify(this.globalEveryHour)}`);
}
await this.setStateChangedAsync('summary.energy.now', { val: Number(totalEnergyNow / globalunit), ack: true });
await this.setStateChangedAsync('summary.energy.nowUntilEndOfDay', {
val: Number(totalEnergyNowUntilEndOfDay / globalunit),
ack: true,
});
await this.setStateChangedAsync('summary.energy.today', {
val: Number(totalEnergyToday / globalunit),
ack: true,
});
await this.setStateChangedAsync('summary.energy.tomorrow', {
val: Number(totalEnergyTomorrow / globalunit),
ack: true,
});
await this.setStateChangedAsync('summary.power.now', { val: Number(totalPowerNow / globalunit), ack: true });
await this.setStateChangedAsync('summary.power.installed', {
val: Number(totalPowerInstalled / globalunit),
ack: true,
});
// add summary to InfluxDB
await asyncForEach(Object.keys(jsonDataSummary), async time => {
await this.addToInfluxDB(`summary.power`, Number(time), jsonDataSummary[time]);
});
// JSON Data
const hasPvnodeSummary = this.config.service === 'pvnode' && Object.keys(jsonDataSummaryClearsky).length > 0;
const jsonDataSummaryFormat = Object.keys(jsonDataSummary).map(time => {
const entry = {
t: Number(time),
y: jsonDataSummary[time],
};
if (hasPvnodeSummary) {
if (jsonDataSummaryClearsky[time] != null) {
entry.clearsky = jsonDataSummaryClearsky[time];
}
if (jsonDataSummaryTemp[time] != null) {
entry.temp = jsonDataSummaryTemp[time];
}
if (jsonDataSummaryWeatherCode[time] != null) {
entry.weather_code = jsonDataSummaryWeatherCode[time];
}
}
return entry;
});
await this.setState('summary.JSONData', { val: JSON.stringify(jsonDataSummaryFormat, null, 2), ack: true });
// JSON Table
const jsonTableSummaryFormat = jsonTableSummary.map(row => {
row['Total'] = this.formatValue(row['Total'], this.config.watt_kw ? 0 : 3);
if (hasPvnodeSummary && row['TotalClearsky'] != null) {
row['TotalClearsky'] = this.formatValue(row['TotalClearsky'], this.config.watt_kw ? 0 : 3);
}
return row;
});
await this.setState('summary.JSONTable', { val: JSON.stringify(jsonTableSummaryFormat, null, 2), ack: true });
// JSON Graph
if (this.config.chartingEnabled) {
if (!this.config.chartingSummary) {
await this.setState('summary.JSONGraph', {
val: JSON.stringify({ graphs: jsonGraphSummary, axisLabels: jsonGraphLabelSummary }, null, 2),
ack: true,
});
} else {
const jsonGraphSummaryTotal = {
// graph
data: Object.keys(jsonDataSummary)
.filter(time => !this.config.chartingJustToday || moment(Number(time)).date() === todaysDate)
.map(time => jsonDataSummary[time]),
type: 'bar',
legendText: 'Total', // TODO: Configurable ?
displayOrder: 1,
color: this.config.chartingSummaryGraphcolor,
tooltip_AppendText: this.config.watt_kw ? 'W' : 'kW',
//datalabel_append: this.config.watt_kw ? 'W' : 'kW',
datalabel_show: true,
datalabel_rotation: this.config.chartingRoation,
datalabel_color: this.config.chartingSummaryLabelcolor,
datalabel_fontSize: this.config.chartingLabelSize,
// graph y-Axis
yAxis_id: 0,
yAxis_position: 'left',
yAxis_show: true,
yAxis_appendix: this.config.watt_kw ? 'W' : 'kW',
yAxis_step: this.config.chartingAxisStepY,
yAxis_max: totalPowerInstalled / globalunit,
yAxis_maximumDigits: this.config.watt_kw ? 0 : 3,
};
await this.setState('summary.JSONGraph', {
val: JSON.stringify(
{ graphs: [jsonGraphSummaryTotal], axisLabels: jsonGraphLabelSummary },
null,
2,
),
ack: true,
});
}
} else {
await this.setState('summary.JSONGraph', {
val: JSON.stringify({}),
ack: true,
q: 0x02,
c: 'Charting is disabled',
});
}
await this.setState('summary.lastUpdated', { val: moment().valueOf(), ack: true });
await this.updateActualWeatherData();
}
async updateActualWeatherData() {
if (this.hasApiKey && this.config.weatherEnabled) {
try {
const serviceDataState = await this.getStateAsync('weather.service.data');
if (serviceDataState && serviceDataState.val) {
const data = JSON.parse(serviceDataState.val);
if (data?.result) {
const weatherNow = data.result
.filter(weather => moment(weather.datetime).valueOf() < moment().valueOf())
.pop();
if (weatherNow) {
this.log.debug(
`[updateActualWeatherData] filling states with weather info from: ${JSON.stringify(weatherNow)}`,
);
await this.setStateChangedAsync('weather.sky', { val: Number(weatherNow.sky), ack: true });
await this.setStateChangedAsync('weather.datetime', {
val: moment(weatherNow.datetime).valueOf(),
ack: true,
});
await this.setStateChangedAsync('weather.temperature', {
val: Number(weatherNow.temperature),
ack: true,
});
await this.setStateChangedAsync('weather.condition', {
val: weatherNow.condition,
ack: true,
});
await this.setStateChangedAsync('weather.icon', { val: weatherNow.icon, ack: true });
await this.setStateChangedAsync('weather.wind_speed', {
val: Number(weatherNow.wind_speed),
ack: true,
});
await this.setStateChangedAsync('weather.wind_degrees', {
val: Number(weatherNow.wind_degrees),
ack: true,
});
await this.setStateChangedAsync('weather.wind_direction', {
val: weatherNow.wind_direction,
ack: true,
});
}
}
}
} catch (err) {
this.log.error(`[updateActualWeatherData] failed to update weather data: ${err}`);
}
}
}
async updateServiceWeatherData() {
try {
if (this.hasApiKey && this.config.weatherEnabled) {
if (this.config.service === 'forecastsolar') {
// https://api.forecast.solar/:key/weather/:lat/:lon (Professional account only)
const url = `https://api.forecast.solar/${this.config.apiKey}/weather/${this.pvLatitude}/${this.pvLongitude}`;
this.logSensitive(`[updateServiceWeatherData] url (professional account only): ${url}`);
try {
const serviceResponse = await axios.get(url);
this.log.debug(
`[updateServiceWeatherData] received data: ${JSON.stringify(serviceResponse.data)}`,
);
if (serviceResponse) {
await this.setState('weather.service.data', {
val: JSON.stringify(serviceResponse.data.result),
ack: true,
});
await this.setState(`weather.service.lastUpdated`, { val: moment().valueOf(), ack: true });
}
} catch (error) {
if (error === 'Error: Request failed with status code 429') {
this.log.error('too many data requests');
} else if (error === 'Error: Request failed with status code 400') {
this.log.error(
'entry out of range (check the notes in settings) => check azimuth, tilt, longitude,latitude',
);
} else {
this.log.error(`Axios Error ${error}`);
}
}
} else {
this.log.warn(`[updateServiceWeatherData] weather data is just available for "forecastsolar"`);
}
}
} catch (err) {
this.log.error(`[updateServiceWeatherData] error: ${err}`);
}
}
async updateServiceData() {
if (this.config.service === 'pvnode') {
await this.updatePvnodeServiceData();
return;
}
const plantArray = this.getPlantConfigData();
await asyncForEach(plantArray, async plant => {
const cleanPlantId = this.cleanNamespace(plant.name);
let url = '';
let requestHeader = {};
if (this.config.service === 'forecastsolar') {
if (this.hasApiKey) {
// https://api.forecast.solar/:apikey/estimate/:lat/:lon/:dec/:az/:kwp
url = `https://api.forecast.solar/${this.config.apiKey}/estimate/${this.pvLatitude}/${this.pvLongitude}/${plant.tilt}/${plant.azimuth}/${plant.peakpower}?damping_morning=${plant.dampm ?? '0'}&damping_evening=${plant.dampe ?? '0'}&time=utc`;
} else {
// https://api.forecast.solar/estimate/:lat/:lon/:dec/:az/:kwp
url = `https://api.forecast.solar/estimate/${this.pvLatitude}/${this.pvLongitude}/${plant.tilt}/${plant.azimuth}/${plant.peakpower}?damping_morning=${plant.dampm ?? '0'}&damping_evening=${plant.dampe ?? '0'}&time=utc`;
}
} else if (this.config.service === 'solcast') {
// url = `https://api.solcast.com.au/data/forecast/rooftop_pv_power?format=json&hours=48&loss_factor=1&latitude=${this.pvLatitude}&longitude=${this.pvLongitude}&tilt=${plant.tilt}&azimuth=${this.convertAzimuth(plant.azimuth)}&capacity=${plant.peakpower}&period=PT30M&output_parameters=pv_power_rooftop&api_key=${this.config.apiKey}`;
url = `https://api.solcast.com.au/rooftop_sites/${plant.resourceId}/forecasts?format=json&api_key=${this.config.apiKey}`;
requestHeader = {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36',
},
};
} else if (this.config.service === 'spa') {
url = `https://solarenergyprediction.p.rapidapi.com/v2.0/solar/prediction?decoration=forecast.solar&lat=${this.pvLatitude}&lon=${this.pvLongitude}°=${plant.tilt}&az=${plant.azimuth}&wp=${plant.peakpower * 1000}`;
if (this.hasApiKey) {
requestHeader = {
headers: {
'X-RapidAPI-Key': this.config.apiKey,
'X-RapidAPI-Host': 'solarenergyprediction.p.rapidapi.com',
},
};
}
}
if (url) {
// Force update when url changed
const serviceDataUrlState = await this.getStateAsync(`plants.${cleanPlantId}.service.url`);
const lastUrl = serviceDataUrlState && serviceDataUrlState.val ? serviceDataUrlState.val : '';
const serviceDataLastUpdatedState = await this.getStateAsync(
`plants.${cleanPlantId}.service.lastUpdated`,
);
const lastUpdate =
serviceDataLastUpdatedState && serviceDataLastUpdatedState.val
? Number(serviceDataLastUpdatedState.val)
: 0;
this.logSensitive(`plant "${plant.name}" - last update: ${lastUpdate}, service url: ${url}`);
if (lastUrl !== url || !lastUpdate || moment().valueOf() - lastUpdate > 60 * 60 * 1000) {
try {
this.log.debug(`Starting update of ${plant.name}`);
const serviceResponse = await axios.get(url, requestHeader);
this.log.debug(
`received "${this.config.service}" data for plant "${plant.name}": ${JSON.stringify(serviceResponse.data)}`,
);
let data;
let message;
if (this.config.service === 'forecastsolar') {
data = serviceResponse.data.result;
message = serviceResponse.data.message;
this.log.debug(
`rate limit for forecastsolar API: ${message.ratelimit.limit} (${message.ratelimit.remaining} left in period)`,
);
} else if (this.config.service === 'solcast') {
data = solcast.convertToForecast(serviceResponse.data);
this.log.debug(`[parseSolcastToForecast] converted JSON: ${JSON.stringify(data)}`);
message = { info: { place: '-' }, type: 'Solcast' };
} else if (this.config.service === 'spa') {
data = serviceResponse.data.result;
message = serviceResponse.data.message;
}
await this.setState(`plants.${cleanPlantId}.service.url`, { val: url, ack: true });
await this.setState(`plants.${cleanPlantId}.service.data`, {
val: JSON.stringify(data, null, 2),
ack: true,
});
await this.setState(`plants.${cleanPlantId}.service.lastUpdated`, {
val: moment().valueOf(),
ack: true,
});
await this.setState(`plants.${cleanPlantId}.service.message`, { val: message.type, ack: true });
await this.setState(`plants.${cleanPlantId}.place`, { val: message.info.place, ack: true });
} catch (error) {
this.handleServiceError(error);
}