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

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