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 ("ADC's"), 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.



AI Catfish: I uploaded my date site photos to GeminiNB3; the guy in the cartoon is supposed to be me. I am way older and not as good looking.  



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 this setup produce decent AC?  Here's an example that used a  simple example 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 1K and faster speeds, the output became really blocky and unusable....


The performance was held back by the overhead imposed by Python as well as speed limits of I2C.  Even increasing the I2C speed to 400K (how to do that on a Raspberry Pi is described here) didn't help much.  

Did fast-mode help?  Not really, but the code below, to do 4x triangles each 90 degrees out of phase, "worked" but was too blocky.  Some sort of RC filter might make it usable in certain applications, but in general an SPI DAC might have proven more useful.

 






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.")
        
        
        
        


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 junkbox.



8591 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 each example):

 




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)
    
    
        



8591 as DAC:


The 8591 had a single channel 8 bit DAC; i wrote some code to make it 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 packages to Linux....

sudo apt-get update

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

Then added luma.oled using uv--I used luma 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...")
    

        



....with I2C working it was time to move onto SPI--much faster--but would Python still hold things back?

SPI--MCP3002 ADC


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

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:



NOW 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 "vibe coding" worked great.

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

Still not sold. See ya next time.



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 ).   ...