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

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