nodeMCU garage door opener

nodeMCU with a power relay used to operate a garage door via wifi

I recently bought some knock-off hiletgo nodeMCU modules. They are basically a powerful arduino with wifi. The possibilities are only limited by your imagination. I think these are especially useful for home automation systems. I made a web-based garage door opener using a nodeMCU and a power relay module. To operate a garage door, you simply short a pair of wires together for two seconds and release.

Wifi is the nodeMCU’s power. Using the available libraries, it can act as a web server, send / receive tcp and udp packets, and change the states of it’s gpio pins based on wireless input. My wife hates when I deploy an experiment in the house that has a rat’s nest of wires hanging out or some unfinished prototype that resembles a road side bomb. I knew that if I wanted to leave this in place permanently, it had to look decent.

garage door opener using nodeMCU in a project enclosure

Using this set up, the whole family can have a garage door opener on their phone. Or, I could open it remotely over the internet if I wanted to let someone into the house.

My set up doesn’t merely operate the door, I have a reed switch connected to an input on the nodeMCU that reports to my home made alarm monitoring system via udp packets whether the door is open or closed.

alarm monitoring station using a pine64

My alarm monitoring station deserves it’s own post. It has a pine64 for brains and uses most of my pine64 modules that I have posted on cpan. An mcp23008 drives the LED indications, it runs an Apache web server that I can access over the internet to monitor alarm conditions, and receives udp updates from alarm modules such as the garage door opener. Locally, it displays alarm conditions on a two row MAX7219 LED display using my PINE64::MAX7219 module, also on cpan.

nodemcu garage door opener home automation
screenshot of web interface to nodeMCU based garage door opener home automation system

I use port forwarding on my ISP cable modem to control this and my other alarm modules over the internet.

JLCPCB and KiCad

A PCB I designed in KiCad and had manufactured by JLCPCB

I normally fabricate my projects on prototype printed circuit boards where I make the circuits with jumper wires. It is error prone and very time consuming. I recently watched a tutorial series made by digi-key on how to design you own PCB’s using KiCad and order them from a PCB manufacturer.

My first project is a board designed to power an Arduino Nano, and break out several analog and digital inputs. One 12-pin header is specifically designed to break out a 16X2 LCD. I designed it to accept a MeanWell IRM-10-5 10 watt 5VDC AC-DC converter to power the arduino and any peripherals. I am really pleased with how it turned out.

Here is the KiCad schematic.
Here is my design in PCBNEW in the early stages before I drew the traces on the board.

The tutorial suggested that I go with OSHPARK to produce my boards. I uploaded my gerber files and the showed me proofs of my design. They quoted me $48 for three boards. I checked around and found that JLCPCB would make five for $2. I thought it was too good to be true, but after shipping, I only paid $13 for 5 boards. They are very high quality, and I only had to wait about 10 days. That’s even cheaper than the prototype PCB’s I had been using. I was able to solder all the components on the board in a few short minutes as opposed to hours. This is definitely the way I will go from now on.

PINE64::MCP23008 Module on CPAN

pinout ot the MCP23008 GPIO extender chip
pinout ot the MCP23008 GPIO extender chip

My latest module to upload to CPAN is a perl-based driver for the MCP23008 GPIO extender. This chip is very handy when you have ran out of GPIO pins on your single board computer and gives you 8 more digital I/O’s. This has come in handy for me on several occasions when I used an LCD for a project that took too many of my digital pins.

The driver works as you would expect: you can make any combination of inputs or outputs, and read in the state of the inputs. This is an i2c device that defaults to an address of 0x20. You can adjust the address by putting high / low values on the address lines A0 – A2 for a possible 8 different chips on a single i2c bus.

Below is a simple implementation of the module configured as an input and then as an output from the synopsis on CPAN ->

The methods basically use Device::I2C to manipulate the chip’s internal registers. Register 0x00 is the I/O direction register. This is an 8-bit register with each bit position representing a digital pin. Valid values are 0 – 255 where all pins default to outputs. So, to make pin 6 an input, you would call the set_direction() method like so:

set_direction(64);

The MCP23008 has internal 100K Ohm pull up resistors when pins are configured as inputs. This can save you room on your board by not having to build external pull up resistors yourself. This is configured in the GPPU (GPIO Pull-up resistor register) 0x06. The code below enables all 8 of the internal pull up resistors.

enable_pullup(255);

