package org.bensteele.jirrigate; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.net.InetAddress; import java.net.UnknownHostException; import java.text.DecimalFormat; import java.util.HashSet; import java.util.Properties; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import javax.naming.ConfigurationException; import joptsimple.OptionException; import joptsimple.OptionParser; import joptsimple.OptionSet; import org.apache.log4j.Level; import org.apache.log4j.Logger; import org.apache.log4j.PatternLayout; import org.apache.log4j.RollingFileAppender; import org.bensteele.jirrigate.controller.Controller; import org.bensteele.jirrigate.controller.Controller.ControllerType; import org.bensteele.jirrigate.controller.EtherRain8Controller; import org.bensteele.jirrigate.controller.zone.EtherRain8Zone; import org.bensteele.jirrigate.controller.zone.Zone; import org.bensteele.jirrigate.weather.WeatherStation; import org.bensteele.jirrigate.weather.WeatherStation.WeatherStationType; import org.bensteele.jirrigate.weather.weatherunderground.WeatherUndergroundStation; import org.joda.time.DateTime; import org.joda.time.DateTimeConstants; import org.joda.time.LocalTime; import org.joda.time.format.DateTimeFormat; import org.joda.time.format.DateTimeFormatter; /** * The main class for the jirrigate application. * <p> * Controls one or more {@link Controller} and (optionally) one or more {@link WeatherStation}. * * @author Ben Steele (ben@bensteele.org) */ public class Irrigator { private static final String VERSION = "1.1"; private static final String LICENSE_HEADER = "Jirrigate v" + VERSION + " Copyright (C) 2014 Ben Steele\n" + "This program comes with ABSOLUTELY NO WARRANTY;\n" + "This is free software, and you are welcome to redistribute it\n" + "under certain conditions; type 'show license' for details.\n"; private static final Logger LOG = Logger.getRootLogger(); private static String configFilePath; public static void main(String[] args) throws IOException, ConfigurationException { System.out.println(LICENSE_HEADER); // TODO fix telnet implementation // final Thread t = new Thread(new TelnetServer(32666)); // t.start(); final Properties config = parseConfigurationFile(args); final Irrigator i = new Irrigator(config); try { System.out.println("Processing configuration file..."); i.processConfiguration(); System.out.println("Done!"); } catch (final ConfigurationException e) { System.out.println(e.getMessage()); System.exit(1); } final Console c = new Console(i); c.startConsole(); } protected static Properties parseConfigurationFile(String[] args) throws IOException { final OptionParser parser = new OptionParser() { { accepts("config").withRequiredArg().required().ofType(String.class) .describedAs("Path to the configuration file."); } }; OptionSet options = null; try { options = parser.parse(args); } catch (final OptionException e) { parser.printHelpOn(System.out); System.exit(1); } configFilePath = (String) options.valueOf("config"); final Properties config = new Properties(); config.load(new FileInputStream(configFilePath)); return config; } protected void reloadConfigurationFromFile() throws FileNotFoundException, IOException { config.load(new FileInputStream(configFilePath)); } private final ExecutorService requestExecutor = Executors.newSingleThreadExecutor(); private final Set<Integer> wateringDays = new TreeSet<Integer>(); private final Set<Controller> controllers = new HashSet<Controller>(); private final Set<WeatherStation> weatherStations = new HashSet<WeatherStation>(); private final Properties config; private final DecimalFormat twoDecimals = new DecimalFormat("###.##"); private final PatternLayout logPattern = new PatternLayout("%d %-5p- %m%n"); private LocalTime wateringStartTime; private int rainDaysToLookBack; private int rainDaysToLookAhead; private int popThreshold; private double totalRainAmountThresholdInMilliMetres = Double.MAX_VALUE; private double currentRainAmountThresholdInMilliMetres = Double.MAX_VALUE; private double currentWindThresholdInKmph = Double.MAX_VALUE; private double maxTempThresholdInCelcius = Double.MAX_VALUE; private double minTempThresholdInCelcius = Double.MAX_VALUE; private String logPath; private double weatherMultiplierValue = 1.0; // Default value of no multiply. private double weatherMultiplierMaxTemp = 1000.00; // Default to value that won't false positive. private int weatherMultiplierDaysToLookAhead; /** * Creates a single instance of an Irrigator and all of its child * {@link Controller} and {@link WeatherStation} Objects. The configuration of * these Objects is derived from the configuration properties passed into the * constructor and may have the following values: * * <pre> * X means a numeric value, generally starting at 1 and incrementing as required * (i.e. controller3 would indicate your third controller configuration). * * logging_directory - The path to the directory to write the jirrigate.log file. * * controllerX_name - Friendly name. <required> * controllerX_ip - IP address. <required> * controllerX_port - Port to connect to. <required> * controllerX_username - Login Username. <optional> * controllerX_password - Login Password. <optional> * controllerX_type - Type of this controller, i.e. EtherRain8. <required> * * controllerX_zoneX_name - Friendly name. <required> * controllerX_zoneX_id - ID of this zone on the controller. <required> * controllerX_zoneX_duration - How long to irrigate this zone, accepts s/m/h delimiters. <required> * * watering_days - Days to irrigate, i.e. monday,wednesday,saturday. <required> * watering_start_time - Time to start irrigation in 24-hour format i.e. 23:30 for 11:30pm. <required> * * weather_watcher - Will stop an active irrigation if thresholds are exceeded, default true. Accepts true/false.<optional> * * <WeatherStation as a whole is optional> * weatherstationX_name - Friendly name.<required> * weatherstationX_type - Type i.e. wunderground.<required> * weatherstationX_staionid - Station specific ID i.e. AUSOUTH123.<required> * weatherstationX_api - API key.<required> * * <Thresholds are optional and require a valid WeatherStation to be accepted> * weather_threshold_rain_days_to_look_ahead - # of days in advance to use for rain thresholds.<optional> * weather_threshold_rain_days_to_look_back - # of days in past to use for rain thresholds.<optional> * weather_threshold_total_rain_amount - Total rain from weather_threshold_rain_days_to_look_ahead, accepts mm/in.<optional> * weather_threshold_current_rain_amount - Amount of rain from today, accepts mm/in.<optional> * weather_threshold_pop - Chance of rain over the weather_threshold_rain_days_to_look_ahead period.<optional> * weather_threshold_wind_speed - Current wind speed, accepts kph/mph.<optional> * weather_threshold_min_temp - Current temperature low cut off, accepts C/F.<optional> * weather_threshold_max_temp - Current temperature high cut off, accepts C/F.<optional> * * <Weather multiplier is optional and require a valid WeatherStation to be accepted> * weather_multiplier_max_temp - Temperature to trigger the multiplier, accepts C/F.<optional> * weather_multiplier_value - Amount to multiply the normal irrigation time for i.e. 1.5 for 50% extra.<optional> * weather_multiplier_days_to_look_ahead - The amount of forecast days ahead to use for gathering data, accepts a whole number.<optional> * * @param config * The configuration to be loaded into the {@link Irrigator}. * @throws IOException * If log4j has trouble creating the log file. * @throws ConfigurationException * If the configuration contains invalid or missing properties. */ public Irrigator(Properties config) throws IOException, ConfigurationException { this.config = config; // Logging setup if (config.getProperty("logging_directory") == null) { logPath = System.getProperty("user.dir"); } else { final File file = new File(config.getProperty("logging_directory")); if (!file.isDirectory()) { throw new ConfigurationException("ERROR: Must specify a logging_directory"); } else { logPath = config.getProperty("logging_directory"); } } final RollingFileAppender fileAppender = new RollingFileAppender(logPattern, logPath + File.separator + "jirrigate.log", true); fileAppender.setMaxFileSize("10MB"); fileAppender.setMaxBackupIndex(10); LOG.addAppender(fileAppender); LOG.setLevel(Level.INFO); // Main irrigator thread. requestExecutor.execute(new Runnable() { @Override public void run() { startIrrigator(); startWeatherWatcher(); } }); } public Properties getConfig() { return config; } public Controller getController(String controllerName) { for (final Controller c : controllers) { if (c.getName().matches(controllerName)) { return c; } } return null; } public Set<Controller> getControllers() { return this.controllers; } public double getCurrentRainAmountThresholdInMilliMetres() { return Double.valueOf(twoDecimals.format(currentRainAmountThresholdInMilliMetres)); } public double getCurrentWindThresholdInKmph() { return Double.valueOf(twoDecimals.format(currentWindThresholdInKmph)); } public double getMaxTempThresholdInCelcius() { return Double.valueOf(twoDecimals.format(maxTempThresholdInCelcius)); } public double getMinTempThresholdInCelcius() { return Double.valueOf(twoDecimals.format(minTempThresholdInCelcius)); } public int getPopThreshold() { return this.popThreshold; } public int getRainDaysToLookAhead() { return this.rainDaysToLookAhead; } public int getRainDaysToLookBack() { return rainDaysToLookBack; } public double getTotalRainAmountThresholdInMilliMetres() { return Double.valueOf(twoDecimals.format(totalRainAmountThresholdInMilliMetres)); } public String getVersion() { return VERSION; } public Set<Integer> getWateringDays() { return this.wateringDays; } public LocalTime getWateringStartTime() { return this.wateringStartTime; } public WeatherStation getWeatherStation(String weatherStationName) { for (final WeatherStation w : weatherStations) { if (w.getName().matches(weatherStationName)) { return w; } } return null; } public Set<WeatherStation> getWeatherStations() { return this.weatherStations; } protected boolean isIrrigating() { for (final Controller c : controllers) { if (c.isIrrigating()) { return true; } } return false; } /** * Returns the time and date the next irrigation is due based on the watering_days and * watering_start_time. It does not take into account whether or not any of the {@link Controller} * are active. * * @return The time and date of the next irrigation for any controller under this irrigator's * control. */ protected DateTime nextIrrigationAt() { DateTime dt = new DateTime(); for (int i = 0; i < 7; i++) { for (final int dayOfWeek : wateringDays) { if (dayOfWeek == (dt.getDayOfWeek())) { // If it's the first run then we may match the same day we are currently on, in this case // we need to check that we don't report a time in the past. Validate that the hour and // minute right now are not past the scheduled watering time. If it's not the first run // then it's ok to let through. if (i != 0 || (i == 0 && dt.toLocalTime().isBefore(wateringStartTime))) { // Reset the hour to 0 and increment until we match the watering hour. dt = dt.withHourOfDay(0); while (dt.getHourOfDay() < wateringStartTime.getHourOfDay()) { dt = dt.plusHours(1); } // Reset the minute to 0 and increment until we match the watering minute. dt = dt.withMinuteOfHour(0); while (dt.getMinuteOfHour() < wateringStartTime.getMinuteOfHour()) { dt = dt.plusMinutes(1); } return dt; } } } dt = dt.plusDays(1); } return null; } public void processConfiguration() throws ConfigurationException, IOException { // Process the weather stations. processWeatherStationConfiguration(); // Process the weather thresholds. processWeatherThresholdConfiguration(); // Process the days that irrigation can be performed. processWateringDaysConfiguration(); // Process the days that irrigation can be performed. processWateringStartTimeConfiguration(); // Process the extreme weather multiplier factor for irrigation duration. processWeatherMultiplierConfiguration(); // Process the irrigation controllers. processControllerConfiguration(); } public void processControllerConfiguration() throws ConfigurationException, IOException { controllers.clear(); for (int i = 1; i < Integer.MAX_VALUE; i++) { final String controllerValue = "controller" + i; final String controllerName = config.getProperty(controllerValue + "_name"); if (controllerName == null) { // No more controllers to parse. break; } else { // IP Address if (config.getProperty(controllerValue + "_ip") == null) { throw new ConfigurationException("ERROR: Unable to parse IP address of " + controllerValue + "_ip"); } InetAddress ipAddress; try { ipAddress = InetAddress.getByName(config.getProperty(controllerValue + "_ip")); } catch (final UnknownHostException e) { throw new ConfigurationException("ERROR: Unable to parse IP address of " + controllerValue + "_ip"); } // Port if (config.getProperty(controllerValue + "_port") == null) { throw new ConfigurationException("ERROR: Unable to parse port of " + controllerValue + "_port"); } int port; try { port = Integer.parseInt(config.getProperty(controllerValue + "_port")); } catch (final NumberFormatException e) { throw new ConfigurationException("ERROR: Unable to parse port of " + controllerValue + "_port"); } // Username final String username = config.getProperty(controllerValue + "_username"); // Password final String password = config.getProperty(controllerValue + "_password"); // Instantiate controller based on controller_type value. if (config.getProperty(controllerValue + "_type") == null) { throw new ConfigurationException("ERROR: " + "no controller type for " + controllerValue + "_type"); } Controller c = null; final String controllerType = config.getProperty(controllerValue + "_type"); for (final ControllerType type : Controller.ControllerType.values()) { if (controllerType.toUpperCase().matches(type.name())) { if (type.name().matches("ETHERRAIN8")) { c = new EtherRain8Controller(controllerName, ipAddress, port, username, password, LOG); } } } if (c == null) { throw new ConfigurationException("ERROR: " + controllerType + " is not a valid controller type for " + controllerValue + "_type"); } // Process the zones for this controller. processControllerZonesConfiguration(c, controllerValue); // Controller configuration has passed, add it to the list of controllers. controllers.add(c); } } } /** * Processes the {@link Zone} configurations for a given {@link Controller}. It will convert any * duration values into seconds but will leave the specifics on what is and isn't valid for a * duration to the {@link Zone} implementation. * * @param c The {@link Controller} the {@link Zone} belong to. * @param controllerValue The order in which it's processed from the configuration ie * "controller3" for the third controller. * @throws ConfigurationException If the configuration is invalid. */ private void processControllerZonesConfiguration(Controller c, String controllerValue) throws ConfigurationException { for (int j = 1; j < Integer.MAX_VALUE; j++) { final String zone = controllerValue + "_zone" + j; final String zoneName = config.getProperty(zone + "_name"); if (zoneName == null) { // No more zones to parse. break; } else { final String zoneId = config.getProperty(zone + "_id"); if (zoneId == null) { throw new ConfigurationException("ERROR: Could not find configuration for " + zone + "_id"); } // Create the zone, it will automatically add itself to the controller. Zone z = null; try { if (c.getClass().equals(EtherRain8Controller.class)) { z = new EtherRain8Zone(c, zoneName, 0, zoneId); } } catch (final IllegalArgumentException e) { throw new ConfigurationException(e.getMessage()); } // Duration is an optional value expressed in [value][s|m|h] where s=seconds, m=minutes // and h=hours in the configuration file. This will be converted into seconds for use // with the controllers. final String zoneDuration = config.getProperty(zone + "_duration"); if (zoneDuration != null) { try { final long durationValue = Long .parseLong(zoneDuration.substring(0, zoneDuration.length() - 1)); final String durationDelimiter = zoneDuration.substring(zoneDuration.length() - 1); if (durationDelimiter.equalsIgnoreCase("s")) { z.setDuration(durationValue); } else if (durationDelimiter.equalsIgnoreCase("m")) { z.setDuration(durationValue * 60); } else if (durationDelimiter.equalsIgnoreCase("h")) { z.setDuration((durationValue * 60) * 60); } else { throw new ConfigurationException("ERROR: Unable to parse duration of " + zone + "_duration"); } } catch (final NumberFormatException e) { throw new ConfigurationException("ERROR: Unable to parse duration of " + zone + "_duration"); } catch (final IllegalArgumentException e) { throw new ConfigurationException(e.getMessage()); } } } } } /** * Parse the configuration file for names of days of the week from the "watering_days" value. * Convert to java.util.Calendar.DAY_OF_WEEK int value and store for scheduling use. * * @param config The configuration file. * @throws ConfigurationException If the configuration is invalid. */ public void processWateringDaysConfiguration() throws ConfigurationException { final String errorMsg = "ERROR: Could not read watering_days from configuration file"; if (config.getProperty("watering_days") == null) { throw new ConfigurationException(errorMsg); } final String[] wateringDaysArg = config.getProperty("watering_days").split(","); for (String s : wateringDaysArg) { s = s.trim(); if (s.equalsIgnoreCase("sunday")) { wateringDays.add(DateTimeConstants.SUNDAY); } if (s.equalsIgnoreCase("monday")) { wateringDays.add(DateTimeConstants.MONDAY); } if (s.equalsIgnoreCase("tuesday")) { wateringDays.add(DateTimeConstants.TUESDAY); } if (s.equalsIgnoreCase("wednesday")) { wateringDays.add(DateTimeConstants.WEDNESDAY); } if (s.equalsIgnoreCase("thursday")) { wateringDays.add(DateTimeConstants.THURSDAY); } if (s.equalsIgnoreCase("friday")) { wateringDays.add(DateTimeConstants.FRIDAY); } if (s.equalsIgnoreCase("saturday")) { wateringDays.add(DateTimeConstants.SATURDAY); } } if (wateringDays.isEmpty()) { throw new ConfigurationException(errorMsg); } } /** * Parse the configuration file for names of days of the week from the "watering_start_time" * value. Expects a 24 hour ":" separated value, stores it as Joda LocalTime. * * @param config The configuration file. * @throws ConfigurationException If the configuration is invalid. */ public void processWateringStartTimeConfiguration() throws ConfigurationException { if (config.getProperty("watering_start_time") == null) { throw new ConfigurationException( "ERROR: Could not read watering_start_time from configuration file"); } final String startTimeString = config.getProperty("watering_start_time"); // Expect 24 hour time format ie 23:10 for 11:10PM. final String[] timeSplit = startTimeString.split(":"); try { final int hour = Integer.parseInt(timeSplit[0]); final int minute = Integer.parseInt(timeSplit[1]); final LocalTime wateringStartTime = new LocalTime(hour, minute); this.wateringStartTime = wateringStartTime; } catch (final NumberFormatException e) { throw new ConfigurationException("ERROR: Unable to parse watering_start_time value"); } } public void processWeatherStationConfiguration() throws ConfigurationException { weatherStations.clear(); for (int i = 1; i < Integer.MAX_VALUE; i++) { final String weatherStationValue = "weatherstation" + i; final String weatherStationName = config.getProperty(weatherStationValue + "_name"); if (weatherStationName == null) { // No more weather stations to parse. break; } else { // Instantiate weather station based on weatherstation_type value. if (config.getProperty(weatherStationValue + "_type") == null) { throw new ConfigurationException("ERROR: " + "no weather station type for " + weatherStationValue + "_type"); } WeatherStation w = null; final String weatherStationType = config.getProperty(weatherStationValue + "_type"); for (final WeatherStationType type : WeatherStation.WeatherStationType.values()) { if (weatherStationType.toUpperCase().matches(type.name())) { if (type.name().matches("WUNDERGROUND")) { if (config.getProperty(weatherStationValue + "_stationid") == null) { throw new ConfigurationException("ERROR: " + "no stationid type for " + weatherStationValue + "_stationid"); } final String stationId = config.getProperty(weatherStationValue + "_stationid"); if (config.getProperty(weatherStationValue + "_api") == null) { throw new ConfigurationException("ERROR: " + "no api key for " + weatherStationValue + "_api"); } final String api = config.getProperty(weatherStationValue + "_api"); w = new WeatherUndergroundStation(weatherStationName, api, stationId); } } } if (w == null) { throw new ConfigurationException("ERROR: " + weatherStationType + " is not a valid weather station type for " + weatherStationValue + "_type"); } // Weather Station configuration has passed, add it to the list of weatherstations. weatherStations.add(w); } } } public void processWeatherThresholdConfiguration() throws ConfigurationException { final String NO_WEATHER_STATION = "ERROR: cannot have weather_threshold values without a weatherstation configured"; if (config.getProperty("weather_threshold_rain_days_to_look_back") != null) { if (weatherStations.isEmpty()) { throw new ConfigurationException(NO_WEATHER_STATION); } if (config.getProperty("weather_threshold_total_rain_amount") == null) { throw new ConfigurationException( "ERROR: must supply a weather_threshold_total_rain_amount value"); } rainDaysToLookBack = Math.abs(Integer.parseInt(config .getProperty("weather_threshold_rain_days_to_look_back"))); final String totalRain = config.getProperty("weather_threshold_total_rain_amount"); final String delimiter = totalRain.substring(totalRain.length() - 2); totalRainAmountThresholdInMilliMetres = Math.abs(Double.parseDouble(totalRain.substring(0, totalRain.length() - 2))); if (delimiter.equalsIgnoreCase("in")) { totalRainAmountThresholdInMilliMetres = (totalRainAmountThresholdInMilliMetres * 25.4); } else if (!delimiter.equalsIgnoreCase("mm")) { throw new ConfigurationException( "ERROR: must supply a delimiter of \"mm\" or \"in\" to weather_threshold_total_rain_amount value"); } } if (config.getProperty("weather_threshold_current_rain_amount") != null) { if (weatherStations.isEmpty()) { throw new ConfigurationException(NO_WEATHER_STATION); } final String currentRain = config.getProperty("weather_threshold_current_rain_amount"); final String delimiter = currentRain.substring(currentRain.length() - 2); currentRainAmountThresholdInMilliMetres = Math.abs(Double.parseDouble(currentRain.substring( 0, currentRain.length() - 2))); if (delimiter.equalsIgnoreCase("in")) { currentRainAmountThresholdInMilliMetres = (currentRainAmountThresholdInMilliMetres * 25.4); } else if (!delimiter.equalsIgnoreCase("mm")) { throw new ConfigurationException( "ERROR: must supply a delimiter of \"mm\" or \"in\" to weather_threshold_current_rain_amount"); } } if (config.getProperty("weather_threshold_wind_speed") != null) { if (weatherStations.isEmpty()) { throw new ConfigurationException(NO_WEATHER_STATION); } final String currentWind = config.getProperty("weather_threshold_wind_speed"); final String delimiter = currentWind.substring(currentWind.length() - 3); currentWindThresholdInKmph = Math.abs(Double.parseDouble(currentWind.substring(0, currentWind.length() - 3))); if (delimiter.equalsIgnoreCase("mph")) { currentWindThresholdInKmph = (currentWindThresholdInKmph * 1.6); } else if (!delimiter.equalsIgnoreCase("kph")) { throw new ConfigurationException( "ERROR: must supply a delimiter of \"kph\" or \"mph\" to weather_threshold_wind_speed"); } } if (config.getProperty("weather_threshold_max_temp") != null) { if (weatherStations.isEmpty()) { throw new ConfigurationException(NO_WEATHER_STATION); } final String maxTemp = config.getProperty("weather_threshold_max_temp"); final String delimiter = maxTemp.substring(maxTemp.length() - 1); maxTempThresholdInCelcius = Double.parseDouble(maxTemp.substring(0, maxTemp.length() - 1)); if (delimiter.equalsIgnoreCase("F")) { maxTempThresholdInCelcius = (maxTempThresholdInCelcius - 32) * (5.0 / 9.0); } else if (!delimiter.equalsIgnoreCase("C")) { throw new ConfigurationException( "ERROR: must supply a delimiter of \"C\" or \"F\" to weather_threshold_max_temp"); } } if (config.getProperty("weather_threshold_min_temp") != null) { if (weatherStations.isEmpty()) { throw new ConfigurationException(NO_WEATHER_STATION); } final String minTemp = config.getProperty("weather_threshold_min_temp"); final String delimiter = minTemp.substring(minTemp.length() - 1); minTempThresholdInCelcius = Double.parseDouble(minTemp.substring(0, minTemp.length() - 1)); if (delimiter.equalsIgnoreCase("F")) { minTempThresholdInCelcius = (minTempThresholdInCelcius - 32) * (5.0 / 9.0); } else if (!delimiter.equalsIgnoreCase("C")) { throw new ConfigurationException( "ERROR: must supply a delimiter of \"C\" or \"F\" to weather_threshold_min_temp"); } } if (config.getProperty("weather_threshold_rain_days_to_look_ahead") != null) { if (weatherStations.isEmpty()) { throw new ConfigurationException(NO_WEATHER_STATION); } if (config.getProperty("weather_threshold_pop") == null) { throw new ConfigurationException("ERROR: must supply a weather_threshold_pop value"); } rainDaysToLookAhead = Math.abs(Integer.parseInt(config .getProperty("weather_threshold_rain_days_to_look_ahead"))); popThreshold = Math.abs(Integer.parseInt(config.getProperty("weather_threshold_pop"))); } } public void startIrrigator() { final DateTimeFormatter formatter = DateTimeFormat.forPattern("dd/MM/yyyy HH:mm"); LOG.info("Irrigator v" + VERSION + " started"); LOG.info("Next irrigation due at " + nextIrrigationAt().toString(formatter)); while (true) { if (!isIrrigating() && timeToIrrigate()) { for (final Controller c : controllers) { if (c.isActive()) { c.irrigationRequest(c.generateDefaultIrrigationRequest(getWeatherMultiplier())); } } LOG.info("Next irrigation due at " + nextIrrigationAt().toString(formatter)); } try { // Try to irrigate once a minute. Thread.sleep(60000); } catch (final InterruptedException e) { System.out.println("Thread interrupted: " + e.getMessage()); System.exit(1); } } } protected boolean thresholdsExceeded() { for (final WeatherStation ws : weatherStations) { if (currentRainAmountThresholdInMilliMetres <= ws.getTodaysRainfallMilliLitres()) { LOG.info("Threshold exceeded: today's rainfall " + ws.getTodaysRainfallMilliLitres() + "mm greater than threshold of " + currentRainAmountThresholdInMilliMetres + "mm"); return true; } if (currentWindThresholdInKmph <= ws.getCurrentWindspeedKiloMetresPerHour()) { LOG.info("Threshold exceeded: current windspeed " + ws.getCurrentWindspeedKiloMetresPerHour() + "kph greater than threshold of " + currentWindThresholdInKmph + "kph"); return true; } if (maxTempThresholdInCelcius <= ws.getCurrentTemperatureCelcius()) { LOG.info("Threshold exceeded: current temperature " + ws.getCurrentTemperatureCelcius() + "C greater than threshold of " + maxTempThresholdInCelcius + "C"); return true; } if (minTempThresholdInCelcius >= ws.getCurrentTemperatureCelcius()) { LOG.info("Threshold exceeded: current temperature " + ws.getCurrentTemperatureCelcius() + "C less than threshold of " + minTempThresholdInCelcius + "C"); return true; } if (rainDaysToLookBack > 0) { if (totalRainAmountThresholdInMilliMetres <= ws .getLastXDaysRainfallMilliLitres(rainDaysToLookBack)) { LOG.info("Threshold exceeded: last " + rainDaysToLookBack + " days rainfall " + ws.getLastXDaysRainfallMilliLitres(rainDaysToLookBack) + "mm greater than threshold of " + totalRainAmountThresholdInMilliMetres + "mm"); return true; } } if (rainDaysToLookAhead > 0) { if (popThreshold <= ws.getNextXDaysPercentageOfPrecipitation(rainDaysToLookAhead)) { LOG.info("Threshold exceeded: next " + rainDaysToLookAhead + " days PoP " + ws.getNextXDaysPercentageOfPrecipitation(rainDaysToLookAhead) + "% greater than threshold of " + popThreshold + "%"); return true; } } } return false; } /** * Determines whether or not it is appropriate to irrigate based on the watering_days, * watering_start_time and watering_threshold values in the configuration. Used by the * {@code #startIrrigator()} worker thread. * * @return true to irrigate. */ protected boolean timeToIrrigate() { final LocalTime lt = new LocalTime(); for (final int dayOfWeek : wateringDays) { if (dayOfWeek == (lt.toDateTimeToday().getDayOfWeek())) { if (wateringStartTime.getHourOfDay() == lt.getHourOfDay()) { if (wateringStartTime.getMinuteOfHour() == lt.getMinuteOfHour()) { LOG.info("It's time to irrigate!"); if (thresholdsExceeded()) { LOG.info("Irrigation cancelled due to threshold being exceeded"); return false; } else { LOG.info("Thresholds all ok"); return true; } } } } } return false; } public void processWeatherMultiplierConfiguration() throws ConfigurationException { final String NO_WEATHER_STATION = "ERROR: cannot have weather_multiplier values without a weatherstation configured"; // If any of the multiplier values are configured then try and process a multiplier // configuration. if ((config.getProperty("weather_multiplier_value") != null) || (config.getProperty("weather_multiplier_max_temp") != null) || (config.getProperty("weather_multiplier_days_to_look_ahead") != null)) { // Must have a weather station to look at weather values if (weatherStations.isEmpty()) { throw new ConfigurationException(NO_WEATHER_STATION); } // Check that ALL values have been populated for the weather multiplier. try { weatherMultiplierValue = Math.abs(Double.parseDouble(config .getProperty("weather_multiplier_value"))); } catch (final NumberFormatException e) { throw new ConfigurationException( "ERROR: must supply a valid weather_multiplier_value value"); } try { final String maxTemp = config.getProperty("weather_multiplier_max_temp"); final String delimiter = maxTemp.substring(maxTemp.length() - 1); maxTempThresholdInCelcius = Double.parseDouble(maxTemp.substring(0, maxTemp.length() - 1)); if (delimiter.equalsIgnoreCase("F")) { maxTempThresholdInCelcius = (maxTempThresholdInCelcius - 32) * (5.0 / 9.0); } else if (!delimiter.equalsIgnoreCase("C")) { throw new ConfigurationException( "ERROR: must supply a delimiter of \"C\" or \"F\" to weather_multiplier_max_temp"); } weatherMultiplierMaxTemp = maxTempThresholdInCelcius; } catch (final NumberFormatException e) { throw new ConfigurationException( "ERROR: must supply a valid weather_multiplier_max_temp value"); } try { weatherMultiplierDaysToLookAhead = Math.abs(Integer.parseInt(config .getProperty("weather_multiplier_days_to_look_ahead"))); } catch (final NumberFormatException e) { throw new ConfigurationException( "ERROR: must supply a valid weather_multiplier_days_to_look_ahead value"); } } } public double getWeatherMultiplier() { for (final WeatherStation ws : weatherStations) { if (ws.getNextXDaysMaxTempCelcius(weatherMultiplierDaysToLookAhead) >= weatherMultiplierMaxTemp) { LOG.info("Weather multiplier of " + weatherMultiplierValue + " triggered due to max temp of " + ws.getNextXDaysMaxTempCelcius(weatherMultiplierDaysToLookAhead) + "C over the next " + weatherMultiplierDaysToLookAhead + " days."); return weatherMultiplierValue; } } return 1.0; } public double getWeatherMultiplierMaxTemp() { return this.weatherMultiplierMaxTemp; } public double getWeatherMultiplierValue() { return weatherMultiplierValue; } public int getWeatherMultiplierDaysToLookAhead() { return weatherMultiplierDaysToLookAhead; } private void startWeatherWatcher() { final Thread purger = new WeatherWatcher(); purger.setName("WeatherWatcher"); purger.setPriority(Thread.MIN_PRIORITY); purger.start(); } /** * Helper thread to periodically check if weather thresholds have been * exceeded and if true then stop any active irrigation. Requires at least one * valid valid {@link WeatherStation} to do anything. * */ private class WeatherWatcher extends Thread { @Override public void run() { final int THREAD_SLEEP = 60000; // Check every 1 minute. for (;;) { try { Thread.sleep(THREAD_SLEEP); // No point checking if we have no weather stations. if (!getWeatherStations().isEmpty()) { if(thresholdsExceeded()) { for(final Controller c : getControllers()) { if(c.isIrrigating()) { LOG.info("Threshold exceeded, stopping active irrigation for Controller: " + c.getName()); c.stopIrrigation(); } } } } } catch (final Exception e) { LOG.error("WeatherWatcher: " + e.getMessage()); } } } } }