UNPKG

iobroker.pvforecast

Version:
1,084 lines (938 loc) 116 kB
'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}&deg=${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); }