Monday, June 7, 2021

Moving the tabs in the new Firefox

 So I upgraded my laptop today, the usual pacman -Syu, and got the new version of Firefox, version 89.0. For some reason, browser makers want to put the tabs ABOVE the address bar, which makes no sense, and lots of people are whining about it on the internet.  I did a bunch of googling and found this solution from Mozilla. That almost worked for this new version, but not quite, and they left out a major step. 

Here's the procedure: 

  1. In Firefox in the address bar, type about:support. Make a note of the location of your profile directory.
  2. Go to profile directory from step 1. Create a subdirectory named "chrome" if it doesn't already exist.
  3. Copy the userChrome.css mention in the solution link above by user cor-el (thanks, cor-el! I've also pasted my version below in case cor-el's version disappears for some reason).
  4. Paste the css you copied in step 3 into a file named 'userChrome.css" into the "chrome" directory you created in step 2.
  5. Open the userChrome.css file in your favorite editor (I recommend jEdit, of course).
  6. On line 20, there should be an entry for "--menubar-height", it is 0, which moved the tabs in the way of my bookmark bar. If you don't have the bookmark bar, you're probably good to go, almost -- keep reading. If you do, I changed --menubar-height to 41px. You'll also need to make this change in line 30 if you use full screen mode.
  7. I noticed that if I open several tabs, the tab bar would not expand across the window as expected. I changed line 45, width: 100wv to width: 1000wv, and now the tab bar expands across the window as expected.
  8. Save the file.
  9. In the Firefox address bar, type about:config, search for "toolkit.legacy", there should only be one result, toolkit.legacyUserProfileCustomizations.stylesheets. Change it to "true". This is necessary so Firefox will actually read your userChrome.css file at start up.
  10. Restart Firefox, you should be good to go.

I also noticed after doing this that the tab height is a little too short, so on line 14, I changed the --tab-min-height setting to 30 instead of 25. It also seems like the text in the tab isn't centered well vertically, so on line 54, I changed the 1px to 5 px, that looks better, I think.

You can play around with the other settings as needed to make you happy. I wasn't able to figure out how to make the tabs narrower by default, but they do resize correctly when you have many tabs open.

Here is the full contents of my userChrome.css file:



@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");

/* TABS: bottom - Firefox 65 and later - updated for 89+ */
/* https://searchfox.org/mozilla-release/source/browser/themes/shared/tabs.inc.css */
/* https://developer.mozilla.org/en-US/docs/Tools/Browser_Toolbox */

/* ROOT - VARS */
/* you can adjust the CSS variables until it looks correct */
/* you can use the Browser Toolbox to get the toolbar heights */

*|*:root {
  --tab-toolbar-navbar-overlap: 0px !important;

  --tab-min-height: 30px !important;
  --tab-min-width:  60px !important;

  --tab-caption: -5px; /* caption buttons on tab bar */
  --tab-adjust:   3px; /* adjust tab bar */

  --menubar-height: 41px; /*30px|41px=11px; caption buttons on menubar*/
  --navbar-height:  31px; /*31px*/
  --bookmarkbar-height: 26px;

  --tabbar-top: calc(var(--menubar-height) + var(--navbar-height) + var(--bookmarkbar-height) + var(--tab-adjust)); /*89+*/
}

/* in case you enable extra toolbars in full screen mode */
*|*:root[inFullscreen] {
  --tab-adjust: 3px;
  --menubar-height: 0px; /*30px*/
  --navbar-height:  31px; /*31px*/
  --bookmarkbar-height: 0px; /*26px*/
  --tabbar-top: calc(var(--menubar-height) + var(--navbar-height) + var(--bookmarkbar-height) + var(--tab-adjust)); /*89+*/
}

/* TAB BAR - below nav-bar */
#navigator-toolbox toolbar:not(#nav-bar):not(#toolbar-menubar) {-moz-box-ordinal-group:10}
#TabsToolbar {-moz-box-ordinal-group:1000}

#TabsToolbar {
  display: block !important;
  position: absolute !important;
/*  bottom: 0 !important; /* 68-88: BOTTOM */
  top: var(--tabbar-top);  /* 89+: TOP */
  width: 1000vw !important;
}

#tabbrowser-tabs {
  width: 100vw !important;
}

