Friday, May 5, 2017

Speaker Controller, version 2, part 3, the code

Here is the code for the speaker controller. There are enough comments that I think it should be self-explanatory.

Download ESP01RelayServer.ino



/*
MIT License

Copyright (c) 2017 Dale Anson

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.
*/

/*
Revised speaker controller code to work with ESP-01 with extended outputs via
an MCP23017.

This is intended for controlling 8 speaker zones. It should be fairly straight-
forward to modify this to run as few as 2 or as many as 128 zones.

The hardware is constructed with two 8 relay modules. One module is used for the
left speakers, the other for the right. Doing it this way makes the code cleaner
in setting the bits for each module since the bit field is the same for each.

There is no real security here, passwords are stored unencrypted in flash memory.
It is NOT recommended to connect the speaker controller directly to the internet,
rather, it should be behind a firewall. Nothing in this code calls the internet,
but I can't claim the same for the included libraries. I don't think they do, but
I haven't confirmed. 
*/

#include <ArduinoJson.h>             // read/write config file
#include <EEPROM.h>                  // to read wifi credentials
#include <ESP8266HTTPUpdateServer.h> // for ota update
#include <ESP8266WebServer.h>
#include <ESP8266WiFi.h>
#include <ESP8266mDNS.h> // dns server
#include <FS.h>          // file system
#include <WiFiClient.h>
#include <WiFiManager.h>
#include <Wire.h> // i2c comms

// i2c address of mcp23017 chip, the chip is hardwired to this address, so don't
// change it unless you rewire the chip
const byte mcp_address = 0x20;

// mcp23017 address of bank A
const byte GPIOA = 0x12;

// mcp23017 address of bank B
const byte GPIOB = 0x13;

// storage for access point password
String apPassword = "speakercontroller";

// default speaker zone names
String zoneNames[] = {"Zone 0", "Zone 1", "Zone 2", "Zone 3", "Zone 4", "Zone 5", "Zone 6", "Zone 7"};

// number of zones
int zones = 8;

// flag indicating configuration needs to be saved
bool shouldSaveConfig = false;

// web server runs on port 80, http only
ESP8266WebServer server(80);
ESP8266HTTPUpdateServer updater;

// bit field for zone state, initially all are off -- note that for relays, 1 means
// means off, 0 means on
byte zoneState = 0b11111111;

// for over-the-air (ota) updates and local dns
const char *host = "speakercontroller";
const char *updatePath = "/firmware/update";
const char *updateUsername = "admin";

void setup(void) {
    // initialize the wire library, use GPIO0 and GPIO2 pins on ESP-01
    // for SDA and SCL, respectively. This sets up comms with the MCP23017.
    Wire.begin(0, 2);

    // serial monitor port
    Serial.begin(115200);
    Serial.println("");
    Serial.println("Speaker Controller starting");

    // load configuration, if any. This also initializes the file system.
    loadConfig();

    // turn off all relays at start up
    Serial.println("initialize relays...");
    initialize();
    Serial.println("relays initialized");

    // for testing
    // resetServer();

    // start dns, this speaker controller can be found at http://speakercontroller.local
    MDNS.begin(host);

    // set up wifi
    setupWifi(false);

    // set up web server
    setupWebServer(false);
}

void loop(void) { server.handleClient(); }

