public class HydrawiseConnectionException extends Exception {
private static final long serialVersionUID = 1L;
+ private int code = 0;
+ private String response = "";
+
public HydrawiseConnectionException(Exception e) {
super(e);
}
public HydrawiseConnectionException(String message) {
super(message);
}
+
+ public HydrawiseConnectionException(String message, int code, String response) {
+ super(message);
+ this.code = code;
+ this.response = response;
+ }
+
+ public static long getSerialversionuid() {
+ return serialVersionUID;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ public String getResponse() {
+ return response;
+ }
}
import org.openhab.binding.hydrawise.internal.api.HydrawiseConnectionException;
import org.openhab.binding.hydrawise.internal.api.graphql.dto.ControllerStatus;
import org.openhab.binding.hydrawise.internal.api.graphql.dto.Forecast;
+import org.openhab.binding.hydrawise.internal.api.graphql.dto.Hardware;
import org.openhab.binding.hydrawise.internal.api.graphql.dto.Mutation;
import org.openhab.binding.hydrawise.internal.api.graphql.dto.MutationResponse;
import org.openhab.binding.hydrawise.internal.api.graphql.dto.MutationResponse.MutationResponseStatus;
.registerTypeAdapter(ZoneRun.class, new ResponseDeserializer<ZoneRun>())
.registerTypeAdapter(Forecast.class, new ResponseDeserializer<Forecast>())
.registerTypeAdapter(Sensor.class, new ResponseDeserializer<Forecast>())
- .registerTypeAdapter(ControllerStatus.class, new ResponseDeserializer<ControllerStatus>()).create();
+ .registerTypeAdapter(ControllerStatus.class, new ResponseDeserializer<ControllerStatus>())
+ .registerTypeAdapter(Hardware.class, new ResponseDeserializer<ControllerStatus>()).create();
private static final String GRAPH_URL = "https://app.hydrawise.com/api/v2/graph";
private static final String MUTATION_START_ZONE = "startZone(zoneId: %d) { status }";
private final HttpClient httpClient;
private final OAuthClientService oAuthService;
private String queryString = "";
+ private String weatherString = "";
public HydrawiseGraphQLClient(HttpClient httpClient, OAuthClientService oAuthService) {
this.httpClient = httpClient;
public @Nullable QueryResponse queryControllers()
throws HydrawiseConnectionException, HydrawiseAuthenticationException {
try {
- QueryRequest query = new QueryRequest(getQueryString());
- String queryJson = gson.toJson(query);
- String response = sendGraphQLQuery(queryJson);
- try {
- return gson.fromJson(response, QueryResponse.class);
- } catch (JsonSyntaxException e) {
- throw new HydrawiseConnectionException("Invalid Response: " + response);
- }
+ return queryRequest(getQueryString());
+ } catch (IOException e) {
+ throw new HydrawiseConnectionException(e);
+ }
+ }
+
+ /**
+ * Sends a GrapQL query for controller data
+ *
+ * @return QueryResponse
+ * @throws HydrawiseConnectionException
+ * @throws HydrawiseAuthenticationException
+ */
+ public @Nullable QueryResponse queryWeather()
+ throws HydrawiseConnectionException, HydrawiseAuthenticationException {
+ try {
+ return queryRequest(getWeatherString());
} catch (IOException e) {
throw new HydrawiseConnectionException(e);
}
}
+ /**
+ * Sends a GrapQL query for controller data
+ *
+ * @param queryString
+ * @return QueryResponse
+ * @throws HydrawiseConnectionException
+ * @throws HydrawiseAuthenticationException
+ */
+ private @Nullable QueryResponse queryRequest(String queryString)
+ throws HydrawiseConnectionException, HydrawiseAuthenticationException {
+ QueryRequest query = new QueryRequest(queryString);
+ String queryJson = gson.toJson(query);
+ String response = sendGraphQLQuery(queryJson);
+ try {
+ return gson.fromJson(response, QueryResponse.class);
+ } catch (JsonSyntaxException e) {
+ throw new HydrawiseConnectionException("Invalid Response: " + response);
+ }
+ }
+
/***
* Stops a given relay
*
int statusCode = response.getStatus();
if (!HttpStatus.isSuccess(statusCode)) {
throw new HydrawiseConnectionException(
- "Request failed with HTTP status code: " + statusCode + " response: " + stringResponse);
+ "Request failed with HTTP status code: " + statusCode + " response: " + stringResponse,
+ statusCode, stringResponse);
}
return stringResponse;
} catch (InterruptedException | TimeoutException | OAuthException | IOException e) {
private String getQueryString() throws IOException {
if (queryString.isBlank()) {
- try (InputStream inputStream = HydrawiseGraphQLClient.class.getClassLoader()
- .getResourceAsStream("query.graphql");
- BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) {
- queryString = bufferedReader.lines().collect(Collectors.joining("\n"));
- }
+ queryString = getResourceString("query.graphql");
}
return queryString;
}
+ private String getWeatherString() throws IOException {
+ if (weatherString.isBlank()) {
+ weatherString = getResourceString("weather.graphql");
+ }
+ return weatherString;
+ }
+
+ private String getResourceString(String name) throws IOException {
+ try (InputStream inputStream = HydrawiseGraphQLClient.class.getClassLoader().getResourceAsStream(name);
+ BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) {
+ return bufferedReader.lines().collect(Collectors.joining("\n"));
+ }
+ }
+
class ResponseDeserializer<T> implements JsonDeserializer<T> {
@Override
@Nullable
public Integer id;
public String name;
public ControllerStatus status;
+ public Hardware hardware;
public Location location;
public List<Zone> zones = null;
public List<Sensor> sensors = null;
- public List<Forecast> forecast = null;
}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.hydrawise.internal.api.graphql.dto;
+
+/**
+ *
+ * @author Dan Cunningham - Initial contribution
+ *
+ */
+public class Hardware {
+ public String version;
+ public Model model;
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.hydrawise.internal.api.graphql.dto;
+
+/**
+ *
+ * @author Dan Cunningham - Initial contribution
+ *
+ */
+public class Model {
+ public Integer maxZones;
+ public String name;
+ public String description;
+}
*/
package org.openhab.binding.hydrawise.internal.discovery;
-import java.time.Instant;
+import java.util.Date;
import java.util.List;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.hydrawise.internal.HydrawiseBindingConstants;
import org.openhab.binding.hydrawise.internal.HydrawiseControllerListener;
import org.openhab.binding.hydrawise.internal.api.graphql.dto.Controller;
import org.openhab.binding.hydrawise.internal.api.graphql.dto.Customer;
import org.openhab.binding.hydrawise.internal.handler.HydrawiseAccountHandler;
-import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.osgi.service.component.annotations.Component;
-import org.osgi.service.component.annotations.ServiceScope;
/**
*
*/
@NonNullByDefault
-@Component(scope = ServiceScope.PROTOTYPE, service = ThingHandlerService.class)
-public class HydrawiseCloudControllerDiscoveryService
- extends AbstractThingHandlerDiscoveryService<HydrawiseAccountHandler> implements HydrawiseControllerListener {
+@Component(service = ThingHandlerService.class)
+public class HydrawiseCloudControllerDiscoveryService extends AbstractDiscoveryService
+ implements HydrawiseControllerListener, ThingHandlerService {
+
private static final int TIMEOUT = 5;
+ @Nullable
+ HydrawiseAccountHandler handler;
public HydrawiseCloudControllerDiscoveryService() {
- super(HydrawiseAccountHandler.class, Set.of(HydrawiseBindingConstants.THING_TYPE_CONTROLLER), TIMEOUT, true);
+ super(Set.of(HydrawiseBindingConstants.THING_TYPE_CONTROLLER), TIMEOUT, true);
}
@Override
protected void startScan() {
- Customer data = thingHandler.lastData();
- if (data != null) {
- data.controllers.forEach(controller -> addDiscoveryResults(controller));
+ HydrawiseAccountHandler localHandler = this.handler;
+ if (localHandler != null) {
+ Customer data = localHandler.lastData();
+ if (data != null) {
+ data.controllers.forEach(controller -> addDiscoveryResults(controller));
+ }
}
}
@Override
- public void dispose() {
- super.dispose();
- removeOlderResults(Instant.now().toEpochMilli(), thingHandler.getThing().getUID());
+ public void deactivate() {
+ HydrawiseAccountHandler localHandler = this.handler;
+ if (localHandler != null) {
+ removeOlderResults(new Date().getTime(), localHandler.getThing().getUID());
+ }
}
@Override
protected synchronized void stopScan() {
super.stopScan();
- removeOlderResults(getTimestampOfLastScan(), thingHandler.getThing().getUID());
+ HydrawiseAccountHandler localHandler = this.handler;
+ if (localHandler != null) {
+ removeOlderResults(getTimestampOfLastScan(), localHandler.getThing().getUID());
+ }
}
@Override
}
@Override
- public void initialize() {
- thingHandler.addControllerListeners(this);
- super.initialize();
+ public void setThingHandler(ThingHandler handler) {
+ this.handler = (HydrawiseAccountHandler) handler;
+ this.handler.addControllerListeners(this);
+ }
+
+ @Override
+ public @Nullable ThingHandler getThingHandler() {
+ return handler;
}
private void addDiscoveryResults(Controller controller) {
- String label = String.format("Hydrawise Controller %s", controller.name);
- int id = controller.id;
- ThingUID bridgeUID = thingHandler.getThing().getUID();
- ThingUID thingUID = new ThingUID(HydrawiseBindingConstants.THING_TYPE_CONTROLLER, bridgeUID,
- String.valueOf(id));
- thingDiscovered(DiscoveryResultBuilder.create(thingUID).withLabel(label).withBridge(bridgeUID)
- .withProperty(HydrawiseBindingConstants.CONFIG_CONTROLLER_ID, id)
- .withRepresentationProperty(HydrawiseBindingConstants.CONFIG_CONTROLLER_ID).build());
+ HydrawiseAccountHandler localHandler = this.handler;
+ if (localHandler != null) {
+ String label = String.format("Hydrawise Controller %s", controller.name);
+ int id = controller.id;
+ ThingUID bridgeUID = localHandler.getThing().getUID();
+ ThingUID thingUID = new ThingUID(HydrawiseBindingConstants.THING_TYPE_CONTROLLER, bridgeUID,
+ String.valueOf(id));
+ thingDiscovered(DiscoveryResultBuilder.create(thingUID).withLabel(label).withBridge(bridgeUID)
+ .withProperty(HydrawiseBindingConstants.CONFIG_CONTROLLER_ID, id)
+ .withRepresentationProperty(HydrawiseBindingConstants.CONFIG_CONTROLLER_ID).build());
+ }
}
}
*/
private static final int MIN_REFRESH_SECONDS = 30;
private static final int TOKEN_REFRESH_SECONDS = 60;
+ private static final int WEATHER_REFRESH_MILLIS = 60 * 60 * 1000; // 1 hour
private static final String BASE_URL = "https://app.hydrawise.com/api/v2/";
private static final String AUTH_URL = BASE_URL + "oauth/access-token";
private static final String CLIENT_SECRET = "zn3CrjglwNV1";
private @Nullable ScheduledFuture<?> pollFuture;
private @Nullable ScheduledFuture<?> tokenFuture;
private @Nullable Customer lastData;
+ private long lastWeatherUpdate;
private int refresh;
public HydrawiseAccountHandler(final Bridge bridge, final HttpClient httpClient, final OAuthFactory oAuthFactory) {
}
private void poll(boolean retry) {
+ HydrawiseGraphQLClient apiClient = this.apiClient;
+ if (apiClient == null) {
+ logger.debug("apiclient not initalized");
+ return;
+ }
try {
QueryResponse response = apiClient.queryControllers();
if (response == null) {
if (getThing().getStatus() != ThingStatus.ONLINE) {
updateStatus(ThingStatus.ONLINE);
}
+ long currentTime = System.currentTimeMillis();
+ if (currentTime > lastWeatherUpdate + WEATHER_REFRESH_MILLIS) {
+ lastWeatherUpdate = currentTime;
+ try {
+ QueryResponse weatherResponse = apiClient.queryWeather();
+ if (weatherResponse != null) {
+ response.data.me.controllers.forEach(controller -> {
+ weatherResponse.data.me.controllers.stream().filter(c -> c.id.equals(controller.id))
+ .findFirst().ifPresent(c -> controller.location.forecast = c.location.forecast);
+ });
+ }
+ } catch (HydrawiseConnectionException e) {
+ logger.debug("Weather data is not supported", e);
+ }
+ }
lastData = response.data.me;
synchronized (controllerListeners) {
controllerListeners.forEach(listener -> {
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
+import javax.measure.quantity.Speed;
+import javax.measure.quantity.Temperature;
import javax.measure.quantity.Volume;
import org.eclipse.jdt.annotation.NonNullByDefault;
updateForecast(controller.location.forecast);
}
if (controller.zones != null) {
- updateZones(controller.zones);
+ updateZones(controller.zones, controller.hardware.model.maxZones);
}
// update values with what the cloud tells us even though the controller may be offline
: UnDefType.NULL);
}
- private void updateZones(List<Zone> zones) {
+ private void updateZones(List<Zone> zones, int maxZones) {
AtomicReference<Boolean> anyRunning = new AtomicReference<>(false);
AtomicReference<Boolean> anySuspended = new AtomicReference<>(false);
for (Zone zone : zones) {
- // there are 12 relays per expander, expanders will have a zoneNumber like:
+ // for expansion modules who zones numbers are > 99
+ // there are maxZones relays per expander, expanders will have a zoneNumber like:
+ // maxZones = 12
// 10 for expander 0, relay 10 = zone10
// 101 for expander 1, relay 1 = zone13
// 212 for expander 2, relay 12 = zone36
// division of integers in Java give whole numbers, not remainders FYI
- int zoneNumber = ((zone.number.value / 100) * 12) + (zone.number.value % 100);
-
+ int zoneNumber = zone.number.value <= 99 ? zone.number.value
+ : ((zone.number.value / 100) * maxZones) + (zone.number.value % 100);
String group = "zone" + zoneNumber;
zoneMaps.put(group, zone);
- logger.trace("Updateing Zone {} {} ", group, zone.name);
+ logger.trace("Updating Zone {} {} ", group, zone.name);
updateGroupState(group, CHANNEL_ZONE_NAME, new StringType(zone.name));
updateGroupState(group, CHANNEL_ZONE_ICON, new StringType(BASE_IMAGE_URL + zone.icon.fileName));
if (zone.scheduledRuns != null) {
updateGroupState(group, CHANNEL_ZONE_SUSPENDUNTIL, UnDefType.UNDEF);
}
}
- updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_RUN, OnOffType.from(anyRunning.get()));
- updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_SUSPEND, OnOffType.from(anySuspended.get()));
+ updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_RUN, anyRunning.get() ? OnOffType.ON : OnOffType.OFF);
+ updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_SUSPEND,
+ anySuspended.get() ? OnOffType.ON : OnOffType.OFF);
updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_SUSPENDUNTIL, UnDefType.UNDEF);
}
int i = 1;
for (Forecast forecast : forecasts) {
String group = "forecast" + (i++);
+ logger.trace("Updating {} {}", group, forecast.time);
updateGroupState(group, CHANNEL_FORECAST_TIME, stringToDateTime(forecast.time));
updateGroupState(group, CHANNEL_FORECAST_CONDITIONS, new StringType(forecast.conditions));
updateGroupState(group, CHANNEL_FORECAST_HUMIDITY, new DecimalType(forecast.averageHumidity.intValue()));
private void updateTemperature(UnitValue temperature, String group, String channel) {
logger.debug("TEMP {} {} {} {}", group, channel, temperature.unit, temperature.value);
- updateGroupState(group, channel, new QuantityType<>(temperature.value,
- "\\u00b0F".equals(temperature.unit) ? ImperialUnits.FAHRENHEIT : SIUnits.CELSIUS));
+ updateGroupState(group, channel, new QuantityType<Temperature>(temperature.value,
+ temperature.unit.indexOf("F") >= 0 ? ImperialUnits.FAHRENHEIT : SIUnits.CELSIUS));
}
private void updateWindspeed(UnitValue wind, String group, String channel) {
- updateGroupState(group, channel, new QuantityType<>(wind.value,
+ updateGroupState(group, channel, new QuantityType<Speed>(wind.value,
"mph".equals(wind.unit) ? ImperialUnits.MILES_PER_HOUR : SIUnits.KILOMETRE_PER_HOUR));
}
}
private QuantityType<Volume> waterFlowToQuantityType(Number flow, String units) {
- double waterFlow = flow.doubleValue();
- if ("gals".equals(units)) {
- waterFlow = waterFlow * 3.785;
- }
- return new QuantityType<>(waterFlow, Units.LITRE);
+ return new QuantityType<>(flow.doubleValue(),
+ "gal".equals(units) ? ImperialUnits.GALLON_LIQUID_US : Units.LITRE);
}
}
updateGroupState(group, CHANNEL_ZONE_TIME_LEFT, new QuantityType<>(0, Units.SECOND));
}
- updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_RUN, OnOffType.from(!status.running.isEmpty()));
+ updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_RUN,
+ !status.running.isEmpty() ? OnOffType.ON : OnOffType.OFF);
});
}
<label>Hydrawise Controller Thing</label>
<description>Hydrawise connected irrigation controller</description>
- <!-- Until we have https://github.com/eclipse/smarthome/issues/1118 fixed, we need to list all possible channel groups.
+ <!-- Until we have https://github.com/eclipse/smarthome/issues/1118 fixed, we need to list
+ all possible channel groups.
Once this is fixed we can dynamically add them to the thing and not list them here. -->
<channel-groups>
<label>Zone 36</label>
<description>Sprinkler Zone 36</description>
</channel-group>
+ <channel-group id="zone37" typeId="zone">
+ <label>Zone 37</label>
+ <description>Sprinkler Zone 37</description>
+ </channel-group>
+ <channel-group id="zone38" typeId="zone">
+ <label>Zone 38</label>
+ <description>Sprinkler Zone 38</description>
+ </channel-group>
+ <channel-group id="zone39" typeId="zone">
+ <label>Zone 39</label>
+ <description>Sprinkler Zone 39</description>
+ </channel-group>
+ <channel-group id="zone40" typeId="zone">
+ <label>Zone 40</label>
+ <description>Sprinkler Zone 40</description>
+ </channel-group>
+ <channel-group id="zone41" typeId="zone">
+ <label>Zone 41</label>
+ <description>Sprinkler Zone 41</description>
+ </channel-group>
+ <channel-group id="zone42" typeId="zone">
+ <label>Zone 42</label>
+ <description>Sprinkler Zone 42</description>
+ </channel-group>
+ <channel-group id="zone43" typeId="zone">
+ <label>Zone 43</label>
+ <description>Sprinkler Zone 43</description>
+ </channel-group>
+ <channel-group id="zone44" typeId="zone">
+ <label>Zone 44</label>
+ <description>Sprinkler Zone 44</description>
+ </channel-group>
+ <channel-group id="zone45" typeId="zone">
+ <label>Zone 45</label>
+ <description>Sprinkler Zone 45</description>
+ </channel-group>
+ <channel-group id="zone46" typeId="zone">
+ <label>Zone 46</label>
+ <description>Sprinkler Zone 46</description>
+ </channel-group>
+ <channel-group id="zone47" typeId="zone">
+ <label>Zone 47</label>
+ <description>Sprinkler Zone 47</description>
+ </channel-group>
+ <channel-group id="zone48" typeId="zone">
+ <label>Zone 48</label>
+ <description>Sprinkler Zone 48</description>
+ </channel-group>
+ <channel-group id="zone49" typeId="zone">
+ <label>Zone 49</label>
+ <description>Sprinkler Zone 49</description>
+ </channel-group>
+ <channel-group id="zone50" typeId="zone">
+ <label>Zone 50</label>
+ <description>Sprinkler Zone 50</description>
+ </channel-group>
+ <channel-group id="zone51" typeId="zone">
+ <label>Zone 51</label>
+ <description>Sprinkler Zone 51</description>
+ </channel-group>
+ <channel-group id="zone52" typeId="zone">
+ <label>Zone 52</label>
+ <description>Sprinkler Zone 52</description>
+ </channel-group>
+ <channel-group id="zone53" typeId="zone">
+ <label>Zone 53</label>
+ <description>Sprinkler Zone 53</description>
+ </channel-group>
+ <channel-group id="zone54" typeId="zone">
+ <label>Zone 54</label>
+ <description>Sprinkler Zone 54</description>
+ </channel-group>
</channel-groups>
<config-description>
<parameter name="controllerId" type="integer" required="true">
controllers {
id
name
- status {
+ status {
summary
online
lastContact {
timestamp
}
- }
+ }
+ hardware {
+ version
+ model {
+ maxZones
+ name
+ description
+ }
+ }
location {
coordinates {
latitude
longitude
}
- forecast(days: 3) {
- time
- updateTime
- conditions
- averageWindSpeed {
- value
- unit
- }
- highTemperature {
- value
- unit
- }
- lowTemperature {
- value
- unit
- }
- probabilityOfPrecipitation
- precipitation {
- value
- unit
- }
- averageHumidity
- }
}
zones {
id
--- /dev/null
+{
+ me {
+ email
+ lastContact
+ controllers {
+ id
+ location {
+ forecast(days: 3) {
+ time
+ updateTime
+ conditions
+ averageWindSpeed {
+ value
+ unit
+ }
+ highTemperature {
+ value
+ unit
+ }
+ lowTemperature {
+ value
+ unit
+ }
+ probabilityOfPrecipitation
+ precipitation {
+ value
+ unit
+ }
+ averageHumidity
+ }
+ }
+ }
+ }
+}