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

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