Create Indestructible SBC's with overlayfs--Part III--Python, I2C and SPI

In previous posts I've set up a Single Board Computer to survive power hits (here) and run code at startup using systemd (here).  

What next? 

Let's run some code.

I got proof-of-concept Python scripts working a Raspberry Pi Zero W, using SPI to communicate with an MCP4922 digital to analog converter (DAC) and an MCP3002  analog to digital converter (ADC), I2C for talking with MCP4728 and PCF8591 Analog to Digital Converters, and I2C for displaying text on a cheap 1306-based OLED

In the coming weeks, time permitting, I'll reuse elements of the code in some new projects.




Here is how I ran each test script.
  • used raspi-config to enable I2C and SPI on the SBC (tutorial here)
  • SSH'd into the SBC
  • cd'd to the directory containing the file
  • ran it: python3 [name of the script]
  • Outputs were visible at DAC output pins and/or the SBC's via its terminal.

I2C--MCP4728


The MCP4728 is an inexpensive 4-channel 12-bit I2C DAC IC.


  
The wiring used:

I2C wiring (SDA, SCL) is the same for other I2C parts used in this post.

The RPI's pinout:

 To orient this correctly, put the SBC on the bench and face its HDMI jack to the left. Useful webpage and site describing this is here.


Pinout of the MCP4728: (p. 22 of data sheet)    1. Vdd: 3.3V     2 and 3 I2C.     4: tie to GND     5: EEPROM (not used for the examples; let it float)     6-9: outputs, 10: tie to GND


MCP4728 Breakout Board. How to program the IC begins on page 23 of its datasheet.




To make this go, I needed to use uv to add smbus2 (I cover uv basics in the last post).

uv add smbus2

smbus abstracts the bit-banging needed to make I2C work--more here.

The 4728 DAC IC supports two modes--"multi-write" allowing writes to any of its 4 channels independently and "fast-write" sending output data to all 4 channels in a single burst.

Python code for multi-write, in this case writing a single value to channel B that can be seen with a DVM or scope:



  
import smbus2 as smbus
#smbus2 can be imported from uv--smbus can't

########################
"""
example: writing value(s) to a single channel of the mcp4728 (B in this case)
even at 400K i2c, not a very fast process, so not good for everything.
OK for simple tasks.
"""


bus = smbus.SMBus(1)
DEVICE_ADDRESS = 0x60 #default addr.

def set_channel_b_voltage(value, vref=1, gain=0):
    """
    Sets voltage for CHANNEL B only using Multi-Write Command.
    value: 0-4095
    vref: 1 (Internal 2.048V), 0 (VDD)
    gain: 0 (x1), 1 (x2)
    """
    value = max(0, min(4095, value))
    
    # 1. Build Command Byte for Channel B
    # Command: 0 1 0 0 0 (Multi-Write)
    # Channel B: 0 1
    # UDAC: 0 (Update now)
    # Binary: 01000010 -> Hex: 0x42
    command_byte = 0x42

    # 2. Build Upper Data Byte (Config + D11-D8)
    # Bits: VREF PD1 PD0 Gx D11 D10 D9 D8
    # We assume PD1=0, PD0=0 (Normal Power)
    upper_data_bits = (value >> 8) & 0x0F
    config_bits = (vref << 7) | (0 << 5) | (gain << 4)
    byte2 = config_bits | upper_data_bits

    # 3. Build Lower Data Byte (D7-D0)
    byte3 = value & 0xFF

    # 4. Send Command
    try:
        # write_i2c_block_data(Address, CommandByte, [DataBytes])
        bus.write_i2c_block_data(DEVICE_ADDRESS, command_byte, [byte2, byte3])
        print(f"Channel B updated to {value}")
    except OSError as e:
        print(f"Error: {e}")

# --- Test ---
# Set Channel B to ~1.024V (Half Scale)

 


Same idea but using "fast-mode" to produce 4 distinct DC voltages at each output.




 import smbus2 as smbus
import time
#smbus2 can be imported from uv--smbus can't

########################
"""
example: writing to a all four channels of the mcp4728
even at 400K i2c, not a very fast process, so not good for everything.
but--fast write is faster vs writing data to each channel individually.
"""


# Initialize I2C
bus = smbus.SMBus(1)
DEVICE_ADDRESS = 0x60

def set_all_channels(va, vb, vc, vd):
    """
    Updates all 4 channels (A, B, C, D) simultaneously using Fast Write.
    Values must be 0-4095.
    """
    # 1. Clamp values to 12-bit range (0-4095)
    values = [va, vb, vc, vd]
    data_payload = []

    for val in values:
        val = max(0, min(4095, val))
        
        # 2. Split 12-bit value into two bytes
        # Byte 1: 0 0 0 0 D11 D10 D9 D8 (Assuming Normal Power Mode)
        # We shift the value right by 8 bits to get the top nibble
        # and mask with 0x0F to ensure the command bits (top 4) are 0000.
        upper_byte = (val >> 8) & 0x0F
        
        # Byte 2: D7 D6 D5 D4 D3 D2 D1 D0
        lower_byte = val & 0xFF
        
        data_payload.extend([upper_byte, lower_byte])

    # 3. Send Data
    # write_i2c_block_data(Address, Register/Cmd, [Data List])
    # The MCP4728 Fast Write command doesn't use a standard register address.
    # The "Command" IS the first byte of the data stream.
    
    # We strip the first byte off our payload to use as the "Cmd" argument,
    # and pass the rest as the list of data bytes.
    try:
        bus.write_i2c_block_data(DEVICE_ADDRESS, data_payload[0], data_payload[1:])
    except OSError as e:
        print(f"I2C Error: {e}")

# --- Example Usage ---

try:
    print("Starting 4-Channel Waveform...")
    
    while True:
        # Example: Set different static voltages
        # A=0V, B=1V, C=2V, D=Full Scale (approx)
        set_all_channels(0, 2000, 3000, 4095)
        time.sleep(1)

        # Example: Invert them
        set_all_channels(4095, 3000, 2000, 0)
        time.sleep(1)

except KeyboardInterrupt:
    # Turn off outputs on exit
    set_all_channels(0, 0, 0, 0)
    print("\nStopped.")

 


I was curious how the IC performed.  Can the 4728 produce decent, quickly modulating AC?  Here's code that created a triangle wave using a lookup table.

 




 
import smbus2 as smbus
import time

"""
needs smbus2 module--add that first.

uses a lookup table to create_triangle_table
a triangle output on Chan A  

Requires 400K I2C 

"""

# --- Configuration ---
DEVICE_ADDRESS = 0x60
BUS_NUMBER = 1
DAC_RESOLUTION = 4096

# Multi-Write Command Constants
CMD_MULTI_WRITE = 0x40  # 0100 0000
# Channel Select bits (shifted to match DAC1, DAC0 position)
CH_A = 0x00  # 0000 0000 (Channel A)
CH_B = 0x02  # 0000 0010 (Channel B)

# --- I2C Setup ---
try:
    bus = smbus.SMBus(BUS_NUMBER)
except Exception as e:
    print(f"Error opening I2C bus: {e}")
    exit(1)

