b272ebaed8daf7fc3cc54784693cc8d23a19825d
[zanchey/dispense2.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                         logging.critical("Error reading config file "+config_file+": " + str(e))
455                         logging.critical("Bailing out")
456                         sys.exit(1)
457
458 def create_pid_file(name):
459         try:
460                 pid_file = file(name, 'w')
461                 pid_file.write('%d\n'%os.getpid())
462                 pid_file.close()
463         except IOError, e:
464                 logging.warning('unable to write to pid file '+name+': '+str(e))
465
466 def set_stuff_up():
467         def do_nothing(signum, stack):
468                 signal.signal(signum, do_nothing)
469         def stop_server(signum, stack): raise KeyboardInterrupt
470         signal.signal(signal.SIGHUP, do_nothing)
471         signal.signal(signal.SIGTERM, stop_server)
472         signal.signal(signal.SIGINT, stop_server)
473
474         options = parse_args()
475         set_up_logging(options)
476         config_opts = VendConfigFile(options.config_file, config_options)
477         if options.daemon: become_daemon()
478         if options.pid_file != '': create_pid_file(options.pid_file)
479
480         return options, config_opts
481
482 def clean_up_nicely(options, config_opts):
483         if options.pid_file != '':
484                 try:
485                         os.unlink(options.pid_file)
486                         logging.debug('Removed pid file '+options.pid_file)
487                 except OSError: pass  # if we can't delete it, meh
488
489 def set_up_logging(options):
490         logger = logging.getLogger()
491         
492         stderr_logger = logging.StreamHandler(sys.stderr)
493         stderr_logger.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
494         logger.addHandler(stderr_logger)
495         
496         if options.log_file != '':
497                 try:
498                         file_logger = logging.FileHandler(options.log_file)
499                         file_logger.setFormatter(logging.Formatter('%(asctime)s %(levelname)s: %(message)s'))
500                         logger.addHandler(file_logger)
501                 except IOError, e:
502                         logger.warning('unable to write to log file '+options.log_file+': '+str(e))
503
504         if options.syslog != None:
505                 sys_logger = logging.handlers.SysLogHandler('/dev/log', options.syslog)
506                 sys_logger.setFormatter(logging.Formatter('vendserver[%d]'%(os.getpid()) + ' %(levelname)s: %(message)s'))
507                 logger.addHandler(sys_logger)
508
509         if options.quiet:
510                 logger.setLevel(logging.WARNING)
511         elif options.verbose:
512                 logger.setLevel(logging.DEBUG)
513         else:
514                 logger.setLevel(logging.INFO)
515
516 def become_daemon():
517         dev_null = file('/dev/null')
518         fd = dev_null.fileno()
519         os.dup2(fd, 0)
520         os.dup2(fd, 1)
521         os.dup2(fd, 2)
522         if os.fork() != 0:
523                 sys.exit(0)
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:
557                         (exc_type, exc_value, exc_traceback) = sys.exc_info()
558                         tb = format_tb(exc_traceback, 20)
559                         del exc_traceback
560                         
561                         logging.critical("Uh-oh, unhandled " + str(exc_type) + " exception")
562                         logging.critical("Message: " + str(exc_value))
563                         logging.critical("Traceback:")
564                         for event in tb:
565                                 for line in event.split('\n'):
566                                         logging.critical('    '+line)
567                         logging.critical("This message should be considered a bug in the Vend Server.")
568                         logging.critical("Please report this to someone who can fix it.")
569                         sleep(10)
570                         logging.warning("Trying again anyway (might not help, but hey...)")
571

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