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

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