I also implemented the I/O polarity feature. With this enabled, the chip will give you the opposite polarity of the current state of the pin. The function call below reverses the polarity of pin 5.

set_polarity(32);

The main methods of the module are read_pin() and write_pin(). They work as you would expect. It reads / writes values to the GPIO port register 0x09.

$gpext->read_pin(4);
$gpext->write_pin(7, 1);

Here is a description of the GPIO port register straight out of the datasheet:

I didn’t implement all of the features of the MCP23008, but enough to make them useful for most projects. Here is the complete source code for the module:

     1	#!/usr/bin/perl 
     2	use strict;
     3	use Device::I2C;
     4	use IO::Handle;
     5	use Fcntl;
       
     6	package PINE64::MCP23008;
       
     7	our $VERSION = '0.9';
       
     8	#global vars
     9	my ($i2cbus, $addr, $gpext);
    10	my $gpregval = 0; 	#init gpio register value to 0
       
    11	my @pin_nums = (1,2,4,8,16,32,64,128);
       
    12	sub new{
    13		my $class = shift;
    14		my $self = bless {}, $class;
       
    15		#first arg is device address
    16		$addr = $_[0];
       
    17		#second arg i2c bus; optional
    18		$i2cbus = $_[1];
    19		
    20		if($i2cbus eq ''){
    21			$i2cbus = '/dev/i2c-0';
    22		}#end if
    23		
    24		$gpext = Device::I2C->new($i2cbus, "r+"); 
       
    25		#init i2c device
    26		$gpext->checkDevice($addr);
    27		$gpext->selectDevice($addr);
       
    28		#init gp register val to all off
    29		$gpregval = 0; 
       
    30		return $self;
    31	}#end new
       
    32	sub set_direction{
    33		#sets value of the IO direction register
    34		#ie 255 makes all input; 0 makes all output
       
    35		my $direction = $_[1];
    36		$gpext->writeByteData(0x00, $direction);	
    37	}#end set_direction
       
    38	sub enable_pullup{
    39		#when a pin is configured as an input
    40		#you can enable internal 100K pull-up
    41		#resistors by writing to the GPPU register
    42		#0-255 are valid values: 0 - all disabled
    43		#255 - all enabled
    44		
    45		my $en_gppu = $_[1];
    46		$gpext->writeByteData(0x06, $en_gppu);
    47	}#end enable_pullup
       
    48	sub set_polarity{
    49		#sets the polarity of the gpio pins; 
    50		#0 is normal polarity
    51		#255 is all pins reversed
    52		
    53		my $io_pol = $_[1];
    54		$gpext->writeByteData(0x01, $io_pol);
    55	}#end set_polarity
       
    56	sub write_pin{
    57		my $ind = $_[1];
    58		my $iox = $pin_nums[$ind];
       
    59		#1 or 0
    60		my $val = $_[2];
       
    61		if($val == 1){
    62			$gpregval+=$iox; 
    63		}#end if
    64		if($val == 0){
    65			$gpregval-=$iox;
    66		}#end if
       
    67		$gpext->writeByteData(0x09, $gpregval);
    68	}#end write_pin
       
    69	sub read_pin{
    70		my $ind = $_[1];
    71		my $iox = $pin_nums[$ind];
    72		my $pinval = 0; 
       
    73		#read GPIO register
    74		my $regval = $gpext->readByteData(0x09); 
       
    75		#ensure 8 binary places are displayed
    76		my $binout = sprintf("%08b", $regval);
       
    77		#parse eight binary digits into an array
    78		my @pinvals = split(//, $binout);
    79		
    80		#reverse array to match pin #'s
    81		@pinvals = reverse(@pinvals);
       
    82		#value of pin is index of $pinvals
    83		$pinval = $pinvals[$ind]; 
       
    84		return $pinval; 
    85	}#end read_pin
       
    86	1;
    87	__END__

PINE64::MCP300x CPAN Module

My MCP300x module on CPAN for the PINE64A+ boards

One of the drawbacks, in my opinion, of the raspberry pi and the pine64 A+ is the lack of any analog inputs. To read an analog signal requires an external analog to digital converter chip, and will tie up several of your GPIO lines. My new module has routines to read values on an MCP3004 or MCP3008 10-bit analog to digital converter.

Here is some sample code ->

use PINE64::MCP300x; 
my $adc = PINE64::MCP300x->new(10,12,11,13); 
#5 bits because the first is the start bit
my @ch0 = (1,1,0,0,0); 
for(my $s=0;$s<200;$s++){
my ($reading, $binval, $voltage ) = $adc->read300x(\@ch0, 50, 5.01);
$voltage = sprintf("%.3f", $voltage);
print "binval: $binval\tvoltage: $voltage vdc\n";
usleep(500000);}#end for

Here is the output of the script ->

PINE64::MAX7219

I have had another cpan module published! This one is a driver for a MAX7219 8-digit LED display on the PINE64 single board computer. The video above is a demo of some of the module’s capabilities. It is implemented as bit-banged SPI using the perl programming language.

A very easy to use display, it only uses 3 GPIO pins, and could be used to output the value of sensors, or simple menus for projects.

arduino based hvac lead-lag controller

arduino lead-lag hvac control
Installed lead-lag controller
arduino lead-lag hvac control
arduino lead-lag hvac control
arduino hvac lead-lag controller
lead-lag controller using arduino nano

My office is in a telecommunications facility full of routers, radios, fiber-optic equipment etc. Reliable environmental controls are essential as this equipment will fail in extreme heat. I have two 20,000 BTU AC units in my office that run on 250VAC. I created this lead-lag controller to turn on the lead AC unit based on the temperature setting, and if the lead unit has run for six minutes without cooling to the setting, turns on the lag unit as well until the temperature setting is reached.

arduino lead-lag hvac controller
arduino lead-lag hvac controller

I have the programming down and the device built and installed. Initial tests work as designed. I am using some 10A 250VAC power relays. With the unit running on high fan speed and the compressor going the AC units draw about 5 amps; well within the rage of the relays.

the lead / lag controller turns on/off the 250VAC outlets of the air conditioner units

Here is the code ->

// include the library code:
#include <LiquidCrystal.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include "RTClib.h"
RTC_DS3231 rtc;

// Sensor input pin
#define DATA_PIN 2
// How many bits to use for temperature values: 9, 10, 11 or 12
#define SENSOR_RESOLUTION 9
// Index of sensors connected to data pin, default: 0
#define SENSOR_INDEX 0

OneWire oneWire(DATA_PIN);
DallasTemperature sensors(&oneWire);
DeviceAddress sensorDeviceAddress;

// initialize the library by associating any needed LCD interface pin
// with the arduino pin number it is connected to
const int rs = 12, en = 11, d4 = 10, d5 = 9, d6 = 8, d7 = 7;
LiquidCrystal lcd(rs, en, d4, d5, d6, d7);
int sample_num = 0;

//thermostat setting; init to 72
int setting = 70; 

//heat or cool mode
char mode = 'C';

//relay pins
const int ac1Pin = 5;
const int ac2Pin = 4;
//const int heatPin = 6;

//control buttons
const int modePin = 3;
const int decrPin = 6;
const int incrPin = 0;

//AC ON/OFF flags
int ac1_f = 0;
int ac2_f = 0;
int heat_f = 0;

//on timers
int ac1_timer = 0;
int ac2_timer = 0;

//RTC stuff
char daysOfTheWeek[7][12] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"};

void setup() {
  // set up the LCD's number of columns and rows:
  lcd.begin(16, 2);
  Serial.begin(9600);

  //AC1 relay
  pinMode(ac1Pin, OUTPUT); 
  pinMode(ac2Pin, OUTPUT);
  //pinMode(heatPin, OUTPUT);

  //set AC1 off
  digitalWrite(ac1Pin, LOW);
  digitalWrite(ac2Pin, LOW);
  //digitalWrite(heatPin, LOW);    

  //control buttons
  pinMode(modePin, INPUT);
  pinMode(decrPin, INPUT);
  pinMode(incrPin, INPUT);

  sensors.begin();
  sensors.getAddress(sensorDeviceAddress, 0);
  sensors.setResolution(sensorDeviceAddress, SENSOR_RESOLUTION);  

  if (! rtc.begin()) {
    Serial.println("Couldn't find RTC");
    while (1);
  }

  if (rtc.lostPower()) {
    Serial.println("RTC lost power, lets set the time!");
    // following line sets the RTC to the date & time this sketch was compiled
    rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
    // This line sets the RTC with an explicit date & time, for example to set
    // January 21, 2014 at 3am you would call:
    // rtc.adjust(DateTime(2014, 1, 21, 3, 0, 0));
  }  
}

  //array to hold 12 readings; 5-per min
  float t_readings[12];

  //sum of readings
  float sum = 0.0;

  //avg of readings
  float average = 0.0;

void loop() {
  
  // set the cursor to (0,0):
  lcd.setCursor(0, 0);

  //reset sum and avg on each iteration
  sum = 0;
  average = 0;

  //12 sample loop; takes 1 min
  for(int x=0;x<12; x++){
    lcd.setCursor(0, 0);
    sensors.requestTemperatures();
    // Measurement may take up to 750ms

    float temperatureInCelsius = sensors.getTempCByIndex(SENSOR_INDEX);
    float temperatureInFahrenheit = sensors.getTempFByIndex(SENSOR_INDEX);
  
    Serial.print("Temperature: ");
    Serial.print(temperatureInCelsius, 1);
    Serial.print(" Celsius, ");
    Serial.print(temperatureInFahrenheit, 1);
    Serial.print(" x: ");
    Serial.print( x );
    Serial.println(" Fahrenheit"); 

    //push reading into array
    t_readings[x] = temperatureInFahrenheit;
  
    lcd.print(temperatureInFahrenheit);
    lcd.print("F");
    //lcd.setCursor(0, 1);
    //lcd.print(sample_num);
    //sample_num++;

    if(digitalRead(modePin) == LOW){
      Serial.println("MODE BUTTON PRESSED! ENTERING SETUP....");
      setTemp();
    }//end if mode pressed

    //print setting to lcd row 2
    lcd.setCursor(0, 1);
    lcd.print("Setting: ");
    lcd.print(setting);    

    //print mode
    lcd.setCursor(15,0);
    lcd.print(mode);

    delay(5000);  //sleep 5 sec
  }//end for loop 12 sample

  //average samples
  for(int i=0; i<12; i++){
    sum = sum + t_readings[i];
  }//end for avg
    Serial.print("sum: ");
    Serial.println(sum);
    average = sum / 12;
    Serial.print("avg: ");
    Serial.println(average);

    //AC relay control
    if(average > setting && mode == 'C'){
      digitalWrite(ac1Pin, HIGH);

      //flag logic
      if(ac1_f == 0){ ac1_f = 1;}

      //timer logic
      ac1_timer += 60;    //60 because of the 12 samples/min cycle
      Serial.print("AC UNIT 1 ON ");
      Serial.print(ac1_timer);
      Serial.print(" seconds ");

      if(ac1_timer > 60){//turn on ac2 after 30 minutes
        if(ac2_f == 0){ 
          ac2_f = 1; 
          digitalWrite(ac2Pin, HIGH);
        }
        else{
          //increment ac2_timer
          ac2_timer += 60;
        }
        Serial.print("AC UNIT 2 ON ");
        Serial.print(ac2_timer);
        Serial.print(" seconds ");
      }//end if turn on AC2

      //poll RTC for time
      DateTime now = rtc.now();
   
      Serial.print(now.year(), DEC);
      Serial.print('/');
      Serial.print(now.month(), DEC);
      Serial.print('/');
      Serial.print(now.day(), DEC);
      Serial.print(" "); 
      Serial.print(" (");
      Serial.print(daysOfTheWeek[now.dayOfTheWeek()]);
      Serial.print(") ");
      Serial.print(now.hour(), DEC);
      Serial.print(':');
      Serial.print(now.minute(), DEC);
      Serial.print(':');
      Serial.println(now.second(), DEC);
    }//end if
    if(average < setting && mode == 'C'){
      //turn AC1 OFF, reset timer & flags
      digitalWrite(ac1Pin, LOW);
      ac1_f = 0;
      ac1_timer = 0;
      Serial.print("AC UNIT 1 OFF ");

      //if on, turn off AC2
      if(ac2_f == 1){
          digitalWrite(ac2Pin, LOW);
          ac2_f = 0;
          ac2_timer = 0;
          Serial.print("AC UNIT 2 OFF ");
      }//end if
      
      //poll RTC for time
      DateTime now = rtc.now();
      
      Serial.print(now.year(), DEC);
      Serial.print('/');
      Serial.print(now.month(), DEC);
      Serial.print('/');
      Serial.print(now.day(), DEC);
      Serial.print(" "); 
      Serial.print(" (");
      Serial.print(daysOfTheWeek[now.dayOfTheWeek()]);
      Serial.print(") ");
      Serial.print(now.hour(), DEC);
      Serial.print(':');
      Serial.print(now.minute(), DEC);
      Serial.print(':');
      Serial.println(now.second(), DEC);      
    }//end if
    
    delay(2000); 
}

void setTemp(){
  int su_flag = 1;
  int mode_flag = 1;
  Serial.println("SETUP MODE ->");
  lcd.begin(16, 2);
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print("Set temp: ");
  lcd.print(setting);
  delay(1000);
  //lcd.clear();
  while(su_flag == 1){
    if(digitalRead(decrPin) == LOW){
      setting--;
      lcd.setCursor(10, 0);
      lcd.print(setting);
    }//end if exit setup
    if(digitalRead(incrPin) == LOW){
      setting++;
      lcd.setCursor(10, 0);
      lcd.print(setting);
    }//end increment temp
    if(digitalRead(modePin) == LOW){
      //exit setup
      su_flag = 0;
    }//end exit setup mode
    delay(250);
  }//end while
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print("Set mode: ");
  lcd.print(mode);
  delay(1500);
  while(mode_flag == 1){
    if(digitalRead(decrPin) == LOW){
      mode = 'C';
      lcd.setCursor(10, 0);
      lcd.print(mode);
    }//end if
    if(digitalRead(incrPin) == LOW){
      mode = 'H';
      lcd.setCursor(10, 0);
      lcd.print(mode);
    }//end if heat mode
    if(digitalRead(modePin) == LOW){
      mode_flag = 0;
    }//end if exit mode set
    delay(250);
  }//end while set mode
  lcd.clear();
}//end setTemp function

perl interface to 18B20 1-wire temperature sensor

raspberry pi zero interfacing with 18B20 1-wire temperature sensor

I am pretty impressed with the responsiveness and accuracy of the 1-wire 18B20 temperature sensor. This is the first 1-wire sensor I have ever used. To get it going on the pi zero, I had to enable the 1-wire protocol via raspi-config, and edit the /boot/config.txt file to tell it what pin to use. Like gpio, 1-wire has a file system interface, which makes perl a great tool to interface to 1-wire devices.

Interestingly, each 1-wire device has a unique serial number, so it is possible to have literally thousands of 1-wire devices on a single bus; certainly far more than i2c allows. Here is the command line output of a manual reading from the 18B20:

file system interface on pi zero for a 1-wire 18B20 temperature sensor
file system interface on pi zero for a 1-wire 18B20 temperature sensor

The part of the output that says t=26500 is the temperature reading in celsius i.e. 26.500 or 79.7F.

Here is my perl code for interfacing with the 18B20 ->

#!/usr/bin/perl -w
use strict;

=pod
reads a 1wire 18B20
include this in your script, 
returns fahrenheit
=cut

#my $temp = read_18b20();
#print "temp: $temp\n";

sub read_18b20{
	`ls /sys/bus/w1/devices > 1w.txt`;
	open OW, "1w.txt" or die $!;
	my $temp_sensor = '';
	while(<OW>){
		if($_ =~ /(28-............)/){
			#print "serial number:  $1\n";
			$temp_sensor = $1;	
		}#end if
	}#end while
	close OW;

	my ($tempC, $tempF) = (0,0);
	`cat /sys/bus/w1/devices/$temp_sensor/w1_slave > 1w_reading.txt`;	
	open RD, "1w_reading.txt" or die $!;
	while(<RD>){
		if($_ =~ /t=(\d\d)(\d\d\d)/){
			$tempC = $1.".".$2;
			#print "celsius: $tempC\n";	
		}#end if
	}#end while
	close RD;
	$tempF = $tempC * (1.8) + 32;
	#print "farenheit: $tempF\n";
	
	return $tempF; 
}#end read_18b20

1;

Here is sample output from a script that samples the sensor every 2 seconds. I breathed on the 18B20 to demonstrate the responsiveness.

samples from a 1-wire 18B20 temperature sensor using perl interface
samples from a 1-wire 18B20 temperature sensor using perl interface

arduino servo example analysis

futaba s3003 arduino servo example script
futaba s3003 arduino servo example script

I admit I know little about pulse width modulation, and have limited experience with robotics or servos. I’ve had a futaba s3003 servo in my toolbox for some time, and done nothing with it. On a lazy day when I had some down time, I decided to get it out and do some experimentation.

I’ve been doing some labor intensive projects at work lately, and while hard work is very rewarding, it takes a lot of physical and mental energy and crowds out innovation. Just out of boredom, I burned the most basic servo script example provided by the arduino IDE into a nano clone I recently bought, and analyzed it with pulseview to better understand pwm.

close up of PWM controlling a futaba s3003 servo
close up of PWM controlling a futaba s3003 servo

Above is a trace of the output of the default arduino ide servo script with me tuning a 10K pot. In this example script, the position of the servo corresponds to the position of the potentiometer. Just at a glance, you can see pulses bunched up at different intervals. What I empirically observed is that when the servo was at position 1, the pulse width was about 0.55ms in duration at the standard 50Hz required by the servo. This pulse was constant, and the servo was stationary. If the pulse width changes at all, it changes position proportionally.

The futaba s3003 has 180 degrees of rotation. At position 3 (180 degrees) the constant pulse width is about 2.4msec. as you can see in the graphic below:

futaba s3003 servo at 180 degrees of rotation

This simple analysis has demystified pwm somewhat for me. Next, I hope to experiment with some pwm on the raspberry pi using perl.

rssi vs. relative humidity

receive signal strength indication vs. relative humidity data logger
receive signal strength indication vs. relative humidity data logger

I finally got around to deploying my rssi / temperature / relative humidity data logger and recorded nearly 6,000 samples of each metric over a five day period. This radio system operates in the ISM band, and is susceptible to propagation issues due to atmospheric conditions, especially humidity. The logic board provides a terminal that outputs a DC voltage that represents the rssi in dBm. My data logger uses a raspberry pi zero w, and reads the rssi voltage with an MCP3004 analog to digital converter using a bit-banged driver I wrote in perl. Each reading is timestamped using time derived from a DS3231 i2c real-time clock chip.

I use a HTU21 temperature / relative humidity sensor break out board from adafruit. This chip is MUCH MORE STABLE & RELIABLE than the DHT22 I was originally using.

temperature vs. relative humidity data capture
temperature vs. relative humidity data capture

For starters, here is the temperature in fahrenheit (red) vs. the relative humidity (green). Predictably, when the temperature rises, the relative humidity goes down and vice versa.

rssi level vs. relative humidity at 900MHz
rssi level vs. relative humidity at 900MHz

Now here is the relative humidity vs. the rssi. This radio is about 20 miles away from the transmitter over flat terrain with an output of 37dBm (5 watts), and has an average rssi of about -90dBm. You can see from the above graph that at about sample 4,000 when the relative humidity takes a noticeable dip that the rssi has a corresponding, albeit small, increase: precisely what we would expect to see.

Nothing surprising here. It just proves the effectiveness of my data logger device. It would be useful in situations where an RF path is going up and down due to atmospheric conditions or other obstructions, and real-time data would be useful in diagnosing problems and coming up with solutions like a higher gain antenna or increasing height.

ssh to a raspberry pi zero over usb

connecting to a rapsberry pi zero via usb
connecting to a raspsberry pi zero via usb

I am working on a data logging project based on the raspberry pi zero.  NOT a pi zero w,  just the pi zero.  Adding software packages to the pi zero is a little more difficult, because it has no wifi or ethernet connection.  I have been taking the sd card out ant putting it in a pi 3b+ to get packages that I need then putting it back in the pi zero.  I have tried and failed to use minicom and zmodem to transfer deb files over UART.

I found this great tutorial that explains how to ssh into a pi via the usb port on the pi.  I did everything it said and was still not able to connect to the pi.  I did an ifconfig on the pi, and saw that it had a APIPA on it’s usb network interface like so:

usb network interface on pi zero
usb network interface on pi zero

I had a usb network interface appear on my laptop, but no ipv4 address:

usb network interface on ubuntu machine
usb network interface on ubuntu machine

I finally got it to work by issuing an ifconfig command to my laptop’s usb interface and assigning an IP address in the same class B subnet :

ifconfig on usb network interface APIPA
ifconfig on usb network interface APIPA

Another benefit to a standard pi zero having ip connectivity via usb is that you can use piscope for troubleshooting.  Here is piscope examining i2c frames polling an HTU21D relative humidity sensor:

examining an HTU21D with piscope on a pi zero with usb network interface
examining an HTU21D with piscope on a pi zero with usb network interface