def create_triangle_table(steps=1000):
    """
    Creates a full cycle triangle wave lookup table (0 -> 4095 -> 0).
    """
    table = []
    # Rising edge
    for i in range(steps // 2):
        val = int((i / (steps // 2)) * (DAC_RESOLUTION - 1))
        table.append(val)
    # Falling edge
    for i in range(steps // 2):
        val = int((1 - (i / (steps // 2))) * (DAC_RESOLUTION - 1))
        table.append(val)
    return table

def send_value_channel_a(value):
    """
    Sends a specific 12-bit value to CHANNEL A using Multi-Write.
    """
    # Clamp value
    value = max(0, min(4095, int(value)))
    
    # --- Construct Multi-Write Command ---
    # Byte 1: Command (01000) | Channel (00 for A) | UDAC (0)
    # Binary: 0100 0000 -> Hex: 0x40
    cmd_byte = CMD_MULTI_WRITE | CH_A

    # Byte 2: Vref(1) | PD(00) | Gain(0) | Upper Data (D11-D8)
    # Vref=1 (Internal 2.048V), Gain=0 (x1)
    # 0x80 is the bitmask for Vref=1
    upper_nibble = (value >> 8) & 0x0F
    byte2 = 0x80 | upper_nibble

    # Byte 3: Lower Data (D7-D0)
    byte3 = value & 0xFF

    try:
        bus.write_i2c_block_data(DEVICE_ADDRESS, cmd_byte, [byte2, byte3])
    except OSError:
        pass 

def estimate_max_update_rate(samples=500):
    """
    Benchmarks I2C speed on Channel A.
    """
    print("Calibrating I2C speed...")
    start = time.time()
    for _ in range(samples):
        send_value_channel_a(2048) 
    duration = time.time() - start
    rate = samples / duration
    print(f"Max Update Rate: {rate:.1f} Hz")
    return rate

def run_triangle_wave(frequency):
    """
    Generates a triangle wave on Channel A at the specified frequency.
    """
    TABLE_SIZE = 2048 
    wave_table = create_triangle_table(TABLE_SIZE)
    
    max_rate = estimate_max_update_rate()
    
    # Calculate Step Size
    step_size = (TABLE_SIZE * frequency) / max_rate
    
    print(f"\n--- Outputting {frequency} Hz Triangle Wave on Channel A ---")
    print(f"Step Size: {step_size:.4f}")
    print("Press CTRL+C to stop.")

    current_index = 0.0
    
    try:
        while True:
            idx = int(current_index) % TABLE_SIZE
            val = wave_table[idx]
            
            send_value_channel_a(val)
            
            current_index += step_size

    except KeyboardInterrupt:
        print("\nStopping...")
        send_value_channel_a(0) # Turn off

if __name__ == "__main__":
    try:
        freq_input = float(input("Enter desired frequency (Hz): "))
        run_triangle_wave(freq_input)
    except ValueError:
        print("Please enter a valid number.")



 



The output was not great....a 100hz Triangle wave looked "stair steppy":

At 100hz, stair-step but usable (maybe). At higher frequencies the DAC's output became heavily distorted and unusable....


The performance was held back by the overhead imposed by Python as well as speed limits of I2C. 

Increasing the SBC's I2C speed to 400K didn't help much.  That's done on an RPi by editing "config.txt":  



sudo vi  /boot/firmware/config.txt
#edit these lines:

#dtparam=i2c_arm=on 
#dtparam=i2c_baudrate=400000 

 


Did 400K and address the MCP4728 using "fast-mode" help? Not really. The code below, to create 4 triangle waveforms 90 degrees out of phase with each other, "worked" but again its output was again too blocky and looked downright awful at frequencies above about 250hz.
 






import smbus2 as smbus
import time

#uv can only import smbus2.

"""
create a triangle lookup table and send 4 signals 90 degrees out of phase
to each of mcp4728's 4 channels.

Works, but output is stairstepped--even at 400k i2c we are pushing
the limits of what this tech can do.

Looks better at sub-audio frequences (eg, as a quadrature LFO)

Prob needs an RC HP filter for each channel....
"""
 
# --- Configuration ---
DEVICE_ADDRESS = 0x60
BUS_NUMBER = 1
TABLE_SIZE = 2048
DAC_MAX = 4095

# --- I2C Setup ---
try:
    bus = smbus.SMBus(BUS_NUMBER)
except Exception as e:
    print(f"Error opening I2C bus: {e}")
    exit(1)

def create_triangle_table(steps):
    """Generates a lookup table for a 0-4095-0 triangle wave."""
    table = []
    half_steps = steps // 2
    
    # Rising (0 -> 4095)
    for i in range(half_steps):
        val = int((i / half_steps) * DAC_MAX)
        table.append(val)
    # Falling (4095 -> 0)
    for i in range(half_steps):
        val = int((1 - (i / half_steps)) * DAC_MAX)
        table.append(val)
    return table

def write_channel(channel_idx, value):
    """
    Writes to a SINGLE channel using the Multi-Write Command.
    channel_idx: 0=A, 1=B, 2=C, 3=D
    """
    value = max(0, min(4095, int(value)))
    
    # --- Command Byte Construction ---
    # Bits: 0 1 0 0 0 DAC1 DAC0 UDAC
    # Multi-Write Code: 01000 (0x08 << 3 = 0x40)
    # Channel: Shifted left by 1 bit
    # UDAC: 0 (Update Immediately)
    
    cmd_byte = 0x40 | (channel_idx << 1) 
    
    # --- Data Bytes ---
    # Byte 2: Vref(1) | PD(00) | Gain(0) | Upper Data
    # Vref=1 (Internal 2.048V) -> 0x80
    upper_nibble = (value >> 8) & 0x0F
    byte2 = 0x80 | upper_nibble
    
    # Byte 3: Lower Data
    byte3 = value & 0xFF
    
    try:
        bus.write_i2c_block_data(DEVICE_ADDRESS, cmd_byte, [byte2, byte3])
    except OSError:
        pass

def run_quad_phase_wave(frequency):
    wave_table = create_triangle_table(TABLE_SIZE)
    
    # Calculate Phase Offsets (indices)
    offset_0   = 0
    offset_90  = TABLE_SIZE // 4      # 512
    offset_180 = TABLE_SIZE // 2      # 1024
    offset_270 = (TABLE_SIZE * 3) // 4 # 1536
    
    # Estimate speed for loop timing
    # Since we do 4 separate writes now, it will be slower.
    # Let's assume approx 300-400 full updates/sec without sleep.
    estimated_updates_per_sec = 350.0 
    
    step_size = (TABLE_SIZE * frequency) / estimated_updates_per_sec
    
    print(f"\n--- Outputting {frequency} Hz Quad-Phase Waves ---")
    print("Sequential Mode: Updating A -> B -> C -> D")
    print("Verify voltages with Multimeter/Scope.")
    
    current_index = 0.0
    
    try:
        while True:
            # Base Index
            base_idx = int(current_index)
            
            # Calculate values for all 4 channels
            val_a = wave_table[(base_idx + offset_0)   % TABLE_SIZE]
            val_b = wave_table[(base_idx + offset_90)  % TABLE_SIZE]
            val_c = wave_table[(base_idx + offset_180) % TABLE_SIZE]
            val_d = wave_table[(base_idx + offset_270) % TABLE_SIZE]
            
            # Send them sequentially
            # 0=A, 1=B, 2=C, 3=D
            write_channel(0, val_a)
            write_channel(1, val_b)
            write_channel(2, val_c)
            write_channel(3, val_d)
            
            current_index += step_size

    except KeyboardInterrupt:
        print("\nStopping...")
        for i in range(4):
            write_channel(i, 0)

if __name__ == "__main__":
    try:
        freq = float(input("Enter frequency (Hz) [Try 1.0 for multimeter testing]: "))
        run_quad_phase_wave(freq)
    except ValueError:
        print("Invalid number.")
        
        
        
        


passive RC filter might make it usable in certain applications.

Next let's move on to an 8-bit I2C ADC:

I2C--PCF8591


The PCF8591 is a super affordable analog to digital converter (ADC) with 4 inputs one DAC output. As a proof of concept I created some code for simple ADC and DC tests.

PCF8591 Breakout Boards are everywhere....and was the only I2C ADC I could find in my junk box.



PCF8591 as ADC:


The BOB had a pot, I could turn that to see outputs change on the SBC (I was SSH'd into the SBC, so this was visible in the same terminal I used to run the code):

 




import smbus2 as smbus
import time

#reads input 3 of the PCF8591 and displays every 500ms
#on most bob's that's the built in pot.
#make sure to have BOB jumper P6 in place.

bus = smbus.SMBus(1)
ADDRESS = 0x48

# We use 0x43 instead of 0x03
# 0x40 (Enable DAC Output) + 0x03 (Channel 3)
# Many modules require the DAC to be enabled to clock the ADC correctly.
COMMAND = 0x43 

def read_ain3_reliable():
    # 1. Send Control Byte
    bus.write_byte(ADDRESS, COMMAND)
    
    # 2. Dummy Read (Clears the buffer)
    bus.read_byte(ADDRESS)
    
    # 3. Real Read (Gets fresh data)
    return bus.read_byte(ADDRESS)

print("Reading Potentiometer on AIN3 (Reliable Mode)...")
print("Press CTRL+C to stop.")

try:
    while True:
        pot_val = read_ain3_reliable()
        
        # Convert to Voltage
        voltage = (pot_val / 255.0) * 3.3
        
        # simple bar chart
        bar = '#' * int(pot_val / 5)
        
        print(f"Value: {pot_val:<3} | Voltage: {voltage:.2f}V | {bar}", end='\r')
        time.sleep(0.1)

except KeyboardInterrupt:
    print("\nStopped.")
    
    
          
        


....and coded a simple "read all 4 inputs" script:


 



 import smbus2 as smbus
import time

#make sure to uv add smbus2

#reads voltages present on the 4 PCV8591 inputs.

bus = smbus.SMBus(1)
ADDRESS = 0x48

def read_all_channels():
    results = []
    # We cycle through channels 0 to 3
    for channel in range(4):
        # 1. Write Control Byte to select the channel
        # Control Byte: 0x40 (Enable Output) | channel_index
        bus.write_byte(ADDRESS, 0x40 | channel)
        
        # 2. Dummy Read (clears the previous conversion from the buffer)
        bus.read_byte(ADDRESS)
        
        # 3. Real Read (gets the current value)
        value = bus.read_byte(ADDRESS)
        results.append(value)
        
    return results

print("PCF8591 input Scanner...")
print("Change input voltages and see which column changes!")
print("AIN0   AIN1   AIN2   AIN3")
print("---------------------------")

while True:
    vals = read_all_channels()
    # Print formatted columns
    print(f"{vals[0]:<7} {vals[1]:<7} {vals[2]:<7} {vals[3]:<7}", end='\r')
    time.sleep(0.2)
    
    
        



PCF8591 as DAC:


The PCF8591 had a single channel 8 bit DAC; I wrote some code to make its output a 10hz ramp (which clipped, but, whatever. I'll fix it later--this was good enough for now).

 



import smbus2 as smbus
import time

# --- Configuration ---
DEVICE_ADDRESS = 0x48
BUS_NUMBER = 1

# Control Byte: 0x40
# Bit 6 (1) = Analog Output Enable (Turn on DAC)
# Bits 1-0 (00) = Channel 0 (doesn't matter for DAC, but good default)
CMD_ENABLE_DAC = 0x40 

try:
    bus = smbus.SMBus(BUS_NUMBER)
except Exception as e:
    print(f"Error opening I2C bus: {e}")
    exit(1)

def set_dac_value(value):
    """
    Writes a value (0-255) to the PCF8591 DAC.
    Protocol: [Address] [Control Byte] [Data Byte]
    """
    # Clamp value to 8-bit range
    value = int(max(0, min(255, value)))
    
    try:
        # write_byte_data sends: Address -> Register(Cmd) -> Value
        bus.write_byte_data(DEVICE_ADDRESS, CMD_ENABLE_DAC, value)
    except OSError:
        pass # Ignore I2C errors to keep the wave running

def estimate_speed(samples=500):
    """
    Benchmarks how fast your Pi can talk to this specific chip.
    """
    print("Calibrating I2C speed...")
    start = time.time()
    for _ in range(samples):
        set_dac_value(128)
    duration = time.time() - start
    rate = samples / duration
    print(f"Max Update Rate: {rate:.1f} Hz")
    return rate

def run_ramp_wave(target_freq):
    # 1. Benchmark
    max_rate = estimate_speed()
    
    # 2. Calculate Step Size
    # Total range is 256 steps (0 to 255)
    # Total steps needed per second = 256 * frequency
    # We can only do 'max_rate' updates per second.
    step_size = (256 * target_freq) / max_rate
    
    print(f"\n--- Outputting {target_freq} Hz Ramp Wave ---")
    print(f"Step Increment: {step_size:.4f}")
    print("Press CTRL+C to stop.")
    
    current_val = 0.0
    
    try:
        while True:
            # Send current integer value
            set_dac_value(current_val)
            
            # Increment
            current_val += step_size
            
            # Reset if we hit the top (Sawtooth shape)
            if current_val >= 256:
                current_val -= 256 

    except KeyboardInterrupt:
        print("\nStopping...")
        set_dac_value(0) # Turn off output

if __name__ == "__main__":
    run_ramp_wave(10) # 10 Hz Target
    
              
        


DAC output:




I2C OLED


DiWHY digital projects usually aren't complete without a cheap OLED.  Could I get that to work?  Yep.

First I needed to add some app packages to Linux....

sudo apt-get update

sudo apt-get install libjpeg-dev zlib1g-dev libfreetype6-dev liblcms2-dev libopenjp2-7 libtiff5

Then I added luma.oled using uv--I used luma to drive the OLED since it didn't consume a lot of memory:

uv add luma.oled
 
Code used:


 import time
from luma.core.interface.serial import i2c
from luma.core.render import canvas
from luma.oled.device import ssd1306

# Initialize I2C connection (Port 1 is standard for Pi)
serial = i2c(port=1, address=0x3C)

# Initialize the SSD1306 device
# Note: Ensure width/height match your specific display (usually 128x64 or 128x32)
device = ssd1306(serial, width=128, height=64)

print("Display initialized. Press Ctrl+C to exit.")

try:
    while True:
        # The 'canvas' allows you to draw on the screen
        # It automatically clears the screen at the start of the block
        # and displays the result at the end of the block.
        with canvas(device) as draw:
            # Draw a white rectangle border
            draw.rectangle(device.bounding_box, outline="white", fill="black")
            
            # Draw text
            draw.text((30, 25), "Hello DiWHY ", fill="white")
        
        # Prevent the script from consuming 100% CPU
        time.sleep(1)

except KeyboardInterrupt:
    # Clear screen on exit
    device.cleanup()
    print("Exiting...")
    

        



"seems working"



 

SPI--MCP3002 ADC


For most projects I use SPI: better quality ADC reads and DAC writes vs. I2C.

For SBC's spidev  abstracts the tricky bit-banging needed for SPI.

uv add spidev

The MCP3002 is a 10-bit 2-channel affordable IC; as a proof of concept I wrote a simple "read both channels and throw it into the terminal" script as a proof-of-concept.

I hate breadboarding....










 




import spidev
import time

# SPI setup
spi = spidev.SpiDev()
spi.open(0, 0)  # Bus 0, Device 0 (CE0)
spi.max_speed_hz = 1000000 # 1 MHz

def read_mcp3002(channel):
    """
    Reads data from MCP3002 (10-bit ADC).
    Channel must be 0 or 1.
    """
    if channel > 1 or channel < 0:
        return -1

    # datasheet Table 5-1 and Fig 5-1 [cite: 1706, 1707]
    # We construct the control byte:
    # Start Bit (1) | SGL/DIFF (1) | ODD/SIGN (channel) | MSBF (1)
    # We position the Start bit to align with the SPI clocking.
    # Sending 0x60 (0110 0000) places the start bit at bit 6.
    # The (channel << 4) selects CH0 (0) or CH1 (1).
    
    config_bits = 0x60 | (channel << 4)
    
    # We send [Config, Dummy Byte]
    # The device returns 10 bits of data.
    resp = spi.xfer2([config_bits, 0x00])
    
    # The result usually comes back across the two bytes.
    # We need to mask and shift to get the 10-bit value.
    # Based on alignment of 0x60, the valid data is in the last 10 bits.
    
    # Parse the 10-bit result
    result = ((resp[0] & 0x03) << 8) | resp[1]
    
    return result

try:
    print("Testing MCP3002 Inputs (Raw SPI)...")
    while True:
        adc_val_0 = read_mcp3002(0)
        adc_val_1 = read_mcp3002(1)
        
        # Convert to voltage (assuming 3.3V Vref)
        voltage_0 = (adc_val_0 * 3.3) / 1023
        voltage_1 = (adc_val_1 * 3.3) / 1023
        
        print(f"CH0: {adc_val_0} ({voltage_0:.2f}V) | CH1: {adc_val_1} ({voltage_1:.2f}V)")
        time.sleep(0.5)

except KeyboardInterrupt:
    spi.close()
    print("\nSPI Closed. Exiting...")
    
    
    
        

Output looked good:



BUT WAIT! A WORD FROM OUR SPONSOR: PCBWAY

                           
 

After getting your SBC set, you will probably want to design a PCB for your SBC then get it fabricated.

For this, please check out PCBWAY.  They are super affordable!

PCBWAY can fabricate PCBs using full color! Details here

In addition to top shelf PCB fabrication they also do fantastic work with assembly3D printinginjection molding, and much more. 
 
As always--you can help this blog by checking out the PCBWAY site. Thanks.

SPI--MCP4922


The MCP4922 is an affordable 12-bit 2 channel DAC.  





I see them cropping up in lots of DIY projects. Would it work with the indestructible SBC on the bench?  You betcha.

 



import spidev
import time
import sys

"""
you need linux app python3-dev.

sudo apt-get update
sudo apt-get install python3-dev

you also need spidev, to add with UV:
uv add spidev

"""


# --- Config ---
SPI_BUS = 0
SPI_DEVICE = 0 # Ensure your CS wire is on Pin 24 (CE0)

spi = spidev.SpiDev()
try:
    spi.open(SPI_BUS, SPI_DEVICE)
    spi.max_speed_hz = 1000000
    spi.mode = 0b00  # Explicitly set Mode 0 (CPOL=0, CPHA=0)
except Exception as e:
    print(f"Error: {e}")
    sys.exit(1)

def send_dac(channel, value):
    # Channel 0 = A, 1 = B
    # Config bits: Buffered, 1x Gain, Active
    # A: 0011 (0x3) | B: 1011 (0xB)
    # Note: I changed bit 12 to '1' (Active) and bit 13 to '1' (1x Gain)
    # The previous script used 0x7 and 0xF which is also valid but let's be explicit.
    
    config = 0x3000 if channel == 0 else 0xB000
    data = config | (value & 0xFFF)
    
    upper = (data >> 8) & 0xFF
    lower = data & 0xFF
    spi.xfer2([upper, lower])

print("--- DEBUG MODE: Slow Toggle ---")
print("Both channels should switch between 0V and 3.3V every 3 seconds.")
print("Measure Pin 14 (A) and Pin 10 (B) now.")

try:
    while True:
        print("Writing 0V (Low)...")
        send_dac(0, 0)    # Channel A -> 0
        send_dac(1, 0)    # Channel B -> 0
        time.sleep(3)
        
        print("Writing 3.3V (High)...")
        send_dac(0, 4095) # Channel A -> Max
        send_dac(1, 4095) # Channel B -> Max
        time.sleep(3)

except KeyboardInterrupt:
    spi.close()
    
       
        


Last thing I tried was 2 triangle waves with the 2nd 90 degrees out of phase. 







With a couple of inverting op amps in series, this forms a super simple, super low parts count quadrature function generator--try doing this entirely in the analog domain, right?

 




 import spidev
import time
import sys

"""
you need linux app python3-dev.

sudo apt-get update
sudo apt-get install python3-dev

you also need spidev, to add with UV:
uv add spidev

"""

# --- Configuration ---
SPI_BUS = 0
SPI_DEVICE = 0  # Uses CE0 (Pin 24)
SPI_SPEED = 4000000 # 4 MHz (Plenty fast for Python)

# MCP4922 Configuration Bits
# Bit 15: 0=A, 1=B
# Bit 14: Buf (1=Buffered)
# Bit 13: Gain (1=1x, 0=2x)
# Bit 12: SHDN (1=Active, 0=Shutdown)

# Config for Channel A: 0 1 1 1 (0x7)
# Note: Unbuffered (0) is fine too, but Buffered (1) drives loads better.
config_A = 0x7000 

# Config for Channel B: 1 1 1 1 (0xF)
config_B = 0xF000

# DAC Resolution
DAC_MAX = 4095
TABLE_SIZE = 2048

# --- Setup SPI ---
spi = spidev.SpiDev()
try:
    spi.open(SPI_BUS, SPI_DEVICE)
    spi.max_speed_hz = SPI_SPEED
except Exception as e:
    print(f"Error opening SPI: {e}")
    sys.exit(1)

def create_triangle_table(steps):
    """Generates a lookup table for a 0-4095-0 triangle wave."""
    table = []
    half_steps = steps // 2
    
    # Rising (0 -> 4095)
    for i in range(half_steps):
        val = int((i / half_steps) * DAC_MAX)
        table.append(val)
    # Falling (4095 -> 0)
    for i in range(half_steps):
        val = int((1 - (i / half_steps)) * DAC_MAX)
        table.append(val)
    return table

def write_dac(channel_config, value):
    """
    Sends a value to the MCP4922.
    channel_config: config_A or config_B base bits
    value: 0-4095
    """
    # 1. Combine Config bits with 12-bit Value
    # The command is 16 bits total.
    # Top 4 bits are Config, Bottom 12 are Data.
    data = channel_config | (value & 0xFFF)
    
    # 2. Split into two bytes
    upper_byte = (data >> 8) & 0xFF
    lower_byte = data & 0xFF
    
    # 3. Send via SPI
    # xfer2 keeps CS low for the transaction
    spi.xfer2([upper_byte, lower_byte])

def benchmark_spi(samples=1000):
    """Checks how fast Python can drive the SPI loop."""
    print("Benchmarking SPI speed...")
    start = time.time()
    for _ in range(samples):
        # Write both channels to simulate real load
        write_dac(config_A, 2048)
        write_dac(config_B, 2048)
    duration = time.time() - start
    rate = samples / duration
    print(f"Max Update Rate (Dual Channel): {rate:.1f} Hz")
    return rate

def run_quadrature_waves(frequency):
    wave_table = create_triangle_table(TABLE_SIZE)
    
    # Measure system speed to calculate step size
    max_rate = benchmark_spi()
    
    # Calculate Step Size
    # We need to traverse 'frequency' full tables per second.
    step_size = (TABLE_SIZE * frequency) / max_rate
    
    # Calculate Phase Offset (90 degrees = 1/4 table)
    offset_90 = TABLE_SIZE // 4
    
    print(f"\n--- Generating {frequency} Hz Waves (90 deg offset) ---")
    print("Connect Scope to Pin 14 (Ch A) and Pin 10 (Ch B)")
    print(f"Step Size: {step_size:.4f}")
    
    current_idx = 0.0
    
    try:
        while True:
            # Calculate Indices
            idx_a = int(current_idx) % TABLE_SIZE
            idx_b = (idx_a + offset_90) % TABLE_SIZE
            
            # Lookup Values
            val_a = wave_table[idx_a]
            val_b = wave_table[idx_b]
            
            # Write to DACs
            write_dac(config_A, val_a)
            write_dac(config_B, val_b)
            
            # Increment
            current_idx += step_size

    except KeyboardInterrupt:
        print("\nStopping...")
        write_dac(config_A, 0)
        write_dac(config_B, 0)
        spi.close()

if __name__ == "__main__":
    try:
        freq = float(input("Enter frequency (Hz): "))
        run_quadrature_waves(freq)
    except ValueError:
        print("Invalid number.")
        
        
 



Are we there yet? Sure.



OUTTRO:


Thank goodness for AI or I'd still be writing and debugging the code fragments in today's post--for simple things like today's POC code AI assistance was invaluable. 

But now the boss wants me code 1000 more scripts in half the time it took to write 5 of them--it's a damn slippery slope.

In other news--I'm moving my workshop so that means packing everything up, labeling, figuring out space in the new space, plugging everything back in, seeing what didn't make it. So no more posts for a bit while I figure all this out.

As the dentist says--you know the drill.

See ya next time.



Create Indestructible SBC's with overlayfs--Part II--uv, flask, and systemd

Last time I covered using overlayfs on a Linux Single Board Computer to survive unwelcomed power hits. 

To understand this post you may want to read or skim the last one...This time I wanted to finish the indestructible SBC setup by adding a Python virtual environment using uv, creating a basic webserver using Python flask, and getting the flask service to auto-start at boot using systemd.

If you are a Linux/Python admin you may already know how to do all of this; consider skipping this post and check out these guys instead. 

As usual, I am writing the steps because I forget everything. 

Pi Zero--512MB RAM but RPi's text-only OS still gave me a surprising amount of extra space to mess around in an overlayfs configuration. 

BUT FIRST--A WORD FROM THIS BLOG'S SPONSOR:


                           
 

After getting your SBC set, you will probably want to design a PCB for your SBC then get it fabricated.

For this, please check out PCBWAY. 

For December 2025 PCBWAY is featuring some terrific holiday specials, details here.

For instance: a special where cool PCB solder mask colors like purple and matte black are a super low price: ten 99 x 99 mm double-sided PCB's for $5USB + tax, tariff, shipping. 

Super affordable!

PCBWAY can fabricate PCBs using full color! Details here

In addition to top shelf PCB fabrication they also do fantastic work with assembly3D printinginjection molding, and much more. 

Their staff is extremely helpful and PCBWAY always turns work around quickly. 

As always--you can help this blog by checking out the PCBWAY site. Thanks.

MAKING CHANGES TO THE READ-ONLY SBC

From last post: we set the Raspberry Lite OS to read-only to survive power hits using overlayfs, but today we are writing OS changes. Here's how:

  • I removed the PI's SD card
  • I mounted it on my PC using an SD to USB adapter  
  • I edited /boot/firmware/cmdline.txt, getting rid of overlayroot=tempfs at the end of the statement, 
  • then saved cmdline.txt. 

Then I put the SD back into the RPI and started it up--it was now a normal read-write device.

When I was ready to go back to overlayfs mode, I added overlayroot=tempfs back to the cmdline.txt using vi, saved, and rebooted. 

WHAT IS UV?

I'd been administering Python environments for years; the number of tools needed to set up/maintain python3 projects-- pip, pip-tools, pipx, poetry, pyenv, twine, virtualenv, and so on, was troublesome. 

Imagine my excitement when I first read about uv, a single tool that kicks the other Python admin tools' butt. 

Read about uv here; a good getting started video is here.  

For Raspberry Pi OS, I opened the Linux terminal  and used the statements below to create a new project called pyflask using uv (Update: if using other linux distros you may need to put sudo in front of these commands or elevate to root):




  
#hint: to make my SSH terminal not look crap, I used UTF-8 

#encoding (I use secureCRT9.6-- 

#properties > appearance > encoding 

#default is "automatic" which wasn't working)

#install uv 

apt update

apt install curl #needed for next command--may already be installed on your SBC

#install uv using less HD space vs using pip install.

curl -LsSf https://astral.sh/uv/install.sh | sh

#SET PATH needed for terminal app to find uv when you try to run it

#put uv into PATH--for RPi I could skip this step and just restart bash; not sure w other distros

echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc

#note, for some distros .local above is replaced by .cargo

#restart bash  

bash  

#is uv working?  This should show a list of basic uv commands.

 uv
 
 
 


CREATE A NEW PYTHON PROJECT AND VIRTUAL ENV USING UV

 




#hint: to make my SSH terminal not look crap, I used UTF-8 
#encoding (I use secureCRT9.6-- 
#properties > appearance > encoding 

#default is "automatic" which wasn't working)

#install uv 

apt update

apt install curl #needed for next command--may already be installed #on your SBC

#install uv using less HD space vs using pip install.

#cd to ~ you should do the next command from your home dir.
uv init pyflask #pyflask is the name of the project

#cd to the project folder you just created.
cd ~/pyflask

uv run main.py  
#shows you successful hello world.

#ACTIVATE THE NEW PROJECT
#you still must be in the project dir.
#must use "source" in the command below or term will open
#a new term, make the change, and pop you back to old one....
source .venv/bin/activate 

#INSTALL PYTHON PACKAGES
#in project root folder....
#uv add [whatever]--use this instead of pip, faster!

#let's add flask
uv add flask

#ASIDE--with UV, WHERE DO YOUR FILES GO?
#see what is where in uv--useful cmd!
uv tree

#in project root.  See below. note-- never edit anything inside the #.venv dir and its subdirs.
#/home/pi/my_weather_bot/  <-- YOUR PROJECT ROOT (You work here)
#├── main.py   <-- Your code goes here
#├── utils.py                   <-- Your code goes here
#├── data/                      <-- Your data folders go here
#│   └── weather_log.csv
#└── .venv/                     <-- THE VIRTUAL ENV (Do not open/edit)
 #   ├── bin/
  #  ├── lib/
   # └── pyvenv.cfg


 

 

After all of this I had a working venv for project pyflask:



GETTING FLASK GOING....

Flask is a simple webserver for Python--more here.  

Using flask I created a webserver that generated "hello world" when viewed from my browser--using only a few lines of Python.  First using the terminal I created a new file to hold the webpage:

touch /home/charlie/pyflask/app.py

vi /home/charlie/pyflask/app.py

I then pasted this python code to the new app.py file:



#we used uv to install the flash module in our venv already.

from flask import Flask 

app = Flask(__name__)

@app.route('/')

def hello():

    return 'Hello, World!'

if __name__ == '__main__':

    app.run(host='0.0.0.0', port=5000, debug=True)

 

...Going back to the terminal I started the new app.




#START PYTHON APP using UV
#use this to test your flask setup.

uv run app.py  

#if you just use python3 app.py the path gets wacked and it won't work

#term will show something like what is below--if flask is ready 
#to process browser GETS and PUTS

########################################

 #* Serving Flask app 'app'
 #* Debug mode: on
#WARNING: This is a development server. Do not use it in a production #deployment. Use a production WSGI server instead.

 #* Running on all addresses (0.0.0.0)
 #* Running on http://127.0.0.1:5000
 #* Running on http://192.168.5.200:5000
#Press CTRL+C to quit

 #* Restarting with stat
 #* Debugger is active!
 #* Debugger PIN: 430-777-572
 
 



ACCESSING THE FLASK WEBPAGE FROM A BROWSER

Once I had everything above working it was time to test from a browser:

#if I open browser or curl using PC on

#the same wifi subnet as RPi Zero using port 5000 

#I see hello world.  

#Your IP will be different, but I hope you get the idea.

http://192.168.5.200:5000/

GETTING FLASK TO AUTO-START

Our indestructible SBC's test app (in this case app.py) had to start on power-up every time.  

There were lots of ways I could have done this, but to me the "Debian way" was to use systemd--video series about how systemd works starts here

Let's get right to it--using the Linux terminal:

#we need to create a .service file for our python app.

#in Raspberry pi and maybe other debian likes create this in this dir.

#/etc/systemd/system/

#remember that systemd only works with

#the full path to a file or executable

#the command to create the .service file using vi on a raspberry pi is this.

sudo vi /etc/systemd/system/flaskapp.service

#what I put in this new file....






[Unit]
Description=My Flask App

# Wait for the network to be ready before starting (crucial for Flask)
After=network.target

[Service]

# The user the app should run as 
User=charlie

# The group  
Group=charlie



# The folder where your app.py is located
WorkingDirectory=/home/charlie/pyflask
 

# The command to start the app. 
# format: /path/to/python /path/to/app.py
ExecStart=/home/charlie/pyflask/.venv/bin/python3 /home/charlie/pyflask/app.py

# Automatically restart the app if it crashes
Restart=always

# Send Python output to the system log immediately
Environment=PYTHONUNBUFFERED=1

[Install]
WantedBy=multi-user.target
 

 
 


FINISHING UP OUR BUILD

#let's load it; first, make the system reread all systemd config files...

sudo systemctl daemon-reload

#enable at boot

sudo systemctl enable flaskapp.service

#start it

sudo systemctl start flaskapp.service

#see its status

sudo systemctl status flaskapp.service

#stop the service (no changes needed to the service file for this)

sudo systemctl stop flaskapp.service

#restart the service

sudo systemctl restart flaskapp.service

OUTTRO


I ran the steps above on a Raspberry Pi Zero W, and it worked great--I was ready to start building Linux- and Python-based projects that power up and down like any other DiWHY build.

Now that I have a framework for a SBC based DiWHY project, what should I build?  

No idea. 

I have read about connecting CODECS and DACS to Raspberry Pi's, I might start there and design my own experimenters' boards; wait, there's more! Tons of support for what we usually do with Microcontrollers and C/C++ but instead, RPi and Python:

OLEDs? Here.
Rotary encoders? here.
ADC?  Here.
GPIO and bit-banging?  Here.
SPI and I2C? Here.

But for now it's time to inhale zero fumes. 

See ya next time!

 

Create Indestructible SBC's with overlaysf -- Cut the Power, Scotty!

A fun discussion at a geeky tech meetup lunch:

You have a Linux single board computer or "SBC" (or any Linux host) as the basis of a cool DiWHY audio project; what is the best way to shut it down?  

Easy!

sudo shutdown -h now #syntax depends on distro

The command makes the OS spin everything down without stressing the file system.

But what if someone disconnects the host's power without issuing said command? 

Any habitual Linux user knows: bad idea. At the next power up the file system will attempt to recover from the abrupt shutdown, and usually can, but every now and then: not as much.


However--there must be a way to do this. There are Linux based routers, set top boxes, Internet TV's and everything else, routinely surviving the aforementioned power loss. 


 

How do they work? 

Huge shoutout to group member MVCL who pointed me to OpenWRT, a Linux based router appliance, and the technology it uses to survive power hits--overlayfs--info here.

In the coming months I'd like to create an SBC based DiWhy appliance that can survive and abrupt power loss. Let's get the basics working.

CHEAT CODE--OVERLAYFS ON A NOVEMBER 2025 RASPBERRY PI:


The folks at RPi made this easy.

Get a Raspberry Pi (I used one of these) and Raspberry Pi OS Lite (I downloaded it here)


Then:

sudo apt-get install overlayroot

 Edit this file:

sudo vi /boot/firmware/cmdline.txt  
# add to the end of the single line in cmdline.txt: overlayroot=tmpfs   

Reboot--

You are good--the OS is read only; contents on the SD card are copied to RAM during boot; end user changes are stored entirely in RAM.

If you pull power your OS configuration is safe; RAM contents are lost; at power up the process starts over.

That's it.

Are we done? 

Nooooo.....

  • What about other distros (like Debian 13)?  Same steps?
  • How does overlayfs work at a deeper level?  
  • How do we change the read-only OS to do things like add programs, change configuration files and perform updates?

TESTING OVERLAYFS WITH VM's

For proofs-of-concept I created 2 VMware Workstation Pro Debian VM's: One to experiment with overlayfs' basics, another to create a "kiosk system" that could survive power hits.

Creating the first VM was pretty easy, here are the commands to enter into the Linux terminal: 




#in Debian, add user your username to sudoers
#this makes sudo commands work like ubuntu.

su - #use the minus to preserve paths for normal user.
usermod -aG sudo charlie 

#(use your ~ username instead of charlie, elmo).

#OverlayFS requires 4 directories to work.
#lower directory: the OS
#Upper directory: where changes are stored
#work directory: the kernel prepares files before moving them to #upper layer
#merged directory: the mountpoint the end user sees

# Create a main project folder
mkdir ~/overlay_lab
cd ~/overlay_lab

# Create the four required directories
mkdir lower upper work merged

#What these directories will be used for.
#overlay: The device name (can be anything, but overlay is standard).
#-o: Specifies we are adding options.

#lower dir: The bottom layer. This is a directory used by overlayfs.
#upper dir: The writable top layer

#This is a directory used by overlayfs
#work dir: The directory for internal operations 

#(must be on the same partition as upperdir).

#merged: The mount point.  This is what the end user sees
#-----------------------------------------------

#put data into lower dir. from overlay_lab dir:
echo "I am a base system file." > lower/base_config.txt
echo "Do not delete me." > lower/important_data.txt

#-----------------------------------------------

#run the overlayfs mount cmd. watch the line wrap!

sudo mount -t overlay overlay -o lowerdir=lower,upperdir=upper,workdir=work merged

#now you can see in merged these 2 files that came from lower.

#what does it all mean?

#changes to files in merged directory are mimicked to upper 

#deletions in merged directory cause a "whiteout" file in upper, 
#"masking" the deleted file

#files added to upper (copying a read only OS for instace are copied to merge dir

#if you edit a file in upper the edits do not appear in merged.

#if you edit fstab incorrectly you'll brick your SBC. This is why we 
#snapshot experimental VM's!

#edit fstab so your new configuration isn't lost at reboot 

#you are going to want to create a VM snapshot before you mess with fstab. 

overlay /home/charlie/overlay_lab/merged overlay noauto,x-systemd.automount,lowerdir=/home/charlie/overlay_lab/lower,upperdir=/home/charlie/overlay_lab/upper,workdir=/home/youruser/overlay_lab/work 0 0





All of this means that you have created a new mount at startup; "merge" is the directory where your active files will reside. Other directories are hands-off--pretty easy.

No Sh*t Sherlock: Docker makes heavy use of overlayfs. Didn't know that! Video here.

AND NOW--A WORD FROM THIS BLOG'S SPONSOR:


               
 

After getting your kiosk going, you might want to design a PCB for your SBC then get a bunch of them fabricated.

For this, you should use PCBWAY. 

This month (December 2025) PCBWAY is featuring some awesome holiday specials, details here.

PCBWAY has a service to fabricate PCB's using any and all visible colors. That's right, full color PCB's! Details here

In addition to top notch PCB fabrication they also do fantastic work with assembly3D printinginjection molding, and so so SOOO much more. 

Their staff is super friendly and PCBWAY always turns work around quickly. 

I'm always impressed! 

As always--you can help this blog by checking out the PCBWAY site. Thanks.

CREATING THE "KIOSK" VM


On a second Debian VM here are the steps I used to configure the kiosk VM:




 
#root and login
su -

#make sure overlayroot is installed. Should be....if not....
apt-get update
apt-get install overlayroot

#add overlay module on next boot. 
#we are adding overlay as a single line to the bottom of the modules #file.

echo "overlay" | tee -a /etc/initramfs-tools/modules

#edit grub; do this carefully 

vi /etc/default/grub

#change the GRUB_CMDLINE_LINUX_DEFAULT to this (quotes are needed)
GRUB_CMDLINE_LINUX_DEFAULT="quiet overlayroot=tmpfs"

#save the file (":wq!)

#rebuild grub
update-grub

update-initramfs -u

#reboot.
sudo shutdown -r now
 



file system (dh -f) should look like this.


The main thing: overlayroot is now mounted as /

For me, I could force-power-off the VM and restart--yep, all seemed OK.  

After several reboots I didn't see tons of inode rebuild errors and whatnot. After many power cuts the virtual machine survived all power hits A-OK.

As I added more apps and code to the VM  (covered in the next section), I could continue to run df -h to see if /overlayfs was filling up.  

So far, so good.

HOW DO I CHANGE THE READ-ONLY FS?

To make minor changes to the read only partition:

overlayroot-chroot

(make changes--apt installs, for instance)

when done...

exit

I could use this for apt installs and updates...."exit" sometimes threw errors, but my changes stuck.

-----------------

Another method: this flipped me back to normal Debian once; when I rebooted the OS it was back to overlayfs mode. 

I read this is best for major OS changes and configuration file redo's.

  1. Reboot the VM.

  2. At the GRUB menu, press e.

  3. Find the line with overlayroot=tmpfs.

  4. Change it to overlayroot=disabled.

  5. Press F10 to boot. (You are now in a standard, persistent Debian environment).

  6. to check: mount | grep overlay # produces no output. overlayfs is not #running.

  7. Run your updates, then reboot to re-enable protection.

Finally, I deployed a trick for advanced Linux nerds.....allowing a menu choice in GRUB to disable overlayfs, visible from the initial grub menu seen at startup--info here....

Editing grub configurations is not for the faint of heart and can potentially brick your SBC's OS configuration; if you aren't into moderate to advanced Linux tweaking you should probably skip this option.

#--------------------------------------------------

Follow the "go to normal once" mode using e key, above.

Once in normal mode create some text we will need later.

grep -A 20 "menuentry '" /boot/grub/grub.cfg >>  grub.txt

Now use vi to open 2 files at once:

vi /boot/grub/grub.txt /etc/grub.d/40_custom

once in vi you see grub.txt....issue this command (no : needed)

20yy

this yanks the top 20 lines of the grub.txt file. 

in vi, issue escape then this command

:bn

this flips you to the 40_custom file, then type

p

this pastes the 20 lines from the bottom of the grub.txt file

...now edit the 40_custom file to match what I have boxed in red:


reboot.

If all went well you get a new option each time you reboot: choosing what is boxed below puts you into "normal" mode. Make OS changes; reboot, and pick "Debian GNU/Linux" to go back to overlayfs.



MORE ABOUT OVERLAYFS ON MY RPI SBC


SBC setup for overlayfs. Most important component: coffee.



As I said at the beginning of this post--getting overlayfs working on a current Raspberry Pi was a piece of cake.  or--should have been.

I hit a few bumps getting it working....because I will forget all of this in about 3 hours, here's more detail about what I did:

I used a RPi 4B purchased in 2018.  

Next I downloaded the latest "Trixie" text-only Raspberry PI OS and RPi imager. 

Getting wireless to work in text-only RPi Trixie was a giant time waster; raspi-config was throwing up errors that made no sense such as:

Could not communicate with wpa_supplicant

 entering the error messages into AI took me down rabbit holes that were equal nonsense.  
 
In the end, I didn't let the RPi imager create a custom configuration. Each time I did the Wi-Fi configuration on boot was broken and couldn't be easily fixed.

I ended up doing a standard install of PI OS and configured Wi-Fi settings on first boot via its wizard.

I had to type in the SSID exactly (doh); but, why did that not work at first?  

After some head scratching: what I called my home Wi-Fi SSID and what the RPi thought it was called were different.

The command in the "Trixie" version of RPI OS to list of available Wi-Fi SSID's was something I'd never seen before:

sudo nmcli dev wifi list  #yeh, I knew that.

There was a difference in case, RADISH vs. Radish. I entered Radish, then raspi-config worked OK; no bonkers error messages. 

With wireless working, I sudo raspi-config again, option 3, interfaces, and enabled SSH.

Cool, what IP to SSH into?

ip -a

This revealed the IP of the RPi. I could SSH into the host.

Next I followed the same steps for overlayfs on Raspberry Pi, stated at the top of this post, only 3 steps were needed.

sudo apt-get install overlayroot

then

sudo vi /boot/firmware/cmdline.txt  
# add to the end of the single line: overlayroot=tmpfs

sudo shutdown -r now  #reboot to overlayfs

To go back to normal RPI (not overlay): I pulled the SD card and edited /boot/firmware/cmdline.txt on my Windows 11 PC (Raspberry OS is formatted fat32, so yes, this worked), getting rid of overlayroot=tempfs then saving cmdline.txt.  

Finally, I made a copy of the single line in cmdline.txt, add the string above to one of them, and comment out the line I didn't want using a #. 

So, editing and saving cmdline.txt on my PC to what is immediately below meant a normal "Read/Write" boot:

sudo vi /boot/firmware/cmdline.txt
#sudo vi /boot/firmware/cmdline.txt overlayroot=tmpfs

While editing cmdline.txt on my PC to this meant an overlayfs "Read Only" boot.

#sudo vi /boot/firmware/cmdline.txt
sudo vi /boot/firmware/cmdline.txt overlayroot=tmpfs

One more tidbit: if I was in overlayfs mode and I tried to do a sudo apt upgrade the terminal would return "Killed".  It didn't tell me I was trying to write to a RO device, instead it just said "killed," but, whatever, and what do I want for free?

WHAT'S NEXT?


I need to get rid of the SBC's mandatory login, then conjure a DIY app for the junker RPi.  (update--this is covered in the next post--go here.)

RPi supports the peripherals we know and love--I2C, SPI, serial UART, all the usual stuff; perhaps what can be done with an Arduino Nano can be done here....I have this old sequencer idea (here) that perhaps I could continue to work on....  

Regardless: overlayfs is an important technology....instead of just entering the cheat code I dug in a bit further. Now I know more about a widely implemented and important technology. 

This knowledge will ultimately enrich my professional skills, my sense of confidence, and my piwece of mind.

There is a lesson here? Nope. Next time: just use the damn cheat code.




Canva Affinity and Kicad 9: Graphical Madness for DiWhy Projects

Many thanks Elton at Otter Mods for introducing me to Kicad and Affinity.

============

When I look at professionally created front panels, surrounding knobs I often see "goes to 11" gradients:

                             




(What are these called? Dials? Tick marks? Tap Mach II? I'm not sure, but, I wanted to add them to my front panels. 

For the post I'll call them "dials").

Using the built-in graphics tools in Kicad 9 didn't get me far, but found I could import graphics into Kicad as footprints, then add them to PCB's used as front panels on a silkscreen layer. 

OK, how?

There are two graphics import methods in Kicad I know of:  

First--easy: through Kicad's "Project Manager"--a tool you see when you first open the program--I could turn PNG graphics into bitmaps, using the Image Converter tool. 


Works, but, I found at times the output was grainy.  

What else?

Elton at Otter Mods, the resident Kicad expert of my geeky tech group, described a better way:

Use Affinity Designer to create SVG formatted files, then import the files into Kicad as footprints. Finally, place the graphic footprints on your front panels.

Today's post outlines a workflow to do this--this may be documented somewhere, but I couldn't find it, and ChatGPT was out to lunch.

CANVA AFFINITY 

First I needed to get Canva Affinity, Elton's choice for creating SVG graphics. 

Tough choice. Pay enormous bucks for an Adobe Creative Cloud subscription, complete with hidden fees, a confusing cloud presence, and terrible tech support? 

Nope. Good news: like Kicad 9, Canva Affinity is free




Or is it? I had to set up a Canva account, last I heard the friendly Canva folks aren't a 501C, rather someone has to buy lunchroom donuts and most important: keep the shareholders happy.  

Whatever....I set up a "free" Canva account, downloaded Affinity, and installed it on my W11 PC. 

So far, so good.

After a few hours of trying out the new Affinity: it felt like a true Adobe killer, combining features found in Illustrator, Photoshop, and InDesign, into one mega-graphics app. Affinity can do most everything Adobe, except grab you by your legs, flip you upside-down, shake the hell out of you, then pocket whatever change falls out.

For creating dials I focused on Affinity's VECTOR persona which closely matched features found in illustrator.  

It had an Export > SVG feature--so far, so good.

CREATING THE DIAL

Knowing squat about Affinity vector I watched this video which contained a lot of what I wanted to do. 

An aside: I found a lot of online documentation for Canva Affinity out of date; perhaps Affinity, like Microsoft, has no problem changing their UI early and often, like soiled chonies;  Internet documentation (and AI) is slower to catch up.  

....they moved around the furniture?
.
After an hour or so I ended up with this:




A few notes; I won't remember any of this 2 days from now.

  • I chose a 120mm x 120mm artboard and made sure the diameter of the outer circle had a 100mm diameter. How to set up artboards and canvases in Affinity is covered in this video--video is not for the UI current as of 12-1-25 but I could follow it. The 100mm diameter made the size conversions described below easier to figure out.

  • I used grids/guides. Good video for that (again, for an earlier version of Affinity Designer but the steps still seem current?) is here. However, there does not appear top be a way to save a custom grid configuration, unfortunate. Here are the settings used:


  • To make the tick marks, use Affinity's Vector "power repeat" feature; take a look at this  how-to video. I had to make sure to set the checkboxes and adjust rotate points as described in the video or I'd end up with an ugly mess. 
  • The repeat settings used for the big ticks:


  • I then created a smaller tick over the large one at 12 o'clock, dragged its rotation point to center (red line and green guide lines visible) and repeated with rotation of 7.5 and number of copies of 64. 

Couldn't find anywhere: how I created the gap between 7PM and 5PM at the bottom of the dial. 

By trial and error, here's how; most everything I found online appeared to be uselessly out of date: 
  • Use the Affinity Vector knife tool
  • If you zoom way in on the circle the knife tool becomes the scissor tool—yeh, I knew that.
  • Make the start and end cuts on the arcs, where we need to get rid of parts of the circle, by left clicking with the scissor tool
  • From the main menu: vector > separate curves 
  • Using the move tool (not node tool!) click between 5 and 7; 
  • The arc to be deleted is boxed with a faint blue rectangle.
  • Hit delete on your keyboard
  • Arc that was boxed is now gone
  • Use node tool to further clean up any ugliness.
Finally I exported the 100mm x 100mm dial using File > Export > Export > SVG > SVG (for export).

AND NOW--A WORD ABOUT THIS BLOG'S SPONSOR


As soon as you have your front panel design and gerber you will need your work fabricated. For this, you should use PCBWAY.

PCBWAY, the sponsor of this blog, has a service to fabricate PCBs and other materials using any color of the rainbow. That's full color PCB's folks! Details are here. I will be covering this super cool service in a future post.

In addition to top notch PCB fabrication they also do fantastic work with assembly, 3D printing, injection molding, and so much more. Their staff is super friendly and PCBWAY always turns the work around quickly. I am always impressed!

You can help this blog by checking out the PCBWAY site.  Thanks.

IMPORTING INTO KICAD


In Kicad 9 I used the footprint editor to create a new footprint library then a new footprint for the dial.  SVG's import: file > import > graphics.

.....this dialog:



This seemed straightforward until I discovered the "Import Scale" was linear but inaccurate. 

Meaning, if I set Import scale to .5 and imported a 100mm circle the resulting footprint was not a 50mm circle. 

Damn!

Not sure if this is documented anywhere--probably is, but, I couldn't it--I ended up crafting this formula which seemed to get the job done:

X = Z/(A*3.08)

Where:

  • X is the # to use for Kicad's import scale
  • Z is final size you want
  • A is the original size of SVG
Why 3.08?  No idea, I figured it might be PI, which got me close, but no, 3.08 yielded better results.  

For instance, for a 100mm diameter, and 25 mm output, I used this.

X = 25/(100*3.08)

X = .0811

BINGO.



For 100mm source, and 15mm target:

X = 15/(100 * 3.08) 

so x = .0487 to use for import scale, here’s what I got in Kicad. BUTTER!



I found that for smaller inputs (e.g., 50mm circle SVG instead of 100mm) the outputs drifted a bit, not sure why, but was still good enough. In general, I figured: why not stick with 100mm SVG source files. That's what I did here.

Summarizing--here are some common Input scale: values to use based on a 100mm SVG source. Target footprint size in millimeters is the column on the left.

8 0.025974026
9 0.029220779
10 0.032467532
11 0.035714286
12 0.038961039
13 0.042207792
14 0.045454545
15 0.048701299
16 0.051948052
17 0.055194805
18 0.058441558
19 0.061688312
20 0.064935065
21 0.068181818
22 0.071428571
23 0.074675325
24 0.077922078
25 0.081168831
26 0.084415584
27 0.087662338
28 0.090909091
29 0.094155844
30 0.097402597
31 0.100649351
32 0.103896104
33 0.107142857
34 0.11038961
35 0.113636364
36 0.116883117
37 0.12012987
38 0.123376623
39 0.126623377
40 0.12987013


Before wrapping up--one more thing. Affinity allowed me to jump between "Vector" ("Illustrator") and "Pixel" ("Photoshop") mode. Great, it meant that I had more cool editing tools at my disposal. But--I found that SVG's modified in Pixel mode often created a file that Kicad couldn't import. I found it best to do all the work in the Vector Persona.

WHAT'S NEXT?


This whole SVG > Kicad thing can be used for more than dials. Line art?  Yep. Logos? Sure. Any single color or BW 2D graphic import should work. 

I uploaded my footprints from this SVG > Kicad silk workflow into Github (here) and will try to keep the repo current.

For the full color materials, Affinity would be a great way to get into that. I'll cover this in a future post....

And of course I will add more dials and graphics to future projects using the methods above.  

Overall, many enjoyable hours down the rabbit hole for this one. More to come.