Controlling CO2 sensor and LCD display with Raspberry Pi and Go

This is a digital gas sensor (CCS811) and an LCD display (HD44780), controlled by Raspberry Pi, that I wired together back in 2019. The CS811 sensor reports two values: 'equivalent carbon dioxide' (eCO2), and 'Volatile Organic Compounds' (VOC), PPM. I am not too sure what those terms mean, not strong on chemistry, but nonetheless I wired it to display the reported values on LCD display continuously. I assume high values mean bad. It can be seen that the values jump momentarily when I blow on the sensor.

There are plenty of guides and libraries for this kind of thing on the Internet, but, for me then, this electronics hobby thing wouldn't have felt like a hobby if I just followed guides and used libraries. So I used Go to write all codes for it (sans the operating system), which was the language I was into then, and still am.

These days, if I were to interface with an unknown hardware or software, I'd always reach for Python, to play with it first. I find Python and its interpreted environment simply superior for exploring and prototyping compared to using compiled languages.

Also, I'd just use a library.

At first, I was concerned that I'd have to use C or low-level instructions to control the hardware ports on RPi, but it turned out Linux provides a neat set of ioctl commands for that use, at least for the I2C ports. So no need to leave Go.

I grabbed this code by this Japanese dude, for controlling I2C ports, and the rest was the data-sheets.

CCS811 control routines

// I2C device address
const DeviceAddress = 0x5A

const ConditioningPeriod = 20 * time.Minute

// Mailbox IDs
const (
	APP_START = 0xF4

	STATUS          = 0x00
	MEAS_MODE       = 0x01
	ALG_RESULT_DATA = 0x02
	HW_ID           = 0x20
)

// Status bits
const (
	ERROR      = 0
	DATA_READY = 3
	APP_VALID  = 4
	FW_MODE    = 7
)

// Status masks
const (
	FW_MODE_MASK    = 1 << FW_MODE
	DATA_READY_MASK = 1 << DATA_READY
	APP_VALID_MASK  = 1 << APP_VALID
	ERROR_MASK      = 1 << ERROR
)

const HW_ID_VALUE = 0x81

type Device struct {
	c              *i2c.I2c_client
	startMeasuring time.Time  // For calculating remaining conditioning period.
}

// Copies c
func NewDevice(c *i2c.I2c_client) *Device {
	return &Device{
		c: c,
	}
}

func (d *Device) Initialize() error {
	if err := Initialize(d.c); err != nil {
    return err
  }
  d.startMeasuring = time.Now()
  return nil
}

func Initialize(c *i2c.I2c_client) error {
	// Hopefully ccs811 is in a good state.

	// Read the status
	b, err := c.ReadByte(DeviceAddress, STATUS)
	if err != nil {
		return err
	}
	log.Printf("Got status value 0x%0X\n", b)
	if (b & FW_MODE_MASK) == 0 {
		log.Printf("Initializing.\n")
		if err := setup(c); err != nil {
			return err
		}
	}
	// Application mode

	if err := c.WriteByte(DeviceAddress, MEAS_MODE, 0x10); err != nil {
		return err
	}

	buf := [8]byte{}

	// Discard one zero data. I get dummy 0 value otherwise.
	if err := c.ReadBytes(DeviceAddress, ALG_RESULT_DATA, buf[0:8]); err != nil {
		return err
	}

	return nil
}

func setup(c *i2c.I2c_client) error {
	b, err := c.ReadByte(DeviceAddress, HW_ID)
	if err != nil {
		return err
	}
	if b != HW_ID_VALUE {
		return fmt.Errorf("HW_ID is not right: 0x%0X", b)
	}

	// Read the status
	b, err = c.ReadByte(DeviceAddress, STATUS)
	if err != nil {
		return err
	}
	if (b & APP_VALID_MASK) == 0 {
		return fmt.Errorf("Firmware missing? Got status value 0x%0X", b)
	}

	log.Printf("Transition to application mode.\n")
	if err := c.WriteRawByte(DeviceAddress, APP_START); err != nil {
		return err
	}

	time.Sleep(time.Millisecond)

	return nil
}

type Values struct {
	ECO2  int
	ETVOC int
}

func (d *Device) ReadValues() (*Values, error) {
	log.Printf("Polling for ready.\n")
	for {
		b, err := d.c.ReadByte(DeviceAddress, STATUS)
		if err != nil {
			return nil, err
		}
		if b&DATA_READY_MASK != 0 {
			break
		}
		time.Sleep(1000 * time.Millisecond)
	}

	buf := [8]byte{}

	if err := d.c.ReadBytes(DeviceAddress, ALG_RESULT_DATA, buf[0:8]); err != nil {
		return nil, err
	}

	eCO2 := (int(buf[0]) << 8) | int(buf[1])
	eTVOC := (int(buf[2]) << 8) | int(buf[3])
	status := buf[4]
	errorID := buf[5]

	values := &Values{ECO2: eCO2, ETVOC: eTVOC}

	if (status & ERROR_MASK) != 0 {
		return values, fmt.Errorf(
			"Error status returned. status=0x%0X, errorID=0x%0X.", status, errorID)
	}

	return values, nil
}

