NixOS CyberDeck Build

More Tinkering with the Intel Compute Stick

Back with another Cyberdeck post!

Obsession Much?

I won’t argue with that… But because of its compact size, I can’t help but tinker and build more upon it!

A few (maybe alot) has changed. For one, the idea of using my ServerDeck as my go to has switched to using my Nix-Stick (new name change..). This change was due to easier package and environment control, which will be needed when I bring this to do some field testing. More will be explained later.

What Does It Look Like?

breadboard prototyping

In Action

Yep, it’s a total mess… Let’s start breaking down what I have:

Hardware

Compute Stick setup:

Breadboard setup:

Close up photo of the Nix-Stick:

Guess where the computer is! breadboard prototyping

Software

In a previous post when I was just focusing on my ServerDeck, I opted to use LibMPSSE that’s in C. I mentioned the documentation being a bit difficult to grasp and so this time I went with Adafruit_Blinka that’s written in Python. There’s alot of examples and tutorials from Adafruit themselves that it’s easier to get things tested!

What’s driving the whole circuit is the FT232H. Currently I am using the GC9A01 to display stats from the BME280, ST7789 is taking the computer’s: Hostname, CPU Temp, CPU Load, and Memory Usage. Finally the 16x2 LCD (potentiometer is used to adjust the backlight) is connected to the 74HC595 for extra outputs, showing longitude and latitude (datetime is separate) from AdaFruit Ultimate GPS V3, it is showing zeroes because you’ll have to be outside in order for it to work. I haven’t used the 74HC165 yet.

The mini SPST switch is used to switch over to start webcam streaming (OpenCV), a red LED is just for indication. The green and blue LEDs are to test if there’s voltage across connecting breadboards.

Here’s the code that drives this circuit:

main.py:

import board
from board import SCK, MOSI, MISO

import busio
import digitalio
from adafruit_rgb_display.st7789 import ST7789
from PIL import Image, ImageDraw, ImageFont

from adafruit_rgb_display.gc9a01a import GC9A01A

import adafruit_bmp280

import numpy as np
import cv2 as cv
import asyncio
import os

import time
import subprocess

import adafruit_character_lcd.character_lcd as character_lcd
import adafruit_74hc595

import wws_74hc165

import serial
import usb.core
import sys
import pynmea2
import datetime

spi = busio.SPI(clock=SCK, MOSI=MOSI, MISO=MISO)

def init_display(disp,cs,dc,reset,rotate,w,h,x_off,y_off,BAUDRATE=24000000):
    CS_PIN = cs
    DC_PIN = dc
    RESET_PIN = reset
    display = disp(
        spi,
        rotation=rotate,
        width=w,
        height=h,
        x_offset=x_off,
        y_offset=y_off,
        baudrate=BAUDRATE,
        cs=digitalio.DigitalInOut(CS_PIN),
        dc=digitalio.DigitalInOut(DC_PIN),
        rst=digitalio.DigitalInOut(RESET_PIN)
    )
    return display

def rotation(display):
    height=0
    width=0
    if display.rotation % 180 == 90:
        height = display.width  # swap height/width to rotate it to landscape
        width = display.height
    else:
        width = display.width  # swap height/width to rotate it to landscape
        height = display.height

    return height, width

def draw_image(disp,width,height,fill=0):
    disp.fill(0)
    disp_image = Image.new("RGB", (width,height))
    disp_draw = ImageDraw.Draw(disp_image)

    return disp_image, disp_draw

