Newer
Older
devtimer / devtimer.py
#!/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

BEEPER = 26                 # Piezo alarm device
FOOTSW = 6                  # GPIO port to read footswitch state

PROFILE_SW_FILM = 23        # Profile selector switch pins
PROFILE_SW_PAPER = 24

# General Constants

BEEP_INTERVAL = 30          # Beep interval
CALIBRATION_OFFSET = 0.003  # Compensate for program overhead in master loop
DEBOUNCE_TIME = 1.5         # In seconds
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 have to symblolically link that to:

         /opt/devtimer/temp_probe
'''

# Read the current temp returned by the probe

def read_probe():

    global CURRENT_TEMP

    probe = open("/opt/devtimer/temp_probe/w1_slave")
    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

    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 for specified count/length

def beep(count, delay):

    for repeat in range(count):
        wiringpi.digitalWrite(BEEPER, 1)
        sleep(delay)
        wiringpi.digitalWrite(BEEPER, 0)
        sleep(delay)

    if DEBUG:
        print("Beep!")


# Callback when footswitch is pressed

def footsw_pressed():
    global RUNNING

    # Mask interrupts during debounce window

    if time() - footsw_pressed.lastISR >= DEBOUNCE_TIME:

        beep(3, 0.1)  # Let user know we're starting/stopping

        # If we are about to start running, determine selected profile

        if not RUNNING:
            read_profile_switch()

        # Reflect changed state and current time

        RUNNING = not RUNNING
        footsw_pressed.lastISR = time()

    if DEBUG:
        print("Running State: %s" % RUNNING)


# Check to see if footswitch got pressed

def footsw_monitor():

    # We store last interrupt service time as a callback global for debounce

    footsw_pressed.lastISR = 0

    # Setup the callback

    wiringpi.wiringPiISR(FOOTSW, wiringpi.GPIO.INT_EDGE_FALLING, footsw_pressed)

    # Run the thread forever waiting for footswitch presses

    while True:
        sleep(10000)


# Read the profile selection switch.
# A pin pulled down means that profile has been selected.
# If neither the film or paper pin is pulled down it means
# we want realtime.

def read_profile_switch():

    global CURRENT_PROFILE, DIM_BY, temp_led, time_led

    if not wiringpi.digitalRead(PROFILE_SW_FILM):
        CURRENT_PROFILE = FILM

    elif not wiringpi.digitalRead(PROFILE_SW_PAPER):
        CURRENT_PROFILE = PAPER

    else:
        CURRENT_PROFILE = REALTIME

    # Dim displays in film mode

    DIM_BY = 0
    if CURRENT_PROFILE == FILM:
        DIM_BY = 2

    temp_led.brightness=DEFAULT_BRIGHTNESS - DIM_BY
    time_led.brightness=DEFAULT_BRIGHTNESS - DIM_BY


    if DEBUG:
        print("Selected Profile: %s" % CURRENT_PROFILE)


# 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(BEEPER, wiringpi.GPIO.OUTPUT)
    wiringpi.pinMode(BEEPER, wiringpi.GPIO.PUD_DOWN)
    wiringpi.pinMode(FOOTSW, wiringpi.GPIO.INPUT)
    wiringpi.pinMode(FOOTSW, wiringpi.GPIO.PUD_UP)
    wiringpi.pinMode(PROFILE_SW_FILM, wiringpi.GPIO.PUD_UP)
    wiringpi.pinMode(PROFILE_SW_PAPER, wiringpi.GPIO.PUD_UP)

    time_led = TM1637(TIME_CLK, TIME_DIO, DEFAULT_BRIGHTNESS - DIM_BY)
    temp_led = TM1637(TEMP_CLK, TEMP_DIO, DEFAULT_BRIGHTNESS - DIM_BY)

    # Get initial profile in case we need to dim

    read_profile_switch()

    # 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=footsw_monitor).start()

    # Start timing, using the selected profile and measured temperature

    elapsed_time = 0
    compensation_factor = 1

    while True:

        # Beep periodically

        if RUNNING and not elapsed_time % BEEP_INTERVAL:
            Thread(name="Beep", target=beep, args=[1, 0.8]).start()

        if DEBUG:
            last = time()

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