notes for later
[uccvend-vendserver.git] / sql-edition / servers / VendServer.py
1 #!/usr/bin/python
2 # vim:ts=4
3
4 USE_DB = 0
5
6 import ConfigParser
7 import sys, os, string, re, pwd, signal, math
8 import logging, logging.handlers
9 from traceback import format_tb
10 if USE_DB: import pg
11 from time import time, sleep
12 from popen2 import popen2
13 from LATClient import LATClient, LATClientException
14 from SerialClient import SerialClient, SerialClientException
15 from VendingMachine import VendingMachine, VendingException
16 from MessageKeeper import MessageKeeper
17 from HorizScroll import HorizScroll
18 from random import random, seed
19 from Idler import TrainIdler,GrayIdler,StringIdler,ClockIdler,FortuneIdler,FileIdler,PipeIdler
20 import socket
21 from posix import geteuid
22
23 CREDITS="""
24 This vending machine software brought to you by:
25 Bernard Blackham
26 Mark Tearle
27 Nick Bannon
28 Cameron Patrick
29 and a collective of hungry alpacas.
30
31
32
33 For a good time call +61 8 6488 3901
34
35
36
37 """
38
39 GREETING = 'UCC SNACKS'
40 PIN_LENGTH = 4
41
42 DOOR = 1
43 SWITCH = 2
44 KEY = 3
45
46 class DispenseDatabaseException(Exception): pass
47
48 class DispenseDatabase:
49         def __init__(self, vending_machine, host, name, user, password):
50                 self.vending_machine = vending_machine
51                 self.db = pg.DB(dbname = name, host = host, user = user, passwd = password)
52                 self.db.query('LISTEN vend_requests')
53
54         def process_requests(self):
55                 logging.debug('database processing')
56                 query = 'SELECT request_id, request_slot FROM vend_requests WHERE request_handled = false'
57                 try:
58                         outstanding = self.db.query(query).getresult()
59                 except (pg.error,), db_err:
60                         raise DispenseDatabaseException('Failed to query database: %s\n'%(db_err.strip()))
61                 for (id, slot) in outstanding:
62                         (worked, code, string) = self.vending_machine.vend(slot)
63                         logging.debug (str((worked, code, string)))
64                         if worked:
65                                 query = 'SELECT vend_success(%s)'%id
66                                 self.db.query(query).getresult()
67                         else:
68                                 query = 'SELECT vend_failed(%s)'%id
69                                 self.db.query(query).getresult()
70
71         def handle_events(self):
72                 notifier = self.db.getnotify()
73                 while notifier is not None:
74                         self.process_requests()
75                         notify = self.db.getnotify()
76
77 def scroll_options(username, mk, welcome = False):
78         if welcome:
79                 msg = [(center('WELCOME'), False, 0.8),
80                            (center(username), False, 0.8)]
81         else:
82                 msg = []
83         choices = ' '*10+'CHOICES: '
84         try:
85                 coke_machine = file('/home/other/coke/coke_contents')
86                 cokes = coke_machine.readlines()
87                 coke_machine.close()
88         except:
89                 cokes = []
90                 pass
91         for c in cokes:
92                 c = c.strip()
93                 (slot_num, price, slot_name) = c.split(' ', 2)
94                 if slot_name == 'dead': continue
95                 choices += '%s8-%s (%sc) '%(slot_num, slot_name, price)
96         choices += '55-DOOR '
97         choices += 'OR A SNACK. '
98         choices += '99 TO READ AGAIN. '
99         choices += 'CHOICE?   '
100         msg.append((choices, False, None))
101         mk.set_messages(msg)
102
103 def get_pin(uid):
104         try:
105                 info = pwd.getpwuid(uid)
106         except KeyError:
107                 logging.info('getting pin for uid %d: user not in password file'%uid)
108                 return None
109         if info.pw_dir == None: return False
110         pinfile = os.path.join(info.pw_dir, '.pin')
111         try:
112                 s = os.stat(pinfile)
113         except OSError:
114                 logging.info('getting pin for uid %d: .pin not found in home directory'%uid)
115                 return None
116         if s.st_mode & 077:
117                 logging.info('getting pin for uid %d: .pin has wrong permissions'%uid)
118                 return None
119         try:
120                 f = file(pinfile)
121         except IOError:
122                 logging.info('getting pin for uid %d: I cannot read pin file'%uid)
123                 return None
124         pinstr = f.readline()
125         f.close()
126         if not re.search('^'+'[0-9]'*PIN_LENGTH+'$', pinstr):
127                 logging.info('getting pin for uid %d: %s not a good pin'%(uid,repr(pinstr)))
128                 return None
129         return int(pinstr)
130
131 def has_good_pin(uid):
132         return get_pin(uid) != None
133
134 def verify_user_pin(uid, pin):
135         if get_pin(uid) == pin:
136                 info = pwd.getpwuid(uid)
137                 logging.info('accepted pin for uid %d (%s)'%(uid,info.pw_name))
138                 return info.pw_name
139         else:
140                 logging.info('refused pin for uid %d'%(uid))
141                 return None
142
143 def door_open_mode(v):
144         logging.warning("Entering open door mode")
145         v.display("-FEED  ME-")
146         while True:
147                 e = v.next_event()
148                 if e == None: break
149                 (event, params) = e
150                 if event == DOOR:
151                         if params == 1: # door closed
152                                 logging.warning('Leaving open door mode')
153                                 v.display("-YUM YUM!-")
154                                 sleep(1)
155                                 return
156
157 def cookie(v):
158         seed(time())
159         messages = ['  WASSUP! ', 'PINK FISH ', ' SECRETS ', '  ESKIMO  ', ' FORTUNES ', 'MORE MONEY']
160         choice = int(random()*len(messages))
161         msg = messages[choice]
162         left = range(len(msg))
163         for i in range(len(msg)):
164                 if msg[i] == ' ': left.remove(i)
165         reveal = 1
166         while left:
167                 s = ''
168                 for i in range(0, len(msg)):
169                         if i in left:
170                                 if reveal == 0:
171                                         left.remove(i)
172                                         s += msg[i]
173                                 else:
174                                         s += chr(int(random()*26)+ord('A'))
175                                 reveal += 1
176                                 reveal %= 17
177                         else:
178                                 s += msg[i]
179                 v.display(s)
180
181 def center(str):
182         LEN = 10
183         return ' '*((LEN-len(str))/2)+str
184
185
186
187 idlers = []
188 idler = None
189
190 def setup_idlers(v):
191         global idlers, idler
192         idlers = [
193                  GrayIdler(v),
194                 StringIdler(v, text="Kill 'em all", repeat=False),
195                  GrayIdler(v,one="*",zero="-"),
196                 StringIdler(v, text=CREDITS),
197                  GrayIdler(v,one="/",zero="\\"),
198                 ClockIdler(v),
199                  GrayIdler(v,one="X",zero="O"),
200                 FileIdler(v, '/usr/share/common-licenses/GPL-2'),
201                  GrayIdler(v,one="*",zero="-",reorder=1),
202                 StringIdler(v, text=str(math.pi) + "            "),
203                 ClockIdler(v),
204                  GrayIdler(v,one="/",zero="\\",reorder=1),
205                 StringIdler(v, text=str(math.e) + "            "),
206                  GrayIdler(v,one="X",zero="O",reorder=1),
207                 StringIdler(v, text="    I want some pizza - please call Pizza Hut Shenton Park on +61 8 9381 9979 - and order as Quinn - I am getting really hungry", repeat=False),
208                 PipeIdler(v, "/usr/bin/ypcat", "passwd"),
209                 FortuneIdler(v),
210                 ClockIdler(v),
211                 StringIdler(v),
212                 TrainIdler(v),
213                 ]
214     disabled = [
215                 ]
216         idler = choose_idler()
217
218 def choose_idler():
219         global idler
220         iiindex = 0
221
222         if idler:
223                 iiindex = idlers.index(idler)
224
225         iilen = len(idlers)
226
227         move = int(random()*len(idlers)) + 1
228
229         while move >= 0:
230                 idler = idlers[( (iiindex + 1) % iilen)]
231                 move = move - idler.affinity()
232
233         idler.reset()
234
235 def idle_step():
236         global idler
237         if idler.finished():
238                 choose_idler()
239         idler.next()
240
241 def run_forever(rfh, wfh, options, cf):
242         v = VendingMachine(rfh, wfh)
243         logging.debug('PING is ' + str(v.ping()))
244
245         if USE_DB: db = DispenseDatabase(v, cf.DBServer, cf.DBName, cf.DBUser, cf.DBPassword)
246         cur_user = ''
247         cur_pin = ''
248         cur_selection = ''
249
250         mk = MessageKeeper(v)
251         mk.set_message(GREETING)
252         time_to_autologout = None
253         setup_idlers(v)
254         time_to_idle = None
255         last_timeout_refresh = None
256
257
258         # This main loop is hideous and the work of the devil - mtearle
259         #
260         #
261         # notes for later surgery
262         #   (event, counter, ' ')
263         #        V
264         #   d[      ] = (method)
265         #
266         #  return state
267
268         while True:
269                 if USE_DB:
270                         try:
271                                 db.handle_events()
272                         except DispenseDatabaseException, e:
273                                 logging.error('Database error: '+str(e))
274
275                 if time_to_autologout != None:
276                         time_left = time_to_autologout - time()
277                         if time_left < 6 and (last_timeout_refresh is None or last_timeout_refresh > time_left):
278                                 mk.set_message('LOGOUT: '+str(int(time_left)))
279                                 last_timeout_refresh = int(time_left)
280                                 cur_selection = ''
281
282                 if time_to_autologout != None and time_to_autologout - time() <= 0:
283                         time_to_autologout = None
284                         cur_user = ''
285                         cur_pin = ''
286                         cur_selection = ''
287                         mk.set_message(GREETING)
288
289                 if time_to_autologout and not mk.done(): time_to_autologout = None
290                 if cur_user == '' and time_to_autologout: time_to_autologout = None
291                 if len(cur_pin) == PIN_LENGTH and mk.done() and time_to_autologout == None:
292                         # start autologout
293                         time_to_autologout = time() + 15
294                         last_timeout_refresh = None
295
296                 if time_to_idle == None and cur_user == '':
297                         time_to_idle = time() + 5
298                         choose_idler()
299                 if time_to_idle is not None and cur_user != '': time_to_idle = None
300                 if time_to_idle is not None and time() > time_to_idle: idle_step()
301                 if time_to_idle is not None and time() > time_to_idle + 300:
302                         time_to_idle = time()
303                         choose_idler()
304
305                 mk.update_display()
306
307                 e = v.next_event(0)
308                 if e == None:
309                         e = v.next_event(0.05)
310                         if e == None:
311                                 continue
312                 time_to_idle = None
313                 (event, params) = e
314                 logging.debug('Got event: ' + repr(e))
315                 if event == DOOR:
316                         if params == 0:
317                                 door_open_mode(v);
318                                 cur_user = ''
319                                 cur_pin = ''
320                                 mk.set_message(GREETING)
321                 elif event == SWITCH:
322                         # don't care right now.
323                         pass
324                 elif event == KEY:
325                         key = params
326                         # complicated key handling here:
327                         if len(cur_user) < 5:
328                                 if key == 11:
329                                         cur_user = ''
330                                         mk.set_message(GREETING)
331                                         continue
332                                 cur_user += chr(key + ord('0'))
333                                 mk.set_message('UID: '+cur_user)
334                                 if len(cur_user) == 5:
335                                         uid = int(cur_user)
336                                         if not has_good_pin(uid):
337                                                 logging.info('user '+cur_user+' has a bad PIN')
338                                                 #mk.set_messages(
339                                                         #[(center('INVALID'), False, 0.7),
340                                                          #(center('PIN'), False, 0.7),
341                                                          #(center('SETUP'), False, 1.0),
342                                                          #(GREETING, False, None)])
343                                                 mk.set_messages(
344                                                         [(' '*10+'INVALID PIN SETUP'+' '*10, False, 3),
345                                                          (GREETING, False, None)])
346                                                 cur_user = ''
347                                                 cur_pin = ''
348                                                 continue
349                                         cur_pin = ''
350                                         mk.set_message('PIN: ')
351                                         logging.info('need pin for user %s'%cur_user)
352                                         continue
353                         elif len(cur_pin) < PIN_LENGTH:
354                                 if key == 11:
355                                         if cur_pin == '':
356                                                 cur_user = ''
357                                                 mk.set_message(GREETING)
358                                                 continue
359                                         cur_pin = ''
360                                         mk.set_message('PIN: ')
361                                         continue
362                                 cur_pin += chr(key + ord('0'))
363                                 mk.set_message('PIN: '+'X'*len(cur_pin))
364                                 if len(cur_pin) == PIN_LENGTH:
365                                         username = verify_user_pin(int(cur_user), int(cur_pin))
366                                         if username:
367                                                 v.beep(0, False)
368                                                 cur_selection = ''
369                                                 scroll_options(username, mk, True)
370                                                 continue
371                                         else:
372                                                 v.beep(40, False)
373                                                 mk.set_messages(
374                                                         [(center('BAD PIN'), False, 1.0),
375                                                          (center('SORRY'), False, 0.5),
376                                                          (GREETING, False, None)])
377                                                 cur_user = ''
378                                                 cur_pin = ''
379                                                 continue
380                         elif len(cur_selection) == 0:
381                                 if key == 11:
382                                         cur_pin = ''
383                                         cur_user = ''
384                                         cur_selection = ''
385                                         mk.set_messages(
386                                                 [(center('BYE!'), False, 1.5),
387                                                  (GREETING, False, None)])
388                                         continue
389                                 cur_selection += chr(key + ord('0'))
390                                 mk.set_message('SELECT: '+cur_selection)
391                                 time_to_autologout = None
392                         elif len(cur_selection) == 1:
393                                 if key == 11:
394                                         cur_selection = ''
395                                         time_to_autologout = None
396                                         scroll_options(username, mk)
397                                         continue
398                                 else:
399                                         cur_selection += chr(key + ord('0'))
400                                         #make_selection(cur_selection)
401                                         # XXX this should move somewhere else:
402                                         if cur_selection == '55':
403                                                 mk.set_message('OPENSESAME')
404                                                 logging.info('dispensing a door for %s'%username)
405                                                 if geteuid() == 0:
406                                                         ret = os.system('su - "%s" -c "dispense door"'%username)
407                                                 else:
408                                                         ret = os.system('dispense door')
409                                                 if ret == 0:
410                                                         logging.info('door opened')
411                                                         mk.set_message(center('DOOR OPEN'))
412                                                 else:
413                                                         logging.warning('user %s tried to dispense a bad door'%username)
414                                                         mk.set_message(center('BAD DOOR'))
415                                                 sleep(1)
416                                         elif cur_selection == '91':
417                                                 cookie(v)
418                                         elif cur_selection == '99':
419                                                 scroll_options(username, mk)
420                                                 cur_selection = ''
421                                                 continue
422                                         elif cur_selection[1] == '8':
423                                                 v.display('GOT COKE?')
424                                                 if ((os.system('su - "%s" -c "dispense %s"'%(username, cur_selection[0])) >> 8) != 0):
425                                                         v.display('SEEMS NOT')
426                                                 else:
427                                                         v.display('GOT COKE!')
428                                         else:
429                                                 v.display(cur_selection+' - $1.00')
430                                                 if ((os.system('su - "%s" -c "dispense snack"'%(username)) >> 8) == 0):
431                                                         v.vend(cur_selection)
432                                                         v.display('THANK YOU')
433                                                 else:
434                                                         v.display('NO MONEY?')
435                                         sleep(1)
436                                         cur_selection = ''
437                                         time_to_autologout = time() + 8
438                                         last_timeout_refresh = None
439
440 def connect_to_vend(options, cf):
441
442         if options.use_lat:
443                 logging.info('Connecting to vending machine using LAT')
444                 latclient = LATClient(service = cf.ServiceName, password = cf.ServicePassword, server_name = cf.ServerName, connect_password = cf.ConnectPassword, priv_password = cf.PrivPassword)
445                 rfh, wfh = latclient.get_fh()
446         elif options.use_serial:
447                 # Open vending machine via serial.
448                 logging.info('Connecting to vending machine using serial')
449                 serialclient = SerialClient(port = '/dev/ttyS1', baud = 9600)
450                 rfh,wfh = serialclient.get_fh()
451         else:
452                 #(rfh, wfh) = popen2('../../virtualvend/vvend.py')
453                 logging.info('Connecting to virtual vending machine on %s:%d'%(options.host,options.port))
454                 import socket
455                 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
456                 sock.connect((options.host, options.port))
457                 rfh = sock.makefile('r')
458                 wfh = sock.makefile('w')
459                 
460         return rfh, wfh
461
462 def parse_args():
463         from optparse import OptionParser
464
465         op = OptionParser(usage="%prog [OPTION]...")
466         op.add_option('-f', '--config-file', default='/etc/dispense/servers.conf', metavar='FILE', dest='config_file', help='use the specified config file instead of /etc/dispense/servers.conf')
467         op.add_option('--serial', action='store_true', default=True, dest='use_serial', help='use the serial port')
468         op.add_option('--lat', action='store_true', default=False, dest='use_lat', help='use LAT')
469         op.add_option('--virtualvend', action='store_false', default=True, dest='use_serial', help='use the virtual vending server instead of LAT')
470         op.add_option('-n', '--hostname', dest='host', default='localhost', help='the hostname to connect to for virtual vending machine mode (default: localhost)')
471         op.add_option('-p', '--port', dest='port', default=5150, type='int', help='the port number to connect to (default: 5150)')
472         op.add_option('-l', '--log-file', metavar='FILE', dest='log_file', default='', help='log output to the specified file')
473         op.add_option('-s', '--syslog', dest='syslog', metavar='FACILITY', default=None, help='log output to given syslog facility')
474         op.add_option('-d', '--daemon', dest='daemon', action='store_true', default=False, help='run as a daemon')
475         op.add_option('-v', '--verbose', dest='verbose', action='store_true', default=False, help='spit out lots of debug output')
476         op.add_option('-q', '--quiet', dest='quiet', action='store_true', default=False, help='only report errors')
477         op.add_option('--pid-file', dest='pid_file', metavar='FILE', default='', help='store daemon\'s pid in the given file')
478         options, args = op.parse_args()
479
480         if len(args) != 0:
481                 op.error('extra command line arguments: ' + ' '.join(args))
482
483         return options
484
485 config_options = {
486         'DBServer': ('Database', 'Server'),
487         'DBName': ('Database', 'Name'),
488         'DBUser': ('VendingMachine', 'DBUser'),
489         'DBPassword': ('VendingMachine', 'DBPassword'),
490         
491         'ServiceName': ('VendingMachine', 'ServiceName'),
492         'ServicePassword': ('VendingMachine', 'Password'),
493         
494         'ServerName': ('DecServer', 'Name'),
495         'ConnectPassword': ('DecServer', 'ConnectPassword'),
496         'PrivPassword': ('DecServer', 'PrivPassword'),
497         }
498
499 class VendConfigFile:
500         def __init__(self, config_file, options):
501                 try:
502                         cp = ConfigParser.ConfigParser()
503                         cp.read(config_file)
504
505                         for option in options:
506                                 section, name = options[option]
507                                 value = cp.get(section, name)
508                                 self.__dict__[option] = value
509                 
510                 except ConfigParser.Error, e:
511                         raise SystemExit("Error reading config file "+config_file+": " + str(e))
512
513 def create_pid_file(name):
514         try:
515                 pid_file = file(name, 'w')
516                 pid_file.write('%d\n'%os.getpid())
517                 pid_file.close()
518         except IOError, e:
519                 logging.warning('unable to write to pid file '+name+': '+str(e))
520
521 def set_stuff_up():
522         def do_nothing(signum, stack):
523                 signal.signal(signum, do_nothing)
524         def stop_server(signum, stack): raise KeyboardInterrupt
525         signal.signal(signal.SIGHUP, do_nothing)
526         signal.signal(signal.SIGTERM, stop_server)
527         signal.signal(signal.SIGINT, stop_server)
528
529         options = parse_args()
530         config_opts = VendConfigFile(options.config_file, config_options)
531         if options.daemon: become_daemon()
532         set_up_logging(options)
533         if options.pid_file != '': create_pid_file(options.pid_file)
534
535         return options, config_opts
536
537 def clean_up_nicely(options, config_opts):
538         if options.pid_file != '':
539                 try:
540                         os.unlink(options.pid_file)
541                         logging.debug('Removed pid file '+options.pid_file)
542                 except OSError: pass  # if we can't delete it, meh
543
544 def set_up_logging(options):
545         logger = logging.getLogger()
546         
547         if not options.daemon:
548                 stderr_logger = logging.StreamHandler(sys.stderr)
549                 stderr_logger.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
550                 logger.addHandler(stderr_logger)
551         
552         if options.log_file != '':
553                 try:
554                         file_logger = logging.FileHandler(options.log_file)
555                         file_logger.setFormatter(logging.Formatter('%(asctime)s %(levelname)s: %(message)s'))
556                         logger.addHandler(file_logger)
557                 except IOError, e:
558                         logger.warning('unable to write to log file '+options.log_file+': '+str(e))
559
560         if options.syslog != None:
561                 sys_logger = logging.handlers.SysLogHandler('/dev/log', options.syslog)
562                 sys_logger.setFormatter(logging.Formatter('vendserver[%d]'%(os.getpid()) + ' %(levelname)s: %(message)s'))
563                 logger.addHandler(sys_logger)
564
565         if options.quiet:
566                 logger.setLevel(logging.WARNING)
567         elif options.verbose:
568                 logger.setLevel(logging.DEBUG)
569         else:
570                 logger.setLevel(logging.INFO)
571
572 def become_daemon():
573         dev_null = file('/dev/null')
574         fd = dev_null.fileno()
575         os.dup2(fd, 0)
576         os.dup2(fd, 1)
577         os.dup2(fd, 2)
578         try:
579                 if os.fork() != 0:
580                         sys.exit(0)
581                 os.setsid()
582         except OSError, e:
583                 raise SystemExit('failed to fork: '+str(e))
584
585 def do_vend_server(options, config_opts):
586         while True:
587                 try:
588                         rfh, wfh = connect_to_vend(options, config_opts)
589                 except (SerialClientException, socket.error), e:
590                         (exc_type, exc_value, exc_traceback) = sys.exc_info()
591                         del exc_traceback
592                         logging.error("Connection error: "+str(exc_type)+" "+str(e))
593                         logging.info("Trying again in 5 seconds.")
594                         sleep(5)
595                         continue
596                 
597                 try:
598                         run_forever(rfh, wfh, options, config_opts)
599                 except VendingException:
600                         logging.error("Connection died, trying again...")
601                         logging.info("Trying again in 5 seconds.")
602                         sleep(5)
603
604 if __name__ == '__main__':
605         options, config_opts = set_stuff_up()
606         while True:
607                 try:
608                         logging.warning('Starting Vend Server')
609                         do_vend_server(options, config_opts)
610                         logging.error('Vend Server finished unexpectedly, restarting')
611                 except KeyboardInterrupt:
612                         logging.info("Killed by signal, cleaning up")
613                         clean_up_nicely(options, config_opts)
614                         logging.warning("Vend Server stopped")
615                         break
616                 except SystemExit:
617                         break
618                 except:
619                         (exc_type, exc_value, exc_traceback) = sys.exc_info()
620                         tb = format_tb(exc_traceback, 20)
621                         del exc_traceback
622                         
623                         logging.critical("Uh-oh, unhandled " + str(exc_type) + " exception")
624                         logging.critical("Message: " + str(exc_value))
625                         logging.critical("Traceback:")
626                         for event in tb:
627                                 for line in event.split('\n'):
628                                         logging.critical('    '+line)
629                         logging.critical("This message should be considered a bug in the Vend Server.")
630                         logging.critical("Please report this to someone who can fix it.")
631                         sleep(10)
632                         logging.warning("Trying again anyway (might not help, but hey...)")
633

UCC git Repository :: git.ucc.asn.au