async def bmp280_task():
    if btn_toggle.value is False:
        gca_draw.rectangle((0, 0, disp_gc9a01.width, disp_gc9a01.height // 2 - 30), fill=(155, 50, 0))
        gca_txt_bmp_temp = "    Temp: {:.1f} C".format(bmp280.temperature) + "\nPres: {:.1f} hPa".format(bmp280.pressure) + "\n    Alt: {:.2f} M".format(bmp280.altitude)
        gca_draw.text(
            (disp_gc9a01.width // 2 - 70 , disp_gc9a01.height // 2 - 100),
            gca_txt_bmp_temp,
            font=gca_bmp_font,
            fill=(255, 255, 0),
        )
        disp_gc9a01.image(gca_image)

async def cam_task():
    if btn_toggle.value is True:
        ret,frame = cap.read()

        if not ret:
            print("Can't receive frame (stream end?). Exiting...")
            os._exit(1)

        resize = cv.resize(frame, (disp_gc9a01.width, disp_gc9a01.height), interpolation=cv.INTER_AREA)
        rgb = cv.cvtColor(resize, cv.COLOR_BGR2RGB)
        pil = Image.fromarray(rgb)
        disp_gc9a01.image(pil)

async def async_main():
    await asyncio.gather(bmp280_task(),cpu_task(),cam_task(),gps_task())

async def cpu_task():
    st7789_draw.rectangle((0, 0, width, height), outline=0, fill=0)

    # REFERENCE: https://unix.stackexchange.com/questions/119126/command-to-display-memory-usage-disk-usage-and-cpu-load
    hostname = subprocess.check_output("hostname",shell=True).decode("utf-8")
    cpu_temp = subprocess.check_output("cat /sys/class/thermal/thermal_zone1/temp |  awk '{printf \"CPU Temp: %.1f C\", $(NF-0) / 1000}'",shell=True).decode("utf-8")
    cpu_load = subprocess.check_output("top -bn1 | grep load | awk '{printf \"CPU Load: %.2f\", $(NF-2)}'",shell=True).decode("utf-8")
    mem = subprocess.check_output("free -m | awk 'NR==2{printf \"Mem: %s/%s MB %.2f%%\", $3,$2,$3*100/$2 }'",shell=True).decode("utf-8")

    st7789_draw.text((x,y+10),hostname,font=st7789_font, fill="#FFFFFF")
    st7789_draw.text((x,y+40),cpu_temp,font=st7789_font, fill="#FFFF00")
    st7789_draw.text((x,y+70),cpu_load,font=st7789_font, fill="#00FF00")
    st7789_draw.text((x,y+100),mem,font=st7789_font, fill="#000FF0")

    # display it by 90 deg
    disp_st7789.image(st7789_image)

async def gps_task():

    # TODO: Find a way to retrieve FTDI name, locate the port
    #       and use that. Check if it is open, otherwise continue 
    with serial.Serial("/dev/ttyUSB1", 9600, timeout=1) as ser:

        data = ser.readline().decode('ascii', errors='replace')
        dt = datetime.datetime.now().strftime("%Y/%m/%d %H:%M")

        if(data[0:6] == "$GPRMC"):
            lcd.clear()

            gps_data = pynmea2.parse(data)

            lon = gps_data.longitude
            lat = gps_data.latitude

            lcd.message = "Lon:{0} Lat:{1}".format(lon,lat)
            print(lcd.message)

        lcd.message = "\n{0}".format(dt)

if __name__ == "__main__":
    # REMINDER: THIS SCRIPT IS IN ONLY SPI MODE!
    #           (SINCE THE FT232H UART/MPSSE OPERATES IN ONE SPECIFIC MODE)

    # -----74HC165 IS INPUT ONLY-----
    _74hc165_isr_latch = board.D5
    _74hc165_isr = wws_74hc165.ShiftRegister74HC165(spi, _74hc165_isr_latch, 1)

    # -----74HC595 IS OUTPUT ONLY-----
    _74hc595_isr_latch = digitalio.DigitalInOut(board.D6)
    _74hc595_isr = adafruit_74hc595.ShiftRegister74HC595(spi, _74hc595_isr_latch, 1)

    # connecting LCD to 74HC595 
    lcd_rs = _74hc595_isr.get_pin(0)
    lcd_en = _74hc595_isr.get_pin(1)
    lcd_d7 = _74hc595_isr.get_pin(2)
    lcd_d6 = _74hc595_isr.get_pin(3)
    lcd_d5 = _74hc595_isr.get_pin(4)
    lcd_d4 = _74hc595_isr.get_pin(5)

    lcd_backlight = _74hc595_isr.get_pin(6)

    lcd_columns = 16
    lcd_rows = 2

    lcd = character_lcd.Character_LCD_Mono(lcd_rs, lcd_en, lcd_d4, lcd_d5, lcd_d6, lcd_d7, lcd_columns, lcd_rows, lcd_backlight)
    lcd_backlight.value = True
    lcd.message = "BOOTING\nUP..."

    # clear old outputs for incoming ones
    subprocess.run(["clear"])

    # switch for camera mode for round display
    btn_toggle = digitalio.DigitalInOut(board.D4)
    btn_toggle.direction = digitalio.Direction.INPUT

    # list connected USB devices
    print("ls /dev/video* : ",subprocess.check_output("ls /dev/video* | grep -oP '/dev/video\d+' | tr '\n' ' ' | sed 's/ $//'",shell=True))
    print("ls /dev/ttyUSB* : ",subprocess.check_output("ls /dev/ttyUSB* | grep -oP '/dev/ttyUSB\d+' | tr '\n' ' ' | sed 's/ $//'",shell=True))

    # camera check
    cap = cv.VideoCapture(-1)
    if not cap.isOpened():
        print("Cannot open camera...")
    cap.set(cv.CAP_PROP_FPS,30)

    # barometer setup for round display
    bmp280 = adafruit_bmp280.Adafruit_BMP280_SPI(spi, digitalio.DigitalInOut(board.C7))
    gca_bmp_font = ImageFont.truetype('./DS-DIGIT.TTF',20)

    # ----------------set up round display ----------------
    disp_gc9a01 = init_display(GC9A01A,board.C6,board.C5,board.C4,180,240,240,0,0)
    gca_image, gca_draw = draw_image(disp_gc9a01,disp_gc9a01.width,disp_gc9a01.height)

    gca_draw.rectangle((0, 0, disp_gc9a01.width, disp_gc9a01.height // 2 - 30), fill=(155, 50, 0))                          # upper rectangle
    gca_draw.rectangle((0, 90, disp_gc9a01.width // 2, disp_gc9a01.height // 10 + 150), fill=(105, 50, 150))                # left rectangle
    gca_draw.rectangle((120, 90, disp_gc9a01.width // 2 + 150, disp_gc9a01.height // 10 + 150), fill=(10, 50, 100))         # right rectangle

    gca_font = ImageFont.truetype('./DS-DIGIT.TTF',25)
    gca_txt_ip = subprocess.check_output("ip -4 addr | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | grep -v '127\.0\.0\.1'",shell=True).decode("utf-8")
    gca_draw.text(
        (disp_gc9a01.width // 2 - 50 , disp_gc9a01.height // 2 + 70),
        gca_txt_ip,
        font=gca_font,
        fill=(255, 255, 0),
    )
    disp_gc9a01.image(gca_image)
    # ------------------------------------------------------


    # ---------------- set up tft display ----------------
    x = 0
    y = -2
    disp_st7789 = init_display(ST7789,board.C3,board.C2,board.C1,90,135,240,53,40)
    height,width = rotation(disp_st7789)
    st7789_image, st7789_draw = draw_image(disp_st7789,width,height)
    st7789_font = ImageFont.truetype('./DS-DIGIT.TTF',23)
    # ------------------------------------------------------

    lcd.clear()

    # gracefully exit when CTRL+C
    try:
        while True:
            asyncio.run(async_main())

    # offload camera, lcd, and exit
    except KeyboardInterrupt:
        cap.release()
        lcd.clear()
        lcd.backlight = False
        os._exit(1)
        print("\nCancelled...")

Of Course There’s Trouble…

There is a forum discussing how difficult it is to use Python in NixOS due to the way how regular Linux Distro’s link C++ .so files. Luckily there was a script I found in the GitHub documentation that I modified for my needs:

default.nix

with import <nixpkgs> { };

let
  pythonPackages = python3Packages;
in pkgs.mkShell rec {
  name = "impurePythonEnv";
  venvDir = "./.venv";
  buildInputs = [
    # A Python interpreter including the 'venv' module is required to bootstrap
    # the environment.
    pythonPackages.python

    # This execute some shell code to initialize a venv in $venvDir before
    # dropping into the shell
    pythonPackages.venvShellHook

    # Those are dependencies that we would like to use from nixpkgs, which will
    # add them to PYTHONPATH and thus make them accessible from within the venv.
    pythonPackages.numpy
    pythonPackages.requests
    pythonPackages.pyftdi
  
    # In this particular example, in order to compile any binary extensions they may
    # require, the Python modules listed in the hypothetical requirements.txt need
    # the following packages to be installed locally:
    libusb1
   ];

  # Run this command, only after creating the virtual environment
#  postVenvCreation = ''
#    unset SOURCE_DATE_EPOCH
#    pip install -r requirements.txt
#  '';

 packages = [ pkgs.screen ];

# For linking OpenCV libraries
 LD_LIBRARY_PATH = lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];

 shellHook = ''
   alias c="clear"
   alias h="history -c"
   alias la="ls -la"
   
   source .venv/bin/activate
   which python3
   python3 sanity_test.py
  # python3 blink_test.py
   ls /dev/ttyUSB*
   ls /dev/video*
 '';


  # Now we can execute any commands within the virtual environment.
  # This is optional and can be left out to run pip manually.
  postShellHook = ''
    # allow pip to install wheels
    unset SOURCE_DATE_EPOCH
    BLINKA_FT232H=1
  '';

}

Since this environment relies on the PIP package, the following is the libraries I’ve used:

requirements.txt

Adafruit-Blinka==8.56.0
adafruit-blinka-displayio==2.1.7
adafruit-circuitpython-74hc595==1.4.6
adafruit-circuitpython-bitmap_font==2.2.0
adafruit-circuitpython-bmp280==3.3.6
adafruit-circuitpython-busdevice==5.2.11
adafruit-circuitpython-charlcd==3.5.1
adafruit-circuitpython-connectionmanager==3.1.3
adafruit-circuitpython-framebuf==1.6.7
adafruit-circuitpython-mcp230xx==2.5.16
adafruit-circuitpython-register==1.10.2
adafruit-circuitpython-requests==4.1.10
adafruit-circuitpython-rgb-display==3.13
adafruit-circuitpython-ticks==1.1.2
adafruit-circuitpython-typing==1.11.2
Adafruit-PlatformDetect==3.77.0
Adafruit-PureIO==1.1.11
binho-host-adapter==0.1.6
brotlicffi==1.1.0.0
certifi==2024.8.30
cffi==1.17.1
charset-normalizer==3.3.2
idna==3.10
numpy==1.26.4
opencv-python-headless==4.11.0.86
pillow==11.1.0
pycparser==2.22
pyftdi==0.55.4
pynmea2==1.19.0
pyserial==3.5
pyusb==1.2.1
requests==2.32.3
sysv_ipc==1.1.0
typing_extensions==4.12.2
urllib3==2.2.3
woolseyworkshop-circuitpython-74hc165==1.0.0

Wait, What About Your ServerDeck?

Don’t worry, it’s still up and running! I probably might use this to handle whatever incoming data Nix-Stick is processing through the USBs Ethernet adapter like I had before.

So What Now?

Hmm…. More prototyping? Get this outside? There’s plenty of things to do with this Compute Stick and frankly I am suprised it’s able to handle a lot.

Share: X (Twitter) Facebook LinkedIn