// initialize the i2c communications. On first call, this will turn off all the
// relays since zoneState will be all off. Then on restarts, the speaker
// state will be recovered from flash memory and used to set them as they were
// before the restart. This is nice for situations like when the power blinks.
//
// NOTE: I put this code here because comms between the esp-01 and mcp23017 is a
// little flaky on the breadboard and I can re-initialize easily on error. There 
// is no flakiness when everything is soldered to a prototype board.
void initialize() {
    // loop until connected with MCP23017
    byte error;
    while (true) {
        Wire.beginTransmission(mcp_address);
        error = Wire.endTransmission();
        Serial.println("initialization error = " + String(error));
        if (error == 0) {
            break;
        }
        delay(1000);
    }

    // set MCP23017 GPAx pins to output
    Wire.beginTransmission(mcp_address);
    Wire.write((byte)0x00); // IODIRA register
    Wire.write((byte)0x00); // set all of bank A to outputs
    error = Wire.endTransmission();
    Serial.println("error = " + String(error));

    // set MCP23017 GPBx pins to output
    Wire.beginTransmission(mcp_address);
    Wire.write((byte)0x01); // IODIRB register
    Wire.write((byte)0x00); // set all of bank B to outputs
    error = Wire.endTransmission();
    Serial.println("error = " + String(error));

    // set the left speaker relays
    Wire.beginTransmission(mcp_address);
    Wire.write(GPIOA);
    Wire.write(zoneState);
    error = Wire.endTransmission();
    Serial.println("error = " + String(error));

    // set the right speaker relays
    Wire.beginTransmission(mcp_address);
    Wire.write(GPIOB);
    Wire.write(zoneState);
    error = Wire.endTransmission();
    Serial.println("error = " + String(error));
}

// set up the wifi. if 'reset' is true, reset all stored wifi parameters, this
// is useful for being able to get to the AP again to be able to attach to a
// new wifi network
void setupWifi(boolean reset) {
    // set up WiFiManager
    Serial.println("set up wifi");
    WiFiManager wifiManager;

    // reset saved settings, this clears out previously saved ssid and pw and
    // any other stored wifi parameters
    if (reset) {
        Serial.println("resetting wifi");
        wifiManager.resetSettings();
    }

    // allow user to change the access point password
    // TODO: finish this, it allows the user to change the access point password
    //WiFiManagerParameter custom_ap_password("apPassword", "AP Password", apPassword.c_str(), 32);
    //wifiManager.addParameter(&custom_ap_password);

    // set up the config callback, The wifiManager will call this when it determines
    // the configuration has been changed.
    wifiManager.setSaveConfigCallback(saveConfigCallback);

    // fetches ssid and pass from eeprom and tries to connect
    // if it does not connect it starts an access point with the specified name
    // and goes into a blocking loop awaiting configuration. the access point
    // should show up in the network manager of a phone or computer, it should
    // have an IP address of 192.168.4.1. This lets the user choose which wifi
    // network to connect to.
    Serial.println("apPassword = " + String(apPassword));
    if (!wifiManager.autoConnect("SpeakerControllerAP", apPassword.c_str())) {
        Serial.println("wifi failed to connect, resetting to try again");
        delay(3000);
        ESP.reset();
        delay(5000);
    }

    // possibly save the wifi configuration
    if (shouldSaveConfig) {
        saveConfig();
    }

    // print out connection details to serial monitor
    Serial.println("wifi connected, details follow:");
    Serial.print("Connected to SSID: ");
    Serial.println(WiFi.SSID());
    Serial.print("IP address: ");
    Serial.println(WiFi.localIP());
    Serial.print("MAC address: ");
    byte mac[6];
    WiFi.macAddress(mac);
    Serial.println(getMacString(mac));
    Serial.print("Gateway: ");
    Serial.println(WiFi.gatewayIP());
    Serial.print("DNS: ");
    Serial.println(WiFi.dnsIP());
}

// callback notifying us of the need to save config
void saveConfigCallback() {
    Serial.println("should save config");
    shouldSaveConfig = true;
}

