d6f48c9b9664ab8789615ac07951b3c3814ba72c
[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 SerialClient import SerialClient, SerialClientException
15 from VendingMachine import VendingMachine, VendingException
16 from MessageKeeper import MessageKeeper
17 from HorizScroll import HorizScroll
18 from random import random, seed
19 from Idler import TrainIdler,GrayIdler,StringIdler,ClockIdler,FortuneIdler,FileIdler
20 import socket
21 from posix import geteuid
22
23 CREDITS="""
24 This vending machine software brought to you by:
25 Bernard Blackham
26 Mark Tearle
27 Nick Bannon
28 Cameron Patrick
29 and a collective of hungry alpacas.
30
31
32
33 For a good time call +61 8 6488 3901
34
35
36
37 """
38
39 GREETING = 'UCC SNACKS'
40 PIN_LENGTH = 4
41
42 DOOR = 1
43 SWITCH = 2
44 KEY = 3
45
46 class DispenseDatabaseException(Exception): pass
47
48 class DispenseDatabase:
49         def __init__(self, vending_machine, host, name, user, password):
50                 self.vending_machine = vending_machine
51                 self.db = pg.DB(dbname = name, host = host, user = user, passwd = password)
52                 self.db.query('LISTEN vend_requests')
53
54         def process_requests(self):
55                 logging.debug('database processing')
56                 query = 'SELECT request_id, request_slot FROM vend_requests WHERE request_handled = false'
57                 try:
58                         outstanding = self.db.query(query).getresult()
59                 except (pg.error,), db_err:
60                         raise DispenseDatabaseException('Failed to query database: %s\n'%(db_err.strip()))
61                 for (id, slot) in outstanding:
62                         (worked, code, string) = self.vending_machine.vend(slot)
63                         logging.debug (str((worked, code, string)))
64                         if worked:
65                                 query = 'SELECT vend_success(%s)'%id
66                                 self.db.query(query).getresult()
67                         else:
68                                 query = 'SELECT vend_failed(%s)'%id
69                                 self.db.query(query).getresult()
70
71         def handle_events(self):
72                 notifier = self.db.getnotify()
73                 while notifier is not None:
74                         self.process_requests()
75                         notify = self.db.getnotify()
76
77 def scroll_options(username, mk, welcome = False):
78         if welcome:
79                 msg = [(center('WELCOME'), False, 0.8),
80                            (center(username), False, 0.8)]
81         else:
82                 msg = []
83         choices = ' '*10+'CHOICES: '
84         try:
85                 coke_machine = file('/home/other/coke/coke_contents')
86                 cokes = coke_machine.readlines()
87                 coke_machine.close()
88         except:
89                 cokes = []
90                 pass
91         for c in cokes:
92                 c = c.strip()
93                 (slot_num, price, slot_name) = c.split(' ', 2)
94                 if slot_name == 'dead': continue
95                 choices += '%s8-%s (%sc) '%(slot_num, slot_name, price)
96         choices += '55-DOOR '
97         choices += 'OR A SNACK. '
98         choices += '99 TO READ AGAIN. '
99         choices += 'CHOICE?   '
100         msg.append((choices, False, None))
101         mk.set_messages(msg)
102
103 def get_pin(uid):
104         try:
105                 info = pwd.getpwuid(uid)
106         except KeyError:
107                 logging.info('getting pin for uid %d: user not in password file'%uid)
108                 return None
109         if info.pw_dir == None: return False
110         pinfile = os.path.join(info.pw_dir, '.pin')
111         try:
112                 s = os.stat(pinfile)
113         except OSError:
114                 logging.info('getting pin for uid %d: .pin not found in home directory'%uid)
115                 return None
116         if s.st_mode & 077:
117                 logging.info('getting pin for uid %d: .pin has wrong permissions'%uid)
118                 return None
119         try:
120                 f = file(pinfile)
121         except IOError:
122                 logging.info('getting pin for uid %d: I cannot read pin file'%uid)
123                 return None
124         pinstr = f.readline()
125         f.close()
126         if not re.search('^'+'[0-9]'*PIN_LENGTH+'$', pinstr):
127                 logging.info('getting pin for uid %d: %s not a good pin'%(uid,repr(pinstr)))
128                 return None
129         return int(pinstr)
130
131 def has_good_pin(uid):
132         return get_pin(uid) != None
133
134 def verify_user_pin(uid, pin):
135         if get_pin(uid) == pin:
136                 info = pwd.getpwuid(uid)
137                 logging.info('accepted pin for uid %d (%s)'%(uid,info.pw_name))
138                 return info.pw_name
139         else:
140                 logging.info('refused pin for uid %d'%(uid))
141                 return None
142
143 def door_open_mode(v):
144         logging.warning("Entering open door mode")
145         v.display("-FEED  ME-")
146         while True:
147                 e = v.next_event()
148                 if e == None: break
149                 (event, params) = e
150                 if event == DOOR:
151                         if params == 1: # door closed
152                                 logging.warning('Leaving open door mode')
153                                 v.display("-YUM YUM!-")
154                                 sleep(1)
155                                 return
156
157 def cookie(v):
158         seed(time())
159         messages = ['  WASSUP! ', 'PINK FISH ', ' SECRETS ', '  ESKIMO  ', ' FORTUNES ', 'MORE MONEY']
160         choice = int(random()*len(messages))
161         msg = messages[choice]
162         left = range(len(msg))
163         for i in range(len(msg)):
164                 if msg[i] == ' ': left.remove(i)
165         reveal = 1
166         while left:
167                 s = ''
168                 for i in range(0, len(msg)):
169                         if i in left:
170                                 if reveal == 0:
171                                         left.remove(i)
172                                         s += msg[i]
173                                 else:
174                                         s += chr(int(random()*26)+ord('A'))
175                                 reveal += 1
176                                 reveal %= 17
177                         else:
178                                 s += msg[i]
179                 v.display(s)
180
181 def center(str):
182         LEN = 10
183         return ' '*((LEN-len(str))/2)+str
184
185
186
187 idlers = []
188 idler = None
189
190 def setup_idlers(v):
191         global idlers, idler
192         idlers = [
193                  GrayIdler(v),
194                 StringIdler(v, text="Kill 'em all", repeat=False),
195                  GrayIdler(v,one="*",zero="-"),
196                 StringIdler(v, text=CREDITS),
197                  GrayIdler(v,one="/",zero="\\"),
198                 ClockIdler(v),
199                 FileIdler(v, '/etc/passwd'),
200                  GrayIdler(v,one="X",zero="O"),
201                 FileIdler(v, '/usr/share/common-licenses/GPL-2'),
202                  GrayIdler(v,one="*",zero="-",reorder=1),
203                 StringIdler(v, text=str(math.pi) + "            "),
204                 ClockIdler(v),
205                  GrayIdler(v,one="/",zero="\\",reorder=1),
206                 StringIdler(v, text=str(math.e) + "            "),
207                  GrayIdler(v,one="X",zero="O",reorder=1),
208                 StringIdler(v, text="I want some pizza - please call Pizza Hut Shenton Park on +61 8 9381 9979 - and order as Quinn - I am getting really hungry", repeat=False),
209                 FortuneIdler(v),
210                 ClockIdler(v),
211                 StringIdler(v),
212                 TrainIdler(v),
213                 ]
214     disabled = [
215                 ]
216         idler = choose_idler()
217
218 def choose_idler():
219         global idler
220         iiindex = 0
221
222         if idler:
223                 iiindex = idlers.index(idler)
224
225         iilen = len(idlers)
226
227         move = int(random()*len(idlers)) + 1
228
229         while move >= 0:
230                 idler = idlers[( (iiindex + 1) % iilen)]
231                 move = move - idler.affinity()
232
233         idler.reset()
234
235 def idle_step():
236         global idler
237         if idler.finished():
238                 choose_idler()
239         idler.next()
240
241 def run_forever(rfh, wfh, options, cf):
242         v = VendingMachine(rfh, wfh)
243         logging.debug('PING is ' + str(v.ping()))
244
245         if USE_DB: db = DispenseDatabase(v, cf.DBServer, cf.DBName, cf.DBUser, cf.DBPassword)
246         cur_user = ''
247         cur_pin = ''
248         cur_selection = ''
249
250         mk = MessageKeeper(v)
251         mk.set_message(GREETING)
252         time_to_autologout = None
253         setup_idlers(v)
254         time_to_idle = None
255         last_timeout_refresh = None
256
257         while True:
258                 if USE_DB:
259                         try:
260                                 db.handle_events()
261                         except DispenseDatabaseException, e:
262                                 logging.error('Database error: '+str(e))
263
264                 if time_to_autologout != None:
265                         time_left = time_to_autologout - time()
266                         if time_left < 6 and (last_timeout_refresh is None or last_timeout_refresh > time_left):
267                                 mk.set_message('LOGOUT: '+str(int(time_left)))
268                                 last_timeout_refresh = int(time_left)
269                                 cur_selection = ''
270
271                 if time_to_autologout != None and time_to_autologout - time() <= 0:
272                         time_to_autologout = None
273                         cur_user = ''
274                         cur_pin = ''
275                         cur_selection = ''
276                         mk.set_message(GREETING)
277
278                 if time_to_autologout and not mk.done(): time_to_autologout = None
279                 if cur_user == '' and time_to_autologout: time_to_autologout = None
280                 if len(cur_pin) == PIN_LENGTH and mk.done() and time_to_autologout == None:
281                         # start autologout
282                         time_to_autologout = time() + 15
283                         last_timeout_refresh = None
284
285                 if time_to_idle == None and cur_user == '':
286                         time_to_idle = time() + 5
287                         choose_idler()
288                 if time_to_idle is not None and cur_user != '': time_to_idle = None
289                 if time_to_idle is not None and time() > time_to_idle: idle_step()
290                 if time_to_idle is not None and time() > time_to_idle + 300:
291                         time_to_idle = time()
292                         choose_idler()
293
294                 mk.update_display()
295
296                 e = v.next_event(0)
297                 if e == None:
298                         e = v.next_event(0.05)
299                         if e == None:
300                                 continue
301                 time_to_idle = None
302                 (event, params) = e
303                 logging.debug('Got event: ' + repr(e))
304                 if event == DOOR:
305                         if params == 0:
306                                 door_open_mode(v);
307                                 cur_user = ''
308                                 cur_pin = ''
309                                 mk.set_message(GREETING)
310                 elif event == SWITCH:
311                         # don't care right now.
312                         pass
313                 elif event == KEY:
314                         key = params
315                         # complicated key handling here:
316                         if len(cur_user) < 5:
317                                 if key == 11:
318                                         cur_user = ''
319                                         mk.set_message(GREETING)
320                                         continue
321                                 cur_user += chr(key + ord('0'))
322                                 mk.set_message('UID: '+cur_user)
323                                 if len(cur_user) == 5:
324                                         uid = int(cur_user)
325                                         if not has_good_pin(uid):
326                                                 logging.info('user '+cur_user+' has a bad PIN')
327                                                 #mk.set_messages(
328                                                         #[(center('INVALID'), False, 0.7),
329                                                          #(center('PIN'), False, 0.7),
330                                                          #(center('SETUP'), False, 1.0),
331                                                          #(GREETING, False, None)])
332                                                 mk.set_messages(
333                                                         [(' '*10+'INVALID PIN SETUP'+' '*10, False, 3),
334                                                          (GREETING, False, None)])
335                                                 cur_user = ''
336                                                 cur_pin = ''
337                                                 continue
338                                         cur_pin = ''
339                                         mk.set_message('PIN: ')
340                                         logging.info('need pin for user %s'%cur_user)
341                                         continue
342                         elif len(cur_pin) < PIN_LENGTH:
343                                 if key == 11:
344                                         if cur_pin == '':
345                                                 cur_user = ''
346                                                 mk.set_message(GREETING)
347                                                 continue
348                                         cur_pin = ''
349                                         mk.set_message('PIN: ')
350                                         continue
351                                 cur_pin += chr(key + ord('0'))
352                                 mk.set_message('PIN: '+'X'*len(cur_pin))
353                                 if len(cur_pin) == PIN_LENGTH:
354                                         username = verify_user_pin(int(cur_user), int(cur_pin))
355                                         if username:
356                                                 v.beep(0, False)
357                                                 cur_selection = ''
358                                                 scroll_options(username, mk, True)
359                                                 continue
360                                         else:
361                                                 v.beep(40, False)
362                                                 mk.set_messages(
363                                                         [(center('BAD PIN'), False, 1.0),
364                                                          (center('SORRY'), False, 0.5),
365                                                          (GREETING, False, None)])
366                                                 cur_user = ''
367                                                 cur_pin = ''
368                                                 continue
369                         elif len(cur_selection) == 0:
370                                 if key == 11:
371                                         cur_pin = ''
372                                         cur_user = ''
373                                         cur_selection = ''
374                                         mk.set_messages(
375                                                 [(center('BYE!'), False, 1.5),
376                                                  (GREETING, False, None)])
377                                         continue
378                                 cur_selection += chr(key + ord('0'))
379                                 mk.set_message('SELECT: '+cur_selection)
380                                 time_to_autologout = None
381                         elif len(cur_selection) == 1:
382                                 if key == 11:
383                                         cur_selection = ''
384                                         time_to_autologout = None
385                                         scroll_options(username, mk)
386                                         continue
387                                 else:
388                                         cur_selection += chr(key + ord('0'))
389                                         #make_selection(cur_selection)
390                                         # XXX this should move somewhere else:
391                                         if cur_selection == '55':
392                                                 mk.set_message('OPENSESAME')
393                                                 logging.info('dispensing a door for %s'%username)
394                                                 if geteuid() == 0:
395                                                         ret = os.system('su - "%s" -c "dispense door"'%username)
396                                                 else:
397                                                         ret = os.system('dispense door')
398                                                 if ret == 0:
399                                                         logging.info('door opened')
400                                                         mk.set_message(center('DOOR OPEN'))
401                                                 else:
402                                                         logging.warning('user %s tried to dispense a bad door'%username)
403                                                         mk.set_message(center('BAD DOOR'))
404                                                 sleep(1)
405                                         elif cur_selection == '91':
406                                                 cookie(v)
407                                         elif cur_selection == '99':
408                                                 scroll_options(username, mk)
409                                                 cur_selection = ''
410                                                 continue
411                                         elif cur_selection[1] == '8':
412                                                 v.display('GOT COKE?')
413                                                 if ((os.system('su - "%s" -c "dispense %s"'%(username, cur_selection[0])) >> 8) != 0):
414                                                         v.display('SEEMS NOT')
415                                                 else:
416                                                         v.display('GOT COKE!')
417                                         else:
418                                                 v.display(cur_selection+' - $1.00')
419                                                 if ((os.system('su - "%s" -c "dispense snack"'%(username)) >> 8) == 0):
420                                                         v.vend(cur_selection)
421                                                         v.display('THANK YOU')
422                                                 else:
423                                                         v.display('NO MONEY?')
424                                         sleep(1)
425                                         cur_selection = ''
426                                         time_to_autologout = time() + 8
427                                         last_timeout_refresh = None
428
429 def connect_to_vend(options, cf):
430
431         if options.use_lat:
432                 logging.info('Connecting to vending machine using LAT')
433                 latclient = LATClient(service = cf.ServiceName, password = cf.ServicePassword, server_name = cf.ServerName, connect_password = cf.ConnectPassword, priv_password = cf.PrivPassword)
434                 rfh, wfh = latclient.get_fh()
435         elif options.use_serial:
436                 # Open vending machine via serial.
437                 logging.info('Connecting to vending machine using serial')
438                 serialclient = SerialClient(port = '/dev/ttyS1', baud = 9600)
439                 rfh,wfh = serialclient.get_fh()
440         else:
441                 #(rfh, wfh) = popen2('../../virtualvend/vvend.py')
442                 logging.info('Connecting to virtual vending machine on %s:%d'%(options.host,options.port))
443                 import socket
444                 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
445                 sock.connect((options.host, options.port))
446                 rfh = sock.makefile('r')
447                 wfh = sock.makefile('w')
448                 
449         return rfh, wfh
450
451 def parse_args():
452         from optparse import OptionParser
453
454         op = OptionParser(usage="%prog [OPTION]...")
455         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')
456         op.add_option('--serial', action='store_true', default=True, dest='use_serial', help='use the serial port')
457         op.add_option('--lat', action='store_true', default=False, dest='use_lat', help='use LAT')
458         op.add_option('--virtualvend', action='store_false', default=True, dest='use_serial', help='use the virtual vending server instead of LAT')
459         op.add_option('-n', '--hostname', dest='host', default='localhost', help='the hostname to connect to for virtual vending machine mode (default: localhost)')
460         op.add_option('-p', '--port', dest='port', default=5150, type='int', help='the port number to connect to (default: 5150)')
461         op.add_option('-l', '--log-file', metavar='FILE', dest='log_file', default='', help='log output to the specified file')
462         op.add_option('-s', '--syslog', dest='syslog', metavar='FACILITY', default=None, help='log output to given syslog facility')
463         op.add_option('-d', '--daemon', dest='daemon', action='store_true', default=False, help='run as a daemon')
464         op.add_option('-v', '--verbose', dest='verbose', action='store_true', default=False, help='spit out lots of debug output')
465         op.add_option('-q', '--quiet', dest='quiet', action='store_true', default=False, help='only report errors')
466         op.add_option('--pid-file', dest='pid_file', metavar='FILE', default='', help='store daemon\'s pid in the given file')
467         options, args = op.parse_args()
468
469         if len(args) != 0:
470                 op.error('extra command line arguments: ' + ' '.join(args))
471
472         return options
473
474 config_options = {
475         'DBServer': ('Database', 'Server'),
476         'DBName': ('Database', 'Name'),
477         'DBUser': ('VendingMachine', 'DBUser'),
478         'DBPassword': ('VendingMachine', 'DBPassword'),
479         
480         'ServiceName': ('VendingMachine', 'ServiceName'),
481         'ServicePassword': ('VendingMachine', 'Password'),
482         
483         'ServerName': ('DecServer', 'Name'),
484         'ConnectPassword': ('DecServer', 'ConnectPassword'),
485         'PrivPassword': ('DecServer', 'PrivPassword'),
486         }
487
488 class VendConfigFile:
489         def __init__(self, config_file, options):
490                 try:
491                         cp = ConfigParser.ConfigParser()
492                         cp.read(config_file)
493
494                         for option in options:
495                                 section, name = options[option]
496                                 value = cp.get(section, name)
497                                 self.__dict__[option] = value
498                 
499                 except ConfigParser.Error, e:
500                         raise SystemExit("Error reading config file "+config_file+": " + str(e))
501
502 def create_pid_file(name):
503         try:
504                 pid_file = file(name, 'w')
505                 pid_file.write('%d\n'%os.getpid())
506                 pid_file.close()
507         except IOError, e:
508                 logging.warning('unable to write to pid file '+name+': '+str(e))
509
510 def set_stuff_up():
511         def do_nothing(signum, stack):
512                 signal.signal(signum, do_nothing)
513         def stop_server(signum, stack): raise KeyboardInterrupt
514         signal.signal(signal.SIGHUP, do_nothing)
515         signal.signal(signal.SIGTERM, stop_server)
516         signal.signal(signal.SIGINT, stop_server)
517
518         options = parse_args()
519         config_opts = VendConfigFile(options.config_file, config_options)
520         if options.daemon: become_daemon()
521         set_up_logging(options)
522         if options.pid_file != '': create_pid_file(options.pid_file)
523
524         return options, config_opts
525
526 def clean_up_nicely(options, config_opts):
527         if options.pid_file != '':
528                 try:
529                         os.unlink(options.pid_file)
530                         logging.debug('Removed pid file '+options.pid_file)
531                 except OSError: pass  # if we can't delete it, meh
532
533 def set_up_logging(options):
534         logger = logging.getLogger()
535         
536         if not options.daemon:
537                 stderr_logger = logging.StreamHandler(sys.stderr)
538                 stderr_logger.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
539                 logger.addHandler(stderr_logger)
540         
541         if options.log_file != '':
542                 try:
543                         file_logger = logging.FileHandler(options.log_file)
544                         file_logger.setFormatter(logging.Formatter('%(asctime)s %(levelname)s: %(message)s'))
545                         logger.addHandler(file_logger)
546                 except IOError, e:
547                         logger.warning('unable to write to log file '+options.log_file+': '+str(e))
548
549         if options.syslog != None:
550                 sys_logger = logging.handlers.SysLogHandler('/dev/log', options.syslog)
551                 sys_logger.setFormatter(logging.Formatter('vendserver[%d]'%(os.getpid()) + ' %(levelname)s: %(message)s'))
552                 logger.addHandler(sys_logger)
553
554         if options.quiet:
555                 logger.setLevel(logging.WARNING)
556         elif options.verbose:
557                 logger.setLevel(logging.DEBUG)
558         else:
559                 logger.setLevel(logging.INFO)
560
561 def become_daemon():
562         dev_null = file('/dev/null')
563         fd = dev_null.fileno()
564         os.dup2(fd, 0)
565         os.dup2(fd, 1)
566         os.dup2(fd, 2)
567         try:
568                 if os.fork() != 0:
569                         sys.exit(0)
570                 os.setsid()
571         except OSError, e:
572                 raise SystemExit('failed to fork: '+str(e))
573
574 def do_vend_server(options, config_opts):
575         while True:
576                 try:
577                         rfh, wfh = connect_to_vend(options, config_opts)
578                 except (SerialClientException, socket.error), e:
579                         (exc_type, exc_value, exc_traceback) = sys.exc_info()
580                         del exc_traceback
581                         logging.error("Connection error: "+str(exc_type)+" "+str(e))
582                         logging.info("Trying again in 5 seconds.")
583                         sleep(5)
584                         continue
585                 
586                 try:
587                         run_forever(rfh, wfh, options, config_opts)
588                 except VendingException:
589                         logging.error("Connection died, trying again...")
590                         logging.info("Trying again in 5 seconds.")
591                         sleep(5)
592
593 if __name__ == '__main__':
594         options, config_opts = set_stuff_up()
595         while True:
596                 try:
597                         logging.warning('Starting Vend Server')
598                         do_vend_server(options, config_opts)
599                         logging.error('Vend Server finished unexpectedly, restarting')
600                 except KeyboardInterrupt:
601                         logging.info("Killed by signal, cleaning up")
602                         clean_up_nicely(options, config_opts)
603                         logging.warning("Vend Server stopped")
604                         break
605                 except SystemExit:
606                         break
607                 except:
608                         (exc_type, exc_value, exc_traceback) = sys.exc_info()
609                         tb = format_tb(exc_traceback, 20)
610                         del exc_traceback
611                         
612                         logging.critical("Uh-oh, unhandled " + str(exc_type) + " exception")
613                         logging.critical("Message: " + str(exc_value))
614                         logging.critical("Traceback:")
615                         for event in tb:
616                                 for line in event.split('\n'):
617                                         logging.critical('    '+line)
618                         logging.critical("This message should be considered a bug in the Vend Server.")
619                         logging.critical("Please report this to someone who can fix it.")
620                         sleep(10)
621                         logging.warning("Trying again anyway (might not help, but hey...)")
622

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