/*
;    Project:       Open Vehicle Monitor System
;    Date:          14th March 2017
;
;    Changes:
;    1.0  Initial release
;
;    (C) 2018       Michael Balzer
;
; Permission is hereby granted, free of charge, to any person obtaining a copy
; of this software and associated documentation files (the "Software"), to deal
; in the Software without restriction, including without limitation the rights
; to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
; copies of the Software, and to permit persons to whom the Software is
; furnished to do so, subject to the following conditions:
;
; The above copyright notice and this permission notice shall be included in
; all copies or substantial portions of the Software.
;
; THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
; IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
; FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
; AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
; LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
; OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
; THE SOFTWARE.
*/

#include "ovms_log.h"
#ifdef CONFIG_OVMS_COMP_CELLULAR
static const char *TAG = "webserver";
#endif

#include <string.h>
#include <stdio.h>
#include <string>
#include <sstream>
#include <fstream>
#include <dirent.h>
#include "ovms_webserver.h"
#include "ovms_config.h"
#include "ovms_metrics.h"
#include "metrics_standard.h"
#include "vehicle.h"
#include "ovms_housekeeping.h"
#include "ovms_peripherals.h"
#include "ovms_version.h"

#ifdef CONFIG_OVMS_COMP_OTA
#include "ovms_ota.h"
#endif

#ifdef CONFIG_OVMS_COMP_PUSHOVER
#include "pushover.h"
#endif

#define _attr(text) (c.encode_html(text).c_str())
#define _html(text) (c.encode_html(text).c_str())


/**
 * HandleStatus: show status overview
 */
void OvmsWebServer::HandleStatus(PageEntry_t& p, PageContext_t& c)
{
  std::string cmd, output;

  c.head(200);

  if (c.method == "POST") {
    cmd = c.getvar("action");
    if (cmd == "reboot") {
      OutputReboot(p, c);
      c.done();
      return;
    }
  }

  PAGE_HOOK("body.pre");
  c.print(
    "<div id=\"livestatus\" class=\"receiver\">"
    "<div class=\"row flex\">"
    "<div class=\"col-sm-6 col-lg-4\">");

  c.panel_start("primary", "Live");
  c.print(
    "<div class=\"table-responsive\">"
      "<table class=\"table table-bordered table-condensed\">"
        "<tbody>"
          "<tr>"
            "<th>Module</th>"
            "<td>"
              "<div class=\"metric number\" data-metric=\"m.freeram\"><span class=\"value\">?</span><span class=\"unit\">bytes free</span></div>"
              "<div class=\"metric number\" data-metric=\"m.tasks\"><span class=\"value\">?</span><span class=\"unit\">tasks running</span></div>"
            "</td>"
          "</tr>"
          "<tr>"
            "<th>Network</th>"
            "<td>"
              "<div class=\"metric text\" data-metric=\"m.net.provider\"><span class=\"value\">?</span><span class=\"metric unit\" data-metric=\"m.net.type\">?</span></div>"
              "<div class=\"metric number\" data-metric=\"m.net.sq\"><span class=\"value\">?</span><span class=\"unit\">dBm</span></div>"
            "</td>"
          "</tr>"
          "<tr>"
            "<th>GPS</th>"
            "<td>"
              "<div class=\"metric number\" data-metric=\"v.p.satcount\"><span class=\"value\">?</span><span class=\"unit\">Satellites</span></div>"
              "<div class=\"metric number\" data-metric=\"v.p.gpssq\"><span class=\"value\">?</span><span class=\"unit\">%</span></div>"
            "</td>"
          "</tr>"
          "<tr>"
            "<th>Main battery</th>"
            "<td>"
              "<div class=\"metric number\" data-metric=\"v.b.soc\" data-prec=\"1\"><span class=\"value\">?</span><span class=\"unit\">%</span></div>"
              "<div class=\"metric number\" data-metric=\"v.b.voltage\" data-prec=\"1\"><span class=\"value\">?</span><span class=\"unit\">V</span></div>"
              "<div class=\"metric number\" data-metric=\"v.b.current\" data-prec=\"1\"><span class=\"value\">?</span><span class=\"unit\">A</span></div>"
            "</td>"
          "</tr>"
          "<tr>"
            "<th>12V battery</th>"
            "<td>"
              "<div class=\"metric number\" data-metric=\"v.b.12v.voltage\" data-prec=\"1\"><span class=\"value\">?</span><span class=\"unit\">V</span></div>"
              "<div class=\"metric number\" data-metric=\"v.b.12v.current\" data-prec=\"1\"><span class=\"value\">?</span><span class=\"unit\">A</span></div>"
            "</td>"
          "</tr>"
          "<tr>"
            "<th>Events</th>"
            "<td>"
              "<ul id=\"eventlog\" class=\"list-unstyled\">"
              "</ul>"
            "</td>"
          "</tr>"
        "</tbody>"
      "</table>"
    "</div>"
    );
  c.panel_end();

  c.print(
    "</div>"
    "<div class=\"col-sm-6 col-lg-4\">");

  c.panel_start("primary", "Vehicle");
  output = ExecuteCommand("stat");
  c.printf("<samp class=\"monitor\" id=\"vehicle-status\" data-updcmd=\"stat\" data-events=\"vehicle.charge\">%s</samp>", _html(output));
  output = ExecuteCommand("location status");
  c.printf("<samp class=\"monitor\" data-updcmd=\"location status\" data-events=\"gps.lock|gps.sq|location\">%s</samp>", _html(output));
  c.panel_end(
    "<ul class=\"list-inline\">"
      "<li><button type=\"button\" class=\"btn btn-default btn-sm\" data-target=\"#vehicle-cmdres\" data-cmd=\"charge start\">Start charge</button></li>"
      "<li><button type=\"button\" class=\"btn btn-default btn-sm\" data-target=\"#vehicle-cmdres\" data-cmd=\"charge stop\">Stop charge</button></li>"
      "<li><samp id=\"vehicle-cmdres\" class=\"samp-inline\"></samp></li>"
    "</ul>");

  c.print(
    "</div>"
    "<div class=\"col-sm-6 col-lg-4\">");

  c.panel_start("primary", "Server");
  output = ExecuteCommand("server v2 status");
  if (!startsWith(output, "Unrecognised"))
    c.printf("<samp class=\"monitor\" id=\"server-v2\" data-updcmd=\"server v2 status\" data-events=\"server.v2\">%s</samp>", _html(output));
  output = ExecuteCommand("server v3 status");
  if (!startsWith(output, "Unrecognised"))
    c.printf("<samp class=\"monitor\" id=\"server-v3\" data-updcmd=\"server v3 status\" data-events=\"server.v3\">%s</samp>", _html(output));
  c.panel_end(
    "<ul class=\"list-inline\">"
      "<li><button type=\"button\" class=\"btn btn-default btn-sm\" data-target=\"#server-cmdres\" data-cmd=\"server v2 start\">Start V2</button></li>"
      "<li><button type=\"button\" class=\"btn btn-default btn-sm\" data-target=\"#server-cmdres\" data-cmd=\"server v2 stop\">Stop V2</button></li>"
      "<li><button type=\"button\" class=\"btn btn-default btn-sm\" data-target=\"#server-cmdres\" data-cmd=\"server v3 start\">Start V3</button></li>"
      "<li><button type=\"button\" class=\"btn btn-default btn-sm\" data-target=\"#server-cmdres\" data-cmd=\"server v3 stop\">Stop V3</button></li>"
      "<li><samp id=\"server-cmdres\" class=\"samp-inline\"></samp></li>"
    "</ul>");

  c.print(
    "</div>"
    "<div class=\"col-sm-6 col-lg-4\">");

  c.panel_start("primary", "SD Card");
  output = ExecuteCommand("sd status");
  c.printf("<samp class=\"monitor\" data-updcmd=\"sd status\" data-events=\"^sd\\.\">%s</samp>", _html(output));
  c.panel_end(
    "<ul class=\"list-inline\">"
      "<li><button type=\"button\" class=\"btn btn-default btn-sm\" data-target=\"#sd-cmdres\" data-cmd=\"sd mount\">Mount</button></li>"
      "<li><button type=\"button\" class=\"btn btn-default btn-sm\" data-target=\"#sd-cmdres\" data-cmd=\"sd unmount 0\">Unmount</button></li>"
      "<li><samp id=\"sd-cmdres\" class=\"samp-inline\"></samp></li>"
    "</ul>");

  c.print(
    "</div>"
    "<div class=\"col-sm-6 col-lg-4\">");

  c.panel_start("primary", "Module");
  output = ExecuteCommand("boot status");
  c.printf("<samp id=\"boot-status-cmdres\">%s</samp>", _html(output));
  c.print("<hr>");
  output = ExecuteCommand("ota status nocheck");
  c.printf("<samp>%s</samp>", _html(output));
  c.panel_end(
    "<ul class=\"list-inline\">"
      "<li><button type=\"button\" class=\"btn btn-default btn-sm\" name=\"action\" value=\"reboot\">Reboot</button></li>"
      "<li><button type=\"button\" class=\"btn btn-default btn-sm\" data-target=\"#boot-status-cmdres\" data-cmd=\"boot clear\nboot status\">Clear counters</button></li>"
    "</ul>");

  c.print(
    "</div>"
    "<div class=\"col-sm-6 col-lg-4\">");

  c.panel_start("primary", "Network");
  output = ExecuteCommand("network status");
  c.printf("<samp class=\"monitor\" data-updcmd=\"network status\" data-events=\"^network\">%s</samp>", _html(output));
  c.panel_end();

  c.print(
    "</div>"
    "<div class=\"col-sm-6 col-lg-4\">");

  c.panel_start("primary", "Wifi");
  output = ExecuteCommand("wifi status");
  c.printf("<samp class=\"monitor\" data-updcmd=\"wifi status\" data-events=\"\\.wifi\\.\">%s</samp>", _html(output));
  c.panel_end();

  c.print(
    "</div>"
    "<div class=\"col-sm-6 col-lg-4\">");

  c.panel_start("primary", "Cellular Modem");
  output = ExecuteCommand("cellular status");
  c.printf("<samp class=\"monitor\" data-updcmd=\"cellular status\" data-events=\"\\.modem\\.\">%s</samp>", _html(output));
  c.panel_end(
    "<ul class=\"list-inline\">"
      "<li><button type=\"button\" class=\"btn btn-default btn-sm\" data-target=\"#modem-cmdres\" data-cmd=\"power cellular on\">Start cellular modem</button></li>"
      "<li><button type=\"button\" class=\"btn btn-default btn-sm\" data-target=\"#modem-cmdres\" data-cmd=\"power cellular off\">Stop cellular modem</button></li>"
      "<li><samp id=\"modem-cmdres\" class=\"samp-inline\"></samp></li>"
    "</ul>");

  c.print(
    "</div>"
    "</div>"
    "<script>"
    "$(\"button[name=action]\").on(\"click\", function(ev){"
      "loaduri(\"#main\", \"post\", \"/status\", { \"action\": $(this).val() });"
    "});"
    "$(\"#livestatus\").on(\"msg:event\", function(e, event){"
      "if (event.startsWith(\"ticker\") || event.startsWith(\"clock\"))"
        "return;"
      "var list = $(\"#eventlog\");"
      "if (list.children().size() >= 5)"
        "list.children().get(0).remove();"
      "var now = (new Date().toLocaleTimeString());"
      "list.append($('<li class=\"event\"><code class=\"time\">[' + now + ']</code><span class=\"type\">' + event + '</span></li>'));"
    "});"
    "</script>"
    );

  PAGE_HOOK("body.post");
  c.done();
}


/**
 * HandleCommand: execute command, stream output
 */
void OvmsWebServer::HandleCommand(PageEntry_t& p, PageContext_t& c)
{
  std::string type = c.getvar("type");
  bool javascript = (type == "js");
  std::string output = c.getvar("output");
  extram::string command;
  c.getvar("command", command);

#ifndef CONFIG_OVMS_SC_JAVASCRIPT_DUKTAPE
  if (javascript) {
    c.head(400);
    c.print("ERROR: Javascript support disabled");
    c.done();
    return;
  }
#endif

  if (!javascript && command.length() > 2000) {
    c.head(400);
    c.print("ERROR: command too long (max 2000 chars)");
    c.done();
    return;
  }

  // Note: application/octet-stream default instead of text/plain is a workaround for an *old*
  //  Chrome/Webkit bug: chunked text/plain is always buffered for the first 1024 bytes.
  if (output == "text") {
    c.head(200,
      "Content-Type: text/plain; charset=utf-8\r\n"
      "Cache-Control: no-cache");
  } else if (output == "json") {
    c.head(200,
      "Content-Type: application/json; charset=utf-8\r\n"
      "Cache-Control: no-cache");
  } else if (output == "binary") {
    // As Safari on iOS still doesn't support responseType=ArrayBuffer on XMLHttpRequests,
    // this is meant to be used for binary data transmissions.
    // Based on Marcus Granado's approach, see…
    // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/Sending_and_Receiving_Binary_Data
    c.head(200,
      "Content-Type: application/octet-stream; charset=x-user-defined\r\n"
      "Cache-Control: no-cache");
  } else {
    c.head(200,
      "Content-Type: application/octet-stream; charset=utf-8\r\n"
      "Cache-Control: no-cache");
  }

  if (command.empty())
    c.done();
  else
    new HttpCommandStream(c.nc, command, javascript);
}


/**
 * HandleShell: command shell
 */
void OvmsWebServer::HandleShell(PageEntry_t& p, PageContext_t& c)
{
  std::string command = c.getvar("command", 2000);
  std::string output;

  if (command != "")
    output = ExecuteCommand(command);

  // generate form:
  c.head(200);
  PAGE_HOOK("body.pre");

  c.print(
    "<style>"
    ".fullscreened #output {"
      "border: 0 none;"
    "}"
    "@media (max-width: 767px) {"
      "#output {"
        "border: 0 none;"
      "}"
    "}"
    ".log { font-size: 87%; color: gray; }"
    ".log.log-I { color: green; }"
    ".log.log-W { color: darkorange; }"
    ".log.log-E { color: red; }"
    "</style>");

  c.panel_start("primary panel-minpad", "Shell"
    "<div class=\"pull-right checkbox\" style=\"margin: 0;\">"
      "<label><input type=\"checkbox\" id=\"logmonitor\" checked accesskey=\"L\"> <u>L</u>og Monitor</label>"
    "</div>");

  c.printf(
    "<pre class=\"receiver get-window-resize\" id=\"output\">%s</pre>"
    "<form id=\"shellform\" method=\"post\" action=\"#\">"
      "<div class=\"input-group\">"
        "<label class=\"input-group-addon hidden-xs\" for=\"input-command\">OVMS#</label>"
        "<input type=\"text\" class=\"form-control font-monospace\" placeholder=\"Enter command\" name=\"command\" id=\"input-command\" value=\"%s\" autocapitalize=\"none\" autocorrect=\"off\" autocomplete=\"section-shell\" spellcheck=\"false\">"
        "<div class=\"input-group-btn\">"
          "<button type=\"submit\" class=\"btn btn-default\">Execute</button>"
        "</div>"
      "</div>"
    "</form>"
    , _html(output.c_str()), _attr(command.c_str()));

  c.print(
    "<script>(function(){"
    "var $output = $('#output'), $command = $('#input-command');"
    "var add_output = function(addhtml) {"
      "var autoscroll = ($output.get(0).scrollTop + $output.innerHeight()) >= $output.get(0).scrollHeight;"
      "$output.append(addhtml);"
      "if (autoscroll) $output.scrollTop($output.get(0).scrollHeight);"
    "};"
    "var htmsg = \"\";"
    "for (msg of loghist)"
      "htmsg += '<div class=\"log log-'+msg[0]+'\">'+encode_html(unwrapLogLine(msg))+'</div>';"
    "$output.html(htmsg);"
    "$output.on(\"msg:log\", function(ev, msg){"
      "if (!$(\"#logmonitor\").prop(\"checked\")) return;"
      "var autoscroll = ($output.get(0).scrollTop + $output.innerHeight()) >= $output.get(0).scrollHeight;"
      "htmsg = '<div class=\"log log-'+msg[0]+'\">'+encode_html(unwrapLogLine(msg))+'</div>';"
      "if ($(\"html\").hasClass(\"loading\"))"
        "$output.find(\"strong:last-of-type\").before(htmsg);"
      "else "
        "$output.append(htmsg);"
      "if (autoscroll) $output.scrollTop($output.get(0).scrollHeight);"
    "});"
    "$output.on(\"window-resize\", function(){"
      "var $this = $(this);"
      "var pad = Number.parseInt($this.parent().css(\"padding-top\")) + Number.parseInt($this.parent().css(\"padding-bottom\"));"
      "var h = $(window).height() - $this.offset().top - pad - 81;"
      "if ($(window).width() <= 767) h += 27;"
      "if ($(\"body\").hasClass(\"fullscreened\")) h -= 4;"
      "$this.height(h);"
      "$this.scrollTop($this.get(0).scrollHeight);"
    "}).trigger(\"window-resize\");"
    "$(\"#shellform\").on(\"submit\", function(event){"
      "var command = $command.val();"
      "$output.scrollTop($output.get(0).scrollHeight);"
      "if (command && !$(\"html\").hasClass(\"loading\")) {"
        "var data = $(this).serialize();"
        "var lastlen = 0, xhr, timeouthd, timeout = 20;"
        "if (/^(test |ota |co .* scan)/.test(command)) timeout = 60;"
        "var checkabort = function(){ if (xhr.readyState != 4) xhr.abort(\"timeout\"); };"
        "xhr = $.ajax({ \"type\": \"post\", \"url\": \"/api/execute\", \"data\": data,"
          "\"timeout\": 0,"
          "\"beforeSend\": function(){"
            "$(\"html\").addClass(\"loading\");"
            "add_output(\"<strong>OVMS#</strong>&nbsp;<kbd>\""
              "+ $(\"<div/>\").text(command).html() + \"</kbd><br>\");"
            "timeouthd = window.setTimeout(checkabort, timeout*1000);"
          "},"
          "\"complete\": function(){"
            "window.clearTimeout(timeouthd);"
            "$(\"html\").removeClass(\"loading\");"
          "},"
          "\"xhrFields\": {"
            "onprogress: function(e){"
              "if (e.currentTarget.status != 200)"
                "return;"
              "var response = e.currentTarget.response;"
              "var addtext = response.substring(lastlen);"
              "lastlen = response.length;"
              "add_output($(\"<div/>\").text(addtext).html());"
              "window.clearTimeout(timeouthd);"
              "timeouthd = window.setTimeout(checkabort, timeout*1000);"
            "},"
          "},"
          "\"success\": function(response, xhrerror, request){"
            "var addtext = response.substring(lastlen);"
            "add_output($(\"<div/>\").text(addtext).html());"
          "},"
          "\"error\": function(response, xhrerror, httperror){"
            "var txt;"
            "if (response.status == 401 || response.status == 403)"
              "txt = \"Session expired. <a class=\\\"btn btn-sm btn-default\\\" href=\\\"javascript:reloadpage()\\\">Login</a>\";"
            "else if (response.status >= 400)"
              "txt = \"Error \" + response.status + \" \" + response.statusText;"
            "else"
              "txt = \"Request \" + (xhrerror||\"failed\") + \", please retry\";"
            "add_output('<div class=\"bg-danger\">'+txt+'</div>');"
          "},"
        "});"
        "if (shellhist.indexOf(command) >= 0)"
          "shellhist.splice(shellhist.indexOf(command), 1);"
        "shellhpos = shellhist.push(command) - 1;"
      "}"
      "event.stopPropagation();"
      "return false;"
    "});"
    "$command.on(\"keydown\", function(ev){"
      "if (ev.key == \"ArrowUp\") {"
        "shellhpos = (shellhist.length + shellhpos - 1) % shellhist.length;"
        "$command.val(shellhist[shellhpos]);"
        "return false;"
      "}"
      "else if (ev.key == \"ArrowDown\") {"
        "shellhpos = (shellhist.length + shellhpos + 1) % shellhist.length;"
        "$command.val(shellhist[shellhpos]);"
        "return false;"
      "}"
      "else if (ev.key == \"Escape\") {"
        "shellhpos = 0;"
        "$command.val('');"
        "return false;"
      "}"
      "else if (ev.key == \"PageUp\") {"
        "$output.scrollTop($output.scrollTop() - $output.height());"
        "return false;"
      "}"
      "else if (ev.key == \"PageDown\") {"
        "$output.scrollTop($output.scrollTop() + $output.height());"
        "return false;"
      "}"
      "else if (ev.key == \"Home\") {"
        "if ($command.get(0).selectionEnd == 0) {"
          "$output.scrollTop(0);"
        "}"
      "}"
      "else if (ev.key == \"End\") {"
        "if ($command.get(0).selectionEnd == $command.get(0).value.length) {"
          "$output.scrollTop($output.get(0).scrollHeight);"
        "}"
      "}"
    "});"
    "$('#logmonitor').on('change', function(ev){ $command.focus(); });"
    "$command.val(shellhist[shellhpos]||'').focus();"
    "})()</script>");

  c.panel_end();
  PAGE_HOOK("body.post");
  c.done();
}


/**
 * HandleCfgPassword: change admin password
 */

void OvmsWebServer::HandleCfgPassword(PageEntry_t& p, PageContext_t& c)
{
  std::string error, info;
  std::string oldpass, newpass1, newpass2;

  if (c.method == "POST") {
    // process form submission:
    oldpass = c.getvar("oldpass");
    newpass1 = c.getvar("newpass1");
    newpass2 = c.getvar("newpass2");

    if (oldpass != MyConfig.GetParamValue("password", "module"))
      error += "<li data-input=\"oldpass\">Old password is not correct</li>";
    if (newpass1 == oldpass)
      error += "<li data-input=\"newpass1\">New password identical to old password</li>";
    if (newpass1.length() < 8)
      error += "<li data-input=\"newpass1\">New password must have at least 8 characters</li>";
    if (newpass2 != newpass1)
      error += "<li data-input=\"newpass2\">Passwords do not match</li>";

    if (error == "") {
      // success:
      if (MyConfig.GetParamValue("password", "module") == MyConfig.GetParamValue("wifi.ap", "OVMS")) {
        MyConfig.SetParamValue("wifi.ap", "OVMS", newpass1);
        info += "<li>New Wifi AP password for network <code>OVMS</code> has been set.</li>";
      }

      MyConfig.SetParamValue("password", "module", newpass1);
      info += "<li>New module &amp; admin password has been set.</li>";

      info = "<p class=\"lead\">Success!</p><ul class=\"infolist\">" + info + "</ul>";
      c.head(200);
      c.alert("success", info.c_str());
      OutputHome(p, c);
      c.done();
      return;
    }

    // output error, return to form:
    error = "<p class=\"lead\">Error!</p><ul class=\"errorlist\">" + error + "</ul>";
    c.head(400);
    c.alert("danger", error.c_str());
  }
  else {
    c.head(200);
  }

  // show password warning:
  if (MyConfig.GetParamValue("password", "module").empty()) {
    c.alert("danger",
      "<p><strong>Warning:</strong> no admin password set. <strong>Web access is open to the public.</strong></p>"
      "<p>Please change your password now.</p>");
  }

  // create some random passwords:
  std::ostringstream pwsugg;
  srand48(StdMetrics.ms_m_monotonic->AsInt() * StdMetrics.ms_m_freeram->AsInt());
  pwsugg << "<p>Inspiration:";
  for (int i=0; i<5; i++)
    pwsugg << " <code class=\"autoselect\">" << c.encode_html(pwgen(12)) << "</code>";
  pwsugg << "</p>";

  // generate form:
  c.panel_start("primary", "Change module &amp; admin password");
  c.form_start(p.uri);
  c.input_password("Old password", "oldpass", "",
    NULL, NULL, "autocomplete=\"section-login current-password\"");
  c.input_password("New password", "newpass1", "",
    "Enter new password, min. 8 characters", pwsugg.str().c_str(), "autocomplete=\"section-login new-password\"");
  c.input_password("…repeat", "newpass2", "",
    "Repeat new password", NULL, "autocomplete=\"section-login new-password\"");
  c.input_button("default", "Submit");
  c.form_end();
  c.panel_end(
    (MyConfig.GetParamValue("password", "module") == MyConfig.GetParamValue("wifi.ap", "OVMS"))
    ? "<p>Note: this changes both the module and the Wifi access point password for network <code>OVMS</code>, as they are identical right now.</p>"
      "<p>You can set a separate Wifi password on the Wifi configuration page.</p>"
    : NULL);

  c.alert("info",
    "<p>Note: if you lose your password, you may need to erase your configuration to restore access to the module.</p>"
    "<p>To set a new password via console, registered App or SMS, execute command <kbd>config set password module …</kbd>.</p>");
  c.done();
}


