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

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