/* navigator-toolbox - PADDING */
*|*:root:not([chromehidden*="toolbar"]) #navigator-toolbox {
  padding-bottom: calc(var(--tab-min-height) + 1px) !important; /*ADJUST*/
  background-color: var(--toolbar-bgcolor) !important;
}

/* TabsToolbar with menubar and titlebar hidden - rules for Firefox 65-73 */
*|*:root[tabsintitlebar]:not([inFullscreen="true"]):not([sizemode="maximized"])
 #toolbar-menubar[autohide="true"] ~ #TabsToolbar{
}

/* TABS: height */
#tabbrowser-tabs,
#tabbrowser-tabs > .tabbrowser-arrowscrollbox,
.tabbrowser-tabs[positionpinnedtabs] > .tabbrowser-tab[pinned] {
  min-height: var(--tab-min-height) !important;
  max-height: var(--tab-min-height) !important;
}

#TabsToolbar {
  height: var(--tab-min-height) !important;
  margin-bottom: 1px !important;
  box-shadow: ThreeDShadow 0 -1px inset, -moz-dialog 0 1px !important; /*OPTIONAL*/
  background-color: var(--toolbar-bgcolor) !important;
  color:            var(--toolbar-color) !important;
  z-index: 1 !important;
}

/* indicators *//*
*|*:root[privatebrowsingmode=temporary] .private-browsing-indicator {
  position: absolute !important;
  display: block !important;
  right: 0px !important;
  bottom: 0px !important;
  width: 14px !important;
  pointer-events: none !important;
}
*/
.private-browsing-indicator {display: none !important;}
.accessibility-indicator    {display: none !important;}

/* Indicators - HIDE *//*
*|*:root:not([accessibilitymode])             .accessibility-indicator    {display: none !important}
*|*:root:not([privatebrowsingmode=temporary]) .private-browsing-indicator {display: none !important}
*/

/* Drag Space */
.titlebar-spacer[type="pre-tabs"],
.titlebar-spacer[type="post-tabs"] {
  width: 20px !important;
}

/* Override vertical shifts when moving a tab */
#navigator-toolbox[movingtab] > #titlebar > #TabsToolbar {
  padding-bottom: unset !important;
}

#navigator-toolbox[movingtab] #tabbrowser-tabs {
  padding-bottom: unset !important;
  margin-bottom: unset !important;
}

#navigator-toolbox[movingtab] > #nav-bar {
  margin-top: unset !important;
}

/* Hide window-controls and caption buttons on Tab Bar */
#TabsToolbar #window-controls {display: none !important;}
#TabsToolbar .titlebar-buttonbox-container {display: none !important;}

Wednesday, May 26, 2021

How to install and remove KDE on Arch Linux

 So I was just playing around, I used to run KDE all the time (years ago) and thought I'd give it another try. 


Installation was easy:

sudo pacman -S xorg plasma plasma-wayland-session kde-applications


It turns out that KDE still isn't for me, it's a little slow and clunky for my 10 year old laptop, so I'll remove it. That's a little harder:


sudo pacman -Rns plasma plasma-wayland-session kde-applications


Didn't work. Notice I didn't include xorg, I probably didn't need it installed along with KDE since I already had it all installed. The problem was dependencies.


So I tried just uninstalling plasma:


sudo pacman -Rns plasma plasma-wayland-session


Nope:

error: failed to prepare transaction (could not satisfy dependencies)
:: removing libksysguard breaks dependency 'libksysguard' required by kdevelop
:: removing plasma-workspace breaks dependency 'plasma-workspace' required by kget
:: removing libkscreen breaks dependency 'libkscreen' required by lxqt-config
:: removing plasma-workspace breaks dependency 'plasma-workspace' required by telepathy-kde-desktop-applets

 

So I'll just remove kdevelop:


danson@deadlock:~$ sudo pacman -Rns kdevelop
checking dependencies...
error: failed to prepare transaction (could not satisfy dependencies)
:: removing kdevelop breaks dependency 'kdevelop' required by kdevelop-php
 

Okay, I'll just remove kdevelop-php:

danson@deadlock:~$ sudo pacman -Rns kdevelop-php
checking dependencies...
error: failed to prepare transaction (could not satisfy dependencies)
:: removing kdevelop-php breaks dependency 'kdevelop-php' required by umbrello

So remove umbrello: Yep! And it removed kdevelop and kdevelop-php at the same time!
danson@deadlock:~$ sudo pacman -Rns umbrello