// set up the urls that this server will recognize
void setupWebServer(boolean reset) {
    // maybe stop the web server
    if (reset) {
        Serial.println("stopping web server");
        server.stop();
        Serial.println("web server stopped");
    }

    // define the url handlers
    Serial.println("starting web server");
    server.on("/", handleRoot);
    server.on("/speaker", handleRoot);
    server.on("/speaker/state", handleState);
    server.on("/speaker/all/on", []() { handleAll(true); });
    server.on("/speaker/all/off", []() { handleAll(false); });
    server.on("/speaker/config", handleConfigForm);
    server.on("/speaker/zoneconfig", handleZoneConfigForm);
    server.on("/speaker/zoneconfig/save", handleZoneConfigSave);
    server.on("/speaker/server/reset/confirmwifi", confirmResetWifi);
    server.on("/speaker/server/reset/confirmall", confirmFactoryReset);
    server.on("/speaker/server/reset/wifi", resetWifi);
    server.on("/speaker/server/reset/all", resetServer);
    server.on("/speaker/server/update", flash);
    server.onNotFound(handleRoot);
    
    // attach update server to web server
    updater.setup(&server, updatePath, updateUsername, apPassword.c_str()); 
    
    // start the web servver
    server.begin();
    
    // update dns service discovery to include the web server
    MDNS.addService("http", "tcp", 80);
    Serial.println("web server started");
}

// this method is called when a request is made to the root of the server. i.e.
// http://speakercontroller.local/, or on any bad url that would usually cause a 404
void handleRoot() { sendPage(); }

// this method is called when the url is /speaker/state. There should be a
// query string something like 1=on&2=on&3=on&4=on, where the number is a zone
// number and the value is 'on'. All that is necessary here is to
// extract the query string and check for each of the zone numbers. Note there
// may not be a query string, in which case, all relays are turned off.
void handleState() {
    // turn off all relays
    allOff();

    // figure out what is in the request, there may not be any args at all.
    for (int i = 0; i < server.args(); i++) {
        String argName = server.argName(i);
        int zone = argName.toInt();
        Serial.println("turning on zone " + zone);
        if (zone >= 0 && zone <= zones) {
            zoneState = bitWrite(zoneState, zone, 0);
        }
    }

    // set the left speaker relays
    Serial.println("zoneState = " + String(zoneState));
    Wire.beginTransmission(mcp_address);
    Wire.write(GPIOA);
    Wire.write(zoneState);
    byte error = Wire.endTransmission();
    Serial.println("handleState, error = " + String(error));

    // set the right relays
    Wire.beginTransmission(mcp_address);
    Wire.write(GPIOB);
    Wire.write(zoneState);
    error = Wire.endTransmission();
    Serial.println("handleState, error = " + String(error));

    sendPage();
}

// turn off all relays
void allOff() {
    zoneState = 0b11111111;

    // left speakers
    Wire.beginTransmission(mcp_address);
    Wire.write(GPIOA);
    Wire.write(zoneState);
    byte error = Wire.endTransmission();
    Serial.println("error = " + String(error));

    // right speakers
    Wire.beginTransmission(mcp_address);
    Wire.write(GPIOB);
    Wire.write(zoneState);
    error = Wire.endTransmission();
    Serial.println("error = " + String(error));
}

// this method is called when the url is /speaker/all/on or /speaker/all/off,
// this turns on or off all relays
void handleAll(boolean state) {
    if (state) {
        zoneState = 0b00000000;

        // turn on all left speakers
        Wire.beginTransmission(mcp_address);
        Wire.write(GPIOA);
        Wire.write(zoneState);
        byte error = Wire.endTransmission();
        Serial.println("error = " + String(error));

        // turn on all right speakers
        Wire.beginTransmission(mcp_address);
        Wire.write(GPIOB);
        Wire.write(zoneState);
        error = Wire.endTransmission();
        Serial.println("error = " + String(error));

    } else {
        allOff();
    }
    sendPage();
}

// this method is called when the url is /speaker/config, this sends the
// configuration page back to the browser
void handleConfigForm() {
    String response = getHead();
    response += getConfigForm();
    response += getTail();
    server.send(200, "text/html", response);
}

// this method is called then the url is /speaker/zoneconfig, this sends the 
// zone configuration page back to the browser
void handleZoneConfigForm() {
    String response = getHead();
    response += getZoneConfig();
    response += getTail();
    server.send(200, "text/html", response);
}

