[solarforecast] Internal rework (#19815)

* sync and optional rework

Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
This commit is contained in:
Bernd Weymann
2026-01-24 16:07:29 +01:00
committed by GitHub
parent 83e5fb0c10
commit 27fa9d3616
18 changed files with 1300 additions and 708 deletions
@@ -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);
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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 + "]";
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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());
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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
@@ -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
}
}
@@ -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");
}
}
@@ -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() {
@@ -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);
}
}
@@ -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;
}
}
@@ -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();
}
}