HD44780 control routines

const deviceAddress = 0x27

// HD44780 bits
const (
	rsBit = 0
	rwBit = 1
	csBit = 2
	btBit = 3
)

// masks
const (
	rsMask = 1 << rsBit
	rwMask = 1 << rwBit
	csMask = 1 << csBit
	btMask = 1 << btBit
)

func Initialize(c *i2c.I2c_client) error {
	if err := initialize(c); err != nil {
		return errors.WithStack(err)
	}

	// Display on. (D=1, C=1, B=1)
	if err := clockByteIn(c, false, false, 0x0F); err != nil {
		return errors.WithStack(err)
	}

	return nil
}

// Convenience procedure
func PrintLines(c *i2c.I2c_client, line1, line2 string) error {
	if err := ClearDisplay(c); err != nil {
		return err
	}
	if err := PrintString(c, line1); err != nil {
		return err
	}
	if err := MoveToSecondLine(c); err != nil {
		return err
	}
	if err := PrintString(c, line2); err != nil {
		return err
	}
	return nil
}

func PrintString(c *i2c.I2c_client, s string) error {
	if err := clockStringIn(c, s); err != nil {
		return errors.WithStack(err)
	}
	return nil
}

func PrintBytes(c *i2c.I2c_client, bs []byte) error {
	if err := clockBytesIn(c, bs); err != nil {
		return errors.WithStack(err)
	}
	return nil
}

func MoveToSecondLine(c *i2c.I2c_client) error {
	if err := clockByteIn(c, false, false, 0xC0); err != nil {
		return errors.WithStack(err)
	}

	return nil
}

func ClearDisplay(c *i2c.I2c_client) error {
	if err := clockByteIn(c, false, false, 0x01); err != nil {
		return errors.WithStack(err)
	}

	return nil
}

func initialize(c *i2c.I2c_client) error {
	// Wait for more than 40ms after V_CC 2.7V.

	// Function set
	if err := clockNibbleIn(c, false, false, 0x03); err != nil {
		return errors.WithStack(err)
	}
	time.Sleep(10 * time.Millisecond) // spec 4.1ms

	// Function set
	if err := clockNibbleIn(c, false, false, 0x03); err != nil {
		return errors.WithStack(err)
	}
	time.Sleep(5 * time.Millisecond) // spec 100us

	// Function set
	if err := clockNibbleIn(c, false, false, 0x03); err != nil {
		return errors.WithStack(err)
	}
	time.Sleep(time.Millisecond)

	// Function set (set interface to 4 bits)
	if err := clockNibbleIn(c, false, false, 0x02); err != nil {
		return errors.WithStack(err)
	}
	time.Sleep(time.Millisecond)

	// Function set (N=1, F=0)
	if err := clockByteIn(c, false, false, 0x28); err != nil {
		return errors.WithStack(err)
	}
	// Display off
	if err := clockByteIn(c, false, false, 0x08); err != nil {
		return errors.WithStack(err)
	}
	// Display clear
	if err := clockByteIn(c, false, false, 0x01); err != nil {
		return errors.WithStack(err)
	}
	// Entry mode set (I/D = 1, S=0)
	if err := clockByteIn(c, false, false, 0x06); err != nil {
		return errors.WithStack(err)
	}

	// Initialization ends.
	return nil
}

func clockStringIn(c *i2c.I2c_client, s string) error {
	for _, ch := range s {
		if err := clockByteIn(c, true, false, int(ch)); err != nil {
			return errors.WithStack(err)
		}
	}
	return nil
}

func clockBytesIn(c *i2c.I2c_client, bs []byte) error {
	for _, b := range bs {
		if err := clockByteIn(c, true, false, int(b)); err != nil {
			return errors.WithStack(err)
		}
	}
	return nil
}

func clockByteIn(c *i2c.I2c_client, rs bool, rw bool, v int) error {
	if err := clockNibbleIn(c, rs, rw, v>>4); err != nil {
		return errors.WithStack(err)
	}
	if err := clockNibbleIn(c, rs, rw, v&0x0F); err != nil {
		return errors.WithStack(err)
	}
	time.Sleep(5 * time.Millisecond)
	return nil
}

func clockNibbleIn(c *i2c.I2c_client, rs bool, rw bool, v int) error {
	b := v << 4
	if rs {
		b |= rsMask
	}
	if rw {
		b |= rwMask
	}

	if err := c.WriteRawByte(deviceAddress, uint8((b | csMask | btMask))); err != nil {
		return errors.WithStack(err)
	}
	if err := c.WriteRawByte(deviceAddress, uint8(b|btMask)); err != nil {
		return errors.WithStack(err)
	}

	return nil
}

End.