93f22f2a05c31d9af896c1daa8e2a2ee92112e63
[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
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                 FileIdler(v, '/etc/passwd'),
200                  GrayIdler(v,one="X",zero="O"),
201                 FileIdler(v, '/usr/share/common-licenses/GPL-2'),
202                  GrayIdler(v,one="*",zero="-",reorder=1),
203                 StringIdler(v, text=str(math.pi) + "            "),
204                 ClockIdler(v),
205                  GrayIdler(v,one="/",zero="\\",reorder=1),
206                 StringIdler(v, text=str(math.e) + "            "),
207                  GrayIdler(v,one="X",zero="O",reorder=1),
208                 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),
209                 PipeIdler(v, "/usr/bin/ypcat", "passwd"),
210                 FortuneIdler(v),
211                 ClockIdler(v),
212                 StringIdler(v),
213                 TrainIdler(v),
214                 ]
215     disabled = [
216                 ]
217         idler = choose_idler()
218
219 def choose_idler():
220         global idler
221         iiindex = 0
222
223         if idler:
224                 iiindex = idlers.index(idler)
225
226         iilen = len(idlers)
227
228         move = int(random()*len(idlers)) + 1
229
230         while move >= 0:
231                 idler = idlers[( (iiindex + 1) % iilen)]
232                 move = move - idler.affinity()
233
234         idler.reset()
235
236 def idle_step():
237         global idler
238         if idler.finished():
239                 choose_idler()
240         idler.next()
241
242 def run_forever(rfh, wfh, options, cf):
243         v = VendingMachine(rfh, wfh)
244         logging.debug('PING is ' + str(v.ping()))
245
246         if USE_DB: db = DispenseDatabase(v, cf.DBServer, cf.DBName, cf.DBUser, cf.DBPassword)
247         cur_user = ''
248         cur_pin = ''
249         cur_selection = ''
250
251         mk = MessageKeeper(v)
252         mk.set_message(GREETING)
253         time_to_autologout = None
254         setup_idlers(v)
255         time_to_idle = None
256         last_timeout_refresh = None
257
258         while True:
259                 if USE_DB:
260                         try:
261                                 db.handle_events()
262                         except DispenseDatabaseException, e:
263                                 logging.error('Database error: '+str(e))
264
265                 if time_to_autologout != None:
266                         time_left = time_to_autologout - time()
267                         if time_left < 6 and (last_timeout_refresh is None or last_timeout_refresh > time_left):
268                                 mk.set_message('LOGOUT: '+str(int(time_left)))
269                                 last_timeout_refresh = int(time_left)
270                                 cur_selection = ''
271
272                 if time_to_autologout != None and time_to_autologout - time() <= 0:
273                         time_to_autologout = None
274                         cur_user = ''
275                         cur_pin = ''
276                         cur_selection = ''
277                         mk.set_message(GREETING)
278
279                 if time_to_autologout and not mk.done(): time_to_autologout = None
280                 if cur_user == '' and time_to_autologout: time_to_autologout = None
281                 if len(cur_pin) == PIN_LENGTH and mk.done() and time_to_autologout == None:
282                         # start autologout
283                         time_to_autologout = time() + 15
284                         last_timeout_refresh = None
285
286                 if time_to_idle == None and cur_user == '':
287                         time_to_idle = time() + 5
288                         choose_idler()
289                 if time_to_idle is not None and cur_user != '': time_to_idle = None
290                 if time_to_idle is not None and time() > time_to_idle: idle_step()
291                 if time_to_idle is not None and time() > time_to_idle + 300:
292                         time_to_idle = time()
293                         choose_idler()
294
295                 mk.update_display()
296
297                 e = v.next_event(0)
298                 if e == None:
299                         e = v.next_event(0.05)
300                         if e == None:
301                                 continue
302                 time_to_idle = None
303                 (event, params) = e
304                 logging.debug('Got event: ' + repr(e))
305                 if event == DOOR:
306                         if params == 0:
307                                 door_open_mode(v);
308                                 cur_user = ''
309                                 cur_pin = ''
310                                 mk.set_message(GREETING)
311                 elif event == SWITCH:
312                         # don't care right now.
313                         pass
314                 elif event == KEY:
315                         key = params
316                         # complicated key handling here:
317                         if len(cur_user) < 5:
318                                 if key == 11:
319                                         cur_user = ''
320                                         mk.set_message(GREETING)
321                                         continue
322                                 cur_user += chr(key + ord('0'))
323                                 mk.set_message('UID: '+cur_user)
324                                 if len(cur_user) == 5:
325                                         uid = int(cur_user)
326                                         if not has_good_pin(uid):
327                                                 logging.info('user '+cur_user+' has a bad PIN')
328                                                 #mk.set_messages(
329                                                         #[(center('INVALID'), False, 0.7),
330                                                          #(center('PIN'), False, 0.7),
331                                                          #(center('SETUP'), False, 1.0),
332                                                          #(GREETING, False, None)])
333                                                 mk.set_messages(
334                                                         [(' '*10+'INVALID PIN SETUP'+' '*10, False, 3),
335                                                          (GREETING, False, None)])
336                                                 cur_user = ''
337                                                 cur_pin = ''
338                                                 continue
339                                         cur_pin = ''
340                                         mk.set_message('PIN: ')
341                                         logging.info('need pin for user %s'%cur_user)
342                                         continue
343                         elif len(cur_pin) < PIN_LENGTH:
344                                 if key == 11:
345                                         if cur_pin == '':
346                                                 cur_user = ''
347                                                 mk.set_message(GREETING)
348                                                 continue
349                                         cur_pin = ''
350                                         mk.set_message('PIN: ')
351                                         continue
352                                 cur_pin += chr(key + ord('0'))
353                                 mk.set_message('PIN: '+'X'*len(cur_pin))
354                                 if len(cur_pin) == PIN_LENGTH:
355                                         username = verify_user_pin(int(cur_user), int(cur_pin))
356                                         if username:
357                                                 v.beep(0, False)
358                                                 cur_selection = ''
359                                                 scroll_options(username, mk, True)
360                                                 continue
361                                         else:
362                                                 v.beep(40, False)
363                                                 mk.set_messages(
364                                                         [(center('BAD PIN'), False, 1.0),
365                                                          (center('SORRY'), False, 0.5),
366                                                          (GREETING, False, None)])
367                                                 cur_user = ''
368                                                 cur_pin = ''
369                                                 continue
370                         elif len(cur_selection) == 0:
371                                 if key == 11:
372                                         cur_pin = ''
373                                         cur_user = ''
374                                         cur_selection = ''
375                                         mk.set_messages(
376                                                 [(center('BYE!'), False, 1.5),
377                                                  (GREETING, False, None)])
378                                         continue
379                                 cur_selection += chr(key + ord('0'))
380                                 mk.set_message('SELECT: '+cur_selection)
381                                 time_to_autologout = None
382                         elif len(cur_selection) == 1:
383                                 if key == 11:
384                                         cur_selection = ''
385                                         time_to_autologout = None
386                                         scroll_options(username, mk)
387                                         continue
388                                 else:
389                                         cur_selection += chr(key + ord('0'))
390                                         #make_selection(cur_selection)
391                                         # XXX this should move somewhere else:
392                                         if cur_selection == '55':
393                                                 mk.set_message('OPENSESAME')
394                                                 logging.info('dispensing a door for %s'%username)
395                                                 if geteuid() == 0:
396                                                         ret = os.system('su - "%s" -c "dispense door"'%username)
397                                                 else:
398                                                         ret = os.system('dispense door')
399                                                 if ret == 0:
400                                                         logging.info('door opened')
401                                                         mk.set_message(center('DOOR OPEN'))
402                                                 else:
403                                                         logging.warning('user %s tried to dispense a bad door'%username)
404                                                         mk.set_message(center('BAD DOOR'))
405                                                 sleep(1)
406                                         elif cur_selection == '91':
407                                                 cookie(v)
408                                         elif cur_selection == '99':
409                                                 scroll_options(username, mk)
410                                                 cur_selection = ''
411                                                 continue
412                                         elif cur_selection[1] == '8':
413                                                 v.display('GOT COKE?')
414                                                 if ((os.system('su - "%s" -c "dispense %s"'%(username, cur_selection[0])) >> 8) != 0):
415                                                         v.display('SEEMS NOT')
416                                                 else:
417                                                         v.display('GOT COKE!')
418                                         else:
419                                                 v.display(cur_selection+' - $1.00')
420                                                 if ((os.system('su - "%s" -c "dispense snack"'%(username)) >> 8) == 0):
421                                                         v.vend(cur_selection)
422                                                         v.display('THANK YOU')
423                                                 else:
424                                                         v.display('NO MONEY?')
425                                         sleep(1)
426                                         cur_selection = ''
427                                         time_to_autologout = time() + 8
428                                         last_timeout_refresh = None
429
430 def connect_to_vend(options, cf):
431
432         if options.use_lat:
433                 logging.info('Connecting to vending machine using LAT')
434                 latclient = LATClient(service = cf.ServiceName, password = cf.ServicePassword, server_name = cf.ServerName, connect_password = cf.ConnectPassword, priv_password = cf.PrivPassword)
435                 rfh, wfh = latclient.get_fh()
436         elif options.use_serial:
437                 # Open vending machine via serial.
438                 logging.info('Connecting to vending machine using serial')
439                 serialclient = SerialClient(port = '/dev/ttyS1', baud = 9600)
440                 rfh,wfh = serialclient.get_fh()
441         else:
442                 #(rfh, wfh) = popen2('../../virtualvend/vvend.py')
443                 logging.info('Connecting to virtual vending machine on %s:%d'%(options.host,options.port))
444                 import socket
445                 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
446                 sock.connect((options.host, options.port))
447                 rfh = sock.makefile('r')
448                 wfh = sock.makefile('w')
449                 
450         return rfh, wfh
451
452 def parse_args():
453         from optparse import OptionParser
454
455         op = OptionParser(usage="%prog [OPTION]...")
456         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')
457         op.add_option('--serial', action='store_true', default=True, dest='use_serial', help='use the serial port')
458         op.add_option('--lat', action='store_true', default=False, dest='use_lat', help='use LAT')
459         op.add_option('--virtualvend', action='store_false', default=True, dest='use_serial', help='use the virtual vending server instead of LAT')
460         op.add_option('-n', '--hostname', dest='host', default='localhost', help='the hostname to connect to for virtual vending machine mode (default: localhost)')
461         op.add_option('-p', '--port', dest='port', default=5150, type='int', help='the port number to connect to (default: 5150)')
462         op.add_option('-l', '--log-file', metavar='FILE', dest='log_file', default='', help='log output to the specified file')
463         op.add_option('-s', '--syslog', dest='syslog', metavar='FACILITY', default=None, help='log output to given syslog facility')
464         op.add_option('-d', '--daemon', dest='daemon', action='store_true', default=False, help='run as a daemon')
465         op.add_option('-v', '--verbose', dest='verbose', action='store_true', default=False, help='spit out lots of debug output')
466         op.add_option('-q', '--quiet', dest='quiet', action='store_true', default=False, help='only report errors')
467         op.add_option('--pid-file', dest='pid_file', metavar='FILE', default='', help='store daemon\'s pid in the given file')
468         options, args = op.parse_args()
469
470         if len(args) != 0:
471                 op.error('extra command line arguments: ' + ' '.join(args))
472
473         return options
474
475 config_options = {
476         'DBServer': ('Database', 'Server'),
477         'DBName': ('Database', 'Name'),
478         'DBUser': ('VendingMachine', 'DBUser'),
479         'DBPassword': ('VendingMachine', 'DBPassword'),
480         
481         'ServiceName': ('VendingMachine', 'ServiceName'),
482         'ServicePassword': ('VendingMachine', 'Password'),
483         
484         'ServerName': ('DecServer', 'Name'),
485         'ConnectPassword': ('DecServer', 'ConnectPassword'),
486         'PrivPassword': ('DecServer', 'PrivPassword'),
487         }
488
489 class VendConfigFile:
490         def __init__(self, config_file, options):
491                 try:
492                         cp = ConfigParser.ConfigParser()
493                         cp.read(config_file)
494
495                         for option in options:
496                                 section, name = options[option]
497                                 value = cp.get(section, name)
498                                 self.__dict__[option] = value
499                 
500                 except ConfigParser.Error, e:
501                         raise SystemExit("Error reading config file "+config_file+": " + str(e))
502
503 def create_pid_file(name):
504         try:
505                 pid_file = file(name, 'w')
506                 pid_file.write('%d\n'%os.getpid())
507                 pid_file.close()
508         except IOError, e:
509                 logging.warning('unable to write to pid file '+name+': '+str(e))
510
511 def set_stuff_up():
512         def do_nothing(signum, stack):
513                 signal.signal(signum, do_nothing)
514         def stop_server(signum, stack): raise KeyboardInterrupt
515         signal.signal(signal.SIGHUP, do_nothing)
516         signal.signal(signal.SIGTERM, stop_server)
517         signal.signal(signal.SIGINT, stop_server)
518
519         options = parse_args()
520         config_opts = VendConfigFile(options.config_file, config_options)
521         if options.daemon: become_daemon()
522         set_up_logging(options)
523         if options.pid_file != '': create_pid_file(options.pid_file)
524
525         return options, config_opts
526
527 def clean_up_nicely(options, config_opts):
528         if options.pid_file != '':
529                 try:
530                         os.unlink(options.pid_file)
531                         logging.debug('Removed pid file '+options.pid_file)
532                 except OSError: pass  # if we can't delete it, meh
533
534 def set_up_logging(options):
535         logger = logging.getLogger()
536         
537         if not options.daemon:
538                 stderr_logger = logging.StreamHandler(sys.stderr)
539                 stderr_logger.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
540                 logger.addHandler(stderr_logger)
541         
542         if options.log_file != '':
543                 try:
544                         file_logger = logging.FileHandler(options.log_file)
545                         file_logger.setFormatter(logging.Formatter('%(asctime)s %(levelname)s: %(message)s'))
546                         logger.addHandler(file_logger)
547                 except IOError, e:
548                         logger.warning('unable to write to log file '+options.log_file+': '+str(e))
549
550         if options.syslog != None:
551                 sys_logger = logging.handlers.SysLogHandler('/dev/log', options.syslog)
552                 sys_logger.setFormatter(logging.Formatter('vendserver[%d]'%(os.getpid()) + ' %(levelname)s: %(message)s'))
553                 logger.addHandler(sys_logger)
554
555         if options.quiet:
556                 logger.setLevel(logging.WARNING)
557         elif options.verbose:
558                 logger.setLevel(logging.DEBUG)
559         else:
560                 logger.setLevel(logging.INFO)
561
562 def become_daemon():
563         dev_null = file('/dev/null')
564         fd = dev_null.fileno()
565         os.dup2(fd, 0)
566         os.dup2(fd, 1)
567         os.dup2(fd, 2)
568         try:
569                 if os.fork() != 0:
570                         sys.exit(0)
571                 os.setsid()
572         except OSError, e:
573                 raise SystemExit('failed to fork: '+str(e))
574
575 def do_vend_server(options, config_opts):
576         while True:
577                 try:
578                         rfh, wfh = connect_to_vend(options, config_opts)
579                 except (SerialClientException, socket.error), e:
580                         (exc_type, exc_value, exc_traceback) = sys.exc_info()
581                         del exc_traceback
582                         logging.error("Connection error: "+str(exc_type)+" "+str(e))
583                         logging.info("Trying again in 5 seconds.")
584                         sleep(5)
585                         continue
586                 
587                 try:
588                         run_forever(rfh, wfh, options, config_opts)
589                 except VendingException:
590                         logging.error("Connection died, trying again...")
591                         logging.info("Trying again in 5 seconds.")
592                         sleep(5)
593
594 if __name__ == '__main__':
595         options, config_opts = set_stuff_up()
596         while True:
597                 try:
598                         logging.warning('Starting Vend Server')
599                         do_vend_server(options, config_opts)
600                         logging.error('Vend Server finished unexpectedly, restarting')
601                 except KeyboardInterrupt:
602                         logging.info("Killed by signal, cleaning up")
603                         clean_up_nicely(options, config_opts)
604                         logging.warning("Vend Server stopped")
605                         break
606                 except SystemExit:
607                         break
608                 except:
609                         (exc_type, exc_value, exc_traceback) = sys.exc_info()
610                         tb = format_tb(exc_traceback, 20)
611                         del exc_traceback
612                         
613                         logging.critical("Uh-oh, unhandled " + str(exc_type) + " exception")
614                         logging.critical("Message: " + str(exc_value))
615                         logging.critical("Traceback:")
616                         for event in tb:
617                                 for line in event.split('\n'):
618                                         logging.critical('    '+line)
619                         logging.critical("This message should be considered a bug in the Vend Server.")
620                         logging.critical("Please report this to someone who can fix it.")
621                         sleep(10)
622                         logging.warning("Trying again anyway (might not help, but hey...)")
623

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