/**
 * HandleCfgVehicle: configure vehicle type & identity (URL: /cfg/vehicle)
 */
void OvmsWebServer::HandleCfgVehicle(PageEntry_t& p, PageContext_t& c)
{
  std::string error, info;
  std::string vehicleid, vehicletype, vehiclename, timezone, timezone_region, units_distance, pin;
  std::string bat12v_factor, bat12v_ref, bat12v_alert;

  if (c.method == "POST") {
    // process form submission:
    vehicleid = c.getvar("vehicleid");
    vehicletype = c.getvar("vehicletype");
    vehiclename = c.getvar("vehiclename");
    timezone = c.getvar("timezone");
    timezone_region = c.getvar("timezone_region");
    units_distance = c.getvar("units_distance");
    bat12v_factor = c.getvar("bat12v_factor");
    bat12v_ref = c.getvar("bat12v_ref");
    bat12v_alert = c.getvar("bat12v_alert");
    pin = c.getvar("pin");

    if (vehicleid.length() == 0)
      error += "<li data-input=\"vehicleid\">Vehicle ID must not be empty</li>";
    if (vehicleid.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-") != std::string::npos)
      error += "<li data-input=\"vehicleid\">Vehicle ID may only contain ASCII letters, digits and '-'</li>";

    if (error == "" && StdMetrics.ms_v_type->AsString() != vehicletype) {
      MyVehicleFactory.SetVehicle(vehicletype.c_str());
      if (!MyVehicleFactory.ActiveVehicle())
        error += "<li data-input=\"vehicletype\">Cannot set vehicle type <code>" + vehicletype + "</code></li>";
      else
        info += "<li>New vehicle type <code>" + vehicletype + "</code> has been set.</li>";
    }

    if (error == "") {
      // success:
      MyConfig.SetParamValue("vehicle", "id", vehicleid);
      MyConfig.SetParamValue("auto", "vehicle.type", vehicletype);
      MyConfig.SetParamValue("vehicle", "name", vehiclename);
      MyConfig.SetParamValue("vehicle", "timezone", timezone);
      MyConfig.SetParamValue("vehicle", "timezone_region", timezone_region);
      MyConfig.SetParamValue("vehicle", "units.distance", units_distance);
      MyConfig.SetParamValue("system.adc", "factor12v", bat12v_factor);
      MyConfig.SetParamValue("vehicle", "12v.ref", bat12v_ref);
      MyConfig.SetParamValue("vehicle", "12v.alert", bat12v_alert);
      if (!pin.empty())
        MyConfig.SetParamValue("password", "pin", pin);

      info = "<p class=\"lead\">Success!</p><ul class=\"infolist\">" + info + "</ul>";
      info += "<script>$(\"title\").data(\"moduleid\", \"" + c.encode_html(vehicleid)
        + "\");$(\"#menu\").load(\"/menu\")</script>";
      c.head(200);
      c.alert("success", info.c_str());
      OutputHome(p, c);
      c.done();
      return;
    }

    // output error, return to form:
    error = "<p class=\"lead\">Error!</p><ul class=\"errorlist\">" + error + "</ul>";
    c.head(400);
    c.alert("danger", error.c_str());
  }
  else {
    // read configuration:
    vehicleid = MyConfig.GetParamValue("vehicle", "id");
    vehicletype = MyConfig.GetParamValue("auto", "vehicle.type");
    vehiclename = MyConfig.GetParamValue("vehicle", "name");
    timezone = MyConfig.GetParamValue("vehicle", "timezone");
    timezone_region = MyConfig.GetParamValue("vehicle", "timezone_region");
    units_distance = MyConfig.GetParamValue("vehicle", "units.distance");
    bat12v_factor = MyConfig.GetParamValue("system.adc", "factor12v");
    bat12v_ref = MyConfig.GetParamValue("vehicle", "12v.ref");
    bat12v_alert = MyConfig.GetParamValue("vehicle", "12v.alert");
    c.head(200);
  }

  // generate form:
  c.panel_start("primary", "Vehicle configuration");
  c.form_start(p.uri);

  c.print(
    "<ul class=\"nav nav-tabs\">"
      "<li class=\"active\"><a data-toggle=\"tab\" href=\"#tab-vehicle\">Vehicle</a></li>"
      "<li><a data-toggle=\"tab\" href=\"#tab-bat12v\">12V Monitor</a></li>"
    "</ul>"
    "<div class=\"tab-content\">"
      "<div id=\"tab-vehicle\" class=\"tab-pane fade in active section-vehicle\">");

  c.input_select_start("Vehicle type", "vehicletype");
  c.input_select_option("&mdash;", "", vehicletype.empty());
  for (OvmsVehicleFactory::map_vehicle_t::iterator k=MyVehicleFactory.m_vmap.begin(); k!=MyVehicleFactory.m_vmap.end(); ++k)
    c.input_select_option(k->second.name, k->first, (vehicletype == k->first));
  c.input_select_end();
  c.input_text("Vehicle ID", "vehicleid", vehicleid.c_str(), "Use ASCII letters, digits and '-'",
    "<p>Note: this is also the <strong>vehicle account ID</strong> for server connections.</p>");
  c.input_text("Vehicle name", "vehiclename", vehiclename.c_str(), "optional, the name of your car");

  c.printf(
    "<div class=\"form-group\">"
      "<label class=\"control-label col-sm-3\" for=\"input-timezone_select\">Time zone:</label>"
      "<div class=\"col-sm-9\">"
        "<input type=\"hidden\" name=\"timezone_region\" id=\"input-timezone_region\" value=\"%s\">"
        "<select class=\"form-control\" id=\"input-timezone_select\">"
          "<option>-Custom-</option>"
        "</select>"
      "</div>"
    "</div>"
    "<div class=\"form-group\">"
      "<div class=\"col-sm-9 col-sm-offset-3\">"
        "<input type=\"text\" class=\"form-control font-monospace\" placeholder=\"optional, default UTC\" name=\"timezone\" id=\"input-timezone\" value=\"%s\">"
        "<span class=\"help-block\"><p>Web links: <a target=\"_blank\" href=\"https://remotemonitoringsystems.ca/time-zone-abbreviations.php\">Example Timezone Strings</a>, <a target=\"_blank\" href=\"https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html\">Glibc manual</a></p></span>"
      "</div>"
    "</div>"
    , _attr(timezone_region)
    , _attr(timezone));

  c.input_radiobtn_start("Distance units", "units_distance");
  c.input_radiobtn_option("units_distance", "Kilometers", "K", units_distance == "K");
  c.input_radiobtn_option("units_distance", "Miles", "M", units_distance == "M");
  c.input_radiobtn_end();
  c.input_password("PIN", "pin", "", "empty = no change",
    "<p>Vehicle PIN code used for unlocking etc.</p>", "autocomplete=\"section-vehiclepin new-password\"");

  c.print(
      "</div>"
      "<div id=\"tab-bat12v\" class=\"tab-pane fade section-bat12v\">");

  c.input_info("12V reading",
      "<div class=\"receiver clearfix\">"
        "<div class=\"metric number\" id=\"display-bat12v_voltage\">"
          "<span class=\"value\">?</span>"
          "<span class=\"unit\">V</span>"
        "</div>"
      "</div>");
  c.input_slider("12V calibration", "bat12v_factor", 6, NULL,
    -1, bat12v_factor.empty() ? 195.7 : atof(bat12v_factor.c_str()), 195.7, 175.0, 225.0, 0.1,
    "<p>Adjust the calibration so the voltage displayed above matches your real voltage.</p>");

  c.input("number", "12V reference", "bat12v_ref", bat12v_ref.c_str(), "Default: 12.6",
    "<p>The nominal resting voltage level of your 12V battery when fully charged.</p>",
    "min=\"10\" max=\"15\" step=\"0.1\"", "V");
  c.input("number", "12V alert threshold", "bat12v_alert", bat12v_alert.c_str(), "Default: 1.6",
    "<p>If the actual voltage drops this far below the maximum of configured and measured reference"
    " level, an alert is sent.</p>",
    "min=\"0\" max=\"3\" step=\"0.1\"", "V");

  c.print(
      "</div>"
    "</div>"
    "<br>");

  c.input_button("default", "Save");
  c.form_end();
  c.panel_end();

  c.print(
    "<script>"
    "(function(){"
      "$.getJSON(\"" URL_ASSETS_ZONES_JSON "\", function(data) {"
        "var items = [];"
        "var region = $('#input-timezone_region').val();"
        "$.each(data, function(key, val) {"
          "items.push('<option data-tz=\"' + val + '\"' + (key==region ? ' selected' : '') + '>' + key + '</option>');"
        "});"
        "$('#input-timezone_select').append(items.join(''));"
        "$('#input-timezone_select').on('change', function(ev){"
          "var opt = $(this).find('option:selected');"
          "$('#input-timezone_region').val(opt.val());"
          "var tz = opt.data(\"tz\");"
          "if (tz) {"
            "$('#input-timezone').val(tz);"
            "$('#input-timezone').prop('readonly', true);"
          "} else {"
            "$('#input-timezone').prop('readonly', false);"
          "}"
        "}).trigger('change');"
      "});"
      "var $bat12v_factor = $('#input-bat12v_factor'),"
        "$bat12v_display = $('#display-bat12v_voltage .value'),"
        "oldfactor = $bat12v_factor.val() || 195.7;"
      "var updatecalib = function(){"
        "var newfactor = $bat12v_factor.val() || 195.7;"
        "var voltage = metrics['v.b.12v.voltage'] * oldfactor / newfactor;"
        "$bat12v_display.text(Number(voltage).toFixed(2));"
      "};"
      "$bat12v_factor.on(\"input change\", updatecalib);"
      "$(\".receiver\").on(\"msg:metrics\", updatecalib).trigger(\"msg:metrics\");"
    "})()"
    "</script>");

  c.done();
}


#ifdef CONFIG_OVMS_COMP_CELLULAR
/**
 * HandleCfgModem: configure APN & cellular modem features (URL /cfg/modem)
 */
void OvmsWebServer::HandleCfgModem(PageEntry_t& p, PageContext_t& c)
{
  std::string apn, apn_user, apn_pass, network_dns, pincode;
  bool enable_gps, enable_gpstime, enable_net, enable_sms, wrongpincode;

  if (c.method == "POST") {
    // process form submission:
    apn = c.getvar("apn");
    apn_user = c.getvar("apn_user");
    apn_pass = c.getvar("apn_pass");
    pincode = c.getvar("pincode");
    network_dns = c.getvar("network_dns");
    enable_net = (c.getvar("enable_net") == "yes");
    enable_sms = (c.getvar("enable_sms") == "yes");
    enable_gps = (c.getvar("enable_gps") == "yes");
    enable_gpstime = (c.getvar("enable_gpstime") == "yes");

    MyConfig.SetParamValue("modem", "apn", apn);
    MyConfig.SetParamValue("modem", "apn.user", apn_user);
    MyConfig.SetParamValue("modem", "apn.password", apn_pass);
    if ( MyConfig.GetParamValueBool("modem","wrongpincode") && (MyConfig.GetParamValue("modem","pincode") != pincode) )
      {
      ESP_LOGI(TAG,"New SIM card PIN code entered. Cleared wrong_pin_code flag");
      MyConfig.SetParamValueBool("modem", "wrongpincode", false);
      }
    MyConfig.SetParamValue("modem", "pincode", pincode);
    MyConfig.SetParamValue("network", "dns", network_dns);
    MyConfig.SetParamValueBool("modem", "enable.net", enable_net);
    MyConfig.SetParamValueBool("modem", "enable.sms", enable_sms);
    MyConfig.SetParamValueBool("modem", "enable.gps", enable_gps);
    MyConfig.SetParamValueBool("modem", "enable.gpstime", enable_gpstime);

    c.head(200);
    c.alert("success", "<p class=\"lead\">Modem configured.</p>");
    OutputHome(p, c);
    c.done();
    return;
  }

  // read configuration:
  apn = MyConfig.GetParamValue("modem", "apn");
  apn_user = MyConfig.GetParamValue("modem", "apn.user");
  apn_pass = MyConfig.GetParamValue("modem", "apn.password");
  pincode = MyConfig.GetParamValue("modem", "pincode");
  wrongpincode = MyConfig.GetParamValueBool("modem", "wrongpincode",false);
  network_dns = MyConfig.GetParamValue("network", "dns");
  enable_net = MyConfig.GetParamValueBool("modem", "enable.net", true);
  enable_sms = MyConfig.GetParamValueBool("modem", "enable.sms", true);
  enable_gps = MyConfig.GetParamValueBool("modem", "enable.gps", false);
  enable_gpstime = MyConfig.GetParamValueBool("modem", "enable.gpstime", false);

  // generate form:
  c.head(200);
  c.panel_start("primary", "Cellular modem configuration");
  c.form_start(p.uri);

  std::string info;
  std::string iccid = StdMetrics.ms_m_net_mdm_iccid->AsString();
  if (!iccid.empty()) {
    info = "<code class=\"autoselect\">" + iccid + "</code>";
  } else {
    info =
      "<div class=\"receiver\">"
        "<code class=\"autoselect\" data-metric=\"m.net.mdm.iccid\">(power cellular modem on to read)</code>"
        "&nbsp;"
        "<button class=\"btn btn-default\" data-cmd=\"power cellular on\" data-target=\"#pso\">Power cellular modem on</button>"
        "&nbsp;"
        "<samp id=\"pso\" class=\"samp-inline\"></samp>"
      "</div>"
      "<script>"
      "$(\".receiver\").on(\"msg:metrics\", function(e, update){"
        "$(this).find(\"[data-metric]\").each(function(){"
          "if (metrics[$(this).data(\"metric\")] != \"\" && $(this).text() != metrics[$(this).data(\"metric\")])"
            "$(this).text(metrics[$(this).data(\"metric\")]);"
        "});"
      "}).trigger(\"msg:metrics\");"
      "</script>";
  }
  c.input_info("SIM ICCID", info.c_str());
  c.input_text("SIM card PIN code", "pincode", pincode.c_str(), "",
    wrongpincode ? "<p style=\"color: red\">Wrong PIN code entered previously!</p>" : "<p>Not needed for Hologram SIM cards</p>");

  c.fieldset_start("Internet");
  c.input_checkbox("Enable IP networking", "enable_net", enable_net);
  c.input_text("APN", "apn", apn.c_str(), NULL,
    "<p>For Hologram, use APN <code>hologram</code> with empty username &amp; password</p>");
  c.input_text("…username", "apn_user", apn_user.c_str());
  c.input_text("…password", "apn_pass", apn_pass.c_str());
  c.input_text("DNS", "network_dns", network_dns.c_str(), "optional fixed DNS servers (space separated)",
    "<p>Set this to i.e. <code>8.8.8.8 8.8.4.4</code> (Google public DNS) if you encounter problems with your network provider DNS</p>");
  c.fieldset_end();

  c.fieldset_start("Features");
  c.input_checkbox("Enable SMS", "enable_sms", enable_sms);
  c.input_checkbox("Enable GPS", "enable_gps", enable_gps);
  c.input_checkbox("Use GPS time", "enable_gpstime", enable_gpstime);
  c.fieldset_end();

  c.hr();
  c.input_button("default", "Save");
  c.form_end();
  c.panel_end();
  c.done();
}
#endif


/**
 * HandleCfgPushover: Configure pushover notifications (URL /cfg/pushover)
 */
#ifdef CONFIG_OVMS_COMP_PUSHOVER
void OvmsWebServer::HandleCfgPushover(PageEntry_t& p, PageContext_t& c)
{
  std::string error;
  OvmsConfigParam* param = MyConfig.CachedParam("pushover");
  ConfigParamMap pmap;
  int i, max;
  char buf[100];
  std::string name, msg, pri;

  if (c.method == "POST") {
    // process form submission:
    pmap["enable"] = (c.getvar("enable") == "yes") ? "yes" : "no";
    pmap["user_key"] = c.getvar("user_key");
    pmap["token"] = c.getvar("token");

    // validate:
    //if (server.length() == 0)
    //  error += "<li data-input=\"server\">Server must not be empty</li>";
    if (pmap["enable"]=="yes")
      {
      if (pmap["user_key"].length() == 0)
        error += "<li data-input=\"user_key\">User key must not be empty</li>";
      if (pmap["user_key"].find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") != std::string::npos)
        error += "<li data-input=\"user_key\">User key may only contain ASCII letters and digits</li>";
      if (pmap["token"].length() == 0)
        error += "<li data-input=\"token\">Token must not be empty</li>";
      if (pmap["token"].find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") != std::string::npos)
        error += "<li data-input=\"user_key\">Token may only contain ASCII letters and digits</li>";
      }

    pmap["sound.normal"] = c.getvar("sound.normal");
    pmap["sound.high"] = c.getvar("sound.high");
    pmap["sound.emergency"] = c.getvar("sound.emergency");
    pmap["expire"] = c.getvar("expire");
    pmap["retry"] = c.getvar("retry");

    // read notification type/subtypes and their priorities
    max = atoi(c.getvar("npmax").c_str());
    for (i = 1; i <= max; i++) {
      sprintf(buf, "nfy_%d", i);
      name = c.getvar(buf);
      if (name == "") continue;
      sprintf(buf, "np_%d", i);
      pri = c.getvar(buf);
      if (pri == "") continue;
      snprintf(buf, sizeof(buf), "np.%s", name.c_str());
      pmap[buf] = pri;
    }

    // read events, their messages and priorities
    max = atoi(c.getvar("epmax").c_str());
    for (i = 1; i <= max; i++) {
      sprintf(buf, "en_%d", i);
      name = c.getvar(buf);
      if (name == "") continue;
      sprintf(buf, "em_%d", i);
      msg = c.getvar(buf);
      sprintf(buf, "ep_%d", i);
      pri = c.getvar(buf);
      if (pri == "") continue;
      snprintf(buf, sizeof(buf), "ep.%s", name.c_str());
      pri.append("/");
      pri.append(msg);
      pmap[buf] = pri;
    }

    if (error == "") {
      if (c.getvar("action") == "save")
        {
        // save:
        param->m_map.clear();
        param->m_map = std::move(pmap);
        param->Save();

        c.head(200);
        c.alert("success", "<p class=\"lead\">Pushover connection configured.</p>");
        OutputHome(p, c);
        c.done();
        return;
        }
      else if (c.getvar("action") == "test")
        {
        std::string reply;
        std::string popup;
        c.head(200);
        c.alert("success", "<p class=\"lead\">Sending message</p>");
        if (!MyPushoverClient.SendMessageOpt(
            c.getvar("user_key"),
            c.getvar("token"),
            c.getvar("test_message"),
            atoi(c.getvar("test_priority").c_str()),
            c.getvar("test_sound"),
            atoi(c.getvar("retry").c_str()),
            atoi(c.getvar("expire").c_str()),
            true /* receive server reply as reply/pushover-type notification */ ))
          {
          c.alert("danger", "<p class=\"lead\">Could not send test message!</p>");
          }
        }
    }
    else {
      // output error, return to form:
      error = "<p class=\"lead\">Error!</p><ul class=\"errorlist\">" + error + "</ul>";
      c.head(400);
      c.alert("danger", error.c_str());
    }

  }
  else {
    // read configuration:
    pmap = param->m_map;

    // generate form:
    c.head(200);
  }

  c.panel_start("primary", "Pushover server configuration");
  c.form_start(p.uri);

  c.printf("<div><p>Please visit <a href=\"https://pushover.net\">Pushover web site</a> to create an account (identified by a <b>user key</b>) "
    " and then register OVMS as an application in order to receive an application <b>token</b>).<br>"
    "Install Pushover iOS/Android application and specify your user key. <br>Finally enter both the user key and the application token here and test connectivity.<br>"
    "To receive specific notifications and events, configure them below.</p></div>" );

  c.input_checkbox("Enable Pushover connectivity", "enable", pmap["enable"] == "yes");
  c.input_text("User key", "user_key", pmap["user_key"].c_str(), "Enter user key (alphanumerical key consisting of around 30 characters");
  c.input_text("Token", "token", pmap["token"].c_str(), "Enter token (alphanumerical key consisting of around 30 characters");

  auto gen_options_priority = [&c](std::string priority) {
    c.printf(
        "<option value=\"-2\" %s>Lowest</option>"
        "<option value=\"-1\" %s>Low</option>"
        "<option value=\"0\" %s>Normal</option>"
        "<option value=\"1\" %s>High</option>"
        "<option value=\"2\" %s>Emergency</option>"
      , (priority=="-2") ? "selected" : ""
      , (priority=="-1") ? "selected" : ""
      , (priority=="0"||priority=="") ? "selected" : ""
      , (priority=="1") ? "selected" : ""
      , (priority=="2") ? "selected" : "");
  };

  auto gen_options_sound = [&c](std::string sound) {
    c.printf(
        "<option value=\"pushover\" %s>Pushover (default)</option>"
        "<option value=\"bike\" %s>Bike</option>"
        "<option value=\"bugle\" %s>Bugle</option>"
        "<option value=\"cashregister\" %s>Cashregister</option>"
        "<option value=\"classical\" %s>Classical</option>"
        "<option value=\"cosmic\" %s>Cosmic</option>"
        "<option value=\"falling\" %s>Falling</option>"
        "<option value=\"gamelan\" %s>Gamelan</option>"
        "<option value=\"incoming\" %s>Incoming</option>"
        "<option value=\"intermission\" %s>Intermission</option>"
        "<option value=\"magic\" %s>Magic</option>"
        "<option value=\"mechanical\" %s>Mechanical</option>"
        "<option value=\"pianobar\" %s>Piano bar</option>"
        "<option value=\"siren\" %s>Siren</option>"
        "<option value=\"spacealarm\" %s>Space alarm</option>"
        "<option value=\"tugboat\" %s>Tug boat</option>"
        "<option value=\"alien\" %s>Alien alarm (long)</option>"
        "<option value=\"climb\" %s>Climb (long)</option>"
        "<option value=\"persistent\" %s>Persistent (long)</option>"
        "<option value=\"echo\" %s>Pushover Echo (long)</option>"
        "<option value=\"updown\" %s>Up Down (long)</option>"
        "<option value=\"none\" %s>None (silent)</option>"
      , (sound=="pushover") || (sound=="") ? "selected" : ""
      , (sound=="bike") ? "selected" : ""
      , (sound=="bugle") ? "selected" : ""
      , (sound=="cashregister") ? "selected" : ""
      , (sound=="classical") ? "selected" : ""
      , (sound=="cosmic") ? "selected" : ""
      , (sound=="falling") ? "selected" : ""
      , (sound=="gamelan") ? "selected" : ""
      , (sound=="incoming") ? "selected" : ""
      , (sound=="intermission") ? "selected" : ""
      , (sound=="magic") ? "selected" : ""
      , (sound=="mechanical") ? "selected" : ""
      , (sound=="pianobar") ? "selected" : ""
      , (sound=="siren") ? "selected" : ""
      , (sound=="spacealarm") ? "selected" : ""
      , (sound=="tugboat") ? "selected" : ""
      , (sound=="alien") ? "selected" : ""
      , (sound=="climb") ? "selected" : ""
      , (sound=="persistent") ? "selected" : ""
      , (sound=="echo") ? "selected" : ""
      , (sound=="updown") ? "selected" : ""
      , (sound=="none") ? "selected" : "");
  };

  c.input_select_start("Normal priority sound", "sound.normal");
  gen_options_sound(pmap["sound.normal"]);
  c.input_select_end();
  c.input_select_start("High priority sound", "sound.high");
  gen_options_sound(pmap["sound.high"]);
  c.input_select_end();
  c.input_select_start("Emergency priority sound", "sound.emergency");
  gen_options_sound(pmap["sound.emergency"]);
  c.input_select_end();

  c.input("number", "Retry", "retry", pmap["retry"].c_str(), "Default: 30",
    "<p>Time period after which new notification is sent if emergency priority message is not acknowledged.</p>",
    "min=\"30\" step=\"1\"", "secs");
  c.input("number", "Expiration", "expire", pmap["expire"].c_str(), "Default: 1800",
    "<p>Time period after an emergency priority message will expire (and will not cause a new notification) if the message is not acknowledged.</p>",
    "min=\"0\" step=\"1\"", "secs");

  // Test message area
  c.print(
    "<div class=\"form-group\">"
    "<label class=\"control-label col-sm-3\">Test connection</label>"
    "<div class=\"col-sm-9\">"
      "<div class=\"table-responsive\">"
        "<table class=\"table\">"
          "<tbody>"
            "<tr>"
              "<td width=\"40%\">");

  c.input_text("Message", "test_message", c.getvar("test_message").c_str(), "Enter test message");
  c.print("</td><td width=\"25%\">");
  c.input_select_start("Priority", "test_priority");
  gen_options_priority(c.getvar("test_priority") != "" ? c.getvar("test_priority") : "0");
  c.input_select_end();
  c.print("</td><td width=\"25%\">");

  c.input_select_start("Sound", "test_sound");
  gen_options_sound( c.getvar("test_sound") != "" ? c.getvar("test_sound").c_str() : pmap["sound.normal"]);
  c.input_select_end();
  c.print("</td><td width=\"10%\">");
  c.input_button("default", "Send", "action", "test");
  c.printf(
          "</td></tr>"
        "</tbody>"
      "</table>"
    "</div>"
    "</div>"
    "</div>");


  // Input area for Notifications
  c.print(
    "<div class=\"form-group\">"
    "<label class=\"control-label col-sm-3\">Notification filtering</label>"
    "<div class=\"col-sm-9\">"
      "<div class=\"table-responsive\">"
        "<table class=\"table\">"
          "<thead>"
            "<tr>"
              "<th width=\"10%\"></th>"
              "<th width=\"45%\">Type/Subtype</th>"
              "<th width=\"45%\">Priority</th>"
            "</tr>"
          "</thead>"
          "<tbody>");


  max = 0;
  for (auto &kv: pmap) {
    if (!startsWith(kv.first, "np."))
      continue;
    max++;
    name = kv.first.substr(3);
    c.printf(
          "<tr>"
            "<td><button type=\"button\" class=\"btn btn-danger\" onclick=\"delRow(this)\"><strong>✖</strong></button></td>"
            "<td><input type=\"text\" class=\"form-control\" name=\"nfy_%d\" value=\"%s\" placeholder=\"Enter notification type/subtype\""
              " autocomplete=\"section-notification-type\"></td>"
            "<td width=\"20%\"><select class=\"form-control\" name=\"np_%d\" size=\"1\">"
      , max, _attr(name)
      , max);
    gen_options_priority(kv.second);
    c.print(
            "</select></td>"
          "</tr>");
  }

  c.printf(
          "<tr>"
            "<td><button type=\"button\" class=\"btn btn-success\" onclick=\"addRow_nfy(this)\"><strong>✚</strong></button></td>"
            "<td></td>"
            "<td></td>"
          "</tr>"
        "</tbody>"
      "</table>"
      "<input type=\"hidden\" name=\"npmax\" value=\"%d\">"
    "</div>"
    "<p>Enter the type of notification (for example <i>\"alert\"</i> or <i>\"info\"</i>) or more specifically the type/subtype tuple (for example <i>\"alert/alarm.sounding\"</i>). "
    " If a notification matches multiple filters, only the more specific will be used. "
    "For more complete listing, see <a href=\"https://docs.openvehicles.com/en/latest/userguide/notifications.html\">OVMS User Guide</a></p>"
    "</div>"
    "</div>"
    , max);


  // Input area for Events
  c.print(
    "<div class=\"form-group\">"
    "<label class=\"control-label col-sm-3\">Event filtering</label>"
    "<div class=\"col-sm-9\">"
      "<div class=\"table-responsive\">"
        "<table class=\"table\">"
          "<thead>"
            "<tr>"
              "<th width=\"10%\"></th>"
              "<th width=\"20%\">Event</th>"
              "<th width=\"55%\">Message</th>"
              "<th width=\"15%\">Priority</th>"
            "</tr>"
          "</thead>"
          "<tbody>");


  max = 0;
  for (auto &kv: pmap) {
    if (!startsWith(kv.first, "ep."))
      continue;
    max++;
    // Priority and message is saved as "priority/message" tuple (eg. "-1/this is a message")
    name = kv.first.substr(3);
    if (kv.second[1]=='/') {
      pri = kv.second.substr(0,1);
      msg = kv.second.substr(2);
    } else
    if (kv.second[2]=='/') {
      pri = kv.second.substr(0,2);
      msg = kv.second.substr(3);
    } else continue;
    c.printf(
          "<tr>"
            "<td><button type=\"button\" class=\"btn btn-danger\" onclick=\"delRow(this)\"><strong>✖</strong></button></td>"
            "<td><input type=\"text\" class=\"form-control\" name=\"en_%d\" value=\"%s\" placeholder=\"Enter event name\""
              " autocomplete=\"section-event-name\"></td>"
            "<td><input type=\"text\" class=\"form-control\" name=\"em_%d\" value=\"%s\" placeholder=\"Enter message\""
              " autocomplete=\"section-event-message\"></td>"
            "<td><select class=\"form-control\" name=\"ep_%d\" size=\"1\">"
      , max, _attr(name)
      , max, _attr(msg)
      , max);
    gen_options_priority(pri);
    c.print(
            "</select></td>"
          "</tr>");
  }

  c.printf(
          "<tr>"
            "<td><button type=\"button\" class=\"btn btn-success\" onclick=\"addRow_ev(this)\"><strong>✚</strong></button></td>"
            "<td></td>"
            "<td></td>"
          "</tr>"
        "</tbody>"
      "</table>"
      "<input type=\"hidden\" name=\"epmax\" value=\"%d\">"
    "</div>"
    "<p>Enter the event name (for example <i>\"vehicle.locked\"</i> or <i>\"vehicle.alert.12v.on\"</i>). "
    "For more complete listing, see <a href=\"https://docs.openvehicles.com/en/latest/userguide/events.html\">OVMS User Guide</a></p>"
    "</div>"
    "</div>"
    , max);


  c.input_button("default", "Save","action","save");
  c.form_end();

  c.print(
    "<script>"
    "function delRow(el){"
      "$(el).parent().parent().remove();"
    "}"
    "function addRow_nfy(el){"
      "var counter = $('input[name=npmax]');"
      "var nr = Number(counter.val()) + 1;"
      "var row = $('"
          "<tr>"
            "<td><button type=\"button\" class=\"btn btn-danger\" onclick=\"delRow(this)\"><strong>✖</strong></button></td>"
            "<td><input type=\"text\" class=\"form-control\" name=\"nfy_' + nr + '\" placeholder=\"Enter type/subtype\""
              " autocomplete=\"section-notification-type\"></td>"
            "<td><select class=\"form-control\" name=\"np_' + nr + '\" size=\"1\">"
              "<option value=\"-2\">Lowest</option>"
              "<option value=\"-1\">Low</option>"
              "<option value=\"0\" selected>Normal</option>"
              "<option value=\"1\">High</option>"
              "<option value=\"2\">Emergency</option>"
            "</select></td>"
          "</tr>"
        "');"
      "$(el).parent().parent().before(row).prev().find(\"input\").first().focus();"
      "counter.val(nr);"
    "}"
    "function addRow_ev(el){"
      "var counter = $('input[name=epmax]');"
      "var nr = Number(counter.val()) + 1;"
      "var row = $('"
          "<tr>"
            "<td><button type=\"button\" class=\"btn btn-danger\" onclick=\"delRow(this)\"><strong>✖</strong></button></td>"
            "<td><input type=\"text\" class=\"form-control\" name=\"en_' + nr + '\" placeholder=\"Enter event name\""
              " autocomplete=\"section-event-name\"></td>"
            "<td><input type=\"text\" class=\"form-control\" name=\"em_' + nr + '\" placeholder=\"Enter message\""
              " autocomplete=\"section-event-message\"></td>"
            "<td><select class=\"form-control\" name=\"ep_' + nr + '\" size=\"1\">"
              "<option value=\"-2\">Lowest</option>"
              "<option value=\"-1\">Low</option>"
              "<option value=\"0\" selected>Normal</option>"
              "<option value=\"1\">High</option>"
              "<option value=\"2\">Emergency</option>"
            "</select></td>"
          "</tr>"
        "');"
      "$(el).parent().parent().before(row).prev().find(\"input\").first().focus();"
      "counter.val(nr);"
    "}"
    "</script>");


  c.panel_end();
  c.done();
}
#endif


#ifdef CONFIG_OVMS_COMP_SERVER
#ifdef CONFIG_OVMS_COMP_SERVER_V2
/**
 * HandleCfgServerV2: configure server v2 connection (URL /cfg/server/v2)
 */
void OvmsWebServer::HandleCfgServerV2(PageEntry_t& p, PageContext_t& c)
{
  std::string error;
  std::string server, vehicleid, password, port;
  std::string updatetime_connected, updatetime_idle;
  bool tls;

  if (c.method == "POST") {
    // process form submission:
    server = c.getvar("server");
    tls = (c.getvar("tls") == "yes");
    vehicleid = c.getvar("vehicleid");
    password = c.getvar("password");
    port = c.getvar("port");
    updatetime_connected = c.getvar("updatetime_connected");
    updatetime_idle = c.getvar("updatetime_idle");

    // validate:
    if (port != "") {
      if (port.find_first_not_of("0123456789") != std::string::npos
          || atoi(port.c_str()) < 0 || atoi(port.c_str()) > 65535) {
        error += "<li data-input=\"port\">Port must be an integer value in the range 0…65535</li>";
      }
    }
    if (vehicleid.length() == 0)
      error += "<li data-input=\"vehicleid\">Vehicle ID must not be empty</li>";
    if (vehicleid.find_first_not_of("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-") != std::string::npos)
      error += "<li data-input=\"vehicleid\">Vehicle ID may only contain ASCII letters, digits and '-'</li>";
    if (updatetime_connected != "") {
      if (atoi(updatetime_connected.c_str()) < 1) {
        error += "<li data-input=\"updatetime_connected\">Update interval (connected) must be at least 1 second</li>";
      }
    }
    if (updatetime_idle != "") {
      if (atoi(updatetime_idle.c_str()) < 1) {
        error += "<li data-input=\"updatetime_idle\">Update interval (idle) must be at least 1 second</li>";
      }
    }

    if (error == "") {
      // success:
      MyConfig.SetParamValue("server.v2", "server", server);
      MyConfig.SetParamValueBool("server.v2", "tls", tls);
      MyConfig.SetParamValue("server.v2", "port", port);
      MyConfig.SetParamValue("vehicle", "id", vehicleid);
      if (password != "")
        MyConfig.SetParamValue("password","server.v2", password);
      MyConfig.SetParamValue("server.v2", "updatetime.connected", updatetime_connected);
      MyConfig.SetParamValue("server.v2", "updatetime.idle", updatetime_idle);

      std::string info = "<p class=\"lead\">Server V2 (MP) connection configured.</p>"
        "<script>$(\"title\").data(\"moduleid\", \"" + c.encode_html(vehicleid) + "\");</script>";

      c.head(200);
      c.alert("success", info.c_str());
      OutputHome(p, c);
      c.done();
      return;
    }

    // output error, return to form:
    error = "<p class=\"lead\">Error!</p><ul class=\"errorlist\">" + error + "</ul>";
    c.head(400);
    c.alert("danger", error.c_str());
  }
  else {
    // read configuration:
    server = MyConfig.GetParamValue("server.v2", "server");
    tls = MyConfig.GetParamValueBool("server.v2", "tls", false);
    vehicleid = MyConfig.GetParamValue("vehicle", "id");
    password = MyConfig.GetParamValue("password", "server.v2");
    port = MyConfig.GetParamValue("server.v2", "port");
    updatetime_connected = MyConfig.GetParamValue("server.v2", "updatetime.connected");
    updatetime_idle = MyConfig.GetParamValue("server.v2", "updatetime.idle");

    // generate form:
    c.head(200);
  }

  c.panel_start("primary", "Server V2 (MP) configuration");
  c.form_start(p.uri);

  c.input_text("Server", "server", server.c_str(), "Enter host name or IP address",
    "<p>Public OVMS V2 servers:</p>"
    "<ul>"
      "<li><code>ovms-server.bit-cloud.de</code> <a href=\"https://ovms.bit-cloud.de/user/register\" target=\"_blank\">Registration</a></li>"
      "<li><code>api.openvehicles.com</code> <a href=\"https://www.openvehicles.com/user/register\" target=\"_blank\">Registration</a></li>"
      "<li><code>ovms.dexters-web.de</code> <a href=\"https://dexters-web.de/?action=NewAccount\" target=\"_blank\">Registration</a></li>"
    "</ul>");
  c.input_checkbox("Enable TLS", "tls", tls,
    "<p>Note: enable transport layer security (encryption) if your server supports it (all public OVMS servers do).</p>");
  c.input_text("Port", "port", port.c_str(), "optional, default: 6867 (no TLS) / 6870 (TLS)");
  c.input_text("Vehicle ID", "vehicleid", vehicleid.c_str(), "Use ASCII letters, digits and '-'",
    NULL, "autocomplete=\"section-serverv2 username\"");
  c.input_password("Server password", "password", "", "empty = no change",
    "<p>Note: enter the password for the <strong>vehicle ID</strong>, <em>not</em> your user account password</p>",
    "autocomplete=\"section-serverv2 current-password\"");

  c.fieldset_start("Update intervals");
  c.input_text("…connected", "updatetime_connected", updatetime_connected.c_str(),
    "optional, in seconds, default: 60");
  c.input_text("…idle", "updatetime_idle", updatetime_idle.c_str(),
    "optional, in seconds, default: 600");
  c.fieldset_end();

  c.hr();
  c.input_button("default", "Save");
  c.form_end();
  c.panel_end();
  c.done();
}
#endif

#ifdef CONFIG_OVMS_COMP_SERVER_V3
/**
 * HandleCfgServerV3: configure server v3 connection (URL /cfg/server/v3)
 */
void OvmsWebServer::HandleCfgServerV3(PageEntry_t& p, PageContext_t& c)
{
  std::string error;
  std::string server, user, password, port, topic_prefix;
  std::string updatetime_connected, updatetime_idle, updatetime_on, updatetime_charging, updatetime_awake, updatetime_sendall;
  bool tls;

  if (c.method == "POST") {
    // process form submission:
    server = c.getvar("server");
    tls = (c.getvar("tls") == "yes");
    user = c.getvar("user");
    password = c.getvar("password");
    port = c.getvar("port");
    topic_prefix = c.getvar("topic_prefix");
    updatetime_connected = c.getvar("updatetime_connected");
    updatetime_idle = c.getvar("updatetime_idle");
    updatetime_on = c.getvar("updatetime_on");
    updatetime_charging = c.getvar("updatetime_charging");
    updatetime_awake = c.getvar("updatetime_awake");
    updatetime_sendall = c.getvar("updatetime_sendall");

    // validate:
    if (port != "") {
      if (port.find_first_not_of("0123456789") != std::string::npos
          || atoi(port.c_str()) < 0 || atoi(port.c_str()) > 65535) {
        error += "<li data-input=\"port\">Port must be an integer value in the range 0…65535</li>";
      }
    }
    if (updatetime_connected != "") {
      if (atoi(updatetime_connected.c_str()) < 1) {
        error += "<li data-input=\"updatetime_connected\">Update interval (connected) must be at least 1 second</li>";
      }
    }
    if (updatetime_idle != "") {
      if (atoi(updatetime_idle.c_str()) < 1) {
        error += "<li data-input=\"updatetime_idle\">Update interval (idle) must be at least 1 second</li>";
      }
    }
    if (updatetime_on != "") {
      if (atoi(updatetime_on.c_str()) < 1) {
        error += "<li data-input=\"updatetime_on\">Update interval (on) must be at least 1 second</li>";
      }
    }
    if (updatetime_charging != "") {
      if (atoi(updatetime_charging.c_str()) < 1) {
        error += "<li data-input=\"updatetime_charging\">Update interval (charging) must be at least 1 second</li>";
      }
    }
    if (updatetime_awake != "") {
      if (atoi(updatetime_awake.c_str()) < 1) {
        error += "<li data-input=\"updatetime_awake\">Update interval (awake) must be at least 1 second</li>";
      }
    }
    if (updatetime_sendall != "") {
      if (atoi(updatetime_sendall.c_str()) < 60) {
        error += "<li data-input=\"updatetime_sendall\">Update interval (sendall) must be at least 60 seconds</li>";
      }
    }

    if (error == "") {
      // success:
      MyConfig.SetParamValue("server.v3", "server", server);
      MyConfig.SetParamValueBool("server.v3", "tls", tls);
      MyConfig.SetParamValue("server.v3", "user", user);
      if (password != "")
        MyConfig.SetParamValue("password", "server.v3", password);
      MyConfig.SetParamValue("server.v3", "port", port);
      MyConfig.SetParamValue("server.v3", "topic.prefix", topic_prefix);
      MyConfig.SetParamValue("server.v3", "updatetime.connected", updatetime_connected);
      MyConfig.SetParamValue("server.v3", "updatetime.idle", updatetime_idle);
      if (updatetime_on == "")
        MyConfig.DeleteInstance("server.v3", "updatetime.on");
      else
        MyConfig.SetParamValue("server.v3", "updatetime.on", updatetime_on);
      if (updatetime_charging == "")
        MyConfig.DeleteInstance("server.v3", "updatetime.charging");
      else
        MyConfig.SetParamValue("server.v3", "updatetime.charging", updatetime_charging);
      if (updatetime_awake == "")
        MyConfig.DeleteInstance("server.v3", "updatetime.awake");
      else
        MyConfig.SetParamValue("server.v3", "updatetime.awake", updatetime_awake);
      if (updatetime_sendall == "")
        MyConfig.DeleteInstance("server.v3", "updatetime.sendall");
      else
        MyConfig.SetParamValue("server.v3", "updatetime.sendall", updatetime_sendall);

      c.head(200);
      c.alert("success", "<p class=\"lead\">Server V3 (MQTT) connection configured.</p>");
      OutputHome(p, c);
      c.done();
      return;
    }

    // output error, return to form:
    error = "<p class=\"lead\">Error!</p><ul class=\"errorlist\">" + error + "</ul>";
    c.head(400);
    c.alert("danger", error.c_str());
  }
  else {
    // read configuration:
    server = MyConfig.GetParamValue("server.v3", "server");
    tls = MyConfig.GetParamValueBool("server.v3", "tls", false);
    user = MyConfig.GetParamValue("server.v3", "user");
    password = MyConfig.GetParamValue("password", "server.v3");
    port = MyConfig.GetParamValue("server.v3", "port");
    topic_prefix = MyConfig.GetParamValue("server.v3", "topic.prefix");
    updatetime_connected = MyConfig.GetParamValue("server.v3", "updatetime.connected");
    updatetime_idle = MyConfig.GetParamValue("server.v3", "updatetime.idle");
    updatetime_on = MyConfig.GetParamValue("server.v3", "updatetime.on");
    updatetime_charging = MyConfig.GetParamValue("server.v3", "updatetime.charging");
    updatetime_awake = MyConfig.GetParamValue("server.v3", "updatetime.awake");
    updatetime_sendall = MyConfig.GetParamValue("server.v3", "updatetime.sendall");

    // generate form:
    c.head(200);
  }

  c.panel_start("primary", "Server V3 (MQTT) configuration");
  c.form_start(p.uri);

  c.input_text("Server", "server", server.c_str(), "Enter host name or IP address",
    "<p>Public OVMS V3 servers (MQTT brokers):</p>"
    "<ul>"
      "<li><code>io.adafruit.com</code> <a href=\"https://accounts.adafruit.com/users/sign_in\" target=\"_blank\">Registration</a></li>"
      "<li><a href=\"https://github.com/mqtt/mqtt.github.io/wiki/public_brokers\" target=\"_blank\">More public MQTT brokers</a></li>"
    "</ul>");
  c.input_checkbox("Enable TLS", "tls", tls,
    "<p>Note: enable transport layer security (encryption) if your server supports it.</p>");
  c.input_text("Port", "port", port.c_str(), "optional, default: 1883 (no TLS) / 8883 (TLS)");
  c.input_text("Username", "user", user.c_str(), "Enter user login name",
    NULL, "autocomplete=\"section-serverv3 username\"");
  c.input_password("Password", "password", "", "Enter user password, empty = no change",
    NULL, "autocomplete=\"section-serverv3 current-password\"");
  c.input_text("Topic Prefix", "topic_prefix", topic_prefix.c_str(),
    "optional, default: ovms/<username>/<vehicle id>/");

  c.fieldset_start("Update intervals");
  c.input_text("…connected", "updatetime_connected", updatetime_connected.c_str(),
    "optional, in seconds, default: 60");
  c.input_text("…idle", "updatetime_idle", updatetime_idle.c_str(),
    "optional, in seconds, default: 600");
  c.input_text("…on", "updatetime_on", updatetime_on.c_str(),
    "optional, in seconds, only used if set");
  c.input_text("…charging", "updatetime_charging", updatetime_charging.c_str(),
    "optional, in seconds, only used if set");
  c.input_text("…awake", "updatetime_awake", updatetime_awake.c_str(),
    "optional, in seconds, only used if set");
  c.input_text("…sendall", "updatetime_sendall", updatetime_sendall.c_str(),
    "optional, in seconds, only used if set");
  c.fieldset_end();

  c.hr();
  c.input_button("default", "Save");
  c.form_end();
  c.panel_end();
  c.done();
}
#endif
#endif


/**
 * HandleCfgNotifications: configure notifications & data logging (URL /cfg/notifications)
 */
void OvmsWebServer::HandleCfgNotifications(PageEntry_t& p, PageContext_t& c)
{
  std::string error;
  std::string vehicle_minsoc, vehicle_stream;
  std::string log_trip_storetime, log_trip_minlength, log_grid_storetime;
  bool report_trip_enable;
  std::string report_trip_minlength;

  if (c.method == "POST") {
    // process form submission:
    vehicle_minsoc = c.getvar("vehicle_minsoc");
    vehicle_stream = c.getvar("vehicle_stream");
    log_trip_storetime = c.getvar("log_trip_storetime");
    log_trip_minlength = c.getvar("log_trip_minlength");
    log_grid_storetime = c.getvar("log_grid_storetime");
    report_trip_enable = (c.getvar("report_trip_enable") == "yes");
    report_trip_minlength = c.getvar("report_trip_minlength");

    if (vehicle_minsoc != "") {
      if (atoi(vehicle_minsoc.c_str()) < 0 || atoi(vehicle_minsoc.c_str()) > 100) {
        error += "<li data-input=\"vehicle_minsoc\">Min SOC must be in the range 0…100 %</li>";
      }
    }
    if (vehicle_stream != "") {
      if (atoi(vehicle_stream.c_str()) < 0 || atoi(vehicle_stream.c_str()) > 60) {
        error += "<li data-input=\"vehicle_stream\">GPS log interval must be in the range 0…60 seconds</li>";
      }
    }

    if (log_trip_storetime != "") {
      if (atoi(log_trip_storetime.c_str()) < 0 || atoi(log_trip_storetime.c_str()) > 365) {
        error += "<li data-input=\"log_trip_storetime\">Trip history log storage time must be in the range 0…365 days</li>";
      }
    }
    if (log_trip_minlength != "") {
      if (atoi(log_trip_minlength.c_str()) < 0) {
        error += "<li data-input=\"log_trip_minlength\">Trip min length must not be negative</li>";
      }
    }
    if (log_grid_storetime != "") {
      if (atoi(log_grid_storetime.c_str()) < 0 || atoi(log_grid_storetime.c_str()) > 365) {
        error += "<li data-input=\"log_grid_storetime\">Grid history log storage time must be in the range 0…365 days</li>";
      }
    }

    if (report_trip_minlength != "") {
      if (atoi(report_trip_minlength.c_str()) < 0) {
        error += "<li data-input=\"report_trip_minlength\">Trip report min length must not be negative</li>";
      }
    }

    if (error == "") {
      // success:
      if (vehicle_minsoc == "")
        MyConfig.DeleteInstance("vehicle", "minsoc");
      else
        MyConfig.SetParamValue("vehicle", "minsoc", vehicle_minsoc);
      if (vehicle_stream == "")
        MyConfig.DeleteInstance("vehicle", "stream");
      else
        MyConfig.SetParamValue("vehicle", "stream", vehicle_stream);

      if (log_trip_storetime == "")
        MyConfig.DeleteInstance("notify", "log.trip.storetime");
      else
        MyConfig.SetParamValue("notify", "log.trip.storetime", log_trip_storetime);
      if (log_trip_minlength == "")
        MyConfig.DeleteInstance("notify", "log.trip.minlength");
      else
        MyConfig.SetParamValue("notify", "log.trip.minlength", log_trip_minlength);
      if (log_grid_storetime == "")
        MyConfig.DeleteInstance("notify", "log.grid.storetime");
      else
        MyConfig.SetParamValue("notify", "log.grid.storetime", log_grid_storetime);

      MyConfig.SetParamValueBool("notify", "report.trip.enable", report_trip_enable);
      if (report_trip_minlength == "")
        MyConfig.DeleteInstance("notify", "report.trip.minlength");
      else
        MyConfig.SetParamValue("notify", "report.trip.minlength", report_trip_minlength);

      c.head(200);
      c.alert("success", "<p class=\"lead\">Notifications configured.</p>");
      OutputHome(p, c);
      c.done();
      return;
    }

    // output error, return to form:
    error = "<p class=\"lead\">Error!</p><ul class=\"errorlist\">" + error + "</ul>";
    c.head(400);
    c.alert("danger", error.c_str());
  }
  else {
    // read configuration:
    vehicle_minsoc = MyConfig.GetParamValue("vehicle", "minsoc");
    vehicle_stream = MyConfig.GetParamValue("vehicle", "stream");
    log_trip_storetime = MyConfig.GetParamValue("notify", "log.trip.storetime");
    log_trip_storetime = MyConfig.GetParamValue("notify", "log.trip.storetime");
    log_trip_minlength = MyConfig.GetParamValue("notify", "log.trip.minlength");
    log_grid_storetime = MyConfig.GetParamValue("notify", "log.grid.storetime");
    report_trip_enable = MyConfig.GetParamValueBool("notify", "report.trip.enable");
    report_trip_minlength = MyConfig.GetParamValue("notify", "report.trip.minlength");

    // generate form:
    c.head(200);
  }

  c.panel_start("primary", "Notifications &amp; Data Logging");
  c.form_start(p.uri);

  c.fieldset_start("Vehicle Monitoring");

  c.input_slider("Location streaming", "vehicle_stream", 3, "sec",
    -1, atoi(vehicle_stream.c_str()), 0, 0, 60, 1,
    "<p>While driving send location updates to server every n seconds, 0 = use default update interval "
    "from server configuration. Same as App feature #8.</p>");

  c.input_slider("Minimum SOC", "vehicle_minsoc", 3, "%",
    -1, atoi(vehicle_minsoc.c_str()), 0, 0, 100, 1,
    "<p>Send an alert when SOC drops below this level, 0 = off. Same as App feature #9.</p>");

  c.fieldset_end();

  c.fieldset_start("Vehicle Reports");

  c.input_checkbox("Enable trip report", "report_trip_enable", report_trip_enable,
    "<p>This will send a textual report on driving statistics after each trip.</p>");
  c.input("number", "Report min trip length", "report_trip_minlength", report_trip_minlength.c_str(), "Default: 0.2 km",
    "<p>Only trips over at least this distance will produce a report. If your vehicle does not support the "
    "<code>v.p.trip</code> metric, set this to 0.</p>",
    "min=\"0\" step=\"0.1\"", "km");

  c.fieldset_end();

  c.fieldset_start("Data Log Storage Times");

  c.input("number", "Trip history log", "log_trip_storetime", log_trip_storetime.c_str(), "Default: empty/0 = disabled",
    "<p>Empty/0 = disabled. If enabled, the trip log receives one entry per trip, "
    "see <a target=\"_blank\" href=\""
    "https://docs.openvehicles.com/en/latest/userguide/notifications.html#trip-history-log"
    "\">user manual</a> for details.</p>",
    "min=\"0\" max=\"365\" step=\"1\"", "days");
  c.input("number", "Trip min length", "log_trip_minlength", log_trip_minlength.c_str(), "Default: 0.2 km",
    "<p>Only trips over at least this distance will be logged. If your vehicle does not support the "
    "<code>v.p.trip</code> metric, set this to 0.</p>",
    "min=\"0\" step=\"0.1\"", "km");

  c.input("number", "Grid history log", "log_grid_storetime", log_grid_storetime.c_str(), "Default: empty/0 = disabled",
    "<p>Empty/0 = disabled. If enabled, the grid log receives one entry per charge/generator state change, "
    "see <a target=\"_blank\" href=\""
    "https://docs.openvehicles.com/en/latest/userguide/notifications.html#grid-history-log"
    "\">user manual</a> for details.</p>",
    "min=\"0\" max=\"365\" step=\"1\"", "days");

  c.fieldset_end();

  c.hr();
  c.input_button("default", "Save");
  c.form_end();
  c.panel_end(
    "<p>Data logs are sent to the server (V2/V3). A V2 server may store the logs as requested by you "
    "for up to 365 days, depending on the server configuration. You can download the log tables "
    "in CSV format from the server using the standard server APIs or the browser UI provided "
    "by the server.</p>"
    "<p>An MQTT (V3) server normally won't store data records for longer than a few days, possibly "
    "hours. You need to fetch them as soon as possible. There are automated MQTT tools available "
    "for this purpose.</p>"
    "<p>See <a target=\"_blank\" href=\""
    "https://docs.openvehicles.com/en/latest/userguide/notifications.html"
    "\">user manual</a> for details on notifications.</p>"
    );
  c.done();
}


/**
 * HandleCfgWebServer: configure web server (URL /cfg/webserver)
 */
void OvmsWebServer::HandleCfgWebServer(PageEntry_t& p, PageContext_t& c)
{
  std::string error, warn;
  std::string docroot, auth_domain, auth_file;
  bool enable_files, enable_dirlist, auth_global;
  extram::string tls_cert, tls_key;

  if (c.method == "POST") {
    // process form submission:
    docroot = c.getvar("docroot");
    auth_domain = c.getvar("auth_domain");
    auth_file = c.getvar("auth_file");
    enable_files = (c.getvar("enable_files") == "yes");
    enable_dirlist = (c.getvar("enable_dirlist") == "yes");
    auth_global = (c.getvar("auth_global") == "yes");
    c.getvar("tls_cert", tls_cert);
    c.getvar("tls_key", tls_key);

    // validate:
    if (docroot != "" && docroot[0] != '/') {
      error += "<li data-input=\"docroot\">Document root must start with '/'</li>";
    }
    if (docroot == "/" || docroot == "/store" || docroot == "/store/" || startsWith(docroot, "/store/ovms_config")) {
      warn += "<li data-input=\"docroot\">Document root <code>" + docroot + "</code> may open access to OVMS configuration files, consider using a sub directory</li>";
    }
    if (!tls_cert.empty() && !startsWith(tls_cert, "-----BEGIN CERTIFICATE-----")) {
      error += "<li data-input=\"tls_cert\">TLS certificate must be in PEM CERTIFICATE format</li>";
    }
    if (!tls_key.empty() && !startsWith(tls_key, "-----BEGIN PRIVATE KEY-----")) {
      error += "<li data-input=\"tls_key\">TLS private key must be in PEM PRIVATE KEY format</li>";
    }
    if (tls_cert.empty() != tls_key.empty()) {
      error += "<li data-input=\"tls_cert,tls_key\">Both TLS certificate and private key must be given</li>";
    }

    // save TLS files:
    if (error == "") {
      if (tls_cert.empty()) {
        unlink("/store/tls/webserver.crt");
        unlink("/store/tls/webserver.key");
      }
      else {
        if (save_file("/store/tls/webserver.crt", tls_cert) != 0) {
          error += "<li data-input=\"tls_cert\">Error saving TLS certificate: ";
          error += strerror(errno);
          error += "</li>";
        }
        if (save_file("/store/tls/webserver.key", tls_key) != 0) {
          error += "<li data-input=\"tls_key\">Error saving TLS private key: ";
          error += strerror(errno);
          error += "</li>";
        }
      }
    }

    if (error == "") {
      // success:
      if (docroot == "")      MyConfig.DeleteInstance("http.server", "docroot");
      else                    MyConfig.SetParamValue("http.server", "docroot", docroot);
      if (auth_domain == "")  MyConfig.DeleteInstance("http.server", "auth.domain");
      else                    MyConfig.SetParamValue("http.server", "auth.domain", auth_domain);
      if (auth_file == "")    MyConfig.DeleteInstance("http.server", "auth.file");
      else                    MyConfig.SetParamValue("http.server", "auth.file", auth_file);

      MyConfig.SetParamValueBool("http.server", "enable.files", enable_files);
      MyConfig.SetParamValueBool("http.server", "enable.dirlist", enable_dirlist);
      MyConfig.SetParamValueBool("http.server", "auth.global", auth_global);

      c.head(200);
      c.alert("success", "<p class=\"lead\">Webserver configuration saved.</p>"
        "<p>Note: if you changed the TLS certificate or key, you need to reboot"
        " the module to activate the change.</p>");
      if (warn != "") {
        warn = "<p class=\"lead\">Warning:</p><ul class=\"warnlist\">" + warn + "</ul>";
        c.alert("warning", warn.c_str());
      }
      OutputHome(p, c);
      c.done();
      return;
    }

    // output error, return to form:
    error = "<p class=\"lead\">Error!</p><ul class=\"errorlist\">" + error + "</ul>";
    c.head(400);
    c.alert("danger", error.c_str());
  }
  else {
    // read configuration:
    docroot = MyConfig.GetParamValue("http.server", "docroot");
    auth_domain = MyConfig.GetParamValue("http.server", "auth.domain");
    auth_file = MyConfig.GetParamValue("http.server", "auth.file");
    enable_files = MyConfig.GetParamValueBool("http.server", "enable.files", true);
    enable_dirlist = MyConfig.GetParamValueBool("http.server", "enable.dirlist", true);
    auth_global = MyConfig.GetParamValueBool("http.server", "auth.global", true);
    load_file("/store/tls/webserver.crt", tls_cert);
    load_file("/store/tls/webserver.key", tls_key);

    // generate form:
    c.head(200);
  }

  c.panel_start("primary", "Webserver configuration");
  c.form_start(p.uri);

  c.input_checkbox("Enable file access", "enable_files", enable_files,
    "<p>If enabled, paths not handled by the webserver itself are mapped to files below the web root path.</p>"
    "<p>Example: <code>&lt;img src=\"/icons/smiley.png\"&gt;</code> → file <code>/sd/icons/smiley.png</code>"
    " (if root path is <code>/sd</code>)</p>");
  c.input_text("Root path", "docroot", docroot.c_str(), "Default: /sd");
  c.input_checkbox("Enable directory listings", "enable_dirlist", enable_dirlist);

  c.input_checkbox("Enable global file auth", "auth_global", auth_global,
    "<p>If enabled, file access is globally protected by the admin password (if set).</p>"
    "<p>Disabling allows public access to directories without an auth file.</p>"
    "<p>To protect a directory, you can e.g. copy the default auth file:"
    " <code class=\"autoselect\">vfs cp /store/.htpasswd /sd/…/.htpasswd</code></p>");
  c.input_text("Directory auth file", "auth_file", auth_file.c_str(), "Default: .htpasswd",
    "<p>Note: sub directories do <u>not</u> inherit the parent auth file.</p>");
  c.input_text("Auth domain/realm", "auth_domain", auth_domain.c_str(), "Default: ovms");

  c.printf(
    "<div class=\"form-group\">\n"
      "<label class=\"control-label col-sm-3\" for=\"input-content\">TLS certificate:</label>\n"
      "<div class=\"col-sm-9\">\n"
        "<textarea class=\"form-control font-monospace\" style=\"font-size:80%%;white-space:pre;\"\n"
          "autocapitalize=\"none\" autocorrect=\"off\" autocomplete=\"off\" spellcheck=\"false\"\n"
          "placeholder=\"-----BEGIN CERTIFICATE-----&#13;&#10;...&#13;&#10;-----END CERTIFICATE-----\"\n"
          "rows=\"5\" id=\"input-tls_cert\" name=\"tls_cert\">%s</textarea>\n"
      "</div>\n"
    "</div>\n"
    , c.encode_html(tls_cert).c_str());
  c.printf(
    "<div class=\"form-group\">\n"
      "<label class=\"control-label col-sm-3\" for=\"input-content\">TLS private key:</label>\n"
      "<div class=\"col-sm-9\">\n"
        "<textarea class=\"form-control font-monospace\" style=\"font-size:80%%;white-space:pre;\"\n"
          "autocapitalize=\"none\" autocorrect=\"off\" autocomplete=\"off\" spellcheck=\"false\"\n"
          "placeholder=\"-----BEGIN PRIVATE KEY-----&#13;&#10;...&#13;&#10;-----END PRIVATE KEY-----\"\n"
          "rows=\"5\" id=\"input-tls_key\" name=\"tls_key\">%s</textarea>\n"
      "</div>\n"
    "</div>\n"
    , c.encode_html(tls_key).c_str());

  c.input_button("default", "Save");
  c.form_end();
  c.panel_end(
    "<p>To enable encryption (https, wss) you need to install a TLS certificate + key. Public"
    " certification authorities (CAs) won't issue certificates for private hosts and IP addresses,"
    " so we recommend to create a self-signed TLS certificate for your module. Use a maximum key"
    " size of 2048 bit for acceptable performance.</p>"
    "<p><u>Example/template using OpenSSL</u>:</p>"
    "<samp class=\"autoselect\">openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \\\n"
    "&nbsp;&nbsp;-keyout ovms.key -out ovms.crt -subj \"/CN=ovmsname.local\" \\\n"
    "&nbsp;&nbsp;-addext \"subjectAltName=IP:192.168.4.1,IP:192.168.2.101\"</samp>"
    "<p>Change the name and add more IPs as needed. The command produces two files in your current"
    " directory, <code>ovms.crt</code> and <code>ovms.key</code>. Copy their contents into the"
    " respective fields above.</p>"
    "<p><u>Note</u>: as this is a self-signed certificate, you will need to explicitly allow the"
    " browser to access the module on the first https connect.</p>");
  c.done();
}


/**
 * HandleCfgWifi: configure wifi networks (URL /cfg/wifi)
 */

void OvmsWebServer::HandleCfgWifi(PageEntry_t& p, PageContext_t& c)
{
  bool cfg_bad_reconnect;
  float cfg_sq_good, cfg_sq_bad;

  if (c.method == "POST") {
    std::string warn, error;

    // process form submission:
    UpdateWifiTable(p, c, "ap", "wifi.ap", warn, error, 8);
    UpdateWifiTable(p, c, "client", "wifi.ssid", warn, error, 0);

    cfg_sq_good = atof(c.getvar("cfg_sq_good").c_str());
    cfg_sq_bad = atof(c.getvar("cfg_sq_bad").c_str());
    cfg_bad_reconnect = (c.getvar("cfg_bad_reconnect") == "yes");

    if (cfg_sq_bad >= cfg_sq_good) {
      error += "<li data-input=\"cfg_sq_bad\">'Bad' signal level must be lower than 'good' level.</li>";
    } else {
      if (cfg_sq_good == -87)
        MyConfig.DeleteInstance("network", "wifi.sq.good");
      else
        MyConfig.SetParamValueFloat("network", "wifi.sq.good", cfg_sq_good);
      if (cfg_sq_bad == -89)
        MyConfig.DeleteInstance("network", "wifi.sq.bad");
      else
        MyConfig.SetParamValueFloat("network", "wifi.sq.bad", cfg_sq_bad);
      if (!cfg_bad_reconnect)
        MyConfig.DeleteInstance("network", "wifi.bad.reconnect");
      else
        MyConfig.SetParamValueBool("network", "wifi.bad.reconnect", cfg_bad_reconnect);
    }

    if (error == "") {
      c.head(200);
      c.alert("success", "<p class=\"lead\">Wifi configuration saved.</p>");
      if (warn != "") {
        warn = "<p class=\"lead\">Warning:</p><ul class=\"warnlist\">" + warn + "</ul>";
        c.alert("warning", warn.c_str());
      }
      OutputHome(p, c);
      c.done();
      return;
    }

    // output error, return to form:
    error = "<p class=\"lead\">Error!</p><ul class=\"errorlist\">" + error + "</ul>";
    c.head(400);
    c.alert("danger", error.c_str());
  }
  else {
    cfg_sq_good = MyConfig.GetParamValueFloat("network", "wifi.sq.good", -87);
    cfg_sq_bad = MyConfig.GetParamValueFloat("network", "wifi.sq.bad", -89);
    cfg_bad_reconnect = MyConfig.GetParamValueBool("network", "wifi.bad.reconnect", false);

    c.head(200);
  }

  // generate form:
  c.panel_start("primary", "Wifi configuration");
  c.printf(
    "<form method=\"post\" action=\"%s\" target=\"#main\">"
    , _attr(p.uri));

  c.fieldset_start("Access point networks");
  OutputWifiTable(p, c, "ap", "wifi.ap", MyConfig.GetParamValue("auto", "wifi.ssid.ap", "OVMS"));
  c.fieldset_end();

  c.fieldset_start("Wifi client networks");
  OutputWifiTable(p, c, "client", "wifi.ssid", MyConfig.GetParamValue("auto", "wifi.ssid.client"));
  c.fieldset_end();

  c.fieldset_start("Wifi client options");
  c.input_slider("Good signal level", "cfg_sq_good", 3, "dBm", -1, cfg_sq_good, -87.0, -128.0, 0.0, 0.1,
    "<p>Threshold for usable wifi signal strength</p>");
  c.input_slider("Bad signal level", "cfg_sq_bad", 3, "dBm", -1, cfg_sq_bad, -89.0, -128.0, 0.0, 0.1,
    "<p>Threshold for unusable wifi signal strength</p>");
  c.input_checkbox("Immediate disconnect/reconnect", "cfg_bad_reconnect", cfg_bad_reconnect,
    "<p>Check to immediately look for better access points when signal level gets bad."
    " Default is to stay with the current AP as long as possible.</p>");
  c.fieldset_end();

  c.print(
    "<hr>"
    "<button type=\"submit\" class=\"btn btn-default center-block\">Save</button>"
    "</form>"
    "<style>\n"
    ".table>tbody>tr.active>td, .table>tbody>tr.active:hover>td {\n"
      "background-color: #bed2e3;\n"
      "cursor: pointer;\n"
    "}\n"
    ".night .table>tbody>tr.active>td {\n"
      "background-color: #293746;\n"
      "cursor: pointer;\n"
    "}\n"
    "</style>\n"
    "<script>"
    "function delRow(el){"
      "$(el).parent().parent().remove();"
    "}"
    "function addRow(el){"
      "var counter = $(el).parents('table').first().prev();"
      "var pfx = counter.attr(\"name\");"
      "var nr = Number(counter.val()) + 1;"
      "var row = $('"
          "<tr>"
            "<td><button type=\"button\" class=\"btn btn-danger\" onclick=\"delRow(this)\"><strong>✖</strong></button></td>"
            "' + ((pfx == 'client') ? '"
              "<td><div class=\"input-group\">"
                "<input type=\"text\" class=\"form-control\" name=\"' + pfx + '_ssid_' + nr + '\" placeholder=\"SSID\""
                " id=\"' + pfx + '_ssid_' + nr + '\" autocomplete=\"section-wifi-' + pfx + ' username\">"
                "<div class=\"input-group-btn\">"
                  "<button type=\"button\" class=\"btn btn-default action-wifiscan\" data-target=\"#' + pfx + '_ssid_' + nr + '\">"
                    "<span class=\"hidden-xs\">Select</span><span class=\"hidden-sm hidden-md hidden-lg\">⊙</span>"
                  "</button>"
                "</div></div></td>"
            "' : '"
              "<td><input type=\"text\" class=\"form-control\" name=\"' + pfx + '_ssid_' + nr + '\" placeholder=\"SSID\""
                " autocomplete=\"section-wifi-' + pfx + ' username\"></td>"
            "') + '"
            "<td><input type=\"password\" class=\"form-control\" name=\"' + pfx + '_pass_' + nr + '\" placeholder=\"Passphrase\""
              " autocomplete=\"section-wifi-' + pfx + ' current-password\"></td>"
          "</tr>"
        "');"
      "$(el).parent().parent().before(row).prev().find(\"input\").first().focus();"
      "counter.val(nr);"
    "}"
    "$('#panel-wifi-configuration').on('click', '.action-wifiscan', function(){\n"
      "var tgt = $(this).data(\"target\");\n"
      "var $tgt = $(tgt);\n"
      "var ssid_sel = \"\";\n"
      "var $dlg = $('<div id=\"wifiscan-dialog\" />').dialog({\n"
        "size: 'lg',\n"
        "title: 'Select Wifi Network',\n"
        "body:\n"
          "'<p id=\"wifiscan-info\">Scanning, please wait…</p>'+\n"
          "'<table id=\"wifiscan-list\" class=\"table table-condensed table-border table-striped table-hover\">'+\n"
          "'<thead><tr>'+\n"
            "'<th class=\"col-xs-4\">AP SSID</th>'+\n"
            "'<th class=\"col-xs-4 hidden-xs\">MAC Address</th>'+\n"
            "'<th class=\"col-xs-1 text-center\">Chan</th>'+\n"
            "'<th class=\"col-xs-1 text-center\">RSSI</th>'+\n"
            "'<th class=\"col-xs-2 hidden-xs\">Auth</th>'+\n"
          "'</tr></thead><tbody/></table>',\n"
        "buttons: [\n"
          "{ label: 'Cancel' },\n"
          "{ label: 'Select', btnClass: 'primary', action: function(input) { $tgt.val(ssid_sel); } },\n"
        "],\n"
      "});\n"
      "var $tb = $dlg.find('#wifiscan-list > tbody'), $info = $dlg.find('#wifiscan-info');\n"
      "$tb.on(\"click\", \"tr\", function(ev) {\n"
        "var $tr = $(this);\n"
        "$tr.addClass(\"active\").siblings().removeClass(\"active\");\n"
        "ssid_sel = $tr.children().first().text();\n"
        "if (ssid_sel == '<HIDDEN>') ssid_sel = '';\n"
      "}).on(\"dblclick\", \"tr\", function(ev) {\n"
        "$dlg.modal(\"hide\");\n"
        "$tgt.val(ssid_sel);\n"
      "});\n"
      "$dlg.addClass(\"loading\");\n"
      "loadcmd(\"wifi scan -j\").then(function(output) {\n"
        "$dlg.removeClass(\"loading\");\n"
        "var res = JSON.parse(output);\n"
        "if (res.error) {\n"
          "$info.text(res.error);\n"
        "} else if (res.list.length == 0) {\n"
          "$info.text(\"Sorry, no networks found.\");\n"
        "} else {\n"
          "var i, ap;\n"
          "$info.text(\"Available networks sorted by signal strength:\");\n"
          "for (i = 0; i < res.list.length; i++) {\n"
            "ap = res.list[i];\n"
            "$('<tr><td>'+encode_html(ap.ssid)+'</td><td class=\"hidden-xs\">'+ap.bssid+\n"
              "'</td><td class=\"text-center\">'+ap.chan+'</td><td class=\"text-center\">'+ap.rssi+\n"
              "'</td><td class=\"hidden-xs\">'+ap.auth+'</td></tr>').appendTo($tb);\n"
          "}\n"
        "}\n"
      "});\n"
    "});\n"
    "</script>");

  c.panel_end(
    "<p>Note: set the Wifi mode and default networks on the"
    " <a href=\"/cfg/autostart\" target=\"#main\">Autostart configuration page</a>.</p>");
  c.done();
}

void OvmsWebServer::OutputWifiTable(PageEntry_t& p, PageContext_t& c, const std::string prefix, const std::string paramname, const std::string autostart_ssid)
{
  OvmsConfigParam* param = MyConfig.CachedParam(paramname);
  int pos = 0, pos_autostart = 0, max;
  char buf[50];
  std::string ssid, pass;

  if (c.method == "POST") {
    max = atoi(c.getvar(prefix.c_str()).c_str());
    sprintf(buf, "%s_autostart", prefix.c_str());
    pos_autostart = atoi(c.getvar(buf).c_str());
  }
  else {
    max = param->m_map.size();
  }

  c.printf(
    "<div class=\"table-responsive\">"
      "<input type=\"hidden\" name=\"%s\" value=\"%d\">"
      "<table class=\"table\">"
        "<thead>"
          "<tr>"
            "<th width=\"10%%\"></th>"
            "<th width=\"45%%\">SSID</th>"
            "<th width=\"45%%\">Passphrase</th>"
          "</tr>"
        "</thead>"
        "<tbody>"
    , _attr(prefix), max);

  auto gen_row = [&c,&pos,&pos_autostart,&prefix,&ssid]() {
    if (prefix == "client") {
      // client entry: add network scanner/selector
      c.printf(
          "<tr>"
            "<td><button type=\"button\" class=\"btn btn-danger\" onclick=\"delRow(this)\" %s><strong>✖</strong></button></td>"
            "<td><div class=\"input-group\">"
              "<input type=\"text\" class=\"form-control\" name=\"%s_ssid_%d\" placeholder=\"SSID\" value=\"%s\""
              " id=\"%s_ssid_%d\" autocomplete=\"section-wifi-%s username\">"
              "<div class=\"input-group-btn\">"
                "<button type=\"button\" class=\"btn btn-default action-wifiscan\" data-target=\"#%s_ssid_%d\">"
                  "<span class=\"hidden-xs\">Select</span><span class=\"hidden-sm hidden-md hidden-lg\">⊙</span>"
                "</button>"
              "</div></div></td>"
            "<td><input type=\"password\" class=\"form-control\" name=\"%s_pass_%d\" placeholder=\"no change\"></td>"
          "</tr>"
      , (pos == pos_autostart) ? "disabled title=\"Current autostart network\"" : ""
      , _attr(prefix), pos, _attr(ssid)
      , _attr(prefix), pos, _attr(prefix)
      , _attr(prefix), pos
      , _attr(prefix), pos);
    } else {
      // ap entry:
      c.printf(
          "<tr>"
            "<td><button type=\"button\" class=\"btn btn-danger\" onclick=\"delRow(this)\" %s><strong>✖</strong></button></td>"
            "<td><input type=\"text\" class=\"form-control\" name=\"%s_ssid_%d\" value=\"%s\" autocomplete=\"section-wifi-%s username\"></td>"
            "<td><input type=\"password\" class=\"form-control\" name=\"%s_pass_%d\" placeholder=\"no change\"></td>"
          "</tr>"
      , (pos == pos_autostart) ? "disabled title=\"Current autostart network\"" : ""
      , _attr(prefix), pos, _attr(ssid), _attr(prefix)
      , _attr(prefix), pos);
    }
  };

  if (c.method == "POST") {
    for (pos = 1; pos <= max; pos++) {
      sprintf(buf, "%s_ssid_%d", prefix.c_str(), pos);
      ssid = c.getvar(buf);
      gen_row();
    }
  }
  else {
    for (auto const& kv : param->m_map) {
      pos++;
      ssid = kv.first;
      if (endsWith(ssid, ".ovms.staticip"))
        continue;
      if (ssid == autostart_ssid)
        pos_autostart = pos;
      gen_row();
    }
  }

  c.print(
          "<tr>"
            "<td><button type=\"button\" class=\"btn btn-success\" onclick=\"addRow(this)\"><strong>✚</strong></button></td>"
            "<td></td>"
            "<td></td>"
          "</tr>"
        "</tbody>"
      "</table>");
  c.printf(
      "<input type=\"hidden\" name=\"%s_autostart\" value=\"%d\">"
    "</div>"
    , _attr(prefix), pos_autostart);
}

void OvmsWebServer::UpdateWifiTable(PageEntry_t& p, PageContext_t& c, const std::string prefix, const std::string paramname,
  std::string& warn, std::string& error, int pass_minlen)
{
  OvmsConfigParam* param = MyConfig.CachedParam(paramname);
  int i, max, pos_autostart;
  std::string ssid, pass, ssid_autostart;
  char buf[50];
  ConfigParamMap newmap;

  max = atoi(c.getvar(prefix.c_str()).c_str());
  sprintf(buf, "%s_autostart", prefix.c_str());
  pos_autostart = atoi(c.getvar(buf).c_str());

  for (i = 1; i <= max; i++) {
    sprintf(buf, "%s_ssid_%d", prefix.c_str(), i);
    ssid = c.getvar(buf);
    if (ssid == "") {
      if (i == pos_autostart)
        error += "<li>Autostart SSID may not be empty</li>";
      continue;
    }
    sprintf(buf, "%s_pass_%d", prefix.c_str(), i);
    pass = c.getvar(buf);
    if (pass == "")
      pass = param->GetValue(ssid);
    if (pass == "") {
      if (i == pos_autostart)
        error += "<li>Autostart SSID <code>" + ssid + "</code> has no password</li>";
      else
        warn += "<li>SSID <code>" + ssid + "</code> has no password</li>";
    }
    else if (pass.length() < pass_minlen) {
      sprintf(buf, "%d", pass_minlen);
      error += "<li>SSID <code>" + ssid + "</code>: password is too short (min " + buf + " chars)</li>";
    }
    newmap[ssid] = pass;
    if (param->IsDefined(ssid + ".ovms.staticip"))
      newmap[ssid + ".ovms.staticip"] = param->GetValue(ssid + ".ovms.staticip");
    if (i == pos_autostart)
      ssid_autostart = ssid;
  }

  if (error == "") {
    // save new map:
    param->m_map.clear();
    param->m_map = std::move(newmap);
    param->Save();

    // set new autostart ssid:
    if (ssid_autostart != "")
      MyConfig.SetParamValue("auto", "wifi.ssid." + prefix, ssid_autostart);
  }
}


/**
 * HandleCfgAutoInit: configure auto init (URL /cfg/autostart)
 */
void OvmsWebServer::HandleCfgAutoInit(PageEntry_t& p, PageContext_t& c)
{
  std::string error, warn;
  bool init, ext12v, modem, server_v2, server_v3;
  #ifdef CONFIG_OVMS_SC_JAVASCRIPT_DUKTAPE
    bool scripting;
  #endif
  bool dbc;
  #ifdef CONFIG_OVMS_COMP_MAX7317
    bool egpio;
    std::string egpio_ports;
  #endif //CONFIG_OVMS_COMP_MAX7317
  std::string vehicle_type, obd2ecu, wifi_mode, wifi_ssid_client, wifi_ssid_ap;

  if (c.method == "POST") {
    // process form submission:
    init = (c.getvar("init") == "yes");
    dbc = (c.getvar("dbc") == "yes");
    ext12v = (c.getvar("ext12v") == "yes");
    #ifdef CONFIG_OVMS_COMP_MAX7317
      egpio = (c.getvar("egpio") == "yes");
      egpio_ports = c.getvar("egpio_ports");
    #endif //CONFIG_OVMS_COMP_MAX7317
    modem = (c.getvar("modem") == "yes");
    server_v2 = (c.getvar("server_v2") == "yes");
    server_v3 = (c.getvar("server_v3") == "yes");
    #ifdef CONFIG_OVMS_SC_JAVASCRIPT_DUKTAPE
      scripting = (c.getvar("scripting") == "yes");
    #endif
    vehicle_type = c.getvar("vehicle_type");
    obd2ecu = c.getvar("obd2ecu");
    wifi_mode = c.getvar("wifi_mode");
    wifi_ssid_ap = c.getvar("wifi_ssid_ap");
    wifi_ssid_client = c.getvar("wifi_ssid_client");

    // check:
    if (wifi_mode == "ap" || wifi_mode == "apclient") {
      if (wifi_ssid_ap.empty())
        wifi_ssid_ap = "OVMS";
      if (MyConfig.GetParamValue("wifi.ap", wifi_ssid_ap).empty()) {
        if (MyConfig.GetParamValue("password", "module").empty())
          error += "<li data-input=\"wifi_ssid_ap\">Wifi AP mode invalid: no password set for SSID!</li>";
        else
          warn += "<li data-input=\"wifi_ssid_ap\">Wifi AP network has no password → uses module password.</li>";
      }
    }
    if (wifi_mode == "client" || wifi_mode == "apclient") {
      if (wifi_ssid_client.empty()) {
        // check for defined client SSIDs:
        OvmsConfigParam* param = MyConfig.CachedParam("wifi.ssid");
        int cnt = 0;
        for (auto const& kv : param->m_map) {
          if (kv.second != "")
            cnt++;
        }
        if (cnt == 0) {
          error += "<li data-input=\"wifi_ssid_client\">Wifi client scan mode invalid: no SSIDs defined!</li>";
        }
      }
      else if (MyConfig.GetParamValue("wifi.ssid", wifi_ssid_client).empty()) {
        error += "<li data-input=\"wifi_ssid_client\">Wifi client mode invalid: no password set for SSID!</li>";
      }
    }

    if (error == "") {
      // success:
      MyConfig.SetParamValueBool("auto", "init", init);
      MyConfig.SetParamValueBool("auto", "dbc", dbc);
      MyConfig.SetParamValueBool("auto", "ext12v", ext12v);
      #ifdef CONFIG_OVMS_COMP_MAX7317
        MyConfig.SetParamValueBool("auto", "egpio", egpio);
        MyConfig.SetParamValue("egpio", "monitor.ports", egpio_ports);
      #endif //CONFIG_OVMS_COMP_MAX7317
      MyConfig.SetParamValueBool("auto", "modem", modem);
      MyConfig.SetParamValueBool("auto", "server.v2", server_v2);
      MyConfig.SetParamValueBool("auto", "server.v3", server_v3);
      #ifdef CONFIG_OVMS_SC_JAVASCRIPT_DUKTAPE
        MyConfig.SetParamValueBool("auto", "scripting", scripting);
      #endif
      MyConfig.SetParamValue("auto", "vehicle.type", vehicle_type);
      MyConfig.SetParamValue("auto", "obd2ecu", obd2ecu);
      MyConfig.SetParamValue("auto", "wifi.mode", wifi_mode);
      MyConfig.SetParamValue("auto", "wifi.ssid.ap", wifi_ssid_ap);
      MyConfig.SetParamValue("auto", "wifi.ssid.client", wifi_ssid_client);

      c.head(200);
      c.alert("success", "<p class=\"lead\">Auto start configuration saved.</p>");
      if (warn != "") {
        warn = "<p class=\"lead\">Warning:</p><ul class=\"warnlist\">" + warn + "</ul>";
        c.alert("warning", warn.c_str());
      }
      if (c.getvar("action") == "save-reboot")
        OutputReboot(p, c);
      else
        OutputHome(p, c);
      c.done();
      return;
    }

    // output error, return to form:
    error = "<p class=\"lead\">Error!</p><ul class=\"errorlist\">" + error + "</ul>";
    c.head(400);
    c.alert("danger", error.c_str());
  }
  else {
    // read configuration:
    init = MyConfig.GetParamValueBool("auto", "init", true);
    dbc = MyConfig.GetParamValueBool("auto", "dbc", false);
    ext12v = MyConfig.GetParamValueBool("auto", "ext12v", false);
    #ifdef CONFIG_OVMS_COMP_MAX7317
      egpio = MyConfig.GetParamValueBool("auto", "egpio", false);
      egpio_ports = MyConfig.GetParamValue("egpio", "monitor.ports");
    #endif //CONFIG_OVMS_COMP_MAX7317
    modem = MyConfig.GetParamValueBool("auto", "modem", false);
    server_v2 = MyConfig.GetParamValueBool("auto", "server.v2", false);
    server_v3 = MyConfig.GetParamValueBool("auto", "server.v3", false);
    #ifdef CONFIG_OVMS_SC_JAVASCRIPT_DUKTAPE
      scripting = MyConfig.GetParamValueBool("auto", "scripting", true);
    #endif
    vehicle_type = MyConfig.GetParamValue("auto", "vehicle.type");
    obd2ecu = MyConfig.GetParamValue("auto", "obd2ecu");
    wifi_mode = MyConfig.GetParamValue("auto", "wifi.mode", "ap");
    wifi_ssid_ap = MyConfig.GetParamValue("auto", "wifi.ssid.ap");
    if (wifi_ssid_ap.empty())
      wifi_ssid_ap = "OVMS";
    wifi_ssid_client = MyConfig.GetParamValue("auto", "wifi.ssid.client");

    c.head(200);
  }

  // generate form:
  c.panel_start("primary", "Auto start configuration");
  c.form_start(p.uri);

  c.input_checkbox("Enable auto start", "init", init,
    "<p>Note: if a crash occurs within 10 seconds after powering the module, autostart will be temporarily"
    " disabled. You may need to use the USB shell to access the module and fix the config.</p>");

  #ifdef CONFIG_OVMS_SC_JAVASCRIPT_DUKTAPE
    c.input_checkbox("Enable Javascript engine (Duktape)", "scripting", scripting,
      "<p>Enable execution of Javascript on the module (plugins, commands, event handlers).</p>");
  #endif

  c.input_checkbox("Autoload DBC files", "dbc", dbc,
    "<p>Enable to autoload DBC files (for reverse engineering).</p>");

  #ifdef CONFIG_OVMS_COMP_MAX7317
    c.input_checkbox("Start EGPIO monitor", "egpio", egpio,
      "<p>Enable to monitor EGPIO input ports and generate metrics and events from changes.</p>");
    c.input_text("EGPIO ports", "egpio_ports", egpio_ports.c_str(), "Ports to monitor",
      "<p>Enter list of port numbers (0…9) to monitor, separated by spaces.</p>");
  #endif //CONFIG_OVMS_COMP_MAX7317

  c.input_checkbox("Power on external 12V", "ext12v", ext12v,
    "<p>Enable to provide 12V to external devices connected to the module (i.e. ECU displays).</p>");

  c.input_select_start("Wifi mode", "wifi_mode");
  c.input_select_option("Access point", "ap", (wifi_mode == "ap"));
  c.input_select_option("Client mode", "client", (wifi_mode == "client"));
  c.input_select_option("Access point + Client", "apclient", (wifi_mode == "apclient"));
  c.input_select_option("Off", "off", (wifi_mode.empty() || wifi_mode == "off"));
  c.input_select_end();

  c.input_select_start("… access point SSID", "wifi_ssid_ap");
  OvmsConfigParam* param = MyConfig.CachedParam("wifi.ap");
  if (param->m_map.find(wifi_ssid_ap) == param->m_map.end())
    c.input_select_option(wifi_ssid_ap.c_str(), wifi_ssid_ap.c_str(), true);
  for (auto const& kv : param->m_map)
    c.input_select_option(kv.first.c_str(), kv.first.c_str(), (kv.first == wifi_ssid_ap));
  c.input_select_end();

  c.input_select_start("… client mode SSID", "wifi_ssid_client");
  param = MyConfig.CachedParam("wifi.ssid");
  c.input_select_option("Any known SSID (scan mode)", "", wifi_ssid_client.empty());
  for (auto const& kv : param->m_map)
    c.input_select_option(kv.first.c_str(), kv.first.c_str(), (kv.first == wifi_ssid_client));
  c.input_select_end();

  c.input_checkbox("Start modem", "modem", modem);

  c.input_select_start("Vehicle type", "vehicle_type");
  c.input_select_option("&mdash;", "", vehicle_type.empty());
  for (OvmsVehicleFactory::map_vehicle_t::iterator k=MyVehicleFactory.m_vmap.begin(); k!=MyVehicleFactory.m_vmap.end(); ++k)
    c.input_select_option(k->second.name, k->first, (vehicle_type == k->first));
  c.input_select_end();

  c.input_select_start("Start OBD2ECU", "obd2ecu");
  c.input_select_option("&mdash;", "", obd2ecu.empty());
  c.input_select_option("can1", "can1", obd2ecu == "can1");
  c.input_select_option("can2", "can2", obd2ecu == "can2");
  c.input_select_option("can3", "can3", obd2ecu == "can3");
  c.input_select_option("can4", "can4", obd2ecu == "can4");
  c.input_select_end(
    "<p>OBD2ECU translates OVMS to OBD2 metrics, i.e. to drive standard ECU displays</p>");

  c.input_checkbox("Start server V2", "server_v2", server_v2);
  c.input_checkbox("Start server V3", "server_v3", server_v3);

  c.print(
    "<div class=\"form-group\">"
      "<div class=\"col-sm-offset-3 col-sm-9\">"
        "<button type=\"submit\" class=\"btn btn-default\" name=\"action\" value=\"save\">Save</button> "
        "<button type=\"submit\" class=\"btn btn-default\" name=\"action\" value=\"save-reboot\">Save &amp; reboot</button> "
      "</div>"
    "</div>");
  c.form_end();
  c.panel_end();
  c.done();
}


#ifdef CONFIG_OVMS_COMP_OTA
/**
 * HandleCfgFirmware: OTA firmware update & boot setup (URL /cfg/firmware)
 */
void OvmsWebServer::HandleCfgFirmware(PageEntry_t& p, PageContext_t& c)
{
  std::string cmdres, mru;
  std::string action;
  ota_info info;
  bool auto_enable, auto_allow_modem;
  std::string auto_hour, server, tag;
  std::string output;
  std::string version;
  const char *what;
  char buf[132];

  if (c.method == "POST") {
    // process form submission:
    bool error = false, showform = true, reboot = false;
    action = c.getvar("action");

    auto_enable = (c.getvar("auto_enable") == "yes");
    auto_allow_modem = (c.getvar("auto_allow_modem") == "yes");
    auto_hour = c.getvar("auto_hour");
    server = c.getvar("server");
    tag = c.getvar("tag");

    if (action.substr(0,3) == "set") {
      info.partition_boot = c.getvar("boot_old");
      std::string partition_boot = c.getvar("boot");
      if (partition_boot != info.partition_boot) {
        cmdres = ExecuteCommand("ota boot " + partition_boot);
        if (cmdres.find("Error:") != std::string::npos)
          error = true;
        output += "<p><samp>" + cmdres + "</samp></p>";
      }
      else {
        output += "<p>Boot partition unchanged.</p>";
      }

      if (!error) {
        MyConfig.SetParamValueBool("auto", "ota", auto_enable);
        MyConfig.SetParamValueBool("ota", "auto.allow.modem", auto_allow_modem);
        MyConfig.SetParamValue("ota", "auto.hour", auto_hour);
        MyConfig.SetParamValue("ota", "server", server);
        MyConfig.SetParamValue("ota", "tag", tag);
      }

      if (!error && action == "set-reboot")
        reboot = true;
    }
    else if (action == "reboot") {
      reboot = true;
    }
    else {
      error = true;
      output = "<p>Unknown action.</p>";
    }

    // output result:
    if (error) {
      output = "<p class=\"lead\">Error!</p>" + output;
      c.head(400);
      c.alert("danger", output.c_str());
    }
    else {
      c.head(200);
      output = "<p class=\"lead\">OK!</p>" + output;
      c.alert("success", output.c_str());
      if (reboot)
        OutputReboot(p, c);
      if (reboot || !showform) {
        c.done();
        return;
      }
    }
  }
  else {
    // read config:
    auto_enable = MyConfig.GetParamValueBool("auto", "ota", true);
    auto_allow_modem = MyConfig.GetParamValueBool("ota", "auto.allow.modem", false);
    auto_hour = MyConfig.GetParamValue("ota", "auto.hour", "2");
    server = MyConfig.GetParamValue("ota", "server");
    tag = MyConfig.GetParamValue("ota", "tag");

    // generate form:
    c.head(200);
  }

  // read status:
  MyOTA.GetStatus(info);

  c.panel_start("primary", "Firmware setup &amp; update");

  c.input_info("Firmware version", info.version_firmware.c_str());
  output = info.version_server;
  output.append(" <button type=\"button\" class=\"btn btn-default\" data-toggle=\"modal\" data-target=\"#version-dialog\">Version info</button>");
  c.input_info("…available", output.c_str());

  c.print(
    "<ul class=\"nav nav-tabs\">"
      "<li class=\"active\"><a data-toggle=\"tab\" href=\"#tab-setup\">Setup</a></li>"
      "<li><a data-toggle=\"tab\" href=\"#tab-flash-http\">Flash <span class=\"hidden-xs\">from</span> web</a></li>"
      "<li><a data-toggle=\"tab\" href=\"#tab-flash-vfs\">Flash <span class=\"hidden-xs\">from</span> file</a></li>"
    "</ul>"
    "<div class=\"tab-content\">"
      "<div id=\"tab-setup\" class=\"tab-pane fade in active section-setup\">");

  c.form_start(p.uri);

  // Boot partition:
  c.input_info("Running partition", info.partition_running.c_str());
  c.printf("<input type=\"hidden\" name=\"boot_old\" value=\"%s\">", _attr(info.partition_boot));
  c.input_select_start("Boot from", "boot");
  what = "Factory image";
  version = GetOVMSPartitionVersion(ESP_PARTITION_SUBTYPE_APP_FACTORY);
  if (version != "") {
    snprintf(buf, sizeof(buf), "%s (%s)", what, version.c_str());
    what = buf;
  }
  c.input_select_option(what, "factory", (info.partition_boot == "factory"));
  what = "OTA_0 image";
  version = GetOVMSPartitionVersion(ESP_PARTITION_SUBTYPE_APP_OTA_0);
  if (version != "") {
    snprintf(buf, sizeof(buf), "%s (%s)", what, version.c_str());
    what = buf;
  }
  c.input_select_option(what, "ota_0", (info.partition_boot == "ota_0"));
  what = "OTA_1 image";
  version = GetOVMSPartitionVersion(ESP_PARTITION_SUBTYPE_APP_OTA_1);
  if (version != "") {
    snprintf(buf, sizeof(buf), "%s (%s)", what, version.c_str());
    what = buf;
  }
  c.input_select_option(what, "ota_1", (info.partition_boot == "ota_1"));
  c.input_select_end();

  // Server & auto update:
  c.print("<hr>");
  c.input_checkbox("Enable auto update", "auto_enable", auto_enable,
    "<p>Strongly recommended: if enabled, the module will perform automatic firmware updates within the hour of day specified.</p>");
  c.input("number", "Auto update hour of day", "auto_hour", auto_hour.c_str(), "0-23, default: 2", NULL, "min=\"0\" max=\"23\" step=\"1\"");
  c.input_checkbox("…allow via modem", "auto_allow_modem", auto_allow_modem,
    "<p>Automatic updates are normally only done if a wifi connection is available at the time. Before allowing updates via modem, be aware a single firmware image has a size of around 3 MB, which may lead to additional costs on your data plan.</p>");
  c.print(
    "<datalist id=\"server-list\">"
      "<option value=\"https://ovms-ota.bit-cloud.de\">"
      "<option value=\"https://api.openvehicles.com/firmware/ota\">"
      "<option value=\"https://ovms.dexters-web.de/firmware/ota\">"
    "</datalist>"
    "<datalist id=\"tag-list\">"
      "<option value=\"main\">"
      "<option value=\"eap\">"
      "<option value=\"edge\">"
    "</datalist>"
    );
  c.input_text("Update server", "server", server.c_str(), "Specify or select from list (clear to see all options)",
    "<p>Default is <code>https://ovms-ota.bit-cloud.de</code>.</p>",
    "list=\"server-list\"");
  c.input_text("Version tag", "tag", tag.c_str(), "Specify or select from list (clear to see all options)",
    "<p>Default is <code>main</code> for standard releases. Use <code>eap</code> (early access program) for stable or <code>edge</code> for bleeding edge developer builds.</p>",
    "list=\"tag-list\"");

  c.print(
    "<hr>"
    "<div class=\"form-group\">"
      "<div class=\"col-sm-offset-3 col-sm-9\">"
        "<button type=\"submit\" class=\"btn btn-default\" name=\"action\" value=\"set\">Set</button> "
        "<button type=\"submit\" class=\"btn btn-default\" name=\"action\" value=\"set-reboot\">Set &amp; reboot</button> "
        "<button type=\"submit\" class=\"btn btn-default\" name=\"action\" value=\"reboot\">Reboot</button> "
      "</div>"
    "</div>");
  c.form_end();

  c.print(
      "</div>"
      "<div id=\"tab-flash-http\" class=\"tab-pane fade section-flash\">");

  // warn about modem / AP connection:
  if (netif_default) {
    if (netif_default->name[0] == 'a' && netif_default->name[1] == 'p') {
      c.alert("warning",
        "<p class=\"lead\"><strong>No internet access.</strong></p>"
        "<p>The module is running in wifi AP mode without cellular modem, so flashing from a public server is currently not possible.</p>"
        "<p>You can still flash from an AP network local IP address (<code>192.168.4.x</code>).</p>");
    }
    else if (netif_default->name[0] == 'p' && netif_default->name[1] == 'p') {
      c.alert("warning",
        "<p class=\"lead\"><strong>Using cellular modem connection for internet.</strong></p>"
        "<p>Downloads from public servers will currently be done via cellular network. Be aware update files are &gt;2 MB, "
        "which may exceed your data plan and need some time depending on your link speed.</p>"
        "<p>You can also flash locally from a wifi network IP address.</p>");
    }
  }

  c.form_start(p.uri);

  // Flash HTTP:
  mru = MyConfig.GetParamValue("ota", "http.mru");
  c.input_text("HTTP URL", "flash_http", mru.c_str(),
    "optional: URL of firmware file",
    "<p>Leave empty to download the latest firmware from the update server. "
    "Note: currently only http is supported.</p>", "list=\"urls\"");

  c.print("<datalist id=\"urls\">");
  if (mru != "")
    c.printf("<option value=\"%s\">", c.encode_html(mru).c_str());
  c.print("</datalist>");

  c.input_button("default", "Flash now", "action", "flash-http");
  c.form_end();

  c.print(
      "</div>"
      "<div id=\"tab-flash-vfs\" class=\"tab-pane fade section-flash\">");

  c.form_start(p.uri);

  // Flash VFS:
  mru = MyConfig.GetParamValue("ota", "vfs.mru");
  c.input_info("Auto flash",
    "<ol>"
      "<li>Place the file <code>ovms3.bin</code> in the SD root directory.</li>"
      "<li>Insert the SD card, wait until the module reboots.</li>"
      "<li>Note: after processing the file will be renamed to <code>ovms3.done</code>.</li>"
    "</ol>");
  c.input_info("Upload",
    "Not yet implemented. Please copy your update file to an SD card and enter the path below.");

  c.printf(
    "<div class=\"form-group\">\n"
      "<label class=\"control-label col-sm-3\" for=\"input-flash_vfs\">File path:</label>\n"
      "<div class=\"col-sm-9\">\n"
        "<div class=\"input-group\">\n"
          "<input type=\"text\" class=\"form-control\" placeholder=\"Path to firmware file\" name=\"flash_vfs\" id=\"input-flash_vfs\" value=\"%s\" list=\"files\">\n"
          "<div class=\"input-group-btn\">\n"
            "<button type=\"button\" class=\"btn btn-default\" data-toggle=\"filedialog\" data-target=\"#select-firmware\" data-input=\"#input-flash_vfs\">Select</button>\n"
          "</div>\n"
        "</div>\n"
        "<span class=\"help-block\">\n"
          "<p>SD card root: <code>/sd/</code></p>\n"
        "</span>\n"
      "</div>\n"
    "</div>\n"
    , mru.c_str());

  c.print("<datalist id=\"files\">");
  if (mru != "")
    c.printf("<option value=\"%s\">", c.encode_html(mru).c_str());
  DIR *dir;
  struct dirent *dp;
  if ((dir = opendir("/sd")) != NULL) {
    while ((dp = readdir(dir)) != NULL) {
      if (strstr(dp->d_name, ".bin") || strstr(dp->d_name, ".done"))
        c.printf("<option value=\"/sd/%s\">", c.encode_html(dp->d_name).c_str());
    }
    closedir(dir);
  }
  c.print("</datalist>");

  c.input_button("default", "Flash now", "action", "flash-vfs");
  c.form_end();

  c.print(
      "</div>"
    "</div>");

  c.panel_end(
    "<p>The module can store up to three firmware images in a factory and two OTA partitions.</p>"
    "<p>Flashing from web or file writes alternating to the OTA partitions, the factory partition remains unchanged.</p>"
    "<p>You can flash the factory partition via USB, see developer manual for details.</p>");

  c.printf(
    "<div class=\"modal fade\" id=\"version-dialog\" role=\"dialog\" data-backdrop=\"static\" data-keyboard=\"false\">"
      "<div class=\"modal-dialog modal-lg\">"
        "<div class=\"modal-content\">"
          "<div class=\"modal-header\">"
            "<button type=\"button\" class=\"close\" data-dismiss=\"modal\">&times;</button>"
            "<h4 class=\"modal-title\">Version info %s</h4>"
          "</div>"
          "<div class=\"modal-body\">"
            "<pre>%s</pre>"
          "</div>"
          "<div class=\"modal-footer\">"
            "<button type=\"button\" class=\"btn btn-default\" data-dismiss=\"modal\">Close</button>"
          "</div>"
        "</div>"
      "</div>"
    "</div>"
    , _html(info.version_server)
    , _html(info.changelog_server));

  c.print(
    "<div class=\"modal fade\" id=\"flash-dialog\" role=\"dialog\" data-backdrop=\"static\" data-keyboard=\"false\">"
      "<div class=\"modal-dialog modal-lg\">"
        "<div class=\"modal-content\">"
          "<div class=\"modal-header\">"
            "<button type=\"button\" class=\"close\" data-dismiss=\"modal\">&times;</button>"
            "<h4 class=\"modal-title\">Flashing…</h4>"
          "</div>"
          "<div class=\"modal-body\">"
            "<pre id=\"output\"></pre>"
          "</div>"
          "<div class=\"modal-footer\">"
            "<button type=\"button\" class=\"btn btn-default action-reboot\">Reboot now</button>"
            "<button type=\"button\" class=\"btn btn-default action-close\">Close</button>"
          "</div>"
        "</div>"
      "</div>"
    "</div>"
    "\n"
    "<div class=\"filedialog\" id=\"select-firmware\" data-options='{\n"
      "\"title\": \"Select firmware file\",\n"
      "\"path\": \"/sd/\",\n"
      "\"quicknav\": [\"/sd/\", \"/sd/firmware/\"],\n"
      "\"filter\": \"\\\\.(bin|done)$\",\n"
      "\"sortBy\": \"date\",\n"
      "\"sortDir\": -1,\n"
      "\"showNewDir\": false\n"
    "}' />\n"
    "\n"
    "<script>\n"
      "function setloading(sel, on){"
        "$(sel+\" button\").prop(\"disabled\", on);"
        "if (on) $(sel).addClass(\"loading\");"
        "else $(sel).removeClass(\"loading\");"
      "}"
      "$(\".section-flash button\").on(\"click\", function(ev){"
        "var action = $(this).attr(\"value\");"
        "if (!action) return;"
        "$(\"#output\").text(\"Processing… (do not interrupt, may take some minutes)\\n\");"
        "setloading(\"#flash-dialog\", true);"
        "$(\"#flash-dialog\").modal(\"show\");"
        "if (action == \"flash-http\") {"
          "var flash_http = $(\"input[name=flash_http]\").val();"
          "loadcmd(\"ota flash http \" + flash_http, \"+#output\").done(function(resp){"
            "setloading(\"#flash-dialog\", false);"
          "});"
        "}"
        "else if (action == \"flash-vfs\") {"
          "var flash_vfs = $(\"input[name=flash_vfs]\").val();"
          "loadcmd(\"ota flash vfs \" + flash_vfs, \"+#output\").done(function(resp){"
            "setloading(\"#flash-dialog\", false);"
          "});"
        "}"
        "else {"
          "$(\"#output\").text(\"Unknown action.\");"
          "setloading(\"#flash-dialog\", false);"
        "}"
        "ev.stopPropagation();"
        "return false;"
      "});"
      "$(\".action-reboot\").on(\"click\", function(ev){"
        "$(\"#flash-dialog\").removeClass(\"fade\").modal(\"hide\");"
        "loaduri(\"#main\", \"post\", \"/cfg/firmware\", { \"action\": \"reboot\" });"
      "});"
      "$(\".action-close\").on(\"click\", function(ev){"
        "$(\"#flash-dialog\").removeClass(\"fade\").modal(\"hide\");"
        "loaduri(\"#main\", \"get\", \"/cfg/firmware\");"
      "});"
    "</script>");

  c.done();
}
#endif


/**
 * HandleCfgLogging: configure logging (URL /cfg/logging)
 */
void OvmsWebServer::HandleCfgLogging(PageEntry_t& p, PageContext_t& c)
{
  std::string error;
  OvmsConfigParam* param = MyConfig.CachedParam("log");
  ConfigParamMap pmap;
  int i, max;
  char buf[100];
  std::string file_path, tag, level;

  if (c.method == "POST") {
    // process form submission:

    pmap["file.enable"] = (c.getvar("file_enable") == "yes") ? "yes" : "no";
    if (c.getvar("file_maxsize") != "")
      pmap["file.maxsize"] = c.getvar("file_maxsize");
    if (c.getvar("file_keepdays") != "")
      pmap["file.keepdays"] = c.getvar("file_keepdays");
    if (c.getvar("file_syncperiod") != "")
      pmap["file.syncperiod"] = c.getvar("file_syncperiod");

    file_path = c.getvar("file_path");
    pmap["file.path"] = file_path;
    if (pmap["file.enable"] == "yes" && !startsWith(file_path, "/sd/") && !startsWith(file_path, "/store/"))
      error += "<li data-input=\"file_path\">File must be on '/sd' or '/store'</li>";

    pmap["level"] = c.getvar("level");
    max = atoi(c.getvar("levelmax").c_str());
    for (i = 1; i <= max; i++) {
      sprintf(buf, "tag_%d", i);
      tag = c.getvar(buf);
      if (tag == "") continue;
      sprintf(buf, "level_%d", i);
      level = c.getvar(buf);
      if (level == "") continue;
      snprintf(buf, sizeof(buf), "level.%s", tag.c_str());
      pmap[buf] = level;
    }

    if (error == "") {
      // save:
      param->m_map.clear();
      param->m_map = std::move(pmap);
      param->Save();

      c.head(200);
      c.alert("success", "<p class=\"lead\">Logging configuration saved.</p>");
      OutputHome(p, c);
      c.done();
      return;
    }

    // output error, return to form:
    error = "<p class=\"lead\">Error!</p><ul class=\"errorlist\">" + error + "</ul>";
    c.head(400);
    c.alert("danger", error.c_str());
  }
  else {
    // read configuration:
    pmap = param->m_map;

    // generate form:
    c.head(200);
  }

  c.panel_start("primary", "Logging configuration");
  c.form_start(p.uri);

  c.input_checkbox("Enable file logging", "file_enable", pmap["file.enable"] == "yes");
  c.input_text("Log file path", "file_path", pmap["file.path"].c_str(), "Enter path on /sd or /store",
    "<p>Logging to SD card will start automatically on mount. Do not remove the SD card while logging is active.</p>"
    "<p><button type=\"button\" class=\"btn btn-default\" data-target=\"#log-close-result\" data-cmd=\"log close\">Close log file for SD removal</button>"
      "<samp id=\"log-close-result\" class=\"samp-inline\"></samp></p>");

  std::string docroot = MyConfig.GetParamValue("http.server", "docroot", "/sd");
  std::string download;
  if (startsWith(pmap["file.path"], docroot)) {
    std::string webpath = pmap["file.path"].substr(docroot.length());
    download = "<a class=\"btn btn-default\" target=\"_blank\" href=\"" + webpath + "\">Open log file</a>";
    auto p = webpath.find_last_of('/');
    if (p != std::string::npos) {
      std::string webdir = webpath.substr(0, p);
      if (webdir != docroot)
        download += " <a class=\"btn btn-default\" target=\"_blank\" href=\"" + webdir + "/#sc=0&amp;so=1\">Open directory</a>";
    }
  } else {
    download = "You can access your logs with the browser if the path is in your webserver root (" + docroot + ").";
  }
  c.input_info("Download", download.c_str());

  c.input("number", "Sync period", "file_syncperiod", pmap["file.syncperiod"].c_str(), "Default: 3",
    "<p>How often to flush log buffer to SD: 0 = never/auto, &lt;0 = every n messages, &gt;0 = after n/2 seconds idle</p>",
    "min=\"-1\" step=\"1\"");
  c.input("number", "Max file size", "file_maxsize", pmap["file.maxsize"].c_str(), "Default: 1024",
    "<p>When exceeding the size, the log will be archived suffixed with date &amp; time and a new file will be started. 0 = disable</p>",
    "min=\"0\" step=\"1\"", "kB");
  c.input("number", "Expire time", "file_keepdays", pmap["file.keepdays"].c_str(), "Default: 30",
    "<p>Automatically delete archived log files. 0 = disable</p>",
    "min=\"0\" step=\"1\"", "days");

  auto gen_options = [&c](std::string level) {
    c.printf(
        "<option value=\"none\" %s>No logging</option>"
        "<option value=\"error\" %s>Errors</option>"
        "<option value=\"warn\" %s>Warnings</option>"
        "<option value=\"info\" %s>Info (default)</option>"
        "<option value=\"debug\" %s>Debug</option>"
        "<option value=\"verbose\" %s>Verbose</option>"
      , (level=="none") ? "selected" : ""
      , (level=="error") ? "selected" : ""
      , (level=="warn") ? "selected" : ""
      , (level=="info"||level=="") ? "selected" : ""
      , (level=="debug") ? "selected" : ""
      , (level=="verbose") ? "selected" : "");
  };

  c.input_select_start("Default level", "level");
  gen_options(pmap["level"]);
  c.input_select_end();

  c.print(
    "<div class=\"form-group\">"
    "<label class=\"control-label col-sm-3\">Component levels:</label>"
    "<div class=\"col-sm-9\">"
      "<div class=\"table-responsive\">"
        "<table class=\"table\">"
          "<thead>"
            "<tr>"
              "<th width=\"10%\"></th>"
              "<th width=\"45%\">Component</th>"
              "<th width=\"45%\">Level</th>"
            "</tr>"
          "</thead>"
          "<tbody>");

  max = 0;
  for (auto &kv: pmap) {
    if (!startsWith(kv.first, "level."))
      continue;
    max++;
    tag = kv.first.substr(6);
    c.printf(
          "<tr>"
            "<td><button type=\"button\" class=\"btn btn-danger\" onclick=\"delRow(this)\"><strong>✖</strong></button></td>"
            "<td><input type=\"text\" class=\"form-control\" name=\"tag_%d\" value=\"%s\" placeholder=\"Enter component tag\""
              " autocomplete=\"section-logging-tag\"></td>"
            "<td><select class=\"form-control\" name=\"level_%d\" size=\"1\">"
      , max, _attr(tag)
      , max);
    gen_options(kv.second);
    c.print(
            "</select></td>"
          "</tr>");
  }

  c.printf(
          "<tr>"
            "<td><button type=\"button\" class=\"btn btn-success\" onclick=\"addRow(this)\"><strong>✚</strong></button></td>"
            "<td></td>"
            "<td></td>"
          "</tr>"
        "</tbody>"
      "</table>"
      "<input type=\"hidden\" name=\"levelmax\" value=\"%d\">"
    "</div>"
    "</div>"
    "</div>"
    , max);

  c.input_button("default", "Save");
  c.form_end();

  c.print(
    "<script>"
    "function delRow(el){"
      "$(el).parent().parent().remove();"
    "}"
    "function addRow(el){"
      "var counter = $('input[name=levelmax]');"
      "var nr = Number(counter.val()) + 1;"
      "var row = $('"
          "<tr>"
            "<td><button type=\"button\" class=\"btn btn-danger\" onclick=\"delRow(this)\"><strong>✖</strong></button></td>"
            "<td><input type=\"text\" class=\"form-control\" name=\"tag_' + nr + '\" placeholder=\"Enter component tag\""
              " autocomplete=\"section-logging-tag\"></td>"
            "<td><select class=\"form-control\" name=\"level_' + nr + '\" size=\"1\">"
              "<option value=\"none\">No logging</option>"
              "<option value=\"error\">Errors</option>"
              "<option value=\"warn\">Warnings</option>"
              "<option value=\"info\" selected>Info (default)</option>"
              "<option value=\"debug\">Debug</option>"
              "<option value=\"verbose\">Verbose</option>"
            "</select></td>"
          "</tr>"
        "');"
      "$(el).parent().parent().before(row).prev().find(\"input\").first().focus();"
      "counter.val(nr);"
    "}"
    "</script>");

  c.panel_end();
  c.done();
}


/**
 * HandleCfgLocations: configure GPS locations (URL /cfg/locations)
 */
void OvmsWebServer::HandleCfgLocations(PageEntry_t& p, PageContext_t& c)
{
  std::string error;
  OvmsConfigParam* param = MyConfig.CachedParam("locations");
  ConfigParamMap pmap;
  int i, max;
  char buf[100];
  std::string latlon, name;
  int radius;
  double lat, lon;

  if (c.method == "POST") {
    // process form submission:
    max = atoi(c.getvar("loc").c_str());
    for (i = 1; i <= max; i++) {
      sprintf(buf, "latlon_%d", i);
      latlon = c.getvar(buf);
      if (latlon == "") continue;
      lat = lon = 0;
      sscanf(latlon.c_str(), "%lf,%lf", &lat, &lon);
      if (lat == 0 || lon == 0)
        error += "<li data-input=\"" + std::string(buf) + "\">Invalid coordinates (enter latitude,longitude)</li>";
      sprintf(buf, "radius_%d", i);
      radius = atoi(c.getvar(buf).c_str());
      if (radius == 0) radius = 100;
      sprintf(buf, "name_%d", i);
      name = c.getvar(buf);
      if (name == "")
        error += "<li data-input=\"" + std::string(buf) + "\">Name must not be empty</li>";
      snprintf(buf, sizeof(buf), "%f,%f,%d", lat, lon, radius);
      pmap[name] = buf;
    }

    if (error == "") {
      // save:
      param->m_map.clear();
      param->m_map = std::move(pmap);
      param->Save();

      c.head(200);
      c.alert("success", "<p class=\"lead\">Locations saved.</p>");
      OutputHome(p, c);
      c.done();
      return;
    }

    // output error, return to form:
    error = "<p class=\"lead\">Error!</p><ul class=\"errorlist\">" + error + "</ul>";
    c.head(400);
    c.alert("danger", error.c_str());
  }
  else {
    // read configuration:
    pmap = param->m_map;

    // generate form:
    c.head(200);
  }

  c.print(
    "<style>\n"
    ".list-editor > table {\n"
      "border-bottom: 1px solid #ddd;\n"
    "}\n"
    "</style>\n"
    "\n");

  c.panel_start("primary panel-single", "Locations");
  c.form_start(p.uri);

  c.print(
    "<div class=\"table-responsive list-editor receiver\" id=\"loced\">"
      "<table class=\"table form-table\">"
        "<colgroup>"
          "<col style=\"width:10%\">"
          "<col style=\"width:90%\">"
        "</colgroup>"
        "<template>"
          "<tr class=\"list-item mode-ITEM_MODE\">\n"
            "<td><button type=\"button\" class=\"btn btn-danger list-item-del\"><strong>✖</strong></button></td>"
            "<td>"
              "<div class=\"form-group\">"
                "<label class=\"control-label col-sm-2\" for=\"input-latlon_ITEM_ID\">Center:</label>"
                "<div class=\"col-sm-10\">"
                  "<div class=\"input-group\">"
                    "<input type=\"text\" class=\"form-control\" placeholder=\"Latitude,longitude (decimal)\" name=\"latlon_ITEM_ID\" id=\"input-latlon_ITEM_ID\" value=\"ITEM_latlon\">"
                    "<div class=\"input-group-btn\">"
                      "<button type=\"button\" class=\"btn btn-default open-gm\" title=\"Google Maps (new tab)\">GM</button>"
                      "<button type=\"button\" class=\"btn btn-default open-osm\" title=\"OpenStreetMaps (new tab)\">OSM</button>"
                    "</div>"
                  "</div>"
                "</div>"
              "</div>"
              "<div class=\"form-group\">"
                "<label class=\"control-label col-sm-2\" for=\"input-radius_ITEM_ID\">Radius:</label>"
                "<div class=\"col-sm-10\">"
                  "<div class=\"input-group\">"
                    "<input type=\"number\" class=\"form-control\" placeholder=\"Enter radius (m)\" name=\"radius_ITEM_ID\" id=\"input-radius_ITEM_ID\" value=\"ITEM_radius\" min=\"1\" step=\"1\">"
                    "<div class=\"input-group-addon\">m</div>"
                  "</div>"
                "</div>"
              "</div>"
              "<div class=\"form-group\">"
                "<label class=\"control-label col-sm-2\" for=\"input-name_ITEM_ID\">Name:</label>"
                "<div class=\"col-sm-10\">"
                  "<input type=\"text\" class=\"form-control\" placeholder=\"Enter name\" autocomplete=\"name\" name=\"name_ITEM_ID\" id=\"input-name_ITEM_ID\" value=\"ITEM_name\">"
                "</div>"
              "</div>"
              "<div class=\"form-group\">\n"
                "<label class=\"control-label col-sm-2\">Scripts:</label>\n"
                "<div class=\"col-sm-10\">\n"
                  "<button type=\"button\" class=\"btn btn-default edit-scripts\" data-edit=\"location.enter.{name}\">Entering</button>\n"
                  "<button type=\"button\" class=\"btn btn-default edit-scripts\" data-edit=\"location.leave.{name}\">Leaving</button>\n"
                "</div>\n"
              "</div>\n"
            "</td>"
          "</tr>"
        "</template>"
        "<tbody class=\"list-items\">"
        "</tbody>"
        "<tfoot>"
          "<tr>"
            "<td><button type=\"button\" class=\"btn btn-success list-item-add\"><strong>✚</strong></button></td>"
            "<td></td>"
          "</tr>"
        "</tfoot>"
      "</table>"
      "<input type=\"hidden\" class=\"list-item-id\" name=\"loc\" value=\"0\">"
    "</div>"
    "<div class=\"text-center\">\n"
      "<button type=\"reset\" class=\"btn btn-default\">Reset</button>\n"
      "<button type=\"submit\" class=\"btn btn-primary\">Save</button>\n"
    "</div>\n"
  );

  c.form_end();

  c.print(
    "<script>"
    "$('#loced').listEditor();"
    "$('#loced').on('click', 'button.open-gm', function(evt) {"
      "var latlon = $(this).parent().prev().val();"
      "window.open('https://www.google.de/maps/search/?api=1&query='+latlon, 'window-gm');"
    "});"
    "$('#loced').on('click', 'button.open-osm', function(evt) {"
      "var latlon = $(this).parent().prev().val();"
      "window.open('https://www.openstreetmap.org/search?query='+latlon, 'window-osm');"
    "});"
    "$('#loced').on('msg:metrics', function(evt, update) {"
      "if ('v.p.latitude' in update || 'v.p.longitude' in update) {"
        "var preset = { latlon: metrics['v.p.latitude']+','+metrics['v.p.longitude'], radius: 100 };"
        "$('#loced button.list-item-add').data('preset', JSON.stringify(preset));"
      "}"
    "}).trigger('msg:metrics', metrics);"
    "$('#loced').on('click', 'button.edit-scripts', function(evt) {\n"
      "var $this = $(this), $tr = $this.closest('tr');\n"
      "var name = $tr.find('input[name^=\"name_\"]').val();\n"
      "var dir = '/store/events/' + $this.data('edit').replace(\"{name}\", name) + '/';\n"
      "var changed = ($('#loced').find('.mode-add').length != 0);\n"
      "if (!changed)\n"
        "$('#loced input').each(function() { changed = changed || ($(this).val() != $(this).attr(\"value\")); });\n"
      "if (name == \"\") {\n"
        "confirmdialog(\"Error\", \"<p>Scripts are bound to the location name, please specify it.</p>\", [\"OK\"]);\n"
      "} else if (!changed) {\n"
        "loaduri('#main', 'get', '/edit', { \"path\": dir });\n"
      "} else {\n"
        "confirmdialog(\"Discard changes?\", \"Loading the editor will discard your list changes.\", [\"Cancel\", \"OK\"], function(ok) {\n"
          "if (ok) loaduri('#main', 'get', '/edit', { \"path\": dir });\n"
        "});\n"
      "}\n"
    "});\n"
  );

  for (auto &kv: pmap) {
    lat = lon = 0;
    radius = 100;
    name = kv.first;
    sscanf(kv.second.c_str(), "%lf,%lf,%d", &lat, &lon, &radius);
    c.printf(
      "$('#loced').listEditor('addItem', { name: '%s', latlon: '%lf,%lf', radius: %d });"
      , json_encode(name).c_str(), lat, lon, radius);
  }

  c.print("</script>");

  c.panel_end();
  c.done();
}


/**
 * HandleCfgBackup: config backup/restore (URL /cfg/backup)
 */
void OvmsWebServer::HandleCfgBackup(PageEntry_t& p, PageContext_t& c)
{
  c.head(200);
  c.print(
    "<style>\n"
    "#backupbrowser tbody { height: 186px; }\n"
    "</style>\n"
    "\n"
    "<div class=\"panel panel-primary\">\n"
      "<div class=\"panel-heading\"><span class=\"hidden-xs\">Configuration</span> Backup &amp; Restore</div>\n"
      "<div class=\"panel-body\">\n"
        "<div class=\"filebrowser\" id=\"backupbrowser\" />\n"
        "<div class=\"action-menu text-right\">\n"
          "<button type=\"button\" class=\"btn btn-default pull-left\" id=\"action-newdir\">New dir</button>\n"
          "<button type=\"button\" class=\"btn btn-primary\" id=\"action-backup\" disabled>Create backup</button>\n"
          "<button type=\"button\" class=\"btn btn-primary\" id=\"action-restore\" disabled>Restore backup</button>\n"
        "</div>\n"
        "<pre id=\"log\" style=\"margin-top:15px\"/>\n"
      "</div>\n"
      "<div class=\"panel-footer\">\n"
        "<p>Use this tool to create or restore backups of your system configuration &amp; scripts.\n"
          "User files or directories in <code>/store</code> will not be included or restored.\n"
          "ZIP files are password protected (hint: use 7z to unzip/create on a PC).</p>\n"
        "<p>Note: the module will perform a reboot after successful restore.</p>\n"
      "</div>\n"
    "</div>\n"
    "\n"
    "<script>\n"
    "(function(){\n"
      "var suggest = '/sd/backup/cfg-' + new Date().toISOString().substr(2,8).replaceAll('-','') + '.zip';\n"
      "var zip = { exists: false, iszip: false };\n"
      "var $panel = $('.panel-body');\n"
    "\n"
      "function updateButtons(enable) {\n"
        "if (enable === false) {\n"
          "$panel.addClass(\"loading disabled\");\n"
          "return;\n"
        "}\n"
        "$panel.removeClass(\"loading disabled\");\n"
        "if (!zip.iszip) {\n"
          "$('#action-backup').prop('disabled', true);\n"
          "$('#action-restore').prop('disabled', true);\n"
        "} else if (zip.exists) {\n"
          "$('#action-backup').prop('disabled', false).text(\"Overwrite backup\");\n"
          "$('#action-restore').prop('disabled', false);\n"
        "} else {\n"
          "$('#action-backup').prop('disabled', false).text(\"Create backup\");\n"
          "$('#action-restore').prop('disabled', true);\n"
        "}\n"
      "};\n"
    "\n"
      "$('#backupbrowser').filebrowser({\n"
        "input: zip,\n"
        "path: suggest,\n"
        "quicknav: ['/sd/', '/sd/backup/', suggest],\n"
        "filter: function(f) { return f.isdir || f.name.match('\\\\.zip$'); },\n"
        "sortBy: 'date',\n"
        "sortDir: -1,\n"
        "onPathChange: function(input) {\n"
          "input.iszip = (input.file.match('\\\\.zip$') != null);\n"
          "if (!input.iszip) {\n"
            "updateButtons();\n"
            "$('#log').html('<div class=\"bg-warning\">Please select/enter a zip file</div>');\n"
          "} else {\n"
            "loadcmd(\"vfs stat \" + input.path).always(function(data) {\n"
              "if (typeof data != \"string\" || data.startsWith(\"Error\")) {\n"
                "input.exists = false;\n"
                "$('#log').empty();\n"
              "} else {\n"
                "input.exists = true;\n"
                "input.url = getPathURL(input.path);\n"
                "$('#log').text(data).append(input.url ? '\\n<a href=\"'+input.url+'\">Download '+input.file+'</a>' : '');\n"
              "}\n"
              "updateButtons();\n"
            "});\n"
          "}\n"
        "},\n"
      "});\n"
    "\n"
      "$('#action-newdir').on('click', function(ev) {\n"
        "$('#backupbrowser').filebrowser('newDir');\n"
      "});\n"
    "\n"
      "$('#action-backup').on('click', function(ev) {\n"
        "$('#backupbrowser').filebrowser('stopLoad');\n"
        "promptdialog(\"password\", \"Create backup\", \"ZIP password / empty = use module password\", [\"Cancel\", \"Create backup\"], function(ok, password) {\n"
          "if (ok) {\n"
            "updateButtons(false);\n"
            "loadcmd(\"config backup \" + zip.path + ((password) ? \" \" + password : \"\"), \"#log\", 120).done(function(output) {\n"
              "if (output.indexOf(\"Error\") < 0) {\n"
                "zip.exists = true;\n"
                "zip.url = getPathURL(zip.path);\n"
                "$('#log').append(zip.url ? '\\n<a href=\"'+zip.url+'\">Download '+zip.file+'</a>' : '');\n"
                "$('#backupbrowser').filebrowser('loadDir');\n"
              "}\n"
            "}).always(function() {\n"
              "updateButtons();\n"
            "});\n"
          "}\n"
        "});\n"
      "});\n"
    "\n"
      "$('#action-restore').on('click', function(ev) {\n"
        "$('#backupbrowser').filebrowser('stopLoad');\n"
        "promptdialog(\"password\", \"Restore backup\", \"ZIP password / empty = use module password\", [\"Cancel\", \"Restore backup\"], function(ok, password) {\n"
          "if (ok) {\n"
            "var rebooting = false;\n"
            "updateButtons(false);\n"
            "loadcmd(\"config restore \" + zip.path + ((password) ? \" \" + password : \"\"), \"#log\", function(msg) {\n"
              "if (msg.text && msg.text.indexOf(\"rebooting\") >= 0) {\n"
                "rebooting = true;\n"
                "msg.request.abort();\n"
              "}\n"
              "return rebooting ? null : standardTextFilter(msg);\n"
            "}, 120).always(function(data, status) {\n"
              "updateButtons();\n"
              "if (rebooting) $('#log').reconnectTicker();\n"
            "});\n"
          "}\n"
        "});\n"
      "});\n"
    "\n"
    "})();\n"
    "</script>\n"
  );
  c.done();
}

/**
 * HandleCfgPlugins: configure/edit web plugins (URL /cfg/plugins)
 */

static void OutputPluginList(PageEntry_t& p, PageContext_t& c)
{
  c.print(
    "<style>\n"
    ".list-editor > table {\n"
      "border-bottom: 1px solid #ddd;\n"
    "}\n"
    ".list-input .form-control,\n"
    ".list-input > .btn-group,\n"
    ".list-input > button {\n"
      "margin-right: 20px;\n"
      "margin-bottom: 5px;\n"
    "}\n"
    "</style>\n"
    "\n"
    "<div class=\"panel panel-primary\">\n"
      "<div class=\"panel-heading\">Webserver Plugins</div>\n"
      "<div class=\"panel-body\">\n"
        "<form class=\"form-inline\" method=\"post\" action=\"/cfg/plugins\" target=\"#main\">\n"
        "<div class=\"list-editor\" id=\"pluginlist\">\n"
          "<table class=\"table form-table\">\n"
            "<colgroup>\n"
              "<col style=\"width:10%\">\n"
              "<col style=\"width:90%\">\n"
            "</colgroup>\n"
            "<template>\n"
              "<tr class=\"list-item mode-ITEM_MODE\">\n"
                "<td><button type=\"button\" class=\"btn btn-danger list-item-del\"><strong>✖</strong></button></td>\n"
                "<td class=\"list-input\">\n"
                  "<input type=\"hidden\" name=\"mode_ITEM_ID\" value=\"ITEM_MODE\">\n"
                  "<select class=\"form-control list-disabled\" size=\"1\" name=\"type_ITEM_ID\">\n"
                    "<option value=\"page\" data-value=\"ITEM_type\">Page</option>\n"
                    "<option value=\"hook\" data-value=\"ITEM_type\">Hook</option>\n"
                  "</select>\n"
                  "<span><input type=\"text\" required pattern=\"^[a-zA-Z0-9._-]+$\" class=\"form-control font-monospace list-disabled\" placeholder=\"Unique name (letters: a-z/A-Z/0-9/./_/-)\"  title=\"Letters: a-z/A-Z/0-9/./_/-\" name=\"key_ITEM_ID\" id=\"input-key_ITEM_ID\" value=\"ITEM_key\"></span>\n"
                  "<div class=\"btn-group\" data-toggle=\"buttons\">\n"
                    "<label class=\"btn btn-default\">\n"
                      "<input type=\"radio\" name=\"enable_ITEM_ID\" value=\"no\" data-value=\"ITEM_enable\"> OFF\n"
                    "</label>\n"
                    "<label class=\"btn btn-default\">\n"
                      "<input type=\"radio\" name=\"enable_ITEM_ID\" value=\"yes\" data-value=\"ITEM_enable\"> ON\n"
                    "</label>\n"
                  "</div>\n"
                  "<button type=\"button\" class=\"btn btn-default action-edit add-disabled\">Edit</button>\n"
                "</td>\n"
              "</tr>\n"
            "</template>\n"
            "<tbody class=\"list-items\">\n"
            "</tbody>\n"
            "<tfoot>\n"
              "<tr>\n"
                "<td><button type=\"button\" class=\"btn btn-success list-item-add\" data-preset='{ \"type\":\"page\", \"enable\":\"yes\" }'><strong>✚</strong></button></td>\n"
                "<td></td>\n"
              "</tr>\n"
            "</tfoot>\n"
          "</table>\n"
          "<input type=\"hidden\" class=\"list-item-id\" name=\"cnt\" value=\"0\">\n"
        "</div>\n"
        "<div class=\"text-center\">\n"
          "<button type=\"submit\" class=\"btn btn-primary\">Save</button>\n"
        "</div>\n"
        "</form>\n"
      "</div>\n"
      "<div class=\"panel-footer\">\n"
        "<p>You can extend your OVMS web interface by using plugins. A plugin can be attached as a new page or hook into an existing page at predefined places.</p>\n"
        "<p>Plugin content is loaded from <code>/store/plugin</code> (covered by backups). Plugins currently must contain valid HTML (other mime types will be supported in the future).</p>\n"
      "</div>\n"
    "</div>\n"
    "\n"
    "<script>\n"
    "$('#pluginlist').listEditor().on('click', 'button.action-edit', function(evt) {\n"
      "var $c = $(this).parent();\n"
      "var data = {\n"
        "key: $c.find('input[name^=\"key\"]').val(),\n"
        "type: $c.find('select[name^=\"type\"]').val(),\n"
      "};\n"
      "if ($('.list-item.mode-add').length == 0) {\n"
        "loaduri('#main', 'get', '/cfg/plugins', data);\n"
      "} else {\n"
        "confirmdialog(\"Discard changes?\", \"Loading the editor will discard your list changes.\", [\"Cancel\", \"OK\"], function(ok) {\n"
          "if (ok) loaduri('#main', 'get', '/cfg/plugins', data);\n"
        "});\n"
      "}\n"
    "}).on('list:validate', function(ev) {\n"
      "var valid = true;\n"
      "var keycnt = {};\n"
      "$(this).find('[name^=\"key\"]').each(function(){ keycnt[$(this).val()] = (keycnt[$(this).val()]||0)+1; });\n"
      "$(this).find('.mode-add [name^=\"key\"]').each(function() {\n"
        "if (keycnt[$(this).val()] > 1 || !$(this).val().match($(this).attr(\"pattern\"))) {\n"
          "valid = false;\n"
          "$(this).parent().addClass(\"has-error\");\n"
        "} else {\n"
          "$(this).parent().removeClass(\"has-error\");\n"
        "}\n"
      "});\n"
      "$(this).closest('form').find('button[type=submit]').prop(\"disabled\", !valid);\n"
    "});\n"
    );

  OvmsConfigParam* cp = MyConfig.CachedParam("http.plugin");
  if (cp)
  {
    const ConfigParamMap& pmap = cp->GetMap();
    std::string key, type, enable;

    for (auto& kv : pmap)
    {
      if (!endsWith(kv.first, ".enable"))
        continue;
      key = kv.first.substr(0, kv.first.length() - 7);
      type = cp->IsDefined(key+".hook") ? "hook" : "page";
      enable = kv.second;
      c.printf(
        "$('#pluginlist').listEditor('addItem', { key: '%s', type: '%s', enable: '%s' });\n"
        , key.c_str(), type.c_str(), enable.c_str());
    }
  }

  c.print(
    "</script>\n"
    );
}

static bool SavePluginList(PageEntry_t& p, PageContext_t& c, std::string& error)
{
  OvmsConfigParam* cp = MyConfig.CachedParam("http.plugin");
  if (!cp) {
    error += "<li>Internal error: <code>http.plugin</code> config not found</li>";
    return false;
  }

  const ConfigParamMap& pmap = cp->GetMap();
  ConfigParamMap nmap;
  std::string key, type, enable, mode;
  int cnt = atoi(c.getvar("cnt").c_str());
  char buf[20];

  for (int i = 1; i <= cnt; i++)
  {
    sprintf(buf, "_%d", i);
    key = c.getvar(std::string("key")+buf);
    mode = c.getvar(std::string("mode")+buf);
    if (key == "" || mode == "")
      continue;
    type = c.getvar(std::string("type")+buf);
    enable = c.getvar(std::string("enable")+buf);

    nmap[key+".enable"] = enable;
    nmap[key+".page"] = (mode=="add") ? "" : cp->GetValue(key+".page");
    if (type == "page") {
      nmap[key+".label"] = (mode=="add") ? "" : cp->GetValue(key+".label");
      nmap[key+".menu"] = (mode=="add") ? "" : cp->GetValue(key+".menu");
      nmap[key+".auth"] = (mode=="add") ? "" : cp->GetValue(key+".auth");
    } else {
      nmap[key+".hook"] = (mode=="add") ? "" : cp->GetValue(key+".hook");
    }
  }

  // deleted:
  for (auto& kv : pmap)
  {
    if (!endsWith(kv.first, ".enable"))
      continue;
    if (nmap.count(kv.first) == 0) {
      key = "/store/plugin/" + kv.first.substr(0, kv.first.length() - 7);
      unlink(key.c_str());
    }
  }

  cp->SetMap(nmap);

  return true;
}

static void OutputPluginEditor(PageEntry_t& p, PageContext_t& c)
{
  std::string key = c.getvar("key");
  std::string type = c.getvar("type");
  std::string page, hook, label, menu, auth;
  extram::string content;

  page = MyConfig.GetParamValue("http.plugin", key+".page");
  if (type == "page") {
    label = MyConfig.GetParamValue("http.plugin", key+".label");
    menu = MyConfig.GetParamValue("http.plugin", key+".menu");
    auth = MyConfig.GetParamValue("http.plugin", key+".auth");
  } else {
    hook = MyConfig.GetParamValue("http.plugin", key+".hook");
  }

  // read plugin content:
  std::string path = "/store/plugin/" + key;
  std::ifstream file(path, std::ios::in | std::ios::binary | std::ios::ate);
  if (file.is_open()) {
    auto size = file.tellg();
    if (size > 0) {
      content.resize(size, '\0');
      file.seekg(0);
      file.read(&content[0], size);
    }
  }

  c.printf(
    "<div class=\"panel panel-primary\">\n"
      "<div class=\"panel-heading\">Plugin Editor: <code>%s</code></div>\n"
      "<div class=\"panel-body\">\n"
      , _html(key));

  c.printf(
        "<form method=\"post\" action=\"/cfg/plugins\" target=\"#main\">\n"
          "<input type=\"hidden\" name=\"key\" value=\"%s\">\n"
          "<input type=\"hidden\" name=\"type\" value=\"%s\">\n"
          , _attr(key)
          , _attr(type));

  if (type == "page")
  {
    c.printf(
          "<div class=\"form-group\">\n"
            "<label class=\"control-label\" for=\"input-page\">Page:</label>\n"
            "<input type=\"text\" class=\"form-control font-monospace\" id=\"input-page\" placeholder=\"Enter page URI\" name=\"page\" value=\"%s\">\n"
            "<span class=\"help-block\">\n"
              "<p>Note: framework URIs have priority. Use prefix <code>/usr/…</code> to avoid conflicts.</p>\n"
            "</span>\n"
          "</div>\n"
          , _attr(page));
    c.printf(
          "<div class=\"form-group\">\n"
            "<label class=\"control-label\" for=\"input-label\">Label:</label>\n"
            "<input type=\"text\" class=\"form-control\" id=\"input-label\" placeholder=\"Enter menu label / page title\" name=\"label\" value=\"%s\">\n"
          "</div>\n"
          , _attr(label));
    c.printf(
          "<div class=\"row\">\n"
            "<div class=\"form-group col-xs-6\">\n"
              "<label class=\"control-menu\" for=\"input-menu\">Menu:</label>\n"
              "<select class=\"form-control\" id=\"input-menu\" size=\"1\" name=\"menu\">\n"
                "<option %s>None</option>\n"
                "<option %s>Main</option>\n"
                "<option %s>Tools</option>\n"
                "<option %s>Config</option>\n"
                "<option %s>Vehicle</option>\n"
              "</select>\n"
            "</div>\n"
          , (menu == "None") ? "selected" : ""
          , (menu == "Main") ? "selected" : ""
          , (menu == "Tools") ? "selected" : ""
          , (menu == "Config") ? "selected" : ""
          , (menu == "Vehicle") ? "selected" : "");
    c.printf(
            "<div class=\"form-group col-xs-6\">\n"
              "<label class=\"control-auth\" for=\"input-auth\">Authorization:</label>\n"
              "<select class=\"form-control\" id=\"input-auth\" size=\"1\" name=\"auth\">\n"
                "<option %s>None</option>\n"
                "<option %s>Cookie</option>\n"
                "<option %s>File</option>\n"
              "</select>\n"
            "</div>\n"
          "</div>\n"
          , (auth == "None") ? "selected" : ""
          , (auth == "Cookie") ? "selected" : ""
          , (auth == "File") ? "selected" : "");
  }
  else // type == "hook"
  {
    c.printf(
          "<div class=\"form-group\">\n"
            "<label class=\"control-label\" for=\"input-page\">Page:</label>\n"
            "<input type=\"text\" class=\"form-control font-monospace\" id=\"input-page\" placeholder=\"Enter page URI\" name=\"page\" value=\"%s\">\n"
          "</div>\n"
          , _attr(page));
    c.printf(
          "<div class=\"form-group\">\n"
            "<label class=\"control-label\" for=\"input-hook\">Hook:</label>\n"
            "<input type=\"text\" class=\"form-control font-monospace\" id=\"input-hook\" placeholder=\"Enter hook code\" name=\"hook\" value=\"%s\" list=\"hooks\">\n"
            "<datalist id=\"hooks\">\n"
              "<option value=\"body.pre\">\n"
              "<option value=\"body.post\">\n"
            "</datalist>\n"
          "</div>\n"
          , _attr(hook));
  }

  c.printf(
          "<div class=\"form-group\">\n"
            "<label class=\"control-label\" for=\"input-content\">Plugin content:</label>\n"
            "<div class=\"textarea-control pull-right\">\n"
              "<button type=\"button\" class=\"btn btn-sm btn-default tac-wrap\" title=\"Wrap long lines\">⇌</button>\n"
              "<button type=\"button\" class=\"btn btn-sm btn-default tac-smaller\">&minus;</button>\n"
              "<button type=\"button\" class=\"btn btn-sm btn-default tac-larger\">&plus;</button>\n"
            "</div>\n"
            "<textarea class=\"form-control fullwidth font-monospace\" rows=\"20\"\n"
              "autocapitalize=\"none\" autocorrect=\"off\" autocomplete=\"off\" spellcheck=\"false\"\n"
              "id=\"input-content\" name=\"content\">%s</textarea>\n"
          "</div>\n"
          , c.encode_html(content).c_str());

  c.print(
          "<div class=\"text-center\">\n"
            "<button type=\"reset\" class=\"btn btn-default\">Reset</button>\n"
            "<button type=\"button\" class=\"btn btn-default action-cancel\">Cancel</button>\n"
            "<button type=\"submit\" class=\"btn btn-primary action-save\">Save</button>\n"
          "</div>\n"
        "</form>\n"
      "</div>\n"
    "</div>\n"
    "<script>\n"
    "$('.action-cancel').on('click', function(ev) {\n"
      "loaduri('#main', 'get', '/cfg/plugins');\n"
    "});\n"
    "/* textarea controls */\n"
    "$('.tac-wrap').on('click', function(ev) {\n"
      "var $this = $(this), $ta = $this.parent().next();\n"
      "$this.toggleClass(\"active\");\n"
      "$ta.css(\"white-space\", $this.hasClass(\"active\") ? \"pre-wrap\" : \"pre\");\n"
      "if (!supportsTouch) $ta.focus();\n"
    "});\n"
    "$('.tac-smaller').on('click', function(ev) {\n"
      "var $this = $(this), $ta = $this.parent().next();\n"
      "var fs = parseInt($ta.css(\"font-size\"));\n"
      "$ta.css(\"font-size\", (fs-1)+\"px\");\n"
      "if (!supportsTouch) $ta.focus();\n"
    "});\n"
    "$('.tac-larger').on('click', function(ev) {\n"
      "var $this = $(this), $ta = $this.parent().next();\n"
      "var fs = parseInt($ta.css(\"font-size\"));\n"
      "$ta.css(\"font-size\", (fs+1)+\"px\");\n"
      "if (!supportsTouch) $ta.focus();\n"
    "});\n"
    "/* remember textarea config: */\n"
    "window.prefs = $.extend({ plugineditor: { height: '300px', wrap: false, fontsize: '13px' } }, window.prefs);\n"
    "$('#input-content').css(\"height\", prefs.plugineditor.height).css(\"font-size\", prefs.plugineditor.fontsize);\n"
    "if (prefs.plugineditor.wrap) $('.tac-wrap').trigger('click');\n"
    "$('#input-content, .textarea-control').on('click', function(ev) {\n"
      "$ta = $('#input-content');\n"
      "prefs.plugineditor.height = $ta.css(\"height\");\n"
      "prefs.plugineditor.fontsize = $ta.css(\"font-size\");\n"
      "prefs.plugineditor.wrap = $('.tac-wrap').hasClass(\"active\");\n"
    "});\n"
    "</script>\n"
    );
}

static bool SavePluginEditor(PageEntry_t& p, PageContext_t& c, std::string& error)
{
  OvmsConfigParam* cp = MyConfig.CachedParam("http.plugin");
  if (!cp) {
    error += "<li>Internal error: <code>http.plugin</code> config not found</li>";
    return false;
  }

  ConfigParamMap nmap = cp->GetMap();
  std::string key = c.getvar("key");
  std::string type = c.getvar("type");
  extram::string content;

  nmap[key+".page"] = c.getvar("page");
  if (type == "page") {
    nmap[key+".label"] = c.getvar("label");
    nmap[key+".menu"] = c.getvar("menu");
    nmap[key+".auth"] = c.getvar("auth");
  } else {
    nmap[key+".hook"] = c.getvar("hook");
  }

  cp->SetMap(nmap);

  // write plugin content:
  c.getvar("content", content);
  content = stripcr(content);
  mkpath("/store/plugin");
  std::string path = "/store/plugin/" + key;
  std::ofstream file(path, std::ios::out | std::ios::binary | std::ios::trunc);
  if (file.is_open()) {
    file.write(content.data(), content.size());
  }
  if (file.fail()) {
    error += "<li>Error writing to <code>" + c.encode_html(path) + "</code>: " + strerror(errno) + "</li>";
    return false;
  }

  return true;
}

void OvmsWebServer::HandleCfgPlugins(PageEntry_t& p, PageContext_t& c)
{
  std::string cnt = c.getvar("cnt");
  std::string key = c.getvar("key");
  std::string error, info;

  if (c.method == "POST") {
    if (cnt != "") {
      if (SavePluginList(p, c, error)) {
        info = "<p class=\"lead\">Plugin registration saved.</p>"
          "<script>after(0.5, reloadmenu)</script>";
      }
    }
    else if (key != "") {
      if (SavePluginEditor(p, c, error)) {
        info = "<p class=\"lead\">Plugin <code>" + c.encode_html(key) + "</code> saved.</p>"
          "<script>after(0.5, reloadmenu)</script>";
        key = "";
      }
    }
  }

  if (error != "") {
    error = "<p class=\"lead\">Error!</p><ul class=\"errorlist\">" + error + "</ul>";
    c.head(400);
    c.alert("danger", error.c_str());
  } else {
    c.head(200);
    if (info != "")
      c.alert("success", info.c_str());
  }

  if (key == "")
    OutputPluginList(p, c);
  else
    OutputPluginEditor(p, c);

  c.done();
}


/**
 * HandleEditor: simple text file editor
 */
void OvmsWebServer::HandleEditor(PageEntry_t& p, PageContext_t& c)
{
  std::string error, info;
  std::string path = c.getvar("path");
  extram::string content;

  if (MyConfig.ProtectedPath(path)) {
    c.head(400);
    c.alert("danger", "<p class=\"lead\">Error: protected path</p>");
    c.done();
    return;
  }

  if (c.method == "POST")
  {
    bool got_content = c.getvar("content", content);
    content = stripcr(content);

    if (path == "" || path.front() != '/' || path.back() == '/') {
      error += "<li>Missing or invalid path</li>";
    }
    else if (!got_content) {
      error += "<li>Missing content</li>";
    }
    else {
      // create path:
      size_t n = path.rfind('/');
      if (n != 0 && n != std::string::npos) {
        std::string dir = path.substr(0, n);
        if (!path_exists(dir)) {
          if (mkpath(dir) != 0)
            error += "<li>Error creating path <code>" + c.encode_html(dir) + "</code>: " + strerror(errno) + "</li>";
          else
            info += "<p class=\"lead\">Path <code>" + c.encode_html(dir) + "</code> created.</p>";
        }
      }
      // write file:
      if (error == "") {
        std::ofstream file(path, std::ios::out | std::ios::binary | std::ios::trunc);
        if (file.is_open())
          file.write(content.data(), content.size());
        if (file.fail()) {
          error += "<li>Error writing to <code>" + c.encode_html(path) + "</code>: " + strerror(errno) + "</li>";
        } else {
          info += "<p class=\"lead\">File <code>" + c.encode_html(path) + "</code> saved.</p>";
          MyEvents.SignalEvent("system.vfs.file.changed", (void*)path.c_str(), path.size()+1);
        }
      }
    }
  }
  else
  {
    if (path == "") {
      path = "/store/";
    } else if (path.back() != '/') {
      // read file:
      std::ifstream file(path, std::ios::in | std::ios::binary | std::ios::ate);
      if (file.is_open()) {
        auto size = file.tellg();
        if (size > 0) {
          content.resize(size, '\0');
          file.seekg(0);
          file.read(&content[0], size);
        }
      }
    }
  }

  // output:
  if (error != "") {
    error = "<p class=\"lead\">Error:</p><ul class=\"errorlist\">" + error + "</ul>";
    c.head(400);
    c.alert("danger", error.c_str());
  } else {
    c.head(200);
    if (info != "")
      c.alert("success", info.c_str());
  }

  c.printf(
    "<style>\n"
    "#input-content {\n"
      "resize: vertical;\n"
    "}\n"
    "#output {\n"
      "height: 200px;\n"
      "resize: vertical;\n"
      "white-space: pre-wrap;\n"
    "}\n"
    ".action-group > div > div {\n"
      "margin-bottom: 10px;\n"
    "}\n"
    "@media (max-width: 767px) {\n"
      ".action-group > div > div {\n"
        "text-align: center !important;\n"
      "}\n"
    "}\n"
    ".log { font-size: 87%; color: gray; }\n"
    ".log.log-I { color: green; }\n"
    ".log.log-W { color: darkorange; }\n"
    ".log.log-E { color: red; }\n"
    "</style>\n"
    "<div class=\"panel panel-primary\">\n"
      "<div class=\"panel-heading\">Text Editor</div>\n"
      "<div class=\"panel-body\">\n"
        "<form method=\"post\" action=\"%s\" target=\"#main\">\n"
          "<div class=\"form-group\">\n"
            "<div class=\"flex-group\">\n"
              "<button type=\"button\" class=\"btn btn-default action-open\" accesskey=\"O\"><u>O</u>pen…</button>\n"
              "<input type=\"text\" class=\"form-control font-monospace\" placeholder=\"File path\"\n"
                "name=\"path\" id=\"input-path\" value=\"%s\" autocapitalize=\"none\" autocorrect=\"off\"\n"
                "autocomplete=\"off\" spellcheck=\"false\">\n"
            "</div>\n"
          "</div>\n"
    , _attr(p.uri), _attr(path));

  c.printf(
          "<div class=\"form-group\">\n"
            "<div class=\"textarea-control pull-right\">\n"
              "<button type=\"button\" class=\"btn btn-sm btn-default tac-wrap\" title=\"Wrap lines\">⇌</button>\n"
              "<button type=\"button\" class=\"btn btn-sm btn-default tac-smaller\">&minus;</button>\n"
              "<button type=\"button\" class=\"btn btn-sm btn-default tac-larger\">&plus;</button>\n"
            "</div>\n"
            "<textarea class=\"form-control fullwidth font-monospace\" rows=\"20\"\n"
              "autocapitalize=\"none\" autocorrect=\"off\" autocomplete=\"off\" spellcheck=\"false\"\n"
              "id=\"input-content\" name=\"content\">%s</textarea>\n"
          "</div>\n"
          "<div class=\"row action-group\">\n"
            "<div class=\"col-sm-6\">\n"
              "<div class=\"text-left\">\n"
                "<button type=\"button\" class=\"btn btn-default action-script-eval\" accesskey=\"V\">E<u>v</u>aluate JS</button>\n"
                "<button type=\"button\" class=\"btn btn-default action-script-reload\">Reload JS Engine</button>\n"
              "</div>\n"
            "</div>\n"
            "<div class=\"col-sm-6\">\n"
              "<div class=\"text-right\">\n"
                "<button type=\"reset\" class=\"btn btn-default\">Reset</button>\n"
                "<button type=\"button\" class=\"btn btn-default action-reload\">Reload</button>\n"
                "<button type=\"button\" class=\"btn btn-default action-saveas\">Save as…</button>\n"
                "<button type=\"button\" class=\"btn btn-primary action-save\" accesskey=\"S\"><u>S</u>ave</button>\n"
              "</div>\n"
            "</div>\n"
          "</div>\n"
        "</form>\n"
        "<div class=\"filedialog\" id=\"fileselect\" />\n"
        "<pre class=\"receiver\" id=\"output\" style=\"display:none\" />\n"
      "</div>\n"
      "<div class=\"panel-footer\">\n"
        "<p>Hints: you don't need to save to evaluate Javascript code. See <a target=\"_blank\"\n"
          "href=\"https://docs.openvehicles.com/en/latest/userguide/scripting.html#testing-javascript-modules\"\n"
          ">user guide</a> on how to test a module lib plugin w/o saving and reloading.\n"
          "Use a second session to test a web plugin.</p>\n"
      "</div>\n"
    "</div>\n"
    , c.encode_html(content).c_str());

  c.print(
    "<script>\n"
    "(function(){\n"
      "var $ta = $('#input-content'), $output = $('#output');\n"
      "var path = $('#input-path').val();\n"
      "var quicknav = ['/sd/', '/store/', '/store/scripts/', '/store/plugin/'];\n"
      "var dir = path.replace(/[^/]*$/, '');\n"
      "if (dir && dir.length > 1 && quicknav.indexOf(dir) < 0)\n"
        "quicknav.push(dir);\n"
      "$(\"#fileselect\").filedialog({\n"
        "\"path\": path,\n"
        "\"quicknav\": quicknav,\n"
      "});\n"
      "$('#input-path').on('keydown', function(ev) {\n"
        "if (ev.which == 13) {\n"
          "ev.preventDefault();\n"
          "return false;\n"
        "}\n"
      "});\n"
      "$('.action-open').on('click', function() {\n"
        "$(\"#fileselect\").filedialog('show', {\n"
          "title: \"Load File\",\n"
          "submit: \"Load\",\n"
          "onSubmit: function(input) {\n"
            "if (input.file)\n"
              "loaduri(\"#main\", \"get\", page.path, { \"path\": input.path });\n"
          "},\n"
        "});\n"
      "});\n"
      "$('.action-saveas').on('click', function() {\n"
        "$(\"#fileselect\").filedialog('show', {\n"
          "title: \"Save File\",\n"
          "submit: \"Save\",\n"
          "onSubmit: function(input) {\n"
            "if (input.file) {\n"
              "$('#input-path').val(input.path);\n"
              "$('form').submit();\n"
            "}\n"
          "},\n"
        "});\n"
      "});\n"
      "$('.action-save').on('click', function() {\n"
        "path = $('#input-path').val();\n"
        "if (path == '' || path.endsWith('/'))\n"
          "$('.action-saveas').click();\n"
        "else\n"
          "$('form').submit();\n"
      "});\n"
      "$('.action-reload').on('click', function() {\n"
        "path = $('#input-path').val();\n"
        "if (path)\n"
          "loaduri(\"#main\", \"get\", page.path, { \"path\": path });\n"
      "});\n"
      "// Reload Javascript engine:\n"
      "$('.action-script-reload').on('click', function() {\n"
        "$('.panel').addClass('loading');\n"
        "$ta.focus();\n"
        "$output.empty().show();\n"
        "loadcmd('script reload', '+#output').then(function(){\n"
          "$('.panel').removeClass('loading');\n"
        "});\n"
      "});\n"
      "// Utility: select textarea line\n"
      "function selectLine(line) {\n"
        "var ta = $ta.get(0);\n"
        "// find line:\n"
        "var txt = ta.value;\n"
        "var lines = txt.split('\\n');\n"
        "if (!lines || lines.length < line) return;\n"
        "var start = 0, end, i;\n"
        "for (i = 0; i < line-1; i++)\n"
          "start += lines[i].length + 1;\n"
        "end = start + lines[i].length;\n"
        "// select & show line:\n"
        "ta.focus();\n"
        "ta.value = txt.substr(0, end);\n"
        "ta.scrollTop = ta.scrollHeight;\n"
        "ta.value = txt;\n"
        "ta.setSelectionRange(start, end);\n"
      "}\n"
      "// Evaluate input as Javascript:\n"
      "$('.action-script-eval').on('click', function() {\n"
        "$('.panel').addClass('loading');\n"
        "$ta.focus();\n"
        "$output.empty().show();\n"
        "loadcmd({ command: $ta.val(), type: 'js' }, '+#output').then(function(output){\n"
          "$('.panel').removeClass('loading');\n"
          "if (output == \"\") {\n"
            "$output.append(\"(OK, no output)\");\n"
            "return;\n"
          "}\n"
          "var hasLine = output.match(/\\(line ([0-9]+)\\)/);\n"
          "if (hasLine && hasLine.length >= 2)\n"
            "selectLine(hasLine[1]);\n"
        "});\n"
      "});\n"
      "$output.on(\"msg:log\", function(ev, msg){\n"
        "if ($output.css(\"display\") == \"none\") return;\n"
        "if (!/^..[0-9()]+ (script|ovms-duk)/.test(msg)) return;\n"
        "var autoscroll = ($output.get(0).scrollTop + $output.innerHeight()) >= $output.get(0).scrollHeight;\n"
        "htmsg = '<div class=\"log log-'+msg[0]+'\">'+encode_html(unwrapLogLine(msg))+'</div>';\n"
        "$output.append(htmsg);\n"
        "if (autoscroll) $output.scrollTop($output.get(0).scrollHeight);\n"
      "});\n"
      "/* textarea controls */\n"
      "$('.tac-wrap').on('click', function(ev) {\n"
        "var $this = $(this);\n"
        "$this.toggleClass(\"active\");\n"
        "$ta.css(\"white-space\", $this.hasClass(\"active\") ? \"pre-wrap\" : \"pre\");\n"
        "if (!supportsTouch) $ta.focus();\n"
      "});\n"
      "$('.tac-smaller').on('click', function(ev) {\n"
        "var $this = $(this);\n"
        "var fs = parseInt($ta.css(\"font-size\"));\n"
        "$ta.css(\"font-size\", (fs-1)+\"px\");\n"
        "if (!supportsTouch) $ta.focus();\n"
      "});\n"
      "$('.tac-larger').on('click', function(ev) {\n"
        "var $this = $(this);\n"
        "var fs = parseInt($ta.css(\"font-size\"));\n"
        "$ta.css(\"font-size\", (fs+1)+\"px\");\n"
        "if (!supportsTouch) $ta.focus();\n"
      "});\n"
      "/* remember textarea config: */\n"
      "window.prefs = $.extend({ texteditor: { height: '300px', wrap: false, fontsize: '13px' } }, window.prefs);\n"
      "$('#input-content').css(\"height\", prefs.texteditor.height).css(\"font-size\", prefs.texteditor.fontsize);\n"
      "if (prefs.texteditor.wrap) $('.tac-wrap').trigger('click');\n"
      "$('#input-content, .textarea-control').on('click', function(ev) {\n"
        "prefs.texteditor.height = $ta.css(\"height\");\n"
        "prefs.texteditor.fontsize = $ta.css(\"font-size\");\n"
        "prefs.texteditor.wrap = $('.tac-wrap').hasClass(\"active\");\n"
      "});\n"
      "/* auto open file dialog: */\n"
      "if (path == dir && $('#input-content').val() == '')\n"
        "$('.action-open').trigger('click');\n"
    "})();\n"
    "</script>\n"
    );

  c.done();
}


/**
 * HandleFile: file load/save API
 *
 *  URL: /api/file
 *
 *  @param method
 *    GET   = load content from path
 *    POST  = save content to path
 *  @param path
 *    Full path to file
 *  @param content
 *    File content for POST
 *
 *  @return
 *    Status: 200 (OK) / 400 (Error)
 *    Body: GET: file content or error message, POST: empty or error message
 */
void OvmsWebServer::HandleFile(PageEntry_t& p, PageContext_t& c)
{
  std::string error;
  std::string path = c.getvar("path");
  extram::string content;

  std::string headers =
    "Content-Type: application/octet-stream; charset=utf-8\r\n"
    "Cache-Control: no-cache";

  if (MyConfig.ProtectedPath(path)) {
    c.head(400, headers.c_str());
    c.print("ERROR: Protected path\n");
    c.done();
    return;
  }

  if (c.method == "POST")
  {
    bool got_content = c.getvar("content", content);

    if (path == "" || path.front() != '/' || path.back() == '/') {
      error += "; Missing or invalid path";
    }
    else if (!got_content) {
      error += "; Missing content";
    }
    else {
      // write file:
      if (save_file(path, content) != 0) {
        error += "; Error writing to path: ";
        error += strerror(errno);
      }
    }
  }
  else
  {
    if (path == "") {
      path = "/store/";
    } else if (path.back() != '/') {
      // read file:
      if (load_file(path, content) != 0) {
        error += "; Error reading from path: ";
        error += strerror(errno);
      }
    }
  }

  // output:
  if (!error.empty()) {
    c.head(400, headers.c_str());
    c.print("ERROR: ");
    c.print(error.substr(2)); // skip "; " intro
    c.print("\n");
  } else {
    c.head(200);
    if (c.method == "GET") {
      c.print(content);
    }
  }

  c.done();
}
