org.noroomattheinn.visibletesla.NotifierController.java Source code

Java tutorial

Introduction

Here is the source code for org.noroomattheinn.visibletesla.NotifierController.java

Source

/*
 * 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);
        }
    };
}