/* * ScheduleItem.java - Copyright(c) 2013 Joe Pasqua * Provided under the MIT License. See the LICENSE file for details. * Created: Sep 7, 2013 */ package org.noroomattheinn.visibletesla; import com.google.common.collect.BiMap; import com.google.common.collect.HashBiMap; import it.sauronsoftware.cron4j.Scheduler; import java.util.Locale; import java.util.Map; import java.util.prefs.Preferences; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.event.ActionEvent; import javafx.event.EventHandler; import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.ButtonBase; import javafx.scene.control.CheckBox; import javafx.scene.control.ComboBox; import javafx.scene.layout.HBox; import org.noroomattheinn.fxextensions.TimeSelector; import org.noroomattheinn.utils.Utils; import org.noroomattheinn.visibletesla.dialogs.NotifyOptionsDialog; import org.noroomattheinn.visibletesla.dialogs.SetChargeDialog; import org.noroomattheinn.visibletesla.dialogs.SetTempDialog; import static org.noroomattheinn.tesla.Tesla.logger; /** * ScheduleItem: Represents a single item to be scheduled. * TO DO: Right now this class directly persists state to the preference * store. It gets the appropriate key and the pref store from the "owner". * Instead it should just be calling the owner and asking it to read and write * persistent state. * @author joe at NoRoomAtTheInn dot org */ class ScheduleItem implements EventHandler<ActionEvent> { /*------------------------------------------------------------------------------ * * Constants and Enums * *----------------------------------------------------------------------------*/ enum Command { HVAC_ON, HVAC_OFF, CHARGE_ON, CHARGE_OFF, CHARGE_SET, AWAKE, SLEEP, UNPLUGGED, SET, MESSAGE, None} private static final BiMap<Command, String> commandMap = HashBiMap.create(); static { commandMap.put(Command.HVAC_ON, "HVAC: On"); commandMap.put(Command.HVAC_OFF, "HVAC: Off"); commandMap.put(Command.CHARGE_SET, "Charge: Set"); commandMap.put(Command.CHARGE_ON, "Charge: Start"); commandMap.put(Command.CHARGE_OFF, "Charge: Stop"); commandMap.put(Command.AWAKE, "Awake"); commandMap.put(Command.SLEEP, "Sleep"); commandMap.put(Command.UNPLUGGED, "Unplugged?"); commandMap.put(Command.MESSAGE, "Message"); commandMap.put(Command.SET, "Set Value"); } // the following map is here to keep track of any updates to the command names. // We store the command names in the prefs file so we need to track // any changes so that when we internalize, we get the new names, not the old ones. private static final Map<String,String> UpdatedCommandNames = Utils.newHashMap( "HVAC On", "HVAC: On", "HVAC Off", "HVAC: Off", "Start Charging", "Charge: Start", "Stop Charging", "Charge: Stop", "Charge: Std", "None", // Obsolete command "Charge: Max", "None", // Obsolete command "Charge: Low", "None", // Obsolete command "Daydream", "None" ); private static final String SchedulerMsgKey = "SCHEDMSG"; private static final String DefaultSubject = "Scheduled Message"; private static final String DefaultMessage = "SOC: {{SOC}}%\n{{LOC}}\n"; interface ScheduleOwner { String getExternalKey(); Preferences getPreferences(); void runCommand(ScheduleItem.Command command, double value, MessageTarget mt); App app(); Prefs prefs(); boolean useDegreesF(); } /*------------------------------------------------------------------------------ * * Internal State * *----------------------------------------------------------------------------*/ // The UI Elements comprising a ScheduleItem private final CheckBox enabled; private final CheckBox[] days = new CheckBox[7]; private final TimeSelector time; private final CheckBox once; private final ComboBox<String> command; private final Button options; private final int id; // Externally assigned ID of this instance private String schedulerID = null; // ID of the cron4j instance private final ScheduleOwner owner; private double targetValue = -1; private MessageTarget messageTarget = null; private boolean internalizing = false; private static final Scheduler scheduler = new Scheduler(); static { // Register with appContext so we can quit it upon exit scheduler.setDaemon(true); } /*============================================================================== * ------- ------- * ------- Public Interface To This Class ------- * ------- ------- *============================================================================*/ /** * Instantiate a ScheduleItem object with the specified ID and a * row of UI elements representing the controls for the scheduler * The controls are each associated with a unique int which is the key * to the row map. The elements are: * 0: The enabled CheckBox * 1-7: The days of the week CheckBoxes (Sun=0 ... Sat=7) * 8: An HBox containing three ComboBoxes: Hour, Min, AM/PM * 9: The "Once" CheckBox * 10: An HBox containing the command ComboBox and a "+" button for options * @param id The externally assigned ID of this row of controls * @param row A map containing the row of controls as described above */ ScheduleItem(int id, Map<Integer,Node> row, final ScheduleOwner owner) { this.id = id; this.owner = owner; enabled = (CheckBox)prepNode(row.get(0)); for (int i = 0; i < 7; i++) { days[i] = (CheckBox)prepNode(row.get(i+1)); } HBox hbox = (HBox)row.get(8); time = new TimeSelector( Utils.<ComboBox<String>>cast(prepNode(hbox.getChildren().get(0))), Utils.<ComboBox<String>>cast(prepNode(hbox.getChildren().get(1))), Utils.<ComboBox<String>>cast(prepNode(hbox.getChildren().get(2)))); once = (CheckBox)prepNode(row.get(9)); hbox = (HBox)row.get(10); command = Utils.<ComboBox<String>>cast(prepNode(hbox.getChildren().get(0))); options = (Button)(hbox.getChildren().get(1)); enabled.selectedProperty().addListener(new ChangeListener<Boolean>() { @Override public void changed(ObservableValue<? extends Boolean> ov, Boolean t, Boolean t1) { enableItems(t1); } }); prepOptionsHandler(command.valueProperty().get()); command.valueProperty().addListener(new ChangeListener<String>() { @Override public void changed(ObservableValue<? extends String> ov, String t, String t1) { prepOptionsHandler(t1); } }); enableItems(enabled.isSelected()); } static void stop() { if (scheduler.isStarted()) { scheduler.stop(); } } /** * We're shutting down, do any necessary cleanup */ void shutDown() { if (schedulerID != null) { scheduler.deschedule(schedulerID); } } void loadExistingSchedule() { internalizing = true; // Load any saved value for this ScheduleItem String key = getFullKey(); String encoded = owner.getPreferences().get(key, null); if (encoded != null) { internalize(encoded); startScheduler(); } internalizing = false; } private MessageTarget loadMessageTarget() { String baseKey = String.format("%s%02d", SchedulerMsgKey, id); return new MessageTarget( owner.prefs(), owner.getExternalKey()+"_MT_"+baseKey, DefaultSubject, DefaultMessage); } private static Command nameToCommand(String commandName) { Command cmd = commandMap.inverse().get(commandName); return (cmd == null) ? Command.None : cmd; } static String commandToName(Command cmd) { return commandMap.get(cmd); } /*------------------------------------------------------------------------------ * * PRIVATE Methods for handling options of various types * *----------------------------------------------------------------------------*/ private void prepOptionsHandler(String forCommand) { if (forCommand.equals(commandMap.get(Command.HVAC_ON))) { options.setVisible(true); options.setOnAction(getTempOptions); } else if (forCommand.equals(commandMap.get(Command.CHARGE_SET))) { options.setVisible(true); options.setOnAction(getChargeOptions); } else if (forCommand.equals(commandMap.get(Command.CHARGE_ON))) { options.setVisible(true); options.setOnAction(getChargeOptions); } else if (forCommand.equals(commandMap.get(Command.MESSAGE))) { options.setVisible(true); options.setOnAction(getMessageTarget); } else { options.setVisible(false); } } private EventHandler<ActionEvent> getMessageTarget = new EventHandler<ActionEvent>() { @Override public void handle(ActionEvent t) { if (messageTarget == null) messageTarget = loadMessageTarget(); NotifyOptionsDialog nod = NotifyOptionsDialog.show( "Message Options", owner.app().stage, messageTarget.getEmail(), messageTarget.getSubject(), messageTarget.getMessage()); if (!nod.cancelled()) { if (!nod.useDefault()) { messageTarget.setEmail(nod.getEmail()); messageTarget.setSubject(nod.getSubject()); messageTarget.setMessage(nod.getMessage()); } else { messageTarget.setEmail(null); messageTarget.setSubject(null); messageTarget.setMessage(null); } messageTarget.externalize(); } } }; EventHandler<ActionEvent> getChargeOptions = new EventHandler<ActionEvent>() { @Override public void handle(ActionEvent e) { SetChargeDialog scd = SetChargeDialog.show( owner.app().stage, targetValue); if (!scd.cancelled()) { if (!scd.useCarsValue()) { targetValue = scd.getValue(); } else { targetValue = -1; } doExternalize(); } } }; EventHandler<ActionEvent> getTempOptions = new EventHandler<ActionEvent>() { @Override public void handle(ActionEvent e) { SetTempDialog std = SetTempDialog.show( owner.app().stage, owner.useDegreesF(), targetValue); if (!std.cancelled()) { if (!std.useCarsValue()) { targetValue = std.getValue(); } else { targetValue = -1; } doExternalize(); } } }; /*------------------------------------------------------------------------------ * * PRIVATE Methods for storing/loading schedules to/from external storage * *----------------------------------------------------------------------------*/ private String externalize() { StringBuilder sb = new StringBuilder(); sb.append(onOff(enabled.isSelected())); sb.append('_'); for (int i = 0; i < 7; i++) { sb.append(onOff(days[i].isSelected())); sb.append('_'); } sb.append(String.format("%04d_", time.getHoursAndMinutes())); sb.append(onOff(once.isSelected())); sb.append('_'); sb.append(command.getSelectionModel().selectedItemProperty().getValue()); sb.append("_"); sb.append(String.format(Locale.US, "%3.1f", targetValue)); return sb.toString(); } private void internalize(String encoded) { // 0 1 2 3 4 5 6 7 8 9 10 [11] // On? Mon Tue Wed Thu Fri Sat Sun Time Min Command [Value] // {0|1}_{0|1}_{0|1}_{0|1}_{0|1}_{0|1}_{0|1}_{0|1}_HHMM_{0|1}_COMMAND_[Double] String[] elements = encoded.split("_"); enabled.setSelected(elements[0].equals("1")); for (int i = 0; i < 7; i++) { days[i].setSelected(elements[i + 1].equals("1")); } time.setHoursAndMinutes(Integer.valueOf(elements[8])); once.setSelected(elements[9].equals("1")); command.getSelectionModel().select(properCommandName(elements[10])); if (elements.length == 12) { try { targetValue = Double.valueOf(elements[11]); } catch (NumberFormatException e) { logger.severe("Bad value for command target: " + elements[11]); targetValue = -1; } } } private String properCommandName(String cmd) { String newName = UpdatedCommandNames.get(cmd); return (newName == null) ? cmd : newName; } private String onOff(boolean b) { return b ? "1" : "0"; } /*------------------------------------------------------------------------------ * * PRIVATE Methods that interface with the actual scheduler library (cron4j) * *----------------------------------------------------------------------------*/ private void startScheduler() { if (schedulerID != null) { scheduler.deschedule(schedulerID); } if (!enabled.isSelected()) { return; } String pattern = getSchedulePattern(); if (pattern.isEmpty()) { return; } schedulerID = scheduler.schedule(pattern, new Runnable() { @Override public void run() { Command cmd = nameToCommand(command.getValue()); if (!enabled.isSelected() || cmd == Command.None) return; if (!(cmd == Command.CHARGE_ON || cmd == Command.HVAC_ON || cmd == Command.CHARGE_SET)) targetValue = -1; if (cmd == Command.MESSAGE && messageTarget == null) { messageTarget = loadMessageTarget(); } owner.runCommand(cmd, targetValue, messageTarget); if (once.isSelected()) { enabled.setSelected(false); enableItems(false); doExternalize(); } } }); if (!scheduler.isStarted()) scheduler.start(); } private String getSchedulePattern() { StringBuilder sb = new StringBuilder(); int theTime = time.getHoursAndMinutes(); sb.append(theTime % 100); sb.append(' '); sb.append(theTime / 100); sb.append(" * * "); boolean hasDays = false; for (int i = 0; i < 7; i++) { if (days[i].isSelected()) { sb.append(i); sb.append(','); hasDays = true; } } if (!hasDays) { return ""; } if (sb.charAt(sb.length() - 1) == ',') { sb.deleteCharAt(sb.length() - 1); } return sb.toString(); } /*------------------------------------------------------------------------------ * * Private Utility Methods - General * *----------------------------------------------------------------------------*/ private String getFullKey() { return String.format("%s_SCHED_%02d", owner.getExternalKey(), id); } private void doExternalize() { if (internalizing) return; String key = getFullKey(); String encoded = externalize(); owner.getPreferences().put(key, encoded); // Save the updated ScheduleItem } @Override public void handle(ActionEvent event) { doExternalize(); startScheduler(); // Start (or restart) the scheduler } private Node prepNode(Node n) { if (n instanceof ComboBox) { ComboBox<String> cbs = Utils.<ComboBox<String>>cast(n); cbs.setOnAction(this); cbs.setVisibleRowCount(13); } else if (n instanceof ButtonBase) { ((ButtonBase) n).setOnAction(this); } n.setStyle("-fx-focus-color: transparent;"); return n; } private void enableItems(boolean enable) { for (int i = 0; i < 7; i++) { days[i].setDisable(!enable); } time.enable(enable); once.setDisable(!enable); command.setDisable(!enable); options.setDisable(!enable); } }