diff --git a/solarnet/build.gradle b/solarnet/build.gradle index 260a6cb5f..8743edcfb 100644 --- a/solarnet/build.gradle +++ b/solarnet/build.gradle @@ -78,7 +78,7 @@ subprojects { jsonSchemaValidatorVersion = '1.3.2' myBatisStarterVersion = '3.0.3' saxonVersion = '12.4' - snCommonVersion = '3.24.1' + snCommonVersion = '3.25.0' snCommonExprSpelVersion = '3.1.0' snCommonMqttVersion = '5.0.0' snCommonMqttNettyVersion = '4.0.2' diff --git a/solarnet/cloud-integrations/build.gradle b/solarnet/cloud-integrations/build.gradle index 5d3d5365f..cce022919 100644 --- a/solarnet/cloud-integrations/build.gradle +++ b/solarnet/cloud-integrations/build.gradle @@ -14,7 +14,7 @@ dependencyManagement { } description = 'SolarNet: Cloud Integrations' -version = '1.4.0' +version = '1.5.0' base { archivesName = 'solarnet-cloud-integrations' diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/CloudDatumStreamService.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/CloudDatumStreamService.java index 5be8c17dd..b79e7c682 100644 --- a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/CloudDatumStreamService.java +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/CloudDatumStreamService.java @@ -45,6 +45,18 @@ public interface CloudDatumStreamService extends Identity, SettingSpecifierProvider, LocalizedServiceInfoProvider { + /** + * A standard setting for either a map or comma-delimited mapping list of + * data value references to associated source ID values. + * + *

+ * This setting is intended to be used by cloud services that can provide + * multiple SolarNetwork datum streams, as a way to map each cloud device to + * a SolarNetwork source. + *

+ */ + String SOURCE_ID_MAP_SETTING = "sourceIdMap"; + /** * Get a localized collection of the available data value filter criteria. * diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/BaseCloudDatumStreamService.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/BaseCloudDatumStreamService.java index b824dd0a3..e4e480eed 100644 --- a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/BaseCloudDatumStreamService.java +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/BaseCloudDatumStreamService.java @@ -29,8 +29,10 @@ import java.math.BigDecimal; import java.util.Collection; import java.util.List; +import java.util.Locale; import java.util.Map; import org.springframework.security.crypto.encrypt.TextEncryptor; +import com.fasterxml.jackson.databind.JsonNode; import net.solarnetwork.central.biz.UserEventAppenderBiz; import net.solarnetwork.central.c2c.biz.CloudDatumStreamService; import net.solarnetwork.central.c2c.biz.CloudIntegrationsExpressionService; @@ -38,16 +40,23 @@ import net.solarnetwork.central.c2c.dao.CloudDatumStreamMappingConfigurationDao; import net.solarnetwork.central.c2c.dao.CloudDatumStreamPropertyConfigurationDao; import net.solarnetwork.central.c2c.dao.CloudIntegrationConfigurationDao; +import net.solarnetwork.central.c2c.domain.BasicCloudDatumStreamLocalizedServiceInfo; import net.solarnetwork.central.c2c.domain.CloudDatumStreamPropertyConfiguration; +import net.solarnetwork.codec.JsonUtils; +import net.solarnetwork.domain.LocalizedServiceInfo; import net.solarnetwork.domain.datum.DatumSamplesExpressionRoot; +import net.solarnetwork.domain.datum.DatumSamplesType; import net.solarnetwork.domain.datum.MutableDatum; +import net.solarnetwork.service.IdentifiableConfiguration; import net.solarnetwork.settings.SettingSpecifier; +import net.solarnetwork.util.IntRange; +import net.solarnetwork.util.StringUtils; /** * Base implementation of {@link CloudDatumStreamService}. * * @author matt - * @version 1.2 + * @version 1.4 */ public abstract class BaseCloudDatumStreamService extends BaseCloudIntegrationsIdentifiableService implements CloudDatumStreamService { @@ -111,6 +120,55 @@ public BaseCloudDatumStreamService(String serviceIdentifier, String displayName, "datumStreamPropertyDao"); } + @Override + public LocalizedServiceInfo getLocalizedServiceInfo(Locale locale) { + return new BasicCloudDatumStreamLocalizedServiceInfo( + super.getLocalizedServiceInfo(locale != null ? locale : Locale.getDefault()), + getSettingSpecifiers(), requiresPolling(), supportedPlaceholders(), + supportedDataValueWildcardIdentifierLevels(), dataValueIdentifierLevelsSourceIdRange()); + } + + /** + * Get the polling requirement. + * + * @return {@literal true} if polling for data is required + * @since 1.3 + */ + protected boolean requiresPolling() { + return true; + } + + /** + * Get the supported placeholder keys. + * + * @return the supported placeholder key, or {@literal null} + * @since 1.3 + */ + protected Iterable supportedPlaceholders() { + return null; + } + + /** + * Get the supported data value wildcard levels. + * + * @return the supported data value wildcard levels, or {@literal null} + * @since 1.3 + */ + protected Iterable supportedDataValueWildcardIdentifierLevels() { + return null; + } + + /** + * Get the supported data value identifier levels source ID range. + * + * @return the supported data value identifier levels source ID range, or + * {@literal null} + * @since 1.4 + */ + protected IntRange dataValueIdentifierLevelsSourceIdRange() { + return null; + } + /** * Evaluate a set of property expressions on a set of datum. * @@ -172,4 +230,85 @@ public void evaulateExpressions(Collection map) { + String s = JsonUtils.parseNonEmptyStringAttribute(node, fieldName); + if ( s != null ) { + map.put(key, s); + } + } + + /** + * Resolve a mapping from a setting on a configuration. + * + * @param configuration + * the configuration to extract the mapping from + * @param key + * the service property key to extract + * @return the mapping, or {@literal null} + * @since 1.4 + */ + @SuppressWarnings("unchecked") + public static Map servicePropertyStringMap(IdentifiableConfiguration configuration, + String key) { + if ( configuration == null ) { + return null; + } + final Object sourceIdMap = configuration.serviceProperty(SOURCE_ID_MAP_SETTING, Object.class); + final Map componentSourceIdMapping; + if ( sourceIdMap instanceof Map ) { + componentSourceIdMapping = (Map) sourceIdMap; + } else if ( sourceIdMap != null ) { + componentSourceIdMapping = StringUtils.commaDelimitedStringToMap(sourceIdMap.toString()); + } else { + componentSourceIdMapping = null; + } + return componentSourceIdMapping; + } + + /** + * Parse a JSON datum property value. + * + * @param val + * the JSON value to parse as a datum property value. + * @param propType + * the desired datum property type + * @return the value, or {@literal null} + */ + public static Object parseJsonDatumPropertyValue(JsonNode val, DatumSamplesType propType) { + return switch (propType) { + case Accumulating, Instantaneous -> { + // convert to number + if ( val.isBigDecimal() ) { + yield val.decimalValue(); + } else if ( val.isFloat() ) { + yield val.floatValue(); + } else if ( val.isDouble() ) { + yield val.doubleValue(); + } else if ( val.isBigInteger() ) { + yield val.bigIntegerValue(); + } else if ( val.isLong() ) { + yield val.longValue(); + } else if ( val.isFloat() ) { + yield val.floatValue(); + } else { + yield narrow(parseNumber(val.asText(), BigDecimal.class), 2); + } + } + case Status, Tag -> val.asText(); + }; + } + } diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/LocusEnergyCloudDatumStreamService.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/LocusEnergyCloudDatumStreamService.java index 76c0adab3..f2bbe7233 100644 --- a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/LocusEnergyCloudDatumStreamService.java +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/LocusEnergyCloudDatumStreamService.java @@ -41,12 +41,9 @@ import static net.solarnetwork.central.c2c.domain.CloudDataValue.intermediateDataValue; import static net.solarnetwork.central.c2c.domain.CloudIntegrationsConfigurationEntity.resolvePlaceholders; import static net.solarnetwork.central.security.AuthorizationException.requireNonNullObject; -import static net.solarnetwork.util.NumberUtils.narrow; -import static net.solarnetwork.util.NumberUtils.parseNumber; import static net.solarnetwork.util.ObjectUtils.requireNonNullArgument; import static org.springframework.util.StringUtils.collectionToCommaDelimitedString; import static org.springframework.web.util.UriComponentsBuilder.fromUri; -import java.math.BigDecimal; import java.time.Instant; import java.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; @@ -137,7 +134,7 @@ * }} * * @author matt - * @version 1.7 + * @version 1.8 */ public class LocusEnergyCloudDatumStreamService extends BaseOAuth2ClientCloudDatumStreamService { @@ -168,6 +165,14 @@ public class LocusEnergyCloudDatumStreamService extends BaseOAuth2ClientCloudDat SETTINGS = List.of(granularitySpec); } + /** + * The supported placeholder keys. + * + * @since 1.8 + */ + public static final List SUPPORTED_PLACEHOLDERS = List.of(SITE_ID_FILTER, + COMPONENT_ID_FILTER); + private AsyncTaskExecutor executor; /** @@ -216,6 +221,11 @@ public LocusEnergyCloudDatumStreamService(AsyncTaskExecutor executor, this.executor = requireNonNullArgument(executor, "executor"); } + @Override + protected Iterable supportedPlaceholders() { + return SUPPORTED_PLACEHOLDERS; + } + @Override public Iterable dataValueFilters(Locale locale) { MessageSource ms = requireNonNullArgument(getMessageSource(), "messageSource"); @@ -697,27 +707,7 @@ private CloudDatumStreamQueryResult queryForDatum(CloudDatumStreamConfiguration JsonNode val = datumValues.get(fieldName); if ( val != null ) { DatumSamplesType propType = property.getPropertyType(); - Object propVal = switch (propType) { - case Accumulating, Instantaneous -> { - // convert to number - if ( val.isBigDecimal() ) { - yield val.decimalValue(); - } else if ( val.isFloat() ) { - yield val.floatValue(); - } else if ( val.isDouble() ) { - yield val.doubleValue(); - } else if ( val.isBigInteger() ) { - yield val.bigIntegerValue(); - } else if ( val.isLong() ) { - yield val.longValue(); - } else if ( val.isFloat() ) { - yield val.floatValue(); - } else { - yield narrow(parseNumber(val.asText(), BigDecimal.class), 2); - } - } - case Status, Tag -> val.asText(); - }; + Object propVal = parseJsonDatumPropertyValue(val, propType); propVal = property.applyValueTransforms(propVal); samples.putSampleValue(propType, property.getPropertyName(), propVal); } diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeCloudDatumStreamService.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeCloudDatumStreamService.java deleted file mode 100644 index 10eb14d18..000000000 --- a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeCloudDatumStreamService.java +++ /dev/null @@ -1,219 +0,0 @@ -/* ================================================================== - * SolarEdgeCloudDatumStreamService.java - 7/10/2024 7:03:25 am - * - * Copyright 2024 SolarNetwork.net Dev Team - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License as - * published by the Free Software Foundation; either version 2 of - * the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA - * 02111-1307 USA - * ================================================================== - */ - -package net.solarnetwork.central.c2c.biz.impl; - -import static net.solarnetwork.central.c2c.domain.CloudDataValue.COUNTRY_METADATA; -import static net.solarnetwork.central.c2c.domain.CloudDataValue.LOCALITY_METADATA; -import static net.solarnetwork.central.c2c.domain.CloudDataValue.POSTAL_CODE_METADATA; -import static net.solarnetwork.central.c2c.domain.CloudDataValue.STATE_PROVINCE_METADATA; -import static net.solarnetwork.central.c2c.domain.CloudDataValue.STREET_ADDRESS_METADATA; -import static net.solarnetwork.central.c2c.domain.CloudDataValue.dataValue; -import static net.solarnetwork.central.security.AuthorizationException.requireNonNullObject; -import static net.solarnetwork.util.ObjectUtils.requireNonNullArgument; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import org.slf4j.LoggerFactory; -import org.springframework.context.MessageSource; -import org.springframework.security.crypto.encrypt.TextEncryptor; -import org.springframework.web.client.RestOperations; -import org.springframework.web.util.UriComponentsBuilder; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import net.solarnetwork.central.biz.UserEventAppenderBiz; -import net.solarnetwork.central.c2c.biz.CloudDatumStreamService; -import net.solarnetwork.central.c2c.biz.CloudIntegrationsExpressionService; -import net.solarnetwork.central.c2c.dao.CloudDatumStreamConfigurationDao; -import net.solarnetwork.central.c2c.dao.CloudDatumStreamMappingConfigurationDao; -import net.solarnetwork.central.c2c.dao.CloudDatumStreamPropertyConfigurationDao; -import net.solarnetwork.central.c2c.dao.CloudIntegrationConfigurationDao; -import net.solarnetwork.central.c2c.domain.CloudDataValue; -import net.solarnetwork.central.c2c.domain.CloudDatumStreamConfiguration; -import net.solarnetwork.central.c2c.domain.CloudDatumStreamQueryFilter; -import net.solarnetwork.central.c2c.domain.CloudDatumStreamQueryResult; -import net.solarnetwork.central.c2c.domain.CloudIntegrationConfiguration; -import net.solarnetwork.central.domain.UserLongCompositePK; -import net.solarnetwork.domain.BasicLocalizedServiceInfo; -import net.solarnetwork.domain.LocalizedServiceInfo; -import net.solarnetwork.domain.datum.Datum; - -/** - * SolarEdge implementation of {@link CloudDatumStreamService}. - * - * @author matt - * @version 1.1 - */ -public class SolarEdgeCloudDatumStreamService extends BaseRestOperationsCloudDatumStreamService { - - /** The service identifier. */ - public static final String SERVICE_IDENTIFIER = "s10k.c2c.ds.solaredge"; - - /** The data value filter key for a site ID. */ - public static final String SITE_ID_FILTER = "siteId"; - - /** - * Constructor. - * - * @param userEventAppenderBiz - * the user event appender service - * @param encryptor - * the sensitive key encryptor - * @param expressionService - * the expression service - * @param integrationDao - * the integration DAO - * @param datumStreamDao - * the datum stream DAO - * @param datumStreamMappingDao - * the datum stream mapping DAO - * @param datumStreamPropertyDao - * the datum stream property DAO - * @param restOps - * the REST operations - * @throws IllegalArgumentException - * if any argument is {@literal null} - */ - public SolarEdgeCloudDatumStreamService(UserEventAppenderBiz userEventAppenderBiz, - TextEncryptor encryptor, CloudIntegrationsExpressionService expressionService, - CloudIntegrationConfigurationDao integrationDao, - CloudDatumStreamConfigurationDao datumStreamDao, - CloudDatumStreamMappingConfigurationDao datumStreamMappingDao, - CloudDatumStreamPropertyConfigurationDao datumStreamPropertyDao, RestOperations restOps) { - super(SERVICE_IDENTIFIER, "SolarEdge Datum Stream Service", userEventAppenderBiz, encryptor, - expressionService, integrationDao, datumStreamDao, datumStreamMappingDao, - datumStreamPropertyDao, Collections.emptyList(), - new SolarEdgeRestOperationsHelper( - LoggerFactory.getLogger(SolarEdgeCloudDatumStreamService.class), - userEventAppenderBiz, restOps, HTTP_ERROR_TAGS, encryptor, - integrationServiceIdentifier -> SolarEdgeCloudIntegrationService.SECURE_SETTINGS)); - } - - @Override - public Iterable dataValueFilters(Locale locale) { - MessageSource ms = requireNonNullArgument(getMessageSource(), "messageSource"); - List result = new ArrayList<>(2); - for ( String key : new String[] { SITE_ID_FILTER } ) { - result.add(new BasicLocalizedServiceInfo(key, locale, - ms.getMessage("dataValueFilter.%s.key".formatted(key), null, key, locale), - ms.getMessage("dataValueFilter.%s.desc".formatted(key), null, null, locale), null)); - } - return result; - } - - @Override - public Iterable dataValues(UserLongCompositePK integrationId, - Map filters) { - final CloudIntegrationConfiguration integration = requireNonNullObject( - integrationDao.get(requireNonNullArgument(integrationId, "integrationId")), - "integration"); - List result = Collections.emptyList(); - if ( filters != null && filters.get(SITE_ID_FILTER) != null ) { - // TODO - } else { - result = sites(integration); - } - Collections.sort(result); - return result; - } - - private List sites(CloudIntegrationConfiguration integration) { - return restOpsHelper.httpGet("List sites", integration, ObjectNode.class, - (req) -> UriComponentsBuilder.fromUri(SolarEdgeCloudIntegrationService.BASE_URI) - .path(SolarEdgeCloudIntegrationService.V2_SITES_LIST_URL).buildAndExpand() - .toUri(), - res -> parseSites(res.getBody())); - } - - private static List parseSites(ObjectNode json) { - assert json != null; - /*- EXAMPLE JSON: - [ - { - "siteId": 93082, - "name": "Smith, John CRM1234", - "peakPower": 6.14, - "installationDate": "2022-11-10", - "location": { - "address": "2888 Main St", - "city": "Green Bay", - "state": "Wisconsin", - "zip": "54311", - "country": "United States" - }, - "activationStatus": "Active", - "note": "Created via API, triggered from CRM" - } - ] - */ - List result = new ArrayList<>(4); - for ( JsonNode siteNode : json ) { - final String id = siteNode.path("siteId").asText(); - final String name = siteNode.path("name").asText().trim(); - final var meta = new LinkedHashMap(4); - final JsonNode locNode = siteNode.path("location"); - if ( locNode.hasNonNull("address") ) { - meta.put(STREET_ADDRESS_METADATA, locNode.path("address").asText().trim()); - } - if ( locNode.hasNonNull("city") ) { - meta.put(LOCALITY_METADATA, locNode.path("city").asText().trim()); - } - if ( locNode.hasNonNull("state") ) { - meta.put(STATE_PROVINCE_METADATA, locNode.path("state").asText().trim()); - } - if ( locNode.hasNonNull("country") ) { - meta.put(COUNTRY_METADATA, locNode.path("country").asText().trim()); - } - if ( locNode.hasNonNull("zip") ) { - meta.put(POSTAL_CODE_METADATA, locNode.path("zip").asText().trim()); - } - if ( siteNode.hasNonNull("activationStatus") ) { - meta.put("activationStatus", siteNode.path("activationStatus").asText().trim()); - } - if ( siteNode.hasNonNull("note") ) { - meta.put("note", siteNode.path("note").asText().trim()); - } - result.add(dataValue(List.of(id), name, meta.isEmpty() ? null : meta)); - } - return result; - } - - @Override - public Iterable latestDatum(CloudDatumStreamConfiguration datumStream) { - requireNonNullArgument(datumStream, "datumStream"); - // TODO - return null; - } - - @Override - public CloudDatumStreamQueryResult datum(CloudDatumStreamConfiguration datumStream, - CloudDatumStreamQueryFilter filter) { - requireNonNullArgument(datumStream, "datumStream"); - requireNonNullArgument(filter, "filter"); - // TODO Auto-generated method stub - return null; - } - -} diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeDeviceType.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeDeviceType.java new file mode 100644 index 000000000..cd6b366f8 --- /dev/null +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeDeviceType.java @@ -0,0 +1,96 @@ +/* ================================================================== + * SolarEdgeDeviceType.java - 23/10/2024 11:37:47 am + * + * Copyright 2024 SolarNetwork.net Dev Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA + * 02111-1307 USA + * ================================================================== + */ + +package net.solarnetwork.central.c2c.biz.impl; + +import com.fasterxml.jackson.annotation.JsonCreator; + +/** + * A SolarEdge device type. + * + * @author matt + * @version 1.0 + */ +public enum SolarEdgeDeviceType { + + /** An inverter. */ + Inverter("inv", "Inverters"), + + /** A meter. */ + Meter("met", "Meters"), + + /** A battery. */ + Battery("bat", "Batteries"), + + /** A sensor, such as irradiance or temperature. */ + Sensor("sen", "Sensors"), + + ; + + private final String key; + private final String groupKey; + + private SolarEdgeDeviceType(String key, String groupKey) { + this.key = key; + this.groupKey = groupKey; + } + + /** + * Get the key. + * + * @return the key, never {@literal null} + */ + public final String getKey() { + return key; + } + + /** + * Get the group key. + * + * @return the group key + */ + public final String getGroupKey() { + return groupKey; + } + + /** + * Get an enum instance for a name or key value. + * + * @param value + * the enumeration name or key value, case-insensitve + * @return the enum + * @throws IllegalArgumentException + * if {@code value} is not a valid value + */ + @JsonCreator + public static SolarEdgeDeviceType fromValue(String value) { + if ( value != null ) { + for ( SolarEdgeDeviceType e : SolarEdgeDeviceType.values() ) { + if ( value.equalsIgnoreCase(e.key) || value.equalsIgnoreCase(e.name()) ) { + return e; + } + } + } + throw new IllegalArgumentException("Unknown SolarEdgeDeviceType value [" + value + "]"); + } + +} diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeResolution.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeResolution.java new file mode 100644 index 000000000..986b2cf6c --- /dev/null +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeResolution.java @@ -0,0 +1,123 @@ +/* ================================================================== + * SolarEdgeResolution.java - 23/10/2024 9:45:52 am + * + * Copyright 2024 SolarNetwork.net Dev Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA + * 02111-1307 USA + * ================================================================== + */ + +package net.solarnetwork.central.c2c.biz.impl; + +import static java.time.ZoneOffset.UTC; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import com.fasterxml.jackson.annotation.JsonCreator; + +/** + * Enumeration of SolarEdge data resolution values. + * + * @author matt + * @version 1.0 + */ +public enum SolarEdgeResolution { + + /** 15 minute resolution. */ + FifteenMinute("QUARTER_OF_AN_HOUR", Duration.ofMinutes(15)), + + /** Hour resolution. */ + Hour("HOUR", Duration.ofHours(1)), + + ; + + private final String key; + private final Duration tickDuration; + + private SolarEdgeResolution(String key) { + this(key, null); + } + + private SolarEdgeResolution(String key, Duration tickDuration) { + this.key = key; + this.tickDuration = tickDuration; + } + + /** + * Get the key. + * + * @return the key, never {@literal null} + */ + public final String getKey() { + return key; + } + + /** + * Get a clock tick duration appropriate for this granularity. + * + * @return the duration, never {@literal null} + */ + public Duration getTickDuration() { + return tickDuration; + } + + /** + * Truncate a date based on the tick duration of this resolution. + * + * @param date + * the date to truncate + * @return the truncated date + */ + public Instant truncateDate(Instant date) { + return Clock.tick(Clock.fixed(date, UTC), tickDuration).instant(); + } + + /** + * Get a date exactly the resolution's {@code tickDuration} after a given + * date. + * + * @param date + * the starting date + * @return the next later date + */ + public Instant nextDate(Instant date) { + return date.plus(tickDuration); + } + + /** + * Get an enum instance for a name or key value. + * + * @param value + * the enumeration name or key value, case-insensitve + * @return the enum; if {@code value} is {@literal null} or empty then + * {@link #FifteenMinute} is returned + * @throws IllegalArgumentException + * if {@code value} is not a valid value + */ + @JsonCreator + public static SolarEdgeResolution fromValue(String value) { + if ( value == null || value.isEmpty() ) { + return FifteenMinute; + } + for ( SolarEdgeResolution e : SolarEdgeResolution.values() ) { + if ( value.equalsIgnoreCase(e.key) || value.equalsIgnoreCase(e.name()) ) { + return e; + } + } + throw new IllegalArgumentException("Unknown SolarEdgeResolution value [" + value + "]"); + } + +} diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeTimeUnit.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeTimeUnit.java new file mode 100644 index 000000000..f99028cac --- /dev/null +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeTimeUnit.java @@ -0,0 +1,110 @@ +/* ================================================================== + * SolarEdgeResolution.java - 23/10/2024 9:45:52 am + * + * Copyright 2024 SolarNetwork.net Dev Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA + * 02111-1307 USA + * ================================================================== + */ + +package net.solarnetwork.central.c2c.biz.impl; + +import java.time.Period; +import com.fasterxml.jackson.annotation.JsonCreator; + +/** + * Enumeration of SolarEdge data time unit values. + * + * @author matt + * @version 1.0 + */ +public enum SolarEdgeTimeUnit { + + /** 15 minute resolution. */ + FifteenMinute("QUARTER_OF_AN_HOUR", Period.ofDays(31)), + + /** Hour resolution. */ + Hour("HOUR", Period.ofDays(31)), + + /** Hour resolution. */ + Day("DAY", Period.ofYears(1)), + + /** Hour resolution. */ + Week("WEEK"), + + /** Hour resolution. */ + Month("MONTH"), + + /** Hour resolution. */ + Year("YEAR"), + + ; + + private final String key; + private final Period constraint; + + private SolarEdgeTimeUnit(String key) { + this(key, null); + } + + private SolarEdgeTimeUnit(String key, Period constraint) { + this.key = key; + this.constraint = constraint; + } + + /** + * Get the key. + * + * @return the key, never {@literal null} + */ + public final String getKey() { + return key; + } + + /** + * Get the query time range constraint. + * + * @return the maximum query time range, or {@literal null} if there is no + * limit + */ + public final Period getConstraint() { + return constraint; + } + + /** + * Get an enum instance for a name or key value. + * + * @param value + * the enumeration name or key value, case-insensitve + * @return the enum; if {@code value} is {@literal null} or empty then + * {@link #FifteenMinute} is returned + * @throws IllegalArgumentException + * if {@code value} is not a valid value + */ + @JsonCreator + public static SolarEdgeTimeUnit fromValue(String value) { + if ( value == null || value.isEmpty() ) { + return FifteenMinute; + } + for ( SolarEdgeTimeUnit e : SolarEdgeTimeUnit.values() ) { + if ( value.equalsIgnoreCase(e.key) || value.equalsIgnoreCase(e.name()) ) { + return e; + } + } + throw new IllegalArgumentException("Unknown SolarEdgeTimeUnit value [" + value + "]"); + } + +} diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeV1CloudDatumStreamService.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeV1CloudDatumStreamService.java new file mode 100644 index 000000000..a32311763 --- /dev/null +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeV1CloudDatumStreamService.java @@ -0,0 +1,1524 @@ +/* ================================================================== + * SolarEdgeV1CloudDatumStreamService.java - 7/10/2024 7:03:25 am + * + * Copyright 2024 SolarNetwork.net Dev Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA + * 02111-1307 USA + * ================================================================== + */ + +package net.solarnetwork.central.c2c.biz.impl; + +import static java.util.Collections.unmodifiableMap; +import static net.solarnetwork.central.c2c.biz.impl.BaseCloudIntegrationService.resolveBaseUrl; +import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeDeviceType.Battery; +import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeDeviceType.Inverter; +import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeDeviceType.Meter; +import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeV1CloudIntegrationService.BASE_URI; +import static net.solarnetwork.central.c2c.domain.CloudDataValue.COUNTRY_METADATA; +import static net.solarnetwork.central.c2c.domain.CloudDataValue.DEVICE_FIRMWARE_VERSION_METADATA; +import static net.solarnetwork.central.c2c.domain.CloudDataValue.DEVICE_MODEL_METADATA; +import static net.solarnetwork.central.c2c.domain.CloudDataValue.DEVICE_SERIAL_NUMBER_METADATA; +import static net.solarnetwork.central.c2c.domain.CloudDataValue.LOCALITY_METADATA; +import static net.solarnetwork.central.c2c.domain.CloudDataValue.MANUFACTURER_METADATA; +import static net.solarnetwork.central.c2c.domain.CloudDataValue.POSTAL_CODE_METADATA; +import static net.solarnetwork.central.c2c.domain.CloudDataValue.STATE_PROVINCE_METADATA; +import static net.solarnetwork.central.c2c.domain.CloudDataValue.STREET_ADDRESS_METADATA; +import static net.solarnetwork.central.c2c.domain.CloudDataValue.TIME_ZONE_METADATA; +import static net.solarnetwork.central.c2c.domain.CloudDataValue.WILDCARD_IDENTIFIER; +import static net.solarnetwork.central.c2c.domain.CloudDataValue.dataValue; +import static net.solarnetwork.central.c2c.domain.CloudDataValue.intermediateDataValue; +import static net.solarnetwork.central.c2c.domain.CloudIntegrationsConfigurationEntity.resolvePlaceholders; +import static net.solarnetwork.central.security.AuthorizationException.requireNonNullObject; +import static net.solarnetwork.util.ObjectUtils.requireNonNullArgument; +import static net.solarnetwork.util.StringUtils.nonEmptyString; +import static org.springframework.web.util.UriComponentsBuilder.fromUri; +import java.time.Clock; +import java.time.DateTimeException; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import javax.cache.Cache; +import org.slf4j.LoggerFactory; +import org.springframework.context.MessageSource; +import org.springframework.security.crypto.encrypt.TextEncryptor; +import org.springframework.validation.BindException; +import org.springframework.validation.Errors; +import org.springframework.web.client.RestOperations; +import com.fasterxml.jackson.databind.JsonNode; +import net.solarnetwork.central.ValidationException; +import net.solarnetwork.central.biz.UserEventAppenderBiz; +import net.solarnetwork.central.c2c.biz.CloudDatumStreamService; +import net.solarnetwork.central.c2c.biz.CloudIntegrationsExpressionService; +import net.solarnetwork.central.c2c.dao.CloudDatumStreamConfigurationDao; +import net.solarnetwork.central.c2c.dao.CloudDatumStreamMappingConfigurationDao; +import net.solarnetwork.central.c2c.dao.CloudDatumStreamPropertyConfigurationDao; +import net.solarnetwork.central.c2c.dao.CloudIntegrationConfigurationDao; +import net.solarnetwork.central.c2c.domain.BasicCloudDatumStreamQueryResult; +import net.solarnetwork.central.c2c.domain.BasicQueryFilter; +import net.solarnetwork.central.c2c.domain.CloudDataValue; +import net.solarnetwork.central.c2c.domain.CloudDatumStreamConfiguration; +import net.solarnetwork.central.c2c.domain.CloudDatumStreamMappingConfiguration; +import net.solarnetwork.central.c2c.domain.CloudDatumStreamPropertyConfiguration; +import net.solarnetwork.central.c2c.domain.CloudDatumStreamQueryFilter; +import net.solarnetwork.central.c2c.domain.CloudDatumStreamQueryResult; +import net.solarnetwork.central.c2c.domain.CloudIntegrationConfiguration; +import net.solarnetwork.central.domain.UserLongCompositePK; +import net.solarnetwork.domain.BasicLocalizedServiceInfo; +import net.solarnetwork.domain.LocalizedServiceInfo; +import net.solarnetwork.domain.datum.Datum; +import net.solarnetwork.domain.datum.DatumId; +import net.solarnetwork.domain.datum.DatumSamples; +import net.solarnetwork.domain.datum.GeneralDatum; +import net.solarnetwork.settings.SettingSpecifier; +import net.solarnetwork.settings.support.BasicMultiValueSettingSpecifier; +import net.solarnetwork.util.DateUtils; +import net.solarnetwork.util.IntRange; +import net.solarnetwork.util.StringUtils; + +/** + * SolarEdge implementation of {@link CloudDatumStreamService} using the V1 API. + * + * @author matt + * @version 1.0 + */ +public class SolarEdgeV1CloudDatumStreamService extends BaseRestOperationsCloudDatumStreamService { + + /** The service identifier. */ + public static final String SERVICE_IDENTIFIER = "s10k.c2c.ds.solaredge.v1"; + + /** The data value filter key for a site ID. */ + public static final String SITE_ID_FILTER = "siteId"; + + /** + * The data value filter key for a {@link SolarEdgeDeviceType} device type. + */ + public static final String DEVICE_TYPE_FILTER = "deviceType"; + + /** The data value filter key for a component ID. */ + private static final String COMPONENT_ID_FILTER = "componentId"; + + /** The setting for resolution. */ + public static final String RESOLUTION_SETTING = "resolution"; + + /** The service settings. */ + public static final List SETTINGS; + static { + // menu for granularity + var resolutionSpec = new BasicMultiValueSettingSpecifier(RESOLUTION_SETTING, + SolarEdgeResolution.FifteenMinute.getKey()); + var resolutionTitles = unmodifiableMap(Arrays.stream(SolarEdgeResolution.values()) + .collect(Collectors.toMap(SolarEdgeResolution::getKey, SolarEdgeResolution::getKey, + (l, r) -> r, () -> new LinkedHashMap<>(SolarEdgeResolution.values().length)))); + resolutionSpec.setValueTitles(resolutionTitles); + + SETTINGS = List.of(resolutionSpec); + } + + /** + * The URI path to list the inventory for a given site. + * + *

+ * Accepts a single {@code {siteId}} parameter. + *

+ */ + public static final String SITE_INVENTORY_URL_TEMPLATE = "/site/{siteId}/inventory"; + + /** + * The URI path to view the details for a given site. + * + *

+ * Accepts one parameter: {@code {siteId}}. + *

+ */ + public static final String SITE_DETAILS_URL_TEMPLATE = "/site/{siteId}/details"; + + /** + * The URI path to list the equipment data for a given site and component. + * + *

+ * Accepts two parameters: {@code {siteId}} and {@code {componentId}}. + *

+ */ + public static final String EQUIPMENT_DATA_URL_TEMPLATE = "/equipment/{siteId}/{componentId}/data"; + + /** + * The URI path to list the meter power data for a given site. + * + *

+ * Accepts one parameter: {@code {siteId}}. + *

+ */ + public static final String POWER_DETAILS_URL_TEMPLATE = "/site/{siteId}/powerDetails"; + + /** + * The URI path to list the meter energy data for a given site. + * + *

+ * Accepts one parameter: {@code {siteId}}. + *

+ */ + public static final String METERS_URL_TEMPLATE = "/site/{siteId}/meters"; + + /** + * The URI path to list the storage (battery) data for a given site. + * + *

+ * Accepts one parameter: {@code {siteId}}. + *

+ */ + public static final String STORAGE_DATA_URL_TEMPLATE = "/site/{siteId}/storageData"; + + /** The supported placeholder keys. */ + public static final List SUPPORTED_PLACEHOLDERS = List.of(SITE_ID_FILTER); + + /** The supported data value wildcard levels. */ + public static final List SUPPORTED_DATA_VALUE_WILDCARD_LEVELS = List.of(2); + + /** The data value identifier levels source ID range. */ + public static final IntRange DATA_VALUE_IDENTIFIER_LEVELS_SOURCE_ID_RANGE = IntRange.rangeOf(1, 2); + + /** The maximum length of time to query for data. */ + public static final Duration MAX_QUERY_TIME_RANGE = Duration.ofDays(7); + + private final Clock clock; + + /** + * A cache of SolarEdge site IDs to associated time zones. This is used + * because the timestamps returned from the API are all in site-local time. + */ + private Cache siteTimeZoneCache; + + /** + * A cache of SolarEdge site IDs to associated inventory information. This + * is used to resolve the available device identifiers for a given site. + */ + private Cache siteInventoryCache; + + /** + * Constructor. + * + * @param userEventAppenderBiz + * the user event appender service + * @param encryptor + * the sensitive key encryptor + * @param expressionService + * the expression service + * @param integrationDao + * the integration DAO + * @param datumStreamDao + * the datum stream DAO + * @param datumStreamMappingDao + * the datum stream mapping DAO + * @param datumStreamPropertyDao + * the datum stream property DAO + * @param restOps + * the REST operations + * @param clock + * the clock to use + * @throws IllegalArgumentException + * if any argument is {@literal null} + */ + public SolarEdgeV1CloudDatumStreamService(UserEventAppenderBiz userEventAppenderBiz, + TextEncryptor encryptor, CloudIntegrationsExpressionService expressionService, + CloudIntegrationConfigurationDao integrationDao, + CloudDatumStreamConfigurationDao datumStreamDao, + CloudDatumStreamMappingConfigurationDao datumStreamMappingDao, + CloudDatumStreamPropertyConfigurationDao datumStreamPropertyDao, RestOperations restOps, + Clock clock) { + super(SERVICE_IDENTIFIER, "SolarEdge V1 Datum Stream Service", userEventAppenderBiz, encryptor, + expressionService, integrationDao, datumStreamDao, datumStreamMappingDao, + datumStreamPropertyDao, SETTINGS, + new SolarEdgeV1RestOperationsHelper( + LoggerFactory.getLogger(SolarEdgeV1CloudDatumStreamService.class), + userEventAppenderBiz, restOps, HTTP_ERROR_TAGS, encryptor, + integrationServiceIdentifier -> SolarEdgeV1CloudIntegrationService.SECURE_SETTINGS)); + this.clock = requireNonNullArgument(clock, "clock"); + } + + @Override + protected Iterable supportedPlaceholders() { + return SUPPORTED_PLACEHOLDERS; + } + + @Override + protected Iterable supportedDataValueWildcardIdentifierLevels() { + return SUPPORTED_DATA_VALUE_WILDCARD_LEVELS; + } + + @Override + protected IntRange dataValueIdentifierLevelsSourceIdRange() { + return DATA_VALUE_IDENTIFIER_LEVELS_SOURCE_ID_RANGE; + } + + @Override + public Iterable dataValueFilters(Locale locale) { + MessageSource ms = requireNonNullArgument(getMessageSource(), "messageSource"); + List result = new ArrayList<>(2); + for ( String key : new String[] { SITE_ID_FILTER, DEVICE_TYPE_FILTER, COMPONENT_ID_FILTER } ) { + result.add(new BasicLocalizedServiceInfo(key, locale, + ms.getMessage("dataValueFilter.%s.key".formatted(key), null, key, locale), + ms.getMessage("dataValueFilter.%s.desc".formatted(key), null, null, locale), null)); + } + return result; + } + + @Override + public Iterable dataValues(UserLongCompositePK integrationId, + Map filters) { + final CloudIntegrationConfiguration integration = requireNonNullObject( + integrationDao.get(requireNonNullArgument(integrationId, "integrationId")), + "integration"); + List result = Collections.emptyList(); + if ( filters != null && filters.get(SITE_ID_FILTER) != null + && filters.get(DEVICE_TYPE_FILTER) != null + && filters.get(COMPONENT_ID_FILTER) != null ) { + result = components(integration, filters); + } else if ( filters != null && filters.get(SITE_ID_FILTER) != null ) { + result = siteInventory(integration, filters); + } else { + // list available sites + result = sites(integration); + } + Collections.sort(result); + return result; + } + + private List sites(CloudIntegrationConfiguration integration) { + return restOpsHelper.httpGet("List sites", integration, JsonNode.class, + (req) -> fromUri(resolveBaseUrl(integration, BASE_URI)) + .path(SolarEdgeV1CloudIntegrationService.SITES_LIST_URL) + .buildAndExpand(integration.getServiceProperties()).toUri(), + res -> parseSites(res.getBody())); + } + + private List siteInventory(CloudIntegrationConfiguration integration, + Map filters) { + return restOpsHelper.httpGet("List site inventory", integration, JsonNode.class, + (req) -> fromUri(resolveBaseUrl(integration, BASE_URI)).path(SITE_INVENTORY_URL_TEMPLATE) + .buildAndExpand(filters).toUri(), + res -> parseSiteInventory(res.getBody(), filters)); + } + + private List components(CloudIntegrationConfiguration integration, + Map filters) { + final String siteId = filters.get(SITE_ID_FILTER).toString(); + final SolarEdgeDeviceType deviceType = SolarEdgeDeviceType + .fromValue(filters.get(DEVICE_TYPE_FILTER).toString()); + final String componentId = filters.get(COMPONENT_ID_FILTER).toString(); + return switch (deviceType) { + case Inverter -> inverterDataValues(siteId, deviceType, componentId); + case Meter -> meterDataValues(siteId, deviceType, componentId); + case Battery -> batteryDataValues(siteId, deviceType, componentId); + default -> Collections.emptyList(); + }; + } + + private static List parseSites(JsonNode json) { + assert json != null; + /*- EXAMPLE JSON: + { + "sites": { + "count": 43, + "site": [ + { + "id": 123123, + "name": "Acme", + "accountId": 123123, + "status": "Active", + "peakPower": 244.05, + "lastUpdateTime": "2024-10-22", + "installationDate": "2022-05-02", + "ptoDate": null, + "notes": "", + "type": "Optimizers & Inverters", + "location": { + "country": "United States", + "state": "Rhode Island", + "city": "East Providence", + "address": "123 Main Street", + "address2": "", + "zip": "02916", + "timeZone": "America/New_York", + "countryCode": "US", + "stateCode": "RI" + }, + "alertQuantity": 1, + "highestImpact": 2, + "primaryModule": { + "manufacturerName": "Q Cells", + "modelName": "Q.PEAK DUO L-G5.2 385", + "maximumPower": 395.0, + "temperatureCoef": -0.28 + }, + "uris": { + "DETAILS": "/site/123123/details", + "DATA_PERIOD": "/site/123123/dataPeriod", + "OVERVIEW": "/site/123123/overview" + }, + "publicSettings": { + "isPublic": false + } + }, + */ + final var result = new ArrayList(4); + for ( JsonNode siteNode : json.path("sites").path("site") ) { + final String id = siteNode.path("id").asText(); + final String name = siteNode.path("name").asText().trim(); + final var meta = new LinkedHashMap(4); + if ( siteNode.hasNonNull("status") ) { + meta.put("status", siteNode.path("status").asText()); + } + final JsonNode locNode = siteNode.path("location"); + if ( locNode.isObject() ) { + populateNonEmptyValue(locNode, "address", STREET_ADDRESS_METADATA, meta); + populateNonEmptyValue(locNode, "city", LOCALITY_METADATA, meta); + populateNonEmptyValue(locNode, "state", STATE_PROVINCE_METADATA, meta); + populateNonEmptyValue(locNode, "country", COUNTRY_METADATA, meta); + populateNonEmptyValue(locNode, "zip", POSTAL_CODE_METADATA, meta); + populateNonEmptyValue(locNode, "timeZone", TIME_ZONE_METADATA, meta); + } + populateNonEmptyValue(siteNode, "activationStatus", "activationStatus", meta); + populateNonEmptyValue(siteNode, "notes", "notes", meta); + + result.add(intermediateDataValue(List.of(id), name, meta.isEmpty() ? null : meta)); + } + return result; + } + + private static List parseSiteInventory(JsonNode json, Map filters) { + final String siteId = filters.get(SITE_ID_FILTER).toString(); + assert json != null; + /*- EXAMPLE JSON: + { + "Inventory": { + "meters": [ + {...} + ], + "sensors": [], + "gateways": [], + "batteries": [ + {...} + ], + "inverters": [ + {...} + ], + "thirdPartyInverters": [ + {...} + ] + } + } + */ + final var result = new ArrayList(4); + final JsonNode inventoryNode = json.path("Inventory"); + + // inverters + if ( !(inventoryNode.path("inverters").isEmpty() + && inventoryNode.path("thirdPartyInverters").isEmpty()) ) { + /*- EXAMPLE JSON: + { + "name": "Inverter 1", + "manufacturer": "SolarEdge", + "model": "SE7600A-USS20NHY2", + "communicationMethod": "SOLAREDGE_LTE", + "dsp1Version": "1.210.1623", + "dsp2Version": "2.52.615", + "cpuVersion": "3.2724.0", + "SN": "77777777-BA", + "connectedOptimizers": 20 + } + */ + final var inverterValues = new ArrayList( + inventoryNode.path("inverters").size()); + for ( String nodeName : new String[] { "inverters", "thirdPartyInverters" } ) { + for ( JsonNode inverterNode : inventoryNode.path(nodeName) ) { + final String id = inverterNode.path("SN").asText().trim(); + if ( id.isEmpty() ) { + continue; + } + final String name = inverterNode.path("name").asText().trim(); + final var meta = new LinkedHashMap(4); + meta.put(DEVICE_SERIAL_NUMBER_METADATA, id); + populateNonEmptyValue(inverterNode, "manufacturer", MANUFACTURER_METADATA, meta); + populateNonEmptyValue(inverterNode, "model", DEVICE_MODEL_METADATA, meta); + if ( inverterNode.hasNonNull("dsp1Version") || inverterNode.hasNonNull("dsp2Version") + || inverterNode.hasNonNull("cpuVersion") ) { + StringBuilder buf = new StringBuilder(); + if ( inverterNode.hasNonNull("dsp1Version") ) { + buf.append("DSP1: ") + .append(inverterNode.path("dsp1Version").asText().trim()); + } + if ( inverterNode.hasNonNull("dsp2Version") ) { + if ( !buf.isEmpty() ) { + buf.append(", "); + } + buf.append("DSP2: ") + .append(inverterNode.path("dsp1Version").asText().trim()); + } + if ( inverterNode.hasNonNull("cpuVersion") ) { + if ( !buf.isEmpty() ) { + buf.append(", "); + } + buf.append("CPU: ").append(inverterNode.path("cpuVersion").asText().trim()); + } + meta.put(DEVICE_FIRMWARE_VERSION_METADATA, buf.toString()); + } + + inverterValues.add( + intermediateDataValue(List.of(siteId, Inverter.getKey(), id), name, meta)); + } + } + result.add(intermediateDataValue(List.of(siteId, Inverter.getKey()), Inverter.getGroupKey(), + null, inverterValues)); + } + + // meters + if ( !inventoryNode.path("meters").isEmpty() ) { + /*- EXAMPLE JSON: + { + "name": "Export Meter", + "manufacturer": "SolarEdge", + "model": "SE-MTR-3Y-240V-A", + "firmwareVersion": "72", + "connectedTo": "Inverter 1", + "connectedSolaredgeDeviceSN": "77777777-BA", + "type": "FeedIn", + "form": "physical", + "SN": "111111111" + } + */ + final var meterValues = new ArrayList(inventoryNode.path("meters").size()); + for ( JsonNode meterNode : inventoryNode.path("meters") ) { + final String id = meterNode.path("type").asText().trim(); + if ( id.isEmpty() ) { + continue; + } + final String name = meterNode.path("name").asText().trim(); + final var meta = new LinkedHashMap(4); + populateNonEmptyValue(meterNode, "SN", DEVICE_SERIAL_NUMBER_METADATA, meta); + populateNonEmptyValue(meterNode, "manufacturer", MANUFACTURER_METADATA, meta); + populateNonEmptyValue(meterNode, "model", DEVICE_MODEL_METADATA, meta); + populateNonEmptyValue(meterNode, "connectedTo", "connectedTo", meta); + populateNonEmptyValue(meterNode, "connectedSolaredgeDeviceSN", "connectedToSerial", + meta); + populateNonEmptyValue(meterNode, "firmwareVersion", DEVICE_FIRMWARE_VERSION_METADATA, + meta); + + meterValues.add(intermediateDataValue(List.of(siteId, Meter.getKey(), id), name, meta)); + } + result.add(intermediateDataValue(List.of(siteId, Meter.getKey()), Meter.getGroupKey(), null, + meterValues)); + } + + // batteries + if ( !inventoryNode.path("batteries").isEmpty() ) { + /*- EXAMPLE JSON: + { + "name": "Battery 1.1", + "manufacturer": "LG", + "model": "R15563P3SSEG12005081037", + "firmwareVersion": "DCDC 7.5.6 BMS 1.9.6.0", + "connectedTo": "Inverter 1", + "connectedInverterSn": "77777777-BA", + "nameplateCapacity": 9800.0, + "SN": "121212121212121212121212121" + } + */ + final var batteryValues = new ArrayList( + inventoryNode.path("batteries").size()); + for ( JsonNode batteryNode : inventoryNode.path("batteries") ) { + final String id = batteryNode.path("SN").asText().trim(); + if ( id.isEmpty() ) { + continue; + } + final String name = batteryNode.path("name").asText().trim(); + final var meta = new LinkedHashMap(4); + populateNonEmptyValue(batteryNode, "SN", DEVICE_SERIAL_NUMBER_METADATA, meta); + populateNonEmptyValue(batteryNode, "manufacturer", MANUFACTURER_METADATA, meta); + populateNonEmptyValue(batteryNode, "model", DEVICE_MODEL_METADATA, meta); + populateNonEmptyValue(batteryNode, "connectedTo", "connectedTo", meta); + populateNonEmptyValue(batteryNode, "connectedInverterSn", "connectedToSerial", meta); + populateNonEmptyValue(batteryNode, "firmwareVersion", DEVICE_FIRMWARE_VERSION_METADATA, + meta); + populateNonEmptyValue(batteryNode, "nameplateCapacity", "capacity", meta); + + batteryValues + .add(intermediateDataValue(List.of(siteId, Battery.getKey(), id), name, meta)); + } + result.add(intermediateDataValue(List.of(siteId, Battery.getKey()), Battery.getGroupKey(), + null, batteryValues)); + } + + return result; + + } + + private static List inverterDataValues(String siteId, SolarEdgeDeviceType deviceType, + String componentId) { + // battery data extracted from /equipment/{siteId}/{componentId}/data + /*- EXAMPLE JSON: + { + "date": "2024-10-22 06:19:07", + "totalActivePower": 0.0, + "dcVoltage": null, + "powerLimit": 0.0, + "totalEnergy": 7.8223802E8, + "temperature": 0.0, + "inverterMode": "SLEEPING", + "operationMode": 0, + "vL1To2": 476.406, + "vL2To3": 475.594, + "vL3To1": 474.875, + "L1Data": { + "acCurrent": 0.0, + "acVoltage": 274.656, + "acFrequency": 59.9603, + "apparentPower": 0.0, + "activePower": 0.0, + "reactivePower": 0.0, + "cosPhi": 0.0 + }, + "L2Data": { + "acCurrent": 0.0, + "acVoltage": 275.406, + "acFrequency": 59.9614, + "apparentPower": 0.0, + "activePower": 0.0, + "reactivePower": 0.0, + "cosPhi": 0.0 + }, + "L3Data": { + "acCurrent": 0.0, + "acVoltage": 273.984, + "acFrequency": 59.9609, + "apparentPower": 0.0, + "activePower": 0.0, + "reactivePower": 0.0, + "cosPhi": 0.0 + } + } + */ + // @formatter:off + return Arrays.asList( + dataValue(List.of(siteId, deviceType.getKey(), componentId, "W"), "Total active power"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "DCV"), "DC voltage"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "GndRes"), "Ground fault resistance"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "LimitW"), "Power limit"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "TotWhExp"), "Total energy"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "Temp"), "Temperature"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "Mode"), "Mode"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "OpMode"), "Operational mode"), + + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PVphAN"), "1 phase line voltage, A"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PVphBN"), "1 phase line voltage, B"), + + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PPVphAB"), "Line-line voltage, A-B"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PPVphBC"), "Line-line voltage, B-C"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PPVphCA"), "Line-line voltage, C-A"), + + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PIA"), "Phase current - A"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PIB"), "Phase current - B"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PIC"), "Phase current - C"), + + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PVA"), "Phase voltage - A"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PVB"), "Phase voltage - B"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PVC"), "Phase voltage - C"), + + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PHzA"), "Phase frequency - A"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PHzB"), "Phase frequency - B"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PHzC"), "Phase frequency - C"), + + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PWA"), "Phase active power - A"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PWB"), "Phase active power - B"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PWC"), "Phase active power - C"), + + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PVAA"), "Phase apparent power - A"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PVAB"), "Phase apparent power - B"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PVAC"), "Phase apparent power - C"), + + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PVARA"), "Phase reactive power - A"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PVARB"), "Phase reactive power - B"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PVARC"), "Phase reactive power - C"), + + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PPFA"), "Phase power factor - A"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PPFB"), "Phase power factor - B"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "PPFC"), "Phase power factor - C") + ); + // @formatter:on + } + + private static List meterDataValues(String siteId, SolarEdgeDeviceType deviceType, + String componentId) { + // power extracted from /site/{siteId}/powerDetails + // lifetime energy extracted from /site/{siteId}/meters + // @formatter:off + return Arrays.asList( + dataValue(List.of(siteId, deviceType.getKey(), componentId, "W"), "Power"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "TotWh"), "Lifetime energy") + ); + // @formatter:on + } + + private static List batteryDataValues(String siteId, SolarEdgeDeviceType deviceType, + String componentId) { + // battery data extracted from /site/{siteId}/storageData + /*- EXAMPLE JSON: + { + "timeStamp": "2024-10-21 22:09:43", + "power": -273.062, + "batteryState": 4, + "lifeTimeEnergyDischarged": 5508927, + "lifeTimeEnergyCharged": 7468751, + "batteryPercentageState": 87.989944, + "fullPackEnergyAvailable": 8751.0, + "internalTemp": 20.7, + "ACGridCharging": 0.0 + } + */ + // @formatter:off + return Arrays.asList( + dataValue(List.of(siteId, deviceType.getKey(), componentId, "W"), "Power"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "State"), "Battery state"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "TotWhExp"), "Lifetime energy discharged"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "TotWhImp"), "Lifetime energy charged"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "SOC"), "State of charge"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "CapWh"), "Energy capacity"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "Temp"), "Internal temperature"), + dataValue(List.of(siteId, deviceType.getKey(), componentId, "GridWhImp"), "AC grid charge energy") + ); + // @formatter:on + } + + @Override + public Iterable latestDatum(CloudDatumStreamConfiguration datumStream) { + requireNonNullArgument(datumStream, "datumStream"); + final SolarEdgeResolution resolution = resolveResolution(datumStream, null); + final Clock queryClock = Clock.tick(clock, resolution.getTickDuration()); + final Instant endDate = queryClock.instant(); + final Instant startDate = endDate.minus(resolution.getTickDuration()); + + final var filter = new BasicQueryFilter(); + filter.setStartDate(startDate); + filter.setEndDate(endDate); + + final var result = datum(datumStream, filter); + if ( result == null || result.isEmpty() ) { + return null; + } + return result.getResults(); + } + + @Override + public CloudDatumStreamQueryResult datum(CloudDatumStreamConfiguration datumStream, + CloudDatumStreamQueryFilter filter) { + requireNonNullArgument(datumStream, "datumStream"); + requireNonNullArgument(filter, "filter"); + + final Instant filterStartDate = requireNonNullArgument(filter.getStartDate(), + "filter.startDate"); + final Instant filterEndDate = requireNonNullArgument(filter.getEndDate(), "filter.startDate"); + + final MessageSource ms = requireNonNullArgument(getMessageSource(), "messageSource"); + + if ( !datumStream.isFullyConfigured() ) { + String msg = "Datum stream is not fully configured."; + Errors errors = new BindException(datumStream, "datumStream"); + errors.reject("error.datumStream.notFullyConfigured", null, msg); + throw new ValidationException(msg, errors, ms); + } + + final var mappingId = new UserLongCompositePK(datumStream.getUserId(), requireNonNullArgument( + datumStream.getDatumStreamMappingId(), "datumStream.datumStreamMappingId")); + final CloudDatumStreamMappingConfiguration mapping = requireNonNullObject( + datumStreamMappingDao.get(mappingId), "datumStreamMapping"); + + final var integrationId = new UserLongCompositePK(datumStream.getUserId(), + requireNonNullArgument(mapping.getIntegrationId(), "datumStreamMapping.integrationId")); + final CloudIntegrationConfiguration integration = requireNonNullObject( + integrationDao.get(integrationId), "integration"); + + final var allProperties = datumStreamPropertyDao.findAll(datumStream.getUserId(), + mapping.getConfigId(), null); + final var valueProps = new ArrayList( + allProperties.size()); + final var exprProps = new ArrayList(allProperties.size()); + for ( CloudDatumStreamPropertyConfiguration conf : allProperties ) { + if ( !(conf.isEnabled() && conf.isFullyConfigured()) ) { + continue; + } + if ( conf.getValueType().isExpression() ) { + exprProps.add(conf); + } else { + valueProps.add(conf); + } + } + if ( valueProps.isEmpty() ) { + String msg = "Datum stream has no properties."; + Errors errors = new BindException(datumStream, "datumStream"); + errors.reject("error.datumStream.noProperties", null, msg); + throw new ValidationException(msg, errors, ms); + } + + final SolarEdgeResolution resolution = resolveResolution(datumStream, null); + + final Map sourceIdMap = servicePropertyStringMap(datumStream, + SOURCE_ID_MAP_SETTING); + + final List resultDatum = new ArrayList<>(16); + final Map queryPlans = resolveSiteQueryPlans(integration, datumStream, + valueProps); + + BasicQueryFilter nextQueryFilter = null; + + Instant startDate = resolution.truncateDate(filterStartDate); + Instant endDate = resolution.truncateDate(filterEndDate); + if ( Duration.between(startDate, endDate).compareTo(MAX_QUERY_TIME_RANGE) > 0 ) { + Instant nextEndDate = startDate.plus(MAX_QUERY_TIME_RANGE.multipliedBy(2)); + if ( nextEndDate.isAfter(endDate) ) { + nextEndDate = endDate; + } + + endDate = startDate.plus(MAX_QUERY_TIME_RANGE); + + nextQueryFilter = new BasicQueryFilter(); + nextQueryFilter.setStartDate(endDate); + nextQueryFilter.setEndDate(nextEndDate); + } + + final BasicQueryFilter usedQueryFilter = new BasicQueryFilter(); + usedQueryFilter.setStartDate(startDate); + usedQueryFilter.setEndDate(endDate); + for ( SiteQueryPlan queryPlan : queryPlans.values() ) { + ZonedDateTime siteStartDate = startDate.atZone(queryPlan.zone); + ZonedDateTime siteEndDate = endDate.atZone(queryPlan.zone); + + DateTimeFormatter timestampFmt = DateUtils.ISO_DATE_OPT_TIME_ALT.withZone(queryPlan.zone); + + String startDateParam = timestampFmt.format(siteStartDate.toLocalDateTime()); + String endDateParam = timestampFmt.format(siteEndDate.toLocalDateTime()); + + // inverter data + if ( queryPlan.inverterIds != null && !queryPlan.inverterIds.isEmpty() ) { + for ( String inverterId : queryPlan.inverterIds ) { + List datum = restOpsHelper.httpGet("List inverter data", integration, + JsonNode.class, + req -> fromUri(resolveBaseUrl(integration, BASE_URI)) + .path(EQUIPMENT_DATA_URL_TEMPLATE) + .queryParam("startTime", startDateParam) + .queryParam("endTime", endDateParam) + .buildAndExpand(queryPlan.siteId, inverterId).toUri(), + res -> parseInverterDatum(res.getBody(), queryPlan.siteId, inverterId, + datumStream, sourceIdMap, queryPlan.inverterRefs, timestampFmt)); + if ( datum != null ) { + resultDatum.addAll(datum); + } + } + } + + // meter data + if ( queryPlan.includeMeters ) { + // have to request two URLs for this + // @formatter:off + JsonNode meterPower = restOpsHelper.httpGet("List meter power data", integration, + JsonNode.class, + req -> fromUri(resolveBaseUrl(integration, BASE_URI)) + .path(POWER_DETAILS_URL_TEMPLATE) + .queryParam("startTime", startDateParam) + .queryParam("endTime", endDateParam) + .queryParam("timeUnit", resolution.getKey()) + .buildAndExpand(queryPlan.siteId) + .toUri(), + res -> res.getBody()); + JsonNode meterEnergy = restOpsHelper.httpGet("List meter energy data", integration, + JsonNode.class, + req -> fromUri(resolveBaseUrl(integration, BASE_URI)) + .path(METERS_URL_TEMPLATE) + .queryParam("startTime", startDateParam) + .queryParam("endTime", endDateParam) + .queryParam("timeUnit", resolution.getKey()) + .buildAndExpand(queryPlan.siteId) + .toUri(), + res -> res.getBody()); + // @formatter:on + Collection datum = parseMeterDatum(meterPower, meterEnergy, + queryPlan.siteId, datumStream, sourceIdMap, queryPlan.meterRefs, timestampFmt, + resolution); + if ( datum != null ) { + resultDatum.addAll(datum); + } + } + + // battery data + if ( queryPlan.includeBatteries ) { + List datum = restOpsHelper.httpGet("List battery data", integration, + JsonNode.class, + req -> fromUri(resolveBaseUrl(integration, BASE_URI)) + .path(STORAGE_DATA_URL_TEMPLATE).queryParam("startTime", startDateParam) + .queryParam("endTime", endDateParam).buildAndExpand(queryPlan.siteId) + .toUri(), + res -> parseBatteryDatum(res.getBody(), queryPlan.siteId, datumStream, + sourceIdMap, queryPlan.batteryRefs, timestampFmt)); + if ( datum != null ) { + resultDatum.addAll(datum); + } + } + + } + + // evaluate expressions on merged datum + if ( !exprProps.isEmpty() ) { + var parameters = Map.of("datumStreamMappingId", datumStream.getDatumStreamMappingId(), + "integrationId", mapping.getIntegrationId()); + evaulateExpressions(exprProps, resultDatum, parameters); + } + + return new BasicCloudDatumStreamQueryResult(usedQueryFilter, nextQueryFilter, + resultDatum.stream().map(Datum.class::cast).toList()); + } + + private static List parseInverterDatum(JsonNode json, Long siteId, String inverterId, + CloudDatumStreamConfiguration datumStream, Map sourceIdMap, + Map> componentRefs, DateTimeFormatter timestampFmt) { + /*- EXAMPLE JSON: + { + "data": { + "count": 246, + "telemetries": [ + { + "date": "2024-10-22 20:49:48", + "totalActivePower": 252.0, + "dcVoltage": 420.938, + "groundFaultResistance": 6000.0, + "powerLimit": 100.0, + "totalEnergy": 3.77542E7, + "temperature": 27.8494, + "inverterMode": "MPPT", + "operationMode": 0, + "vL1ToN": 123.219, + "vL2ToN": 123.531, + "L1Data": { + "acCurrent": 1.4375, + "acVoltage": 248.031, + "acFrequency": 60.0014, + "apparentPower": 354.5, + "activePower": 252.0, + "reactivePower": -249.0, + "cosPhi": 1.0 + } + }, + */ + String sourceId = resolveSourceId(datumStream, siteId, SolarEdgeDeviceType.Inverter, inverterId, + sourceIdMap); + if ( sourceId == null ) { + return Collections.emptyList(); + } + List result = new ArrayList<>(8); + for ( JsonNode telem : json.findValue("telemetries") ) { + String dateVal = nonEmptyString(telem.path("date").asText()); + if ( dateVal == null ) { + continue; + } + Instant ts = timestampFmt.parse(dateVal, Instant::from); + GeneralDatum d = new GeneralDatum( + new DatumId(datumStream.getKind(), datumStream.getObjectId(), sourceId, ts), + new DatumSamples()); + for ( String componentId : new String[] { WILDCARD_IDENTIFIER, inverterId } ) { + if ( !componentRefs.containsKey(componentId) ) { + continue; + } + for ( ValueRef ref : componentRefs.get(componentId) ) { + JsonNode fieldNode = switch (ref.fieldName) { + case "W" -> telem.path("totalActivePower"); + case "DCV" -> telem.path("dcVoltage"); + case "GndRes" -> telem.path("groundFaultResistance"); + case "LimitW" -> telem.path("powerLimit"); + case "TotWhExp" -> telem.path("totalEnergy"); + case "Temp" -> telem.path("temperature"); + case "Mode" -> telem.path("inverterMode"); + case "OpMode" -> telem.path("operationMode"); + + case "PVphAN" -> telem.path("vL1ToN"); + case "PVphBN" -> telem.path("vL2ToN"); + + case "PPVphAB" -> telem.path("vL1To2"); + case "PPVphBC" -> telem.path("vL2To3"); + case "PPVphCA" -> telem.path("vL3To1"); + + case "PIA" -> telem.path("L1Data").path("acCurrent"); + case "PIB" -> telem.path("L2Data").path("acCurrent"); + case "PIC" -> telem.path("L3Data").path("acCurrent"); + + case "PVA" -> telem.path("L1Data").path("acVoltage"); + case "PVB" -> telem.path("L2Data").path("acVoltage"); + case "PVC" -> telem.path("L3Data").path("acVoltage"); + + case "PHzA" -> telem.path("L1Data").path("acFrequency"); + case "PHzB" -> telem.path("L2Data").path("acFrequency"); + case "PHzC" -> telem.path("L3Data").path("acFrequency"); + + case "PWA" -> telem.path("L1Data").path("activePower"); + case "PWB" -> telem.path("L2Data").path("activePower"); + case "PWC" -> telem.path("L3Data").path("activePower"); + + case "PVAA" -> telem.path("L1Data").path("apparentPower"); + case "PVAB" -> telem.path("L2Data").path("apparentPower"); + case "PVAC" -> telem.path("L3Data").path("apparentPower"); + + case "PVARA" -> telem.path("L1Data").path("reactivePower"); + case "PVARB" -> telem.path("L2Data").path("reactivePower"); + case "PVARC" -> telem.path("L3Data").path("reactivePower"); + + case "PPFA" -> telem.path("L1Data").path("cosPhi"); + case "PPFB" -> telem.path("L2Data").path("cosPhi"); + case "PPFC" -> telem.path("L3Data").path("cosPhi"); + + default -> null; + }; + if ( fieldNode == null || fieldNode.isNull() || fieldNode.isMissingNode() ) { + continue; + } + + Object propVal = parseJsonDatumPropertyValue(fieldNode, + ref.property.getPropertyType()); + propVal = ref.property.applyValueTransforms(propVal); + if ( propVal != null ) { + d.getSamples().putSampleValue(ref.property.getPropertyType(), + ref.property.getPropertyName(), propVal); + } + } + } + if ( !d.isEmpty() ) { + result.add(d); + } + } + return result; + } + + private static Collection parseMeterDatum(JsonNode powerJson, JsonNode energyJson, + Long siteId, CloudDatumStreamConfiguration datumStream, Map sourceIdMap, + Map> componentRefs, DateTimeFormatter timestampFmt, + SolarEdgeResolution resolution) { + /*- EXAMPLE JSON (Power): + { + "powerDetails": { + "timeUnit": "QUARTER_OF_AN_HOUR", + "unit": "W", + "meters": [ + { + "type": "FeedIn", + "values": [ + { + "date": "2024-10-22 20:30:00", + "value": 8.190719 + }, + */ + /*- EXAMPLE JSON (Energy): + { + "meterEnergyDetails": { + "timeUnit": "QUARTER_OF_AN_HOUR", + "unit": "Wh", + "meters": [ + { + "meterSerialNumber": "11111111", + "connectedSolaredgeDeviceSN": "1111111-BA", + "model": "SE-RGMTR-1D-240C-A", + "meterType": "Production", + "values": [ + { + "date": "2024-10-22 20:44:49", + "value": 3.7514836E7 + }, + */ + Map result = new TreeMap<>(); + for ( JsonNode json : new JsonNode[] { powerJson, energyJson } ) { + final boolean power = (json == powerJson); + for ( JsonNode meterNode : json.findValue("meters") ) { + String meterId = nonEmptyString( + (power ? meterNode.path("type") : meterNode.path("meterType")).asText()); + if ( meterId == null ) { + continue; + } + String sourceId = resolveSourceId(datumStream, siteId, SolarEdgeDeviceType.Meter, + meterId, sourceIdMap); + if ( sourceId == null ) { + continue; + } + for ( JsonNode telem : meterNode.path("values") ) { + String dateVal = nonEmptyString(telem.path("date").asText()); + if ( dateVal == null ) { + continue; + } + // force align date + Instant ts = resolution.truncateDate(timestampFmt.parse(dateVal, Instant::from)); + DatumId datumId = new DatumId(datumStream.getKind(), datumStream.getObjectId(), + sourceId, ts); + GeneralDatum d = result.computeIfAbsent(datumId, + id -> new GeneralDatum(datumId, new DatumSamples())); + for ( String componentId : new String[] { WILDCARD_IDENTIFIER, meterId } ) { + if ( !componentRefs.containsKey(componentId) ) { + continue; + } + for ( ValueRef ref : componentRefs.get(componentId) ) { + if ( power && !ref.fieldName.equals("W") ) { + continue; + } else if ( !power && !ref.fieldName.equals("TotWh") ) { + continue; + } + JsonNode fieldNode = telem.path("value"); + if ( fieldNode == null || fieldNode.isNull() || fieldNode.isMissingNode() ) { + continue; + } + + Object propVal = parseJsonDatumPropertyValue(fieldNode, + ref.property.getPropertyType()); + propVal = ref.property.applyValueTransforms(propVal); + if ( propVal != null ) { + d.getSamples().putSampleValue(ref.property.getPropertyType(), + ref.property.getPropertyName(), propVal); + } + } + } + } + } + } + return result.values().stream().filter(d -> !d.isEmpty()).toList(); + } + + private static List parseBatteryDatum(JsonNode json, Long siteId, + CloudDatumStreamConfiguration datumStream, Map sourceIdMap, + Map> componentRefs, DateTimeFormatter timestampFmt) { + /*- EXAMPLE JSON: + { + "storageData": { + "batteryCount": 1, + "batteries": [ + { + "nameplate": 9800.0, + "serialNumber": "AAAAAAAAAAAAAAAAAAAAA", + "modelNumber": "AAAAAAAAAAAAAAAAAAAAA", + "telemetryCount": 246, + "telemetries": [ + { + "timeStamp": "2024-10-22 20:49:48", + "power": 0.0, + "batteryState": 6, + "lifeTimeEnergyDischarged": 5509872, + "lifeTimeEnergyCharged": 7471463, + "batteryPercentageState": 87.989944, + "fullPackEnergyAvailable": 8751.0, + "internalTemp": 22.0, + "ACGridCharging": 0.0 + }, + */ + List result = new ArrayList<>(8); + for ( JsonNode battery : json.findValue("batteries") ) { + String batteryId = nonEmptyString(battery.path("serialNumber").asText()); + if ( batteryId == null ) { + continue; + } + String sourceId = resolveSourceId(datumStream, siteId, SolarEdgeDeviceType.Battery, + batteryId, sourceIdMap); + if ( sourceId == null ) { + continue; + } + for ( JsonNode telem : battery.path("telemetries") ) { + String dateVal = nonEmptyString(telem.path("timeStamp").asText()); + if ( dateVal == null ) { + continue; + } + Instant ts = timestampFmt.parse(dateVal, Instant::from); + GeneralDatum d = new GeneralDatum( + new DatumId(datumStream.getKind(), datumStream.getObjectId(), sourceId, ts), + new DatumSamples()); + for ( String componentId : new String[] { WILDCARD_IDENTIFIER, batteryId } ) { + if ( !componentRefs.containsKey(componentId) ) { + continue; + } + for ( ValueRef ref : componentRefs.get(componentId) ) { + JsonNode fieldNode = switch (ref.fieldName) { + case "W" -> telem.path("power"); + case "State" -> telem.path("batteryState"); + case "TotWhExp" -> telem.path("lifeTimeEnergyDischarged"); + case "TotWhImp" -> telem.path("lifeTimeEnergyCharged"); + case "SOC" -> telem.path("batteryPercentageState"); + case "CapWh" -> telem.path("fullPackEnergyAvailable"); + case "Temp" -> telem.path("internalTemp"); + case "GridWhImp" -> telem.path("ACGridCharging"); + + default -> null; + }; + if ( fieldNode == null || fieldNode.isNull() || fieldNode.isMissingNode() ) { + continue; + } + + Object propVal = parseJsonDatumPropertyValue(fieldNode, + ref.property.getPropertyType()); + propVal = ref.property.applyValueTransforms(propVal); + if ( propVal != null ) { + d.getSamples().putSampleValue(ref.property.getPropertyType(), + ref.property.getPropertyName(), propVal); + } + } + } + if ( !d.isEmpty() ) { + result.add(d); + } + } + } + return result; + } + + private static String resolveSourceId(CloudDatumStreamConfiguration datumStream, Long siteId, + SolarEdgeDeviceType deviceType, String componentId, Map sourceIdMap) { + if ( sourceIdMap != null ) { + String key = "/%s/%s/%s".formatted(siteId, deviceType.getKey(), componentId); + return sourceIdMap.get(key); + } + return "%s/%s/%s".formatted(datumStream.getSourceId(), deviceType, componentId); + } + + private SolarEdgeResolution resolveResolution(CloudDatumStreamConfiguration datumStream, + Map parameters) { + SolarEdgeResolution result = null; + try { + String settingVal = null; + if ( parameters != null && parameters.get(RESOLUTION_SETTING) instanceof String s ) { + settingVal = s; + } else if ( datumStream != null ) { + settingVal = datumStream.serviceProperty(RESOLUTION_SETTING, String.class); + } + if ( settingVal != null && !settingVal.isEmpty() ) { + result = SolarEdgeResolution.fromValue(settingVal); + } + } catch ( IllegalArgumentException e ) { + // ignore + } + return (result != null ? result : SolarEdgeResolution.FifteenMinute); + } + + private ZoneId resolveSiteTimeZone(CloudIntegrationConfiguration integration, Long siteId) { + assert integration != null && siteId != null; + final var cache = getSiteTimeZoneCache(); + + ZoneId result = (cache != null ? cache.get(siteId) : null); + if ( result != null ) { + return result; + } + + /*- EXAMPLE JSON: + { + "details": { + "id": 123123, + "name": "My Site", + "accountId": 1234, + "status": "Active", + "peakPower": 7.4, + "lastUpdateTime": "2024-10-22", + "installationDate": "2020-07-16", + "ptoDate": null, + "notes": "", + "type": "Optimizers & Inverters", + "location": { + "country": "United States", + "state": "Connecticut", + "city": "Anytown", + "address": "123 Main Street", + "address2": "", + "zip": "06830", + "timeZone": "America/New_York", + "countryCode": "US", + "stateCode": "CT" + }, + "primaryModule": { + "manufacturerName": "LG", + "modelName": "LG335", + "maximumPower": 335.0 + }, + "uris": { + "SITE_IMAGE": "/site/1715515/siteImage/skyviewventures.PNG", + "DATA_PERIOD": "/site/1715515/dataPeriod", + "DETAILS": "/site/1715515/details", + "OVERVIEW": "/site/1715515/overview" + }, + "publicSettings": { + "isPublic": false + } + } + } + */ + + result = restOpsHelper.httpGet("Query for site details", integration, JsonNode.class, + (headers) -> { + // @formatter:off + return fromUri(resolveBaseUrl(integration, BASE_URI)) + .path(SITE_DETAILS_URL_TEMPLATE) + .buildAndExpand(siteId) + .toUri(); + // @formatter:on + }, res -> { + ZoneId zone = ZoneOffset.UTC; + var json = res.getBody(); + String zoneId = StringUtils.nonEmptyString(json.findValue("timeZone").asText()); + if ( zoneId != null ) { + try { + zone = ZoneId.of(zoneId); + } catch ( DateTimeException e ) { + log.warn("Site [{}] time zone [{}] not usable, will use UTC: {}", siteId, + zoneId, e.toString()); + } + } + return zone; + }); + + if ( result != null && cache != null ) { + cache.put(siteId, result); + } + + return result; + } + + private CloudDataValue[] resolveSiteInventory(CloudIntegrationConfiguration integration, + Long siteId) { + assert integration != null && siteId != null; + final var cache = getSiteInventoryCache(); + + CloudDataValue[] result = (cache != null ? cache.get(siteId) : null); + if ( result != null ) { + return result; + } + + List response = siteInventory(integration, Map.of(SITE_ID_FILTER, siteId)); + if ( response != null ) { + result = response.toArray(CloudDataValue[]::new); + if ( cache != null ) { + cache.put(siteId, result); + } + } + + return result; + } + + /** + * Value reference pattern, with component matching groups. + * + *

+ * The matching groups are + *

+ * + *
    + *
  1. siteId
  2. + *
  3. deviceType
  4. + *
  5. componentId
  6. + *
  7. field
  8. + *
+ */ + private static final Pattern VALUE_REF_PATTERN = Pattern.compile("/([^/]+)/([^/]+)/([^/]+)/(.+)"); + + private static record ValueRef(Object siteId, SolarEdgeDeviceType deviceType, String componentId, + String fieldName, CloudDatumStreamPropertyConfiguration property) { + + } + + /** + * A site-specific query plan. + * + * This plan is constructed from a set of + * {@link CloudDatumStreamPropertyConfiguration}, and used to determine + * which SolarEdge APIs are necessary to satisfy those configurations. + */ + private static class SiteQueryPlan { + + /** The SolarEdge site ID. */ + private final Long siteId; + + /** The time zone used by this site. */ + private final ZoneId zone; + + /** The set of inverter IDs required. */ + private Set inverterIds; + + /** Flag to indicate if meter data is required. */ + private boolean includeMeters; + + /** Flag to indicate if battery data is required. */ + private boolean includeBatteries; + + private Map> inverterRefs = new LinkedHashMap<>(8); + + private Map> meterRefs = new LinkedHashMap<>(8); + + private Map> batteryRefs = new LinkedHashMap<>(8); + + private SiteQueryPlan(Long siteId, ZoneId zone) { + super(); + this.siteId = requireNonNullArgument(siteId, "siteId"); + this.zone = requireNonNullArgument(zone, "zone"); + } + } + + private Map resolveSiteQueryPlans(CloudIntegrationConfiguration integration, + CloudDatumStreamConfiguration datumStream, + List propConfigs) { + final var result = new LinkedHashMap(2); + for ( CloudDatumStreamPropertyConfiguration config : propConfigs ) { + String ref = resolvePlaceholders(config.getValueReference(), datumStream); + Matcher m = VALUE_REF_PATTERN.matcher(ref); + if ( !m.matches() ) { + continue; + } + // groups: 1 = siteId, 2 = deviceType, 3 = componentId, 4 = field + Long siteId = Long.valueOf(m.group(1)); + String deviceTypeKey = m.group(2); + String componentId = m.group(3); + String fieldName = m.group(4); + + SolarEdgeDeviceType deviceType; + try { + deviceType = SolarEdgeDeviceType.fromValue(deviceTypeKey); + } catch ( IllegalArgumentException e ) { + // ignore and continue + continue; + } + + SiteQueryPlan plan = result.computeIfAbsent(siteId, id -> { + ZoneId zone = resolveSiteTimeZone(integration, id); + return new SiteQueryPlan(siteId, zone); + }); + + ValueRef valueRef = new ValueRef(siteId, deviceType, componentId, fieldName, config); + Map> valueRefMap = null; + + if ( deviceType == SolarEdgeDeviceType.Battery ) { + plan.includeBatteries = true; + if ( plan.batteryRefs == null ) { + plan.batteryRefs = new LinkedHashMap<>(8); + } + valueRefMap = plan.batteryRefs; + } else if ( deviceType == SolarEdgeDeviceType.Meter ) { + plan.includeMeters = true; + if ( plan.meterRefs == null ) { + plan.meterRefs = new LinkedHashMap<>(8); + } + valueRefMap = plan.meterRefs; + } else if ( deviceType == SolarEdgeDeviceType.Inverter ) { + if ( plan.inverterIds == null ) { + plan.inverterIds = new LinkedHashSet<>(8); + } + plan.inverterIds.add(componentId); + if ( plan.inverterRefs == null ) { + plan.inverterRefs = new LinkedHashMap<>(8); + } + valueRefMap = plan.inverterRefs; + } + if ( valueRefMap != null ) { + valueRefMap.computeIfAbsent(valueRef.componentId, id -> new ArrayList<>(8)) + .add(valueRef); + + } + } + + // resolve wildcard inverter component IDs + for ( SiteQueryPlan plan : result.values() ) { + if ( plan.inverterIds == null || !plan.inverterIds.contains(WILDCARD_IDENTIFIER) ) { + continue; + } + + Set resolvedInverterIds = new LinkedHashSet<>(8); + CloudDataValue[] inventory = resolveSiteInventory(integration, plan.siteId); + CloudDataValue inverters = Arrays.stream(inventory) + .filter(e -> Inverter.getGroupKey().equals(e.getName())).findAny().orElse(null); + if ( inverters != null && inverters.getChildren() != null ) { + for ( CloudDataValue inverter : inverters.getChildren() ) { + resolvedInverterIds.add(inverter.getIdentifiers().getLast()); + } + } + + plan.inverterIds.remove(WILDCARD_IDENTIFIER); + plan.inverterIds.addAll(resolvedInverterIds); + if ( plan.inverterIds.isEmpty() ) { + plan.inverterIds = null; + } + } + + return result; + } + + /** + * Get the site time zone cache. + * + * @return the cache + */ + public final Cache getSiteTimeZoneCache() { + return siteTimeZoneCache; + } + + /** + * Set the site time zone cache. + * + *

+ * This cache can be provided to help with time zone lookup by SolarEdge + * site ID. + *

+ * + * @param siteTimeZoneCache + * the cache to set + */ + public final void setSiteTimeZoneCache(Cache siteTimeZoneCache) { + this.siteTimeZoneCache = siteTimeZoneCache; + } + + /** + * Get the site inventory cache. + * + * @return the cache + */ + public final Cache getSiteInventoryCache() { + return siteInventoryCache; + } + + /** + * Set the site inventory cache. + * + *

+ * This cache can be provided to help with device lookup by SolarEdge site + * ID. + *

+ * + * @param siteInventoryCache + * the cache to set + */ + public final void setSiteInventoryCache(Cache siteInventoryCache) { + this.siteInventoryCache = siteInventoryCache; + } + +} diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeCloudIntegrationService.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeV1CloudIntegrationService.java similarity index 80% rename from solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeCloudIntegrationService.java rename to solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeV1CloudIntegrationService.java index 2bd758483..4027a2e39 100644 --- a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeCloudIntegrationService.java +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeV1CloudIntegrationService.java @@ -1,5 +1,5 @@ /* ================================================================== - * SolarEdgeCloudIntegrationService.java - 7/10/2024 6:49:06 am + * SolarEdgeV1CloudIntegrationService.java - 7/10/2024 6:49:06 am * * Copyright 2024 SolarNetwork.net Dev Team * @@ -52,20 +52,17 @@ * @author matt * @version 1.0 */ -public class SolarEdgeCloudIntegrationService extends BaseRestOperationsCloudIntegrationService { +public class SolarEdgeV1CloudIntegrationService extends BaseRestOperationsCloudIntegrationService { /** The service identifier. */ - public static final String SERVICE_IDENTIFIER = "s10k.c2c.i9n.solaredge"; + public static final String SERVICE_IDENTIFIER = "s10k.c2c.i9n.solaredge.v1"; /** The URL template for listing sites. */ - public static final String V2_SITES_LIST_URL = "/v2/sites"; + public static final String SITES_LIST_URL = "/sites/list"; /** The user API key authorization HTTP header name. */ public static final String API_KEY_HEADER = "X-API-Key"; - /** The account API key authorization HTTP header name. */ - public static final String ACCOUNT_KEY_HEADER = "X-Account-Key"; - /** The JSON and {@code problem+json} accept HTTP header value. */ public static final String JSON_AND_PROBLEM_ACCEPT_HEADER_VALUE = "application/json, application/problem+json"; @@ -75,9 +72,6 @@ public class SolarEdgeCloudIntegrationService extends BaseRestOperationsCloudInt /** An API key setting name. */ public static final String API_KEY_SETTING = "apiKey"; - /** An account key setting name. */ - public static final String ACCOUNT_KEY_SETTING = "accountKey"; - /** * The well-known URLs. */ @@ -91,7 +85,6 @@ public class SolarEdgeCloudIntegrationService extends BaseRestOperationsCloudInt public static final List SETTINGS; static { var settings = new ArrayList(1); - settings.add(new BasicTextFieldSettingSpecifier(ACCOUNT_KEY_SETTING, null, true)); settings.add(new BasicTextFieldSettingSpecifier(API_KEY_SETTING, null, true)); SETTINGS = Collections.unmodifiableList(settings); } @@ -114,12 +107,12 @@ public class SolarEdgeCloudIntegrationService extends BaseRestOperationsCloudInt * @throws IllegalArgumentException * if any argument is {@literal null} */ - public SolarEdgeCloudIntegrationService(Collection datumStreamServices, + public SolarEdgeV1CloudIntegrationService(Collection datumStreamServices, UserEventAppenderBiz userEventAppenderBiz, TextEncryptor encryptor, RestOperations restOps) { super(SERVICE_IDENTIFIER, "SolarEdge", datumStreamServices, userEventAppenderBiz, encryptor, SETTINGS, WELL_KNOWN_URLS, - new SolarEdgeRestOperationsHelper( - LoggerFactory.getLogger(SolarEdgeCloudIntegrationService.class), + new SolarEdgeV1RestOperationsHelper( + LoggerFactory.getLogger(SolarEdgeV1CloudIntegrationService.class), userEventAppenderBiz, restOps, HTTP_ERROR_TAGS, encryptor, integrationServiceIdentifier -> SECURE_SETTINGS)); } @@ -130,12 +123,6 @@ public Result validate(CloudIntegrationConfiguration integration, Locale l final List errorDetails = new ArrayList<>(2); final MessageSource ms = requireNonNullArgument(getMessageSource(), "messageSource"); - final String accountKey = integration.serviceProperty(ACCOUNT_KEY_SETTING, String.class); - if ( accountKey == null || accountKey.isEmpty() ) { - String errMsg = ms.getMessage("error.accountKey.missing", null, locale); - errorDetails.add(new ErrorDetail(ACCOUNT_KEY_SETTING, null, errMsg)); - } - final String apiKey = integration.serviceProperty(API_KEY_SETTING, String.class); if ( apiKey == null || apiKey.isEmpty() ) { String errMsg = ms.getMessage("error.apiKey.missing", null, locale); @@ -147,11 +134,11 @@ public Result validate(CloudIntegrationConfiguration integration, Locale l return Result.error("SECI.0001", errMsg, errorDetails); } - // validate by requesting the available sites for the partner ID + // validate by requesting the V1 available sites try { final String response = restOpsHelper.httpGet("List sites", integration, String.class, - (req) -> UriComponentsBuilder.fromUri(SolarEdgeCloudIntegrationService.BASE_URI) - .path(SolarEdgeCloudIntegrationService.V2_SITES_LIST_URL).buildAndExpand() + (req) -> UriComponentsBuilder.fromUri(SolarEdgeV1CloudIntegrationService.BASE_URI) + .path(SolarEdgeV1CloudIntegrationService.SITES_LIST_URL).buildAndExpand() .toUri(), res -> res.getBody()); log.debug("Validation of config {} succeeded: {}", integration.getConfigId(), response); diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeRestOperationsHelper.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeV1RestOperationsHelper.java similarity index 78% rename from solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeRestOperationsHelper.java rename to solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeV1RestOperationsHelper.java index adc71a7db..b58445194 100644 --- a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeRestOperationsHelper.java +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolarEdgeV1RestOperationsHelper.java @@ -1,5 +1,5 @@ /* ================================================================== - * SolarEdgeRestOperationsHelper.java - 7/10/2024 10:49:34 am + * SolarEdgeV1RestOperationsHelper.java - 7/10/2024 10:49:34 am * * Copyright 2024 SolarNetwork.net Dev Team * @@ -22,10 +22,8 @@ package net.solarnetwork.central.c2c.biz.impl; -import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeCloudIntegrationService.ACCOUNT_KEY_HEADER; -import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeCloudIntegrationService.ACCOUNT_KEY_SETTING; -import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeCloudIntegrationService.API_KEY_HEADER; -import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeCloudIntegrationService.API_KEY_SETTING; +import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeV1CloudIntegrationService.API_KEY_HEADER; +import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeV1CloudIntegrationService.API_KEY_SETTING; import java.net.URI; import java.util.Set; import java.util.function.Function; @@ -45,7 +43,7 @@ * @author matt * @version 1.0 */ -public class SolarEdgeRestOperationsHelper extends RestOperationsHelper { +public class SolarEdgeV1RestOperationsHelper extends RestOperationsHelper { /** * Constructor. @@ -65,7 +63,7 @@ public class SolarEdgeRestOperationsHelper extends RestOperationsHelper { * @throws IllegalArgumentException * if any argument is {@literal null} */ - public SolarEdgeRestOperationsHelper(Logger log, UserEventAppenderBiz userEventAppenderBiz, + public SolarEdgeV1RestOperationsHelper(Logger log, UserEventAppenderBiz userEventAppenderBiz, RestOperations restOps, String[] errorEventTags, TextEncryptor encryptor, Function> sensitiveKeyProvider) { super(log, userEventAppenderBiz, restOps, errorEventTags, encryptor, sensitiveKeyProvider); @@ -78,10 +76,6 @@ public T httpGet(String description, CloudIntegrationConfiguration integr return super.httpGet(description, integration, responseType, (headers) -> { final var decrypted = integration.clone(); decrypted.unmaskSensitiveInformation(sensitiveKeyProvider, encryptor); - final String accountKey = decrypted.serviceProperty(ACCOUNT_KEY_SETTING, String.class); - if ( accountKey != null ) { - headers.add(ACCOUNT_KEY_HEADER, accountKey); - } final String apiKey = decrypted.serviceProperty(API_KEY_SETTING, String.class); if ( apiKey != null ) { headers.add(API_KEY_HEADER, apiKey); diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolrenViewCloudDatumStreamService.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolrenViewCloudDatumStreamService.java index a8bfeb528..df63b3eda 100644 --- a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolrenViewCloudDatumStreamService.java +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/SolrenViewCloudDatumStreamService.java @@ -114,7 +114,7 @@ import net.solarnetwork.settings.support.BasicMultiValueSettingSpecifier; import net.solarnetwork.settings.support.BasicTextAreaSettingSpecifier; import net.solarnetwork.support.XmlSupport; -import net.solarnetwork.util.StringUtils; +import net.solarnetwork.util.IntRange; /** * SolrenView implementation of {@link CloudDatumStreamService}. @@ -192,7 +192,7 @@ * * * @author matt - * @version 1.5 + * @version 1.7 */ public class SolrenViewCloudDatumStreamService extends BaseRestOperationsCloudDatumStreamService { @@ -205,12 +205,6 @@ public class SolrenViewCloudDatumStreamService extends BaseRestOperationsCloudDa /** The setting for granularity. */ public static final String GRANULARITY_SETTING = "granularity"; - /** - * The setting for either a map or comma-delimited mapping list of component - * IDs to associated source ID values. - */ - public static final String SOURCE_ID_MAP_SETTING = "sourceIdMap"; - /** The service settings. */ public static final List SETTINGS; static { @@ -227,6 +221,27 @@ public class SolrenViewCloudDatumStreamService extends BaseRestOperationsCloudDa SETTINGS = List.of(granularitySpec, sourceIdMapSpec); } + /** + * The supported placeholder keys. + * + * @since 1.6 + */ + public static final List SUPPORTED_PLACEHOLDERS = List.of(SITE_ID_FILTER); + + /** + * The supported data value wildcard levels. + * + * @since 1.6 + */ + public static final List SUPPORTED_DATA_VALUE_WILDCARD_LEVELS = List.of(1); + + /** + * The data value identifier levels source ID range. + * + * @since 1.7 + */ + public static final IntRange DATA_VALUE_IDENTIFIER_LEVELS_SOURCE_ID_RANGE = IntRange.rangeOf(1); + private static final XmlSupport XML_SUPPORT; private static final XPathExpression M_COMPONENTS_XPATH; static { @@ -260,6 +275,8 @@ public class SolrenViewCloudDatumStreamService extends BaseRestOperationsCloudDa * the datum stream property DAO * @param restOps * the REST operations + * @param clock + * the clock to use * @throws IllegalArgumentException * if any argument is {@literal null} */ @@ -280,6 +297,21 @@ public SolrenViewCloudDatumStreamService(UserEventAppenderBiz userEventAppenderB this.clock = requireNonNullArgument(clock, "clock"); } + @Override + protected Iterable supportedPlaceholders() { + return SUPPORTED_PLACEHOLDERS; + } + + @Override + protected Iterable supportedDataValueWildcardIdentifierLevels() { + return SUPPORTED_DATA_VALUE_WILDCARD_LEVELS; + } + + @Override + protected IntRange dataValueIdentifierLevelsSourceIdRange() { + return DATA_VALUE_IDENTIFIER_LEVELS_SOURCE_ID_RANGE; + } + @Override public Iterable dataValueFilters(Locale locale) { MessageSource ms = requireNonNullArgument(getMessageSource(), "messageSource"); @@ -774,18 +806,8 @@ private Void parseDatum(CloudDatumStreamConfiguration datumStream, Long siteId, return null; } - @SuppressWarnings("unchecked") private Map componentSourceIdMap(CloudDatumStreamConfiguration datumStream) { - final Object sourceIdMap = datumStream.serviceProperty(SOURCE_ID_MAP_SETTING, Object.class); - final Map componentSourceIdMapping; - if ( sourceIdMap instanceof Map ) { - componentSourceIdMapping = (Map) sourceIdMap; - } else if ( sourceIdMap != null ) { - componentSourceIdMapping = StringUtils.commaDelimitedStringToMap(sourceIdMap.toString()); - } else { - componentSourceIdMapping = null; - } - return componentSourceIdMapping; + return servicePropertyStringMap(datumStream, SOURCE_ID_MAP_SETTING); } private void parseDatumProperties(Node componentNode, Long siteId, String componentId, diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/SolarEdgeConfig.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/SolarEdgeConfig.java index 277b6e2d9..e02d1db3e 100644 --- a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/SolarEdgeConfig.java +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/SolarEdgeConfig.java @@ -23,9 +23,14 @@ package net.solarnetwork.central.c2c.config; import static net.solarnetwork.central.c2c.config.SolarNetCloudIntegrationsConfiguration.CLOUD_INTEGRATIONS; +import java.time.Clock; +import java.time.ZoneId; import java.util.Collection; +import javax.cache.Cache; +import javax.cache.CacheManager; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @@ -38,12 +43,14 @@ import net.solarnetwork.central.c2c.biz.CloudIntegrationsExpressionService; import net.solarnetwork.central.c2c.biz.impl.BaseCloudDatumStreamService; import net.solarnetwork.central.c2c.biz.impl.BaseCloudIntegrationService; -import net.solarnetwork.central.c2c.biz.impl.SolarEdgeCloudDatumStreamService; -import net.solarnetwork.central.c2c.biz.impl.SolarEdgeCloudIntegrationService; +import net.solarnetwork.central.c2c.biz.impl.SolarEdgeV1CloudDatumStreamService; +import net.solarnetwork.central.c2c.biz.impl.SolarEdgeV1CloudIntegrationService; import net.solarnetwork.central.c2c.dao.CloudDatumStreamConfigurationDao; import net.solarnetwork.central.c2c.dao.CloudDatumStreamMappingConfigurationDao; import net.solarnetwork.central.c2c.dao.CloudDatumStreamPropertyConfigurationDao; import net.solarnetwork.central.c2c.dao.CloudIntegrationConfigurationDao; +import net.solarnetwork.central.c2c.domain.CloudDataValue; +import net.solarnetwork.central.support.CacheSettings; /** * Configuration for the SolarEdge cloud integration services. @@ -55,9 +62,15 @@ @Profile(CLOUD_INTEGRATIONS) public class SolarEdgeConfig { - /** A qualifier for SolarEdge configuraiton. */ + /** A qualifier for SolarEdge configuration. */ public static final String SOLAREDGE = "solaredge"; + /** A qualifier for SolarEdge site time zone configuration. */ + public static final String SOLAREDGE_SITE_TZ = "solaredge-site-tz"; + + /** A qualifier for SolarEdge site inventory configuration. */ + public static final String SOLAREDGE_SITE_INVENTORY = "solaredge-site-inventory"; + @Autowired private UserEventAppenderBiz userEventAppender; @@ -83,30 +96,69 @@ public class SolarEdgeConfig { @Autowired private CloudIntegrationsExpressionService expressionService; + @Autowired + private CacheManager cacheManager; + + @Bean + @Qualifier(SOLAREDGE_SITE_TZ) + @ConfigurationProperties(prefix = "app.c2c.cache.solaredge-site-tz") + public CacheSettings solarEdgeSiteTimeZoneCacheSettings() { + return new CacheSettings(); + } + + @Bean + @Qualifier(SOLAREDGE_SITE_TZ) + public Cache solarEdgeSiteTimeZoneCache( + @Qualifier(SOLAREDGE_SITE_TZ) CacheSettings settings) { + return settings.createCache(cacheManager, Long.class, ZoneId.class, + SOLAREDGE_SITE_TZ + "-cache"); + } + + @Bean + @Qualifier(SOLAREDGE_SITE_INVENTORY) + @ConfigurationProperties(prefix = "app.c2c.cache.solaredge-site-inventory") + public CacheSettings solarEdgeSiteInventoryCacheSettings() { + return new CacheSettings(); + } + + @Bean + @Qualifier(SOLAREDGE_SITE_INVENTORY) + public Cache solarEdgeSiteInventoryCache( + @Qualifier(SOLAREDGE_SITE_INVENTORY) CacheSettings settings) { + return settings.createCache(cacheManager, Long.class, CloudDataValue[].class, + SOLAREDGE_SITE_INVENTORY + "-cache"); + } + @Bean @Qualifier(SOLAREDGE) - public CloudDatumStreamService solarEdgeCloudDatumStreamService() { - var service = new SolarEdgeCloudDatumStreamService(userEventAppender, encryptor, + public CloudDatumStreamService solarEdgeV1CloudDatumStreamService( + @Qualifier(SOLAREDGE_SITE_TZ) Cache solarEdgeSiteTimeZoneCache, + @Qualifier(SOLAREDGE_SITE_INVENTORY) Cache solarEdgeSiteInventoryCache) { + var service = new SolarEdgeV1CloudDatumStreamService(userEventAppender, encryptor, expressionService, integrationConfigurationDao, datumStreamConfigurationDao, - datumStreamMappingConfigurationDao, datumStreamPropertyConfigurationDao, restOps); + datumStreamMappingConfigurationDao, datumStreamPropertyConfigurationDao, restOps, + Clock.systemUTC()); ResourceBundleMessageSource msgSource = new ResourceBundleMessageSource(); - msgSource.setBasenames(SolarEdgeCloudDatumStreamService.class.getName(), + msgSource.setBasenames(SolarEdgeV1CloudDatumStreamService.class.getName(), BaseCloudDatumStreamService.class.getName()); service.setMessageSource(msgSource); + service.setSiteTimeZoneCache(solarEdgeSiteTimeZoneCache); + service.setSiteInventoryCache(solarEdgeSiteInventoryCache); + return service; } @Bean @Qualifier(SOLAREDGE) - public CloudIntegrationService solarEdgeCloudIntegrationService( + public CloudIntegrationService solarEdgeV1CloudIntegrationService( @Qualifier(SOLAREDGE) Collection datumStreamServices) { - var service = new SolarEdgeCloudIntegrationService(datumStreamServices, userEventAppender, + var service = new SolarEdgeV1CloudIntegrationService(datumStreamServices, userEventAppender, encryptor, restOps); ResourceBundleMessageSource msgSource = new ResourceBundleMessageSource(); - msgSource.setBasenames(SolarEdgeCloudIntegrationService.class.getName(), + msgSource.setBasenames(SolarEdgeV1CloudIntegrationService.class.getName(), BaseCloudIntegrationService.class.getName()); service.setMessageSource(msgSource); diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/SolrenViewConfig.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/SolrenViewConfig.java index 3f85aea77..aa31070f8 100644 --- a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/SolrenViewConfig.java +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/SolrenViewConfig.java @@ -56,7 +56,7 @@ @Profile(CLOUD_INTEGRATIONS) public class SolrenViewConfig { - /** A qualifier for SolrenView configuraiton. */ + /** A qualifier for SolrenView configuration. */ public static final String SOLRENVIEW = "solrenview"; @Autowired diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/domain/BasicCloudDatumStreamLocalizedServiceInfo.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/domain/BasicCloudDatumStreamLocalizedServiceInfo.java new file mode 100644 index 000000000..2142a827e --- /dev/null +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/domain/BasicCloudDatumStreamLocalizedServiceInfo.java @@ -0,0 +1,93 @@ +/* ================================================================== + * BasicCloudDatumStreamLocalizedServiceInfo.java - 23/10/2024 8:55:21 am + * + * Copyright 2024 SolarNetwork.net Dev Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA + * 02111-1307 USA + * ================================================================== + */ + +package net.solarnetwork.central.c2c.domain; + +import java.util.List; +import net.solarnetwork.domain.LocalizedServiceInfo; +import net.solarnetwork.settings.SettingSpecifier; +import net.solarnetwork.settings.support.BasicConfigurableLocalizedServiceInfo; +import net.solarnetwork.util.IntRange; + +/** + * Basic implementation of {@link CloudDatumStreamLocalizedServiceInfo}. + * + * @author matt + * @version 1.0 + */ +public class BasicCloudDatumStreamLocalizedServiceInfo extends BasicConfigurableLocalizedServiceInfo + implements CloudDatumStreamLocalizedServiceInfo { + + private final boolean requiresPolling; + private final Iterable supportedPlaceholders; + private final Iterable supportedDataValueWildcardIdentifierLevels; + private final IntRange dataValueIdentifierLevelsSourceIdRange; + + /** + * Copy constructor from another {@link LocalizedServiceInfo} instance. + * + * @param info + * the info to copy + * @param settings + * the settings + * @param requiresPolling + * the polling requirement + * @param supportedPlaceholders + * the supported placeholder keys + * @param supportedDataValueWildcardIdentifierLevels + * the supported data value wildcard levels + * @param dataValueIdentifierLevelsSourceIdRange + * the data value identifier levels source ID range + */ + public BasicCloudDatumStreamLocalizedServiceInfo(LocalizedServiceInfo info, + List settings, boolean requiresPolling, + Iterable supportedPlaceholders, + Iterable supportedDataValueWildcardIdentifierLevels, + IntRange dataValueIdentifierLevelsSourceIdRange) { + super(info, settings); + this.requiresPolling = requiresPolling; + this.supportedPlaceholders = supportedPlaceholders; + this.supportedDataValueWildcardIdentifierLevels = supportedDataValueWildcardIdentifierLevels; + this.dataValueIdentifierLevelsSourceIdRange = dataValueIdentifierLevelsSourceIdRange; + } + + @Override + public boolean isRequiresPolling() { + return requiresPolling; + } + + @Override + public final Iterable getSupportedPlaceholders() { + return supportedPlaceholders; + } + + @Override + public final Iterable getSupportedDataValueWildcardIdentifierLevels() { + return supportedDataValueWildcardIdentifierLevels; + } + + @Override + public final IntRange getDataValueIdentifierLevelsSourceIdRange() { + return dataValueIdentifierLevelsSourceIdRange; + } + +} diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/domain/CloudDataValue.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/domain/CloudDataValue.java index 9e3a1f4d5..cd9b916b8 100644 --- a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/domain/CloudDataValue.java +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/domain/CloudDataValue.java @@ -39,7 +39,7 @@ *

* * @author matt - * @version 1.1 + * @version 1.3 */ @JsonPropertyOrder({ "name", "reference", "identifiers", "metadata", "children" }) public class CloudDataValue implements Serializable, Comparable { @@ -70,6 +70,13 @@ public class CloudDataValue implements Serializable, Comparable /** Standard metadata key for a device model name. */ public static final String DEVICE_MODEL_METADATA = "model"; + /** + * Standard metadata key for a device firmware version. + * + * @since 1.2 + */ + public static final String DEVICE_FIRMWARE_VERSION_METADATA = "firmwareVersion"; + /** Standard metadata key for a device serial number name. */ public static final String DEVICE_SERIAL_NUMBER_METADATA = "serial"; @@ -109,6 +116,26 @@ public static String pathReferenceValue(Collection identifiers) { return buf.toString(); } + /** + * Create a new data value instance. + * + *

+ * The {@code reference} will be set to a path-like value using the + * {@code identifier} components. + *

+ * + * @param identifiers + * the value identifiers, unique within the overall hierarchy + * @param name + * the component name + * @throws IllegalArgumentException + * if {@code identifiers} or {@code name} is {@literal null} + * @since 1.3 + */ + public static CloudDataValue dataValue(List identifiers, String name) { + return dataValue(identifiers, name, null); + } + /** * Create a new data value instance. * diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/domain/CloudDatumStreamLocalizedServiceInfo.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/domain/CloudDatumStreamLocalizedServiceInfo.java new file mode 100644 index 000000000..9d0ef1c9d --- /dev/null +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/domain/CloudDatumStreamLocalizedServiceInfo.java @@ -0,0 +1,72 @@ +/* ================================================================== + * CloudDatumStreamLocalizedServiceInfo.java - 23/10/2024 8:52:22 am + * + * Copyright 2024 SolarNetwork.net Dev Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA + * 02111-1307 USA + * ================================================================== + */ + +package net.solarnetwork.central.c2c.domain; + +import net.solarnetwork.settings.ConfigurableLocalizedServiceInfo; +import net.solarnetwork.util.IntRange; + +/** + * Localized service information for cloud datum stream services. + * + * @author matt + * @version 1.0 + */ +public interface CloudDatumStreamLocalizedServiceInfo extends ConfigurableLocalizedServiceInfo { + + /** + * Get the polling requirement. + * + * @return {@literal true} if this service requires polling to acquire data + */ + boolean isRequiresPolling(); + + /** + * Get a collection of supported placeholder keys. + * + * @return the placeholders, or {@literal null} + */ + Iterable getSupportedPlaceholders(); + + /** + * Get a set of data value wildcard identifier levels. + * + *

+ * The values in this set represent 0-based offsets within a + * {@link CloudDataValue#getIdentifiers()} list that allow a + * {@link CloudDataValue#WILDCARD_IDENTIFIER} value to be specified in a + * {@link CloudDatumStreamPropertyConfiguration#getValueReference()}. + *

+ * + * @return the 0-based list offsets, or {@literal null} + */ + Iterable getSupportedDataValueWildcardIdentifierLevels(); + + /** + * Get the data value identifier levels that can uniquely identify a + * SolarNetwork source ID. + * + * @return the 0-based range, or {@literal null} + */ + IntRange getDataValueIdentifierLevelsSourceIdRange(); + +} diff --git a/solarnet/cloud-integrations/src/main/resources/net/solarnetwork/central/c2c/biz/impl/SolarEdgeCloudDatumStreamService.properties b/solarnet/cloud-integrations/src/main/resources/net/solarnetwork/central/c2c/biz/impl/SolarEdgeCloudDatumStreamService.properties deleted file mode 100644 index 1f71472ed..000000000 --- a/solarnet/cloud-integrations/src/main/resources/net/solarnetwork/central/c2c/biz/impl/SolarEdgeCloudDatumStreamService.properties +++ /dev/null @@ -1,5 +0,0 @@ -title = SolarEdge Datum Stream - -dataValueFilter.siteId.key = Site ID -dataValueFilter.siteId.desc = The site identifier to restrict the results to. - diff --git a/solarnet/cloud-integrations/src/main/resources/net/solarnetwork/central/c2c/biz/impl/SolarEdgeV1CloudDatumStreamService.properties b/solarnet/cloud-integrations/src/main/resources/net/solarnetwork/central/c2c/biz/impl/SolarEdgeV1CloudDatumStreamService.properties new file mode 100644 index 000000000..feec606f9 --- /dev/null +++ b/solarnet/cloud-integrations/src/main/resources/net/solarnetwork/central/c2c/biz/impl/SolarEdgeV1CloudDatumStreamService.properties @@ -0,0 +1,18 @@ +title = SolarEdge V1 Datum Stream + +dataValueFilter.siteId.key = Site ID +dataValueFilter.siteId.desc = The site identifier to restrict the results to. + +dataValueFilter.deviceType.key = Device Type +dataValueFilter.deviceType.desc = The device type to restrict the results to, one of \ + inv, met, or bat for \ + inverter, meter, or battery. + +dataValueFilter.componentId.key = Component ID +dataValueFilter.componentId.desc = The component identifier to restrict the results to. + +resolution.key = Resolution +resolution.desc = The resolution of the data to request. + +resolution.QUARTER_HOUR.key = 15 minute +resolution.HOUR.key = Hourly diff --git a/solarnet/cloud-integrations/src/main/resources/net/solarnetwork/central/c2c/biz/impl/SolarEdgeCloudIntegrationService.properties b/solarnet/cloud-integrations/src/main/resources/net/solarnetwork/central/c2c/biz/impl/SolarEdgeV1CloudIntegrationService.properties similarity index 100% rename from solarnet/cloud-integrations/src/main/resources/net/solarnetwork/central/c2c/biz/impl/SolarEdgeCloudIntegrationService.properties rename to solarnet/cloud-integrations/src/main/resources/net/solarnetwork/central/c2c/biz/impl/SolarEdgeV1CloudIntegrationService.properties diff --git a/solarnet/cloud-integrations/src/test/java/net/solarnetwork/central/c2c/biz/impl/test/SolarEdgeCloudIntegrationServiceTests.java b/solarnet/cloud-integrations/src/test/java/net/solarnetwork/central/c2c/biz/impl/test/SolarEdgeCloudIntegrationServiceTests.java index 2ac5f456f..44d927f32 100644 --- a/solarnet/cloud-integrations/src/test/java/net/solarnetwork/central/c2c/biz/impl/test/SolarEdgeCloudIntegrationServiceTests.java +++ b/solarnet/cloud-integrations/src/test/java/net/solarnetwork/central/c2c/biz/impl/test/SolarEdgeCloudIntegrationServiceTests.java @@ -23,8 +23,7 @@ package net.solarnetwork.central.c2c.biz.impl.test; import static java.time.Instant.now; -import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeCloudIntegrationService.ACCOUNT_KEY_SETTING; -import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeCloudIntegrationService.API_KEY_SETTING; +import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeV1CloudIntegrationService.API_KEY_SETTING; import static net.solarnetwork.central.test.CommonTestUtils.randomLong; import static net.solarnetwork.central.test.CommonTestUtils.randomString; import static org.assertj.core.api.BDDAssertions.and; @@ -51,13 +50,13 @@ import net.solarnetwork.central.biz.UserEventAppenderBiz; import net.solarnetwork.central.c2c.biz.CloudDatumStreamService; import net.solarnetwork.central.c2c.biz.impl.BaseCloudIntegrationService; -import net.solarnetwork.central.c2c.biz.impl.SolarEdgeCloudIntegrationService; +import net.solarnetwork.central.c2c.biz.impl.SolarEdgeV1CloudIntegrationService; import net.solarnetwork.central.c2c.domain.CloudIntegrationConfiguration; import net.solarnetwork.domain.Result; import net.solarnetwork.domain.Result.ErrorDetail; /** - * Test cases for the {@link SolarEdgeCloudIntegrationService} class. + * Test cases for the {@link SolarEdgeV1CloudIntegrationService} class. * * @author matt * @version 1.1 @@ -80,15 +79,15 @@ public class SolarEdgeCloudIntegrationServiceTests { @Mock private TextEncryptor encryptor; - private SolarEdgeCloudIntegrationService service; + private SolarEdgeV1CloudIntegrationService service; @BeforeEach public void setup() { - service = new SolarEdgeCloudIntegrationService(Collections.singleton(datumStreamService), + service = new SolarEdgeV1CloudIntegrationService(Collections.singleton(datumStreamService), userEventAppenderBiz, encryptor, restOps); ResourceBundleMessageSource msg = new ResourceBundleMessageSource(); - msg.setBasenames(SolarEdgeCloudIntegrationService.class.getName(), + msg.setBasenames(SolarEdgeV1CloudIntegrationService.class.getName(), BaseCloudIntegrationService.class.getName()); service.setMessageSource(msg); } @@ -118,18 +117,12 @@ public void validate_missingAuthSettings() { .returns(false, from(Result::getSuccess)) .satisfies(r -> { and.then(r.getErrors()) - .as("Error details provided for missing account, API keys") - .hasSize(2) + .as("Error details provided for missing API keys") + .hasSize(1) .satisfies(errors -> { and.then(errors) .as("Error detail") .element(0) - .as("Account key flagged") - .returns(ACCOUNT_KEY_SETTING, from(ErrorDetail::getLocation)) - ; - and.then(errors) - .as("Error detail") - .element(1) .as("API key flagged") .returns(API_KEY_SETTING, from(ErrorDetail::getLocation)) ; @@ -143,20 +136,18 @@ public void validate_missingAuthSettings() { @Test public void validate_ok() { // GIVEN - final String accountKey = randomString(); final String apiKey = randomString(); final CloudIntegrationConfiguration conf = new CloudIntegrationConfiguration(TEST_USER_ID, randomLong(), now()); // @formatter:off conf.setServiceProps(Map.of( - SolarEdgeCloudIntegrationService.ACCOUNT_KEY_SETTING, accountKey, - SolarEdgeCloudIntegrationService.API_KEY_SETTING, apiKey + SolarEdgeV1CloudIntegrationService.API_KEY_SETTING, apiKey )); // @formatter:on - final URI listSitesUri = SolarEdgeCloudIntegrationService.BASE_URI - .resolve(SolarEdgeCloudIntegrationService.V2_SITES_LIST_URL); + final URI listSitesUri = SolarEdgeV1CloudIntegrationService.BASE_URI + .resolve(SolarEdgeV1CloudIntegrationService.SITES_LIST_URL); final ResponseEntity res = new ResponseEntity(randomString(), HttpStatus.OK); given(restOps.exchange(eq(listSitesUri), eq(HttpMethod.GET), any(), eq(String.class))) .willReturn(res); diff --git a/solarnet/cloud-integrations/src/test/java/net/solarnetwork/central/c2c/biz/impl/test/SolarEdgeV1CloudDatumStreamServiceTests.java b/solarnet/cloud-integrations/src/test/java/net/solarnetwork/central/c2c/biz/impl/test/SolarEdgeV1CloudDatumStreamServiceTests.java new file mode 100644 index 000000000..b259e9938 --- /dev/null +++ b/solarnet/cloud-integrations/src/test/java/net/solarnetwork/central/c2c/biz/impl/test/SolarEdgeV1CloudDatumStreamServiceTests.java @@ -0,0 +1,395 @@ +/* ================================================================== + * SolarEdgeV1CloudDatumStreamServiceTests.java - 24/10/2024 9:33:49 am + * + * Copyright 2024 SolarNetwork.net Dev Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA + * 02111-1307 USA + * ================================================================== + */ + +package net.solarnetwork.central.c2c.biz.impl.test; + +import static java.time.Instant.now; +import static java.time.ZoneOffset.UTC; +import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeDeviceType.Battery; +import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeDeviceType.Inverter; +import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeDeviceType.Meter; +import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeResolution.FifteenMinute; +import static net.solarnetwork.central.c2c.biz.impl.SolarEdgeV1CloudIntegrationService.BASE_URI; +import static net.solarnetwork.central.test.CommonTestUtils.randomLong; +import static net.solarnetwork.central.test.CommonTestUtils.randomString; +import static net.solarnetwork.central.test.CommonTestUtils.utf8StringResource; +import static net.solarnetwork.util.DateUtils.ISO_DATE_OPT_TIME_ALT; +import static org.assertj.core.api.BDDAssertions.and; +import static org.assertj.core.api.BDDAssertions.from; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.web.util.UriComponentsBuilder.fromUri; +import java.io.IOException; +import java.math.BigDecimal; +import java.net.URI; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.encrypt.TextEncryptor; +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.web.client.RestOperations; +import org.threeten.extra.MutableClock; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import net.solarnetwork.central.biz.UserEventAppenderBiz; +import net.solarnetwork.central.c2c.biz.CloudDatumStreamService; +import net.solarnetwork.central.c2c.biz.CloudIntegrationsExpressionService; +import net.solarnetwork.central.c2c.biz.impl.BaseCloudDatumStreamService; +import net.solarnetwork.central.c2c.biz.impl.SolarEdgeDeviceType; +import net.solarnetwork.central.c2c.biz.impl.SolarEdgeResolution; +import net.solarnetwork.central.c2c.biz.impl.SolarEdgeV1CloudDatumStreamService; +import net.solarnetwork.central.c2c.biz.impl.SpelCloudIntegrationsExpressionService; +import net.solarnetwork.central.c2c.dao.CloudDatumStreamConfigurationDao; +import net.solarnetwork.central.c2c.dao.CloudDatumStreamMappingConfigurationDao; +import net.solarnetwork.central.c2c.dao.CloudDatumStreamPropertyConfigurationDao; +import net.solarnetwork.central.c2c.dao.CloudIntegrationConfigurationDao; +import net.solarnetwork.central.c2c.domain.CloudDatumStreamConfiguration; +import net.solarnetwork.central.c2c.domain.CloudDatumStreamMappingConfiguration; +import net.solarnetwork.central.c2c.domain.CloudDatumStreamPropertyConfiguration; +import net.solarnetwork.central.c2c.domain.CloudDatumStreamValueType; +import net.solarnetwork.central.c2c.domain.CloudIntegrationConfiguration; +import net.solarnetwork.codec.JsonUtils; +import net.solarnetwork.domain.datum.Datum; +import net.solarnetwork.domain.datum.DatumSamples; +import net.solarnetwork.domain.datum.DatumSamplesType; +import net.solarnetwork.domain.datum.ObjectDatumKind; + +/** + * Test cases for the {@link SolarEdgeV1CloudDatumStreamService} class. + * + * @author matt + * @version 1.0 + */ +@SuppressWarnings("static-access") +@ExtendWith(MockitoExtension.class) +public class SolarEdgeV1CloudDatumStreamServiceTests { + + private static final Long TEST_USER_ID = randomLong(); + + @Mock + private UserEventAppenderBiz userEventAppenderBiz; + + @Mock + private RestOperations restOps; + + @Mock + private OAuth2AuthorizedClientManager oauthClientManager; + + @Captor + private ArgumentCaptor authRequestCaptor; + + @Mock + private TextEncryptor encryptor; + + @Mock + private CloudIntegrationConfigurationDao integrationDao; + + @Mock + private CloudDatumStreamConfigurationDao datumStreamDao; + + @Mock + private CloudDatumStreamMappingConfigurationDao datumStreamMappingDao; + + @Mock + private CloudDatumStreamPropertyConfigurationDao datumStreamPropertyDao; + + @Captor + private ArgumentCaptor uriCaptor; + + @Captor + private ArgumentCaptor> httpEntityCaptor; + + private CloudIntegrationsExpressionService expressionService; + + private MutableClock clock = MutableClock.of(Instant.now().truncatedTo(ChronoUnit.DAYS), UTC); + + private SolarEdgeV1CloudDatumStreamService service; + + private ObjectMapper objectMapper; + + @BeforeEach + public void setup() { + objectMapper = JsonUtils.newObjectMapper(); + + expressionService = new SpelCloudIntegrationsExpressionService(); + service = new SolarEdgeV1CloudDatumStreamService(userEventAppenderBiz, encryptor, + expressionService, integrationDao, datumStreamDao, datumStreamMappingDao, + datumStreamPropertyDao, restOps, clock); + + ResourceBundleMessageSource msg = new ResourceBundleMessageSource(); + msg.setBasenames(SolarEdgeV1CloudDatumStreamService.class.getName(), + BaseCloudDatumStreamService.class.getName()); + service.setMessageSource(msg); + } + + private static String componentValueRef(Object siteId, SolarEdgeDeviceType deviceType, + Object componentId, String fieldName) { + return "/%s/%s/%s/%s".formatted(siteId, deviceType.getKey(), componentId, fieldName); + } + + private static String placeholderComponentValueRef(SolarEdgeDeviceType deviceType, + String fieldName) { + return componentValueRef("{siteId}", deviceType, "*", fieldName); + } + + @Test + public void requestLatest() throws IOException { + // GIVEN + final Long siteId = randomLong(); + final String inverterComponentId = randomString(); + final String meterComponentId = "Production"; + final String batteryComponentId = "11111111111111111111111"; + final ZoneId siteTimeZone = ZoneId.of("America/New_York"); + + // configure integration + final CloudIntegrationConfiguration integration = new CloudIntegrationConfiguration(TEST_USER_ID, + randomLong(), now()); + + given(integrationDao.get(integration.getId())).willReturn(integration); + + // configure datum stream mapping + final CloudDatumStreamMappingConfiguration mapping = new CloudDatumStreamMappingConfiguration( + TEST_USER_ID, randomLong(), now()); + mapping.setIntegrationId(integration.getConfigId()); + + given(datumStreamMappingDao.get(mapping.getId())).willReturn(mapping); + + // configure datum stream properties + final CloudDatumStreamPropertyConfiguration c1p1 = new CloudDatumStreamPropertyConfiguration( + TEST_USER_ID, mapping.getConfigId(), 1, now()); + c1p1.setEnabled(true); + c1p1.setPropertyType(DatumSamplesType.Instantaneous); + c1p1.setPropertyName("watts"); + c1p1.setValueType(CloudDatumStreamValueType.Reference); + c1p1.setValueReference(componentValueRef(siteId, Inverter, inverterComponentId, "W")); + + final CloudDatumStreamPropertyConfiguration c1p2 = new CloudDatumStreamPropertyConfiguration( + TEST_USER_ID, mapping.getConfigId(), 2, now()); + c1p2.setEnabled(true); + c1p2.setPropertyType(DatumSamplesType.Accumulating); + c1p2.setPropertyName("wattHours"); + c1p2.setValueType(CloudDatumStreamValueType.Reference); + c1p2.setValueReference(componentValueRef(siteId, Inverter, inverterComponentId, "TotWhExp")); + + final CloudDatumStreamPropertyConfiguration c2p1 = new CloudDatumStreamPropertyConfiguration( + TEST_USER_ID, mapping.getConfigId(), 3, now()); + c2p1.setEnabled(true); + c2p1.setPropertyType(DatumSamplesType.Instantaneous); + c2p1.setPropertyName("watts"); + c2p1.setValueType(CloudDatumStreamValueType.Reference); + c2p1.setValueReference(componentValueRef(siteId, Meter, meterComponentId, "W")); + + final CloudDatumStreamPropertyConfiguration c2p2 = new CloudDatumStreamPropertyConfiguration( + TEST_USER_ID, mapping.getConfigId(), 4, now()); + c2p2.setEnabled(true); + c2p2.setPropertyType(DatumSamplesType.Accumulating); + c2p2.setPropertyName("wattHours"); + c2p2.setValueType(CloudDatumStreamValueType.Reference); + c2p2.setValueReference(componentValueRef(siteId, Meter, meterComponentId, "TotWh")); + + final CloudDatumStreamPropertyConfiguration c3p1 = new CloudDatumStreamPropertyConfiguration( + TEST_USER_ID, mapping.getConfigId(), 3, now()); + c3p1.setEnabled(true); + c3p1.setPropertyType(DatumSamplesType.Instantaneous); + c3p1.setPropertyName("watts"); + c3p1.setValueType(CloudDatumStreamValueType.Reference); + c3p1.setValueReference(componentValueRef(siteId, Battery, batteryComponentId, "W")); + + final CloudDatumStreamPropertyConfiguration c3p2 = new CloudDatumStreamPropertyConfiguration( + TEST_USER_ID, mapping.getConfigId(), 4, now()); + c3p2.setEnabled(true); + c3p2.setPropertyType(DatumSamplesType.Accumulating); + c3p2.setPropertyName("wattHours"); + c3p2.setValueType(CloudDatumStreamValueType.Reference); + c3p2.setValueReference(componentValueRef(siteId, Battery, batteryComponentId, "TotWhExp")); + + given(datumStreamPropertyDao.findAll(TEST_USER_ID, mapping.getConfigId(), null)) + .willReturn(List.of(c1p1, c1p2, c2p1, c2p2, c3p1, c3p2)); + + // configure datum stream + final Long nodeId = randomLong(); + final String sourceId = randomString(); + final CloudDatumStreamConfiguration datumStream = new CloudDatumStreamConfiguration(TEST_USER_ID, + randomLong(), now()); + datumStream.setDatumStreamMappingId(mapping.getConfigId()); + datumStream.setKind(ObjectDatumKind.Node); + datumStream.setObjectId(nodeId); + datumStream.setSourceId(sourceId); + // @formatter:off + datumStream.setServiceProps(Map.of( + CloudDatumStreamService.SOURCE_ID_MAP_SETTING, Map.of( + "/%s/%s/%s".formatted(siteId, Inverter.getKey(), inverterComponentId), "INV/1", + "/%s/%s/%s".formatted(siteId, Meter.getKey(), meterComponentId), "MET/1", + "/%s/%s/%s".formatted(siteId, Battery.getKey(), batteryComponentId), "BAT/1" + ) + )); + // @formatter:on + + // request site time zone info + final URI siteDetailsUri = fromUri(BASE_URI) + .path(SolarEdgeV1CloudDatumStreamService.SITE_DETAILS_URL_TEMPLATE) + .buildAndExpand(siteId).toUri(); + final JsonNode siteDetailsJson = objectMapper + .readTree(utf8StringResource("solaredge-v1-site-details-01.json", getClass())); + final var siteDetailsRes = new ResponseEntity(siteDetailsJson, HttpStatus.OK); + given(restOps.exchange(eq(siteDetailsUri), eq(HttpMethod.GET), any(), eq(JsonNode.class))) + .willReturn(siteDetailsRes); + + // expected date range is clock-aligned + final ZonedDateTime expectedEndDate = clock.instant().atZone(siteTimeZone); + final ZonedDateTime expectedStartDate = expectedEndDate.minus(FifteenMinute.getTickDuration()); + final DateTimeFormatter timestampFmt = ISO_DATE_OPT_TIME_ALT.withZone(siteTimeZone); + + // request inverter data + final URI inverterDataUri = fromUri(BASE_URI) + .path(SolarEdgeV1CloudDatumStreamService.EQUIPMENT_DATA_URL_TEMPLATE) + .queryParam("startTime", timestampFmt.format(expectedStartDate.toLocalDateTime())) + .queryParam("endTime", timestampFmt.format(expectedEndDate.toLocalDateTime())) + .buildAndExpand(siteId, inverterComponentId).toUri(); + final JsonNode inverterDataJson = objectMapper + .readTree(utf8StringResource("solaredge-v1-inverter-data-01.json", getClass())); + final var inverterDataRes = new ResponseEntity(inverterDataJson, HttpStatus.OK); + given(restOps.exchange(eq(inverterDataUri), eq(HttpMethod.GET), any(), eq(JsonNode.class))) + .willReturn(inverterDataRes); + + // request meter power data + final URI meterPowerDataUri = fromUri(BASE_URI) + .path(SolarEdgeV1CloudDatumStreamService.POWER_DETAILS_URL_TEMPLATE) + .queryParam("startTime", timestampFmt.format(expectedStartDate.toLocalDateTime())) + .queryParam("endTime", timestampFmt.format(expectedEndDate.toLocalDateTime())) + .queryParam("timeUnit", SolarEdgeResolution.FifteenMinute.getKey()) + .buildAndExpand(siteId).toUri(); + final JsonNode meterPowerDataJson = objectMapper + .readTree(utf8StringResource("solaredge-v1-meter-power-data-01.json", getClass())); + final var meterPowerDataRes = new ResponseEntity(meterPowerDataJson, HttpStatus.OK); + given(restOps.exchange(eq(meterPowerDataUri), eq(HttpMethod.GET), any(), eq(JsonNode.class))) + .willReturn(meterPowerDataRes); + + // request meter energy data + final URI meterEnergyDataUri = fromUri(BASE_URI) + .path(SolarEdgeV1CloudDatumStreamService.METERS_URL_TEMPLATE) + .queryParam("startTime", timestampFmt.format(expectedStartDate.toLocalDateTime())) + .queryParam("endTime", timestampFmt.format(expectedEndDate.toLocalDateTime())) + .queryParam("timeUnit", SolarEdgeResolution.FifteenMinute.getKey()) + .buildAndExpand(siteId).toUri(); + final JsonNode meterEnergyDataJson = objectMapper + .readTree(utf8StringResource("solaredge-v1-meter-energy-data-01.json", getClass())); + final var meterEnergyDataRes = new ResponseEntity(meterEnergyDataJson, HttpStatus.OK); + given(restOps.exchange(eq(meterEnergyDataUri), eq(HttpMethod.GET), any(), eq(JsonNode.class))) + .willReturn(meterEnergyDataRes); + + // request battery data + final URI batteryDataUri = fromUri(BASE_URI) + .path(SolarEdgeV1CloudDatumStreamService.STORAGE_DATA_URL_TEMPLATE) + .queryParam("startTime", timestampFmt.format(expectedStartDate.toLocalDateTime())) + .queryParam("endTime", timestampFmt.format(expectedEndDate.toLocalDateTime())) + .buildAndExpand(siteId).toUri(); + final JsonNode batteryDataJson = objectMapper + .readTree(utf8StringResource("solaredge-v1-storage-data-01.json", getClass())); + final var storageDataRes = new ResponseEntity(batteryDataJson, HttpStatus.OK); + given(restOps.exchange(eq(batteryDataUri), eq(HttpMethod.GET), any(), eq(JsonNode.class))) + .willReturn(storageDataRes); + + // WHEN + Iterable result = service.latestDatum(datumStream); + + // THEN + // @formatter:off + + and.then(result) + .as("Datum parsed from HTTP response") + .hasSize(12) + .allSatisfy(d -> { + and.then(d) + .as("Datum kind is from DatumStream configuration") + .returns(datumStream.getKind(), Datum::getKind) + .as("Datum object ID is from DatumStream configuration") + .returns(datumStream.getObjectId(), Datum::getObjectId) + ; + }) + .satisfies(list -> { + // inverter + and.then(list).element(0) + .as("Datum source ID is mapped from DatumStream configuration") + .returns("INV/1", from(Datum::getSourceId)) + .as("Timestamp from inverter data") + .returns(timestampFmt.parse("2024-10-23 16:19:30", Instant::from), from(Datum::getTimestamp)) + .as("Datum samples from inverter data") + .returns(new DatumSamples(Map.of( + "watts", new BigDecimal(2011) + ), Map.of( + "wattHours", new BigDecimal("3.77792E+7") + ), null), + Datum::asSampleOperations) + ; + // meter + and.then(list).element(4) + .as("Datum source ID is mapped from DatumStream configuration") + .returns("MET/1", from(Datum::getSourceId)) + .as("Timestamp from clock-aligned meter data") + .returns(timestampFmt.parse("2024-10-23 16:15:00", Instant::from), from(Datum::getTimestamp)) + .as("Datum samples from merged meter power and energy data") + .returns(new DatumSamples(Map.of( + "watts", new BigDecimal("1720.0271") + ), Map.of( + "wattHours", new BigDecimal("3.7539808E+7") + ), null), + Datum::asSampleOperations) + ; + // battery + and.then(list).element(6) + .as("Datum source ID is mapped from DatumStream configuration") + .returns("BAT/1", from(Datum::getSourceId)) + .as("Timestamp from battery data") + .returns(timestampFmt.parse("2024-10-23 16:19:30", Instant::from), from(Datum::getTimestamp)) + .as("Datum samples from battery data") + .returns(new DatumSamples(Map.of( + "watts", new BigDecimal("0") + ), Map.of( + "wattHours", 5510545 + ), null), + Datum::asSampleOperations) + ; + }) + ; + // @formatter:on + } + +} diff --git a/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/solaredge-v1-inverter-data-01.json b/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/solaredge-v1-inverter-data-01.json new file mode 100644 index 000000000..542714dd8 --- /dev/null +++ b/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/solaredge-v1-inverter-data-01.json @@ -0,0 +1,95 @@ +{ + "data": { + "count": 4, + "telemetries": [ + { + "date": "2024-10-23 16:19:30", + "totalActivePower": 2011.0, + "dcVoltage": 428.438, + "groundFaultResistance": 6000.0, + "powerLimit": 100.0, + "totalEnergy": 3.77792E7, + "temperature": 38.6175, + "inverterMode": "MPPT", + "operationMode": 0, + "vL1ToN": 122.906, + "vL2ToN": 123.344, + "L1Data": { + "acCurrent": 8.19531, + "acVoltage": 247.172, + "acFrequency": 60.0128, + "apparentPower": 2018.0, + "activePower": 2011.0, + "reactivePower": -168.0, + "cosPhi": 1.0 + } + }, + { + "date": "2024-10-23 16:24:30", + "totalActivePower": 1700.0, + "dcVoltage": 429.188, + "groundFaultResistance": 6000.0, + "powerLimit": 100.0, + "totalEnergy": 3.77794E7, + "temperature": 38.1624, + "inverterMode": "MPPT", + "operationMode": 0, + "vL1ToN": 122.844, + "vL2ToN": 123.156, + "L1Data": { + "acCurrent": 6.95312, + "acVoltage": 246.109, + "acFrequency": 60.0137, + "apparentPower": 1710.0, + "activePower": 1700.0, + "reactivePower": 181.0, + "cosPhi": 1.0 + } + }, + { + "date": "2024-10-23 16:29:29", + "totalActivePower": 1489.0, + "dcVoltage": 428.562, + "groundFaultResistance": 6000.0, + "powerLimit": 100.0, + "totalEnergy": 3.77795E7, + "temperature": 37.4937, + "inverterMode": "MPPT", + "operationMode": 0, + "vL1ToN": 122.875, + "vL2ToN": 123.094, + "L1Data": { + "acCurrent": 6.09375, + "acVoltage": 246.203, + "acFrequency": 60.0201, + "apparentPower": 1498.5, + "activePower": 1489.0, + "reactivePower": 164.5, + "cosPhi": 1.0 + } + }, + { + "date": "2024-10-23 16:34:29", + "totalActivePower": 1388.0, + "dcVoltage": 429.375, + "groundFaultResistance": 6000.0, + "powerLimit": 100.0, + "totalEnergy": 3.77796E7, + "temperature": 37.0009, + "inverterMode": "MPPT", + "operationMode": 0, + "vL1ToN": 122.625, + "vL2ToN": 123.0, + "L1Data": { + "acCurrent": 5.67188, + "acVoltage": 246.344, + "acFrequency": 60.0061, + "apparentPower": 1393.0, + "activePower": 1388.0, + "reactivePower": -118.0, + "cosPhi": 1.0 + } + } + ] + } +} \ No newline at end of file diff --git a/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/solaredge-v1-meter-energy-data-01.json b/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/solaredge-v1-meter-energy-data-01.json new file mode 100644 index 000000000..a97314a11 --- /dev/null +++ b/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/solaredge-v1-meter-energy-data-01.json @@ -0,0 +1,56 @@ +{ + "meterEnergyDetails": { + "timeUnit": "QUARTER_OF_AN_HOUR", + "unit": "Wh", + "meters": [ + { + "meterSerialNumber": "111111111", + "connectedSolaredgeDeviceSN": "11111111-BA", + "model": "SE-RGMTR-1D-240C-A", + "meterType": "Production", + "values": [ + { + "date": "2024-10-23 16:19:30", + "value": 3.7539808E7 + }, + { + "date": "2024-10-23 16:34:29", + "value": 3.7540216E7 + } + ] + }, + { + "meterSerialNumber": "222222222", + "connectedSolaredgeDeviceSN": "11111111-BA", + "model": "SE-MTR-3Y-240V-A", + "meterType": "FeedIn", + "values": [ + { + "date": "2024-10-23 16:19:30", + "value": 2.4263696E7 + }, + { + "date": "2024-10-23 16:34:29", + "value": 2.4263898E7 + } + ] + }, + { + "meterSerialNumber": "333333333", + "connectedSolaredgeDeviceSN": "11111111-BA", + "model": "SE-MTR-3Y-240V-A", + "meterType": "Purchased", + "values": [ + { + "date": "2024-10-23 16:19:30", + "value": 7375532.0 + }, + { + "date": "2024-10-23 16:34:29", + "value": 7375532.0 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/solaredge-v1-meter-power-data-01.json b/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/solaredge-v1-meter-power-data-01.json new file mode 100644 index 000000000..c1c4e189a --- /dev/null +++ b/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/solaredge-v1-meter-power-data-01.json @@ -0,0 +1,60 @@ +{ + "powerDetails": { + "timeUnit": "QUARTER_OF_AN_HOUR", + "unit": "W", + "meters": [ + { + "type": "FeedIn", + "values": [ + { + "date": "2024-10-23 16:15:00", + "value": 935.3284 + }, + { + "date": "2024-10-23 16:30:00", + "value": 449.77917 + } + ] + }, + { + "type": "Consumption", + "values": [ + { + "date": "2024-10-23 16:15:00", + "value": 784.69867 + }, + { + "date": "2024-10-23 16:30:00", + "value": 911.8978 + } + ] + }, + { + "type": "Purchased", + "values": [ + { + "date": "2024-10-23 16:15:00", + "value": 0.0 + }, + { + "date": "2024-10-23 16:30:00", + "value": 0.0 + } + ] + }, + { + "type": "Production", + "values": [ + { + "date": "2024-10-23 16:15:00", + "value": 1720.0271 + }, + { + "date": "2024-10-23 16:30:00", + "value": 1361.677 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/solaredge-v1-site-details-01.json b/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/solaredge-v1-site-details-01.json new file mode 100644 index 000000000..d76597582 --- /dev/null +++ b/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/solaredge-v1-site-details-01.json @@ -0,0 +1,39 @@ +{ + "details": { + "id": 1234567, + "name": "Example Site", + "accountId": 12345, + "status": "Active", + "peakPower": 7.4, + "lastUpdateTime": "2024-10-22", + "installationDate": "2020-07-16", + "ptoDate": null, + "notes": "Very good", + "type": "Optimizers & Inverters", + "location": { + "country": "United States", + "state": "Connecticut", + "city": "Anytown", + "address": "123 Main Street", + "address2": "", + "zip": "06830", + "timeZone": "America/New_York", + "countryCode": "US", + "stateCode": "CT" + }, + "primaryModule": { + "manufacturerName": "LG", + "modelName": "LG335", + "maximumPower": 335.0 + }, + "uris": { + "SITE_IMAGE": "/site/1234567/siteImage/example.PNG", + "DATA_PERIOD": "/site/1234567/dataPeriod", + "DETAILS": "/site/1234567/details", + "OVERVIEW": "/site/1234567/overview" + }, + "publicSettings": { + "isPublic": false + } + } +} \ No newline at end of file diff --git a/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/solaredge-v1-storage-data-01.json b/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/solaredge-v1-storage-data-01.json new file mode 100644 index 000000000..7a4220d42 --- /dev/null +++ b/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/solaredge-v1-storage-data-01.json @@ -0,0 +1,81 @@ +{ + "storageData": { + "batteryCount": 1, + "batteries": [ + { + "nameplate": 9800.0, + "serialNumber": "11111111111111111111111", + "modelNumber": "11111111111111111111111", + "telemetryCount": 6, + "telemetries": [ + { + "timeStamp": "2024-10-23 16:19:30", + "power": 0.0, + "batteryState": 6, + "lifeTimeEnergyDischarged": 5510545, + "lifeTimeEnergyCharged": 7474239, + "batteryPercentageState": 100.0, + "fullPackEnergyAvailable": 8751.0, + "internalTemp": 20.8, + "ACGridCharging": 0.0 + }, + { + "timeStamp": "2024-10-23 16:24:30", + "power": 0.0, + "batteryState": 6, + "lifeTimeEnergyDischarged": 5510545, + "lifeTimeEnergyCharged": 7474239, + "batteryPercentageState": 100.0, + "fullPackEnergyAvailable": 8751.0, + "internalTemp": 20.8, + "ACGridCharging": 0.0 + }, + { + "timeStamp": "2024-10-23 16:29:29", + "power": 0.0, + "batteryState": 6, + "lifeTimeEnergyDischarged": 5510545, + "lifeTimeEnergyCharged": 7474239, + "batteryPercentageState": 100.0, + "fullPackEnergyAvailable": 8751.0, + "internalTemp": 20.8, + "ACGridCharging": 0.0 + }, + { + "timeStamp": "2024-10-23 16:34:29", + "power": 0.0, + "batteryState": 6, + "lifeTimeEnergyDischarged": 5510545, + "lifeTimeEnergyCharged": 7474239, + "batteryPercentageState": 100.0, + "fullPackEnergyAvailable": 8751.0, + "internalTemp": 20.8, + "ACGridCharging": 0.0 + }, + { + "timeStamp": "2024-10-23 16:39:29", + "power": 0.0, + "batteryState": 6, + "lifeTimeEnergyDischarged": 5510546, + "lifeTimeEnergyCharged": 7474239, + "batteryPercentageState": 100.0, + "fullPackEnergyAvailable": 8751.0, + "internalTemp": 20.9, + "ACGridCharging": 0.0 + }, + { + "timeStamp": "2024-10-23 16:44:29", + "power": 0.0, + "batteryState": 6, + "lifeTimeEnergyDischarged": 5510546, + "lifeTimeEnergyCharged": 7474239, + "batteryPercentageState": 100.0, + "fullPackEnergyAvailable": 8751.0, + "internalTemp": 20.9, + "ACGridCharging": 0.0 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/solarnet/common/build.gradle b/solarnet/common/build.gradle index 83cc2a669..ddbd32988 100644 --- a/solarnet/common/build.gradle +++ b/solarnet/common/build.gradle @@ -16,7 +16,7 @@ dependencyManagement { } description = 'SolarNet: Common' -version = '2.19.0' +version = '2.19.1' base { archivesName = 'solarnet-common' diff --git a/solarnet/common/src/main/java/net/solarnetwork/central/domain/ClaimableJobState.java b/solarnet/common/src/main/java/net/solarnetwork/central/domain/ClaimableJobState.java index 6ec14a32f..33b23eace 100644 --- a/solarnet/common/src/main/java/net/solarnetwork/central/domain/ClaimableJobState.java +++ b/solarnet/common/src/main/java/net/solarnetwork/central/domain/ClaimableJobState.java @@ -22,8 +22,6 @@ package net.solarnetwork.central.domain; -import com.fasterxml.jackson.annotation.JsonValue; - /** * API for a claimable job state. * @@ -35,7 +33,7 @@ *

* * @author matt - * @version 1.1 + * @version 1.2 * @since 1.44 */ public interface ClaimableJobState { @@ -68,7 +66,6 @@ public interface ClaimableJobState { * @return the key as a string * @since 1.1 */ - @JsonValue default String keyValue() { return String.valueOf(getKey()); } diff --git a/solarnet/solarjobs/build.gradle b/solarnet/solarjobs/build.gradle index ae914b06b..518da7417 100644 --- a/solarnet/solarjobs/build.gradle +++ b/solarnet/solarjobs/build.gradle @@ -9,7 +9,7 @@ apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' description = 'SolarJobs' -version = '2.13.1' +version = '2.14.0' base { archivesName = 'solarjobs' diff --git a/solarnet/solarjobs/src/main/resources/application.yml b/solarnet/solarjobs/src/main/resources/application.yml index 0892729f3..972aef46f 100644 --- a/solarnet/solarjobs/src/main/resources/application.yml +++ b/solarnet/solarjobs/src/main/resources/application.yml @@ -10,11 +10,21 @@ app: ttl: 3600 cache.persistence.path: "var/cache" c2c: - cache.expression-cache: - tti: 3600 - ttl: 0 - heap-max-entries: 5000 - disk-max-size-mb: 0 + cache: + expression-cache: + tti: 3600 + ttl: 0 + heap-max-entries: 5000 + disk-max-size-mb: 0 + solaredge-site-inventory: + ttl: 3600 + heap-max-entries: 1000 + disk-max-size-mb: 5 + solaredge-site-tz: + tti: 3600 + ttl: 0 + heap-max-entries: 5000 + disk-max-size-mb: 0 encryptor: password: "Secret123" salt-hex: "01234567" diff --git a/solarnet/solaruser/build.gradle b/solarnet/solaruser/build.gradle index 4803d015a..58996b786 100644 --- a/solarnet/solaruser/build.gradle +++ b/solarnet/solaruser/build.gradle @@ -11,7 +11,7 @@ apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' description = 'SolarUser' -version = '2.22.0' +version = '2.23.0' base { archivesName = 'solaruser' diff --git a/solarnet/solaruser/src/main/resources/application.yml b/solarnet/solaruser/src/main/resources/application.yml index e0e11b246..1bc407351 100644 --- a/solarnet/solaruser/src/main/resources/application.yml +++ b/solarnet/solaruser/src/main/resources/application.yml @@ -10,11 +10,21 @@ app: ttl: 3600 cache.persistence.path: "var/cache" c2c: - cache.expression-cache: - tti: 3600 - ttl: 0 - heap-max-entries: 5000 - disk-max-size-mb: 0 + cache: + expression-cache: + tti: 3600 + ttl: 0 + heap-max-entries: 5000 + disk-max-size-mb: 0 + solaredge-site-inventory: + ttl: 3600 + heap-max-entries: 1000 + disk-max-size-mb: 5 + solaredge-site-tz: + tti: 3600 + ttl: 0 + heap-max-entries: 5000 + disk-max-size-mb: 0 encryptor: password: "Secret123" salt-hex: "01234567"