e17de1ce0c43e16d0a3fd3e84ee00a9280d38ab4
[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, math
8 import logging, logging.handlers
9 from traceback import format_tb
10 if USE_DB: import pg
11 from time import time, sleep
12 from popen2 import popen2
13 from LATClient import LATClient, LATClientException
14 from VendingMachine import VendingMachine, VendingException
15 from MessageKeeper import MessageKeeper
16 from HorizScroll import HorizScroll
17 from random import random, seed
18 from Idler import TrainIdler,GrayIdler,StringIdler,ClockIdler,FortuneIdler,FileIdler
19 import socket
20 from posix import geteuid
21
22 CREDITS="""
23 This vending machine software brought to you by:
24 Bernard Blackham
25 Mark Tearle
26 Nick Bannon
27 Cameron Patrick
28 and a collective of hungry alpacas.
29
30
31
32 For a good time call +61 8 6488 3901
33
34
35
36 """
37
38 GREETING = 'UCC SNACKS'
39 PIN_LENGTH = 4
40
41 DOOR = 1
42 SWITCH = 2
43 KEY = 3
44
45 class DispenseDatabaseException(Exception): pass
46
47 class DispenseDatabase:
48         def __init__(self, vending_machine, host, name, user, password):
49                 self.vending_machine = vending_machine
50                 self.db = pg.DB(dbname = name, host = host, user = user, passwd = password)
51                 self.db.query('LISTEN vend_requests')
52
53         def process_requests(self):
54                 logging.debug('database processing')
55                 query = 'SELECT request_id, request_slot FROM vend_requests WHERE request_handled = false'
56                 try:
57                         outstanding = self.db.query(query).getresult()
58                 except (pg.error,), db_err:
59                         raise DispenseDatabaseException('Failed to query database: %s\n'%(db_err.strip()))
60                 for (id, slot) in outstanding:
61                         (worked, code, string) = self.vending_machine.vend(slot)
62                         logging.debug (str((worked, code, string)))
63                         if worked:
64                                 query = 'SELECT vend_success(%s)'%id
65                                 self.db.query(query).getresult()
66                         else:
67                                 query = 'SELECT vend_failed(%s)'%id
68                                 self.db.query(query).getresult()
69
70         def handle_events(self):
71                 notifier = self.db.getnotify()
72                 while notifier is not None:
73                         self.process_requests()
74                         notify = self.db.getnotify()
75
76 def scroll_options(username, mk, welcome = False):
77         if welcome:
78                 msg = [(center('WELCOME'), False, 0.8),
79                            (center(username), False, 0.8)]
80         else:
81                 msg = []
82         choices = ' '*10+'CHOICES: '
83         try:
84                 coke_machine = file('/home/other/coke/coke_contents')
85                 cokes = coke_machine.readlines()
86                 coke_machine.close()
87         except:
88                 cokes = []
89                 pass
90         for c in cokes:
91                 c = c.strip()
92                 (slot_num, price, slot_name) = c.split(' ', 2)
93                 if slot_name == 'dead': continue
94                 choices += '%s8-%s (%sc) '%(slot_num, slot_name, price)
95         choices += '55-DOOR '
96         choices += 'OR A SNACK. '
97         choices += '99 TO READ AGAIN. '
98         choices += 'CHOICE?   '
99         msg.append((choices, False, None))
100         mk.set_messages(msg)
101
102 def get_pin(uid):
103         try:
104                 info = pwd.getpwuid(uid)
105         except KeyError:
106                 logging.info('getting pin for uid %d: user not in password file'%uid)
107                 return None
108         if info.pw_dir == None: return False
109         pinfile = os.path.join(info.pw_dir, '.pin')
110         try:
111                 s = os.stat(pinfile)
112         except OSError:
113                 logging.info('getting pin for uid %d: .pin not found in home directory'%uid)
114                 return None
115         if s.st_mode & 077:
116                 logging.info('getting pin for uid %d: .pin has wrong permissions'%uid)
117                 return None
118         try:
119                 f = file(pinfile)
120         except IOError:
121                 logging.info('getting pin for uid %d: I cannot read pin file'%uid)
122                 return None
123         pinstr = f.readline()
124         f.close()
125         if not re.search('^'+'[0-9]'*PIN_LENGTH+'$', pinstr):
126                 logging.info('getting pin for uid %d: %s not a good pin'%(uid,repr(pinstr)))
127                 return None
128         return int(pinstr)
129
130 def has_good_pin(uid):
131         return get_pin(uid) != None
132
133 def verify_user_pin(uid, pin):
134         if get_pin(uid) == pin:
135                 info = pwd.getpwuid(uid)
136                 logging.info('accepted pin for uid %d (%s)'%(uid,info.pw_name))
137                 return info.pw_name
138         else:
139                 logging.info('refused pin for uid %d'%(uid))
140                 return None
141
142 def door_open_mode(v):
143         logging.warning("Entering open door mode")
144         v.display("-FEED  ME-")
145         while True:
146                 e = v.next_event()
147                 if e == None: break
148                 (event, params) = e
149                 if event == DOOR:
150                         if params == 1: # door closed
151                                 logging.warning('Leaving open door mode')
152                                 v.display("-YUM YUM!-")
153                                 sleep(1)
154                                 return
155
156 def cookie(v):
157         seed(time())
158         messages = ['  WASSUP! ', 'PINK FISH ', ' SECRETS ', '  ESKIMO  ', ' FORTUNES ', 'MORE MONEY']
159         choice = int(random()*len(messages))
160         msg = messages[choice]
161         left = range(len(msg))
162         for i in range(len(msg)):
163                 if msg[i] == ' ': left.remove(i)
164         reveal = 1
165         while left:
166                 s = ''
167                 for i in range(0, len(msg)):
168                         if i in left:
169                                 if reveal == 0:
170                                         left.remove(i)
171                                         s += msg[i]
172                                 else:
173                                         s += chr(int(random()*26)+ord('A'))
174                                 reveal += 1
175                                 reveal %= 17
176                         else:
177                                 s += msg[i]
178                 v.display(s)
179
180 def center(str):
181         LEN = 10
182         return ' '*((LEN-len(str))/2)+str
183
184
185
186 idlers = []
187 idler = None
188
189 def setup_idlers(v):
190         global idlers, idler
191         idlers = [
192                  GrayIdler(v),
193                 StringIdler(v, text="Kill 'em all", repeat=False),
194                  GrayIdler(v,one="*",zero="-"),
195                 StringIdler(v, text=CREDITS),
196                  GrayIdler(v,one="/",zero="\\"),
197                 FileIdler(v, '/etc/passwd'),
198                  GrayIdler(v,one="X",zero="O"),
199                 FileIdler(v, '/usr/share/common-licenses/GPL-2'),
200                  GrayIdler(v,one="*",zero="-",reorder=1),
201                 StringIdler(v, text=str(math.pi) + "            "),
202                  GrayIdler(v,one="/",zero="\\",reorder=1),
203                 StringIdler(v, text=str(math.e) + "            "),
204                  GrayIdler(v,one="X",zero="O",reorder=1),
205                 FortuneIdler(v),
206                 ClockIdler(v),
207                 StringIdler(v),
208                 TrainIdler(v),
209                 ]
210     disabled = [
211                 ]
212         idler = choose_idler()
213
214 def choose_idler():
215         global idler
216         iiindex = 0
217
218         if idler:
219                 iiindex = idlers.index(idler)
220
221         iilen = len(idlers)
222
223         move = int(random()*len(idlers)) + 1
224
225         while move >= 0:
226                 idler = idlers[( (iiindex + 1) % iilen)]
227                 move = move - idler.affinity()
228
229         idler.reset()
230
231 def idle_step():
232         global idler
233         if idler.finished():
234                 choose_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