// this method is called when the url is /speaker/zoneconfig/save. There should be a
// query string something like 1=Foo&2=Bar, where the number is a zone number and
// the value is the name to use for that zone.
void handleZoneConfigSave() {
    for (byte i = 0; i < zones; i++) {
        String argName = String(i);
        String value = server.arg(argName);
        if (value.length() > 0) {
            zoneNames[i] = value;
        }
    }
    saveConfig();
    sendPage();
}

// sends a confirmation page to the browser asking the user to confirm resetting
// the wifi parameters
void confirmResetWifi() {
    String response = getHead();
    response += getConfirmResetWifiForm();
    response += getTail();
    server.send(200, "text/html", response);
}

// clears stored wifi configuration, user will need to reconfigure wifi to connect
// to SpeakerControllerAP to set up wifi again.
void resetWifi() {
    Serial.println("reset wifi");
    String response = getHead();
    response += getResetWifiMessage();
    response += getTail();
    server.send(200, "text/html", response);
    setupWifi(true);      // 'true' resets wifi manager settings
    setupWebServer(true); // 'true' stops and restarts the web server
}

// sends a confirmation page to the browser asking the user to confirm resetting
// all saved settings
void confirmFactoryReset() {
    String response = getHead();
    response += getConfirmFactoryResetForm();
    response += getTail();
    server.send(200, "text/html", response);
}

// clears stored configuration and wifi settings, and restarts the web server
void resetServer() {
    // send the reset message back to the browser
    Serial.println("reset server");
    String response = getHead();
    response += getResetWifiMessage();
    response += getTail();
    server.send(200, "text/html", response);
    
    // wipe eeprom storage, this is where the ssid and password are stored
    EEPROM.begin(255);
    for (int i = 0; i < 255; i++) {
        EEPROM.write(i, 0);
    }
    EEPROM.end();
    
    // wipe the file system, this is where json configuration is stored
    SPIFFS.format();
    
    // reset wifi and webserver
    setupWifi(true);      // 'true' resets wifi manager settings
    setupWebServer(true); // 'true' stops and restarts the web server
}

// this lets the user upload a new .bin file to replace the current running code.
// This doesn't quite work, the file uploads okay, but then the ESP-01 restarts and
// fails to run.
// TODO: fix it so it works. I think what needs to happen is the 'flash' button
// needs to be held down while uploading the new code... maybe?
void flash() {
    // redirect to flash page
    Serial.println("redirecting to flash page");
    WiFi.mode(WIFI_AP_STA);
    String url = String("http://") + String(host) + String(".local") + String(updatePath);
    Serial.println("update url = " + url);
    server.sendHeader("Location", url, true);
    server.send(302, "text/plain", "");
}

// loads stored settings from the esp8266 file system, the configuration is 
// stored as a json file.
void loadConfig() {
    // for testing, this wipes the file system partition from the flash memory
    // SPIFFS.format();

    // read configuration from the file system, the configuratio file is named config.json
    Serial.println("mounting FS...");

    if (SPIFFS.begin()) {
        Serial.println("mounted file system");
        if (SPIFFS.exists("/config.json")) {
            Serial.println("reading config file");
            File configFile = SPIFFS.open("/config.json", "r");
            if (configFile) {
                Serial.println("opened config file");

                // allocate a buffer to store contents of the file
                size_t size = configFile.size();
                std::unique_ptr<char[]> buf(new char[size]);
                configFile.readBytes(buf.get(), size);

                // parse the json config file
                DynamicJsonBuffer jsonBuffer;
                JsonObject &json = jsonBuffer.parseObject(buf.get());
                if (json.success()) {
                    json.printTo(Serial); // see contents in serial monitor
                    Serial.println("\nparsed json");

                    // read zone labels
                    for (byte i = 0; i < zones; i++) {
                        String key = "zone" + String(i);
                        if (json.containsKey(key)) {
                            zoneNames[i] = json[key].asString();
                        }
                    }

                    // read stored zone state, if any
                    if (json.containsKey("zonestate")) {
                        zoneState = json["zonestate"];
                    }

                    // read access point password, if any
                    if (json.containsKey("apPassword")) {
                        apPassword = json["apPassword"].asString();
                    }

                } else {
                    Serial.println("failed to load json config");
                }
            }
        } else {
            Serial.println("config file not found");
        }
    } else {
        Serial.println("failed to mount FS");
    }
}

