#!/usr/bin/env python3 # devtimer.py - Temperature Controlled Photographic Darkroom Timer # Targeted for RaspberryPi # Copyright (c) 2018 TundraWare Inc. # Permission Hereby Granted For Unrestricted Personal Or Commercial Use ##### # Imports ##### # General Imports import sys from threading import Thread from time import time, sleep # Hardware Support from tm1637 import * import wiringpi ##### # Constants ##### DEBUG = True # Debugging switch # GPIO Ports And Other Hardware Constants DEFAULT_BRIGHTNESS = 0x0a # Default LED brightness level TEMP_CLK = 12 # Display connections TEMP_DIO = 16 TIME_CLK = 21 TIME_DIO = 20 FOOTSW = 6 # GPIO port to read footswitch state # General Constants BEEP = 30 # Beep interval CALIBRATION_OFFSET = 0.003 # Compensate for program overhead in master loop MINUS = 16 # Index of minus segment table lookup TEMP_SENTINEL = 999 # Briefly appears at start, if it stays, temp measurement isn't working # Range of compensated timing TEMP_LOW=60 TEMP_HIGH=80 # Timing profiles REALTIME = 0 PAPER = 1 FILM = 2 ##### # Globals ##### # These get updated by the threads that read the switches and # thermocouple. On a slow machine like the Pi Zero, we want to avoid # unnecessary function calls, so we make these globally RW. # So, shoot me ... CURRENT_PROFILE = REALTIME CURRENT_TEMP = TEMP_SENTINEL # Operating globals DIM_BY=0 # How much are we currently dimming FARENHEIGHT=0 # Will be populated with segment pattern for "F" OUTOFRANGE = False # Flag for compensating profiles when temp is out of range RUNNING = False # Whether or not to run the timer ##### # Lookup Table For Compensating Factors ##### ''' There are 3 tables in the list below. In order: Realtime - never actually used, just there as a placeholder Paper Film Each contains entires for multiplicative corrections from 60F to 80F. The profile global above selects which of these tuples to index into - using the normalized temp global above as the index. We don't want to use a dictionary here (with profile as the key) because of the overhead that incurs. Straight tuple indexing should be much quicker. WARNING: It takes about 250ms to update the display on a Pi Zero. So, if the "virtual second" falls at or below this, the code will be attempting to do updates faster than the display can handle. So ... the total compensation cannot reduce the virtual second to less than about 0.300 to be on the safe side. ''' compensate = ( (1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000), (1.724, 1.611, 1.505, 1.406, 1.313, 1.227, 1.146, 1.070, 1.000, 0.934, 0.873, 0.815, 0.762, 0.711, 0.665, 0.621, 0.580, 0.542, 0.506, 0.473, 0.442), (1.445, 1.380, 1.318, 1.259, 1.202, 1.148, 1.096, 1.047, 1.000, 0.955, 0.912, 0.871, 0.832, 0.795, 0.759, 0.725, 0.692, 0.661, 0.631, 0.603, 0.576) ) ##### # Temperature Measurement Subroutines ##### ''' This is based on the widely available DS18B20 temperature probe. It is a 1-wire protocol device that returns temperature directly degrees C. The dataline must go on the Pi GPIO 4 (pin 7) which should be pulled up to VCC with a 4.7K resistor. You have to do several thingsto make this work: 1) Enable 1-wire support in the Pi: edit /boot/config.txt set: dtoverlay=w1-gpio reboot 2) Each 1-wire device is connected to the same pin (7) on the Pi. It distinguishes between them by a uniq address. You have to find that address *for your specific device*. You do this by looking at: ls -l /sys/bus/w1/devices You'll see the ID of your device there. That needs to be set in the line below for this code to work with your device. ''' DS18B20_ID = "28-0416840ac6ff" # Read the current temp returned by the probe def read_probe(): global CURRENT_TEMP probe = open("/sys/bus/w1/devices/%s/w1_slave" % DS18B20_ID) temp = float(probe.readlines()[-1].split()[-1].split("=")[-1])/1000 # Parse probe output temp = int(round((temp * 9/5) +32)) # Convert C-F and round into an integer # 1-wire interfaces (like the DS18B20 uses) can occasionally # return wildly wrong results. For this reason, we throw away # values that have changed more than 10 degrees since the last # reading, since that's almost certainly noise. # At startup we want to suppress this check because we have a # sentinel value set as default for temperature that will briefly # display an outrageous temperature. If it stays on, it means # the temperature measurement process isn't working. if CURRENT_TEMP == TEMP_SENTINEL: CURRENT_TEMP = temp elif abs(temp - CURRENT_TEMP) <= 10: CURRENT_TEMP = temp probe.close() # Update and store temperature periodically def monitor_temps(): global CURRENT_TEMP while True: update_temp = Thread(name="Update Temp", target=read_probe).start() Thread(name="Temp", target=show_temp, args=(temp_led, CURRENT_TEMP)).start() if DEBUG: sys.stdout.write("Temp: %sF\n" % CURRENT_TEMP) sleep(1) # Display current temperature # Negative temps are 2 digits, positive temps are 3 digits. # Suppress leading zeros. def show_temp(temp_led, temp): global OUTOFRANGE minus = hun = ten = one = 0 # Tells display to show nothing temp_led.brightness=DEFAULT_BRIGHTNESS - DIM_BY if temp < 0: # The leading digit is a minus sign minus = temp_led.digit_to_segment[MINUS] temp = abs(temp) elif temp > 99: # The 100s position is non-zero hun = temp // 100 hun = temp_led.digit_to_segment[hun] if temp > 9: # The 10s position is non-zero ten =(temp % 100) // 10 ten = temp_led.digit_to_segment[ten] one = temp_led.digit_to_segment[(temp % 100) % 10] # If temp is negative, display sign next to most significant digit if minus: if not ten: ten = minus else: hun = minus # Update the temperature LED temp_led.set_segments([hun, ten, one, FARENHEIGHT]) sleep(0.3) # If we're in a compensating profile and out of # temperature range, blink the time display if OUTOFRANGE: temp_led.brightness=0 temp_led.set_segments([hun, ten, one, FARENHEIGHT]) ##### # Utility Subroutines ##### # Beep at the user at fixed intervals def beep(): print("Beep!") # Check to see if footswitch got pressed def monitor_footsw(): global RUNNING while True: if not wiringpi.digitalRead(FOOTSW): # Button push pulls down sleep(.030) # Wait for debounce if not wiringpi.digitalRead(FOOTSW): RUNNING = not RUNNING sleep(.100) # Update the display with elapsed time def show_elapsed(time_led, elapsed): min = elapsed // 60 sec = elapsed % 60 d0 = time_led.digit_to_segment[min // 10] d1 = time_led.digit_to_segment[min % 10] d2 = time_led.digit_to_segment[sec // 10] d3 = time_led.digit_to_segment[sec % 10] time_led.set_segments([d0, 0x80 + d1, d2, d3]) ##### # Program entry point ##### ''' We start a perpetual thread to read the current temperature and adjust time accordingly Notice that the actual updating of the display gets run on its own thread as well. That's because - on a Pi Zero, at least - it takes over 250ms to do this. We don't want that time added to our timing loop, so we send it off on a parallel thread, and initiate timing for the next round in this thread. ''' if __name__ == "__main__": # Setup the hardware wiringpi.wiringPiSetupGpio() wiringpi.pinMode(FOOTSW, wiringpi.GPIO.INPUT) # Dim displays in film mode DIM_BY = 0 if CURRENT_PROFILE == FILM: DIM_BY = 2 time_led = TM1637(TIME_CLK, TIME_DIO, DEFAULT_BRIGHTNESS - DIM_BY) temp_led = TM1637(TEMP_CLK, TEMP_DIO, DEFAULT_BRIGHTNESS - DIM_BY) # Get segment pattern for "F" - only need to do this once, not on every update FARENHEIGHT = temp_led.digit_to_segment[0x0f] # Initialize the time display Thread(name="InitTimeDisplay", target=show_elapsed, args=[time_led, 0]).start() # Start measuring temperature Thread(name="Temperatures", target=monitor_temps).start() sleep(1) # Wait a bit for the 1st temp measurement to complete # Start monitoring for footswitch presses Thread(name="MonitorFootSW", target=monitor_footsw).start() # Start timing, using the selected profile and measured temperature elapsed_time = 0 compensation_factor = 1 while True: # Beep periodically if not elapsed_time % BEEP: beep() if DEBUG: last = time() print("Running State: %s" % RUNNING) # If we're not running, don't update elapsed time if not RUNNING: elapsed_time = 0 # Update the time display Thread(name="Timer", target=show_elapsed, args=(time_led, elapsed_time)).start() # For temperatures in-range, look up the compensating factor OUTOFRANGE = False if (CURRENT_PROFILE == REALTIME): compensation_factor = 1 # Realtime requires no compensation elif TEMP_LOW <= CURRENT_TEMP <= TEMP_HIGH: compensation_factor = compensate[CURRENT_PROFILE][CURRENT_TEMP-TEMP_LOW] # Temperature is out of range for our correction table # This implictly uses the last known compensation factor so we can keep running else: OUTOFRANGE = True sleep(compensation_factor - CALIBRATION_OFFSET) elapsed_time += 1 elapsed_time %= 6000 if DEBUG: print("Current Temp: %s Factor: %s Inter-update Time: %s" % (CURRENT_TEMP, compensation_factor, str(time()-last)))