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

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