Try to remove plasma again:

danson@deadlock:~$ sudo pacman -Rns plasma plasma-wayland-session
checking dependencies...
error: failed to prepare transaction (could not satisfy dependencies)
:: removing plasma-workspace breaks dependency 'plasma-workspace' required by kget
:: removing libkscreen breaks dependency 'libkscreen' required by lxqt-config
:: removing plasma-workspace breaks dependency 'plasma-workspace' required by telepathy-kde-desktop-applets

Okay, so whack kget: Yep!
danson@deadlock:~$ sudo pacman -Rns kget


Try plasma again:

danson@deadlock:~$ sudo pacman -Rns plasma plasma-wayland-session
checking dependencies...
error: failed to prepare transaction (could not satisfy dependencies)
:: removing libkscreen breaks dependency 'libkscreen' required by lxqt-config
:: removing plasma-workspace breaks dependency 'plasma-workspace' required by telepathy-kde-desktop-applets
danson@deadlock:~$ sudo pacman -Rns telepathy-kde-desktop-applets

That worked, also removed lxqt-config, then reinstalled it, since I'm going to be running lqxt.

Now plasma goes away!

danson@deadlock:~$ sudo pacman -Rns plasma plasma-wayland-session


Reinstall lxqt-config:

danson@deadlock:~$ sudo pacman -S lxqt-config

And finally remove the KDE application packages:

danson@deadlock:~$ sudo pacman -Rns kde-applications


That's it. Not particularly straightforward, but easy enough to get all this stuff off of my laptop.



Tuesday, September 29, 2020

Freedb, the CD database is gone, here are alternatives

Freedb shutdown earlier this year, and it was the default CDDB for Asunder, which is what I usually use for ripping CDs. 

I found two alternatives:

  1. gnudb.gnudb.org, port 8880
    This is a replacement for freedb, with the stated goal of keeping the database alive, free, and active. 
  2. freedb.freac.org, port 80
    This is another replacement for freedb, and seems to be somewhat more complete than gnudb.

In Asunder, go to Preferences - Advanced, and fill in one of these server names and port.

Saturday, June 13, 2020

Arduino IDE and ctags

This took me a while to figure out -- suddenly, sketches that have been compiling and uploading stopped compiling. The error message was useless:

exit status 1
Error compiling for board WeMos D1 R1.


I've been using jEdit as my editor for Arduino, there is a setting in the Arduino IDE to use an external editor, and that's what I've been doing. Since *.ino files are essentially C or C++ files, I wanted to use the ctags sidekick in jEdit to see the various declarations. As *.ino is not a standard file name extension for ctags, I added it in my ~/.ctags file like this:

--langmap=C++:+.ino

It turns out that didn't work for jEdit, for some reason, the plugin doesn't pick up that setting. In the plugin options for the ctags sidekick, there is a place to enter ctags invocation options, so I entered the above line and now the sidekick properly parses *.ino files. Great!

Not so great. Now nothing will compile in Arduino IDE. It turns out the IDE also uses ctags, and that line in my ~/.ctags file caused the problem. Since I put the same line in jEdit, I didn't really need the .ctags file any more, so I deleted it and sketches compiled and uploaded again. Weird!

Friday, June 12, 2020

How to Compile iSpindel with Arduino IDE


My next little electronics project is to build an iSpindel. This is an open source project for DIY people to build their own Tilt-like specific gravity monitor for beer brewing, so this combines two of my favorite hobbies, brewing and electronics. The basic idea is as the beer ferments the angle of the iSpindel device sinks to a lower angle, and then does some math to figure out the specific gravity. The idea isn't so much to have exact gravity readings, but to know when fermentation is done by seeing the gravity flat-line.

iSpindel is set up to compile on PlatformIO, but I don't want to have to install and learn how to use a new IDE, so I'm going to do it in Arduino IDE. I realize the project has firmware files that are already compiled and ready to upload, but I want to make a couple of small changes, so I want to be able to compile it myself. The license says it's okay for me to do that:

"This project is free to use. It's permitted to modify for personal use.
It's not permitted to distribute the modified project. Modification can be
distributed via the official iSpindel release only. It's not permitted to distribute
 in a commercial way without permission."

So I'll make my little changes, and I won't share them with anyone. Hmm.