// saves configuration to the esp8266 file system. The file is a json file
// named config.json
void saveConfig() {
    Serial.println("saving configuration");
    DynamicJsonBuffer jsonBuffer;
    Serial.println("create json object");
    JsonObject &json = jsonBuffer.createObject();

    // zone labels
    Serial.println("add zone labels");
    for (byte i = 0; i < zones; i++) {
        String label = "zone" + String(i);
        json[label] = zoneNames[i];
    }

    // zone state
    Serial.println("add zone state");
    json["zonestate"] = zoneState;

    // access point password
    Serial.println("add access point password");
    json["apPassword"] = apPassword;

    Serial.println("open config.json");
    File configFile = SPIFFS.open("/config.json", "w");
    if (!configFile) {
        Serial.println("failed to open config file for writing");
    }
    Serial.println("config file open, print to serial");
    json.printTo(Serial);
    Serial.println("\nconfig file open, print to file");
    json.printTo(configFile);
    Serial.println("close file");
    configFile.close();
}

String getMacString(byte *mac) {
    String result = "";
    String separator = "";
    for (int b = 0; b < 6; b++) {
        result += separator + String(mac[b], HEX);
        separator = ":";
    }
    return result;
}

// sends the main page to the browser
void sendPage() {
    String response = getHead();
    response += getForm();
    response += getTail();
    server.send(200, "text/html", response);
}

// the 'bootstrap' css makes the page look good in desktop browsers and on phones
String getHead() {
    return "<html>\n<head>\n<title>Speaker Control</title>\n<meta name='viewport' content='width=device-width, "
           "initial-scale=1'>\n<link rel='stylesheet' "
           "href='http://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css'>\n</head>\n<body>\n";
}

// form to turn on and off the speaker zones
String getForm() {
    String message = "<div class='container'>\n";
    message += "<h2>Speaker Control</h2><br/>\n";
    message += "<form action='/speaker/state' role='form'>\n";

    for (byte i = 0; i < zones; i++) {
        int pinState = bitRead(zoneState, i) == 1 ? 0 : 1;
        message += "<div class='checkbox'><label><input type='checkbox' name='" + String(i) + "'";
        message += pinState == HIGH ? " checked='checked'" : "";
        message += ">" + zoneNames[i] + "</label></div><br/>\n";
    }
    // save button
    message += "<br/>\n<button type='submit' class='btn btn-primary'>Save</button>\n";
    // all on button
    message += "<a class='btn btn-primary' href='/speaker/all/on' role='button'>All On</a>\n";
    // all off button
    message += "<a class='btn btn-primary' href='/speaker/all/off' role='button'>All Off</a>\n";
    // config button
    message += "<a class='btn btn-primary' href='/speaker/config' role='button'>Config</a>\n</form>\n</div>\n";

    return message;
}

String getTail() { return "</body>\n</html>\n"; }

String getConfigForm() {
    String message = "<div class='container'>\n<h2>Configuration Options</h2><br/>\n";
    message += "<form role='form'>\n";

    // zone configuration button
    message += "<a class='btn btn-primary' href='/speaker/zoneconfig' role='button'>Zone Config</a>\n";
    // reset wifi button
    message += "<a class='btn btn-primary' href='/speaker/server/reset/confirmwifi' role='button'>Reset Wifi</a>\n";
    // reset all button
    message += "<a class='btn btn-primary' href='/speaker/server/reset/confirmall' role='button'>Factory Reset</a>\n";
    // flash button
    message += "<a class='btn btn-primary' href='/speaker/server/update' role='button'>OTA Flash</a></form>\n";
    
    message += "<p><a href='/'>Home</a>\n</div>\n";

    return message;
}

