diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/AlsoEnergyCloudDatumStreamService.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/AlsoEnergyCloudDatumStreamService.java index 59a9053d9..572b72fc3 100644 --- a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/AlsoEnergyCloudDatumStreamService.java +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/AlsoEnergyCloudDatumStreamService.java @@ -22,26 +22,47 @@ package net.solarnetwork.central.c2c.biz.impl; +import static java.util.Collections.unmodifiableMap; +import static net.solarnetwork.central.c2c.biz.impl.AlsoEnergyCloudIntegrationService.BASE_URI; import static net.solarnetwork.central.c2c.biz.impl.BaseCloudIntegrationService.resolveBaseUrl; import static net.solarnetwork.central.c2c.domain.CloudDataValue.DEVICE_SERIAL_NUMBER_METADATA; 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.DateUtils.ISO_DATE_OPT_TIME_OPT_MILLIS_UTC; 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.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Map.Entry; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.slf4j.LoggerFactory; import org.springframework.context.MessageSource; -import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; import org.springframework.security.crypto.encrypt.TextEncryptor; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +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; @@ -49,8 +70,11 @@ 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.CloudDatumStreamPropertyConfiguration; import net.solarnetwork.central.c2c.domain.CloudDatumStreamQueryFilter; import net.solarnetwork.central.c2c.domain.CloudDatumStreamQueryResult; import net.solarnetwork.central.c2c.domain.CloudIntegrationConfiguration; @@ -59,7 +83,12 @@ 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.settings.support.BasicTextFieldSettingSpecifier; /** * AlsoEnergy implementation of {@link CloudDatumStreamService}. @@ -75,6 +104,15 @@ public class AlsoEnergyCloudDatumStreamService extends BaseOAuth2ClientCloudDatu /** The data value filter key for a site ID. */ public static final String SITE_ID_FILTER = "siteId"; + /** The data value filter key for a hardware ID. */ + public static final String HARDWARE_ID_FILTER = "hardwareId"; + + /** The setting for granularity. */ + public static final String GRANULARITY_SETTING = "granularity"; + + /** The setting for time zone identifier. */ + public static final String TIME_ZONE_SETTING = "tz"; + /** * The URI path to list the hardware for a given site. * @@ -84,23 +122,35 @@ public class AlsoEnergyCloudDatumStreamService extends BaseOAuth2ClientCloudDatu */ public static final String SITE_HARDWARE_URL_TEMPLATE = "/sites/{siteId}/hardware"; + /** The URI path to query for data. */ + public static final String BIN_DATA_URL = "/v2/data/bindata"; + /** The service settings. */ public static final List SETTINGS; static { + // menu for granularity + var granularitySpec = new BasicMultiValueSettingSpecifier(GRANULARITY_SETTING, + AlsoEnergyGranularity.Raw.getKey()); + var granularityTitles = unmodifiableMap(Arrays.stream(AlsoEnergyGranularity.values()) + .collect(Collectors.toMap(AlsoEnergyGranularity::getKey, AlsoEnergyGranularity::getKey, + (l, r) -> r, + () -> new LinkedHashMap<>(LocusEnergyGranularity.values().length)))); + granularitySpec.setValueTitles(granularityTitles); - SETTINGS = List.of(); + var tzSpec = new BasicTextFieldSettingSpecifier(TIME_ZONE_SETTING, null); + SETTINGS = List.of(granularitySpec, tzSpec); } /** The supported placeholder keys. */ - public static final List SUPPORTED_PLACEHOLDERS = List.of(SITE_ID_FILTER); + public static final List SUPPORTED_PLACEHOLDERS = List.of(SITE_ID_FILTER, + HARDWARE_ID_FILTER); - private AsyncTaskExecutor executor; + /** The maximum period of time to request data for in one request. */ + private static final Duration MAX_QUERY_TIME_RANGE = Duration.ofDays(7); /** * Constructor. * - * @param executor - * an executor * @param userEventAppenderBiz * the user event appender service * @param encryptor @@ -122,9 +172,8 @@ public class AlsoEnergyCloudDatumStreamService extends BaseOAuth2ClientCloudDatu * @throws IllegalArgumentException * if any argument is {@literal null} */ - public AlsoEnergyCloudDatumStreamService(AsyncTaskExecutor executor, - UserEventAppenderBiz userEventAppenderBiz, TextEncryptor encryptor, - CloudIntegrationsExpressionService expressionService, + public AlsoEnergyCloudDatumStreamService(UserEventAppenderBiz userEventAppenderBiz, + TextEncryptor encryptor, CloudIntegrationsExpressionService expressionService, CloudIntegrationConfigurationDao integrationDao, CloudDatumStreamConfigurationDao datumStreamDao, CloudDatumStreamMappingConfigurationDao datumStreamMappingDao, @@ -139,7 +188,6 @@ public AlsoEnergyCloudDatumStreamService(AsyncTaskExecutor executor, integrationServiceIdentifier -> AlsoEnergyCloudIntegrationService.SECURE_SETTINGS, oauthClientManager), oauthClientManager); - this.executor = requireNonNullArgument(executor, "executor"); } @Override @@ -166,9 +214,7 @@ public Iterable dataValues(UserLongCompositePK integrationId, integrationDao.get(requireNonNullArgument(integrationId, "integrationId")), "integration"); List result = Collections.emptyList(); - if ( false ) { - // TODO - } else if ( filters != null && filters.get(SITE_ID_FILTER) != null ) { + if ( filters != null && filters.get(SITE_ID_FILTER) != null ) { result = siteHardware(integration, filters); } else { // list available sites @@ -180,15 +226,128 @@ public Iterable dataValues(UserLongCompositePK integrationId, @Override public Iterable latestDatum(CloudDatumStreamConfiguration datumStream) { - // TODO Auto-generated method stub - return null; + requireNonNullArgument(datumStream, "datumStream"); + final ZoneId zone = resolveTimeZone(datumStream, null); + final AlsoEnergyGranularity granularity = resolveGranularity(datumStream, null); + + final Instant endDate; + final Instant startDate; + if ( granularity == AlsoEnergyGranularity.Raw ) { + endDate = Instant.now().truncatedTo(ChronoUnit.SECONDS); + startDate = endDate.minus(Duration.ofMinutes(10)); + } else { + endDate = granularity.tickStart(Instant.now().truncatedTo(ChronoUnit.SECONDS), zone); + startDate = granularity.prevTickStart(endDate, zone); + } + + 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) { - // TODO Auto-generated method stub - return null; + requireNonNullArgument(datumStream, "datumStream"); + requireNonNullArgument(filter, "filter"); + return performAction(datumStream, (ms, ds, mapping, integration, valueProps, exprProps) -> { + + if ( valueProps.isEmpty() ) { + String msg = "Datum stream has no properties."; + Errors errors = new BindException(ds, "datumStream"); + errors.reject("error.datumStream.noProperties", null, msg); + throw new ValidationException(msg, errors, ms); + } + + final Instant filterStartDate = requireNonNullArgument(filter.getStartDate(), + "filter.startDate"); + final Instant filterEndDate = requireNonNullArgument(filter.getEndDate(), + "filter.startDate"); + + final ZoneId zone = resolveTimeZone(datumStream, null); + + final AlsoEnergyGranularity resolution = resolveGranularity(ds, null); + + final Map sourceIdMap = servicePropertyStringMap(ds, SOURCE_ID_MAP_SETTING); + + // construct (siteId, hardwareId) to ValueRef[] mapping + final Map> hardwareGroups = resolveHardwareGroups( + integration, ds, valueProps); + + BasicQueryFilter nextQueryFilter = null; + + Instant startDate = resolution.tickStart(filterStartDate, zone); + Instant endDate = resolution.tickStart(filterEndDate, zone); + if ( Duration.between(startDate, endDate).compareTo(MAX_QUERY_TIME_RANGE) > 0 ) { + Instant nextEndDate = resolution + .tickStart(startDate.plus(MAX_QUERY_TIME_RANGE.multipliedBy(2)), zone); + if ( nextEndDate.isAfter(endDate) ) { + nextEndDate = endDate; + } + + endDate = resolution.tickStart(startDate.plus(MAX_QUERY_TIME_RANGE), zone); + + nextQueryFilter = new BasicQueryFilter(); + nextQueryFilter.setStartDate(endDate); + nextQueryFilter.setEndDate(nextEndDate); + } + + final BasicQueryFilter usedQueryFilter = new BasicQueryFilter(); + usedQueryFilter.setStartDate(startDate); + usedQueryFilter.setEndDate(endDate); + + final Map dataMap = new TreeMap<>(); + + for ( Entry> e : hardwareGroups.entrySet() ) { + final ZonedDateTime siteStartDate = startDate.atZone(zone); + final ZonedDateTime siteEndDate = endDate.atZone(zone); + + final var reqBody = new ArrayList>(e.getValue().size()); + for ( ValueRef ref : e.getValue() ) { + var reqField = new LinkedHashMap(4); + reqField.put("siteId", ref.siteId); + reqField.put("hardwareId", ref.hardwareId); + reqField.put("function", ref.fn.name()); + reqField.put("fieldName", ref.fieldName); + reqBody.add(reqField); + } + + String startDateParam = ISO_DATE_OPT_TIME_OPT_MILLIS_UTC + .format(siteStartDate.toLocalDateTime()); + String endDateParam = ISO_DATE_OPT_TIME_OPT_MILLIS_UTC + .format(siteEndDate.toLocalDateTime()); + + restOpsHelper.http("List data for hardware", HttpMethod.POST, reqBody, integration, + JsonNode.class, req -> { + req.setContentType(MediaType.APPLICATION_JSON); + // @formatter:off + return fromUri(resolveBaseUrl(integration, BASE_URI)).path(BIN_DATA_URL) + .queryParam("from", startDateParam) + .queryParam("to", endDateParam) + .queryParam("binSizes", resolution.getQueryKey()) + .queryParam("tz", zone.getId()) + .buildAndExpand().toUri(); + // @formatter:on + }, res -> parseDatum(res.getBody(), e.getValue(), ds, sourceIdMap, dataMap)); + } + + List resultDatum = dataMap.entrySet().stream() + .filter(e -> !e.getValue().isEmpty()) + .map(e -> new GeneralDatum(e.getKey(), e.getValue())).toList(); + + // evaluate expressions on merged datum + evaluateExpressions(exprProps, resultDatum, mapping.getConfigId(), + integration.getConfigId()); + + return new BasicCloudDatumStreamQueryResult(usedQueryFilter, nextQueryFilter, + resultDatum.stream().map(Datum.class::cast).toList()); + }); } private List sites(CloudIntegrationConfiguration integration) { @@ -311,7 +470,14 @@ private static List parseSiteHardware(JsonNode json, Map fields = new ArrayList<>(fieldsNode.size()); for ( JsonNode fieldNode : fieldsNode ) { final String fieldName = fieldNode.asText(); - fields.add(dataValue(List.of(siteId, id, fieldName), fieldName)); + + List aggs = new ArrayList<>(AlsoEnergyFieldFunction.values().length); + for ( AlsoEnergyFieldFunction fn : AlsoEnergyFieldFunction.values() ) { + aggs.add(dataValue(List.of(siteId, id, fieldName, fn.name()), + fieldName + " " + fn.name())); + } + + fields.add(intermediateDataValue(List.of(siteId, id, fieldName), fieldName, null, aggs)); } result.add(intermediateDataValue(List.of(siteId, id), name, meta, fields)); @@ -319,4 +485,165 @@ private static List parseSiteHardware(JsonNode json, Map parameters) { + AlsoEnergyGranularity result = null; + try { + String settingVal = null; + if ( parameters != null && parameters.get(GRANULARITY_SETTING) instanceof String s ) { + settingVal = s; + } else if ( datumStream != null ) { + settingVal = datumStream.serviceProperty(GRANULARITY_SETTING, String.class); + } + if ( settingVal != null && !settingVal.isEmpty() ) { + result = AlsoEnergyGranularity.fromValue(settingVal); + } + } catch ( IllegalArgumentException e ) { + // ignore + } + return (result != null ? result : AlsoEnergyGranularity.Raw); + } + + private ZoneId resolveTimeZone(CloudDatumStreamConfiguration datumStream, + Map parameters) { + ZoneId result = null; + try { + String settingVal = null; + if ( parameters != null && parameters.get(TIME_ZONE_SETTING) instanceof String s ) { + settingVal = s; + } else if ( datumStream != null ) { + settingVal = datumStream.serviceProperty(TIME_ZONE_SETTING, String.class); + } + if ( settingVal != null && !settingVal.isEmpty() ) { + result = ZoneId.of(settingVal); + } + } catch ( Exception e ) { + // ignore + } + return (result != null ? result : ZoneOffset.UTC); + } + + /** + * Value reference pattern, with component matching groups. + * + *

+ * The matching groups are + *

+ * + *
    + *
  1. siteId
  2. + *
  3. hardwareId
  4. + *
  5. field
  6. + *
  7. function
  8. + *
+ */ + private static final Pattern VALUE_REF_PATTERN = Pattern.compile("/([^/]+)/([^/]+)/([^/]+)/(.+)"); + + private static record ValueRef(Long siteId, Long hardwareId, String fieldName, + AlsoEnergyFieldFunction fn, String sourceId, + CloudDatumStreamPropertyConfiguration property) { + + public ValueRef(Long siteId, Long hardwareId, String fieldName, AlsoEnergyFieldFunction fn, + CloudDatumStreamPropertyConfiguration property) { + this(siteId, hardwareId, fieldName, fn, "/%s/%s/%s".formatted(siteId, hardwareId, fieldName), + property); + } + } + + private Map> resolveHardwareGroups( + CloudIntegrationConfiguration integration, CloudDatumStreamConfiguration datumStream, + List propConfigs) { + var result = new LinkedHashMap>(16); + 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 = hardwareId, 3 = field, 4 = function + Long siteId = Long.valueOf(m.group(1)); + Long hardwareId = Long.valueOf(m.group(2)); + String fieldName = m.group(3); + AlsoEnergyFieldFunction fn; + try { + fn = AlsoEnergyFieldFunction.valueOf(m.group(4)); + } catch ( IllegalArgumentException e ) { + fn = AlsoEnergyFieldFunction.Last; + } + + result.computeIfAbsent(new UserLongCompositePK(siteId, hardwareId), k -> new ArrayList<>(8)) + .add(new ValueRef(siteId, hardwareId, fieldName, fn, config)); + } + return result; + } + + private Void parseDatum(JsonNode body, List refs, + CloudDatumStreamConfiguration datumStream, Map sourceIdMap, + Map dataMap) { + /*- EXAMPLE JSON: + { + "info": [ + { + "hardwareId": 12345, + "dataIndex": 0, + "name": "KwAC", + "units": "Kilowatts" + }, + { + "hardwareId": 12345, + "dataIndex": 1, + "name": "KwhAC", + "units": "KilowattHours" + } + ], + "items": [ + { + "timestamp": "2024-11-21T10:00:00-05:00", + "data": [ + 0.0, + 438.201 + ] + }, + */ + final JsonNode items = body.path("items"); + final int refCount = refs.size(); + for ( JsonNode item : items ) { + JsonNode tsNode = item.path("timestamp"); + if ( !tsNode.isTextual() ) { + continue; + } + Instant ts = Instant.parse(tsNode.asText()); + JsonNode dataNode = item.path("data"); + if ( dataNode.size() != refCount ) { + continue; + } + for ( int i = 0; i < refCount; i++ ) { + ValueRef ref = refs.get(i); + final String sourceId = nonEmptyString(resolveSourceId(datumStream, ref, sourceIdMap)); + if ( sourceId == null ) { + continue; + } + JsonNode valNode = dataNode.get(i); + Object propVal = parseJsonDatumPropertyValue(valNode, ref.property.getPropertyType()); + propVal = ref.property.applyValueTransforms(propVal); + if ( propVal != null ) { + dataMap.computeIfAbsent( + new DatumId(datumStream.getKind(), datumStream.getObjectId(), sourceId, ts), + k -> new DatumSamples()).putSampleValue(ref.property.getPropertyType(), + ref.property.getPropertyName(), propVal); + } + + } + } + return null; + } + + private String resolveSourceId(CloudDatumStreamConfiguration datumStream, ValueRef ref, + Map sourceIdMap) { + if ( sourceIdMap != null ) { + return sourceIdMap.get(ref.sourceId); + } + return datumStream.getSourceId() + ref.sourceId; + } + } diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/AlsoEnergyFieldFunction.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/AlsoEnergyFieldFunction.java new file mode 100644 index 000000000..8a36875a6 --- /dev/null +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/AlsoEnergyFieldFunction.java @@ -0,0 +1,60 @@ +/* ================================================================== + * AlsoEnergyFieldFunction.java - 22/11/2024 5:33:25 pm + * + * 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; + +/** + * Enumeration of AlsoEnergy field functions. + * + * @author matt + * @version 1.0 + */ +public enum AlsoEnergyFieldFunction { + /** Average. */ + Avg, + + /** Last posted. */ + Last, + + /** Minimum. */ + Min, + + /** Maximum. */ + Max, + + /** Difference. */ + Diff, + + /** Sum. */ + Sum, + + /** Integral. */ + Integral, + + /** Difference, non zero. */ + DiffNonZero, + + /** Previous. */ + Previous, + + ; +} diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/AlsoEnergyGranularity.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/AlsoEnergyGranularity.java new file mode 100644 index 000000000..fafbb764b --- /dev/null +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/AlsoEnergyGranularity.java @@ -0,0 +1,170 @@ +/* ================================================================== + * AlsoEnergyGranularity.java - 22/11/2024 2:19:11 pm + * + * 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.Duration; +import java.time.Instant; +import java.time.Period; +import java.time.ZoneId; +import java.time.temporal.TemporalAmount; +import com.fasterxml.jackson.annotation.JsonCreator; + +/** + * Enumeration of AlsoEnergy data granularity ("BinSize") values. + * + * @author matt + * @version 1.0 + */ +public enum AlsoEnergyGranularity { + + /** Raw data. */ + Raw("Raw", null), + + /** Five minutes. */ + FiveMinute("5Min", Duration.ofMinutes(5)), + + /** Fifteen minutes. */ + FifteenMinute("15Min", Duration.ofMinutes(15)), + + /** One hour. */ + Hour("1Hour", Duration.ofHours(1)), + + /** One day. */ + Day("Day", Duration.ofDays(1)), + + /** One month. */ + Month("Month", Period.ofMonths(1)), + + /** One year. */ + Year("Year", Period.ofYears(1)), + + ; + + private final String key; + private final String queryKey; + private final TemporalAmount tickAmount; + + private AlsoEnergyGranularity(String key, TemporalAmount tickAmount) { + this.key = key; + this.queryKey = "Bin" + key; + this.tickAmount = tickAmount; + } + + /** + * Get the key. + * + * @return the key, never {@code null} + */ + public String getKey() { + return key; + } + + /** + * Get the query key. + * + * @return the query key, never {@code null} + */ + public String getQueryKey() { + return queryKey; + } + + /** + * Get a clock tick duration appropriate for this granularity. + * + * @return the duration, or {@code null} + */ + public TemporalAmount getTickAmount() { + return tickAmount; + } + + /** + * Get the start of a tick boundary that includes a given instant. + * + * @param ts + * the instant to get the tick boundary start for + * @param zone + * the time zone, for tick amounts greater than a day + * @return the start instant + */ + public Instant tickStart(Instant ts, ZoneId zone) { + if ( tickAmount == null ) { + return ts; + } + if ( tickAmount instanceof Duration d ) { + return CloudIntegrationsUtils.truncateDate(ts, d); + } else if ( tickAmount instanceof Period p ) { + return CloudIntegrationsUtils.truncateDate(ts, p, zone); + } + return ts; + } + + /** + * Get the previous starting tick boundary. + * + * @param tickStart + * the starting tick boundary + * @param zone + * the time zone, for tick amounts greater than a day + * @return the starting tick boundary immediately before {@code tickStart} + */ + public Instant prevTickStart(Instant tickStart, ZoneId zone) { + return CloudIntegrationsUtils.prevTickStart(tickAmount, tickStart, zone); + } + + /** + * Get the next starting tick boundary. + * + * @param tickStart + * the starting tick boundary + * @param zone + * the time zone, for tick amounts greater than a day + * @return the starting tick boundary immediately after {@code tickStart} + */ + public Instant nextTickStart(Instant tickStart, ZoneId zone) { + return CloudIntegrationsUtils.nextTickStart(tickAmount, tickStart, zone); + } + + /** + * 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 #Latest} is returned + * @throws IllegalArgumentException + * if {@code value} is not a valid value + */ + @JsonCreator + public static AlsoEnergyGranularity fromValue(String value) { + if ( value == null || value.isEmpty() ) { + return Raw; + } + for ( AlsoEnergyGranularity e : AlsoEnergyGranularity.values() ) { + if ( value.equalsIgnoreCase(e.key) || value.equalsIgnoreCase(e.name()) ) { + return e; + } + } + throw new IllegalArgumentException("Unknown AlsoEnergyGranularity value [" + value + "]"); + } + +} diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/CloudIntegrationsUtils.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/CloudIntegrationsUtils.java index b0179e7d3..a768aa090 100644 --- a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/CloudIntegrationsUtils.java +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/biz/impl/CloudIntegrationsUtils.java @@ -26,8 +26,16 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.time.Clock; +import java.time.DayOfWeek; import java.time.Duration; import java.time.Instant; +import java.time.Period; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjuster; +import java.time.temporal.TemporalAdjusters; +import java.time.temporal.TemporalAmount; /** * Helper methods for cloud integrations. @@ -60,4 +68,71 @@ public static Instant truncateDate(Instant date, Duration period) { return Clock.tick(Clock.fixed(date, UTC), period).instant(); } + /** + * Truncate a date based on a period. + * + * @param date + * the date to truncate + * @param period + * the period to truncate to; only week, month, and year are + * supported + * @param zone + * the zone + * @return the truncated date + */ + public static Instant truncateDate(Instant date, Period period, ZoneId zone) { + TemporalAdjuster adj = period.getYears() > 0 ? TemporalAdjusters.firstDayOfYear() + : period.getMonths() > 0 ? TemporalAdjusters.firstDayOfMonth() + : TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY); + return date.atZone(zone).with(adj).toInstant().truncatedTo(ChronoUnit.DAYS); + } + + /** + * Get the previous starting tick boundary. + * + * @param tick + * the tick amount; a valid time- or date-based unit is assumed; see + * {@link java.time.temporal.TemporalUnit#isTimeBased()} and + * {@link java.time.temporal.TemporalUnit#isDateBased()} + * @param tickStart + * a starting tick boundary instant + * @param zone + * the time zone, for ticks larger than 1 day + * @return the previous boundary start + */ + public static Instant prevTickStart(TemporalAmount tick, Instant tickStart, ZoneId zone) { + if ( tick == null ) { + return tickStart; + } + if ( tick.getUnits().get(0).isTimeBased() ) { + return tickStart.minus(tick); + } + ZonedDateTime zdt = tickStart.atZone(zone); + return zdt.minus(tick).toInstant(); + } + + /** + * Get the next starting tick boundary. + * + * @param tick + * the tick amount; a valid time- or date-based unit is assumed; see + * {@link java.time.temporal.TemporalUnit#isTimeBased()} and + * {@link java.time.temporal.TemporalUnit#isDateBased()} + * @param tickStart + * a starting tick boundary instant + * @param zone + * the time zone, for ticks larger than 1 day + * @return the next boundary start + */ + public static Instant nextTickStart(TemporalAmount tick, Instant tickStart, ZoneId zone) { + if ( tick == null ) { + return tickStart; + } + if ( tick.getUnits().get(0).isTimeBased() ) { + return tickStart.plus(tick); + } + ZonedDateTime zdt = tickStart.atZone(zone); + return zdt.plus(tick).toInstant(); + } + } diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/AlsoEnergyConfig.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/AlsoEnergyConfig.java index 9f6a66ee1..70b3cbb57 100644 --- a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/AlsoEnergyConfig.java +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/config/AlsoEnergyConfig.java @@ -34,7 +34,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.context.support.ResourceBundleMessageSource; -import org.springframework.core.task.AsyncTaskExecutor; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.jdbc.core.JdbcOperations; @@ -120,9 +119,6 @@ public class AlsoEnergyConfig { @Autowired private CloudIntegrationsExpressionService expressionService; - @Autowired - private AsyncTaskExecutor taskExecutor; - @Autowired(required = false) private UserServiceAuditor userServiceAuditor; @@ -176,7 +172,7 @@ public OAuth2AuthorizedClientManager alsoEnergyOauthAuthorizedClientManager( @Qualifier(ALSO_ENERGY) public CloudDatumStreamService alsoEnergyCloudDatumStreamService( @Qualifier(ALSO_ENERGY) OAuth2AuthorizedClientManager oauthClientManager) { - var service = new AlsoEnergyCloudDatumStreamService(taskExecutor, userEventAppender, encryptor, + var service = new AlsoEnergyCloudDatumStreamService(userEventAppender, encryptor, expressionService, integrationConfigurationDao, datumStreamConfigurationDao, datumStreamMappingConfigurationDao, datumStreamPropertyConfigurationDao, restOps, oauthClientManager); diff --git a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/http/OAuth2RestOperationsHelper.java b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/http/OAuth2RestOperationsHelper.java index 8a5a945a9..c52e7b5c2 100644 --- a/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/http/OAuth2RestOperationsHelper.java +++ b/solarnet/cloud-integrations/src/main/java/net/solarnetwork/central/c2c/http/OAuth2RestOperationsHelper.java @@ -29,6 +29,7 @@ import java.util.function.Function; import org.slf4j.Logger; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.encrypt.TextEncryptor; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; @@ -79,10 +80,10 @@ public OAuth2RestOperationsHelper(Logger log, UserEventAppenderBiz userEventAppe } @Override - public , K extends UserRelatedCompositeKey, T> T httpGet( - String description, C configuration, Class responseType, Function setup, - Function, T> handler) { - return super.httpGet(description, configuration, responseType, (headers) -> { + public , K extends UserRelatedCompositeKey, T> T http( + String description, HttpMethod method, B body, C configuration, Class responseType, + Function setup, Function, T> handler) { + return super.http(description, method, body, configuration, responseType, (headers) -> { if ( configuration instanceof CloudIntegrationConfiguration integration ) { final var decrypted = integration.copyWithId(integration.getId()); decrypted.unmaskSensitiveInformation(sensitiveKeyProvider, encryptor); diff --git a/solarnet/cloud-integrations/src/main/resources/net/solarnetwork/central/c2c/biz/impl/AlsoEnergyCloudDatumStreamService.properties b/solarnet/cloud-integrations/src/main/resources/net/solarnetwork/central/c2c/biz/impl/AlsoEnergyCloudDatumStreamService.properties index 54c43ad37..72b79b582 100644 --- a/solarnet/cloud-integrations/src/main/resources/net/solarnetwork/central/c2c/biz/impl/AlsoEnergyCloudDatumStreamService.properties +++ b/solarnet/cloud-integrations/src/main/resources/net/solarnetwork/central/c2c/biz/impl/AlsoEnergyCloudDatumStreamService.properties @@ -2,3 +2,18 @@ title = AlsoEnergy Datum Stream dataValueFilter.siteId.key = Site ID dataValueFilter.siteId.desc = The site identifier to restrict the results to. + +granularity.key = Granularity +granularity.desc = The resolution of the data to request. + +granularity.raw.key = Raw +granularity.5min.key = 5 minute +granularity.15min.key = 15 minute +granularity.hourly.key = Hourly +granularity.daily.key = Daily +granularity.monthly.key = Monthly +granularity.yearly.key = Yearly + +tz.key = Time Zone +tz.desc = The time zone of the source data, for example Pacific/Auckland. Only \ + required if Granularity is Monthly or higher. diff --git a/solarnet/cloud-integrations/src/test/java/net/solarnetwork/central/c2c/biz/impl/test/CloudIntegrationsUtilsTests.java b/solarnet/cloud-integrations/src/test/java/net/solarnetwork/central/c2c/biz/impl/test/CloudIntegrationsUtilsTests.java new file mode 100644 index 000000000..262cfe5d9 --- /dev/null +++ b/solarnet/cloud-integrations/src/test/java/net/solarnetwork/central/c2c/biz/impl/test/CloudIntegrationsUtilsTests.java @@ -0,0 +1,225 @@ +/* ================================================================== + * CloudIntegrationsUtilsTests.java - 22/11/2024 2:51:29 pm + * + * 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.ofEpochSecond; +import static java.time.ZoneOffset.UTC; +import static java.time.temporal.ChronoUnit.HOURS; +import static java.time.temporal.ChronoUnit.MINUTES; +import static java.time.temporal.ChronoUnit.SECONDS; +import static org.assertj.core.api.BDDAssertions.then; +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.Instant; +import java.time.Period; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; +import org.junit.jupiter.api.Test; +import net.solarnetwork.central.c2c.biz.impl.CloudIntegrationsUtils; + +/** + * Test cases for the {@link CloudIntegrationsUtils} class. + * + * @author matt + * @version 1.0 + */ +public class CloudIntegrationsUtilsTests { + + @Test + public void truncateDate_5min() { + // GIVEN + Instant ts = Instant.now().truncatedTo(SECONDS); + + // WHEN + Instant result = CloudIntegrationsUtils.truncateDate(ts, Duration.ofMinutes(5)); + + // THEN + // @formatter:off + then(result) + .as("Date truncated to 5min boundary start") + .isEqualTo(ofEpochSecond(ts.getEpochSecond() - (ts.getEpochSecond() % (5 * 60))).truncatedTo(MINUTES)) + ; + // @formatter:on + } + + @Test + public void truncateDate_hour() { + // GIVEN + Instant ts = Instant.now(); + + // WHEN + Instant result = CloudIntegrationsUtils.truncateDate(ts, Duration.ofHours(1)); + + // THEN + // @formatter:off + then(result) + .as("Date truncated to hour boundary start") + .isEqualTo(ts.truncatedTo(HOURS)) + ; + // @formatter:on + } + + @Test + public void truncateDate_week() { + // GIVEN + Instant ts = Instant.now(); + + // WHEN + Instant result = CloudIntegrationsUtils.truncateDate(ts, Period.ofWeeks(1), ZoneOffset.UTC); + + // THEN + // @formatter:off + then(result) + .as("Date truncated to Monday week boundary start") + .isEqualTo(ts.atZone(ZoneOffset.UTC).with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + .toInstant().truncatedTo(ChronoUnit.DAYS)) + ; + // @formatter:on + } + + @Test + public void truncateDate_month() { + // GIVEN + Instant ts = Instant.now(); + + // WHEN + Instant result = CloudIntegrationsUtils.truncateDate(ts, Period.ofMonths(1), ZoneOffset.UTC); + + // THEN + // @formatter:off + then(result) + .as("Date truncated to month boundary start") + .isEqualTo(ts.atZone(ZoneOffset.UTC).with(TemporalAdjusters.firstDayOfMonth()) + .toInstant().truncatedTo(ChronoUnit.DAYS)) + ; + // @formatter:on + } + + @Test + public void truncateDate_year() { + // GIVEN + Instant ts = Instant.now(); + + // WHEN + Instant result = CloudIntegrationsUtils.truncateDate(ts, Period.ofYears(1), ZoneOffset.UTC); + + // THEN + // @formatter:off + then(result) + .as("Date truncated to year boundary start") + .isEqualTo(ts.atZone(ZoneOffset.UTC).with(TemporalAdjusters.firstDayOfYear()) + .toInstant().truncatedTo(ChronoUnit.DAYS)) + ; + // @formatter:on + } + + @Test + public void nextTickStart_5min() { + // GIVEN + Instant ts = CloudIntegrationsUtils.truncateDate(Instant.now(), Duration.ofMinutes(5)); + + // WHEN + Instant result = CloudIntegrationsUtils.nextTickStart(Duration.ofMinutes(5), ts, UTC); + + // THEN + // @formatter:off + then(result) + .as("Date shifted to next boundary start") + .isEqualTo(ts.plus(Duration.ofMinutes(5))) + ; + // @formatter:on + } + + @Test + public void nextTickStart_hour() { + // GIVEN + Instant ts = CloudIntegrationsUtils.truncateDate(Instant.now(), Duration.ofHours(1)); + + // WHEN + Instant result = CloudIntegrationsUtils.nextTickStart(Duration.ofHours(1), ts, UTC); + + // THEN + // @formatter:off + then(result) + .as("Date shifted to next boundary start") + .isEqualTo(ts.plus(Duration.ofHours(1))) + ; + // @formatter:on + } + + @Test + public void nextTickStart_week() { + // GIVEN + Period p = Period.ofWeeks(1); + Instant ts = CloudIntegrationsUtils.truncateDate(Instant.now(), p, UTC); + + // WHEN + Instant result = CloudIntegrationsUtils.nextTickStart(p, ts, UTC); + + // THEN + // @formatter:off + then(result) + .as("Date shifted to next boundary start") + .isEqualTo(ts.atZone(UTC).plusWeeks(1).toInstant()) + ; + // @formatter:on + } + + @Test + public void nextTickStart_month() { + // GIVEN + Period p = Period.ofMonths(1); + Instant ts = CloudIntegrationsUtils.truncateDate(Instant.now(), p, UTC); + + // WHEN + Instant result = CloudIntegrationsUtils.nextTickStart(p, ts, UTC); + + // THEN + // @formatter:off + then(result) + .as("Date shifted to next boundary start") + .isEqualTo(ts.atZone(UTC).plusMonths(1).toInstant()) + ; + // @formatter:on + } + + @Test + public void nextTickStart_year() { + // GIVEN + Period p = Period.ofYears(1); + Instant ts = CloudIntegrationsUtils.truncateDate(Instant.now(), p, UTC); + + // WHEN + Instant result = CloudIntegrationsUtils.nextTickStart(p, ts, UTC); + + // THEN + // @formatter:off + then(result) + .as("Date shifted to next boundary start") + .isEqualTo(ts.atZone(UTC).plusYears(1).toInstant()) + ; + // @formatter:on + } + +}