Start at https://github.com/universam1/iSpindel
Download the project as a zip file.
Unzipped to ~/src/iSpindel-master

Go to ~/src/iSpindel-master/pio/src, rename iSpindel.cpp to iSpindel.ino

Open iSpindel.ino in Arduino IDE, it will asks to make a folder named iSpindel and move the ino file into it. Do it.

In the ~/src/iSpindel-master/pio/lib folder, package these as individual zip files:
DoubleResetDetector
Globals
MPUOffset
Sender
WiFiManagerKT
tinyexpr

Install these into Arduino IDE by going to Sketch, Include Library, Add .zip Library, select the zip files. You have to do this one file at a time, and the IDE doesn't remember where it was, so it's kind of a pain.

Attempting to compile at this point gives an error about missing PubSubClient.h, downloaded from
https://github.com/knolleary/pubsubclient/releases/tag/v2.8
Download the zip file, not the tar.gz
In Arduino IDE, go to Sketch, Include Library, Add .zip library, select the downloaded zip file.

Skip this step, see below.
Attempting to compile at this poing give an error about missing I2Cdev.h.
Go to https://github.com/jrowberg/i2cdevlib/tree/master/Arduino/I2Cdev
Download I2Cdev.cpp and I2Cdev.h, make a zip file with these, then do the Include Library thing again for this zip file.
I downloaded them to ~/src/iSpindel-master/pio/lib/I2Cdev.

Now missing MPU6050.h:
Go to Sketch, Include Library, Library Manager, search for MPU6050, install the one from Electronic Cats, version 0.0.2.

Now a fatal error, OneWire.h not found, installed DS18B20_RT library version 0.1.6, from Library Manager, it installs OneWire version 2.3.5 as a dependency. This is for the temperature sensor.

Also, multiple libraries found for I2Cdev.h:
Multiple libraries were found for "I2Cdev.h"
 Used: ~/Arduino/libraries/I2Cdev
 Not used: ~/Arduino/libraries/MPU6050

I'm going to delete the Used one and use the Not used one. I just deleted the folder ~/Arduino/libraries/I2Cdev, recompiled, and that error went away. Just skip installing I2Cdev as I mentioned above and this won't be a problem.

Next, DallasTemperature.h not found. Go to Sketch, Include Library, Library Manager, search for Dallas Temperature, install version 3.8.0.

Next, RunningMedian.h not found. Go to Sketch, Include Library, Library Manager, search for RunningMedian, install version 0.2.0.

Next, ThingSpeak.h not found. Install version 1.5.0 from Library Manager.

Next, BlynkSimpleEsp8266.h. Install BlynkGSM_Manager version 1.0.9 from Library Manager. It wants to also install Blynk version 0.6.1 and TinyGSM version 0.10.5, install all.

Yay! It compiled! There is one warning:

WARNING: library MPU6050 claims to run on avr, samd architecture(s) and may be incompatible with your current board which runs on esp8266 architecture(s).


Update, June 14, 2020: It compiles. It uploads. It doesn't work. Well, it does, but it's impossible to get to the configuration. I spent the good part of a day trying to get it to work, but no luck. It hangs on fetching the stored json configuration from the file system, but of course, there is no stored json yet since I can't get to the configuration. Hacked around that, then the DoubleResetDetector doesn't work, it just doesn't detect. I'm going to start from scratch and write my own code, borrowing from the iSpindel code as needed. Reading through that code, I can see there is a lot of checking for this and that that really isn't necessary. I pretty much already know how to set up the wifi, read the temperature, and read the motion/gyro/accelerometer. What I don't have is the experience the iSpindel folks have to figure out the math to calculate specific gravity, so I'll be "borrowing" that code.



Wednesday, June 3, 2020

Wifi Volume Control

Mostly I play music on my home stereo system via Kodi and the Android Yatse app. Yatse lets me adjust the volume, but if I'm playing something on Youtube or Pandora or even Facebook, there is no way for me to remotely adjust the volume for my outside speakers. So I built a little volume controller and added it to my speaker controller. This turned out to be a fun little project.

First, a couple of pictures:

