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

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