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

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