diff --git a/machine_controller/app.py b/machine_controller/app.py index 1460f5e..11d6919 100644 --- a/machine_controller/app.py +++ b/machine_controller/app.py @@ -39,7 +39,7 @@ token_value = token_value[:-1] app = Flask(__name__) -merch = Merch() +merch = Merch.Instance() @app.route('/vend', methods=['POST']) def hello_world(): diff --git a/machine_controller/mockgpio.py b/machine_controller/mockgpio.py new file mode 100644 index 0000000..a55bb8a --- /dev/null +++ b/machine_controller/mockgpio.py @@ -0,0 +1,161 @@ +import copy + + + +class MockGPIO: + # From https://github.com/TCAllen07/raspi-device-mocks + # Map format is : + bcm_board_map = { 2: 3, + 3: 5, 4: 7, 14: 8, 15: 10, 17: 11, + 18: 12, 27: 13, 22: 15, 23: 16, 24: 18, + 10: 19, 9: 21, 25: 22, 11: 23, 8: 24, + 7: 26, 5: 29, 6: 31, 12: 32, 13: 33, + 19: 35, 16: 36, 26: 37, 20: 38, 21: 40} + + # Map format is : + gpio_board_map = { 3: 2, + 5: 3, 7: 4, 8: 14, 10: 15, 11: 17, + 12: 18, 13: 27, 15: 22, 16: 23, 18: 24, + 19: 10, 21: 9, 22: 25, 23: 11, 24: 8, + 26: 7, 29: 5, 31: 6, 32: 12, 33: 13, + 35: 19, 36: 16, 37: 26, 38: 20, 40: 21} + + + + LOW = 0 + HIGH = 1 + + BCM = 11 + BOARD = 10 + + OUT = 0 + IN = 1 + + PUD_OFF = 20 + PUD_DOWN = 21 + PUD_UP = 22 + + # Indexed by board pin number + gpio_direction = {k: 1 for k in bcm_board_map.values()} + gpio_values = {} + + def __init__(self): + self.mode = -1 + + self.setmode_run = False + self.setup_run = False + + self.states = [] + + + + def setmode(self, mode): + if mode not in (self.BCM, self.BOARD): + raise ValueError("An invalid mode was passed to setmode()") + self.mode = mode + self.setmode_run = True + + def getmode(self): + return self.mode + + + def __pin_validate(self, pin): + if self.mode == self.BCM: + if pin not in self.bcm_board_map.keys(): + raise ValueError('Pin is invalid') + elif self.mode == self.BOARD: + if pin not in self.gpio_board_map.keys(): + raise ValueError('Pin is invalid') + else: + raise ValueError('Setup has not been called yet') + + + + + def output(self, pins, value): + if not hasattr(pins, '__iter__'): + pins = [pins, ] + for pin in pins: + self.__pin_validate(pin) + + if value not in (self.HIGH, self.LOW): + raise ValueError('An invalid value was passed to output()') + + if not self.setmode_run: + raise RuntimeError('output() was called before setmode()') + if not self.setup_run: + raise RuntimeError('output() was called before setup()') + + for pin in pins: + self.gpio_values[pin] = value + self.states.append(copy.deepcopy(self.gpio_values)) + + + def input(self, pins): + if not hasattr(pins, '__iter__'): + pins = [pins, ] + for pin in pins: + self.__pin_validate(pin) + + if not self.setmode_run: + raise RuntimeError('input() was called before setmode()') + if not self.setup_run: + raise RuntimeError('input() was called before setup()') + + def gpio_function(self, pin): + self.__pin_validate(pin) + if not self.setmode_run: + raise RuntimeError('gpio_function() was called before setmode()') + if self.mode == self.BCM: + return self.gpio_direction[self.bcm_board_map[pin]] + else: + return self.gpio_direction[pin] + + def cleanup(self): + self.setup_run = False + self.setmode_run = False + self.mode = -1 + + for pin in self.gpio_direction: + self.gpio_direction[pin] = self.IN + + def setup(self, pins, direction, pull_up_down=None, initial=None): + if not hasattr(pins, '__iter__'): + pins = [pins, ] + + for pin in pins: + self.__pin_validate(pin) + + if direction not in (self.IN, self.OUT): + raise ValueError('An invalid direction was passed to setup()') + if (pull_up_down is not None and + pull_up_down not in (self.PUD_OFF, self.PUD_DOWN, self.PUD_UP)): + raise ValueError('Invalid Pull Up Down setting passed to setup()') + self.setup_run = True + if self.mode == self.BCM: + self.gpio_direction[self.bcm_board_map[pin]] = direction + else: + self.gpio_direction[pin] = direction + + + # Placeholders + def add_event_callback(self, *args): + pass + + def add_event_detect(self, *args): + pass + + def setwarnings(self, *args): + pass + + def wait_for_edge(self, *args): + pass + + def event_detected(self, *args): + pass + + def remove_event_detect(self, *args): + pass + + +GPIO = MockGPIO() diff --git a/machine_controller/singleton.py b/machine_controller/singleton.py new file mode 100644 index 0000000..c0ded98 --- /dev/null +++ b/machine_controller/singleton.py @@ -0,0 +1,38 @@ +# From http://stackoverflow.com/questions/31875/is-there-a-simple-elegant-way-to-define-singletons +class Singleton: + """ + A non-thread-safe helper class to ease implementing singletons. + This should be used as a decorator -- not a metaclass -- to the + class that should be a singleton. + + The decorated class can define one `__init__` function that + takes only the `self` argument. Also, the decorated class cannot be + inherited from. Other than that, there are no restrictions that apply + to the decorated class. + + To get the singleton instance, use the `Instance` method. Trying + to use `__call__` will result in a `TypeError` being raised. + + """ + + def __init__(self, decorated): + self._decorated = decorated + + def Instance(self): + """ + Returns the singleton instance. Upon its first call, it creates a + new instance of the decorated class and calls its `__init__` method. + On all subsequent calls, the already created instance is returned. + + """ + try: + return self._instance + except AttributeError: + self._instance = self._decorated() + return self._instance + + def __call__(self): + raise TypeError('Singletons must be accessed through `Instance()`.') + + def __instancecheck__(self, inst): + return isinstance(inst, self._decorated) diff --git a/machine_controller/task_queue.py b/machine_controller/task_queue.py new file mode 100644 index 0000000..08a64b2 --- /dev/null +++ b/machine_controller/task_queue.py @@ -0,0 +1,137 @@ +# University of Illinois/NCSA Open Source License +# +# Copyright (c) 2017 ACM@UIUC +# All rights reserved. +# +# Developed by: SIGBot +# ACM@UIUC +# https://acm.illinois.edu +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the 'Software'), to deal +# with the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimers. +# +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimers in the +# documentation and/or other materials provided with the distribution. +# +# * Neither the names of the SIGBot, ACM@UIUC, nor the names of its +# contributors may be used to endorse or promote products derived from +# this Software without specific prior written permission. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH +# THE SOFTWARE. + + +from threading import Thread, Condition, Lock + + +class TaskQueue(Thread): + '''Task Queue that runs in a separate "thread"''' + class Promise(): + '''An object that can be waited on''' + def __init__(self): + self.condition = Condition() + self.wait_done = False + + def wait(self): + '''Wait for the work to be done''' + with self.condition: + while not self.wait_done: + self.condition.wait() + + def notify(self): + '''Wake waiters''' + with self.condition: + self.wait_done = True + self.condition.notifyAll() + + class Work(): + '''Represents a piece of work to be done''' + def __init__(self, func): + self.func = func + self.promise = TaskQueue.Promise() + + def __call__(self): + self.run() + + def run(self): + self.func() + self.promise.notify() + + def __init__(self): + super(TaskQueue, self).__init__() + self.work_queue = [] + # Condition variable to protect the work queue + # In the threading library, this acts as both a lock and a condition + # variable + self.work_condition = Condition() + + self.shutdown_lock = Lock() + self.shutdown_ = False + def __del__(self): + self.shutdown() + + def run(self): + '''Start doing work in a separate thread''' + + self.shutdown_lock.acquire() + while not self.shutdown_: + self.shutdown_lock.release() + + work = None + # Make sure to handle the work queue with the lock + with self.work_condition: + while len(self.work_queue) == 0: + self.shutdown_lock.acquire() + if self.shutdown_: + self.shutdown_lock.release() + return + self.shutdown_lock.release() + # Wait for values to be available + self.work_condition.wait() + + # I just recently found out that this is an atomic operation... + work = self.work_queue.pop(0) + + if work: + # Do the work. Arguments should be bound to the function object + work() + + # Reacquire the lock before we check its value in the loop + self.shutdown_lock.acquire() + self.shutdown_lock.release() + + def add_work(self, func): + '''Add work to the queue + + Arguments: + work -- a function to be called by the work queue. If the function to + be called has arguments, use partial application + (`from functools import partial`) + ''' + with self.work_condition: + work = TaskQueue.Work(func) + self.work_queue.append(work) + + # We're notifying all waiters, but there should only be one + self.work_condition.notifyAll() + return work.promise + + def shutdown(self): + '''Shut down the work queue''' + with self.shutdown_lock: + self.shutdown_ = True + with self.work_condition: + self.work_condition.notifyAll() diff --git a/machine_controller/test_vend.py b/machine_controller/test_vend.py new file mode 100644 index 0000000..eba4e80 --- /dev/null +++ b/machine_controller/test_vend.py @@ -0,0 +1,130 @@ +# University of Illinois/NCSA Open Source License +# +# Copyright (c) 2017 ACM@UIUC +# All rights reserved. +# +# Developed by: SIGBot +# ACM@UIUC +# https://acm.illinois.edu +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# with the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimers. +# +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimers in the +# documentation and/or other materials provided with the distribution. +# +# * Neither the names of the SIGBot, ACM@UIUC, nor the names of its +# contributors may be used to endorse or promote products derived from +# this Software without specific prior written permission. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH +# THE SOFTWARE. +import unittest + +from vend import Merch + + +keys_map = { + 0b000: {0b11: 'A', 0b01: '1', 0b10: '2'}, + 0b001: {0b11: 'B', 0b01: '3', 0b10: '4'}, + 0b010: {0b11: 'C', 0b01: '5', 0b10: '6'}, + 0b011: {0b11: 'D', 0b01: '7', 0b10: '8'}, + 0b100: {0b11: 'E', 0b01: '9', 0b10: '0'}, + 0b101: {0b11: 'F', 0b01: '*', 0b10: 'CLR'}, +} + +def stateToKey(state): + '''Convert a state to a key press''' + + row_binary = ((0b100 * state[Merch.Instance().ROW[0]]) + + (0b010 * state[Merch.Instance().ROW[1]]) + + (0b001 * state[Merch.Instance().ROW[2]])) + col_binary = ((0b01 * state[Merch.Instance().COL[1]]) + + (0b10 * state[Merch.Instance().COL[0]])) + + if not state[Merch.Instance().GATE]: + return None + + try: + key = keys_map[row_binary][col_binary] + except: + key = None + + return key + +def zeroed(state): + for pin in Merch.Instance().ROW + Merch.Instance().COL: + if state[pin] != 0: + return False + return True + + + +class MerchTestCase(unittest.TestCase): + def setUp(self): + self.merch = Merch.Instance() + self.merch.testing = True + + def tearDown(self): + self.app.merch.queue.shutdown() + + def test_vend(self): + self.merch.vend('F', 4) + self.merch.wait() + + new_states = [] + for state in self.merch.GPIO.states: + if state[self.merch.GATE]: + new_states.append(state) + + # Validate vend sequence + # 1. Zero everything + self.assertTrue(zeroed(new_states[0])) + + # 2. Press 'F' + self.assertEqual(stateToKey(new_states[1]), 'F') + + # 3. Press '4' + self.assertEqual(stateToKey(new_states[2]), '4') + + def test_vend_bad_char(self): + with self.assertRaises(ValueError): + self.merch.vend(chr(ord('a') - 1), 0) + + with self.assertRaises(ValueError): + self.merch.vend(chr(ord(Merch.Instance().MAX_LETTER) + 1), 0) + + with self.assertRaises(ValueError): + self.merch.vend(chr(ord('A') - 1), 0) + + with self.assertRaises(TypeError): + self.merch.vend('50', 0) + + with self.assertRaises(TypeError): + self.merch.vend(50, 0) + + def test_vend_bad_digit(self): + with self.assertRaises(ValueError): + self.merch.vend('A', -1) + + with self.assertRaises(ValueError): + self.merch.vend('A', 'this is not an integer') + + with self.assertRaises(ValueError): + self.merch.vend('A', Merch.Instance().MAX_NUMBER + 1) + +if __name__ == '__main__': + unittest.main() diff --git a/machine_controller/vend.py b/machine_controller/vend.py index bf2fb18..0fa6c25 100644 --- a/machine_controller/vend.py +++ b/machine_controller/vend.py @@ -32,72 +32,92 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH # THE SOFTWARE. -import RPi.GPIO as GPIO +try: + import RPi.GPIO as rpi_GPIO +except (ImportError, RuntimeError): + from mockgpio import GPIO as rpi_GPIO + import time +from functools import partial +import threading + +from task_queue import TaskQueue + +from singleton import Singleton +@Singleton class Merch: '''Merch Hardware Controller''' - CLOCK = 26 + GATE = 26 ROW = [21, 20, 16] COL = [19, 13] MAX_LETTER = 'F' - MAX_NUMBER = '0' + MAX_NUMBER = 10 - def __init__(self, debug=False): - self.debug = debug + debug = False + testing = False + + def __init__(self, GPIO=rpi_GPIO): + self.GPIO = GPIO + self.promises = [] self.__setup() self.__low() self.__commit() + self.queue = TaskQueue() + self.queue.start() + def __del__(self): self.__cleanup() + self.queue.shutdown() + self.queue.join() def __cleanup(self): ''' Clean up all of the GPIO pins ''' - GPIO.cleanup() + self.GPIO.cleanup() def __setup(self): ''' Setup all of the GPIO pins ''' - GPIO.setmode(GPIO.BCM) + self.GPIO.setmode(self.GPIO.BCM) - GPIO.setup(self.CLOCK, GPIO.OUT, initial=GPIO.LOW) - for row in self.ROW: - GPIO.setup(row, GPIO.OUT, initial=GPIO.LOW) - for col in self.COL: - GPIO.setup(col, GPIO.OUT, initial=GPIO.LOW) + self.GPIO.setup([self.GATE] + self.ROW + self.COL, self.GPIO.OUT, + initial=self.GPIO.LOW) def __low(self): ''' Writes all outputs to low. Does not commit them ''' - GPIO.output(self.CLOCK, GPIO.LOW) + self.GPIO.output([self.GATE] + self.ROW + self.COL, self.GPIO.LOW) - for row in self.ROW: - GPIO.output(row, GPIO.LOW) - for col in self.COL: - GPIO.output(col, GPIO.LOW) def __commit(self): ''' Clocks the flip flop that gates the output ''' - GPIO.output(self.CLOCK, GPIO.HIGH) - time.sleep(0.5) - GPIO.output(self.CLOCK, GPIO.LOW) + self.GPIO.output(self.GATE, self.GPIO.HIGH) + if not self.testing: + time.sleep(0.5) + self.GPIO.output(self.GATE, self.GPIO.LOW) self.__low() + def wait(self): + '''Wait for all vends to complete''' + for promise in self.promises: + promise.wait() + # Wrap the base _vend function, which doesn't check arguments def vend(self, letter, number): ''' Presses the keypad with letter, number''' - char = 0 try: char = ord(letter) except TypeError: raise TypeError('Letter %s does not represent a character' % str(letter)) + char = ord(letter.upper()) + # Maybe we should use the actual keypad value? - if char < ord('A') or char > ord('Z'): + if char < ord('A') or char > ord(self.MAX_LETTER): raise ValueError('Invalid Letter: %s' % str(letter)) num = 0 @@ -110,7 +130,11 @@ def vend(self, letter, number): if num < 0 or num > 10: raise ValueError('Number %d is not in the range 1-10' % num) - self.__vend(letter, str(number)) + func = partial(self.__vend, letter, num) + + # Non blocking add to work queue + promise = self.queue.add_work(func) + self.promises.append(promise) def __vend(self, letter, number): ''' Base vending function that handles GPIO's @@ -147,6 +171,7 @@ def __sendKey(self, key): # 101 01 * # 101 10 CLR + keys = { 'A': (0b000, 0b011), 'B': (0b001, 0b011), @@ -186,5 +211,3 @@ def __sendKey(self, key): print('Vending', letter_key[0], letter_key[1]) self.__commit() - -