mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[solarforecast] Internal rework (#19815)
* sync and optional rework Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
This commit is contained in:
+2
-7
@@ -14,8 +14,6 @@ package org.openhab.binding.solarforecast.internal;
|
||||
|
||||
import static org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants.*;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.solarforecast.internal.forecastsolar.handler.AdjustableForecastSolarPlaneHandler;
|
||||
@@ -59,7 +57,7 @@ public class SolarForecastHandlerFactory extends BaseThingHandlerFactory {
|
||||
private final TimeZoneProvider timeZoneProvider;
|
||||
private final HttpClientFactory httpClientFactory;
|
||||
private final PersistenceServiceRegistry persistenceRegistry;
|
||||
private Optional<PointType> location = Optional.empty();
|
||||
private @Nullable PointType location;
|
||||
private Storage<String> storage;
|
||||
|
||||
@Activate
|
||||
@@ -70,10 +68,7 @@ public class SolarForecastHandlerFactory extends BaseThingHandlerFactory {
|
||||
timeZoneProvider = tzp;
|
||||
httpClientFactory = hcf;
|
||||
Utils.setTimeZoneProvider(tzp);
|
||||
PointType pt = lp.getLocation();
|
||||
if (pt != null) {
|
||||
location = Optional.of(pt);
|
||||
}
|
||||
location = lp.getLocation();
|
||||
storage = storageService.getStorage(SolarForecastBindingConstants.BINDING_ID);
|
||||
}
|
||||
|
||||
|
||||
+10
@@ -14,6 +14,7 @@ package org.openhab.binding.solarforecast.internal.actions;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
import javax.measure.quantity.Energy;
|
||||
import javax.measure.quantity.Power;
|
||||
@@ -119,4 +120,13 @@ public interface SolarForecast {
|
||||
* @return Instant of the forecast creation
|
||||
*/
|
||||
Instant getCreationInstant();
|
||||
|
||||
/**
|
||||
* Get the SolarForecastAdjuster used for adjustment
|
||||
*
|
||||
* @return SolarForecastAdjuster object
|
||||
*/
|
||||
default Optional<SolarForecastAdjuster> getAdjuster() {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
+65
-96
@@ -14,9 +14,7 @@ package org.openhab.binding.solarforecast.internal.actions;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import javax.measure.MetricPrefix;
|
||||
import javax.measure.quantity.Energy;
|
||||
@@ -45,66 +43,53 @@ import org.slf4j.LoggerFactory;
|
||||
@NonNullByDefault
|
||||
public class SolarForecastActions implements ThingActions {
|
||||
private final Logger logger = LoggerFactory.getLogger(SolarForecastActions.class);
|
||||
private Optional<ThingHandler> thingHandler = Optional.empty();
|
||||
|
||||
private @Nullable ThingHandler thingHandler;
|
||||
|
||||
@RuleAction(label = "@text/actionDayLabel", description = "@text/actionDayDesc")
|
||||
public @ActionOutput(label = "Energy Of Day", type = "QuantityType<Energy>") QuantityType<Energy> getEnergyOfDay(
|
||||
@ActionInput(name = "localDate", label = "@text/actionInputDayLabel", description = "@text/actionInputDayDesc") LocalDate localDate,
|
||||
@ActionInput(name = "args") String... args) {
|
||||
if (thingHandler.isPresent()) {
|
||||
List<SolarForecast> l = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
|
||||
if (!l.isEmpty()) {
|
||||
QuantityType<Energy> measure = QuantityType.valueOf(0, Units.KILOWATT_HOUR);
|
||||
for (Iterator<SolarForecast> iterator = l.iterator(); iterator.hasNext();) {
|
||||
SolarForecast solarForecast = iterator.next();
|
||||
QuantityType<Energy> qt = solarForecast.getDay(localDate, args);
|
||||
if (qt.floatValue() >= 0) {
|
||||
measure = measure.add(qt);
|
||||
} else {
|
||||
// break in case of failure getting values to avoid ambiguous values
|
||||
logger.debug("Ambiguous measure {} found for {}", qt, localDate);
|
||||
return Utils.getEnergyState(-1);
|
||||
}
|
||||
}
|
||||
return measure;
|
||||
} else {
|
||||
logger.debug("No forecasts found for {}", localDate);
|
||||
return Utils.getEnergyState(-1);
|
||||
}
|
||||
} else {
|
||||
logger.trace("Handler missing");
|
||||
List<SolarForecast> forecasts = getProvider().getSolarForecasts();
|
||||
if (forecasts.isEmpty()) {
|
||||
logger.debug("No forecasts found for {}", localDate);
|
||||
return Utils.getEnergyState(-1);
|
||||
}
|
||||
QuantityType<Energy> measure = QuantityType.valueOf(0, Units.KILOWATT_HOUR);
|
||||
for (SolarForecast forecast : forecasts) {
|
||||
QuantityType<Energy> qt = forecast.getDay(localDate, args);
|
||||
if (qt.floatValue() >= 0) {
|
||||
measure = measure.add(qt);
|
||||
} else {
|
||||
// break in case of failure getting values to avoid ambiguous values
|
||||
logger.debug("Ambiguous measure {} found for {}", qt, localDate);
|
||||
return Utils.getEnergyState(-1);
|
||||
}
|
||||
}
|
||||
return measure;
|
||||
}
|
||||
|
||||
@RuleAction(label = "@text/actionPowerLabel", description = "@text/actionPowerDesc")
|
||||
public @ActionOutput(label = "Power", type = "QuantityType<Power>") QuantityType<Power> getPower(
|
||||
@ActionInput(name = "timestamp", label = "@text/actionInputDateTimeLabel", description = "@text/actionInputDateTimeDesc") Instant timestamp,
|
||||
@ActionInput(name = "args") String... args) {
|
||||
if (thingHandler.isPresent()) {
|
||||
List<SolarForecast> l = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
|
||||
if (!l.isEmpty()) {
|
||||
QuantityType<Power> measure = QuantityType.valueOf(0, MetricPrefix.KILO(Units.WATT));
|
||||
for (Iterator<SolarForecast> iterator = l.iterator(); iterator.hasNext();) {
|
||||
SolarForecast solarForecast = iterator.next();
|
||||
QuantityType<Power> qt = solarForecast.getPower(timestamp, args);
|
||||
if (qt.floatValue() >= 0) {
|
||||
measure = measure.add(qt);
|
||||
} else {
|
||||
// break in case of failure getting values to avoid ambiguous values
|
||||
logger.debug("Ambiguous measure {} found for {}", qt, timestamp);
|
||||
return Utils.getPowerState(-1);
|
||||
}
|
||||
}
|
||||
return measure;
|
||||
} else {
|
||||
logger.debug("No forecasts found for {}", timestamp);
|
||||
return Utils.getPowerState(-1);
|
||||
}
|
||||
} else {
|
||||
logger.trace("Handler missing");
|
||||
List<SolarForecast> forecasts = getProvider().getSolarForecasts();
|
||||
if (forecasts.isEmpty()) {
|
||||
logger.debug("No forecasts found for {}", timestamp);
|
||||
return Utils.getPowerState(-1);
|
||||
}
|
||||
QuantityType<Power> measure = QuantityType.valueOf(0, MetricPrefix.KILO(Units.WATT));
|
||||
for (SolarForecast forecast : forecasts) {
|
||||
QuantityType<Power> qt = forecast.getPower(timestamp, args);
|
||||
if (qt.floatValue() >= 0) {
|
||||
measure = measure.add(qt);
|
||||
} else {
|
||||
// break in case of failure getting values to avoid ambiguous values
|
||||
logger.debug("Ambiguous measure {} found for {}", qt, timestamp);
|
||||
return Utils.getPowerState(-1);
|
||||
}
|
||||
}
|
||||
return measure;
|
||||
}
|
||||
|
||||
@RuleAction(label = "@text/actionEnergyLabel", description = "@text/actionEnergyDesc")
|
||||
@@ -112,64 +97,43 @@ public class SolarForecastActions implements ThingActions {
|
||||
@ActionInput(name = "start", label = "@text/actionInputDateTimeBeginLabel", description = "@text/actionInputDateTimeBeginDesc") Instant start,
|
||||
@ActionInput(name = "end", label = "@text/actionInputDateTimeEndLabel", description = "@text/actionInputDateTimeEndDesc") Instant end,
|
||||
@ActionInput(name = "args") String... args) {
|
||||
if (thingHandler.isPresent()) {
|
||||
List<SolarForecast> l = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
|
||||
if (!l.isEmpty()) {
|
||||
QuantityType<Energy> measure = QuantityType.valueOf(0, Units.KILOWATT_HOUR);
|
||||
for (Iterator<SolarForecast> iterator = l.iterator(); iterator.hasNext();) {
|
||||
SolarForecast solarForecast = iterator.next();
|
||||
QuantityType<Energy> qt = solarForecast.getEnergy(start, end, args);
|
||||
if (qt.floatValue() >= 0) {
|
||||
measure = measure.add(qt);
|
||||
} else {
|
||||
// break in case of failure getting values to avoid ambiguous values
|
||||
logger.debug("Ambiguous measure {} found between {} and {}", qt, start, end);
|
||||
return Utils.getEnergyState(-1);
|
||||
}
|
||||
}
|
||||
return measure;
|
||||
} else {
|
||||
logger.debug("No forecasts found for between {} and {}", start, end);
|
||||
return Utils.getEnergyState(-1);
|
||||
}
|
||||
} else {
|
||||
logger.trace("Handler missing");
|
||||
List<SolarForecast> forecasts = getProvider().getSolarForecasts();
|
||||
if (forecasts.isEmpty()) {
|
||||
logger.debug("No forecasts found for between {} and {}", start, end);
|
||||
return Utils.getEnergyState(-1);
|
||||
}
|
||||
QuantityType<Energy> measure = QuantityType.valueOf(0, Units.KILOWATT_HOUR);
|
||||
for (SolarForecast forecast : forecasts) {
|
||||
QuantityType<Energy> qt = forecast.getEnergy(start, end, args);
|
||||
if (qt.floatValue() >= 0) {
|
||||
measure = measure.add(qt);
|
||||
} else {
|
||||
// break in case of failure getting values to avoid ambiguous values
|
||||
logger.debug("Ambiguous measure {} found between {} and {}", qt, start, end);
|
||||
return Utils.getEnergyState(-1);
|
||||
}
|
||||
}
|
||||
return measure;
|
||||
}
|
||||
|
||||
@RuleAction(label = "@text/actionForecastBeginLabel", description = "@text/actionForecastBeginDesc")
|
||||
public @ActionOutput(label = "Forecast Begin", type = "java.time.Instant") Instant getForecastBegin() {
|
||||
if (thingHandler.isPresent()) {
|
||||
List<SolarForecast> forecastObjectList = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
|
||||
return Utils.getCommonStartTime(forecastObjectList);
|
||||
} else {
|
||||
logger.trace("Handler missing - return invalid date MAX");
|
||||
return Instant.MAX;
|
||||
}
|
||||
List<SolarForecast> forecasts = getProvider().getSolarForecasts();
|
||||
return Utils.getCommonStartTime(forecasts);
|
||||
}
|
||||
|
||||
@RuleAction(label = "@text/actionForecastEndLabel", description = "@text/actionForecastEndDesc")
|
||||
public @ActionOutput(label = "Forecast End", type = "java.time.Instant") Instant getForecastEnd() {
|
||||
if (thingHandler.isPresent()) {
|
||||
List<SolarForecast> forecastObjectList = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
|
||||
return Utils.getCommonEndTime(forecastObjectList);
|
||||
} else {
|
||||
logger.trace("Handler missing - return invalid date MIN");
|
||||
return Instant.MIN;
|
||||
}
|
||||
List<SolarForecast> forecasts = getProvider().getSolarForecasts();
|
||||
return Utils.getCommonEndTime(forecasts);
|
||||
}
|
||||
|
||||
@RuleAction(label = "@text/actionTriggerUpdateLabel", description = "@text/actionTriggerUpdateDesc")
|
||||
public void triggerUpdate() {
|
||||
if (thingHandler.isPresent()) {
|
||||
List<SolarForecast> forecastObjectList = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
|
||||
forecastObjectList.forEach(forecast -> {
|
||||
forecast.triggerUpdate();
|
||||
});
|
||||
} else {
|
||||
logger.trace("Handler missing");
|
||||
}
|
||||
List<SolarForecast> forecasts = getProvider().getSolarForecasts();
|
||||
forecasts.forEach(forecast -> {
|
||||
forecast.triggerUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
public static QuantityType<Energy> getEnergyOfDay(ThingActions actions, LocalDate ld, String... args) {
|
||||
@@ -196,16 +160,21 @@ public class SolarForecastActions implements ThingActions {
|
||||
((SolarForecastActions) actions).triggerUpdate();
|
||||
}
|
||||
|
||||
SolarForecastProvider getProvider() {
|
||||
if (thingHandler instanceof SolarForecastProvider provider) {
|
||||
return provider;
|
||||
} else {
|
||||
throw new IllegalStateException("ThingHandler " + thingHandler + " is not a SolarForecastProvider");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setThingHandler(ThingHandler handler) {
|
||||
thingHandler = Optional.of(handler);
|
||||
thingHandler = handler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ThingHandler getThingHandler() {
|
||||
if (thingHandler.isPresent()) {
|
||||
return thingHandler.get();
|
||||
}
|
||||
return null;
|
||||
return thingHandler;
|
||||
}
|
||||
}
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.solarforecast.internal.actions;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link SolarForecastAdjuster} provides the adjustment parameters which are used to calculate the correction
|
||||
* factor
|
||||
*
|
||||
* @author Bernd Weymann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class SolarForecastAdjuster {
|
||||
|
||||
private final String identifier;
|
||||
private final double correctionFactor;
|
||||
private final double inverterEnergy;
|
||||
private final double forecastEnergy;
|
||||
private final boolean isHoldingTimeElapsed;
|
||||
|
||||
public SolarForecastAdjuster(String identifier, double correctionFactor, double inverterEnergy,
|
||||
double forecastEnergy, boolean isHoldingTimeElapsed) {
|
||||
this.identifier = identifier;
|
||||
this.correctionFactor = correctionFactor;
|
||||
this.inverterEnergy = inverterEnergy;
|
||||
this.forecastEnergy = forecastEnergy;
|
||||
this.isHoldingTimeElapsed = isHoldingTimeElapsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the correction factor used to adjust the forecast
|
||||
*
|
||||
* @return correction factor
|
||||
*/
|
||||
public double getCorrectionFactor() {
|
||||
return correctionFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the inverter energy used for adjustment calculation at the time forecast is created
|
||||
*
|
||||
* @return inverter energy as double in kWh
|
||||
*/
|
||||
public double getInverterEnergy() {
|
||||
return inverterEnergy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the forecast energy used for adjustment calculation at the time forecast is created
|
||||
*
|
||||
* @return forecast energy as double in kWh
|
||||
*/
|
||||
public double getForecastEnergy() {
|
||||
return forecastEnergy;
|
||||
}
|
||||
|
||||
public boolean isHoldingTimeElapsed() {
|
||||
return isHoldingTimeElapsed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return identifier + " SolarForecastAdjuster [correctionFactor=" + correctionFactor + ", inverterEnergy="
|
||||
+ inverterEnergy + ", forecastEnergy=" + forecastEnergy + ", isHoldingTimeElapsed="
|
||||
+ isHoldingTimeElapsed + "]";
|
||||
}
|
||||
}
|
||||
+107
-43
@@ -12,6 +12,8 @@
|
||||
*/
|
||||
package org.openhab.binding.solarforecast.internal.forecastsolar;
|
||||
|
||||
import static org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants.PATTERN_FORMAT;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
@@ -30,11 +32,13 @@ import javax.measure.quantity.Energy;
|
||||
import javax.measure.quantity.Power;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
|
||||
import org.openhab.binding.solarforecast.internal.SolarForecastException;
|
||||
import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
|
||||
import org.openhab.binding.solarforecast.internal.actions.SolarForecastAdjuster;
|
||||
import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
|
||||
import org.openhab.binding.solarforecast.internal.utils.Utils;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
@@ -49,6 +53,7 @@ import org.slf4j.LoggerFactory;
|
||||
* @author Bernd Weymann - Initial contribution
|
||||
* @author Bernd Weymann - TimeSeries delivers only future values, otherwise
|
||||
* past values are overwritten
|
||||
* @author Bernd Weymann - Make object immutable
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ForecastSolarObject implements SolarForecast {
|
||||
@@ -56,44 +61,94 @@ public class ForecastSolarObject implements SolarForecast {
|
||||
private final TreeMap<String, Double> wattHourDayMap = new TreeMap<>();
|
||||
private final TreeMap<ZonedDateTime, Double> wattHourMap = new TreeMap<>();
|
||||
private final TreeMap<ZonedDateTime, Double> wattMap = new TreeMap<>();
|
||||
private final DateTimeFormatter dateTimeInputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private final DateTimeFormatter dateTimeInputFormatter = DateTimeFormatter.ofPattern(PATTERN_FORMAT);
|
||||
private final DateTimeFormatter dateInputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
private final @Nullable SolarForecastAdjuster adjuster;
|
||||
private final Instant creationDateTime;
|
||||
private final String identifier;
|
||||
final ZoneId zone;
|
||||
final DateTimeFormatter dateOutputFormatter;
|
||||
|
||||
private DateTimeFormatter dateOutputFormatter = DateTimeFormatter
|
||||
.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT).withZone(ZoneId.systemDefault());
|
||||
private ZoneId zone = ZoneId.systemDefault();
|
||||
private Instant expirationDateTime;
|
||||
private Instant creationDateTime;
|
||||
private String identifier;
|
||||
// mutable expire flag accessed across threads; keep it volatile for visibility
|
||||
private volatile Instant expirationDateTime;
|
||||
|
||||
/**
|
||||
* Constructor delivering an empty expired forecast object
|
||||
*
|
||||
* @param id for logging
|
||||
*/
|
||||
public ForecastSolarObject(String id) {
|
||||
expirationDateTime = Utils.now().minusSeconds(1);
|
||||
creationDateTime = Utils.now();
|
||||
identifier = id;
|
||||
adjuster = null;
|
||||
zone = Utils.getTimeZoneProvider().getTimeZone();
|
||||
dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT).withZone(zone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor to create a new {@link ForecastSolarObject} based on an existing one and an
|
||||
* observed energy production value, applying a calculated correction factor.
|
||||
*
|
||||
* @param other the original {@link ForecastSolarObject} to copy
|
||||
* @param energyProduction the observed or measured energy production used to calculate the correction factor
|
||||
* @param isHoldingTimeElapsed {@code true} if the holding time has elapsed and the correction factor
|
||||
* should be applied immediately, {@code false} if it should only be prepared
|
||||
*/
|
||||
public ForecastSolarObject(ForecastSolarObject other, double energyProduction, boolean isHoldingTimeElapsed) {
|
||||
identifier = other.getIdentifier();
|
||||
zone = other.zone;
|
||||
dateOutputFormatter = other.dateOutputFormatter;
|
||||
creationDateTime = other.getCreationInstant();
|
||||
expirationDateTime = other.expirationDateTime;
|
||||
wattHourDayMap.putAll(other.wattHourDayMap);
|
||||
wattHourMap.putAll(other.wattHourMap);
|
||||
wattMap.putAll(other.wattMap);
|
||||
|
||||
// calculate correction factor
|
||||
double correctionFactor = 1;
|
||||
double forecastEnergyProduction = getActualEnergyValue(ZonedDateTime.now(Utils.getClock()));
|
||||
if (forecastEnergyProduction > 0) {
|
||||
correctionFactor = energyProduction / forecastEnergyProduction;
|
||||
adjuster = new SolarForecastAdjuster(identifier, correctionFactor, energyProduction,
|
||||
forecastEnergyProduction, isHoldingTimeElapsed);
|
||||
} else {
|
||||
// ensure adjuster is available even if no forecast energy production is available
|
||||
adjuster = new SolarForecastAdjuster(identifier, correctionFactor, energyProduction, 0, false);
|
||||
}
|
||||
// only set factor after holding time elapsed
|
||||
if (isHoldingTimeElapsed) {
|
||||
setCorrectionFactor(correctionFactor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor to create a new ForecastSolarObject from JSON content
|
||||
*
|
||||
* @param id for logging
|
||||
* @param content JSON content as string
|
||||
* @param expirationDate expiration date time
|
||||
* @throws SolarForecastException in case of parsing errors
|
||||
*/
|
||||
public ForecastSolarObject(String id, String content, Instant expirationDate) throws SolarForecastException {
|
||||
expirationDateTime = expirationDate;
|
||||
creationDateTime = Utils.now();
|
||||
identifier = id;
|
||||
adjuster = null;
|
||||
if (!content.isEmpty()) {
|
||||
try {
|
||||
JSONObject contentJson = new JSONObject(content);
|
||||
JSONObject resultJson = contentJson.getJSONObject("result");
|
||||
|
||||
// first get daily production values
|
||||
JSONObject wattsDay = resultJson.getJSONObject("watt_hours_day");
|
||||
JSONObject wattsDay = Utils.getJSONObjectFrom("result/watt_hours_day", contentJson, false);
|
||||
wattsDay.keys().forEachRemaining(date -> {
|
||||
wattHourDayMap.put(date, wattsDay.getDouble(date) / 1000.0);
|
||||
});
|
||||
|
||||
// fill map with hourly production and power values
|
||||
JSONObject wattHourJson = resultJson.getJSONObject("watt_hours");
|
||||
JSONObject wattJson = resultJson.getJSONObject("watts");
|
||||
String zoneStr = contentJson.getJSONObject("message").getJSONObject("info").getString("timezone");
|
||||
zone = ZoneId.of(zoneStr);
|
||||
dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT)
|
||||
.withZone(zone);
|
||||
JSONObject wattHourJson = Utils.getJSONObjectFrom("result/watt_hours", contentJson, false);
|
||||
JSONObject wattJson = Utils.getJSONObjectFrom("result/watts", contentJson, false);
|
||||
zone = ZoneId.of(Utils.getPropertyFrom("message/info/timezone", contentJson, false));
|
||||
Iterator<String> iter = wattHourJson.keys();
|
||||
// put all values of the current day into sorted tree map
|
||||
while (iter.hasNext()) {
|
||||
@@ -104,21 +159,26 @@ public class ForecastSolarObject implements SolarForecast {
|
||||
wattHourMap.put(zdt, wattHourJson.getDouble(dateStr));
|
||||
wattMap.put(zdt, wattJson.getDouble(dateStr));
|
||||
} catch (DateTimeParseException dtpe) {
|
||||
logger.warn("Error parsing time {} Reason: {}", dateStr, dtpe.getMessage());
|
||||
logger.warn("{} Error parsing time {} Reason: {}", identifier, dateStr, dtpe.getMessage());
|
||||
throw new SolarForecastException(this,
|
||||
"Error parsing time " + dateStr + " Reason: " + dtpe.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// log ratelimit if available
|
||||
JSONObject messageJson = contentJson.getJSONObject("message");
|
||||
JSONObject rateLimitJson = messageJson.getJSONObject("ratelimit");
|
||||
logger.debug("Rate limit: {}/{}", rateLimitJson.getInt("remaining"), rateLimitJson.getInt("limit"));
|
||||
if (logger.isDebugEnabled()) {
|
||||
// log rate limit if available
|
||||
logger.debug("{} Rate limit: {}/{}", identifier,
|
||||
Utils.getPropertyFrom("message/ratelimit/remaining", contentJson, true),
|
||||
Utils.getPropertyFrom("message/ratelimit/limit", contentJson, true));
|
||||
}
|
||||
} catch (JSONException je) {
|
||||
throw new SolarForecastException(this,
|
||||
"Error parsing JSON response " + content + " Reason: " + je.getMessage());
|
||||
}
|
||||
} else {
|
||||
zone = Utils.getTimeZoneProvider().getTimeZone();
|
||||
}
|
||||
dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT).withZone(zone);
|
||||
}
|
||||
|
||||
public boolean isExpired() {
|
||||
@@ -269,15 +329,6 @@ public class ForecastSolarObject implements SolarForecast {
|
||||
return daily - actual;
|
||||
}
|
||||
|
||||
public ZoneId getZone() {
|
||||
return zone;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Expiration: " + expirationDateTime + ", Data:" + wattHourMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* SolarForecast Interface
|
||||
*/
|
||||
@@ -366,7 +417,8 @@ public class ForecastSolarObject implements SolarForecast {
|
||||
throw new SolarForecastException(this,
|
||||
"Query " + dateOutputFormatter.format(query) + " too late. " + getTimeRange());
|
||||
} else {
|
||||
logger.warn("Query {} is fine. {}", dateOutputFormatter.format(query), getTimeRange());
|
||||
logger.info("{} Query {} inside {} but no data found", identifier, dateOutputFormatter.format(query),
|
||||
getTimeRange());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,12 +433,14 @@ public class ForecastSolarObject implements SolarForecast {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the correction factor for the forecast from now on, not for past values. This is used to adjust the forecast
|
||||
* based on actual production.
|
||||
* Sets the correction factor for the forecast from
|
||||
* - now on (not for past values)
|
||||
* - till the end of day (not for next days)
|
||||
* Used to adjust the forecast based on actual production.
|
||||
*
|
||||
* @param factor The correction factor to apply.
|
||||
*/
|
||||
public void setCorrectionFactor(double factor) {
|
||||
private void setCorrectionFactor(double factor) {
|
||||
ZonedDateTime startCorrection = ZonedDateTime.now(Utils.getClock()).toLocalDate().atStartOfDay(zone);
|
||||
ZonedDateTime endCorrection = startCorrection.toLocalDate().plusDays(1).atStartOfDay(zone);
|
||||
|
||||
@@ -396,20 +450,30 @@ public class ForecastSolarObject implements SolarForecast {
|
||||
wattHourDayMap.put(dayKey, dayProduction * factor);
|
||||
}
|
||||
|
||||
wattHourMap.forEach((timestamp, value) -> {
|
||||
if (timestamp.isAfter(startCorrection) && timestamp.isBefore(endCorrection)) {
|
||||
wattHourMap.put(timestamp, value * factor);
|
||||
}
|
||||
});
|
||||
wattMap.forEach((timestamp, value) -> {
|
||||
if (timestamp.isAfter(startCorrection) && timestamp.isBefore(endCorrection)) {
|
||||
wattMap.put(timestamp, value * factor);
|
||||
}
|
||||
});
|
||||
wattHourMap.replaceAll(
|
||||
(timestamp, value) -> (timestamp.isAfter(startCorrection) && timestamp.isBefore(endCorrection))
|
||||
? value * factor
|
||||
: value);
|
||||
wattMap.replaceAll(
|
||||
(timestamp, value) -> (timestamp.isAfter(startCorrection) && timestamp.isBefore(endCorrection))
|
||||
? value * factor
|
||||
: value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant getCreationInstant() {
|
||||
return creationDateTime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return identifier + " from " + getForecastBegin() + " to " + getForecastEnd() + " data size "
|
||||
+ wattHourMap.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<SolarForecastAdjuster> getAdjuster() {
|
||||
SolarForecastAdjuster localAdjuster = adjuster;
|
||||
return (localAdjuster != null) ? Optional.of(localAdjuster) : Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
+39
-53
@@ -12,20 +12,18 @@
|
||||
*/
|
||||
package org.openhab.binding.solarforecast.internal.forecastsolar.handler;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.openhab.binding.solarforecast.internal.forecastsolar.config.ForecastSolarPlaneConfiguration;
|
||||
import org.openhab.binding.solarforecast.internal.utils.Utils;
|
||||
import org.openhab.core.persistence.PersistenceService;
|
||||
import org.openhab.core.persistence.PersistenceServiceRegistry;
|
||||
import org.openhab.core.persistence.QueryablePersistenceService;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -40,7 +38,7 @@ public class AdjustableForecastSolarPlaneHandler extends ForecastSolarPlaneHandl
|
||||
private final Logger logger = LoggerFactory.getLogger(AdjustableForecastSolarPlaneHandler.class);
|
||||
protected final PersistenceServiceRegistry persistenceRegistry;
|
||||
|
||||
protected Optional<QueryablePersistenceService> persistenceService = Optional.empty();
|
||||
protected @Nullable QueryablePersistenceService persistenceService;
|
||||
|
||||
public AdjustableForecastSolarPlaneHandler(Thing thing, HttpClient hc, PersistenceServiceRegistry psr) {
|
||||
super(thing, hc);
|
||||
@@ -49,80 +47,68 @@ public class AdjustableForecastSolarPlaneHandler extends ForecastSolarPlaneHandl
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
if (super.doInitialize()) {
|
||||
if (!configuration.calculationItemName.isBlank()) {
|
||||
PersistenceService service = persistenceRegistry.get(configuration.calculationItemPersistence);
|
||||
if (service != null) {
|
||||
if (service instanceof QueryablePersistenceService queryService) {
|
||||
if (Utils.checkPersistence(configuration.calculationItemName, queryService)) {
|
||||
persistenceService = Optional.of(queryService);
|
||||
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE,
|
||||
"@text/solarforecast.plane.status.await-feedback");
|
||||
bridgeHandler.ifPresentOrElse(handler -> {
|
||||
handler.addPlane(this);
|
||||
}, () -> {
|
||||
// bridge handler is not available, so we cannot add the plane
|
||||
configErrorStatus("@text/solarforecast.plane.status.bridge-handler-not-found");
|
||||
});
|
||||
} else {
|
||||
// item not found in persistence
|
||||
configErrorStatus("@text/solarforecast.plane.status.item-not-in-persistence" + " [\""
|
||||
+ configuration.calculationItemName + "\", \""
|
||||
+ configuration.calculationItemPersistence + "\"]");
|
||||
}
|
||||
configuration = getConfigAs(ForecastSolarPlaneConfiguration.class);
|
||||
if (!configuration.calculationItemName.isBlank()) {
|
||||
PersistenceService service = persistenceRegistry.get(configuration.calculationItemPersistence);
|
||||
if (service != null) {
|
||||
if (service instanceof QueryablePersistenceService queryService) {
|
||||
if (Utils.checkPersistence(configuration.calculationItemName, queryService)) {
|
||||
persistenceService = queryService;
|
||||
super.initialize();
|
||||
} else {
|
||||
// persistence service not queryable
|
||||
configErrorStatus("@text/solarforecast.plane.status.persistence-not-queryable" + " [\""
|
||||
// item not found in persistence
|
||||
configErrorStatus("@text/solarforecast.plane.status.item-not-in-persistence" + " [\""
|
||||
+ configuration.calculationItemName + "\", \""
|
||||
+ configuration.calculationItemPersistence + "\"]");
|
||||
}
|
||||
} else {
|
||||
// persistence service not found
|
||||
configErrorStatus("@text/solarforecast.plane.status.persistence-not-found" + " [\""
|
||||
// persistence service cannot be queried
|
||||
configErrorStatus("@text/solarforecast.plane.status.persistence-not-queryable" + " [\""
|
||||
+ configuration.calculationItemPersistence + "\"]");
|
||||
}
|
||||
} else {
|
||||
// calculation item not configured
|
||||
configErrorStatus("@text/solarforecast.plane.status.item-not-found" + " [\""
|
||||
+ configuration.calculationItemName + "\"]");
|
||||
// persistence service not found
|
||||
configErrorStatus("@text/solarforecast.plane.status.persistence-not-found" + " [\""
|
||||
+ configuration.calculationItemPersistence + "\"]");
|
||||
}
|
||||
} else {
|
||||
// calculation item not configured
|
||||
configErrorStatus("@text/solarforecast.plane.status.item-not-found" + " [\""
|
||||
+ configuration.calculationItemName + "\"]");
|
||||
}
|
||||
// else initialization failed already in super.doInitialize()
|
||||
}
|
||||
|
||||
@Override
|
||||
/**
|
||||
* Adds actual energy production to the query parameters if holding time has passed.
|
||||
*/
|
||||
protected Map<String, String> queryParameters() {
|
||||
Map<String, String> parameters = super.queryParameters();
|
||||
|
||||
protected void queryParameters(Map<String, String> parameters) {
|
||||
// add parameters from super class
|
||||
super.queryParameters(parameters);
|
||||
// add parameters from config
|
||||
if (isHoldingTimeElapsed()) {
|
||||
if (!configuration.calculationItemName.isBlank() && persistenceService.isPresent() && apiKey.isPresent()) {
|
||||
if (!configuration.calculationItemName.isBlank() && persistenceService != null) {
|
||||
// https://doc.forecast.solar/actual
|
||||
Optional<Double> energyCalculation = Utils.getEnergyTillNow(configuration.calculationItemName,
|
||||
persistenceService.get());
|
||||
parameters.put("actual", String.valueOf(energyCalculation.orElse(0.0)));
|
||||
persistenceService);
|
||||
energyCalculation.ifPresentOrElse(value -> {
|
||||
parameters.put("actual", String.valueOf(value));
|
||||
}, () -> {
|
||||
logger.debug("Add reset parameters - failed to calculate energy from item {} in persistence {}",
|
||||
configuration.calculationItemName, persistenceService);
|
||||
parameters.put("actual", "0");
|
||||
});
|
||||
} else {
|
||||
logger.debug("Add reset parameters - config missing calculationItem, persistence or API key");
|
||||
logger.debug("Add reset parameters - config missing for item {} in persistence {}",
|
||||
configuration.calculationItemName, persistenceService);
|
||||
parameters.put("actual", "0");
|
||||
}
|
||||
} else {
|
||||
logger.debug("Holding time not elapsed, no adjustment of forecast");
|
||||
}
|
||||
return parameters;
|
||||
}
|
||||
|
||||
public boolean isHoldingTimeElapsed() {
|
||||
Optional<Instant> firstMeasure = forecast.getFirstPowerTimestamp();
|
||||
if (firstMeasure.isPresent()) {
|
||||
return Instant.now(Utils.getClock())
|
||||
.isAfter(firstMeasure.get().plus(configuration.holdingTime, ChronoUnit.MINUTES));
|
||||
}
|
||||
if (!forecast.isEmpty()) {
|
||||
logger.warn("No adjustment possible: Unable to find first measure in forecast");
|
||||
} else {
|
||||
logger.debug("Forecast is empty, no first measure available");
|
||||
}
|
||||
return false;
|
||||
return Utils.isHoldingTimeElapsed(getForecast(), configuration.holdingTime);
|
||||
}
|
||||
}
|
||||
|
||||
+208
-143
@@ -18,16 +18,18 @@ import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.solarforecast.internal.SolarForecastException;
|
||||
import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
|
||||
import org.openhab.binding.solarforecast.internal.actions.SolarForecastActions;
|
||||
@@ -36,6 +38,7 @@ import org.openhab.binding.solarforecast.internal.forecastsolar.ForecastSolarObj
|
||||
import org.openhab.binding.solarforecast.internal.forecastsolar.config.ForecastSolarBridgeConfiguration;
|
||||
import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
|
||||
import org.openhab.binding.solarforecast.internal.utils.Utils;
|
||||
import org.openhab.core.common.ThreadPoolManager;
|
||||
import org.openhab.core.config.core.Configuration;
|
||||
import org.openhab.core.library.types.PointType;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
@@ -48,7 +51,6 @@ import org.openhab.core.thing.binding.ThingHandlerService;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.RefreshType;
|
||||
import org.openhab.core.types.TimeSeries;
|
||||
import org.openhab.core.types.TimeSeries.Policy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -60,69 +62,62 @@ import org.slf4j.LoggerFactory;
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ForecastSolarBridgeHandler extends BaseBridgeHandler implements SolarForecastProvider {
|
||||
private static final String BASE_URL = "https://api.forecast.solar/";
|
||||
private static final int CALM_DOWN_TIME_MINUTES = 61;
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(ForecastSolarBridgeHandler.class);
|
||||
private final AtomicBoolean updateTimeseriesNeeded = new AtomicBoolean(true);
|
||||
private final ScheduledExecutorService sequentialScheduler = ThreadPoolManager
|
||||
.getPoolBasedSequentialScheduledExecutorService(BINDING_ID, "thingHandler");
|
||||
|
||||
private ForecastSolarBridgeConfiguration configuration = new ForecastSolarBridgeConfiguration();
|
||||
private Optional<ScheduledFuture<?>> refreshJob = Optional.empty();
|
||||
private Optional<PointType> homeLocation;
|
||||
private Instant calmDownEnd = Instant.MIN;
|
||||
private @Nullable ScheduledFuture<?> refreshJob;
|
||||
private @Nullable PointType homeLocation;
|
||||
|
||||
protected List<ForecastSolarPlaneHandler> planes = new ArrayList<>();
|
||||
protected CopyOnWriteArrayList<ForecastSolarPlaneHandler> planes = new CopyOnWriteArrayList<>();
|
||||
|
||||
public ForecastSolarBridgeHandler(Bridge bridge, Optional<PointType> location) {
|
||||
public ForecastSolarBridgeHandler(Bridge bridge, @Nullable PointType location) {
|
||||
super(bridge);
|
||||
homeLocation = location;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||
return List.of(SolarForecastActions.class);
|
||||
}
|
||||
/**
|
||||
* #####################
|
||||
* Handler functionality
|
||||
* #####################
|
||||
*/
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
configuration = getConfigAs(ForecastSolarBridgeConfiguration.class);
|
||||
PointType locationConfigured;
|
||||
|
||||
// handle location error cases
|
||||
if (configuration.location.isBlank()) {
|
||||
if (homeLocation.isEmpty()) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/solarforecast.site.status.location-missing");
|
||||
return;
|
||||
} else {
|
||||
locationConfigured = homeLocation.get();
|
||||
// continue with openHAB location
|
||||
}
|
||||
} else {
|
||||
if (!configuration.location.isBlank()) {
|
||||
// if configuration location is set, it has precedence
|
||||
try {
|
||||
locationConfigured = new PointType(configuration.location);
|
||||
homeLocation = new PointType(configuration.location);
|
||||
// continue with location from configuration
|
||||
} catch (Exception e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
|
||||
return;
|
||||
}
|
||||
}
|
||||
// handle location error cases
|
||||
PointType localHomeLocation = homeLocation;
|
||||
if (localHomeLocation == null) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/solarforecast.site.status.location-missing");
|
||||
return;
|
||||
}
|
||||
|
||||
// update configuration with location
|
||||
Configuration editConfig = editConfiguration();
|
||||
editConfig.put("location", locationConfigured.toString());
|
||||
editConfig.put("location", localHomeLocation.toString());
|
||||
updateConfiguration(editConfig);
|
||||
configuration = getConfigAs(ForecastSolarBridgeConfiguration.class);
|
||||
|
||||
// update attached planes with changed parameters
|
||||
planes.forEach(plane -> {
|
||||
plane.setLocation(new PointType(configuration.location));
|
||||
if (!configuration.apiKey.isBlank()) {
|
||||
plane.setApiKey(configuration.apiKey);
|
||||
}
|
||||
});
|
||||
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
refreshJob = Optional
|
||||
.of(scheduler.scheduleWithFixedDelay(this::getData, 0, REFRESH_ACTUAL_INTERVAL, TimeUnit.MINUTES));
|
||||
refreshJob = sequentialScheduler.scheduleWithFixedDelay(this::updateData, 0, REFRESH_ACTUAL_INTERVAL,
|
||||
TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -134,136 +129,206 @@ public class ForecastSolarBridgeHandler extends BaseBridgeHandler implements Sol
|
||||
case CHANNEL_ENERGY_REMAIN:
|
||||
case CHANNEL_ENERGY_TODAY:
|
||||
case CHANNEL_POWER_ACTUAL:
|
||||
getData();
|
||||
sequentialScheduler.execute(this::updateData);
|
||||
break;
|
||||
case CHANNEL_POWER_ESTIMATE:
|
||||
case CHANNEL_ENERGY_ESTIMATE:
|
||||
forecastUpdate();
|
||||
updateTimeseriesNeeded.set(true);
|
||||
sequentialScheduler.execute(this::updateData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
ScheduledFuture<?> localRefreshJob = refreshJob;
|
||||
if (localRefreshJob != null) {
|
||||
localRefreshJob.cancel(true);
|
||||
refreshJob = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void addPlane(ForecastSolarPlaneHandler planeHandler) {
|
||||
logger.trace("Adding plane {}", planeHandler.getThing().getLabel());
|
||||
sequentialScheduler.execute(() -> planes.addIfAbsent(planeHandler));
|
||||
sequentialScheduler.execute(this::updateData);
|
||||
}
|
||||
|
||||
public void removePlane(ForecastSolarPlaneHandler planeHandler) {
|
||||
logger.trace("Removing plane {}", planeHandler.getThing().getLabel());
|
||||
sequentialScheduler.execute(() -> planes.remove(planeHandler));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data for all planes. Synchronized to protect plane list from being modified during update
|
||||
* #####################
|
||||
* Forecast functionality
|
||||
* #####################
|
||||
*/
|
||||
protected synchronized void getData() {
|
||||
|
||||
/**
|
||||
* Callback of refreshJob to update all data, nowhere else called
|
||||
* 1) Check for planes
|
||||
* 2) Update all planes
|
||||
* 3) Update channels
|
||||
* 4) Update timeseries if needed
|
||||
*/
|
||||
protected void updateData() {
|
||||
// 1) check if there are planes attached return immediately if not
|
||||
if (planes.isEmpty()) {
|
||||
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NOT_YET_READY,
|
||||
"@text/solarforecast.site.status.no-planes");
|
||||
return;
|
||||
}
|
||||
// 2) all planes update their data, dirty flags inside each handler is set if new forecast was fetched
|
||||
if (!isInCalmDownPeriod()) {
|
||||
planes.forEach(planeHandler -> {
|
||||
planeHandler.updateData();
|
||||
});
|
||||
}
|
||||
try {
|
||||
// 3) update channels each time with actual data
|
||||
updateChannels();
|
||||
// 4) only update timeseries if bridge or any plane indicates that an update is needed
|
||||
if (updateTimeseriesNeeded.getAndSet(false)
|
||||
|| planes.stream().anyMatch(plane -> plane.isTimeseriesUpdateNeeded())) {
|
||||
updateTimeseries();
|
||||
}
|
||||
} catch (SolarForecastException sfe) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
|
||||
"@text/solarforecast.site.status.exception [\"" + sfe.getMessage() + "\"]");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the actual channels from all planes.
|
||||
*/
|
||||
protected void updateChannels() {
|
||||
double energySum = 0;
|
||||
double powerSum = 0;
|
||||
double daySum = 0;
|
||||
List<ForecastSolarObject> forecastObjects = getForecasts();
|
||||
ZonedDateTime now = Utils.getZdtFromUTC(Utils.now());
|
||||
for (ForecastSolarObject forecast : forecastObjects) {
|
||||
energySum += forecast.getActualEnergyValue(now);
|
||||
powerSum += forecast.getActualPowerValue(now);
|
||||
daySum += forecast.getDayTotal(now.toLocalDate());
|
||||
}
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
updateState(CHANNEL_ENERGY_ACTUAL, Utils.getEnergyState(energySum));
|
||||
// during unit tests there's the possibility of slight negative values when adding up sums
|
||||
// 2026-01-05 22:03:55.147 [TRACE] [r.handler.ForecastSolarBridgeHandler] - Actual 1.0039800206714415 Day
|
||||
// 1.0039800206714413 Diff -2.220446049250313E-16
|
||||
// avoid negative remaining energy due to rounding issues
|
||||
double remainingEnergy = Math.max(0, daySum - energySum);
|
||||
updateState(CHANNEL_ENERGY_REMAIN, Utils.getEnergyState(remainingEnergy));
|
||||
updateState(CHANNEL_ENERGY_TODAY, Utils.getEnergyState(daySum));
|
||||
updateState(CHANNEL_POWER_ACTUAL, Utils.getPowerState(powerSum));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update of the forecasted timeseries from all planes
|
||||
*/
|
||||
protected void updateTimeseries() {
|
||||
TreeMap<Instant, QuantityType<?>> combinedPowerForecast = new TreeMap<>();
|
||||
TreeMap<Instant, QuantityType<?>> combinedEnergyForecast = new TreeMap<>();
|
||||
List<SolarForecast> forecastObjects = getSolarForecasts();
|
||||
// bugfix: https://github.com/weymann/OH3-SolarForecast-Drops/issues/5
|
||||
// find common start and end time which fits to all forecast objects to avoid ambiguous values
|
||||
final Instant commonStart = Utils.getCommonStartTime(forecastObjects);
|
||||
final Instant commonEnd = Utils.getCommonEndTime(forecastObjects);
|
||||
for (SolarForecast fo : forecastObjects) {
|
||||
TimeSeries powerTS = fo.getPowerTimeSeries(QueryMode.Average);
|
||||
Utils.addAll(combinedPowerForecast, powerTS, commonStart, commonEnd);
|
||||
TimeSeries energyTS = fo.getEnergyTimeSeries(QueryMode.Average);
|
||||
Utils.addAll(combinedEnergyForecast, energyTS, commonStart, commonEnd);
|
||||
}
|
||||
sendTimeSeries(CHANNEL_POWER_ESTIMATE, Utils.toTimeseries(combinedPowerForecast));
|
||||
sendTimeSeries(CHANNEL_ENERGY_ESTIMATE, Utils.toTimeseries(combinedEnergyForecast));
|
||||
}
|
||||
|
||||
public List<ForecastSolarObject> getForecasts() {
|
||||
return planes.stream().map(plane -> plane.getForecast()).toList();
|
||||
}
|
||||
|
||||
private boolean isInCalmDownPeriod() {
|
||||
if (calmDownEnd.isAfter(Utils.now())) {
|
||||
// wait until calm down time is expired
|
||||
long minutes = Duration.between(Utils.now(), calmDownEnd).toMinutes();
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/solarforecast.site.status.calmdown [\"" + minutes + "\"]");
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
boolean update = true;
|
||||
double energySum = 0;
|
||||
double powerSum = 0;
|
||||
double daySum = 0;
|
||||
for (Iterator<ForecastSolarPlaneHandler> iterator = planes.iterator(); iterator.hasNext();) {
|
||||
try {
|
||||
ForecastSolarPlaneHandler sfph = iterator.next();
|
||||
ForecastSolarObject fo = sfph.fetchData();
|
||||
ZonedDateTime now = ZonedDateTime.now(Utils.getClock());
|
||||
energySum += fo.getActualEnergyValue(now);
|
||||
powerSum += fo.getActualPowerValue(now);
|
||||
daySum += fo.getDayTotal(now.toLocalDate());
|
||||
} catch (SolarForecastException sfe) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
|
||||
"@text/solarforecast.site.status.exception [\"" + sfe.getMessage() + "\"]");
|
||||
update = false;
|
||||
}
|
||||
}
|
||||
if (update) {
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
updateState(CHANNEL_ENERGY_ACTUAL, Utils.getEnergyState(energySum));
|
||||
updateState(CHANNEL_ENERGY_REMAIN, Utils.getEnergyState(daySum - energySum));
|
||||
updateState(CHANNEL_ENERGY_TODAY, Utils.getEnergyState(daySum));
|
||||
updateState(CHANNEL_POWER_ACTUAL, Utils.getPowerState(powerSum));
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void forecastUpdate() {
|
||||
if (planes.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
TreeMap<Instant, QuantityType<?>> combinedPowerForecast = new TreeMap<>();
|
||||
TreeMap<Instant, QuantityType<?>> combinedEnergyForecast = new TreeMap<>();
|
||||
List<SolarForecast> forecastObjects = new ArrayList<>();
|
||||
for (Iterator<ForecastSolarPlaneHandler> iterator = planes.iterator(); iterator.hasNext();) {
|
||||
ForecastSolarPlaneHandler sfph = iterator.next();
|
||||
forecastObjects.addAll(sfph.getSolarForecasts());
|
||||
}
|
||||
|
||||
// bugfix: https://github.com/weymann/OH3-SolarForecast-Drops/issues/5
|
||||
// find common start and end time which fits to all forecast objects to avoid ambiguous values
|
||||
final Instant commonStart = Utils.getCommonStartTime(forecastObjects);
|
||||
final Instant commonEnd = Utils.getCommonEndTime(forecastObjects);
|
||||
forecastObjects.forEach(fc -> {
|
||||
TimeSeries powerTS = fc.getPowerTimeSeries(QueryMode.Average);
|
||||
powerTS.getStates().forEach(entry -> {
|
||||
if (Utils.isAfterOrEqual(entry.timestamp(), commonStart)
|
||||
&& Utils.isBeforeOrEqual(entry.timestamp(), commonEnd)) {
|
||||
Utils.addState(combinedPowerForecast, entry);
|
||||
}
|
||||
});
|
||||
TimeSeries energyTS = fc.getEnergyTimeSeries(QueryMode.Average);
|
||||
energyTS.getStates().forEach(entry -> {
|
||||
if (Utils.isAfterOrEqual(entry.timestamp(), commonStart)
|
||||
&& Utils.isBeforeOrEqual(entry.timestamp(), commonEnd)) {
|
||||
Utils.addState(combinedEnergyForecast, entry);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
TimeSeries powerSeries = new TimeSeries(Policy.REPLACE);
|
||||
combinedPowerForecast.forEach((timestamp, state) -> {
|
||||
powerSeries.add(timestamp, state);
|
||||
});
|
||||
sendTimeSeries(CHANNEL_POWER_ESTIMATE, powerSeries);
|
||||
|
||||
TimeSeries energySeries = new TimeSeries(Policy.REPLACE);
|
||||
combinedEnergyForecast.forEach((timestamp, state) -> {
|
||||
energySeries.add(timestamp, state);
|
||||
});
|
||||
sendTimeSeries(CHANNEL_ENERGY_ESTIMATE, energySeries);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
refreshJob.ifPresent(job -> job.cancel(true));
|
||||
}
|
||||
|
||||
public synchronized void addPlane(ForecastSolarPlaneHandler sfph) {
|
||||
logger.trace("Adding plane {}", sfph.getThing().getUID());
|
||||
planes.add(sfph);
|
||||
// update passive PV plane with necessary data
|
||||
sfph.setLocation(new PointType(configuration.location));
|
||||
if (!configuration.apiKey.isBlank()) {
|
||||
sfph.setApiKey(configuration.apiKey);
|
||||
}
|
||||
getData();
|
||||
}
|
||||
|
||||
public synchronized void removePlane(ForecastSolarPlaneHandler sfph) {
|
||||
logger.trace("Removing plane {}", sfph.getThing().getUID());
|
||||
planes.remove(sfph);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized List<SolarForecast> getSolarForecasts() {
|
||||
List<SolarForecast> l = new ArrayList<SolarForecast>();
|
||||
planes.forEach(entry -> {
|
||||
l.addAll(entry.getSolarForecasts());
|
||||
});
|
||||
return l;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void calmDown() {
|
||||
calmDownEnd = Utils.now().plus(CALM_DOWN_TIME_MINUTES, ChronoUnit.MINUTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* #####################
|
||||
* Helper functionality
|
||||
* #####################
|
||||
*/
|
||||
|
||||
private PointType location() {
|
||||
PointType localHomeLocation = homeLocation;
|
||||
if (localHomeLocation == null) {
|
||||
throw new IllegalStateException("Location is not set");
|
||||
}
|
||||
return localHomeLocation;
|
||||
}
|
||||
|
||||
ScheduledExecutorService getSequentialScheduler() {
|
||||
return sequentialScheduler;
|
||||
}
|
||||
|
||||
/**
|
||||
* #####################
|
||||
* URL functionality
|
||||
* #####################
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculates base URL for API access with
|
||||
* - api key if available
|
||||
* - latitude and longitude from location
|
||||
*
|
||||
* @return base URL as String
|
||||
*/
|
||||
public String getBaseUrl() {
|
||||
String url = BASE_URL;
|
||||
if (!configuration.apiKey.isBlank()) {
|
||||
url += configuration.apiKey + SLASH;
|
||||
}
|
||||
return url + "estimate/" + location().getLatitude() + SLASH + location().getLongitude() + SLASH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to add inverter kWp parameter if configured
|
||||
*
|
||||
* @param Mutable map of parameters to add inverter kWp
|
||||
*/
|
||||
void queryParameters(Map<String, String> parameters) {
|
||||
if (configuration.inverterKwp != Double.MAX_VALUE) {
|
||||
parameters.put("inverter", String.valueOf(configuration.inverterKwp));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #####################
|
||||
* Actions functionality
|
||||
* #####################
|
||||
*/
|
||||
|
||||
@Override
|
||||
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||
return List.of(SolarForecastActions.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SolarForecast> getSolarForecasts() {
|
||||
return planes.stream().flatMap(plane -> plane.getSolarForecasts().stream()).toList();
|
||||
}
|
||||
}
|
||||
|
||||
+233
-122
@@ -22,13 +22,16 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.client.api.ContentResponse;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
|
||||
import org.openhab.binding.solarforecast.internal.SolarForecastException;
|
||||
import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
|
||||
@@ -38,7 +41,6 @@ import org.openhab.binding.solarforecast.internal.forecastsolar.ForecastSolarObj
|
||||
import org.openhab.binding.solarforecast.internal.forecastsolar.config.ForecastSolarPlaneConfiguration;
|
||||
import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
|
||||
import org.openhab.binding.solarforecast.internal.utils.Utils;
|
||||
import org.openhab.core.library.types.PointType;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.Thing;
|
||||
@@ -61,48 +63,45 @@ import org.slf4j.LoggerFactory;
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ForecastSolarPlaneHandler extends BaseThingHandler implements SolarForecastProvider {
|
||||
public static final String BASE_URL = "https://api.forecast.solar/";
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(ForecastSolarPlaneHandler.class);
|
||||
private final AtomicBoolean dirty = new AtomicBoolean(false);
|
||||
private final HttpClient httpClient;
|
||||
|
||||
private Optional<PointType> location = Optional.empty();
|
||||
private ForecastSolarObject forecast;
|
||||
|
||||
protected ForecastSolarPlaneConfiguration configuration = new ForecastSolarPlaneConfiguration();
|
||||
protected Optional<ForecastSolarBridgeHandler> bridgeHandler = Optional.empty();
|
||||
protected Optional<String> apiKey = Optional.empty();
|
||||
protected ForecastSolarObject forecast;
|
||||
protected String identifier;
|
||||
protected @Nullable ForecastSolarBridgeHandler bridgeHandler;
|
||||
|
||||
public ForecastSolarPlaneHandler(Thing thing, HttpClient hc) {
|
||||
super(thing);
|
||||
httpClient = hc;
|
||||
forecast = new ForecastSolarObject(thing.getUID().getAsString());
|
||||
String label = thing.getLabel();
|
||||
identifier = (label == null) ? thing.getUID().getAsString() : label;
|
||||
forecast = new ForecastSolarObject(identifier);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||
return List.of(SolarForecastActions.class);
|
||||
}
|
||||
/**
|
||||
* #####################
|
||||
* Handler functionality
|
||||
* #####################
|
||||
*/
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
doInitialize();
|
||||
bridgeHandler.ifPresent(handler -> {
|
||||
handler.addPlane(this);
|
||||
});
|
||||
}
|
||||
|
||||
protected boolean doInitialize() {
|
||||
configuration = getConfigAs(ForecastSolarPlaneConfiguration.class);
|
||||
if (!isConfigurationValid()) {
|
||||
return;
|
||||
}
|
||||
Bridge bridge = getBridge();
|
||||
if (bridge != null) {
|
||||
BridgeHandler handler = bridge.getHandler();
|
||||
if (handler != null) {
|
||||
if (handler instanceof ForecastSolarBridgeHandler fsbh) {
|
||||
bridgeHandler = Optional.of(fsbh);
|
||||
bridgeHandler = fsbh;
|
||||
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE,
|
||||
"@text/solarforecast.plane.status.await-feedback");
|
||||
return true;
|
||||
bridge().addPlane(this);
|
||||
} else {
|
||||
configErrorStatus("@text/solarforecast.plane.status.wrong-handler" + " [\"" + handler + "\"]");
|
||||
}
|
||||
@@ -112,7 +111,27 @@ public class ForecastSolarPlaneHandler extends BaseThingHandler implements Solar
|
||||
} else {
|
||||
configErrorStatus("@text/solarforecast.plane.status.bridge-missing");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isConfigurationValid() {
|
||||
// Validate configuration
|
||||
if (configuration.declination < 0 || configuration.declination > 90) {
|
||||
configErrorStatus("Declination must be between 0 and 90.");
|
||||
return false;
|
||||
}
|
||||
if (configuration.azimuth < -180 || configuration.azimuth > 180) {
|
||||
configErrorStatus("Azimuth must be between -180 and 180.");
|
||||
return false;
|
||||
}
|
||||
if (configuration.kwp <= 0) {
|
||||
configErrorStatus("Installed kWp must be positive.");
|
||||
return false;
|
||||
}
|
||||
if (configuration.refreshInterval < 0) {
|
||||
configErrorStatus("Refresh interval must be non-negative.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected void configErrorStatus(String message) {
|
||||
@@ -121,84 +140,191 @@ public class ForecastSolarPlaneHandler extends BaseThingHandler implements Solar
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
super.dispose();
|
||||
if (bridgeHandler.isPresent()) {
|
||||
bridgeHandler.get().removePlane(this);
|
||||
}
|
||||
bridge().removePlane(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||
if (command instanceof RefreshType) {
|
||||
ForecastSolarObject localForecast = getForecast();
|
||||
if (CHANNEL_POWER_ESTIMATE.equals(channelUID.getIdWithoutGroup())) {
|
||||
sendTimeSeries(CHANNEL_POWER_ESTIMATE, forecast.getPowerTimeSeries(QueryMode.Average));
|
||||
sendTimeSeries(CHANNEL_POWER_ESTIMATE, localForecast.getPowerTimeSeries(QueryMode.Average));
|
||||
} else if (CHANNEL_ENERGY_ESTIMATE.equals(channelUID.getIdWithoutGroup())) {
|
||||
sendTimeSeries(CHANNEL_ENERGY_ESTIMATE, forecast.getEnergyTimeSeries(QueryMode.Average));
|
||||
sendTimeSeries(CHANNEL_ENERGY_ESTIMATE, localForecast.getEnergyTimeSeries(QueryMode.Average));
|
||||
} else {
|
||||
fetchData();
|
||||
bridge().getSequentialScheduler().execute(this::updateData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #####################
|
||||
* Forecast functionality
|
||||
* #####################
|
||||
*/
|
||||
|
||||
/**
|
||||
* This is the main function called by the bridge to update current data and refresh if expired
|
||||
* Only called from sequential bridge scheduler thread!
|
||||
* 1) Fetch new data if expired
|
||||
* 2) Update channels with current data
|
||||
* 3) Update timeseries if fetchData delivered new forecast
|
||||
* 4) Update thing status to ONLINE
|
||||
*/
|
||||
public void updateData() {
|
||||
ForecastSolarObject localForecast = getForecast();
|
||||
// 1) fetch new data if expired
|
||||
if (localForecast.isExpired()) {
|
||||
fetchData();
|
||||
}
|
||||
try {
|
||||
// 2) Update channels with current data
|
||||
updateChannels();
|
||||
// 3) Update timeseries if dirty flag is set by fetchData before
|
||||
if (dirty.get()) {
|
||||
updateTimeseries();
|
||||
}
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
} catch (SolarForecastException sfe) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
|
||||
"@text/solarforecast.plane.status.exception [\"" + sfe.getMessage() + "\"]");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch new forecast data from the forecast.solar API if the current data is expired.
|
||||
* https://doc.forecast.solar/doku.php?id=api:estimate
|
||||
*/
|
||||
protected ForecastSolarObject fetchData() {
|
||||
if (location.isPresent()) {
|
||||
if (forecast.isExpired()) {
|
||||
// create URL with mandatory parameters
|
||||
String url = getBaseUrl() + "estimate/" + location.get().getLatitude() + SLASH
|
||||
+ location.get().getLongitude() + SLASH + configuration.declination + SLASH
|
||||
+ configuration.azimuth + SLASH + configuration.kwp + "?damping=" + configuration.dampAM + ","
|
||||
+ configuration.dampPM;
|
||||
// add parameters calculated by queryParameters() including subclasses
|
||||
for (Entry<String, String> entry : queryParameters().entrySet()) {
|
||||
url += "&" + entry.getKey() + "=" + entry.getValue();
|
||||
}
|
||||
logger.trace("Call {}", url);
|
||||
try {
|
||||
ContentResponse cr = httpClient.GET(url);
|
||||
int responseStatus = cr.getStatus();
|
||||
if (responseStatus == 200) {
|
||||
try {
|
||||
ForecastSolarObject localForecast = new ForecastSolarObject(thing.getUID().getAsString(),
|
||||
cr.getContentAsString(), Instant.now(Utils.getClock())
|
||||
.plus(configuration.refreshInterval, ChronoUnit.MINUTES));
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
setForecast(localForecast);
|
||||
} catch (SolarForecastException fse) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
|
||||
"@text/solarforecast.plane.status.json-status [\"" + fse.getMessage() + "\"]");
|
||||
}
|
||||
} else if (responseStatus == 429) {
|
||||
// special handling for 429 response: https://doc.forecast.solar/facing429
|
||||
// bridge shall "calm down" until at least one hour is expired
|
||||
if (bridgeHandler.isPresent()) {
|
||||
bridgeHandler.get().calmDown();
|
||||
}
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/solarforecast.plane.status.http-status [\"" + cr.getStatus() + "\"]");
|
||||
} else {
|
||||
logger.trace("Call {} failed with status {}. Response: {}", url, cr.getStatus(),
|
||||
cr.getContentAsString());
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/solarforecast.plane.status.http-status [\"" + cr.getStatus() + "\"]");
|
||||
}
|
||||
} catch (ExecutionException | TimeoutException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||
} catch (InterruptedException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
} else {
|
||||
// else use available forecast
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
updateChannels(forecast);
|
||||
}
|
||||
} else {
|
||||
logger.warn("{} Location not present", thing.getLabel());
|
||||
private void fetchData() {
|
||||
ForecastSolarObject localForecast = getForecast();
|
||||
if (!localForecast.isExpired()) {
|
||||
return;
|
||||
}
|
||||
return forecast;
|
||||
String url = buildUrl();
|
||||
logger.trace("Call {}", Utils.redactUrlForLog(url));
|
||||
try {
|
||||
ContentResponse cr = httpClient.newRequest(url).timeout(10, TimeUnit.SECONDS).send();
|
||||
int responseStatus = cr.getStatus();
|
||||
handleResponse(responseStatus, cr.getContentAsString());
|
||||
} catch (ExecutionException | TimeoutException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||
} catch (InterruptedException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleResponse(int responseStatus, String forecastContent) {
|
||||
if (responseStatus == HttpStatus.OK_200) {
|
||||
try {
|
||||
ForecastSolarObject newForecast = new ForecastSolarObject(identifier, forecastContent,
|
||||
Instant.now(Utils.getClock()).plus(configuration.refreshInterval, ChronoUnit.MINUTES));
|
||||
updateForecast(newForecast);
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
} catch (SolarForecastException fse) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
|
||||
"@text/solarforecast.plane.status.json-status [\"" + fse.getMessage() + "\"]");
|
||||
}
|
||||
} else if (responseStatus == HttpStatus.TOO_MANY_REQUESTS_429) {
|
||||
// special handling for 429 response: https://doc.forecast.solar/facing429
|
||||
// bridge shall "calm down" until at least one hour is expired
|
||||
bridge().calmDown();
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/solarforecast.plane.status.http-status [\"" + responseStatus + "\"]");
|
||||
} else {
|
||||
logger.trace("Call failed with status {}. Response: {}", responseStatus, forecastContent);
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/solarforecast.plane.status.http-status [\"" + responseStatus + "\"]");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the channels with the current forecast data.
|
||||
*/
|
||||
private void updateChannels() {
|
||||
ForecastSolarObject localForecast = getForecast();
|
||||
ZonedDateTime now = ZonedDateTime.now(Utils.getClock());
|
||||
double energyDay = localForecast.getDayTotal(now.toLocalDate());
|
||||
double energyProduced = localForecast.getActualEnergyValue(now);
|
||||
// energyDay (separate field in JSON) and energyProduced (sum of actual values) can differ slightly due to
|
||||
// rounding)
|
||||
// 2026-01-05 22:03:55.147 [TRACE] [r.handler.ForecastSolarPlaneHandler] - Actual 1.0039800206714415 Day
|
||||
// 1.0039800206714413 Diff -2.220446049250313E-16
|
||||
// avoid negative remaining energy due to rounding issues
|
||||
double remainingEnergy = Math.max(0, (energyDay - energyProduced));
|
||||
updateState(CHANNEL_ENERGY_ACTUAL, Utils.getEnergyState(energyProduced));
|
||||
updateState(CHANNEL_ENERGY_REMAIN, Utils.getEnergyState(remainingEnergy));
|
||||
updateState(CHANNEL_ENERGY_TODAY, Utils.getEnergyState(energyDay));
|
||||
updateState(CHANNEL_POWER_ACTUAL, Utils.getPowerState(localForecast.getActualPowerValue(now)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update timeseries with the current forecast data.
|
||||
*/
|
||||
private void updateTimeseries() {
|
||||
ForecastSolarObject localForecast = getForecast();
|
||||
sendTimeSeries(CHANNEL_POWER_ESTIMATE, localForecast.getPowerTimeSeries(QueryMode.Average));
|
||||
sendTimeSeries(CHANNEL_ENERGY_ESTIMATE, localForecast.getEnergyTimeSeries(QueryMode.Average));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the new forecast data.
|
||||
*
|
||||
* @param newForecast set as actual forecast data
|
||||
*/
|
||||
protected void updateForecast(ForecastSolarObject newForecast) {
|
||||
synchronized (this) {
|
||||
forecast = newForecast;
|
||||
dirty.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current forecast data reference in a thread-safe manner.
|
||||
*
|
||||
* @return the current shared {@link ForecastSolarObject} reference
|
||||
*/
|
||||
public ForecastSolarObject getForecast() {
|
||||
synchronized (this) {
|
||||
ForecastSolarObject localForecast = forecast;
|
||||
return localForecast;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by bridge to check if timeseries needs to be updated.
|
||||
* Check if timeseries update is needed and reset the flag.
|
||||
*
|
||||
* @return true if timeseries update is needed
|
||||
*/
|
||||
public boolean isTimeseriesUpdateNeeded() {
|
||||
return dirty.getAndSet(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* #####################
|
||||
* URL functionality
|
||||
* #####################
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build the forecast.solar API URL with mandatory and optional parameters.
|
||||
*
|
||||
* @return complete URL for the forecast request
|
||||
*/
|
||||
protected String buildUrl() {
|
||||
// create URL with mandatory parameters using StringBuilder
|
||||
StringBuilder url = new StringBuilder();
|
||||
url.append(bridge().getBaseUrl()).append(configuration.declination).append(SLASH).append(configuration.azimuth)
|
||||
.append(SLASH).append(configuration.kwp).append("?damping=").append(configuration.dampAM).append(",")
|
||||
.append(configuration.dampPM);
|
||||
// add parameters calculated by queryParameters() including subclasses
|
||||
Map<String, String> parameters = new HashMap<>();
|
||||
queryParameters(parameters);
|
||||
for (Entry<String, String> entry : parameters.entrySet()) {
|
||||
url.append("&").append(entry.getKey()).append("=").append(entry.getValue());
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,57 +333,42 @@ public class ForecastSolarPlaneHandler extends BaseThingHandler implements Solar
|
||||
*
|
||||
* @return Map with parameter key
|
||||
*/
|
||||
protected Map<String, String> queryParameters() {
|
||||
Map<String, String> parameters = new HashMap<>();
|
||||
protected void queryParameters(Map<String, String> parameters) {
|
||||
bridge().queryParameters(parameters);
|
||||
parameters.put("full", "1"); // full forecast data including hours without sun
|
||||
if (!SolarForecastBindingConstants.EMPTY.equals(configuration.horizon)) {
|
||||
parameters.put("horizon", configuration.horizon); // horizon if configured
|
||||
}
|
||||
return parameters;
|
||||
}
|
||||
|
||||
protected void updateChannels(ForecastSolarObject f) {
|
||||
ZonedDateTime now = ZonedDateTime.now(Utils.getClock());
|
||||
double energyDay = f.getDayTotal(now.toLocalDate());
|
||||
double energyProduced = f.getActualEnergyValue(now);
|
||||
updateState(CHANNEL_ENERGY_ACTUAL, Utils.getEnergyState(energyProduced));
|
||||
updateState(CHANNEL_ENERGY_REMAIN, Utils.getEnergyState(energyDay - energyProduced));
|
||||
updateState(CHANNEL_ENERGY_TODAY, Utils.getEnergyState(energyDay));
|
||||
updateState(CHANNEL_POWER_ACTUAL, Utils.getPowerState(f.getActualPowerValue(now)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by Bridge to set location directly
|
||||
*
|
||||
* @param loc
|
||||
* #####################
|
||||
* Helper functionality
|
||||
* #####################
|
||||
*/
|
||||
void setLocation(PointType loc) {
|
||||
location = Optional.of(loc);
|
||||
}
|
||||
|
||||
void setApiKey(String key) {
|
||||
apiKey = Optional.of(key);
|
||||
}
|
||||
|
||||
String getBaseUrl() {
|
||||
String url = BASE_URL;
|
||||
if (apiKey.isPresent()) {
|
||||
url += apiKey.get() + SLASH;
|
||||
private ForecastSolarBridgeHandler bridge() {
|
||||
ForecastSolarBridgeHandler localBridgeHandler = bridgeHandler;
|
||||
if (localBridgeHandler != null) {
|
||||
return localBridgeHandler;
|
||||
} else {
|
||||
throw new IllegalStateException("Bridge handler not initialized");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
protected synchronized void setForecast(ForecastSolarObject f) {
|
||||
forecast = f;
|
||||
sendTimeSeries(CHANNEL_POWER_ESTIMATE, forecast.getPowerTimeSeries(QueryMode.Average));
|
||||
sendTimeSeries(CHANNEL_ENERGY_ESTIMATE, forecast.getEnergyTimeSeries(QueryMode.Average));
|
||||
bridgeHandler.ifPresent(h -> {
|
||||
h.forecastUpdate();
|
||||
});
|
||||
/**
|
||||
* #####################
|
||||
* Actions functionality
|
||||
* #####################
|
||||
*/
|
||||
|
||||
@Override
|
||||
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||
return List.of(SolarForecastActions.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized List<SolarForecast> getSolarForecasts() {
|
||||
return List.of(forecast);
|
||||
public List<SolarForecast> getSolarForecasts() {
|
||||
return List.of(getForecast());
|
||||
}
|
||||
}
|
||||
|
||||
+21
-23
@@ -14,16 +14,14 @@ package org.openhab.binding.solarforecast.internal.forecastsolar.handler;
|
||||
|
||||
import static org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants.CHANNEL_CORRECTION_FACTOR;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.solarforecast.internal.SolarForecastException;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.solarforecast.internal.actions.SolarForecastAdjuster;
|
||||
import org.openhab.core.library.types.DecimalType;
|
||||
import org.openhab.core.library.types.PointType;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -36,30 +34,25 @@ import org.slf4j.LoggerFactory;
|
||||
public class SmartForecastSolarBridgeHandler extends ForecastSolarBridgeHandler {
|
||||
private final Logger logger = LoggerFactory.getLogger(SmartForecastSolarBridgeHandler.class);
|
||||
|
||||
public SmartForecastSolarBridgeHandler(Bridge bridge, Optional<PointType> location) {
|
||||
public SmartForecastSolarBridgeHandler(Bridge bridge, @Nullable PointType location) {
|
||||
super(bridge, location);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook into the forecast update process with update of correction factor.
|
||||
* Hook into the timeseriesUpdate process and update correction factor channel
|
||||
*/
|
||||
@Override
|
||||
public synchronized void forecastUpdate() {
|
||||
super.forecastUpdate();
|
||||
public void updateTimeseries() {
|
||||
super.updateTimeseries();
|
||||
double energyProductionSum = 0;
|
||||
double forecastProductionSum = 0;
|
||||
boolean holdingTimeElapsed = true;
|
||||
for (Iterator<ForecastSolarPlaneHandler> iterator = planes.iterator(); iterator.hasNext();) {
|
||||
try {
|
||||
SmartForecastSolarPlaneHandler sfph = (SmartForecastSolarPlaneHandler) iterator.next();
|
||||
energyProductionSum += sfph.getEnergyProduction();
|
||||
forecastProductionSum += sfph.getForecastProduction();
|
||||
holdingTimeElapsed = holdingTimeElapsed && sfph.isHoldingTimeElapsed();
|
||||
} catch (SolarForecastException sfe) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
|
||||
"@text/solarforecast.site.status.exception [\"" + sfe.getMessage() + "\"]");
|
||||
return;
|
||||
}
|
||||
|
||||
for (SolarForecastAdjuster adjuster : getAdjusters()) {
|
||||
energyProductionSum += adjuster.getInverterEnergy();
|
||||
forecastProductionSum += adjuster.getForecastEnergy();
|
||||
holdingTimeElapsed = holdingTimeElapsed && adjuster.isHoldingTimeElapsed();
|
||||
logger.trace("factor calculation {}", adjuster.toString());
|
||||
}
|
||||
double factor = 1;
|
||||
if (holdingTimeElapsed) {
|
||||
@@ -67,10 +60,15 @@ public class SmartForecastSolarBridgeHandler extends ForecastSolarBridgeHandler
|
||||
factor = energyProductionSum / forecastProductionSum;
|
||||
}
|
||||
}
|
||||
logger.trace("forecastUpdate Inverter {}, Forecast {} factor {}", energyProductionSum, forecastProductionSum,
|
||||
factor);
|
||||
|
||||
logger.trace("Factor calculation Inverter {}, Forecast {} factor {}", energyProductionSum,
|
||||
forecastProductionSum, factor);
|
||||
// calculate new correction factor out of each plane and their production values
|
||||
updateState(CHANNEL_CORRECTION_FACTOR, new DecimalType(factor));
|
||||
}
|
||||
|
||||
private List<SolarForecastAdjuster> getAdjusters() {
|
||||
return planes.stream().map(plane -> plane.getSolarForecasts()).flatMap(List::stream)
|
||||
.filter(solarForecast -> solarForecast.getAdjuster().isPresent())
|
||||
.map(solarForecast -> solarForecast.getAdjuster().get()).toList();
|
||||
}
|
||||
}
|
||||
|
||||
+30
-33
@@ -14,12 +14,12 @@ package org.openhab.binding.solarforecast.internal.forecastsolar.handler;
|
||||
|
||||
import static org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants.CHANNEL_CORRECTION_FACTOR;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.openhab.binding.solarforecast.internal.actions.SolarForecastAdjuster;
|
||||
import org.openhab.binding.solarforecast.internal.forecastsolar.ForecastSolarObject;
|
||||
import org.openhab.binding.solarforecast.internal.utils.Utils;
|
||||
import org.openhab.core.library.types.DecimalType;
|
||||
@@ -37,19 +37,16 @@ import org.slf4j.LoggerFactory;
|
||||
@NonNullByDefault
|
||||
public class SmartForecastSolarPlaneHandler extends AdjustableForecastSolarPlaneHandler {
|
||||
private final Logger logger = LoggerFactory.getLogger(SmartForecastSolarPlaneHandler.class);
|
||||
private double energyProduction = 0;
|
||||
private double forecastProduction = 0;
|
||||
|
||||
public SmartForecastSolarPlaneHandler(Thing thing, HttpClient hc, PersistenceServiceRegistry psr) {
|
||||
super(thing, hc, psr);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Map<String, String> queryParameters() {
|
||||
Map<String, String> params = super.queryParameters();
|
||||
protected void queryParameters(Map<String, String> parameters) {
|
||||
super.queryParameters(parameters);
|
||||
// remove actual key if present - smart does calculate adjustment itself
|
||||
params.remove("actual");
|
||||
return params;
|
||||
parameters.remove("actual");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,37 +54,37 @@ public class SmartForecastSolarPlaneHandler extends AdjustableForecastSolarPlane
|
||||
* It calculates the correction factor based on the current energy production and the forecasted energy production.
|
||||
* The factor is applied to the forecast, and the adjusted power and energy time series are sent to the channel.
|
||||
*
|
||||
* @param f The forecast object containing the forecast data
|
||||
* @param newForecast forecast object containing the forecast data
|
||||
*/
|
||||
@Override
|
||||
protected synchronized void setForecast(ForecastSolarObject f) {
|
||||
forecast = f;
|
||||
Optional<Double> energyCalculation = Utils.getEnergyTillNow(configuration.calculationItemName,
|
||||
persistenceService.get());
|
||||
energyProduction = energyCalculation.orElse(0.0);
|
||||
forecastProduction = forecast.getActualEnergyValue(ZonedDateTime.now(Utils.getClock()));
|
||||
protected void updateForecast(ForecastSolarObject newForecast) {
|
||||
ForecastSolarObject adjustedForecast = newForecast;
|
||||
if (persistenceService != null) {
|
||||
// Get inverter energy production till now and predicted energy production from forecast
|
||||
Optional<Double> energyCalculation = Utils.getEnergyTillNow(configuration.calculationItemName,
|
||||
persistenceService);
|
||||
double energyProduction = energyCalculation.isPresent() ? energyCalculation.get() : 0;
|
||||
|
||||
double factor = 1;
|
||||
if (isHoldingTimeElapsed()) {
|
||||
if (forecastProduction > 0) {
|
||||
factor = energyProduction / forecastProduction;
|
||||
// calculate correction factor if holding time elapsed
|
||||
boolean holdingTimeElapsed = Utils.isHoldingTimeElapsed(adjustedForecast, configuration.holdingTime);
|
||||
adjustedForecast = new ForecastSolarObject(newForecast, energyProduction, holdingTimeElapsed);
|
||||
Optional<SolarForecastAdjuster> adjuster = adjustedForecast.getAdjuster();
|
||||
double factor = 1;
|
||||
if (adjuster.isPresent()) {
|
||||
if (holdingTimeElapsed) {
|
||||
factor = adjuster.get().getCorrectionFactor();
|
||||
}
|
||||
logger.debug("{}", adjuster.get().toString());
|
||||
} else {
|
||||
logger.debug("No adjuster available for forecast adjustment");
|
||||
}
|
||||
forecast.setCorrectionFactor(factor);
|
||||
|
||||
// factor is applied to the forecast so new adapted values are available
|
||||
updateState(CHANNEL_CORRECTION_FACTOR, new DecimalType(factor));
|
||||
} else {
|
||||
logger.debug("Holding time not elapsed, no adjustment of forecast");
|
||||
logger.debug("No persistence service available, no adjustment of forecast");
|
||||
}
|
||||
logger.debug("Inverter {}, Forecast {} factor {}", energyProduction, forecastProduction, factor);
|
||||
|
||||
// factor is applied to the forecast so new adapted values are available
|
||||
updateState(CHANNEL_CORRECTION_FACTOR, new DecimalType(factor));
|
||||
super.setForecast(forecast);
|
||||
}
|
||||
|
||||
public double getEnergyProduction() {
|
||||
return energyProduction;
|
||||
}
|
||||
|
||||
public double getForecastProduction() {
|
||||
return forecastProduction;
|
||||
// finally call superclass to set the adjusted forecast
|
||||
super.updateForecast(adjustedForecast);
|
||||
}
|
||||
}
|
||||
|
||||
+104
-11
@@ -32,7 +32,9 @@ import javax.measure.quantity.Power;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.json.JSONObject;
|
||||
import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
|
||||
import org.openhab.binding.solarforecast.internal.forecastsolar.ForecastSolarObject;
|
||||
import org.openhab.core.i18n.TimeZoneProvider;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.library.unit.Units;
|
||||
@@ -40,7 +42,9 @@ import org.openhab.core.persistence.FilterCriteria;
|
||||
import org.openhab.core.persistence.HistoricItem;
|
||||
import org.openhab.core.persistence.QueryablePersistenceService;
|
||||
import org.openhab.core.types.State;
|
||||
import org.openhab.core.types.TimeSeries;
|
||||
import org.openhab.core.types.TimeSeries.Entry;
|
||||
import org.openhab.core.types.TimeSeries.Policy;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -74,6 +78,10 @@ public class Utils {
|
||||
timeZoneProvider = tzp;
|
||||
}
|
||||
|
||||
public static TimeZoneProvider getTimeZoneProvider() {
|
||||
return timeZoneProvider;
|
||||
}
|
||||
|
||||
public static Clock getClock() {
|
||||
return clock.withZone(timeZoneProvider.getTimeZone());
|
||||
}
|
||||
@@ -117,14 +125,13 @@ public class Utils {
|
||||
return Instant.MAX;
|
||||
}
|
||||
Instant start = Instant.MIN;
|
||||
for (Iterator<SolarForecast> iterator = forecastObjects.iterator(); iterator.hasNext();) {
|
||||
SolarForecast sf = iterator.next();
|
||||
for (SolarForecast forecast : forecastObjects) {
|
||||
// if start is maximum there's no forecast data available - return immediately
|
||||
if (sf.getForecastBegin().equals(Instant.MAX)) {
|
||||
if (forecast.getForecastBegin().equals(Instant.MAX)) {
|
||||
return Instant.MAX;
|
||||
} else if (sf.getForecastBegin().isAfter(start)) {
|
||||
} else if (forecast.getForecastBegin().isAfter(start)) {
|
||||
// take latest timestamp from all forecasts
|
||||
start = sf.getForecastBegin();
|
||||
start = forecast.getForecastBegin();
|
||||
}
|
||||
}
|
||||
return start;
|
||||
@@ -135,14 +142,13 @@ public class Utils {
|
||||
return Instant.MIN;
|
||||
}
|
||||
Instant end = Instant.MAX;
|
||||
for (Iterator<SolarForecast> iterator = forecastObjects.iterator(); iterator.hasNext();) {
|
||||
SolarForecast sf = iterator.next();
|
||||
for (SolarForecast forecast : forecastObjects) {
|
||||
// if end is minimum there's no forecast data available - return immediately
|
||||
if (sf.getForecastEnd().equals(Instant.MIN)) {
|
||||
if (forecast.getForecastEnd().equals(Instant.MIN)) {
|
||||
return Instant.MIN;
|
||||
} else if (sf.getForecastEnd().isBefore(end)) {
|
||||
} else if (forecast.getForecastEnd().isBefore(end)) {
|
||||
// take earliest timestamp from all forecast
|
||||
end = sf.getForecastEnd();
|
||||
end = forecast.getForecastEnd();
|
||||
}
|
||||
}
|
||||
return end;
|
||||
@@ -184,7 +190,12 @@ public class Utils {
|
||||
* @param service the persistence service to query
|
||||
* @return the total energy produced in kWh, empty if the item unit is not power or energy
|
||||
*/
|
||||
public static Optional<Double> getEnergyTillNow(String calculationItemName, QueryablePersistenceService service) {
|
||||
public static Optional<Double> getEnergyTillNow(String calculationItemName,
|
||||
@Nullable QueryablePersistenceService service) {
|
||||
if (service == null) {
|
||||
LOGGER.info("No persistence service available");
|
||||
return Optional.empty();
|
||||
}
|
||||
ZonedDateTime beginPeriodDT = ZonedDateTime.now(Utils.getClock()).truncatedTo(ChronoUnit.DAYS);
|
||||
ZonedDateTime endPeriodDT = ZonedDateTime.now(Utils.getClock());
|
||||
FilterCriteria fc = new FilterCriteria();
|
||||
@@ -288,4 +299,86 @@ public class Utils {
|
||||
long durationSeconds = Duration.between(begin, end).getSeconds();
|
||||
return power * durationSeconds / 3600;
|
||||
}
|
||||
|
||||
public static TimeSeries toTimeseries(TreeMap<Instant, QuantityType<?>> map) {
|
||||
TimeSeries series = new TimeSeries(Policy.REPLACE);
|
||||
map.forEach((timestamp, state) -> {
|
||||
series.add(timestamp, state);
|
||||
});
|
||||
return series;
|
||||
}
|
||||
|
||||
public static void addAll(TreeMap<Instant, QuantityType<?>> targetMap, TimeSeries timeseries, Instant commonStart,
|
||||
Instant commonEnd) {
|
||||
timeseries.getStates().forEach(entry -> {
|
||||
if (Utils.isAfterOrEqual(entry.timestamp(), commonStart)
|
||||
&& Utils.isBeforeOrEqual(entry.timestamp(), commonEnd)) {
|
||||
Utils.addState(targetMap, entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static TreeMap<Instant, State> toMap(TimeSeries timeseries) {
|
||||
TreeMap<Instant, State> map = new TreeMap<>();
|
||||
timeseries.getStates().forEach(entry -> {
|
||||
map.put(entry.timestamp(), entry.state());
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
public static boolean isHoldingTimeElapsed(ForecastSolarObject queryForecast, long holdingTimeMin) {
|
||||
Optional<Instant> firstMeasure = queryForecast.getFirstPowerTimestamp();
|
||||
if (firstMeasure.isPresent()) {
|
||||
return Instant.now(Utils.getClock()).isAfter(firstMeasure.get().plus(holdingTimeMin, ChronoUnit.MINUTES));
|
||||
}
|
||||
if (!queryForecast.isEmpty()) {
|
||||
LOGGER.warn("No adjustment possible: Unable to find first measure in forecast");
|
||||
} else {
|
||||
LOGGER.debug("Forecast is empty, no first measure available");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact API key in URLs for logging purposes.
|
||||
*/
|
||||
public static String redactUrlForLog(String url) {
|
||||
if (!url.contains("api.forecast.solar/estimate")) {
|
||||
// hide api key in log
|
||||
return url.replaceFirst("(https://api\\.forecast\\.solar/)[^/]+/", "$1****/");
|
||||
}
|
||||
// nothing to hide
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets property from deep nested JSON object by given path with "/" as separator
|
||||
*
|
||||
* @param path path with "/" as separator
|
||||
* @param source JSON object
|
||||
* @return property value as Object or empty string if not found
|
||||
*/
|
||||
private static Object getJsonEntry(String path, JSONObject source, boolean safe) {
|
||||
String[] keys = path.split("/");
|
||||
JSONObject iterator = source;
|
||||
for (int i = 0; i < keys.length - 1; i++) {
|
||||
iterator = safe ? iterator.optJSONObject(keys[i]) : iterator.getJSONObject(keys[i]);
|
||||
if (iterator == null) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return safe ? iterator.opt(keys[keys.length - 1]) : iterator.get(keys[keys.length - 1]);
|
||||
}
|
||||
|
||||
public static JSONObject getJSONObjectFrom(String path, JSONObject source, boolean safe) {
|
||||
Object obj = getJsonEntry(path, source, safe);
|
||||
if (obj instanceof JSONObject jsonObj) {
|
||||
return jsonObj;
|
||||
}
|
||||
return new JSONObject();
|
||||
}
|
||||
|
||||
public static String getPropertyFrom(String path, JSONObject source, boolean safe) {
|
||||
return getJsonEntry(path, source, safe).toString();
|
||||
}
|
||||
}
|
||||
|
||||
+40
-3
@@ -12,6 +12,10 @@
|
||||
*/
|
||||
package org.openhab.binding.solarforecast;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -52,7 +56,7 @@ public class CallbackMock implements ThingHandlerCallback {
|
||||
Bridge bridge;
|
||||
Map<String, TimeSeries> seriesMap = new HashMap<>();
|
||||
Map<String, List<State>> stateMap = new HashMap<>();
|
||||
ThingStatusInfo currentInfo = new ThingStatusInfo(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, null);
|
||||
volatile ThingStatusInfo currentStatusInfo = new ThingStatusInfo(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, null);
|
||||
|
||||
@Override
|
||||
public void stateUpdated(ChannelUID channelUID, State state) {
|
||||
@@ -73,6 +77,21 @@ public class CallbackMock implements ThingHandlerCallback {
|
||||
return stateList;
|
||||
}
|
||||
|
||||
public void waitForStateUpdates(String cuid, int count) {
|
||||
Instant endWait = Instant.now().plus(5, ChronoUnit.SECONDS);
|
||||
synchronized (this) {
|
||||
while (getStateList(cuid).size() != count && Instant.now().isBefore(endWait)) {
|
||||
try {
|
||||
wait(500);
|
||||
} catch (InterruptedException e) {
|
||||
fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
assertEquals(count, getStateList(cuid).size(),
|
||||
getStateList(cuid).size() + " state updates received instead of " + count);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postCommand(ChannelUID channelUID, Command command) {
|
||||
}
|
||||
@@ -92,11 +111,29 @@ public class CallbackMock implements ThingHandlerCallback {
|
||||
|
||||
@Override
|
||||
public void statusUpdated(Thing thing, ThingStatusInfo thingStatus) {
|
||||
currentInfo = thingStatus;
|
||||
currentStatusInfo = thingStatus;
|
||||
synchronized (this) {
|
||||
notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
public void waitForStatus(ThingStatus status) {
|
||||
Instant endWait = Instant.now().plus(5, ChronoUnit.SECONDS);
|
||||
synchronized (this) {
|
||||
while (currentStatusInfo.getStatus() != status && Instant.now().isBefore(endWait)) {
|
||||
try {
|
||||
wait(100);
|
||||
} catch (InterruptedException e) {
|
||||
fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
assertEquals(status, currentStatusInfo.getStatus(),
|
||||
"Thing did not reach expected " + status + ", Status reached " + currentStatusInfo);
|
||||
}
|
||||
}
|
||||
|
||||
public ThingStatusInfo getStatus() {
|
||||
return currentInfo;
|
||||
return currentStatusInfo;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+193
-148
@@ -23,31 +23,27 @@ import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Iterator;
|
||||
import java.util.Optional;
|
||||
|
||||
import javax.measure.quantity.Energy;
|
||||
import javax.measure.quantity.Power;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
|
||||
import org.junit.jupiter.api.parallel.Execution;
|
||||
import org.junit.jupiter.api.parallel.ExecutionMode;
|
||||
import org.openhab.binding.solarforecast.internal.SolarForecastException;
|
||||
import org.openhab.binding.solarforecast.internal.actions.SolarForecastActions;
|
||||
import org.openhab.binding.solarforecast.internal.forecastsolar.ForecastSolarObject;
|
||||
import org.openhab.binding.solarforecast.internal.forecastsolar.handler.ForecastSolarBridgeHandler;
|
||||
import org.openhab.binding.solarforecast.internal.forecastsolar.handler.ForecastSolarPlaneHandler;
|
||||
import org.openhab.binding.solarforecast.internal.forecastsolar.handler.ForecastSolarBridgeMock;
|
||||
import org.openhab.binding.solarforecast.internal.forecastsolar.handler.ForecastSolarMockFactory;
|
||||
import org.openhab.binding.solarforecast.internal.forecastsolar.handler.ForecastSolarPlaneMock;
|
||||
import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
|
||||
import org.openhab.binding.solarforecast.internal.utils.Utils;
|
||||
import org.openhab.core.library.types.PointType;
|
||||
import org.openhab.core.config.core.Configuration;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.library.unit.Units;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.openhab.core.thing.internal.BridgeImpl;
|
||||
import org.openhab.core.types.RefreshType;
|
||||
import org.openhab.core.types.State;
|
||||
import org.openhab.core.types.TimeSeries;
|
||||
|
||||
@@ -57,6 +53,7 @@ import org.openhab.core.types.TimeSeries;
|
||||
* @author Bernd Weymann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Execution(ExecutionMode.SAME_THREAD)
|
||||
class ForecastSolarTest {
|
||||
private static final double TOLERANCE = 0.001;
|
||||
public static final ZoneId TEST_ZONE = ZoneId.of("Europe/Berlin");
|
||||
@@ -69,8 +66,8 @@ class ForecastSolarTest {
|
||||
public static final String NO_FORECAST_INDICATOR = "No forecast data";
|
||||
public static final String DAY_MISSING_INDICATOR = "not available in forecast";
|
||||
|
||||
@BeforeAll
|
||||
static void setFixedTime() {
|
||||
@BeforeEach
|
||||
void setFixedTime() {
|
||||
// Instant matching the date of test resources
|
||||
String fixedInstant = "2022-07-17T15:00:00Z";
|
||||
Clock fixedClock = Clock.fixed(Instant.parse(fixedInstant), TEST_ZONE);
|
||||
@@ -376,72 +373,101 @@ class ForecastSolarTest {
|
||||
|
||||
@Test
|
||||
void testPowerTimeSeries() {
|
||||
ForecastSolarBridgeHandler fsbh = new ForecastSolarBridgeHandler(
|
||||
new BridgeImpl(SolarForecastBindingConstants.FORECAST_SOLAR_SITE, "bridge"),
|
||||
Optional.of(PointType.valueOf("1,2")));
|
||||
CallbackMock cm = new CallbackMock();
|
||||
fsbh.setCallback(cm);
|
||||
fsbh.initialize();
|
||||
ForecastSolarBridgeMock fsBridgeHandler = ForecastSolarMockFactory.createBridgeHandler();
|
||||
ForecastSolarPlaneMock fsPlaneHandler1 = ForecastSolarMockFactory.createPlaneHandler(fsBridgeHandler, "plane1",
|
||||
"src/test/resources/forecastsolar/result.json");
|
||||
ForecastSolarPlaneMock fsPlaneHandler2 = ForecastSolarMockFactory.createPlaneHandler(fsBridgeHandler, "plane2",
|
||||
"src/test/resources/forecastsolar/result.json");
|
||||
|
||||
String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||
ForecastSolarObject fso1 = new ForecastSolarObject("fs-test", content, Instant.now().plus(1, ChronoUnit.DAYS));
|
||||
ForecastSolarPlaneHandler fsph1 = new ForecastSolarPlaneMock(fso1);
|
||||
fsbh.addPlane(fsph1);
|
||||
fsbh.forecastUpdate();
|
||||
TimeSeries ts1 = cm.getTimeSeries("solarforecast:fs-site:bridge:power-estimate");
|
||||
fsBridgeHandler.updateTimeseries();
|
||||
|
||||
ForecastSolarPlaneHandler fsph2 = new ForecastSolarPlaneMock(fso1);
|
||||
fsbh.addPlane(fsph2);
|
||||
fsbh.forecastUpdate();
|
||||
TimeSeries ts2 = cm.getTimeSeries("solarforecast:fs-site:bridge:power-estimate");
|
||||
Iterator<TimeSeries.Entry> iter1 = ts1.getStates().iterator();
|
||||
Iterator<TimeSeries.Entry> iter2 = ts2.getStates().iterator();
|
||||
while (iter1.hasNext()) {
|
||||
TimeSeries.Entry e1 = iter1.next();
|
||||
TimeSeries.Entry e2 = iter2.next();
|
||||
assertEquals("kW", ((QuantityType<?>) e1.state()).getUnit().toString(), "Power Unit");
|
||||
assertEquals("kW", ((QuantityType<?>) e2.state()).getUnit().toString(), "Power Unit");
|
||||
assertEquals(((QuantityType<?>) e1.state()).doubleValue(), ((QuantityType<?>) e2.state()).doubleValue() / 2,
|
||||
CallbackMock cmSite = (CallbackMock) fsBridgeHandler.getCallback();
|
||||
CallbackMock cmPlane1 = (CallbackMock) fsPlaneHandler1.getCallback();
|
||||
CallbackMock cmPlane2 = (CallbackMock) fsPlaneHandler2.getCallback();
|
||||
assertNotNull(cmSite);
|
||||
assertNotNull(cmPlane1);
|
||||
assertNotNull(cmPlane2);
|
||||
|
||||
TimeSeries tsSite = cmSite.getTimeSeries("solarforecast:fs-site:bridge:power-estimate");
|
||||
TimeSeries tsPlaneOne = cmPlane1.getTimeSeries("test::plane1:power-estimate");
|
||||
TimeSeries tsPlaneTwo = cmPlane2.getTimeSeries("test::plane2:power-estimate");
|
||||
|
||||
Iterator<TimeSeries.Entry> siteIter = tsSite.getStates().iterator();
|
||||
Iterator<TimeSeries.Entry> plane1Iter = tsPlaneOne.getStates().iterator();
|
||||
Iterator<TimeSeries.Entry> plane2Iter = tsPlaneTwo.getStates().iterator();
|
||||
while (siteIter.hasNext()) {
|
||||
TimeSeries.Entry siteEntry = siteIter.next();
|
||||
TimeSeries.Entry plane1Entry = plane1Iter.next();
|
||||
TimeSeries.Entry plane2Entry = plane2Iter.next();
|
||||
assertEquals("kW", ((QuantityType<?>) siteEntry.state()).getUnit().toString(), "Power Unit");
|
||||
assertEquals("kW", ((QuantityType<?>) plane1Entry.state()).getUnit().toString(), "Power Unit");
|
||||
assertEquals("kW", ((QuantityType<?>) plane2Entry.state()).getUnit().toString(), "Power Unit");
|
||||
assertEquals(((QuantityType<?>) siteEntry.state()).doubleValue(),
|
||||
((QuantityType<?>) plane1Entry.state()).doubleValue()
|
||||
+ ((QuantityType<?>) plane2Entry.state()).doubleValue(),
|
||||
0.1, "Power Value");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCommonForecastStartEnd() {
|
||||
ForecastSolarBridgeHandler fsbh = new ForecastSolarBridgeHandler(
|
||||
new BridgeImpl(SolarForecastBindingConstants.FORECAST_SOLAR_SITE, "bridge"),
|
||||
Optional.of(PointType.valueOf("1,2")));
|
||||
CallbackMock cmSite = new CallbackMock();
|
||||
fsbh.setCallback(cmSite);
|
||||
fsbh.initialize();
|
||||
String contentOne = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||
ForecastSolarObject fso1One = new ForecastSolarObject("fs-test", contentOne,
|
||||
Instant.now().plus(1, ChronoUnit.DAYS));
|
||||
ForecastSolarPlaneHandler fsph1 = new ForecastSolarPlaneMock(fso1One);
|
||||
fsbh.addPlane(fsph1);
|
||||
fsbh.forecastUpdate();
|
||||
String fixedInstant = "2022-07-18T15:00:00Z";
|
||||
Clock fixedClock = Clock.fixed(Instant.parse(fixedInstant), TEST_ZONE);
|
||||
Utils.setClock(fixedClock);
|
||||
|
||||
String contentTwo = FileReader.readFileInString("src/test/resources/forecastsolar/resultNextDay.json");
|
||||
ForecastSolarObject fso1Two = new ForecastSolarObject("fs-plane", contentTwo,
|
||||
Instant.now().plus(1, ChronoUnit.DAYS));
|
||||
ForecastSolarPlaneHandler fsph2 = new ForecastSolarPlaneMock(fso1Two);
|
||||
CallbackMock cmPlane = new CallbackMock();
|
||||
fsph2.setCallback(cmPlane);
|
||||
((ForecastSolarPlaneMock) fsph2).updateForecast(fso1Two);
|
||||
fsbh.addPlane(fsph2);
|
||||
fsbh.forecastUpdate();
|
||||
ForecastSolarBridgeMock fsBridgeHandler = ForecastSolarMockFactory.createBridgeHandler();
|
||||
ForecastSolarPlaneMock fsPlaneHandler1 = ForecastSolarMockFactory.createPlaneHandler(fsBridgeHandler, "plane1",
|
||||
"src/test/resources/forecastsolar/result.json");
|
||||
ForecastSolarPlaneMock fsPlaneHandler2 = ForecastSolarMockFactory.createPlaneHandler(fsBridgeHandler, "plane2",
|
||||
"src/test/resources/forecastsolar/resultNextDay.json");
|
||||
|
||||
CallbackMock cmSite = (CallbackMock) fsBridgeHandler.getCallback();
|
||||
CallbackMock cmPlane1 = (CallbackMock) fsPlaneHandler1.getCallback();
|
||||
CallbackMock cmPlane2 = (CallbackMock) fsPlaneHandler2.getCallback();
|
||||
assertNotNull(cmSite);
|
||||
assertNotNull(cmPlane1);
|
||||
assertNotNull(cmPlane2);
|
||||
|
||||
cmPlane1.waitForStatus(ThingStatus.ONLINE);
|
||||
cmPlane2.waitForStatus(ThingStatus.ONLINE);
|
||||
cmSite.waitForStatus(ThingStatus.ONLINE);
|
||||
// force update of timeseries after both planes are online
|
||||
fsBridgeHandler.updateTimeseries();
|
||||
|
||||
TimeSeries tsPlaneOne = cmPlane.getTimeSeries("test::plane:power-estimate");
|
||||
TimeSeries tsSite = cmSite.getTimeSeries("solarforecast:fs-site:bridge:power-estimate");
|
||||
Iterator<TimeSeries.Entry> planeIter = tsPlaneOne.getStates().iterator();
|
||||
TimeSeries tsPlaneOne = cmPlane1.getTimeSeries("test::plane1:power-estimate");
|
||||
TimeSeries tsPlaneTwo = cmPlane2.getTimeSeries("test::plane2:power-estimate");
|
||||
|
||||
Iterator<TimeSeries.Entry> siteIter = tsSite.getStates().iterator();
|
||||
while (siteIter.hasNext()) {
|
||||
TimeSeries.Entry planeEntry = planeIter.next();
|
||||
TimeSeries.Entry siteEntry = siteIter.next();
|
||||
assertEquals("kW", ((QuantityType<?>) planeEntry.state()).getUnit().toString(), "Power Unit");
|
||||
TimeSeries.Entry plane1Entry = null;
|
||||
Iterator<TimeSeries.Entry> planeIter1 = tsPlaneOne.getStates().iterator();
|
||||
while (planeIter1.hasNext()) {
|
||||
TimeSeries.Entry e = planeIter1.next();
|
||||
if (e.timestamp().equals(siteEntry.timestamp())) {
|
||||
plane1Entry = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
TimeSeries.Entry plane2Entry = null;
|
||||
Iterator<TimeSeries.Entry> planeIter2 = tsPlaneTwo.getStates().iterator();
|
||||
while (planeIter2.hasNext()) {
|
||||
TimeSeries.Entry e = planeIter2.next();
|
||||
if (e.timestamp().equals(siteEntry.timestamp())) {
|
||||
plane2Entry = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertNotNull(plane1Entry);
|
||||
assertNotNull(plane2Entry);
|
||||
assertEquals("kW", ((QuantityType<?>) plane1Entry.state()).getUnit().toString(), "Power Unit");
|
||||
assertEquals("kW", ((QuantityType<?>) plane2Entry.state()).getUnit().toString(), "Power Unit");
|
||||
assertEquals("kW", ((QuantityType<?>) siteEntry.state()).getUnit().toString(), "Power Unit");
|
||||
assertEquals(((QuantityType<?>) planeEntry.state()).doubleValue(),
|
||||
((QuantityType<?>) siteEntry.state()).doubleValue() / 2, 0.1, "Power Value");
|
||||
assertEquals(
|
||||
((QuantityType<?>) plane1Entry.state()).doubleValue()
|
||||
+ ((QuantityType<?>) plane2Entry.state()).doubleValue(),
|
||||
((QuantityType<?>) siteEntry.state()).doubleValue(), 0.1, "Power Value");
|
||||
}
|
||||
// only one day shall be reported which is available in both planes
|
||||
LocalDate ld = LocalDate.of(2022, 7, 18);
|
||||
@@ -453,32 +479,19 @@ class ForecastSolarTest {
|
||||
|
||||
@Test
|
||||
void testActions() {
|
||||
ForecastSolarBridgeHandler fsbh = new ForecastSolarBridgeHandler(
|
||||
new BridgeImpl(SolarForecastBindingConstants.FORECAST_SOLAR_SITE, "bridge"),
|
||||
Optional.of(PointType.valueOf("1,2")));
|
||||
CallbackMock cmSite = new CallbackMock();
|
||||
fsbh.setCallback(cmSite);
|
||||
fsbh.initialize();
|
||||
String fixedInstant = "2022-07-18T15:00:00Z";
|
||||
Clock fixedClock = Clock.fixed(Instant.parse(fixedInstant), TEST_ZONE);
|
||||
Utils.setClock(fixedClock);
|
||||
|
||||
String contentOne = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||
ForecastSolarObject fso1One = new ForecastSolarObject("fs-test", contentOne,
|
||||
Instant.now().plus(1, ChronoUnit.DAYS));
|
||||
ForecastSolarPlaneHandler fsph1 = new ForecastSolarPlaneMock(fso1One);
|
||||
fsbh.addPlane(fsph1);
|
||||
fsbh.forecastUpdate();
|
||||
|
||||
String contentTwo = FileReader.readFileInString("src/test/resources/forecastsolar/resultNextDay.json");
|
||||
ForecastSolarObject fso1Two = new ForecastSolarObject("fs-plane", contentTwo,
|
||||
Instant.now().plus(1, ChronoUnit.DAYS));
|
||||
ForecastSolarPlaneHandler fsph2 = new ForecastSolarPlaneMock(fso1Two);
|
||||
CallbackMock cmPlane = new CallbackMock();
|
||||
fsph2.setCallback(cmPlane);
|
||||
((ForecastSolarPlaneMock) fsph2).updateForecast(fso1Two);
|
||||
fsbh.addPlane(fsph2);
|
||||
fsbh.forecastUpdate();
|
||||
ForecastSolarBridgeMock fsBridgeHandler = ForecastSolarMockFactory.createBridgeHandler();
|
||||
ForecastSolarMockFactory.createPlaneHandler(fsBridgeHandler, "plane1",
|
||||
"src/test/resources/forecastsolar/result.json");
|
||||
ForecastSolarMockFactory.createPlaneHandler(fsBridgeHandler, "plane2",
|
||||
"src/test/resources/forecastsolar/resultNextDay.json");
|
||||
fsBridgeHandler.updateTimeseries();
|
||||
|
||||
SolarForecastActions sfa = new SolarForecastActions();
|
||||
sfa.setThingHandler(fsbh);
|
||||
sfa.setThingHandler(fsBridgeHandler);
|
||||
// only one day shall be reported which is available in both planes
|
||||
LocalDate ld = LocalDate.of(2022, 7, 18);
|
||||
assertEquals(ld.atStartOfDay(ZoneId.of("UTC")).toInstant(), sfa.getForecastBegin().truncatedTo(ChronoUnit.DAYS),
|
||||
@@ -489,76 +502,108 @@ class ForecastSolarTest {
|
||||
|
||||
@Test
|
||||
void testEnergyTimeSeries() {
|
||||
ForecastSolarBridgeHandler fsbh = new ForecastSolarBridgeHandler(
|
||||
new BridgeImpl(SolarForecastBindingConstants.FORECAST_SOLAR_SITE, "bridge"),
|
||||
Optional.of(PointType.valueOf("1,2")));
|
||||
CallbackMock cm = new CallbackMock();
|
||||
fsbh.setCallback(cm);
|
||||
fsbh.initialize();
|
||||
ForecastSolarBridgeMock fsBridgeHandler = ForecastSolarMockFactory.createBridgeHandler();
|
||||
ForecastSolarPlaneMock fsPlaneHandler1 = ForecastSolarMockFactory.createPlaneHandler(fsBridgeHandler, "plane1",
|
||||
"src/test/resources/forecastsolar/result.json");
|
||||
ForecastSolarPlaneMock fsPlaneHandler2 = ForecastSolarMockFactory.createPlaneHandler(fsBridgeHandler, "plane2",
|
||||
"src/test/resources/forecastsolar/result.json");
|
||||
|
||||
String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||
ForecastSolarObject fso1 = new ForecastSolarObject("fs-test", content, Instant.now().plus(1, ChronoUnit.DAYS));
|
||||
ForecastSolarPlaneHandler fsph1 = new ForecastSolarPlaneMock(fso1);
|
||||
fsbh.addPlane(fsph1);
|
||||
fsbh.forecastUpdate();
|
||||
TimeSeries ts1 = cm.getTimeSeries("solarforecast:fs-site:bridge:energy-estimate");
|
||||
fsBridgeHandler.updateTimeseries();
|
||||
|
||||
ForecastSolarPlaneHandler fsph2 = new ForecastSolarPlaneMock(fso1);
|
||||
fsbh.addPlane(fsph2);
|
||||
fsbh.forecastUpdate();
|
||||
TimeSeries ts2 = cm.getTimeSeries("solarforecast:fs-site:bridge:energy-estimate");
|
||||
Iterator<TimeSeries.Entry> iter1 = ts1.getStates().iterator();
|
||||
Iterator<TimeSeries.Entry> iter2 = ts2.getStates().iterator();
|
||||
while (iter1.hasNext()) {
|
||||
TimeSeries.Entry e1 = iter1.next();
|
||||
TimeSeries.Entry e2 = iter2.next();
|
||||
assertEquals("kWh", ((QuantityType<?>) e1.state()).getUnit().toString(), "Power Unit");
|
||||
assertEquals("kWh", ((QuantityType<?>) e2.state()).getUnit().toString(), "Power Unit");
|
||||
assertEquals(((QuantityType<?>) e1.state()).doubleValue(), ((QuantityType<?>) e2.state()).doubleValue() / 2,
|
||||
0.1, "Power Value");
|
||||
CallbackMock cmSite = (CallbackMock) fsBridgeHandler.getCallback();
|
||||
CallbackMock cmPlane1 = (CallbackMock) fsPlaneHandler1.getCallback();
|
||||
CallbackMock cmPlane2 = (CallbackMock) fsPlaneHandler2.getCallback();
|
||||
assertNotNull(cmSite);
|
||||
assertNotNull(cmPlane1);
|
||||
assertNotNull(cmPlane2);
|
||||
|
||||
TimeSeries tsSite = cmSite.getTimeSeries("solarforecast:fs-site:bridge:energy-estimate");
|
||||
TimeSeries tsPlaneOne = cmPlane1.getTimeSeries("test::plane1:energy-estimate");
|
||||
TimeSeries tsPlaneTwo = cmPlane2.getTimeSeries("test::plane2:energy-estimate");
|
||||
|
||||
Iterator<TimeSeries.Entry> siteIter = tsSite.getStates().iterator();
|
||||
Iterator<TimeSeries.Entry> plane1Iter = tsPlaneOne.getStates().iterator();
|
||||
Iterator<TimeSeries.Entry> plane2Iter = tsPlaneTwo.getStates().iterator();
|
||||
while (siteIter.hasNext()) {
|
||||
TimeSeries.Entry siteEntry = siteIter.next();
|
||||
TimeSeries.Entry plane1Entry = plane1Iter.next();
|
||||
TimeSeries.Entry plane2Entry = plane2Iter.next();
|
||||
assertEquals("kWh", ((QuantityType<?>) siteEntry.state()).getUnit().toString(), "Energy Unit");
|
||||
assertEquals("kWh", ((QuantityType<?>) plane1Entry.state()).getUnit().toString(), "Energy Unit");
|
||||
assertEquals("kWh", ((QuantityType<?>) plane2Entry.state()).getUnit().toString(), "Energy Unit");
|
||||
assertEquals(((QuantityType<?>) siteEntry.state()).doubleValue(),
|
||||
((QuantityType<?>) plane1Entry.state()).doubleValue()
|
||||
+ ((QuantityType<?>) plane2Entry.state()).doubleValue(),
|
||||
0.1, "Energy Value");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCalmDown() {
|
||||
ForecastSolarBridgeHandler fsbh = new ForecastSolarBridgeHandler(
|
||||
new BridgeImpl(SolarForecastBindingConstants.FORECAST_SOLAR_SITE, "bridge"),
|
||||
Optional.of(PointType.valueOf("1,2")));
|
||||
CallbackMock cm = new CallbackMock();
|
||||
fsbh.setCallback(cm);
|
||||
fsbh.initialize();
|
||||
void testBaseUrl() {
|
||||
ForecastSolarBridgeMock fsBridgeHandler = ForecastSolarMockFactory.createBridgeHandler();
|
||||
String url = fsBridgeHandler.getBaseUrl();
|
||||
assertEquals("https://api.forecast.solar/estimate/1/2/", url, "Base URL");
|
||||
assertEquals("https://api.forecast.solar/estimate/1/2/", Utils.redactUrlForLog(url), "Base URL");
|
||||
|
||||
String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||
ForecastSolarObject fso1 = new ForecastSolarObject("fs-test", content, Instant.now().plus(1, ChronoUnit.DAYS));
|
||||
ForecastSolarPlaneHandler fsph1 = new ForecastSolarPlaneMock(fso1);
|
||||
fsbh.addPlane(fsph1);
|
||||
// first update after add plane - 1 state shall be received
|
||||
assertEquals(1, cm.getStateList("solarforecast:fs-site:bridge:power-actual").size(), "First update");
|
||||
assertEquals(ThingStatus.ONLINE, cm.getStatus().getStatus(), "Online");
|
||||
fsbh.handleCommand(
|
||||
new ChannelUID("solarforecast:fs-site:bridge:" + SolarForecastBindingConstants.CHANNEL_ENERGY_ACTUAL),
|
||||
RefreshType.REFRESH);
|
||||
// second update after refresh request - 2 states shall be received
|
||||
assertEquals(2, cm.getStateList("solarforecast:fs-site:bridge:power-actual").size(), "Second update");
|
||||
assertEquals(ThingStatus.ONLINE, cm.getStatus().getStatus(), "Online");
|
||||
Configuration config = new Configuration();
|
||||
config.put("apiKey", "xyz");
|
||||
newSiteConfig(fsBridgeHandler, config);
|
||||
url = fsBridgeHandler.getBaseUrl();
|
||||
assertEquals("https://api.forecast.solar/xyz/estimate/1/2/", fsBridgeHandler.getBaseUrl(), "Base URL");
|
||||
assertEquals("https://api.forecast.solar/****/estimate/1/2/", Utils.redactUrlForLog(url), "Base URL");
|
||||
|
||||
fsbh.calmDown();
|
||||
fsbh.handleCommand(
|
||||
new ChannelUID("solarforecast:fs-site:bridge:" + SolarForecastBindingConstants.CHANNEL_ENERGY_ACTUAL),
|
||||
RefreshType.REFRESH);
|
||||
// after calm down refresh shall have no effect . still 2 states
|
||||
assertEquals(2, cm.getStateList("solarforecast:fs-site:bridge:power-actual").size(), "Calm update");
|
||||
assertEquals(ThingStatus.OFFLINE, cm.getStatus().getStatus(), "Offline");
|
||||
assertEquals(ThingStatusDetail.COMMUNICATION_ERROR, cm.getStatus().getStatusDetail(), "Offline");
|
||||
config.put("location", "1.234,9.876");
|
||||
newSiteConfig(fsBridgeHandler, config);
|
||||
url = fsBridgeHandler.getBaseUrl();
|
||||
assertEquals("https://api.forecast.solar/xyz/estimate/1.234/9.876/", fsBridgeHandler.getBaseUrl(), "Base URL");
|
||||
assertEquals("https://api.forecast.solar/****/estimate/1.234/9.876/", Utils.redactUrlForLog(url), "Base URL");
|
||||
}
|
||||
|
||||
// forward Clock to get ONLINE again
|
||||
String fixedInstant = "2022-07-17T16:15:00Z";
|
||||
Clock fixedClock = Clock.fixed(Instant.parse(fixedInstant), ZoneId.of("UTC"));
|
||||
Utils.setClock(fixedClock);
|
||||
fsbh.handleCommand(
|
||||
new ChannelUID("solarforecast:fs-site:bridge:" + SolarForecastBindingConstants.CHANNEL_ENERGY_ACTUAL),
|
||||
RefreshType.REFRESH);
|
||||
assertEquals(3, cm.getStateList("solarforecast:fs-site:bridge:power-actual").size(), "Second update");
|
||||
assertEquals(ThingStatus.ONLINE, cm.getStatus().getStatus(), "Online");
|
||||
@Test
|
||||
void testFullUrl() {
|
||||
ForecastSolarBridgeMock fsBridgeHandler = ForecastSolarMockFactory.createBridgeHandler();
|
||||
|
||||
Configuration siteConfig = new Configuration();
|
||||
siteConfig.put("location", "1.234,9.876");
|
||||
siteConfig.put("apiKey", "xyz");
|
||||
newSiteConfig(fsBridgeHandler, siteConfig);
|
||||
|
||||
ForecastSolarPlaneMock fsPlaneHandler1 = ForecastSolarMockFactory.createPlaneHandler(fsBridgeHandler, "plane1",
|
||||
"src/test/resources/forecastsolar/result.json");
|
||||
Configuration planeConfig = new Configuration();
|
||||
planeConfig.put("declination", "45");
|
||||
planeConfig.put("azimuth", "-10");
|
||||
planeConfig.put("kwp", "5.5");
|
||||
planeConfig.put("dampAM", "0.5");
|
||||
planeConfig.put("dampPM", "0.3");
|
||||
newPlaneConfig(fsPlaneHandler1, planeConfig);
|
||||
assertEquals("https://api.forecast.solar/xyz/estimate/1.234/9.876/45/-10/5.5?damping=0.5,0.3&full=1",
|
||||
fsPlaneHandler1.getURL(), "Full URL");
|
||||
|
||||
siteConfig.put("inverterKwp", "0.8");
|
||||
newSiteConfig(fsBridgeHandler, siteConfig);
|
||||
assertEquals(
|
||||
"https://api.forecast.solar/xyz/estimate/1.234/9.876/45/-10/5.5?damping=0.5,0.3&inverter=0.8&full=1",
|
||||
fsPlaneHandler1.getURL(), "Full URL");
|
||||
|
||||
planeConfig.put("horizon", "0,0,0,0,0,0,10,20,20,20,20,20");
|
||||
newPlaneConfig(fsPlaneHandler1, planeConfig);
|
||||
assertEquals(
|
||||
"https://api.forecast.solar/xyz/estimate/1.234/9.876/45/-10/5.5?damping=0.5,0.3&horizon=0,0,0,0,0,0,10,20,20,20,20,20&inverter=0.8&full=1",
|
||||
fsPlaneHandler1.getURL(), "Full URL");
|
||||
}
|
||||
|
||||
private void newSiteConfig(ForecastSolarBridgeMock handler, Configuration config) {
|
||||
handler.updateConfiguration(config);
|
||||
handler.dispose();
|
||||
handler.initialize();
|
||||
// no need to wait for states as URL is built during initialize
|
||||
}
|
||||
|
||||
private void newPlaneConfig(ForecastSolarPlaneMock handler, Configuration config) {
|
||||
handler.updateConfiguration(config);
|
||||
handler.dispose();
|
||||
handler.initialize();
|
||||
// no need to wait for states as URL is built during initialize
|
||||
}
|
||||
}
|
||||
|
||||
+21
-14
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
package org.openhab.binding.solarforecast;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
@@ -20,6 +20,7 @@ import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Optional;
|
||||
|
||||
import javax.measure.quantity.Energy;
|
||||
import javax.measure.quantity.Power;
|
||||
@@ -27,6 +28,7 @@ import javax.measure.quantity.Power;
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.solarforecast.internal.actions.SolarForecastAdjuster;
|
||||
import org.openhab.binding.solarforecast.internal.forecastsolar.ForecastSolarObject;
|
||||
import org.openhab.binding.solarforecast.internal.utils.Utils;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
@@ -61,10 +63,10 @@ class SmartForecastSolarTest {
|
||||
void testFirstTimestamp() {
|
||||
String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||
ZonedDateTime queryDateTime = LocalDateTime.of(2022, 7, 17, 17, 00).atZone(TEST_ZONE);
|
||||
ForecastSolarObject fo = new ForecastSolarObject("fs-test", content,
|
||||
ForecastSolarObject forecastObject = new ForecastSolarObject("fs-test", content,
|
||||
queryDateTime.toInstant().plus(1, ChronoUnit.DAYS));
|
||||
assertEquals(Instant.parse("2022-07-17T03:31:00Z"), fo.getForecastBegin(), "First entry");
|
||||
assertEquals(Instant.parse("2022-07-17T04:00:00Z"), fo.getFirstPowerTimestamp().get(),
|
||||
assertEquals(Instant.parse("2022-07-17T03:31:00Z"), forecastObject.getForecastBegin(), "First entry");
|
||||
assertEquals(Instant.parse("2022-07-17T04:00:00Z"), forecastObject.getFirstPowerTimestamp().get(),
|
||||
"First entry with positive power value");
|
||||
}
|
||||
|
||||
@@ -72,24 +74,29 @@ class SmartForecastSolarTest {
|
||||
void testSmartAdjsutment() {
|
||||
String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||
ZonedDateTime queryDateTime = LocalDateTime.of(2022, 7, 17, 17, 00).atZone(TEST_ZONE);
|
||||
ForecastSolarObject fo = new ForecastSolarObject("fs-test", content,
|
||||
ForecastSolarObject forecastObject = new ForecastSolarObject("fs-test", content,
|
||||
queryDateTime.toInstant().plus(1, ChronoUnit.DAYS));
|
||||
fo.setCorrectionFactor(0.5); // set correction factor to 50% for testing)
|
||||
// set half of energy production for adjustment
|
||||
ForecastSolarObject adjusted = new ForecastSolarObject(forecastObject,
|
||||
forecastObject.getActualEnergyValue(queryDateTime) / 2, true);
|
||||
Optional<SolarForecastAdjuster> adjuster = adjusted.getAdjuster();
|
||||
assertTrue(adjuster.isPresent(), "Adjuster present");
|
||||
assertEquals(0.5, adjuster.get().getCorrectionFactor(), TOLERANCE, "Factor");
|
||||
// "2022-07-17 21:32:00": 63583,
|
||||
assertEquals(31.792, fo.getDayTotal(queryDateTime.toLocalDate()), TOLERANCE, "Total production");
|
||||
assertEquals(31.792, adjusted.getDayTotal(queryDateTime.toLocalDate()), TOLERANCE, "Total production");
|
||||
// "2022-07-17 17:00:00": 52896,
|
||||
assertEquals(26.448, fo.getActualEnergyValue(queryDateTime), TOLERANCE, "Current Production");
|
||||
assertEquals(26.448, adjusted.getActualEnergyValue(queryDateTime), TOLERANCE, "Current Production");
|
||||
// 63583 - 52896 = 10687
|
||||
assertEquals(5.344, fo.getRemainingProduction(queryDateTime), TOLERANCE, "Current Production");
|
||||
assertEquals(5.344, adjusted.getRemainingProduction(queryDateTime), TOLERANCE, "Current Production");
|
||||
// sum cross check
|
||||
assertEquals(fo.getDayTotal(queryDateTime.toLocalDate()),
|
||||
fo.getActualEnergyValue(queryDateTime) + fo.getRemainingProduction(queryDateTime), TOLERANCE,
|
||||
"actual + remain = total");
|
||||
assertEquals(adjusted.getDayTotal(queryDateTime.toLocalDate()),
|
||||
adjusted.getActualEnergyValue(queryDateTime) + adjusted.getRemainingProduction(queryDateTime),
|
||||
TOLERANCE, "actual + remain = total");
|
||||
|
||||
queryDateTime = LocalDateTime.of(2022, 7, 18, 19, 00).atZone(TEST_ZONE);
|
||||
// "2022-07-18 19:00:00": 63067,
|
||||
assertEquals(63.067, fo.getActualEnergyValue(queryDateTime), TOLERANCE, "Actual production");
|
||||
assertEquals(63.067, adjusted.getActualEnergyValue(queryDateTime), TOLERANCE, "Actual production");
|
||||
// "2022-07-18 21:31:00": 65554
|
||||
assertEquals(65.554, fo.getDayTotal(queryDateTime.toLocalDate()), TOLERANCE, "Total production");
|
||||
assertEquals(65.554, adjusted.getDayTotal(queryDateTime.toLocalDate()), TOLERANCE, "Total production");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -98,7 +98,8 @@ class SolcastTest {
|
||||
|
||||
JSONArray forecastJson = getForecast();
|
||||
now = LocalDateTime.of(2022, 7, 18, 0, 0).atZone(TEST_ZONE);
|
||||
scfo = new SolcastObject("sc-test", forecastJson, now.toInstant(), TIMEZONEPROVIDER, mock(Storage.class));
|
||||
scfo = new SolcastObject("sc-test", forecastJson, now.toInstant(), TIMEZONEPROVIDER,
|
||||
(Storage<String>) mock(Storage.class));
|
||||
}
|
||||
|
||||
static void setFixedTimeJul18() {
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.solarforecast.internal.forecastsolar.handler;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.config.core.Configuration;
|
||||
import org.openhab.core.library.types.PointType;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.binding.ThingHandlerCallback;
|
||||
|
||||
/**
|
||||
* The {@link ForecastSolarBridgeMock} mocks bridge handler for solar.forecast
|
||||
*
|
||||
* @author Bernd Weymann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ForecastSolarBridgeMock extends ForecastSolarBridgeHandler {
|
||||
|
||||
public ForecastSolarBridgeMock(Bridge bridge, PointType location) {
|
||||
super(bridge, location);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ThingHandlerCallback getCallback() {
|
||||
return super.getCallback();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateTimeseries() {
|
||||
super.updateTimeseries();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateConfiguration(Configuration config) {
|
||||
super.updateConfiguration(config);
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.solarforecast.internal.forecastsolar.handler;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.solarforecast.CallbackMock;
|
||||
import org.openhab.binding.solarforecast.FileReader;
|
||||
import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
|
||||
import org.openhab.binding.solarforecast.internal.forecastsolar.ForecastSolarObject;
|
||||
import org.openhab.core.config.core.Configuration;
|
||||
import org.openhab.core.library.types.PointType;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.openhab.core.thing.internal.BridgeImpl;
|
||||
import org.openhab.core.thing.internal.ThingImpl;
|
||||
|
||||
/**
|
||||
* The {@link ForecastSolarMockFactory} creates mock handler for solar.forecast
|
||||
*
|
||||
* @author Bernd Weymann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ForecastSolarMockFactory {
|
||||
|
||||
public static ForecastSolarBridgeMock createBridgeHandler() {
|
||||
BridgeImpl forecastSolarBridge = new BridgeImpl(SolarForecastBindingConstants.FORECAST_SOLAR_SITE, "bridge");
|
||||
ForecastSolarBridgeMock forecastSolarSite = new ForecastSolarBridgeMock(forecastSolarBridge,
|
||||
PointType.valueOf("1,2"));
|
||||
forecastSolarBridge.setHandler(forecastSolarSite);
|
||||
CallbackMock forecastSolarSiteCallback = new CallbackMock();
|
||||
forecastSolarSiteCallback.setBridge(forecastSolarBridge);
|
||||
forecastSolarSite.setCallback(forecastSolarSiteCallback);
|
||||
forecastSolarSite.initialize();
|
||||
return forecastSolarSite;
|
||||
}
|
||||
|
||||
public static ForecastSolarPlaneMock createPlaneHandler(ForecastSolarBridgeHandler bridgeHandler, String name,
|
||||
String forecastFile) {
|
||||
String contentOne = FileReader.readFileInString(forecastFile);
|
||||
ForecastSolarObject forecastSolarObject = new ForecastSolarObject(name + "-forecast", contentOne,
|
||||
Instant.now().plus(1, ChronoUnit.DAYS));
|
||||
ThingImpl forecastSolarPlaneThing = new ThingImpl(SolarForecastBindingConstants.FORECAST_SOLAR_PLANE,
|
||||
new ThingUID("test", name));
|
||||
forecastSolarPlaneThing.setBridgeUID(new ThingUID("solarforecast", "fs-site"));
|
||||
CallbackMock forecastSolarPlaneCallback = new CallbackMock();
|
||||
forecastSolarPlaneCallback.setBridge(bridgeHandler.getThing());
|
||||
ForecastSolarPlaneMock forecastSolarPlane = new ForecastSolarPlaneMock(forecastSolarPlaneThing,
|
||||
forecastSolarPlaneCallback);
|
||||
forecastSolarPlane.setCallback(forecastSolarPlaneCallback);
|
||||
forecastSolarPlane.updateConfiguration(getDefaultPlaneConfig());
|
||||
forecastSolarPlane.updateForecast(forecastSolarObject);
|
||||
forecastSolarPlane.initialize();
|
||||
forecastSolarPlaneCallback.waitForStatus(ThingStatus.ONLINE);
|
||||
return forecastSolarPlane;
|
||||
}
|
||||
|
||||
private static Configuration getDefaultPlaneConfig() {
|
||||
Configuration config = new Configuration();
|
||||
config.put("declination", "90");
|
||||
config.put("azimuth", "90");
|
||||
config.put("kwp", "90");
|
||||
return config;
|
||||
}
|
||||
}
|
||||
+22
-11
@@ -15,13 +15,13 @@ package org.openhab.binding.solarforecast.internal.forecastsolar.handler;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.openhab.binding.solarforecast.CallbackMock;
|
||||
import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
|
||||
import org.openhab.binding.solarforecast.internal.forecastsolar.ForecastSolarObject;
|
||||
import org.openhab.core.library.types.PointType;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.openhab.core.thing.internal.ThingImpl;
|
||||
import org.openhab.core.config.core.Configuration;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.binding.ThingHandlerCallback;
|
||||
|
||||
/**
|
||||
* The {@link ForecastSolarPlaneMock} mocks Plane Handler for solar.forecast
|
||||
@@ -32,15 +32,26 @@ import org.openhab.core.thing.internal.ThingImpl;
|
||||
@NonNullByDefault
|
||||
public class ForecastSolarPlaneMock extends ForecastSolarPlaneHandler {
|
||||
|
||||
public ForecastSolarPlaneMock(ForecastSolarObject fso) {
|
||||
super(new ThingImpl(SolarForecastBindingConstants.FORECAST_SOLAR_PLANE, new ThingUID("test", "plane")),
|
||||
mock(HttpClient.class));
|
||||
super.setCallback(new CallbackMock());
|
||||
setLocation(PointType.valueOf("1.23,9.87"));
|
||||
super.setForecast(fso);
|
||||
public ForecastSolarPlaneMock(Thing thing, CallbackMock cm) {
|
||||
super(thing, mock(HttpClient.class));
|
||||
super.setCallback(cm);
|
||||
}
|
||||
|
||||
public void updateForecast(ForecastSolarObject fso) {
|
||||
super.setForecast(fso);
|
||||
super.updateForecast(fso);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ThingHandlerCallback getCallback() {
|
||||
return super.getCallback();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateConfiguration(Configuration config) {
|
||||
super.updateConfiguration(config);
|
||||
}
|
||||
|
||||
public String getURL() {
|
||||
return super.buildUrl();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user