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

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