String getConfirmResetWifiForm() {
    String message = "<div class='container'>\n<h2>Confirm Reset Wifi</h2><br/>\n";
    message += "<form role='form'>\n";

    // zone configuration button
    message += "<a class='btn btn-primary' href='/speaker/server/reset/wifi' role='button'>Reset Wifi</a>\n";
    // reset wifi button
    message += "<a class='btn btn-primary' href='/speaker/config' role='button'>Cancel</a></form></div>\n";

    return message;
}

String getConfirmFactoryResetForm() {
    String message = "<div class='container'>\n<h2>Confirm Factory Reset</h2><br/>\n";
    message += "<form role='form'>\n";

    // zone configuration button
    message += "<a class='btn btn-primary' href='/speaker/server/reset/all' role='button'>Factory Reset</a>\n";
    // reset wifi button
    message += "<a class='btn btn-primary' href='/speaker/config' role='button'>Cancel</a></form></div>\n";

    return message;
}

String getResetWifiMessage() {
    String message = "<div class='container'>\n<h2>Wifi is resetting!</h2><br/>\n";
    message += "Use your network manager to connect to SpeakerControllerAP to configure the speaker controller to connect to a new wifi network.</div>";
    return message;
}

String getFactoryResetMessage() {
    String message = "<div class='container'>\n<h2>SpeakerController is resetting!</h2><br/>\n";
    message += "Use your network manager to connect to SpeakerControllerAP to configure the speaker controller to connect to a new wifi network.<br>\n";
    message += "Once wifi is reconfigured, zone names will need to be reconfigured.</div>";
    return message;
}

// form to configure the names of the speaker zones
String getZoneConfig() {
    String message = "<div class='container'>\n<h2>Zone Name Configuration</h2><br/>\n<form "
                     "action='/speaker/zoneconfig/save' role='form'>\n";
    for (byte i = 0; i < zones; i++) {
        String index = String(i);
        String label = "Zone " + index;
        String name = zoneNames[i];
        message += "<div class='form-group'>\n";
        message += "<label for='" + index + "'>" + label + "</label>\n";
        message += "<input type='text' class='form-control' id='" + index + "' name='" + index + "' value='" + name +
                   "'>\n</div>\n";
    }
    message += "<br/>\n<button type='submit' class='btn btn-primary'>Save</button>\n";
    message += "<a class='btn btn-primary' href='/speaker/config' role='button'>Cancel</a></form></div>\n";
    return message;
}

6 comments:

Anonymous said...

Hi Dale,
Thanks so much for posting this. What a great project. I am a complete novice but I have built the hardware and copied the code into the Arduino but it's coming back with errors. Not sure if you are still active here or checking this site. Could I trouble you for some help?
Mario

Dale Anson said...

Hi Mario -- I saw your other question also, but only because I happened to be looking, I guess I need to set my notifications differently so I can tell when people ask questions.

FS.h is the File System library, see the details at https://esp8266.github.io/Arduino/versions/2.0.0/doc/filesystem.html

You'll need to install the library in your Arduino IDE then the header include should work.

The json library is from https://arduinojson.org/, same thing, install the library in your Arduino IDE then the header include should work.

Mario said...

Dale would you be willing to share your libraries with me?
Thanks again Mario

Dale Anson said...

I installed all the libraries thru the Arduino IDE, so you should be able to do the same.

mario said...

Hi Dale sorry to bother you the error code in the serial monitor is actually as follows:

Speaker Controller starting
mounting FS...
mounted file system
config file not found
initialize relays...
initialization error = 4

Mario said...

Hey Dale,
Apologize for the trouble but I think I am almost there. I am getting an "initialization error 2" in the serial monitor. Any idea what this might be? When I reset the esp-01 it also mentions it can't find the "config file" before it repeats the same error 2 message. Any guidance would be appreciated. ☻