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
+ *
+ *
+ *
+ * - siteId
+ * - deviceType
+ * - componentId
+ * - field
+ *
+ */
+ 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"