[amberelectric] Add support for forecasts, add site data to Thing properties (#18716)

* Initial support for TimeSeries - only for current pricing, limited to next 10 periods

Signed-off-by: Paul Smedley <paul@smedley.id.au>
pull/18623/head
Paul Smedley 2025-05-30 16:51:18 +09:30 committed by GitHub
parent 64ade8912c
commit b2ee5f2b98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 150 additions and 117 deletions

View File

@ -1,6 +1,6 @@
# Amber Electric Binding
A binding that supports the Australian energy retailer Amber's API (<https://www.amber.com.au/>) and provides data on the current pricing for buying and selling power, as well as the current level of renewables in the NEM.
A binding that supports the Australian energy retailer Amber's API (<https://www.amber.com.au/>) and provides data on the current pricing for buying and selling power, as well as the current level of renewables in the Australian National Electricity Market (NEM).
## Supported Things
@ -12,11 +12,12 @@ The binding does not support auto discovery.
## Thing Configuration
As a minimum, the IP address is needed:
- `apiKey` - The API key from the 'Developer' section of <https://apps.amber.com.au>
- 'nmi' optional - the NMI for your property. Required if you have multiple properties with Amber
- 'refresh' the refresh rate for querying the API.
As a minimum, the apiKey is needed:
| Thing Parameter | Default Value | Required | Advanced | Description |
|-----------------|---------------|----------|----------|-------------------------------------------------------------------------------------------------------|
| apiKey | N/A | Yes | No | The API key from the 'Developer' section of <https://apps.amber.com.au> |
| nmi | N/A | No | No | The NMI (NMI (National Metering Identifier) for your property. Required if you have multiple accounts |
| refresh | 60 | No | Yes | The refresh rate (in seconds) for querying the API. |
## Channels
@ -29,7 +30,7 @@ As a minimum, the IP address is needed:
| controlled-load-status | String | Current price status of controlled load import |
| feed-in-status | String | Current price status of Feed-In |
| nem-time | String | NEM time of last pricing update |
| renewables | Number:Dimensionless | Current level of renewables in the grid |
| renewables | Number:Dimensionless | Current level of renewables in the NEM |
| spike | Switch | Report if the grid has a current price spike |
## Full Example

View File

@ -24,4 +24,5 @@ public class AmberElectricConfiguration {
public String apiKey = "";
public String nmi = "";
public long refresh = 60;
public long forecasts = 288;
}

View File

@ -12,7 +12,11 @@
*/
package org.openhab.binding.amberelectric.internal;
import static org.openhab.core.types.TimeSeries.Policy.REPLACE;
import java.io.IOException;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
@ -35,9 +39,15 @@ import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.TimeSeries;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonParser;
/**
* The {@link AmberElectricHandler} is responsible for handling commands, which are
* sent to one of the channels.
@ -58,6 +68,8 @@ public class AmberElectricHandler extends BaseThingHandler {
private @NonNullByDefault({}) AmberElectricWebTargets webTargets;
private @Nullable ScheduledFuture<?> pollFuture;
private Gson gson = new Gson();
public AmberElectricHandler(Thing thing) {
super(thing);
}
@ -117,49 +129,100 @@ public class AmberElectricHandler extends BaseThingHandler {
}
}
private State convertPriceToState(double price) {
final String electricityUnit = " AUD/kWh";
Unit<?> unit = CurrencyUnits.getInstance().getUnit("AUD");
return (unit == null) ? new DecimalType(price / 100) : new QuantityType<>(price / 100 + " " + electricityUnit);
}
private void pollStatus() throws IOException {
try {
if (siteID.isEmpty()) {
Sites sites = webTargets.getSites(apiKey, nmi);
// add error handling
siteID = sites.siteid;
Configuration configuration = editConfiguration();
configuration.put("nmi", sites.nmi);
updateConfiguration(configuration);
logger.debug("Detected amber siteid is {}, for nmi {}", sites.siteid, sites.nmi);
if ("".equals(siteID)) {
String responseSites = webTargets.getSites(apiKey);
logger.trace("responseSites = {}", responseSites);
JsonArray jsonArraySites = JsonParser.parseString(responseSites).getAsJsonArray();
Sites sites = new Sites();
for (int i = 0; i < jsonArraySites.size(); i++) {
sites = gson.fromJson(jsonArraySites.get(i), Sites.class);
if (sites == null) {
return;
}
if (nmi.equals(sites.nmi)) {
siteID = sites.id;
}
}
if ("".equals(nmi) || "".equals(siteID)) { // nmi not specified, or not found so we take the first
// siteid found
sites = gson.fromJson(jsonArraySites.get(0), Sites.class);
if (sites == null) {
return;
}
siteID = sites.id;
nmi = sites.nmi;
Configuration configuration = editConfiguration();
configuration.put("nmi", nmi);
updateConfiguration(configuration);
}
Map<String, String> properties = editProperties();
properties.put("network", sites.network);
properties.put("status", sites.status);
properties.put("activeFrom", sites.activeFrom);
if (sites.channels != null && sites.channels.length > 0) {
properties.put("tariff", sites.channels[0].tariff);
}
properties.put("intervalLength", String.valueOf(sites.intervalLength));
updateProperties(properties);
logger.debug("Detected amber siteid is {}, for nmi {}", sites.id, sites.nmi);
}
CurrentPrices currentPrices = webTargets.getCurrentPrices(siteID, apiKey);
final String electricityUnit = " AUD/kWh";
updateStatus(ThingStatus.ONLINE);
Unit<?> unit = CurrencyUnits.getInstance().getUnit("AUD");
if (unit == null) {
logger.trace("Currency AUD is unknown, falling back to DecimalType");
updateState(AmberElectricBindingConstants.CHANNEL_ELECTRICITY_PRICE,
new DecimalType(currentPrices.elecPerKwh / 100));
updateState(AmberElectricBindingConstants.CHANNEL_CONTROLLED_LOAD_PRICE,
new DecimalType(currentPrices.clPerKwh / 100));
updateState(AmberElectricBindingConstants.CHANNEL_FEED_IN_PRICE,
new DecimalType(currentPrices.feedInPerKwh / 100));
} else {
updateState(AmberElectricBindingConstants.CHANNEL_ELECTRICITY_PRICE,
new QuantityType<>(currentPrices.elecPerKwh / 100 + " " + electricityUnit));
updateState(AmberElectricBindingConstants.CHANNEL_CONTROLLED_LOAD_PRICE,
new QuantityType<>(currentPrices.clPerKwh / 100 + " " + electricityUnit));
updateState(AmberElectricBindingConstants.CHANNEL_FEED_IN_PRICE,
new QuantityType<>(currentPrices.feedInPerKwh / 100 + " " + electricityUnit));
String response = webTargets.getCurrentPrices(siteID, apiKey);
JsonArray jsonArray = JsonParser.parseString(response).getAsJsonArray();
CurrentPrices currentPrices;
TimeSeries elecTimeSeries = new TimeSeries(REPLACE);
TimeSeries feedInTimeSeries = new TimeSeries(REPLACE);
for (int i = 0; i < jsonArray.size(); i++) {
currentPrices = gson.fromJson(jsonArray.get(i), CurrentPrices.class);
if (currentPrices != null) {
Instant instantStart = Instant.parse(currentPrices.startTime);
if ("CurrentInterval".equals(currentPrices.type) && "general".equals(currentPrices.channelType)) {
updateState(AmberElectricBindingConstants.CHANNEL_ELECTRICITY_STATUS,
new StringType(currentPrices.descriptor));
updateState(AmberElectricBindingConstants.CHANNEL_NEM_TIME,
new StringType(currentPrices.nemTime));
updateState(AmberElectricBindingConstants.CHANNEL_RENEWABLES,
new DecimalType(currentPrices.renewables));
updateState(AmberElectricBindingConstants.CHANNEL_SPIKE,
OnOffType.from(!"none".equals(currentPrices.spikeStatus)));
updateState(AmberElectricBindingConstants.CHANNEL_ELECTRICITY_PRICE,
convertPriceToState(currentPrices.perKwh));
elecTimeSeries.add(instantStart, convertPriceToState(currentPrices.perKwh));
}
if ("ForecastInterval".equals(currentPrices.type) && "general".equals(currentPrices.channelType)) {
elecTimeSeries.add(instantStart, convertPriceToState(currentPrices.perKwh));
}
if ("CurrentInterval".equals(currentPrices.type) && "feedIn".equals(currentPrices.channelType)) {
updateState(AmberElectricBindingConstants.CHANNEL_FEED_IN_STATUS,
new StringType(currentPrices.descriptor));
updateState(AmberElectricBindingConstants.CHANNEL_FEED_IN_PRICE,
convertPriceToState(-1 * currentPrices.perKwh));
feedInTimeSeries.add(instantStart, convertPriceToState(-1 * currentPrices.perKwh));
}
if ("ForecastInterval".equals(currentPrices.type) && "feedIn".equals(currentPrices.channelType)) {
feedInTimeSeries.add(instantStart, convertPriceToState(-1 * currentPrices.perKwh));
}
if ("CurrentInterval".equals(currentPrices.type)
&& "controlledLoad".equals(currentPrices.channelType)) {
updateState(AmberElectricBindingConstants.CHANNEL_CONTROLLED_LOAD_STATUS,
new StringType(currentPrices.descriptor));
updateState(AmberElectricBindingConstants.CHANNEL_CONTROLLED_LOAD_STATUS,
convertPriceToState(currentPrices.perKwh));
}
}
}
updateState(AmberElectricBindingConstants.CHANNEL_CONTROLLED_LOAD_STATUS,
new StringType(currentPrices.clStatus));
updateState(AmberElectricBindingConstants.CHANNEL_ELECTRICITY_STATUS,
new StringType(currentPrices.elecStatus));
updateState(AmberElectricBindingConstants.CHANNEL_FEED_IN_STATUS,
new StringType(currentPrices.feedInStatus));
updateState(AmberElectricBindingConstants.CHANNEL_NEM_TIME, new StringType(currentPrices.nemTime));
updateState(AmberElectricBindingConstants.CHANNEL_RENEWABLES, new DecimalType(currentPrices.renewables));
updateState(AmberElectricBindingConstants.CHANNEL_SPIKE,
OnOffType.from(!"none".equals(currentPrices.spikeStatus)));
sendTimeSeries(AmberElectricBindingConstants.CHANNEL_ELECTRICITY_PRICE, elecTimeSeries);
sendTimeSeries(AmberElectricBindingConstants.CHANNEL_FEED_IN_PRICE, feedInTimeSeries);
} catch (AmberElectricCommunicationException e) {
logger.debug("Unexpected error connecting to Amber Electric API", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());

View File

@ -18,8 +18,6 @@ import java.util.Properties;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.amberelectric.internal.api.CurrentPrices;
import org.openhab.binding.amberelectric.internal.api.Sites;
import org.openhab.core.io.net.http.HttpUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -39,18 +37,18 @@ public class AmberElectricWebTargets {
public AmberElectricWebTargets() {
}
public Sites getSites(String apiKey, String nmi) throws AmberElectricCommunicationException {
public String getSites(String apiKey) throws AmberElectricCommunicationException {
String getSitesUri = BASE_URI + "sites";
String response = invoke("GET", getSitesUri, apiKey);
logger.trace("Received response: \"{}\"", response);
return Sites.parse(response, nmi);
return response;
}
public CurrentPrices getCurrentPrices(String siteid, String apiKey) throws AmberElectricCommunicationException {
String getCurrentPricesUri = BASE_URI + "sites/" + siteid + "/prices/current";
public String getCurrentPrices(String siteid, String apiKey) throws AmberElectricCommunicationException {
String getCurrentPricesUri = BASE_URI + "sites/" + siteid + "/prices/current?next=288";
String response = invoke("GET", getCurrentPricesUri, apiKey);
logger.trace("Received response: \"{}\"", response);
return CurrentPrices.parse(response);
return response;
}
protected Properties getHttpHeaders(String accessToken) {

View File

@ -14,10 +14,6 @@ package org.openhab.binding.amberelectric.internal.api;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
/**
* Container class for Current Pricing, related to amberelectric
*
@ -26,43 +22,27 @@ import com.google.gson.JsonParser;
*/
@NonNullByDefault
public class CurrentPrices {
public double elecPerKwh;
public double clPerKwh;
public double feedInPerKwh;
public String elecStatus = "";
public String clStatus = "";
public String feedInStatus = "";
public double renewables;
public String spikeStatus = "";
public String type = "";
public String date = "";
public int duration;
public String startTime = "";
public String endTime = "";
public String nemTime = "";
public double perKwh;
public double renewables;
public double spotPerKwh;
public String channelType = "";
public String spikeStatus = "";
public String descriptor = "";
public boolean estimate;
public @NonNullByDefault({}) AdvancedPrice advancedPrice;
public class AdvancedPrice {
public double low;
public double predicted;
public double high;
}
private CurrentPrices() {
}
public static CurrentPrices parse(String response) {
/* parse json string */
JsonArray jsonArray = JsonParser.parseString(response).getAsJsonArray();
JsonObject jsonObject = jsonArray.get(0).getAsJsonObject();
CurrentPrices currentprices = new CurrentPrices();
currentprices.nemTime = jsonObject.get("nemTime").getAsString();
currentprices.renewables = jsonObject.get("renewables").getAsDouble();
currentprices.spikeStatus = jsonObject.get("spikeStatus").getAsString();
for (int i = 0; i < jsonArray.size(); i++) {
jsonObject = jsonArray.get(i).getAsJsonObject();
if ("general".equals(jsonObject.get("channelType").getAsString())) {
currentprices.elecPerKwh = jsonObject.get("perKwh").getAsDouble();
currentprices.elecStatus = jsonObject.get("descriptor").getAsString();
}
if ("feedIn".equals(jsonObject.get("channelType").getAsString())) {
// Multiple value from API by -1 to make the value match the app
currentprices.feedInPerKwh = -1 * jsonObject.get("perKwh").getAsDouble();
currentprices.feedInStatus = jsonObject.get("descriptor").getAsString();
}
if ("controlledLoad".equals(jsonObject.get("channelType").getAsString())) {
currentprices.clPerKwh = jsonObject.get("perKwh").getAsDouble();
currentprices.clStatus = jsonObject.get("descriptor").getAsString();
}
}
return currentprices;
}
}

View File

@ -14,42 +14,29 @@ package org.openhab.binding.amberelectric.internal.api;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
/**
* Class for holding the set of parameters used to read the controller variables.
* Container class for Sites, related to amberelectric
*
* @author Paul Smedley - Initial Contribution
*
*/
@NonNullByDefault
public class Sites {
public String siteid = "";
public String id = "";
public String nmi = "";
public @NonNullByDefault({}) Channels[] channels;
public String network = "";
public String status = "";
public String activeFrom = "";
public int intervalLength;
private Sites() {
public class Channels {
public String identifier = "";
public String type = "";
public String tariff = "";
}
public static Sites parse(String response, String nem) {
/* parse json string */
JsonArray jsonArray = JsonParser.parseString(response).getAsJsonArray();
Sites sites = new Sites();
for (int i = 0; i < jsonArray.size(); i++) {
JsonObject jsonObject = jsonArray.get(i).getAsJsonObject();
if (nem.equals(jsonObject.get("nmi").getAsString())) {
sites.siteid = jsonObject.get("id").getAsString();
sites.nmi = jsonObject.get("nmi").getAsString();
}
}
if ((nem.isEmpty()) || (sites.siteid.isEmpty())) { // nem not specified, or not found so we take the first
// siteid
// found
JsonObject jsonObject = jsonArray.get(0).getAsJsonObject();
sites.siteid = jsonObject.get("id").getAsString();
sites.nmi = jsonObject.get("nmi").getAsString();
}
return sites;
public Sites() {
}
}

View File

@ -20,6 +20,8 @@ thing-type.amberelectric.service.channel.feed-in-status.description = Current pr
thing-type.config.amberelectric.service.apiKey.label = API Key
thing-type.config.amberelectric.service.apiKey.description = API key from the Amber website
thing-type.config.amberelectric.service.forecasts.label = Forecasts
thing-type.config.amberelectric.service.forecasts.description = Specifies the number of forecasts to fetch (Optional)
thing-type.config.amberelectric.service.nmi.label = NMI
thing-type.config.amberelectric.service.nmi.description = NMI for your address (Optional)
thing-type.config.amberelectric.service.refresh.label = Refresh Interval

View File

@ -50,6 +50,7 @@
<label>Refresh Interval</label>
<description>Specifies the refresh interval in seconds</description>
<default>60</default>
<advanced>true</advanced>
</parameter>
</config-description>