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 4df4890e9..945c37df1 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 @@ -368,9 +368,13 @@ private List sites(CloudIntegrationConfiguration integration) { private List siteHardware(CloudIntegrationConfiguration integration, Map filters) { return restOpsHelper.httpGet("List sites", integration, JsonNode.class, + // @formatter:off (req) -> fromUri(resolveBaseUrl(integration, AlsoEnergyCloudIntegrationService.BASE_URI)) - .path(SITE_HARDWARE_URL_TEMPLATE).queryParam("includeArchivedFields", true) + .path(SITE_HARDWARE_URL_TEMPLATE) + .queryParam("includeArchivedFields", true) + .queryParam("includeDeviceConfig", true) .buildAndExpand(filters).toUri(), + // @formatter:on res -> parseSiteHardware(res.getBody(), filters)); } @@ -457,22 +461,21 @@ private static List parseSiteHardware(JsonNode json, Map(4); - for ( JsonNode meterNode : json.path("hardware") ) { - final JsonNode fieldsNode = meterNode.path("fieldsArchived"); + for ( JsonNode deviceNode : json.path("hardware") ) { + final JsonNode fieldsNode = deviceNode.path("fieldsArchived"); if ( !(fieldsNode.isArray() && fieldsNode.size() > 0) ) { continue; } - final String id = meterNode.path("id").asText().trim(); + final String id = deviceNode.path("id").asText().trim(); if ( id.isEmpty() ) { continue; } - final String name = meterNode.path("name").asText().trim(); + final String name = deviceNode.path("name").asText().trim(); final var meta = new LinkedHashMap(4); - populateNonEmptyValue(meterNode, "functionCode", "functionCode", meta); - for ( JsonNode configNode : meterNode.path("config") ) { - populateNonEmptyValue(configNode, "serialNumber", DEVICE_SERIAL_NUMBER_METADATA, meta); - populateNonEmptyValue(configNode, "deviceType", "deviceType", meta); - } + populateNonEmptyValue(deviceNode, "functionCode", "functionCode", meta); + JsonNode configNode = deviceNode.path("config"); + populateNonEmptyValue(configNode, "serialNumber", DEVICE_SERIAL_NUMBER_METADATA, meta); + populateNonEmptyValue(configNode, "deviceType", "deviceType", meta); List fields = new ArrayList<>(fieldsNode.size()); for ( JsonNode fieldNode : fieldsNode ) { diff --git a/solarnet/cloud-integrations/src/test/java/net/solarnetwork/central/c2c/biz/impl/test/AlsoEnergyCloudDatumStreamServiceTests.java b/solarnet/cloud-integrations/src/test/java/net/solarnetwork/central/c2c/biz/impl/test/AlsoEnergyCloudDatumStreamServiceTests.java index 5663f507e..75033c59e 100644 --- a/solarnet/cloud-integrations/src/test/java/net/solarnetwork/central/c2c/biz/impl/test/AlsoEnergyCloudDatumStreamServiceTests.java +++ b/solarnet/cloud-integrations/src/test/java/net/solarnetwork/central/c2c/biz/impl/test/AlsoEnergyCloudDatumStreamServiceTests.java @@ -26,6 +26,7 @@ import static java.time.ZoneOffset.UTC; import static java.time.temporal.ChronoUnit.MINUTES; import static net.solarnetwork.central.c2c.biz.impl.AlsoEnergyCloudDatumStreamService.BIN_DATA_URL; +import static net.solarnetwork.central.c2c.biz.impl.AlsoEnergyCloudDatumStreamService.SITE_HARDWARE_URL_TEMPLATE; import static net.solarnetwork.central.c2c.biz.impl.AlsoEnergyCloudIntegrationService.BASE_URI; import static net.solarnetwork.central.c2c.biz.impl.AlsoEnergyFieldFunction.Avg; import static net.solarnetwork.central.c2c.biz.impl.AlsoEnergyFieldFunction.Last; @@ -36,6 +37,7 @@ import static net.solarnetwork.util.DateUtils.ISO_DATE_OPT_TIME_OPT_MILLIS_UTC; import static org.assertj.core.api.BDDAssertions.and; import static org.assertj.core.api.BDDAssertions.from; +import static org.assertj.core.api.InstanceOfAssertFactories.list; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @@ -75,12 +77,14 @@ import net.solarnetwork.central.c2c.biz.CloudIntegrationsExpressionService; import net.solarnetwork.central.c2c.biz.impl.AlsoEnergyCloudDatumStreamService; import net.solarnetwork.central.c2c.biz.impl.AlsoEnergyCloudIntegrationService; +import net.solarnetwork.central.c2c.biz.impl.AlsoEnergyFieldFunction; import net.solarnetwork.central.c2c.biz.impl.BaseCloudDatumStreamService; import net.solarnetwork.central.c2c.biz.impl.BasicCloudIntegrationsExpressionService; 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.CloudDatumStreamMappingConfiguration; import net.solarnetwork.central.c2c.domain.CloudDatumStreamPropertyConfiguration; @@ -163,6 +167,125 @@ private static String componentValueRef(Long siteId, Long hardwareId, String fie return "/%d/%d/%s/%s".formatted(siteId, hardwareId, fieldName, fn); } + @Test + public void dataValues_site() { + // GIVEN + final String tokenUri = "https://example.com/oauth/token"; + final String clientId = randomString(); + final String username = randomString(); + final String password = randomString(); + final Long siteId = randomLong(); + + // configure integration + final CloudIntegrationConfiguration integration = new CloudIntegrationConfiguration(TEST_USER_ID, + randomLong(), now()); + // @formatter:off + integration.setServiceProps(Map.of( + AlsoEnergyCloudIntegrationService.OAUTH_CLIENT_ID_SETTING, clientId, + AlsoEnergyCloudIntegrationService.USERNAME_SETTING, username, + AlsoEnergyCloudIntegrationService.PASSWORD_SETTING, password + )); + // @formatter:on + given(integrationDao.get(integration.getId())).willReturn(integration); + + // @formatter:off + @SuppressWarnings("deprecation") + final ClientRegistration oauthClientReg = ClientRegistration.withRegistrationId("test") + .authorizationGrantType(AuthorizationGrantType.PASSWORD) + .clientId(randomString()) + .clientSecret(randomString()) + .tokenUri(tokenUri) + .build(); + // @formatter:on + + final OAuth2AccessToken oauthAccessToken = new OAuth2AccessToken(TokenType.BEARER, + randomString(), now(), now().plusSeconds(60)); + + final OAuth2AuthorizedClient oauthAuthClient = new OAuth2AuthorizedClient(oauthClientReg, "Test", + oauthAccessToken); + + given(oauthClientManager.authorize(any())).willReturn(oauthAuthClient); + + // request data + final JsonNode resJson = getObjectFromJSON( + utf8StringResource("alsoenergy-hardware-01.json", getClass()), ObjectNode.class); + final var res = new ResponseEntity(resJson, HttpStatus.OK); + given(restOps.exchange(any(), eq(HttpMethod.GET), any(), eq(JsonNode.class))).willReturn(res); + + // WHEN + Iterable results = service.dataValues(integration.getId(), + Map.of("siteId", siteId)); + + // THEN + then(restOps).should().exchange(uriCaptor.capture(), eq(HttpMethod.GET), any(), + eq(JsonNode.class)); + + and.then(uriCaptor.getValue()).as("Request URI").isEqualTo( + BASE_URI.resolve(SITE_HARDWARE_URL_TEMPLATE.replace("{siteId}", siteId.toString()) + + "?includeArchivedFields=true&includeDeviceConfig=true")); + + // @formatter:off + and.then(results) + .as("Results provided") + .hasSize(2) + .satisfies(devices -> { + and.then(devices) + .element(0) + .as("Identifiers from response") + .returns(List.of(siteId.toString(), "12345"), from(CloudDataValue::getIdentifiers)) + .as("Device name from response") + .returns("Elkor Production Meter", from(CloudDataValue::getName)) + .as("No reference for device object") + .returns(null, from(CloudDataValue::getReference)) + .as("Metadata from response") + .returns(Map.of("functionCode", "PM" + , "serial", "20647" + , "deviceType", "ProductionPowerMeter" + ), from(CloudDataValue::getMetadata)) + .extracting(CloudDataValue::getChildren, list(CloudDataValue.class)) + .as("Has 2 field children") + .hasSize(2) + .satisfies(fields -> { + and.then(fields) + .element(0) + .as("Identifiers from response") + .returns(List.of(siteId.toString(), "12345", "KW"), from(CloudDataValue::getIdentifiers)) + .as("Field name from response") + .returns("KW", from(CloudDataValue::getName)) + .as("No reference for field object") + .returns(null, from(CloudDataValue::getReference)) + .as("No metadata for field object") + .returns(null, from(CloudDataValue::getMetadata)) + .extracting(CloudDataValue::getChildren, list(CloudDataValue.class)) + .as("Has field function children") + .hasSize(AlsoEnergyFieldFunction.values().length) + .satisfies(functions -> { + for ( int i =0, len = AlsoEnergyFieldFunction.values().length; i < len; i++ ) { + var fn = AlsoEnergyFieldFunction.values()[i]; + and.then(functions).element(i) + .as("Identifiers from response") + .returns(List.of(siteId.toString(), "12345", "KW", fn.name()), + from(CloudDataValue::getIdentifiers)) + .as("Function name is field + function") + .returns("KW %s".formatted(fn.name()), from(CloudDataValue::getName)) + .as("Feference for function object") + .returns("/%s/12345/KW/%s".formatted(siteId, fn.name()), + from(CloudDataValue::getReference)) + .as("No metadata for function") + .returns(null, CloudDataValue::getMetadata) + .as("No children for function") + .returns(null, CloudDataValue::getChildren) + ; + } + }) + ; + }) + ; + }) + ; + // @formatter:on + } + @Test public void requestLatest_singleComponent() { // GIVEN diff --git a/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/alsoenergy-hardware-01.json b/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/alsoenergy-hardware-01.json new file mode 100644 index 000000000..ccd6ff933 --- /dev/null +++ b/solarnet/cloud-integrations/src/test/resources/net/solarnetwork/central/c2c/biz/impl/test/alsoenergy-hardware-01.json @@ -0,0 +1,81 @@ +{ + "hardware": [ + { + "id": 12345, + "stringId": "C11111_S55555_PM1", + "functionCode": "PM", + "flags": [ + "IsEnabled" + ], + "fieldsArchived": [ + "KW", + "Frequency" + ], + "name": "Elkor Production Meter", + "lastUpdate": "2024-11-21T17:20:22.9045092-05:00", + "lastUpload": "2024-11-21T17:17:00-05:00", + "iconUrl": "https://alsoenergy.com/Pub/Images/device/7855.png", + "config": { + "deviceType": "ProductionPowerMeter", + "hardwareStrId": "C11111_S55555_PM1", + "hardwareId": 12345, + "address": 3557664960, + "portNumber": 0, + "baudRate": 0, + "comType": "Unknown", + "serialNumber": "20647", + "name": "Elkor Production Meter", + "outputHardwareId": 0, + "weatherStationId": 0, + "meterConfig": { + "scaleFactor": 0.0, + "isReversed": false, + "grossEnergy": "None", + "maxPowerKw": 58444.96, + "maxVoltage": 480.0, + "maxAmperage": 12200.0, + "acPhase": "Wye" + } + } + }, + { + "id": 23456, + "stringId": "C22222_S66666_PV0", + "functionCode": "PV", + "flags": [ + "IsEnabled" + ], + "fieldsArchived": [ + "KwAC", + "KwhAC" + ], + "name": "SolarEdge SE100K Inverter", + "lastUpdate": "2024-11-21T17:19:03.951886-05:00", + "lastUpload": "2024-11-21T17:17:00-05:00", + "iconUrl": "https://alsoenergy.com/Pub/Images/device/pvpowered.png", + "config": { + "deviceType": "Inverter", + "hardwareStrId": "C22222_S66666_PV0", + "hardwareId": 23456, + "address": 1, + "portNumber": 1, + "baudRate": 9600, + "comType": "Rs485_2Wire", + "name": "SolarEdge SE100K Inverter", + "outputHardwareId": 315520, + "weatherStationId": 315586, + "inverterConfig": [ + { + "ratedAcPower": 100.0, + "stringCount": 9, + "modulesPerString": 42, + "wattsPerModule": 395.0, + "azimuth": 45.0, + "tilt": 10.0, + "trackingMode": "Fixed" + } + ] + } + } + ] +} \ No newline at end of file