This shot shows the inside of my speaker controller. There are a lot of parts, I used an old computer case to hold everything together. My original speaker controller is in the middle with all the wires and the blue relays. The box on the right is another speaker switcher, which I kept as part of the system since it does impedance balancing that my controller doesn't do. At the very top are three individual volume knobs, one for each speaker zone around the house. These are manual knobs, there isn't a way to control them remotely. See the wires coming out of the Arduino and going around to the right under the black box? Those are the connections for the volume controller -- power, ground, data, and clock.

This picture shows the volume controller that I built. It's the little green circuit board on the right. It works as a "master" volume in that it adjusts the volume for all three zones at once. This turns out to be fine, it's really the same thing that Yatse does for Kodi, but now I can adjust the volume for other apps as well.


























Here's the layout for the volume controller:

What's missing here???
























I followed the schematic very closely, but I forgot that the speaker connections have TWO wires each, they all have a ground in addition to in/out. Also, the above has the ground from the PT2257 going to the wrong place. So this is a better picture:

This one has connections for the ground wires.
























I added the code to run this to the code for the speaker controller, so it's all in one place. Here are the parts that I added:

In the "html head" section, I added a little bit of javascript:

    server.println("<script>");
    server.println("function doit(){");
    server.println("document.getElementById(\"vF\").submit();}");
    server.println("</script>


Then in the main body of the html I added a slider to set the volume:

    server.print("</div><div class='container'><h3>Master Volume</h3><form id='vF' action='/' role='form'>");
    server.print("<input type='range' name='volume' min='0' max='79' value='");
    server.print(volume);
    server.print("' oninput='doit(value)'>");
    server.print("</form>");
    server.print("<form id='mF' action='/' role='form'>");
    server.print("<input type='submit' name='mute' value='Mute'/></form>


To handle the form data, I added these lines to the "handleRequest" method:

    if (request.indexOf("volume=") > -1) {
        int start = request.indexOf("volume=") + 7; // 7 is length of "volume="
        int end = request.indexOf(" ", start);
        volume = request.substring(start, end).toInt();
    } else {
        if (request.indexOf("mute") > -1) {
            volume = 0;
        }
    }



There are several libraries around for running the PT2257, but it's pretty simple and doesn't really need a whole library for what I want to do, so I only needed to add these few methods:

void setVolume(int level) {
    byte bbbaaaa = calculateVolume(level);
    byte aaaa = bbbaaaa & 0b00001111;
    byte bbb = (bbbaaaa >> 4) & 0b00001111;

    Wire.beginTransmission(EVC_ADDR);
    Wire.write(STEP_10 | bbb);
    Wire.write(STEP_1 | aaaa);
    Wire.endTransmission();
}

byte calculateVolume(int level) {
    if (level > 79)
        level = 79;
    if (level < 0)
        level = 0;
    level = 79 - level;     // invert the volume, so 0 is low and 79 is high
    uint8_t b = level / 10; // get the most significant digit (eg. 79 gets 7)
    uint8_t a = level % 10; // get the least significant digit (eg. 79 gets 9)
    b = b & 0b0000111;      // limit the most significant digit to 3 bit (7)
    return (b << 4) | a;    // return both numbers in one byte (0BBBAAAA)
}

void unmute() {
    byte value = MUTE_OFF;
    Wire.beginTransmission(EVC_ADDR);
    Wire.write(value);
    byte error = Wire.endTransmission();
}


Now the webpage looks like this on my phone:



Here is the complete code. I had to remove a few thing and optimize a bit as this barely fits in the memory of the Arduino.


/*
Simple web server to serve a form to turn on or off digital pins. In this case,
the pins are connected to relays to turn speakers on or off. This uses pins
2 through 9 in pairs, so 2 and 3 control the hot tub speakers, 4 and 5 control
the dining room speakers, and 6 and 7 control the portico speakers. 8 and 9 are
available in case I ever find a need to connect one more pair of speakers.
*/

#include <Ethernet.h>
#include <SPI.h>
#include <Wire.h>
#include <string.h>

// MAC address can be anything that is unique within the local network.
byte mac[] = {0x00, 0x1E, 0x2A, 0x76, 0x24, 0x08};

// Some unused IP address on the local network.
byte ip[] = {192, 168, 2, 251};

// web server, nothing fancy, just port 80 for http
EthernetServer server(80);

#define RELAY_ON 0
#define RELAY_OFF 1

// settings for the volume controller
#define EVC_ADDR 0x44       // volume control chip address
#define STEP_1 0b11010000   // 2-Channel, -1dB/step
#define STEP_10 0b11100000  // 2-Channel, -10dB/step
#define MUTE_OFF 0b01111000 // 2-Channel MUTE off

// true, just show speaker status or false, actually change the speaker states
boolean showStatus = false;

// which zones are on or off, initially, all are off
boolean zone1 = false;
boolean zone2 = false;
boolean zone3 = false;
boolean zone4 = false;

// default volume is 25 to start
int volume = 0;

// pin definition, one pin per speaker,
// so the left speaker in zone 1 is pin 2, etc
static const int zone1L = 2;
static const int zone1R = 3;
static const int zone2L = 4;
static const int zone2R = 5;
static const int zone3L = 6;
static const int zone3R = 7;
static const int zone4L = 8;
static const int zone4R = 9;

// set up pins, initially all speakers in all zones are off
void setupPins() {
    digitalWrite(zone1L, RELAY_OFF);
    digitalWrite(zone1R, RELAY_OFF);
    digitalWrite(zone2L, RELAY_OFF);
    digitalWrite(zone2R, RELAY_OFF);
    digitalWrite(zone3L, RELAY_OFF);
    digitalWrite(zone3R, RELAY_OFF);
    digitalWrite(zone4L, RELAY_OFF);
    digitalWrite(zone4R, RELAY_OFF);

    pinMode(zone1L, OUTPUT);
    pinMode(zone1R, OUTPUT);
    pinMode(zone2L, OUTPUT);
    pinMode(zone2R, OUTPUT);
    pinMode(zone3L, OUTPUT);
    pinMode(zone3R, OUTPUT);
    pinMode(zone4L, OUTPUT);
    pinMode(zone4R, OUTPUT);

    delay(2000);
}

void setup() {
    Ethernet.begin(mac, ip);
    server.begin();
    Wire.begin(); // join i2c bus
    setupPins();
    //Serial.begin(115200);
}

// set up buffer for reading web requests
static const int bufferMax = 128;
int bufferSize;
char buffer[bufferMax];

void loop() {
    EthernetClient client = server.available();
    if (client) {
        waitForRequest(client);
        handleRequest();
        client.stop();
    }
}

void waitForRequest(EthernetClient client) {
    bufferSize = 0;

    while (client.connected()) {
        if (client.available()) {
            char c = client.read();
            if (c == '\n') {
                break;
            } else {
                if (bufferSize < bufferMax) {
                    buffer[bufferSize++] = c;
                } else {
                    break;
                }
            }
        }
    }
}

void handleRequest() {
    // Received buffer contains a standard HTTP GET line, something like
    // "GET /?X=X&Y=Y HTTP/1.1".
    // Could have up to 4 parameters, 1=on&2=on&3=on&4=on,
    // one for each set of speakers. All that is necessary here is to
    // extract the query string and check for each of the zone numbers.
    String request = String(buffer);
    //int firstSpace = request.indexOf(" ");                // right after GET
    //int lastSpace = request.indexOf(" ", firstSpace + 1); // just after the query string
    //request = request.substring(firstSpace, lastSpace);
    //Serial.println(request);

    showStatus = request.indexOf("?") == -1;

    if (request.indexOf("1=on") > -1) {
        zone1 = true;
    } else if (request.indexOf("1=off") > -1) {
        zone1 = false;
    }
    if (request.indexOf("2=on") > -1) {
        zone2 = true;
    } else if (request.indexOf("2=off") > -1) {
        zone2 = false;
    }
    if (request.indexOf("3=on") > -1) {
        zone3 = true;
    } else if (request.indexOf("3=off") > -1) {
        zone3 = false;
    }
    if (request.indexOf("4=on") > -1) {
        zone4 = true;
    } else if (request.indexOf("4=off") > -1) {
        zone4 = false;
    }
    if (request.indexOf("volume=") > -1) {
        int start = request.indexOf("volume=") + 7; // 7 is length of "volume="
        int end = request.indexOf(" ", start);
        volume = request.substring(start, end).toInt();
    } else {
        if (request.indexOf("mute") > -1) {
            volume = 0;
        }
    }
    //Serial.println("Volume = " + String(volume));
    if (!showStatus) {
        setSpeakerState();
    }
    sendPage();
}

void setSpeakerState() {
    digitalWrite(zone1L, zone1 ? RELAY_ON : RELAY_OFF);
    digitalWrite(zone1R, zone1 ? RELAY_ON : RELAY_OFF);
    digitalWrite(zone2L, zone2 ? RELAY_ON : RELAY_OFF);
    digitalWrite(zone2R, zone2 ? RELAY_ON : RELAY_OFF);
    digitalWrite(zone3L, zone3 ? RELAY_ON : RELAY_OFF);
    digitalWrite(zone3R, zone3 ? RELAY_ON : RELAY_OFF);
    digitalWrite(zone4L, zone4 ? RELAY_ON : RELAY_OFF);
    digitalWrite(zone4R, zone4 ? RELAY_ON : RELAY_OFF);
    unmute();
    setVolume(volume);
}

void sendPage() {
    // http response header
    /*
    int length = 1442;
    if (digitalRead(zone4L) == RELAY_ON)
        length += 18;
    if (digitalRead(zone3L) == RELAY_ON)
        length += 18;
    if (digitalRead(zone2L) == RELAY_ON)
        length += 18;
    if (digitalRead(zone1L) == RELAY_ON)
        length += 18;
    */
    server.println("HTTP/1.1 200 OK");
    server.println("Content-Type: text/html");
    // server.print("Content-Length: ");
    // server.println(length);
    server.println();

    // html head
    server.println("<html><head><meta charset=\"UTF-8\">");
    server.println("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
    server.println("<link rel=\"stylesheet\" href=\"http://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css\">");
    server.println("<title>Speaker Control</title>");
    server.println("<script>");
    server.println("function doit(){");
    server.println("document.getElementById(\"vF\").submit();}");
    server.println("</script></head><body>");

    // html body
    server.println("<div class='container'><h2>Speaker Control</h2><form action='/' role='form'>");

    int pinState = digitalRead(zone4L);
    printCheckBox(4, pinState, "Hot tub");
    

    pinState = digitalRead(zone3L);
    printCheckBox(3, pinState, "Dining Room");

    pinState = digitalRead(zone2L);
    printCheckBox(2, pinState, "Portico");
    
    /* not used
    pinState = digitalRead(zone1L);
    printCheckBox(1, pinState, "(Empty)");
    */

    server.print("<br/><button type='submit'>Save</button></form></div>");

    server.print("</div><div class='container'><h3>Master Volume</h3><form id='vF' action='/' role='form'>");
    server.print("<input type='range' name='volume' min='0' max='79' value='");
    server.print(volume);
    server.print("' oninput='doit(value)'>");
    server.print("</form>");
    server.print("<form id='mF' action='/' role='form'>");
    server.print("<input type='submit' name='mute' value='Mute'/></form></div></body></html>");
}

void printCheckBox(int zone, int pinState, String label) {
    server.print("<br/><input type='hidden' name='");
    server.print(zone);
    server.print("' value='off'/><div class='checkbox'><label><input type='checkbox' name='");
    server.print(zone);
    server.print("' value='on'");
    server.print(pinState == RELAY_ON ? " checked='checked'" : "");
    server.print(">");
    server.print(label);
    server.print("</label></div>");
}

void setVolume(int level) {
    byte bbbaaaa = calculateVolume(level);
    byte aaaa = bbbaaaa & 0b00001111;
    byte bbb = (bbbaaaa >> 4) & 0b00001111;

    Wire.beginTransmission(EVC_ADDR);
    Wire.write(STEP_10 | bbb);
    Wire.write(STEP_1 | aaaa);
    Wire.endTransmission();
    //Serial.println("set volume to " + (79 - level));
}

byte calculateVolume(int level) {
    if (level > 79)
        level = 79;
    if (level < 0)
        level = 0;
    level = 79 - level;     // invert the volume, so 0 is low and 79 is high
    uint8_t b = level / 10; // get the most significant digit (eg. 79 gets 7)
    uint8_t a = level % 10; // get the least significant digit (eg. 79 gets 9)
    b = b & 0b0000111;      // limit the most significant digit to 3 bit (7)
    return (b << 4) | a;    // return both numbers in one byte (0BBBAAAA)
}

void unmute() {
    byte value = MUTE_OFF;
    Wire.beginTransmission(EVC_ADDR);
    Wire.write(value);
    byte error = Wire.endTransmission();
    //if (error)
    //    Serial.println("mute, error code = " + String(error));
}