/* * NotifierController.java - Copyright(c) 2013 Joe Pasqua * Provided under the MIT License. See the LICENSE file for details. * Created: Dec 6, 2013 */ package org.noroomattheinn.visibletesla; import java.io.IOException; import java.math.BigDecimal; import java.net.URL; import java.net.URLConnection; import java.text.DecimalFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Locale; import java.util.Map; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.CheckBox; import javafx.scene.control.Label; import javafx.scene.control.RadioButton; import javafx.scene.control.Slider; import jfxtras.labs.scene.control.BigDecimalField; import org.apache.commons.lang3.StringUtils; import org.noroomattheinn.tesla.ChargeState; import org.noroomattheinn.tesla.StreamState; import org.noroomattheinn.tesla.Vehicle; import org.noroomattheinn.utils.GeoUtils; import org.noroomattheinn.utils.MailGun; import org.noroomattheinn.utils.ThreadManager; import org.noroomattheinn.utils.Utils; import org.noroomattheinn.visibletesla.dialogs.ChooseLocationDialog; import org.noroomattheinn.visibletesla.dialogs.NotifyOptionsDialog; import org.noroomattheinn.visibletesla.trigger.DeviationTrigger; import org.noroomattheinn.visibletesla.trigger.GenericTrigger; import org.noroomattheinn.visibletesla.trigger.StationaryTrigger; import static org.noroomattheinn.tesla.Tesla.logger; /** * NotifierController * * @author Joe Pasqua <joe at NoRoomAtTheInn dot org> */ public class NotifierController extends BaseController { /*------------------------------------------------------------------------------ * * Constants, Enums, and Types * *----------------------------------------------------------------------------*/ private static class GeoTrigger { GenericTrigger<GeoUtils.CircularArea> trigger; MessageTarget messageTarget; ObjectProperty<GeoUtils.CircularArea> prop = new SimpleObjectProperty<>(new GeoUtils.CircularArea()); Button defineArea; Button optionsButton; CheckBox enabled; GeoTrigger(Button options, Button defArea, CheckBox enabled) { this.optionsButton = options; this.defineArea = defArea; this.enabled = enabled; } } private static class StringList extends ArrayList<String> { StringList(String s) { super(); add(s); } StringList() { super(); } } private static final long TypicalDebounce = 10 * 60 * 1000; // 10 Minutes private static final long SpeedDebounce = 30 * 60 * 1000; // 30 Minutes private static final long GeoDebounce = 30 * 1000; // 30 Seconds private static final long UnlockedThreshold = 10; // 10 Minutes private static final String NotifySEKey = "NOTIFY_SE"; private static final String NotifyCSKey = "NOTIFY_CS"; private static final String NotifyCAKey = "NOTIFY_CA"; private static final String NotifyULKey = "NOTIFY_UL"; private static final String NotifyULValKey = "NOTIFY_UL_VAL"; private static final String NotifySpeedKey = "NOTIFY_SPEED"; private static final String NotifySOCHitsKey = "NOTIFY_SOC_HITS"; private static final String NotifySOCFallsKey = "NOTIFY_SOC_FALLS"; private static final String NotifyEnterKey = "NOTIFY_ENTER_AREA"; private static final String NotifyLeftKey = "NOTIFY_LEFT_AREA"; private static final String NotifyOdoKey = "NOTIFY_ODO"; private static final String OdoCheckKey = "LAST_ODO_CHECK"; private static final String UnlockedSubj = "Unlocked at {{TIME}}"; private static final String UnlockedMsg = "Unlocked at {{TIME}} for {{CUR}} minutes"; private static final String OdoHitsSubj = "Odometer: {{CUR}}"; private static final String OdoHitsMsg = "Odometer Past: {{TARGET}} {{D_UNITS}} ({{CUR}})"; private static final String SOCHitSubj = "SOC: {{CUR}}%"; private static final String SOCHitMsg = "SOC Hit or Exceeded: {{TARGET}}% ({{CUR}}%)"; private static final String SOCFellSubj = "SOC: {{CUR}}%"; private static final String SOCFellMsg = "SOC Fell Below: {{TARGET}}% ({{CUR}}%)"; private static final String SpeedHitSubj = "Speed: {{SPEED}} {{S_UNITS}}"; private static final String SpeedHitMsg = "Speed Hit or Exceeded: {{TARGET}} {{S_UNITS}} ({{SPEED}})"; private static final String SchedEventSubj = "Scheduled Event: {{CUR}}"; private static final String SchedEventMsg = "Scheduled Event: {{CUR}}"; private static final String ChargeStateSubj = "Charge State: {{CHARGE_STATE}}"; private static final String ChargeStateMsg = "Charge State: {{CHARGE_STATE}}" + "\nSOC: {{SOC}}%" + "\nRange: {{RATED}} {{D_UNITS}}" + "\nEstimated Range: {{ESTIMATED}} {{D_UNITS}}" + "\nIdeal Range: {{IDEAL}} {{D_UNITS}}"; private static final String ChargeAnomalySubj = "Charge Anomaly"; private static final String ChargeAnomalyMsg = "{{A_CT}} current deviated significantly from baseline" + "\nBaseline: {{TARGET}}A" + "\nMost Recent: {{CUR}}A"; private static final String EnterAreaSubj = "Entered {{TARGET}}"; private static final String EnterAreaMsg = "Entered {{TARGET}}"; private static final String LeftAreaSubj = "Left {{TARGET}}"; private static final String LeftAreaMsg = "Left {{TARGET}}"; /*------------------------------------------------------------------------------ * * Internal State * *----------------------------------------------------------------------------*/ private GenericTrigger<BigDecimal> speedHitsTrigger; private MessageTarget shMessageTarget; private GenericTrigger<BigDecimal> odoHitsTrigger; private MessageTarget ohMessageTarget; private double lastOdoCheck; private GenericTrigger<String> seTrigger; private MessageTarget seMessageTarget; private GenericTrigger<BigDecimal> socHitsTrigger; private MessageTarget socHitsMessageTarget; private GenericTrigger<BigDecimal> socFallsTrigger; private MessageTarget socFallsMessageTarget; private GenericTrigger<StringList> csTrigger; private MessageTarget csMessageTarget; private ObjectProperty<StringList> csSelectProp = new SimpleObjectProperty<>(new StringList()); private boolean useMiles = true; private GeoTrigger[] geoTriggers = new GeoTrigger[8]; // Charge Anomoly Triggers private DeviationTrigger ccTrigger; private DeviationTrigger pcTrigger; private MessageTarget caMessageTarget; // Unlocked Trigger private StationaryTrigger unlockedTrigger; private MessageTarget ulMessageTarget; private boolean checkForUnlocked; /*------------------------------------------------------------------------------ * * UI Elements * *----------------------------------------------------------------------------*/ @FXML private CheckBox chargeState; @FXML private Button chargeBecomesOptions; @FXML private RadioButton csbAny, csbCharging, csbComplete, csbDisconnected, csbNoPower, csbStarting, csbStopped; @FXML private CheckBox schedulerEvent; @FXML private Button seOptions; @FXML private CheckBox socFalls; @FXML private BigDecimalField socFallsField; @FXML private Slider socFallsSlider; @FXML private Button socFallsOptions; @FXML private CheckBox socHits; @FXML private BigDecimalField socHitsField; @FXML private Slider socHitsSlider; @FXML private Button socHitsOptions; @FXML private CheckBox odoHits; @FXML private BigDecimalField odoHitsField; @FXML private Label odoHitsLabel; @FXML private Button odoHitsOptions; @FXML private CheckBox speedHits; @FXML private BigDecimalField speedHitsField; @FXML private Slider speedHitsSlider; @FXML private Label speedUnitsLabel; @FXML private Button speedHitsOptions; @FXML private CheckBox carEntered1, carEntered2, carEntered3, carEntered4; @FXML private Button defineEnterButton1, defineEnterButton2, defineEnterButton3, defineEnterButton4; @FXML private Button carEnteredOptions1, carEnteredOptions2, carEnteredOptions3, carEnteredOptions4; @FXML private CheckBox carLeft1, carLeft2, carLeft3, carLeft4; @FXML private Button defineLeftButton1, defineLeftButton2, defineLeftButton3, defineLeftButton4; @FXML private Button carLeftOptions1, carLeftOptions2, carLeftOptions3, carLeftOptions4; @FXML private CheckBox chargeAnomaly; @FXML private Button caOptions; @FXML private CheckBox unlocked; @FXML private Button unlockedOptions; @FXML private BigDecimalField unlockedDoorsField; @FXML private Slider unlockedDoorsSlider; /*------------------------------------------------------------------------------ * * UI Action Handlers * *----------------------------------------------------------------------------*/ @FXML void enabledEvent(ActionEvent event) { // TO DO: Remove this. This should happen automatically by the // Trigger which is listening for change events on the property // associated with the checkbox } @FXML void defineArea(ActionEvent event) { Button b = (Button)event.getSource(); for (GeoTrigger g: geoTriggers) { if (b == g.defineArea) { showAreaDialog(g.prop); return; } } logger.warning("Unexpected button: " + b.toString()); } @FXML void optionsButton(ActionEvent event) { Button b = (Button)event.getSource(); // Show the options Dialog if (b == odoHitsOptions) { showDialog(ohMessageTarget); } else if (b == chargeBecomesOptions) { showDialog(csMessageTarget); } else if (b == seOptions) { showDialog(seMessageTarget); } else if (b == socFallsOptions) { showDialog(socFallsMessageTarget); } else if (b == socHitsOptions) { showDialog(socHitsMessageTarget); } else if (b == speedHitsOptions) { showDialog(shMessageTarget); } else if (b == caOptions) { showDialog(caMessageTarget); } else if (b == unlockedOptions) { showDialog(ulMessageTarget); } else { for (GeoTrigger g: geoTriggers) { if (b == g.optionsButton) { showDialog(g.messageTarget); return; } } logger.warning("Unexpected button: " + b.toString()); } } @FXML void csbItemClicked(ActionEvent event) { RadioButton rb = (RadioButton)event.getSource(); StringList s = new StringList(); if (rb == csbAny && csbAny.isSelected()) { s.add("Anything"); csbCharging.setSelected(false); csbComplete.setSelected(false); csbDisconnected.setSelected(false); csbNoPower.setSelected(false); csbStarting.setSelected(false); csbStopped.setSelected(false); } else { if (csbCharging.isSelected()) s.add("Charging"); if (csbComplete.isSelected()) s.add("Complete"); if (csbDisconnected.isSelected()) s.add("Disconnected"); if (csbNoPower.isSelected()) s.add("No Power"); if (csbStarting.isSelected()) s.add("Starting"); if (csbStopped.isSelected()) s.add("Stopped"); csbAny.setSelected(false); } this.csSelectProp.set(s); } private ChangeListener<StringList> csPropListener = new ChangeListener<StringList>() { @Override public void changed(ObservableValue<? extends StringList> ov, StringList t, StringList t1) { if (t1.contains("Anything")) { csbAny.setSelected(true); csbCharging.setSelected(false); csbComplete.setSelected(false); csbDisconnected.setSelected(false); csbNoPower.setSelected(false); csbStarting.setSelected(false); csbStopped.setSelected(false); } else { if (t1.contains("Charging")) csbCharging.setSelected(true); if (t1.contains("Complete")) csbComplete.setSelected(true); if (t1.contains("Disconnected")) csbDisconnected.setSelected(true); if (t1.contains("No Power")) csbNoPower.setSelected(true); if (t1.contains("Starting")) csbStarting.setSelected(true); if (t1.contains("Stopped")) csbStopped.setSelected(true); } } }; private ChangeListener<Boolean> caListener = new ChangeListener<Boolean>() { @Override public void changed( ObservableValue<? extends Boolean> ov, Boolean old, Boolean cur) { prefs.storage().putBoolean(vinKey(NotifyCAKey), cur); } }; private ChangeListener<Boolean> ulListener = new ChangeListener<Boolean>() { @Override public void changed( ObservableValue<? extends Boolean> ov, Boolean old, Boolean cur) { prefs.storage().putBoolean(vinKey(NotifyULKey), cur); } }; private ChangeListener<BigDecimal> ulvListener = new ChangeListener<BigDecimal>() { @Override public void changed( ObservableValue<? extends BigDecimal> ov, BigDecimal old, BigDecimal cur) { prefs.storage().putLong(vinKey(NotifyULValKey), cur.longValue()); } }; /*------------------------------------------------------------------------------ * * Methods overriden from BaseController * *----------------------------------------------------------------------------*/ @Override protected void fxInitialize() { bindBidrectional(speedHitsField, speedHitsSlider); bindBidrectional(socHitsField, socHitsSlider); bindBidrectional(socFallsField, socFallsSlider); bindBidrectional(unlockedDoorsField, unlockedDoorsSlider); csSelectProp.addListener(csPropListener); chargeAnomaly.selectedProperty().addListener(caListener); unlocked.selectedProperty().addListener(ulListener); unlockedDoorsField.numberProperty().addListener(ulvListener); geoTriggers[0] = new GeoTrigger(carEnteredOptions1, defineEnterButton1, carEntered1); geoTriggers[1] = new GeoTrigger(carEnteredOptions2, defineEnterButton2, carEntered2); geoTriggers[2] = new GeoTrigger(carEnteredOptions3, defineEnterButton3, carEntered3); geoTriggers[3] = new GeoTrigger(carEnteredOptions4, defineEnterButton4, carEntered4); geoTriggers[4] = new GeoTrigger(carLeftOptions1, defineLeftButton1, carLeft1); geoTriggers[5] = new GeoTrigger(carLeftOptions2, defineLeftButton2, carLeft2); geoTriggers[6] = new GeoTrigger(carLeftOptions3, defineLeftButton3, carLeft3); geoTriggers[7] = new GeoTrigger(carLeftOptions4, defineLeftButton4, carLeft4); } @Override protected void initializeState() { lastOdoCheck = prefs.storage().getDouble(OdoCheckKey, 0); socHitsTrigger = new GenericTrigger<>( socHits.selectedProperty(), bdHelper, "SOC", NotifySOCHitsKey, GenericTrigger.Predicate.HitsOrExceeds, socHitsField.numberProperty(), new BigDecimal(88.0), TypicalDebounce); socHitsMessageTarget = new MessageTarget( prefs, vinKey("MT_"+NotifySOCHitsKey), SOCHitSubj, SOCHitMsg); socFallsTrigger = new GenericTrigger<>( socFalls.selectedProperty(), bdHelper, "SOC", NotifySOCFallsKey, GenericTrigger.Predicate.FallsBelow, socFallsField.numberProperty(), new BigDecimal(50.0), TypicalDebounce); socFallsMessageTarget = new MessageTarget( prefs, vinKey("MT_"+NotifySOCFallsKey), SOCFellSubj, SOCFellMsg); speedHitsTrigger = new GenericTrigger<>( speedHits.selectedProperty(), bdHelper, "Speed", NotifySpeedKey, GenericTrigger.Predicate.HitsOrExceeds, speedHitsField.numberProperty(), new BigDecimal(70.0), SpeedDebounce); shMessageTarget = new MessageTarget( prefs, vinKey("MT_"+NotifySpeedKey), SpeedHitSubj, SpeedHitMsg); odoHitsTrigger = new GenericTrigger<>( odoHits.selectedProperty(), bdHelper, "Odometer", NotifyOdoKey, GenericTrigger.Predicate.GT, odoHitsField.numberProperty(), new BigDecimal(14325), TypicalDebounce); ohMessageTarget = new MessageTarget( prefs, vinKey("MT_"+NotifyOdoKey), OdoHitsSubj, OdoHitsMsg); seTrigger = new GenericTrigger<>( schedulerEvent.selectedProperty(), stringHelper, "Scheduler", NotifySEKey, GenericTrigger.Predicate.AnyChange, new SimpleObjectProperty<>("Anything"), "Anything", 0L); seMessageTarget = new MessageTarget( prefs, vinKey("MT_"+NotifySEKey), SchedEventSubj, SchedEventMsg); csTrigger = new GenericTrigger<>( chargeState.selectedProperty(), stringListHelper, "Charge State", NotifyCSKey, GenericTrigger.Predicate.Becomes, csSelectProp, new StringList("Anything"), 0L); csMessageTarget = new MessageTarget( prefs, vinKey("MT_"+NotifyCSKey), ChargeStateSubj, ChargeStateMsg); for (int i = 0; i < 4; i++) { GeoTrigger gt = geoTriggers[i]; gt.trigger = new GenericTrigger<>( gt.enabled.selectedProperty(), areaHelper, "Enter GeoUtils.CircularArea", NotifyEnterKey+i, GenericTrigger.Predicate.HitsOrExceeds, gt.prop, new GeoUtils.CircularArea(), GeoDebounce); gt.messageTarget = new MessageTarget( prefs, vinKey("MT_"+NotifyEnterKey+i), EnterAreaSubj, EnterAreaMsg); } for (int i = 4; i < 8; i++) { GeoTrigger gt = geoTriggers[i]; gt.trigger = new GenericTrigger<>( gt.enabled.selectedProperty(), areaHelper, "Left GeoUtils.CircularArea", NotifyLeftKey+i, GenericTrigger.Predicate.FallsBelow, gt.prop, new GeoUtils.CircularArea(), GeoDebounce); gt.messageTarget = new MessageTarget( prefs, vinKey("MT_"+NotifyLeftKey+i), LeftAreaSubj, LeftAreaMsg); } for (final GeoTrigger g : geoTriggers) { String name = g.prop.get().name; if (name != null && !name.isEmpty()) { g.enabled.setText(name); } g.prop.addListener(new ChangeListener<GeoUtils.CircularArea>() { @Override public void changed(ObservableValue<? extends GeoUtils.CircularArea> ov, GeoUtils.CircularArea t, GeoUtils.CircularArea t1) { if (t1.name != null && !t1.name.isEmpty()) { g.enabled.setText(t1.name); } } }); } // Other types of trigger ccTrigger = new DeviationTrigger(0.19, 5 * 60 * 1000); pcTrigger = new DeviationTrigger(0.19, 5 * 60 * 1000); caMessageTarget = new MessageTarget( prefs, vinKey("MT_"+NotifyCAKey), ChargeAnomalySubj, ChargeAnomalyMsg); chargeAnomaly.setSelected( prefs.storage().getBoolean(vinKey(NotifyCAKey), false)); unlockedTrigger = new StationaryTrigger( unlocked.selectedProperty(), unlockedDoorsField.numberProperty()); ulMessageTarget = new MessageTarget( prefs, vinKey("MT_"+NotifyULKey), UnlockedSubj, UnlockedMsg); unlocked.setSelected( prefs.storage().getBoolean(vinKey(NotifyULKey), false)); unlockedDoorsField.numberProperty().set( new BigDecimal(prefs.storage().getLong( vinKey(NotifyULValKey), UnlockedThreshold))); checkForUnlocked = false; startListening(); } @Override protected void activateTab() { if (vtVehicle.unitType() == Utils.UnitType.Imperial) { speedUnitsLabel.setText("mph"); speedHitsSlider.setMin(0); speedHitsSlider.setMax(100); speedHitsSlider.setMajorTickUnit(25); speedHitsSlider.setMinorTickCount(4); odoHitsLabel.setText("miles"); } else { speedUnitsLabel.setText("km/h"); speedHitsSlider.setMin(0); speedHitsSlider.setMax(160); speedHitsSlider.setMajorTickUnit(30); speedHitsSlider.setMinorTickCount(2); odoHitsLabel.setText("km"); } } @Override protected void refresh() { } /*------------------------------------------------------------------------------ * * PRIVATE - Methods related to getting a geographic area from the user * *----------------------------------------------------------------------------*/ private void showAreaDialog(ObjectProperty<GeoUtils.CircularArea> areaProp) { String apiKey = prefs.useCustomGoogleAPIKey.get() ? prefs.googleAPIKey.get() : Prefs.GoogleMapsAPIKey; ChooseLocationDialog cld = ChooseLocationDialog.show(app.stage, areaProp.get(), apiKey); if (!cld.cancelled()) { areaProp.set(cld.getArea()); } } private void showDialog(MessageTarget mt) { NotifyOptionsDialog nod = NotifyOptionsDialog.show( "Message Options", app.stage, mt.getEmail(), mt.getSubject(), mt.getMessage()); if (!nod.cancelled()) { if (!nod.useDefault()) { mt.setEmail(nod.getEmail()); mt.setSubject(nod.getSubject()); mt.setMessage(nod.getMessage()); } else { mt.setEmail(null); mt.setSubject(null); mt.setMessage(null); } mt.externalize(); } } /*------------------------------------------------------------------------------ * * PRIVATE - Methods for detecting changes and testing triggers * *----------------------------------------------------------------------------*/ private void startListening() { vtVehicle.chargeState.addTracker(csListener); vtVehicle.streamState.addTracker(ssListener); vtVehicle.vehicleState.addTracker(vsListener); app.schedulerActivity.addTracker(schedListener); } private Runnable schedListener = new Runnable() { @Override public void run() { if (seTrigger.evalPredicate(app.schedulerActivity.get())) { notifyUser(seTrigger, seMessageTarget); } } }; private Runnable csListener = new Runnable() { private boolean withinNPercent(double actual, double expected, double percent) { double delta = expected * percent; return (actual >= expected - delta && actual <= expected+ delta); } private boolean worthChecking(ChargeState cs) { if (!cs.isCharging() || cs.fastChargerPresent) return false; double expectedPower = cs.chargerActualCurrent * cs.chargerVoltage; if (withinNPercent(cs.chargerPower, expectedPower, 10.0)); return false; } @Override public synchronized void run() { ChargeState cur = vtVehicle.chargeState.get(); if (cur.chargingState != ChargeState.Status.Unknown) { if (csTrigger.evalPredicate(new StringList(cur.chargingState.name()))) { notifyUser(csTrigger, csMessageTarget); } } // Handle charge anomalies if (chargeAnomaly.isSelected() && worthChecking(cur)) { if (ccTrigger.evalPredicate(cur.chargerActualCurrent)) { Map<String,String> contextSpecific = Utils.newHashMap( "CUR", String.format("%d", cur.chargerActualCurrent), "TARGET", String.format("%.0f", ccTrigger.getBaseline()), "A_CT", "Charge"); notifyUser(contextSpecific, caMessageTarget); } if (pcTrigger.evalPredicate(cur.chargerPilotCurrent)) { Map<String,String> contextSpecific = Utils.newHashMap( "CUR", String.format("%d", cur.chargerPilotCurrent), "TARGET", String.format("%.0f", pcTrigger.getBaseline()), "A_CT", "Pilot"); notifyUser(contextSpecific, caMessageTarget); } } } }; private Runnable ssListener = new Runnable() { @Override public void run() { StreamState cur = vtVehicle.streamState.get(); if (socHitsTrigger.evalPredicate(new BigDecimal(cur.soc))) { notifyUser(socHitsTrigger, socHitsMessageTarget); } if (socFallsTrigger.evalPredicate(new BigDecimal(cur.soc))) { notifyUser(socFallsTrigger, socFallsMessageTarget); } double speed = useMiles ? cur.speed : Utils.milesToKm(cur.speed); if (speedHitsTrigger.evalPredicate(new BigDecimal(speed))) { notifyUser(speedHitsTrigger, shMessageTarget); } if (vtVehicle.inProperUnits(lastOdoCheck) < odoHitsField.getNumber().doubleValue()) { double odo = vtVehicle.inProperUnits(cur.odometer); if (odoHitsTrigger.evalPredicate(new BigDecimal(odo))) { notifyUser(odoHitsTrigger, ohMessageTarget); // Store in miles, but convert & test relative to the GUI setting prefs.storage().putDouble(OdoCheckKey, cur.odometer); lastOdoCheck = cur.odometer; } } GeoUtils.CircularArea curLoc = new GeoUtils.CircularArea(cur.estLat, cur.estLng, 0, "Current Location"); for (GeoTrigger g : geoTriggers) { if (g.trigger.evalPredicate(curLoc)) { notifyUser(g.trigger, g.messageTarget); } } // Handle other triggers if (unlockedTrigger.evalPredicate(cur.speed, cur.shiftState())) { checkForUnlocked = true; updateState(Vehicle.StateType.Vehicle); } } }; private Runnable vsListener = new Runnable() { @Override public void run() { if (!checkForUnlocked) { return; } if (!vtVehicle.vehicleState.get().locked) { int minutes = unlockedDoorsField.numberProperty().getValue().intValue(); Map<String, String> contextSpecific = Utils.newHashMap( "CUR", String.format("%d", minutes), "TARGET", String.format("%d", minutes)); notifyUser(contextSpecific, ulMessageTarget); checkForUnlocked = false; } } }; private void notifyUser(Map<String,String> contextSpecific, MessageTarget target) { String addr = target.getActiveEmail(); String lower = addr.toLowerCase(); // Don't muck with the original addr. // URLs are case sensitive if (lower.startsWith("http://") || lower.startsWith("https://")) { (new HTTPAsyncGet(addr)).exec(); } if (lower.startsWith("command:")) { String command = StringUtils.remove(addr, "command:"); String args = target.getSubject(); if (args != null) args = (new MessageTemplate(args)).getMessage( app.api, vtVehicle, contextSpecific); String stdin = target.getMessage(); if (stdin != null) stdin = (new MessageTemplate(stdin)).getMessage( app.api, vtVehicle, contextSpecific); ThreadManager.get().launchExternal(command, args, stdin, 60 * 1000); logger.info("Executing external command for notification: " + command); } else { MessageTemplate mt = new MessageTemplate(target.getActiveMsg()); MessageTemplate st = new MessageTemplate(target.getActiveSubj()); MailGun.get().send( addr, st.getMessage(app.api, vtVehicle, contextSpecific), mt.getMessage(app.api, vtVehicle, contextSpecific)); } } private void notifyUser(GenericTrigger t, MessageTarget target) { Map<String,String> contextSpecific = Utils.newHashMap( "CUR", t.getCurrentVal(), "TARGET", t.getTargetVal()); notifyUser(contextSpecific, target); } private void bindBidrectional(final BigDecimalField bdf, final Slider slider) { bdf.setFormat(new DecimalFormat("##0.0")); bdf.setStepwidth(BigDecimal.valueOf(0.5)); bdf.setNumber(new BigDecimal(Utils.round(slider.getValue(), 1))); slider.valueProperty().addListener(new ChangeListener<Number>() { @Override public void changed( ObservableValue<? extends Number> ov, Number t, Number t1) { double val = Utils.round(t1.doubleValue(), 1); slider.setValue(val); bdf.setNumber(new BigDecimal(val)); } }); bdf.numberProperty().addListener(new ChangeListener<BigDecimal>() { @Override public void changed( ObservableValue<? extends BigDecimal> ov, BigDecimal t, BigDecimal t1) { double val = Utils.round(t1.doubleValue(), 1); slider.setValue(val); bdf.setNumber(new BigDecimal(val)); } }); } private static class HTTPAsyncGet implements Runnable { private static final int timeout = 5 * 1000; private URLConnection connection; HTTPAsyncGet(String urlString) { try { logger.info("HTTPAsyncGet new with url: " + urlString); URL url = new URL(urlString); connection = url.openConnection(); connection.setConnectTimeout(timeout); if (url.getUserInfo() != null) { String basicAuth = "Basic " + encode(url.getUserInfo().getBytes()); connection.setRequestProperty("Authorization", basicAuth); } } catch (IOException ex) { logger.warning("Problem with URL: " + ex); } } void exec() { logger.info("HTTPAsyncGet exec with url: " + connection.getURL()); ThreadManager.get().launch(this, "HTTPAsyncGet"); } @Override public void run() { try { logger.info("HTTPAsyncGet run with url: " + connection.getURL()); connection.getInputStream(); } catch (IOException ex) { logger.warning("Problem with HTTP Get: " + ex); } } private String encode(byte[] bytes) { return Utils.toB64(bytes); } } /*------------------------------------------------------------------------------ * * Private Methods for internalizing, externalizing, and comparing target values * *----------------------------------------------------------------------------*/ private abstract class Persistence<T> implements GenericTrigger.RW<T> { @Override public void persist(String key, String value) { prefs.storage().put(vinKey(key), value); } @Override public String load(String key, String dflt) { return prefs.storage().get(vinKey(key), "0_" + dflt); } } private GenericTrigger.RW<StringList> stringListHelper = new StringListRW(); private class StringListRW extends Persistence<StringList> { @Override public int compare(StringList value, StringList candidates) { if (value == null || value.isEmpty()) return -1; if (candidates == null || candidates.isEmpty()) return -1; String curVal = value.get(0); for (String candidate : candidates) { if (candidate.equals(curVal)) return 0; } return -1; } @Override public String toExternal(StringList list) { StringBuilder sb = new StringBuilder(); int nItems = list.size(); for (int i = 0; i < nItems; i++) { sb.append(list.get(i)); if (i < nItems-1) sb.append("^"); } return sb.toString(); } @Override public StringList fromExternal(String external) { StringList l = new StringList(); if (external != null) { String[] items = external.split("\\^"); l.addAll(Arrays.asList(items)); } return l; } @Override public String formatted(StringList list) { StringBuilder sb = new StringBuilder(); int nItems = list.size(); sb.append('('); for (int i = 0; i < nItems; i++) { sb.append(list.get(i)); if (i < nItems-1) sb.append(", "); } sb.append(')'); return sb.toString(); } @Override public boolean isAny(StringList value) { if (value == null || value.isEmpty()) return false; return value.get(0).equals("Anything"); } }; private GenericTrigger.RW<BigDecimal> bdHelper = new BigDecimalRW(); private class BigDecimalRW extends Persistence<BigDecimal> { @Override public String toExternal(BigDecimal value) { return String.format(Locale.US, "%3.1f", value.doubleValue()); } @Override public BigDecimal fromExternal(String external) { try { return new BigDecimal(Double.valueOf(external)); } catch (NumberFormatException e) { logger.warning("Malformed externalized Trigger value: " + external); return new BigDecimal(Double.valueOf(50)); } } @Override public String formatted(BigDecimal value) { return String.format("%3.1f", value.doubleValue()); } @Override public int compare(BigDecimal o1, BigDecimal o2) { return o1.compareTo(o2); } @Override public boolean isAny(BigDecimal value) { return false; } @Override public void persist(String key, String value) { prefs.storage().put(vinKey(key), value); } @Override public String load(String key, String dflt) { return prefs.storage().get(vinKey(key), "0_" + dflt); } }; private GenericTrigger.RW<String> stringHelper = new StringRW(); private class StringRW extends Persistence<String> { @Override public String toExternal(String value) { return value; } @Override public String fromExternal(String external) { return external; } @Override public String formatted(String value) { return value; } @Override public int compare(String o1, String o2) { return o1.compareTo(o2); } @Override public boolean isAny(String value) { return false; } @Override public void persist(String key, String value) { prefs.storage().put(vinKey(key), value); } @Override public String load(String key, String dflt) { return prefs.storage().get(vinKey(key), "0_" + dflt); } }; private GenericTrigger.RW<GeoUtils.CircularArea> areaHelper = new AreaRW(); private class AreaRW extends Persistence<GeoUtils.CircularArea> { @Override public String toExternal(GeoUtils.CircularArea value) { return String.format(Locale.US, "%3.5f^%3.5f^%2.1f^%s", value.lat, value.lng, value.radius, value.name); } @Override public GeoUtils.CircularArea fromExternal(String external) { String[] elements = external.split("\\^"); if (elements.length != 4) { logger.severe("Malformed GeoUtils.CircularArea String: " + external); return new GeoUtils.CircularArea(); } double lat, lng, radius; try { lat = Double.valueOf(elements[0]); lng = Double.valueOf(elements[1]); radius = Double.valueOf(elements[2]); return new GeoUtils.CircularArea(lat, lng, radius, elements[3]); } catch (NumberFormatException e) { logger.severe("Malformed GeoUtils.CircularArea String: " + external); return new GeoUtils.CircularArea(); } } @Override public String formatted(GeoUtils.CircularArea value) { return String.format("[%s] within %2.1f meters", value.name, value.radius); } @Override public int compare(GeoUtils.CircularArea o1, GeoUtils.CircularArea o2) { return o1.compareTo(o2); } @Override public boolean isAny(GeoUtils.CircularArea value) { return false; } @Override public void persist(String key, String value) { prefs.storage().put(vinKey(key), value); } @Override public String load(String key, String dflt) { return prefs.storage().get(vinKey(key), "0_" + dflt); } }; }