#!/usr/bin/env python3
# devtimer.py - Temperature Controlled Photographic Darkroom Timer
# Targeted for RaspberryPi
# Copyright (c) 2018-2025 TundraWare Inc.
# Permission Hereby Granted For Unrestricted Personal Or Commercial Use
# Code Repo Here: https://gitbucket.tundraware.com/tundra/devtimer
#####
# Imports
#####
# General Imports
import sys
from threading import Thread
from time import time, sleep
# Hardware Support
from tm1637 import *
import wiringpi
#####
# Constants
#####
DEBUG = False # Debugging switch
# GPIO Ports And Other Hardware Constants
# Default is to always run the LEDs as dim as possible.
DEFAULT_BRIGHTNESS = 0x08 # Default LED brightness level
DIM_BY = 0 # Amount to dim when running film profile
# Use these setting if you only want to dim LEDs for film processing
# DEFAULT_BRIGHTNESS = 0x0a # Default LED brightness level
# DIM_BY = 2 # Amount to dim when running film profile
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
# Error Codes
PROBEERR = 998 # Indicates probe read error
SENTINEL = 999 # Initial temp display
# Range of compensated timing
TEMP_LOW=55
TEMP_HIGH=85
# Timing profiles
REALTIME = 0
PAPER = 1
FILM = 2
#####
# 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 55F to 85F.
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 = (
(),
(2.424, 2.264, 2.115, 1.976, 1.846, 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, 0.413, 0.385, 0.360, 0.336, 0.314),
(1.818, 1.737, 1.659, 1.584, 1.513, 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, 0.550, 0.525, 0.502, 0.479, 0.457),
)
#####
# Temperature Measurement Subroutines
#####
'''
This is based on the widely available DS18B20 temperature probe.
It is a 1-wire protocol device that returns temperature directly
in 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 things to 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 unique
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 symbolically link that to:
/opt/devtimer/temp_probe
'''
# Read current temp from probe and display it
def monitor_temps():
while True:
try:
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
probe.close()
except:
temp = PROBEERR
# 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 20 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 monitor_temps.CURRENT_TEMP == monitor_temps.SENTINEL:
monitor_temps.CURRENT_TEMP = temp
elif abs(temp - monitor_temps.CURRENT_TEMP) <= 20 or temp == PROBERR:
monitor_temps.CURRENT_TEMP = temp
Thread(name="Temp", target=show_temp, args=[show_temp.LED, monitor_temps.CURRENT_TEMP]).start()
sleep(1.5)
#####
# Profile Switch Handling
#####
# 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 monitor_profile_sw():
while True:
if not wiringpi.digitalRead(PROFILE_SW_FILM):
monitor_profile_sw.CURRENT_PROFILE = FILM
elif not wiringpi.digitalRead(PROFILE_SW_PAPER):
monitor_profile_sw.CURRENT_PROFILE = PAPER
else:
monitor_profile_sw.CURRENT_PROFILE = REALTIME
# Dim displays in film mode
monitor_profile_sw.DIM_BY = 0
if monitor_profile_sw.CURRENT_PROFILE == FILM:
monitor_profile_sw.DIM_BY = DIM_BY
show_temp.LED.brightness=DEFAULT_BRIGHTNESS - monitor_profile_sw.DIM_BY
show_time.LED.brightness=DEFAULT_BRIGHTNESS - monitor_profile_sw.DIM_BY
sleep(1)
#####
# Footswitch Handling
#####
# Callback when footswitch is pressed
def footsw_pressed():
# Mask interrupts during debounce window
if time() - footsw_pressed.lastISR >= DEBOUNCE_TIME:
beep(3, 0.1) # Let user know we're starting/stopping
# Reflect changed state and current time
run_timer.RUNNING = not run_timer.RUNNING
footsw_pressed.lastISR = time()
# 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)
#####
# 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)
# Update the display with elapsed time
def show_time(led, elapsed):
min = elapsed // 60
sec = elapsed % 60
d0 = led.digit_to_segment[min // 10]
d1 = led.digit_to_segment[min % 10]
d2 = led.digit_to_segment[sec // 10]
d3 = led.digit_to_segment[sec % 10]
led.set_segments([d0, 0x80 + d1, d2, d3])
# Display current temperature
# Negative temps are 2 digits, positive temps are 3 digits.
# Suppress leading zeros.
def show_temp(led, temp):
minus = hun = ten = one = 0 # Tells display to show nothing
if temp < 0: # The leading digit is a minus sign
minus = led.digit_to_segment[MINUS]
temp = abs(temp)
elif temp > 99: # The 100s position is non-zero
hun = temp // 100
hun = led.digit_to_segment[hun]
if temp > 9: # The 10s position is non-zero
ten =(temp % 100) // 10
ten = led.digit_to_segment[ten]
one = 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
led.set_segments([hun, ten, one, show_temp.farenheight])
sleep(0.3)
# If we're in a compensating profile and out of
# temperature range, blink the temp display
if show_temp.OUTOFRANGE:
led.brightness=0
led.set_segments([hun, ten, one, show_temp.farenheight])
beep(5, 0.03)
#####
# Core Timer Logic
#####
def run_timer():
# Start timing, using the selected profile and measured temperature
elapsed_time = 0
compensation_factor = 1
last_update = time()
while True:
# Beep periodically
if run_timer.RUNNING and not elapsed_time % BEEP_INTERVAL:
Thread(name="Beep", target=beep, args=[1, 0.30]).start()
# If we're not running, don't update elapsed time
if not run_timer.RUNNING:
elapsed_time = 0
# Update the time display
Thread(name="Timer", target=show_time, args=(show_time.LED, elapsed_time)).start()
# For temperatures in-range, look up the compensating factor
show_temp.OUTOFRANGE = False
if (monitor_profile_sw.CURRENT_PROFILE == REALTIME):
compensation_factor = 1 # Realtime requires no compensation
elif TEMP_LOW <= monitor_temps.CURRENT_TEMP <= TEMP_HIGH:
compensation_factor = compensate[monitor_profile_sw.CURRENT_PROFILE][monitor_temps.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:
show_temp.OUTOFRANGE = True
if DEBUG:
print("Running: %s Elapsed: %s Current Temp: %s Profile: %s Factor: %s Inter-update Time: %s" % \
(run_timer.RUNNING,
elapsed_time,
monitor_temps.CURRENT_TEMP,
monitor_profile_sw.CURRENT_PROFILE,
compensation_factor,
str(time() - last_update)
)
)
last_update = time()
sleep(compensation_factor - CALIBRATION_OFFSET)
elapsed_time += 1
elapsed_time %= 6000
#####
# Program entry point
#####
'''
Prime the hardware and setup perpetual threads to monitor
switches, read temperature, and display virtual time.
Notice that the actual updating of the displays gets run on their own
threads. That's because - on a Pi Zero, at least - it takes
over 250ms for a display update. We don't want that time added to our timing
loop. So, we run timing, monitoring, and display updates all as separately
threaded activities.
'''
if __name__ == "__main__":
# Setup the hardware connected to GPIO
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)
# Initialize objects to control timing and temp LEDs
monitor_profile_sw.DIM_BY=0 # How much are we currently dimming
show_time.LED = TM1637(TIME_CLK, TIME_DIO, DEFAULT_BRIGHTNESS - monitor_profile_sw.DIM_BY)
show_temp.LED = TM1637(TEMP_CLK, TEMP_DIO, DEFAULT_BRIGHTNESS - monitor_profile_sw.DIM_BY)
# Issue audible start alert
beep(8, 0.02)
# Start monitoring the profile selection switch.
# This allows the user to changes profiles during timer runs.
monitor_profile_sw.CURRENT_PROFILE = REALTIME
Thread(name="MonitorProfileSW", target=monitor_profile_sw).start()
# Get segment pattern for "F" - only need to do this once, not on every update
show_temp.farenheight = show_temp.LED.digit_to_segment[0x0f]
# Initialize the time display
Thread(name="InitTimeDisplay", target=show_time, args=[show_time.LED, 0]).start()
# Show the initial temp sentinel
show_temp.OUTOFRANGE = False # Flag for compensating profiles when temp is out of range
monitor_temps.SENTINEL = monitor_temps.CURRENT_TEMP = SENTINEL
show_temp(show_temp.LED, monitor_temps.CURRENT_TEMP)
# 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 the timer thread
run_timer.RUNNING = False # Whether or not to run the timer
Thread(name="RunTimer", target=run_timer).start()