So I have finally got round to blogging about the Artwork I computerised with a Raspberry Pi in 2013; just three years late then. This is in small part due to my not having a blog to blog on and in large to me just not getting it done; no real excuse then as the not having a blog is very easily resolved.
The picture above is of the finished work by artist Jane Clatworthy. You can see some of her work on the Saatchi Art site here and also on Carbonmade here. The work was partly Lottery funded and commissioned by the Roses Theatre in Tewkesbury. As part of Gloucestershire’s Your Future project the work concentrates on experiences of domestic violence; though that is incidental to the use of the Raspberry Pi. Jane produced an interactive sculpture in the form of a traditional fairground machine with scales; weights are placed on one side or the other based on the result of spinning a wheel of fortune. When one side of the scale is down a recording of positive experiences was to be played; progressively louder as more weights were added. And when the other side of the scale is down negative experiences; again louder as more weights are added.
Jane’s issue was how to construct something that would achieve the playback and mechanical possibilities involving, for example, two mp3 or cassette players were being considered – but how to change the volume, how to only have positive or negative playing back, how to rewind the cassette, how to do the mechanical linkage etc.? This is when I got involved.
I suggested we experiment with the use of a Raspberry Pi as that should remove most of the mechanical and electro-mechanical issues; hopefully making it much simpler, much cheaper and more reliable to implement.
My initial thoughts were to have an arm connected to the pivot point of the scales holding a magnet that moved over an array of reed switches. That got prototyped but seemed clumsy and still left a mechanical linkage to contrive. Discussing this one lunchtime with my colleague Alaric he suggested using an ultrasound distance measurer.
Enter the HC-SR04
A quick look brought up the HC-SR04 and Matt’s excellent write up here. From his blog I took some code as an initial framework and started playing, wiring it as below:
Once working the decision was how to mount it inside the cabinet. We drilled holes in the cabinet below the scales, attached rods to the centre of the scale baskets which went into the cabinet and then on the end of the rods mounted horizontally a couple of round wooden discs. I’d originally though of having two HC-SR04s but soon realised that was OTT (and what if they ‘voted’ differently and both insisted the scaled were down on their side) and one on just one side would do fine.
So it looked like this (the dotted box is inside the cabinet):
The distance sensor on the left can tell (after calibration) whether the scales are balanced or if not, how far up or down they are. The code works out the measurement in CM but for the purposes of runtime the actual physical distance is irrelevant. What was needed was a measurement of the balance point, a hard coded window around that point and then the minimum and maximum the scales could be at.
Next was how to get the audio to work. For this I eventually settled on the pygame.mixer.
With this I could set the Pi playing both the two tracks (positive experience and negative experience) and simply change the volume of positive increasing as the distance increased above balanced or the volume of negative increasing as the distance decreased below balanced.
I did have to do some playing at the command line to get the volume up as it was initially far too quiet:
amixer cset numid=3 100,100 sudo alsactl store amixer cset numid=1 100% sudo alsactl store
“3 100,100” sets both channels to 100%, “1 100%” sets the output to headphones at 100%.
Tuning, feedback and calibration
Once the system was working I did various experimentation with smoothing the volume changes so they were not too harsh. In the end I used a fifo based on deque from collections and weighted the most recent readings more than the older.
Another addition to the software was for calibration. I realised that hard coding the distance readings (fully down, fully up and balanced) was probably going to lead to my being called in to re-calibrate at some time in the future, or more likely every time the cabinet was moved. To avoid this I added a button to put the Pi into calibration mode and some LEDs to walk through the calibration using ConfigParser to store and read the results. The LEDs I also used at runtime to indicate whether the system thought the scales were up, down or balanced. Calibration Instructions.
Assembly or “crude but functional”
So how to put it all together. It needed to be easy to install and remove as a single item so the Pi and electronics were all attached to a plank of wood. As it is a one off I decided not to go down the path of making a PCB or even using veroboard but to just wire it together using patch leads and terminal blocks. OK, so it’s not neat and tidy but it does have the advantage over PCB/vero that if I decide to tweak the wiring once in-situ it’s a lot easier. The whole thing could definitely be described as “crude but functional”.
Here is a pic of it all mounted ready to go into the cabinet. I also drilled a large hole underneath the Pi CPU for ventilation.
A tweak to the crontab to make the script run on boot:
@reboot python /home/pi/player4.py &
So everything seems fine and the piece is installed in the first exhibition space and then I get a call from Jane saying it has stopped working and the LEDs are not flashing any more. Grab the diagnostics file and reboot and all is OK. A quick look at the diagnostics log file and I realise that the code has got stuck waiting for the HC-SR04 to reply. I guess that what has happened is that the Pi, running Linux which is a non-deterministic OS, has missed the reply from the HC-SR04. Presumably the kernel was off doing something else. I recreated the issue and then looked for a solution eventually finding this great python watchdog timer module. After adding that in around the call to my measure() function no more system halts.
# Wrap the call to measure in case it doesn't trigger the HC-SR04 correctly try: with Watchdog(2): distance = measure() except Watchdog: distance = BALANCE_TOO_HIGH logger.warning('Watchdog timeout')
The code …
So finally the code is here, if any of it is any use to you feel free to take it in whole or in part and do what you wish :
#!/usr/bin/python # ----------------------- # Import required Python libraries # ----------------------- import time import RPi.GPIO as GPIO import pygame.mixer as pgm from collections import deque import ConfigParser from watchdog import Watchdog import logging # ----------------------- # Define some constants # ----------------------- config_file = "/home/pi/config.ini" c1_file = "/home/pi/PositivewithFX.wav" c2_file = "/home/pi/NegativewithFX.wav" # Define GPIO pins to use on Pi GPIO_TRIGGER = 23 GPIO_ECHO = 24 GPIO_LEDA = 22 GPIO_LEDB = 27 GPIO_LEDC = 25 GPIO_CALIBRATE = 4 BALANCE_MID = 10.6 BALANCE_LOW = 5.6 BALANCE_HIGH = 16.6 BALANCE_MARGIN = 0.5 BALANCE_MID_HIGH = BALANCE_MID + BALANCE_MARGIN BALANCE_MID_LOW = BALANCE_MID - BALANCE_MARGIN BALANCE_DOWN_DIST = BALANCE_MID - BALANCE_LOW BALANCE_UP_DIST = BALANCE_HIGH - BALANCE_MID BALANCE_TOO_HIGH = BALANCE_HIGH + 2.0 MASK_LEDA = 4 MASK_LEDB = 2 MASK_LEDC = 1 MASK_ALL = MASK_LEDA + MASK_LEDB + MASK_LEDC # ----------------------- # Define some functions # ----------------------- def measure(): # This function measures a distance GPIO.output(GPIO_TRIGGER, True) time.sleep(0.00001) GPIO.output(GPIO_TRIGGER, False) start = time.time() while GPIO.input(GPIO_ECHO)==0: pass start = time.time() while GPIO.input(GPIO_ECHO)==1: pass stop = time.time() elapsed = stop-start distance = (elapsed * 34300)/2 return distance def measure_average(): # This function takes 3 measurements and # returns the average. distance1=measure() time.sleep(0.1) distance2=measure() time.sleep(0.1) distance3=measure() distance = distance1 + distance2 + distance3 distance = distance / 3 return distance def readconfig(): global BALANCE_LOW global BALANCE_MID global BALANCE_HIGH global BALANCE_MID_HIGH global BALANCE_MID_LOW global BALANCE_DOWN_DIST global BALANCE_UP_DIST global BALANCE_TOO_HIGH confreader = ConfigParser.SafeConfigParser() confreader.read(config_file) BALANCE_LOW = confreader.getfloat('settings', 'min') BALANCE_MID = confreader.getfloat('settings', 'balance') BALANCE_HIGH = confreader.getfloat('settings', 'max') BALANCE_MID_HIGH = BALANCE_MID + BALANCE_MARGIN BALANCE_MID_LOW = BALANCE_MID - BALANCE_MARGIN BALANCE_DOWN_DIST = BALANCE_MID - BALANCE_LOW BALANCE_UP_DIST = BALANCE_HIGH - BALANCE_MID BALANCE_TOO_HIGH = BALANCE_HIGH + 2.0 msg = 'Read calibration file: LOW[%0.3f] MID[%0.3f] HIGH[%0.3f]' % (BALANCE_LOW, BALANCE_MID, BALANCE_HIGH) logger.info(msg) msg = ' calculated values: MID_HIGH[%0.3f] MID_LOW[%0.3f] DOWN_DIST[%0.3f] UP_DIST[%0.3f] TOO_HIGH[%0.3f]' % (BALANCE_MID_HIGH, BALANCE_MID_LOW, BALANCE_DOWN_DIST, BALANCE_UP_DIST, BALANCE_TOO_HIGH) logger.info(msg) return def writeconfig(): confwriter = ConfigParser.SafeConfigParser() confwriter.add_section('settings') confwriter.set('settings', 'min', str(BALANCE_LOW)) confwriter.set('settings', 'balance', str(BALANCE_MID)) confwriter.set('settings', 'max', str(BALANCE_HIGH)) #for section in confwriter.sections(): # print section # for name, value in confwriter.items(section): # print ' %s = %r' % (name, value) with open(config_file, 'wb') as configfile: confwriter.write(configfile) configfile.close() return def tf(val, mask): return (val & MASK_ALL & mask) != 0 def setleds(l): GPIO.output(GPIO_LEDA, tf(l, MASK_LEDA)) GPIO.output(GPIO_LEDB, tf(l, MASK_LEDB)) GPIO.output(GPIO_LEDC, tf(l, MASK_LEDC)) def calibrate(): global BALANCE_LOW global BALANCE_MID global BALANCE_HIGH logger.info("Calibration button detected") start = time.time() stop = start while GPIO.input(GPIO_CALIBRATE)==1: stop = time.time() elapsed = stop-start #print "Button pressed for %ds" % elapsed if elapsed <= 2: logger.warning("Calibration button not held for >2s, not calibrating") else: logger.info("Begin calibration") #centre LED steady, left & right flash leds = MASK_ALL while GPIO.input(GPIO_CALIBRATE)==0: leds = ~ leds leds = leds | MASK_LEDB setleds(leds) time.sleep(0.1) setleds(MASK_LEDB) while GPIO.input(GPIO_CALIBRATE)==1: time.sleep(0.1) # take 20 readings and average setleds(MASK_LEDB) total = 0.0 for num in range(1,20): total += measure() time.sleep(0.2) BALANCE_MID = total / 20 #left LED steady, centre and right flash leds = MASK_ALL while GPIO.input(GPIO_CALIBRATE)==0: leds = ~ leds leds = leds | MASK_LEDC setleds(leds) time.sleep(0.1) setleds(MASK_LEDC) while GPIO.input(GPIO_CALIBRATE)==1: time.sleep(0.1) # take 20 readings and average setleds(MASK_LEDC) total = 0.0 for num in range(1,20): total += measure() time.sleep(0.2) BALANCE_HIGH = total / 20 #right LED steady, left and centre flash leds = MASK_ALL while GPIO.input(GPIO_CALIBRATE)==0: leds = ~ leds leds = leds | MASK_LEDA setleds(leds) time.sleep(0.1) setleds(MASK_LEDA) while GPIO.input(GPIO_CALIBRATE)==1: time.sleep(0.1) # take 20 readings and average setleds(MASK_LEDA) total = 0.0 for num in range(1,20): total += measure() time.sleep(0.2) BALANCE_LOW = total / 20 if BALANCE_LOW > BALANCE_HIGH: BALANCE_LOW,BALANCE_HIGH = BALANCE_HIGH,BALANCE_LOW msg = "Calibration result = LOW[%.3f] MID[%.3f] HIGH[%.3f]" % (BALANCE_LOW, BALANCE_MID, BALANCE_HIGH) logger.info(msg) if (BALANCE_LOW < BALANCE_MID) and (BALANCE_MID < BALANCE_HIGH): writeconfig() setleds(MASK_ALL) time.sleep(5.0) return # ----------------------- # Main Script # ----------------------- logger = logging.getLogger('myapp') hdlr = logging.FileHandler('/home/pi/player.log') formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s') hdlr.setFormatter(formatter) logger.addHandler(hdlr) logger.setLevel(logging.INFO) pgm.init() pgm.set_num_channels(2) c1_sound = pgm.Sound(c1_file) c2_sound = pgm.Sound(c2_file) c1_channel = c1_sound.play(-1) c2_channel = c2_sound.play(-1) c1_sound.set_volume(0.0) c2_sound.set_volume(0.0) c1_volume = 0.0 c2_volume = 0.0 # Use BCM GPIO references # instead of physical pin numbers GPIO.setmode(GPIO.BCM) # Set pins as output and input GPIO.setup(GPIO_TRIGGER,GPIO.OUT) # Trigger GPIO.setup(GPIO_ECHO,GPIO.IN) # Echo GPIO.setup(GPIO_LEDA,GPIO.OUT) # LED GPIO.setup(GPIO_LEDB,GPIO.OUT) GPIO.setup(GPIO_LEDC,GPIO.OUT) GPIO.setup(GPIO_CALIBRATE,GPIO.IN) # Calibrate button # Set trigger to False (Low) GPIO.output(GPIO_TRIGGER, False) # Set LEDs to off GPIO.output(GPIO_LEDA, False) GPIO.output(GPIO_LEDB, False) GPIO.output(GPIO_LEDC, False) #writeconfig() #used only when no config file at all! readconfig() #distance_hist = [BALANCE_MID,BALANCE_MID,BALANCE_MID,BALANCE_MID,BALANCE_MID] distance_hist = [BALANCE_MID,BALANCE_MID,BALANCE_MID] dh = deque(distance_hist) # Wrap main content in a try block so we can # catch the user pressing CTRL-C and run the # GPIO cleanup function. This will also prevent # the user seeing lots of unnecessary error # messages. try: LEDMAP = MASK_ALL while True: if GPIO.input(GPIO_CALIBRATE)==1: c1_sound.set_volume(0.0) c2_sound.set_volume(0.0) calibrate() readconfig() # Wrap the call to measure in case it doesn't trigger the HC-SR04 correctly try: with Watchdog(2): distance = measure() except Watchdog: distance = BALANCE_TOO_HIGH logger.warning('Watchdog timeout') #print "watchdog" #distance = measure_average() #print distance # Check if the distance is wildly high - if so reject completely (means it's timed out or mis-read) if distance < BALANCE_TOO_HIGH: if distance < BALANCE_LOW: distance = BALANCE_LOW if distance > BALANCE_HIGH: distance = BALANCE_HIGH dh.pop() dh.appendleft(distance) #print dh #avgdist = (dh + dh + dh + dh + dh) / 5 #avgdist = ((dh * 5) + (dh * 4) + (dh * 3) + (dh * 2) + dh) / 15 avgdist = ((dh * 3) + (dh * 2) + (dh * 1)) / 6 #print avgdist avgdist = distance if ((avgdist >= BALANCE_MID_LOW) and (avgdist <= BALANCE_MID_HIGH)): # Balanced so silent c1_volume = 0.0 c2_volume = 0.0 elif avgdist < BALANCE_MID: # Down so c1 c2_volume = 0.0 distdown = BALANCE_MID - avgdist c1_volume = distdown / BALANCE_DOWN_DIST else: # Up so c2 c1_volume = 0.0 distup = avgdist - BALANCE_MID c2_volume = distup / BALANCE_UP_DIST #print "Volume c1:%.3f c2:%.3f" % (c1_volume, c2_volume) c1_sound.set_volume(c1_volume) c2_sound.set_volume(c2_volume) # Flash the LEDs except for the up/down/balanced LED which should be static on LEDMAP = ~ LEDMAP if c1_volume == c2_volume: LEDMAP = LEDMAP | MASK_LEDB elif c1_volume > 0.0: LEDMAP = LEDMAP | MASK_LEDA elif c2_volume > 0.0: LEDMAP = LEDMAP | MASK_LEDC setleds(LEDMAP) #else: # msg = "Distance over limit [%.3f]" % (distance) # logger.warning(msg) time.sleep(0.25) except KeyboardInterrupt: # User pressed CTRL-C c1_sound.stop() c2_sound.stop() # Reset GPIO settings GPIO.cleanup()